Files
terdut-server/internal/web/static/js/format.js
T
Niklas Ye fb86a18988 Show which cluster an incident came from, as a chip
A team with one Alertmanager per Kubernetes cluster could not tell at a
glance where an incident started: the cluster was only a word inside the
title. When an alert carries a `cluster` label, the queue rows, the incident
page and the alerts list now show it as a chip in that cluster's colour, a
stable pick from the existing six-colour palette. A queue row drops
`cluster=...` from its title, since the chip says it, and the incident page
keeps the full title.

An incident has the label only when it is in Alertmanager's group_by, which
is also what keeps two clusters' identical alerts apart: incidents are matched
on the team and the groupKey, and the groupKey does not include external
labels. The README has a section on the two settings (Prometheus
externalLabels and group_by). The alerts list reads the label from the alert
itself, so it shows the chip with only the external label.

Web UI and docs only: no endpoint or JSON shape changed, so nothing to mirror
in terdut-tui. Nothing changes for a team whose alerts have no cluster label.
2026-10-08 17:24:52 +02:00

153 lines
5.3 KiB
JavaScript

// Formatting of times, durations and labels.
const MIN = 60 * 1000;
const HOUR = 60 * MIN;
const DAY = 24 * HOUR;
// Compact age for list rows: "now", "4m", "3h", "2d".
export function age(iso, now = Date.now()) {
const ms = Math.max(0, now - Date.parse(iso));
if (ms < MIN) return 'now';
if (ms < HOUR) return `${Math.floor(ms / MIN)}m`;
if (ms < DAY) return `${Math.floor(ms / HOUR)}h`;
return `${Math.floor(ms / DAY)}d`;
}
// "4 min ago", "3 h ago", "yesterday"-free: stays unambiguous at 3am.
export function ago(iso, now = Date.now()) {
const a = age(iso, now);
return a === 'now' ? 'just now' : `${a} ago`;
}
// Time remaining until iso, e.g. "1h 20m".
export function until(iso, now = Date.now()) {
return duration(Date.parse(iso) - now);
}
export function duration(ms) {
ms = Math.max(0, ms);
if (ms < MIN) return '<1m';
const d = Math.floor(ms / DAY);
const h = Math.floor((ms % DAY) / HOUR);
const m = Math.floor((ms % HOUR) / MIN);
if (d) return h ? `${d}d ${h}h` : `${d}d`;
if (h) return m ? `${h}h ${m}m` : `${h}h`;
return `${m}m`;
}
const timeFmt = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' });
const dayTimeFmt = new Intl.DateTimeFormat(undefined, {
weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit',
});
// Local timestamp; the date is dropped when it is today.
export function when(iso) {
const d = new Date(iso);
const today = new Date();
if (d.toDateString() === today.toDateString()) return timeFmt.format(d);
return dayTimeFmt.format(d);
}
export function isFuture(iso) {
return iso != null && Date.parse(iso) > Date.now();
}
// Local calendar dates, as the schedule stores them (YYYY-MM-DD).
export function isoDate(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export function mondayOf(d) {
const r = new Date(d.getFullYear(), d.getMonth(), d.getDate());
r.setDate(r.getDate() - ((r.getDay() + 6) % 7));
return r;
}
// ISO 8601 week number: weeks start on Monday and week 1 is the one holding the
// year's first Thursday, which is what a rota that runs Monday to Sunday means
// by "week 40". Taken from the Thursday of d's week, whose year is the week's.
export function isoWeek(d) {
const thu = new Date(d.getFullYear(), d.getMonth(), d.getDate());
thu.setDate(thu.getDate() + 3 - ((thu.getDay() + 6) % 7));
const jan4 = new Date(thu.getFullYear(), 0, 4);
return 1 + Math.round(((thu - jan4) / 86400000 - 3 + ((jan4.getDay() + 6) % 7)) / 7);
}
export function addDays(d, n) {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r;
}
export const STATUS_LABEL = {
triggered: 'Triggered',
acknowledged: 'Acknowledged',
resolved: 'Resolved',
snoozed: 'Snoozed',
firing: 'Firing',
};
export function severityClass(sev) {
const s = (sev || '').toLowerCase();
if (s === 'critical' || s === 'page' || s === 'error') return 'sev-critical';
if (s === 'warning' || s === 'warn') return 'sev-warning';
if (s) return 'sev-info';
return '';
}
// A stable identity colour for a team, so the same team always reads the same
// colour without the server needing to store one. Teams have no colour field;
// this hashes the id into the six-colour rcN palette app.css already has for
// the rota's per-person chips (a team is not a status, so never severity).
export function teamColorClass(teamID) {
return `rc${(((teamID % 6) + 6) % 6) + 1}`;
}
// A one-line summary of the group labels, without the one the title already shows.
export function labelSummary(labels, skip = 'alertname') {
return Object.entries(labels || {})
.filter(([k]) => k !== skip)
.map(([k, v]) => `${k}=${v}`)
.join(' · ');
}
export function initial(name) {
return (name || '?').trim().charAt(0) || '?';
}
// Where an incident or alert came from, for a team with several Kubernetes
// clusters (or other origins) behind it: the value of one label, `cluster` by
// convention. It comes from Prometheus's externalLabels, so it is on every
// alert; an incident carries it only when it is in Alertmanager's group_by,
// which is also what keeps two clusters' identical alerts from merging into one
// incident (see the README, "Several clusters, one team").
export const ORIGIN_LABEL = 'cluster';
export function originOf(labels) {
const v = labels && labels[ORIGIN_LABEL];
return v ? String(v) : null;
}
// A stable colour for an origin, from the six-colour rcN palette app.css has,
// so "prod-eu" is the same colour on every row without anything being stored.
export function originClass(value) {
let n = 0;
for (const ch of value) n = (n * 31 + ch.charCodeAt(0)) >>> 0;
return `rc${(n % 6) + 1}`;
}
// The title without its `cluster=...` part, for a row that shows the origin as
// a chip: the server puts every grouping label in the title, so the cluster
// would otherwise appear twice. Anything that does not look like the server's
// "name (k=v, k=v)" shape is returned unchanged.
export function titleWithoutOrigin(title, value) {
if (!value) return title;
const m = title.match(/^(.*?) \((.*)\)$/s);
if (!m) return title;
const rest = m[2].split(', ').filter((p) => p !== `${ORIGIN_LABEL}=${value}`);
return rest.length ? `${m[1]} (${rest.join(', ')})` : m[1];
}