bcf1a3e99b
The desktop sidebar is now grouped: Queue, On-call, Alerts and Stats; Team and Admin; then Account at the foot, shown as the signed-in person with an avatar and their name. The active item has a bar as well as a tint. The queue's row of team chips is gone: the team selector is the one place the team is chosen, and the chips offered the same choice a second time. Tables get 16px between columns, so a right-aligned count no longer touches the text beside it. An empty SSO group reads "Not configured" instead of a dash, and the main action on each of those pages (New source, New switch, Add member, Assign, and the admin Add, Create invite and Create) is a filled button, with Edit and Cancel staying secondary. The bare triangles on "Grouped by" and the label lists are real disclosure buttons: a chevron that turns and a 44px target on a touch screen. On a phone the More tab was a <button> that kept the browser's grey box, so it looked highlighted next to four plain links. It now matches them, the labels are 12px and the open tab's icon is filled. Web UI only: no endpoint or JSON shape changed, so nothing to mirror in terdut-tui.
242 lines
8.7 KiB
JavaScript
242 lines
8.7 KiB
JavaScript
// The incident queue: filter chips and a list of incident rows.
|
|
|
|
import * as api from './api.js';
|
|
import { h, clear, badge, severityBadge, emptyState, spinner } from './ui.js';
|
|
import { ago, until, isFuture, severityClass, labelSummary } 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;
|
|
}
|
|
|
|
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.
|
|
chips.push(h('span', { class: 'chips-fade', 'aria-hidden': 'true' }));
|
|
|
|
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, 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;
|
|
|
|
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: `Triggered ${ago(inc.triggered_at)}` }),
|
|
h('div', { class: 'row-meta' },
|
|
!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;
|
|
}
|