Compare commits

..

3 Commits

Author SHA1 Message Date
Niklas Ye 3a96b20cbe Set the chart's placeholder version to 0.41.1
CI / chart (push) Successful in 1s
CI / security (push) Successful in 27s
CI / test (push) Successful in 5m55s
Release / test (push) Successful in 7s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 41s
Release / image (push) Successful in 1m19s
Release / scan-image (push) Successful in 1s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these two fields decide nothing about what gets published. Still
done, as with 065b557 (0.41.0) and ead5df1 (0.40.0) before it, because a
tree heading for v0.41.1 that still says 0.41.0 tells its reader
something false.
2026-10-08 14:19:12 +02:00
Niklas Ye 7b9d309d13 Draw the rota as shift bars, and the escalation chain as a ladder
The rota put an initial on every day, so one person covering a week was
seven identical chips and a legend to decode them. A shift is now one bar
across its days with the person's name on it; a flat end with a chevron
means it carries on across the row break. Days with nobody on call are a
hatched amber bar while they can still be fixed and a quiet grey one once
they are history, and the footer is a green banner when the month is
covered or an amber one when it is not. Tapping a day still opens it: the
bars ignore the pointer, so the tap reaches the cell underneath. The month
title was monospace because its button borrowed .label, which belongs to
label chips; it has its own class now, and a Today button sits beside the
arrows.

The escalation card was a five-column table. It is now the ladder it
describes: a numbered step per level with its status and who it pages, the
wait before the next level on its own line, and what happens after the last
one at the bottom. With no fallback topic that last step is an amber
callout, with an Add fallback button for owners that opens the editor on the
field. That gap is what incident #36 hit.

Web UI only: no endpoint or JSON shape changed, so nothing to mirror in
terdut-tui.
2026-10-08 14:17:49 +02:00
Niklas Ye 7412456c5a 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.
2026-10-08 14:07:24 +02:00
7 changed files with 342 additions and 153 deletions
+2 -2
View File
@@ -15,5 +15,5 @@ type: application
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing.
version: 0.41.0
appVersion: "v0.41.0"
version: 0.41.1
appVersion: "v0.41.1"
+136 -41
View File
@@ -20,23 +20,23 @@
--accent-text: #ffffff;
--accent-soft: #e8eefc;
--crit: #d0342c;
--crit: #c22d26;
--crit-soft: #fdecea;
--warn: #b86e00;
--warn: #8f5500;
--warn-soft: #fdf3e1;
--info: #2f6fdf;
--info: #245fc7;
--info-soft: #e9f0fd;
--ok: #1d7f4c;
--ok: #1a7445;
--ok-soft: #e6f5ec;
--snooze: #6b5bd2;
--snooze: #6050c8;
--snooze-soft: #efedfb;
/* 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
already an alarm. */
--teal: #0f7d8c;
--teal: #0c7180;
--teal-soft: #e3f4f6;
--pink: #b3427e;
--pink: #a63b73;
--pink-soft: #fbe8f2;
--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); }
.theme-picker { padding: 0 0 8px; }
.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)
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
@@ -422,7 +424,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
background: var(--sev, var(--border-strong));
}
.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-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.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-acknowledged { background: var(--warn-soft); color: var(--warn); }
.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-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; }
/* 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 {
background: var(--surface);
@@ -588,18 +590,26 @@ details[open] > summary { margin-bottom: 8px; }
.tl-item::before {
content: ""; position: absolute; left: 23px; top: 0; bottom: 0; width: 2px; background: var(--border);
}
.tl-item:first-child::before { top: 16px; }
.tl-item:last-child::before { bottom: calc(100% - 16px); }
.tl-item:first-child::before { top: 18px; }
.tl-item:last-child::before { bottom: calc(100% - 18px); }
.tl-dot {
position: relative; z-index: 1;
width: 10px; height: 10px; margin: 5px 0 0 5px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--faint);
display: grid; place-items: center;
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); }
.tl-acknowledged .tl-dot { border-color: var(--warn); background: var(--warn); }
.tl-resolved .tl-dot { border-color: var(--ok); background: var(--ok); }
.tl-snoozed .tl-dot { border-color: var(--snooze); }
.tl-note .tl-dot { border-color: var(--accent); background: var(--accent); }
/* An event with no icon keeps the old plain dot. */
.tl-dot:empty { width: 10px; height: 10px; margin: 5px 0 0 5px; }
.tl-icon { width: 11px; height: 11px; stroke-width: 2.6; }
.tl-triggered .tl-dot, .tl-notify_failed .tl-dot, .tl-deadman_silent .tl-dot { border-color: var(--crit); color: var(--crit); }
.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-text { overflow-wrap: anywhere; }
.tl-text .who { font-weight: 650; }
@@ -708,7 +718,7 @@ details[open] > summary { margin-bottom: 8px; }
.you-badge { margin-left: 6px; }
.week-nav { display: flex; align-items: center; gap: 4px; }
.week-nav .label { font-size: 14px; font-weight: 650; min-width: 9em; text-align: center; }
.week-nav .month-label { font-size: 15px; font-weight: 650; min-width: 8em; text-align: center; }
/* The week-nav button that shows the date range, "28 Sep – 4 Oct", with the
ISO week number as secondary text inside it — a separate class from
.label above (team.js's month-nav uses that one) so its <small> isn't
@@ -906,7 +916,17 @@ kbd {
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
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; }
.status-table th, .status-table td { white-space: nowrap; }
.status-table td.wrap { white-space: normal; min-width: 12em; }
@@ -979,23 +999,32 @@ kbd {
too alike down a column to read, so a day carries an initial in that
person's colour and the legend underneath says whose. A shift is then a run
of one colour, which is the shape the question actually has. */
.rota-grid { display: grid; grid-template-columns: 2.4em repeat(7, 1fr); gap: 2px; padding: 10px; }
.rota-grid { padding: 10px 12px 4px; }
.rota-heads, .rota-row { display: grid; grid-template-columns: 2.4em repeat(7, minmax(0, 1fr)); column-gap: 2px; }
.rota-wd {
padding-bottom: 4px; text-align: center;
padding-bottom: 6px; text-align: center;
color: var(--muted); font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.04em;
}
.rota-row { grid-template-rows: 26px 34px; padding: 4px 0 6px; }
.rota-row + .rota-row { border-top: 1px solid var(--border); }
/* A day is a cell that spans both of its row's lines: the number on the first,
the shift bar drawn over the second. The bar ignores the pointer, so a tap
anywhere in the cell reaches the button. */
.rota-day {
display: flex; flex-direction: column; align-items: center; gap: 4px;
min-height: 52px; padding: 6px 0 8px;
grid-row: 1 / span 2;
display: flex; justify-content: center; align-items: flex-start;
min-width: 0; padding-top: 2px;
border: 0; border-radius: var(--radius-sm); background: none;
font: inherit; color: inherit;
}
button.rota-day { cursor: pointer; }
button.rota-day:hover { background: var(--surface-2); }
button.rota-day:focus-visible, button.rota-week:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* The week number starts each row. Quiet by default, because it is a label
first; an owner's tap on it is the second thing it does. */
.rota-week {
grid-column: 1; grid-row: 1 / span 2;
display: grid; place-items: center;
border: 0; border-radius: var(--radius-sm); background: none;
font: inherit; font-size: 12px; font-variant-numeric: tabular-nums;
@@ -1003,18 +1032,43 @@ button.rota-day:hover { background: var(--surface-2); }
}
button.rota-week { cursor: pointer; }
button.rota-week:hover { background: var(--surface-2); color: var(--text); }
.rota-week.current { color: var(--accent); font-weight: 700; }
.rota-week.current { color: var(--text); font-weight: 700; }
/* The sheet's row of who holds each day of the week. */
.week-holders { display: flex; justify-content: space-between; gap: 4px; margin: 4px 0 12px; }
.week-holder { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; }
.week-holder.past { opacity: 0.55; }
.rota-num { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.rota-day.today { background: var(--accent-soft); }
.rota-day.today .rota-num { color: var(--accent); font-weight: 700; }
.rota-day.past { opacity: 0.55; }
.rota-num {
min-width: 22px; height: 22px; padding: 0 3px; border-radius: 11px;
text-align: center; line-height: 22px;
color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums;
}
/* Today is a filled number, a marker and not the accent, which means "you can
act on this". */
.rota-day.today .rota-num { background: var(--text); color: var(--bg); font-weight: 700; }
/* The days either side of the month are real days and are drawn, but they
belong to the month you are not looking at. */
.rota-day.outside { opacity: 0.35; }
.rota-day.outside .rota-num { opacity: 0.4; }
/* A shift: a run of consecutive days held by one person, in that person's
colour (.rc1-.rc6 below). Rounded ends are where it really starts or stops;
a flat end with a chevron carries on across the row break. */
.rota-bar {
grid-row: 2; align-self: center; z-index: 1; pointer-events: none;
display: flex; align-items: center; min-width: 0; height: 28px; padding: 0 8px;
border-radius: 6px;
font-size: 13px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.rota-bar.past { opacity: 0.55; }
.rota-bar.outside { opacity: 0.4; }
.rota-bar.cont-l { margin-left: -2px; padding-left: 10px; border-top-left-radius: 0; border-bottom-left-radius: 0; }
.rota-bar.cont-r { margin-right: -2px; border-top-right-radius: 0; border-bottom-right-radius: 0; }
.rota-bar.cont-l::before { content: "‹"; margin-right: 4px; opacity: 0.7; }
/* Nobody on call. Loud while it can still be fixed, quiet once it is history. */
.rota-bar.gap {
background: repeating-linear-gradient(135deg, transparent 0 5px, var(--warn-soft) 5px 10px);
border: 1px dashed var(--warn); color: var(--warn);
}
.rota-bar.gap.past { background: none; border-color: var(--border-strong); color: var(--faint); }
.rota-chip {
display: grid; place-items: center;
@@ -1034,14 +1088,18 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
.rc5 { background: var(--teal-soft); color: var(--teal); }
.rc6 { background: var(--pink-soft); color: var(--pink); }
.rota-foot { padding: 12px 14px; border-top: 1px solid var(--border); }
.rota-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; font-size: 14px; }
.rota-key { display: inline-flex; align-items: center; gap: 6px; }
.rota-key .rota-chip { width: 22px; height: 22px; font-size: 11px; }
.rota-note { margin: 10px 0 0; color: var(--muted); font-size: 13px; }
.rota-note:first-child { margin-top: 0; }
.rota-bulk { padding: 12px 14px; border-top: 1px solid var(--border); }
.rota-bulk .stacked-form { margin-top: 4px; }
.rota-banner {
display: flex; align-items: center; gap: 10px;
padding: 12px 14px; border-top: 1px solid var(--border); font-size: 14px;
}
.rota-banner .icon { width: 20px; height: 20px; }
.rota-banner.ok { color: var(--ok); }
.rota-banner.warn { color: var(--warn); background: var(--warn-soft); }
.rota-foot {
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
padding: 12px 14px; border-top: 1px solid var(--border);
}
.rota-bulk-form { padding: 0 14px 14px; }
.sheet-pick { display: flex; align-items: center; gap: 8px; font-size: 14px; }
.ladder-level {
@@ -1054,6 +1112,43 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
.ladder-editor { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; margin-top: 12px; }
/* A target that would not wake anybody says why, in place: it is the reason a
level is red, and the thing to go and fix. */
/* The escalation ladder: a numbered step per level, the wait between levels as
its own line, and what happens after the last one at the bottom. */
.ladder { list-style: none; margin: 16px 0 0; padding: 0; }
.step { display: grid; grid-template-columns: 32px minmax(0, 1fr); column-gap: 14px; }
.step-rail { display: flex; flex-direction: column; align-items: center; }
.step-node {
display: grid; place-items: center; flex: none;
width: 32px; height: 32px; border-radius: 50%;
border: 2px solid var(--border-strong); background: var(--surface);
font-size: 14px; font-weight: 750;
}
.step-node.st-ready { border-color: var(--ok); color: var(--ok); }
.step-node.st-escalating { border-color: var(--warn); color: var(--warn); background: var(--warn-soft); }
.step-node.st-unreachable { border-color: var(--crit); color: var(--crit); background: var(--crit-soft); }
.step-node.end { border-style: dashed; color: var(--muted); }
.step-node.end.warn { border-color: var(--warn); color: var(--warn); background: var(--warn-soft); }
.step-node .icon { width: 16px; height: 16px; }
.step-line { flex: 1; width: 2px; min-height: 8px; background: var(--border-strong); }
.step-body { min-width: 0; padding-bottom: 4px; }
.step-top { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; min-height: 32px; }
.step-pages { display: flex; flex-direction: column; gap: 2px; margin-top: 2px; font-size: 14px; }
.step-waiting { margin-top: 6px; font-size: 13px; color: var(--muted); }
.step-waiting a { font-weight: 650; }
.step-gap { display: flex; align-items: center; gap: 8px; padding: 6px 0 10px; color: var(--muted); font-size: 13px; font-weight: 600; }
.step-gap .icon { width: 14px; height: 14px; }
.callout {
display: flex; align-items: flex-start; gap: 12px; margin-top: 4px;
padding: 12px 14px; border: 1px solid var(--warn); border-radius: var(--radius);
background: var(--warn-soft);
}
.callout > .icon { flex: none; width: 20px; height: 20px; margin-top: 1px; color: var(--warn); }
.callout strong { color: var(--warn); }
.callout p { margin: 2px 0 10px; font-size: 14px; }
.ladder-facts { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
.ladder-facts p { margin: 0; }
.ladder-facts p + p { margin-top: 4px; }
.target-line { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
.target-problem { color: var(--crit); font-size: 12px; font-weight: 600; }
+3 -3
View File
@@ -2,8 +2,8 @@
// incident it belongs to, which is where anything can be done about it.
import * as api from './api.js';
import { h, clear, badge, emptyState, spinner } from './ui.js';
import { age, severityClass, labelSummary } from './format.js';
import { h, clear, badge, severityBadge, emptyState, spinner } from './ui.js';
import { age, labelSummary } from './format.js';
const FILTERS = [
{ 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-meta' },
badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`),
sev && badge(sev, `plain ${severityClass(sev)}`),
sev && severityBadge(sev),
summary && h('span', { text: summary }),
labels && h('span', { class: 'labels', text: labels }),
),
+31 -24
View File
@@ -4,10 +4,10 @@
import * as api from './api.js';
import * as poll from './poll.js';
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';
import {
ago, when, until, duration, isFuture, severityClass, STATUS_LABEL,
ago, when, until, duration, isFuture, STATUS_LABEL,
} from './format.js';
import { myID, users } from './state.js';
import { back } from './app.js';
@@ -86,7 +86,6 @@ function render() {
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
h('h1', { class: 'detail-title', text: inc.title }),
h('div', { class: 'detail-badges' }, statusBadges()),
quickActions(),
facts(),
groupLabels(),
alertsSection(),
@@ -100,7 +99,7 @@ function render() {
function statusBadges() {
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}`));
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
out.push(badge(`Snoozed · ${until(inc.snoozed_until)} left`, 'st-snoozed'));
@@ -145,7 +144,7 @@ function facts() {
: ackActorLabel() || 'Unassigned';
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))),
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),
));
@@ -339,10 +338,30 @@ function timelineSection() {
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) {
const mine = isNote(ev) && ev.user_id === myID();
return h('li', { class: `tl-item tl-${ev.type}` },
h('span', { class: 'tl-dot' }),
const problem = isProblem(ev);
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-text' }, eventText(ev)),
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 isSnoozed = () => isOpen() && isFuture(inc.snoozed_until);
// primaryAction and secondaryAction are factories, not shared nodes — a
// button can only live in one place, and quickActions() below needs its own
// copy of the primary one rather than the actionbar's.
// primaryAction and secondaryAction are factories, not shared nodes: a button
// can only live in one place, and the actionbar is rebuilt on every render.
function primaryAction() {
if (inc.status === 'triggered') {
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: snooze }, icon('clock'), 'Snooze');
}
return h('button', { class: 'btn', type: 'button', onclick: addNote }, icon('note'), 'Note');
}
// 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;
return h('button', { class: 'btn', type: 'button', onclick: copyIncident }, icon('copy'), 'Copy');
}
// Desktop has room to show what a phone folds into the More sheet below — see
@@ -494,7 +502,6 @@ function extraActions() {
if (!isOpen()) return [];
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: addNote }, icon('note'), 'Add note…'),
];
out.push(inc.status === 'acknowledged'
? 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(item('checkCircle', 'Resolve…', resolve, 'danger'));
} 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('copy', 'Copy incident', copyIncident));
items.push(inc.archived_at ? item('undo', 'Unarchive', unarchive) : item('archive', 'Archive', archive));
}
const fn = await openSheet(() => [
+11 -5
View File
@@ -1,7 +1,7 @@
// 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 { h, clear, badge, severityBadge, emptyState, spinner } from './ui.js';
import { ago, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js';
import { state, myID, setSelectedTeam, onTeamChange } from './state.js';
import * as onboarding from './onboarding.js';
@@ -129,7 +129,7 @@ function renderChips() {
'aria-selected': String(f.id === filter),
onclick: () => setFilter(f.id),
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
@@ -194,6 +194,10 @@ 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');
@@ -214,7 +218,9 @@ function row(inc, index) {
// 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
// ...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;
@@ -227,10 +233,10 @@ function row(inc, 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' },
status,
!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 && badge(inc.severity, `plain ${severityClass(inc.severity)}`),
inc.severity && severityBadge(inc.severity),
assignee,
team,
labels && h('span', { class: 'labels', text: labels }),
+142 -78
View File
@@ -234,8 +234,8 @@ function overview() {
// A month of it, as a grid. It used to be thirty rows of "date — username",
// which is a rota spelled out one day at a time: the question asked of it is
// "who has which stretch", and thirty names down a column is the one shape
// that answer cannot be read in. So each day carries a coloured initial
// instead, the legend says whose, and a shift becomes a run of one colour.
// that answer cannot be read in. So a shift is drawn as one bar across its
// days with the person's name on it.
//
// The same month laid out the same way as the on-call page's week, because it
// is the same rota — heading and arrows outside the card, days inside it.
@@ -274,22 +274,8 @@ function scheduleCard() {
const today = isoDate(new Date());
const month = monthStart.getMonth();
// Whose colours to explain, in the order the month meets them. Only the days
// of this month count: a name that appears solely in the overhang belongs to
// the month next door and would be explaining a chip nobody asked about.
const seen = new Map();
const cells = [];
for (let i = 0; i < count; i++) {
const d = addDays(start, i);
const key = isoDate(d);
const e = byDate.get(key);
const inMonth = d.getMonth() === month;
// Every row starts with its week number, which is also the way to fill the
// whole week at once.
if (i % 7 === 0) cells.push(weekCell(d, byDate, today));
if (inMonth && e && !seen.has(e.user_id)) seen.set(e.user_id, e.username);
cells.push(dayCell(d, key, e, inMonth, today));
}
const rows = [];
for (let w = 0; w < count / 7; w++) rows.push(weekRow(addDays(start, w * 7), byDate, today, month));
const heads = [h('span', { class: 'rota-wd', title: 'ISO week number', text: 'Wk' })];
for (let i = 0; i < 7; i++) {
@@ -297,38 +283,104 @@ function scheduleCard() {
heads.push(h('span', { class: 'rota-wd', text: weekdayFmt.format(new Date(2024, 0, 1 + i)) }));
}
// The range form is the way to fill a whole shift at once, but it is not
// what the page is for, so it stays folded away until asked for.
let bulk = null;
if (isOwner()) {
const form = h('div', { class: 'rota-bulk-form', hidden: true }, assignForm());
const toggle = h('button', {
class: 'btn', type: 'button', 'aria-expanded': 'false',
onclick: () => {
form.hidden = !form.hidden;
toggle.setAttribute('aria-expanded', String(!form.hidden));
},
}, icon('calendar'), 'Assign a range of days');
bulk = [
h('div', { class: 'rota-foot' },
h('span', { class: 'muted small', text: 'Tap a day to change who holds it.' }), toggle),
form,
];
}
const thisMonth = isoDate(monthStart) === isoDate(firstOfMonth(new Date()));
return [
h('div', { class: 'page-head' },
h('h2', { text: 'On-call rota' }),
h('div', { class: 'week-nav' },
h('button', {
class: 'btn btn-ghost btn-icon', type: 'button',
class: 'btn btn-icon week-arrow', type: 'button',
'aria-label': 'Previous month', onclick: () => shiftMonth(-1),
}, icon('chevronLeft')),
h('span', { class: 'month-label', text: monthFmt.format(monthStart) }),
h('button', {
class: 'btn btn-ghost label', type: 'button',
title: 'Back to this month',
onclick: () => { monthStart = firstOfMonth(new Date()); refresh(); },
text: monthFmt.format(monthStart),
}),
h('button', {
class: 'btn btn-ghost btn-icon', type: 'button',
class: 'btn btn-icon week-arrow', type: 'button',
'aria-label': 'Next month', onclick: () => shiftMonth(1),
}, icon('chevronRight')),
h('button', {
class: 'btn btn-sm', type: 'button', text: 'Today', disabled: thisMonth,
onclick: () => { monthStart = firstOfMonth(new Date()); refresh(); },
}),
),
),
h('div', { class: 'card' },
h('div', { class: 'rota-grid' }, heads, cells),
h('div', { class: 'rota-foot' }, legend(seen), coverNote(byDate)),
// The range form is the way to fill a whole shift at once, but it is not
// what the page is for, so it stays folded away under the month it edits.
isOwner() && h('details', { class: 'rota-bulk' },
h('summary', { text: 'Assign a range of days' }),
assignForm()),
h('div', { class: 'rota-grid' }, h('div', { class: 'rota-heads' }, heads), rows),
coverNote(byDate),
bulk,
),
];
}
// One Monday-to-Sunday row: the week number, a tappable cell per day, and the
// shifts drawn over the days as bars. A bar is a run of consecutive days held
// by the same person, so a week handed to one person is one bar with one name
// on it. The bars ignore the pointer, so a tap lands on the day underneath.
function weekRow(monday, byDate, today, month) {
const days = Array.from({ length: 7 }, (_, i) => addDays(monday, i));
const keys = days.map(isoDate);
const entries = keys.map((k) => byDate.get(k) || null);
const uid = (i) => (entries[i] ? entries[i].user_id : null);
const before = byDate.get(isoDate(addDays(monday, -1)));
const after = byDate.get(isoDate(addDays(monday, 7)));
const cells = [weekCell(monday, byDate, today)];
days.forEach((d, i) => {
const cell = dayCell(d, keys[i], entries[i], d.getMonth() === month, today);
cell.style.gridColumn = String(i + 2);
cells.push(cell);
});
for (let i = 0; i < 7;) {
let j = i;
while (j + 1 < 7 && uid(j + 1) === uid(i)) j++;
const entry = entries[i];
// A flat end says the shift carries on past this row, not that it stops.
const contL = !!entry && i === 0 && before?.user_id === entry.user_id;
const contR = !!entry && j === 6 && after?.user_id === entry.user_id;
cells.push(shiftBar(entry, i, j, { days, keys, month, today, contL, contR }));
i = j + 1;
}
return h('div', { class: 'rota-row' }, cells);
}
function shiftBar(entry, i, j, o) {
const span = j - i + 1;
const outside = o.days.slice(i, j + 1).every((d) => d.getMonth() !== o.month);
const cls = ['rota-bar', entry ? colorClass(entry.user_id) : 'gap',
outside && 'outside', o.keys[j] < o.today && 'past', o.contL && 'cont-l', o.contR && 'cont-r']
.filter(Boolean).join(' ');
// One day is too narrow for a name on a phone; the initial stands in and the
// full name is in the day's own label underneath.
const name = entry
? `${entry.username}${entry.user_id === myID() ? ' (you)' : ''}`
: (span >= 3 ? 'Nobody on call' : '');
const bar = h('div', {
class: cls, 'aria-hidden': 'true', title: name || 'Nobody on call',
text: entry && span === 1 ? initial(entry.username) : name,
});
bar.style.gridColumn = `${i + 2} / span ${span}`;
return bar;
}
// The ISO week number at the start of a row. For an owner it is a button: one
// tap fills the week, which is the way a rota is usually handed out — a person
// takes a week, not seven separate days.
@@ -347,15 +399,10 @@ function weekCell(monday, byDate, today) {
}
function dayCell(d, key, e, inMonth, today) {
const cls = ['rota-day', !inMonth && 'outside', key === today && 'today', key < today && 'past']
const cls = ['rota-day', !inMonth && 'outside', key === today && 'today']
.filter(Boolean).join(' ');
const label = `${key} · ${e ? e.username : 'nobody'}`;
const body = [
h('span', { class: 'rota-num', text: String(d.getDate()) }),
e
? h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) })
: h('span', { class: 'rota-chip none' }),
];
const body = [h('span', { class: 'rota-num', text: String(d.getDate()) })];
// A member sees the same grid without the affordance, the way every other
// control on this page is hidden rather than shown and refused.
return isOwner()
@@ -374,17 +421,7 @@ function colorClass(userID) {
return `rc${((i < 0 ? userID : i) % 6) + 1}`;
}
function legend(seen) {
if (!seen.size) return null;
return h('div', { class: 'rota-legend' },
[...seen].map(([id, name]) => h('span', { class: 'rota-key' },
h('span', { class: `rota-chip ${colorClass(id)}`, text: initial(name) }),
h('span', { text: name }),
id === myID() && h('span', { class: 'you', text: 'you' }),
)));
}
// The gap count, which is the one thing the grid states only by omission. Days
// The gap count, which is the one thing the bars state only by omission. Days
// already past are not counted: an empty Tuesday last week is history, not a
// hole somebody still has to fill.
function coverNote(byDate) {
@@ -395,10 +432,14 @@ function coverNote(byDate) {
const key = isoDate(new Date(monthStart.getFullYear(), monthStart.getMonth(), day));
if (key >= today && !byDate.has(key)) gaps++;
}
if (gaps === 0) return h('p', { class: 'rota-note', text: 'Every day left this month has somebody on call.' });
return h('p', { class: 'rota-note' },
h('strong', { text: gaps === 1 ? '1 day' : `${gaps} days` }),
' left this month with nobody on call.');
if (gaps === 0) {
return h('div', { class: 'rota-banner ok' }, icon('checkCircle'),
h('span', { text: 'Every day left this month has somebody on call.' }));
}
return h('div', { class: 'rota-banner warn', role: 'status' }, icon('alertTriangle'),
h('span', {},
h('strong', { text: gaps === 1 ? '1 day' : `${gaps} days` }),
' left this month with nobody on call.'));
}
// One week, in the sheet: who holds each day of it, and one person to put on
@@ -567,23 +608,49 @@ function escalationCard() {
const esc = data.escalation || {};
const levels = esc.levels || [];
const rows = levels.map((l) => h('tr', {},
h('td', {}, h('strong', { text: `Level ${l.position}` })),
h('td', {}, levelBadge(l.status)),
h('td', { class: 'wrap' }, ...l.targets.map(targetLine)),
h('td', { class: 'muted small', text: duration(l.timeout_seconds * 1000) }),
h('td', { class: 'small' }, l.waiting?.length
? l.waiting.flatMap((id, i) => [i > 0 && ', ', h('a', { href: `/incidents/${id}`, text: `#${id}` })])
: h('span', { class: 'muted', text: '—' })),
));
// The ladder as steps, top to bottom: each level, the wait before the next,
// and what happens when the last one is not answered. The wait belongs to the
// level above it ("then after 5m"), which is how the API states it.
const steps = [];
levels.forEach((l) => {
steps.push(h('li', { class: 'step' },
h('div', { class: 'step-rail' },
h('span', { class: `step-node st-${l.status}`, text: String(l.position) }),
h('span', { class: 'step-line' })),
h('div', { class: 'step-body' },
h('div', { class: 'step-top' }, h('strong', { text: `Level ${l.position}` }), levelBadge(l.status)),
h('div', { class: 'step-pages' }, ...l.targets.map(targetLine)),
l.waiting?.length > 0 && h('div', { class: 'step-waiting' }, 'Waiting now: ',
...l.waiting.flatMap((id, i) => [i > 0 && ', ', h('a', { href: `/incidents/${id}`, text: `#${id}` })])))));
steps.push(h('li', { class: 'step step-wait' },
h('div', { class: 'step-rail' }, h('span', { class: 'step-line' })),
h('div', { class: 'step-gap' }, icon('clock', 'icon'), `then after ${duration(l.timeout_seconds * 1000)}`)));
});
if (levels.length) {
steps.push(h('li', { class: 'step' },
h('div', { class: 'step-rail' }, h('span', { class: `step-node end${esc.fallback_topic ? '' : ' warn'}` },
icon(esc.fallback_topic ? 'bell' : 'flag', 'icon'))),
h('div', { class: 'step-body' },
h('div', { class: 'step-top' },
h('strong', { text: esc.fallback_topic ? 'Fallback topic' : 'End of the chain' })),
esc.fallback_topic
? h('div', { class: 'step-pages' }, h('code', { text: esc.fallback_topic }), ' is paged once.')
: h('div', { class: 'callout', role: 'status' }, icon('alertTriangle', 'icon'),
h('div', {},
h('strong', { text: 'No fallback topic' }),
h('p', { text: 'After the last level the chain just ends and nobody else is woken.' }),
isOwner() && h('button', {
class: 'btn btn-sm', type: 'button', onclick: () => openLadderEditor({ focusFallback: true }),
}, icon('plus'), 'Add fallback'))))));
}
const facts = [];
if (levels.length) {
const n = esc.repeat_count || 0;
if (n) facts.push(`Then the whole ladder repeats ${n} more ${n === 1 ? 'time' : 'times'}.`);
facts.push(esc.fallback_topic
? ['Finally the ntfy topic ', h('code', { text: esc.fallback_topic }), ' is paged once.']
: 'No fallback topic: after the last level the chain just ends.');
facts.push(n
? `Then the whole ladder repeats ${n} more ${n === 1 ? 'time' : 'times'}.`
: 'Nothing repeats: the ladder runs once.');
facts.push(esc.last_escalated_at
? ['Last escalated ',
h('span', { title: when(esc.last_escalated_at), text: ago(esc.last_escalated_at) }),
@@ -595,23 +662,18 @@ function escalationCard() {
h('div', { class: 'card-head' },
h('h2', { text: 'Escalation' }),
isOwner() && h('button', {
class: 'btn', type: 'button', onclick: openLadderEditor,
class: 'btn btn-primary', type: 'button', onclick: () => openLadderEditor(),
text: levels.length ? 'Edit ladder' : 'Set up ladder',
})),
h('p', { class: 'muted small' },
'When a level’s wait passes and nobody has acknowledged, the next level is ',
'paged. Acknowledging or resolving stops it; snoozing pauses it.'),
levels.length
? h('div', { class: 'table-scroll' },
h('table', { class: 'admin-table status-table' },
h('thead', {}, h('tr', {},
h('th', { text: 'Level' }), h('th', { text: 'Status' }), h('th', { text: 'Pages' }),
h('th', { text: 'Then after' }), h('th', { text: 'Waiting now' }))),
h('tbody', {}, rows)))
? h('ol', { class: 'ladder' }, steps)
: h('p', { class: 'muted' },
'No ladder. An unacknowledged incident re-pages the same person every ',
'reminder interval and nobody else is woken.'),
...facts.map((f) => h('p', { class: 'muted small' }, f)),
facts.length > 0 && h('div', { class: 'ladder-facts' }, ...facts.map((f) => h('p', { class: 'muted small' }, f))),
);
}
@@ -619,7 +681,7 @@ function escalationCard() {
// it wholesale: the levels are an order, and patching one rung would leave the
// numbering of the others undecided. The draft lives in the sheet, so a poll of
// the page underneath cannot throw away half an edit.
function openLadderEditor() {
function openLadderEditor({ focusFallback = false } = {}) {
const esc = data.escalation || {};
const draft = {
repeat_count: esc.repeat_count || 0,
@@ -631,6 +693,7 @@ function openLadderEditor() {
};
const body = h('div', { class: 'ladder-editor' });
let fallbackInput = null;
const problem = h('p', { class: 'load-error', hidden: true });
const paint = () => {
@@ -670,7 +733,7 @@ function openLadderEditor() {
value: String(draft.repeat_count),
oninput: (e) => { draft.repeat_count = Number(e.target.value); },
});
const fallback = h('input', {
const fallback = fallbackInput = h('input', {
type: 'text', value: draft.fallback_topic, placeholder: 'terdut-oncall-all',
oninput: (e) => { draft.fallback_topic = e.target.value; },
});
@@ -701,6 +764,7 @@ function openLadderEditor() {
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
save),
]);
if (focusFallback) requestAnimationFrame(() => fallbackInput?.focus());
}
function targetRow(level, target, index, repaint) {
+17
View File
@@ -1,5 +1,7 @@
// 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
// attributes; boolean true sets an empty attribute, false/null skips it.
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.
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'],
more: ['M5 12h.01M12 12h.01M19 12h.01'],
queueList: ['M4 6h16M4 12h16M4 18h10'],
@@ -171,6 +178,16 @@ export function badge(text, cls = '') {
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
// 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.