turnstone/web/src/views/SecurityAlertsView.vue
pyr0ball e2a78d45ef feat(alerts): security alerts tab — full scorer integration
- Fix loadScorerStatus: was spreading data.state + data.config (both
  undefined); API returns flat object; now uses data directly
- Fix v-for to use filteredDetections (was using raw detections array,
  breaking the Unacknowledged tab filter)
- Fix double-prefix URL bug: BASE already contains /turnstone, so
  fetches to ${BASE}/turnstone/api/... doubled the prefix → returned
  SPA HTML → silent JSON parse failure. Fixed all fetch URLs to use
  ${BASE}/api/... in SecurityAlertsView and DashboardView
- Add CybersecStatus interface to replace Record<string, unknown>
- Add scorer field to Detection interface; show 'cybersec' badge in
  label cell when scorer !== 'anomaly'
- Add cybersecStatus.running to cybersec badge (pulse animation)
- Add ANOMALY / CYBERSEC stats rows side-by-side
- Add 'Run cybersec' button with cybersecTriggerLoading state and
  runCybersec() function posting to /api/cybersec/run
- Rename 'Run scorer' → 'Run anomaly' for clarity

Closes: #11
2026-06-10 14:32:43 -07:00

563 lines
22 KiB
Vue

<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>
<!-- Cybersec scorer status -->
<span
v-if="cybersecStatus"
:class="[
'text-xs px-2 py-1 rounded border font-mono',
cybersecStatus.enabled
? cybersecStatus.running
? 'border-accent text-accent animate-pulse'
: 'border-surface-border text-text-dim'
: 'border-surface-border text-text-dim opacity-40'
]"
:title="cybersecStatus.enabled ? `cybersec: ${cybersecStatus.model}` : 'TURNSTONE_CYBERSEC_MODEL not set'"
>
{{ cybersecStatus.running ? 'cybersec scoring…' : cybersecStatus.enabled ? 'cybersec on' : 'cybersec 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 anomaly' }}
</button>
<button
@click="runCybersec"
:disabled="!cybersecStatus?.enabled || cybersecTriggerLoading || cybersecStatus?.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 a cybersec scoring pass"
>
{{ cybersecTriggerLoading ? 'triggering…' : 'Run cybersec' }}
</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 class="mb-5 flex flex-wrap gap-x-6 gap-y-2 text-xs text-text-dim">
<template v-if="scorerStatus?.enabled">
<span class="text-text-dim/60 uppercase tracking-wider font-medium">Anomaly:</span>
<span>scored <span class="text-text-primary font-mono">{{ scorerStatus.total_scored ?? '—' }}</span></span>
<span>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">error: {{ scorerStatus.last_error }}</span>
</template>
<template v-if="cybersecStatus?.enabled">
<span class="text-text-dim/60 uppercase tracking-wider font-medium ml-2">Cybersec:</span>
<span>scored <span class="text-text-primary font-mono">{{ cybersecStatus.total_scored ?? '—' }}</span></span>
<span>detections <span class="text-text-primary font-mono">{{ cybersecStatus.total_detections ?? '—' }}</span></span>
<span v-if="cybersecStatus.last_run_at">
last run <span class="text-text-primary font-mono">{{ formatTs(cybersecStatus.last_run_at) }}</span>
</span>
<span v-if="cybersecStatus.last_error" class="text-sev-error">error: {{ cybersecStatus.last_error }}</span>
</template>
</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>
<!-- Scorer filter -->
<div class="flex items-center gap-2 shrink-0">
<label for="scorer-filter" class="text-xs text-text-dim whitespace-nowrap">Source:</label>
<select
id="scorer-filter"
v-model="scorerFilter"
@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 value="anomaly">Anomaly scorer</option>
<option value="cybersec">Cybersec scorer</option>
</select>
</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>
<optgroup label="Anomaly labels">
<option v-for="lbl in anomalyLabels" :key="lbl" :value="lbl">{{ lbl }}</option>
</optgroup>
<optgroup label="Cybersec labels">
<option v-for="lbl in cybersecLabels" :key="lbl" :value="lbl">{{ lbl }}</option>
</optgroup>
</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 filteredDetections"
: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">
<div class="flex items-center gap-1.5 flex-wrap">
<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>
<span
v-if="det.scorer === 'cybersec'"
class="text-xs px-1.5 py-0.5 rounded bg-surface-raised border border-surface-border text-text-dim font-mono"
>cybersec</span>
</div>
</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
scorer: 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
}
interface CybersecStatus {
enabled: boolean
running: boolean
model: string | null
threshold: number
device: string
last_run_at: string | null
last_duration_s: number | 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 cybersecStatus = ref<CybersecStatus | null>(null)
const loading = ref(true)
const triggerLoading = ref(false)
const cybersecTriggerLoading = 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 scorerFilter = ref('')
const tabRefs = ref<(HTMLElement | null)[]>([])
const anomalyLabels = [
'SECURITY_ANOMALY', 'SYSTEM_FAILURE', 'PERFORMANCE_ISSUE',
'NETWORK_ANOMALY', 'CONFIG_ERROR', 'HARDWARE_ISSUE',
'CRITICAL', 'ERROR',
]
const cybersecLabels = [
'authentication failure or brute force attack',
'privilege escalation or unauthorized access',
'network intrusion or port scan',
'malware or suspicious process activity',
'data exfiltration or unusual outbound traffic',
]
// ── 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)
if (scorerFilter.value) params.set('scorer', scorerFilter.value)
try {
const res = await fetch(`${BASE}/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 [anomalyRes, cybersecRes] = await Promise.all([
fetch(`${BASE}/api/anomaly/status`),
fetch(`${BASE}/api/cybersec/status`),
])
if (anomalyRes.ok) {
scorerStatus.value = await anomalyRes.json()
}
if (cybersecRes.ok) {
const data = await cybersecRes.json()
cybersecStatus.value = data
}
} catch {
// scorer status is non-critical — fail silently
}
}
onMounted(() => {
loadScorerStatus()
loadDetections()
})
// ── Actions ──────────────────────────────────────────────────────────────────
async function runScorer() {
triggerLoading.value = true
try {
await fetch(`${BASE}/api/anomaly/run`, { method: 'POST' })
setTimeout(() => { loadScorerStatus(); loadDetections() }, 2000)
} finally {
triggerLoading.value = false
}
}
async function runCybersec() {
cybersecTriggerLoading.value = true
try {
await fetch(`${BASE}/api/cybersec/run`, { method: 'POST' })
setTimeout(() => { loadScorerStatus(); loadDetections() }, 2000)
} finally {
cybersecTriggerLoading.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}/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>