Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee22eb000c | |||
| 07914d5cdb |
@@ -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
|
||||||
|
|||||||
@@ -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.1
|
version: 0.17.0
|
||||||
appVersion: "v0.16.1"
|
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; }
|
||||||
|
|||||||
@@ -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 --------------------------------------------------------------
|
||||||
|
|||||||
@@ -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