turnstone/web/src/views/DashboardView.vue
pyr0ball 560eaf706d fix(ui): nested overflow wrapper to prevent overflow-hidden clipping table columns
overflow-hidden and overflow-x-auto on the same element conflict in Tailwind's
CSS generation order. The shorthand overflow:hidden can override overflow-x:auto,
clipping the rightmost column (diagnose buttons). Fix: outer div keeps
overflow-hidden for rounded corners, inner div handles overflow-x-auto scrolling.
2026-05-16 09:11:42 -07:00

268 lines
10 KiB
Vue

<template>
<div class="p-4 sm:p-6 max-w-5xl mx-auto space-y-8">
<!-- Watch status + freshness row -->
<div v-if="!loading && stats" class="space-y-2">
<!-- Live watch indicator -->
<div class="flex items-center justify-end gap-2">
<span
:class="watchActive ? 'bg-green-500' : 'bg-surface-border'"
class="w-2 h-2 rounded-full flex-shrink-0"
></span>
<span :class="watchActive ? 'text-green-400' : 'text-text-dim'" class="text-xs">
{{ watchActive ? `Live — ${watchSources.length} source${watchSources.length !== 1 ? 's' : ''} watched` : 'Manual ingest mode' }}
</span>
</div>
<!-- Stale data banner -->
<div
v-if="isStale"
class="flex items-center gap-2 rounded border border-surface-border bg-surface-raised px-4 py-2.5 text-xs text-text-dim"
>
<span class="text-sev-warn"></span>
<span v-if="watchActive">Live watch active — last event: <span class="text-text-muted">{{ shortTs(stats.last_ingested) }}</span>. Waiting for new entries to arrive.</span>
<span v-else>Last ingested: <span class="text-text-muted">{{ shortTs(stats.last_ingested) }}</span> 24h counts reflect this window, not today.</span>
</div>
</div>
<!-- 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>
<p v-if="stats?.suppressed_criticals" class="text-xs text-text-dim mt-1">
{{ stats.suppressed_criticals }} suppressed by overrides
</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>
<RouterLink
to="/incidents"
class="rounded border bg-surface-raised p-5 block hover:bg-surface transition-colors"
:class="activeIncidents > 0 ? 'border-sev-warn' : 'border-surface-border'"
>
<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>
</RouterLink>
</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">
<div class="overflow-x-auto">
<table class="w-full text-sm min-w-[560px]">
<caption class="sr-only">Source health last 24 hours</caption>
<thead class="bg-surface-raised border-b border-surface-border">
<tr>
<th scope="col" class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider w-4">
<span class="sr-only">Status</span>
</th>
<th scope="col" class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Source</th>
<th scope="col" class="text-right px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Events</th>
<th scope="col" class="text-right px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Errors</th>
<th scope="col" class="text-left px-4 py-2.5 text-text-dim font-medium text-xs uppercase tracking-wider">Latest</th>
<th scope="col" 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)"
aria-hidden="true"
></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)"
:aria-label="`Diagnose ${src.source_id}`"
>diagnose</button>
</td>
</tr>
</tbody>
</table>
</div>
</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>
<p v-if="stats.suppressed_criticals" class="text-xs text-text-dim mt-2">
{{ stats.suppressed_criticals }} additional critical{{ stats.suppressed_criticals !== 1 ? 's' : '' }} hidden by
<RouterLink to="/settings" class="text-accent hover:underline">severity overrides</RouterLink>.
</p>
</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, RouterLink } 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
suppressed_criticals: number
last_ingested: string | null
source_health: SourceHealth[]
recent_criticals: Array<{
entry_id: string
source_id: string
timestamp_iso: string | null
severity: string
text: string
}>
}
interface WatchSourceStatus {
source_id: string
type: string
running: boolean
entries_ingested: number
last_event: string | null
error: string | null
}
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 watchSources = ref<WatchSourceStatus[]>([])
const activeIncidents = computed(() =>
incidents.value.filter(i => !i.ended_at).length
)
const watchActive = computed(() =>
watchSources.value.some(s => s.running)
)
const isStale = computed(() => {
if (!stats.value?.last_ingested) return false
const age = Date.now() - new Date(stats.value.last_ingested).getTime()
return age > 25 * 60 * 60 * 1000 // older than 25h
})
onMounted(async () => {
await Promise.all([loadStats(), loadIncidents(), loadWatchStatus()])
})
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
}
}
async function loadWatchStatus() {
try {
const res = await fetch(`${BASE}/api/watch/status`)
if (res.ok) watchSources.value = (await res.json()).sources ?? []
} catch { /* non-critical */ }
}
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: { source: 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>