From f5893c600389873882d549b028d92ea392cd2eb2 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 11 May 2026 03:41:55 -0700 Subject: [PATCH] feat: dashboard view, stats API, and composite index for query perf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GET /api/stats endpoint with 24h windowed aggregation (criticals, errors, per-source health, recent criticals list) - Fix timestamp format bug: strftime('%Y-%m-%dT%H:%M:%S', ...) to match stored ISO-8601 T-separated timestamps (datetime('now') uses space) - Add composite index idx_ts_repeat(timestamp_iso, repeat_count) — drops stats query from 3.5 s to <1 ms by resolving both WHERE conditions from the index without table row fetches - New DashboardView: 3 stat cards, source health table with health dots, diagnose-per-source button, recent criticals panel, zero-state card - Router default / → /dashboard; Dashboard first in nav - DiagnoseView: reads ?q= query param on mount and auto-runs; shows formatted LLM summary block - LogEntryRow: expand/collapse for long entries (>200 chars or multiline) --- app/ingest/pipeline.py | 9 +- app/rest.py | 8 ++ app/services/search.py | 80 ++++++++++++ web/src/App.vue | 1 + web/src/components/LogEntryRow.vue | 25 ++-- web/src/router/index.ts | 4 +- web/src/views/DashboardView.vue | 201 +++++++++++++++++++++++++++++ web/src/views/DiagnoseView.vue | 21 ++- 8 files changed, 335 insertions(+), 14 deletions(-) create mode 100644 web/src/views/DashboardView.vue diff --git a/app/ingest/pipeline.py b/app/ingest/pipeline.py index 1b8f440..ddc3c13 100644 --- a/app/ingest/pipeline.py +++ b/app/ingest/pipeline.py @@ -29,10 +29,11 @@ CREATE TABLE IF NOT EXISTS log_entries ( matched_patterns TEXT DEFAULT '[]', text TEXT NOT NULL ); -CREATE INDEX IF NOT EXISTS idx_source ON log_entries(source_id); -CREATE INDEX IF NOT EXISTS idx_timestamp ON log_entries(timestamp_iso); -CREATE INDEX IF NOT EXISTS idx_severity ON log_entries(severity); -CREATE INDEX IF NOT EXISTS idx_patterns ON log_entries(matched_patterns); +CREATE INDEX IF NOT EXISTS idx_source ON log_entries(source_id); +CREATE INDEX IF NOT EXISTS idx_timestamp ON log_entries(timestamp_iso); +CREATE INDEX IF NOT EXISTS idx_ts_repeat ON log_entries(timestamp_iso, repeat_count); +CREATE INDEX IF NOT EXISTS idx_severity ON log_entries(severity); +CREATE INDEX IF NOT EXISTS idx_patterns ON log_entries(matched_patterns); CREATE TABLE IF NOT EXISTS incidents ( id TEXT PRIMARY KEY, diff --git a/app/rest.py b/app/rest.py index 991e937..aa57470 100644 --- a/app/rest.py +++ b/app/rest.py @@ -29,6 +29,7 @@ from app.services.search import ( search as _search, list_sources as _list_sources, recent_source_errors as _source_errors, + stats_summary as _stats, format_results, ) @@ -161,6 +162,13 @@ def list_sources() -> dict: return {"sources": _list_sources(DB_PATH)} +@router.get("/api/stats") +def get_stats( + window: Annotated[int, Query(ge=1, le=168, description="Hours to look back")] = 24, +) -> dict: + return _stats(DB_PATH, window_hours=window) + + @router.post("/api/incidents") def create_incident_endpoint(body: IncidentCreate) -> dict: incident = create_incident( diff --git a/app/services/search.py b/app/services/search.py index 983ee04..9334605 100644 --- a/app/services/search.py +++ b/app/services/search.py @@ -317,6 +317,86 @@ def list_sources(db_path: Path) -> list[dict]: ] +def stats_summary(db_path: Path, window_hours: int = 24) -> dict: + """Return aggregate health stats for the dashboard. + + Queries plain log_entries (not FTS) so it works even before the index is built. + """ + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA journal_mode=WAL") + conn.row_factory = sqlite3.Row + + since_expr = f"strftime('%Y-%m-%dT%H:%M:%S', 'now', '-{window_hours} hours')" + + # Overall counts in window + row = conn.execute(f""" + SELECT + COUNT(*) AS total, + SUM(CASE WHEN severity = 'CRITICAL' THEN 1 ELSE 0 END) AS criticals, + SUM(CASE WHEN severity IN ('ERROR','CRITICAL','EMERGENCY','ALERT') THEN 1 ELSE 0 END) AS errors + FROM log_entries + WHERE timestamp_iso >= {since_expr} + AND repeat_count = 1 + """).fetchone() + total_24h = int(row["total"] or 0) + criticals_24h = int(row["criticals"] or 0) + errors_24h = int(row["errors"] or 0) + + # Per-source breakdown + source_rows = conn.execute(f""" + SELECT + source_id, + COUNT(*) AS entry_count, + SUM(CASE WHEN severity IN ('ERROR','CRITICAL','EMERGENCY','ALERT') THEN 1 ELSE 0 END) AS error_count, + MAX(timestamp_iso) AS latest + FROM log_entries + WHERE timestamp_iso >= {since_expr} + AND repeat_count = 1 + GROUP BY source_id + ORDER BY error_count DESC, entry_count DESC + """).fetchall() + source_health = [ + { + "source_id": r["source_id"], + "entry_count": int(r["entry_count"]), + "error_count": int(r["error_count"]), + "latest": r["latest"], + } + for r in source_rows + ] + + # 5 most recent critical entries + crit_rows = conn.execute(""" + SELECT id as entry_id, source_id, sequence, timestamp_iso, severity, + repeat_count, out_of_order, matched_patterns, text, 0.0 as rank + FROM log_entries + WHERE severity = 'CRITICAL' AND repeat_count = 1 + ORDER BY timestamp_iso DESC + LIMIT 5 + """).fetchall() + recent_criticals = [ + { + "entry_id": r["entry_id"], + "source_id": r["source_id"], + "timestamp_iso": r["timestamp_iso"], + "severity": r["severity"], + "text": r["text"], + } + for r in crit_rows + ] + + conn.close() + + return { + "window_hours": window_hours, + "total_24h": total_24h, + "criticals_24h": criticals_24h, + "errors_24h": errors_24h, + "source_health": source_health, + "recent_criticals": recent_criticals, + } + + def format_results(results: list[SearchResult], max_text: int = 300) -> str: """Format search results as readable text for LLM context.""" if not results: diff --git a/web/src/App.vue b/web/src/App.vue index dbb1ae6..ebe22fd 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -36,6 +36,7 @@ import { RouterLink, RouterView } from 'vue-router' import StatusDot from '@/components/StatusDot.vue' const navLinks = [ + { to: '/dashboard', label: 'Dashboard' }, { to: '/search', label: 'Search' }, { to: '/diagnose', label: 'Diagnose' }, { to: '/incidents', label: 'Incidents' }, diff --git a/web/src/components/LogEntryRow.vue b/web/src/components/LogEntryRow.vue index 46d965f..e6420c2 100644 --- a/web/src/components/LogEntryRow.vue +++ b/web/src/components/LogEntryRow.vue @@ -1,7 +1,8 @@ diff --git a/web/src/views/DiagnoseView.vue b/web/src/views/DiagnoseView.vue index 2a342a6..a56dcfa 100644 --- a/web/src/views/DiagnoseView.vue +++ b/web/src/views/DiagnoseView.vue @@ -36,6 +36,13 @@
{{ entries.length }} relevant log{{ entries.length !== 1 ? 's' : '' }} — sorted chronologically
+ + +
+

Formatted summary

+
{{ formatted }}
+
+