feat(ui): security alert dedup, clickable criticals, loading shimmer

Security Alerts:
- Client-side duplicate collapsing via anomaly_label + text fingerprint
- ×N count badge chip on collapsed rows; toggle to expand
- Skeleton shimmer rows replace "Loading..." text

Dashboard:
- Clickable Recent Criticals — inline LLM explanation via SSE stream
- ±5 min time window scoped to source_id for useful context
- Explanation cache keyed by entry_id (no re-fetch on re-expand)
- Default diagnose query injected on Diagnose button navigation to
  prevent local models hallucinating from bare log data
- Stat card and source-health skeleton shimmer loading states

Backend:
- anomaly.py: 4-attempt retry on "database is locked" with 10s backoff
- search.py: migrate build_fts_index to get_conn() (WAL race fix);
  add timeline_events to stats_summary for clickable criticals feature
- theme.css: @keyframes shimmer + .loading-shimmer utility;
  prefers-reduced-motion degrades gracefully to static muted block
This commit is contained in:
pyr0ball 2026-06-13 09:32:26 -07:00
parent b9b8f6401d
commit eba1f825f6
7 changed files with 494 additions and 88 deletions

View file

@ -20,6 +20,7 @@ from __future__ import annotations
import logging import logging
import os import os
import time
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
@ -230,10 +231,19 @@ def score_unscored(
if label in _ANOMALOUS_LABELS and pred["score"] >= threshold: if label in _ANOMALOUS_LABELS and pred["score"] >= threshold:
detection_rows.append(enriched) detection_rows.append(enriched)
with get_conn(db_path) as conn: for _attempt in range(4):
_write_scores(conn, scored_rows, scored_at) try:
det_count = _insert_detections(conn, detection_rows, tenant_id, scored_at) with get_conn(db_path) as conn:
conn.commit() _write_scores(conn, scored_rows, scored_at)
det_count = _insert_detections(conn, detection_rows, tenant_id, scored_at)
conn.commit()
break
except Exception as exc:
if "database is locked" in str(exc).lower() and _attempt < 3:
logger.warning("DB locked, retrying write in 10s (attempt %d/4)", _attempt + 1)
time.sleep(10)
else:
raise
total_scored += len(scored_rows) total_scored += len(scored_rows)
total_detections += det_count total_detections += det_count

View file

@ -40,44 +40,42 @@ def build_fts_index(db_path: Path) -> None:
if BACKEND == Backend.POSTGRES: if BACKEND == Backend.POSTGRES:
return return
raw = sqlite3.connect(str(db_path), timeout=30.0) with get_conn(db_path) as conn:
raw.execute("PRAGMA journal_mode=WAL") needs_rebuild = False
try:
conn.execute("SELECT sequence FROM log_fts LIMIT 0")
except Exception:
needs_rebuild = True
needs_rebuild = False if needs_rebuild:
try: conn.execute("DROP TABLE IF EXISTS log_fts")
raw.execute("SELECT sequence FROM log_fts LIMIT 0") conn.commit()
except sqlite3.OperationalError:
needs_rebuild = True
if needs_rebuild: conn.execute("""
raw.execute("DROP TABLE IF EXISTS log_fts") CREATE VIRTUAL TABLE IF NOT EXISTS log_fts USING fts5(
text,
raw.executescript(""" entry_id UNINDEXED,
CREATE VIRTUAL TABLE IF NOT EXISTS log_fts USING fts5( source_id UNINDEXED,
text, sequence UNINDEXED,
entry_id UNINDEXED, severity UNINDEXED,
source_id UNINDEXED, timestamp_iso UNINDEXED,
sequence UNINDEXED, matched_patterns UNINDEXED,
severity UNINDEXED, repeat_count UNINDEXED,
timestamp_iso UNINDEXED, out_of_order UNINDEXED,
matched_patterns UNINDEXED, tokenize = 'porter ascii'
repeat_count UNINDEXED, )
out_of_order UNINDEXED, """)
tokenize = 'porter ascii' conn.execute("""
); INSERT INTO log_fts(text, entry_id, source_id, sequence, severity,
""") timestamp_iso, matched_patterns,
raw.execute(""" repeat_count, out_of_order)
INSERT INTO log_fts(text, entry_id, source_id, sequence, severity, SELECT e.text, e.id, e.source_id, e.sequence, e.severity,
timestamp_iso, matched_patterns, e.timestamp_iso, e.matched_patterns,
repeat_count, out_of_order) e.repeat_count, e.out_of_order
SELECT e.text, e.id, e.source_id, e.sequence, e.severity, FROM log_entries e
e.timestamp_iso, e.matched_patterns, WHERE e.id NOT IN (SELECT entry_id FROM log_fts WHERE entry_id IS NOT NULL)
e.repeat_count, e.out_of_order """)
FROM log_entries e conn.commit()
WHERE e.id NOT IN (SELECT entry_id FROM log_fts WHERE entry_id IS NOT NULL)
""")
raw.commit()
raw.close()
def _sanitize_fts_query(raw: str, or_mode: bool = False) -> str: def _sanitize_fts_query(raw: str, or_mode: bool = False) -> str:
@ -659,6 +657,21 @@ def stats_summary(db_path: Path, window_hours: int = 24, severity_overrides: lis
(tid,), (tid,),
).fetchall() ).fetchall()
timeline_rows = conn.execute(
"""
SELECT id as entry_id, source_id, timestamp_iso, severity, text
FROM log_entries
WHERE severity IN ('CRITICAL','ERROR','WARN','WARNING','EMERGENCY','ALERT')
AND timestamp_iso >= ?
AND timestamp_iso IS NOT NULL
AND repeat_count = 1
AND (tenant_id = ? OR tenant_id = '')
ORDER BY timestamp_iso DESC
LIMIT 300
""",
(since_iso, tid),
).fetchall()
last_row = conn.execute( last_row = conn.execute(
"SELECT MAX(ingest_time) AS t FROM log_entries WHERE (tenant_id = ? OR tenant_id = '')", "SELECT MAX(ingest_time) AS t FROM log_entries WHERE (tenant_id = ? OR tenant_id = '')",
(tid,), (tid,),
@ -691,6 +704,17 @@ def stats_summary(db_path: Path, window_hours: int = 24, severity_overrides: lis
else: else:
suppressed += 1 suppressed += 1
timeline_events = [
{
"entry_id": r["entry_id"],
"source_id": r["source_id"],
"timestamp_iso": r["timestamp_iso"],
"severity": r["severity"],
"text": r["text"],
}
for r in timeline_rows
]
last_gleaned: str | None = last_row["t"] if last_row else None last_gleaned: str | None = last_row["t"] if last_row else None
return { return {
@ -702,6 +726,7 @@ def stats_summary(db_path: Path, window_hours: int = 24, severity_overrides: lis
"recent_criticals": recent_criticals, "recent_criticals": recent_criticals,
"suppressed_criticals": suppressed, "suppressed_criticals": suppressed,
"last_gleaned": last_gleaned, "last_gleaned": last_gleaned,
"timeline_events": timeline_events,
} }

View file

@ -8,13 +8,19 @@
</div> </div>
<!-- SVG strip --> <!-- SVG strip -->
<div class="relative rounded border border-surface-border bg-surface overflow-hidden" style="height:64px"> <div
class="relative rounded border bg-surface overflow-hidden"
:class="brushable ? 'border-accent/40 cursor-crosshair' : 'border-surface-border'"
style="height:64px"
>
<svg <svg
:viewBox="`0 0 ${W} ${H}`" :viewBox="`0 0 ${W} ${H}`"
preserveAspectRatio="none" preserveAspectRatio="none"
class="w-full h-full" class="w-full h-full select-none"
@mousedown="onMouseDown"
@mousemove="onMouseMove" @mousemove="onMouseMove"
@mouseleave="tooltip = null" @mouseup="onMouseUp"
@mouseleave="onMouseLeave"
> >
<!-- Burst density bands (bin shading) --> <!-- Burst density bands (bin shading) -->
<rect <rect
@ -52,8 +58,23 @@
:height="ev.h" :height="ev.h"
:fill="ev.color" :fill="ev.color"
:fill-opacity="ev.alpha" :fill-opacity="ev.alpha"
class="cursor-pointer" :class="brushable ? '' : 'cursor-pointer'"
@click="$emit('select-entry', ev.index)" @click.stop="!brushable && $emit('select-entry', ev.index)"
/>
<!-- Brush selection rect -->
<rect
v-if="brushable && brushW > 4"
:x="brushLeft"
:width="brushW"
y="0"
:height="H"
fill="var(--color-accent)"
fill-opacity="0.18"
stroke="var(--color-accent)"
stroke-width="1"
stroke-opacity="0.5"
pointer-events="none"
/> />
<!-- Axis baseline --> <!-- Axis baseline -->
@ -64,9 +85,9 @@
/> />
</svg> </svg>
<!-- Hover tooltip --> <!-- Hover tooltip (hidden while brushing) -->
<div <div
v-if="tooltip" v-if="tooltip && !isDragging"
class="absolute pointer-events-none z-10 bg-surface-raised border border-surface-border rounded px-2 py-1 text-xs text-text-primary shadow-md max-w-xs truncate" class="absolute pointer-events-none z-10 bg-surface-raised border border-surface-border rounded px-2 py-1 text-xs text-text-primary shadow-md max-w-xs truncate"
:style="{ left: `${tooltip.px}px`, top: '4px', transform: tooltip.flip ? 'translateX(-100%)' : '' }" :style="{ left: `${tooltip.px}px`, top: '4px', transform: tooltip.flip ? 'translateX(-100%)' : '' }"
> >
@ -86,7 +107,8 @@
<span class="inline-block w-2 h-2 rounded-sm" :style="{ background: sev.color }"></span> <span class="inline-block w-2 h-2 rounded-sm" :style="{ background: sev.color }"></span>
{{ sev.label }} {{ sev.label }}
</span> </span>
<span class="ml-auto">{{ entries.length }} events</span> <span v-if="brushable" class="ml-auto text-text-dim opacity-70 italic">drag to filter</span>
<span v-else class="ml-auto">{{ entries.length }} events</span>
</div> </div>
</div> </div>
</template> </template>
@ -106,12 +128,22 @@ const props = defineProps<{
entries: Entry[] entries: Entry[]
startedAt?: string | null startedAt?: string | null
endedAt?: string | null endedAt?: string | null
brushable?: boolean
}>() }>()
defineEmits<{ const emit = defineEmits<{
'select-entry': [index: number] 'select-entry': [index: number]
'select-range': [range: { from: string; to: string } | null]
}>() }>()
// brush state
const isDragging = ref(false)
const brushAnchor = ref(0) // SVG-space X where drag started
const brushCursor = ref(0) // SVG-space X of current mouse position
const brushLeft = computed(() => Math.min(brushAnchor.value, brushCursor.value))
const brushW = computed(() => Math.abs(brushCursor.value - brushAnchor.value))
// SVG logical dimensions // SVG logical dimensions
const W = 1000 const W = 1000
const H = 64 const H = 64
@ -249,13 +281,37 @@ interface Tooltip {
const tooltip = ref<Tooltip | null>(null) const tooltip = ref<Tooltip | null>(null)
function onMouseMove(e: MouseEvent) { function _svgX(e: MouseEvent): number {
const svg = e.currentTarget as SVGElement const svg = e.currentTarget as SVGElement
const rect = svg.getBoundingClientRect() const rect = svg.getBoundingClientRect()
const relX = (e.clientX - rect.left) / rect.width // 0..1 return ((e.clientX - rect.left) / rect.width) * W
}
function _pxX(e: MouseEvent): number {
const svg = e.currentTarget as SVGElement
return e.clientX - svg.getBoundingClientRect().left
}
function onMouseDown(e: MouseEvent) {
if (!props.brushable) return
const x = _svgX(e)
isDragging.value = true
brushAnchor.value = x
brushCursor.value = x
e.preventDefault()
}
function onMouseMove(e: MouseEvent) {
if (props.brushable && isDragging.value) {
brushCursor.value = Math.max(0, Math.min(W, _svgX(e)))
return
}
const svg = e.currentTarget as SVGElement
const rect = svg.getBoundingClientRect()
const relX = (e.clientX - rect.left) / rect.width
const ms = tMin.value + relX * span.value const ms = tMin.value + relX * span.value
// Find nearest entry
let nearest = timed.value[0] let nearest = timed.value[0]
let nearestDist = Infinity let nearestDist = Infinity
for (const entry of timed.value) { for (const entry of timed.value) {
@ -264,13 +320,12 @@ function onMouseMove(e: MouseEvent) {
} }
if (!nearest) return if (!nearest) return
// Only show if within ~3% of span
if (nearestDist > span.value * 0.03 + 5000) { if (nearestDist > span.value * 0.03 + 5000) {
tooltip.value = null tooltip.value = null
return return
} }
const px = e.clientX - rect.left const px = _pxX(e)
tooltip.value = { tooltip.value = {
px, px,
flip: px > rect.width * 0.7, flip: px > rect.width * 0.7,
@ -280,6 +335,36 @@ function onMouseMove(e: MouseEvent) {
} }
} }
function onMouseUp(e: MouseEvent) {
if (!props.brushable || !isDragging.value) return
isDragging.value = false
const dragW = Math.abs(brushCursor.value - brushAnchor.value)
if (dragW < 8) {
// Click without meaningful drag clear selection
brushAnchor.value = 0
brushCursor.value = 0
emit('select-range', null)
return
}
const x0 = Math.min(brushAnchor.value, brushCursor.value)
const x1 = Math.max(brushAnchor.value, brushCursor.value)
const fromMs = tMin.value + (x0 / W) * span.value
const toMs = tMin.value + (x1 / W) * span.value
emit('select-range', {
from: new Date(fromMs).toISOString(),
to: new Date(toMs).toISOString(),
})
}
function onMouseLeave() {
tooltip.value = null
if (isDragging.value) {
isDragging.value = false
brushAnchor.value = 0
brushCursor.value = 0
}
}
function severityClass(sev: string | null): string { function severityClass(sev: string | null): string {
return { return {
ERROR: 'text-sev-error', CRITICAL: 'text-sev-critical', ERROR: 'text-sev-error', CRITICAL: 'text-sev-critical',

View file

@ -33,15 +33,29 @@
</div> </div>
<!-- Source scope badge --> <!-- Source scope badge -->
<div v-if="sourceScope" class="flex items-center gap-2 mb-4 text-xs"> <div v-if="sourceScope || timeFrom" class="flex flex-wrap items-center gap-2 mb-4 text-xs">
<span class="text-text-dim">Scoped to:</span> <template v-if="sourceScope">
<span class="font-mono text-surface bg-accent rounded px-2 py-0.5">{{ sourceScope }}</span> <span class="text-text-dim">Scoped to:</span>
<button <span class="font-mono text-surface bg-accent rounded px-2 py-0.5">{{ sourceScope }}</span>
@click="sourceScope = null" <button
class="text-text-dim hover:text-text-primary ml-1" @click="sourceScope = null"
title="Clear scope" class="text-text-dim hover:text-text-primary"
aria-label="Clear source scope filter" title="Clear scope"
></button> aria-label="Clear source scope filter"
></button>
</template>
<template v-if="timeFrom">
<span class="text-text-dim ml-1">Window:</span>
<span class="font-mono text-surface bg-accent/80 rounded px-2 py-0.5">
{{ _fmtTs(timeFrom) }} {{ timeTo ? _fmtTs(timeTo) : 'now' }}
</span>
<button
@click="timeFrom = null; timeTo = null"
class="text-text-dim hover:text-text-primary"
title="Clear time window"
aria-label="Clear time window filter"
></button>
</template>
</div> </div>
<!-- Error --> <!-- Error -->
@ -192,6 +206,8 @@ interface Summary {
const query = ref('') const query = ref('')
const sourceScope = ref<string | null>(null) const sourceScope = ref<string | null>(null)
const timeFrom = ref<string | null>(null)
const timeTo = ref<string | null>(null)
const entries = ref<LogEntry[]>([]) const entries = ref<LogEntry[]>([])
const summary = ref<Summary | null>(null) const summary = ref<Summary | null>(null)
const reasoning = ref<string | null>(null) const reasoning = ref<string | null>(null)
@ -210,9 +226,19 @@ const severityFilter = ref<string | null>(null)
let capturedSince: string | null = null let capturedSince: string | null = null
let capturedUntil: string | null = null let capturedUntil: string | null = null
function _fmtTs(iso: string): string {
try {
return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
} catch { return iso }
}
onMounted(async () => { onMounted(async () => {
const s = route.query.source const s = route.query.source
if (typeof s === 'string' && s.trim()) sourceScope.value = s if (typeof s === 'string' && s.trim()) sourceScope.value = s
const f = route.query.from
const t = route.query.to
if (typeof f === 'string' && f) timeFrom.value = f
if (typeof t === 'string' && t) timeTo.value = t
const q = route.query.q const q = route.query.q
if (typeof q === 'string' && q.trim()) { if (typeof q === 'string' && q.trim()) {
query.value = q query.value = q
@ -258,7 +284,12 @@ async function run() {
const res = await fetch(`${BASE}/api/diagnose/stream`, { const res = await fetch(`${BASE}/api/diagnose/stream`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: query.value, source: sourceScope.value }), body: JSON.stringify({
query: query.value,
source: sourceScope.value,
since: timeFrom.value || undefined,
until: timeTo.value || undefined,
}),
}) })
if (!res.ok) throw new Error(`API returned ${res.status}`) if (!res.ok) throw new Error(`API returned ${res.status}`)
if (!res.body) throw new Error('No response body') if (!res.body) throw new Error('No response body')

View file

@ -65,3 +65,27 @@ button {
outline: 2px solid var(--color-accent); outline: 2px solid var(--color-accent);
outline-offset: 2px; outline-offset: 2px;
} }
/* Loading skeleton shimmer */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.loading-shimmer {
background: linear-gradient(
90deg,
var(--color-surface-raised) 25%,
var(--color-surface-border) 50%,
var(--color-surface-raised) 75%
);
background-size: 200% 100%;
animation: shimmer 1.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.loading-shimmer {
animation: none;
background: var(--color-surface-raised);
}
}

View file

@ -1,6 +1,22 @@
<template> <template>
<div class="p-4 sm:p-6 max-w-5xl mx-auto space-y-8"> <div class="p-4 sm:p-6 max-w-5xl mx-auto space-y-8">
<!-- Timeline brush filter banner -->
<div
v-if="timelineRange"
class="flex items-center gap-3 rounded border border-accent/40 bg-surface-raised px-4 py-2.5 text-xs"
>
<span class="text-accent font-semibold">Filtered:</span>
<span class="text-text-primary font-mono">{{ shortTs(timelineRange.from) }}</span>
<span class="text-text-dim"></span>
<span class="text-text-primary font-mono">{{ shortTs(timelineRange.to) }}</span>
<button
@click="timelineRange = null"
class="ml-auto text-text-dim hover:text-sev-error transition-colors"
aria-label="Clear time filter"
> clear</button>
</div>
<!-- Watch status + freshness row --> <!-- Watch status + freshness row -->
<div v-if="!loading && stats" class="space-y-2"> <div v-if="!loading && stats" class="space-y-2">
<!-- Live watch indicator --> <!-- Live watch indicator -->
@ -29,8 +45,9 @@
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div class="rounded border border-surface-border bg-surface-raised p-5"> <div class="rounded border border-surface-border bg-surface-raised p-5">
<p class="text-text-dim text-xs uppercase tracking-widest mb-2">Criticals (24h)</p> <p class="text-text-dim text-xs uppercase tracking-widest mb-2">Criticals (24h)</p>
<p class="text-3xl font-semibold tabular-nums" :class="stats?.criticals_24h ? 'text-sev-critical' : 'text-text-muted'"> <div v-if="loading" class="loading-shimmer h-9 w-16 rounded mt-1" />
{{ loading ? '…' : (stats?.criticals_24h ?? 0) }} <p v-else class="text-3xl font-semibold tabular-nums" :class="stats?.criticals_24h ? 'text-sev-critical' : 'text-text-muted'">
{{ stats?.criticals_24h ?? 0 }}
</p> </p>
<p v-if="stats?.suppressed_criticals" class="text-xs text-text-dim mt-1"> <p v-if="stats?.suppressed_criticals" class="text-xs text-text-dim mt-1">
{{ stats.suppressed_criticals }} suppressed by overrides {{ stats.suppressed_criticals }} suppressed by overrides
@ -38,8 +55,9 @@
</div> </div>
<div class="rounded border border-surface-border bg-surface-raised p-5"> <div class="rounded border border-surface-border bg-surface-raised p-5">
<p class="text-text-dim text-xs uppercase tracking-widest mb-2">Errors (24h)</p> <p class="text-text-dim text-xs uppercase tracking-widest mb-2">Errors (24h)</p>
<p class="text-3xl font-semibold tabular-nums" :class="stats?.errors_24h ? 'text-sev-error' : 'text-text-muted'"> <div v-if="loading" class="loading-shimmer h-9 w-16 rounded mt-1" />
{{ loading ? '…' : (stats?.errors_24h ?? 0) }} <p v-else class="text-3xl font-semibold tabular-nums" :class="stats?.errors_24h ? 'text-sev-error' : 'text-text-muted'">
{{ stats?.errors_24h ?? 0 }}
</p> </p>
</div> </div>
<RouterLink <RouterLink
@ -48,8 +66,9 @@
:class="activeIncidents > 0 ? 'border-sev-warn' : 'border-surface-border'" :class="activeIncidents > 0 ? 'border-sev-warn' : 'border-surface-border'"
> >
<p class="text-text-dim text-xs uppercase tracking-widest mb-2">Active Incidents</p> <p class="text-text-dim text-xs uppercase tracking-widest mb-2">Active Incidents</p>
<p class="text-3xl font-semibold tabular-nums" :class="activeIncidents > 0 ? 'text-sev-warn' : 'text-text-muted'"> <div v-if="incidentsLoading" class="loading-shimmer h-9 w-12 rounded mt-1" />
{{ incidentsLoading ? '…' : activeIncidents }} <p v-else class="text-3xl font-semibold tabular-nums" :class="activeIncidents > 0 ? 'text-sev-warn' : 'text-text-muted'">
{{ activeIncidents }}
</p> </p>
</RouterLink> </RouterLink>
<RouterLink <RouterLink
@ -58,17 +77,37 @@
:class="unackedAlerts > 0 ? 'border-sev-error' : 'border-surface-border'" :class="unackedAlerts > 0 ? 'border-sev-error' : 'border-surface-border'"
> >
<p class="text-text-dim text-xs uppercase tracking-widest mb-2">Unreviewed Alerts</p> <p class="text-text-dim text-xs uppercase tracking-widest mb-2">Unreviewed Alerts</p>
<p class="text-3xl font-semibold tabular-nums" :class="unackedAlerts > 0 ? 'text-sev-error' : 'text-text-muted'"> <div v-if="alertsLoading" class="loading-shimmer h-9 w-12 rounded mt-1" />
{{ alertsLoading ? '…' : unackedAlerts }} <p v-else class="text-3xl font-semibold tabular-nums" :class="unackedAlerts > 0 ? 'text-sev-error' : 'text-text-muted'">
{{ unackedAlerts }}
</p> </p>
</RouterLink> </RouterLink>
</div> </div>
<!-- Activity timeline -->
<div v-if="stats?.timeline_events?.length">
<h2 class="text-text-primary text-sm font-semibold uppercase tracking-wider mb-3">Activity Timeline Last 24 Hours</h2>
<IncidentTimeline
:entries="stats.timeline_events"
:brushable="true"
@select-range="onTimelineRange"
/>
</div>
<!-- Source health (24h) --> <!-- Source health (24h) -->
<div> <div>
<h2 class="text-text-primary text-sm font-semibold uppercase tracking-wider mb-3">Source Health Last 24 Hours</h2> <h2 class="text-text-primary text-sm font-semibold uppercase tracking-wider mb-3">Source Health Last 24 Hours</h2>
<div v-if="loading" class="text-text-dim text-sm py-4">Loading</div> <div v-if="loading" class="rounded border border-surface-border overflow-hidden divide-y divide-surface-border">
<div v-for="i in 4" :key="i" class="px-4 py-3 flex items-center gap-4">
<div class="loading-shimmer w-2 h-2 rounded-full shrink-0" />
<div class="loading-shimmer h-3.5 rounded" :style="`width: ${50 + (i * 23) % 80}px`" />
<div class="loading-shimmer h-3.5 w-10 rounded ml-auto" />
<div class="loading-shimmer h-3.5 w-8 rounded" />
<div class="loading-shimmer h-3.5 w-20 rounded" />
<div class="loading-shimmer h-6 w-16 rounded" />
</div>
</div>
<div v-else-if="!stats?.source_health?.length" class="text-text-dim text-sm py-4"> <div v-else-if="!stats?.source_health?.length" class="text-text-dim text-sm py-4">
No log entries in the last 24 hours. No log entries in the last 24 hours.
@ -117,7 +156,7 @@
class="text-text-dim hover:text-accent text-xs px-2 py-1 rounded hover:bg-surface transition-colors" class="text-text-dim hover:text-accent text-xs px-2 py-1 rounded hover:bg-surface transition-colors"
@click="diagnoseSource(src.source_id)" @click="diagnoseSource(src.source_id)"
:aria-label="`Diagnose ${src.source_id}`" :aria-label="`Diagnose ${src.source_id}`"
>diagnose</button> >diagnose </button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -133,14 +172,52 @@
<div <div
v-for="entry in stats.recent_criticals" v-for="entry in stats.recent_criticals"
:key="entry.entry_id" :key="entry.entry_id"
class="border-b border-surface-border border-l-2 border-l-sev-critical px-4 py-3 hover:bg-surface-raised transition-colors" class="border-b border-surface-border last:border-b-0"
> >
<div class="flex items-center gap-2 mb-1 flex-wrap"> <!-- Entry header row (clickable to expand) -->
<span class="text-sev-critical text-xs font-semibold">CRITICAL</span> <div
<span class="text-accent text-xs">{{ entry.source_id }}</span> class="border-l-2 border-l-sev-critical px-4 py-3 hover:bg-surface-raised transition-colors cursor-pointer select-none flex items-start gap-2"
<span v-if="entry.timestamp_iso" class="text-text-dim text-xs">{{ shortTs(entry.timestamp_iso) }}</span> :class="expandedEntryId === entry.entry_id ? 'bg-surface-raised' : ''"
@click="explainCritical(entry)"
:aria-expanded="expandedEntryId === entry.entry_id"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1 flex-wrap">
<span class="text-sev-critical text-xs font-semibold">CRITICAL</span>
<span class="text-accent text-xs font-mono">{{ entry.source_id }}</span>
<span v-if="entry.timestamp_iso" class="text-text-dim text-xs">{{ shortTs(entry.timestamp_iso) }}</span>
</div>
<p
class="text-text-primary text-sm font-mono leading-relaxed"
:class="expandedEntryId !== entry.entry_id ? 'line-clamp-2' : ''"
>{{ entry.text }}</p>
</div>
<span class="text-text-dim text-[10px] shrink-0 mt-0.5 select-none opacity-60">
{{ expandedEntryId === entry.entry_id ? '▲' : '▼' }}
</span>
</div> </div>
<p class="text-text-primary text-sm font-mono leading-relaxed line-clamp-2">{{ entry.text }}</p>
<!-- Inline explain panel -->
<Transition name="expand">
<div
v-if="expandedEntryId === entry.entry_id"
class="border-l-2 border-l-accent/40 bg-surface px-4 py-3"
>
<div v-if="entryExplaining === entry.entry_id" class="flex items-center gap-2 text-xs text-text-dim py-1">
<span class="inline-block w-3 h-3 rounded-full border-2 border-accent border-t-transparent animate-spin motion-reduce:animate-none" aria-hidden="true" />
Analysing surrounding logs
</div>
<div v-else-if="entryExplanations[entry.entry_id]" class="text-sm text-text-primary leading-relaxed whitespace-pre-wrap mb-3">
{{ entryExplanations[entry.entry_id] }}
</div>
<div class="flex gap-2 mt-2">
<button
@click.stop="diagnoseSource(entry.source_id)"
class="text-xs px-2 py-1 rounded border border-surface-border text-text-dim hover:text-accent hover:border-accent transition-colors"
>Diagnose source </button>
</div>
</div>
</Transition>
</div> </div>
</div> </div>
<p v-if="stats.suppressed_criticals" class="text-xs text-text-dim mt-2"> <p v-if="stats.suppressed_criticals" class="text-xs text-text-dim mt-2">
@ -164,6 +241,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router' import { useRouter, RouterLink } from 'vue-router'
import IncidentTimeline from '@/components/IncidentTimeline.vue'
const router = useRouter() const router = useRouter()
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '') const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
@ -175,6 +253,14 @@ interface SourceHealth {
latest: string | null latest: string | null
} }
interface TimelineEvent {
entry_id: string
source_id: string
timestamp_iso: string | null
severity: string | null
text: string
}
interface StatsResponse { interface StatsResponse {
window_hours: number window_hours: number
total_24h: number total_24h: number
@ -183,6 +269,7 @@ interface StatsResponse {
suppressed_criticals: number suppressed_criticals: number
last_gleaned: string | null last_gleaned: string | null
source_health: SourceHealth[] source_health: SourceHealth[]
timeline_events: TimelineEvent[]
recent_criticals: Array<{ recent_criticals: Array<{
entry_id: string entry_id: string
source_id: string source_id: string
@ -203,6 +290,7 @@ interface WatchSourceStatus {
interface Incident { interface Incident {
id: string id: string
started_at: string | null
ended_at: string | null ended_at: string | null
} }
@ -213,10 +301,26 @@ const incidentsLoading = ref(true)
const watchSources = ref<WatchSourceStatus[]>([]) const watchSources = ref<WatchSourceStatus[]>([])
const unackedAlerts = ref(0) const unackedAlerts = ref(0)
const alertsLoading = ref(true) const alertsLoading = ref(true)
const timelineRange = ref<{ from: string; to: string } | null>(null)
const expandedEntryId = ref<string | null>(null)
const entryExplanations = ref<Record<string, string>>({})
const entryExplaining = ref<string | null>(null)
const activeIncidents = computed(() => const activeIncidents = computed(() => {
incidents.value.filter(i => !i.ended_at).length const open = incidents.value.filter(i => !i.ended_at)
) if (!timelineRange.value) return open.length
const from = new Date(timelineRange.value.from).getTime()
const to = new Date(timelineRange.value.to).getTime()
return open.filter(i => {
if (!i.started_at) return true
const start = new Date(i.started_at).getTime()
return start <= to
}).length
})
function onTimelineRange(range: { from: string; to: string } | null) {
timelineRange.value = range
}
const watchActive = computed(() => const watchActive = computed(() =>
watchSources.value.some(s => s.running) watchSources.value.some(s => s.running)
@ -273,7 +377,74 @@ function healthDot(errors: number, total: number): string {
} }
function diagnoseSource(sourceId: string) { function diagnoseSource(sourceId: string) {
router.push({ path: '/diagnose', query: { source: sourceId } }) const query: Record<string, string> = {
tab: 'quick',
source: sourceId,
q: 'Summarize what errors or issues occurred — what went wrong and what is the likely cause?',
}
if (timelineRange.value) {
query.from = timelineRange.value.from
query.to = timelineRange.value.to
}
router.push({ path: '/diagnose', query })
}
type CriticalEntry = { entry_id: string; source_id: string; timestamp_iso: string | null; text: string }
async function explainCritical(entry: CriticalEntry) {
if (expandedEntryId.value === entry.entry_id) {
expandedEntryId.value = null
return
}
expandedEntryId.value = entry.entry_id
if (entryExplanations.value[entry.entry_id]) return
entryExplaining.value = entry.entry_id
let explanation = ''
try {
const sinceMs = entry.timestamp_iso ? new Date(entry.timestamp_iso).getTime() - 5 * 60_000 : null
const untilMs = entry.timestamp_iso ? new Date(entry.timestamp_iso).getTime() + 5 * 60_000 : null
const res = await fetch(`${BASE}/api/diagnose/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `Explain this critical log error and its likely cause: ${entry.text.slice(0, 300)}`,
source: entry.source_id,
since: sinceMs ? new Date(sinceMs).toISOString() : undefined,
until: untilMs ? new Date(untilMs).toISOString() : undefined,
}),
})
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const parts = buf.split('\n\n')
buf = parts.pop() ?? ''
for (const part of parts) {
const line = part.trim()
if (!line.startsWith('data: ')) continue
try {
const evt = JSON.parse(line.slice(6))
if (evt.type === 'reasoning') explanation = evt.text
} catch { /* malformed SSE chunk — skip */ }
}
}
entryExplanations.value = {
...entryExplanations.value,
[entry.entry_id]: explanation || 'No explanation returned — try the full diagnose view for more context.',
}
} catch {
entryExplanations.value = {
...entryExplanations.value,
[entry.entry_id]: 'Failed to load explanation.',
}
} finally {
entryExplaining.value = null
}
} }
function shortTs(iso: string | null): string { function shortTs(iso: string | null): string {
@ -286,3 +457,17 @@ function shortTs(iso: string | null): string {
} catch { return iso } } catch { return iso }
} }
</script> </script>
<style scoped>
.expand-enter-active,
.expand-leave-active {
transition: opacity 0.15s ease, max-height 0.2s ease;
overflow: hidden;
max-height: 400px;
}
.expand-enter-from,
.expand-leave-to {
opacity: 0;
max-height: 0;
}
</style>

View file

@ -154,10 +154,34 @@
</optgroup> </optgroup>
</select> </select>
</div> </div>
<!-- Collapse dupes toggle -->
<button
@click="collapseDupes = !collapseDupes"
:class="[
'text-xs px-2 py-1 rounded border transition-colors shrink-0',
collapseDupes
? 'border-accent text-accent bg-accent/10'
: 'border-surface-border text-text-dim hover:text-text-primary'
]"
:title="collapseDupes ? 'Showing one per message — click to expand' : 'Click to collapse duplicate messages'"
>
{{ collapseDupes ? 'collapsed' : 'collapse similar' }}
</button>
</div> </div>
<!-- Loading state --> <!-- Loading state -->
<div v-if="loading" class="text-text-dim py-12 text-center text-sm">Loading</div> <div v-if="loading" class="rounded border border-surface-border overflow-hidden divide-y divide-surface-border">
<div v-for="i in 6" :key="i" class="px-4 py-3 flex items-center gap-4">
<div class="loading-shimmer h-4 w-14 rounded" />
<div class="loading-shimmer h-4 rounded" :style="`width: ${80 + (i * 37) % 100}px`" />
<div class="loading-shimmer h-3 w-10 rounded" />
<div class="loading-shimmer h-3 w-20 rounded" />
<div class="loading-shimmer h-3 flex-1 rounded" />
<div class="loading-shimmer h-3 w-24 rounded" />
<div class="loading-shimmer h-7 w-20 rounded" />
</div>
</div>
<!-- Empty state --> <!-- Empty state -->
<div v-else-if="detections.length === 0" class="text-text-dim py-12 text-center text-sm"> <div v-else-if="detections.length === 0" class="text-text-dim py-12 text-center text-sm">
@ -205,6 +229,11 @@
v-if="det.scorer === 'cybersec'" 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" class="text-xs px-1.5 py-0.5 rounded bg-surface-raised border border-surface-border text-text-dim font-mono"
>cybersec</span> >cybersec</span>
<span
v-if="collapseDupes && det.count && det.count > 1"
class="text-xs px-1.5 py-0.5 rounded bg-accent/10 border border-accent/40 text-accent font-mono"
:title="`${det.count} similar events collapsed`"
>×{{ det.count }}</span>
</div> </div>
</td> </td>
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
@ -330,6 +359,7 @@ interface Detection {
acknowledged_at: string | null acknowledged_at: string | null
notes: string notes: string
scorer: string scorer: string
count?: number
} }
interface ScorerStatus { interface ScorerStatus {
@ -376,6 +406,7 @@ const drawer = ref<Detection | null>(null)
const activeTab = ref<'all' | 'unacked'>('all') const activeTab = ref<'all' | 'unacked'>('all')
const labelFilter = ref('') const labelFilter = ref('')
const scorerFilter = ref('') const scorerFilter = ref('')
const collapseDupes = ref(true)
const tabRefs = ref<(HTMLElement | null)[]>([]) const tabRefs = ref<(HTMLElement | null)[]>([])
const anomalyLabels = [ const anomalyLabels = [
@ -401,11 +432,26 @@ const tabs = computed(() => [
{ value: 'unacked', label: 'Unacknowledged', count: unackedCount.value }, { value: 'unacked', label: 'Unacknowledged', count: unackedCount.value },
]) ])
const filteredDetections = computed(() => const filteredDetections = computed(() => {
activeTab.value === 'unacked' const base = activeTab.value === 'unacked'
? detections.value.filter(d => !d.acknowledged) ? detections.value.filter(d => !d.acknowledged)
: detections.value : detections.value
) if (!collapseDupes.value) return base
const groups = new Map<string, Detection>()
const counts = new Map<string, number>()
for (const d of base) {
const key = d.anomaly_label + '|' + d.text.slice(0, 100)
const existing = groups.get(key)
if (!existing || d.anomaly_score > existing.anomaly_score) {
groups.set(key, d)
}
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return Array.from(groups.values()).map(d => ({
...d,
count: counts.get(d.anomaly_label + '|' + d.text.slice(0, 100)) ?? 1,
}))
})
// Data loading // Data loading