a27ff49171
terdut can now sign people in through any OIDC provider (written against Authentik), and let groups at the provider decide who may sign in, which teams they belong to and whether they administer the install. Password login keeps working alongside it; TERDUT_PASSWORD_LOGIN=false turns it off, and is refused at startup unless SSO is configured. With no TERDUT_OIDC_* setting nothing changes, so every existing install behaves as before. Identity is (issuer, subject), never email or username: those are mutable at the provider and a recycled address must not inherit an account. An existing user is linked by email only when the provider marks it verified, or TERDUT_OIDC_TRUST_EMAIL is set, which Authentik needs. Group grants are marked source='oidc' on team_members and users, and the sync changes only those rows. Hand-made memberships and administrators are left alone, and the sync bypasses the last-owner and last-admin guards because the provider is the source of truth for what it grants. Editing managed access by hand is refused with 409, since the next sign-in would undo it. The web UI badges it as SSO and disables the controls. Groups are read only at sign-in, so an SSO session carries a hard ceiling (sessions.max_expires_at, 12h by default) that sliding never extends. There is no refresh token, which means API keys of somebody removed at the provider stay valid until an administrator disables the user. That is accepted and documented, not fixed. A client with no browser, the TUI over SSH, signs in with a device code run by terdut itself (POST /api/oidc/device and /device/token), so the terminal never talks to the provider and ends up with the ordinary terdut_session cookie. Only a browser session can approve a code; an API key cannot. /device?code= sends a signed-out visitor through sign-in and back, which is what oidc_logins.next is for. oauth2 is pinned to v0.36.0: v0.37 needs Go 1.26 and the Dockerfile builds on 1.25. Migrations 011 and 012 add tables and defaulted columns only.
1186 lines
48 KiB
JavaScript
1186 lines
48 KiB
JavaScript
// 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 } from './ui.js';
|
||
import { state, currentTeam, 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' },
|
||
];
|
||
|
||
let teamID = null;
|
||
// Which sub-section is open. Remembered rather than passed, because the poll
|
||
// loop calls refresh() with no route.
|
||
let tab = null;
|
||
let data = null; // { team, ... }; which fields are present varies by tab
|
||
let error = null;
|
||
let freshKey = null; // an integration key, shown once, until the view is left
|
||
|
||
export function show(route) {
|
||
const next = route?.tab ?? null;
|
||
// A different sub-section wants different data, so the old answer goes
|
||
// rather than being shown under the new heading until the fetch lands.
|
||
if (next !== tab) {
|
||
tab = next;
|
||
data = null;
|
||
}
|
||
if (!data) clear(view(), subnav(), spinner());
|
||
refresh();
|
||
}
|
||
|
||
function selectedTeam() {
|
||
const teams = state.teams || [];
|
||
return teams.find((t) => t.id === teamID) || currentTeam();
|
||
}
|
||
|
||
export async function refresh() {
|
||
const team = selectedTeam();
|
||
if (!team) {
|
||
data = null;
|
||
render();
|
||
return;
|
||
}
|
||
teamID = team.id;
|
||
try {
|
||
data = { team, ...(await load(team.id)) };
|
||
error = null;
|
||
} catch (err) {
|
||
error = err.message;
|
||
}
|
||
render();
|
||
}
|
||
|
||
// Only what the open sub-section shows. A member may read all of it; only the
|
||
// writes are owner-only.
|
||
//
|
||
// Three of the five need the member list besides their own endpoint, and for
|
||
// the same reason each time: a rota, a ladder target and a role are all a
|
||
// person, and the page has to be able to name them. The overview is the one
|
||
// that fetches everything, because saying how much of each there is means
|
||
// asking each of them.
|
||
async function load(id) {
|
||
if (tab === 'rota') {
|
||
const grid = gridDays();
|
||
const [members, schedule] = await Promise.all([
|
||
api.teamMembers(id),
|
||
api.schedule(id, isoDate(grid.start), isoDate(addDays(grid.start, grid.count - 1))),
|
||
]);
|
||
return { members, schedule };
|
||
}
|
||
if (tab === 'members') {
|
||
const [members, users] = await Promise.all([api.teamMembers(id), allUsers()]);
|
||
return { members, users };
|
||
}
|
||
if (tab === 'escalation') {
|
||
const [members, escalation] = await Promise.all([api.teamMembers(id), api.escalation(id)]);
|
||
return { members, escalation };
|
||
}
|
||
if (tab === 'sources') return { integrations: await api.integrations(id) };
|
||
if (tab === 'deadman') return { deadman: await api.deadmanSwitches(id) };
|
||
|
||
const grid = gridDays();
|
||
const [members, integrations, escalation, deadman, schedule] = await Promise.all([
|
||
api.teamMembers(id),
|
||
api.integrations(id),
|
||
api.escalation(id),
|
||
api.deadmanSwitches(id),
|
||
api.schedule(id, isoDate(grid.start), isoDate(addDays(grid.start, grid.count - 1))),
|
||
]);
|
||
return { members, integrations, escalation, deadman, schedule };
|
||
}
|
||
|
||
function isOwner() {
|
||
return data?.team?.role === 'owner' || state.me?.user?.is_admin;
|
||
}
|
||
|
||
function render() {
|
||
if (!data) {
|
||
clear(view(), error
|
||
? h('div', { class: 'load-error', text: error })
|
||
: h('div', { class: 'card' }, h('p', { class: 'muted', text: 'You are not in a team yet.' })));
|
||
return;
|
||
}
|
||
clear(view(),
|
||
subnav(),
|
||
teamPicker(),
|
||
// Said once on the overview rather than on all six pages: it explains why
|
||
// the controls further down are missing, and a page of nothing but the
|
||
// rota has no controls to explain.
|
||
!isOwner() && tab === null && h('div', { class: 'card' },
|
||
h('p', { class: 'muted small', text: 'You are a member of this team. Only an owner can change its settings.' })),
|
||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||
section(),
|
||
);
|
||
}
|
||
|
||
function section() {
|
||
if (tab === 'rota') return scheduleCard();
|
||
if (tab === 'members') return membersCard();
|
||
if (tab === 'escalation') return escalationCard();
|
||
if (tab === 'sources') return integrationsCard();
|
||
if (tab === 'deadman') return deadmanCard();
|
||
return overview();
|
||
}
|
||
|
||
// The strip across the top of every team page. Ordinary links rather than
|
||
// buttons, because these are six URLs: app.js intercepts the click, the
|
||
// browser's Back walks them, and a reload lands where you were.
|
||
function subnav() {
|
||
return h('nav', { class: 'subnav', 'aria-label': 'Team' },
|
||
TABS.map((t) => h('a', {
|
||
class: 'subnav-link',
|
||
href: t.path,
|
||
text: t.label,
|
||
'aria-current': t.tab === tab ? 'page' : null,
|
||
})));
|
||
}
|
||
|
||
// Only shown to somebody in more than one team, like the queue's filter chips.
|
||
// It is above the sections rather than inside one because it changes the
|
||
// subject of all six.
|
||
function teamPicker() {
|
||
if ((state.teams || []).length < 2) {
|
||
return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
|
||
}
|
||
const select = h('select', { class: 'team-picker' },
|
||
...state.teams.map((t) => h('option', {
|
||
value: String(t.id), text: t.name, selected: t.id === teamID,
|
||
})));
|
||
select.addEventListener('change', () => {
|
||
teamID = Number(select.value);
|
||
data = null;
|
||
freshKey = null;
|
||
refresh();
|
||
});
|
||
return h('div', { class: 'card' }, h('h2', { text: 'Team' }), select);
|
||
}
|
||
|
||
// --- overview --------------------------------------------------------------
|
||
|
||
// /team itself. The strip already links to the five, so this earns its place
|
||
// the way /admin's does: by saying how much of each there is, which is the one
|
||
// thing a menu cannot.
|
||
function overview() {
|
||
const today = isoDate(new Date());
|
||
const onToday = (data.schedule || []).find((e) => e.date === today);
|
||
const owners = (data.members || []).filter((m) => m.role === 'owner').length;
|
||
const levels = (data.escalation?.levels || []).length;
|
||
const keys = (data.integrations || []).length;
|
||
const unused = (data.integrations || []).filter((i) => !i.last_used_at).length;
|
||
const switches = (data.deadman || []).length;
|
||
const dead = (data.deadman || []).filter((s) => s.status === 'dead').length;
|
||
|
||
return h('div', { class: 'overview-menu' },
|
||
menuCard('/team/rota', 'Rota', null,
|
||
onToday ? `${onToday.username} is on call today.` : 'Nobody is on call today.'),
|
||
menuCard('/team/members', 'Members', (data.members || []).length,
|
||
owners === 1 ? 'One owner.' : `${owners} owners.`),
|
||
menuCard('/team/escalation', 'Escalation', levels || null,
|
||
levels
|
||
? `${levels === 1 ? 'One level' : `${levels} levels`}${data.escalation.fallback_topic ? ', then a fallback topic.' : '.'}`
|
||
: 'No ladder — nobody but the first person is woken.'),
|
||
menuCard('/team/sources', 'Alert sources', keys || null,
|
||
keys
|
||
? (unused ? `${unused} of them never used.` : 'All in use.')
|
||
: 'No key yet, so nothing can reach this team.'),
|
||
menuCard('/team/deadman', 'Dead man’s switches', switches || null,
|
||
switches
|
||
? (dead ? `${dead} of them silent.` : 'All quiet, as they should be.')
|
||
: 'Nothing watched.'),
|
||
);
|
||
}
|
||
|
||
// --- schedule --------------------------------------------------------------
|
||
|
||
// The rota is one person per UTC day. The on-call page shows it; this is where
|
||
// it is set, which until now was the TUI's job and the TUI cannot do it any
|
||
// more.
|
||
//
|
||
// A month of it, as a grid. It used to be thirty rows of "date — username",
|
||
// which is a rota spelled out one day at a time: the question asked of it is
|
||
// "who has which stretch", and thirty names down a column is the one shape
|
||
// that answer cannot be read in. So each day carries a coloured initial
|
||
// instead, the legend says whose, and a shift becomes a run of one colour.
|
||
//
|
||
// The same month laid out the same way as the on-call page's week, because it
|
||
// is the same rota — heading and arrows outside the card, days inside it.
|
||
|
||
const monthFmt = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' });
|
||
const weekdayFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short' });
|
||
const 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();
|
||
|
||
// 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;
|
||
// Every row starts with its week number, which is also the way to fill the
|
||
// whole week at once.
|
||
if (i % 7 === 0) cells.push(weekCell(d, byDate, today));
|
||
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 = [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)) }));
|
||
}
|
||
|
||
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()),
|
||
),
|
||
];
|
||
}
|
||
|
||
// 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', 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 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', type: 'submit', text: 'Assign' }));
|
||
|
||
form.addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
const start = new Date(from.value + 'T00:00:00Z');
|
||
const dates = [];
|
||
for (let i = 0; i < Number(days.value || 1); i++) dates.push(isoDate(addDays(start, i)));
|
||
act(() => api.assignSchedule(teamID, Number(who.value), dates, replace.checked));
|
||
});
|
||
return form;
|
||
}
|
||
|
||
function memberSelect(selected) {
|
||
return h('select', {},
|
||
...(data.members || []).map((m) => h('option', {
|
||
value: String(m.user_id), text: m.username, selected: m.user_id === selected,
|
||
})));
|
||
}
|
||
|
||
// --- escalation ------------------------------------------------------------
|
||
|
||
const LEVEL_STATUS = {
|
||
ready: { label: 'Ready', hint: 'Somebody here can be woken.' },
|
||
escalating: { label: 'Escalating', hint: 'An unanswered incident has climbed to this level.' },
|
||
unreachable: { label: 'Pages nobody', hint: 'Nobody on this level can be woken right now.' },
|
||
};
|
||
|
||
const levelBadge = (status) => statusBadge(LEVEL_STATUS, status, 'ready');
|
||
|
||
// One target as the list shows it: who it means today, and why it would not
|
||
// wake them if it would not.
|
||
function targetLine(t) {
|
||
const label = t.kind === 'oncall'
|
||
? `On call${t.username ? ` · ${t.username}` : ''}`
|
||
: (t.username || 'Unknown person');
|
||
return h('div', { class: 'target-line' },
|
||
h('span', { text: label }),
|
||
t.problem && h('span', { class: 'target-problem', text: t.problem }));
|
||
}
|
||
|
||
function escalationCard() {
|
||
const esc = data.escalation || {};
|
||
const levels = esc.levels || [];
|
||
|
||
const rows = levels.map((l) => h('tr', {},
|
||
h('td', {}, h('strong', { text: `Level ${l.position}` })),
|
||
h('td', {}, levelBadge(l.status)),
|
||
h('td', { class: 'wrap' }, ...l.targets.map(targetLine)),
|
||
h('td', { class: 'muted small', text: duration(l.timeout_seconds * 1000) }),
|
||
h('td', { class: 'small' }, l.waiting?.length
|
||
? l.waiting.flatMap((id, i) => [i > 0 && ', ', h('a', { href: `/incidents/${id}`, text: `#${id}` })])
|
||
: h('span', { class: 'muted', text: '—' })),
|
||
));
|
||
|
||
const facts = [];
|
||
if (levels.length) {
|
||
const n = esc.repeat_count || 0;
|
||
if (n) facts.push(`Then the whole ladder repeats ${n} more ${n === 1 ? 'time' : 'times'}.`);
|
||
facts.push(esc.fallback_topic
|
||
? ['Finally the ntfy topic ', h('code', { text: esc.fallback_topic }), ' is paged once.']
|
||
: 'No fallback topic: after the last level the chain just ends.');
|
||
facts.push(esc.last_escalated_at
|
||
? ['Last escalated ',
|
||
h('span', { title: when(esc.last_escalated_at), text: ago(esc.last_escalated_at) }),
|
||
' on ', h('a', { href: `/incidents/${esc.last_escalated_incident_id}`, text: `#${esc.last_escalated_incident_id}` }), '.']
|
||
: 'Nothing has needed to escalate yet.');
|
||
}
|
||
|
||
return h('div', { class: 'card' },
|
||
h('div', { class: 'card-head' },
|
||
h('h2', { text: 'Escalation' }),
|
||
isOwner() && h('button', {
|
||
class: 'btn', type: 'button', onclick: openLadderEditor,
|
||
text: levels.length ? 'Edit ladder' : 'Set up ladder',
|
||
})),
|
||
h('p', { class: 'muted small' },
|
||
'When a level’s wait passes and nobody has acknowledged, the next level is ',
|
||
'paged. Acknowledging or resolving stops it; snoozing pauses it.'),
|
||
levels.length
|
||
? h('div', { class: 'table-scroll' },
|
||
h('table', { class: 'admin-table status-table' },
|
||
h('thead', {}, h('tr', {},
|
||
h('th', { text: 'Level' }), h('th', { text: 'Status' }), h('th', { text: 'Pages' }),
|
||
h('th', { text: 'Then after' }), h('th', { text: 'Waiting now' }))),
|
||
h('tbody', {}, rows)))
|
||
: h('p', { class: 'muted' },
|
||
'No ladder. An unacknowledged incident re-pages the same person every ',
|
||
'reminder interval and nobody else is woken.'),
|
||
...facts.map((f) => h('p', { class: 'muted small' }, f)),
|
||
);
|
||
}
|
||
|
||
// The ladder is edited as a whole and sent as a whole, because the API replaces
|
||
// it wholesale: the levels are an order, and patching one rung would leave the
|
||
// numbering of the others undecided. The draft lives in the sheet, so a poll of
|
||
// the page underneath cannot throw away half an edit.
|
||
function openLadderEditor() {
|
||
const esc = data.escalation || {};
|
||
const draft = {
|
||
repeat_count: esc.repeat_count || 0,
|
||
fallback_topic: esc.fallback_topic || '',
|
||
levels: (esc.levels || []).map((l) => ({
|
||
timeout_seconds: l.timeout_seconds,
|
||
targets: (l.targets || []).map((t) => ({ kind: t.kind, user_id: t.user_id })),
|
||
})),
|
||
};
|
||
|
||
const body = h('div', { class: 'ladder-editor' });
|
||
const problem = h('p', { class: 'load-error', hidden: true });
|
||
|
||
const paint = () => {
|
||
const parts = [];
|
||
if (!draft.levels.length) {
|
||
parts.push(h('p', { class: 'muted small' }, 'No levels yet. Add the first one.'));
|
||
}
|
||
draft.levels.forEach((level, i) => {
|
||
parts.push(h('div', { class: 'ladder-level' },
|
||
h('div', { class: 'ladder-head' },
|
||
h('strong', { text: `Level ${i + 1}` }),
|
||
h('button', {
|
||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||
onclick: () => { draft.levels.splice(i, 1); paint(); },
|
||
})),
|
||
h('label', {}, 'Wait ', minutesInput(level.timeout_seconds, (secs) => {
|
||
level.timeout_seconds = secs;
|
||
}), ' before the next level'),
|
||
h('div', { class: 'ladder-targets' },
|
||
...level.targets.map((t, ti) => targetRow(level, t, ti, paint)),
|
||
h('button', {
|
||
class: 'btn-sm', type: 'button', text: '+ target',
|
||
onclick: () => { level.targets.push({ kind: 'oncall' }); paint(); },
|
||
})),
|
||
));
|
||
});
|
||
parts.push(h('button', {
|
||
class: 'btn-sm', type: 'button', text: '+ level',
|
||
onclick: () => {
|
||
draft.levels.push({ timeout_seconds: 300, targets: [{ kind: 'oncall' }] });
|
||
paint();
|
||
},
|
||
}));
|
||
|
||
const repeat = h('input', {
|
||
type: 'number', min: '0', max: '10', class: 'setting-value',
|
||
value: String(draft.repeat_count),
|
||
oninput: (e) => { draft.repeat_count = Number(e.target.value); },
|
||
});
|
||
const fallback = h('input', {
|
||
type: 'text', value: draft.fallback_topic, placeholder: 'terdut-oncall-all',
|
||
oninput: (e) => { draft.fallback_topic = e.target.value; },
|
||
});
|
||
parts.push(h('label', {}, 'Repeat the whole ladder ', repeat, ' more times'));
|
||
parts.push(h('label', {}, 'Then page this ntfy topic once ', fallback));
|
||
clear(body, ...parts);
|
||
};
|
||
paint();
|
||
|
||
const save = h('button', { class: 'btn btn-primary', type: 'button', text: 'Save ladder' });
|
||
save.addEventListener('click', async () => {
|
||
try {
|
||
await api.setEscalation(teamID, draft);
|
||
} catch (err) {
|
||
problem.textContent = err.message;
|
||
problem.hidden = false;
|
||
return;
|
||
}
|
||
closeSheet(true);
|
||
refresh();
|
||
});
|
||
|
||
openSheet(() => [
|
||
h('h2', { class: 'sheet-title', text: 'Edit ladder' }),
|
||
body,
|
||
problem,
|
||
h('div', { class: 'sheet-actions' },
|
||
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
|
||
save),
|
||
]);
|
||
}
|
||
|
||
function targetRow(level, target, index, repaint) {
|
||
const kind = h('select', {},
|
||
h('option', { value: 'oncall', text: 'Whoever is on call', selected: target.kind === 'oncall' }),
|
||
h('option', { value: 'user', text: 'A specific person', selected: target.kind === 'user' }));
|
||
kind.addEventListener('change', () => {
|
||
target.kind = kind.value;
|
||
target.user_id = kind.value === 'user' ? (data.members[0] || {}).user_id : undefined;
|
||
repaint();
|
||
});
|
||
|
||
const who = target.kind === 'user'
|
||
? memberSelect(target.user_id)
|
||
: null;
|
||
if (who) {
|
||
who.addEventListener('change', () => { target.user_id = Number(who.value); });
|
||
}
|
||
|
||
return h('div', { class: 'target-row' }, kind, who,
|
||
h('button', {
|
||
class: 'btn-sm danger', type: 'button', text: '×',
|
||
title: 'Remove this target',
|
||
onclick: () => { level.targets.splice(index, 1); repaint(); },
|
||
}));
|
||
}
|
||
|
||
function minutesInput(seconds, onChange) {
|
||
const input = h('input', {
|
||
type: 'number', min: '1', class: 'setting-value',
|
||
value: String(Math.max(1, Math.round(seconds / 60))),
|
||
oninput: (e) => onChange(Number(e.target.value) * 60),
|
||
});
|
||
return h('span', {}, input, ' minutes');
|
||
}
|
||
|
||
// --- integrations ----------------------------------------------------------
|
||
|
||
function integrationsCard() {
|
||
const rows = (data.integrations || []).map((i) =>
|
||
h('tr', {},
|
||
h('td', {}, sourceBadge(i.status)),
|
||
h('td', { class: 'wrap' },
|
||
h('strong', { text: i.name }),
|
||
h('div', { class: 'muted small', text: i.kind })),
|
||
// When the key last posted, and when an alert last arrived on it. They
|
||
// differ: a payload with nothing usable in it stamps only the first.
|
||
h('td', { class: 'muted small' }, timeCell(i.last_used_at)),
|
||
h('td', { class: 'muted small' }, timeCell(i.last_alert_at)),
|
||
h('td', { class: 'muted small num', title: 'Distinct alerts refreshed in the last 24 hours',
|
||
text: String(i.alerts_24h ?? 0) }),
|
||
h('td', { class: 'muted small' }, h('span', { title: when(i.created_at), text: ago(i.created_at) })),
|
||
h('td', {}, isOwner() && h('div', { class: 'row-actions' },
|
||
h('button', {
|
||
class: 'btn-sm', type: 'button', text: 'Rename', onclick: () => openRenameSource(i),
|
||
}),
|
||
h('button', {
|
||
class: 'btn-sm danger', type: 'button', text: 'Revoke',
|
||
onclick: async () => {
|
||
if (!(await confirm({
|
||
title: `Revoke ${i.name}?`,
|
||
text: 'Anything posting with this key stops delivering immediately. Alerts it already delivered stay.',
|
||
confirmLabel: 'Revoke',
|
||
danger: true,
|
||
}))) return;
|
||
act(() => api.deleteIntegration(teamID, i.id));
|
||
},
|
||
}))),
|
||
));
|
||
|
||
return h('div', { class: 'card' },
|
||
h('div', { class: 'card-head' },
|
||
h('h2', { text: 'Alert sources' }),
|
||
isOwner() && h('button', {
|
||
class: 'btn', type: 'button', text: 'New source', onclick: openNewSource,
|
||
})),
|
||
h('p', { class: 'muted small' },
|
||
'Alerts arrive on an integration key, which says both that the sender may ',
|
||
'post and which team the alerts belong to.'),
|
||
freshKey && newKeyPanel(),
|
||
rows.length
|
||
? h('div', { class: 'table-scroll' },
|
||
h('table', { class: 'admin-table status-table' },
|
||
h('thead', {}, h('tr', {},
|
||
h('th', { text: 'Status' }), h('th', { text: 'Source' }),
|
||
h('th', { text: 'Last webhook' }), h('th', { text: 'Last alert' }),
|
||
h('th', { class: 'num', text: 'Alerts 24h' }), h('th', { text: 'Created' }), h('th'))),
|
||
h('tbody', {}, rows)))
|
||
: h('p', { class: 'muted', text: 'No alert source yet, so nothing can reach this team.' }),
|
||
);
|
||
}
|
||
|
||
// The key is returned exactly once. Say so, show it large, and give the
|
||
// Alertmanager snippet with it already in place — the next thing anybody does
|
||
// with it is paste it into a config.
|
||
function newKeyPanel() {
|
||
const url = freshKey.url || `${location.origin}/api/integrations/${freshKey.key}/alertmanager`;
|
||
const snippet = `receivers:
|
||
- name: terdut
|
||
webhook_configs:
|
||
- url: ${url}
|
||
send_resolved: true`;
|
||
|
||
return h('div', { class: 'key-panel' },
|
||
h('strong', { text: 'Copy this now — it is not shown again.' }),
|
||
h('pre', { class: 'key-url' }, h('code', { text: url })),
|
||
h('button', {
|
||
class: 'btn-sm', type: 'button', text: 'Copy URL',
|
||
onclick: () => navigator.clipboard?.writeText(url),
|
||
}),
|
||
h('p', { class: 'muted small', text: 'Alertmanager receiver:' }),
|
||
h('pre', {}, h('code', { text: snippet })),
|
||
h('button', {
|
||
class: 'btn-sm', type: 'button', text: 'Done',
|
||
onclick: () => { freshKey = null; render(); },
|
||
}),
|
||
);
|
||
}
|
||
|
||
// A sheet with one name field, for adding a source and for renaming one: the two
|
||
// differ only in what they call and what they put in the box.
|
||
function openNameSheet({ title, submit, value, run }) {
|
||
const name = h('input', {
|
||
type: 'text', placeholder: 'prod alertmanager', required: true, value, autofocus: true,
|
||
});
|
||
const problem = h('p', { class: 'load-error', hidden: true });
|
||
const form = h('form', { class: 'stacked-form' },
|
||
h('label', {}, 'Name ', name),
|
||
problem,
|
||
h('div', { class: 'sheet-actions' },
|
||
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
|
||
h('button', { class: 'btn btn-primary', type: 'submit', text: submit })));
|
||
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
await run(name.value.trim());
|
||
} catch (err) {
|
||
problem.textContent = err.message;
|
||
problem.hidden = false;
|
||
return;
|
||
}
|
||
closeSheet(true);
|
||
refresh();
|
||
});
|
||
openSheet(() => [h('h2', { class: 'sheet-title', text: title }), form]);
|
||
}
|
||
|
||
function openNewSource() {
|
||
openNameSheet({
|
||
title: 'New source', submit: 'Add source', value: '',
|
||
// The key comes back once, and the card shows it until dismissed.
|
||
run: async (name) => { freshKey = await api.createIntegration(teamID, name); },
|
||
});
|
||
}
|
||
|
||
function openRenameSource(i) {
|
||
openNameSheet({
|
||
title: `Rename ${i.name}`, submit: 'Rename', value: i.name,
|
||
run: (name) => api.renameIntegration(teamID, i.id, name),
|
||
});
|
||
}
|
||
|
||
// --- dead man's switches ---------------------------------------------------
|
||
|
||
// Status badges, shared by the Sources and Switches lists: a table of label and
|
||
// hint per status, and one function to draw it. Module-level, so the two cards
|
||
// can be defined in either order.
|
||
const SWITCH_STATUS = {
|
||
healthy: { label: 'Healthy', hint: 'Heard from within its timeout.' },
|
||
dead: { label: 'Dead', hint: 'Silent for longer than its timeout.' },
|
||
dormant: { label: 'Dormant', hint: 'Nothing has matched yet, so there is nothing to lose.' },
|
||
};
|
||
|
||
const SOURCE_STATUS = {
|
||
active: { label: 'Active', hint: 'Posted within the last day.' },
|
||
quiet: { label: 'Quiet', hint: 'Has posted, but not in the last day. Nothing firing is a fine reason.' },
|
||
never: { label: 'Never used', hint: 'Nothing has been posted with this key yet.' },
|
||
};
|
||
|
||
function statusBadge(table, status, fallback) {
|
||
const s = table[status] || table[fallback];
|
||
const el = badge(s.label, `st-${status}`);
|
||
el.title = s.hint;
|
||
return el;
|
||
}
|
||
|
||
const switchBadge = (status) => statusBadge(SWITCH_STATUS, status, 'dormant');
|
||
const sourceBadge = (status) => statusBadge(SOURCE_STATUS, status, 'never');
|
||
|
||
const timeCell = (iso) => iso
|
||
? h('span', { title: when(iso), text: ago(iso) })
|
||
: h('span', { class: 'muted', text: 'never' });
|
||
|
||
// When it last opened an incident. An incident that is still open is a link,
|
||
// because that is the thing somebody looking at a red row wants next.
|
||
const triggeredCell = (iso, incidentID) => {
|
||
if (!iso) return h('span', { class: 'muted', text: 'never' });
|
||
return incidentID
|
||
? h('a', { href: `/incidents/${incidentID}`, title: when(iso) }, `#${incidentID} · ${ago(iso)}`)
|
||
: h('span', { title: when(iso), text: ago(iso) });
|
||
};
|
||
|
||
function switchRows(sw) {
|
||
const main = h('tr', {},
|
||
h('td', {}, switchBadge(sw.status)),
|
||
h('td', { class: 'wrap' },
|
||
h('strong', { text: sw.name }),
|
||
sw.name !== sw.matcher && h('div', { class: 'muted small' }, h('code', { text: sw.matcher }))),
|
||
h('td', { class: 'muted small' }, timeCell(sw.last_heartbeat_at)),
|
||
h('td', { class: 'muted small' }, triggeredCell(sw.last_triggered_at, sw.open_incident_id)),
|
||
h('td', { class: 'muted small', text: duration(sw.timeout_seconds * 1000) }),
|
||
h('td', {}, isOwner() && h('button', {
|
||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||
onclick: async () => {
|
||
if (!(await confirm({
|
||
title: `Remove ${sw.name}?`,
|
||
text: 'It stops being watched. An incident it already opened stays open until it is resolved.',
|
||
confirmLabel: 'Remove',
|
||
danger: true,
|
||
}))) return;
|
||
act(() => api.deleteDeadmanSwitch(teamID, sw.id));
|
||
},
|
||
})),
|
||
);
|
||
|
||
// One heartbeat is the switch's own times; several are worth telling apart,
|
||
// since a live cluster must not hide a dead one.
|
||
const sources = sw.sources.length > 1
|
||
? sw.sources.map((src) => h('tr', { class: 'source-row' },
|
||
h('td', {}, switchBadge(src.status)),
|
||
h('td', { class: 'source-labels' },
|
||
...Object.entries(src.labels || {})
|
||
.filter(([k]) => k !== 'alertname')
|
||
.map(([k, v]) => labelChip(k, v)),
|
||
!Object.keys(src.labels || {}).some((k) => k !== 'alertname')
|
||
&& h('code', { class: 'small', text: src.fingerprint })),
|
||
h('td', { class: 'muted small' }, timeCell(src.last_heartbeat_at)),
|
||
h('td', { class: 'muted small' }, triggeredCell(src.last_triggered_at, src.incident_id)),
|
||
h('td'), h('td')))
|
||
: [];
|
||
return [main, ...sources];
|
||
}
|
||
|
||
function deadmanCard() {
|
||
const switches = data.deadman || [];
|
||
|
||
return h('div', { class: 'card' },
|
||
h('div', { class: 'card-head' },
|
||
h('h2', { text: 'Dead man’s switches' }),
|
||
isOwner() && h('button', {
|
||
class: 'btn', type: 'button', text: 'New switch', onclick: openNewSwitch,
|
||
})),
|
||
h('p', { class: 'muted small' },
|
||
'Alerts whose ABSENCE is the signal. Receiving one opens nothing; going ',
|
||
'quiet for longer than the switch’s timeout opens an incident.'),
|
||
switches.length
|
||
? h('div', { class: 'table-scroll' },
|
||
h('table', { class: 'admin-table status-table' },
|
||
h('thead', {}, h('tr', {},
|
||
h('th', { text: 'Status' }), h('th', { text: 'Switch' }),
|
||
h('th', { text: 'Last heartbeat' }), h('th', { text: 'Last triggered' }),
|
||
h('th', { text: 'Silent after' }), h('th'))),
|
||
h('tbody', {}, switches.flatMap(switchRows))))
|
||
: h('p', { class: 'muted', text: 'Nothing watched.' }),
|
||
);
|
||
}
|
||
|
||
// The form lives in the sheet, not on the page: most visits are to look at the
|
||
// list, and a form that is always open is the page this replaced.
|
||
function openNewSwitch() {
|
||
const name = h('input', { type: 'text', placeholder: 'Prod Watchdog', autofocus: true });
|
||
const matcher = h('input', {
|
||
type: 'text', placeholder: 'alertname=Watchdog,cluster=prod', class: 'wide', required: true,
|
||
});
|
||
const timeout = h('input', {
|
||
type: 'number', min: '1', value: '15', class: 'setting-value', required: true,
|
||
});
|
||
const severity = h('select', {},
|
||
...['critical', 'error', 'warning', 'info'].map((s) => h('option', { value: s, text: s })));
|
||
const problem = h('p', { class: 'load-error', hidden: true });
|
||
|
||
const form = h('form', { class: 'stacked-form' },
|
||
h('label', {}, 'Name (optional) ', name),
|
||
h('label', {}, 'Heartbeat alert ', matcher),
|
||
h('p', { class: 'muted small' },
|
||
'Conditions are ', h('code', { text: 'label=value' }), ' separated by commas, and one ',
|
||
'must be ', h('code', { text: 'alertname' }), '. Every distinct label set that ',
|
||
'matches is watched on its own.'),
|
||
h('label', {}, 'Declare dead after ', timeout, ' minutes of silence'),
|
||
h('label', {}, 'Open the incident at severity ', severity),
|
||
problem,
|
||
h('div', { class: 'sheet-actions' },
|
||
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
|
||
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Add switch' })));
|
||
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
await api.createDeadmanSwitch(teamID, {
|
||
name: name.value.trim(),
|
||
matcher: matcher.value.trim(),
|
||
timeout_seconds: Math.round(Number(timeout.value) * 60),
|
||
severity: severity.value,
|
||
});
|
||
} catch (err) {
|
||
problem.textContent = err.message;
|
||
problem.hidden = false;
|
||
return;
|
||
}
|
||
closeSheet(true);
|
||
refresh();
|
||
});
|
||
|
||
openSheet(() => [h('h2', { class: 'sheet-title', text: 'New switch' }), form]);
|
||
}
|
||
|
||
// --- members ---------------------------------------------------------------
|
||
|
||
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' });
|
||
}
|
||
|
||
function membersCard() {
|
||
const members = data.members || [];
|
||
const owners = members.filter((m) => m.role === 'owner').length;
|
||
|
||
const rows = members.map((m) => {
|
||
const lastOwner = m.role === 'owner' && owners === 1;
|
||
return 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) })),
|
||
h('td', {}, isOwner() && h('div', { class: 'row-actions' },
|
||
h('button', {
|
||
class: 'btn-sm', 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: () => openEditMember(m),
|
||
}),
|
||
h('button', {
|
||
class: 'btn-sm 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 () => {
|
||
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));
|
||
},
|
||
}))),
|
||
);
|
||
});
|
||
|
||
return h('div', { class: 'card' },
|
||
h('div', { class: 'card-head' },
|
||
h('h2', { text: 'Members' }),
|
||
isOwner() && h('button', {
|
||
class: 'btn', 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: 'Member' }), h('th', { text: 'Role' }),
|
||
h('th', { text: 'Rota' }), h('th', { text: 'Last active' }), h('th', { text: 'Joined' }),
|
||
h('th'))),
|
||
h('tbody', {}, rows)))
|
||
: h('p', { class: 'muted', text: 'Nobody is in this team.' }),
|
||
);
|
||
}
|
||
|
||
// 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();
|
||
}
|