423ed9b3a3
Statistics used to live only in terdut-tui; the account page said so. The page shows the same figures as the TUI's Stats tab -- incident counts, MTTA and MTTR, top alerts, and alert frequency by hour (UTC) and by day of week -- and adds a range picker (Today, 7d, 30d, 90d, All) that the TUI does not have. The ranges are day-granular because the server reads from/to as whole UTC dates, so there is no 24h chip. No server change: the page uses the existing /api/stats/* endpoints, which already scope to the caller's teams. Charts are inline SVG and plain elements sized from script, because the CSP forbids inline styles, inline scripts and CDN libraries. Removes the "statistics are in terdut-tui" notes from the account page and the README.
420 lines
15 KiB
JavaScript
420 lines
15 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 admin from './admin.js';
|
|
import * as adminuser from './adminuser.js';
|
|
import * as adminteam from './adminteam.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 },
|
|
};
|
|
|
|
// The mobile hamburger menu's contents — the same sections the desktop
|
|
// sidebar's .nav-link list carries in index.html, in the same order.
|
|
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' },
|
|
{ path: '/team', section: 'team', label: 'Team', icon: 'team' },
|
|
{ path: '/admin', section: 'admin', label: 'Admin', icon: 'shield', adminOnly: true },
|
|
{ path: '/more', section: 'more', label: 'Account', icon: 'user' },
|
|
];
|
|
|
|
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') 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 mobile hamburger menu: same shape as the sheet-based action menus in
|
|
// incident.js (openSheet + a <ul class="menu"> of menu-item buttons), one
|
|
// item per NAV_ITEMS entry, resolving with a path for navigate() to use.
|
|
function openNavMenu() {
|
|
const current = SECTIONS[route.section].nav || route.section;
|
|
const triggered = state.open.filter((i) => i.status === 'triggered').length;
|
|
const items = NAV_ITEMS.filter((n) => !n.adminOnly || state.me?.user?.is_admin);
|
|
ui.openSheet(() => [
|
|
ui.h('h2', { class: 'sheet-title', text: 'Sections' }),
|
|
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,
|
|
n.section === 'queue' && triggered > 0 && ui.badge(String(triggered), 'st-triggered menu-sub'),
|
|
))),
|
|
),
|
|
]).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.textContent = open ? `${open} open` : 'All clear';
|
|
pill.classList.toggle('has-triggered', triggered > 0);
|
|
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
|
|
|
|
// Two badges carry this count: the sidebar's Queue tab (desktop) and the
|
|
// hamburger button (mobile) — only one of the two is ever visible at once.
|
|
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);
|
|
$('menu-btn').addEventListener('click', openNavMenu);
|
|
|
|
// /signup is the one route that works without a session.
|
|
if (location.pathname.replace(/\/$/, '') === '/signup') {
|
|
$('boot').hidden = true;
|
|
await showSignup();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
state.me = await api.me();
|
|
await loadTeams();
|
|
// 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();
|
|
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
|
showApp();
|
|
} catch (ex) {
|
|
err.textContent = ex.message;
|
|
err.hidden = false;
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function showLogin() {
|
|
poll.stop();
|
|
ui.closeSheet(null);
|
|
reset();
|
|
$('boot').hidden = true;
|
|
$('app').hidden = true;
|
|
$('login').hidden = false;
|
|
$('signup-form').hidden = true;
|
|
$('login-form').hidden = false;
|
|
const form = $('login-form');
|
|
form.querySelector('.form-error').hidden = true;
|
|
// 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;
|
|
const err = form.querySelector('.form-error');
|
|
const btn = form.querySelector('button[type=submit]');
|
|
err.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() {
|
|
$('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();
|