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