f3918b863c
The page was a bare form: it did not say which switches existed or
whether they were alive. It now lists them, each with a Healthy, Dead or
Dormant badge, when its heartbeat was last heard and when it last opened
an incident (linked while that incident is open). A matcher that several
clusters satisfy is broken down per cluster, since a live cluster must
not hide a dead one. The form moved into a "New switch" sheet, and each
row has a Remove with a confirm.
That needed a switch to be a thing, so switches are rows now
(migration 009) with their own name, matcher, timeout and severity,
instead of one string with one team-wide timeout in deadman_configs.
Existing configuration is split into one row per matcher; a team whose
timeout was zero simply has none. The sweeper and the status endpoint
share one death rule (deadmanAlert.dead), so the page cannot disagree
with the pager. Incident group keys are unchanged, so incidents that
are open across the upgrade keep working.
The environment defaults (TERDUT_DEADMAN_*) are seeded into teams once
per install, recorded in settings, so a team that deletes its last
switch does not get it back on the next restart. Installs that already
had per-team rows are marked as seeded by the migration.
Removing a switch stops the watching but leaves an incident it already
opened open until someone resolves it.
API: GET/PUT /api/teams/{id}/deadman are replaced by
GET/POST /deadman/switches and DELETE /deadman/switches/{switchID}.
terdut-tui does not call them, so nothing to mirror there.
850 lines
33 KiB
JavaScript
850 lines
33 KiB
JavaScript
// One team: the rota, who is in it, where its alerts come from, what it
|
||
// escalates through, and which of its alerts are heartbeats.
|
||
//
|
||
// Everything here was API-only until v0.12.0, which meant a team owner had to
|
||
// use curl to set up escalation — the feature this whole line of work exists
|
||
// for.
|
||
//
|
||
// Each of those five is a route of its own behind a strip across the top, with
|
||
// /team an overview, the way 07914d5 split the Admin tab. The same reasons
|
||
// applied here and more sharply: five cards on one page meant no way to link
|
||
// somebody to the escalation ladder, no way to the switches but past a month
|
||
// of rota, and a poll that refetched six endpoints however little of the page
|
||
// you were looking at.
|
||
//
|
||
// The server decides what a role may do: an owner's edits succeed, a member's
|
||
// are refused with 403, and a non-member gets 404 for the lot. This view hides
|
||
// the controls a member cannot use, because a form that always fails is worse
|
||
// than no form, but it is not the thing enforcing anything.
|
||
|
||
import * as api from './api.js';
|
||
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip } from './ui.js';
|
||
import { state, currentTeam, users as allUsers, myID } from './state.js';
|
||
import { isoDate, addDays, mondayOf, initial, ago, when, duration } from './format.js';
|
||
|
||
const view = () => document.getElementById('view-team');
|
||
|
||
// The sub-sections, in the order the strip shows them. The overview is /team
|
||
// itself, so it has no tab of its own. This table is the only place the six
|
||
// routes are written down: app.js parses against it and the strip is built
|
||
// from it, the same contract admin.js has.
|
||
//
|
||
// `label` is what the strip says and `title` what the top bar and the document
|
||
// title say, where a strip label alone would be too thin to name a page —
|
||
// "Sources · terdut" in a browser tab does not say sources of what.
|
||
export const TABS = [
|
||
{ tab: null, path: '/team', label: 'Overview' },
|
||
{ tab: 'rota', path: '/team/rota', label: 'Rota', title: 'On-call rota' },
|
||
{ tab: 'members', path: '/team/members', label: 'Members' },
|
||
{ tab: 'escalation', path: '/team/escalation', label: 'Escalation' },
|
||
{ tab: 'sources', path: '/team/sources', label: 'Sources', title: 'Alert sources' },
|
||
{ tab: 'deadman', path: '/team/deadman', label: 'Switches', title: 'Dead man’s switches' },
|
||
];
|
||
|
||
let teamID = null;
|
||
// Which sub-section is open. Remembered rather than passed, because the poll
|
||
// loop calls refresh() with no route.
|
||
let tab = null;
|
||
let data = null; // { team, ... }; which fields are present varies by tab
|
||
let error = null;
|
||
let freshKey = null; // an integration key, shown once, until the view is left
|
||
|
||
export function show(route) {
|
||
const next = route?.tab ?? null;
|
||
// A different sub-section wants different data, so the old answer goes
|
||
// rather than being shown under the new heading until the fetch lands. The
|
||
// ladder draft goes with it: it is an edit of the page being left.
|
||
if (next !== tab) {
|
||
tab = next;
|
||
data = null;
|
||
draft = null;
|
||
}
|
||
if (!data) clear(view(), subnav(), spinner());
|
||
refresh();
|
||
}
|
||
|
||
function selectedTeam() {
|
||
const teams = state.teams || [];
|
||
return teams.find((t) => t.id === teamID) || currentTeam();
|
||
}
|
||
|
||
export async function refresh() {
|
||
const team = selectedTeam();
|
||
if (!team) {
|
||
data = null;
|
||
render();
|
||
return;
|
||
}
|
||
teamID = team.id;
|
||
try {
|
||
data = { team, ...(await load(team.id)) };
|
||
error = null;
|
||
} catch (err) {
|
||
error = err.message;
|
||
}
|
||
render();
|
||
}
|
||
|
||
// Only what the open sub-section shows. A member may read all of it; only the
|
||
// writes are owner-only.
|
||
//
|
||
// Three of the five need the member list besides their own endpoint, and for
|
||
// the same reason each time: a rota, a ladder target and a role are all a
|
||
// person, and the page has to be able to name them. The overview is the one
|
||
// that fetches everything, because saying how much of each there is means
|
||
// asking each of them.
|
||
async function load(id) {
|
||
if (tab === 'rota') {
|
||
const grid = gridDays();
|
||
const [members, schedule] = await Promise.all([
|
||
api.teamMembers(id),
|
||
api.schedule(id, isoDate(grid.start), isoDate(addDays(grid.start, grid.count - 1))),
|
||
]);
|
||
return { members, schedule };
|
||
}
|
||
if (tab === 'members') {
|
||
const [members, users] = await Promise.all([api.teamMembers(id), allUsers()]);
|
||
return { members, users };
|
||
}
|
||
if (tab === 'escalation') {
|
||
const [members, escalation] = await Promise.all([api.teamMembers(id), api.escalation(id)]);
|
||
return { members, escalation };
|
||
}
|
||
if (tab === 'sources') return { integrations: await api.integrations(id) };
|
||
if (tab === 'deadman') return { deadman: await api.deadmanSwitches(id) };
|
||
|
||
const grid = gridDays();
|
||
const [members, integrations, escalation, deadman, schedule] = await Promise.all([
|
||
api.teamMembers(id),
|
||
api.integrations(id),
|
||
api.escalation(id),
|
||
api.deadmanSwitches(id),
|
||
api.schedule(id, isoDate(grid.start), isoDate(addDays(grid.start, grid.count - 1))),
|
||
]);
|
||
return { members, integrations, escalation, deadman, schedule };
|
||
}
|
||
|
||
function isOwner() {
|
||
return data?.team?.role === 'owner' || state.me?.user?.is_admin;
|
||
}
|
||
|
||
function render() {
|
||
if (!data) {
|
||
clear(view(), error
|
||
? h('div', { class: 'load-error', text: error })
|
||
: h('div', { class: 'card' }, h('p', { class: 'muted', text: 'You are not in a team yet.' })));
|
||
return;
|
||
}
|
||
clear(view(),
|
||
subnav(),
|
||
teamPicker(),
|
||
// Said once on the overview rather than on all six pages: it explains why
|
||
// the controls further down are missing, and a page of nothing but the
|
||
// rota has no controls to explain.
|
||
!isOwner() && tab === null && h('div', { class: 'card' },
|
||
h('p', { class: 'muted small', text: 'You are a member of this team. Only an owner can change its settings.' })),
|
||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||
section(),
|
||
);
|
||
}
|
||
|
||
function section() {
|
||
if (tab === 'rota') return scheduleCard();
|
||
if (tab === 'members') return membersCard();
|
||
if (tab === 'escalation') return escalationCard();
|
||
if (tab === 'sources') return integrationsCard();
|
||
if (tab === 'deadman') return deadmanCard();
|
||
return overview();
|
||
}
|
||
|
||
// The strip across the top of every team page. Ordinary links rather than
|
||
// buttons, because these are six URLs: app.js intercepts the click, the
|
||
// browser's Back walks them, and a reload lands where you were.
|
||
function subnav() {
|
||
return h('nav', { class: 'subnav', 'aria-label': 'Team' },
|
||
TABS.map((t) => h('a', {
|
||
class: 'subnav-link',
|
||
href: t.path,
|
||
text: t.label,
|
||
'aria-current': t.tab === tab ? 'page' : null,
|
||
})));
|
||
}
|
||
|
||
// Only shown to somebody in more than one team, like the queue's filter chips.
|
||
// It is above the sections rather than inside one because it changes the
|
||
// subject of all six.
|
||
function teamPicker() {
|
||
if ((state.teams || []).length < 2) {
|
||
return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
|
||
}
|
||
const select = h('select', { class: 'team-picker' },
|
||
...state.teams.map((t) => h('option', {
|
||
value: String(t.id), text: t.name, selected: t.id === teamID,
|
||
})));
|
||
select.addEventListener('change', () => {
|
||
teamID = Number(select.value);
|
||
data = null;
|
||
freshKey = null;
|
||
draft = null;
|
||
refresh();
|
||
});
|
||
return h('div', { class: 'card' }, h('h2', { text: 'Team' }), select);
|
||
}
|
||
|
||
// --- overview --------------------------------------------------------------
|
||
|
||
// /team itself. The strip already links to the five, so this earns its place
|
||
// the way /admin's does: by saying how much of each there is, which is the one
|
||
// thing a menu cannot.
|
||
function overview() {
|
||
const today = isoDate(new Date());
|
||
const onToday = (data.schedule || []).find((e) => e.date === today);
|
||
const owners = (data.members || []).filter((m) => m.role === 'owner').length;
|
||
const levels = (data.escalation?.levels || []).length;
|
||
const keys = (data.integrations || []).length;
|
||
const unused = (data.integrations || []).filter((i) => !i.last_used_at).length;
|
||
const switches = (data.deadman || []).length;
|
||
const dead = (data.deadman || []).filter((s) => s.status === 'dead').length;
|
||
|
||
return h('div', { class: 'overview-menu' },
|
||
menuCard('/team/rota', 'Rota', null,
|
||
onToday ? `${onToday.username} is on call today.` : 'Nobody is on call today.'),
|
||
menuCard('/team/members', 'Members', (data.members || []).length,
|
||
owners === 1 ? 'One owner.' : `${owners} owners.`),
|
||
menuCard('/team/escalation', 'Escalation', levels || null,
|
||
levels
|
||
? `${levels === 1 ? 'One level' : `${levels} levels`}${data.escalation.fallback_topic ? ', then a fallback topic.' : '.'}`
|
||
: 'No ladder — nobody but the first person is woken.'),
|
||
menuCard('/team/sources', 'Alert sources', keys || null,
|
||
keys
|
||
? (unused ? `${unused} of them never used.` : 'All in use.')
|
||
: 'No key yet, so nothing can reach this team.'),
|
||
menuCard('/team/deadman', 'Dead man’s switches', switches || null,
|
||
switches
|
||
? (dead ? `${dead} of them silent.` : 'All quiet, as they should be.')
|
||
: 'Nothing watched.'),
|
||
);
|
||
}
|
||
|
||
// --- schedule --------------------------------------------------------------
|
||
|
||
// The rota is one person per UTC day. The on-call page shows it; this is where
|
||
// it is set, which until now was the TUI's job and the TUI cannot do it any
|
||
// more.
|
||
//
|
||
// A month of it, as a grid. It used to be thirty rows of "date — username",
|
||
// which is a rota spelled out one day at a time: the question asked of it is
|
||
// "who has which stretch", and thirty names down a column is the one shape
|
||
// that answer cannot be read in. So each day carries a coloured initial
|
||
// instead, the legend says whose, and a shift becomes a run of one colour.
|
||
//
|
||
// The same month laid out the same way as the on-call page's week, because it
|
||
// is the same rota — heading and arrows outside the card, days inside it.
|
||
|
||
const monthFmt = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' });
|
||
const weekdayFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short' });
|
||
const longDayFmt = new Intl.DateTimeFormat(undefined, {
|
||
weekday: 'long', day: 'numeric', month: 'long',
|
||
});
|
||
|
||
let monthStart = firstOfMonth(new Date());
|
||
|
||
function firstOfMonth(d) {
|
||
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||
}
|
||
|
||
// The grid runs Monday to Sunday, so it starts before the 1st and ends after
|
||
// the last. Both overhangs are fetched and drawn: a shift that begins on the
|
||
// 30th is a fact about this month even though the days it runs into are not.
|
||
function gridDays() {
|
||
const start = mondayOf(monthStart);
|
||
const last = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0);
|
||
const span = Math.round((last - start) / 86400000) + 1;
|
||
return { start, count: Math.ceil(span / 7) * 7 };
|
||
}
|
||
|
||
function shiftMonth(n) {
|
||
monthStart = new Date(monthStart.getFullYear(), monthStart.getMonth() + n, 1);
|
||
refresh();
|
||
}
|
||
|
||
function scheduleCard() {
|
||
const { start, count } = gridDays();
|
||
const byDate = new Map((data.schedule || []).map((e) => [e.date, e]));
|
||
const today = isoDate(new Date());
|
||
const month = monthStart.getMonth();
|
||
|
||
// Whose colours to explain, in the order the month meets them. Only the days
|
||
// of this month count: a name that appears solely in the overhang belongs to
|
||
// the month next door and would be explaining a chip nobody asked about.
|
||
const seen = new Map();
|
||
const cells = [];
|
||
for (let i = 0; i < count; i++) {
|
||
const d = addDays(start, i);
|
||
const key = isoDate(d);
|
||
const e = byDate.get(key);
|
||
const inMonth = d.getMonth() === month;
|
||
if (inMonth && e && !seen.has(e.user_id)) seen.set(e.user_id, e.username);
|
||
cells.push(dayCell(d, key, e, inMonth, today));
|
||
}
|
||
|
||
const heads = [];
|
||
for (let i = 0; i < 7; i++) {
|
||
// Any Monday will do; this one is a Monday.
|
||
heads.push(h('span', { class: 'rota-wd', text: weekdayFmt.format(new Date(2024, 0, 1 + i)) }));
|
||
}
|
||
|
||
return [
|
||
h('div', { class: 'page-head' },
|
||
h('h2', { text: 'On-call rota' }),
|
||
h('div', { class: 'week-nav' },
|
||
h('button', {
|
||
class: 'btn btn-ghost btn-icon', type: 'button',
|
||
'aria-label': 'Previous month', onclick: () => shiftMonth(-1),
|
||
}, icon('chevronLeft')),
|
||
h('button', {
|
||
class: 'btn btn-ghost label', type: 'button',
|
||
title: 'Back to this month',
|
||
onclick: () => { monthStart = firstOfMonth(new Date()); refresh(); },
|
||
text: monthFmt.format(monthStart),
|
||
}),
|
||
h('button', {
|
||
class: 'btn btn-ghost btn-icon', type: 'button',
|
||
'aria-label': 'Next month', onclick: () => shiftMonth(1),
|
||
}, icon('chevronRight')),
|
||
),
|
||
),
|
||
h('div', { class: 'card' },
|
||
h('div', { class: 'rota-grid' }, heads, cells),
|
||
h('div', { class: 'rota-foot' }, legend(seen), coverNote(byDate)),
|
||
// The range form is the way to fill a whole shift at once, but it is not
|
||
// what the page is for, so it stays folded away under the month it edits.
|
||
isOwner() && h('details', { class: 'rota-bulk' },
|
||
h('summary', { text: 'Assign a range of days' }),
|
||
assignForm()),
|
||
),
|
||
];
|
||
}
|
||
|
||
function dayCell(d, key, e, inMonth, today) {
|
||
const cls = ['rota-day', !inMonth && 'outside', key === today && 'today', key < today && 'past']
|
||
.filter(Boolean).join(' ');
|
||
const label = `${key} · ${e ? e.username : 'nobody'}`;
|
||
const body = [
|
||
h('span', { class: 'rota-num', text: String(d.getDate()) }),
|
||
e
|
||
? h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) })
|
||
: h('span', { class: 'rota-chip none' }),
|
||
];
|
||
// A member sees the same grid without the affordance, the way every other
|
||
// control on this page is hidden rather than shown and refused.
|
||
return isOwner()
|
||
? h('button', {
|
||
class: cls, type: 'button', title: label, 'aria-label': label,
|
||
onclick: () => daySheet(key, e),
|
||
}, body)
|
||
: h('div', { class: cls, title: label }, body);
|
||
}
|
||
|
||
// A colour per person, taken from their place in the member list so that it
|
||
// holds still as you page between months. Somebody who holds days but has
|
||
// since left the team is not in that list and falls back to their id.
|
||
function colorClass(userID) {
|
||
const i = (data.members || []).findIndex((m) => m.user_id === userID);
|
||
return `rc${((i < 0 ? userID : i) % 6) + 1}`;
|
||
}
|
||
|
||
function legend(seen) {
|
||
if (!seen.size) return null;
|
||
return h('div', { class: 'rota-legend' },
|
||
[...seen].map(([id, name]) => h('span', { class: 'rota-key' },
|
||
h('span', { class: `rota-chip ${colorClass(id)}`, text: initial(name) }),
|
||
h('span', { text: name }),
|
||
id === myID() && h('span', { class: 'you', text: 'you' }),
|
||
)));
|
||
}
|
||
|
||
// The gap count, which is the one thing the grid states only by omission. Days
|
||
// already past are not counted: an empty Tuesday last week is history, not a
|
||
// hole somebody still has to fill.
|
||
function coverNote(byDate) {
|
||
const today = isoDate(new Date());
|
||
const last = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0).getDate();
|
||
let gaps = 0;
|
||
for (let day = 1; day <= last; day++) {
|
||
const key = isoDate(new Date(monthStart.getFullYear(), monthStart.getMonth(), day));
|
||
if (key >= today && !byDate.has(key)) gaps++;
|
||
}
|
||
if (gaps === 0) return h('p', { class: 'rota-note', text: 'Every day left this month has somebody on call.' });
|
||
return h('p', { class: 'rota-note' },
|
||
h('strong', { text: gaps === 1 ? '1 day' : `${gaps} days` }),
|
||
' left this month with nobody on call.');
|
||
}
|
||
|
||
// One day, in the sheet: who has it, who should, and the way to empty it. This
|
||
// is where the per-row Clear button went — the grid has no room for thirty of
|
||
// them, and the day you want to change is the one you just tapped.
|
||
function daySheet(date, entry) {
|
||
const who = memberSelect(entry ? entry.user_id : undefined);
|
||
openSheet(() => [
|
||
h('h2', { class: 'sheet-title', text: longDayFmt.format(parseISO(date)) }),
|
||
h('p', { class: 'sheet-text', text: entry ? `${entry.username} is on call.` : 'Nobody is on call.' }),
|
||
h('label', { class: 'sheet-pick' }, 'On call ', who),
|
||
h('div', { class: 'sheet-actions' },
|
||
entry && h('button', {
|
||
class: 'btn btn-danger', type: 'button', text: 'Clear',
|
||
onclick: () => { closeSheet(); act(() => api.unassignSchedule(teamID, entry.id)); },
|
||
}),
|
||
h('button', {
|
||
class: 'btn btn-primary', type: 'button', autofocus: true, text: 'Assign',
|
||
// replace, where the range form asks first: the sheet has just named
|
||
// whoever holds the day, so taking it from them is the thing that was
|
||
// asked for rather than something to be warned about.
|
||
onclick: () => {
|
||
closeSheet();
|
||
act(() => api.assignSchedule(teamID, Number(who.value), [date], true));
|
||
},
|
||
}),
|
||
),
|
||
]);
|
||
}
|
||
|
||
function parseISO(s) {
|
||
const [y, m, d] = s.split('-').map(Number);
|
||
return new Date(y, m - 1, d);
|
||
}
|
||
|
||
function assignForm() {
|
||
const who = memberSelect();
|
||
// The form opens on the month above it rather than on today: it is folded
|
||
// into that month's card, and paging to March to fill March and being handed
|
||
// today's date would be the card and the form disagreeing about the subject.
|
||
const now = new Date();
|
||
const sameMonth = monthStart.getFullYear() === now.getFullYear()
|
||
&& monthStart.getMonth() === now.getMonth();
|
||
const from = h('input', {
|
||
type: 'date', required: true, value: isoDate(sameMonth ? now : monthStart),
|
||
});
|
||
const days = h('input', { type: 'number', min: '1', max: '31', value: '1', class: 'setting-value' });
|
||
const replace = h('input', { type: 'checkbox' });
|
||
|
||
const form = h('form', { class: 'stacked-form' },
|
||
h('label', {}, 'Who ', who),
|
||
h('label', {}, 'From ', from),
|
||
h('label', {}, 'Days ', days),
|
||
// Taking a day somebody else holds has to be asked for, the same rule the
|
||
// API enforces: a plain assignment that silently moved a shift would move
|
||
// who gets paged without telling either of them.
|
||
h('label', { class: 'checkbox' }, replace, ' Take days somebody else holds'),
|
||
h('button', { class: 'btn', type: 'submit', text: 'Assign' }));
|
||
|
||
form.addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
const start = new Date(from.value + 'T00:00:00Z');
|
||
const dates = [];
|
||
for (let i = 0; i < Number(days.value || 1); i++) dates.push(isoDate(addDays(start, i)));
|
||
act(() => api.assignSchedule(teamID, Number(who.value), dates, replace.checked));
|
||
});
|
||
return form;
|
||
}
|
||
|
||
function memberSelect(selected) {
|
||
return h('select', {},
|
||
...(data.members || []).map((m) => h('option', {
|
||
value: String(m.user_id), text: m.username, selected: m.user_id === selected,
|
||
})));
|
||
}
|
||
|
||
// --- escalation ------------------------------------------------------------
|
||
|
||
// The ladder is edited as a whole and sent as a whole, because the API replaces
|
||
// it wholesale: the levels are an order, and patching one rung would leave the
|
||
// numbering of the others undecided.
|
||
let draft = null;
|
||
|
||
function escalationCard() {
|
||
const esc = data.escalation;
|
||
if (!draft) {
|
||
draft = {
|
||
repeat_count: esc.repeat_count || 0,
|
||
fallback_topic: esc.fallback_topic || '',
|
||
levels: (esc.levels || []).map((l) => ({
|
||
timeout_seconds: l.timeout_seconds,
|
||
targets: (l.targets || []).map((t) => ({ kind: t.kind, user_id: t.user_id })),
|
||
})),
|
||
};
|
||
}
|
||
|
||
const body = [];
|
||
if (!draft.levels.length) {
|
||
body.push(h('p', { class: 'muted' },
|
||
'No ladder. An unacknowledged incident re-pages the same person every ',
|
||
'reminder interval and nobody else is woken.'));
|
||
}
|
||
|
||
draft.levels.forEach((level, i) => {
|
||
body.push(h('div', { class: 'ladder-level' },
|
||
h('div', { class: 'ladder-head' },
|
||
h('strong', { text: `Level ${i + 1}` }),
|
||
isOwner() && h('button', {
|
||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||
onclick: () => { draft.levels.splice(i, 1); render(); },
|
||
})),
|
||
h('label', {}, 'Wait ', minutesInput(level.timeout_seconds, (secs) => {
|
||
level.timeout_seconds = secs;
|
||
}), ' before the next level'),
|
||
h('div', { class: 'ladder-targets' },
|
||
...level.targets.map((t, ti) => targetRow(level, t, ti)),
|
||
isOwner() && h('button', {
|
||
class: 'btn-sm', type: 'button', text: '+ target',
|
||
onclick: () => { level.targets.push({ kind: 'oncall' }); render(); },
|
||
})),
|
||
));
|
||
});
|
||
|
||
if (isOwner()) {
|
||
body.push(h('button', {
|
||
class: 'btn-sm', type: 'button', text: '+ level',
|
||
onclick: () => {
|
||
draft.levels.push({ timeout_seconds: 300, targets: [{ kind: 'oncall' }] });
|
||
render();
|
||
},
|
||
}));
|
||
|
||
const repeat = h('input', {
|
||
type: 'number', min: '0', max: '10', class: 'setting-value',
|
||
value: String(draft.repeat_count),
|
||
oninput: (e) => { draft.repeat_count = Number(e.target.value); },
|
||
});
|
||
const fallback = h('input', {
|
||
type: 'text', value: draft.fallback_topic, placeholder: 'terdut-oncall-all',
|
||
oninput: (e) => { draft.fallback_topic = e.target.value; },
|
||
});
|
||
body.push(h('label', {}, 'Repeat the whole ladder ', repeat, ' more times'));
|
||
body.push(h('label', {}, 'Then page this ntfy topic once ', fallback));
|
||
body.push(h('button', {
|
||
class: 'btn', type: 'button', text: 'Save ladder',
|
||
onclick: () => act(() => api.setEscalation(teamID, draft), { resetDraft: true }),
|
||
}));
|
||
}
|
||
|
||
return h('div', { class: 'card' },
|
||
h('h2', { text: 'Escalation' }),
|
||
h('p', { class: 'muted small' },
|
||
'When a level’s wait passes and nobody has acknowledged, the next level is ',
|
||
'paged. Acknowledging or resolving stops it; snoozing pauses it.'),
|
||
...body,
|
||
);
|
||
}
|
||
|
||
function targetRow(level, target, index) {
|
||
const kind = h('select', {},
|
||
h('option', { value: 'oncall', text: 'Whoever is on call', selected: target.kind === 'oncall' }),
|
||
h('option', { value: 'user', text: 'A specific person', selected: target.kind === 'user' }));
|
||
kind.addEventListener('change', () => {
|
||
target.kind = kind.value;
|
||
target.user_id = kind.value === 'user' ? (data.members[0] || {}).user_id : undefined;
|
||
render();
|
||
});
|
||
|
||
const who = target.kind === 'user'
|
||
? memberSelect(target.user_id)
|
||
: null;
|
||
if (who) {
|
||
who.addEventListener('change', () => { target.user_id = Number(who.value); });
|
||
}
|
||
|
||
return h('div', { class: 'target-row' }, kind, who,
|
||
isOwner() && h('button', {
|
||
class: 'btn-sm danger', type: 'button', text: '×',
|
||
title: 'Remove this target',
|
||
onclick: () => { level.targets.splice(index, 1); render(); },
|
||
}));
|
||
}
|
||
|
||
function minutesInput(seconds, onChange) {
|
||
const input = h('input', {
|
||
type: 'number', min: '1', class: 'setting-value',
|
||
value: String(Math.max(1, Math.round(seconds / 60))),
|
||
oninput: (e) => onChange(Number(e.target.value) * 60),
|
||
});
|
||
return h('span', {}, input, ' minutes');
|
||
}
|
||
|
||
// --- integrations ----------------------------------------------------------
|
||
|
||
function integrationsCard() {
|
||
const rows = (data.integrations || []).map((i) =>
|
||
h('tr', {},
|
||
h('td', {}, h('strong', { text: i.name })),
|
||
h('td', { class: 'muted small', text: i.kind }),
|
||
h('td', { class: 'muted small', text: i.last_used_at ? 'in use' : 'never used' }),
|
||
h('td', {}, isOwner() && h('button', {
|
||
class: 'btn-sm danger', type: 'button', text: 'Revoke',
|
||
onclick: async () => {
|
||
if (!(await confirm({
|
||
title: `Revoke ${i.name}?`,
|
||
text: 'Anything posting with this key stops delivering immediately.',
|
||
confirmLabel: 'Revoke',
|
||
danger: true,
|
||
}))) return;
|
||
act(() => api.deleteIntegration(teamID, i.id));
|
||
},
|
||
})),
|
||
));
|
||
|
||
return h('div', { class: 'card' },
|
||
h('h2', { text: 'Alert sources' }),
|
||
h('p', { class: 'muted small' },
|
||
'Alerts arrive on an integration key, which says both that the sender may ',
|
||
'post and which team the alerts belong to.'),
|
||
rows.length
|
||
? h('table', { class: 'admin-table' }, h('tbody', {}, rows))
|
||
: h('p', { class: 'muted', text: 'No alert source yet, so nothing can reach this team.' }),
|
||
freshKey && newKeyPanel(),
|
||
isOwner() && !freshKey && newIntegrationForm(),
|
||
);
|
||
}
|
||
|
||
// The key is returned exactly once. Say so, show it large, and give the
|
||
// Alertmanager snippet with it already in place — the next thing anybody does
|
||
// with it is paste it into a config.
|
||
function newKeyPanel() {
|
||
const url = freshKey.url || `${location.origin}/api/integrations/${freshKey.key}/alertmanager`;
|
||
const snippet = `receivers:
|
||
- name: terdut
|
||
webhook_configs:
|
||
- url: ${url}
|
||
send_resolved: true`;
|
||
|
||
return h('div', { class: 'key-panel' },
|
||
h('strong', { text: 'Copy this now — it is not shown again.' }),
|
||
h('pre', { class: 'key-url' }, h('code', { text: url })),
|
||
h('button', {
|
||
class: 'btn-sm', type: 'button', text: 'Copy URL',
|
||
onclick: () => navigator.clipboard?.writeText(url),
|
||
}),
|
||
h('p', { class: 'muted small', text: 'Alertmanager receiver:' }),
|
||
h('pre', {}, h('code', { text: snippet })),
|
||
h('button', {
|
||
class: 'btn-sm', type: 'button', text: 'Done',
|
||
onclick: () => { freshKey = null; render(); },
|
||
}),
|
||
);
|
||
}
|
||
|
||
function newIntegrationForm() {
|
||
const name = h('input', { type: 'text', placeholder: 'prod alertmanager', required: true });
|
||
const form = h('form', { class: 'inline-form' }, name,
|
||
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
freshKey = await api.createIntegration(teamID, name.value.trim());
|
||
await refresh();
|
||
} catch (err) {
|
||
error = err.message;
|
||
render();
|
||
}
|
||
});
|
||
return form;
|
||
}
|
||
|
||
// --- dead man's switches ---------------------------------------------------
|
||
|
||
const SWITCH_STATUS = {
|
||
healthy: { label: 'Healthy', hint: 'Heard from within its timeout.' },
|
||
dead: { label: 'Dead', hint: 'Silent for longer than its timeout.' },
|
||
dormant: { label: 'Dormant', hint: 'Nothing has matched yet, so there is nothing to lose.' },
|
||
};
|
||
|
||
function switchBadge(status) {
|
||
const s = SWITCH_STATUS[status] || SWITCH_STATUS.dormant;
|
||
const el = badge(s.label, `st-${status}`);
|
||
el.title = s.hint;
|
||
return el;
|
||
}
|
||
|
||
const timeCell = (iso) => iso
|
||
? h('span', { title: when(iso), text: ago(iso) })
|
||
: h('span', { class: 'muted', text: 'never' });
|
||
|
||
// When it last opened an incident. An incident that is still open is a link,
|
||
// because that is the thing somebody looking at a red row wants next.
|
||
const triggeredCell = (iso, incidentID) => {
|
||
if (!iso) return h('span', { class: 'muted', text: 'never' });
|
||
return incidentID
|
||
? h('a', { href: `/incidents/${incidentID}`, title: when(iso) }, `#${incidentID} · ${ago(iso)}`)
|
||
: h('span', { title: when(iso), text: ago(iso) });
|
||
};
|
||
|
||
function switchRows(sw) {
|
||
const main = h('tr', {},
|
||
h('td', {}, switchBadge(sw.status)),
|
||
h('td', {},
|
||
h('strong', { text: sw.name }),
|
||
sw.name !== sw.matcher && h('div', { class: 'muted small' }, h('code', { text: sw.matcher }))),
|
||
h('td', { class: 'muted small' }, timeCell(sw.last_heartbeat_at)),
|
||
h('td', { class: 'muted small' }, triggeredCell(sw.last_triggered_at, sw.open_incident_id)),
|
||
h('td', { class: 'muted small', text: duration(sw.timeout_seconds * 1000) }),
|
||
h('td', {}, isOwner() && h('button', {
|
||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||
onclick: async () => {
|
||
if (!(await confirm({
|
||
title: `Remove ${sw.name}?`,
|
||
text: 'It stops being watched. An incident it already opened stays open until it is resolved.',
|
||
confirmLabel: 'Remove',
|
||
danger: true,
|
||
}))) return;
|
||
act(() => api.deleteDeadmanSwitch(teamID, sw.id));
|
||
},
|
||
})),
|
||
);
|
||
|
||
// One heartbeat is the switch's own times; several are worth telling apart,
|
||
// since a live cluster must not hide a dead one.
|
||
const sources = sw.sources.length > 1
|
||
? sw.sources.map((src) => h('tr', { class: 'source-row' },
|
||
h('td', {}, switchBadge(src.status)),
|
||
h('td', { class: 'source-labels' },
|
||
...Object.entries(src.labels || {})
|
||
.filter(([k]) => k !== 'alertname')
|
||
.map(([k, v]) => labelChip(k, v)),
|
||
!Object.keys(src.labels || {}).some((k) => k !== 'alertname')
|
||
&& h('code', { class: 'small', text: src.fingerprint })),
|
||
h('td', { class: 'muted small' }, timeCell(src.last_heartbeat_at)),
|
||
h('td', { class: 'muted small' }, triggeredCell(src.last_triggered_at, src.incident_id)),
|
||
h('td'), h('td')))
|
||
: [];
|
||
return [main, ...sources];
|
||
}
|
||
|
||
function deadmanCard() {
|
||
const switches = data.deadman || [];
|
||
|
||
return h('div', { class: 'card' },
|
||
h('div', { class: 'card-head' },
|
||
h('h2', { text: 'Dead man’s switches' }),
|
||
isOwner() && h('button', {
|
||
class: 'btn', type: 'button', text: 'New switch', onclick: openNewSwitch,
|
||
})),
|
||
h('p', { class: 'muted small' },
|
||
'Alerts whose ABSENCE is the signal. Receiving one opens nothing; going ',
|
||
'quiet for longer than the switch’s timeout opens an incident.'),
|
||
switches.length
|
||
? h('div', { class: 'table-scroll' },
|
||
h('table', { class: 'admin-table switch-table' },
|
||
h('thead', {}, h('tr', {},
|
||
h('th', { text: 'Status' }), h('th', { text: 'Switch' }),
|
||
h('th', { text: 'Last heartbeat' }), h('th', { text: 'Last triggered' }),
|
||
h('th', { text: 'Silent after' }), h('th'))),
|
||
h('tbody', {}, switches.flatMap(switchRows))))
|
||
: h('p', { class: 'muted', text: 'Nothing watched.' }),
|
||
);
|
||
}
|
||
|
||
// The form lives in the sheet, not on the page: most visits are to look at the
|
||
// list, and a form that is always open is the page this replaced.
|
||
function openNewSwitch() {
|
||
const name = h('input', { type: 'text', placeholder: 'Prod Watchdog', autofocus: true });
|
||
const matcher = h('input', {
|
||
type: 'text', placeholder: 'alertname=Watchdog,cluster=prod', class: 'wide', required: true,
|
||
});
|
||
const timeout = h('input', {
|
||
type: 'number', min: '1', value: '15', class: 'setting-value', required: true,
|
||
});
|
||
const severity = h('select', {},
|
||
...['critical', 'error', 'warning', 'info'].map((s) => h('option', { value: s, text: s })));
|
||
const problem = h('p', { class: 'load-error', hidden: true });
|
||
|
||
const form = h('form', { class: 'stacked-form' },
|
||
h('label', {}, 'Name (optional) ', name),
|
||
h('label', {}, 'Heartbeat alert ', matcher),
|
||
h('p', { class: 'muted small' },
|
||
'Conditions are ', h('code', { text: 'label=value' }), ' separated by commas, and one ',
|
||
'must be ', h('code', { text: 'alertname' }), '. Every distinct label set that ',
|
||
'matches is watched on its own.'),
|
||
h('label', {}, 'Declare dead after ', timeout, ' minutes of silence'),
|
||
h('label', {}, 'Open the incident at severity ', severity),
|
||
problem,
|
||
h('div', { class: 'sheet-actions' },
|
||
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
|
||
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Add switch' })));
|
||
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
await api.createDeadmanSwitch(teamID, {
|
||
name: name.value.trim(),
|
||
matcher: matcher.value.trim(),
|
||
timeout_seconds: Math.round(Number(timeout.value) * 60),
|
||
severity: severity.value,
|
||
});
|
||
} catch (err) {
|
||
problem.textContent = err.message;
|
||
problem.hidden = false;
|
||
return;
|
||
}
|
||
closeSheet(true);
|
||
refresh();
|
||
});
|
||
|
||
openSheet(() => [h('h2', { class: 'sheet-title', text: 'New switch' }), form]);
|
||
}
|
||
|
||
// --- members ---------------------------------------------------------------
|
||
|
||
function membersCard() {
|
||
const rows = (data.members || []).map((m) =>
|
||
h('tr', {},
|
||
h('td', {}, h('strong', { text: m.username })),
|
||
h('td', { class: 'muted small', text: m.role }),
|
||
h('td', {}, isOwner() && h('button', {
|
||
class: 'btn-sm', type: 'button',
|
||
text: m.role === 'owner' ? 'Make member' : 'Make owner',
|
||
onclick: () => act(() =>
|
||
api.addTeamMember(teamID, m.user_id, m.role === 'owner' ? 'member' : 'owner')),
|
||
}), isOwner() && h('button', {
|
||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||
onclick: () => act(() => api.removeTeamMember(teamID, m.user_id)),
|
||
})),
|
||
));
|
||
|
||
const inTeam = new Set((data.members || []).map((m) => m.user_id));
|
||
const candidates = (data.users || []).filter((u) => !inTeam.has(u.id) && !u.disabled_at);
|
||
const pick = h('select', {},
|
||
...candidates.map((u) => h('option', { value: String(u.id), text: u.username })));
|
||
const role = h('select', {},
|
||
h('option', { value: 'member', text: 'member' }),
|
||
h('option', { value: 'owner', text: 'owner' }));
|
||
const form = h('form', { class: 'inline-form' }, pick, role,
|
||
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
|
||
form.addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
act(() => api.addTeamMember(teamID, Number(pick.value), role.value));
|
||
});
|
||
|
||
return h('div', { class: 'card' },
|
||
h('h2', { text: 'Members' }),
|
||
h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
|
||
isOwner() && candidates.length > 0 && form,
|
||
);
|
||
}
|
||
|
||
// --- plumbing --------------------------------------------------------------
|
||
|
||
// act runs a write and reloads. Errors are shown rather than thrown away: a
|
||
// 409 from the last-owner guard or the schedule's conflict rule is the server
|
||
// explaining itself, and the reader needs to see it.
|
||
async function act(fn, { resetDraft = false } = {}) {
|
||
try {
|
||
await fn();
|
||
error = null;
|
||
if (resetDraft) draft = null;
|
||
} catch (err) {
|
||
error = err.message;
|
||
}
|
||
if (!resetDraft) draft = null;
|
||
await refresh();
|
||
}
|