feat: security alerts tab — UI view for anomaly detections (#11)
New SecurityAlertsView (/alerts route) surfaces the detections table built in #10. Features: - All / Unacknowledged tab filter with live counts - Label dropdown (SECURITY_ANOMALY, SYSTEM_FAILURE, NETWORK_ANOMALY, etc.) - Score confidence bar per detection (colour-coded by threshold) - Acknowledge drawer: full log text, optional notes, in-place row dim on save - Scorer status badge + manual "Run scorer" button - Config warning when TURNSTONE_ANOMALY_MODEL is unset Dashboard: new "Unreviewed Alerts" stat card (red border when > 0) links to /alerts so alerts surface on the landing page without navigating away. Closes: #11
This commit is contained in:
parent
ae13322648
commit
d1e6871525
4 changed files with 482 additions and 1 deletions
|
|
@ -76,6 +76,7 @@ const navLinks = [
|
|||
{ to: '/search', label: 'Search' },
|
||||
{ to: '/diagnose', label: 'Diagnose' },
|
||||
{ to: '/incidents', label: 'Incidents' },
|
||||
{ to: '/alerts', label: 'Alerts' },
|
||||
{ to: '/bundles', label: 'Bundles' },
|
||||
{ to: '/sources', label: 'Sources' },
|
||||
{ to: '/context', label: 'Context' },
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import BundlesView from '@/views/BundlesView.vue'
|
|||
import SettingsView from '@/views/SettingsView.vue'
|
||||
import ContextView from '@/views/ContextView.vue'
|
||||
import BlocklistView from '@/views/BlocklistView.vue'
|
||||
import SecurityAlertsView from '@/views/SecurityAlertsView.vue'
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
|
@ -17,6 +18,7 @@ export default createRouter({
|
|||
{ path: '/search', component: LogSearchView },
|
||||
{ path: '/diagnose', component: DiagnoseView },
|
||||
{ path: '/incidents', component: IncidentsView },
|
||||
{ path: '/alerts', component: SecurityAlertsView },
|
||||
{ path: '/bundles', component: BundlesView },
|
||||
{ path: '/sources', component: SourcesView },
|
||||
{ path: '/context', component: ContextView },
|
||||
|
|
|
|||
|
|
@ -52,6 +52,16 @@
|
|||
{{ incidentsLoading ? '…' : activeIncidents }}
|
||||
</p>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/alerts"
|
||||
class="rounded border bg-surface-raised p-5 block hover:bg-surface transition-colors"
|
||||
:class="unackedAlerts > 0 ? 'border-sev-error' : 'border-surface-border'"
|
||||
>
|
||||
<p class="text-text-dim text-xs uppercase tracking-widest mb-2">Unreviewed Alerts</p>
|
||||
<p class="text-3xl font-semibold tabular-nums" :class="unackedAlerts > 0 ? 'text-sev-error' : 'text-text-muted'">
|
||||
{{ alertsLoading ? '…' : unackedAlerts }}
|
||||
</p>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Source health (24h) -->
|
||||
|
|
@ -201,6 +211,8 @@ const loading = ref(true)
|
|||
const incidents = ref<Incident[]>([])
|
||||
const incidentsLoading = ref(true)
|
||||
const watchSources = ref<WatchSourceStatus[]>([])
|
||||
const unackedAlerts = ref(0)
|
||||
const alertsLoading = ref(true)
|
||||
|
||||
const activeIncidents = computed(() =>
|
||||
incidents.value.filter(i => !i.ended_at).length
|
||||
|
|
@ -217,7 +229,7 @@ const isStale = computed(() => {
|
|||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadStats(), loadIncidents(), loadWatchStatus()])
|
||||
await Promise.all([loadStats(), loadIncidents(), loadWatchStatus(), loadAlertCount()])
|
||||
})
|
||||
|
||||
async function loadStats() {
|
||||
|
|
@ -245,6 +257,14 @@ async function loadWatchStatus() {
|
|||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
async function loadAlertCount() {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/turnstone/api/anomaly/detections?unacked_only=true&limit=1000`)
|
||||
if (res.ok) unackedAlerts.value = (await res.json()).total ?? 0
|
||||
} catch { /* non-critical — scorer may be disabled */ }
|
||||
finally { alertsLoading.value = false }
|
||||
}
|
||||
|
||||
function healthDot(errors: number, total: number): string {
|
||||
if (errors === 0) return 'bg-green-500'
|
||||
const ratio = errors / Math.max(total, 1)
|
||||
|
|
|
|||
458
web/src/views/SecurityAlertsView.vue
Normal file
458
web/src/views/SecurityAlertsView.vue
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
<template>
|
||||
<div class="p-4 sm:p-6 max-w-5xl mx-auto">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-5 flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 class="text-text-primary text-xl font-semibold mb-1">Security Alerts</h1>
|
||||
<p class="text-text-dim text-sm">
|
||||
Anomaly detections from the scoring pipeline.
|
||||
Acknowledge entries after review to track your triage state.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Scorer controls -->
|
||||
<div class="flex items-center gap-3 shrink-0 flex-wrap">
|
||||
<!-- Status badge -->
|
||||
<span
|
||||
v-if="scorerStatus"
|
||||
:class="[
|
||||
'text-xs px-2 py-1 rounded border font-mono',
|
||||
scorerStatus.enabled
|
||||
? scorerStatus.running
|
||||
? 'border-accent text-accent animate-pulse'
|
||||
: 'border-surface-border text-text-dim'
|
||||
: 'border-surface-border text-text-dim opacity-60'
|
||||
]"
|
||||
:title="scorerStatus.enabled ? `model: ${scorerStatus.model}` : 'TURNSTONE_ANOMALY_MODEL not set'"
|
||||
>
|
||||
{{ scorerStatus.running ? 'scoring…' : scorerStatus.enabled ? 'scorer ready' : 'scorer off' }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
@click="runScorer"
|
||||
:disabled="!scorerStatus?.enabled || triggerLoading || scorerStatus?.running"
|
||||
class="px-3 py-1.5 bg-accent text-surface text-xs rounded font-medium hover:opacity-90 transition-opacity disabled:opacity-40"
|
||||
title="Manually trigger an anomaly scoring pass"
|
||||
>
|
||||
{{ triggerLoading ? 'triggering…' : 'Run scorer' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scorer config warning (no model set) -->
|
||||
<div
|
||||
v-if="scorerStatus && !scorerStatus.enabled"
|
||||
class="mb-5 px-4 py-3 rounded border border-sev-warn/40 bg-surface-raised text-sev-warn text-sm"
|
||||
>
|
||||
Anomaly scoring is disabled — set <code class="font-mono text-xs bg-surface px-1 py-0.5 rounded">TURNSTONE_ANOMALY_MODEL</code>
|
||||
in your <code class="font-mono text-xs bg-surface px-1 py-0.5 rounded">.env</code> and restart Turnstone.
|
||||
</div>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div v-if="scorerStatus?.enabled" class="mb-5 flex flex-wrap gap-4 text-xs text-text-dim">
|
||||
<span>Total scored: <span class="text-text-primary font-mono">{{ scorerStatus.total_scored ?? '—' }}</span></span>
|
||||
<span>Total detections: <span class="text-text-primary font-mono">{{ scorerStatus.total_detections ?? '—' }}</span></span>
|
||||
<span v-if="scorerStatus.last_run_at">
|
||||
Last run: <span class="text-text-primary font-mono">{{ formatTs(scorerStatus.last_run_at) }}</span>
|
||||
</span>
|
||||
<span v-if="scorerStatus.last_error" class="text-sev-error">
|
||||
Last error: {{ scorerStatus.last_error }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Filter / Tab bar -->
|
||||
<div class="mb-4 flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<!-- Tabs -->
|
||||
<div role="tablist" aria-label="Filter by acknowledgement" class="flex gap-1 border-b border-surface-border flex-1">
|
||||
<button
|
||||
v-for="(tab, idx) in tabs"
|
||||
:key="tab.value"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === tab.value"
|
||||
:tabindex="activeTab === tab.value ? 0 : -1"
|
||||
@click="activeTab = tab.value as 'all' | 'unacked'; loadDetections()"
|
||||
@keydown="handleTabKey($event, idx)"
|
||||
:ref="(el) => collectTabRef(el as HTMLElement | null, idx)"
|
||||
:class="[
|
||||
'px-4 py-2 text-sm transition-colors border-b-2 -mb-px whitespace-nowrap',
|
||||
activeTab === tab.value
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-text-dim hover:text-text-primary'
|
||||
]"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span v-if="tab.count !== null" class="ml-1 text-xs opacity-70">({{ tab.count }})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Label filter -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<label for="label-filter" class="text-xs text-text-dim whitespace-nowrap">Label:</label>
|
||||
<select
|
||||
id="label-filter"
|
||||
v-model="labelFilter"
|
||||
@change="loadDetections()"
|
||||
class="text-xs bg-surface border border-surface-border rounded px-2 py-1 text-text-primary focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option v-for="lbl in knownLabels" :key="lbl" :value="lbl">{{ lbl }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="text-text-dim py-12 text-center text-sm">Loading…</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="detections.length === 0" class="text-text-dim py-12 text-center text-sm">
|
||||
<p v-if="activeTab === 'unacked'">No unacknowledged detections — all clear.</p>
|
||||
<p v-else-if="!scorerStatus?.enabled">Enable anomaly scoring to start detecting.</p>
|
||||
<p v-else>No detections yet. Run the scorer after gleaning to populate this list.</p>
|
||||
</div>
|
||||
|
||||
<!-- Detections table -->
|
||||
<div v-else class="rounded border border-surface-border overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[700px]">
|
||||
<thead class="bg-surface-raised border-b border-surface-border">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider w-20">Sev</th>
|
||||
<th class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Label</th>
|
||||
<th class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider w-16">Score</th>
|
||||
<th class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Source</th>
|
||||
<th class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Log entry</th>
|
||||
<th class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider w-32">Detected</th>
|
||||
<th class="px-4 py-2.5 w-28"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="det in detections"
|
||||
:key="det.id"
|
||||
:class="[
|
||||
'border-b border-surface-border transition-colors cursor-pointer',
|
||||
det.acknowledged ? 'opacity-50 hover:opacity-75' : 'hover:bg-surface-raised'
|
||||
]"
|
||||
@click="openDrawer(det)"
|
||||
>
|
||||
<td class="px-4 py-2.5">
|
||||
<span :class="['text-xs font-semibold', severityTextClass(det.severity)]">
|
||||
{{ det.severity }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="font-mono text-xs text-accent bg-surface px-1.5 py-0.5 rounded border border-surface-border">
|
||||
{{ det.anomaly_label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-10 h-1.5 bg-surface-raised rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
:class="scoreBarColor(det.anomaly_score)"
|
||||
:style="{ width: `${Math.round(det.anomaly_score * 100)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-text-dim font-mono">{{ Math.round(det.anomaly_score * 100) }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-text-dim text-xs font-mono truncate max-w-[120px]">{{ det.source_id }}</td>
|
||||
<td class="px-4 py-2.5 text-text-dim text-xs truncate max-w-[260px]" :title="det.text">{{ det.text }}</td>
|
||||
<td class="px-4 py-2.5 text-text-dim text-xs whitespace-nowrap">{{ formatTs(det.detected_at) }}</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<span
|
||||
v-if="det.acknowledged"
|
||||
class="text-xs text-text-dim italic"
|
||||
>reviewed</span>
|
||||
<button
|
||||
v-else
|
||||
@click.stop="openDrawer(det)"
|
||||
class="text-xs px-2 py-1 rounded border border-surface-border text-text-dim hover:text-text-primary hover:border-accent transition-colors"
|
||||
>
|
||||
Acknowledge
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Acknowledge drawer -->
|
||||
<Transition name="drawer">
|
||||
<div v-if="drawer" class="mt-6 rounded border border-accent bg-surface p-5">
|
||||
<div class="flex items-start justify-between mb-4 gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span :class="['text-xs font-semibold', severityTextClass(drawer.severity)]">{{ drawer.severity }}</span>
|
||||
<span class="font-mono text-xs text-accent bg-surface-raised px-1.5 py-0.5 rounded border border-surface-border">
|
||||
{{ drawer.anomaly_label }}
|
||||
</span>
|
||||
<span class="text-xs text-text-dim font-mono">{{ Math.round(drawer.anomaly_score * 100) }}% confidence</span>
|
||||
</div>
|
||||
<p class="text-text-dim text-xs font-mono">
|
||||
source: {{ drawer.source_id }}
|
||||
<span v-if="drawer.timestamp_iso"> · {{ formatTs(drawer.timestamp_iso) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="drawer = null"
|
||||
class="text-text-dim hover:text-text-primary transition-colors shrink-0 text-lg leading-none"
|
||||
aria-label="Close drawer"
|
||||
>✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Full log text -->
|
||||
<div class="mb-4 bg-surface-raised rounded border border-surface-border p-3 text-xs font-mono text-text-primary break-all leading-relaxed max-h-40 overflow-y-auto">
|
||||
{{ drawer.text }}
|
||||
</div>
|
||||
|
||||
<!-- Already acknowledged -->
|
||||
<div v-if="drawer.acknowledged" class="text-text-dim text-sm">
|
||||
<p class="mb-1">Acknowledged <span class="text-text-primary">{{ formatTs(drawer.acknowledged_at) }}</span></p>
|
||||
<p v-if="drawer.notes" class="text-xs italic">{{ drawer.notes }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Acknowledge form -->
|
||||
<div v-else>
|
||||
<label for="ack-notes" class="block text-xs text-text-dim mb-1.5">Notes (optional)</label>
|
||||
<textarea
|
||||
id="ack-notes"
|
||||
v-model="ackNotes"
|
||||
rows="2"
|
||||
placeholder="False positive, known pattern, remediated…"
|
||||
class="w-full bg-surface border border-surface-border rounded px-3 py-2 text-sm text-text-primary placeholder-text-dim focus:outline-none focus:border-accent resize-none mb-3"
|
||||
></textarea>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="acknowledge(drawer)"
|
||||
:disabled="ackLoading"
|
||||
class="px-4 py-2 bg-accent text-surface text-sm rounded font-medium hover:opacity-90 transition-opacity disabled:opacity-40"
|
||||
>
|
||||
{{ ackLoading ? 'Saving…' : 'Mark as reviewed' }}
|
||||
</button>
|
||||
<button
|
||||
@click="drawer = null"
|
||||
class="px-4 py-2 text-text-dim text-sm rounded border border-surface-border hover:text-text-primary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<span v-if="ackError" class="text-xs text-sev-error">{{ ackError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Detection {
|
||||
id: string
|
||||
source_id: string
|
||||
entry_id: string
|
||||
anomaly_label: string
|
||||
anomaly_score: number
|
||||
severity: string
|
||||
text: string
|
||||
timestamp_iso: string | null
|
||||
detected_at: string
|
||||
acknowledged: number | boolean
|
||||
acknowledged_at: string | null
|
||||
notes: string
|
||||
}
|
||||
|
||||
interface ScorerStatus {
|
||||
enabled: boolean
|
||||
running: boolean
|
||||
model: string | null
|
||||
threshold: number
|
||||
device: string
|
||||
last_run_at: string | null
|
||||
last_scored: number
|
||||
last_detections: number
|
||||
last_error: string | null
|
||||
total_scored: number
|
||||
total_detections: number
|
||||
}
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const detections = ref<Detection[]>([])
|
||||
const scorerStatus = ref<ScorerStatus | null>(null)
|
||||
const loading = ref(true)
|
||||
const triggerLoading = ref(false)
|
||||
const ackLoading = ref(false)
|
||||
const ackError = ref<string | null>(null)
|
||||
const ackNotes = ref('')
|
||||
const drawer = ref<Detection | null>(null)
|
||||
const activeTab = ref<'all' | 'unacked'>('all')
|
||||
const labelFilter = ref('')
|
||||
const tabRefs = ref<(HTMLElement | null)[]>([])
|
||||
|
||||
const knownLabels = [
|
||||
'SECURITY_ANOMALY', 'SYSTEM_FAILURE', 'PERFORMANCE_ISSUE',
|
||||
'NETWORK_ANOMALY', 'CONFIG_ERROR', 'HARDWARE_ISSUE',
|
||||
'CRITICAL', 'ERROR',
|
||||
]
|
||||
|
||||
// ── Tabs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const unackedCount = computed(() => detections.value.filter(d => !d.acknowledged).length)
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ value: 'all', label: 'All', count: detections.value.length },
|
||||
{ value: 'unacked', label: 'Unacknowledged', count: unackedCount.value },
|
||||
])
|
||||
|
||||
const filteredDetections = computed(() =>
|
||||
activeTab.value === 'unacked'
|
||||
? detections.value.filter(d => !d.acknowledged)
|
||||
: detections.value
|
||||
)
|
||||
|
||||
// ── Data loading ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadDetections() {
|
||||
loading.value = true
|
||||
const params = new URLSearchParams({ limit: '200' })
|
||||
if (labelFilter.value) params.set('label', labelFilter.value)
|
||||
try {
|
||||
const res = await fetch(`${BASE}/turnstone/api/anomaly/detections?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
detections.value = (data.detections ?? []).map((d: Detection) => ({
|
||||
...d,
|
||||
acknowledged: !!d.acknowledged,
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error('Failed to load detections', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScorerStatus() {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/turnstone/api/anomaly/status`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
scorerStatus.value = { ...data.state, ...data.config }
|
||||
} catch {
|
||||
// scorer status is non-critical — fail silently
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadScorerStatus()
|
||||
loadDetections()
|
||||
})
|
||||
|
||||
// ── Actions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function runScorer() {
|
||||
triggerLoading.value = true
|
||||
try {
|
||||
await fetch(`${BASE}/turnstone/api/anomaly/run`, { method: 'POST' })
|
||||
// reload status after a short delay so the running flag has time to flip
|
||||
setTimeout(() => { loadScorerStatus(); loadDetections() }, 2000)
|
||||
} finally {
|
||||
triggerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDrawer(det: Detection) {
|
||||
ackNotes.value = det.notes ?? ''
|
||||
ackError.value = null
|
||||
drawer.value = det
|
||||
}
|
||||
|
||||
async function acknowledge(det: Detection) {
|
||||
ackLoading.value = true
|
||||
ackError.value = null
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (ackNotes.value.trim()) params.set('notes', ackNotes.value.trim())
|
||||
const res = await fetch(
|
||||
`${BASE}/turnstone/api/anomaly/detections/${det.id}/acknowledge?${params}`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
// update in-place so the row dims without a full reload
|
||||
const idx = detections.value.findIndex(d => d.id === det.id)
|
||||
const existing = idx !== -1 ? detections.value[idx] : null
|
||||
if (existing) {
|
||||
detections.value.splice(idx, 1, { ...existing, acknowledged: true, notes: ackNotes.value.trim() })
|
||||
}
|
||||
drawer.value = null
|
||||
ackNotes.value = ''
|
||||
loadScorerStatus()
|
||||
} catch (e) {
|
||||
ackError.value = 'Failed to save — try again'
|
||||
console.error(e)
|
||||
} finally {
|
||||
ackLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function severityTextClass(sev: string | null): string {
|
||||
return ({
|
||||
CRITICAL: 'text-sev-critical',
|
||||
ERROR: 'text-sev-error',
|
||||
WARN: 'text-sev-warn',
|
||||
WARNING: 'text-sev-warn',
|
||||
INFO: 'text-sev-info',
|
||||
DEBUG: 'text-text-dim',
|
||||
} as Record<string, string>)[sev?.toUpperCase() ?? ''] ?? 'text-text-dim'
|
||||
}
|
||||
|
||||
function scoreBarColor(score: number): string {
|
||||
if (score >= 0.90) return 'bg-sev-critical'
|
||||
if (score >= 0.80) return 'bg-sev-error'
|
||||
if (score >= 0.65) return 'bg-sev-warn'
|
||||
return 'bg-sev-info'
|
||||
}
|
||||
|
||||
function formatTs(iso: string | null): string {
|
||||
if (!iso) return '—'
|
||||
try {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
} catch { return iso }
|
||||
}
|
||||
|
||||
// ── Keyboard nav for tabs ─────────────────────────────────────────────────────
|
||||
|
||||
function collectTabRef(el: HTMLElement | null, idx: number) {
|
||||
tabRefs.value[idx] = el
|
||||
}
|
||||
|
||||
function handleTabKey(e: KeyboardEvent, idx: number) {
|
||||
const count = tabs.value.length
|
||||
let next = idx
|
||||
if (e.key === 'ArrowRight') next = (idx + 1) % count
|
||||
else if (e.key === 'ArrowLeft') next = (idx - 1 + count) % count
|
||||
else return
|
||||
e.preventDefault()
|
||||
tabRefs.value[next]?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active { transition: opacity 0.15s, transform 0.15s; }
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to { opacity: 0; transform: translateY(-6px); }
|
||||
</style>
|
||||
Loading…
Reference in a new issue