74359c72ab
The rest of #4. Two halves that belong together because they are the same sentence from opposite ends: a team decides which of its alerts are heartbeats, and the UI has to be able to say which team it is talking about. Switches were three environment variables, which made them one setting for the whole install. That was the last piece of the alerting path a team could not control: it could take its own alerts on its own key and still not say which of them were heartbeats, or how long a silence had to last. They are a row per team now, edited by an owner through PUT /api/teams/{teamID}/deadman, and the sweeper runs each team against its own matchers, timeout and severity. The environment variables become the starting point rather than the setting. Every team without a configuration is seeded from them at startup, so an upgrade keeps watching exactly what it was watching, and SeedDeadmanConfigs never overwrites -- a redeploy must not put the environment's value back over an owner's edit. A team created later watches nothing until somebody says otherwise: inheriting an install-wide heartbeat would page a new team about a source it has never heard of, and a switch nobody chose is the kind that gets muted rather than fixed. A matcher string with no alertname in it is refused at the door instead of stored. Storing it would produce a switch that watches nothing silently, which is the exact failure the feature exists to prevent. NewRouter and Sweep lose their DeadmanConfig parameter -- there is no longer one answer to hand them. The type stays, because parsing a matcher string is still parsing a matcher string. The UI side: rows in the queue carry a team badge, the filter row gains a team chip per team, and "on call now" shows one card per team. All three appear only when the viewer is in more than one team -- otherwise they are the same word repeated down a list, which is noise rather than information, and the single-team install reads exactly as it did before teams existed. Verified against a live two-team server as well as in tests: the combined queue labelled by team, the team_id filter, a heartbeat that is a heartbeat in one team and an ordinary alert in another, and a new team's switches starting empty while the upgraded team keeps the environment's. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
242 lines
7.4 KiB
JavaScript
242 lines
7.4 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 { 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 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);
|
|
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');
|
|
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}`))));
|
|
// 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;
|
|
}
|