a8dc89e23d
Secondary text was too dim to read: --faint sat at about 4.0:1 in the dark theme and 3.3:1 in the light one, and it carries row ages, hints and labels. It now clears 4.5:1 in both. The dark surfaces and borders are a step further apart so cards stand out from the page, and the light borders a touch stronger. Account has an Appearance section with System, Light and Dark. System is the old behaviour. The choice is per browser, kept in localStorage, and is applied by a small js/theme.js loaded from <head> so there is no flash of the other theme; the CSP allows no inline script, hence a file of its own. On-call is redesigned: a hero card for who is on call now, with when the shift ends for the selected team, and the week as seven day cells instead of grouped rows. Today is marked with a neutral tint and a bar rather than the accent colour, which now means only things you can act on; your own days are marked by the "you" badge, not a fill. On a wide screen the hero and week sit beside your shifts under a page title. The page stays read-only: shifts are still edited in the TUI. Smaller fixes: the team switcher no longer appears in the phone's bottom bar as well as the top bar (a shared rule overrode the one that hides it); the Team and Admin tab strips fade at the edge and scroll the open section into view, so "Sources" is no longer cut off; and "All clear" is a green status badge with a check instead of a grey pill that looked like a button. Web UI only: no endpoint or JSON shape changed, so nothing to mirror in terdut-tui.
509 lines
19 KiB
JavaScript
509 lines
19 KiB
JavaScript
// Entry point: session, routing, badges and keyboard.
|
|
|
|
import * as api from './api.js';
|
|
import * as ui from './ui.js';
|
|
import * as poll from './poll.js';
|
|
import { state, reset, loadTeams } from './state.js';
|
|
import * as queue from './queue.js';
|
|
import * as incident from './incident.js';
|
|
import * as oncall from './oncall.js';
|
|
import * as alerts from './alerts.js';
|
|
import * as stats from './stats.js';
|
|
import * as account from './account.js';
|
|
import * as team from './team.js';
|
|
import * as teamselector from './teamselector.js';
|
|
import * as admin from './admin.js';
|
|
import * as adminuser from './adminuser.js';
|
|
import * as adminteam from './adminteam.js';
|
|
import * as device from './device.js';
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
// One route per section; /incidents/{id} is the queue with a detail open, and
|
|
// /admin/users/{id} is a section of its own rather than a mode of the Admin
|
|
// tab, because it replaces the page rather than opening beside it.
|
|
const SECTIONS = {
|
|
queue: { title: 'Queue', view: queue },
|
|
oncall: { title: 'On-call', view: oncall },
|
|
alerts: { title: 'Alerts', view: alerts },
|
|
stats: { title: 'Stats', view: stats },
|
|
team: { title: 'Team', view: team },
|
|
admin: { title: 'Admin', view: admin },
|
|
adminuser: { title: 'User', view: adminuser, nav: 'admin' },
|
|
adminteam: { title: 'Team', view: adminteam, nav: 'admin' },
|
|
more: { title: 'Account', view: account },
|
|
// Reached by link from a terminal's sign-in prompt, not from the nav.
|
|
device: { title: 'Sign in a terminal', view: device },
|
|
};
|
|
|
|
// The desktop sidebar's .nav-link list in index.html, in the same order.
|
|
// `secondary` marks the ones that fold into the phone bottom bar's "More"
|
|
// sheet (openNavMenu below) instead of getting a tab of their own there.
|
|
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: '/stats', section: 'stats', label: 'Stats', icon: 'chart', secondary: true },
|
|
{ path: '/team', section: 'team', label: 'Team', icon: 'team' },
|
|
{ path: '/admin', section: 'admin', label: 'Admin', icon: 'shield', adminOnly: true, secondary: true },
|
|
{ path: '/more', section: 'more', label: 'Account', icon: 'user', secondary: true },
|
|
];
|
|
|
|
function parseRoute(pathname) {
|
|
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
|
|
if (m) return { section: 'queue', incident: Number(m[1]) };
|
|
const u = pathname.match(/^\/admin\/users\/(\d+)\/?$/);
|
|
if (u) return { section: 'adminuser', user: Number(u[1]) };
|
|
// Before the TABS lookup below, which matches a path exactly and would let
|
|
// /admin/teams/7 fall through to the queue.
|
|
const g = pathname.match(/^\/admin\/teams\/(\d+)\/?$/);
|
|
if (g) return { section: 'adminteam', team: Number(g[1]) };
|
|
const name = pathname.replace(/^\/|\/$/g, '');
|
|
// 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 };
|
|
const tt = team.TABS.find((x) => x.path === `/${name}`);
|
|
if (tt) return { section: 'team', tab: tt.tab };
|
|
if (name === 'oncall' || name === 'alerts' || name === 'stats' || name === 'more' || name === 'device') return { section: name };
|
|
return { section: 'queue', incident: null };
|
|
}
|
|
|
|
// 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 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);
|
|
// 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.
|
|
let depth = 0;
|
|
let listScroll = 0;
|
|
|
|
export function navigate(path, { replace = false } = {}) {
|
|
if (path === location.pathname + location.search) return;
|
|
if (replace) {
|
|
history.replaceState({ depth }, '', path);
|
|
} else {
|
|
depth += 1;
|
|
history.pushState({ depth }, '', path);
|
|
}
|
|
render();
|
|
}
|
|
|
|
export function back() {
|
|
if (depth > 0) history.back();
|
|
else navigate('/', { replace: true });
|
|
}
|
|
|
|
window.addEventListener('popstate', (e) => {
|
|
depth = (e.state && e.state.depth) || 0;
|
|
render();
|
|
});
|
|
|
|
function render() {
|
|
const prev = route;
|
|
route = parseRoute(location.pathname);
|
|
const app = $('app');
|
|
|
|
for (const name of Object.keys(SECTIONS)) {
|
|
const el = $(`view-${name}`);
|
|
el.hidden = name !== route.section;
|
|
if (name === route.section) $('topbar-title').textContent = title(route);
|
|
}
|
|
// 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.
|
|
const current = SECTIONS[route.section].nav || route.section;
|
|
for (const link of document.querySelectorAll('.nav-link')) {
|
|
if (link.dataset.section === current) link.setAttribute('aria-current', 'page');
|
|
else link.removeAttribute('aria-current');
|
|
}
|
|
|
|
const detailOpen = route.section === 'queue' && route.incident != null;
|
|
const wasOpen = prev.section === 'queue' && prev.incident != null;
|
|
if (detailOpen && !wasOpen) listScroll = window.scrollY;
|
|
app.classList.toggle('detail-open', detailOpen);
|
|
$('view-queue').classList.toggle('has-detail', detailOpen);
|
|
|
|
if (route.section === 'queue') {
|
|
queue.show(route.incident);
|
|
incident.show(route.incident);
|
|
} else {
|
|
incident.show(null);
|
|
SECTIONS[route.section].view.show(route);
|
|
}
|
|
|
|
if (detailOpen && !wasOpen) window.scrollTo(0, 0);
|
|
else if (!detailOpen && wasOpen) requestAnimationFrame(() => window.scrollTo(0, listScroll));
|
|
// A changed tab counts as a changed page: stepping from a long user list to
|
|
// the settings should not land you halfway down them. So does a changed
|
|
// subject — one team to the next is two pages, not one scrolled page.
|
|
else if (prev.section !== route.section || prev.tab !== route.tab
|
|
|| prev.user !== route.user || prev.team !== route.team) window.scrollTo(0, 0);
|
|
|
|
updateTitle();
|
|
}
|
|
|
|
// ---------- nav menu ----------
|
|
|
|
// The phone bottom bar's "More" sheet: same shape as the sheet-based action
|
|
// menus in incident.js (openSheet + a <ul class="menu"> of menu-item
|
|
// buttons), one item per secondary NAV_ITEMS entry — the ones the bar itself
|
|
// has no room for, since Queue/On-call/Alerts/Team already have their own
|
|
// tab and don't need to be reachable here too.
|
|
function openNavMenu() {
|
|
const current = SECTIONS[route.section].nav || route.section;
|
|
const items = NAV_ITEMS.filter((n) => n.secondary && (!n.adminOnly || state.me?.user?.is_admin));
|
|
ui.openSheet(() => [
|
|
ui.h('h2', { class: 'sheet-title', text: 'More' }),
|
|
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,
|
|
))),
|
|
),
|
|
]).then((path) => {
|
|
if (path) navigate(path);
|
|
});
|
|
}
|
|
|
|
// ---------- refresh + badges ----------
|
|
|
|
async function refresh() {
|
|
state.open = await api.incidents({ sort: 'severity' });
|
|
updateBadges();
|
|
const jobs = [];
|
|
if (route.section === 'queue') {
|
|
jobs.push(queue.refresh());
|
|
if (route.incident != null) jobs.push(incident.refresh());
|
|
} else {
|
|
const v = SECTIONS[route.section].view;
|
|
if (v.refresh) jobs.push(v.refresh());
|
|
}
|
|
await Promise.allSettled(jobs);
|
|
}
|
|
|
|
function updateBadges() {
|
|
const open = state.open.length;
|
|
const triggered = state.open.filter((i) => i.status === 'triggered').length;
|
|
|
|
const pill = $('open-pill');
|
|
pill.hidden = false;
|
|
pill.replaceChildren(...(open ? [`${open} open`] : [ui.icon('checkCircle'), 'All clear']));
|
|
pill.classList.toggle('all-clear', open === 0);
|
|
pill.classList.toggle('has-triggered', triggered > 0);
|
|
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
|
|
|
|
// Just the Queue tab's own badge now — phone bottom bar and desktop
|
|
// sidebar both read the same [data-badge] span on that one nav-link.
|
|
for (const badge of document.querySelectorAll('[data-badge]')) {
|
|
badge.hidden = triggered === 0;
|
|
badge.textContent = String(triggered);
|
|
}
|
|
updateTitle();
|
|
}
|
|
|
|
function updateTitle() {
|
|
const triggered = state.open.filter((i) => i.status === 'triggered').length;
|
|
const section = title(route);
|
|
const base = route.section === 'queue' && route.incident == null ? 'terdut' : `${section} · terdut`;
|
|
document.title = triggered ? `(${triggered}) ${base}` : base;
|
|
}
|
|
|
|
// ---------- session ----------
|
|
|
|
async function boot() {
|
|
ui.initSheet();
|
|
api.setUnauthorizedHandler(showLogin);
|
|
document.addEventListener('click', interceptLinks);
|
|
document.addEventListener('keydown', onKey);
|
|
$('login-form').addEventListener('submit', onLogin);
|
|
$('signup-form').addEventListener('submit', onSignup);
|
|
$('nav-more-btn').addEventListener('click', openNavMenu);
|
|
teamselector.init();
|
|
ssoErrorCode = takeSSOError();
|
|
|
|
// /signup is the one route that works without a session.
|
|
if (location.pathname.replace(/\/$/, '') === '/signup') {
|
|
$('boot').hidden = true;
|
|
await showSignup();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Before anything renders: the account view decides from it whether a
|
|
// password is worth offering to set.
|
|
await loadAuthConfig();
|
|
state.me = await api.me();
|
|
await loadTeams();
|
|
teamselector.render();
|
|
// The Admin tab exists only for an administrator. Somebody who types /admin
|
|
// anyway gets the view's own "ask an administrator" card, not a blank page.
|
|
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
|
showApp();
|
|
} catch (err) {
|
|
if (err.status === 401) showLogin();
|
|
else showBootError(err);
|
|
}
|
|
}
|
|
|
|
function showBootError(err) {
|
|
ui.clear($('boot'), ui.emptyState('Cannot load terdut', err.message));
|
|
$('boot').append(ui.h('button', { class: 'btn', onclick: () => location.reload(), text: 'Retry' }));
|
|
}
|
|
|
|
// The sign-up screen. Reached at /signup, with an optional ?invite= that the
|
|
// server has already judged — the form says whether the link is good before
|
|
// somebody picks a password, rather than after.
|
|
async function showSignup() {
|
|
poll.stop();
|
|
ui.closeSheet(null);
|
|
reset();
|
|
$('boot').hidden = true;
|
|
$('app').hidden = true;
|
|
$('login').hidden = false;
|
|
$('login-form').hidden = true;
|
|
$('signup-form').hidden = false;
|
|
|
|
const invite = new URLSearchParams(location.search).get('invite');
|
|
const intro = $('signup-intro');
|
|
const form = $('signup-form');
|
|
const teamLabel = $('signup-team-label');
|
|
form.querySelector('.form-error').hidden = true;
|
|
|
|
let info;
|
|
try {
|
|
info = await api.signupInfo(invite);
|
|
} catch (err) {
|
|
intro.textContent = err.message;
|
|
return;
|
|
}
|
|
|
|
if (invite && info.invite_valid) {
|
|
intro.textContent = `You have been invited to ${info.invite_team}.`;
|
|
teamLabel.hidden = true;
|
|
form.team_name.required = false;
|
|
} else if (invite) {
|
|
// One answer for expired, revoked, used up and never existed, matching the
|
|
// server: which it was is not a stranger's business.
|
|
intro.textContent = 'That invite link is not usable. Ask whoever sent it for a new one.';
|
|
form.querySelector('button[type=submit]').disabled = true;
|
|
} else if (info.mode === 'open') {
|
|
intro.textContent = 'Create an account and a team to put your alerts in.';
|
|
teamLabel.hidden = false;
|
|
form.team_name.required = true;
|
|
} else {
|
|
intro.textContent = 'Sign-up on this server is invite-only. Ask a team owner for a link.';
|
|
form.querySelector('button[type=submit]').disabled = true;
|
|
}
|
|
form.username.focus();
|
|
}
|
|
|
|
async function onSignup(e) {
|
|
e.preventDefault();
|
|
const form = e.currentTarget;
|
|
const err = form.querySelector('.form-error');
|
|
const btn = form.querySelector('button[type=submit]');
|
|
err.hidden = true;
|
|
btn.disabled = true;
|
|
try {
|
|
state.me = await api.signup({
|
|
username: form.username.value.trim(),
|
|
email: form.email.value.trim(),
|
|
password: form.password.value,
|
|
invite: new URLSearchParams(location.search).get('invite') || undefined,
|
|
team_name: form.team_name.value.trim() || undefined,
|
|
});
|
|
form.password.value = '';
|
|
// Signing up signs you in, so go straight to the queue rather than to a
|
|
// login form asking for the credential just chosen.
|
|
history.replaceState({ depth: 0 }, '', '/');
|
|
route = parseRoute('/');
|
|
await loadTeams();
|
|
teamselector.render();
|
|
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
|
showApp();
|
|
} catch (ex) {
|
|
err.textContent = ex.message;
|
|
err.hidden = false;
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// Why single sign-on sent the browser back, by the code the server puts in
|
|
// ?sso_error=. The provider's name is the one the administrator configured.
|
|
function ssoErrorText(code, name) {
|
|
const sso = name || 'single sign-on';
|
|
return {
|
|
denied: `Signing in with ${sso} was cancelled or refused.`,
|
|
expired: 'That sign-in expired or was already used. Try again.',
|
|
failed: `Signing in with ${sso} failed. Try again, and tell an administrator if it keeps happening.`,
|
|
unavailable: `${sso} could not be reached. Try again in a moment.`,
|
|
not_allowed: 'Your account is not allowed to use terdut. Ask an administrator to add you to the right group.',
|
|
no_email: `${sso} did not send an email address for you, which terdut needs.`,
|
|
email_conflict: 'An account with your email address already exists and could not be linked to this sign-in. Ask an administrator.',
|
|
disabled: 'Your account is disabled. Ask an administrator.',
|
|
not_bootstrapped: 'This install is still setting up. Try again in a moment.',
|
|
}[code] || `Signing in with ${sso} failed.`;
|
|
}
|
|
|
|
// Reads how the server can be signed in to. An older server has no such
|
|
// endpoint, and one that cannot be asked is treated as offering passwords only:
|
|
// the form that always existed is better than a blank page.
|
|
async function loadAuthConfig() {
|
|
try {
|
|
state.auth = await api.authConfig();
|
|
} catch {
|
|
/* keep the last answer, or the defaults */
|
|
}
|
|
return state.auth;
|
|
}
|
|
|
|
// The reason the last single sign-on attempt failed, read once at boot. It is
|
|
// held here rather than re-read from the address because showLogin runs more
|
|
// than once on the way to the form (the 401 from /api/me reaches it through the
|
|
// API layer and again through boot's own catch), and only the first would see it.
|
|
let ssoErrorCode = null;
|
|
|
|
// Takes ?sso_error= off the address, so a reload does not repeat the message.
|
|
function takeSSOError() {
|
|
const params = new URLSearchParams(location.search);
|
|
const code = params.get('sso_error');
|
|
if (code === null) return null;
|
|
params.delete('sso_error');
|
|
const query = params.toString();
|
|
history.replaceState(null, '', location.pathname + (query ? `?${query}` : '') + location.hash);
|
|
return code;
|
|
}
|
|
|
|
async function showLogin() {
|
|
poll.stop();
|
|
ui.closeSheet(null);
|
|
reset();
|
|
$('app').hidden = true;
|
|
$('signup-form').hidden = true;
|
|
|
|
// Ask before showing anything, so the form does not flash the password
|
|
// fields at somebody whose server has turned them off.
|
|
const auth = await loadAuthConfig();
|
|
const sso = auth.oidc?.enabled ? auth.oidc : null;
|
|
const passwords = auth.password_login !== false;
|
|
|
|
const link = $('sso-link');
|
|
link.hidden = !sso;
|
|
if (sso) {
|
|
link.textContent = `Sign in with ${sso.name || 'SSO'}`;
|
|
// Come back to the page that was asked for: a link to /device?code=... has
|
|
// to survive the trip through the provider. The server only honours paths
|
|
// on this server, and ignores the front page.
|
|
const here = location.pathname + location.search;
|
|
link.href = here === '/' ? '/api/oidc/login' : `/api/oidc/login?next=${encodeURIComponent(here)}`;
|
|
}
|
|
$('login-or').hidden = !(sso && passwords);
|
|
$('password-login').hidden = !passwords;
|
|
|
|
const ssoErr = $('sso-error');
|
|
ssoErr.hidden = ssoErrorCode === null;
|
|
if (ssoErrorCode !== null) ssoErr.textContent = ssoErrorText(ssoErrorCode, sso?.name);
|
|
|
|
$('boot').hidden = true;
|
|
$('login').hidden = false;
|
|
$('login-form').hidden = false;
|
|
const form = $('login-form');
|
|
form.querySelector('#password-login .form-error').hidden = true;
|
|
if (!passwords) return;
|
|
// Only offer the door that is open. Somebody without an invite on an
|
|
// invite-only server should be told, not sent to a form that refuses them.
|
|
api.signupInfo().then((info) => {
|
|
$('signup-link').hidden = info.mode !== 'open';
|
|
}).catch(() => {});
|
|
form.password.value = '';
|
|
(form.username.value ? form.password : form.username).focus();
|
|
}
|
|
|
|
async function onLogin(e) {
|
|
e.preventDefault();
|
|
const form = e.currentTarget;
|
|
// Not the first .form-error: that one is the single sign-on message above.
|
|
const err = form.querySelector('#password-login .form-error');
|
|
const btn = form.querySelector('button[type=submit]');
|
|
err.hidden = true;
|
|
// Whatever single sign-on said is about the last attempt, not this one.
|
|
ssoErrorCode = null;
|
|
$('sso-error').hidden = true;
|
|
btn.disabled = true;
|
|
try {
|
|
state.me = await api.login(form.username.value.trim(), form.password.value);
|
|
form.password.value = '';
|
|
showApp();
|
|
} catch (ex) {
|
|
err.textContent = ex.message;
|
|
err.hidden = false;
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
export async function signOut() {
|
|
try {
|
|
await api.logout();
|
|
} catch {
|
|
/* the cookie is cleared server-side or already gone */
|
|
}
|
|
showLogin();
|
|
}
|
|
|
|
function showApp() {
|
|
ssoErrorCode = null;
|
|
$('boot').hidden = true;
|
|
$('login').hidden = true;
|
|
$('app').hidden = false;
|
|
render();
|
|
poll.start(refresh);
|
|
poll.now();
|
|
}
|
|
|
|
// ---------- links + keys ----------
|
|
|
|
function interceptLinks(e) {
|
|
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
const a = e.target.closest('a[href]');
|
|
if (!a || a.target || a.origin !== location.origin || a.pathname.startsWith('/api/')) return;
|
|
e.preventDefault();
|
|
navigate(a.pathname + a.search);
|
|
}
|
|
|
|
function onKey(e) {
|
|
if (e.metaKey || e.ctrlKey || e.altKey || ui.sheetIsOpen() || $('app').hidden) return;
|
|
const tag = e.target.tagName;
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
|
|
|
if (e.key === 'r') {
|
|
poll.now();
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
if (route.section !== 'queue') return;
|
|
if (route.incident != null && incident.key(e)) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
if (queue.key(e)) e.preventDefault();
|
|
}
|
|
|
|
boot();
|