// 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. if (next !== tab) { tab = next; data = 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; 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 ------------------------------------------------------------ const LEVEL_STATUS = { ready: { label: 'Ready', hint: 'Somebody here can be woken.' }, escalating: { label: 'Escalating', hint: 'An unanswered incident has climbed to this level.' }, unreachable: { label: 'Pages nobody', hint: 'Nobody on this level can be woken right now.' }, }; const levelBadge = (status) => statusBadge(LEVEL_STATUS, status, 'ready'); // One target as the list shows it: who it means today, and why it would not // wake them if it would not. function targetLine(t) { const label = t.kind === 'oncall' ? `On call${t.username ? ` · ${t.username}` : ''}` : (t.username || 'Unknown person'); return h('div', { class: 'target-line' }, h('span', { text: label }), t.problem && h('span', { class: 'target-problem', text: t.problem })); } function escalationCard() { const esc = data.escalation || {}; const levels = esc.levels || []; const rows = levels.map((l) => h('tr', {}, h('td', {}, h('strong', { text: `Level ${l.position}` })), h('td', {}, levelBadge(l.status)), h('td', { class: 'wrap' }, ...l.targets.map(targetLine)), h('td', { class: 'muted small', text: duration(l.timeout_seconds * 1000) }), h('td', { class: 'small' }, l.waiting?.length ? l.waiting.flatMap((id, i) => [i > 0 && ', ', h('a', { href: `/incidents/${id}`, text: `#${id}` })]) : h('span', { class: 'muted', text: '—' })), )); const facts = []; if (levels.length) { const n = esc.repeat_count || 0; if (n) facts.push(`Then the whole ladder repeats ${n} more ${n === 1 ? 'time' : 'times'}.`); facts.push(esc.fallback_topic ? ['Finally the ntfy topic ', h('code', { text: esc.fallback_topic }), ' is paged once.'] : 'No fallback topic: after the last level the chain just ends.'); facts.push(esc.last_escalated_at ? ['Last escalated ', h('span', { title: when(esc.last_escalated_at), text: ago(esc.last_escalated_at) }), ' on ', h('a', { href: `/incidents/${esc.last_escalated_incident_id}`, text: `#${esc.last_escalated_incident_id}` }), '.'] : 'Nothing has needed to escalate yet.'); } return h('div', { class: 'card' }, h('div', { class: 'card-head' }, h('h2', { text: 'Escalation' }), isOwner() && h('button', { class: 'btn', type: 'button', onclick: openLadderEditor, text: levels.length ? 'Edit ladder' : 'Set up ladder', })), 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.'), levels.length ? h('div', { class: 'table-scroll' }, h('table', { class: 'admin-table status-table' }, h('thead', {}, h('tr', {}, h('th', { text: 'Level' }), h('th', { text: 'Status' }), h('th', { text: 'Pages' }), h('th', { text: 'Then after' }), h('th', { text: 'Waiting now' }))), h('tbody', {}, rows))) : h('p', { class: 'muted' }, 'No ladder. An unacknowledged incident re-pages the same person every ', 'reminder interval and nobody else is woken.'), ...facts.map((f) => h('p', { class: 'muted small' }, f)), ); } // 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. The draft lives in the sheet, so a poll of // the page underneath cannot throw away half an edit. function openLadderEditor() { const esc = data.escalation || {}; const 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 = h('div', { class: 'ladder-editor' }); const problem = h('p', { class: 'load-error', hidden: true }); const paint = () => { const parts = []; if (!draft.levels.length) { parts.push(h('p', { class: 'muted small' }, 'No levels yet. Add the first one.')); } draft.levels.forEach((level, i) => { parts.push(h('div', { class: 'ladder-level' }, h('div', { class: 'ladder-head' }, h('strong', { text: `Level ${i + 1}` }), h('button', { class: 'btn-sm danger', type: 'button', text: 'Remove', onclick: () => { draft.levels.splice(i, 1); paint(); }, })), 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, paint)), h('button', { class: 'btn-sm', type: 'button', text: '+ target', onclick: () => { level.targets.push({ kind: 'oncall' }); paint(); }, })), )); }); parts.push(h('button', { class: 'btn-sm', type: 'button', text: '+ level', onclick: () => { draft.levels.push({ timeout_seconds: 300, targets: [{ kind: 'oncall' }] }); paint(); }, })); 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; }, }); parts.push(h('label', {}, 'Repeat the whole ladder ', repeat, ' more times')); parts.push(h('label', {}, 'Then page this ntfy topic once ', fallback)); clear(body, ...parts); }; paint(); const save = h('button', { class: 'btn btn-primary', type: 'button', text: 'Save ladder' }); save.addEventListener('click', async () => { try { await api.setEscalation(teamID, draft); } catch (err) { problem.textContent = err.message; problem.hidden = false; return; } closeSheet(true); refresh(); }); openSheet(() => [ h('h2', { class: 'sheet-title', text: 'Edit ladder' }), body, problem, h('div', { class: 'sheet-actions' }, h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }), save), ]); } function targetRow(level, target, index, repaint) { 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; repaint(); }); 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, h('button', { class: 'btn-sm danger', type: 'button', text: '×', title: 'Remove this target', onclick: () => { level.targets.splice(index, 1); repaint(); }, })); } 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', {}, sourceBadge(i.status)), h('td', { class: 'wrap' }, h('strong', { text: i.name }), h('div', { class: 'muted small', text: i.kind })), // When the key last posted, and when an alert last arrived on it. They // differ: a payload with nothing usable in it stamps only the first. h('td', { class: 'muted small' }, timeCell(i.last_used_at)), h('td', { class: 'muted small' }, timeCell(i.last_alert_at)), h('td', { class: 'muted small num', title: 'Distinct alerts refreshed in the last 24 hours', text: String(i.alerts_24h ?? 0) }), h('td', { class: 'muted small' }, h('span', { title: when(i.created_at), text: ago(i.created_at) })), h('td', {}, isOwner() && h('div', { class: 'row-actions' }, h('button', { class: 'btn-sm', type: 'button', text: 'Rename', onclick: () => openRenameSource(i), }), 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. Alerts it already delivered stay.', confirmLabel: 'Revoke', danger: true, }))) return; act(() => api.deleteIntegration(teamID, i.id)); }, }))), )); return h('div', { class: 'card' }, h('div', { class: 'card-head' }, h('h2', { text: 'Alert sources' }), isOwner() && h('button', { class: 'btn', type: 'button', text: 'New source', onclick: openNewSource, })), 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.'), freshKey && newKeyPanel(), rows.length ? h('div', { class: 'table-scroll' }, h('table', { class: 'admin-table status-table' }, h('thead', {}, h('tr', {}, h('th', { text: 'Status' }), h('th', { text: 'Source' }), h('th', { text: 'Last webhook' }), h('th', { text: 'Last alert' }), h('th', { class: 'num', text: 'Alerts 24h' }), h('th', { text: 'Created' }), h('th'))), h('tbody', {}, rows))) : h('p', { class: 'muted', text: 'No alert source yet, so nothing can reach this team.' }), ); } // 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(); }, }), ); } // A sheet with one name field, for adding a source and for renaming one: the two // differ only in what they call and what they put in the box. function openNameSheet({ title, submit, value, run }) { const name = h('input', { type: 'text', placeholder: 'prod alertmanager', required: true, value, autofocus: true, }); const problem = h('p', { class: 'load-error', hidden: true }); const form = h('form', { class: 'stacked-form' }, h('label', {}, 'Name ', name), 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: submit }))); form.addEventListener('submit', async (e) => { e.preventDefault(); try { await run(name.value.trim()); } catch (err) { problem.textContent = err.message; problem.hidden = false; return; } closeSheet(true); refresh(); }); openSheet(() => [h('h2', { class: 'sheet-title', text: title }), form]); } function openNewSource() { openNameSheet({ title: 'New source', submit: 'Add source', value: '', // The key comes back once, and the card shows it until dismissed. run: async (name) => { freshKey = await api.createIntegration(teamID, name); }, }); } function openRenameSource(i) { openNameSheet({ title: `Rename ${i.name}`, submit: 'Rename', value: i.name, run: (name) => api.renameIntegration(teamID, i.id, name), }); } // --- dead man's switches --------------------------------------------------- // Status badges, shared by the Sources and Switches lists: a table of label and // hint per status, and one function to draw it. Module-level, so the two cards // can be defined in either order. 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.' }, }; const SOURCE_STATUS = { active: { label: 'Active', hint: 'Posted within the last day.' }, quiet: { label: 'Quiet', hint: 'Has posted, but not in the last day. Nothing firing is a fine reason.' }, never: { label: 'Never used', hint: 'Nothing has been posted with this key yet.' }, }; function statusBadge(table, status, fallback) { const s = table[status] || table[fallback]; const el = badge(s.label, `st-${status}`); el.title = s.hint; return el; } const switchBadge = (status) => statusBadge(SWITCH_STATUS, status, 'dormant'); const sourceBadge = (status) => statusBadge(SOURCE_STATUS, status, 'never'); 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', { class: 'wrap' }, 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 status-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) { try { await fn(); error = null; } catch (err) { error = err.message; } await refresh(); }