6e95c86521
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
283 lines
11 KiB
JavaScript
283 lines
11 KiB
JavaScript
// The metrics under one alert: a line chart of the expression that fired it, and
|
||
// what that expression reads right now. The data comes from this server, which
|
||
// asked the alert's own Prometheus (see GET /api/incidents/{id}/metrics); the
|
||
// browser never talks to Prometheus.
|
||
|
||
import { h } from './ui.js';
|
||
import { when } from './format.js';
|
||
|
||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||
const MAX_SERIES = 5;
|
||
|
||
function svg(tag, attrs = {}, text) {
|
||
const el = document.createElementNS(SVG_NS, tag);
|
||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
|
||
if (text != null) el.textContent = text;
|
||
return el;
|
||
}
|
||
|
||
const iso = (sec) => new Date(sec * 1000).toISOString();
|
||
|
||
// Compact number for an axis or a badge: 1.2k, 0.034, 12.
|
||
export function fmtValue(v) {
|
||
if (v == null || Number.isNaN(v)) return '–';
|
||
const a = Math.abs(v);
|
||
if (a >= 1e9) return `${+(v / 1e9).toPrecision(3)}G`;
|
||
if (a >= 1e6) return `${+(v / 1e6).toPrecision(3)}M`;
|
||
if (a >= 1e4) return `${+(v / 1e3).toPrecision(3)}k`;
|
||
if (a === 0) return '0';
|
||
if (a >= 100) return String(Math.round(v));
|
||
return String(+v.toPrecision(3));
|
||
}
|
||
|
||
const keyOf = (labels) => JSON.stringify(Object.entries(labels || {}).sort());
|
||
|
||
// seriesName is the labels that tell the series apart, e.g. {instance="web-1"}.
|
||
function seriesName(labels) {
|
||
const parts = Object.entries(labels || {}).filter(([k]) => k !== '__name__').map(([k, v]) => `${k}="${v}"`);
|
||
return parts.length ? `{${parts.join(', ')}}` : 'value';
|
||
}
|
||
|
||
// merge combines the snapshot taken when the incident opened with the live
|
||
// window, so the chart keeps history Prometheus may since have dropped. On the
|
||
// same timestamp the later source wins.
|
||
function merge(...sources) {
|
||
const byKey = new Map();
|
||
for (const src of sources) {
|
||
for (const s of src || []) {
|
||
const k = keyOf(s.labels);
|
||
const cur = byKey.get(k) || { labels: s.labels, points: new Map() };
|
||
for (const [t, v] of s.points || []) cur.points.set(t, v);
|
||
byKey.set(k, cur);
|
||
}
|
||
}
|
||
return [...byKey.values()].map((s) => ({
|
||
labels: s.labels,
|
||
points: [...s.points.entries()].sort((a, b) => a[0] - b[0]),
|
||
}));
|
||
}
|
||
|
||
function parseSeries(raw) {
|
||
if (Array.isArray(raw)) return raw;
|
||
return [];
|
||
}
|
||
|
||
// pick chooses what to draw: while the incident is open, the opened snapshot
|
||
// plus live data; once it is closed, the closed snapshot (the whole incident),
|
||
// falling back to the opened one.
|
||
function pick(entry) {
|
||
const snaps = entry.snapshots || {};
|
||
const live = entry.live;
|
||
if (live && !live.error) {
|
||
return {
|
||
series: merge(parseSeries(snaps.opened && snaps.opened.series), live.series),
|
||
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: [], step: snap.step_seconds, isLive: false,
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// alertMetrics renders the metrics block for one alert, or null when there is
|
||
// nothing worth saying (the feature off, or no Prometheus link on the alert).
|
||
export function alertMetrics(entry, startsAtISO, resolvedISO) {
|
||
if (!entry) return null;
|
||
if (!entry.available) {
|
||
return entry.reason && entry.expr
|
||
? h('div', { class: 'metrics-note', text: `Metrics unavailable: ${entry.reason}.` })
|
||
: null;
|
||
}
|
||
const data = pick(entry);
|
||
if (!data) {
|
||
const err = (entry.live && entry.live.error)
|
||
|| (entry.snapshots && ((entry.snapshots.closed && entry.snapshots.closed.error) || (entry.snapshots.opened && entry.snapshots.opened.error)));
|
||
return h('div', { class: 'metrics-note', text: err ? `Metrics could not be fetched: ${err}` : 'Metrics are being collected…' });
|
||
}
|
||
const startSec = startsAtISO ? Date.parse(startsAtISO) / 1000 : null;
|
||
const endSec = resolvedISO ? Date.parse(resolvedISO) / 1000 : null;
|
||
return h('div', { class: 'metrics' },
|
||
currentLine(entry, data),
|
||
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 }),
|
||
);
|
||
}
|
||
|
||
function currentLine(entry, data) {
|
||
if (!data.isLive) return null;
|
||
const vals = data.current.map((s) => s.points && s.points[0] && s.points[0][1]).filter((v) => v != null);
|
||
if (!vals.length) {
|
||
return h('div', { class: 'metrics-now none' },
|
||
h('span', { class: 'metrics-now-label', text: 'Now' }),
|
||
h('span', { text: 'no data — the alert condition no longer holds' }));
|
||
}
|
||
return h('div', { class: 'metrics-now' },
|
||
h('span', { class: 'metrics-now-label', text: 'Now' }),
|
||
h('strong', { text: vals.length === 1 ? fmtValue(vals[0]) : vals.map(fmtValue).join(' · ') }));
|
||
}
|
||
|
||
function legend(series) {
|
||
if (series.length < 2 && !(series[0] && Object.keys(series[0].labels || {}).length)) return null;
|
||
return h('ul', { class: 'metrics-legend' }, series.slice(0, MAX_SERIES).map((s, i) =>
|
||
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;
|
||
const padL = 40;
|
||
const padR = 8;
|
||
const padT = 8;
|
||
const padB = 18;
|
||
const series = data.series.slice(0, MAX_SERIES);
|
||
|
||
let t0 = Infinity; let t1 = -Infinity; let lo = Infinity; let hi = -Infinity;
|
||
for (const s of series) {
|
||
for (const [t, v] of s.points) {
|
||
if (t < t0) t0 = t;
|
||
if (t > t1) t1 = t;
|
||
if (v < lo) lo = v;
|
||
if (v > hi) hi = v;
|
||
}
|
||
}
|
||
if (!Number.isFinite(t0)) { t0 = data.from; t1 = data.to; lo = 0; hi = 1; }
|
||
// Show the whole window, not just where there are samples, so the shape of
|
||
// "it only exists while firing" is visible.
|
||
t0 = Math.min(t0, data.from);
|
||
t1 = Math.max(t1, data.to);
|
||
if (t1 === t0) t1 = t0 + 1;
|
||
if (lo === hi) { lo -= 1; hi += 1; }
|
||
if (lo > 0 && lo < (hi - lo) * 0.5) lo = 0;
|
||
|
||
const x = (t) => padL + ((t - t0) / (t1 - t0)) * (W - padL - padR);
|
||
const y = (v) => padT + (1 - (v - lo) / (hi - lo)) * (H - padT - padB);
|
||
|
||
const root = svg('svg', {
|
||
class: 'metric-chart', viewBox: `0 0 ${W} ${H}`, role: 'img',
|
||
'aria-label': `Metric over time, ${fmtValue(lo)} to ${fmtValue(hi)}, from ${when(iso(t0))} to ${when(iso(t1))}`,
|
||
});
|
||
// Gridlines and y labels at the extremes and the middle.
|
||
for (const v of [lo, (lo + hi) / 2, hi]) {
|
||
root.appendChild(svg('line', { class: 'mc-grid', x1: padL, x2: W - padR, y1: y(v), y2: y(v) }));
|
||
root.appendChild(svg('text', { class: 'mc-label', x: padL - 4, y: y(v) + 3, 'text-anchor': 'end' }, fmtValue(v)));
|
||
}
|
||
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))));
|
||
|
||
addMarkers(root, { t0, t1, x, top: padT, bottom: H - padB, startSec, endSec });
|
||
|
||
series.forEach((s, i) => {
|
||
if (!s.points.length) return;
|
||
const d = s.points.map(([t, v], n) => `${n ? 'L' : 'M'}${x(t).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
|
||
const line = svg('path', { class: `mc-line s${i}`, d });
|
||
line.appendChild(svg('title', {}, `${seriesName(s.labels)}: ${fmtValue(s.points[s.points.length - 1][1])} at ${when(iso(s.points[s.points.length - 1][0]))}`));
|
||
root.appendChild(line);
|
||
// A single sample has no line to draw; show it as a dot.
|
||
if (s.points.length === 1) {
|
||
root.appendChild(svg('circle', { class: `mc-dot s${i}`, cx: x(s.points[0][0]), cy: y(s.points[0][1]), r: 3 }));
|
||
}
|
||
});
|
||
return root;
|
||
}
|