Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 429d5fdda3 | |||
| 3cdd5aee1f |
@@ -85,11 +85,16 @@ How a browser stays signed in:
|
||||
With `TERDUT_PUBLIC_URL` set, tapping a push notification opens the incident in
|
||||
the web UI (`/incidents/{id}`).
|
||||
|
||||
A **Team** tab holds everything a team owns: the on-call rota, the escalation
|
||||
ladder, the alert sources with their keys, the dead man's switches and the
|
||||
membership. An owner edits it; a member sees the same page read-only, because
|
||||
the server refuses their writes anyway. Somebody in more than one team picks
|
||||
between them at the top.
|
||||
A **Team** tab holds everything a team owns, in five sub-sections with a URL
|
||||
each and a strip across the top to move between them: the on-call rota
|
||||
(`/team/rota`), the membership (`/team/members`), the escalation ladder
|
||||
(`/team/escalation`), the alert sources with their keys (`/team/sources`) and
|
||||
the dead man's switches (`/team/deadman`). `/team` itself is an overview — who
|
||||
is on call today, how many members and owners, how many ladder levels, how many
|
||||
keys and how many switches — so a page fetches only what it shows. An owner
|
||||
edits it; a member sees the same pages read-only, because the server refuses
|
||||
their writes anyway. Somebody in more than one team picks between them above
|
||||
the strip, since the choice changes the subject of all five.
|
||||
|
||||
The rota is a month at a time, one coloured initial per day with a legend
|
||||
underneath, and it says how many days are left uncovered — the question a rota
|
||||
|
||||
@@ -15,5 +15,5 @@ type: application
|
||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
||||
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||
# metadata and drives nothing.
|
||||
version: 0.18.0
|
||||
appVersion: "v0.18.0"
|
||||
version: 0.19.0
|
||||
appVersion: "v0.19.0"
|
||||
|
||||
+19
-14
@@ -836,11 +836,14 @@ button.rota-day:hover { background: var(--surface-2); }
|
||||
|
||||
.signup-intro { margin: 0 0 4px; font-size: 14px; color: var(--muted); }
|
||||
|
||||
/* --- admin sub-navigation ------------------------------------------------
|
||||
A strip of links across the top of every admin page, one per sub-section.
|
||||
Deliberately not .chip: chips filter what a page already shows, here and in
|
||||
the queue, and these four go somewhere. Same aria-current convention as the
|
||||
tab bar, so the state lives on the attribute rather than in a class. */
|
||||
/* --- sub-navigation -------------------------------------------------------
|
||||
A strip of links across the top of every Admin and every Team page, one per
|
||||
sub-section. Deliberately not .chip: chips filter what a page already shows,
|
||||
here and in the queue, and these go somewhere. Same aria-current convention
|
||||
as the tab bar, so the state lives on the attribute rather than in a class.
|
||||
|
||||
Six entries do not fit a phone's width, which is what the horizontal scroll
|
||||
below is for -- the Team tab's strip is the one that needs it. */
|
||||
.subnav {
|
||||
display: flex; gap: 2px;
|
||||
margin: 12px auto 0;
|
||||
@@ -857,13 +860,15 @@ button.rota-day:hover { background: var(--surface-2); }
|
||||
.subnav-link:hover { color: var(--text); }
|
||||
.subnav-link[aria-current="page"] { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
/* The overview at /admin. The strip above already links to the three, so these
|
||||
carry the counts, which is the part a menu cannot say. */
|
||||
.admin-menu { display: grid; gap: 10px; margin-top: 16px; }
|
||||
/* The overview a tab opens on, at /admin and at /team. The strip above already
|
||||
links to the sections, so these carry the counts, which is the part a menu
|
||||
cannot say. Not named for either tab: both use it, and the one that renamed
|
||||
.user-link to .row-link is the same rename for the same reason. */
|
||||
.overview-menu { display: grid; gap: 10px; margin-top: 16px; }
|
||||
/* The grid's gap is the spacing here, so .card + .card must not add its own. */
|
||||
.admin-menu .card + .card { margin-top: 0; }
|
||||
.admin-menu-item { display: block; padding: 14px; }
|
||||
.admin-menu-item:hover { background: var(--surface-hover); }
|
||||
.admin-menu-head { display: flex; align-items: baseline; gap: 8px; }
|
||||
.admin-menu-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
|
||||
.admin-menu-item p { margin: 4px 0 0; }
|
||||
.overview-menu .card + .card { margin-top: 0; }
|
||||
.overview-item { display: block; padding: 14px; }
|
||||
.overview-item:hover { background: var(--surface-hover); }
|
||||
.overview-head { display: flex; align-items: baseline; gap: 8px; }
|
||||
.overview-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
|
||||
.overview-item p { margin: 4px 0 0; }
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// gate.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, spinner, confirm } from './ui.js';
|
||||
import { h, clear, spinner, confirm, menuCard } from './ui.js';
|
||||
import { state, myID } from './state.js';
|
||||
|
||||
const view = () => document.getElementById('view-admin');
|
||||
@@ -123,26 +123,17 @@ function overview() {
|
||||
const people = [`${admins} ${admins === 1 ? 'administrator' : 'administrators'}`];
|
||||
if (disabled > 0) people.push(`${disabled} disabled`);
|
||||
|
||||
return h('div', { class: 'admin-menu' },
|
||||
menuItem('/admin/teams', 'Teams', data.teams.length,
|
||||
return h('div', { class: 'overview-menu' },
|
||||
menuCard('/admin/teams', 'Teams', data.teams.length,
|
||||
open > 0
|
||||
? `${open} open ${open === 1 ? 'incident' : 'incidents'} between them.`
|
||||
: 'Nothing open anywhere.'),
|
||||
menuItem('/admin/users', 'Users', data.users.length, `${people.join(', ')}.`),
|
||||
menuItem('/admin/settings', 'Settings', null,
|
||||
menuCard('/admin/users', 'Users', data.users.length, `${people.join(', ')}.`),
|
||||
menuCard('/admin/settings', 'Settings', null,
|
||||
'How the server behaves, and where it is plugged in.'),
|
||||
);
|
||||
}
|
||||
|
||||
function menuItem(href, label, count, note) {
|
||||
return h('a', { class: 'card admin-menu-item', href },
|
||||
h('div', { class: 'admin-menu-head' },
|
||||
h('strong', { text: label }),
|
||||
count != null && h('span', { class: 'admin-menu-count', text: String(count) })),
|
||||
h('p', { class: 'muted small', text: note }),
|
||||
);
|
||||
}
|
||||
|
||||
// --- teams -----------------------------------------------------------------
|
||||
|
||||
function teamsCard() {
|
||||
|
||||
@@ -40,22 +40,26 @@ function parseRoute(pathname) {
|
||||
const g = pathname.match(/^\/admin\/teams\/(\d+)\/?$/);
|
||||
if (g) return { section: 'adminteam', team: Number(g[1]) };
|
||||
const name = pathname.replace(/^\/|\/$/g, '');
|
||||
// The Admin tab's sub-sections are routes of their own. admin.js owns the
|
||||
// table of them, since it also builds the strip that links to them.
|
||||
// The Admin and Team tabs' sub-sections are routes of their own. Each view
|
||||
// owns the table of its own, since each also builds the strip that links to
|
||||
// them; /team is in team.TABS as the overview, so it is matched here too.
|
||||
const t = admin.TABS.find((x) => x.path === `/${name}`);
|
||||
if (t) return { section: 'admin', tab: t.tab };
|
||||
if (name === 'oncall' || name === 'alerts' || name === 'team' || name === 'more') return { section: name };
|
||||
const tt = team.TABS.find((x) => x.path === `/${name}`);
|
||||
if (tt) return { section: 'team', tab: tt.tab };
|
||||
if (name === 'oncall' || name === 'alerts' || name === 'more') return { section: name };
|
||||
return { section: 'queue', incident: null };
|
||||
}
|
||||
|
||||
// What the top bar and the document title call this route. Admin's sub-sections
|
||||
// are pages in their own right, so they say which one rather than "Admin" four
|
||||
// times; the overview keeps the tab's own name.
|
||||
// What the top bar and the document title call this route. The sub-sections of
|
||||
// Admin and Team are pages in their own right, so they say which one rather
|
||||
// than the tab's name four or six times; either overview keeps the tab's own
|
||||
// name. A tab may carry a `title` where its strip label is too short to name a
|
||||
// page on its own.
|
||||
function title(r) {
|
||||
const t = r.section === 'admin' && r.tab
|
||||
? admin.TABS.find((x) => x.tab === r.tab)
|
||||
: null;
|
||||
return t ? t.label : SECTIONS[r.section].title;
|
||||
const tabs = r.section === 'admin' ? admin.TABS : r.section === 'team' ? team.TABS : null;
|
||||
const t = tabs && r.tab ? tabs.find((x) => x.tab === r.tab) : null;
|
||||
return t ? (t.title || t.label) : SECTIONS[r.section].title;
|
||||
}
|
||||
|
||||
let route = parseRoute(location.pathname);
|
||||
|
||||
+151
-27
@@ -1,8 +1,16 @@
|
||||
// Team settings: the rota, who is in the team, where its alerts come from,
|
||||
// what it escalates through, and which of its alerts are heartbeats.
|
||||
// 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 now, which meant a team owner had to use
|
||||
// curl to set up escalation — the feature this whole line of work exists for.
|
||||
// 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
|
||||
@@ -10,19 +18,48 @@
|
||||
// than no form, but it is not the thing enforcing anything.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, spinner, confirm, icon, openSheet, closeSheet } from './ui.js';
|
||||
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard } from './ui.js';
|
||||
import { state, currentTeam, users as allUsers, myID } from './state.js';
|
||||
import { isoDate, addDays, mondayOf, initial } from './format.js';
|
||||
|
||||
const view = () => document.getElementById('view-team');
|
||||
|
||||
// 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;
|
||||
let data = null; // { team, members, integrations, escalation, deadman, schedule, users }
|
||||
// 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() {
|
||||
if (!data) clear(view(), spinner());
|
||||
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. The
|
||||
// ladder draft goes with it: it is an edit of the page being left.
|
||||
if (next !== tab) {
|
||||
tab = next;
|
||||
data = null;
|
||||
draft = null;
|
||||
}
|
||||
if (!data) clear(view(), subnav(), spinner());
|
||||
refresh();
|
||||
}
|
||||
|
||||
@@ -39,18 +76,8 @@ export async function refresh() {
|
||||
return;
|
||||
}
|
||||
teamID = team.id;
|
||||
const grid = gridDays();
|
||||
try {
|
||||
// A member may read all of this; only the writes are owner-only.
|
||||
const [members, integrations, escalation, deadman, schedule, users] = await Promise.all([
|
||||
api.teamMembers(team.id),
|
||||
api.integrations(team.id),
|
||||
api.escalation(team.id),
|
||||
api.deadman(team.id),
|
||||
api.schedule(team.id, isoDate(grid.start), isoDate(addDays(grid.start, grid.count - 1))),
|
||||
allUsers(),
|
||||
]);
|
||||
data = { team, members, integrations, escalation, deadman, schedule, users };
|
||||
data = { team, ...(await load(team.id)) };
|
||||
error = null;
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
@@ -58,6 +85,45 @@ export async function refresh() {
|
||||
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.deadman(id) };
|
||||
|
||||
const grid = gridDays();
|
||||
const [members, integrations, escalation, deadman, schedule] = await Promise.all([
|
||||
api.teamMembers(id),
|
||||
api.integrations(id),
|
||||
api.escalation(id),
|
||||
api.deadman(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;
|
||||
}
|
||||
@@ -70,19 +136,43 @@ function render() {
|
||||
return;
|
||||
}
|
||||
clear(view(),
|
||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||
subnav(),
|
||||
teamPicker(),
|
||||
!isOwner() && h('div', { class: 'card' },
|
||||
// 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.' })),
|
||||
scheduleCard(),
|
||||
escalationCard(),
|
||||
integrationsCard(),
|
||||
deadmanCard(),
|
||||
membersCard(),
|
||||
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 }));
|
||||
@@ -95,11 +185,45 @@ function teamPicker() {
|
||||
teamID = Number(select.value);
|
||||
data = null;
|
||||
freshKey = null;
|
||||
show();
|
||||
draft = 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?.matchers || '')
|
||||
.split(';').map((x) => x.trim()).filter(Boolean).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 ? 'Alerts whose absence opens an incident.' : 'Nothing watched.'),
|
||||
);
|
||||
}
|
||||
|
||||
// --- schedule --------------------------------------------------------------
|
||||
|
||||
// The rota is one person per UTC day. The on-call page shows it; this is where
|
||||
|
||||
@@ -160,6 +160,18 @@ export function labelChip(k, v) {
|
||||
return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: v }));
|
||||
}
|
||||
|
||||
// One entry in a section's overview: a card that is a link, carrying the count
|
||||
// only that section can state. Both the Admin tab and the Team tab open on one
|
||||
// of these menus, and a menu item is a shape rather than a page's own idea.
|
||||
export function menuCard(href, label, count, note) {
|
||||
return h('a', { class: 'card overview-item', href },
|
||||
h('div', { class: 'overview-head' },
|
||||
h('strong', { text: label }),
|
||||
count != null && h('span', { class: 'overview-count', text: String(count) })),
|
||||
h('p', { class: 'muted small', text: note }),
|
||||
);
|
||||
}
|
||||
|
||||
export function emptyState(title, text, iconName) {
|
||||
return h('div', { class: 'empty' },
|
||||
iconName && icon(iconName),
|
||||
|
||||
Reference in New Issue
Block a user