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:
parent
d05430ef85
commit
fa4d23dd20
8 changed files with 335 additions and 14 deletions
|
|
@ -31,6 +31,7 @@ CREATE TABLE IF NOT EXISTS log_entries (
|
|||
);
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
201
web/src/views/DashboardView.vue
Normal file
201
web/src/views/DashboardView.vue
Normal 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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue