Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e77f04b55e | |||
| e536fdd2c0 | |||
| 429d5fdda3 | |||
| 3cdd5aee1f |
@@ -48,10 +48,10 @@ curl -H "Authorization: Bearer $KEY" http://localhost:8080/api/users
|
||||
The server serves a web UI at `/`: the incident queue, each incident's alerts
|
||||
and timeline with every action (acknowledge, assign, snooze, note, resolve,
|
||||
archive), who is on call, the alert feed, and an *Account* tab for your own
|
||||
password and the ntfy topic your pages go to. It is built for a phone first. On a phone it has a bottom tab bar and a sticky action
|
||||
bar, it follows the system's dark mode, and it can be added to the home screen.
|
||||
From 900px wide it switches to a sidebar with the queue and the incident side by
|
||||
side. Statistics remain in
|
||||
password and the ntfy topic your pages go to. It is built for a phone first. On a phone
|
||||
it navigates through a hamburger menu and has a sticky action bar, it follows the
|
||||
system's dark mode, and it can be added to the home screen. From 900px wide it switches
|
||||
to a sidebar with the queue and the incident side by side. Statistics remain in
|
||||
[terdut-tui](https://github.com/yeniklas/terdut-tui) for now.
|
||||
|
||||
You sign in with a username and password. Users have no password until one is
|
||||
@@ -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.20.0
|
||||
appVersion: "v0.20.0"
|
||||
|
||||
+35
-35
@@ -48,7 +48,11 @@
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
|
||||
--topbar-h: 52px;
|
||||
--tabbar-h: 58px;
|
||||
/* No bottom tab bar on any breakpoint any more — mobile uses the hamburger
|
||||
menu in the topbar, desktop the sidebar — so this stays 0. Kept as a
|
||||
variable rather than deleted since .view, .toast and .nav's own height
|
||||
calc still read it. */
|
||||
--tabbar-h: 0px;
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
@@ -196,7 +200,13 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
-webkit-backdrop-filter: saturate(1.4) blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.topbar-title { font-size: 18px; font-weight: 700; letter-spacing: -0.01em; }
|
||||
.topbar-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.topbar-title {
|
||||
font-size: 18px; font-weight: 700; letter-spacing: -0.01em;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
|
||||
}
|
||||
#menu-btn { position: relative; }
|
||||
.nav-badge.menu-btn-badge { top: 2px; left: auto; right: 2px; }
|
||||
|
||||
.open-pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
@@ -209,15 +219,12 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
.open-pill.has-triggered::before { background: var(--crit); }
|
||||
.open-pill.all-acked::before { background: var(--warn); }
|
||||
|
||||
/* Bottom tab bar on phones. */
|
||||
/* Hidden on phones — mobile navigates through the hamburger menu in the
|
||||
topbar instead (see #menu-btn / openNavMenu in app.js). Reappears as the
|
||||
left sidebar from 900px, where the desktop block below redeclares display. */
|
||||
.nav {
|
||||
display: none;
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
||||
/* One column per link, however many there are. This was repeat(4, 1fr) when
|
||||
there were four tabs; Team and Admin arriving pushed six items into four
|
||||
columns, which on a phone is how they stopped fitting. Auto columns mean
|
||||
the next tab cannot break the row either — and Admin is only rendered for
|
||||
an administrator, so the count genuinely varies between viewers. */
|
||||
display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
|
||||
height: calc(var(--tabbar-h) + var(--safe-bottom));
|
||||
padding-bottom: var(--safe-bottom);
|
||||
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
||||
@@ -239,16 +246,6 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
}
|
||||
.nav-link svg { width: 24px; height: 24px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
/* Narrow phones, where six tabs each get about 55-65px. Tightening is enough:
|
||||
the widest label, "On-call", is about 38px at this size, so nothing has to
|
||||
be hidden. The ellipsis above is the backstop if a future tab is named
|
||||
something longer. */
|
||||
@media (max-width: 420px) {
|
||||
.nav-link { font-size: 10px; gap: 1px; }
|
||||
.nav-link svg { width: 21px; height: 21px; }
|
||||
.nav-badge { left: calc(50% + 4px); min-width: 16px; height: 16px; font-size: 10px; line-height: 16px; }
|
||||
}
|
||||
|
||||
.nav-link[aria-current="page"] { color: var(--accent); }
|
||||
.nav-badge {
|
||||
position: absolute; top: 6px; left: calc(50% + 6px);
|
||||
@@ -601,8 +598,6 @@ kbd {
|
||||
/* ---------- desktop ---------- */
|
||||
|
||||
@media (min-width: 900px) {
|
||||
:root { --tabbar-h: 0px; }
|
||||
|
||||
.app { display: grid; grid-template-columns: 220px 1fr; height: 100dvh; }
|
||||
|
||||
.nav {
|
||||
@@ -836,11 +831,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 +855,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; }
|
||||
|
||||
@@ -108,7 +108,13 @@
|
||||
</nav>
|
||||
|
||||
<header class="topbar">
|
||||
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
||||
<div class="topbar-left">
|
||||
<button class="btn btn-ghost btn-icon" id="menu-btn" type="button" aria-label="Menu" aria-haspopup="menu">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||
<span class="nav-badge menu-btn-badge" data-badge hidden></span>
|
||||
</button>
|
||||
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
||||
</div>
|
||||
<span class="open-pill" id="open-pill" hidden></span>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -30,6 +30,17 @@ const SECTIONS = {
|
||||
more: { title: 'Account', view: account },
|
||||
};
|
||||
|
||||
// The mobile hamburger menu's contents — the same sections the desktop
|
||||
// sidebar's .nav-link list carries in index.html, in the same order.
|
||||
const NAV_ITEMS = [
|
||||
{ path: '/', section: 'queue', label: 'Queue', icon: 'queueList' },
|
||||
{ path: '/oncall', section: 'oncall', label: 'On-call', icon: 'calendar' },
|
||||
{ path: '/alerts', section: 'alerts', label: 'Alerts', icon: 'bell' },
|
||||
{ path: '/team', section: 'team', label: 'Team', icon: 'team' },
|
||||
{ path: '/admin', section: 'admin', label: 'Admin', icon: 'shield', adminOnly: true },
|
||||
{ path: '/more', section: 'more', label: 'Account', icon: 'user' },
|
||||
];
|
||||
|
||||
function parseRoute(pathname) {
|
||||
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
|
||||
if (m) return { section: 'queue', incident: Number(m[1]) };
|
||||
@@ -40,22 +51,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);
|
||||
@@ -128,6 +143,35 @@ function render() {
|
||||
updateTitle();
|
||||
}
|
||||
|
||||
// ---------- nav menu ----------
|
||||
|
||||
// The mobile hamburger menu: same shape as the sheet-based action menus in
|
||||
// incident.js (openSheet + a <ul class="menu"> of menu-item buttons), one
|
||||
// item per NAV_ITEMS entry, resolving with a path for navigate() to use.
|
||||
function openNavMenu() {
|
||||
const current = SECTIONS[route.section].nav || route.section;
|
||||
const triggered = state.open.filter((i) => i.status === 'triggered').length;
|
||||
const items = NAV_ITEMS.filter((n) => !n.adminOnly || state.me?.user?.is_admin);
|
||||
ui.openSheet(() => [
|
||||
ui.h('h2', { class: 'sheet-title', text: 'Sections' }),
|
||||
ui.h('ul', { class: 'menu', role: 'menu' }, items.map((n) =>
|
||||
ui.h('li', {}, ui.h('button', {
|
||||
class: 'menu-item',
|
||||
type: 'button',
|
||||
role: 'menuitemradio',
|
||||
'aria-checked': String(n.section === current),
|
||||
onclick: () => ui.closeSheet(n.path),
|
||||
},
|
||||
ui.icon(n.icon),
|
||||
n.label,
|
||||
n.section === 'queue' && triggered > 0 && ui.badge(String(triggered), 'st-triggered menu-sub'),
|
||||
))),
|
||||
),
|
||||
]).then((path) => {
|
||||
if (path) navigate(path);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- refresh + badges ----------
|
||||
|
||||
async function refresh() {
|
||||
@@ -154,9 +198,12 @@ function updateBadges() {
|
||||
pill.classList.toggle('has-triggered', triggered > 0);
|
||||
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
|
||||
|
||||
const badge = document.querySelector('[data-badge]');
|
||||
badge.hidden = triggered === 0;
|
||||
badge.textContent = String(triggered);
|
||||
// Two badges carry this count: the sidebar's Queue tab (desktop) and the
|
||||
// hamburger button (mobile) — only one of the two is ever visible at once.
|
||||
for (const badge of document.querySelectorAll('[data-badge]')) {
|
||||
badge.hidden = triggered === 0;
|
||||
badge.textContent = String(triggered);
|
||||
}
|
||||
updateTitle();
|
||||
}
|
||||
|
||||
@@ -176,6 +223,7 @@ async function boot() {
|
||||
document.addEventListener('keydown', onKey);
|
||||
$('login-form').addEventListener('submit', onLogin);
|
||||
$('signup-form').addEventListener('submit', onSignup);
|
||||
$('menu-btn').addEventListener('click', openNavMenu);
|
||||
|
||||
// /signup is the one route that works without a session.
|
||||
if (location.pathname.replace(/\/$/, '') === '/signup') {
|
||||
|
||||
+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
|
||||
|
||||
@@ -34,6 +34,10 @@ export function clear(el, ...children) {
|
||||
const ICONS = {
|
||||
back: ['M15 18l-6-6 6-6'],
|
||||
more: ['M5 12h.01M12 12h.01M19 12h.01'],
|
||||
queueList: ['M4 6h16M4 12h16M4 18h10'],
|
||||
calendar: ['rect:3.5,5,17,15,2', 'M3.5 10h17M8 3v4M16 3v4'],
|
||||
team: ['circle:9,8,3', 'circle:17,9,2.5', 'M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4'],
|
||||
shield: ['M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z'],
|
||||
check: ['M5 12.5l4.5 4.5L19 7'],
|
||||
checkCircle: ['M8 12.5l3 3 5-6', 'circle:12,12,9'],
|
||||
undo: ['M9 14L4 9l5-5', 'M4 9h10a6 6 0 0 1 0 12h-3'],
|
||||
@@ -64,6 +68,14 @@ export function icon(name, cls = 'icon') {
|
||||
node.setAttribute('cx', cx);
|
||||
node.setAttribute('cy', cy);
|
||||
node.setAttribute('r', r);
|
||||
} else if (d.startsWith('rect:')) {
|
||||
const [x, y, w, hgt, rx] = d.slice(5).split(',');
|
||||
node = document.createElementNS(SVG, 'rect');
|
||||
node.setAttribute('x', x);
|
||||
node.setAttribute('y', y);
|
||||
node.setAttribute('width', w);
|
||||
node.setAttribute('height', hgt);
|
||||
if (rx) node.setAttribute('rx', rx);
|
||||
} else {
|
||||
node = document.createElementNS(SVG, 'path');
|
||||
node.setAttribute('d', d);
|
||||
@@ -160,6 +172,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