Serve a web UI for the incident queue, built for phones

Whoever is on call gets paged on a phone, and until now the only ways to
act on a page were the notification's Acknowledge button or a terminal.
Tapping the notification itself opened /api/incidents/{id}, which a
browser can only answer with a 401 in JSON. The server now serves a web
UI at / covering the incident queue, each incident's alerts and timeline
with every action on it, who is on call, the alert feed, and changing
your own password. The notification link now points at /incidents/{id}
in that UI.

It is embedded in the binary and has no build step: plain HTML, CSS and
ES modules under internal/web/static, served with an ETag per file and a
CSP that allows nothing from any other origin. That is how rd-web is
built. It avoids adding a node toolchain to the Dockerfile and the
pipeline for a page this size, and it keeps the page on the same origin
as the API, so no CORS is needed and nothing else has to be deployed.
Paths without a file extension fall back to index.html, so a deep link
survives a reload. An unknown path under /api/ still gets a JSON 404
rather than the page.

Signing in uses a username and password, because pasting a 64-character
API key into a phone at 3am is not a sign-in flow. Users have no
password until one is set through PUT /api/users/{id}/password, or
optionally at bootstrap. A user without a password is exactly where they
were before this commit and can only use API keys. A login sets an
HttpOnly, SameSite=Lax session cookie. It lasts 30 days and slides
forward while in use, so an on-call phone does not sign itself out.
Only the token's hash is stored, as for API keys.

The cookie needs a CSRF guard where a bearer header does not, because
browsers attach cookies to requests other sites make. So cookie-
authenticated requests go through Go 1.25's http.CrossOriginProtection,
and bearer requests do not. A request carrying an Authorization header
is judged on that header alone and never falls back to the cookie.
Changing a password ends every other session of that user. Changing
your own requires the current password, so a phone left signed in
cannot be used to take the account over.

Failed logins are counted per username and per client address. Ten
failures for one username in 15 minutes refuse that username for the
rest of the window, even with the right password. That makes locking
somebody out possible for anyone who knows their username. It was
accepted because the alternative is unlimited guessing, and during a
lockout the notification's Acknowledge button and API keys keep
working. The address limit reads the first X-Forwarded-For hop, since
behind the gateway RemoteAddr is Envoy. It is looser, because a whole
office behind one NAT shares it.

The Secure flag follows TERDUT_PUBLIC_URL, since TLS terminates at the
gateway and the server itself only ever sees plain HTTP. The chart
already defaults that variable to https://<hostname>.

Schedule editing, statistics and user management stay in terdut-tui for
now. The API they use is unchanged, and bearer authentication behaves
exactly as before.
This commit is contained in:
Niklas Ye
2026-09-19 17:48:21 +02:00
parent 9669b8f477
commit dc3879eca6
31 changed files with 3494 additions and 38 deletions
+622
View File
@@ -0,0 +1,622 @@
/* terdut web UI.
*
* Mobile first: one column, a top bar and a bottom tab bar. From 900px the tab
* bar becomes a sidebar and the queue shows list and detail side by side.
* Colour is reserved for severity and status; everything else is neutral.
*/
:root {
--bg: #f5f6f8;
--surface: #ffffff;
--surface-2: #eff1f4;
--surface-hover: #f7f8fa;
--border: #e2e5ea;
--border-strong: #cfd3da;
--text: #16181d;
--muted: #5b626e;
--faint: #8a909b;
--accent: #2f5bd3;
--accent-text: #ffffff;
--accent-soft: #e8eefc;
--crit: #d0342c;
--crit-soft: #fdecea;
--warn: #b86e00;
--warn-soft: #fdf3e1;
--info: #2f6fdf;
--info-soft: #e9f0fd;
--ok: #1d7f4c;
--ok-soft: #e6f5ec;
--snooze: #6b5bd2;
--snooze-soft: #efedfb;
--radius: 10px;
--radius-sm: 6px;
--shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%);
--shadow-lg: 0 12px 32px rgb(16 24 40 / 18%);
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--topbar-h: 52px;
--tabbar-h: 58px;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1115;
--surface: #171a20;
--surface-2: #1f232b;
--surface-hover: #1c2027;
--border: #2a2f38;
--border-strong: #394050;
--text: #e7e9ed;
--muted: #a0a7b3;
--faint: #737a87;
--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;
--shadow: 0 1px 2px rgb(0 0 0 / 40%);
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
}
}
*, *::before, *::after { box-sizing: border-box; }
[hidden] { display: none !important; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 15px/1.45 var(--font);
font-variant-numeric: tabular-nums;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
}
a { color: inherit; text-decoration: none; }
button, input, textarea, select { font: inherit; color: inherit; }
code { font-family: var(--mono); font-size: 0.9em; }
h1, h2, h3 { margin: 0; line-height: 1.25; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ---------- boot + login ---------- */
.boot { display: grid; place-items: center; min-height: 100dvh; }
.spinner {
width: 26px; height: 26px; border-radius: 50%;
border: 3px solid var(--border); border-top-color: var(--accent);
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.login {
min-height: 100dvh;
display: grid; place-items: center;
padding: calc(24px + var(--safe-top)) 16px calc(24px + var(--safe-bottom));
}
.login-card {
width: 100%; max-width: 360px;
display: grid; gap: 14px;
}
.login-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.login-brand h1 { font-size: 24px; letter-spacing: -0.01em; }
.login-hint { color: var(--faint); font-size: 13px; margin: 4px 0 0; }
label { display: grid; gap: 6px; }
label > span { font-size: 13px; font-weight: 600; color: var(--muted); }
input, textarea, select {
width: 100%;
min-height: 44px;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
font-size: 16px; /* 16px stops iOS zooming into the field */
}
textarea { min-height: 110px; resize: vertical; line-height: 1.45; }
input:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.form-error {
margin: 0; padding: 10px 12px;
background: var(--crit-soft); color: var(--crit);
border-radius: var(--radius-sm); font-size: 14px;
}
/* ---------- buttons ---------- */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
min-height: 44px; padding: 0 16px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface);
font-weight: 600; font-size: 15px;
cursor: pointer;
white-space: nowrap;
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
}
.btn:hover { background: var(--surface-hover); }
.btn:disabled { opacity: 0.55; cursor: default; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
.btn-primary:hover { background: var(--accent); filter: brightness(1.06); }
.btn-danger { background: var(--crit); border-color: var(--crit); color: #fff; }
.btn-danger:hover { background: var(--crit); filter: brightness(1.06); }
.btn-ghost { background: transparent; border-color: transparent; }
.btn-ghost:hover { background: var(--surface-2); }
.btn-block { width: 100%; }
.btn-icon { width: 44px; padding: 0; }
.btn-sm { min-height: 32px; padding: 0 8px; font-size: 13px; }
.btn-sm svg { width: 16px; height: 16px; }
.btn svg, .icon { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
/* ---------- app frame ---------- */
.app { min-height: 100dvh; }
.topbar {
position: sticky; top: 0; z-index: 10;
display: flex; align-items: center; justify-content: space-between; gap: 12px;
height: calc(var(--topbar-h) + var(--safe-top));
padding: var(--safe-top) 16px 0;
background: color-mix(in srgb, var(--bg) 88%, transparent);
backdrop-filter: saturate(1.4) blur(12px);
-webkit-backdrop-filter: saturate(1.4) blur(12px);
border-bottom: 1px solid var(--border);
}
.topbar-title { font-size: 18px; font-weight: 700; letter-spacing: -0.01em; }
.open-pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 10px; border-radius: 999px;
font-size: 13px; font-weight: 600;
background: var(--surface-2); color: var(--muted);
}
.open-pill::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); }
.open-pill.has-triggered { background: var(--crit-soft); color: var(--crit); }
.open-pill.has-triggered::before { background: var(--crit); }
.open-pill.all-acked::before { background: var(--warn); }
/* Bottom tab bar on phones. */
.nav {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
display: grid; grid-template-columns: repeat(4, 1fr);
height: calc(var(--tabbar-h) + var(--safe-bottom));
padding-bottom: var(--safe-bottom);
background: color-mix(in srgb, var(--surface) 92%, transparent);
backdrop-filter: saturate(1.4) blur(12px);
-webkit-backdrop-filter: saturate(1.4) blur(12px);
border-top: 1px solid var(--border);
}
.nav-brand { display: none; }
.nav-link {
position: relative;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
color: var(--faint); font-size: 11px; font-weight: 600;
}
.nav-link svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.nav-link[aria-current="page"] { color: var(--accent); }
.nav-badge {
position: absolute; top: 6px; left: calc(50% + 6px);
min-width: 18px; height: 18px; padding: 0 5px;
border-radius: 999px; background: var(--crit); color: #fff;
font-size: 11px; font-weight: 700; line-height: 18px; text-align: center;
}
.view { padding-bottom: calc(var(--tabbar-h) + var(--safe-bottom)); }
.view-page { padding-left: 16px; padding-right: 16px; }
.view-page > * { max-width: 760px; margin-left: auto; margin-right: auto; }
/* Phone detail: the incident takes the whole screen with its own action bar,
so the tab bar and top bar step aside. */
.app.detail-open .nav,
.app.detail-open .topbar { display: none; }
.app.detail-open .pane-list { display: none; }
.app.detail-open .view-queue { padding-bottom: 0; }
.view-queue:not(.has-detail) .pane-detail { display: none; }
/* ---------- chips ---------- */
.chips {
display: flex; gap: 6px;
padding: 12px 16px 8px;
overflow-x: auto; scrollbar-width: none;
}
.chips::-webkit-scrollbar { display: none; }
.chip {
flex: none;
min-height: 34px; padding: 0 12px;
border: 1px solid var(--border-strong); border-radius: 999px;
background: var(--surface); color: var(--muted);
font-size: 13px; font-weight: 600; cursor: pointer;
}
.chip[aria-selected="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
.chip .count { margin-left: 4px; opacity: 0.7; }
/* ---------- lists ---------- */
.list { padding: 0 16px 16px; display: grid; gap: 8px; }
.row {
position: relative;
display: grid; grid-template-columns: 1fr auto; gap: 4px 12px;
padding: 11px 14px 11px 18px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
cursor: pointer;
overflow: hidden;
}
.row:hover { background: var(--surface-hover); }
.row[aria-current="true"] { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.row.kbd-focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.row::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: var(--sev, var(--border-strong));
}
.row-title {
font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.row-age { color: var(--faint); font-size: 13px; text-align: right; white-space: nowrap; }
.row-meta {
grid-column: 1 / -1;
display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px;
color: var(--muted); font-size: 13px;
min-width: 0;
}
.row-meta .labels { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; max-width: 100%; color: var(--faint); }
.row.resolved .row-title { color: var(--muted); }
.sev-critical { --sev: var(--crit); }
.sev-warning { --sev: var(--warn); }
.sev-info { --sev: var(--info); }
.empty {
padding: 48px 16px; text-align: center; color: var(--muted);
}
.empty strong { display: block; color: var(--text); font-size: 16px; margin-bottom: 4px; }
.empty .icon { width: 36px; height: 36px; color: var(--ok); margin-bottom: 8px; }
.load-error {
margin: 12px 16px; padding: 10px 12px;
background: var(--crit-soft); color: var(--crit);
border-radius: var(--radius-sm); font-size: 14px;
}
/* ---------- badges ---------- */
.badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 1px 8px; border-radius: 999px;
font-size: 12px; font-weight: 700; letter-spacing: 0.01em;
background: var(--surface-2); color: var(--muted);
white-space: nowrap;
}
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.badge.plain::before { display: none; }
.badge.st-triggered, .badge.st-firing { background: var(--crit-soft); color: var(--crit); }
.badge.st-acknowledged { background: var(--warn-soft); color: var(--warn); }
.badge.st-snoozed { background: var(--snooze-soft); color: var(--snooze); }
.badge.st-resolved { background: var(--ok-soft); color: var(--ok); }
.badge.sev-critical { background: var(--crit-soft); color: var(--crit); }
.badge.sev-warning { background: var(--warn-soft); color: var(--warn); }
.badge.sev-info { background: var(--info-soft); color: var(--info); }
/* ---------- incident detail ---------- */
.detail { padding: 0 16px calc(96px + var(--safe-bottom)); }
.detail-head {
position: sticky; top: 0; z-index: 5;
display: flex; align-items: center; gap: 4px;
height: calc(var(--topbar-h) + var(--safe-top));
margin: 0 -16px; padding: var(--safe-top) 8px 0;
background: color-mix(in srgb, var(--bg) 88%, transparent);
backdrop-filter: saturate(1.4) blur(12px);
-webkit-backdrop-filter: saturate(1.4) blur(12px);
border-bottom: 1px solid var(--border);
}
.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; }
.detail-title { font-size: 21px; 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; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.card + .card, .section + .section { margin-top: 14px; }
.card-pad { padding: 14px; }
.facts { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; padding: 14px; font-size: 14px; }
.facts dt { color: var(--muted); }
.facts dd { margin: 0; overflow-wrap: anywhere; }
.facts .sub { color: var(--faint); }
.section { margin-top: 22px; }
.section-title {
display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--muted); margin: 0 2px 8px;
}
.labels-wrap { display: flex; flex-wrap: wrap; gap: 6px; }
.label {
display: inline-flex; max-width: 100%;
font-family: var(--mono); font-size: 12px;
border: 1px solid var(--border); border-radius: var(--radius-sm);
overflow: hidden;
}
.label > span { padding: 2px 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.label > span:first-child { background: var(--surface-2); color: var(--muted); }
.alert-item { padding: 12px 14px; display: grid; gap: 6px; }
.alert-item + .alert-item { border-top: 1px solid var(--border); }
.alert-item-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.alert-item-name { font-weight: 650; overflow-wrap: anywhere; }
.alert-item-summary { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
.alert-item-foot { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; color: var(--faint); }
.alert-item-foot a { color: var(--accent); font-weight: 600; }
details > summary { cursor: pointer; color: var(--muted); font-size: 13px; font-weight: 600; list-style: none; }
details > summary::-webkit-details-marker { display: none; }
details > summary::before { content: "▸ "; }
details[open] > summary::before { content: "▾ "; }
details[open] > summary { margin-bottom: 8px; }
.timeline { list-style: none; margin: 0; padding: 4px 0; }
.tl-item {
position: relative;
display: grid; grid-template-columns: 20px 1fr; gap: 10px;
padding: 8px 14px;
}
.tl-item::before {
content: ""; position: absolute; left: 23px; top: 0; bottom: 0; width: 2px; background: var(--border);
}
.tl-item:first-child::before { top: 16px; }
.tl-item:last-child::before { bottom: calc(100% - 16px); }
.tl-dot {
position: relative; z-index: 1;
width: 10px; height: 10px; margin: 5px 0 0 5px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--faint);
}
.tl-triggered .tl-dot, .tl-notify_failed .tl-dot, .tl-deadman_silent .tl-dot { border-color: var(--crit); background: var(--crit); }
.tl-acknowledged .tl-dot { border-color: var(--warn); background: var(--warn); }
.tl-resolved .tl-dot { border-color: var(--ok); background: var(--ok); }
.tl-snoozed .tl-dot { border-color: var(--snooze); }
.tl-note .tl-dot { border-color: var(--accent); background: var(--accent); }
.tl-body { min-width: 0; font-size: 14px; }
.tl-text { overflow-wrap: anywhere; }
.tl-text .who { font-weight: 650; }
.tl-time { color: var(--faint); font-size: 12px; }
.tl-note .note {
margin-top: 6px; padding: 10px 12px;
background: var(--surface-2); border-radius: var(--radius-sm);
white-space: pre-wrap; overflow-wrap: anywhere;
}
.note-actions { display: flex; justify-content: flex-end; }
.note-actions .btn { color: var(--muted); }
/* The action bar sits at the bottom of the screen on a phone, and at the
bottom of the detail pane on desktop. */
.actionbar {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 15;
display: flex; gap: 8px;
padding: 10px 16px calc(10px + var(--safe-bottom));
background: var(--surface);
border-top: 1px solid var(--border);
}
.actionbar .btn { min-height: 48px; }
.actionbar .btn-primary { flex: 1; font-size: 16px; }
.detail-placeholder {
display: grid; place-items: center; height: 100%;
color: var(--faint); text-align: center; padding: 32px;
}
/* ---------- sheet (dialog) ---------- */
.sheet {
width: 100%; max-width: 100%;
max-height: 88dvh;
margin: auto 0 0; padding: 0;
border: 0; border-radius: 16px 16px 0 0;
background: var(--surface); color: var(--text);
box-shadow: var(--shadow-lg);
overflow: auto;
}
.sheet::backdrop { background: rgb(0 0 0 / 40%); }
.sheet[open] { animation: sheet-up 0.18s ease-out; }
@keyframes sheet-up { from { transform: translateY(24px); opacity: 0.6; } }
.sheet-inner { padding: 8px 16px calc(16px + var(--safe-bottom)); }
.sheet-grab { width: 40px; height: 4px; margin: 0 auto 12px; border-radius: 2px; background: var(--border-strong); }
.sheet-title { font-size: 17px; font-weight: 700; margin: 0 0 4px; }
.sheet-text { color: var(--muted); margin: 0 0 14px; font-size: 14px; }
.sheet-form { display: grid; gap: 12px; }
.sheet-actions { display: flex; gap: 8px; margin-top: 16px; }
.sheet-actions .btn { flex: 1; }
.menu { list-style: none; margin: 0 -4px; padding: 0; }
.menu-item {
display: flex; align-items: center; gap: 12px;
width: 100%; min-height: 50px; padding: 0 12px;
background: none; border: 0; border-radius: var(--radius-sm);
text-align: left; font-size: 16px; cursor: pointer;
}
.menu-item:hover { background: var(--surface-2); }
.menu-item .icon { color: var(--muted); flex: none; }
.menu-item .menu-sub { margin-left: auto; color: var(--faint); font-size: 13px; }
.menu-item.danger, .menu-item.danger .icon { color: var(--crit); }
.menu-item[aria-checked="true"] { font-weight: 700; }
.menu-item[aria-checked="true"]::after { content: "✓"; margin-left: 8px; color: var(--accent); }
.menu-sep { height: 1px; background: var(--border); margin: 6px 12px; }
/* ---------- toast ---------- */
.toast {
position: fixed; left: 50%; z-index: 50;
bottom: calc(var(--tabbar-h) + var(--safe-bottom) + 12px);
transform: translateX(-50%);
max-width: calc(100% - 32px);
padding: 10px 16px; border-radius: var(--radius);
background: var(--text); color: var(--bg);
font-size: 14px; font-weight: 600;
box-shadow: var(--shadow-lg);
}
.toast.error { background: var(--crit); color: #fff; }
.app.detail-open ~ .toast { bottom: calc(80px + var(--safe-bottom)); }
/* ---------- on-call ---------- */
.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); }
.now-card { display: flex; align-items: center; gap: 14px; padding: 16px; margin-top: 16px; }
.avatar {
flex: none; display: grid; place-items: center;
width: 44px; height: 44px; border-radius: 50%;
background: var(--accent-soft); color: var(--accent);
font-weight: 750; font-size: 17px; text-transform: uppercase;
}
.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; }
.week-nav { display: flex; align-items: center; gap: 4px; }
.week-nav .label { font-size: 14px; font-weight: 650; min-width: 9em; text-align: center; }
.days { list-style: none; margin: 0; padding: 0; }
.day { display: grid; grid-template-columns: 3.2em 4.2em 1fr; align-items: center; gap: 8px; min-height: 50px; padding: 0 14px; }
.day + .day { border-top: 1px solid var(--border); }
.day-name { font-weight: 650; }
.day-date { color: var(--faint); font-size: 13px; }
.day-who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.day-who.nobody { color: var(--faint); font-style: italic; }
.day.today { background: var(--accent-soft); }
.day.today:first-child { border-radius: var(--radius) var(--radius) 0 0; }
.day.today:last-child { border-radius: 0 0 var(--radius) var(--radius); }
.day.today .day-name { color: var(--accent); }
.day.past { opacity: 0.6; }
.shift-list { list-style: none; margin: 0; padding: 0; }
.shift-list li { display: flex; justify-content: space-between; padding: 12px 14px; }
.shift-list li + li { border-top: 1px solid var(--border); }
.shift-list .muted { color: var(--faint); }
/* ---------- alerts page ---------- */
.view-alerts .chips { padding-left: 0; padding-right: 0; }
.view-alerts .list { padding-left: 0; padding-right: 0; }
.row.st-firing { --sev: var(--crit); }
.row.st-resolved { --sev: var(--ok); }
.row.no-link { cursor: default; }
/* ---------- account ---------- */
.account-card { display: flex; align-items: center; gap: 14px; padding: 16px; margin-top: 16px; }
.account-name { font-size: 18px; font-weight: 750; }
.account-email { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
.pw-form { display: grid; gap: 12px; padding: 16px; }
.form-ok {
margin: 0; padding: 10px 12px;
background: var(--ok-soft); color: var(--ok);
border-radius: var(--radius-sm); font-size: 14px;
}
.kbd-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.kbd-table td { padding: 8px 14px; border-top: 1px solid var(--border); }
.kbd-table tr:first-child td { border-top: 0; }
kbd {
display: inline-block; min-width: 1.6em; padding: 1px 6px;
font-family: var(--mono); font-size: 12px; text-align: center;
background: var(--surface-2); border: 1px solid var(--border-strong); border-bottom-width: 2px;
border-radius: 4px;
}
.only-desktop { display: none; }
.foot-note { color: var(--faint); font-size: 13px; text-align: center; margin: 24px auto; }
/* ---------- desktop ---------- */
@media (min-width: 900px) {
:root { --tabbar-h: 0px; }
.app { display: grid; grid-template-columns: 220px 1fr; height: 100dvh; }
.nav {
position: static; grid-row: 1 / span 2;
display: flex; flex-direction: column; gap: 2px;
height: auto; padding: 16px 12px;
background: var(--surface);
border-top: 0; border-right: 1px solid var(--border);
backdrop-filter: none;
}
.nav-brand {
display: flex; align-items: center; gap: 10px;
padding: 4px 10px 18px; font-size: 18px; font-weight: 750; letter-spacing: -0.01em;
}
.nav-link {
flex-direction: row; justify-content: flex-start; gap: 12px;
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
color: var(--muted); font-size: 14px;
}
.nav-link:hover { background: var(--surface-2); }
.nav-link[aria-current="page"] { background: var(--accent-soft); color: var(--accent); }
.nav-link svg { width: 20px; height: 20px; }
.nav-badge { position: static; margin-left: auto; }
.topbar { display: none; }
.view { padding-bottom: 0; overflow: auto; height: 100dvh; }
.view-page { padding: 8px 32px 32px; }
.view-queue {
display: grid; grid-template-columns: minmax(340px, 420px) 1fr;
overflow: hidden;
}
.view-queue .pane { overflow: auto; height: 100dvh; }
.pane-list { border-right: 1px solid var(--border); }
.pane-list .chips { position: sticky; top: 0; z-index: 2; background: var(--bg); padding-top: 16px; }
.view-queue:not(.has-detail) .pane-detail { display: block; }
/* On desktop the list stays visible next to the detail. */
.app.detail-open .nav { display: flex; }
.app.detail-open .pane-list { display: block; }
.detail-head .back { display: none; }
.detail-head { padding-left: 16px; }
.pane-detail { position: relative; display: flex; flex-direction: column; }
.detail { flex: 1; padding: 0 32px 24px; max-width: 900px; width: 100%; }
.detail-head { margin: 0 -32px; padding-left: 32px; }
.actionbar {
position: sticky; bottom: 0;
padding: 12px 32px;
}
.actionbar .btn-primary { flex: 0 1 240px; }
.sheet {
width: min(440px, calc(100% - 32px));
margin: auto; border-radius: 14px;
}
.sheet-grab { display: none; }
.sheet-inner { padding: 20px; }
.toast, .app.detail-open ~ .toast { bottom: 24px; }
.only-desktop { display: block; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#1b1e25"/>
<path d="M10 34h11l5-12 8 22 6-15 3 5h11" fill="none" stroke="#ff6b61" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 265 B

+89
View File
@@ -0,0 +1,89 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#f5f6f8" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0f1115" media="(prefers-color-scheme: dark)">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="terdut">
<title>terdut</title>
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" href="/app.css">
<script type="module" src="/js/app.js"></script>
</head>
<body>
<div id="boot" class="boot" aria-busy="true"><span class="spinner"></span></div>
<main id="login" class="login" hidden>
<form id="login-form" class="login-card" autocomplete="on">
<div class="login-brand">
<img src="/icon.svg" alt="" width="40" height="40">
<h1>terdut</h1>
</div>
<label>
<span>Username</span>
<input name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required>
</label>
<label>
<span>Password</span>
<input name="password" type="password" autocomplete="current-password" required>
</label>
<p class="form-error" role="alert" hidden></p>
<button class="btn btn-primary btn-block" type="submit">Sign in</button>
<p class="login-hint">No password yet? Ask an admin to set one, or run
<code>PUT /api/users/{id}/password</code> with your API key.</p>
</form>
</main>
<div id="app" class="app" hidden>
<nav class="nav" aria-label="Sections">
<a class="nav-brand" href="/">
<img src="/icon.svg" alt="" width="28" height="28">
<span>terdut</span>
</a>
<a class="nav-link" href="/" data-section="queue">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
<span class="nav-label">Queue</span>
<span class="nav-badge" data-badge hidden></span>
</a>
<a class="nav-link" href="/oncall" data-section="oncall">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5" width="17" height="15" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg>
<span class="nav-label">On-call</span>
</a>
<a class="nav-link" href="/alerts" data-section="alerts">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
<span class="nav-label">Alerts</span>
</a>
<a class="nav-link" href="/more" data-section="more">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/></svg>
<span class="nav-label">Account</span>
</a>
</nav>
<header class="topbar">
<h1 class="topbar-title" id="topbar-title">Queue</h1>
<span class="open-pill" id="open-pill" hidden></span>
</header>
<section id="view-queue" class="view view-queue" data-view="queue">
<div class="pane pane-list">
<div class="chips" id="queue-filters" role="tablist" aria-label="Filter"></div>
<div id="queue-list" class="list"></div>
</div>
<div class="pane pane-detail" id="detail" aria-live="polite"></div>
</section>
<section id="view-oncall" class="view view-page" data-view="oncall" hidden></section>
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
<section id="view-more" class="view view-page" data-view="more" hidden></section>
</div>
<dialog id="sheet" class="sheet"></dialog>
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
</body>
</html>
+110
View File
@@ -0,0 +1,110 @@
// Account: who you are signed in as, changing your password, signing out.
import * as api from './api.js';
import { h, clear, icon, toast } from './ui.js';
import { initial } from './format.js';
import { state } from './state.js';
import { signOut } from './app.js';
const view = () => document.getElementById('view-more');
// Rendered once per visit rather than on every poll, so a half-typed password
// is never wiped out from under you.
export function show() {
render();
}
function render() {
const { user, has_password: hasPassword } = state.me;
clear(view(),
h('div', { class: 'card account-card' },
h('div', { class: 'avatar', text: initial(user.username) }),
h('div', {},
h('div', { class: 'account-name', text: user.username }),
h('div', { class: 'account-email', text: user.email }))),
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
passwordForm(user, hasPassword),
h('div', { class: 'only-desktop' },
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
h('div', { class: 'card' }, shortcuts())),
h('div', { class: 'page-head' }),
h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'),
h('p', { class: 'foot-note', text: 'Schedule editing, statistics and user management are in terdut-tui for now.' }),
);
}
function passwordForm(user, hasPassword) {
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
const current = hasPassword
? h('input', { name: 'current', type: 'password', autocomplete: 'current-password', required: true })
: null;
const next = h('input', { name: 'next', type: 'password', autocomplete: 'new-password', required: true, minlength: '10' });
const again = h('input', { name: 'again', type: 'password', autocomplete: 'new-password', required: true, minlength: '10' });
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save password' });
// A hidden username field lets password managers file the new password
// under the right account.
const form = h('form', { class: 'card pw-form', autocomplete: 'on' },
h('input', { type: 'text', name: 'username', autocomplete: 'username', value: user.username, hidden: true, readonly: true }),
current && h('label', {}, h('span', { text: 'Current password' }), current),
h('label', {}, h('span', { text: 'New password' }), next),
h('label', {}, h('span', { text: 'Repeat new password' }), again),
err, ok, submit,
);
form.addEventListener('submit', async (e) => {
e.preventDefault();
err.hidden = true;
ok.hidden = true;
if (next.value !== again.value) {
err.textContent = 'The new passwords do not match.';
err.hidden = false;
return;
}
submit.disabled = true;
try {
await api.setPassword(user.id, next.value, current ? current.value : '');
state.me.has_password = true;
form.reset();
if (!current) {
// From now on the form needs the current-password field.
render();
toast('Password saved');
return;
}
ok.textContent = 'Password saved. Other devices have been signed out.';
ok.hidden = false;
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
submit.disabled = false;
}
});
return form;
}
function shortcuts() {
const rows = [
['j / k', 'Move through the queue'],
['Enter', 'Open incident'],
['Esc', 'Back to the queue'],
['f', 'Cycle the queue filter'],
['a / A', 'Acknowledge / clear acknowledgement'],
['R', 'Resolve (asks first)'],
['s', 'Assign'],
['z / Z', 'Snooze / end snooze'],
['c', 'Add a note'],
['x', 'Archive / unarchive a resolved incident'],
['r', 'Refresh now'],
];
return h('table', { class: 'kbd-table' },
h('tbody', {}, rows.map(([k, v]) =>
h('tr', {},
h('td', {}, k.split(' / ').map((x, i) => [i ? ' / ' : '', h('kbd', { text: x })])),
h('td', { text: v })))));
}
+91
View File
@@ -0,0 +1,91 @@
// The alert feed: Alertmanager's own records, read-only. Each row leads to the
// incident it belongs to, which is where anything can be done about it.
import * as api from './api.js';
import { h, clear, badge, emptyState, spinner } from './ui.js';
import { age, severityClass, labelSummary } from './format.js';
const FILTERS = [
{ id: 'firing', label: 'Firing', query: { status: 'firing' } },
{ id: 'resolved', label: 'Resolved', query: { status: 'resolved' } },
{ id: 'all', label: 'All', query: {} },
{ id: 'archived', label: 'Archived', query: { archived: 'true' } },
];
const view = () => document.getElementById('view-alerts');
let filter = 'firing';
let items = null;
let error = null;
export function show() {
render();
refresh();
}
export async function refresh() {
const requested = filter;
const f = FILTERS.find((x) => x.id === filter);
try {
const result = await api.alerts({ ...f.query, limit: 200 });
if (requested !== filter) return;
items = result;
error = null;
} catch (err) {
if (requested !== filter) return;
error = err.message;
}
render();
}
function setFilter(id) {
if (id === filter) return;
filter = id;
items = null;
render();
refresh();
}
function render() {
const chips = h('div', { class: 'chips', role: 'tablist', 'aria-label': 'Filter' },
FILTERS.map((f) => h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(f.id === filter),
onclick: () => setFilter(f.id),
text: f.label,
})));
let body;
if (error && !items) body = h('div', { class: 'load-error', text: error });
else if (!items) body = spinner();
else if (!items.length) body = emptyState(filter === 'firing' ? 'Nothing firing' : 'No alerts', '', filter === 'firing' ? 'checkCircle' : null);
else {
body = h('div', { class: 'list' },
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
items.map(row));
}
clear(view(), h('div', {}, chips, body));
}
function row(a) {
const summary = (a.annotations && a.annotations.summary) || '';
const sev = a.labels && a.labels.severity;
const labels = labelSummary(Object.fromEntries(
Object.entries(a.labels || {}).filter(([k]) => k !== 'severity')));
const linked = a.incident_id != null;
return h(linked ? 'a' : 'div', {
class: `row st-${a.status} ${linked ? '' : 'no-link'}`,
href: linked ? `/incidents/${a.incident_id}` : null,
},
h('div', { class: 'row-title', text: a.name }),
h('div', { class: 'row-age', title: a.starts_at, text: age(a.status === 'firing' ? a.starts_at : a.received_at) }),
h('div', { class: 'row-meta' },
badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`),
sev && badge(sev, `plain ${severityClass(sev)}`),
summary && h('span', { text: summary }),
labels && h('span', { class: 'labels', text: labels }),
),
);
}
+98
View File
@@ -0,0 +1,98 @@
// The terdut-server client. The page is served by the server itself, so every
// call is same-origin and carries the session cookie.
export class ApiError extends Error {
constructor(status, message) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
// Called whenever the server says the session is gone, so the app can put the
// login form back up wherever the user happened to be.
let onUnauthorized = () => {};
export function setUnauthorizedHandler(fn) {
onUnauthorized = fn;
}
async function call(method, path, { query, body, signal } = {}) {
const url = new URL('/api' + path, location.origin);
for (const [k, v] of Object.entries(query || {})) {
if (v === '' || v == null) continue;
url.searchParams.set(k, v);
}
const headers = { Accept: 'application/json' };
if (body !== undefined) headers['Content-Type'] = 'application/json';
let resp;
try {
resp = await fetch(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
credentials: 'same-origin',
signal,
});
} catch (err) {
if (err.name === 'AbortError') throw err;
throw new ApiError(0, 'Cannot reach the server.');
}
if (resp.status === 204) return null;
let data = null;
try {
data = await resp.json();
} catch {
/* non-JSON body: keep null */
}
if (!resp.ok) {
if (resp.status === 401 && path !== '/login') onUnauthorized();
const message = (data && data.error) || `Server answered ${resp.status}.`;
throw new ApiError(resp.status, message);
}
return data;
}
// session
export const me = () => call('GET', '/me');
export const login = (username, password) => call('POST', '/login', { body: { username, password } });
export const logout = () => call('POST', '/logout');
export const setPassword = (userID, password, currentPassword) =>
call('PUT', `/users/${userID}/password`, { body: { password, current_password: currentPassword } });
// users
export const users = () => call('GET', '/users');
// incidents
export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts });
export const incident = (id) => call('GET', `/incidents/${id}`);
export const timeline = (id) => call('GET', `/incidents/${id}/timeline`);
export const acknowledge = (id) => call('POST', `/incidents/${id}/acknowledge`);
export const unacknowledge = (id) => call('DELETE', `/incidents/${id}/acknowledge`);
export const resolve = (id) => call('POST', `/incidents/${id}/resolve`);
export const assign = (id, userID) => call('POST', `/incidents/${id}/assign`, { body: { user_id: userID } });
export const snooze = (id, spec) => call('POST', `/incidents/${id}/snooze`, { body: spec });
export const unsnooze = (id) => call('DELETE', `/incidents/${id}/snooze`);
export const archive = (id) => call('POST', `/incidents/${id}/archive`);
export const unarchive = (id) => call('DELETE', `/incidents/${id}/archive`);
export const addNote = (id, content) => call('POST', `/incidents/${id}/notes`, { body: { content } });
export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`);
// alerts
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
// schedule
export const schedule = (from, to) => call('GET', '/schedule', { query: { from, to } });
export async function onCallNow() {
try {
return await call('GET', '/schedule/current');
} catch (err) {
if (err instanceof ApiError && err.status === 404) return null;
throw err;
}
}
+234
View File
@@ -0,0 +1,234 @@
// Entry point: session, routing, badges and keyboard.
import * as api from './api.js';
import * as ui from './ui.js';
import * as poll from './poll.js';
import { state, reset } from './state.js';
import * as queue from './queue.js';
import * as incident from './incident.js';
import * as oncall from './oncall.js';
import * as alerts from './alerts.js';
import * as account from './account.js';
const $ = (id) => document.getElementById(id);
// One route per section; /incidents/{id} is the queue with a detail open.
const SECTIONS = {
queue: { title: 'Queue', view: queue },
oncall: { title: 'On-call', view: oncall },
alerts: { title: 'Alerts', view: alerts },
more: { title: 'Account', view: account },
};
function parseRoute(pathname) {
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
if (m) return { section: 'queue', incident: Number(m[1]) };
const name = pathname.replace(/^\/|\/$/g, '');
if (name === 'oncall' || name === 'alerts' || name === 'more') return { section: name };
return { section: 'queue', incident: null };
}
let route = parseRoute(location.pathname);
// How many in-app navigations deep we are, so Back can use the browser's
// history when there is somewhere to go back to, and the queue otherwise.
let depth = 0;
let listScroll = 0;
export function navigate(path, { replace = false } = {}) {
if (path === location.pathname + location.search) return;
if (replace) {
history.replaceState({ depth }, '', path);
} else {
depth += 1;
history.pushState({ depth }, '', path);
}
render();
}
export function back() {
if (depth > 0) history.back();
else navigate('/', { replace: true });
}
window.addEventListener('popstate', (e) => {
depth = (e.state && e.state.depth) || 0;
render();
});
function render() {
const prev = route;
route = parseRoute(location.pathname);
const app = $('app');
for (const [name, s] of Object.entries(SECTIONS)) {
const el = $(`view-${name}`);
el.hidden = name !== route.section;
if (name === route.section) $('topbar-title').textContent = s.title;
}
for (const link of document.querySelectorAll('.nav-link')) {
if (link.dataset.section === route.section) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
}
const detailOpen = route.section === 'queue' && route.incident != null;
const wasOpen = prev.section === 'queue' && prev.incident != null;
if (detailOpen && !wasOpen) listScroll = window.scrollY;
app.classList.toggle('detail-open', detailOpen);
$('view-queue').classList.toggle('has-detail', detailOpen);
if (route.section === 'queue') {
queue.show(route.incident);
incident.show(route.incident);
} else {
incident.show(null);
SECTIONS[route.section].view.show();
}
if (detailOpen && !wasOpen) window.scrollTo(0, 0);
else if (!detailOpen && wasOpen) requestAnimationFrame(() => window.scrollTo(0, listScroll));
else if (prev.section !== route.section) window.scrollTo(0, 0);
updateTitle();
}
// ---------- refresh + badges ----------
async function refresh() {
state.open = await api.incidents({ sort: 'severity' });
updateBadges();
const jobs = [];
if (route.section === 'queue') {
jobs.push(queue.refresh());
if (route.incident != null) jobs.push(incident.refresh());
} else {
const v = SECTIONS[route.section].view;
if (v.refresh) jobs.push(v.refresh());
}
await Promise.allSettled(jobs);
}
function updateBadges() {
const open = state.open.length;
const triggered = state.open.filter((i) => i.status === 'triggered').length;
const pill = $('open-pill');
pill.hidden = false;
pill.textContent = open ? `${open} open` : 'All clear';
pill.classList.toggle('has-triggered', triggered > 0);
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
const badge = document.querySelector('[data-badge]');
badge.hidden = triggered === 0;
badge.textContent = String(triggered);
updateTitle();
}
function updateTitle() {
const triggered = state.open.filter((i) => i.status === 'triggered').length;
const section = SECTIONS[route.section].title;
const base = route.section === 'queue' && route.incident == null ? 'terdut' : `${section} · terdut`;
document.title = triggered ? `(${triggered}) ${base}` : base;
}
// ---------- session ----------
async function boot() {
ui.initSheet();
api.setUnauthorizedHandler(showLogin);
document.addEventListener('click', interceptLinks);
document.addEventListener('keydown', onKey);
$('login-form').addEventListener('submit', onLogin);
try {
state.me = await api.me();
showApp();
} catch (err) {
if (err.status === 401) showLogin();
else showBootError(err);
}
}
function showBootError(err) {
ui.clear($('boot'), ui.emptyState('Cannot load terdut', err.message));
$('boot').append(ui.h('button', { class: 'btn', onclick: () => location.reload(), text: 'Retry' }));
}
function showLogin() {
poll.stop();
ui.closeSheet(null);
reset();
$('boot').hidden = true;
$('app').hidden = true;
$('login').hidden = false;
const form = $('login-form');
form.querySelector('.form-error').hidden = true;
form.password.value = '';
(form.username.value ? form.password : form.username).focus();
}
async function onLogin(e) {
e.preventDefault();
const form = e.currentTarget;
const err = form.querySelector('.form-error');
const btn = form.querySelector('button[type=submit]');
err.hidden = true;
btn.disabled = true;
try {
state.me = await api.login(form.username.value.trim(), form.password.value);
form.password.value = '';
showApp();
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
btn.disabled = false;
}
}
export async function signOut() {
try {
await api.logout();
} catch {
/* the cookie is cleared server-side or already gone */
}
showLogin();
}
function showApp() {
$('boot').hidden = true;
$('login').hidden = true;
$('app').hidden = false;
render();
poll.start(refresh);
poll.now();
}
// ---------- links + keys ----------
function interceptLinks(e) {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const a = e.target.closest('a[href]');
if (!a || a.target || a.origin !== location.origin || a.pathname.startsWith('/api/')) return;
e.preventDefault();
navigate(a.pathname + a.search);
}
function onKey(e) {
if (e.metaKey || e.ctrlKey || e.altKey || ui.sheetIsOpen() || $('app').hidden) return;
const tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (e.key === 'r') {
poll.now();
e.preventDefault();
return;
}
if (route.section !== 'queue') return;
if (route.incident != null && incident.key(e)) {
e.preventDefault();
return;
}
if (queue.key(e)) e.preventDefault();
}
boot();
+110
View File
@@ -0,0 +1,110 @@
// Formatting of times, durations and labels.
const MIN = 60 * 1000;
const HOUR = 60 * MIN;
const DAY = 24 * HOUR;
// Compact age for list rows: "now", "4m", "3h", "2d".
export function age(iso, now = Date.now()) {
const ms = Math.max(0, now - Date.parse(iso));
if (ms < MIN) return 'now';
if (ms < HOUR) return `${Math.floor(ms / MIN)}m`;
if (ms < DAY) return `${Math.floor(ms / HOUR)}h`;
return `${Math.floor(ms / DAY)}d`;
}
// "4 min ago", "3 h ago", "yesterday"-free: stays unambiguous at 3am.
export function ago(iso, now = Date.now()) {
const a = age(iso, now);
return a === 'now' ? 'just now' : `${a} ago`;
}
// Time remaining until iso, e.g. "1h 20m".
export function until(iso, now = Date.now()) {
return duration(Date.parse(iso) - now);
}
export function duration(ms) {
ms = Math.max(0, ms);
if (ms < MIN) return '<1m';
const d = Math.floor(ms / DAY);
const h = Math.floor((ms % DAY) / HOUR);
const m = Math.floor((ms % HOUR) / MIN);
if (d) return h ? `${d}d ${h}h` : `${d}d`;
if (h) return m ? `${h}h ${m}m` : `${h}h`;
return `${m}m`;
}
const timeFmt = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' });
const dayTimeFmt = new Intl.DateTimeFormat(undefined, {
weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit',
});
// Local timestamp; the date is dropped when it is today.
export function when(iso) {
const d = new Date(iso);
const today = new Date();
if (d.toDateString() === today.toDateString()) return timeFmt.format(d);
return dayTimeFmt.format(d);
}
export function isFuture(iso) {
return iso != null && Date.parse(iso) > Date.now();
}
// Local calendar dates, as the schedule stores them (YYYY-MM-DD).
export function isoDate(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export function mondayOf(d) {
const r = new Date(d.getFullYear(), d.getMonth(), d.getDate());
r.setDate(r.getDate() - ((r.getDay() + 6) % 7));
return r;
}
export function addDays(d, n) {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r;
}
// ISO 8601 week number.
export function isoWeek(d) {
const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
const day = t.getUTCDay() || 7;
t.setUTCDate(t.getUTCDate() + 4 - day);
const yearStart = new Date(Date.UTC(t.getUTCFullYear(), 0, 1));
return Math.ceil(((t - yearStart) / DAY + 1) / 7);
}
export const STATUS_LABEL = {
triggered: 'Triggered',
acknowledged: 'Acknowledged',
resolved: 'Resolved',
snoozed: 'Snoozed',
firing: 'Firing',
};
export function severityClass(sev) {
const s = (sev || '').toLowerCase();
if (s === 'critical' || s === 'page' || s === 'error') return 'sev-critical';
if (s === 'warning' || s === 'warn') return 'sev-warning';
if (s) return 'sev-info';
return '';
}
// A one-line summary of the group labels, without the one the title already shows.
export function labelSummary(labels, skip = 'alertname') {
return Object.entries(labels || {})
.filter(([k]) => k !== skip)
.map(([k, v]) => `${k}=${v}`)
.join(' · ');
}
export function initial(name) {
return (name || '?').trim().charAt(0) || '?';
}
+447
View File
@@ -0,0 +1,447 @@
// Incident detail: facts, member alerts, the timeline with notes, and the
// action bar that carries everything a responder does to an incident.
import * as api from './api.js';
import * as poll from './poll.js';
import {
h, clear, icon, badge, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
} from './ui.js';
import {
ago, when, until, isFuture, severityClass, STATUS_LABEL,
} from './format.js';
import { myID, users } from './state.js';
import { back } from './app.js';
const pane = () => document.getElementById('detail');
let currentID = null;
let inc = null;
let events = [];
let error = null;
let busy = false;
export function show(id) {
if (id === currentID) return;
currentID = id;
inc = null;
events = [];
error = null;
if (id == null) {
renderPlaceholder();
return;
}
render();
refresh();
}
export async function refresh() {
const id = currentID;
if (id == null) return;
try {
const [i, t] = await Promise.all([api.incident(id), api.timeline(id)]);
if (id !== currentID) return;
inc = i;
events = t;
error = null;
} catch (err) {
if (id !== currentID) return;
error = err.status === 404 ? 'This incident does not exist.' : err.message;
}
render();
}
function renderPlaceholder() {
clear(pane(), h('div', { class: 'detail-placeholder' },
h('div', {}, icon('flag', 'icon'), h('p', { text: 'Select an incident to see its alerts and timeline.' }))));
}
function render() {
const head = h('div', { class: 'detail-head' },
h('button', { class: 'btn btn-ghost btn-icon back', type: 'button', 'aria-label': 'Back to queue', onclick: back },
icon('back')),
h('span', { class: 'crumb', text: currentID != null ? `Incident #${currentID}` : '' }),
);
if (!inc) {
clear(pane(), h('div', { class: 'detail' }, head,
error ? h('div', { class: 'load-error', text: error }) : spinner()));
return;
}
// Keep the scroll position across the periodic re-render.
const scroller = document.querySelector('.pane-detail');
const top = scroller ? scroller.scrollTop : 0;
clear(pane(),
h('article', { class: 'detail' },
head,
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
h('h1', { class: 'detail-title', text: inc.title }),
h('div', { class: 'detail-badges' }, statusBadges()),
facts(),
groupLabels(),
alertsSection(),
timelineSection(),
),
actionBar(),
);
if (scroller) scroller.scrollTop = top;
}
function statusBadges() {
const out = [];
if (inc.severity) out.push(badge(inc.severity, `plain ${severityClass(inc.severity)}`));
out.push(badge(STATUS_LABEL[inc.status] || inc.status, `st-${inc.status}`));
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
out.push(badge(`Snoozed · ${until(inc.snoozed_until)} left`, 'st-snoozed'));
}
if (inc.archived_at) out.push(badge('Archived', 'plain'));
return out;
}
function who(id, name) {
if (id != null && id === myID()) return 'you';
return name || 'someone';
}
function facts() {
const rows = [];
const add = (k, ...v) => rows.push(h('dt', { text: k }), h('dd', {}, ...v));
add('Triggered', when(inc.triggered_at), h('span', { class: 'sub', text: ` · ${ago(inc.triggered_at)}` }));
if (inc.acknowledged_at) {
add('Acknowledged', `${who(inc.acknowledged_by_id, inc.acknowledged_by)} · ${when(inc.acknowledged_at)}`);
}
add('Assigned', inc.assigned_to_id != null ? who(inc.assigned_to_id, inc.assigned_to) : 'Unassigned');
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
add('Snoozed until', when(inc.snoozed_until));
}
if (inc.resolved_at) {
const how = inc.resolution_source === 'manual' ? 'by hand' : 'alerts stopped firing';
add('Resolved', when(inc.resolved_at), h('span', { class: 'sub', text: ` · ${how}` }));
}
if (inc.archived_at) add('Archived', when(inc.archived_at));
return h('div', { class: 'card' }, h('dl', { class: 'facts' }, rows));
}
function groupLabels() {
const entries = Object.entries(inc.group_labels || {});
if (!entries.length) return null;
return h('section', { class: 'section' },
h('h2', { class: 'section-title', text: 'Grouped by' }),
h('div', { class: 'labels-wrap' }, entries.map(([k, v]) => labelChip(k, v))),
);
}
function alertsSection() {
const list = inc.alerts || [];
const firing = list.filter((a) => a.status === 'firing').length;
return h('section', { class: 'section' },
h('h2', { class: 'section-title' },
h('span', { text: `Alerts (${list.length})` }),
firing ? h('span', { text: `${firing} firing` }) : null),
list.length
? h('div', { class: 'card' }, list.map(alertItem))
: h('div', { class: 'card card-pad', text: 'No alerts attached.' }),
);
}
function alertItem(a) {
const summary = (a.annotations && (a.annotations.summary || a.annotations.description)) || '';
const labels = Object.entries(a.labels || {});
return h('div', { class: 'alert-item' },
h('div', { class: 'alert-item-head' },
h('span', { class: 'alert-item-name', text: a.name }),
badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`)),
summary && h('div', { class: 'alert-item-summary', text: summary }),
h('div', { class: 'alert-item-foot' },
h('span', { text: `Started ${ago(a.starts_at)}` }),
h('span', { text: `Last seen ${ago(a.received_at)}` }),
a.generator_url && h('a', { href: a.generator_url, target: '_blank', rel: 'noopener noreferrer' }, 'Source ↗'),
),
labels.length > 0 && h('details', {},
h('summary', { text: `${labels.length} labels` }),
h('div', { class: 'labels-wrap' }, labels.map(([k, v]) => labelChip(k, v)))),
);
}
// ---------- timeline ----------
function eventText(ev) {
const person = ev.user_id != null ? who(ev.user_id, ev.username) : null;
const strong = (t) => h('span', { class: 'who', text: t || 'someone' });
const alertName = () => {
const a = (inc.alerts || []).find((x) => x.id === ev.alert_id);
return a ? a.name : 'an alert';
};
switch (ev.type) {
case 'triggered': return ['Incident triggered'];
case 'alert_added': return [`Alert added: ${alertName()}`];
case 'alert_resolved': return [`Alert resolved: ${alertName()}`];
case 'acknowledged': return [strong(person), ' acknowledged'];
case 'unacknowledged': return [strong(person), ' cleared the acknowledgement'];
case 'assigned': return ['Assigned to ', strong(person)];
case 'snoozed': return [strong(person), ` snoozed until ${ev.detail ? when(ev.detail) : '…'}`];
case 'unsnoozed': return [strong(person), ' ended the snooze'];
case 'resolved': return person ? [strong(person), ' resolved the incident'] : ['Resolved: every alert stopped firing'];
case 'note': return [strong(person), ' added a note'];
case 'notified': {
const to = person ? strong(person) : 'the fallback topic';
if (ev.detail === 'reminder') return ['Reminder sent to ', to];
if (ev.detail === 'resolved') return ['Resolution sent to ', to];
return ['Paged ', to];
}
case 'notify_failed': return ['Notification failed', ev.detail ? `: ${ev.detail}` : ''];
case 'deadman_silent': return ['Heartbeat went silent', ev.detail ? ` (${ev.detail})` : ''];
default: return [ev.type, ev.detail ? `: ${ev.detail}` : ''];
}
}
function timelineSection() {
const sorted = [...events].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id - b.id);
return h('section', { class: 'section' },
h('h2', { class: 'section-title' },
h('span', { text: 'Timeline' }),
h('button', { class: 'btn btn-ghost btn-sm', type: 'button', onclick: addNote },
icon('note'), 'Add note')),
h('div', { class: 'card' },
sorted.length
? h('ol', { class: 'timeline' }, sorted.map(timelineItem))
: emptyState('No events yet', '')),
);
}
function timelineItem(ev) {
const mine = ev.type === 'note' && ev.user_id === myID();
return h('li', { class: `tl-item tl-${ev.type}` },
h('span', { class: 'tl-dot' }),
h('div', { class: 'tl-body' },
h('div', { class: 'tl-text' }, eventText(ev)),
h('div', { class: 'tl-time', title: ev.created_at, text: `${when(ev.created_at)} · ${ago(ev.created_at)}` }),
ev.type === 'note' && h('div', { class: 'note', text: ev.detail || '' }),
mine && h('div', { class: 'note-actions' },
h('button', { class: 'btn btn-ghost btn-sm', type: 'button', onclick: () => deleteNote(ev) }, icon('trash'), 'Delete')),
),
);
}
// ---------- actions ----------
const isOpen = () => inc.status !== 'resolved';
const isSnoozed = () => isOpen() && isFuture(inc.snoozed_until);
function actionBar() {
let primary;
let secondary;
if (inc.status === 'triggered') {
primary = h('button', { class: 'btn btn-primary', type: 'button', onclick: acknowledge }, icon('check'), 'Acknowledge');
} else if (inc.status === 'acknowledged') {
primary = h('button', { class: 'btn btn-primary', type: 'button', onclick: resolve }, icon('checkCircle'), 'Resolve');
} else {
primary = inc.archived_at
? h('button', { class: 'btn btn-primary', type: 'button', onclick: unarchive }, icon('undo'), 'Unarchive')
: h('button', { class: 'btn btn-primary', type: 'button', onclick: archive }, icon('archive'), 'Archive');
}
if (isOpen()) {
secondary = isSnoozed()
? h('button', { class: 'btn', type: 'button', onclick: unsnooze }, icon('bell'), 'Unsnooze')
: h('button', { class: 'btn', type: 'button', onclick: snooze }, icon('clock'), 'Snooze');
} else {
secondary = h('button', { class: 'btn', type: 'button', onclick: addNote }, icon('note'), 'Note');
}
const more = h('button', { class: 'btn btn-icon', type: 'button', 'aria-label': 'More actions', onclick: moreMenu }, icon('more'));
const bar = h('div', { class: 'actionbar' }, primary, secondary, more);
if (busy) for (const b of bar.querySelectorAll('button')) b.disabled = true;
return bar;
}
// run performs one action, then reloads the incident and the queue.
async function run(fn, done) {
if (busy) return;
busy = true;
render();
try {
await fn();
if (done) toast(done);
} catch (err) {
toast(err.message, 'error');
} finally {
busy = false;
await refresh();
poll.now();
}
}
function acknowledge() {
const id = inc.id;
return run(() => api.acknowledge(id), 'Acknowledged');
}
function unacknowledge() {
const id = inc.id;
return run(() => api.unacknowledge(id), 'Acknowledgement cleared');
}
async function resolve() {
const id = inc.id;
const ok = await confirm({
title: 'Resolve this incident?',
text: 'Resolving is final. If these alerts fire again they open a new incident, '
+ 'and if any are still firing this one stays closed regardless. '
+ 'Use snooze if you only need it out of the way.',
confirmLabel: 'Resolve',
danger: true,
});
if (ok) await run(() => api.resolve(id), 'Resolved');
}
function archive() {
const id = inc.id;
return run(() => api.archive(id), 'Archived');
}
function unarchive() {
const id = inc.id;
return run(() => api.unarchive(id), 'Unarchived');
}
function unsnooze() {
const id = inc.id;
return run(() => api.unsnooze(id), 'Snooze ended');
}
async function snooze() {
const id = inc.id;
const tomorrow9 = new Date();
tomorrow9.setDate(tomorrow9.getDate() + 1);
tomorrow9.setHours(9, 0, 0, 0);
const options = [
['30 minutes', { duration: '30m' }],
['1 hour', { duration: '1h' }],
['2 hours', { duration: '2h' }],
['4 hours', { duration: '4h' }],
['8 hours', { duration: '8h' }],
['Until 09:00 tomorrow', { until: tomorrow9.toISOString() }],
];
const spec = await openSheet(() => [
h('h2', { class: 'sheet-title', text: 'Snooze' }),
h('p', { class: 'sheet-text', text: 'Hide it from the queue for a while. It comes back on its own.' }),
h('ul', { class: 'menu' }, options.map(([label, value]) =>
h('li', {}, h('button', { class: 'menu-item', type: 'button', onclick: () => closeSheet(value) },
icon('clock'), label)))),
]);
if (spec) await run(() => api.snooze(id, spec), 'Snoozed');
}
async function assign() {
const id = inc.id;
let list;
let onCall;
try {
[list, onCall] = await Promise.all([users(), api.onCallNow()]);
} catch (err) {
toast(err.message, 'error');
return;
}
const me = myID();
const sorted = [...list].sort((a, b) => (b.id === me) - (a.id === me) || a.username.localeCompare(b.username));
const userID = await openSheet(() => [
h('h2', { class: 'sheet-title', text: 'Assign to' }),
h('ul', { class: 'menu', role: 'menu' }, sorted.map((u) =>
h('li', {}, h('button', {
class: 'menu-item',
type: 'button',
role: 'menuitemradio',
'aria-checked': String(u.id === inc.assigned_to_id),
onclick: () => closeSheet(u.id),
},
icon('user'),
u.id === me ? `${u.username} (you)` : u.username,
onCall && onCall.user_id === u.id ? h('span', { class: 'menu-sub', text: 'on call' }) : null,
)))),
]);
if (userID != null) await run(() => api.assign(id, userID), 'Assigned');
}
async function addNote() {
const id = inc.id;
const content = await openSheet(() => {
const textarea = h('textarea', {
name: 'content', required: true, autofocus: true, placeholder: 'What did you find? What did you do?', maxlength: '10000',
});
const form = h('form', {
class: 'sheet-form',
onsubmit: (e) => {
e.preventDefault();
const v = textarea.value.trim();
if (v) closeSheet(v);
},
},
h('h2', { class: 'sheet-title', text: 'Add note' }),
textarea,
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(null), text: 'Cancel' }),
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save note' })),
);
// Ctrl/Cmd+Enter saves, as in most note fields.
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) form.requestSubmit();
});
return form;
});
if (content) await run(() => api.addNote(id, content), 'Note added');
}
async function deleteNote(ev) {
const id = inc.id;
const ok = await confirm({ title: 'Delete this note?', text: ev.detail || '', confirmLabel: 'Delete', danger: true });
if (ok) await run(() => api.deleteNote(id, ev.id), 'Note deleted');
}
async function moreMenu() {
const item = (iconName, label, fn, cls = '') =>
h('li', {}, h('button', { class: `menu-item ${cls}`, type: 'button', onclick: () => closeSheet(fn) }, icon(iconName), label));
const items = [];
if (isOpen()) {
if (inc.status === 'triggered') items.push(item('check', 'Acknowledge', acknowledge));
else items.push(item('undo', 'Clear acknowledgement', unacknowledge));
items.push(item('user', 'Assign…', assign));
items.push(isSnoozed() ? item('bell', 'End snooze', unsnooze) : item('clock', 'Snooze…', snooze));
items.push(item('note', 'Add note…', addNote));
items.push(h('li', { class: 'menu-sep', role: 'separator' }));
items.push(item('checkCircle', 'Resolve…', resolve, 'danger'));
} else {
items.push(item('note', 'Add note…', addNote));
items.push(inc.archived_at ? item('undo', 'Unarchive', unarchive) : item('archive', 'Archive', archive));
}
const fn = await openSheet(() => [
h('h2', { class: 'sheet-title', text: inc.title }),
h('ul', { class: 'menu' }, items),
]);
if (fn) await fn();
}
// key handles the detail's shortcuts. Returns true when it used the key.
export function key(e) {
if (!inc) {
if (e.key === 'Escape') {
back();
return true;
}
return false;
}
switch (e.key) {
case 'Escape': back(); return true;
case 'a': if (inc.status === 'triggered') acknowledge(); return true;
case 'A': if (inc.status === 'acknowledged') unacknowledge(); return true;
case 'R': if (isOpen()) resolve(); return true;
case 's': if (isOpen()) assign(); return true;
case 'z': if (isOpen() && !isSnoozed()) snooze(); return true;
case 'Z': if (isSnoozed()) unsnooze(); return true;
case 'c': addNote(); return true;
case 'x': if (!isOpen()) (inc.archived_at ? unarchive() : archive()); return true;
default: return false;
}
}
+141
View File
@@ -0,0 +1,141 @@
// 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.
import * as api from './api.js';
import { h, clear, icon, spinner } from './ui.js';
import { isoDate, mondayOf, addDays, isoWeek, initial } from './format.js';
import { myID } from './state.js';
const view = () => document.getElementById('view-oncall');
let weekStart = mondayOf(new Date());
let data = null;
let error = null;
const dayName = new Intl.DateTimeFormat(undefined, { weekday: 'short' });
const dayDate = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' });
export function show() {
if (!data) clear(view(), spinner());
refresh();
}
export async function refresh() {
const start = weekStart;
const today = new Date();
try {
const [now, week, upcoming] = await Promise.all([
api.onCallNow(),
api.schedule(isoDate(start), isoDate(addDays(start, 6))),
api.schedule(isoDate(today), isoDate(addDays(today, 60))),
]);
if (start !== weekStart) return;
data = { now, week, upcoming };
error = null;
} catch (err) {
error = err.message;
}
render();
}
function shiftWeek(n) {
weekStart = addDays(weekStart, 7 * n);
refresh();
}
function render() {
if (!data) {
clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner());
return;
}
clear(view(),
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
nowCard(),
weekCard(),
myShifts(),
);
}
function you(userID) {
return userID === myID() ? h('span', { class: 'you', text: 'you' }) : null;
}
function nowCard() {
const n = data.now;
return h('div', { class: 'card now-card' },
h('div', { class: `avatar ${n ? '' : 'none'}`, text: n ? initial(n.username) : '–' }),
h('div', {},
h('div', { class: 'now-label', text: 'On call now' }),
h('div', { class: 'now-name' }, n ? n.username : 'Nobody', n && you(n.user_id)),
),
);
}
function weekCard() {
const byDate = new Map(data.week.map((e) => [e.date, e]));
const today = isoDate(new Date());
const days = [];
for (let i = 0; i < 7; i++) {
const d = addDays(weekStart, i);
const key = isoDate(d);
const e = byDate.get(key);
days.push(h('li', { class: `day ${key === today ? 'today' : ''} ${key < today ? 'past' : ''}` },
h('span', { class: 'day-name', text: dayName.format(d) }),
h('span', { class: 'day-date', text: dayDate.format(d) }),
h('span', { class: `day-who ${e ? '' : 'nobody'}` }, e ? e.username : 'nobody', e && you(e.user_id)),
));
}
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
return [
h('div', { class: 'page-head' },
h('h2', { text: thisWeek ? 'This week' : 'Week' }),
h('div', { class: 'week-nav' },
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) },
icon('chevronLeft')),
h('button', {
class: 'btn btn-ghost label',
type: 'button',
title: 'Back to this week',
onclick: () => { weekStart = mondayOf(new Date()); refresh(); },
text: `Week ${isoWeek(weekStart)}`,
}),
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) },
icon('chevronRight')),
),
),
h('ul', { class: 'card days' }, days),
];
}
// myShifts groups your upcoming dates into runs of consecutive days.
function myShifts() {
const mine = data.upcoming.filter((e) => e.user_id === myID()).map((e) => e.date).sort();
const runs = [];
for (const date of mine) {
const last = runs[runs.length - 1];
if (last && isoDate(addDays(parse(last.to), 1)) === date) last.to = date;
else runs.push({ from: date, to: date });
}
const fmt = (s) => `${dayName.format(parse(s))} ${dayDate.format(parse(s))}`;
return [
h('div', { class: 'page-head' }, h('h2', { text: 'Your next shifts' })),
h('div', { class: 'card' },
runs.length
? h('ul', { class: 'shift-list' }, runs.slice(0, 8).map((r) =>
h('li', {},
h('span', { text: r.from === r.to ? fmt(r.from) : `${fmt(r.from)} – ${fmt(r.to)}` }),
h('span', { class: 'muted', text: days(r) })),
))
: h('div', { class: 'empty', text: 'Nothing scheduled in the next 60 days.' })),
];
}
function parse(s) {
const [y, m, d] = s.split('-').map(Number);
return new Date(y, m - 1, d);
}
function days(r) {
const n = Math.round((parse(r.to) - parse(r.from)) / 86400000) + 1;
return n === 1 ? '1 day' : `${n} days`;
}
+56
View File
@@ -0,0 +1,56 @@
// Keeps the page fresh the way the TUI does: refresh on an interval, but only
// while the page is visible, and immediately when it becomes visible again —
// which is the moment a phone is picked up after a page.
const INTERVAL = 20 * 1000;
let refreshFn = null;
let timer = 0;
let running = false;
let inFlight = null;
export function start(fn) {
refreshFn = fn;
running = true;
schedule();
}
export function stop() {
running = false;
clearInterval(timer);
}
// now refreshes straight away and restarts the interval, after an action.
export function now() {
if (!running) return Promise.resolve();
schedule();
return tick();
}
function tick() {
if (!refreshFn) return Promise.resolve();
// Collapse overlapping refreshes into the one already under way.
if (!inFlight) {
inFlight = Promise.resolve()
.then(refreshFn)
.catch(() => {})
.finally(() => {
inFlight = null;
});
}
return inFlight;
}
function schedule() {
clearInterval(timer);
if (running && !document.hidden) timer = setInterval(tick, INTERVAL);
}
document.addEventListener('visibilitychange', () => {
if (!running) return;
if (!document.hidden) tick();
schedule();
});
window.addEventListener('online', () => {
if (running) tick();
});
+182
View File
@@ -0,0 +1,182 @@
// The incident queue: filter chips and a list of incident rows.
import * as api from './api.js';
import { h, clear, badge, emptyState, spinner } from './ui.js';
import { age, until, isFuture, severityClass, labelSummary } from './format.js';
import { state, myID } from './state.js';
import { navigate } from './app.js';
// The same filters as the TUI's `f` cycle, plus archived ones to get back to.
const FILTERS = [
{ id: 'open', label: 'Open', query: { sort: 'severity' } },
{ id: 'triggered', label: 'Triggered', query: { status: 'triggered', sort: 'severity' } },
{ id: 'acknowledged', label: 'Acked', query: { status: 'acknowledged', sort: 'severity' } },
{ id: 'snoozed', label: 'Snoozed', query: { snoozed: 'true' } },
{ id: 'resolved', label: 'Resolved', query: { status: 'resolved' } },
{ id: 'archived', label: 'Archived', query: { status: 'resolved', archived: 'true' } },
];
const EMPTY = {
open: ['All clear', 'Nothing open right now.'],
triggered: ['Nothing triggered', 'Every open incident has been acknowledged.'],
acknowledged: ['Nothing acknowledged', 'No one is working an incident right now.'],
snoozed: ['Nothing snoozed', 'Snoozed incidents show up here until the snooze runs out.'],
resolved: ['Nothing resolved', 'Resolved incidents are archived after a while.'],
archived: ['Nothing archived', ''],
};
let filter = loadFilter();
let items = null; // null while loading
let error = null;
let selected = null;
let cursor = -1; // keyboard position in the list
let built = false;
function loadFilter() {
try {
const f = sessionStorage.getItem('terdut.queue.filter');
if (FILTERS.some((x) => x.id === f)) return f;
} catch {
/* storage unavailable */
}
return 'open';
}
function saveFilter() {
try {
sessionStorage.setItem('terdut.queue.filter', filter);
} catch {
/* storage unavailable */
}
}
export function show(incidentID) {
selected = incidentID;
if (!built) {
renderChips();
built = true;
}
renderList();
}
export async function refresh({ fresh = false } = {}) {
const f = FILTERS.find((x) => x.id === filter);
const requested = filter;
try {
// The open list is already fetched for the badges; no need to ask twice.
const result = filter === 'open' && !fresh ? state.open : await api.incidents(f.query);
if (requested !== filter) return;
items = result;
error = null;
} catch (err) {
if (requested !== filter) return;
error = err.message;
}
renderList();
}
function setFilter(id) {
if (id === filter) return;
filter = id;
saveFilter();
items = null;
cursor = -1;
renderChips();
renderList();
refresh({ fresh: true });
}
function renderChips() {
const el = document.getElementById('queue-filters');
clear(el, FILTERS.map((f) =>
h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(f.id === filter),
onclick: () => setFilter(f.id),
text: f.label,
}),
));
}
function renderList() {
const el = document.getElementById('queue-list');
if (error && !items) {
clear(el, h('div', { class: 'load-error', text: error }));
return;
}
if (!items) {
clear(el, spinner());
return;
}
if (!items.length) {
const [title, text] = EMPTY[filter];
clear(el, emptyState(title, text, filter === 'open' ? 'checkCircle' : null));
return;
}
clear(el,
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
items.map((inc, i) => row(inc, i)),
);
}
function row(inc, index) {
const snoozed = isFuture(inc.snoozed_until);
const resolved = inc.status === 'resolved';
let status;
if (resolved) status = badge('Resolved', 'st-resolved');
else if (snoozed) status = badge(`Snoozed · ${until(inc.snoozed_until)}`, 'st-snoozed');
else if (inc.status === 'acknowledged') {
const by = inc.acknowledged_by_id === myID() ? 'you' : inc.acknowledged_by;
status = badge(`Acked${by ? ' · ' + by : ''}`, 'st-acknowledged');
}
else status = badge('Triggered', 'st-triggered');
let assignee = null;
if (inc.assigned_to_id != null) {
assignee = h('span', { text: inc.assigned_to_id === myID() ? '→ you' : `→ ${inc.assigned_to}` });
}
// The server already puts the group labels in the title; show only the rest.
const labels = labelSummary(Object.fromEntries(
Object.entries(inc.group_labels || {}).filter(([k, v]) => !inc.title.includes(`${k}=${v}`))));
return h('a', {
class: `row ${severityClass(inc.severity)} ${resolved ? 'resolved' : ''} ${index === cursor ? 'kbd-focus' : ''}`,
href: `/incidents/${inc.id}`,
'aria-current': inc.id === selected ? 'true' : null,
dataset: { index: String(index) },
},
h('div', { class: 'row-title', text: inc.title }),
h('div', { class: 'row-age', title: inc.triggered_at, text: age(inc.triggered_at) }),
h('div', { class: 'row-meta' },
status,
assignee,
labels && h('span', { class: 'labels', text: labels }),
),
);
}
// key handles j/k/enter on the list. Returns true when it used the key.
export function key(e) {
if (!items || !items.length) return false;
if (e.key === 'j' || e.key === 'ArrowDown') {
cursor = Math.min(items.length - 1, cursor + 1);
} else if (e.key === 'k' || e.key === 'ArrowUp') {
cursor = Math.max(0, cursor - 1);
} else if (e.key === 'Enter' && cursor >= 0) {
navigate(`/incidents/${items[cursor].id}`);
return true;
} else if (e.key === 'f') {
const i = FILTERS.findIndex((x) => x.id === filter);
setFilter(FILTERS[(i + 1) % FILTERS.length].id);
return true;
} else {
return false;
}
renderList();
const el = document.querySelector(`#queue-list [data-index="${cursor}"]`);
if (el) el.scrollIntoView({ block: 'nearest' });
return true;
}
+31
View File
@@ -0,0 +1,31 @@
// State shared between views: who is signed in, the user list, and the open
// queue that drives the badges.
import * as api from './api.js';
export const state = {
me: null, // { user, has_password }
open: [], // the default queue: open, not snoozed
};
export function myID() {
return state.me ? state.me.user.id : null;
}
// The user list changes rarely; it is fetched once and then at most every
// five minutes, for the assign sheet and for display names.
let usersCache = null;
let usersAt = 0;
export async function users() {
if (!usersCache || Date.now() - usersAt > 5 * 60 * 1000) {
usersCache = await api.users();
usersAt = Date.now();
}
return usersCache;
}
export function reset() {
state.me = null;
state.open = [];
usersCache = null;
}
+173
View File
@@ -0,0 +1,173 @@
// DOM helpers, the bottom sheet, confirmation and toasts.
// h builds an element. attrs: class, text, on<event>, dataset, aria/other
// attributes; boolean true sets an empty attribute, false/null skips it.
export function h(tag, attrs = {}, ...children) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (v == null || v === false) continue;
if (k === 'class') el.className = v;
else if (k === 'text') el.textContent = v;
else if (k === 'dataset') Object.assign(el.dataset, v);
else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);
else if (k in el && typeof v !== 'string') el[k] = v;
else el.setAttribute(k, v === true ? '' : v);
}
append(el, children);
return el;
}
function append(el, children) {
for (const c of children.flat(Infinity)) {
if (c == null || c === false) continue;
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
}
}
export function clear(el, ...children) {
el.replaceChildren();
append(el, children);
return el;
}
// Stroke icons, 24×24. Built as SVG nodes so the CSP needs no inline anything.
const ICONS = {
back: ['M15 18l-6-6 6-6'],
more: ['M5 12h.01M12 12h.01M19 12h.01'],
check: ['M5 12.5l4.5 4.5L19 7'],
checkCircle: ['M8 12.5l3 3 5-6', 'circle:12,12,9'],
undo: ['M9 14L4 9l5-5', 'M4 9h10a6 6 0 0 1 0 12h-3'],
user: ['circle:12,8,3.5', 'M5 20a7 7 0 0 1 14 0'],
clock: ['circle:12,12,9', 'M12 7v5l3 2'],
bell: ['M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z', 'M10 20.5a2 2 0 0 0 4 0'],
note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'],
archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'],
flag: ['M5 21V4', 'M5 4h11l-2 4 2 4H5'],
trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'],
chevronLeft: ['M15 18l-6-6 6-6'],
chevronRight: ['M9 6l6 6-6 6'],
external: ['M14 4h6v6', 'M20 4l-9 9', 'M18 14v6H4V6h6'],
logout: ['M15 4h4v16h-4', 'M10 17l5-5-5-5', 'M15 12H4'],
};
const SVG = 'http://www.w3.org/2000/svg';
export function icon(name, cls = 'icon') {
const svg = document.createElementNS(SVG, 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('class', cls);
for (const d of ICONS[name] || []) {
let node;
if (d.startsWith('circle:')) {
const [cx, cy, r] = d.slice(7).split(',');
node = document.createElementNS(SVG, 'circle');
node.setAttribute('cx', cx);
node.setAttribute('cy', cy);
node.setAttribute('r', r);
} else {
node = document.createElementNS(SVG, 'path');
node.setAttribute('d', d);
}
svg.append(node);
}
return svg;
}
// ---------- sheet ----------
const sheet = () => document.getElementById('sheet');
let sheetResolve = null;
// openSheet shows content in the bottom sheet (a centred dialog on desktop)
// and resolves with whatever closeSheet is given, or null when dismissed.
export function openSheet(build) {
const dlg = sheet();
if (dlg.open) closeSheet(null);
const inner = h('div', { class: 'sheet-inner' }, h('div', { class: 'sheet-grab' }));
append(inner, [build()]);
clear(dlg, inner);
dlg.showModal();
const first = dlg.querySelector('[autofocus]');
if (first) first.focus();
return new Promise((resolve) => {
sheetResolve = resolve;
});
}
export function closeSheet(value = null) {
const dlg = sheet();
const resolve = sheetResolve;
sheetResolve = null;
if (dlg.open) dlg.close();
if (resolve) resolve(value);
}
export function sheetIsOpen() {
return sheet().open;
}
export function initSheet() {
const dlg = sheet();
// A tap on the backdrop lands on the dialog element itself.
dlg.addEventListener('click', (e) => {
if (e.target === dlg) closeSheet(null);
});
dlg.addEventListener('cancel', (e) => {
e.preventDefault();
closeSheet(null);
});
}
// confirm asks a yes/no question in the sheet.
export function confirm({ title, text, confirmLabel = 'Confirm', danger = false }) {
return openSheet(() => [
h('h2', { class: 'sheet-title', text: title }),
text && h('p', { class: 'sheet-text', text }),
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(false), text: 'Cancel' }),
h('button', {
class: `btn ${danger ? 'btn-danger' : 'btn-primary'}`,
type: 'button',
autofocus: true,
onclick: () => closeSheet(true),
text: confirmLabel,
}),
),
]).then((v) => v === true);
}
// ---------- toast ----------
let toastTimer = 0;
export function toast(message, kind = '') {
const el = document.getElementById('toast');
el.textContent = message;
el.className = `toast ${kind}`;
el.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
el.hidden = true;
}, kind === 'error' ? 5000 : 2500);
}
// ---------- misc ----------
export function badge(text, cls = '') {
return h('span', { class: `badge ${cls}`, text });
}
export function labelChip(k, v) {
return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: v }));
}
export function emptyState(title, text, iconName) {
return h('div', { class: 'empty' },
iconName && icon(iconName),
h('strong', { text: title }),
text && h('span', { text }),
);
}
export function spinner() {
return h('div', { class: 'empty' }, h('span', { class: 'spinner' }));
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "terdut",
"short_name": "terdut",
"description": "Incident queue and on-call for terdut-server",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0f1115",
"theme_color": "#1b1e25",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+135
View File
@@ -0,0 +1,135 @@
// Package web serves the web UI, compiled into the binary.
//
// There is no build step: the files under static/ are what the browser gets.
// The page talks to the server's own /api over the same origin, signed in with
// the session cookie from POST /api/login.
package web
import (
"bytes"
"crypto/sha256"
"embed"
"encoding/base64"
"errors"
"io/fs"
"net/http"
"path"
"strings"
"time"
)
//go:embed static
var files embed.FS
// asset is one embedded file, with its validator computed once at startup.
type asset struct {
body []byte
etag string
ctype string
cache string
}
// Handler serves the embedded site. A path without a file extension that
// matches no file gets index.html, so a deep link such as /incidents/42 — the
// target of a notification tap — survives a reload; the page reads the path
// and renders the right view. A missing file with an extension is a real 404.
func Handler() (http.Handler, error) {
root, err := fs.Sub(files, "static")
if err != nil {
return nil, err
}
assets := make(map[string]*asset)
err = fs.WalkDir(root, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
b, err := fs.ReadFile(root, p)
if err != nil {
return err
}
sum := sha256.Sum256(b)
assets["/"+p] = &asset{
body: b,
etag: `"` + base64.RawURLEncoding.EncodeToString(sum[:16]) + `"`,
ctype: contentType(p),
cache: cacheControl(p),
}
return nil
})
if err != nil {
return nil, err
}
index, ok := assets["/index.html"]
if !ok {
return nil, errors.New("web: static/index.html is missing")
}
// embed.FS reports a zero ModTime, so http.FileServerFS would emit no
// validator and every asset would be refetched in full on every load.
// Hence the ETag above and ServeContent below, with a zero time that
// suppresses Last-Modified.
var noTime time.Time
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
p := path.Clean(r.URL.Path)
f, ok := assets[p]
if !ok {
if path.Ext(p) != "" {
http.NotFound(w, r)
return
}
f = index
}
w.Header().Set("Content-Type", f.ctype)
w.Header().Set("Cache-Control", f.cache)
w.Header().Set("ETag", f.etag)
// The page loads nothing from anywhere else, so the policy can say so
// outright rather than carve out exceptions.
w.Header().Set("Content-Security-Policy",
"default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; "+
"connect-src 'self'; manifest-src 'self'; form-action 'self'; "+
"frame-ancestors 'none'; base-uri 'none'")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "same-origin")
http.ServeContent(w, r, "", noTime, bytes.NewReader(f.body))
}), nil
}
func contentType(p string) string {
switch path.Ext(p) {
case ".html":
return "text/html; charset=utf-8"
case ".css":
return "text/css; charset=utf-8"
case ".js":
return "text/javascript; charset=utf-8"
case ".svg":
return "image/svg+xml"
case ".png":
return "image/png"
case ".webmanifest":
return "application/manifest+json"
default:
return "application/octet-stream"
}
}
// cacheControl keeps index.html revalidating on every load, because it names
// the current asset paths. Assets carry an ETag, so a five-minute window costs
// one conditional request after a deploy rather than a stale page.
func cacheControl(p string) string {
if strings.HasSuffix(p, ".html") {
return "no-cache"
}
return "public, max-age=300"
}