a955356821
A team with a cluster per alert source can now narrow the queue to one cluster. A dropdown under the status chips lists the clusters, and appears once there are two or more to choose between, the rule the team selector follows. The choice is kept per browser, like the selected team, and dropped if the server no longer knows the cluster rather than leaving an empty list with no explanation. The status chip counts follow it, and a row leaves out its cluster chip once the queue is narrowed to one. The filter is on the server. The list is capped at 50 rows, so filtering what is on screen would quietly miss older incidents in the Resolved and Archived lists. GET /api/incidents takes ?cluster=<value>, matched against the incident's `cluster` group label, and GET /api/incidents/clusters lists the distinct values from the last 90 days (optionally for one team), so the dropdown is not limited to what the current page happens to show. Both are additive: no existing parameter or JSON shape changed, so terdut-tui keeps working unchanged and has nothing it must mirror. An incident carries the label only when `cluster` is in Alertmanager's group_by, so the filter only sees those; the README says so.
341 lines
12 KiB
JavaScript
341 lines
12 KiB
JavaScript
// The incident queue: filter chips and a list of incident rows.
|
|
|
|
import * as api from './api.js';
|
|
import { h, clear, icon, 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(async () => {
|
|
renderChips();
|
|
await loadClusters(true);
|
|
renderOrigin();
|
|
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';
|
|
}
|
|
|
|
// Which cluster the queue is narrowed to, or null for all of them. Kept in
|
|
// localStorage like the selected team: it is a standing choice, not a
|
|
// per-tab one.
|
|
const ORIGIN_KEY = 'terdut.queue.cluster';
|
|
let origin = loadOrigin();
|
|
let clusters = []; // the clusters the server has seen, for the dropdown
|
|
let clustersAt = 0;
|
|
|
|
function loadOrigin() {
|
|
try {
|
|
return localStorage.getItem(ORIGIN_KEY) || null;
|
|
} catch {
|
|
return null; // storage unavailable
|
|
}
|
|
}
|
|
|
|
function saveOrigin() {
|
|
try {
|
|
if (origin) localStorage.setItem(ORIGIN_KEY, origin);
|
|
else localStorage.removeItem(ORIGIN_KEY);
|
|
} catch {
|
|
/* storage unavailable */
|
|
}
|
|
}
|
|
|
|
// The clusters to offer, from the server because the list on screen is only
|
|
// the fifty most recent. Asked again at most every five minutes; a cluster
|
|
// first met in an incident on screen is added at once (see noteClusters). A
|
|
// remembered choice the server no longer knows would filter to nothing with no
|
|
// hint why, so it is dropped.
|
|
async function loadClusters(force = false) {
|
|
if (!force && Date.now() - clustersAt < 5 * 60 * 1000) return;
|
|
try {
|
|
const query = state.selectedTeamID != null ? { team_id: state.selectedTeamID } : undefined;
|
|
clusters = await api.clusters(query);
|
|
clustersAt = Date.now();
|
|
if (origin && !clusters.includes(origin)) {
|
|
origin = null;
|
|
saveOrigin();
|
|
}
|
|
} catch {
|
|
/* the filter is a convenience: without the list it stays hidden */
|
|
}
|
|
}
|
|
|
|
function noteClusters(list) {
|
|
let added = false;
|
|
for (const inc of list || []) {
|
|
const c = originOf(inc.group_labels);
|
|
if (c && !clusters.includes(c)) {
|
|
clusters = [...clusters, c].sort();
|
|
added = true;
|
|
}
|
|
}
|
|
return added;
|
|
}
|
|
|
|
function saveFilter() {
|
|
try {
|
|
sessionStorage.setItem('terdut.queue.filter', filter);
|
|
} catch {
|
|
/* storage unavailable */
|
|
}
|
|
}
|
|
|
|
export function show(incidentID) {
|
|
selected = incidentID;
|
|
if (!built) {
|
|
renderChips();
|
|
renderOrigin();
|
|
built = true;
|
|
}
|
|
renderList();
|
|
}
|
|
|
|
export async function refresh({ fresh = false } = {}) {
|
|
const f = FILTERS.find((x) => x.id === filter);
|
|
const requested = filter;
|
|
try {
|
|
// First, so a remembered cluster the server no longer knows is dropped
|
|
// before it is sent as a filter.
|
|
await loadClusters();
|
|
// 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 = { ...f.query };
|
|
if (state.selectedTeamID != null) query.team_id = state.selectedTeamID;
|
|
if (origin) query.cluster = origin;
|
|
const cached = filter === 'open' && !fresh && state.selectedTeamID == null && !origin;
|
|
const result = cached ? state.open : await api.incidents(query);
|
|
await onboarding.load();
|
|
if (requested !== filter) return;
|
|
items = result;
|
|
noteClusters(state.open);
|
|
noteClusters(items);
|
|
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();
|
|
renderOrigin();
|
|
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.open.filter((i) =>
|
|
(state.selectedTeamID == null || i.team_id === state.selectedTeamID)
|
|
&& (!origin || originOf(i.group_labels) === origin));
|
|
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();
|
|
}
|
|
|
|
// The cluster dropdown, under the status chips. Not shown at all until there
|
|
// are two clusters to choose between, the same rule the team selector follows.
|
|
function renderOrigin() {
|
|
const el = document.getElementById('queue-origin');
|
|
if (clusters.length < 2) {
|
|
el.hidden = true;
|
|
return;
|
|
}
|
|
const select = h('select', { class: 'origin-select', 'aria-label': 'Cluster' },
|
|
h('option', { value: '', text: 'All clusters', selected: !origin }),
|
|
clusters.map((c) => h('option', { value: c, text: c, selected: c === origin })));
|
|
select.addEventListener('change', () => {
|
|
origin = select.value || null;
|
|
saveOrigin();
|
|
items = null;
|
|
cursor = -1;
|
|
renderChips();
|
|
renderList();
|
|
refresh({ fresh: true });
|
|
});
|
|
clear(el, icon('server', 'icon origin-select-icon'), select);
|
|
el.hidden = false;
|
|
}
|
|
|
|
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 rowOrigin = 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, rowOrigin) }),
|
|
h('div', { class: 'row-age', title: inc.triggered_at, text: `Triggered ${ago(inc.triggered_at)}` }),
|
|
h('div', { class: 'row-meta' },
|
|
rowOrigin && !origin && originChip(rowOrigin),
|
|
!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;
|
|
}
|