- 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
563 lines
22 KiB
Vue
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>
|