Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a96b20cbe | |||
| 7b9d309d13 | |||
| 7412456c5a | |||
| 065b557860 | |||
| a8dc89e23d |
@@ -15,5 +15,5 @@ type: application
|
|||||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
# 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
|
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||||
# metadata and drives nothing.
|
# metadata and drives nothing.
|
||||||
version: 0.40.0
|
version: 0.41.1
|
||||||
appVersion: "v0.40.0"
|
appVersion: "v0.41.1"
|
||||||
|
|||||||
+249
-76
@@ -10,33 +10,33 @@
|
|||||||
--surface: #ffffff;
|
--surface: #ffffff;
|
||||||
--surface-2: #eff1f4;
|
--surface-2: #eff1f4;
|
||||||
--surface-hover: #f7f8fa;
|
--surface-hover: #f7f8fa;
|
||||||
--border: #e2e5ea;
|
--border: #d9dde4;
|
||||||
--border-strong: #cfd3da;
|
--border-strong: #c5cad3;
|
||||||
--text: #16181d;
|
--text: #16181d;
|
||||||
--muted: #5b626e;
|
--muted: #5b626e;
|
||||||
--faint: #8a909b;
|
--faint: #676d79;
|
||||||
|
|
||||||
--accent: #2f5bd3;
|
--accent: #2f5bd3;
|
||||||
--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;
|
||||||
@@ -72,17 +72,21 @@
|
|||||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark palette. Applied by the OS preference unless the user chose Light in
|
||||||
|
Account (data-theme="light" on <html>, set by js/theme.js), and always when
|
||||||
|
they chose Dark. The two blocks must stay identical: CSS has no way to share
|
||||||
|
a declaration list between a media query and an attribute selector. */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root:not([data-theme="light"]) {
|
||||||
--bg: #0f1115;
|
--bg: #0f1115;
|
||||||
--surface: #171a20;
|
--surface: #1a1e26;
|
||||||
--surface-2: #1f232b;
|
--surface-2: #232834;
|
||||||
--surface-hover: #1c2027;
|
--surface-hover: #20252e;
|
||||||
--border: #2a2f38;
|
--border: #343b49;
|
||||||
--border-strong: #394050;
|
--border-strong: #444d5f;
|
||||||
--text: #e7e9ed;
|
--text: #e7e9ed;
|
||||||
--muted: #a0a7b3;
|
--muted: #a0a7b3;
|
||||||
--faint: #737a87;
|
--faint: #8a92a0;
|
||||||
|
|
||||||
--accent: #6d8ff0;
|
--accent: #6d8ff0;
|
||||||
--accent-text: #0b0d12;
|
--accent-text: #0b0d12;
|
||||||
@@ -108,6 +112,42 @@
|
|||||||
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
|
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg: #0f1115;
|
||||||
|
--surface: #1a1e26;
|
||||||
|
--surface-2: #232834;
|
||||||
|
--surface-hover: #20252e;
|
||||||
|
--border: #343b49;
|
||||||
|
--border-strong: #444d5f;
|
||||||
|
--text: #e7e9ed;
|
||||||
|
--muted: #a0a7b3;
|
||||||
|
--faint: #8a92a0;
|
||||||
|
|
||||||
|
--accent: #6d8ff0;
|
||||||
|
--accent-text: #0b0d12;
|
||||||
|
--accent-soft: #1d2640;
|
||||||
|
|
||||||
|
--crit: #ff6b61;
|
||||||
|
--crit-soft: #3a1c1b;
|
||||||
|
--warn: #f0b140;
|
||||||
|
--warn-soft: #362a14;
|
||||||
|
--info: #74a3ff;
|
||||||
|
--info-soft: #1a2640;
|
||||||
|
--ok: #4cc488;
|
||||||
|
--ok-soft: #15301f;
|
||||||
|
--snooze: #a89bff;
|
||||||
|
--snooze-soft: #262245;
|
||||||
|
|
||||||
|
--teal: #4fc2d4;
|
||||||
|
--teal-soft: #0f2e33;
|
||||||
|
--pink: #f07fb8;
|
||||||
|
--pink-soft: #3a1c2d;
|
||||||
|
|
||||||
|
--shadow: 0 1px 2px rgb(0 0 0 / 40%);
|
||||||
|
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
|
||||||
|
}
|
||||||
|
:root[data-theme="light"] { color-scheme: light; }
|
||||||
|
:root[data-theme="dark"] { color-scheme: dark; }
|
||||||
|
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
[hidden] { display: none !important; }
|
[hidden] { display: none !important; }
|
||||||
@@ -237,6 +277,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
.open-pill.has-triggered { background: var(--crit-soft); color: var(--crit); }
|
.open-pill.has-triggered { background: var(--crit-soft); color: var(--crit); }
|
||||||
.open-pill.has-triggered::before { background: var(--crit); }
|
.open-pill.has-triggered::before { background: var(--crit); }
|
||||||
.open-pill.all-acked::before { background: var(--warn); }
|
.open-pill.all-acked::before { background: var(--warn); }
|
||||||
|
/* "All clear" is a status, not an action: green with a check, no dot. */
|
||||||
|
.open-pill.all-clear { background: var(--ok-soft); color: var(--ok); }
|
||||||
|
.open-pill.all-clear::before { content: none; }
|
||||||
|
.open-pill .icon { width: 14px; height: 14px; }
|
||||||
|
|
||||||
/* Bottom tab bar on phones (Queue, On-call, Alerts, Team, More); becomes the
|
/* Bottom tab bar on phones (Queue, On-call, Alerts, Team, More); becomes the
|
||||||
left sidebar from 900px, where the desktop block below redeclares display
|
left sidebar from 900px, where the desktop block below redeclares display
|
||||||
@@ -290,13 +334,13 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
|
|
||||||
.nav-team-selector,
|
.nav-team-selector,
|
||||||
.team-selector-mobile {
|
.team-selector-mobile {
|
||||||
display: inline-flex; align-items: center; gap: 8px;
|
align-items: center; gap: 8px;
|
||||||
border: 1px solid var(--border-strong); border-radius: 999px;
|
border: 1px solid var(--border-strong); border-radius: 999px;
|
||||||
background: var(--surface); color: var(--text);
|
background: var(--surface); color: var(--text);
|
||||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
padding: 4px 12px; max-width: 100%;
|
padding: 4px 12px; max-width: 100%;
|
||||||
}
|
}
|
||||||
.team-selector-mobile { padding: 4px 10px; font-size: 12px; max-width: 120px; }
|
.team-selector-mobile { display: inline-flex; padding: 4px 10px; font-size: 12px; max-width: 120px; }
|
||||||
.team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.team-selector-chevron { width: 14px; height: 14px; flex: none; color: var(--faint); margin-left: -2px; }
|
.team-selector-chevron { width: 14px; height: 14px; flex: none; color: var(--faint); margin-left: -2px; }
|
||||||
|
|
||||||
@@ -340,8 +384,11 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
background: var(--surface); color: var(--muted);
|
background: var(--surface); color: var(--muted);
|
||||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
}
|
}
|
||||||
.chip[aria-selected="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; }
|
||||||
.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
|
||||||
@@ -377,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 {
|
||||||
@@ -436,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); }
|
||||||
@@ -471,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);
|
||||||
@@ -543,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; }
|
||||||
@@ -650,7 +705,6 @@ details[open] > summary { margin-bottom: 8px; }
|
|||||||
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 16px auto 12px; }
|
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 16px auto 12px; }
|
||||||
.page-head h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
.page-head h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
||||||
|
|
||||||
.now-card { display: flex; align-items: center; gap: 14px; padding: 16px; margin-top: 16px; }
|
|
||||||
.avatar {
|
.avatar {
|
||||||
flex: none; display: grid; place-items: center;
|
flex: none; display: grid; place-items: center;
|
||||||
width: 44px; height: 44px; border-radius: 50%;
|
width: 44px; height: 44px; border-radius: 50%;
|
||||||
@@ -658,40 +712,62 @@ details[open] > summary { margin-bottom: 8px; }
|
|||||||
font-weight: 750; font-size: 17px; text-transform: uppercase;
|
font-weight: 750; font-size: 17px; text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.avatar.none { background: var(--surface-2); color: var(--faint); }
|
.avatar.none { background: var(--surface-2); color: var(--faint); }
|
||||||
.now-label { color: var(--muted); font-size: 13px; font-weight: 600; }
|
|
||||||
.now-name { font-size: 20px; font-weight: 750; }
|
|
||||||
.you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; }
|
.you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; }
|
||||||
/* Oncall's own "you" indicator only (see you() in oncall.js) — a pill badge
|
/* Oncall's own "you" indicator only (see you() in oncall.js) — a pill badge
|
||||||
is easier to spot there than this plain accent-coloured text. */
|
is easier to spot there than this plain accent-coloured text. */
|
||||||
.you-badge { margin-left: 6px; }
|
.you-badge { margin-left: 6px; }
|
||||||
|
|
||||||
.week-nav { display: flex; align-items: center; gap: 4px; }
|
.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
|
/* 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
|
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
|
.label above (team.js's month-nav uses that one) so its <small> isn't
|
||||||
caught by the unrelated .label > span styling meant for label chips. */
|
caught by the unrelated .label > span styling meant for label chips. */
|
||||||
.week-nav .week-label { font-size: 14px; font-weight: 650; min-width: 11.5em; text-align: center; white-space: nowrap; }
|
.week-nav .week-label { font-size: 14px; font-weight: 650; min-width: 11.5em; text-align: center; white-space: nowrap; }
|
||||||
.week-label small { color: var(--faint); font-weight: 600; font-size: 11px; margin-left: 2px; }
|
.week-label small { color: var(--faint); font-weight: 600; font-size: 11px; margin-left: 2px; }
|
||||||
.days { list-style: none; margin: 0 auto; padding: 0; }
|
.oncall-title h1 { font-size: 24px; font-weight: 600; letter-spacing: -0.01em; margin: 8px 0 0; }
|
||||||
.day { display: grid; grid-template-columns: 3.2em 4.2em 1fr; align-items: center; gap: 8px; min-height: 50px; padding: 0 14px; }
|
.oncall-title p { margin: 2px 0 0; font-size: 14px; }
|
||||||
.day + .day { border-top: 1px solid var(--border); }
|
.oncall-grid { display: grid; gap: 0; }
|
||||||
.day-name { font-weight: 650; }
|
.oncall-main, .oncall-side { min-width: 0; }
|
||||||
.day-date { color: var(--faint); font-size: 13px; }
|
.hero-list { display: grid; gap: 12px; }
|
||||||
.day-who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.day-who.nobody { color: var(--faint); font-style: italic; }
|
.hero { display: grid; gap: 14px; padding: 18px; margin-top: 16px; }
|
||||||
/* A run of several days held by the same person (or left empty), replacing
|
.hero-top { display: flex; align-items: center; gap: 16px; }
|
||||||
what used to be one identical row per day — see weekRuns() in oncall.js. */
|
.hero-label { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); }
|
||||||
.day.range { grid-template-columns: 1fr auto; }
|
.hero-avatar { width: 64px; height: 64px; font-size: 24px; }
|
||||||
.day-range { font-weight: 650; }
|
.hero-name { font-size: 24px; font-weight: 650; letter-spacing: -0.01em; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
|
||||||
.day.today { background: var(--accent-soft); }
|
.hero-until { color: var(--muted); font-size: 15px; margin-top: 2px; }
|
||||||
.day.today:first-child { border-radius: var(--radius) var(--radius) 0 0; }
|
|
||||||
.day.today:last-child { border-radius: 0 0 var(--radius) var(--radius); }
|
.week-card { margin-top: 16px; padding: 16px 12px 12px; }
|
||||||
.day.today .day-name, .day.today .day-range { color: var(--accent); }
|
.week-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; padding: 0 4px; }
|
||||||
.day.past { opacity: 0.6; }
|
.week-head h2 { margin: 0; font-size: 15px; font-weight: 650; }
|
||||||
/* Highlights whichever row is yours, same soft tint as .today — they already
|
/* Bordered, so the arrows read as buttons. 40px: a touch target without
|
||||||
read fine layered (today's own row is almost always one of yours too). */
|
crowding the date between them. */
|
||||||
.day.mine { background: var(--accent-soft); }
|
.week-arrow { width: 40px; min-height: 40px; border: 1px solid var(--border-strong); }
|
||||||
|
.strip { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px; }
|
||||||
|
.strip-day {
|
||||||
|
position: relative; min-width: 0;
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||||
|
padding: 10px 0 8px; border: 1px solid transparent; border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.strip-name { font-size: 12px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
|
||||||
|
.strip-date { font-size: 15px; font-weight: 650; }
|
||||||
|
.strip-avatar { width: 32px; height: 32px; font-size: 13px; }
|
||||||
|
.strip-who { max-width: 100%; padding: 0 2px; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.strip-who.nobody { font-style: italic; color: var(--faint); }
|
||||||
|
.strip-day.past { opacity: 0.6; }
|
||||||
|
/* "Current", not "selected": a neutral tint, a bar and aria-current, so it
|
||||||
|
reads as a marker and not as a pressed button. The accent stays for things
|
||||||
|
you can act on. */
|
||||||
|
.strip-day.today { background: var(--surface-2); border-color: var(--border-strong); }
|
||||||
|
.strip-day.today::before {
|
||||||
|
content: ""; position: absolute; top: -1px; left: 12px; right: 12px; height: 3px;
|
||||||
|
border-radius: 0 0 3px 3px; background: var(--text);
|
||||||
|
}
|
||||||
|
.strip-day.today .strip-name { color: var(--text); }
|
||||||
|
.strip-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 10px; padding: 10px 4px 0; border-top: 1px solid var(--border); font-size: 13px; color: var(--muted); }
|
||||||
|
.strip-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.strip-legend .strip-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
|
||||||
|
|
||||||
.shift-list { list-style: none; margin: 0; padding: 0; }
|
.shift-list { list-style: none; margin: 0; padding: 0; }
|
||||||
.shift-list li { display: flex; justify-content: space-between; padding: 12px 14px; }
|
.shift-list li { display: flex; justify-content: space-between; padding: 12px 14px; }
|
||||||
@@ -840,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; }
|
||||||
@@ -913,23 +999,32 @@ kbd {
|
|||||||
too alike down a column to read, so a day carries an initial in that
|
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
|
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. */
|
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 {
|
.rota-wd {
|
||||||
padding-bottom: 4px; text-align: center;
|
padding-bottom: 6px; text-align: center;
|
||||||
color: var(--muted); font-size: 11px; font-weight: 700;
|
color: var(--muted); font-size: 11px; font-weight: 700;
|
||||||
text-transform: uppercase; letter-spacing: 0.04em;
|
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 {
|
.rota-day {
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
grid-row: 1 / span 2;
|
||||||
min-height: 52px; padding: 6px 0 8px;
|
display: flex; justify-content: center; align-items: flex-start;
|
||||||
|
min-width: 0; padding-top: 2px;
|
||||||
border: 0; border-radius: var(--radius-sm); background: none;
|
border: 0; border-radius: var(--radius-sm); background: none;
|
||||||
font: inherit; color: inherit;
|
font: inherit; color: inherit;
|
||||||
}
|
}
|
||||||
button.rota-day { cursor: pointer; }
|
button.rota-day { cursor: pointer; }
|
||||||
button.rota-day:hover { background: var(--surface-2); }
|
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
|
/* 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. */
|
first; an owner's tap on it is the second thing it does. */
|
||||||
.rota-week {
|
.rota-week {
|
||||||
|
grid-column: 1; grid-row: 1 / span 2;
|
||||||
display: grid; place-items: center;
|
display: grid; place-items: center;
|
||||||
border: 0; border-radius: var(--radius-sm); background: none;
|
border: 0; border-radius: var(--radius-sm); background: none;
|
||||||
font: inherit; font-size: 12px; font-variant-numeric: tabular-nums;
|
font: inherit; font-size: 12px; font-variant-numeric: tabular-nums;
|
||||||
@@ -937,18 +1032,43 @@ button.rota-day:hover { background: var(--surface-2); }
|
|||||||
}
|
}
|
||||||
button.rota-week { cursor: pointer; }
|
button.rota-week { cursor: pointer; }
|
||||||
button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
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. */
|
/* 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-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 { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; }
|
||||||
.week-holder.past { opacity: 0.55; }
|
.week-holder.past { opacity: 0.55; }
|
||||||
.rota-num { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
.rota-num {
|
||||||
.rota-day.today { background: var(--accent-soft); }
|
min-width: 22px; height: 22px; padding: 0 3px; border-radius: 11px;
|
||||||
.rota-day.today .rota-num { color: var(--accent); font-weight: 700; }
|
text-align: center; line-height: 22px;
|
||||||
.rota-day.past { opacity: 0.55; }
|
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
|
/* The days either side of the month are real days and are drawn, but they
|
||||||
belong to the month you are not looking at. */
|
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 {
|
.rota-chip {
|
||||||
display: grid; place-items: center;
|
display: grid; place-items: center;
|
||||||
@@ -968,14 +1088,18 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
|||||||
.rc5 { background: var(--teal-soft); color: var(--teal); }
|
.rc5 { background: var(--teal-soft); color: var(--teal); }
|
||||||
.rc6 { background: var(--pink-soft); color: var(--pink); }
|
.rc6 { background: var(--pink-soft); color: var(--pink); }
|
||||||
|
|
||||||
.rota-foot { padding: 12px 14px; border-top: 1px solid var(--border); }
|
.rota-banner {
|
||||||
.rota-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; font-size: 14px; }
|
display: flex; align-items: center; gap: 10px;
|
||||||
.rota-key { display: inline-flex; align-items: center; gap: 6px; }
|
padding: 12px 14px; border-top: 1px solid var(--border); font-size: 14px;
|
||||||
.rota-key .rota-chip { width: 22px; height: 22px; font-size: 11px; }
|
}
|
||||||
.rota-note { margin: 10px 0 0; color: var(--muted); font-size: 13px; }
|
.rota-banner .icon { width: 20px; height: 20px; }
|
||||||
.rota-note:first-child { margin-top: 0; }
|
.rota-banner.ok { color: var(--ok); }
|
||||||
.rota-bulk { padding: 12px 14px; border-top: 1px solid var(--border); }
|
.rota-banner.warn { color: var(--warn); background: var(--warn-soft); }
|
||||||
.rota-bulk .stacked-form { margin-top: 4px; }
|
.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; }
|
.sheet-pick { display: flex; align-items: center; gap: 8px; font-size: 14px; }
|
||||||
|
|
||||||
.ladder-level {
|
.ladder-level {
|
||||||
@@ -988,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; }
|
.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
|
/* 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. */
|
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-line { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
|
||||||
.target-problem { color: var(--crit); font-size: 12px; font-weight: 600; }
|
.target-problem { color: var(--crit); font-size: 12px; font-weight: 600; }
|
||||||
|
|
||||||
@@ -1038,6 +1199,11 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
|||||||
overflow-x: auto; scrollbar-width: none;
|
overflow-x: auto; scrollbar-width: none;
|
||||||
}
|
}
|
||||||
.subnav::-webkit-scrollbar { display: none; }
|
.subnav::-webkit-scrollbar { display: none; }
|
||||||
|
/* A fade on the right edge says "there is more" where the strip overflows;
|
||||||
|
the desktop width fits every entry, so it is phone-only. */
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.subnav { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); }
|
||||||
|
}
|
||||||
.subnav-link {
|
.subnav-link {
|
||||||
flex: none;
|
flex: none;
|
||||||
padding: 8px 12px; margin-bottom: -1px;
|
padding: 8px 12px; margin-bottom: -1px;
|
||||||
@@ -1097,3 +1263,10 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
|||||||
@media (min-width: 900px) {
|
@media (min-width: 900px) {
|
||||||
.stat-tiles { grid-template-columns: repeat(3, 1fr); }
|
.stat-tiles { grid-template-columns: repeat(3, 1fr); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* On-call, wide: the hero and the week on the left, your shifts on the right.
|
||||||
|
.view-page > * caps its children at 760px, so the grid lifts that. */
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
#view-oncall > .oncall-grid { max-width: 1080px; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: 0 24px; align-items: start; }
|
||||||
|
#view-oncall > .oncall-title { max-width: 1080px; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<link rel="stylesheet" href="/app.css">
|
<link rel="stylesheet" href="/app.css">
|
||||||
|
<script src="/js/theme.js"></script>
|
||||||
<script type="module" src="/js/app.js"></script>
|
<script type="module" src="/js/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ function render() {
|
|||||||
|
|
||||||
...passwordSection(user, hasPassword),
|
...passwordSection(user, hasPassword),
|
||||||
|
|
||||||
|
h('div', { class: 'page-head' }, h('h2', { text: 'Appearance' })),
|
||||||
|
themePicker(),
|
||||||
|
|
||||||
h('div', { class: 'only-desktop' },
|
h('div', { class: 'only-desktop' },
|
||||||
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
|
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
|
||||||
h('div', { class: 'card' }, shortcuts())),
|
h('div', { class: 'card' }, shortcuts())),
|
||||||
@@ -39,6 +42,23 @@ function render() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// System / Light / Dark. Per browser, not per account: it lives in
|
||||||
|
// localStorage (see js/theme.js), the same place the selected team does.
|
||||||
|
const THEMES = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']];
|
||||||
|
|
||||||
|
function themePicker() {
|
||||||
|
const theme = window.terdutTheme;
|
||||||
|
const buttons = THEMES.map(([value, text]) => h('button', {
|
||||||
|
class: 'chip', type: 'button', role: 'radio', text,
|
||||||
|
onclick: () => { theme.set(value); sync(); },
|
||||||
|
}));
|
||||||
|
const sync = () => buttons.forEach((b, i) => b.setAttribute('aria-checked', String(THEMES[i][0] === theme.get())));
|
||||||
|
sync();
|
||||||
|
return h('div', { class: 'card card-pad' },
|
||||||
|
h('div', { class: 'chips theme-picker', role: 'radiogroup', 'aria-label': 'Theme' }, ...buttons),
|
||||||
|
h('p', { class: 'muted small', text: 'System follows your device. This applies to this browser only.' }));
|
||||||
|
}
|
||||||
|
|
||||||
// Where this user's pages go. The onboarding checklist's first step sends
|
// Where this user's pages go. The onboarding checklist's first step sends
|
||||||
// people here for it, and until now there was nothing here to send them to:
|
// people here for it, and until now there was nothing here to send them to:
|
||||||
// the topic could only be set with curl or by an administrator.
|
// the topic could only be set with curl or by an administrator.
|
||||||
|
|||||||
@@ -102,13 +102,18 @@ function section() {
|
|||||||
// buttons, because these are four URLs: app.js intercepts the click, the
|
// buttons, because these are four URLs: app.js intercepts the click, the
|
||||||
// browser's Back walks them, and a reload lands where you were.
|
// browser's Back walks them, and a reload lands where you were.
|
||||||
function subnav() {
|
function subnav() {
|
||||||
return h('nav', { class: 'subnav', 'aria-label': 'Administration' },
|
const nav = h('nav', { class: 'subnav', 'aria-label': 'Administration' },
|
||||||
TABS.map((t) => h('a', {
|
TABS.map((t) => h('a', {
|
||||||
class: 'subnav-link',
|
class: 'subnav-link',
|
||||||
href: t.path,
|
href: t.path,
|
||||||
text: t.label,
|
text: t.label,
|
||||||
'aria-current': t.tab === tab ? 'page' : null,
|
'aria-current': t.tab === tab ? 'page' : null,
|
||||||
})));
|
})));
|
||||||
|
// On a phone the strip overflows; bring the open section into view so a
|
||||||
|
// tab past the edge (Sources, Switches) is never the one that is hidden.
|
||||||
|
requestAnimationFrame(() => nav.querySelector('[aria-current]')
|
||||||
|
?.scrollIntoView({ inline: 'center', block: 'nearest' }));
|
||||||
|
return nav;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- overview --------------------------------------------------------------
|
// --- overview --------------------------------------------------------------
|
||||||
|
|||||||
@@ -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 }),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -202,7 +202,8 @@ function updateBadges() {
|
|||||||
|
|
||||||
const pill = $('open-pill');
|
const pill = $('open-pill');
|
||||||
pill.hidden = false;
|
pill.hidden = false;
|
||||||
pill.textContent = open ? `${open} open` : 'All clear';
|
pill.replaceChildren(...(open ? [`${open} open`] : [ui.icon('checkCircle'), 'All clear']));
|
||||||
|
pill.classList.toggle('all-clear', open === 0);
|
||||||
pill.classList.toggle('has-triggered', triggered > 0);
|
pill.classList.toggle('has-triggered', triggered > 0);
|
||||||
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
|
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
|
||||||
|
|
||||||
|
|||||||
@@ -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,5 +1,6 @@
|
|||||||
// On-call: who is on duty now, the week around it, and your own next shifts.
|
// On-call: who is on duty now, the week around it, and your own next shifts.
|
||||||
// Read-only for now; the TUI edits the schedule.
|
// Read-only for now; the TUI edits the schedule, so there is no add or swap
|
||||||
|
// button here.
|
||||||
//
|
//
|
||||||
// One team's rota at a time — the viewer's first team, since a viewer in one
|
// One team's rota at a time — the viewer's first team, since a viewer in one
|
||||||
// team has nothing to choose between. "On call now" is the exception and shows
|
// team has nothing to choose between. "On call now" is the exception and shows
|
||||||
@@ -59,11 +60,17 @@ function render() {
|
|||||||
clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const team = currentTeam();
|
||||||
clear(view(),
|
clear(view(),
|
||||||
|
// The top bar carries the title on a phone; the desktop has none.
|
||||||
|
h('div', { class: 'only-desktop oncall-title' },
|
||||||
|
h('h1', { text: 'On-call' }),
|
||||||
|
team && h('p', { class: 'muted', text: `${team.name} · who is on call, the week ahead and your shifts` })),
|
||||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||||
nowCard(),
|
h('div', { class: 'oncall-grid' },
|
||||||
weekCard(),
|
h('div', { class: 'oncall-main' }, nowCard(), weekCard()),
|
||||||
myShifts(),
|
h('div', { class: 'oncall-side' }, myShifts()),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,75 +85,73 @@ function nowCard() {
|
|||||||
const entries = data.now || [];
|
const entries = data.now || [];
|
||||||
const showTeam = entries.length > 1;
|
const showTeam = entries.length > 1;
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return h('div', { class: 'card now-card' },
|
return h('div', { class: 'card hero' },
|
||||||
h('div', { class: 'avatar none', text: '–' }),
|
h('div', { class: 'hero-label', text: 'On call now' }),
|
||||||
h('div', {},
|
h('div', { class: 'hero-top' },
|
||||||
h('div', { class: 'now-label', text: 'On call now' }),
|
h('div', { class: 'avatar hero-avatar none', text: '–' }),
|
||||||
h('div', { class: 'now-name', text: 'Nobody' }),
|
h('div', { class: 'hero-name', text: 'Nobody' })));
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return h('div', {}, ...entries.map((n) =>
|
return h('div', { class: 'hero-list' }, ...entries.map((n) => {
|
||||||
h('div', { class: 'card now-card' },
|
const until = shiftEnd(n);
|
||||||
h('div', { class: 'avatar', text: initial(n.username) }),
|
return h('div', { class: 'card hero' },
|
||||||
h('div', {},
|
h('div', { class: 'hero-label', text: showTeam ? `On call now · ${n.team_name}` : 'On call now' }),
|
||||||
h('div', {
|
h('div', { class: 'hero-top' },
|
||||||
class: 'now-label',
|
h('div', { class: 'avatar hero-avatar', text: initial(n.username) }),
|
||||||
text: showTeam ? `On call now · ${n.team_name}` : 'On call now',
|
h('div', {},
|
||||||
}),
|
h('div', { class: 'hero-name' }, n.username, you(n.user_id)),
|
||||||
h('div', { class: 'now-name' }, n.username, you(n.user_id)),
|
until && h('div', { class: 'hero-until', text: until }))));
|
||||||
),
|
}));
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Groups the week's 7 days into runs held by the same person (or the same
|
// "until Mon 12 Oct · ends in 3d 20h", for the current team only: the shift's
|
||||||
// empty slot) — the week's own version of the consecutive-day grouping
|
// end is read off the rota (the run of consecutive days from today held by the
|
||||||
// myShifts does for a single person's own dates, below. Seven identical rows
|
// same person), and that is only loaded for one team. The same midnight
|
||||||
// for one person all week collapses to the one bar this way.
|
// boundary the "Current shift" row below uses.
|
||||||
function weekRuns(byDate) {
|
function shiftEnd(n) {
|
||||||
const runs = [];
|
const team = currentTeam();
|
||||||
for (let i = 0; i < 7; i++) {
|
if (!team || n.team_id !== team.id) return null;
|
||||||
const date = isoDate(addDays(weekStart, i));
|
const mine = new Map(data.upcoming.map((e) => [e.date, e.user_id]));
|
||||||
const e = byDate.get(date) || null;
|
let day = new Date();
|
||||||
const uid = e ? e.user_id : null;
|
if (mine.get(isoDate(day)) !== n.user_id) return null;
|
||||||
const last = runs[runs.length - 1];
|
while (mine.get(isoDate(addDays(day, 1))) === n.user_id) day = addDays(day, 1);
|
||||||
if (last && last.uid === uid) last.to = date;
|
// Still holding the last day loaded: the shift may run on past it.
|
||||||
else runs.push({ uid, entry: e, from: date, to: date });
|
if (isoDate(day) >= data.upcoming.reduce((m, e) => (e.date > m ? e.date : m), '')) return null;
|
||||||
}
|
const end = addDays(parse(isoDate(day)), 1);
|
||||||
return runs;
|
return `until ${dayName.format(end)} ${dayDate.format(end)} · ends in ${duration(end - Date.now())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A stable colour per person from the six-colour rcN palette. The week page
|
||||||
|
// has no member list to take an index from (team.js does), so the id decides.
|
||||||
|
const personClass = (userID) => `rc${(userID % 6) + 1}`;
|
||||||
|
|
||||||
|
// The week as seven cells, Monday to Sunday, each showing who holds that day.
|
||||||
|
// A hand-over in the middle of the week is visible without reading anything.
|
||||||
function weekCard() {
|
function weekCard() {
|
||||||
const byDate = new Map(data.week.map((e) => [e.date, e]));
|
const byDate = new Map(data.week.map((e) => [e.date, e]));
|
||||||
const today = isoDate(new Date());
|
const today = isoDate(new Date());
|
||||||
const mine = myID();
|
const mine = myID();
|
||||||
const days = weekRuns(byDate).map((r) => {
|
const seen = new Map();
|
||||||
const single = r.from === r.to;
|
const cells = [];
|
||||||
const cls = [
|
for (let i = 0; i < 7; i++) {
|
||||||
'day',
|
const d = addDays(weekStart, i);
|
||||||
single ? '' : 'range',
|
const date = isoDate(d);
|
||||||
r.from <= today && today <= r.to ? 'today' : '',
|
const e = byDate.get(date) || null;
|
||||||
r.to < today ? 'past' : '',
|
if (e) seen.set(e.user_id, e.username);
|
||||||
r.uid === mine ? 'mine' : '',
|
cells.push(h('li', {
|
||||||
].filter(Boolean).join(' ');
|
class: ['strip-day', date === today && 'today', date < today && 'past'].filter(Boolean).join(' '),
|
||||||
const label = single
|
'aria-current': date === today ? 'date' : null,
|
||||||
? [h('span', { class: 'day-name', text: dayName.format(parse(r.from)) }),
|
},
|
||||||
h('span', { class: 'day-date', text: dayDate.format(parse(r.from)) })]
|
h('span', { class: 'strip-name', text: dayName.format(d) }),
|
||||||
: [h('span', {
|
h('span', { class: 'strip-date', text: String(d.getDate()) }),
|
||||||
class: 'day-range',
|
h('span', { class: `avatar strip-avatar ${e ? personClass(e.user_id) : 'none'}`, text: e ? initial(e.username) : '–' }),
|
||||||
text: `${dayName.format(parse(r.from))} ${dayDate.format(parse(r.from))} – ${dayName.format(parse(r.to))} ${dayDate.format(parse(r.to))}`,
|
h('span', { class: `strip-who${e ? '' : ' nobody'}`, text: e ? (e.user_id === mine ? 'You' : e.username) : 'nobody' })));
|
||||||
})];
|
}
|
||||||
return h('li', { class: cls },
|
|
||||||
...label,
|
|
||||||
h('span', { class: `day-who ${r.entry ? '' : 'nobody'}` }, r.entry ? r.entry.username : 'nobody', r.entry && you(r.entry.user_id)),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
|
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
|
||||||
return [
|
return h('div', { class: 'card week-card' },
|
||||||
h('div', { class: 'page-head' },
|
h('div', { class: 'week-head' },
|
||||||
h('h2', { text: thisWeek ? 'This week' : 'Week' }),
|
h('h2', { text: thisWeek ? 'This week' : 'Week' }),
|
||||||
h('div', { class: 'week-nav' },
|
h('div', { class: 'week-nav' },
|
||||||
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) },
|
h('button', { class: 'btn btn-icon week-arrow', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) },
|
||||||
icon('chevronLeft')),
|
icon('chevronLeft')),
|
||||||
h('button', {
|
h('button', {
|
||||||
class: 'btn btn-ghost week-label',
|
class: 'btn btn-ghost week-label',
|
||||||
@@ -155,12 +160,13 @@ function weekCard() {
|
|||||||
onclick: () => { weekStart = mondayOf(new Date()); refresh(); },
|
onclick: () => { weekStart = mondayOf(new Date()); refresh(); },
|
||||||
text: `${dayDate.format(weekStart)} – ${dayDate.format(addDays(weekStart, 6))}`,
|
text: `${dayDate.format(weekStart)} – ${dayDate.format(addDays(weekStart, 6))}`,
|
||||||
}, h('small', { text: ` Week ${isoWeek(weekStart)}` })),
|
}, h('small', { text: ` Week ${isoWeek(weekStart)}` })),
|
||||||
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) },
|
h('button', { class: 'btn btn-icon week-arrow', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) },
|
||||||
icon('chevronRight')),
|
icon('chevronRight')))),
|
||||||
),
|
h('ul', { class: 'strip' }, cells),
|
||||||
),
|
seen.size > 0 && h('div', { class: 'strip-legend' },
|
||||||
h('ul', { class: 'card days' }, days),
|
[...seen].map(([id, name]) => h('span', {},
|
||||||
];
|
h('i', { class: `strip-dot ${personClass(id)}` }),
|
||||||
|
id === mine ? `${name} (you)` : name))));
|
||||||
}
|
}
|
||||||
|
|
||||||
// myShifts groups your upcoming dates into runs of consecutive days, then
|
// myShifts groups your upcoming dates into runs of consecutive days, then
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
+148
-79
@@ -168,13 +168,18 @@ function section() {
|
|||||||
// buttons, because these are six URLs: app.js intercepts the click, the
|
// buttons, because these are six URLs: app.js intercepts the click, the
|
||||||
// browser's Back walks them, and a reload lands where you were.
|
// browser's Back walks them, and a reload lands where you were.
|
||||||
function subnav() {
|
function subnav() {
|
||||||
return h('nav', { class: 'subnav', 'aria-label': 'Team' },
|
const nav = h('nav', { class: 'subnav', 'aria-label': 'Team' },
|
||||||
TABS.map((t) => h('a', {
|
TABS.map((t) => h('a', {
|
||||||
class: 'subnav-link',
|
class: 'subnav-link',
|
||||||
href: t.path,
|
href: t.path,
|
||||||
text: t.label,
|
text: t.label,
|
||||||
'aria-current': t.tab === tab ? 'page' : null,
|
'aria-current': t.tab === tab ? 'page' : null,
|
||||||
})));
|
})));
|
||||||
|
// On a phone the strip overflows; bring the open section into view so a
|
||||||
|
// tab past the edge (Sources, Switches) is never the one that is hidden.
|
||||||
|
requestAnimationFrame(() => nav.querySelector('[aria-current]')
|
||||||
|
?.scrollIntoView({ inline: 'center', block: 'nearest' }));
|
||||||
|
return nav;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Names which team's settings the six sections below belong to. It used to be
|
// Names which team's settings the six sections below belong to. It used to be
|
||||||
@@ -229,8 +234,8 @@ function overview() {
|
|||||||
// A month of it, as a grid. It used to be thirty rows of "date — username",
|
// 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
|
// 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
|
// "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
|
// that answer cannot be read in. So a shift is drawn as one bar across its
|
||||||
// instead, the legend says whose, and a shift becomes a run of one colour.
|
// 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
|
// 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.
|
// is the same rota — heading and arrows outside the card, days inside it.
|
||||||
@@ -269,22 +274,8 @@ function scheduleCard() {
|
|||||||
const today = isoDate(new Date());
|
const today = isoDate(new Date());
|
||||||
const month = monthStart.getMonth();
|
const month = monthStart.getMonth();
|
||||||
|
|
||||||
// Whose colours to explain, in the order the month meets them. Only the days
|
const rows = [];
|
||||||
// of this month count: a name that appears solely in the overhang belongs to
|
for (let w = 0; w < count / 7; w++) rows.push(weekRow(addDays(start, w * 7), byDate, today, month));
|
||||||
// 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 heads = [h('span', { class: 'rota-wd', title: 'ISO week number', text: 'Wk' })];
|
const heads = [h('span', { class: 'rota-wd', title: 'ISO week number', text: 'Wk' })];
|
||||||
for (let i = 0; i < 7; i++) {
|
for (let i = 0; i < 7; i++) {
|
||||||
@@ -292,38 +283,104 @@ function scheduleCard() {
|
|||||||
heads.push(h('span', { class: 'rota-wd', text: weekdayFmt.format(new Date(2024, 0, 1 + i)) }));
|
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 [
|
return [
|
||||||
h('div', { class: 'page-head' },
|
h('div', { class: 'page-head' },
|
||||||
h('h2', { text: 'On-call rota' }),
|
h('h2', { text: 'On-call rota' }),
|
||||||
h('div', { class: 'week-nav' },
|
h('div', { class: 'week-nav' },
|
||||||
h('button', {
|
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),
|
'aria-label': 'Previous month', onclick: () => shiftMonth(-1),
|
||||||
}, icon('chevronLeft')),
|
}, icon('chevronLeft')),
|
||||||
|
h('span', { class: 'month-label', text: monthFmt.format(monthStart) }),
|
||||||
h('button', {
|
h('button', {
|
||||||
class: 'btn btn-ghost label', type: 'button',
|
class: 'btn btn-icon week-arrow', 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',
|
|
||||||
'aria-label': 'Next month', onclick: () => shiftMonth(1),
|
'aria-label': 'Next month', onclick: () => shiftMonth(1),
|
||||||
}, icon('chevronRight')),
|
}, 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: 'card' },
|
||||||
h('div', { class: 'rota-grid' }, heads, cells),
|
h('div', { class: 'rota-grid' }, h('div', { class: 'rota-heads' }, heads), rows),
|
||||||
h('div', { class: 'rota-foot' }, legend(seen), coverNote(byDate)),
|
coverNote(byDate),
|
||||||
// The range form is the way to fill a whole shift at once, but it is not
|
bulk,
|
||||||
// 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()),
|
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// 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
|
// tap fills the week, which is the way a rota is usually handed out — a person
|
||||||
// takes a week, not seven separate days.
|
// takes a week, not seven separate days.
|
||||||
@@ -342,15 +399,10 @@ function weekCell(monday, byDate, today) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dayCell(d, key, e, inMonth, 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(' ');
|
.filter(Boolean).join(' ');
|
||||||
const label = `${key} · ${e ? e.username : 'nobody'}`;
|
const label = `${key} · ${e ? e.username : 'nobody'}`;
|
||||||
const body = [
|
const body = [h('span', { class: 'rota-num', text: String(d.getDate()) })];
|
||||||
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' }),
|
|
||||||
];
|
|
||||||
// A member sees the same grid without the affordance, the way every other
|
// A member sees the same grid without the affordance, the way every other
|
||||||
// control on this page is hidden rather than shown and refused.
|
// control on this page is hidden rather than shown and refused.
|
||||||
return isOwner()
|
return isOwner()
|
||||||
@@ -369,17 +421,7 @@ function colorClass(userID) {
|
|||||||
return `rc${((i < 0 ? userID : i) % 6) + 1}`;
|
return `rc${((i < 0 ? userID : i) % 6) + 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function legend(seen) {
|
// The gap count, which is the one thing the bars state only by omission. Days
|
||||||
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
|
|
||||||
// already past are not counted: an empty Tuesday last week is history, not a
|
// already past are not counted: an empty Tuesday last week is history, not a
|
||||||
// hole somebody still has to fill.
|
// hole somebody still has to fill.
|
||||||
function coverNote(byDate) {
|
function coverNote(byDate) {
|
||||||
@@ -390,10 +432,14 @@ function coverNote(byDate) {
|
|||||||
const key = isoDate(new Date(monthStart.getFullYear(), monthStart.getMonth(), day));
|
const key = isoDate(new Date(monthStart.getFullYear(), monthStart.getMonth(), day));
|
||||||
if (key >= today && !byDate.has(key)) gaps++;
|
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.' });
|
if (gaps === 0) {
|
||||||
return h('p', { class: 'rota-note' },
|
return h('div', { class: 'rota-banner ok' }, icon('checkCircle'),
|
||||||
h('strong', { text: gaps === 1 ? '1 day' : `${gaps} days` }),
|
h('span', { text: 'Every day left this month has somebody on call.' }));
|
||||||
' left this month with nobody 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
|
// One week, in the sheet: who holds each day of it, and one person to put on
|
||||||
@@ -562,23 +608,49 @@ function escalationCard() {
|
|||||||
const esc = data.escalation || {};
|
const esc = data.escalation || {};
|
||||||
const levels = esc.levels || [];
|
const levels = esc.levels || [];
|
||||||
|
|
||||||
const rows = levels.map((l) => h('tr', {},
|
// The ladder as steps, top to bottom: each level, the wait before the next,
|
||||||
h('td', {}, h('strong', { text: `Level ${l.position}` })),
|
// and what happens when the last one is not answered. The wait belongs to the
|
||||||
h('td', {}, levelBadge(l.status)),
|
// level above it ("then after 5m"), which is how the API states it.
|
||||||
h('td', { class: 'wrap' }, ...l.targets.map(targetLine)),
|
const steps = [];
|
||||||
h('td', { class: 'muted small', text: duration(l.timeout_seconds * 1000) }),
|
levels.forEach((l) => {
|
||||||
h('td', { class: 'small' }, l.waiting?.length
|
steps.push(h('li', { class: 'step' },
|
||||||
? l.waiting.flatMap((id, i) => [i > 0 && ', ', h('a', { href: `/incidents/${id}`, text: `#${id}` })])
|
h('div', { class: 'step-rail' },
|
||||||
: h('span', { class: 'muted', text: '—' })),
|
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 = [];
|
const facts = [];
|
||||||
if (levels.length) {
|
if (levels.length) {
|
||||||
const n = esc.repeat_count || 0;
|
const n = esc.repeat_count || 0;
|
||||||
if (n) facts.push(`Then the whole ladder repeats ${n} more ${n === 1 ? 'time' : 'times'}.`);
|
facts.push(n
|
||||||
facts.push(esc.fallback_topic
|
? `Then the whole ladder repeats ${n} more ${n === 1 ? 'time' : 'times'}.`
|
||||||
? ['Finally the ntfy topic ', h('code', { text: esc.fallback_topic }), ' is paged once.']
|
: 'Nothing repeats: the ladder runs once.');
|
||||||
: 'No fallback topic: after the last level the chain just ends.');
|
|
||||||
facts.push(esc.last_escalated_at
|
facts.push(esc.last_escalated_at
|
||||||
? ['Last escalated ',
|
? ['Last escalated ',
|
||||||
h('span', { title: when(esc.last_escalated_at), text: ago(esc.last_escalated_at) }),
|
h('span', { title: when(esc.last_escalated_at), text: ago(esc.last_escalated_at) }),
|
||||||
@@ -590,23 +662,18 @@ function escalationCard() {
|
|||||||
h('div', { class: 'card-head' },
|
h('div', { class: 'card-head' },
|
||||||
h('h2', { text: 'Escalation' }),
|
h('h2', { text: 'Escalation' }),
|
||||||
isOwner() && h('button', {
|
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',
|
text: levels.length ? 'Edit ladder' : 'Set up ladder',
|
||||||
})),
|
})),
|
||||||
h('p', { class: 'muted small' },
|
h('p', { class: 'muted small' },
|
||||||
'When a level’s wait passes and nobody has acknowledged, the next level is ',
|
'When a level’s wait passes and nobody has acknowledged, the next level is ',
|
||||||
'paged. Acknowledging or resolving stops it; snoozing pauses it.'),
|
'paged. Acknowledging or resolving stops it; snoozing pauses it.'),
|
||||||
levels.length
|
levels.length
|
||||||
? h('div', { class: 'table-scroll' },
|
? h('ol', { class: 'ladder' }, steps)
|
||||||
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('p', { class: 'muted' },
|
: h('p', { class: 'muted' },
|
||||||
'No ladder. An unacknowledged incident re-pages the same person every ',
|
'No ladder. An unacknowledged incident re-pages the same person every ',
|
||||||
'reminder interval and nobody else is woken.'),
|
'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))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,7 +681,7 @@ function escalationCard() {
|
|||||||
// it wholesale: the levels are an order, and patching one rung would leave the
|
// 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
|
// numbering of the others undecided. The draft lives in the sheet, so a poll of
|
||||||
// the page underneath cannot throw away half an edit.
|
// the page underneath cannot throw away half an edit.
|
||||||
function openLadderEditor() {
|
function openLadderEditor({ focusFallback = false } = {}) {
|
||||||
const esc = data.escalation || {};
|
const esc = data.escalation || {};
|
||||||
const draft = {
|
const draft = {
|
||||||
repeat_count: esc.repeat_count || 0,
|
repeat_count: esc.repeat_count || 0,
|
||||||
@@ -626,6 +693,7 @@ function openLadderEditor() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const body = h('div', { class: 'ladder-editor' });
|
const body = h('div', { class: 'ladder-editor' });
|
||||||
|
let fallbackInput = null;
|
||||||
const problem = h('p', { class: 'load-error', hidden: true });
|
const problem = h('p', { class: 'load-error', hidden: true });
|
||||||
|
|
||||||
const paint = () => {
|
const paint = () => {
|
||||||
@@ -665,7 +733,7 @@ function openLadderEditor() {
|
|||||||
value: String(draft.repeat_count),
|
value: String(draft.repeat_count),
|
||||||
oninput: (e) => { draft.repeat_count = Number(e.target.value); },
|
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',
|
type: 'text', value: draft.fallback_topic, placeholder: 'terdut-oncall-all',
|
||||||
oninput: (e) => { draft.fallback_topic = e.target.value; },
|
oninput: (e) => { draft.fallback_topic = e.target.value; },
|
||||||
});
|
});
|
||||||
@@ -696,6 +764,7 @@ function openLadderEditor() {
|
|||||||
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
|
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
|
||||||
save),
|
save),
|
||||||
]);
|
]);
|
||||||
|
if (focusFallback) requestAnimationFrame(() => fallbackInput?.focus());
|
||||||
}
|
}
|
||||||
|
|
||||||
function targetRow(level, target, index, repaint) {
|
function targetRow(level, target, index, repaint) {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Theme override: System (no attribute, the OS decides), Light or Dark.
|
||||||
|
//
|
||||||
|
// A classic script loaded from <head>, not a module, so the saved choice is on
|
||||||
|
// <html> before the first paint and a Dark user on a light OS never sees a
|
||||||
|
// flash. The CSP allows no inline script, hence a file of its own. Account
|
||||||
|
// (account.js) reads and writes the choice through window.terdutTheme.
|
||||||
|
(function () {
|
||||||
|
var KEY = 'terdut.theme';
|
||||||
|
var COLORS = { light: '#f5f6f8', dark: '#0f1115' };
|
||||||
|
|
||||||
|
function get() {
|
||||||
|
try {
|
||||||
|
var v = localStorage.getItem(KEY);
|
||||||
|
return v === 'light' || v === 'dark' ? v : 'system';
|
||||||
|
} catch (e) {
|
||||||
|
return 'system'; // storage unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The browser chrome colour follows the choice too; the two <meta>s are
|
||||||
|
// media-keyed to the OS, so a forced theme sets both to the same colour.
|
||||||
|
function apply(theme) {
|
||||||
|
var root = document.documentElement;
|
||||||
|
if (theme === 'system') root.removeAttribute('data-theme');
|
||||||
|
else root.setAttribute('data-theme', theme);
|
||||||
|
var metas = document.querySelectorAll('meta[name="theme-color"]');
|
||||||
|
for (var i = 0; i < metas.length; i++) {
|
||||||
|
var scheme = /dark/.test(metas[i].media) ? 'dark' : 'light';
|
||||||
|
metas[i].content = COLORS[theme === 'system' ? scheme : theme];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function set(theme) {
|
||||||
|
try {
|
||||||
|
if (theme === 'system') localStorage.removeItem(KEY);
|
||||||
|
else localStorage.setItem(KEY, theme);
|
||||||
|
} catch (e) {
|
||||||
|
/* storage unavailable: applies for this page view only */
|
||||||
|
}
|
||||||
|
apply(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.terdutTheme = { get: get, set: set };
|
||||||
|
apply(get());
|
||||||
|
})();
|
||||||
@@ -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