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 os
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
@ -230,10 +231,19 @@ def score_unscored(
if label in _ANOMALOUS_LABELS and pred["score"] >= threshold:
detection_rows.append(enriched)
for _attempt in range(4):
try:
with get_conn(db_path) as conn:
_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_detections += det_count

View file

@ -40,19 +40,18 @@ def build_fts_index(db_path: Path) -> None:
if BACKEND == Backend.POSTGRES:
return
raw = sqlite3.connect(str(db_path), timeout=30.0)
raw.execute("PRAGMA journal_mode=WAL")
with get_conn(db_path) as conn:
needs_rebuild = False
try:
raw.execute("SELECT sequence FROM log_fts LIMIT 0")
except sqlite3.OperationalError:
conn.execute("SELECT sequence FROM log_fts LIMIT 0")
except Exception:
needs_rebuild = True
if needs_rebuild:
raw.execute("DROP TABLE IF EXISTS log_fts")
conn.execute("DROP TABLE IF EXISTS log_fts")
conn.commit()
raw.executescript("""
conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS log_fts USING fts5(
text,
entry_id UNINDEXED,
@ -64,9 +63,9 @@ def build_fts_index(db_path: Path) -> None:
repeat_count UNINDEXED,
out_of_order UNINDEXED,
tokenize = 'porter ascii'
);
)
""")
raw.execute("""
conn.execute("""
INSERT INTO log_fts(text, entry_id, source_id, sequence, severity,
timestamp_iso, matched_patterns,
repeat_count, out_of_order)
@ -76,8 +75,7 @@ def build_fts_index(db_path: Path) -> None:
FROM log_entries e
WHERE e.id NOT IN (SELECT entry_id FROM log_fts WHERE entry_id IS NOT NULL)
""")
raw.commit()
raw.close()
conn.commit()
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,),
).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(
"SELECT MAX(ingest_time) AS t FROM log_entries WHERE (tenant_id = ? OR tenant_id = '')",
(tid,),
@ -691,6 +704,17 @@ def stats_summary(db_path: Path, window_hours: int = 24, severity_overrides: lis
else:
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
return {
@ -702,6 +726,7 @@ def stats_summary(db_path: Path, window_hours: int = 24, severity_overrides: lis
"recent_criticals": recent_criticals,
"suppressed_criticals": suppressed,
"last_gleaned": last_gleaned,
"timeline_events": timeline_events,
}

View file

@ -8,13 +8,19 @@
</div>
<!-- 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
:viewBox="`0 0 ${W} ${H}`"
preserveAspectRatio="none"
class="w-full h-full"
class="w-full h-full select-none"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseleave="tooltip = null"
@mouseup="onMouseUp"
@mouseleave="onMouseLeave"
>
<!-- Burst density bands (bin shading) -->
<rect
@ -52,8 +58,23 @@
:height="ev.h"
:fill="ev.color"
:fill-opacity="ev.alpha"
class="cursor-pointer"
@click="$emit('select-entry', ev.index)"
:class="brushable ? '' : 'cursor-pointer'"
@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 -->
@ -64,9 +85,9 @@
/>
</svg>
<!-- Hover tooltip -->
<!-- Hover tooltip (hidden while brushing) -->
<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"
: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>
{{ sev.label }}
</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>
</template>
@ -106,12 +128,22 @@ const props = defineProps<{
entries: Entry[]
startedAt?: string | null
endedAt?: string | null
brushable?: boolean
}>()
defineEmits<{
const emit = defineEmits<{
'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
const W = 1000
const H = 64
@ -249,13 +281,37 @@ interface Tooltip {
const tooltip = ref<Tooltip | null>(null)
function onMouseMove(e: MouseEvent) {
function _svgX(e: MouseEvent): number {
const svg = e.currentTarget as SVGElement
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
// Find nearest entry
let nearest = timed.value[0]
let nearestDist = Infinity
for (const entry of timed.value) {
@ -264,13 +320,12 @@ function onMouseMove(e: MouseEvent) {
}
if (!nearest) return
// Only show if within ~3% of span
if (nearestDist > span.value * 0.03 + 5000) {
tooltip.value = null
return
}
const px = e.clientX - rect.left
const px = _pxX(e)
tooltip.value = {
px,
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 {
return {
ERROR: 'text-sev-error', CRITICAL: 'text-sev-critical',

View file

@ -33,15 +33,29 @@
</div>
<!-- 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">
<template v-if="sourceScope">
<span class="text-text-dim">Scoped to:</span>
<span class="font-mono text-surface bg-accent rounded px-2 py-0.5">{{ sourceScope }}</span>
<button
@click="sourceScope = null"
class="text-text-dim hover:text-text-primary ml-1"
class="text-text-dim hover:text-text-primary"
title="Clear scope"
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>
<!-- Error -->
@ -192,6 +206,8 @@ interface Summary {
const query = ref('')
const sourceScope = ref<string | null>(null)
const timeFrom = ref<string | null>(null)
const timeTo = ref<string | null>(null)
const entries = ref<LogEntry[]>([])
const summary = ref<Summary | null>(null)
const reasoning = ref<string | null>(null)
@ -210,9 +226,19 @@ const severityFilter = ref<string | null>(null)
let capturedSince: 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 () => {
const s = route.query.source
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
if (typeof q === 'string' && q.trim()) {
query.value = q
@ -258,7 +284,12 @@ async function run() {
const res = await fetch(`${BASE}/api/diagnose/stream`, {
method: 'POST',
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.body) throw new Error('No response body')

View file

@ -65,3 +65,27 @@ button {
outline: 2px solid var(--color-accent);
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>
<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 -->
<div v-if="!loading && stats" class="space-y-2">
<!-- Live watch indicator -->
@ -29,8 +45,9 @@
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<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-3xl font-semibold tabular-nums" :class="stats?.criticals_24h ? 'text-sev-critical' : 'text-text-muted'">
{{ loading ? '…' : (stats?.criticals_24h ?? 0) }}
<div v-if="loading" class="loading-shimmer h-9 w-16 rounded mt-1" />
<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 v-if="stats?.suppressed_criticals" class="text-xs text-text-dim mt-1">
{{ stats.suppressed_criticals }} suppressed by overrides
@ -38,8 +55,9 @@
</div>
<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-3xl font-semibold tabular-nums" :class="stats?.errors_24h ? 'text-sev-error' : 'text-text-muted'">
{{ loading ? '…' : (stats?.errors_24h ?? 0) }}
<div v-if="loading" class="loading-shimmer h-9 w-16 rounded mt-1" />
<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>
</div>
<RouterLink
@ -48,8 +66,9 @@
: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-3xl font-semibold tabular-nums" :class="activeIncidents > 0 ? 'text-sev-warn' : 'text-text-muted'">
{{ incidentsLoading ? '…' : activeIncidents }}
<div v-if="incidentsLoading" class="loading-shimmer h-9 w-12 rounded mt-1" />
<p v-else class="text-3xl font-semibold tabular-nums" :class="activeIncidents > 0 ? 'text-sev-warn' : 'text-text-muted'">
{{ activeIncidents }}
</p>
</RouterLink>
<RouterLink
@ -58,17 +77,37 @@
: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-3xl font-semibold tabular-nums" :class="unackedAlerts > 0 ? 'text-sev-error' : 'text-text-muted'">
{{ alertsLoading ? '…' : unackedAlerts }}
<div v-if="alertsLoading" class="loading-shimmer h-9 w-12 rounded mt-1" />
<p v-else class="text-3xl font-semibold tabular-nums" :class="unackedAlerts > 0 ? 'text-sev-error' : 'text-text-muted'">
{{ unackedAlerts }}
</p>
</RouterLink>
</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) -->
<div>
<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">
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"
@click="diagnoseSource(src.source_id)"
:aria-label="`Diagnose ${src.source_id}`"
>diagnose</button>
>diagnose </button>
</td>
</tr>
</tbody>
@ -133,14 +172,52 @@
<div
v-for="entry in stats.recent_criticals"
: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"
>
<!-- Entry header row (clickable to expand) -->
<div
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"
: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">{{ entry.source_id }}</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 line-clamp-2">{{ entry.text }}</p>
<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>
<!-- 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>
<p v-if="stats.suppressed_criticals" class="text-xs text-text-dim mt-2">
@ -164,6 +241,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import IncidentTimeline from '@/components/IncidentTimeline.vue'
const router = useRouter()
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
@ -175,6 +253,14 @@ interface SourceHealth {
latest: string | null
}
interface TimelineEvent {
entry_id: string
source_id: string
timestamp_iso: string | null
severity: string | null
text: string
}
interface StatsResponse {
window_hours: number
total_24h: number
@ -183,6 +269,7 @@ interface StatsResponse {
suppressed_criticals: number
last_gleaned: string | null
source_health: SourceHealth[]
timeline_events: TimelineEvent[]
recent_criticals: Array<{
entry_id: string
source_id: string
@ -203,6 +290,7 @@ interface WatchSourceStatus {
interface Incident {
id: string
started_at: string | null
ended_at: string | null
}
@ -213,10 +301,26 @@ const incidentsLoading = ref(true)
const watchSources = ref<WatchSourceStatus[]>([])
const unackedAlerts = ref(0)
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(() =>
incidents.value.filter(i => !i.ended_at).length
)
const activeIncidents = computed(() => {
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(() =>
watchSources.value.some(s => s.running)
@ -273,7 +377,74 @@ function healthDot(errors: number, total: number): 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 {
@ -286,3 +457,17 @@ function shortTs(iso: string | null): string {
} catch { return iso }
}
</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>
</select>
</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>
<!-- 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 -->
<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'"
class="text-xs px-1.5 py-0.5 rounded bg-surface-raised border border-surface-border text-text-dim font-mono"
>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>
</td>
<td class="px-4 py-2.5">
@ -330,6 +359,7 @@ interface Detection {
acknowledged_at: string | null
notes: string
scorer: string
count?: number
}
interface ScorerStatus {
@ -376,6 +406,7 @@ const drawer = ref<Detection | null>(null)
const activeTab = ref<'all' | 'unacked'>('all')
const labelFilter = ref('')
const scorerFilter = ref('')
const collapseDupes = ref(true)
const tabRefs = ref<(HTMLElement | null)[]>([])
const anomalyLabels = [
@ -401,11 +432,26 @@ const tabs = computed(() => [
{ value: 'unacked', label: 'Unacknowledged', count: unackedCount.value },
])
const filteredDetections = computed(() =>
activeTab.value === 'unacked'
const filteredDetections = computed(() => {
const base = activeTab.value === 'unacked'
? detections.value.filter(d => !d.acknowledged)
: 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