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
674e945004
commit
bf3f90fd56
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)
|
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(
|
||||||
|
|
|
||||||
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>
|
||||||
|
|
||||||
<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
|
||||||
|
id="incident-entries"
|
||||||
|
class="space-y-1 max-h-96 overflow-y-auto"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
v-for="entry in selectedEntries"
|
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'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue