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
|
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
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ type: application
|
|||||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
# 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
|
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||||
# metadata and drives nothing.
|
# metadata and drives nothing.
|
||||||
version: 0.18.0
|
version: 0.19.0
|
||||||
appVersion: "v0.18.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); }
|
.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