// 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, 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.'], 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()); // 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; } 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(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, )); } } 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; }