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:
pyr0ball 2026-06-10 00:28:15 -07:00
parent ae13322648
commit d1e6871525
4 changed files with 482 additions and 1 deletions

View file

@ -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' },

View file

@ -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 },

View file

@ -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)

View 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>