3cdd5aee1f
The Team tab was five cards stacked on one page: the rota, the escalation ladder, the alert sources, the dead man's switches and the membership.07914d5split the Admin tab for three reasons, and all three were sharper here. There was no way to link somebody to the escalation ladder, which is the thing a team owner most often has to be talked through. There was no way to the switches but scrolling past a month of rota -- and the rota became a month grid in v0.18.0, which made the page taller rather than shorter. And the poll loop refetched six endpoints every tick however little of the page you were looking at. Each is now a route: /team/rota, /team/members, /team/escalation, /team/sources, /team/deadman, reached from the same strip of links the Admin tab uses, with /team an overview. A page fetches only what it shows, so the switches are one GET and the sources are one, where every tick used to be six. Three of the five fetch the member list besides their own endpoint, and for the same reason each time: a rota entry, 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 -- who is on call today, how many members and owners, how many ladder levels and whether a fallback follows them, how many keys and how many never used, how many switches. That is what it is for; a strip that already links to the five does not need a second menu that repeats it. team.js owns the table of its six routes, as admin.js owns its four, and app.js parses against both rather than keeping a third list to drift from them. The table carries a title beside the label where the strip's word is too thin to name a page on its own: "Sources" is a fine tab and a poor browser tab, so that page titles as Alert sources and the switches keep their apostrophe in the top bar. menuItem left admin.js for ui.js as menuCard, since both tabs now open on one, and its CSS went from .admin-menu* to .overview-*. That is the rename .user-link -> .row-link was in v0.18.0, for the same reason: the class was named after the first page that used it rather than after what it is. The read-only notice a member sees is now on the overview only. It explains why the controls further down are missing, and a page that is nothing but the rota grid has no controls to explain. The team picker sits above the strip, because it changes the subject of all five, and it drops the ladder draft when it moves -- an unsaved edit belongs to the team it was started in. No server change. Extensionless paths already fall back to index.html, so /team/rota survives a reload the way /admin/users/{id} does, and no endpoint, payload or permission moved. Nobody has looked at this in a browser, the caveat07914d5anda6fa673carried. What is checked is the wiring, and rather more of it than last time: every sub-page was rendered against a stub fetch and a pocket DOM, each with exactly one aria-current and fetching only the endpoints named above; and app.js itself was booted the same way and walked through all seventeen URLs the app has, which resolve to one section each with the right title -- the six new ones, the four Admin ones, both subject pages, and /incidents/42 and /nonsense still falling to the queue. Whether six entries scroll cleanly at phone width is not checked. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
749 lines
29 KiB
JavaScript
749 lines
29 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 } from './ui.js';
|
||
import { state, currentTeam, users as allUsers, myID } from './state.js';
|
||
import { isoDate, addDays, mondayOf, initial } 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.deadman(id) };
|
||
|
||
const grid = gridDays();
|
||
const [members, integrations, escalation, deadman, schedule] = await Promise.all([
|
||
api.teamMembers(id),
|
||
api.integrations(id),
|
||
api.escalation(id),
|
||
api.deadman(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?.matchers || '')
|
||
.split(';').map((x) => x.trim()).filter(Boolean).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 ? 'Alerts whose absence opens an incident.' : '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 ---------------------------------------------------
|
||
|
||
function deadmanCard() {
|
||
const d = data.deadman || {};
|
||
const matchers = h('input', {
|
||
type: 'text', value: d.matchers || '', placeholder: 'alertname=Watchdog',
|
||
class: 'wide',
|
||
});
|
||
const timeout = h('input', {
|
||
type: 'number', min: '0', class: 'setting-value',
|
||
value: String(Math.round((d.timeout_seconds || 0) / 60)),
|
||
});
|
||
const severity = h('select', {},
|
||
...['critical', 'error', 'warning', 'info'].map((s) =>
|
||
h('option', { value: s, text: s, selected: (d.severity || 'critical') === s })));
|
||
|
||
const form = h('form', { class: 'stacked-form' },
|
||
h('label', {}, 'Heartbeat alerts ', matchers),
|
||
h('label', {}, 'Declare dead after ', timeout, ' minutes of silence'),
|
||
h('label', {}, 'Open the incident at severity ', severity),
|
||
h('button', { class: 'btn', type: 'submit', text: 'Save switches' }));
|
||
|
||
form.addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
act(() => api.setDeadman(teamID, {
|
||
matchers: matchers.value.trim(),
|
||
timeout_seconds: Number(timeout.value) * 60,
|
||
severity: severity.value,
|
||
}));
|
||
});
|
||
|
||
return h('div', { class: 'card' },
|
||
h('h2', { text: 'Dead man’s switches' }),
|
||
h('p', { class: 'muted small' },
|
||
'Alerts whose ABSENCE is the signal. Receiving one opens nothing; going ',
|
||
'quiet for longer than the timeout opens an incident. ',
|
||
h('code', { text: 'alertname=Watchdog,cluster=prod; alertname=EdgeHeartbeat' }),
|
||
' — semicolons separate switches, commas separate conditions, and every ',
|
||
'switch must name an alertname. Leave empty to watch nothing.'),
|
||
isOwner() ? form : h('p', { class: 'muted', text: d.matchers || 'Nothing watched.' }),
|
||
);
|
||
}
|
||
|
||
// --- 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();
|
||
}
|