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:
if db_path is None:
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
try:
raw.execute("PRAGMA journal_mode=WAL")
raw.execute("PRAGMA busy_timeout=90000")
raw.execute("PRAGMA foreign_keys=ON")
yield DbConn(raw, BACKEND)
finally:

View file

@ -259,7 +259,7 @@ async function loadWatchStatus() {
async function loadAlertCount() {
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
} catch { /* non-critical — scorer may be disabled */ }
finally { alertsLoading.value = false }

View file

@ -35,12 +35,14 @@
:class="[
'text-xs px-2 py-1 rounded border font-mono',
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'
]"
: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>
<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"
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>
</div>
</div>
@ -64,15 +75,25 @@
</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>
<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>
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 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 -->
@ -162,7 +183,7 @@
</thead>
<tbody>
<tr
v-for="det in detections"
v-for="det in filteredDetections"
:key="det.id"
:class="[
'border-b border-surface-border transition-colors cursor-pointer',
@ -176,9 +197,15 @@
</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">
@ -302,6 +329,7 @@ interface Detection {
acknowledged: number | boolean
acknowledged_at: string | null
notes: string
scorer: string
}
interface ScorerStatus {
@ -318,13 +346,29 @@ interface ScorerStatus {
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<Record<string, unknown> | 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('')
@ -371,7 +415,7 @@ async function loadDetections() {
if (labelFilter.value) params.set('label', labelFilter.value)
if (scorerFilter.value) params.set('scorer', scorerFilter.value)
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}`)
const data = await res.json()
detections.value = (data.detections ?? []).map((d: Detection) => ({
@ -388,12 +432,11 @@ async function loadDetections() {
async function loadScorerStatus() {
try {
const [anomalyRes, cybersecRes] = await Promise.all([
fetch(`${BASE}/turnstone/api/anomaly/status`),
fetch(`${BASE}/turnstone/api/cybersec/status`),
fetch(`${BASE}/api/anomaly/status`),
fetch(`${BASE}/api/cybersec/status`),
])
if (anomalyRes.ok) {
const data = await anomalyRes.json()
scorerStatus.value = { ...data.state, ...data.config }
scorerStatus.value = await anomalyRes.json()
}
if (cybersecRes.ok) {
const data = await cybersecRes.json()
@ -414,14 +457,23 @@ onMounted(() => {
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
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
@ -435,7 +487,7 @@ async function acknowledge(det: Detection) {
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}`,
`${BASE}/api/anomaly/detections/${det.id}/acknowledge?${params}`,
{ method: 'POST' }
)
if (!res.ok) throw new Error(`HTTP ${res.status}`)