// 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, ssoBadge, SSO_MANAGED, fadeOnOverflow } from './ui.js'; import { state, currentTeam, onTeamChange, users as allUsers, myID } from './state.js'; import { isoDate, addDays, mondayOf, isoWeek, 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' }, ]; // Cached from currentTeam() on each refresh(), for the many actions below // (assignSchedule, addTeamMember, ...) that need a plain id rather than a // round trip through state. 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 // The global team selector is what changes which team this page shows now; // re-fetch under whichever sub-section is open when it fires. onTeamChange(() => { data = null; refresh(); }); 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(); } export async function refresh() { const team = currentTeam(); 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, oidcGroups] = await Promise.all([ api.teamMembers(id), allUsers(), state.auth?.oidc?.enabled ? api.oidcGroups(id) : null, ]); return { members, users, oidcGroups }; } 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() { const nav = 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, }))); // On a phone the strip overflows; bring the open section into view so a // tab past the edge (Sources, Switches) is never the one that is hidden. fadeOnOverflow(nav); requestAnimationFrame(() => nav.querySelector('[aria-current]') ?.scrollIntoView({ inline: 'center', block: 'nearest' })); return nav; } // Names which team's settings the six sections below belong to. It used to be // a picker of its own for somebody in more than one team; that job now belongs // to the global team selector in the nav, which is what onTeamChange above // reacts to. function teamPicker() { return h('h1', { class: 'detail-title', text: data.team.name }); } // --- 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 ? '1 owner, full access.' : `${owners} owners, full access.`), menuCard('/team/escalation', 'Escalation', levels || null, levels ? `${levels} escalation level${levels === 1 ? '' : 's'}${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 ${keys} never used.` : `${keys} key${keys === 1 ? '' : 's'}, all used recently.`) : 'No key yet, so nothing can reach this team.'), menuCard('/team/deadman', 'Dead man’s switches', switches || null, switches ? (dead ? `${dead} of ${switches} silent.` : [icon('checkCircle', 'icon overview-note-icon'), ' Dead man’s switch: healthy.']) : '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 a shift is drawn as one bar across its // days with the person's name on it. // // 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 dayShortFmt = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: '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(); const rows = []; for (let w = 0; w < count / 7; w++) rows.push(weekRow(addDays(start, w * 7), byDate, today, month)); const heads = [h('span', { class: 'rota-wd', title: 'ISO week number', text: 'Wk' })]; 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)) })); } // 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 until asked for. let bulk = null; if (isOwner()) { const form = h('div', { class: 'rota-bulk-form', hidden: true }, assignForm()); const toggle = h('button', { class: 'btn', type: 'button', 'aria-expanded': 'false', onclick: () => { form.hidden = !form.hidden; toggle.setAttribute('aria-expanded', String(!form.hidden)); }, }, icon('calendar'), 'Assign a range of days'); bulk = [ h('div', { class: 'rota-foot' }, h('span', { class: 'muted small', text: 'Tap a day to change who holds it.' }), toggle), form, ]; } const thisMonth = isoDate(monthStart) === isoDate(firstOfMonth(new Date())); return [ h('div', { class: 'page-head' }, h('h2', { text: 'On-call rota' }), h('div', { class: 'week-nav' }, h('button', { class: 'btn btn-icon week-arrow', type: 'button', 'aria-label': 'Previous month', onclick: () => shiftMonth(-1), }, icon('chevronLeft')), h('span', { class: 'month-label', text: monthFmt.format(monthStart) }), h('button', { class: 'btn btn-icon week-arrow', type: 'button', 'aria-label': 'Next month', onclick: () => shiftMonth(1), }, icon('chevronRight')), h('button', { class: 'btn btn-sm', type: 'button', text: 'Today', disabled: thisMonth, onclick: () => { monthStart = firstOfMonth(new Date()); refresh(); }, }), ), ), h('div', { class: 'card' }, h('div', { class: 'rota-grid' }, h('div', { class: 'rota-heads' }, heads), rows), coverNote(byDate), bulk, ), ]; } // One Monday-to-Sunday row: the week number, a tappable cell per day, and the // shifts drawn over the days as bars. A bar is a run of consecutive days held // by the same person, so a week handed to one person is one bar with one name // on it. The bars ignore the pointer, so a tap lands on the day underneath. function weekRow(monday, byDate, today, month) { const days = Array.from({ length: 7 }, (_, i) => addDays(monday, i)); const keys = days.map(isoDate); const entries = keys.map((k) => byDate.get(k) || null); const uid = (i) => (entries[i] ? entries[i].user_id : null); const before = byDate.get(isoDate(addDays(monday, -1))); const after = byDate.get(isoDate(addDays(monday, 7))); const cells = [weekCell(monday, byDate, today)]; days.forEach((d, i) => { const cell = dayCell(d, keys[i], entries[i], d.getMonth() === month, today); cell.style.gridColumn = String(i + 2); cells.push(cell); }); for (let i = 0; i < 7;) { let j = i; while (j + 1 < 7 && uid(j + 1) === uid(i)) j++; const entry = entries[i]; // A flat end says the shift carries on past this row, not that it stops. const contL = !!entry && i === 0 && before?.user_id === entry.user_id; const contR = !!entry && j === 6 && after?.user_id === entry.user_id; cells.push(shiftBar(entry, i, j, { days, keys, month, today, contL, contR })); i = j + 1; } return h('div', { class: 'rota-row' }, cells); } function shiftBar(entry, i, j, o) { const span = j - i + 1; const outside = o.days.slice(i, j + 1).every((d) => d.getMonth() !== o.month); const cls = ['rota-bar', entry ? colorClass(entry.user_id) : 'gap', outside && 'outside', o.keys[j] < o.today && 'past', o.contL && 'cont-l', o.contR && 'cont-r'] .filter(Boolean).join(' '); // One day is too narrow for a name on a phone; the initial stands in and the // full name is in the day's own label underneath. const name = entry ? `${entry.username}${entry.user_id === myID() ? ' (you)' : ''}` : (span >= 3 ? 'Nobody on call' : ''); const bar = h('div', { class: cls, 'aria-hidden': 'true', title: name || 'Nobody on call', text: entry && span === 1 ? initial(entry.username) : name, }); bar.style.gridColumn = `${i + 2} / span ${span}`; return bar; } // The ISO week number at the start of a row. For an owner it is a button: one // tap fills the week, which is the way a rota is usually handed out — a person // takes a week, not seven separate days. function weekCell(monday, byDate, today) { const n = isoWeek(monday); const current = isoDate(monday) <= today && today < isoDate(addDays(monday, 7)); const cls = `rota-week${current ? ' current' : ''}`; const label = `Week ${n}`; return isOwner() ? h('button', { class: cls, type: 'button', text: String(n), title: `${label} · assign somebody for the whole week`, 'aria-label': label, onclick: () => weekSheet(monday, byDate), }) : h('div', { class: cls, title: label, text: String(n) }); } function dayCell(d, key, e, inMonth, today) { const cls = ['rota-day', !inMonth && 'outside', key === today && 'today'] .filter(Boolean).join(' '); const label = `${key} · ${e ? e.username : 'nobody'}`; const body = [h('span', { class: 'rota-num', text: String(d.getDate()) })]; // 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}`; } // The gap count, which is the one thing the bars state 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('div', { class: 'rota-banner ok' }, icon('checkCircle'), h('span', { text: 'Every day left this month has somebody on call.' })); } return h('div', { class: 'rota-banner warn', role: 'status' }, icon('alertTriangle'), h('span', {}, h('strong', { text: gaps === 1 ? '1 day' : `${gaps} days` }), ' left this month with nobody on call.')); } // One week, in the sheet: who holds each day of it, and one person to put on // all of them. Days already gone are left alone — who was on call last Tuesday // is a fact, and "the whole week" should not rewrite it — and the week's // overhang into the next month is included, since it is the same week. function weekSheet(monday, byDate) { const today = isoDate(new Date()); const days = Array.from({ length: 7 }, (_, i) => addDays(monday, i)); const keys = days.map(isoDate); const ahead = keys.filter((k) => k >= today); const range = `${dayShortFmt.format(days[0])} – ${dayShortFmt.format(days[6])}`; const who = memberSelect(); const onlyEmpty = h('input', { type: 'checkbox' }); const problem = h('p', { class: 'load-error', hidden: true }); const holders = h('div', { class: 'week-holders' }, days.map((d, i) => { const e = byDate.get(keys[i]); return h('span', { class: `week-holder${keys[i] < today ? ' past' : ''}`, title: `${keys[i]} · ${e ? e.username : 'nobody'}`, }, h('span', { class: 'rota-num', text: weekdayFmt.format(d) }), e ? h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) }) : h('span', { class: 'rota-chip none' })); })); openSheet(() => [ h('h2', { class: 'sheet-title', text: `Week ${isoWeek(monday)}` }), h('p', { class: 'sheet-text', text: range }), holders, ahead.length ? [ h('label', { class: 'sheet-pick' }, 'On call ', who), h('label', { class: 'checkbox' }, onlyEmpty, ' Only fill days nobody has yet'), h('p', { class: 'muted small' }, ahead.length < 7 ? `Days already past are left alone, so this covers the ${ahead.length} still to come. ` : '', 'Anybody already on those days is replaced unless you tick the box.'), ] : h('p', { class: 'muted', text: 'This whole week is already over.' }), problem, h('div', { class: 'sheet-actions' }, h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet() }), ahead.length > 0 && h('button', { class: 'btn btn-primary', type: 'button', autofocus: true, text: 'Assign week', onclick: () => { const dates = onlyEmpty.checked ? ahead.filter((k) => !byDate.has(k)) : ahead; if (!who.value) { problem.textContent = 'There is nobody in this team to assign.'; problem.hidden = false; return; } if (!dates.length) { problem.textContent = 'Every day still to come already has somebody.'; problem.hidden = false; return; } closeSheet(); act(() => api.assignSchedule(teamID, Number(who.value), dates, !onlyEmpty.checked)); }, }), ), ]); } // 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 btn-primary', 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 || []; // The ladder as steps, top to bottom: each level, the wait before the next, // and what happens when the last one is not answered. The wait belongs to the // level above it ("then after 5m"), which is how the API states it. const steps = []; levels.forEach((l) => { steps.push(h('li', { class: 'step' }, h('div', { class: 'step-rail' }, h('span', { class: `step-node st-${l.status}`, text: String(l.position) }), h('span', { class: 'step-line' })), h('div', { class: 'step-body' }, h('div', { class: 'step-top' }, h('strong', { text: `Level ${l.position}` }), levelBadge(l.status)), h('div', { class: 'step-pages' }, ...l.targets.map(targetLine)), l.waiting?.length > 0 && h('div', { class: 'step-waiting' }, 'Waiting now: ', ...l.waiting.flatMap((id, i) => [i > 0 && ', ', h('a', { href: `/incidents/${id}`, text: `#${id}` })]))))); steps.push(h('li', { class: 'step step-wait' }, h('div', { class: 'step-rail' }, h('span', { class: 'step-line' })), h('div', { class: 'step-gap' }, icon('clock', 'icon'), `then after ${duration(l.timeout_seconds * 1000)}`))); }); if (levels.length) { steps.push(h('li', { class: 'step' }, h('div', { class: 'step-rail' }, h('span', { class: `step-node end${esc.fallback_topic ? '' : ' warn'}` }, icon(esc.fallback_topic ? 'bell' : 'flag', 'icon'))), h('div', { class: 'step-body' }, h('div', { class: 'step-top' }, h('strong', { text: esc.fallback_topic ? 'Fallback topic' : 'End of the chain' })), esc.fallback_topic ? h('div', { class: 'step-pages' }, h('code', { text: esc.fallback_topic }), ' is paged once.') : h('div', { class: 'callout', role: 'status' }, icon('alertTriangle', 'icon'), h('div', {}, h('strong', { text: 'No fallback topic' }), h('p', { text: 'After the last level the chain just ends and nobody else is woken.' }), isOwner() && h('button', { class: 'btn btn-sm', type: 'button', onclick: () => openLadderEditor({ focusFallback: true }), }, icon('plus'), 'Add fallback')))))); } const facts = []; if (levels.length) { const n = esc.repeat_count || 0; facts.push(n ? `Then the whole ladder repeats ${n} more ${n === 1 ? 'time' : 'times'}.` : 'Nothing repeats: the ladder runs once.'); 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 btn-primary', 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('ol', { class: 'ladder' }, steps) : h('p', { class: 'muted' }, 'No ladder. An unacknowledged incident re-pages the same person every ', 'reminder interval and nobody else is woken.'), facts.length > 0 && h('div', { class: 'ladder-facts' }, ...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({ focusFallback = false } = {}) { 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' }); let fallbackInput = null; 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 = fallbackInput = 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), ]); if (focusFallback) requestAnimationFrame(() => fallbackInput?.focus()); } 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) => clickableRow(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) })), ), () => openSourceDetail(i))); return h('div', { class: 'card' }, h('div', { class: 'card-head' }, h('h2', { text: 'Alert sources' }), isOwner() && h('button', { class: 'btn btn-primary', 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: 'Name' }), h('th', { text: 'Last webhook' }), h('th', { text: 'Last alert' }), h('th', { class: 'num', text: 'Alerts 24h' }), h('th', { text: 'Created' }))), 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 openSourceDetail(i) { openDetailSheet(i.name, [ sheetFact('Status', sourceBadge(i.status)), sheetFact('Kind', i.kind), sheetFact('Last webhook', timeCell(i.last_used_at)), sheetFact('Last alert', timeCell(i.last_alert_at)), sheetFact('Alerts, 24h', String(i.alerts_24h ?? 0)), sheetFact('Created', h('span', { title: when(i.created_at), text: ago(i.created_at) })), ], isOwner() && h('button', { class: 'btn', type: 'button', text: 'Rename', onclick: () => { closeSheet(); openRenameSource(i); }, }), isOwner() && h('button', { class: 'btn btn-danger', type: 'button', text: 'Revoke', onclick: async () => { closeSheet(); 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)); }, })); } 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) }); }; // A list row that opens its details: the list is for finding the thing, the // sheet it opens is where the buttons are. Links inside the row keep working. function clickableRow(tr, open) { tr.classList.add('clickable'); tr.tabIndex = 0; tr.addEventListener('click', (e) => { if (!e.target.closest('a')) open(); }); tr.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target === tr) open(); }); return tr; } // Label/value rows for a details sheet, and the sheet itself: facts above, then // Close and whatever the viewer may do. Falsy actions (a non-owner's) drop out. const sheetFact = (label, value) => [h('dt', { text: label }), h('dd', {}, value)]; function openDetailSheet(title, facts, ...actions) { openSheet(() => [ h('h2', { class: 'sheet-title', text: title }), h('dl', { class: 'switch-facts' }, facts), h('div', { class: 'sheet-actions' }, h('button', { class: 'btn', type: 'button', text: 'Close', onclick: () => closeSheet() }), ...actions), ]); } 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) }), ); clickableRow(main, () => openSwitchDetail(sw)); // 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'))) : []; 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 btn-primary', type: 'button', text: 'New switch', onclick: () => openSwitchForm(), })), 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: 'Name' }), h('th', { text: 'Last heartbeat' }), h('th', { text: 'Last triggered' }), h('th', { text: 'Silent after' }))), 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 openSwitchForm(existing) { 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', step: 'any', value: '15', class: 'setting-value', required: true, }); const severity = h('select', {}, ...['critical', 'error', 'warning', 'info'].map((s) => h('option', { value: s, text: s }))); if (existing) { name.value = existing.name; matcher.value = existing.matcher; timeout.value = String(existing.timeout_seconds / 60); severity.value = existing.severity; } 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: existing ? 'Save' : 'Add switch' }))); form.addEventListener('submit', async (e) => { e.preventDefault(); try { const body = { name: name.value.trim(), matcher: matcher.value.trim(), timeout_seconds: Math.round(Number(timeout.value) * 60), severity: severity.value, }; await (existing ? api.updateDeadmanSwitch(teamID, existing.id, body) : api.createDeadmanSwitch(teamID, body)); } catch (err) { problem.textContent = err.message; problem.hidden = false; return; } closeSheet(true); refresh(); }); openSheet(() => [ h('h2', { class: 'sheet-title', text: existing ? 'Edit switch' : 'New switch' }), form]); } // What the list row has no room for, and where Edit and Delete live. function openSwitchDetail(sw) { const sources = sw.sources.length > 1 ? [sheetFact('Sources', h('div', {}, ...sw.sources.map((src) => h('div', { class: 'source-labels' }, switchBadge(src.status), ...Object.entries(src.labels || {}) .filter(([k]) => k !== 'alertname') .map(([k, v]) => labelChip(k, v)), h('span', { class: 'muted small' }, ' ', timeCell(src.last_heartbeat_at))))))] : []; openDetailSheet(sw.name, [ sheetFact('Status', switchBadge(sw.status)), sheetFact('Matcher', h('code', { text: sw.matcher })), sheetFact('Silent after', duration(sw.timeout_seconds * 1000)), sheetFact('Severity', sw.severity), sheetFact('Last heartbeat', timeCell(sw.last_heartbeat_at)), sheetFact('Last triggered', triggeredCell(sw.last_triggered_at, sw.open_incident_id)), sources, ], isOwner() && h('button', { class: 'btn', type: 'button', text: 'Edit', onclick: () => { closeSheet(); openSwitchForm(sw); }, }), isOwner() && h('button', { class: 'btn btn-danger', type: 'button', text: 'Delete', onclick: async () => { closeSheet(); 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)); }, })); } // --- members --------------------------------------------------------------- const MEMBER_STATUS = { oncall: { label: 'On call', hint: 'The rota has them today.' }, reachable: { label: 'Reachable', hint: 'Has an ntfy topic, so a page would reach them.' }, unpageable: { label: 'Can’t be paged', hint: 'A page to them would go nowhere.' }, }; const memberBadge = (m) => { const el = statusBadge(MEMBER_STATUS, m.status, 'reachable'); if (m.problem) el.title = `${MEMBER_STATUS.unpageable.hint} ${m.problem}.`; return el; }; // Rota days are UTC dates with no time in them; formatting one in the viewer's // zone could show the day before. const shiftFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC', }); const shiftDay = (ymd) => shiftFmt.format(new Date(`${ymd}T00:00:00Z`)); function shiftCell(m) { if (m.on_call) { return h('span', { text: m.next_shift ? `today, then ${shiftDay(m.next_shift)}` : 'today' }); } return m.next_shift ? h('span', { text: shiftDay(m.next_shift) }) : h('span', { text: 'not scheduled' }); } // The team's own OIDC group binding, shown only on an SSO-enabled install: // which group grants membership and which grants ownership. Read-only text // for a member, an edit sheet for an owner — the server enforces the same // split on the endpoint underneath. function oidcGroupsCard() { if (!state.auth?.oidc?.enabled) return null; const g = data.oidcGroups || { member_group: '', owner_group: '' }; // With nothing set, Edit is the one thing to do here, so it leads. const unset = !g.member_group && !g.owner_group; return h('div', { class: 'card' }, h('div', { class: 'card-head' }, h('h2', { text: 'Single sign-on' }), isOwner() && h('button', { class: unset ? 'btn btn-primary' : 'btn', type: 'button', text: 'Edit', onclick: openOidcGroupsEditor, })), h('p', { class: 'muted small' }, 'Members of the group below are added to this team automatically at ', 'sign-in; members of the owner group become owners. Leave a field ', 'blank to grant nothing this way.'), h('dl', { class: 'user-facts' }, fact('Member group', g.member_group || notConfigured()), fact('Owner group', g.owner_group || notConfigured()), ), ); } const notConfigured = () => h('span', { class: 'muted', text: 'Not configured' }); function fact(label, value) { return [h('dt', { text: label }), h('dd', { text: value })]; } function openOidcGroupsEditor() { const g = data.oidcGroups || { member_group: '', owner_group: '' }; const memberGroup = h('input', { type: 'text', value: g.member_group, placeholder: 'e.g. sre', autofocus: true, }); const ownerGroup = h('input', { type: 'text', value: g.owner_group, placeholder: 'e.g. sre-leads' }); const problem = h('p', { class: 'load-error', hidden: true }); const form = h('form', { class: 'stacked-form' }, h('label', {}, 'Member group ', memberGroup), h('label', {}, 'Owner group ', ownerGroup), h('p', { class: 'muted small' }, 'A person in both becomes an owner. Whoever the group lists is kept in ', 'sync at their next sign-in — a member added by hand can still be made ', 'an owner, but not the other way round.'), 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: 'Save' }))); form.addEventListener('submit', async (e) => { e.preventDefault(); try { await api.setOidcGroups(teamID, { member_group: memberGroup.value.trim(), owner_group: ownerGroup.value.trim(), }); } catch (err) { problem.textContent = err.message; problem.hidden = false; return; } closeSheet(true); refresh(); }); openSheet(() => [h('h2', { class: 'sheet-title', text: 'Single sign-on groups' }), form]); } function membersCard() { const members = data.members || []; const owners = members.filter((m) => m.role === 'owner').length; const rows = members.map((m) => clickableRow(h('tr', {}, h('td', {}, memberBadge(m)), h('td', { class: 'wrap' }, h('strong', { text: m.username }), m.user_id === myID() && h('span', { class: 'muted small', text: ' (you)' }), m.problem && h('div', { class: 'target-problem', text: m.problem })), h('td', { class: 'muted small' }, m.role, m.source === 'oidc' && ssoBadge()), h('td', { class: 'muted small' }, shiftCell(m)), h('td', { class: 'muted small' }, timeCell(m.last_active_at)), h('td', { class: 'muted small' }, h('span', { title: when(m.joined_at), text: ago(m.joined_at) })), ), () => openMemberDetail(m, owners))); return [oidcGroupsCard(), h('div', { class: 'card' }, h('div', { class: 'card-head' }, h('h2', { text: 'Members' }), isOwner() && h('button', { class: 'btn btn-primary', type: 'button', text: 'Add member', onclick: openAddMember, })), h('p', { class: 'muted small' }, 'Owners set up the team; members work its incidents. Somebody who can’t be ', 'paged is worth fixing before their next shift.'), members.length ? h('div', { class: 'table-scroll' }, h('table', { class: 'admin-table status-table' }, h('thead', {}, h('tr', {}, h('th', { text: 'Status' }), h('th', { text: 'Name' }), h('th', { text: 'Role' }), h('th', { text: 'Rota' }), h('th', { text: 'Last active' }), h('th', { text: 'Joined' }))), h('tbody', {}, rows))) : h('p', { class: 'muted', text: 'Nobody is in this team.' }), )]; } function openMemberDetail(m, owners) { const lastOwner = m.role === 'owner' && owners === 1; openDetailSheet(m.username, [ sheetFact('Status', memberBadge(m)), sheetFact('Role', h('span', {}, m.role, m.source === 'oidc' && ssoBadge())), sheetFact('Rota', shiftCell(m)), sheetFact('Last active', timeCell(m.last_active_at)), sheetFact('Joined', h('span', { title: when(m.joined_at), text: ago(m.joined_at) })), m.problem && sheetFact('Problem', h('span', { class: 'target-problem', text: m.problem })), ], isOwner() && h('button', { class: 'btn', type: 'button', text: 'Edit', // The server refuses to edit a membership the groups grant. disabled: m.source === 'oidc', title: m.source === 'oidc' ? SSO_MANAGED : null, onclick: () => { closeSheet(); openEditMember(m); }, }), isOwner() && h('button', { class: 'btn btn-danger', type: 'button', text: 'Remove', disabled: lastOwner || m.source === 'oidc', title: m.source === 'oidc' ? SSO_MANAGED : lastOwner ? 'A team needs an owner. Make somebody else one first.' : null, onclick: async () => { closeSheet(); if (!(await confirm({ title: `Remove ${m.username}?`, text: 'They lose access to this team. Rota days already assigned to them are not ' + 'changed, so reassign those from the Rota tab.', confirmLabel: 'Remove', danger: true, }))) return; act(() => api.removeTeamMember(teamID, m.user_id)); }, })); } // One sheet for both jobs a member's row has: who, and as what. Adding is // choosing a person and a role; editing is the same with the person fixed. The // API is one call either way — POST upserts the role. function openMemberSheet({ title, submit, person, role, run }) { const roleSelect = h('select', {}, ...['member', 'owner'].map((r) => h('option', { value: r, text: r, selected: r === role }))); const problem = h('p', { class: 'load-error', hidden: true }); const form = h('form', { class: 'stacked-form' }, person.label, h('label', {}, 'Role ', roleSelect), 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(person.userID(), roleSelect.value); } catch (err) { problem.textContent = err.message; problem.hidden = false; return; } closeSheet(true); refresh(); }); openSheet(() => [h('h2', { class: 'sheet-title', text: title }), form]); } function openAddMember() { 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 }))); openMemberSheet({ title: 'Add member', submit: 'Add member', role: 'member', person: { label: candidates.length ? h('label', {}, 'Person ', pick) : h('p', { class: 'muted', text: 'Everybody with an account is already in this team.' }), userID: () => Number(pick.value), }, run: (userID, role) => { if (!userID) throw new Error('Nobody to add'); return api.addTeamMember(teamID, userID, role); }, }); } function openEditMember(m) { openMemberSheet({ title: `Edit ${m.username}`, submit: 'Save', role: m.role, person: { label: h('p', { class: 'muted small', text: m.username }), userID: () => m.user_id }, run: (userID, role) => api.addTeamMember(teamID, userID, role), }); } // --- 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(); }