Files
terdut-server/internal/web/static/js/queue.js
T
Niklas Ye fb86a18988 Show which cluster an incident came from, as a chip
A team with one Alertmanager per Kubernetes cluster could not tell at a
glance where an incident started: the cluster was only a word inside the
title. When an alert carries a `cluster` label, the queue rows, the incident
page and the alerts list now show it as a chip in that cluster's colour, a
stable pick from the existing six-colour palette. A queue row drops
`cluster=...` from its title, since the chip says it, and the incident page
keeps the full title.

An incident has the label only when it is in Alertmanager's group_by, which
is also what keeps two clusters' identical alerts apart: incidents are matched
on the team and the groupKey, and the groupKey does not include external
labels. The README has a section on the two settings (Prometheus
externalLabels and group_by). The alerts list reads the label from the alert
itself, so it shows the chip with only the external label.

Web UI and docs only: no endpoint or JSON shape changed, so nothing to mirror
in terdut-tui. Nothing changes for a team whose alerts have no cluster label.
2026-10-08 17:24:52 +02:00

249 lines
9.0 KiB
JavaScript

// The incident queue: filter chips and a list of incident rows.
import * as api from './api.js';
import { h, clear, badge, severityBadge, originChip, emptyState, spinner, fadeOnOverflow } from './ui.js';
import { ago, until, isFuture, severityClass, labelSummary, originOf, titleWithoutOrigin } from './format.js';
import { state, myID, 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;
}
// Set the first time the chips are drawn: the strip element is the same one
// for the life of the page, so one scroll listener and one observer will do.
let chipsFade;
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 === 0 ? 'count zero' : 'count', text: String(count) }));
});
// There are no team chips here: the team selector in the sidebar (and in the
// phone's top bar) is the one place the team is chosen, and chips for it
// would be the same choice offered twice.
clear(el, chips);
chipsFade ??= fadeOnOverflow(el);
chipsFade();
}
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';
// A filter that already says what the status is (Triggered, Resolved,
// Archived) would only have every row repeat it.
const impliedStatus = filter === 'triggered' || filter === 'resolved' || filter === 'archived';
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.
// ...and not when the queue is already narrowed to one team: that team is
// the same word on every row there too.
const team = state.teams.length > 1 && state.selectedTeamID == null && inc.team_name
? h('span', { class: 'row-team', text: inc.team_name })
: null;
const origin = originOf(inc.group_labels);
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: titleWithoutOrigin(inc.title, origin) }),
h('div', { class: 'row-age', title: inc.triggered_at, text: `Triggered ${ago(inc.triggered_at)}` }),
h('div', { class: 'row-meta' },
origin && originChip(origin),
!impliedStatus && 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 && severityBadge(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;
}