// Team settings: the rota, who is in the team, where its alerts come from, // what it escalates through, and which of its alerts are heartbeats. // // Everything here was API-only until now, which meant a team owner had to use // curl to set up escalation — the feature this whole line of work exists for. // // 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 } 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'); let teamID = null; let data = null; // { team, members, integrations, escalation, deadman, schedule, users } let error = null; let freshKey = null; // an integration key, shown once, until the view is left export function show() { if (!data) clear(view(), 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; const grid = gridDays(); try { // A member may read all of this; only the writes are owner-only. const [members, integrations, escalation, deadman, schedule, users] = await Promise.all([ api.teamMembers(team.id), api.integrations(team.id), api.escalation(team.id), api.deadman(team.id), api.schedule(team.id, isoDate(grid.start), isoDate(addDays(grid.start, grid.count - 1))), allUsers(), ]); data = { team, members, integrations, escalation, deadman, schedule, users }; error = null; } catch (err) { error = err.message; } render(); } 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(), error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }), teamPicker(), !isOwner() && h('div', { class: 'card' }, h('p', { class: 'muted small', text: 'You are a member of this team. Only an owner can change its settings.' })), scheduleCard(), escalationCard(), integrationsCard(), deadmanCard(), membersCard(), ); } // Only shown to somebody in more than one team, like the queue's filter chips. 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; show(); }); return h('div', { class: 'card' }, h('h2', { text: 'Team' }), select); } // --- 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(); }