Initial commit: Nexus Mission Control Platform
- ASP.NET Core 10 Backend (JWT Auth, Agent config API) - Vue 3 Frontend (Dashboard, Team, Agents, Config Editor) - PostgreSQL Database - Docker Compose setup - Mission Control Dashboard redesign
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { FileText, AlertTriangle, AlertCircle, Info, Activity, ArrowLeft, Clock, Loader2 } from '@lucide/vue'
|
||||
import { apiFetch } from '../services/api'
|
||||
import { renderMarkdown } from '../utils/markdown'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
interface IncidentFile {
|
||||
name: string
|
||||
title: string
|
||||
date: string | null
|
||||
severity: string
|
||||
excerpt: string
|
||||
size: number
|
||||
}
|
||||
|
||||
interface IncidentDetail extends IncidentFile {
|
||||
content: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// State
|
||||
const incidents = ref<IncidentFile[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const selectedIncident = ref<IncidentDetail | null>(null)
|
||||
const contentLoading = ref(false)
|
||||
|
||||
// Sorted incidents (newest first by date)
|
||||
const sortedIncidents = computed(() => {
|
||||
return [...incidents.value].sort((a, b) => {
|
||||
if (a.date && b.date) return b.date.localeCompare(a.date)
|
||||
if (a.date) return -1
|
||||
if (b.date) return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
})
|
||||
|
||||
// Severity badge mapping
|
||||
function severityClass(s: string): string {
|
||||
const lower = s.toLowerCase()
|
||||
if (lower.includes('critical')) return 'sev-critical'
|
||||
if (lower.includes('major')) return 'sev-major'
|
||||
if (lower.includes('minor')) return 'sev-minor'
|
||||
return 'sev-unknown'
|
||||
}
|
||||
|
||||
function severityIcon(s: string) {
|
||||
const lower = s.toLowerCase()
|
||||
if (lower.includes('critical')) return AlertCircle
|
||||
if (lower.includes('major')) return AlertTriangle
|
||||
return Info
|
||||
}
|
||||
|
||||
async function loadIncidents() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await apiFetch('/api/v1/incidents')
|
||||
if (!response.ok) throw new Error('Failed to load incidents')
|
||||
incidents.value = await response.json()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load incidents'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIncidentContent(name: string) {
|
||||
contentLoading.value = true
|
||||
selectedIncident.value = null
|
||||
try {
|
||||
const response = await apiFetch(`/api/v1/incidents/${encodeURIComponent(name)}`)
|
||||
if (!response.ok) throw new Error('Failed to load incident')
|
||||
selectedIncident.value = await response.json()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load incident'
|
||||
} finally {
|
||||
contentLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectIncident(name: string) {
|
||||
loadIncidentContent(name)
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
selectedIncident.value = null
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
onMounted(loadIncidents)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<span class="eyebrow">OPERATIONS</span>
|
||||
<h1>Incidents</h1>
|
||||
<p>Post-mortem reports and operational incidents across Noveria.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="incident-layout">
|
||||
<!-- Left column: incident list -->
|
||||
<aside class="incident-sidebar">
|
||||
<template v-if="loading">
|
||||
<div class="incident-status">
|
||||
<Loader2 :size="16" class="spin" />
|
||||
Loading incidents...
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="error">
|
||||
<div class="incident-status error">{{ error }}</div>
|
||||
</template>
|
||||
<template v-else-if="sortedIncidents.length">
|
||||
<div class="incident-list-header">{{ sortedIncidents.length }} reports</div>
|
||||
<button
|
||||
v-for="inc in sortedIncidents"
|
||||
:key="inc.name"
|
||||
:class="['incident-file-item', { active: selectedIncident?.name === inc.name }]"
|
||||
@click="selectIncident(inc.name)"
|
||||
>
|
||||
<div class="incident-file-icon" :class="severityClass(inc.severity)">
|
||||
<component :is="severityIcon(inc.severity)" :size="14" />
|
||||
</div>
|
||||
<div class="incident-file-info">
|
||||
<strong>{{ inc.title }}</strong>
|
||||
<span class="incident-file-meta">
|
||||
<Clock :size="10" />
|
||||
{{ inc.date ? formatDate(inc.date) : 'Unknown date' }}
|
||||
<span :class="['severity-badge', severityClass(inc.severity)]">{{ inc.severity }}</span>
|
||||
</span>
|
||||
<span class="incident-file-excerpt">{{ inc.excerpt }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<div v-else class="incident-status">No incidents recorded</div>
|
||||
</aside>
|
||||
|
||||
<!-- Right column: detail view -->
|
||||
<main class="incident-content">
|
||||
<template v-if="contentLoading">
|
||||
<div class="incident-status">
|
||||
<Loader2 :size="20" class="spin" />
|
||||
Loading incident...
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="selectedIncident">
|
||||
<header class="incident-content-header">
|
||||
<button class="incident-back-btn" @click="goBack">
|
||||
<ArrowLeft :size="14" />
|
||||
Back
|
||||
</button>
|
||||
<div>
|
||||
<div class="incident-content-title-row">
|
||||
<strong>{{ selectedIncident.title }}</strong>
|
||||
<span :class="['severity-badge', severityClass(selectedIncident.severity)]">{{ selectedIncident.severity }}</span>
|
||||
</div>
|
||||
<span class="incident-content-meta">
|
||||
<Clock :size="10" />
|
||||
{{ selectedIncident.date ? formatDate(selectedIncident.date) : 'Unknown date' }}
|
||||
· {{ formatSize(selectedIncident.size) }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<article
|
||||
class="incident-rendered"
|
||||
v-html="renderMarkdown(selectedIncident.content)"
|
||||
></article>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="incident-empty-state">
|
||||
<AlertTriangle :size="28" />
|
||||
<h3>Select an incident report</h3>
|
||||
<p>Choose a report from the list to view its post-mortem details.</p>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.incident-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 12px;
|
||||
min-height: 480px;
|
||||
}
|
||||
.incident-sidebar {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
background: var(--panel);
|
||||
padding: 8px;
|
||||
max-height: 640px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.incident-list-header {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #7065c8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
padding: 10px 8px 6px;
|
||||
}
|
||||
.incident-file-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #e8eaf0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.incident-file-item:hover,
|
||||
.incident-file-item.active {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.incident-file-icon {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.incident-file-icon.sev-critical {
|
||||
color: #e74c3c;
|
||||
background: rgba(231, 76, 60, 0.12);
|
||||
}
|
||||
.incident-file-icon.sev-major {
|
||||
color: #e67e22;
|
||||
background: rgba(230, 126, 34, 0.12);
|
||||
}
|
||||
.incident-file-icon.sev-minor {
|
||||
color: #f1c40f;
|
||||
background: rgba(241, 196, 15, 0.12);
|
||||
}
|
||||
.incident-file-icon.sev-unknown {
|
||||
color: #7e8799;
|
||||
background: rgba(126, 135, 153, 0.12);
|
||||
}
|
||||
.incident-file-info strong {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
margin-bottom: 3px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.incident-file-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: #7e8799;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.incident-file-excerpt {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-size: 9px;
|
||||
color: #6b7385;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.severity-badge {
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.severity-badge.sev-critical {
|
||||
background: rgba(231, 76, 60, 0.15);
|
||||
color: #e74c3c;
|
||||
}
|
||||
.severity-badge.sev-major {
|
||||
background: rgba(230, 126, 34, 0.15);
|
||||
color: #e67e22;
|
||||
}
|
||||
.severity-badge.sev-minor {
|
||||
background: rgba(241, 196, 15, 0.15);
|
||||
color: #f1c40f;
|
||||
}
|
||||
.severity-badge.sev-unknown {
|
||||
background: rgba(126, 135, 153, 0.15);
|
||||
color: #7e8799;
|
||||
}
|
||||
.incident-content {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
background: var(--panel);
|
||||
padding: 24px;
|
||||
min-height: 480px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.incident-content-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.incident-content-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.incident-content-title-row strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
.incident-content-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: #7e8799;
|
||||
}
|
||||
.incident-back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #8991a1;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.incident-back-btn:hover {
|
||||
color: #e8eaf0;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.incident-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 32px;
|
||||
color: #7e8799;
|
||||
font-size: 11px;
|
||||
}
|
||||
.incident-status.error {
|
||||
color: #e16e75;
|
||||
}
|
||||
.incident-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
min-height: 360px;
|
||||
color: #6b7385;
|
||||
}
|
||||
.incident-empty-state h3 {
|
||||
margin: 12px 0 6px;
|
||||
font-size: 14px;
|
||||
color: #a5adba;
|
||||
}
|
||||
.incident-empty-state p {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
color: #7e8799;
|
||||
}
|
||||
.incident-rendered {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #d0d4dd;
|
||||
}
|
||||
.incident-rendered :deep(h1),
|
||||
.incident-rendered :deep(h2),
|
||||
.incident-rendered :deep(h3) {
|
||||
color: #e8eaf0;
|
||||
margin: 1.2em 0 0.5em;
|
||||
}
|
||||
.incident-rendered :deep(h1) { font-size: 1.3rem; }
|
||||
.incident-rendered :deep(h2) { font-size: 1.1rem; }
|
||||
.incident-rendered :deep(h3) { font-size: 1rem; }
|
||||
.incident-rendered :deep(p) { margin: 0.6em 0; }
|
||||
.incident-rendered :deep(code) {
|
||||
padding: 2px 5px;
|
||||
background: rgba(139,124,246,.08);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.incident-rendered :deep(pre) {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #0d1016;
|
||||
border: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.incident-rendered :deep(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
.incident-rendered :deep(a) {
|
||||
color: #a99cf5;
|
||||
text-decoration: none;
|
||||
}
|
||||
.incident-rendered :deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.incident-rendered :deep(ul) {
|
||||
padding-left: 20px;
|
||||
}
|
||||
.incident-rendered :deep(li) {
|
||||
margin: 0.3em 0;
|
||||
}
|
||||
.incident-rendered :deep(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 1.2em 0;
|
||||
}
|
||||
.incident-rendered :deep(strong) {
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.incident-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.incident-sidebar {
|
||||
max-height: 280px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user