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
9659e74aee
commit
a33d983128
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:
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
Loading…
Reference in a new issue