feat(incidents): incident timeline visualizer + fix entry lookup using wrong DB path

Adds IncidentTimeline.vue — a pure SVG time-axis component rendered inside the
incident detail drawer when entries are present:
- Horizontal strip scaled to incident window (preserveAspectRatio=none)
- Event ticks colored by severity, height proportional to severity level
- 50-bin density shading shows burst periods as blue bands
- Gap markers (dashed lines) for silence > 10% of window or > 60s
- Hover tooltip showing nearest entry's severity, time, and truncated text
- Click-to-scroll: clicking a tick highlights and scrolls to its entry in the list below
- Legend showing only severity levels present in the incident

Also fixes a pre-existing bug: get_incident_endpoint and both build_bundle callers
were passing INCIDENTS_DB_PATH to get_incident_entries/build_bundle, causing all
incident entry lookups to silently search the empty incidents DB instead of the
main log DB. This made all incident detail views show "No log entries found".

Closes: #57
This commit is contained in:
pyr0ball 2026-06-10 16:02:24 -07:00
parent 674e945004
commit bf3f90fd56
3 changed files with 321 additions and 7 deletions

View file

@ -1011,7 +1011,7 @@ def get_incident_endpoint(incident_id: str) -> dict:
incident = get_incident(INCIDENTS_DB_PATH, incident_id) incident = get_incident(INCIDENTS_DB_PATH, incident_id)
if not incident: if not incident:
raise HTTPException(status_code=404, detail="Incident not found") raise HTTPException(status_code=404, detail="Incident not found")
entries = get_incident_entries(INCIDENTS_DB_PATH, incident) entries = get_incident_entries(DB_PATH, incident)
return { return {
**dataclasses.asdict(incident), **dataclasses.asdict(incident),
"entries": [dataclasses.asdict(e) for e in entries], "entries": [dataclasses.asdict(e) for e in entries],
@ -1030,7 +1030,7 @@ def get_incident_bundle(incident_id: str, sanitize: bool = False) -> dict:
incident = get_incident(INCIDENTS_DB_PATH, incident_id) incident = get_incident(INCIDENTS_DB_PATH, incident_id)
if not incident: if not incident:
raise HTTPException(status_code=404, detail="Incident not found") raise HTTPException(status_code=404, detail="Incident not found")
bundle = build_bundle(INCIDENTS_DB_PATH, incident, source_host=SOURCE_HOST, sanitize=sanitize) bundle = build_bundle(DB_PATH, incident, source_host=SOURCE_HOST, sanitize=sanitize)
record_sent_bundle(INCIDENTS_DB_PATH, incident_id, bundle, sanitized=sanitize) record_sent_bundle(INCIDENTS_DB_PATH, incident_id, bundle, sanitized=sanitize)
return bundle return bundle
@ -1048,7 +1048,7 @@ def send_incident_bundle(incident_id: str, sanitize: bool = False) -> dict:
incident = get_incident(INCIDENTS_DB_PATH, incident_id) incident = get_incident(INCIDENTS_DB_PATH, incident_id)
if not incident: if not incident:
raise HTTPException(status_code=404, detail="Incident not found") raise HTTPException(status_code=404, detail="Incident not found")
bundle = build_bundle(INCIDENTS_DB_PATH, incident, source_host=SOURCE_HOST, sanitize=sanitize) bundle = build_bundle(DB_PATH, incident, source_host=SOURCE_HOST, sanitize=sanitize)
record_sent_bundle(INCIDENTS_DB_PATH, incident_id, bundle, sanitized=sanitize) record_sent_bundle(INCIDENTS_DB_PATH, incident_id, bundle, sanitized=sanitize)
payload = json.dumps(bundle).encode() payload = json.dumps(bundle).encode()
req = urllib.request.Request( req = urllib.request.Request(

View file

@ -0,0 +1,290 @@
<template>
<div class="incident-timeline" v-if="hasData">
<!-- Axis labels -->
<div class="flex justify-between text-xs text-text-dim mb-1 px-1 font-mono">
<span>{{ startLabel }}</span>
<span class="text-center text-text-dim opacity-60 text-[10px]">{{ totalLabel }}</span>
<span>{{ endLabel }}</span>
</div>
<!-- SVG strip -->
<div class="relative rounded border border-surface-border bg-surface overflow-hidden" style="height:64px">
<svg
:viewBox="`0 0 ${W} ${H}`"
preserveAspectRatio="none"
class="w-full h-full"
@mousemove="onMouseMove"
@mouseleave="tooltip = null"
>
<!-- Burst density bands (bin shading) -->
<rect
v-for="(bin, i) in densityBins"
:key="`bin-${i}`"
:x="bin.x"
:width="bin.w"
y="0"
:height="H"
:fill="bin.fill"
:fill-opacity="bin.opacity"
/>
<!-- Gap markers -->
<line
v-for="(gap, i) in gapMarkers"
:key="`gap-${i}`"
:x1="gap.x"
:x2="gap.x"
y1="4"
:y2="H - 4"
stroke="var(--color-text-dim)"
stroke-width="1"
stroke-dasharray="3,3"
opacity="0.5"
/>
<!-- Event ticks -->
<rect
v-for="(ev, i) in eventTicks"
:key="`ev-${i}`"
:x="ev.x - 1"
width="2"
:y="ev.y"
:height="ev.h"
:fill="ev.color"
:fill-opacity="ev.alpha"
class="cursor-pointer"
@click="$emit('select-entry', ev.index)"
/>
<!-- Axis baseline -->
<line
x1="0" :x2="W" :y1="H - 6" :y2="H - 6"
stroke="var(--color-surface-border)"
stroke-width="1"
/>
</svg>
<!-- Hover tooltip -->
<div
v-if="tooltip"
class="absolute pointer-events-none z-10 bg-surface-raised border border-surface-border rounded px-2 py-1 text-xs text-text-primary shadow-md max-w-xs truncate"
:style="{ left: `${tooltip.px}px`, top: '4px', transform: tooltip.flip ? 'translateX(-100%)' : '' }"
>
<span :class="severityClass(tooltip.severity)" class="mr-1 font-bold">{{ tooltip.severity }}</span>
<span class="text-text-dim mr-1">{{ tooltip.time }}</span>
<span class="text-text-muted">{{ tooltip.text }}</span>
</div>
</div>
<!-- Legend -->
<div class="flex gap-3 mt-1.5 text-[10px] text-text-dim px-1">
<span
v-for="sev in legendItems"
:key="sev.label"
class="flex items-center gap-1"
>
<span class="inline-block w-2 h-2 rounded-sm" :style="{ background: sev.color }"></span>
{{ sev.label }}
</span>
<span class="ml-auto">{{ entries.length }} events</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
interface Entry {
entry_id: string
source_id: string
timestamp_iso: string | null
severity: string | null
text: string
}
const props = defineProps<{
entries: Entry[]
startedAt?: string | null
endedAt?: string | null
}>()
defineEmits<{
'select-entry': [index: number]
}>()
// SVG logical dimensions
const W = 1000
const H = 64
// colour map
const SEV_COLORS: Record<string, string> = {
DEBUG: 'var(--color-sev-debug)',
INFO: 'var(--color-sev-info)',
WARN: 'var(--color-sev-warn)',
WARNING: 'var(--color-sev-warn)',
ERROR: 'var(--color-sev-error)',
CRITICAL: 'var(--color-sev-critical)',
}
function sevColor(sev: string | null): string {
return SEV_COLORS[(sev ?? '').toUpperCase()] ?? 'var(--color-text-dim)'
}
// time range
const timed = computed(() =>
props.entries
.filter(e => e.timestamp_iso)
.map(e => ({ ...e, ms: new Date(e.timestamp_iso!).getTime() }))
.sort((a, b) => a.ms - b.ms)
)
const tMin = computed(() => {
if (props.startedAt) return new Date(props.startedAt).getTime()
return timed.value[0]?.ms ?? Date.now()
})
const tMax = computed(() => {
if (props.endedAt) return new Date(props.endedAt).getTime()
const last = timed.value[timed.value.length - 1]?.ms ?? Date.now()
return Math.max(last, tMin.value + 1000) // at least 1s span
})
const span = computed(() => Math.max(tMax.value - tMin.value, 1))
function xOf(ms: number): number {
return ((ms - tMin.value) / span.value) * W
}
const hasData = computed(() => timed.value.length > 0)
// axis labels
function fmtTs(ms: number): string {
return new Date(ms).toLocaleTimeString(undefined, {
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
}
function fmtDuration(ms: number): string {
const s = Math.round(ms / 1000)
if (s < 60) return `${s}s`
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
}
const startLabel = computed(() => fmtTs(tMin.value))
const endLabel = computed(() => fmtTs(tMax.value))
const totalLabel = computed(() => fmtDuration(span.value))
// density bins (burst shading)
const NUM_BINS = 50
const densityBins = computed(() => {
const binW = span.value / NUM_BINS
const counts = new Array<number>(NUM_BINS).fill(0)
for (const e of timed.value) {
const idx = Math.min(Math.floor((e.ms - tMin.value) / binW), NUM_BINS - 1)
counts[idx] = (counts[idx] ?? 0) + 1
}
const maxCount = Math.max(...counts, 1)
return counts.map((count, i) => ({
x: (i / NUM_BINS) * W,
w: W / NUM_BINS + 0.5,
fill: count > 0 ? 'var(--color-accent)' : 'transparent',
opacity: count > 0 ? Math.min(0.08 + (count / maxCount) * 0.25, 0.33) : 0,
}))
})
// gap markers (silence periods >10% of span or >60s)
const gapMarkers = computed(() => {
if (timed.value.length < 2) return []
const minGapMs = Math.max(span.value * 0.1, 60_000)
const markers: { x: number }[] = []
for (let i = 1; i < timed.value.length; i++) {
const prev = timed.value[i - 1]!
const curr = timed.value[i]!
const gap = curr.ms - prev.ms
if (gap >= minGapMs) {
markers.push({ x: xOf(prev.ms + gap / 2) })
}
}
return markers
})
// event ticks
const SEV_HEIGHT: Record<string, number> = {
DEBUG: 16, INFO: 24, WARN: 32, WARNING: 32, ERROR: 44, CRITICAL: 52,
}
const eventTicks = computed(() =>
timed.value.map((e, i) => {
const sevKey = (e.severity ?? '').toUpperCase()
const h = SEV_HEIGHT[sevKey] ?? 24
return {
index: i,
x: xOf(e.ms),
y: H - 6 - h,
h,
color: sevColor(e.severity),
alpha: 0.85,
}
})
)
// legend items (only severities present in this incident)
const legendItems = computed(() => {
const seen = new Set(timed.value.map(e => (e.severity ?? 'UNKNOWN').toUpperCase()))
return (['CRITICAL', 'ERROR', 'WARN', 'INFO', 'DEBUG'] as const)
.filter(s => seen.has(s))
.map(s => ({ label: s, color: sevColor(s) }))
})
// hover tooltip
interface Tooltip {
px: number
flip: boolean
severity: string | null
time: string
text: string
}
const tooltip = ref<Tooltip | null>(null)
function onMouseMove(e: MouseEvent) {
const svg = e.currentTarget as SVGElement
const rect = svg.getBoundingClientRect()
const relX = (e.clientX - rect.left) / rect.width // 0..1
const ms = tMin.value + relX * span.value
// Find nearest entry
let nearest = timed.value[0]
let nearestDist = Infinity
for (const entry of timed.value) {
const d = Math.abs(entry.ms - ms)
if (d < nearestDist) { nearestDist = d; nearest = entry }
}
if (!nearest) return
// Only show if within ~3% of span
if (nearestDist > span.value * 0.03 + 5000) {
tooltip.value = null
return
}
const px = e.clientX - rect.left
tooltip.value = {
px,
flip: px > rect.width * 0.7,
severity: nearest.severity,
time: fmtTs(nearest.ms),
text: nearest.text.slice(0, 120),
}
}
function severityClass(sev: string | null): string {
return {
ERROR: 'text-sev-error', CRITICAL: 'text-sev-critical',
WARN: 'text-sev-warn', WARNING: 'text-sev-warn',
INFO: 'text-sev-info', DEBUG: 'text-text-dim',
}[(sev ?? '').toUpperCase()] ?? 'text-text-dim'
}
</script>

View file

@ -115,12 +115,25 @@
</div> </div>
<div v-else> <div v-else>
<p class="text-text-dim text-xs mb-3">{{ selectedEntries.length }} entries in window</p> <!-- Timeline visualizer -->
<div class="space-y-1 max-h-96 overflow-y-auto"> <IncidentTimeline
class="mb-4"
:entries="selectedEntries"
:started-at="selected.started_at"
:ended-at="selected.ended_at"
@select-entry="scrollToEntry"
/>
<div <div
v-for="entry in selectedEntries" id="incident-entries"
class="space-y-1 max-h-96 overflow-y-auto"
>
<div
v-for="(entry, idx) in selectedEntries"
:key="entry.entry_id" :key="entry.entry_id"
class="font-mono text-xs py-1 px-2 rounded bg-surface-raised border border-surface-border" :id="`incident-entry-${idx}`"
class="font-mono text-xs py-1 px-2 rounded bg-surface-raised border border-surface-border transition-colors"
:class="{ 'ring-1 ring-accent': highlightIdx === idx }"
> >
<span class="text-text-dim mr-2">{{ shortTs(entry.timestamp_iso) }}</span> <span class="text-text-dim mr-2">{{ shortTs(entry.timestamp_iso) }}</span>
<span :class="['mr-2', severityTextClass(entry.severity)]">{{ entry.severity || '?' }}</span> <span :class="['mr-2', severityTextClass(entry.severity)]">{{ entry.severity || '?' }}</span>
@ -138,6 +151,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import IncidentTimeline from '@/components/IncidentTimeline.vue'
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '') const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
@ -224,6 +238,16 @@ async function sendBundle(id: string) {
} }
} }
// timeline interaction
const highlightIdx = ref<number | null>(null)
function scrollToEntry(idx: number) {
highlightIdx.value = idx
const el = document.getElementById(`incident-entry-${idx}`)
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
setTimeout(() => { highlightIdx.value = null }, 1500)
}
// helpers // helpers
function severityStyle(sev: string): Record<string, string> { function severityStyle(sev: string): Record<string, string> {
const k = sev?.toLowerCase() ?? 'low' const k = sev?.toLowerCase() ?? 'low'