Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee22eb000c | |||
| 07914d5cdb | |||
| 7b9a337d25 | |||
| fc8b0c8d58 |
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 --------------------------------------------------------------
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user