Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7f4bf3765 | |||
| 6e95c86521 |
@@ -15,5 +15,5 @@ type: application
|
|||||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
||||||
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||||
# metadata and drives nothing.
|
# metadata and drives nothing.
|
||||||
version: 0.50.0
|
version: 0.51.0
|
||||||
appVersion: "v0.50.0"
|
appVersion: "v0.51.0"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ func TestCopyIncidentIsEmbedded(t *testing.T) {
|
|||||||
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