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
This commit is contained in:
pyr0ball 2026-06-10 14:32:43 -07:00
parent 9659e74aee
commit a33d983128
3 changed files with 85 additions and 32 deletions

View file

@ -117,10 +117,11 @@ def get_conn(db_path: Path | None = None) -> Generator[DbConn, None, None]:
else: else:
if db_path is None: if db_path is None:
raise ValueError("db_path is required for SQLite backend") raise ValueError("db_path is required for SQLite backend")
raw = sqlite3.connect(str(db_path), timeout=30.0) raw = sqlite3.connect(str(db_path), timeout=90.0)
raw.row_factory = sqlite3.Row raw.row_factory = sqlite3.Row
try: try:
raw.execute("PRAGMA journal_mode=WAL") raw.execute("PRAGMA journal_mode=WAL")
raw.execute("PRAGMA busy_timeout=90000")
raw.execute("PRAGMA foreign_keys=ON") raw.execute("PRAGMA foreign_keys=ON")
yield DbConn(raw, BACKEND) yield DbConn(raw, BACKEND)
finally: finally:

View file

@ -259,7 +259,7 @@ async function loadWatchStatus() {
async function loadAlertCount() { async function loadAlertCount() {
try { try {
const res = await fetch(`${BASE}/turnstone/api/anomaly/detections?unacked_only=true&limit=1000`) const res = await fetch(`${BASE}/api/anomaly/detections?unacked_only=true&limit=1000`)
if (res.ok) unackedAlerts.value = (await res.json()).total ?? 0 if (res.ok) unackedAlerts.value = (await res.json()).total ?? 0
} catch { /* non-critical — scorer may be disabled */ } } catch { /* non-critical — scorer may be disabled */ }
finally { alertsLoading.value = false } finally { alertsLoading.value = false }

View file

@ -35,12 +35,14 @@
:class="[ :class="[
'text-xs px-2 py-1 rounded border font-mono', 'text-xs px-2 py-1 rounded border font-mono',
cybersecStatus.enabled cybersecStatus.enabled
? 'border-surface-border text-text-dim' ? cybersecStatus.running
? 'border-accent text-accent animate-pulse'
: 'border-surface-border text-text-dim'
: 'border-surface-border text-text-dim opacity-40' : 'border-surface-border text-text-dim opacity-40'
]" ]"
:title="cybersecStatus.enabled ? `cybersec: ${cybersecStatus.model}` : 'TURNSTONE_CYBERSEC_MODEL not set'" :title="cybersecStatus.enabled ? `cybersec: ${cybersecStatus.model}` : 'TURNSTONE_CYBERSEC_MODEL not set'"
> >
{{ cybersecStatus.enabled ? 'cybersec on' : 'cybersec off' }} {{ cybersecStatus.running ? 'cybersec scoring…' : cybersecStatus.enabled ? 'cybersec on' : 'cybersec off' }}
</span> </span>
<button <button
@ -49,7 +51,16 @@
class="px-3 py-1.5 bg-accent text-surface text-xs rounded font-medium hover:opacity-90 transition-opacity disabled:opacity-40" 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" title="Manually trigger an anomaly scoring pass"
> >
{{ triggerLoading ? 'triggering…' : 'Run scorer' }} {{ 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> </button>
</div> </div>
</div> </div>
@ -64,15 +75,25 @@
</div> </div>
<!-- Stats row --> <!-- Stats row -->
<div v-if="scorerStatus?.enabled" class="mb-5 flex flex-wrap gap-4 text-xs text-text-dim"> <div class="mb-5 flex flex-wrap gap-x-6 gap-y-2 text-xs text-text-dim">
<span>Total scored: <span class="text-text-primary font-mono">{{ scorerStatus.total_scored ?? '—' }}</span></span> <template v-if="scorerStatus?.enabled">
<span>Total detections: <span class="text-text-primary font-mono">{{ scorerStatus.total_detections ?? '—' }}</span></span> <span class="text-text-dim/60 uppercase tracking-wider font-medium">Anomaly:</span>
<span v-if="scorerStatus.last_run_at"> <span>scored <span class="text-text-primary font-mono">{{ scorerStatus.total_scored ?? '—' }}</span></span>
Last run: <span class="text-text-primary font-mono">{{ formatTs(scorerStatus.last_run_at) }}</span> <span>detections <span class="text-text-primary font-mono">{{ scorerStatus.total_detections ?? '—' }}</span></span>
</span> <span v-if="scorerStatus.last_run_at">
<span v-if="scorerStatus.last_error" class="text-sev-error"> last run <span class="text-text-primary font-mono">{{ formatTs(scorerStatus.last_run_at) }}</span>
Last error: {{ scorerStatus.last_error }} </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> </div>
<!-- Filter / Tab bar --> <!-- Filter / Tab bar -->
@ -162,7 +183,7 @@
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="det in detections" v-for="det in filteredDetections"
:key="det.id" :key="det.id"
:class="[ :class="[
'border-b border-surface-border transition-colors cursor-pointer', 'border-b border-surface-border transition-colors cursor-pointer',
@ -176,9 +197,15 @@
</span> </span>
</td> </td>
<td class="px-4 py-2.5"> <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"> <div class="flex items-center gap-1.5 flex-wrap">
{{ det.anomaly_label }} <span class="font-mono text-xs text-accent bg-surface px-1.5 py-0.5 rounded border border-surface-border">
</span> {{ 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>
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
@ -302,6 +329,7 @@ interface Detection {
acknowledged: number | boolean acknowledged: number | boolean
acknowledged_at: string | null acknowledged_at: string | null
notes: string notes: string
scorer: string
} }
interface ScorerStatus { interface ScorerStatus {
@ -318,14 +346,30 @@ interface ScorerStatus {
total_detections: 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 // State
const detections = ref<Detection[]>([]) const detections = ref<Detection[]>([])
const scorerStatus = ref<ScorerStatus | null>(null) const scorerStatus = ref<ScorerStatus | null>(null)
const cybersecStatus = ref<Record<string, unknown> | null>(null) const cybersecStatus = ref<CybersecStatus | null>(null)
const loading = ref(true) const loading = ref(true)
const triggerLoading = ref(false) const triggerLoading = ref(false)
const ackLoading = ref(false) const cybersecTriggerLoading = ref(false)
const ackLoading = ref(false)
const ackError = ref<string | null>(null) const ackError = ref<string | null>(null)
const ackNotes = ref('') const ackNotes = ref('')
const drawer = ref<Detection | null>(null) const drawer = ref<Detection | null>(null)
@ -371,7 +415,7 @@ async function loadDetections() {
if (labelFilter.value) params.set('label', labelFilter.value) if (labelFilter.value) params.set('label', labelFilter.value)
if (scorerFilter.value) params.set('scorer', scorerFilter.value) if (scorerFilter.value) params.set('scorer', scorerFilter.value)
try { try {
const res = await fetch(`${BASE}/turnstone/api/anomaly/detections?${params}`) const res = await fetch(`${BASE}/api/anomaly/detections?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json() const data = await res.json()
detections.value = (data.detections ?? []).map((d: Detection) => ({ detections.value = (data.detections ?? []).map((d: Detection) => ({
@ -388,12 +432,11 @@ async function loadDetections() {
async function loadScorerStatus() { async function loadScorerStatus() {
try { try {
const [anomalyRes, cybersecRes] = await Promise.all([ const [anomalyRes, cybersecRes] = await Promise.all([
fetch(`${BASE}/turnstone/api/anomaly/status`), fetch(`${BASE}/api/anomaly/status`),
fetch(`${BASE}/turnstone/api/cybersec/status`), fetch(`${BASE}/api/cybersec/status`),
]) ])
if (anomalyRes.ok) { if (anomalyRes.ok) {
const data = await anomalyRes.json() scorerStatus.value = await anomalyRes.json()
scorerStatus.value = { ...data.state, ...data.config }
} }
if (cybersecRes.ok) { if (cybersecRes.ok) {
const data = await cybersecRes.json() const data = await cybersecRes.json()
@ -414,14 +457,23 @@ onMounted(() => {
async function runScorer() { async function runScorer() {
triggerLoading.value = true triggerLoading.value = true
try { try {
await fetch(`${BASE}/turnstone/api/anomaly/run`, { method: 'POST' }) await fetch(`${BASE}/api/anomaly/run`, { method: 'POST' })
// reload status after a short delay so the running flag has time to flip
setTimeout(() => { loadScorerStatus(); loadDetections() }, 2000) setTimeout(() => { loadScorerStatus(); loadDetections() }, 2000)
} finally { } finally {
triggerLoading.value = false 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) { function openDrawer(det: Detection) {
ackNotes.value = det.notes ?? '' ackNotes.value = det.notes ?? ''
ackError.value = null ackError.value = null
@ -435,7 +487,7 @@ async function acknowledge(det: Detection) {
const params = new URLSearchParams() const params = new URLSearchParams()
if (ackNotes.value.trim()) params.set('notes', ackNotes.value.trim()) if (ackNotes.value.trim()) params.set('notes', ackNotes.value.trim())
const res = await fetch( const res = await fetch(
`${BASE}/turnstone/api/anomaly/detections/${det.id}/acknowledge?${params}`, `${BASE}/api/anomaly/detections/${det.id}/acknowledge?${params}`,
{ method: 'POST' } { method: 'POST' }
) )
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)