Pad the settings cards, and make status and timeline readable at a glance
The Team settings cards (Escalation, Sources, Members, Switches, Single sign-on) printed their text flush against the border with the button in the corner: .card has never had padding and these never added any. A card with a header row now pads itself, with the title left, the button right and a divider before the content. The incident page no longer carries the primary action twice. The copy up by the status is gone and the sticky bar keeps it; Note is the timeline's link, with Copy in the bar for a resolved incident, and the phone's More sheet drops what the bar already shows. In the queue, a row's title wraps to two lines so the namespace that tells rows apart is no longer cut off, zero counts on the filter chips are dimmed, and a row omits the status the filter already states and the team once the queue is narrowed to one. Status colours failed 4.5:1 against their own fill in the light theme (warning 3.6, info 4.1, critical 4.4, ok 4.45, snooze 4.49), so the light tokens are darker; the dark theme already passed and is unchanged. Severity badges now carry a shape as well as a colour, and each timeline event has an icon. An escalation that ran out of levels, a failed notification and a silent heartbeat stand out in amber or red. Web UI only: no endpoint or JSON shape changed, so nothing to mirror in terdut-tui.
This commit is contained in:
+42
-22
@@ -20,23 +20,23 @@
|
|||||||
--accent-text: #ffffff;
|
--accent-text: #ffffff;
|
||||||
--accent-soft: #e8eefc;
|
--accent-soft: #e8eefc;
|
||||||
|
|
||||||
--crit: #d0342c;
|
--crit: #c22d26;
|
||||||
--crit-soft: #fdecea;
|
--crit-soft: #fdecea;
|
||||||
--warn: #b86e00;
|
--warn: #8f5500;
|
||||||
--warn-soft: #fdf3e1;
|
--warn-soft: #fdf3e1;
|
||||||
--info: #2f6fdf;
|
--info: #245fc7;
|
||||||
--info-soft: #e9f0fd;
|
--info-soft: #e9f0fd;
|
||||||
--ok: #1d7f4c;
|
--ok: #1a7445;
|
||||||
--ok-soft: #e6f5ec;
|
--ok-soft: #e6f5ec;
|
||||||
--snooze: #6b5bd2;
|
--snooze: #6050c8;
|
||||||
--snooze-soft: #efedfb;
|
--snooze-soft: #efedfb;
|
||||||
|
|
||||||
/* Two hues that mean nothing on their own. The rota needs six colours to
|
/* Two hues that mean nothing on their own. The rota needs six colours to
|
||||||
tell six people apart and the palette above only has four that are not
|
tell six people apart and the palette above only has four that are not
|
||||||
already an alarm. */
|
already an alarm. */
|
||||||
--teal: #0f7d8c;
|
--teal: #0c7180;
|
||||||
--teal-soft: #e3f4f6;
|
--teal-soft: #e3f4f6;
|
||||||
--pink: #b3427e;
|
--pink: #a63b73;
|
||||||
--pink-soft: #fbe8f2;
|
--pink-soft: #fbe8f2;
|
||||||
|
|
||||||
--radius: 10px;
|
--radius: 10px;
|
||||||
@@ -387,6 +387,8 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
.chip[aria-selected="true"], .chip[aria-checked="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
|
.chip[aria-selected="true"], .chip[aria-checked="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
|
||||||
.theme-picker { padding: 0 0 8px; }
|
.theme-picker { padding: 0 0 8px; }
|
||||||
.chip .count { margin-left: 4px; opacity: 0.7; }
|
.chip .count { margin-left: 4px; opacity: 0.7; }
|
||||||
|
/* Nothing in it: step back so the chips that have something stand out. */
|
||||||
|
.chip .count.zero { opacity: 0.4; }
|
||||||
/* An overlay, not a flex item: absolute against .chips' own (non-scrolling)
|
/* An overlay, not a flex item: absolute against .chips' own (non-scrolling)
|
||||||
box stays flush with its real right edge regardless of scroll position,
|
box stays flush with its real right edge regardless of scroll position,
|
||||||
which turned out not to be true of position:sticky here — as a flex
|
which turned out not to be true of position:sticky here — as a flex
|
||||||
@@ -422,7 +424,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
background: var(--sev, var(--border-strong));
|
background: var(--sev, var(--border-strong));
|
||||||
}
|
}
|
||||||
.row-title {
|
.row-title {
|
||||||
font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
font-weight: 650; overflow: hidden; overflow-wrap: anywhere;
|
||||||
|
/* Two lines, not one: titles differ at the end ("PodRestarting
|
||||||
|
(namespace=...") and an ellipsis cut exactly the part that tells rows apart. */
|
||||||
|
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2;
|
||||||
}
|
}
|
||||||
.row-age { color: var(--faint); font-size: 13px; text-align: right; white-space: nowrap; }
|
.row-age { color: var(--faint); font-size: 13px; text-align: right; white-space: nowrap; }
|
||||||
.row-meta {
|
.row-meta {
|
||||||
@@ -481,6 +486,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
}
|
}
|
||||||
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||||||
.badge.plain::before { display: none; }
|
.badge.plain::before { display: none; }
|
||||||
|
.badge .badge-icon { width: 12px; height: 12px; stroke-width: 2.4; }
|
||||||
.badge.st-triggered, .badge.st-firing { background: var(--crit-soft); color: var(--crit); }
|
.badge.st-triggered, .badge.st-firing { background: var(--crit-soft); color: var(--crit); }
|
||||||
.badge.st-acknowledged { background: var(--warn-soft); color: var(--warn); }
|
.badge.st-acknowledged { background: var(--warn-soft); color: var(--warn); }
|
||||||
.badge.st-snoozed { background: var(--snooze-soft); color: var(--snooze); }
|
.badge.st-snoozed { background: var(--snooze-soft); color: var(--snooze); }
|
||||||
@@ -516,10 +522,6 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; }
|
.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; }
|
||||||
.detail-title { font-size: var(--fs-xl); font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; }
|
.detail-title { font-size: var(--fs-xl); font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; }
|
||||||
.detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
|
.detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
|
||||||
/* A copy of the sticky actionbar's primary button, right under the status
|
|
||||||
it responds to — see quickActions() in incident.js. */
|
|
||||||
.detail-quick-actions { margin-bottom: 14px; }
|
|
||||||
.detail-quick-actions .btn-primary { font-size: 16px; min-height: 44px; }
|
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -588,18 +590,26 @@ details[open] > summary { margin-bottom: 8px; }
|
|||||||
.tl-item::before {
|
.tl-item::before {
|
||||||
content: ""; position: absolute; left: 23px; top: 0; bottom: 0; width: 2px; background: var(--border);
|
content: ""; position: absolute; left: 23px; top: 0; bottom: 0; width: 2px; background: var(--border);
|
||||||
}
|
}
|
||||||
.tl-item:first-child::before { top: 16px; }
|
.tl-item:first-child::before { top: 18px; }
|
||||||
.tl-item:last-child::before { bottom: calc(100% - 16px); }
|
.tl-item:last-child::before { bottom: calc(100% - 18px); }
|
||||||
.tl-dot {
|
.tl-dot {
|
||||||
position: relative; z-index: 1;
|
position: relative; z-index: 1;
|
||||||
width: 10px; height: 10px; margin: 5px 0 0 5px; border-radius: 50%;
|
display: grid; place-items: center;
|
||||||
background: var(--surface); border: 2px solid var(--faint);
|
width: 20px; height: 20px; border-radius: 50%;
|
||||||
|
background: var(--surface); border: 2px solid var(--border-strong); color: var(--muted);
|
||||||
}
|
}
|
||||||
.tl-triggered .tl-dot, .tl-notify_failed .tl-dot, .tl-deadman_silent .tl-dot { border-color: var(--crit); background: var(--crit); }
|
/* An event with no icon keeps the old plain dot. */
|
||||||
.tl-acknowledged .tl-dot { border-color: var(--warn); background: var(--warn); }
|
.tl-dot:empty { width: 10px; height: 10px; margin: 5px 0 0 5px; }
|
||||||
.tl-resolved .tl-dot { border-color: var(--ok); background: var(--ok); }
|
.tl-icon { width: 11px; height: 11px; stroke-width: 2.6; }
|
||||||
.tl-snoozed .tl-dot { border-color: var(--snooze); }
|
.tl-triggered .tl-dot, .tl-notify_failed .tl-dot, .tl-deadman_silent .tl-dot { border-color: var(--crit); color: var(--crit); }
|
||||||
.tl-note .tl-dot { border-color: var(--accent); background: var(--accent); }
|
.tl-acknowledged .tl-dot { border-color: var(--warn); color: var(--warn); }
|
||||||
|
.tl-resolved .tl-dot { border-color: var(--ok); color: var(--ok); }
|
||||||
|
.tl-snoozed .tl-dot { border-color: var(--snooze); color: var(--snooze); }
|
||||||
|
.tl-note .tl-dot, .tl-resolution_note .tl-dot { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.tl-problem .tl-dot { border-color: var(--warn); background: var(--warn-soft); color: var(--warn); }
|
||||||
|
.tl-problem.tl-notify_failed .tl-dot, .tl-problem.tl-deadman_silent .tl-dot { border-color: var(--crit); background: var(--crit-soft); color: var(--crit); }
|
||||||
|
.tl-problem .tl-text { color: var(--warn); font-weight: 600; }
|
||||||
|
.tl-problem.tl-notify_failed .tl-text, .tl-problem.tl-deadman_silent .tl-text { color: var(--crit); }
|
||||||
.tl-body { min-width: 0; font-size: 14px; }
|
.tl-body { min-width: 0; font-size: 14px; }
|
||||||
.tl-text { overflow-wrap: anywhere; }
|
.tl-text { overflow-wrap: anywhere; }
|
||||||
.tl-text .who { font-weight: 650; }
|
.tl-text .who { font-weight: 650; }
|
||||||
@@ -906,7 +916,17 @@ kbd {
|
|||||||
Six columns do not fit a phone, so the table scrolls inside its card rather
|
Six columns do not fit a phone, so the table scrolls inside its card rather
|
||||||
than the page. A heartbeat under a switch with several is indented, the way
|
than the page. A heartbeat under a switch with several is indented, the way
|
||||||
the escalation ladder indents its levels. */
|
the escalation ladder indents its levels. */
|
||||||
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
/* A card with a header row (the Team and Admin settings cards) pads itself:
|
||||||
|
plain .card has no padding, so these used to print their text flush against
|
||||||
|
the border with the button jammed in the corner. The divider separates the
|
||||||
|
title row from the content below it. */
|
||||||
|
.card:has(> .card-head) { padding: 20px; }
|
||||||
|
@media (min-width: 900px) { .card:has(> .card-head) { padding: 24px; } }
|
||||||
|
.card-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
|
||||||
|
padding-bottom: 14px; margin-bottom: 14px; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.card-head h2 { margin: 0; font-size: 17px; font-weight: 650; }
|
||||||
.table-scroll { overflow-x: auto; margin-top: 12px; }
|
.table-scroll { overflow-x: auto; margin-top: 12px; }
|
||||||
.status-table th, .status-table td { white-space: nowrap; }
|
.status-table th, .status-table td { white-space: nowrap; }
|
||||||
.status-table td.wrap { white-space: normal; min-width: 12em; }
|
.status-table td.wrap { white-space: normal; min-width: 12em; }
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
// incident it belongs to, which is where anything can be done about it.
|
// incident it belongs to, which is where anything can be done about it.
|
||||||
|
|
||||||
import * as api from './api.js';
|
import * as api from './api.js';
|
||||||
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
import { h, clear, badge, severityBadge, emptyState, spinner } from './ui.js';
|
||||||
import { age, severityClass, labelSummary } from './format.js';
|
import { age, labelSummary } from './format.js';
|
||||||
|
|
||||||
const FILTERS = [
|
const FILTERS = [
|
||||||
{ id: 'firing', label: 'Firing', query: { status: 'firing' } },
|
{ id: 'firing', label: 'Firing', query: { status: 'firing' } },
|
||||||
@@ -87,7 +87,7 @@ function row(a) {
|
|||||||
h('div', { class: 'row-age', title: a.starts_at, text: age(a.status === 'firing' ? a.starts_at : a.received_at) }),
|
h('div', { class: 'row-age', title: a.starts_at, text: age(a.status === 'firing' ? a.starts_at : a.received_at) }),
|
||||||
h('div', { class: 'row-meta' },
|
h('div', { class: 'row-meta' },
|
||||||
badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`),
|
badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`),
|
||||||
sev && badge(sev, `plain ${severityClass(sev)}`),
|
sev && severityBadge(sev),
|
||||||
summary && h('span', { text: summary }),
|
summary && h('span', { text: summary }),
|
||||||
labels && h('span', { class: 'labels', text: labels }),
|
labels && h('span', { class: 'labels', text: labels }),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
import * as api from './api.js';
|
import * as api from './api.js';
|
||||||
import * as poll from './poll.js';
|
import * as poll from './poll.js';
|
||||||
import {
|
import {
|
||||||
h, clear, icon, badge, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
|
h, clear, icon, badge, severityBadge, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
|
||||||
} from './ui.js';
|
} from './ui.js';
|
||||||
import {
|
import {
|
||||||
ago, when, until, duration, isFuture, severityClass, STATUS_LABEL,
|
ago, when, until, duration, isFuture, STATUS_LABEL,
|
||||||
} from './format.js';
|
} from './format.js';
|
||||||
import { myID, users } from './state.js';
|
import { myID, users } from './state.js';
|
||||||
import { back } from './app.js';
|
import { back } from './app.js';
|
||||||
@@ -86,7 +86,6 @@ function render() {
|
|||||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||||
h('h1', { class: 'detail-title', text: inc.title }),
|
h('h1', { class: 'detail-title', text: inc.title }),
|
||||||
h('div', { class: 'detail-badges' }, statusBadges()),
|
h('div', { class: 'detail-badges' }, statusBadges()),
|
||||||
quickActions(),
|
|
||||||
facts(),
|
facts(),
|
||||||
groupLabels(),
|
groupLabels(),
|
||||||
alertsSection(),
|
alertsSection(),
|
||||||
@@ -100,7 +99,7 @@ function render() {
|
|||||||
|
|
||||||
function statusBadges() {
|
function statusBadges() {
|
||||||
const out = [];
|
const out = [];
|
||||||
if (inc.severity) out.push(badge(inc.severity, `plain ${severityClass(inc.severity)}`));
|
if (inc.severity) out.push(severityBadge(inc.severity));
|
||||||
out.push(badge(STATUS_LABEL[inc.status] || inc.status, `st-${inc.status}`));
|
out.push(badge(STATUS_LABEL[inc.status] || inc.status, `st-${inc.status}`));
|
||||||
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
|
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
|
||||||
out.push(badge(`Snoozed · ${until(inc.snoozed_until)} left`, 'st-snoozed'));
|
out.push(badge(`Snoozed · ${until(inc.snoozed_until)} left`, 'st-snoozed'));
|
||||||
@@ -145,7 +144,7 @@ function facts() {
|
|||||||
: ackActorLabel() || 'Unassigned';
|
: ackActorLabel() || 'Unassigned';
|
||||||
rows.push(h('dt', { text: 'At a glance' }), h('dd', { class: 'fact-summary' },
|
rows.push(h('dt', { text: 'At a glance' }), h('dd', { class: 'fact-summary' },
|
||||||
h('span', { class: 'fact-chip' }, icon('clock', 'icon fact-icon'), duration(elapsedTo - Date.parse(inc.triggered_at))),
|
h('span', { class: 'fact-chip' }, icon('clock', 'icon fact-icon'), duration(elapsedTo - Date.parse(inc.triggered_at))),
|
||||||
inc.severity && badge(inc.severity, `plain ${severityClass(inc.severity)}`),
|
inc.severity && severityBadge(inc.severity),
|
||||||
h('span', { class: 'fact-chip' }, icon('user', 'icon fact-icon'), responsible),
|
h('span', { class: 'fact-chip' }, icon('user', 'icon fact-icon'), responsible),
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -339,10 +338,30 @@ function timelineSection() {
|
|||||||
|
|
||||||
const isNote = (ev) => ev.type === 'note' || ev.type === 'resolution_note';
|
const isNote = (ev) => ev.type === 'note' || ev.type === 'resolution_note';
|
||||||
|
|
||||||
|
// One icon per kind of event, so the rail says what happened before the text
|
||||||
|
// does. Anything not listed keeps a plain dot.
|
||||||
|
const EVENT_ICON = {
|
||||||
|
triggered: 'bell', notified: 'bell', escalated: 'arrowUp',
|
||||||
|
acknowledged: 'check', unacknowledged: 'undo', assigned: 'user',
|
||||||
|
snoozed: 'clock', unsnoozed: 'clock', resolved: 'checkCircle',
|
||||||
|
alert_added: 'plus', alert_resolved: 'check',
|
||||||
|
archived: 'archive', unarchived: 'undo',
|
||||||
|
note: 'note', resolution_note: 'note',
|
||||||
|
notify_failed: 'alertTriangle', deadman_silent: 'alertTriangle',
|
||||||
|
};
|
||||||
|
|
||||||
|
// An escalation that ran out of levels, or of anywhere to send the page, is
|
||||||
|
// the one event on the rail that means "nobody is being told": it gets the
|
||||||
|
// warning treatment instead of reading like any other step.
|
||||||
|
const isProblem = (ev) => ev.type === 'notify_failed' || ev.type === 'deadman_silent'
|
||||||
|
|| (ev.type === 'escalated' && /exhausted/i.test(ev.detail || ''));
|
||||||
|
|
||||||
function timelineItem(ev) {
|
function timelineItem(ev) {
|
||||||
const mine = isNote(ev) && ev.user_id === myID();
|
const mine = isNote(ev) && ev.user_id === myID();
|
||||||
return h('li', { class: `tl-item tl-${ev.type}` },
|
const problem = isProblem(ev);
|
||||||
h('span', { class: 'tl-dot' }),
|
const iconName = problem ? 'alertTriangle' : EVENT_ICON[ev.type];
|
||||||
|
return h('li', { class: `tl-item tl-${ev.type}${problem ? ' tl-problem' : ''}` },
|
||||||
|
h('span', { class: 'tl-dot' }, iconName && icon(iconName, 'icon tl-icon')),
|
||||||
h('div', { class: 'tl-body' },
|
h('div', { class: 'tl-body' },
|
||||||
h('div', { class: 'tl-text' }, eventText(ev)),
|
h('div', { class: 'tl-text' }, eventText(ev)),
|
||||||
h('div', { class: 'tl-time', title: ev.created_at, text: `${when(ev.created_at)} · ${ago(ev.created_at)}` }),
|
h('div', { class: 'tl-time', title: ev.created_at, text: `${when(ev.created_at)} · ${ago(ev.created_at)}` }),
|
||||||
@@ -452,9 +471,8 @@ async function copyIncident() {
|
|||||||
const isOpen = () => inc.status !== 'resolved';
|
const isOpen = () => inc.status !== 'resolved';
|
||||||
const isSnoozed = () => isOpen() && isFuture(inc.snoozed_until);
|
const isSnoozed = () => isOpen() && isFuture(inc.snoozed_until);
|
||||||
|
|
||||||
// primaryAction and secondaryAction are factories, not shared nodes — a
|
// primaryAction and secondaryAction are factories, not shared nodes: a button
|
||||||
// button can only live in one place, and quickActions() below needs its own
|
// can only live in one place, and the actionbar is rebuilt on every render.
|
||||||
// copy of the primary one rather than the actionbar's.
|
|
||||||
function primaryAction() {
|
function primaryAction() {
|
||||||
if (inc.status === 'triggered') {
|
if (inc.status === 'triggered') {
|
||||||
return h('button', { class: 'btn btn-primary', type: 'button', onclick: acknowledge }, icon('check'), 'Acknowledge');
|
return h('button', { class: 'btn btn-primary', type: 'button', onclick: acknowledge }, icon('check'), 'Acknowledge');
|
||||||
@@ -473,17 +491,7 @@ function secondaryAction() {
|
|||||||
? h('button', { class: 'btn', type: 'button', onclick: unsnooze }, icon('bell'), 'Unsnooze')
|
? h('button', { class: 'btn', type: 'button', onclick: unsnooze }, icon('bell'), 'Unsnooze')
|
||||||
: h('button', { class: 'btn', type: 'button', onclick: snooze }, icon('clock'), 'Snooze');
|
: h('button', { class: 'btn', type: 'button', onclick: snooze }, icon('clock'), 'Snooze');
|
||||||
}
|
}
|
||||||
return h('button', { class: 'btn', type: 'button', onclick: addNote }, icon('note'), 'Note');
|
return h('button', { class: 'btn', type: 'button', onclick: copyIncident }, icon('copy'), 'Copy');
|
||||||
}
|
|
||||||
|
|
||||||
// A copy of the primary action (Acknowledge/Resolve/…) up where it's seen
|
|
||||||
// right away, next to the status it responds to. The sticky actionbar below
|
|
||||||
// keeps carrying every action, primary included, for whenever the page has
|
|
||||||
// been scrolled past it.
|
|
||||||
function quickActions() {
|
|
||||||
const div = h('div', { class: 'detail-quick-actions' }, primaryAction());
|
|
||||||
if (busy) for (const b of div.querySelectorAll('button')) b.disabled = true;
|
|
||||||
return div;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Desktop has room to show what a phone folds into the More sheet below — see
|
// Desktop has room to show what a phone folds into the More sheet below — see
|
||||||
@@ -494,7 +502,6 @@ function extraActions() {
|
|||||||
if (!isOpen()) return [];
|
if (!isOpen()) return [];
|
||||||
const out = [
|
const out = [
|
||||||
h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: assign }, icon('user'), 'Assign…'),
|
h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: assign }, icon('user'), 'Assign…'),
|
||||||
h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: addNote }, icon('note'), 'Add note…'),
|
|
||||||
];
|
];
|
||||||
out.push(inc.status === 'acknowledged'
|
out.push(inc.status === 'acknowledged'
|
||||||
? h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: unacknowledge }, icon('undo'), 'Clear ack')
|
? h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: unacknowledge }, icon('undo'), 'Clear ack')
|
||||||
@@ -690,9 +697,9 @@ async function moreMenu() {
|
|||||||
items.push(h('li', { class: 'menu-sep', role: 'separator' }));
|
items.push(h('li', { class: 'menu-sep', role: 'separator' }));
|
||||||
items.push(item('checkCircle', 'Resolve…', resolve, 'danger'));
|
items.push(item('checkCircle', 'Resolve…', resolve, 'danger'));
|
||||||
} else {
|
} else {
|
||||||
|
// The bar already carries the primary action (Archive/Unarchive) and
|
||||||
|
// Copy, so the sheet adds only what it lacks.
|
||||||
items.push(item('note', 'Add note…', addNote));
|
items.push(item('note', 'Add note…', addNote));
|
||||||
items.push(item('copy', 'Copy incident', copyIncident));
|
|
||||||
items.push(inc.archived_at ? item('undo', 'Unarchive', unarchive) : item('archive', 'Archive', archive));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn = await openSheet(() => [
|
const fn = await openSheet(() => [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// The incident queue: filter chips and a list of incident rows.
|
// The incident queue: filter chips and a list of incident rows.
|
||||||
|
|
||||||
import * as api from './api.js';
|
import * as api from './api.js';
|
||||||
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
import { h, clear, badge, severityBadge, emptyState, spinner } from './ui.js';
|
||||||
import { ago, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js';
|
import { ago, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js';
|
||||||
import { state, myID, setSelectedTeam, onTeamChange } from './state.js';
|
import { state, myID, setSelectedTeam, onTeamChange } from './state.js';
|
||||||
import * as onboarding from './onboarding.js';
|
import * as onboarding from './onboarding.js';
|
||||||
@@ -129,7 +129,7 @@ function renderChips() {
|
|||||||
'aria-selected': String(f.id === filter),
|
'aria-selected': String(f.id === filter),
|
||||||
onclick: () => setFilter(f.id),
|
onclick: () => setFilter(f.id),
|
||||||
text: f.label,
|
text: f.label,
|
||||||
}, count != null && h('span', { class: 'count', text: String(count) }));
|
}, count != null && h('span', { class: count === 0 ? 'count zero' : 'count', text: String(count) }));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Somebody in one team has nothing to choose between, so the row of team
|
// Somebody in one team has nothing to choose between, so the row of team
|
||||||
@@ -194,6 +194,10 @@ function row(inc, index) {
|
|||||||
const snoozed = isFuture(inc.snoozed_until);
|
const snoozed = isFuture(inc.snoozed_until);
|
||||||
const resolved = inc.status === 'resolved';
|
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;
|
let status;
|
||||||
if (resolved) status = badge('Resolved', 'st-resolved');
|
if (resolved) status = badge('Resolved', 'st-resolved');
|
||||||
else if (snoozed) status = badge(`Snoozed · ${until(inc.snoozed_until)}`, 'st-snoozed');
|
else if (snoozed) status = badge(`Snoozed · ${until(inc.snoozed_until)}`, 'st-snoozed');
|
||||||
@@ -214,7 +218,9 @@ function row(inc, index) {
|
|||||||
// The team is shown only to somebody who is in more than one. For everybody
|
// 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
|
// else it is the same word on every row, which is noise rather than
|
||||||
// information.
|
// information.
|
||||||
const team = state.teams.length > 1 && inc.team_name
|
// ...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 })
|
? h('span', { class: 'row-team', text: inc.team_name })
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -227,10 +233,10 @@ function row(inc, index) {
|
|||||||
h('div', { class: 'row-title', text: inc.title }),
|
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-age', title: inc.triggered_at, text: `Triggered ${ago(inc.triggered_at)}` }),
|
||||||
h('div', { class: 'row-meta' },
|
h('div', { class: 'row-meta' },
|
||||||
status,
|
!impliedStatus && status,
|
||||||
// The left-border colour alone doesn't say what it means; spell it out
|
// The left-border colour alone doesn't say what it means; spell it out
|
||||||
// too, same badge the incident detail page uses for severity.
|
// too, same badge the incident detail page uses for severity.
|
||||||
inc.severity && badge(inc.severity, `plain ${severityClass(inc.severity)}`),
|
inc.severity && severityBadge(inc.severity),
|
||||||
assignee,
|
assignee,
|
||||||
team,
|
team,
|
||||||
labels && h('span', { class: 'labels', text: labels }),
|
labels && h('span', { class: 'labels', text: labels }),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// DOM helpers, the bottom sheet, confirmation and toasts.
|
// DOM helpers, the bottom sheet, confirmation and toasts.
|
||||||
|
|
||||||
|
import { severityClass } from './format.js';
|
||||||
|
|
||||||
// h builds an element. attrs: class, text, on<event>, dataset, aria/other
|
// h builds an element. attrs: class, text, on<event>, dataset, aria/other
|
||||||
// attributes; boolean true sets an empty attribute, false/null skips it.
|
// attributes; boolean true sets an empty attribute, false/null skips it.
|
||||||
export function h(tag, attrs = {}, ...children) {
|
export function h(tag, attrs = {}, ...children) {
|
||||||
@@ -32,6 +34,11 @@ export function clear(el, ...children) {
|
|||||||
|
|
||||||
// Stroke icons, 24×24. Built as SVG nodes so the CSP needs no inline anything.
|
// Stroke icons, 24×24. Built as SVG nodes so the CSP needs no inline anything.
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
|
alertTriangle: ['M12 4l9.5 16.5h-19z', 'M12 10v4.5', 'M12 17.5h.01'],
|
||||||
|
alertCircle: ['circle:12,12,9', 'M12 7.5v5', 'M12 16h.01'],
|
||||||
|
infoCircle: ['circle:12,12,9', 'M12 11v5.5', 'M12 7.5h.01'],
|
||||||
|
arrowUp: ['M12 19V5', 'M6 11l6-6 6 6'],
|
||||||
|
plus: ['M12 5v14M5 12h14'],
|
||||||
back: ['M15 18l-6-6 6-6'],
|
back: ['M15 18l-6-6 6-6'],
|
||||||
more: ['M5 12h.01M12 12h.01M19 12h.01'],
|
more: ['M5 12h.01M12 12h.01M19 12h.01'],
|
||||||
queueList: ['M4 6h16M4 12h16M4 18h10'],
|
queueList: ['M4 6h16M4 12h16M4 18h10'],
|
||||||
@@ -171,6 +178,16 @@ export function badge(text, cls = '') {
|
|||||||
return h('span', { class: `badge ${cls}`, text });
|
return h('span', { class: `badge ${cls}`, text });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The severity as a badge with a shape as well as a colour: a triangle for
|
||||||
|
// critical, a circle with ! for warning, a circle with i for anything else, so
|
||||||
|
// the coloured bar and badge are not the only thing that says it.
|
||||||
|
const SEVERITY_ICON = { 'sev-critical': 'alertTriangle', 'sev-warning': 'alertCircle' };
|
||||||
|
export function severityBadge(severity) {
|
||||||
|
const cls = severityClass(severity);
|
||||||
|
return h('span', { class: `badge plain ${cls}` },
|
||||||
|
icon(SEVERITY_ICON[cls] || 'infoCircle', 'icon badge-icon'), severity);
|
||||||
|
}
|
||||||
|
|
||||||
// Access granted by the identity provider's groups. The server refuses to edit
|
// Access granted by the identity provider's groups. The server refuses to edit
|
||||||
// it by hand (it would be undone at the next sign-in), so the controls that
|
// it by hand (it would be undone at the next sign-in), so the controls that
|
||||||
// would try are disabled with this as their reason.
|
// would try are disabled with this as their reason.
|
||||||
|
|||||||
Reference in New Issue
Block a user