diff --git a/docs/metrics.md b/docs/metrics.md index 81fbccb..8d02c95 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -51,6 +51,10 @@ data" for the current value means the condition no longer holds**, not that Prom A rule expression matches every series, not just the one that fired; only series that agree with the alert's labels are kept, at most five per alert. +When every series only ever reads `0` or `1` (a boolean such as `up == 0`), a line is not +informative, so the UI draws an on/off timeline instead: one row per series, filled where the value +was `1`, a thin mark where it was `0`, and the bare track where there were no samples. + ## Endpoint `GET /api/incidents/{id}/metrics` — same team scoping as the other incident routes. diff --git a/internal/web/incident_test.go b/internal/web/incident_test.go index e0ea6c3..c925536 100644 --- a/internal/web/incident_test.go +++ b/internal/web/incident_test.go @@ -12,8 +12,9 @@ func TestCopyIncidentIsEmbedded(t *testing.T) { t.Fatal(err) } for file, want := range map[string]string{ - "js/incident.js": "copyIncident", - "js/ui.js": "copy:", + "js/incident.js": "copyIncident", + "js/ui.js": "copy:", + "js/metricchart.js": "isBool", } { b, err := fs.ReadFile(sub, file) if err != nil { diff --git a/internal/web/static/app.css b/internal/web/static/app.css index b295b83..d43da0f 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -622,6 +622,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh .mc-line.s4, .mc-swatch.s4 { stroke: var(--muted); background: var(--muted); } .mc-dot.s0 { fill: var(--accent); } .mc-dot.s1 { fill: var(--warn); } .mc-dot.s2 { fill: var(--ok); } .mc-dot.s3 { fill: var(--crit); } .mc-dot.s4 { fill: var(--muted); } +.mc-track { fill: var(--border); opacity: .45; } +.mc-off { fill: var(--faint); opacity: .6; } +.mc-on.s0 { fill: var(--accent); } .mc-on.s1 { fill: var(--warn); } .mc-on.s2 { fill: var(--ok); } +.mc-on.s3 { fill: var(--crit); } .mc-on.s4 { fill: var(--muted); } .mc-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; flex: none; } .metrics-legend { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 2px 14px; font-size: 12px; color: var(--muted); } .metrics-legend li { display: flex; align-items: center; gap: 6px; overflow-wrap: anywhere; } diff --git a/internal/web/static/js/metricchart.js b/internal/web/static/js/metricchart.js index e0f3ac1..0c6d64e 100644 --- a/internal/web/static/js/metricchart.js +++ b/internal/web/static/js/metricchart.js @@ -74,13 +74,14 @@ function pick(entry) { from: Math.min(live.window_start, snaps.opened ? snaps.opened.window_start : Infinity), to: live.window_end, current: live.current || [], + step: live.step_seconds, isLive: true, }; } const snap = snaps.closed || snaps.opened; if (snap && !snap.error) { return { - series: merge(parseSeries(snap.series)), from: snap.window_start, to: snap.window_end, current: [], isLive: false, + series: merge(parseSeries(snap.series)), from: snap.window_start, to: snap.window_end, current: [], step: snap.step_seconds, isLive: false, }; } return null; @@ -105,7 +106,7 @@ export function alertMetrics(entry, startsAtISO, resolvedISO) { const endSec = resolvedISO ? Date.parse(resolvedISO) / 1000 : null; return h('div', { class: 'metrics' }, currentLine(entry, data), - data.series.length ? chart(data, startSec, endSec) : h('div', { class: 'metrics-note', text: 'No samples in this window.' }), + data.series.length ? (isBool(data.series) ? timeline(data, startSec, endSec) : chart(data, startSec, endSec)) : h('div', { class: 'metrics-note', text: 'No samples in this window.' }), legend(data.series), entry.expr && h('code', { class: 'metrics-expr', text: entry.expr }), ); @@ -130,6 +131,98 @@ function legend(series) { h('li', {}, h('span', { class: `mc-swatch s${i}` }), h('span', { text: seriesName(s.labels) })))); } +// addMarkers draws the dashed "Alert started" / "Incident resolved" lines. +function addMarkers(root, { t0, t1, x, top, bottom, startSec, endSec }) { + const marker = (t, cls, text) => { + if (t == null || t < t0 || t > t1) return; + const m = svg('line', { class: `mc-marker ${cls}`, x1: x(t), x2: x(t), y1: top, y2: bottom }); + m.appendChild(svg('title', {}, `${text} ${when(iso(t))}`)); + root.appendChild(m); + }; + marker(startSec, 'start', 'Alert started'); + marker(endSec, 'end', 'Incident resolved'); +} + +// isBool is true when every drawn series only ever reads 0 or 1; such a metric +// says when something was on, so it is drawn as a timeline, not a line. +function isBool(allSeries) { + const series = allSeries.slice(0, MAX_SERIES); + let any = false; + for (const s of series) { + for (const [, v] of s.points) { + if (v !== 0 && v !== 1) return false; + any = true; + } + } + return any; +} + +// runsOf folds a series into [from, to, value] spans. A sample lasts until the +// next one, or one step when the next is missing or more than 1.5 steps away. +function runsOf(points, step, tEnd) { + const runs = []; + points.forEach(([t, v], n) => { + const next = points[n + 1]; + const contiguous = next && (!step || next[0] - t <= step * 1.5); + const end = Math.min(contiguous ? next[0] : t + (step || 0), tEnd); + const last = runs[runs.length - 1]; + if (last && last[2] === v && last[1] === t) last[1] = end; + else runs.push([t, end, v]); + }); + return runs; +} + +function timeline(data, startSec, endSec) { + const W = 480; + const padL = 40; + const padR = 8; + const padT = 8; + const padB = 18; + const rowH = 16; + const rowGap = 4; + const series = data.series.slice(0, MAX_SERIES); + const H = padT + padB + series.length * rowH + (series.length - 1) * rowGap; + + let t0 = data.from; let t1 = data.to; + for (const s of series) { + for (const [t] of s.points) { + if (t < t0) t0 = t; + if (t > t1) t1 = t; + } + } + if (t1 === t0) t1 = t0 + 1; + const x = (t) => padL + ((t - t0) / (t1 - t0)) * (W - padL - padR); + + const root = svg('svg', { + class: 'metric-chart', viewBox: `0 0 ${W} ${H}`, role: 'img', + 'aria-label': `On/off timeline from ${when(iso(t0))} to ${when(iso(t1))}`, + }); + root.appendChild(svg('text', { class: 'mc-label', x: padL, y: H - 4, 'text-anchor': 'start' }, when(iso(t0)))); + root.appendChild(svg('text', { class: 'mc-label', x: W - padR, y: H - 4, 'text-anchor': 'end' }, when(iso(t1)))); + + series.forEach((s, i) => { + const top = padT + i * (rowH + rowGap); + const name = seriesName(s.labels); + // Without a sample the alert condition did not hold: the bare track. + root.appendChild(svg('rect', { class: 'mc-track', x: padL, y: top, width: W - padL - padR, height: rowH })); + if (series.length > 1) { + root.appendChild(svg('text', { class: 'mc-label', x: padL - 4, y: top + rowH / 2 + 3, 'text-anchor': 'end' }, `${i + 1}`)); + } + for (const [from, to, v] of runsOf(s.points, data.step, t1)) { + const w = Math.max(x(to) - x(from), 1.5); + const on = v === 1; + const rect = svg('rect', { + class: on ? `mc-on s${i}` : 'mc-off', + x: x(from), y: on ? top : top + rowH / 2 - 1.5, width: w, height: on ? rowH : 3, + }); + rect.appendChild(svg('title', {}, `${name}: ${v} from ${when(iso(from))} to ${when(iso(to))}`)); + root.appendChild(rect); + } + }); + addMarkers(root, { t0, t1, x, top: padT, bottom: H - padB, startSec, endSec }); + return root; +} + function chart(data, startSec, endSec) { const W = 480; const H = 130; @@ -172,14 +265,7 @@ function chart(data, startSec, endSec) { root.appendChild(svg('text', { class: 'mc-label', x: padL, y: H - 4, 'text-anchor': 'start' }, when(iso(t0)))); root.appendChild(svg('text', { class: 'mc-label', x: W - padR, y: H - 4, 'text-anchor': 'end' }, when(iso(t1)))); - const marker = (t, cls, text) => { - if (t == null || t < t0 || t > t1) return; - const m = svg('line', { class: `mc-marker ${cls}`, x1: x(t), x2: x(t), y1: padT, y2: H - padB }); - m.appendChild(svg('title', {}, `${text} ${when(iso(t))}`)); - root.appendChild(m); - }; - marker(startSec, 'start', 'Alert started'); - marker(endSec, 'end', 'Incident resolved'); + addMarkers(root, { t0, t1, x, top: padT, bottom: H - padB, startSec, endSec }); series.forEach((s, i) => { if (!s.points.length) return;