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 @@
{{ entry.text }} {{ entry.text }}
Formatted summary
+{{ formatted }}
+