Compare commits

...

6 Commits

Author SHA1 Message Date
Niklas Ye 43f004499b Set the chart's placeholder version to 0.20.1
CI / chart (push) Successful in 1s
CI / security (push) Successful in 13s
CI / test (push) Successful in 2m30s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 15s
Release / image (push) Successful in 55s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as e77f04b and 429d5fd, so a
tree heading for v0.20.1 doesn't say 0.20.0.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-22 22:42:13 +02:00
Niklas Ye 559be6de6e Retry the first database ping instead of dying on it
Every start crashed once or twice before going healthy: kube-router
enforces this namespace's NetworkPolicy per-node, reacting to the new
pod's creation event, and the app's first connection attempt can
reach Postgres's node before that node's allow-set has been updated
with the new pod's IP. The result is "connection refused" -- an
active reject, not a timeout, which is how it was told apart from
Postgres itself not being ready (it had been up for two days in the
run that was diagnosed).

That race resolves within several seconds in practice, so Open now
retries the ping up to five times, two seconds apart, logging each
failure, before giving up with the same wrapped error as before.
Nothing else about Open's behaviour changed: a genuinely absent
database still fails, just after ~8s instead of immediately.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-22 22:42:05 +02:00
Niklas Ye e77f04b55e Set the chart's placeholder version to 0.20.0
CI / chart (push) Successful in 2s
CI / security (push) Successful in 20s
CI / test (push) Successful in 2m35s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 18s
Release / image (push) Successful in 55s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as 429d5fd and 6a03698, so a
tree heading for v0.20.0 doesn't say 0.19.0.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-22 21:47:25 +02:00
Niklas Ye e536fdd2c0 Replace the mobile tab bar with a hamburger menu
Six tabs (Queue, On-call, Alerts, Team, Admin, Account) had already
outgrown the bottom bar once: 56b8191 let it auto-size its columns to
fit however many there were, but on a phone that only left each tab
55-65px wide. Squeezing further wasn't an option, so the bar is gone
on mobile and a hamburger button in the topbar opens a menu instead.

The menu reuses the sheet + menu-item pattern already used for the
snooze and assign-to actions in incident.js, rather than a new overlay
component. It lists the same sections the sidebar does, including
hiding Admin for non-admins, and the triggered-incident badge that
used to sit on the Queue tab icon now shows on the hamburger button.

Desktop (>=900px) is untouched: the sidebar is the same markup, CSS
alone hides it below 900px and shows it above, so nothing there
changed behaviourally.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-22 21:47:15 +02:00
Niklas Ye 429d5fdda3 Set the chart's placeholder version to 0.19.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 13s
CI / test (push) Successful in 2m32s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 24s
Release / image (push) Successful in 55s
Release / scan-image (push) Successful in 2s
Cosmetic, and done anyway for the same reason as 6a03698, ee22eb0 and
7b9a337 before it: .gitea/workflows/release.yaml passes --version and
--app-version to `helm package` from the git tag, so neither line decides
anything about what is published. Being read is all they do, and a tree
heading for v0.19.0 that says 0.18.0 tells its reader something false.

appVersion keeps the v, per APPVERSION_PREFIX in .release.conf, and
image.tag in values.yaml stays "latest" -- that one is what a local
`helm install ./charts/terdut-server` actually pulls.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-22 12:23:21 +02:00
Niklas Ye 3cdd5aee1f 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.
07914d5 split 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 caveat 07914d5 and a6fa673
carried. 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
2026-09-22 12:23:11 +02:00
9 changed files with 325 additions and 105 deletions
+14 -9
View File
@@ -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
+2 -2
View File
@@ -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.1
appVersion: "v0.20.1"
+26 -4
View File
@@ -5,6 +5,7 @@ import (
"embed"
"fmt"
"io/fs"
"log"
"sort"
"strings"
"time"
@@ -15,6 +16,19 @@ import (
//go:embed migrations
var migrationsFS embed.FS
// pingAttempts and pingRetryDelay bound the retry on the first connection.
// This pod's own IP can reach the Postgres pod's node before that node's
// NetworkPolicy enforcement (kube-router, reacting to the pod's creation
// event) has added it to the allowed-source set, which fails the ping with
// "connection refused" rather than a timeout. That race resolves within
// several seconds in practice; five attempts two seconds apart give it
// comfortable room without turning a genuinely absent database into a long
// hang.
const (
pingAttempts = 5
pingRetryDelay = 2 * time.Second
)
// Open connects to Postgres. dsn is a libpq connection string or URL, e.g.
// postgres://terdut:secret@localhost:5432/terdut?sslmode=disable.
//
@@ -33,11 +47,19 @@ func Open(dsn string) (*sql.DB, error) {
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(time.Hour)
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("ping: %w", err)
for attempt := 1; ; attempt++ {
err = db.Ping()
if err == nil {
return db, nil
}
if attempt == pingAttempts {
db.Close()
return nil, fmt.Errorf("ping: %w", err)
}
log.Printf("open db: ping attempt %d/%d failed, retrying in %s: %v", attempt, pingAttempts, pingRetryDelay, err)
time.Sleep(pingRetryDelay)
}
return db, nil
}
// Migrate applies every embedded migration that has not been applied yet, in
+35 -35
View File
@@ -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; }
+7 -1
View File
@@ -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>
+5 -14
View File
@@ -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() {
+61 -13
View File
@@ -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
View File
@@ -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
+24
View File
@@ -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),