Compare commits

..

4 Commits

Author SHA1 Message Date
Niklas Ye ee22eb000c Set the chart's placeholder version to 0.17.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 13s
CI / test (push) Successful in 2m31s
Release / test (push) Successful in 8s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 19s
Release / image (push) Successful in 53s
Release / scan-image (push) Successful in 3s
Cosmetic, and done anyway for the same reason as 7b9a337, 828cf87 and
8869ac8 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.17.0 that says 0.16.1 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-21 21:11:29 +02:00
Niklas Ye 07914d5cdb Give the Admin tab sub-sections of its own
Administration was one scrolling page with three cards on it: the teams,
the people, and the settings. There was no way to link somebody to the
settings, no way back to the top of the user list but scrolling, and the
poll loop refetched all three endpoints every tick however little of the
page you were looking at.

Each is now a route -- /admin/teams, /admin/users, /admin/settings --
reached from a strip across the top, with /admin an overview that says
how many of each there are. The three cards themselves are untouched;
they are simply rendered one at a time, so a tab fetches only what it
shows. The Users page is the exception and fetches the teams too, since
its invite form has to offer a team to invite somebody into.

The strip is ordinary links rather than chips. Chips filter what a page
already shows, here and in the queue, and these four go somewhere: the
browser's Back walks them, a reload lands where you were, and the click
is intercepted by the same handler every other link in the app uses.
The current one is marked with aria-current="page", the convention the
tab bar has used since it existed, so the state lives on the attribute
and not in a class.

admin.js owns the table of the four routes, because it also builds the
strip that links to them; app.js parses against that table rather than
keeping a second list to drift from it. Adding a fifth sub-section is
one line.

The bottom tab bar still has six items. 56b8191 made it count-agnostic
when Admin arriving pushed it past four, and the note there records that
six at 420px already leaves 55-65px each -- so the sub-sections went
inside the Admin page rather than beside it.

A person's page keeps its own route at /admin/users/{id}; its back link
now returns to the user list rather than to the top of everything.

Nobody has looked at this in a browser, the same caveat ac9af8e carried.
What is checked is the wiring: the module graph evaluates at every admin
URL, all four tabs render against live server responses with one
aria-current each and the fetches the table above describes, the
non-administrator branch still refuses without fetching, and the server
serves index.html for each new path so a reload survives. The strip's
appearance at phone and desktop width is not checked.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 21:11:08 +02:00
Niklas Ye 7b9a337d25 Set the chart's placeholder version to 0.16.1
CI / chart (push) Successful in 1s
CI / security (push) Successful in 16s
CI / test (push) Successful in 2m33s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 4s
Release / binaries (push) Successful in 28s
Release / image (push) Successful in 1m5s
Release / scan-image (push) Successful in 2s
Cosmetic, and done anyway. release.yaml passes --version and --app-version
from the git tag when it packages, so neither line decides anything about
what is published; they exist to be read by somebody looking at the tree
before the tag does. A tree heading for v0.16.1 that says 0.16.0 tells that
reader something false.

Its own commit, like 828cf87, 8869ac8 and 9376105 before it, so the feature
commit's diff stays the feature.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 15:51:26 +02:00
Niklas Ye fc8b0c8d58 Let people set their own ntfy topic under Account
The first-run checklist's first step is "Set where your pages go", and its
button navigated to /more — which had no field for it. Every new user was
sent to a page that could not do the thing it sent them there for, and the
only ways to actually set a topic were curl or asking an administrator.
That has been true since the checklist shipped in v0.15.0.

Account now has a Notifications section above the password form: the topic,
prefilled and saved through the endpoint that already existed, and a Send a
test push button. The test is offered only once a topic is saved, because
it publishes what the server has stored rather than what is half-typed in
the field, and a button that silently tested the previous value would be
worse than no button.

Saving assigns the response to state.me.user, so the checklist stops asking
and the test button appears without a reload. Clearing works by saving an
empty topic: the server treats that as "no topic of their own" rather than
an error, and returns a user with ntfy_topic absent — it is omitempty — so
the form reads the cleared state from the response rather than assuming it.

The copy says the topic is a shared secret, because people reach for their
own name and it is the only thing between a stranger and their pages. Same
reason the topic stays out of an incident's timeline, which every API key
can read.

No server change: PUT /api/users/{id}/notify has been self-or-admin since
#3 and needed nothing. Only the ntfy topic is per-person — the server is
the install's one TERDUT_NTFY_URL and is not something a user picks.

Also drops a line on that page still sending people to terdut-tui for user
management, which stopped being true one release ago.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 15:51:16 +02:00
8 changed files with 269 additions and 35 deletions
+25 -12
View File
@@ -47,8 +47,8 @@ curl -H "Authorization: Bearer $KEY" http://localhost:8080/api/users
The server serves a web UI at `/`: the incident queue, each incident's alerts The server serves a web UI at `/`: the incident queue, each incident's alerts
and timeline with every action (acknowledge, assign, snooze, note, resolve, and timeline with every action (acknowledge, assign, snooze, note, resolve,
archive), who is on call, the alert feed, and changing your own password. It is archive), who is on call, the alert feed, and an *Account* tab for your own
built for a phone first. On a phone it has a bottom tab bar and a sticky action 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. 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 From 900px wide it switches to a sidebar with the queue and the incident side by
side. Statistics remain in side. Statistics remain in
@@ -92,14 +92,17 @@ the server refuses their writes anyway. Somebody in more than one team picks
between them at the top. between them at the top.
The **Admin** tab appears only for a system administrator, and holds what The **Admin** tab appears only for a system administrator, and holds what
belongs to the whole server rather than to one team: every team, every user, and belongs to the whole server rather than to one team. It has three sub-sections,
the settings that used to be environment variables. Adding somebody is minting each with a URL of its own and a strip across the top to move between them:
them an invite link into a team, rather than creating a bare account: the person every team (`/admin/teams`), every user (`/admin/users`), and the settings that
who accepts it picks their own password, so one never passes through an used to be environment variables (`/admin/settings`). `/admin` itself is an
administrator, and the link carries the team, so they land somewhere with a overview — how many of each, and what each section is for. Adding somebody is
minting them an invite link into a team, rather than creating a bare account:
the person who accepts it picks their own password, so one never passes through
an administrator, and the link carries the team, so they land somewhere with a
queue in it. queue in it.
A name in that list opens **that person's page**, at `/admin/users/{id}`: their A name in the user list opens **that person's page**, at `/admin/users/{id}`: their
email and when they joined, where their notifications go, whether they are an email and when they joined, where their notifications go, whether they are an
administrator, whether the account is disabled, the teams they are in with their administrator, whether the account is disabled, the teams they are in with their
role in each, a password field for a first or forgotten one, and deletion. It is role in each, a password field for a first or forgotten one, and deletion. It is
@@ -351,10 +354,20 @@ exactly as it was rather than with a hole in it.
### Push notifications ### Push notifications
With `TERDUT_NTFY_URL` set, an incident that opens is pushed to the on-call With `TERDUT_NTFY_URL` set, an incident that opens is pushed to the on-call
person's phone through [ntfy](https://ntfy.sh). Set each user's topic with person's phone through [ntfy](https://ntfy.sh). Everybody sets their own topic
`PUT /api/users/{id}/notify`; a user with no topic falls back to under *Account* in the web UI, where a **Send a test push** button proves it
`TERDUT_NTFY_FALLBACK_TOPIC`, as does an incident that opens with nobody on call. before an incident has to; `PUT /api/users/{id}/notify` is the same thing over
If neither yields a topic, nothing is queued. the API, and an administrator may set somebody else's. A user with no topic
falls back to `TERDUT_NTFY_FALLBACK_TOPIC`, as does an incident that opens with
nobody on call. If neither yields a topic, nothing is queued.
The **server** is the install's one ntfy, from `TERDUT_NTFY_URL`, and is not
something a user picks. Only the topic is per-person.
A topic is a shared secret with the ntfy server: anyone who knows it can both
read the pages and publish to it, so an unguessable one is worth the trouble.
That is also why the topic never appears in an incident's timeline, which every
API key can read.
Three things get pushed: Three things get pushed:
+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: # 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.16.0 version: 0.17.0
appVersion: "v0.16.0" appVersion: "v0.17.0"
+32
View File
@@ -765,3 +765,35 @@ kbd {
.step-actions { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; } .step-actions { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.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 ------------------------------------------------
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. */
.subnav {
display: flex; gap: 2px;
margin: 12px auto 0;
border-bottom: 1px solid var(--border);
overflow-x: auto; scrollbar-width: none;
}
.subnav::-webkit-scrollbar { display: none; }
.subnav-link {
flex: none;
padding: 8px 12px; margin-bottom: -1px;
border-bottom: 2px solid transparent;
color: var(--muted); font-size: 14px; font-weight: 600; white-space: nowrap;
}
.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 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; }
+85 -1
View File
@@ -23,6 +23,9 @@ function render() {
h('div', { class: 'account-name', text: user.username }), h('div', { class: 'account-name', text: user.username }),
h('div', { class: 'account-email', text: user.email }))), h('div', { class: 'account-email', text: user.email }))),
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
notifyForm(user),
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })), h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
passwordForm(user, hasPassword), passwordForm(user, hasPassword),
@@ -32,10 +35,91 @@ function render() {
h('div', { class: 'page-head' }), h('div', { class: 'page-head' }),
h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'), h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'),
h('p', { class: 'foot-note', text: 'Schedule editing, statistics and user management are in terdut-tui for now.' }), h('p', { class: 'foot-note', text: 'Statistics are in terdut-tui for now.' }),
); );
} }
// Where this user's pages go. The onboarding checklist's first step sends
// people here for it, and until now there was nothing here to send them to:
// the topic could only be set with curl or by an administrator.
//
// The topic is the whole address — the server it is published to is the
// install's one ntfy, set in the deployment and not something a user picks.
function notifyForm(user) {
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
const topic = h('input', {
name: 'ntfy_topic', type: 'text', autocomplete: 'off',
autocapitalize: 'none', spellcheck: false,
value: user.ntfy_topic || '',
placeholder: 'terdut-a7f3c91e',
});
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' });
// Only offered once a topic is saved: the test publishes to whatever the
// server has stored, not to whatever is half-typed in the field.
const test = h('button', {
class: 'btn', type: 'button', text: 'Send a test push',
hidden: !user.ntfy_topic,
onclick: async () => {
err.hidden = true;
ok.hidden = true;
test.disabled = true;
try {
await api.testNotification();
ok.textContent = 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.';
ok.hidden = false;
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
test.disabled = false;
}
},
});
const form = h('form', { class: 'card pw-form' },
h('label', {},
h('span', { text: 'ntfy topic' }),
topic),
h('p', { class: 'muted small' },
'Subscribe to this topic in the ntfy app and incidents assigned to you ',
'reach your phone. Leave it empty and they page the team’s fallback ',
'topic instead.'),
// Worth saying plainly: people reach for their own name, and the topic is
// the only thing standing between a stranger and their pages.
h('p', { class: 'muted small' },
'Anyone who knows the topic can read your pages and publish to it, so ',
'pick something unguessable rather than your name.'),
err, ok,
h('div', { class: 'row-actions' }, submit, test),
);
form.addEventListener('submit', async (e) => {
e.preventDefault();
err.hidden = true;
ok.hidden = true;
submit.disabled = true;
try {
const updated = await api.setNotifyTarget(user.id, topic.value.trim());
// Keep the cached user in step, so the onboarding checklist stops
// asking for this and the test button appears without a reload.
state.me.user = updated;
ok.textContent = updated.ntfy_topic
? 'Topic saved.'
: 'Topic cleared. Your pages go to the team’s fallback topic.';
ok.hidden = false;
test.hidden = !updated.ntfy_topic;
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
submit.disabled = false;
}
});
return form;
}
function passwordForm(user, hasPassword) { function passwordForm(user, hasPassword) {
const err = h('p', { class: 'form-error', role: 'alert', hidden: true }); const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true }); const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
+97 -13
View File
@@ -1,6 +1,12 @@
// Administration: the teams on this server, the people who can sign in, and // Administration: the teams on this server, the people who can sign in, and
// the settings that change how the server behaves. // the settings that change how the server behaves.
// //
// Each of those three is a route of its own, reached from a strip across the
// top, with /admin itself an overview. They used to be three cards stacked on
// one page, which meant no way to link to the settings, no way back to the top
// of the user list but scrolling, and a poll that refetched all three endpoints
// however little of the page you were looking at.
//
// Only rendered for a system administrator. The server enforces that on every // Only rendered for a system administrator. The server enforces that on every
// endpoint regardless — hiding a section is a courtesy to the reader, not a // endpoint regardless — hiding a section is a courtesy to the reader, not a
// permission — so this view simply says so rather than pretending to be a // permission — so this view simply says so rather than pretending to be a
@@ -12,12 +18,34 @@ import { state, myID } from './state.js';
const view = () => document.getElementById('view-admin'); const view = () => document.getElementById('view-admin');
let data = null; // { teams, users, settings } // The sub-sections, in the order the strip shows them. The overview is /admin
// itself, so it has no tab of its own. This table is the only place the four
// routes are written down: app.js parses against it and the strip is built
// from it, so adding a fifth is one line here.
export const TABS = [
{ tab: null, path: '/admin', label: 'Overview' },
{ tab: 'teams', path: '/admin/teams', label: 'Teams' },
{ tab: 'users', path: '/admin/users', label: 'Users' },
{ tab: 'settings', path: '/admin/settings', label: 'Settings' },
];
// Which sub-section is open. Remembered rather than passed, because the poll
// loop calls refresh() with no route — the same reason adminuser.js keeps its
// user ID in the module.
let tab = null;
let data = null; // whatever the current tab needs; the shape varies by tab
let error = null; let error = null;
let busy = false; let busy = false;
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.
if (next !== tab) {
tab = next;
data = null;
}
if (!data) clear(view(), subnav(), spinner());
refresh(); refresh();
} }
@@ -28,12 +56,7 @@ export async function refresh() {
return; return;
} }
try { try {
const [teams, users, settings] = await Promise.all([ data = await load();
api.adminTeams(),
api.users(),
api.adminSettings(),
]);
data = { teams, users, settings };
error = null; error = null;
} catch (err) { } catch (err) {
error = err.message; error = err.message;
@@ -41,6 +64,16 @@ export async function refresh() {
render(); render();
} }
// Only what the open sub-section shows. Users is the one that needs two: its
// invite form has to offer a team to invite somebody into, and the overview
// counts both.
async function load() {
if (tab === 'teams') return { teams: await api.adminTeams() };
if (tab === 'settings') return { settings: await api.adminSettings() };
const [teams, users] = await Promise.all([api.adminTeams(), api.users()]);
return { teams, users };
}
function render() { function render() {
if (!state.me?.user?.is_admin) { if (!state.me?.user?.is_admin) {
clear(view(), h('div', { class: 'card' }, clear(view(), h('div', { class: 'card' },
@@ -48,14 +81,65 @@ function render() {
return; return;
} }
if (!data) { if (!data) {
clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner()); clear(view(), subnav(), error ? h('div', { class: 'load-error', text: error }) : spinner());
return; return;
} }
clear(view(), clear(view(),
subnav(),
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }), error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
teamsCard(), section(),
usersCard(), );
settingsCard(), }
function section() {
if (tab === 'teams') return teamsCard();
if (tab === 'users') return usersCard();
if (tab === 'settings') return settingsCard();
return overview();
}
// The strip across the top of every admin page. Ordinary links rather than
// buttons, because these are four 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': 'Administration' },
TABS.map((t) => h('a', {
class: 'subnav-link',
href: t.path,
text: t.label,
'aria-current': t.tab === tab ? 'page' : null,
})));
}
// --- overview --------------------------------------------------------------
// /admin itself. The strip already links to the three, so this earns its place
// by saying how much of each there is — the one thing a menu cannot.
function overview() {
const admins = data.users.filter((u) => u.is_admin).length;
const disabled = data.users.filter((u) => u.disabled_at).length;
const open = data.teams.reduce((n, t) => n + t.open_incidents, 0);
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,
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,
'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 }),
); );
} }
+2 -2
View File
@@ -83,7 +83,7 @@ function render() {
} }
function backLink() { function backLink() {
return h('a', { class: 'back-link', href: '/admin' }, icon('chevronLeft'), h('span', { text: 'Admin' })); return h('a', { class: 'back-link', href: '/admin/users' }, icon('chevronLeft'), h('span', { text: 'Users' }));
} }
// --- identity -------------------------------------------------------------- // --- identity --------------------------------------------------------------
@@ -271,7 +271,7 @@ async function deleteUser() {
return; return;
} }
toast('User deleted.'); toast('User deleted.');
navigate('/admin'); navigate('/admin/users');
} }
// --- plumbing -------------------------------------------------------------- // --- plumbing --------------------------------------------------------------
+5
View File
@@ -71,6 +71,11 @@ export const users = () => call('GET', '/users');
// about anybody else, which is what the admin page's per-user view needs. // about anybody else, which is what the admin page's per-user view needs.
export const userTeams = (id) => call('GET', `/users/${id}/teams`); export const userTeams = (id) => call('GET', `/users/${id}/teams`);
// Where this user's pages go. An empty topic clears it, which the server
// treats as "no topic of their own" rather than an error.
export const setNotifyTarget = (id, ntfyTopic) =>
call('PUT', `/users/${id}/notify`, { body: { ntfy_topic: ntfyTopic } });
// incidents // incidents
export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts }); export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts });
export const incident = (id) => call('GET', `/incidents/${id}`); export const incident = (id) => call('GET', `/incidents/${id}`);
+21 -5
View File
@@ -34,10 +34,24 @@ function parseRoute(pathname) {
const u = pathname.match(/^\/admin\/users\/(\d+)\/?$/); const u = pathname.match(/^\/admin\/users\/(\d+)\/?$/);
if (u) return { section: 'adminuser', user: Number(u[1]) }; if (u) return { section: 'adminuser', user: Number(u[1]) };
const name = pathname.replace(/^\/|\/$/g, ''); const name = pathname.replace(/^\/|\/$/g, '');
if (name === 'oncall' || name === 'alerts' || name === 'team' || name === 'admin' || name === 'more') return { section: name }; // 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.
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 };
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
// are pages in their own right, so they say which one rather than "Admin" four
// times; the overview keeps the tab's own name.
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;
}
let route = parseRoute(location.pathname); let route = parseRoute(location.pathname);
// How many in-app navigations deep we are, so Back can use the browser's // How many in-app navigations deep we are, so Back can use the browser's
// history when there is somewhere to go back to, and the queue otherwise. // history when there is somewhere to go back to, and the queue otherwise.
@@ -70,10 +84,10 @@ function render() {
route = parseRoute(location.pathname); route = parseRoute(location.pathname);
const app = $('app'); const app = $('app');
for (const [name, s] of Object.entries(SECTIONS)) { for (const name of Object.keys(SECTIONS)) {
const el = $(`view-${name}`); const el = $(`view-${name}`);
el.hidden = name !== route.section; el.hidden = name !== route.section;
if (name === route.section) $('topbar-title').textContent = s.title; if (name === route.section) $('topbar-title').textContent = title(route);
} }
// A section may light up somebody else's tab: /admin/users/{id} is still the // A section may light up somebody else's tab: /admin/users/{id} is still the
// Admin tab as far as the nav is concerned, since there is no tab of its own. // Admin tab as far as the nav is concerned, since there is no tab of its own.
@@ -99,7 +113,9 @@ function render() {
if (detailOpen && !wasOpen) window.scrollTo(0, 0); if (detailOpen && !wasOpen) window.scrollTo(0, 0);
else if (!detailOpen && wasOpen) requestAnimationFrame(() => window.scrollTo(0, listScroll)); else if (!detailOpen && wasOpen) requestAnimationFrame(() => window.scrollTo(0, listScroll));
else if (prev.section !== route.section) window.scrollTo(0, 0); // A changed tab counts as a changed page: stepping from a long user list to
// the settings should not land you halfway down them.
else if (prev.section !== route.section || prev.tab !== route.tab) window.scrollTo(0, 0);
updateTitle(); updateTitle();
} }
@@ -138,7 +154,7 @@ function updateBadges() {
function updateTitle() { function updateTitle() {
const triggered = state.open.filter((i) => i.status === 'triggered').length; const triggered = state.open.filter((i) => i.status === 'triggered').length;
const section = SECTIONS[route.section].title; const section = title(route);
const base = route.section === 'queue' && route.incident == null ? 'terdut' : `${section} · terdut`; const base = route.section === 'queue' && route.incident == null ? 'terdut' : `${section} · terdut`;
document.title = triggered ? `(${triggered}) ${base}` : base; document.title = triggered ? `(${triggered}) ${base}` : base;
} }