// 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 { ago, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js'; import { state, myID, setSelectedTeam, onTeamChange } 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.', 'checkCircle'], triggered: ['Nothing triggered', 'Every open incident has been acknowledged.', 'checkCircle'], acknowledged: ['Nothing acknowledged', 'No one is working an incident right now.', 'checkCircle'], snoozed: ['Nothing snoozed', 'Snoozed incidents show up here until the snooze runs out.', 'clock'], resolved: ['Nothing resolved', 'Resolved incidents are archived after a while.', null], archived: ['Nothing archived', 'Resolved incidents land here once archived.', 'archive'], }; onboarding.onRerender(() => renderList()); // The queue used to keep its own team filter (a per-tab sessionStorage value, // out of step with team.js's own picker); both now defer to the global // selector's shared state, so re-render whenever it changes. onTeamChange(() => { renderChips(); refresh({ fresh: true }); }); let filter = loadFilter(); let items = null; // null while loading let error = null; let selected = null; let cursor = -1; // keyboard position in the list let built = false; 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 = state.selectedTeamID != null ? { ...f.query, team_id: state.selectedTeamID } : f.query; const cached = filter === 'open' && !fresh && state.selectedTeamID == null; 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; } // state.open (what the chip counts read) has just been refreshed too, by // whichever caller updated it before calling here — app.js's poll, or the // `cached` branch above. renderChips(); renderList(); } function setFilter(id) { if (id === filter) return; filter = id; saveFilter(); items = null; cursor = -1; renderChips(); renderList(); refresh({ fresh: true }); } // Counts for the three chips derivable from the open list already fetched // for the badges — Snoozed/Resolved/Archived would need a request of their // own, so those chips stay count-less for now. function chipCount(id) { const open = state.selectedTeamID == null ? state.open : state.open.filter((i) => i.team_id === state.selectedTeamID); if (id === 'open') return open.length; if (id === 'triggered') return open.filter((i) => i.status === 'triggered').length; if (id === 'acknowledged') return open.filter((i) => i.status === 'acknowledged').length; return null; } function renderChips() { const el = document.getElementById('queue-filters'); const chips = FILTERS.map((f) => { const count = chipCount(f.id); return h('button', { class: 'chip', type: 'button', role: 'tab', 'aria-selected': String(f.id === filter), onclick: () => setFilter(f.id), text: f.label, }, count != null && h('span', { class: 'count', text: String(count) })); }); // 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(state.selectedTeamID == null), onclick: () => setSelectedTeam(null), }, h('span', { class: 'team-dot' }), ' All teams')); for (const team of state.teams) { chips.push(h('button', { class: 'chip', type: 'button', role: 'tab', 'aria-selected': String(team.id === state.selectedTeamID), onclick: () => setSelectedTeam(team.id), }, h('span', { class: `team-dot ${teamColorClass(team.id)}` }), ' ' + team.name, )); } } // A scroll hint for the phone-width row, where the chips can run off the // right edge with nothing to suggest there's more; the desktop sidebar // wraps instead of scrolling (see .pane-list .chips), so this fades out // there via CSS rather than being left out here. chips.push(h('span', { class: 'chips-fade', 'aria-hidden': 'true' })); 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, iconName] = EMPTY[filter]; clear(el, checklist, emptyState(title, text, iconName)); 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: `Triggered ${ago(inc.triggered_at)}` }), h('div', { class: 'row-meta' }, status, // The left-border colour alone doesn't say what it means; spell it out // too, same badge the incident detail page uses for severity. inc.severity && badge(inc.severity, `plain ${severityClass(inc.severity)}`), 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; }