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:
parent
db2e4f85e7
commit
e2a78d45ef
3 changed files with 85 additions and 32 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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>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">
|
<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>
|
||||||
<span v-if="scorerStatus.last_error" class="text-sev-error">
|
<span v-if="scorerStatus.last_error" class="text-sev-error">error: {{ scorerStatus.last_error }}</span>
|
||||||
Last error: {{ scorerStatus.last_error }}
|
</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>
|
||||||
|
<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">
|
||||||
|
<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">
|
<span class="font-mono text-xs text-accent bg-surface px-1.5 py-0.5 rounded border border-surface-border">
|
||||||
{{ det.anomaly_label }}
|
{{ det.anomaly_label }}
|
||||||
</span>
|
</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,13 +346,29 @@ 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 cybersecTriggerLoading = ref(false)
|
||||||
const ackLoading = ref(false)
|
const ackLoading = ref(false)
|
||||||
const ackError = ref<string | null>(null)
|
const ackError = ref<string | null>(null)
|
||||||
const ackNotes = ref('')
|
const ackNotes = ref('')
|
||||||
|
|
@ -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}`)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue