Commit Graph

3 Commits

Author SHA1 Message Date
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 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
Niklas Ye dc3879eca6 Serve a web UI for the incident queue, built for phones
Whoever is on call gets paged on a phone, and until now the only ways to
act on a page were the notification's Acknowledge button or a terminal.
Tapping the notification itself opened /api/incidents/{id}, which a
browser can only answer with a 401 in JSON. The server now serves a web
UI at / covering the incident queue, each incident's alerts and timeline
with every action on it, who is on call, the alert feed, and changing
your own password. The notification link now points at /incidents/{id}
in that UI.

It is embedded in the binary and has no build step: plain HTML, CSS and
ES modules under internal/web/static, served with an ETag per file and a
CSP that allows nothing from any other origin. That is how rd-web is
built. It avoids adding a node toolchain to the Dockerfile and the
pipeline for a page this size, and it keeps the page on the same origin
as the API, so no CORS is needed and nothing else has to be deployed.
Paths without a file extension fall back to index.html, so a deep link
survives a reload. An unknown path under /api/ still gets a JSON 404
rather than the page.

Signing in uses a username and password, because pasting a 64-character
API key into a phone at 3am is not a sign-in flow. Users have no
password until one is set through PUT /api/users/{id}/password, or
optionally at bootstrap. A user without a password is exactly where they
were before this commit and can only use API keys. A login sets an
HttpOnly, SameSite=Lax session cookie. It lasts 30 days and slides
forward while in use, so an on-call phone does not sign itself out.
Only the token's hash is stored, as for API keys.

The cookie needs a CSRF guard where a bearer header does not, because
browsers attach cookies to requests other sites make. So cookie-
authenticated requests go through Go 1.25's http.CrossOriginProtection,
and bearer requests do not. A request carrying an Authorization header
is judged on that header alone and never falls back to the cookie.
Changing a password ends every other session of that user. Changing
your own requires the current password, so a phone left signed in
cannot be used to take the account over.

Failed logins are counted per username and per client address. Ten
failures for one username in 15 minutes refuse that username for the
rest of the window, even with the right password. That makes locking
somebody out possible for anyone who knows their username. It was
accepted because the alternative is unlimited guessing, and during a
lockout the notification's Acknowledge button and API keys keep
working. The address limit reads the first X-Forwarded-For hop, since
behind the gateway RemoteAddr is Envoy. It is looser, because a whole
office behind one NAT shares it.

The Secure flag follows TERDUT_PUBLIC_URL, since TLS terminates at the
gateway and the server itself only ever sees plain HTTP. The chart
already defaults that variable to https://<hostname>.

Schedule editing, statistics and user management stay in terdut-tui for
now. The API they use is unchanged, and bearer authentication behaves
exactly as before.
2026-09-19 17:48:21 +02:00