9029d48584
- TERDUT_OPERATOR_KEY creates or re-keys the instance-scoped service account
"terdut-operator" at every start, so terdut-operator needs no bootstrap
handshake. An instance-scoped account now acts as owner of every team's
configuration, but is not a member of any team.
- POST /api/teams takes an external_id (instance service accounts only) and
is idempotent on it, so automation finds its own team again after a crash
instead of adopting by display name. GET /api/teams?name= is removed.
- Integration and dead man's switch names are unique per team (409). The
escalation PUT accepts usernames and resolves them itself.
- The 18 migrations are squashed into 001_schema.sql, with no Default team.
TERDUT_DEADMAN_* and the env seeding of switches are removed: teams carry
their own. Existing development databases must be recreated.
Security and robustness:
- GET /api/users no longer returns other people's email or ntfy topic to
non-admins.
- The access log records the route pattern, so integration keys and ack
tokens in the path are not written to the log. Server errors are logged.
- Rate limits take the client address TERDUT_TRUSTED_PROXIES hops from the
right of X-Forwarded-For instead of trusting the first, forgeable entry.
- /api/bootstrap runs in a transaction under an advisory lock, so two
concurrent calls cannot both create an administrator.
- API key last_used_at is written at most every five minutes.
Cleanup: remove GET /api/incidents/{id}/alerts, unused exports, SQLite
remnants in comments and config.
Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
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 docs/incidents.md, "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];
|
|
}
|