diff --git a/README.md b/README.md index b1102c4..86ce339 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,16 @@ How a browser stays signed in: With `TERDUT_PUBLIC_URL` set, tapping a push notification opens the incident in the web UI (`/incidents/{id}`). +A **Team** tab holds everything a team owns: the on-call rota, the escalation +ladder, the alert sources with their keys, the dead man's switches and the +membership. An owner edits it; a member sees the same page read-only, because +the server refuses their writes anyway. Somebody in more than one team picks +between them at the top. + +The **Admin** tab appears only for a system administrator, and holds what +belongs to the whole server rather than to one team: every team, every user, and +the settings that used to be environment variables. + ### Docker ```bash diff --git a/internal/api/incident_store.go b/internal/api/incident_store.go index d065ea7..955f9ca 100644 --- a/internal/api/incident_store.go +++ b/internal/api/incident_store.go @@ -52,6 +52,13 @@ type querier interface { const incidentSelectFrom = ` SELECT i.id, i.team_id, t.name, i.group_key, i.title, i.group_labels, i.status, i.severity, + i.escalation_level, + -- When this level runs out. Computed here rather than in Go because + -- the timeout lives beside the level in the policy, and one join is + -- cheaper than a second query per incident in a list. + (SELECT i.escalation_level_at + el.timeout_seconds + FROM escalation_levels el + WHERE el.team_id = i.team_id AND el.position = i.escalation_level), i.triggered_at, i.acknowledged_by, i.acknowledged_at, ack.username, i.assigned_to, asg.username, i.snoozed_until, @@ -65,10 +72,11 @@ func scanIncident(s scanner) (models.Incident, error) { var i models.Incident var groupLabelsJSON string var triggeredAt int64 - var ackAt, snoozedUntil, resolvedAt, archivedAt *int64 + var ackAt, snoozedUntil, resolvedAt, archivedAt, escalationDue *int64 if err := s.Scan( &i.ID, &i.TeamID, &i.TeamName, &i.GroupKey, &i.Title, &groupLabelsJSON, &i.Status, &i.Severity, + &i.EscalationLevel, &escalationDue, &triggeredAt, &i.AcknowledgedByID, &ackAt, &i.AcknowledgedByUser, &i.AssignedToID, &i.AssignedToUser, &snoozedUntil, @@ -83,6 +91,7 @@ func scanIncident(s scanner) (models.Incident, error) { i.SnoozedUntil = unixPtr(snoozedUntil) i.ResolvedAt = unixPtr(resolvedAt) i.ArchivedAt = unixPtr(archivedAt) + i.EscalationDueAt = unixPtr(escalationDue) return i, nil } diff --git a/internal/models/incident.go b/internal/models/incident.go index 91dc89e..bacb4b6 100644 --- a/internal/models/incident.go +++ b/internal/models/incident.go @@ -11,6 +11,13 @@ import "time" // the webhook and the sweeper may flip to "resolved" once every member alert has // stopped firing. type Incident struct { + // EscalationLevel is which rung of its team's ladder this incident is on, + // 0 for none — either the team has no ladder, or somebody has answered. + // EscalationDueAt is when the current level runs out, so a client can say + // how long is left rather than only what already happened. + EscalationLevel int64 `json:"escalation_level"` + EscalationDueAt *time.Time `json:"escalation_due_at,omitempty"` + // TeamID is the team that owns this incident, fixed when it opens: an // incident never moves between teams. TeamName rides along so the combined // queue can badge each row without a second request. diff --git a/internal/web/static/app.css b/internal/web/static/app.css index c9bdd6e..50e96c2 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -657,3 +657,33 @@ kbd { .admin-settings .setting-unit { max-width: 8em; } .admin-settings button[type="submit"] { margin-top: 12px; } .small { font-size: 13px; } + +/* --- team settings ------------------------------------------------------- + Forms with a label above each control, rather than the queue's rows of + links. The escalation ladder is the only nested structure in the app, so it + gets a little indentation to make the levels read as an order. */ +.stacked-form { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; align-items: flex-start; } +.stacked-form label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; } +.stacked-form label.checkbox { gap: 8px; } +.stacked-form input.wide { min-width: min(420px, 100%); } +.team-picker { margin-top: 8px; max-width: 100%; } + +.ladder-level { + border-left: 3px solid var(--border-strong); + padding: 8px 0 8px 12px; margin: 12px 0; +} +.ladder-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; } +.ladder-targets { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; } +.target-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } + +/* An integration key is shown exactly once, so it should look like something + to act on rather than another row of text. */ +.key-panel { + margin-top: 12px; padding: 12px; + border: 1px solid var(--accent); border-radius: 8px; background: var(--accent-soft); +} +.key-panel pre { + overflow-x: auto; background: var(--surface); border: 1px solid var(--border); + border-radius: 6px; padding: 8px; font-size: 12px; +} +.key-url code { word-break: break-all; } diff --git a/internal/web/static/index.html b/internal/web/static/index.html index bbc1d3e..b3b6eb1 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -59,6 +59,10 @@ Alerts + + + Team + @@ -87,6 +91,7 @@ + diff --git a/internal/web/static/js/api.js b/internal/web/static/js/api.js index d9cb09f..147adc9 100644 --- a/internal/web/static/js/api.js +++ b/internal/web/static/js/api.js @@ -92,6 +92,30 @@ export const createTeam = (name) => call('POST', '/teams', { body: { name } }); export const renameTeam = (id, name) => call('PUT', `/teams/${id}`, { body: { name } }); export const deleteTeam = (id) => call('DELETE', `/teams/${id}`); +// A team's own settings. Every write is owner-only and every read is +// member-only; the server answers 403 and 404 respectively, so the UI shows +// what the role allows rather than guarding it. +export const teamMembers = (id) => call('GET', `/teams/${id}/members`); +export const addTeamMember = (id, userID, role) => + call('POST', `/teams/${id}/members`, { body: { user_id: userID, role } }); +export const removeTeamMember = (id, userID) => call('DELETE', `/teams/${id}/members/${userID}`); + +export const integrations = (id) => call('GET', `/teams/${id}/integrations`); +export const createIntegration = (id, name) => + call('POST', `/teams/${id}/integrations`, { body: { name } }); +export const deleteIntegration = (id, integrationID) => + call('DELETE', `/teams/${id}/integrations/${integrationID}`); + +export const deadman = (id) => call('GET', `/teams/${id}/deadman`); +export const setDeadman = (id, body) => call('PUT', `/teams/${id}/deadman`, { body }); + +export const escalation = (id) => call('GET', `/teams/${id}/escalation`); +export const setEscalation = (id, body) => call('PUT', `/teams/${id}/escalation`, { body }); + +export const assignSchedule = (id, userID, dates, replace = false) => + call('POST', `/teams/${id}/schedule`, { body: { user_id: userID, dates, replace } }); +export const unassignSchedule = (id, entryID) => call('DELETE', `/teams/${id}/schedule/${entryID}`); + // Administration. Every one of these is refused with 403 for anybody without // the flag, so the UI hides the section rather than guarding it. export const adminTeams = () => call('GET', '/admin/teams'); diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js index bc6121c..2efd074 100644 --- a/internal/web/static/js/app.js +++ b/internal/web/static/js/app.js @@ -9,6 +9,7 @@ import * as incident from './incident.js'; import * as oncall from './oncall.js'; import * as alerts from './alerts.js'; import * as account from './account.js'; +import * as team from './team.js'; import * as admin from './admin.js'; const $ = (id) => document.getElementById(id); @@ -18,6 +19,7 @@ const SECTIONS = { queue: { title: 'Queue', view: queue }, oncall: { title: 'On-call', view: oncall }, alerts: { title: 'Alerts', view: alerts }, + team: { title: 'Team', view: team }, admin: { title: 'Admin', view: admin }, more: { title: 'Account', view: account }, }; @@ -26,7 +28,7 @@ function parseRoute(pathname) { const m = pathname.match(/^\/incidents\/(\d+)\/?$/); if (m) return { section: 'queue', incident: Number(m[1]) }; const name = pathname.replace(/^\/|\/$/g, ''); - if (name === 'oncall' || name === 'alerts' || name === 'admin' || name === 'more') return { section: name }; + if (name === 'oncall' || name === 'alerts' || name === 'team' || name === 'admin' || name === 'more') return { section: name }; return { section: 'queue', incident: null }; } diff --git a/internal/web/static/js/incident.js b/internal/web/static/js/incident.js index daea9d8..d630004 100644 --- a/internal/web/static/js/incident.js +++ b/internal/web/static/js/incident.js @@ -95,6 +95,15 @@ function statusBadges() { if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) { out.push(badge(`Snoozed · ${until(inc.snoozed_until)} left`, 'st-snoozed')); } + // Where it is on the ladder, while it is still climbing. The queue shows + // what happened; this says what happens next, which is the question somebody + // looking at an unacknowledged incident actually has. + if (inc.escalation_level > 0) { + const left = inc.escalation_due_at && isFuture(inc.escalation_due_at) + ? ` · next in ${until(inc.escalation_due_at)}` + : ' · next page due'; + out.push(badge(`Escalating · level ${inc.escalation_level}${left}`, 'st-triggered')); + } if (inc.archived_at) out.push(badge('Archived', 'plain')); return out; } @@ -115,6 +124,10 @@ function facts() { if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) { add('Snoozed until', when(inc.snoozed_until)); } + if (inc.escalation_level > 0 && inc.escalation_due_at) { + add('Escalates next', when(inc.escalation_due_at), + h('span', { class: 'sub', text: ` · level ${inc.escalation_level}` })); + } if (inc.resolved_at) { const how = inc.resolution_source === 'manual' ? 'by hand' : 'alerts stopped firing'; add('Resolved', when(inc.resolved_at), h('span', { class: 'sub', text: ` · ${how}` })); diff --git a/internal/web/static/js/team.js b/internal/web/static/js/team.js new file mode 100644 index 0000000..2da21ea --- /dev/null +++ b/internal/web/static/js/team.js @@ -0,0 +1,453 @@ +// 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(); +}