Files
terdut-server/internal/web/static/js/queue.js
T
Niklas Ye b39aac36b7
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 13s
CI / test (pull_request) Successful in 2m28s
Add the sign-up page and the first-run checklist
Second half of #7. The API could create accounts from invite links since
the last change; this is the part somebody can actually use.

/signup is the one route that works without a session. It asks the server
what it may offer before showing anything: an invite link that is good
names the team it leads to, a link that is not says so before somebody
picks a password rather than after, and an invite-only server with no
link says that instead of presenting a form it will refuse. The login
card only offers "create one" when sign-up is open, so the door nobody
can walk through is not advertised.

Signing up signs you in and lands on the queue, because the alternative
is a form saying "now go and log in" about the credential just chosen.

The checklist is the other half. Four things have to be true before an
alert reaches a phone -- a notification topic, somebody on the rota, an
alert source, and an alert that has actually arrived -- and on a fresh
install none of them are. It sits above the queue until they are.

It is computed from the data rather than from stored progress: a topic is
set or it is not, an integration exists or it does not. That means it
cannot claim a step is done when it is not, and it comes back by itself
if somebody deletes their integration a month later. The only stored
state is the dismissal, which is per user and not per browser --
finishing on a laptop should not leave the phone nagging.

The topic step is the only one the checklist can finish itself, and the
only proof that counts is a phone buzzing, so there is a test push.
POST /api/me/notify/test publishes directly rather than through the
outbox, which requires an incident this deliberately does not have. Its
failure is the useful part: a wrong topic, a rejected token and an ntfy
that is down all look identical from the phone, which is silence, so the
error comes back to the browser instead.

Verified against a live server with a real ntfy stand-in, the whole path:
an owner mints an invite, the sign-up page reports it valid and names the
team, the invitee signs up and is signed in as a member of that team, the
checklist's four questions answer correctly on a fresh install, a test
push is refused with no topic and delivered with one -- "PAGED
terdut-owner | terdut test" -- and the dismissal survives a reload.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 10:47:15 +02:00

248 lines
7.6 KiB
JavaScript

// The incident queue: filter chips and a list of incident rows.
import * as api from './api.js';
import { h, clear, badge, emptyState, spinner } from './ui.js';
import { age, until, isFuture, severityClass, labelSummary } from './format.js';
import { state, myID } from './state.js';
import * as onboarding from './onboarding.js';
import { navigate } from './app.js';
// The same filters as the TUI's `f` cycle, plus archived ones to get back to.
const FILTERS = [
{ id: 'open', label: 'Open', query: { sort: 'severity' } },
{ id: 'triggered', label: 'Triggered', query: { status: 'triggered', sort: 'severity' } },
{ id: 'acknowledged', label: 'Acked', query: { status: 'acknowledged', sort: 'severity' } },
{ id: 'snoozed', label: 'Snoozed', query: { snoozed: 'true' } },
{ id: 'resolved', label: 'Resolved', query: { status: 'resolved' } },
{ id: 'archived', label: 'Archived', query: { status: 'resolved', archived: 'true' } },
];
const EMPTY = {
open: ['All clear', 'Nothing open right now.'],
triggered: ['Nothing triggered', 'Every open incident has been acknowledged.'],
acknowledged: ['Nothing acknowledged', 'No one is working an incident right now.'],
snoozed: ['Nothing snoozed', 'Snoozed incidents show up here until the snooze runs out.'],
resolved: ['Nothing resolved', 'Resolved incidents are archived after a while.'],
archived: ['Nothing archived', ''],
};
onboarding.onRerender(() => renderList());
let filter = loadFilter();
let teamFilter = loadTeamFilter(); // '' for every team the viewer is in
let items = null; // null while loading
let error = null;
let selected = null;
let cursor = -1; // keyboard position in the list
let built = false;
function loadTeamFilter() {
try {
return sessionStorage.getItem('terdut.queue.team') || '';
} catch {
return '';
}
}
function setTeamFilter(id) {
teamFilter = id;
try {
sessionStorage.setItem('terdut.queue.team', id);
} catch {
/* storage unavailable */
}
renderChips();
refresh({ fresh: true });
}
function loadFilter() {
try {
const f = sessionStorage.getItem('terdut.queue.filter');
if (FILTERS.some((x) => x.id === f)) return f;
} catch {
/* storage unavailable */
}
return 'open';
}
function saveFilter() {
try {
sessionStorage.setItem('terdut.queue.filter', filter);
} catch {
/* storage unavailable */
}
}
export function show(incidentID) {
selected = incidentID;
if (!built) {
renderChips();
built = true;
}
renderList();
}
export async function refresh({ fresh = false } = {}) {
const f = FILTERS.find((x) => x.id === filter);
const requested = filter;
try {
// The open list is already fetched for the badges; no need to ask twice.
// The cached open queue covers every team, so it can only be reused when
// no team filter is applied.
const query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query;
const cached = filter === 'open' && !fresh && !teamFilter;
const result = cached ? state.open : await api.incidents(query);
await onboarding.load();
if (requested !== filter) return;
items = result;
error = null;
} catch (err) {
if (requested !== filter) return;
error = err.message;
}
renderList();
}
function setFilter(id) {
if (id === filter) return;
filter = id;
saveFilter();
items = null;
cursor = -1;
renderChips();
renderList();
refresh({ fresh: true });
}
function renderChips() {
const el = document.getElementById('queue-filters');
const chips = FILTERS.map((f) =>
h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(f.id === filter),
onclick: () => setFilter(f.id),
text: f.label,
}),
);
// Somebody in one team has nothing to choose between, so the row of team
// chips appears only when there is more than one. The default is all of
// them: the combined queue is the point.
if (state.teams.length > 1) {
chips.push(h('span', { class: 'chip-sep' }));
chips.push(h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(teamFilter === ''),
onclick: () => setTeamFilter(''),
text: 'All teams',
}));
for (const team of state.teams) {
chips.push(h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(teamFilter === String(team.id)),
onclick: () => setTeamFilter(String(team.id)),
text: team.name,
}));
}
}
clear(el, chips);
}
function renderList() {
const el = document.getElementById('queue-list');
const checklist = onboarding.card();
if (error && !items) {
clear(el, checklist, h('div', { class: 'load-error', text: error }));
return;
}
if (!items) {
clear(el, checklist, spinner());
return;
}
if (!items.length) {
const [title, text] = EMPTY[filter];
clear(el, checklist, emptyState(title, text, filter === 'open' ? 'checkCircle' : null));
return;
}
clear(el,
checklist,
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
items.map((inc, i) => row(inc, i)),
);
}
function row(inc, index) {
const snoozed = isFuture(inc.snoozed_until);
const resolved = inc.status === 'resolved';
let status;
if (resolved) status = badge('Resolved', 'st-resolved');
else if (snoozed) status = badge(`Snoozed · ${until(inc.snoozed_until)}`, 'st-snoozed');
else if (inc.status === 'acknowledged') {
const by = inc.acknowledged_by_id === myID() ? 'you' : inc.acknowledged_by;
status = badge(`Acked${by ? ' · ' + by : ''}`, 'st-acknowledged');
}
else status = badge('Triggered', 'st-triggered');
let assignee = null;
if (inc.assigned_to_id != null) {
assignee = h('span', { text: inc.assigned_to_id === myID() ? '→ you' : `→ ${inc.assigned_to}` });
}
// The server already puts the group labels in the title; show only the rest.
const labels = labelSummary(Object.fromEntries(
Object.entries(inc.group_labels || {}).filter(([k, v]) => !inc.title.includes(`${k}=${v}`))));
// The team is shown only to somebody who is in more than one. For everybody
// else it is the same word on every row, which is noise rather than
// information.
const team = state.teams.length > 1 && inc.team_name
? h('span', { class: 'row-team', text: inc.team_name })
: null;
return h('a', {
class: `row ${severityClass(inc.severity)} ${resolved ? 'resolved' : ''} ${index === cursor ? 'kbd-focus' : ''}`,
href: `/incidents/${inc.id}`,
'aria-current': inc.id === selected ? 'true' : null,
dataset: { index: String(index) },
},
h('div', { class: 'row-title', text: inc.title }),
h('div', { class: 'row-age', title: inc.triggered_at, text: age(inc.triggered_at) }),
h('div', { class: 'row-meta' },
status,
assignee,
team,
labels && h('span', { class: 'labels', text: labels }),
),
);
}
// key handles j/k/enter on the list. Returns true when it used the key.
export function key(e) {
if (!items || !items.length) return false;
if (e.key === 'j' || e.key === 'ArrowDown') {
cursor = Math.min(items.length - 1, cursor + 1);
} else if (e.key === 'k' || e.key === 'ArrowUp') {
cursor = Math.max(0, cursor - 1);
} else if (e.key === 'Enter' && cursor >= 0) {
navigate(`/incidents/${items[cursor].id}`);
return true;
} else if (e.key === 'f') {
const i = FILTERS.findIndex((x) => x.id === filter);
setFilter(FILTERS[(i + 1) % FILTERS.length].id);
return true;
} else {
return false;
}
renderList();
const el = document.querySelector(`#queue-list [data-index="${cursor}"]`);
if (el) el.scrollIntoView({ block: 'nearest' });
return true;
}