diff --git a/app/services/anomaly.py b/app/services/anomaly.py index 4e525fe..4dbc21b 100644 --- a/app/services/anomaly.py +++ b/app/services/anomaly.py @@ -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) - 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() + 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 diff --git a/app/services/search.py b/app/services/search.py index 47a74e9..90ad4d7 100644 --- a/app/services/search.py +++ b/app/services/search.py @@ -40,44 +40,42 @@ 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: + conn.execute("SELECT sequence FROM log_fts LIMIT 0") + except Exception: + needs_rebuild = True - needs_rebuild = False - try: - raw.execute("SELECT sequence FROM log_fts LIMIT 0") - except sqlite3.OperationalError: - needs_rebuild = True + if needs_rebuild: + conn.execute("DROP TABLE IF EXISTS log_fts") + conn.commit() - if needs_rebuild: - raw.execute("DROP TABLE IF EXISTS log_fts") - - raw.executescript(""" - CREATE VIRTUAL TABLE IF NOT EXISTS log_fts USING fts5( - text, - entry_id UNINDEXED, - source_id UNINDEXED, - sequence UNINDEXED, - severity UNINDEXED, - timestamp_iso UNINDEXED, - matched_patterns UNINDEXED, - repeat_count UNINDEXED, - out_of_order UNINDEXED, - tokenize = 'porter ascii' - ); - """) - raw.execute(""" - INSERT INTO log_fts(text, entry_id, source_id, sequence, severity, - timestamp_iso, matched_patterns, - repeat_count, out_of_order) - SELECT e.text, e.id, e.source_id, e.sequence, e.severity, - e.timestamp_iso, e.matched_patterns, - e.repeat_count, e.out_of_order - 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.execute(""" + CREATE VIRTUAL TABLE IF NOT EXISTS log_fts USING fts5( + text, + entry_id UNINDEXED, + source_id UNINDEXED, + sequence UNINDEXED, + severity UNINDEXED, + timestamp_iso UNINDEXED, + matched_patterns UNINDEXED, + 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, + repeat_count, out_of_order) + SELECT e.text, e.id, e.source_id, e.sequence, e.severity, + e.timestamp_iso, e.matched_patterns, + e.repeat_count, e.out_of_order + FROM log_entries e + WHERE e.id NOT IN (SELECT entry_id FROM log_fts WHERE entry_id IS NOT NULL) + """) + 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, } diff --git a/web/src/components/IncidentTimeline.vue b/web/src/components/IncidentTimeline.vue index 43ab564..f6ed925 100644 --- a/web/src/components/IncidentTimeline.vue +++ b/web/src/components/IncidentTimeline.vue @@ -8,13 +8,19 @@ -
+
+ + + @@ -64,9 +85,9 @@ /> - +
@@ -86,7 +107,8 @@ {{ sev.label }} - {{ entries.length }} events + drag to filter + {{ entries.length }} events
@@ -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(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', diff --git a/web/src/components/QuickCapture.vue b/web/src/components/QuickCapture.vue index fc96e49..a71bd42 100644 --- a/web/src/components/QuickCapture.vue +++ b/web/src/components/QuickCapture.vue @@ -33,15 +33,29 @@
-
- Scoped to: - {{ sourceScope }} - +
+ +
@@ -192,6 +206,8 @@ interface Summary { const query = ref('') const sourceScope = ref(null) +const timeFrom = ref(null) +const timeTo = ref(null) const entries = ref([]) const summary = ref(null) const reasoning = ref(null) @@ -210,9 +226,19 @@ const severityFilter = ref(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') diff --git a/web/src/style/theme.css b/web/src/style/theme.css index c29e315..04e84c0 100644 --- a/web/src/style/theme.css +++ b/web/src/style/theme.css @@ -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); + } +} diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue index 3f3175f..a18da5a 100644 --- a/web/src/views/DashboardView.vue +++ b/web/src/views/DashboardView.vue @@ -1,6 +1,22 @@