feat: dashboard view, stats API, and composite index for query perf

- 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)
This commit is contained in:
pyr0ball 2026-05-11 03:41:55 -07:00
parent d05430ef85
commit fa4d23dd20
8 changed files with 335 additions and 14 deletions

View file

@ -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,

View file

@ -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(

View file

@ -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:

View file

@ -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' },

View file

@ -1,7 +1,8 @@
<template>
<div
class="border-b border-surface-border px-4 py-3 hover:bg-surface-raised transition-colors cursor-default"
:class="{ 'border-l-2 border-l-sev-error': isHighSeverity }"
class="border-b border-surface-border px-4 py-3 hover:bg-surface-raised transition-colors"
:class="{ 'border-l-2 border-l-sev-error': isHighSeverity, 'cursor-pointer': isLong }"
@click="isLong && (expanded = !expanded)"
>
<div class="flex items-start gap-3">
<SeverityBadge :severity="entry.severity" class="mt-0.5 shrink-0 w-16 text-center" />
@ -14,28 +15,36 @@
:key="p"
class="px-1.5 py-0.5 rounded bg-accent-muted text-accent text-xs"
>{{ p }}</span>
<span
v-if="entry.repeat_count > 1"
class="text-text-dim text-xs"
>×{{ entry.repeat_count }}</span>
<span v-if="entry.repeat_count > 1" class="text-text-dim text-xs">×{{ entry.repeat_count }}</span>
</div>
<p class="text-text-primary text-sm whitespace-pre-wrap break-all leading-relaxed">{{ entry.text }}</p>
<p
class="text-text-primary text-sm whitespace-pre-wrap break-words leading-relaxed"
:class="{ 'line-clamp-3': isLong && !expanded }"
>{{ entry.text }}</p>
<button
v-if="isLong"
class="mt-1 text-text-dim hover:text-accent text-xs transition-colors"
@click.stop="expanded = !expanded"
>{{ expanded ? '▲ collapse' : '▼ expand' }}</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ref, computed } from 'vue'
import type { LogEntry } from '@/stores/search'
import SeverityBadge from './SeverityBadge.vue'
const props = defineProps<{ entry: LogEntry }>()
const expanded = ref(false)
const isHighSeverity = computed(() =>
['ERROR', 'CRITICAL'].includes((props.entry.severity ?? '').toUpperCase())
)
const isLong = computed(() => props.entry.text.length > 200 || props.entry.text.includes('\n'))
function formatTs(iso: string): string {
try {
return new Date(iso).toLocaleString(undefined, {

View file

@ -1,4 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router'
import DashboardView from '@/views/DashboardView.vue'
import LogSearchView from '@/views/LogSearchView.vue'
import DiagnoseView from '@/views/DiagnoseView.vue'
import SourcesView from '@/views/SourcesView.vue'
@ -7,7 +8,8 @@ import IncidentsView from '@/views/IncidentsView.vue'
export default createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', redirect: '/search' },
{ path: '/', redirect: '/dashboard' },
{ path: '/dashboard', component: DashboardView },
{ path: '/search', component: LogSearchView },
{ path: '/diagnose', component: DiagnoseView },
{ path: '/sources', component: SourcesView },

View file

@ -0,0 +1,201 @@
<template>
<div class="p-6 max-w-5xl mx-auto space-y-8">
<!-- Stat cards -->
<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) }}
</p>
</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) }}
</p>
</div>
<div
class="rounded border bg-surface-raised p-5 cursor-pointer hover:bg-surface transition-colors"
:class="activeIncidents > 0 ? 'border-sev-warn' : 'border-surface-border'"
@click="$router.push('/incidents')"
>
<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 }}
</p>
</div>
</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-else-if="!stats?.source_health?.length" class="text-text-dim text-sm py-4">
No log entries in the last 24 hours.
<RouterLink to="/sources" class="text-accent hover:underline ml-1">View all sources </RouterLink>
</div>
<div v-else class="rounded border border-surface-border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-surface-raised border-b border-surface-border">
<tr>
<th class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider w-4"></th>
<th class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Source</th>
<th class="text-right px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Events</th>
<th class="text-right px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Errors</th>
<th class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Latest</th>
<th class="px-4 py-2.5"></th>
</tr>
</thead>
<tbody>
<tr
v-for="src in stats.source_health"
:key="src.source_id"
class="border-b border-surface-border hover:bg-surface-raised transition-colors"
>
<td class="pl-4 py-2.5">
<span
class="inline-block w-2 h-2 rounded-full"
:class="healthDot(src.error_count, src.entry_count)"
></span>
</td>
<td class="px-4 py-2.5 text-accent font-mono text-xs">{{ src.source_id }}</td>
<td class="px-4 py-2.5 text-text-muted text-right tabular-nums">{{ src.entry_count.toLocaleString() }}</td>
<td class="px-4 py-2.5 text-right tabular-nums">
<span :class="src.error_count > 0 ? 'text-sev-error' : 'text-text-dim'">
{{ src.error_count.toLocaleString() }}
</span>
</td>
<td class="px-4 py-2.5 text-text-dim text-xs">{{ shortTs(src.latest) }}</td>
<td class="px-4 py-2.5 text-right">
<button
class="text-text-dim hover:text-accent text-xs px-2 py-1 rounded hover:bg-surface transition-colors"
@click="diagnoseSource(src.source_id)"
>diagnose</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Recent criticals -->
<div v-if="stats?.recent_criticals?.length">
<h2 class="text-text-primary text-sm font-semibold uppercase tracking-wider mb-3">Recent Criticals</h2>
<div class="rounded border border-surface-border overflow-hidden">
<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"
>
<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 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>
</div>
</div>
</div>
<!-- Zero state: everything clean -->
<div
v-if="!loading && stats && stats.criticals_24h === 0 && stats.errors_24h === 0 && activeIncidents === 0"
class="rounded border border-surface-border bg-surface-raised p-8 text-center"
>
<p class="text-text-muted text-base mb-1">No errors or criticals in the last 24 hours.</p>
<p class="text-text-dim text-sm">Use <RouterLink to="/search" class="text-accent hover:underline">Search</RouterLink> or <RouterLink to="/diagnose" class="text-accent hover:underline">Diagnose</RouterLink> to investigate further.</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
interface SourceHealth {
source_id: string
entry_count: number
error_count: number
latest: string | null
}
interface StatsResponse {
window_hours: number
total_24h: number
criticals_24h: number
errors_24h: number
source_health: SourceHealth[]
recent_criticals: Array<{
entry_id: string
source_id: string
timestamp_iso: string | null
severity: string
text: string
}>
}
interface Incident {
id: string
ended_at: string | null
}
const stats = ref<StatsResponse | null>(null)
const loading = ref(true)
const incidents = ref<Incident[]>([])
const incidentsLoading = ref(true)
const activeIncidents = computed(() =>
incidents.value.filter(i => !i.ended_at).length
)
onMounted(async () => {
await Promise.all([loadStats(), loadIncidents()])
})
async function loadStats() {
try {
const res = await fetch(`${BASE}/api/stats`)
if (res.ok) stats.value = await res.json()
} finally {
loading.value = false
}
}
async function loadIncidents() {
try {
const res = await fetch(`${BASE}/api/incidents`)
if (res.ok) incidents.value = (await res.json()).incidents
} finally {
incidentsLoading.value = false
}
}
function healthDot(errors: number, total: number): string {
if (errors === 0) return 'bg-green-500'
const ratio = errors / Math.max(total, 1)
if (ratio > 0.1) return 'bg-sev-error'
return 'bg-sev-warn'
}
function diagnoseSource(sourceId: string) {
router.push({ path: '/diagnose', query: { q: sourceId } })
}
function shortTs(iso: string | null): string {
if (!iso) return '—'
try {
return new Date(iso).toLocaleString(undefined, {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})
} catch { return iso }
}
</script>

View file

@ -36,6 +36,13 @@
<div class="mb-3 text-text-dim text-xs">
{{ entries.length }} relevant log{{ entries.length !== 1 ? 's' : '' }} sorted chronologically
</div>
<!-- Plain-text summary (pre-formatted for LLM context) -->
<div v-if="formatted" class="mb-4 rounded border border-surface-border bg-surface-raised p-4 overflow-x-auto">
<p class="text-text-dim text-xs uppercase tracking-widest mb-2">Formatted summary</p>
<pre class="text-text-muted text-xs whitespace-pre-wrap leading-relaxed font-mono">{{ formatted }}</pre>
</div>
<div class="rounded border border-surface-border overflow-hidden">
<LogEntryRow
v-for="entry in entries"
@ -54,12 +61,15 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import type { LogEntry } from '@/stores/search'
import LogEntryRow from '@/components/LogEntryRow.vue'
const route = useRoute()
const symptom = ref('')
const entries = ref<LogEntry[]>([])
const formatted = ref('')
const loading = ref(false)
const error = ref<string | null>(null)
const ranOnce = ref(false)
@ -67,6 +77,14 @@ const lastQuery = ref('')
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
onMounted(() => {
const q = route.query.q
if (typeof q === 'string' && q.trim()) {
symptom.value = q
run()
}
})
async function run() {
if (!symptom.value.trim()) return
loading.value = true
@ -79,6 +97,7 @@ async function run() {
if (!res.ok) throw new Error(`API returned ${res.status}`)
const data = await res.json()
entries.value = data.results
formatted.value = data.formatted ?? ''
ranOnce.value = true
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)