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:
parent
5f7296ad6d
commit
4dcc1a441a
3 changed files with 321 additions and 7 deletions
|
|
@ -1011,7 +1011,7 @@ def get_incident_endpoint(incident_id: str) -> dict:
|
|||
incident = get_incident(INCIDENTS_DB_PATH, incident_id)
|
||||
if not incident:
|
||||
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 {
|
||||
**dataclasses.asdict(incident),
|
||||
"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)
|
||||
if not incident:
|
||||
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)
|
||||
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)
|
||||
if not incident:
|
||||
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)
|
||||
payload = json.dumps(bundle).encode()
|
||||
req = urllib.request.Request(
|
||||
|
|
|
|||
290
web/src/components/IncidentTimeline.vue
Normal file
290
web/src/components/IncidentTimeline.vue
Normal 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>
|
||||
|
|
@ -115,12 +115,25 @@
|
|||
</div>
|
||||
|
||||
<div v-else>
|
||||
<p class="text-text-dim text-xs mb-3">{{ selectedEntries.length }} entries in window</p>
|
||||
<div class="space-y-1 max-h-96 overflow-y-auto">
|
||||
<!-- Timeline visualizer -->
|
||||
<IncidentTimeline
|
||||
class="mb-4"
|
||||
:entries="selectedEntries"
|
||||
:started-at="selected.started_at"
|
||||
:ended-at="selected.ended_at"
|
||||
@select-entry="scrollToEntry"
|
||||
/>
|
||||
|
||||
<div
|
||||
id="incident-entries"
|
||||
class="space-y-1 max-h-96 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
v-for="entry in selectedEntries"
|
||||
v-for="(entry, idx) in selectedEntries"
|
||||
: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="['mr-2', severityTextClass(entry.severity)]">{{ entry.severity || '?' }}</span>
|
||||
|
|
@ -138,6 +151,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import IncidentTimeline from '@/components/IncidentTimeline.vue'
|
||||
|
||||
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 ───────────────────────────────────────────────────
|
||||
function severityStyle(sev: string): Record<string, string> {
|
||||
const k = sev?.toLowerCase() ?? 'low'
|
||||
|
|
|
|||
Loading…
Reference in a new issue