// 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 account from './account.js'; import * as team from './team.js'; import * as admin from './admin.js'; import * as adminuser from './adminuser.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 }, team: { title: 'Team', view: team }, admin: { title: 'Admin', view: admin }, adminuser: { title: 'User', view: adminuser, nav: 'admin' }, more: { title: 'Account', view: account }, }; 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]) }; const name = pathname.replace(/^\/|\/$/g, ''); if (name === 'oncall' || name === 'alerts' || name === 'team' || name === 'admin' || name === 'more') return { section: name }; return { section: 'queue', incident: null }; } 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, s] of Object.entries(SECTIONS)) { const el = $(`view-${name}`); el.hidden = name !== route.section; if (name === route.section) $('topbar-title').textContent = s.title; } // 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)); else if (prev.section !== route.section) window.scrollTo(0, 0); updateTitle(); } // ---------- 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); const badge = document.querySelector('[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 = SECTIONS[route.section].title; 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); // /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();