fb86a18988
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.
153 lines
5.3 KiB
JavaScript
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];
|
|
}
|