67d68ce058
The Team tab printed the next thirty days as thirty rows of date, name and
a Clear button. That is a rota spelled out one day at a time, and it is the
one shape the question cannot be read in: what anybody wants from a rota is
who holds which stretch, and thirty names down a column hides a handover
between two rows that look the same. It was also the longest thing on the
page by a wide margin, so the escalation ladder and the alert sources sat
below a screen of dates.
It is a month now, Monday to Sunday, one coloured initial per day. A shift
becomes a run of one colour, which is the shape the answer actually has; a
gap becomes a hole you can see. The legend underneath says whose colour is
whose, and one line says how many days are left uncovered, counting only
from today -- an empty Tuesday last week is history, not a hole somebody
still has to fill.
Laid out like the on-call page's week, deliberately: heading and arrows
outside the card, days inside it. It is the same rota, and two pages
showing it two ways would be two things to learn.
Colours come from a person's place in the member list, so they hold still
as you page between months, and six of them repeat -- the initial inside
still tells two people apart, and a legend that has to explain nine hues is
not a legend. They are not the severity palette: nothing on a rota is
critical, and a red Thursday would read as one. --teal and --pink are new
in both themes for the two the palette was short.
The per-row Clear button had nowhere left to live, so a day opens the sheet
the app already uses for confirmations: who holds it, a picker, Assign and
Clear. That assign sends replace=true where the range form still asks
first, and the difference is the point -- 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 warn about. The range form is unchanged and folded
into a details, since filling a whole shift is what it is for; it opens on
the month above it rather than on today, so paging to March to fill March
does not hand you September.
The server is untouched. The month drawn is the month fetched -- the grid's
Monday overhang and its trailing days are real days and are fetched with
it -- so paging is one GET /api/teams/{id}/schedule per month with from and
to, where it used to be one fixed thirty-day window. No new endpoint, no
change to what the API returns, and terdut-tui is unaffected.
Nobody has looked at this in a browser either. What is checked is the
rendering: team.js's own refresh() was run against a stub fetch and a
pocket DOM for September 2026, and it produces 35 cells for a month whose
1st is a Tuesday, the right from/to on the schedule call, today marked on
the 22nd, three people in the legend with "you" on the viewer, the gap
count over a five-day hole, and -- as a member rather than an owner -- the
same grid as plain divs with no sheet and no range form. How it looks at
phone width, and whether the six colours hold up in dark mode, are not
checked.
Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
625 lines
24 KiB
JavaScript
625 lines
24 KiB
JavaScript
// 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, icon, openSheet, closeSheet } from './ui.js';
|
||
import { state, currentTeam, users as allUsers, myID } from './state.js';
|
||
import { isoDate, addDays, mondayOf, initial } 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;
|
||
const grid = gridDays();
|
||
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(grid.start), isoDate(addDays(grid.start, grid.count - 1))),
|
||
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.
|
||
//
|
||
// A month of it, as a grid. It used to be thirty rows of "date — username",
|
||
// which is a rota spelled out one day at a time: the question asked of it is
|
||
// "who has which stretch", and thirty names down a column is the one shape
|
||
// that answer cannot be read in. So each day carries a coloured initial
|
||
// instead, the legend says whose, and a shift becomes a run of one colour.
|
||
//
|
||
// The same month laid out the same way as the on-call page's week, because it
|
||
// is the same rota — heading and arrows outside the card, days inside it.
|
||
|
||
const monthFmt = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' });
|
||
const weekdayFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short' });
|
||
const longDayFmt = new Intl.DateTimeFormat(undefined, {
|
||
weekday: 'long', day: 'numeric', month: 'long',
|
||
});
|
||
|
||
let monthStart = firstOfMonth(new Date());
|
||
|
||
function firstOfMonth(d) {
|
||
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||
}
|
||
|
||
// The grid runs Monday to Sunday, so it starts before the 1st and ends after
|
||
// the last. Both overhangs are fetched and drawn: a shift that begins on the
|
||
// 30th is a fact about this month even though the days it runs into are not.
|
||
function gridDays() {
|
||
const start = mondayOf(monthStart);
|
||
const last = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0);
|
||
const span = Math.round((last - start) / 86400000) + 1;
|
||
return { start, count: Math.ceil(span / 7) * 7 };
|
||
}
|
||
|
||
function shiftMonth(n) {
|
||
monthStart = new Date(monthStart.getFullYear(), monthStart.getMonth() + n, 1);
|
||
refresh();
|
||
}
|
||
|
||
function scheduleCard() {
|
||
const { start, count } = gridDays();
|
||
const byDate = new Map((data.schedule || []).map((e) => [e.date, e]));
|
||
const today = isoDate(new Date());
|
||
const month = monthStart.getMonth();
|
||
|
||
// Whose colours to explain, in the order the month meets them. Only the days
|
||
// of this month count: a name that appears solely in the overhang belongs to
|
||
// the month next door and would be explaining a chip nobody asked about.
|
||
const seen = new Map();
|
||
const cells = [];
|
||
for (let i = 0; i < count; i++) {
|
||
const d = addDays(start, i);
|
||
const key = isoDate(d);
|
||
const e = byDate.get(key);
|
||
const inMonth = d.getMonth() === month;
|
||
if (inMonth && e && !seen.has(e.user_id)) seen.set(e.user_id, e.username);
|
||
cells.push(dayCell(d, key, e, inMonth, today));
|
||
}
|
||
|
||
const heads = [];
|
||
for (let i = 0; i < 7; i++) {
|
||
// Any Monday will do; this one is a Monday.
|
||
heads.push(h('span', { class: 'rota-wd', text: weekdayFmt.format(new Date(2024, 0, 1 + i)) }));
|
||
}
|
||
|
||
return [
|
||
h('div', { class: 'page-head' },
|
||
h('h2', { text: 'On-call rota' }),
|
||
h('div', { class: 'week-nav' },
|
||
h('button', {
|
||
class: 'btn btn-ghost btn-icon', type: 'button',
|
||
'aria-label': 'Previous month', onclick: () => shiftMonth(-1),
|
||
}, icon('chevronLeft')),
|
||
h('button', {
|
||
class: 'btn btn-ghost label', type: 'button',
|
||
title: 'Back to this month',
|
||
onclick: () => { monthStart = firstOfMonth(new Date()); refresh(); },
|
||
text: monthFmt.format(monthStart),
|
||
}),
|
||
h('button', {
|
||
class: 'btn btn-ghost btn-icon', type: 'button',
|
||
'aria-label': 'Next month', onclick: () => shiftMonth(1),
|
||
}, icon('chevronRight')),
|
||
),
|
||
),
|
||
h('div', { class: 'card' },
|
||
h('div', { class: 'rota-grid' }, heads, cells),
|
||
h('div', { class: 'rota-foot' }, legend(seen), coverNote(byDate)),
|
||
// The range form is the way to fill a whole shift at once, but it is not
|
||
// what the page is for, so it stays folded away under the month it edits.
|
||
isOwner() && h('details', { class: 'rota-bulk' },
|
||
h('summary', { text: 'Assign a range of days' }),
|
||
assignForm()),
|
||
),
|
||
];
|
||
}
|
||
|
||
function dayCell(d, key, e, inMonth, today) {
|
||
const cls = ['rota-day', !inMonth && 'outside', key === today && 'today', key < today && 'past']
|
||
.filter(Boolean).join(' ');
|
||
const label = `${key} · ${e ? e.username : 'nobody'}`;
|
||
const body = [
|
||
h('span', { class: 'rota-num', text: String(d.getDate()) }),
|
||
e
|
||
? h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) })
|
||
: h('span', { class: 'rota-chip none' }),
|
||
];
|
||
// A member sees the same grid without the affordance, the way every other
|
||
// control on this page is hidden rather than shown and refused.
|
||
return isOwner()
|
||
? h('button', {
|
||
class: cls, type: 'button', title: label, 'aria-label': label,
|
||
onclick: () => daySheet(key, e),
|
||
}, body)
|
||
: h('div', { class: cls, title: label }, body);
|
||
}
|
||
|
||
// A colour per person, taken from their place in the member list so that it
|
||
// holds still as you page between months. Somebody who holds days but has
|
||
// since left the team is not in that list and falls back to their id.
|
||
function colorClass(userID) {
|
||
const i = (data.members || []).findIndex((m) => m.user_id === userID);
|
||
return `rc${((i < 0 ? userID : i) % 6) + 1}`;
|
||
}
|
||
|
||
function legend(seen) {
|
||
if (!seen.size) return null;
|
||
return h('div', { class: 'rota-legend' },
|
||
[...seen].map(([id, name]) => h('span', { class: 'rota-key' },
|
||
h('span', { class: `rota-chip ${colorClass(id)}`, text: initial(name) }),
|
||
h('span', { text: name }),
|
||
id === myID() && h('span', { class: 'you', text: 'you' }),
|
||
)));
|
||
}
|
||
|
||
// The gap count, which is the one thing the grid states only by omission. Days
|
||
// already past are not counted: an empty Tuesday last week is history, not a
|
||
// hole somebody still has to fill.
|
||
function coverNote(byDate) {
|
||
const today = isoDate(new Date());
|
||
const last = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0).getDate();
|
||
let gaps = 0;
|
||
for (let day = 1; day <= last; day++) {
|
||
const key = isoDate(new Date(monthStart.getFullYear(), monthStart.getMonth(), day));
|
||
if (key >= today && !byDate.has(key)) gaps++;
|
||
}
|
||
if (gaps === 0) return h('p', { class: 'rota-note', text: 'Every day left this month has somebody on call.' });
|
||
return h('p', { class: 'rota-note' },
|
||
h('strong', { text: gaps === 1 ? '1 day' : `${gaps} days` }),
|
||
' left this month with nobody on call.');
|
||
}
|
||
|
||
// One day, in the sheet: who has it, who should, and the way to empty it. This
|
||
// is where the per-row Clear button went — the grid has no room for thirty of
|
||
// them, and the day you want to change is the one you just tapped.
|
||
function daySheet(date, entry) {
|
||
const who = memberSelect(entry ? entry.user_id : undefined);
|
||
openSheet(() => [
|
||
h('h2', { class: 'sheet-title', text: longDayFmt.format(parseISO(date)) }),
|
||
h('p', { class: 'sheet-text', text: entry ? `${entry.username} is on call.` : 'Nobody is on call.' }),
|
||
h('label', { class: 'sheet-pick' }, 'On call ', who),
|
||
h('div', { class: 'sheet-actions' },
|
||
entry && h('button', {
|
||
class: 'btn btn-danger', type: 'button', text: 'Clear',
|
||
onclick: () => { closeSheet(); act(() => api.unassignSchedule(teamID, entry.id)); },
|
||
}),
|
||
h('button', {
|
||
class: 'btn btn-primary', type: 'button', autofocus: true, text: 'Assign',
|
||
// replace, where the range form asks first: the sheet has just named
|
||
// whoever holds the day, so taking it from them is the thing that was
|
||
// asked for rather than something to be warned about.
|
||
onclick: () => {
|
||
closeSheet();
|
||
act(() => api.assignSchedule(teamID, Number(who.value), [date], true));
|
||
},
|
||
}),
|
||
),
|
||
]);
|
||
}
|
||
|
||
function parseISO(s) {
|
||
const [y, m, d] = s.split('-').map(Number);
|
||
return new Date(y, m - 1, d);
|
||
}
|
||
|
||
function assignForm() {
|
||
const who = memberSelect();
|
||
// The form opens on the month above it rather than on today: it is folded
|
||
// into that month's card, and paging to March to fill March and being handed
|
||
// today's date would be the card and the form disagreeing about the subject.
|
||
const now = new Date();
|
||
const sameMonth = monthStart.getFullYear() === now.getFullYear()
|
||
&& monthStart.getMonth() === now.getMonth();
|
||
const from = h('input', {
|
||
type: 'date', required: true, value: isoDate(sameMonth ? now : monthStart),
|
||
});
|
||
const days = h('input', { type: 'number', min: '1', max: '31', value: '1', class: 'setting-value' });
|
||
const replace = h('input', { type: 'checkbox' });
|
||
|
||
const form = h('form', { class: 'stacked-form' },
|
||
h('label', {}, 'Who ', who),
|
||
h('label', {}, 'From ', from),
|
||
h('label', {}, 'Days ', days),
|
||
// Taking a day somebody else holds has to be asked for, the same rule the
|
||
// API enforces: a plain assignment that silently moved a shift would move
|
||
// who gets paged without telling either of them.
|
||
h('label', { class: 'checkbox' }, replace, ' Take days somebody else holds'),
|
||
h('button', { class: 'btn', type: 'submit', text: 'Assign' }));
|
||
|
||
form.addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
const start = new Date(from.value + 'T00:00:00Z');
|
||
const dates = [];
|
||
for (let i = 0; i < Number(days.value || 1); i++) dates.push(isoDate(addDays(start, i)));
|
||
act(() => api.assignSchedule(teamID, Number(who.value), dates, replace.checked));
|
||
});
|
||
return form;
|
||
}
|
||
|
||
function memberSelect(selected) {
|
||
return h('select', {},
|
||
...(data.members || []).map((m) => h('option', {
|
||
value: String(m.user_id), text: m.username, selected: m.user_id === selected,
|
||
})));
|
||
}
|
||
|
||
// --- escalation ------------------------------------------------------------
|
||
|
||
// 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();
|
||
}
|