diff --git a/README.md b/README.md index bf70c4c..4f1b560 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 8002803..d7e790f 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -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; } diff --git a/internal/web/static/js/admin.js b/internal/web/static/js/admin.js index 1e2a766..1f5af30 100644 --- a/internal/web/static/js/admin.js +++ b/internal/web/static/js/admin.js @@ -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() { diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js index 4c96e2d..5e89698 100644 --- a/internal/web/static/js/app.js +++ b/internal/web/static/js/app.js @@ -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); diff --git a/internal/web/static/js/team.js b/internal/web/static/js/team.js index 8ae2dee..1715ed8 100644 --- a/internal/web/static/js/team.js +++ b/internal/web/static/js/team.js @@ -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 diff --git a/internal/web/static/js/ui.js b/internal/web/static/js/ui.js index e9fe384..93209bc 100644 --- a/internal/web/static/js/ui.js +++ b/internal/web/static/js/ui.js @@ -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),