// 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 } from './ui.js'; import { state, currentTeam, users as allUsers } from './state.js'; import { isoDate, addDays } 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; 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(new Date()), isoDate(addDays(new Date(), 30))), 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. function scheduleCard() { const rows = (data.schedule || []).map((e) => h('tr', {}, h('td', { text: e.date }), h('td', {}, h('strong', { text: e.username })), h('td', {}, isOwner() && h('button', { class: 'btn-sm danger', type: 'button', text: 'Clear', onclick: () => act(() => api.unassignSchedule(teamID, e.id)), })), )); return h('div', { class: 'card' }, h('h2', { text: 'On-call rota' }), h('p', { class: 'muted small', text: 'One person per UTC day, for the next 30 days.' }), rows.length ? h('table', { class: 'admin-table' }, h('tbody', {}, rows)) : h('p', { class: 'muted', text: 'Nobody is scheduled.' }), isOwner() && assignForm(), ); } function assignForm() { const who = memberSelect(); const from = h('input', { type: 'date', required: true, value: isoDate(new Date()) }); 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(); }