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: '/search', label: 'Search' },
|
||||||
{ to: '/diagnose', label: 'Diagnose' },
|
{ to: '/diagnose', label: 'Diagnose' },
|
||||||
{ to: '/incidents', label: 'Incidents' },
|
{ to: '/incidents', label: 'Incidents' },
|
||||||
|
{ to: '/alerts', label: 'Alerts' },
|
||||||
{ to: '/bundles', label: 'Bundles' },
|
{ to: '/bundles', label: 'Bundles' },
|
||||||
{ to: '/sources', label: 'Sources' },
|
{ to: '/sources', label: 'Sources' },
|
||||||
{ to: '/context', label: 'Context' },
|
{ to: '/context', label: 'Context' },
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import BundlesView from '@/views/BundlesView.vue'
|
||||||
import SettingsView from '@/views/SettingsView.vue'
|
import SettingsView from '@/views/SettingsView.vue'
|
||||||
import ContextView from '@/views/ContextView.vue'
|
import ContextView from '@/views/ContextView.vue'
|
||||||
import BlocklistView from '@/views/BlocklistView.vue'
|
import BlocklistView from '@/views/BlocklistView.vue'
|
||||||
|
import SecurityAlertsView from '@/views/SecurityAlertsView.vue'
|
||||||
|
|
||||||
export default createRouter({
|
export default createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
|
@ -17,6 +18,7 @@ export default createRouter({
|
||||||
{ path: '/search', component: LogSearchView },
|
{ path: '/search', component: LogSearchView },
|
||||||
{ path: '/diagnose', component: DiagnoseView },
|
{ path: '/diagnose', component: DiagnoseView },
|
||||||
{ path: '/incidents', component: IncidentsView },
|
{ path: '/incidents', component: IncidentsView },
|
||||||
|
{ path: '/alerts', component: SecurityAlertsView },
|
||||||
{ path: '/bundles', component: BundlesView },
|
{ path: '/bundles', component: BundlesView },
|
||||||
{ path: '/sources', component: SourcesView },
|
{ path: '/sources', component: SourcesView },
|
||||||
{ path: '/context', component: ContextView },
|
{ path: '/context', component: ContextView },
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,16 @@
|
||||||
{{ incidentsLoading ? '…' : activeIncidents }}
|
{{ incidentsLoading ? '…' : activeIncidents }}
|
||||||
</p>
|
</p>
|
||||||
</RouterLink>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Source health (24h) -->
|
<!-- Source health (24h) -->
|
||||||
|
|
@ -201,6 +211,8 @@ const loading = ref(true)
|
||||||
const incidents = ref<Incident[]>([])
|
const incidents = ref<Incident[]>([])
|
||||||
const incidentsLoading = ref(true)
|
const incidentsLoading = ref(true)
|
||||||
const watchSources = ref<WatchSourceStatus[]>([])
|
const watchSources = ref<WatchSourceStatus[]>([])
|
||||||
|
const unackedAlerts = ref(0)
|
||||||
|
const alertsLoading = ref(true)
|
||||||
|
|
||||||
const activeIncidents = computed(() =>
|
const activeIncidents = computed(() =>
|
||||||
incidents.value.filter(i => !i.ended_at).length
|
incidents.value.filter(i => !i.ended_at).length
|
||||||
|
|
@ -217,7 +229,7 @@ const isStale = computed(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadStats(), loadIncidents(), loadWatchStatus()])
|
await Promise.all([loadStats(), loadIncidents(), loadWatchStatus(), loadAlertCount()])
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
|
|
@ -245,6 +257,14 @@ async function loadWatchStatus() {
|
||||||
} catch { /* non-critical */ }
|
} 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 {
|
function healthDot(errors: number, total: number): string {
|
||||||
if (errors === 0) return 'bg-green-500'
|
if (errors === 0) return 'bg-green-500'
|
||||||
const ratio = errors / Math.max(total, 1)
|
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