Put a team's own settings in the web UI
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 13s
CI / test (pull_request) Successful in 2m19s

Closes #17. Everything a team owner configures was API-only: escalation,
integrations, dead man's switches, membership, and the rota -- which the
on-call view still described as the TUI's job, and the TUI has been broken
against this server since teams landed. Setting up the feature this whole
line of work exists for meant using curl.

A Team tab now holds all of it, one team at a time, with a picker for
somebody in more than one. An owner edits; a member sees the same page
without the controls, because the server refuses their writes anyway --
hiding a button is a courtesy to the reader, not the thing enforcing
anything.

The escalation editor holds a draft and sends the whole ladder, because
the API replaces it wholesale: the levels are an order, and patching one
rung leaves the numbering of the others undecided. Adding a level
defaults to five minutes and the rota, which is the shape almost every
ladder starts as.

An integration key is returned exactly once, so creating one opens a
panel that says so, shows the URL large with a copy button, and renders
the Alertmanager receiver snippet with the URL already in it -- the next
thing anybody does with that key is paste it into a config. The panel
stays until it is dismissed rather than disappearing on the next
re-render.

The incident view gains where an incident is on the ladder and when the
next page is due, which is the question somebody looking at an
unacknowledged incident actually has. The API carries it: the incident
payload now includes escalation_level and escalation_due_at, the latter
computed in the incident SELECT by joining the level's timeout, so a list
costs no extra queries.

Verified against a live server by making every call the page makes,
including the writes: the six reads the Team tab issues, a two-level
ladder saved and read back, an integration created and its key returned
once, three days of rota assigned, switches set, and an incident showing
level 1 with a due time five minutes out.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
This commit is contained in:
Niklas Ye
2026-09-20 21:22:24 +02:00
parent 53e5e03f4e
commit d728af53b1
9 changed files with 555 additions and 2 deletions
+10
View File
@@ -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
+10 -1
View File
@@ -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
}
+7
View File
@@ -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.
+30
View File
@@ -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; }
+5
View File
@@ -59,6 +59,10 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
<span class="nav-label">Alerts</span>
</a>
<a class="nav-link" href="/team" data-section="team">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>
<span class="nav-label">Team</span>
</a>
<!-- Hidden unless the signed-in user is a system administrator; app.js
unhides it once /api/me says so. The server refuses every admin
endpoint regardless, so this is a courtesy and not a gate. -->
@@ -87,6 +91,7 @@
<section id="view-oncall" class="view view-page" data-view="oncall" hidden></section>
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
<section id="view-team" class="view view-page" data-view="team" hidden></section>
<section id="view-admin" class="view view-page" data-view="admin" hidden></section>
<section id="view-more" class="view view-page" data-view="more" hidden></section>
</div>
+24
View File
@@ -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');
+3 -1
View File
@@ -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 };
}
+13
View File
@@ -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}` }));
+453
View File
@@ -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();
}