Give the Team tab sub-sections of its own
The Team tab was five cards stacked on one page: the rota, the escalation ladder, the alert sources, the dead man's switches and the membership.07914d5split the Admin tab for three reasons, and all three were sharper here. There was no way to link somebody to the escalation ladder, which is the thing a team owner most often has to be talked through. There was no way to the switches but scrolling past a month of rota -- and the rota became a month grid in v0.18.0, which made the page taller rather than shorter. And the poll loop refetched six endpoints every tick however little of the page you were looking at. Each is now a route: /team/rota, /team/members, /team/escalation, /team/sources, /team/deadman, reached from the same strip of links the Admin tab uses, with /team an overview. A page fetches only what it shows, so the switches are one GET and the sources are one, where every tick used to be six. Three of the five fetch the member list besides their own endpoint, and for the same reason each time: a rota entry, 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 -- who is on call today, how many members and owners, how many ladder levels and whether a fallback follows them, how many keys and how many never used, how many switches. That is what it is for; a strip that already links to the five does not need a second menu that repeats it. team.js owns the table of its six routes, as admin.js owns its four, and app.js parses against both rather than keeping a third list to drift from them. The table carries a title beside the label where the strip's word is too thin to name a page on its own: "Sources" is a fine tab and a poor browser tab, so that page titles as Alert sources and the switches keep their apostrophe in the top bar. menuItem left admin.js for ui.js as menuCard, since both tabs now open on one, and its CSS went from .admin-menu* to .overview-*. That is the rename .user-link -> .row-link was in v0.18.0, for the same reason: the class was named after the first page that used it rather than after what it is. The read-only notice a member sees is now on the overview only. It explains why the controls further down are missing, and a page that is nothing but the rota grid has no controls to explain. The team picker sits above the strip, because it changes the subject of all five, and it drops the ladder draft when it moves -- an unsaved edit belongs to the team it was started in. No server change. Extensionless paths already fall back to index.html, so /team/rota survives a reload the way /admin/users/{id} does, and no endpoint, payload or permission moved. Nobody has looked at this in a browser, the caveat07914d5anda6fa673carried. What is checked is the wiring, and rather more of it than last time: every sub-page was rendered against a stub fetch and a pocket DOM, each with exactly one aria-current and fetching only the endpoints named above; and app.js itself was booted the same way and walked through all seventeen URLs the app has, which resolve to one section each with the right title -- the six new ones, the four Admin ones, both subject pages, and /incidents/42 and /nonsense still falling to the queue. Whether six entries scroll cleanly at phone width is not checked. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
This commit is contained in:
@@ -85,11 +85,16 @@ How a browser stays signed in:
|
|||||||
With `TERDUT_PUBLIC_URL` set, tapping a push notification opens the incident in
|
With `TERDUT_PUBLIC_URL` set, tapping a push notification opens the incident in
|
||||||
the web UI (`/incidents/{id}`).
|
the web UI (`/incidents/{id}`).
|
||||||
|
|
||||||
A **Team** tab holds everything a team owns: the on-call rota, the escalation
|
A **Team** tab holds everything a team owns, in five sub-sections with a URL
|
||||||
ladder, the alert sources with their keys, the dead man's switches and the
|
each and a strip across the top to move between them: the on-call rota
|
||||||
membership. An owner edits it; a member sees the same page read-only, because
|
(`/team/rota`), the membership (`/team/members`), the escalation ladder
|
||||||
the server refuses their writes anyway. Somebody in more than one team picks
|
(`/team/escalation`), the alert sources with their keys (`/team/sources`) and
|
||||||
between them at the top.
|
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
|
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
|
underneath, and it says how many days are left uncovered — the question a rota
|
||||||
|
|||||||
+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); }
|
.signup-intro { margin: 0 0 4px; font-size: 14px; color: var(--muted); }
|
||||||
|
|
||||||
/* --- admin sub-navigation ------------------------------------------------
|
/* --- sub-navigation -------------------------------------------------------
|
||||||
A strip of links across the top of every admin page, one per sub-section.
|
A strip of links across the top of every Admin and every Team page, one per
|
||||||
Deliberately not .chip: chips filter what a page already shows, here and in
|
sub-section. Deliberately not .chip: chips filter what a page already shows,
|
||||||
the queue, and these four go somewhere. Same aria-current convention as the
|
here and in the queue, and these go somewhere. Same aria-current convention
|
||||||
tab bar, so the state lives on the attribute rather than in a class. */
|
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 {
|
.subnav {
|
||||||
display: flex; gap: 2px;
|
display: flex; gap: 2px;
|
||||||
margin: 12px auto 0;
|
margin: 12px auto 0;
|
||||||
@@ -857,13 +860,15 @@ button.rota-day:hover { background: var(--surface-2); }
|
|||||||
.subnav-link:hover { color: var(--text); }
|
.subnav-link:hover { color: var(--text); }
|
||||||
.subnav-link[aria-current="page"] { color: var(--accent); border-bottom-color: var(--accent); }
|
.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
|
/* The overview a tab opens on, at /admin and at /team. The strip above already
|
||||||
carry the counts, which is the part a menu cannot say. */
|
links to the sections, so these carry the counts, which is the part a menu
|
||||||
.admin-menu { display: grid; gap: 10px; margin-top: 16px; }
|
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. */
|
/* The grid's gap is the spacing here, so .card + .card must not add its own. */
|
||||||
.admin-menu .card + .card { margin-top: 0; }
|
.overview-menu .card + .card { margin-top: 0; }
|
||||||
.admin-menu-item { display: block; padding: 14px; }
|
.overview-item { display: block; padding: 14px; }
|
||||||
.admin-menu-item:hover { background: var(--surface-hover); }
|
.overview-item:hover { background: var(--surface-hover); }
|
||||||
.admin-menu-head { display: flex; align-items: baseline; gap: 8px; }
|
.overview-head { display: flex; align-items: baseline; gap: 8px; }
|
||||||
.admin-menu-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
|
.overview-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
|
||||||
.admin-menu-item p { margin: 4px 0 0; }
|
.overview-item p { margin: 4px 0 0; }
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
// gate.
|
// gate.
|
||||||
|
|
||||||
import * as api from './api.js';
|
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';
|
import { state, myID } from './state.js';
|
||||||
|
|
||||||
const view = () => document.getElementById('view-admin');
|
const view = () => document.getElementById('view-admin');
|
||||||
@@ -123,26 +123,17 @@ function overview() {
|
|||||||
const people = [`${admins} ${admins === 1 ? 'administrator' : 'administrators'}`];
|
const people = [`${admins} ${admins === 1 ? 'administrator' : 'administrators'}`];
|
||||||
if (disabled > 0) people.push(`${disabled} disabled`);
|
if (disabled > 0) people.push(`${disabled} disabled`);
|
||||||
|
|
||||||
return h('div', { class: 'admin-menu' },
|
return h('div', { class: 'overview-menu' },
|
||||||
menuItem('/admin/teams', 'Teams', data.teams.length,
|
menuCard('/admin/teams', 'Teams', data.teams.length,
|
||||||
open > 0
|
open > 0
|
||||||
? `${open} open ${open === 1 ? 'incident' : 'incidents'} between them.`
|
? `${open} open ${open === 1 ? 'incident' : 'incidents'} between them.`
|
||||||
: 'Nothing open anywhere.'),
|
: 'Nothing open anywhere.'),
|
||||||
menuItem('/admin/users', 'Users', data.users.length, `${people.join(', ')}.`),
|
menuCard('/admin/users', 'Users', data.users.length, `${people.join(', ')}.`),
|
||||||
menuItem('/admin/settings', 'Settings', null,
|
menuCard('/admin/settings', 'Settings', null,
|
||||||
'How the server behaves, and where it is plugged in.'),
|
'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 -----------------------------------------------------------------
|
// --- teams -----------------------------------------------------------------
|
||||||
|
|
||||||
function teamsCard() {
|
function teamsCard() {
|
||||||
|
|||||||
@@ -40,22 +40,26 @@ function parseRoute(pathname) {
|
|||||||
const g = pathname.match(/^\/admin\/teams\/(\d+)\/?$/);
|
const g = pathname.match(/^\/admin\/teams\/(\d+)\/?$/);
|
||||||
if (g) return { section: 'adminteam', team: Number(g[1]) };
|
if (g) return { section: 'adminteam', team: Number(g[1]) };
|
||||||
const name = pathname.replace(/^\/|\/$/g, '');
|
const name = pathname.replace(/^\/|\/$/g, '');
|
||||||
// The Admin tab's sub-sections are routes of their own. admin.js owns the
|
// The Admin and Team tabs' sub-sections are routes of their own. Each view
|
||||||
// table of them, since it also builds the strip that links to them.
|
// 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}`);
|
const t = admin.TABS.find((x) => x.path === `/${name}`);
|
||||||
if (t) return { section: 'admin', tab: t.tab };
|
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 };
|
return { section: 'queue', incident: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the top bar and the document title call this route. Admin's sub-sections
|
// What the top bar and the document title call this route. The sub-sections of
|
||||||
// are pages in their own right, so they say which one rather than "Admin" four
|
// Admin and Team are pages in their own right, so they say which one rather
|
||||||
// times; the overview keeps the tab's own name.
|
// 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) {
|
function title(r) {
|
||||||
const t = r.section === 'admin' && r.tab
|
const tabs = r.section === 'admin' ? admin.TABS : r.section === 'team' ? team.TABS : null;
|
||||||
? admin.TABS.find((x) => x.tab === r.tab)
|
const t = tabs && r.tab ? tabs.find((x) => x.tab === r.tab) : null;
|
||||||
: null;
|
return t ? (t.title || t.label) : SECTIONS[r.section].title;
|
||||||
return t ? t.label : SECTIONS[r.section].title;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let route = parseRoute(location.pathname);
|
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,
|
// One team: the rota, who is in it, where its alerts come from, what it
|
||||||
// what it escalates through, and which of its alerts are heartbeats.
|
// escalates through, and which of its alerts are heartbeats.
|
||||||
//
|
//
|
||||||
// Everything here was API-only until now, which meant a team owner had to use
|
// Everything here was API-only until v0.12.0, which meant a team owner had to
|
||||||
// curl to set up escalation — the feature this whole line of work exists for.
|
// 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
|
// 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
|
// 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.
|
// than no form, but it is not the thing enforcing anything.
|
||||||
|
|
||||||
import * as api from './api.js';
|
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 { state, currentTeam, users as allUsers, myID } from './state.js';
|
||||||
import { isoDate, addDays, mondayOf, initial } from './format.js';
|
import { isoDate, addDays, mondayOf, initial } from './format.js';
|
||||||
|
|
||||||
const view = () => document.getElementById('view-team');
|
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 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 error = null;
|
||||||
let freshKey = null; // an integration key, shown once, until the view is left
|
let freshKey = null; // an integration key, shown once, until the view is left
|
||||||
|
|
||||||
export function show() {
|
export function show(route) {
|
||||||
if (!data) clear(view(), spinner());
|
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();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,18 +76,8 @@ export async function refresh() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
teamID = team.id;
|
teamID = team.id;
|
||||||
const grid = gridDays();
|
|
||||||
try {
|
try {
|
||||||
// A member may read all of this; only the writes are owner-only.
|
data = { team, ...(await load(team.id)) };
|
||||||
const [members, integrations, escalation, deadman, schedule, users] = await Promise.all([
|
|
||||||
api.teamMembers(team.id),
|
|
||||||
api.integrations(team.id),
|
|
||||||
api.escalation(team.id),
|
|
||||||
api.deadman(team.id),
|
|
||||||
api.schedule(team.id, isoDate(grid.start), isoDate(addDays(grid.start, grid.count - 1))),
|
|
||||||
allUsers(),
|
|
||||||
]);
|
|
||||||
data = { team, members, integrations, escalation, deadman, schedule, users };
|
|
||||||
error = null;
|
error = null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message;
|
error = err.message;
|
||||||
@@ -58,6 +85,45 @@ export async function refresh() {
|
|||||||
render();
|
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() {
|
function isOwner() {
|
||||||
return data?.team?.role === 'owner' || state.me?.user?.is_admin;
|
return data?.team?.role === 'owner' || state.me?.user?.is_admin;
|
||||||
}
|
}
|
||||||
@@ -70,19 +136,43 @@ function render() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clear(view(),
|
clear(view(),
|
||||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
subnav(),
|
||||||
teamPicker(),
|
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.' })),
|
h('p', { class: 'muted small', text: 'You are a member of this team. Only an owner can change its settings.' })),
|
||||||
scheduleCard(),
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||||
escalationCard(),
|
section(),
|
||||||
integrationsCard(),
|
|
||||||
deadmanCard(),
|
|
||||||
membersCard(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
// 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() {
|
function teamPicker() {
|
||||||
if ((state.teams || []).length < 2) {
|
if ((state.teams || []).length < 2) {
|
||||||
return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
|
return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
|
||||||
@@ -95,11 +185,45 @@ function teamPicker() {
|
|||||||
teamID = Number(select.value);
|
teamID = Number(select.value);
|
||||||
data = null;
|
data = null;
|
||||||
freshKey = null;
|
freshKey = null;
|
||||||
show();
|
draft = null;
|
||||||
|
refresh();
|
||||||
});
|
});
|
||||||
return h('div', { class: 'card' }, h('h2', { text: 'Team' }), select);
|
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 --------------------------------------------------------------
|
// --- schedule --------------------------------------------------------------
|
||||||
|
|
||||||
// The rota is one person per UTC day. The on-call page shows it; this is where
|
// 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 }));
|
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) {
|
export function emptyState(title, text, iconName) {
|
||||||
return h('div', { class: 'empty' },
|
return h('div', { class: 'empty' },
|
||||||
iconName && icon(iconName),
|
iconName && icon(iconName),
|
||||||
|
|||||||
Reference in New Issue
Block a user