b39aac36b7
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
248 lines
7.6 KiB
JavaScript
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;
|
|
}
|