Draw boolean alert metrics as an on/off timeline
A metric that only ever reads 0 or 1 (up == 0, anything with bool) gave the line chart nothing to show: a flat line or a square wave on a y axis padded to -1..2. When every drawn series is 0/1 the incident page now draws one row per series instead, filled where the value was 1, a thin mark where it was 0, and the bare track where Prometheus returned no samples, which for these expressions means the condition did not hold. The decision is per chart, so one chart never mixes styles; any other value keeps the line chart as before. Frontend only: the endpoint already returns the points and step_seconds, which pick() now passes on so the last sample gets a width and a gap in the samples shows as a gap. Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
@@ -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
|
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.
|
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
|
## Endpoint
|
||||||
|
|
||||||
`GET /api/incidents/{id}/metrics` — same team scoping as the other incident routes.
|
`GET /api/incidents/{id}/metrics` — same team scoping as the other incident routes.
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ func TestCopyIncidentIsEmbedded(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for file, want := range map[string]string{
|
for file, want := range map[string]string{
|
||||||
"js/incident.js": "copyIncident",
|
"js/incident.js": "copyIncident",
|
||||||
"js/ui.js": "copy:",
|
"js/ui.js": "copy:",
|
||||||
|
"js/metricchart.js": "isBool",
|
||||||
} {
|
} {
|
||||||
b, err := fs.ReadFile(sub, file)
|
b, err := fs.ReadFile(sub, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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-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.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-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; }
|
.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 { 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; }
|
.metrics-legend li { display: flex; align-items: center; gap: 6px; overflow-wrap: anywhere; }
|
||||||
|
|||||||
@@ -74,13 +74,14 @@ function pick(entry) {
|
|||||||
from: Math.min(live.window_start, snaps.opened ? snaps.opened.window_start : Infinity),
|
from: Math.min(live.window_start, snaps.opened ? snaps.opened.window_start : Infinity),
|
||||||
to: live.window_end,
|
to: live.window_end,
|
||||||
current: live.current || [],
|
current: live.current || [],
|
||||||
|
step: live.step_seconds,
|
||||||
isLive: true,
|
isLive: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const snap = snaps.closed || snaps.opened;
|
const snap = snaps.closed || snaps.opened;
|
||||||
if (snap && !snap.error) {
|
if (snap && !snap.error) {
|
||||||
return {
|
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;
|
return null;
|
||||||
@@ -105,7 +106,7 @@ export function alertMetrics(entry, startsAtISO, resolvedISO) {
|
|||||||
const endSec = resolvedISO ? Date.parse(resolvedISO) / 1000 : null;
|
const endSec = resolvedISO ? Date.parse(resolvedISO) / 1000 : null;
|
||||||
return h('div', { class: 'metrics' },
|
return h('div', { class: 'metrics' },
|
||||||
currentLine(entry, data),
|
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),
|
legend(data.series),
|
||||||
entry.expr && h('code', { class: 'metrics-expr', text: entry.expr }),
|
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) }))));
|
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) {
|
function chart(data, startSec, endSec) {
|
||||||
const W = 480;
|
const W = 480;
|
||||||
const H = 130;
|
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: 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))));
|
root.appendChild(svg('text', { class: 'mc-label', x: W - padR, y: H - 4, 'text-anchor': 'end' }, when(iso(t1))));
|
||||||
|
|
||||||
const marker = (t, cls, text) => {
|
addMarkers(root, { t0, t1, x, top: padT, bottom: H - padB, startSec, endSec });
|
||||||
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');
|
|
||||||
|
|
||||||
series.forEach((s, i) => {
|
series.forEach((s, i) => {
|
||||||
if (!s.points.length) return;
|
if (!s.points.length) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user