5b4683febf
Team membership from single sign-on used to come from one env var,
TERDUT_OIDC_GROUP_MAPPINGS, matched against a team by name and creating
the team if none existed. That put the decision in the server's
environment rather than the team's own hands, needed a restart to
change, and let a typo in a team name silently create a stray team.
Each team now carries its own oidc_member_group and oidc_owner_group,
set by its owner (or an administrator) from the Members tab, or PUT
/api/teams/{teamID}/oidc-groups. The "highest role wins" rule
TERDUT_OIDC_GROUP_MAPPINGS used to apply across mappings now applies
across one team's own two fields: being in both makes somebody an
owner. The sync no longer creates a team by name; a group only ever
grants into a team that already exists.
This is a breaking change for anyone already using
TERDUT_OIDC_GROUP_MAPPINGS, deliberately not auto-migrated: an
OIDC-sourced membership is dropped at a user's next sign-in until its
team's owner re-sets the group. The README's OIDC section spells out
the migration and the risk of a visible access gap during it.
TERDUT_OIDC_ADMIN_GROUP and TERDUT_OIDC_ALLOWED_GROUPS are untouched --
only team membership moved. terdut-tui needs no change: it only reads
GET /api/teams and GET /api/teams/{id}/members, and neither response
shape moved.
1257 lines
50 KiB
JavaScript
1257 lines
50 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, oidcGroups] = await Promise.all([
|
||
api.teamMembers(id),
|
||
allUsers(),
|
||
state.auth?.oidc?.enabled ? api.oidcGroups(id) : null,
|
||
]);
|
||
return { members, users, oidcGroups };
|
||
}
|
||
if (tab === 'escalation') {
|
||
const [members, escalation] = await Promise.all([api.teamMembers(id), api.escalation(id)]);
|
||
return { members, escalation };
|
||
}
|
||
if (tab === 'sources') return { integrations: await api.integrations(id) };
|
||
if (tab === 'deadman') return { deadman: await api.deadmanSwitches(id) };
|
||
|
||
const grid = gridDays();
|
||
const [members, integrations, escalation, deadman, schedule] = await Promise.all([
|
||
api.teamMembers(id),
|
||
api.integrations(id),
|
||
api.escalation(id),
|
||
api.deadmanSwitches(id),
|
||
api.schedule(id, isoDate(grid.start), isoDate(addDays(grid.start, grid.count - 1))),
|
||
]);
|
||
return { members, integrations, escalation, deadman, schedule };
|
||
}
|
||
|
||
function isOwner() {
|
||
return data?.team?.role === 'owner' || state.me?.user?.is_admin;
|
||
}
|
||
|
||
function render() {
|
||
if (!data) {
|
||
clear(view(), error
|
||
? h('div', { class: 'load-error', text: error })
|
||
: h('div', { class: 'card' }, h('p', { class: 'muted', text: 'You are not in a team yet.' })));
|
||
return;
|
||
}
|
||
clear(view(),
|
||
subnav(),
|
||
teamPicker(),
|
||
// Said once on the overview rather than on all six pages: it explains why
|
||
// the controls further down are missing, and a page of nothing but the
|
||
// rota has no controls to explain.
|
||
!isOwner() && tab === null && h('div', { class: 'card' },
|
||
h('p', { class: 'muted small', text: 'You are a member of this team. Only an owner can change its settings.' })),
|
||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||
section(),
|
||
);
|
||
}
|
||
|
||
function section() {
|
||
if (tab === 'rota') return scheduleCard();
|
||
if (tab === 'members') return membersCard();
|
||
if (tab === 'escalation') return escalationCard();
|
||
if (tab === 'sources') return integrationsCard();
|
||
if (tab === 'deadman') return deadmanCard();
|
||
return overview();
|
||
}
|
||
|
||
// The strip across the top of every team page. Ordinary links rather than
|
||
// buttons, because these are six URLs: app.js intercepts the click, the
|
||
// browser's Back walks them, and a reload lands where you were.
|
||
function subnav() {
|
||
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' });
|
||
}
|
||
|
||
// The team's own OIDC group binding, shown only on an SSO-enabled install:
|
||
// which group grants membership and which grants ownership. Read-only text
|
||
// for a member, an edit sheet for an owner — the server enforces the same
|
||
// split on the endpoint underneath.
|
||
function oidcGroupsCard() {
|
||
if (!state.auth?.oidc?.enabled) return null;
|
||
const g = data.oidcGroups || { member_group: '', owner_group: '' };
|
||
return h('div', { class: 'card' },
|
||
h('div', { class: 'card-head' },
|
||
h('h2', { text: 'Single sign-on' }),
|
||
isOwner() && h('button', {
|
||
class: 'btn', type: 'button', text: 'Edit', onclick: openOidcGroupsEditor,
|
||
})),
|
||
h('p', { class: 'muted small' },
|
||
'Members of the group below are added to this team automatically at ',
|
||
'sign-in; members of the owner group become owners. Leave a field ',
|
||
'blank to grant nothing this way.'),
|
||
h('dl', { class: 'user-facts' },
|
||
fact('Member group', g.member_group || '—'),
|
||
fact('Owner group', g.owner_group || '—'),
|
||
),
|
||
);
|
||
}
|
||
|
||
function fact(label, value) {
|
||
return [h('dt', { text: label }), h('dd', { text: value })];
|
||
}
|
||
|
||
function openOidcGroupsEditor() {
|
||
const g = data.oidcGroups || { member_group: '', owner_group: '' };
|
||
const memberGroup = h('input', {
|
||
type: 'text', value: g.member_group, placeholder: 'e.g. sre', autofocus: true,
|
||
});
|
||
const ownerGroup = h('input', { type: 'text', value: g.owner_group, placeholder: 'e.g. sre-leads' });
|
||
const problem = h('p', { class: 'load-error', hidden: true });
|
||
|
||
const form = h('form', { class: 'stacked-form' },
|
||
h('label', {}, 'Member group ', memberGroup),
|
||
h('label', {}, 'Owner group ', ownerGroup),
|
||
h('p', { class: 'muted small' },
|
||
'A person in both becomes an owner. Whoever the group lists is kept in ',
|
||
'sync at their next sign-in — a member added by hand can still be made ',
|
||
'an owner, but not the other way round.'),
|
||
problem,
|
||
h('div', { class: 'sheet-actions' },
|
||
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
|
||
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save' })));
|
||
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
await api.setOidcGroups(teamID, {
|
||
member_group: memberGroup.value.trim(),
|
||
owner_group: ownerGroup.value.trim(),
|
||
});
|
||
} catch (err) {
|
||
problem.textContent = err.message;
|
||
problem.hidden = false;
|
||
return;
|
||
}
|
||
closeSheet(true);
|
||
refresh();
|
||
});
|
||
|
||
openSheet(() => [h('h2', { class: 'sheet-title', text: 'Single sign-on groups' }), form]);
|
||
}
|
||
|
||
function membersCard() {
|
||
const members = data.members || [];
|
||
const owners = members.filter((m) => m.role === 'owner').length;
|
||
|
||
const rows = members.map((m) => {
|
||
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 [oidcGroupsCard(), 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();
|
||
}
|