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:
parent
f3d807d991
commit
502ff54fd0
7 changed files with 494 additions and 88 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue