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
a3c0962277
commit
f5893c6003
8 changed files with 335 additions and 14 deletions
|
|
@ -29,10 +29,11 @@ CREATE TABLE IF NOT EXISTS log_entries (
|
||||||
matched_patterns TEXT DEFAULT '[]',
|
matched_patterns TEXT DEFAULT '[]',
|
||||||
text TEXT NOT NULL
|
text TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_source ON log_entries(source_id);
|
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_timestamp ON log_entries(timestamp_iso);
|
||||||
CREATE INDEX IF NOT EXISTS idx_severity ON log_entries(severity);
|
CREATE INDEX IF NOT EXISTS idx_ts_repeat ON log_entries(timestamp_iso, repeat_count);
|
||||||
CREATE INDEX IF NOT EXISTS idx_patterns ON log_entries(matched_patterns);
|
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 (
|
CREATE TABLE IF NOT EXISTS incidents (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ from app.services.search import (
|
||||||
search as _search,
|
search as _search,
|
||||||
list_sources as _list_sources,
|
list_sources as _list_sources,
|
||||||
recent_source_errors as _source_errors,
|
recent_source_errors as _source_errors,
|
||||||
|
stats_summary as _stats,
|
||||||
format_results,
|
format_results,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -161,6 +162,13 @@ def list_sources() -> dict:
|
||||||
return {"sources": _list_sources(DB_PATH)}
|
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")
|
@router.post("/api/incidents")
|
||||||
def create_incident_endpoint(body: IncidentCreate) -> dict:
|
def create_incident_endpoint(body: IncidentCreate) -> dict:
|
||||||
incident = create_incident(
|
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:
|
def format_results(results: list[SearchResult], max_text: int = 300) -> str:
|
||||||
"""Format search results as readable text for LLM context."""
|
"""Format search results as readable text for LLM context."""
|
||||||
if not results:
|
if not results:
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import { RouterLink, RouterView } from 'vue-router'
|
||||||
import StatusDot from '@/components/StatusDot.vue'
|
import StatusDot from '@/components/StatusDot.vue'
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
|
{ to: '/dashboard', label: 'Dashboard' },
|
||||||
{ to: '/search', label: 'Search' },
|
{ to: '/search', label: 'Search' },
|
||||||
{ to: '/diagnose', label: 'Diagnose' },
|
{ to: '/diagnose', label: 'Diagnose' },
|
||||||
{ to: '/incidents', label: 'Incidents' },
|
{ to: '/incidents', label: 'Incidents' },
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="border-b border-surface-border px-4 py-3 hover:bg-surface-raised transition-colors cursor-default"
|
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 }"
|
:class="{ 'border-l-2 border-l-sev-error': isHighSeverity, 'cursor-pointer': isLong }"
|
||||||
|
@click="isLong && (expanded = !expanded)"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<SeverityBadge :severity="entry.severity" class="mt-0.5 shrink-0 w-16 text-center" />
|
<SeverityBadge :severity="entry.severity" class="mt-0.5 shrink-0 w-16 text-center" />
|
||||||
|
|
@ -14,28 +15,36 @@
|
||||||
:key="p"
|
:key="p"
|
||||||
class="px-1.5 py-0.5 rounded bg-accent-muted text-accent text-xs"
|
class="px-1.5 py-0.5 rounded bg-accent-muted text-accent text-xs"
|
||||||
>{{ p }}</span>
|
>{{ p }}</span>
|
||||||
<span
|
<span v-if="entry.repeat_count > 1" class="text-text-dim text-xs">×{{ entry.repeat_count }}</span>
|
||||||
v-if="entry.repeat_count > 1"
|
|
||||||
class="text-text-dim text-xs"
|
|
||||||
>×{{ entry.repeat_count }}</span>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { LogEntry } from '@/stores/search'
|
import type { LogEntry } from '@/stores/search'
|
||||||
import SeverityBadge from './SeverityBadge.vue'
|
import SeverityBadge from './SeverityBadge.vue'
|
||||||
|
|
||||||
const props = defineProps<{ entry: LogEntry }>()
|
const props = defineProps<{ entry: LogEntry }>()
|
||||||
|
const expanded = ref(false)
|
||||||
|
|
||||||
const isHighSeverity = computed(() =>
|
const isHighSeverity = computed(() =>
|
||||||
['ERROR', 'CRITICAL'].includes((props.entry.severity ?? '').toUpperCase())
|
['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 {
|
function formatTs(iso: string): string {
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleString(undefined, {
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import DashboardView from '@/views/DashboardView.vue'
|
||||||
import LogSearchView from '@/views/LogSearchView.vue'
|
import LogSearchView from '@/views/LogSearchView.vue'
|
||||||
import DiagnoseView from '@/views/DiagnoseView.vue'
|
import DiagnoseView from '@/views/DiagnoseView.vue'
|
||||||
import SourcesView from '@/views/SourcesView.vue'
|
import SourcesView from '@/views/SourcesView.vue'
|
||||||
|
|
@ -7,7 +8,8 @@ import IncidentsView from '@/views/IncidentsView.vue'
|
||||||
export default createRouter({
|
export default createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', redirect: '/search' },
|
{ path: '/', redirect: '/dashboard' },
|
||||||
|
{ path: '/dashboard', component: DashboardView },
|
||||||
{ path: '/search', component: LogSearchView },
|
{ path: '/search', component: LogSearchView },
|
||||||
{ path: '/diagnose', component: DiagnoseView },
|
{ path: '/diagnose', component: DiagnoseView },
|
||||||
{ path: '/sources', component: SourcesView },
|
{ 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">
|
<div class="mb-3 text-text-dim text-xs">
|
||||||
{{ entries.length }} relevant log{{ entries.length !== 1 ? 's' : '' }} — sorted chronologically
|
{{ entries.length }} relevant log{{ entries.length !== 1 ? 's' : '' }} — sorted chronologically
|
||||||
</div>
|
</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">
|
<div class="rounded border border-surface-border overflow-hidden">
|
||||||
<LogEntryRow
|
<LogEntryRow
|
||||||
v-for="entry in entries"
|
v-for="entry in entries"
|
||||||
|
|
@ -54,12 +61,15 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 type { LogEntry } from '@/stores/search'
|
||||||
import LogEntryRow from '@/components/LogEntryRow.vue'
|
import LogEntryRow from '@/components/LogEntryRow.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const symptom = ref('')
|
const symptom = ref('')
|
||||||
const entries = ref<LogEntry[]>([])
|
const entries = ref<LogEntry[]>([])
|
||||||
|
const formatted = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const ranOnce = ref(false)
|
const ranOnce = ref(false)
|
||||||
|
|
@ -67,6 +77,14 @@ const lastQuery = ref('')
|
||||||
|
|
||||||
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
|
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() {
|
async function run() {
|
||||||
if (!symptom.value.trim()) return
|
if (!symptom.value.trim()) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
@ -79,6 +97,7 @@ async function run() {
|
||||||
if (!res.ok) throw new Error(`API returned ${res.status}`)
|
if (!res.ok) throw new Error(`API returned ${res.status}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
entries.value = data.results
|
entries.value = data.results
|
||||||
|
formatted.value = data.formatted ?? ''
|
||||||
ranOnce.value = true
|
ranOnce.value = true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : String(e)
|
error.value = e instanceof Error ? e.message : String(e)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue