// 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 { 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', ''], }; 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. const result = filter === 'open' && !fresh ? state.open : await api.incidents(f.query); 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'); clear(el, FILTERS.map((f) => h('button', { class: 'chip', type: 'button', role: 'tab', 'aria-selected': String(f.id === filter), onclick: () => setFilter(f.id), text: f.label, }), )); } function renderList() { const el = document.getElementById('queue-list'); if (error && !items) { clear(el, h('div', { class: 'load-error', text: error })); return; } if (!items) { clear(el, spinner()); return; } if (!items.length) { const [title, text] = EMPTY[filter]; clear(el, emptyState(title, text, filter === 'open' ? 'checkCircle' : null)); return; } clear(el, 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}`)))); 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, 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; }