Files
Sandbar/internal/sandbar/web/index.html
T
VeillaxandClaude Sonnet 5.5 b4fd31a182
Release / release (push) Successful in 3m49s
CI / test (push) Successful in 4m45s
Add website analytics
A Tianji-style Websites section: a small tracking script, a public collection endpoint, and a
page per website with visitors, page views, visits, bounce rate, a chart and top lists for pages,
referrers, browsers, systems, devices, countries, languages and events. No cookies and no stored
IP address or user agent. SANDBAR_TRUST_PROXY makes the client address come from the proxy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 04:22:55 -06:00

897 lines
78 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Sandbar</title>
<style>
/* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
:root {
--accent:#1f5c5c; --accent-contrast:#ffffff;
--bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
--text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
--accent-text:var(--accent);
--accent-soft:color-mix(in srgb, var(--accent) 9%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 88%, #000000);
--focus:color-mix(in srgb, var(--accent) 70%, #ffffff);
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --neutral-bg:#eceae6;
--overlay:rgb(20 19 17 / 0.55); --shadow:0 8px 24px rgb(20 19 17 / 0.14);
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
--radius:6px; --radius-lg:10px; --border-w:1px;
--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--text-xs:12px; --text-sm:13px; --text-base:15px; --text-lg:18px; --text-xl:22px; --text-2xl:28px; --leading:1.5;
--sidebar-w:232px; --content-max:1120px; --touch:44px; --control-h:38px;
--z-sticky:20; --z-overlay:100; --z-toast:200; --ease:140ms ease;
color-scheme:light;
}
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
} }
:root[data-theme="dark"] {
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
}
/* tokens:end */
*, *::before, *::after { box-sizing:border-box; }
html, body { height:100%; }
html { -webkit-text-size-adjust:100%; }
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/var(--leading) var(--font); -webkit-font-smoothing:antialiased; }
h1, h2, h3, h4 { margin:0; line-height:1.25; font-weight:600; letter-spacing:-.01em; }
h1 { font-size:var(--text-2xl); } h2 { font-size:var(--text-xl); } h3 { font-size:var(--text-lg); }
p { margin:0 0 var(--s3); }
a { color:var(--accent-text); text-underline-offset:2px; }
a:hover { text-decoration-thickness:2px; }
:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); }
.sr-only { position:absolute; width:1px; height:1px; margin:-1px; padding:0; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0; }
.skip { position:absolute; left:var(--s3); top:-100px; z-index:var(--z-toast); background:var(--surface); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); }
.skip:focus { top:var(--s3); }
.dim { color:var(--text-muted); } .err { color:var(--danger); } .small { font-size:var(--text-sm); }
.num { font-variant-numeric:tabular-nums; }
/* long machine messages (certificate errors) wrap and stop at three lines; the full text is in the tooltip */
.clip { display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; overflow-wrap:anywhere; max-width:340px; }
code, .mono { font-family:var(--font-mono); font-size:var(--text-sm); }
.icon { width:18px; height:18px; flex:none; stroke-width:1.75; }
/* shell */
.shell { display:grid; grid-template-columns:var(--sidebar-w) minmax(0,1fr); min-height:100vh; }
.sidebar { position:sticky; top:0; height:100vh; display:flex; flex-direction:column; gap:var(--s3); padding:var(--s5) var(--s3); background:var(--surface); border-right:var(--border-w) solid var(--border); overflow-y:auto; }
.sidebar-head { display:flex; align-items:center; justify-content:space-between; gap:var(--s2); }
.sidebar button.sidebar-close { display:none; }
.brand { display:flex; align-items:center; gap:var(--s2); font-size:var(--text-lg); font-weight:700; letter-spacing:-.02em; padding:0 var(--s3); color:var(--text); text-decoration:none; }
.brand .icon { color:var(--accent-text); }
.orgsel select { width:100%; }
.navsec { font-size:var(--text-xs); font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-muted); padding:var(--s4) var(--s3) var(--s1); }
.nav { display:flex; flex-direction:column; gap:2px; }
.navitem { display:flex; align-items:center; gap:var(--s3); width:100%; min-height:36px; padding:var(--s2) var(--s3); border-radius:var(--radius); color:var(--text-muted); text-decoration:none; font:inherit; font-weight:500; background:none; border:0; cursor:pointer; text-align:left; }
.navitem:hover { background:var(--surface-2); color:var(--text); }
.navitem.on { background:var(--accent-soft); color:var(--accent-text); }
.grow { flex:1 1 auto; min-width:0; }
.userbox { border-top:var(--border-w) solid var(--border); padding-top:var(--s3); }
.userbox .who { padding:var(--s1) var(--s3) var(--s2); font-size:var(--text-sm); color:var(--text-muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.content { min-width:0; }
main { max-width:var(--content-max); margin:0 auto; padding:var(--s6) var(--s5) var(--s8); }
.topbar, .backdrop { display:none; }
.page-h { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--s4); margin-bottom:var(--s5); flex-wrap:wrap; }
.page-h p { margin:var(--s1) 0 0; color:var(--text-muted); max-width:70ch; }
.page-h .btn { white-space:nowrap; }
/* cards, layout */
.card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); margin-bottom:var(--s4); overflow-x:auto; }
.card.flush { padding:0; overflow:hidden; }
.card > h2 { font-size:var(--text-lg); margin:0 0 var(--s3); }
.row-h { display:flex; align-items:center; justify-content:flex-end; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
.row-h h2 { display:none; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:var(--s4); align-items:start; }
.stack { display:flex; flex-direction:column; gap:var(--s4); }
/* a button or checkbox in a row of fields lines up with the inputs, below their labels */
.grid > button { justify-self:start; margin-top:calc(var(--text-sm) * var(--leading) + var(--s1)); }
.grid > label.chk { margin-top:calc(var(--text-sm) * var(--leading) + var(--s1) + var(--s2)); }
.sect-h { padding:var(--s3) var(--s4); font-weight:600; border-bottom:var(--border-w) solid var(--border); }
.scroll { overflow-x:auto; }
.actions { display:flex; gap:var(--s2); flex-wrap:wrap; }
.form-actions { justify-content:flex-end; margin-top:var(--s2); }
.toolbar { display:flex; align-items:center; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
.toolbar input[type=search] { max-width:320px; }
.center { max-width:440px; margin:var(--s8) auto; padding:0 var(--s4); }
.center .brand { justify-content:center; font-size:var(--text-xl); padding-bottom:var(--s4); }
/* choices (add resource) */
.choices { display:grid; grid-template-columns:repeat(auto-fill,minmax(210px,1fr)); gap:var(--s3); }
.choice { text-align:left; background:var(--surface); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius-lg); padding:var(--s3) var(--s4); color:var(--text); cursor:pointer; font:inherit; display:flex; flex-direction:column; gap:var(--s1); }
.choice:hover { background:var(--surface-2); }
.choice.on { border-color:var(--accent); background:var(--accent-soft); }
.choice-t { font-weight:600; }
/* update progress and log */
.progress { height:6px; background:var(--surface-2); border-radius:999px; overflow:hidden; }
.progress > div { height:100%; background:var(--accent); transition:width var(--ease); }
.progress > div.bad { background:var(--danger); }
.logbox { margin:0; background:var(--bg); border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s3); font:var(--text-xs)/1.6 var(--font-mono); max-height:260px; overflow:auto; white-space:pre-wrap; word-break:break-word; }
/* forms */
label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; color:var(--text); }
label.chk { flex-direction:row; align-items:flex-start; gap:var(--s2); font-size:var(--text-base); font-weight:400; }
input, select, textarea { background:var(--surface); color:var(--text); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; font-weight:400; min-width:0; width:100%; }
textarea { min-height:96px; resize:vertical; }
input[type=checkbox] { width:16px; height:16px; min-height:0; padding:0; margin-top:3px; accent-color:var(--accent); }
input:focus, select:focus, textarea:focus { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); }
input:disabled, select:disabled { opacity:.6; }
input[aria-invalid="true"] { border-color:var(--danger); }
.sub { color:var(--text-muted); font-size:var(--text-sm); font-weight:400; }
/* buttons: one height and shape. btn is the primary action, ghost the default, danger and quiet the others */
button.btn, button.ghost, .btn-link { display:inline-flex; align-items:center; justify-content:center; gap:var(--s2); min-height:var(--control-h); padding:0 var(--s4); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; white-space:nowrap; text-decoration:none; transition:background var(--ease), border-color var(--ease); }
button.btn { background:var(--accent); color:var(--accent-contrast); border:var(--border-w) solid var(--accent); }
button.btn:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
button.ghost { background:var(--surface); border:var(--border-w) solid var(--border-strong); color:var(--text); }
button.ghost:hover { background:var(--surface-2); }
button.quiet { background:transparent; border-color:transparent; }
button.danger { color:var(--danger); border-color:var(--danger); }
button.danger:hover { background:var(--danger-bg); }
button.btn-icon { width:var(--control-h); padding:0; }
td button.ghost, .actions button.ghost, .tgt button.ghost { min-height:30px; padding:0 var(--s3); font-size:var(--text-sm); }
button:disabled { opacity:.55; cursor:not-allowed; }
/* tables */
table { width:100%; border-collapse:collapse; }
th { text-align:left; font-size:var(--text-sm); color:var(--text-muted); font-weight:600; padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); white-space:nowrap; background:var(--surface); }
td { padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); vertical-align:middle; }
tbody tr:hover td { background:var(--surface-2); }
tr:last-child td { border-bottom:0; }
/* badges and tags: status always carries a text label, never color alone */
.badge { display:inline-flex; align-items:center; gap:var(--s1); padding:2px var(--s2); border-radius:999px; font-size:var(--text-xs); font-weight:600; background:var(--neutral-bg); color:var(--text-muted); white-space:nowrap; }
.badge-success { background:var(--success-bg); color:var(--success); }
.badge-warning { background:var(--warning-bg); color:var(--warning); }
.badge-danger { background:var(--danger-bg); color:var(--danger); }
.badge-accent { background:var(--accent-soft); color:var(--accent-text); }
.tag { display:inline-block; padding:1px var(--s2); border:var(--border-w) solid var(--border); border-radius:var(--radius); font-size:var(--text-xs); color:var(--text-muted); }
pre { background:var(--surface-2); border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s3); overflow:auto; margin:var(--s2) 0 0; user-select:all; white-space:pre-wrap; word-break:break-all; }
/* empty state, notice, loading */
.empty { text-align:center; padding:var(--s7) var(--s5); color:var(--text-muted); border:var(--border-w) dashed var(--border-strong); border-radius:var(--radius-lg); margin:var(--s3); }
.empty h3 { color:var(--text); margin-bottom:var(--s1); }
.empty p { margin:0 auto var(--s3); max-width:56ch; }
.empty .icon { width:28px; height:28px; margin:0 auto var(--s3); color:var(--text-faint); display:block; }
.card > .empty { margin:0; }
.notice { padding:var(--s3) var(--s4); border-radius:var(--radius); border:var(--border-w) solid var(--border); background:var(--surface-2); font-size:var(--text-sm); margin-bottom:var(--s4); }
.notice-danger { background:var(--danger-bg); border-color:var(--danger); color:var(--danger); }
.notice-success { background:var(--success-bg); border-color:var(--success); color:var(--success); }
.skeleton { background:linear-gradient(90deg, var(--surface-2), var(--border), var(--surface-2)); background-size:200% 100%; animation:shimmer 1.3s infinite; border-radius:var(--radius); }
@keyframes shimmer { to { background-position:-200% 0; } }
/* dialogs, tabs, toasts */
.shade { position:fixed; inset:0; background:var(--overlay); display:grid; place-items:start center; padding:var(--s7) var(--s4); overflow:auto; z-index:var(--z-overlay); }
.modal { background:var(--surface); border-radius:var(--radius-lg); box-shadow:var(--shadow); padding:var(--s5); width:min(480px,100%); }
.modal.wide { width:min(780px,100%); }
.modal h2 { margin:0 0 var(--s3); font-size:var(--text-lg); }
fieldset { border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s4); margin:0; min-width:0; }
legend { font-size:var(--text-sm); font-weight:600; color:var(--text-muted); padding:0 var(--s2); }
.tgt { display:grid; grid-template-columns:1.2fr 1.4fr .7fr auto auto; gap:var(--s2); align-items:end; }
.pillrow { display:flex; flex-wrap:wrap; gap:var(--s2) var(--s4); }
.tabs2 { display:inline-flex; margin-bottom:var(--s4); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); overflow:hidden; max-width:100%; flex-wrap:wrap; background:var(--surface); }
.tabs2 button { background:var(--surface); border:0; color:var(--text-muted); padding:0 var(--s3); min-height:var(--control-h); cursor:pointer; font:inherit; font-weight:500; }
.tabs2 button + button { border-left:var(--border-w) solid var(--border-strong); }
.tabs2 button:hover { background:var(--surface-2); }
.tabs2 button.on { background:var(--accent-soft); color:var(--accent-text); }
.toasts { position:fixed; right:var(--s4); bottom:var(--s4); z-index:var(--z-toast); display:flex; flex-direction:column; gap:var(--s2); max-width:min(360px, calc(100vw - 32px)); }
.toast { background:var(--text); color:var(--bg); padding:var(--s3) var(--s4); border-radius:var(--radius); box-shadow:var(--shadow); font-size:var(--text-sm); }
.toast-error { background:var(--danger); color:var(--accent-contrast); }
@media (prefers-reduced-motion: reduce) { .skeleton { animation:none; } * { transition:none !important; } html { scroll-behavior:auto; } }
@media (pointer: coarse) {
button.btn, button.ghost, .navitem, input, select { min-height:var(--touch); }
td button.ghost, .actions button.ghost, .tgt button.ghost { min-height:36px; }
input, select, textarea { font-size:16px; }
}
@media (max-width:860px) {
.shell { grid-template-columns:minmax(0,1fr); grid-template-rows:auto minmax(0,1fr); }
.sidebar { display:none; }
.sidebar.open { display:flex; position:fixed; inset:0 auto 0 0; width:min(280px,85vw); height:100dvh; z-index:var(--z-overlay); box-shadow:var(--shadow); }
.sidebar button.sidebar-close { display:inline-flex; }
.topbar { display:flex; align-items:center; justify-content:space-between; min-height:52px; padding:var(--s2) var(--s4); background:var(--surface); border-bottom:var(--border-w) solid var(--border); position:sticky; top:0; z-index:var(--z-sticky); }
.backdrop.open { display:block; position:fixed; inset:0; background:var(--overlay); z-index:calc(var(--z-overlay) - 1); }
main { padding:var(--s5) var(--s4) var(--s7); }
.tgt { grid-template-columns:1fr 1fr; }
}
@media (max-width:640px) {
.modal { padding:var(--s4); }
.shade { padding:var(--s4) var(--s3); }
.toasts { left:var(--s3); right:var(--s3); bottom:var(--s3); max-width:none; }
.page-h > .btn { width:100%; }
}
/* Sandbar additions: built only from the tokens above */
.listrow { display:flex; gap:var(--s2); align-items:flex-end; padding-bottom:var(--s3); border-bottom:var(--border-w) solid var(--border); }
.listrow > :first-child { flex:1 1 auto; min-width:0; }
.timeline { list-style:none; margin:0; padding:0; }
.timeline li { display:flex; gap:var(--s3); padding:var(--s2) 0; border-bottom:var(--border-w) solid var(--border); font-size:var(--text-sm); }
.timeline li:last-child { border-bottom:0; }
.timeline .when { color:var(--text-muted); flex:none; min-width:150px; }
.steps { margin:0; padding-left:var(--s5); }
.feed { display:flex; flex-direction:column; gap:var(--s2); max-height:360px; overflow:auto; }
.feed .empty { margin:0; }
.msg { border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s2) var(--s3); display:flex; flex-direction:column; gap:var(--s1); }
.msg .tag { margin-right:var(--s1); }
/* website analytics */
.stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(104px,1fr)); gap:var(--s3); margin-bottom:var(--s4); }
.stat { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s3) var(--s4); }
.stat .n { font-size:var(--text-xl); font-weight:600; font-variant-numeric:tabular-nums; }
.stat .l { font-size:var(--text-sm); color:var(--text-muted); }
.chart { display:flex; align-items:flex-end; gap:2px; height:180px; margin-top:var(--s3); }
.chart .col { position:relative; flex:1; min-width:0; height:100%; display:flex; align-items:flex-end; }
.chart .col:hover { background:var(--surface-2); }
.chart .v, .chart .u { position:absolute; left:0; right:0; bottom:0; border-radius:2px 2px 0 0; }
.chart .v { background:color-mix(in srgb, var(--accent) 35%, var(--surface)); }
.chart .u { background:var(--accent); }
.axis2 { display:flex; justify-content:space-between; margin-top:var(--s1); font-size:var(--text-xs); color:var(--text-faint); }
.legend { display:flex; gap:var(--s4); font-size:var(--text-sm); color:var(--text-muted); flex-wrap:wrap; }
.legend i { display:inline-block; width:10px; height:10px; border-radius:2px; margin-right:var(--s1); vertical-align:baseline; }
.cols { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:var(--s4); margin-top:var(--s4); }
.cols .card { margin:0; }
.top { width:100%; border-collapse:collapse; font-size:var(--text-sm); }
.top td { padding:var(--s1) var(--s2); }
.top td:first-child { background:linear-gradient(to right, var(--accent-soft) var(--w), transparent var(--w)); overflow-wrap:anywhere; }
.top td.num { text-align:right; white-space:nowrap; width:1%; }
.top th { text-align:right; font-weight:500; color:var(--text-muted); padding:var(--s1) var(--s2); }
.top th:first-child { text-align:left; }
</style>
</head>
<body>
<div id="app"></div>
<script>
// ---------- tiny toolkit ----------
function h(tag, props, ...kids) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(props || {})) {
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
else if (k === 'class') el.className = v;
else if (v === true) el.setAttribute(k, '');
else if (v !== false && v != null) el.setAttribute(k, v);
}
for (const k of kids.flat(Infinity)) if (k != null && k !== false) el.append(k.nodeType ? k : document.createTextNode(String(k)));
return el;
}
const $app = document.getElementById('app');
// theme: follows the system unless the person picked one
try { const t = localStorage.getItem('theme'); if (t) document.documentElement.dataset.theme = t; } catch (e) {}
function toggleTheme() {
const dark = document.documentElement.dataset.theme ? document.documentElement.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
const next = dark ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
try { localStorage.setItem('theme', next); } catch (e) {}
}
// short confirmations at the bottom right; errors stay longer and are announced as alerts
let toastBox;
function toast(msg, bad) {
if (!toastBox) { toastBox = h('div', {class:'toasts', 'aria-live':'polite'}); document.body.append(toastBox); }
const t = h('div', {class:'toast' + (bad ? ' toast-error' : ''), role:bad ? 'alert' : 'status'}, msg);
toastBox.append(t);
setTimeout(() => t.remove(), bad ? 7000 : 3000);
}
const SIGNED_OUT = 'Your session ended. Sign in again to continue.';
const V1 = '/api/v1';
async function api(method, url, body) {
const r = await fetch(url, { method, headers: {'Content-Type':'application/json'}, body: body !== undefined ? JSON.stringify(body) : undefined });
const j = await r.json().catch(() => ({}));
if (r.status === 401 && S.me) { S.me = null; boot(); throw new Error(SIGNED_OUT); }
if (!r.ok) throw new Error(j.error || r.statusText);
return j;
}
// run an action; show errors as a toast instead of throwing into the void
const act = fn => async (...a) => { try { await fn(...a); } catch (e) { toast(e.message, true); } };
// dates with the month written out, as the design language asks ("Oct 21, 2026")
const fmtDate = t => new Date(t * 1000).toLocaleDateString(undefined, {month:'short', day:'numeric', year:'numeric'});
const fmtTime = t => new Date(t * 1000).toLocaleString(undefined, {month:'short', day:'numeric', year:'numeric', hour:'numeric', minute:'2-digit'});
const ago = t => { if (!t) return 'never'; const s = Date.now() / 1000 - t; return s < 90 ? 'just now' : s < 5400 ? Math.round(s / 60) + ' min ago' : s < 129600 ? Math.round(s / 3600) + ' h ago' : Math.round(s / 86400) + ' d ago'; };
const toUnix = v => v ? Math.floor(new Date(v).getTime() / 1000) : 0;
const fromUnix = t => { if (!t) return ''; const d = new Date(t * 1000); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0, 16); };
const field = (label, el, hint) => h('label', {}, label, el, hint ? h('span', {class:'sub'}, hint) : null);
const chk = (label, checked, onchange) => { const i = h('input', {type:'checkbox', checked: !!checked}); if (onchange) i.onchange = () => onchange(i.checked); return Object.assign(h('label', {class:'chk'}, i, label), {box:i}); };
const select = (options, value, props) => h('select', props || {}, options.map(([v, t]) => h('option', {value:v, selected:v === value}, t)));
const card = (title, ...kids) => h('div', {class:'card'}, title ? h('h2', {}, title) : null, ...kids);
function dialogForm(title, intro, fields, label, submit) {
const close = modal(title, h('div', {class:'stack'}, intro ? h('p', {class:'dim'}, intro) : null, ...fields,
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick: act(async () => { await submit(); close(); })}, label))), {sticky:true});
}
const cmdBox = t => h('pre', {class:'mono'}, t);
// A dialog: focus moves in, Tab stays inside, Escape closes, and focus goes back to what opened it.
let dialogSeq = 0;
function modal(title, body, opts = {}) {
const opener = document.activeElement, id = 'dialog-title-' + (++dialogSeq);
const shade = h('div', {class:'shade'});
const box = h('div', {class:'modal' + (opts.wide ? ' wide' : ''), role:'dialog', 'aria-modal':'true', 'aria-labelledby':id, tabindex:'-1'}, h('h2', {id}, title), body);
let closed = false;
const close = () => {
if (closed) return; closed = true;
document.removeEventListener('keydown', onKey, true); shade.remove();
if (opener && opener.isConnected && opener.focus) opener.focus();
if (opts.onClose) opts.onClose();
};
const focusables = () => [...box.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')].filter(e => e.offsetParent !== null);
function onKey(e) {
const top = [...document.querySelectorAll('.shade')].pop();
if (top !== shade) return;
if (e.key === 'Escape') { e.stopPropagation(); close(); }
else if (e.key === 'Tab') {
const f = focusables(); if (!f.length) { e.preventDefault(); return; }
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && (document.activeElement === first || document.activeElement === box)) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}
shade.addEventListener('mousedown', e => { if (e.target === shade && !opts.sticky) close(); });
shade.append(box);
document.body.append(shade);
document.addEventListener('keydown', onKey, true);
(box.querySelector('input,select,textarea') || focusables()[0] || box).focus();
return close;
}
// A confirmation that names what it affects and says what is lost. Resolves true only when confirmed.
function confirmDialog({title, body, confirm = 'Confirm', cancel = 'Cancel', danger = true}) {
return new Promise(resolve => {
let close;
const done = v => { resolve(v); close(); };
close = modal(title, h('div', {class:'stack'}, h('p', {class:'dim', style:'margin:0'}, body),
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => done(false)}, cancel),
h('button', {class:danger ? 'ghost danger' : 'btn', onclick:() => done(true)}, confirm))), {onClose:() => resolve(false)});
});
}
const ask = (opts, run) => act(async () => { if (await confirmDialog(opts)) await run(); });
// status pill: color always travels with a text label
const badge = (text, tone) => h('span', {class:'badge' + (tone ? ' badge-' + tone : '')}, text);
const emptyState = (name, title, text, action) => h('div', {class:'empty'}, icon(name, 22), h('h3', {}, title), h('p', {}, text), action || null);
// placeholder rows while a page loads, so the layout does not jump
const skeleton = () => h('div', {class:'card', role:'status', 'aria-busy':'true'}, h('span', {class:'sr-only'}, 'Loading'),
...[60, 100, 90, 100, 75].map(w => h('div', {class:'skeleton', style:`height:16px;width:${w}%;margin-bottom:14px`})));
const modalOpen = () => !!document.querySelector('.shade');
const table = (head, rows) => h('div', {class:'card flush'}, h('div', {class:'scroll'}, h('table', {}, h('thead', {}, h('tr', {}, head.map(c => h('th', {scope:'col'}, c)))), h('tbody', {}, rows))));
// A list of rows the person can add to and remove from (people in a rotation, steps in a policy).
function rowList(addLabel, make, initial) {
const box = h('div', {class:'stack'});
const add = (v) => {
const r = make(v || {});
const row = h('div', {class:'listrow'}, r.el, h('button', {class:'ghost quiet btn-icon', 'aria-label':'Remove', type:'button', onclick:() => row.remove()}, icon('close', 16)));
row.get = r.get; box.insertBefore(row, addBtn);
};
const addBtn = h('button', {class:'ghost', type:'button', onclick:() => add()}, icon('add', 16), addLabel);
box.append(addBtn);
(initial || []).forEach(add);
return Object.assign(box, {values: () => [...box.querySelectorAll('.listrow')].map(r => r.get())});
}
// ---------- icons (lucide-style, static strings) ----------
const ICONS = {
sandbar:'<path d="M2 18c3-4 6-4 10 0s7 4 10 0"/><path d="M2 12c3-4 6-4 10 0s7 4 10 0"/><path d="M2 6c3-4 6-4 10 0s7 4 10 0"/>',
alerts:'<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
incidents:'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/>',
oncall:'<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
monitors:'<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
websites:'<path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
topics:'<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
channels:'<path d="m22 2-7 20-4-9-9-4z"/><path d="M22 2 11 13"/>',
status:'<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
users:'<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
account:'<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
out:'<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>',
menu:'<path d="M4 6h16M4 12h16M4 18h16"/>',
close:'<path d="M18 6 6 18M6 6l12 12"/>',
add:'<path d="M5 12h14M12 5v14"/>',
check:'<path d="M20 6 9 17l-5-5"/>',
back:'<path d="m15 18-6-6 6-6"/>',
light:'<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>',
dark:'<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
alert:'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/>',
};
function icon(name, size = 18) {
const s = document.createElement('span');
s.style.display = 'inline-flex'; s.setAttribute('aria-hidden', 'true');
s.innerHTML = `<svg class="icon" xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" style="width:${size}px;height:${size}px">${ICONS[name] || ''}</svg>`;
return s;
}
const brand = () => h('div', {class:'brand'}, icon('sandbar', 22), 'Sandbar');
// ---------- state ----------
const S = { me: null, tab: 'alerts', arg: '', pub: null, users: [], policies: [], schedules: [], headerAction: null };
const isAdmin = () => S.me && S.me.role === 'admin';
const userName = id => (S.users.find(u => u.id === id) || {}).name || '';
const userOpts = (blank) => [...(blank ? [['', blank]] : []), ...S.users.map(u => [u.id, u.name])];
const origin = () => location.origin;
async function boot() {
try { S.pub = await api('GET', V1 + '/state'); } catch (e) { $app.replaceChildren(card('Cannot reach the server', e.message)); return; }
if (S.pub.needsSetup) return $app.replaceChildren(setupView());
S.me = S.pub.user;
if (!S.me) return $app.replaceChildren(loginView());
await render();
}
// ---------- first run and login ----------
function setupView() {
const f = { name: h('input', {placeholder:'Your name'}), email: h('input', {type:'email'}), pw: h('input', {type:'password'}) };
return h('div', {class:'center'}, card('', brand(), h('h2', {}, 'Welcome to Sandbar'), h('p', {class:'dim'}, 'Create the first administrator.'),
h('div', {class:'stack'}, field('Your name', f.name), field('Email', f.email), field('Password', f.pw, 'At least 10 characters'),
h('button', {class:'btn', onclick: act(async () => {
await api('POST', V1 + '/setup', {name:f.name.value, email:f.email.value, password:f.pw.value}); boot();
})}, 'Create account'))));
}
function loginView() {
const email = h('input', {type:'email', autocomplete:'username'}), pw = h('input', {type:'password', autocomplete:'current-password'});
const go = act(async () => { await api('POST', V1 + '/login', {email:email.value, password:pw.value}); if (toastBox) toastBox.replaceChildren(); boot(); });
[email, pw].forEach(i => i.addEventListener('keydown', e => e.key === 'Enter' && go()));
return h('div', {class:'center'}, card('', brand(), h('h2', {}, 'Sign in'), h('div', {class:'stack'}, field('Email', email), field('Password', pw), h('button', {class:'btn', onclick:go}, 'Sign in'))));
}
// ---------- shell ----------
const PAGES = {
alerts: ['Alerts', 'Problems that need someone, from monitors and from anything that can send a request.'],
incidents: ['Incidents', 'Coordinate the response when something is wrong, and keep a record of what happened.'],
oncall: ['On-call', 'Who is responsible right now, and who is told next when nobody answers.'],
monitors: ['Monitors', 'Checks that tell you when a website, port, certificate or scheduled job stops working.'],
websites: ['Websites', 'Count visitors and page views on your sites, without cookies or personal data.'],
topics: ['Topics', 'Publish and subscribe to messages with plain HTTP requests. Phones can use the ntfy app.'],
channels: ['Channels', 'Email addresses and webhooks that receive every alert and incident update.'],
users: ['People', 'Everyone who can sign in, and how to reach them.'],
account: ['Account', 'Your password, API keys and personal topic.'],
};
const NAV = [
['Respond', [['alerts', 'Alerts', 'alerts'], ['incidents', 'Incidents', 'incidents'], ['oncall', 'On-call', 'oncall']]],
['Observe', [['monitors', 'Monitors', 'monitors'], ['websites', 'Websites', 'websites'], ['topics', 'Topics', 'topics']]],
['Set up', [['channels', 'Channels', 'channels'], ['users', 'People', 'users']]],
];
function route() {
const [tab, arg] = (location.hash.replace(/^#\/?/, '') || 'alerts').split('/');
S.tab = PAGES[tab] ? tab : 'alerts'; S.arg = arg || '';
}
window.addEventListener('hashchange', () => { if (S.me) { route(); render(); } });
async function render() {
route();
const nav = (key, label, ic) => h('a', {class:'navitem' + (S.tab === key ? ' on' : ''), href:'#' + key, 'aria-current':S.tab === key ? 'page' : null, onclick:() => setDrawer(false)}, icon(ic), label);
const side = [h('div', {class:'sidebar-head'}, brand(), h('button', {class:'ghost quiet btn-icon sidebar-close', 'aria-label':'Close menu', onclick:() => setDrawer(false)}, icon('close')))];
for (const [sec, items] of NAV) {
side.push(h('div', {class:'navsec'}, sec));
for (const [k, l, i] of items) side.push(nav(k, l, i));
}
side.push(h('div', {class:'navsec'}, 'Public'), h('a', {class:'navitem', href:'/status', target:'_blank', rel:'noopener'}, icon('status'), 'Status page'));
side.push(h('div', {class:'grow'}), h('div', {class:'userbox'}, h('div', {class:'who'}, S.me.email), nav('account', 'Account', 'account'),
h('button', {class:'navitem', onclick:toggleTheme}, icon('dark'), 'Switch theme'),
h('button', {class:'navitem', onclick: act(async () => { await api('POST', V1 + '/logout'); S.me = null; boot(); })}, icon('out'), 'Sign out')));
S.drawn = null;
$app.replaceChildren(h('a', {class:'skip', href:'#view'}, 'Skip to content'), h('div', {class:'shell'},
h('div', {class:'topbar'}, brand(), h('button', {class:'ghost quiet btn-icon', id:'menu-btn', 'aria-label':'Open menu', 'aria-controls':'sidebar', 'aria-expanded':'false', onclick:() => setDrawer(true)}, icon('menu'))),
h('div', {class:'backdrop', id:'backdrop', onclick:() => setDrawer(false)}),
h('aside', {class:'sidebar', id:'sidebar', 'aria-label':'Main'}, side),
h('div', {class:'content'}, h('main', {id:'view', tabindex:'-1'}))));
await refresh();
}
// the sidebar is a slide-in drawer on small screens
function setDrawer(open) {
const sb = document.getElementById('sidebar'), bd = document.getElementById('backdrop'), mb = document.getElementById('menu-btn');
if (!sb) return;
sb.classList.toggle('open', open); bd.classList.toggle('open', open); mb.setAttribute('aria-expanded', open);
if (open) { const f = sb.querySelector('a,button'); if (f) f.focus(); } else mb.focus();
}
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !modalOpen()) { const sb = document.getElementById('sidebar'); if (sb && sb.classList.contains('open')) setDrawer(false); } });
function pageHeader(tab) {
const [t, d] = PAGES[tab] || ['', ''];
const a = S.headerAction;
return h('div', {class:'page-h'}, h('div', {}, h('h1', {}, t), d ? h('p', {}, d) : null), a ? h('button', {class:'btn', onclick:a[1]}, icon('add', 16), a[0]) : null);
}
let seq = 0;
async function refresh() {
const my = ++seq;
try {
S.headerAction = null;
const first = document.getElementById('view');
const key = S.tab + '/' + S.arg;
if (first && S.drawn !== key) first.replaceChildren(pageHeader(S.tab), skeleton());
[S.users, S.policies, S.schedules] = await Promise.all([api('GET', V1 + '/users'), api('GET', V1 + '/policies'), api('GET', V1 + '/schedules')]);
const view = await views[S.tab]();
if (my !== seq) return;
const v = document.getElementById('view');
if (v) { v.replaceChildren(S.noHeader ? '' : pageHeader(S.tab), view); S.drawn = key; }
} catch (e) { if (e.message !== SIGNED_OUT) toast(e.message, true); }
}
setInterval(() => {
if (!S.me || document.hidden || modalOpen()) return;
const a = document.activeElement;
if (a && ['INPUT', 'SELECT', 'TEXTAREA'].includes(a.tagName)) return;
if (['alerts', 'monitors', 'incidents', 'oncall'].includes(S.tab) && !S.arg) refresh();
}, 10000);
const sevTone = s => ({critical:'danger', warning:'warning'}[s] || '');
const sevLabel = s => ({critical:'Critical', warning:'Warning', info:'Info'}[s] || s);
const alertStatus = a => a.status === 'firing' ? badge('Firing', 'danger') : a.status === 'acked' ? badge('Acknowledged', 'warning') : badge('Resolved', 'success');
const timeline = ev => h('ul', {class:'timeline'}, ev.map(e => h('li', {}, h('span', {class:'when num'}, fmtTime(e.time)), h('span', {}, (e.actor ? e.actor + ': ' : '') + e.text))));
const views = {};
// ---------- alerts ----------
views.alerts = async () => {
const filter = S.alertFilter || 'open';
S.headerAction = ['Raise alert', () => raiseAlertDialog()];
const list = await api('GET', V1 + '/alerts?status=' + filter);
const tabs = h('div', {class:'tabs2', role:'group', 'aria-label':'Filter alerts'}, [['open', 'Open'], ['resolved', 'Resolved'], ['all', 'All']].map(([k, l]) =>
h('button', {class:filter === k ? 'on' : '', 'aria-pressed':filter === k, onclick:() => { S.alertFilter = k; refresh(); }}, l)));
if (!list.length) return h('div', {}, tabs, emptyState('alerts', filter === 'open' ? 'No open alerts' : 'No alerts yet', 'Alerts appear here when a monitor goes down or something sends one to the API.'));
return h('div', {}, tabs, table(['Severity', 'Alert', 'Status', 'Started', ''], list.map(a => h('tr', {},
h('td', {}, badge(sevLabel(a.severity), sevTone(a.severity))),
h('td', {}, h('div', {}, a.summary), a.detail ? h('div', {class:'small dim clip', title:a.detail}, a.detail) : null, a.count > 1 ? h('div', {class:'small dim'}, 'Seen ' + a.count + ' times') : null),
h('td', {}, alertStatus(a), a.status === 'acked' ? h('div', {class:'small dim'}, 'by ' + a.ackedBy) : null),
h('td', {class:'num'}, fmtTime(a.created)),
h('td', {}, h('div', {class:'actions'},
a.status === 'firing' ? h('button', {class:'ghost', onclick:act(async () => { await api('POST', `${V1}/alerts/${a.id}/ack`); toast('Alert acknowledged.'); refresh(); })}, 'Acknowledge') : null,
a.status !== 'resolved' ? h('button', {class:'ghost', onclick:act(async () => { await api('POST', `${V1}/alerts/${a.id}/resolve`); toast('Alert resolved.'); refresh(); })}, 'Resolve') : null,
h('button', {class:'ghost', onclick:act(() => alertDetail(a.id))}, 'Details')))))));
};
async function alertDetail(id) {
const a = await api('GET', `${V1}/alerts/${id}`);
const pol = S.policies.find(p => p.id === a.policyId);
const body = h('div', {class:'stack'}, h('div', {}, alertStatus(a), ' ', badge(sevLabel(a.severity), sevTone(a.severity))),
a.detail ? h('p', {class:'dim', style:'margin:0'}, a.detail) : null,
h('p', {class:'small dim', style:'margin:0'}, 'Escalation policy: ' + (pol ? pol.name : 'none, broadcast only')),
timeline(a.timeline || []),
h('div', {class:'actions form-actions'},
a.status === 'firing' ? h('button', {class:'ghost', onclick:act(async () => { await api('POST', `${V1}/alerts/${id}/snooze`, {minutes:30}); toast('Escalation paused for 30 minutes.'); close(); refresh(); })}, 'Snooze 30 minutes') : null,
a.status !== 'resolved' && !a.incidentId ? h('button', {class:'ghost', onclick:() => { close(); declareDialog([a.id], a.summary); }}, 'Declare incident') : null,
h('button', {class:'btn', onclick:() => close()}, 'Close')));
const close = modal(a.summary, body, {wide:true});
}
function raiseAlertDialog() {
const f = { summary: h('input'), detail: h('textarea'), sev: select([['critical', 'Critical'], ['warning', 'Warning'], ['info', 'Info']], 'warning'),
pol: select([['', 'None, only broadcast'], ...S.policies.map(p => [p.id, p.name])], '') };
dialogForm('Raise an alert', 'Use this to test an escalation policy, or to page someone by hand.',
[field('Summary', f.summary), field('Details', f.detail), field('Severity', f.sev), field('Escalation policy', f.pol)], 'Raise alert',
async () => { await api('POST', V1 + '/alerts', {key:'manual-' + Date.now(), summary:f.summary.value, detail:f.detail.value, severity:f.sev.value, policy:f.pol.value, source:'manual'}); toast('Alert raised.'); refresh(); });
}
// ---------- monitors ----------
const KINDS = [['http', 'Website (HTTP)'], ['tcp', 'Port (TCP)'], ['dns', 'DNS lookup'], ['tls', 'Certificate expiry'], ['heartbeat', 'Heartbeat (cron job or service pings us)']];
views.monitors = async () => {
S.headerAction = ['Add monitor', () => monitorDialog()];
const [mons, states] = await Promise.all([api('GET', V1 + '/monitors'), api('GET', V1 + '/monitor-states')]);
if (!mons.length) return emptyState('monitors', 'No monitors yet', 'Add a check for a website, a port, a certificate or a scheduled job. Failures raise an alert.', h('button', {class:'btn', onclick:() => monitorDialog()}, 'Add monitor'));
const statusBadge = (m, st) => !m.enabled ? badge('Paused') : st.status === 'up' ? badge('Up', 'success') : st.status === 'down' ? badge('Down', 'danger') : badge('Waiting for first check');
return table(['Name', 'Check', 'Status', 'Last check', 'Uptime', ''], mons.map(m => { const st = states[m.id] || {};
return h('tr', {},
h('td', {}, m.name, m.public ? h('div', {}, badge('On status page', 'accent')) : null),
h('td', {}, h('div', {}, (KINDS.find(k => k[0] === m.kind) || [])[1] || m.kind), m.kind !== 'heartbeat' ? h('div', {class:'small dim mono'}, m.target) : h('div', {class:'small dim'}, 'Every ' + m.intervalS + ' s')),
h('td', {}, statusBadge(m, st), st.status === 'down' && st.detail ? h('div', {class:'small dim clip', title:st.detail}, st.detail) : null),
h('td', {class:'num'}, st.lastCheck ? ago(st.lastCheck) : 'never', m.kind !== 'heartbeat' && st.lastCheck ? h('div', {class:'small dim'}, st.lastMs + ' ms') : null),
h('td', {class:'num'}, st.uptime30 >= 0 ? st.uptime30.toFixed(2) + '%' : 'No data yet'),
h('td', {}, h('div', {class:'actions'},
h('button', {class:'ghost', onclick:() => monitorDialog(m)}, 'Edit'),
h('button', {class:'ghost danger', onclick:ask({title:`Delete ${m.name}?`, body:'The monitor, its history and any open alert for it are removed. This cannot be undone.', confirm:'Delete monitor'}, async () => { await api('DELETE', `${V1}/monitors/${m.id}`); toast('Monitor deleted.'); refresh(); })}, 'Delete'))));
}));
};
function monitorDialog(m) {
const e = !!m; m = m || {kind:'http', intervalS:60, timeoutS:10, retries:2, severity:'critical', enabled:true, warnDays:14};
const f = { name: h('input', {value:m.name || ''}), kind: select(KINDS, m.kind, {disabled:e}), target: h('input', {value:m.target || ''}),
interval: h('input', {type:'number', min:10, value:m.intervalS}), timeout: h('input', {type:'number', min:1, max:120, value:m.timeoutS}),
retries: h('input', {type:'number', min:1, value:m.retries}), expect: h('input', {value:m.expect || '', placeholder:'200-299'}), keyword: h('input', {value:m.keyword || ''}),
warn: h('input', {type:'number', min:1, value:m.warnDays || 14}), grace: h('input', {type:'number', min:0, value:m.graceS || 0}),
pol: select([['', 'None, only broadcast'], ...S.policies.map(p => [p.id, p.name])], m.policyId || ''),
sev: select([['critical', 'Critical'], ['warning', 'Warning'], ['info', 'Info']], m.severity || 'critical'),
pub: chk('Show on the public status page', m.public), priv: chk('Allow targets on private networks', m.allowPrivate), on: chk('Run this check', m.enabled) };
const only = (kinds, el) => { const w = h('div', {class:'stack'}, el); w.kinds = kinds; return w; };
const groups = [
only(['http', 'tcp', 'dns', 'tls'], field('Target', f.target, 'A URL for websites, host:port for ports, a hostname for DNS and certificates.')),
only(['http'], field('Expected status', f.expect, 'Leave empty for any 2xx. A range such as 200-399 also works.')),
only(['http'], field('Page must contain', f.keyword, 'Optional text that has to appear in the response.')),
only(['tls'], field('Alert when expiring within (days)', f.warn)),
only(['heartbeat'], field('Allowed delay after the interval (seconds)', f.grace)),
only(['http', 'tcp', 'dns', 'tls'], f.priv),
];
const sync = () => groups.forEach(g => g.style.display = g.kinds.includes(f.kind.value) ? '' : 'none');
f.kind.onchange = sync; sync();
const pingUrl = m.token ? h('div', {}, h('div', {class:'small dim'}, 'Send a request here every time the job runs:'), cmdBox(`${origin()}/api/v1/hb/${m.token}`)) : null;
const hbOnly = only(['heartbeat'], pingUrl || h('p', {class:'small dim', style:'margin:0'}, 'The ping address appears here after you save.'));
groups.push(hbOnly); sync();
dialogForm(e ? 'Edit ' + m.name : 'Add a monitor', null, [field('Name', f.name), field('Type', f.kind), ...groups,
h('div', {class:'grid'}, field('Check every (seconds)', f.interval, 'At least 10.'), field('Timeout (seconds)', f.timeout), field('Failures before down', f.retries, 'A blip is ignored.')),
h('div', {class:'grid'}, field('Alert severity', f.sev), field('Escalation policy', f.pol)), f.pub, f.on], e ? 'Save changes' : 'Add monitor',
async () => {
const body = {...m, name:f.name.value, kind:f.kind.value, target:f.target.value, intervalS:+f.interval.value, timeoutS:+f.timeout.value, retries:+f.retries.value,
expect:f.expect.value, keyword:f.keyword.value, warnDays:+f.warn.value, graceS:+f.grace.value, policyId:f.pol.value, severity:f.sev.value,
public:f.pub.box.checked, allowPrivate:f.priv.box.checked, enabled:f.on.box.checked};
const saved = await api(e ? 'PUT' : 'POST', V1 + '/monitors' + (e ? '/' + m.id : ''), body);
toast(e ? 'Monitor saved.' : 'Monitor added.'); refresh();
if (!e && saved.kind === 'heartbeat') { const done = modal('Heartbeat address', h('div', {class:'stack'}, h('p', {class:'dim', style:'margin:0'}, 'Have your job request this address each time it runs. It goes down when the requests stop.'), cmdBox(`${origin()}/api/v1/hb/${saved.token}`), h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => done()}, 'Done')))); }
});
}
// ---------- on-call ----------
views.oncall = async () => {
const which = S.oncallTab || 'schedules';
S.headerAction = which === 'schedules' ? ['Add schedule', () => scheduleDialog()] : ['Add policy', () => policyDialog()];
const tabs = h('div', {class:'tabs2', role:'group', 'aria-label':'On-call sections'}, [['schedules', 'Schedules'], ['policies', 'Escalation policies']].map(([k, l]) =>
h('button', {class:which === k ? 'on' : '', 'aria-pressed':which === k, onclick:() => { S.oncallTab = k; refresh(); }}, l)));
if (which === 'schedules') {
const now = await api('GET', V1 + '/oncall');
if (!S.schedules.length) return h('div', {}, tabs, emptyState('oncall', 'No schedules yet', 'A schedule rotates a list of people. Use it in an escalation policy so the right person is told.', h('button', {class:'btn', onclick:() => scheduleDialog()}, 'Add schedule')));
return h('div', {}, tabs, table(['Schedule', 'On call now', 'Next', 'Rotation', ''], S.schedules.map(sc => { const n = now.find(x => x.id === sc.id) || {};
return h('tr', {}, h('td', {}, sc.name), h('td', {}, n.user ? badge(n.user, 'accent') : badge('Nobody')), h('td', {}, n.next || 'Nobody'),
h('td', {}, `${sc.members.length} ${sc.members.length === 1 ? 'person' : 'people'}, every ${sc.rotationHours % 24 === 0 ? sc.rotationHours / 24 + (sc.rotationHours === 24 ? ' day' : ' days') : sc.rotationHours + ' hours'}`),
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => scheduleDialog(sc)}, 'Edit'),
h('button', {class:'ghost danger', onclick:ask({title:`Delete ${sc.name}?`, body:'Policies that use this schedule will have nobody to notify for that step. This cannot be undone.', confirm:'Delete schedule'}, async () => { await api('DELETE', `${V1}/schedules/${sc.id}`); toast('Schedule deleted.'); refresh(); })}, 'Delete'))));
})));
}
if (!S.policies.length) return h('div', {}, tabs, emptyState('oncall', 'No escalation policies yet', 'A policy lists who to tell, and how long to wait before telling the next person.', h('button', {class:'btn', onclick:() => policyDialog()}, 'Add policy')));
const who = st => [...st.users.map(userName), ...st.schedules.map(id => 'on call for ' + ((S.schedules.find(s => s.id === id) || {}).name || 'a removed schedule'))].filter(Boolean).join(', ') || 'Nobody';
return h('div', {}, tabs, table(['Policy', 'Steps', ''], S.policies.map(p => h('tr', {}, h('td', {}, p.name),
h('td', {}, h('ol', {class:'steps'}, p.steps.map(st => h('li', {}, (st.delayMin ? `After ${st.delayMin} min: ` : 'At once: ') + who(st)))), p.repeatMin ? h('div', {class:'small dim'}, `Starts over every ${p.repeatMin} min until acknowledged.`) : null),
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => policyDialog(p)}, 'Edit'),
h('button', {class:'ghost danger', onclick:ask({title:`Delete ${p.name}?`, body:'Monitors that use this policy will only broadcast. Alerts already escalating stop. This cannot be undone.', confirm:'Delete policy'}, async () => { await api('DELETE', `${V1}/policies/${p.id}`); toast('Policy deleted.'); refresh(); })}, 'Delete')))))));
};
function scheduleDialog(sc) {
const e = !!sc; sc = sc || {members:[], overrides:[], rotationHours:168};
const name = h('input', {value:sc.name || ''}), start = h('input', {type:'datetime-local', value:fromUnix(sc.start || Date.now() / 1000)});
const preset = [[24, 'Every day'], [168, 'Every week'], [336, 'Every two weeks']];
const rot = select([...preset, ['custom', 'Custom']], preset.some(p => p[0] === sc.rotationHours) ? sc.rotationHours : 'custom'), hours = h('input', {type:'number', min:1, value:sc.rotationHours});
const sync = () => { hours.parentElement.style.display = rot.value === 'custom' ? '' : 'none'; if (rot.value !== 'custom') hours.value = rot.value; };
rot.onchange = sync;
const members = rowList('Add person', v => { const s = select(userOpts(), v.id || S.users[0]?.id); return {el:field('Person', s), get:() => s.value}; }, sc.members.map(id => ({id})));
const overrides = rowList('Add override', v => { const u = select(userOpts(), v.userId), a = h('input', {type:'datetime-local', value:fromUnix(v.from)}), b = h('input', {type:'datetime-local', value:fromUnix(v.to)});
return {el:h('div', {class:'grid'}, field('Covered by', u), field('From', a), field('Until', b)), get:() => ({userId:u.value, from:toUnix(a.value), to:toUnix(b.value)})}; }, sc.overrides);
const hw = field('Hours per shift', hours);
dialogForm(e ? 'Edit ' + sc.name : 'Add a schedule', 'People take turns in the order listed.', [field('Name', name), field('Hand over', rot), hw,
field('First shift starts', start), h('fieldset', {}, h('legend', {}, 'People in the rotation'), members), h('fieldset', {}, h('legend', {}, 'Overrides'), h('p', {class:'sub'}, 'Cover a holiday or swap a shift for a set time.'), overrides)],
e ? 'Save changes' : 'Add schedule', async () => {
await api(e ? 'PUT' : 'POST', V1 + '/schedules' + (e ? '/' + sc.id : ''), {name:name.value, rotationHours:+hours.value, start:toUnix(start.value), members:members.values(), overrides:overrides.values()});
toast('Schedule saved.'); refresh(); });
sync();
}
function policyDialog(p) {
const e = !!p; p = p || {steps:[{delayMin:0, users:[], schedules:[]}], repeatMin:0};
const name = h('input', {value:p.name || ''}), repeat = h('input', {type:'number', min:0, value:p.repeatMin || 0});
const steps = rowList('Add step', v => {
const delay = h('input', {type:'number', min:0, value:v.delayMin || 0});
const users = S.users.map(u => chk(u.name, (v.users || []).includes(u.id))), scs = S.schedules.map(s => chk(s.name, (v.schedules || []).includes(s.id)));
return {el:h('div', {class:'stack'}, field('Wait before this step (minutes)', delay), h('div', {class:'pillrow'}, users, scs.length ? h('span', {class:'sub'}, 'On call for:') : null, scs)),
get:() => ({delayMin:+delay.value, users:S.users.filter((u, i) => users[i].box.checked).map(u => u.id), schedules:S.schedules.filter((s, i) => scs[i].box.checked).map(s => s.id)})};
}, p.steps);
dialogForm(e ? 'Edit ' + p.name : 'Add an escalation policy', 'Each step tells these people. If nobody acknowledges, the next step runs after its wait.', [field('Name', name), h('fieldset', {}, h('legend', {}, 'Steps'), steps),
field('Start over after (minutes)', repeat, 'Zero stops after the last step.')], e ? 'Save changes' : 'Add policy', async () => {
await api(e ? 'PUT' : 'POST', V1 + '/policies' + (e ? '/' + p.id : ''), {name:name.value, steps:steps.values(), repeatMin:+repeat.value}); toast('Policy saved.'); refresh(); });
}
// ---------- incidents ----------
const incTone = s => ({sev1:'danger', sev2:'warning'}[s] || '');
const incStatus = s => s === 'resolved' ? badge('Resolved', 'success') : s === 'monitoring' ? badge('Monitoring', 'accent') : s === 'identified' ? badge('Identified', 'warning') : badge('Investigating', 'danger');
const STATUSES = [['investigating', 'Investigating'], ['identified', 'Identified'], ['monitoring', 'Monitoring'], ['resolved', 'Resolved']];
const SEVS = [['sev1', 'Severity 1, major outage'], ['sev2', 'Severity 2, partial impact'], ['sev3', 'Severity 3, minor']];
views.incidents = async () => {
if (S.arg) return incidentDetail(S.arg);
S.headerAction = ['Declare incident', () => declareDialog()];
const list = await api('GET', V1 + '/incidents');
if (!list.length) return emptyState('incidents', 'No incidents yet', 'Declare an incident when something is wrong and people need to work together. Every step is kept in a timeline.', h('button', {class:'btn', onclick:() => declareDialog()}, 'Declare incident'));
return table(['Severity', 'Incident', 'Status', 'Lead', 'Started'], list.map(i => h('tr', {},
h('td', {}, badge(i.severity.replace('sev', 'Sev '), incTone(i.severity))),
h('td', {}, h('a', {href:'#incidents/' + i.id}, i.title), i.public ? h('div', {}, badge('On status page', 'accent')) : null),
h('td', {}, incStatus(i.status)), h('td', {}, userName(i.lead) || 'Nobody'), h('td', {class:'num'}, fmtTime(i.created)))));
};
function declareDialog(alertIds, title) {
const f = { title: h('input', {value:title || ''}), sev: select(SEVS, 'sev2'), sum: h('textarea'), lead: select(userOpts('Me'), '') };
dialogForm('Declare an incident', 'Everyone subscribed to the incidents topic and every channel is told.', [field('Title', f.title, 'What is wrong, in a few words.'), field('Severity', f.sev), field('What we know so far', f.sum), field('Incident lead', f.lead)], 'Declare incident',
async () => { const i = await api('POST', V1 + '/incidents', {title:f.title.value, severity:f.sev.value, summary:f.sum.value, lead:f.lead.value, alertIds:alertIds || []}); toast('Incident declared.'); location.hash = 'incidents/' + i.id; });
}
async function incidentDetail(id) {
S.noHeader = true;
const i = await api('GET', `${V1}/incidents/${id}`);
const patch = act(async (body, msg) => { await api('PATCH', `${V1}/incidents/${id}`, body); toast(msg || 'Incident updated.'); refresh(); });
const status = select(STATUSES, i.status, {onchange:e => patch({status:e.target.value})}), sev = select(SEVS, i.severity, {onchange:e => patch({severity:e.target.value})});
const lead = select(userOpts('Nobody'), i.lead, {onchange:e => patch({lead:e.target.value})}), comms = select(userOpts('Nobody'), i.comms, {onchange:e => patch({comms:e.target.value})});
const pub = chk('Show on the public status page', i.public, v => patch({public:v}));
const sum = h('textarea', {}, i.summary), pm = h('textarea', {style:'min-height:220px', class:'mono'}, i.postmortem), note = h('input', {placeholder:'Add a comment for the timeline'});
const say = act(async () => { if (!note.value.trim()) return; await api('POST', `${V1}/incidents/${id}/comments`, {text:note.value}); note.value = ''; refresh(); });
note.addEventListener('keydown', e => e.key === 'Enter' && say());
setTimeout(() => { S.noHeader = false; });
return h('div', {}, h('div', {class:'page-h'}, h('div', {}, h('a', {href:'#incidents', class:'small'}, 'Back to incidents'), h('h1', {}, i.title), h('p', {}, 'Declared ' + fmtTime(i.created) + (i.resolved ? ', resolved ' + fmtTime(i.resolved) : ''))), h('div', {}, incStatus(i.status))),
card('Status', h('div', {class:'grid'}, field('Status', status), field('Severity', sev), field('Lead', lead), field('Communications', comms)), pub),
card('Summary', h('div', {class:'stack'}, sum, h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => patch({summary:sum.value}, 'Summary saved.')}, 'Save summary')))),
card('Timeline', timeline(i.timeline || []), h('div', {class:'actions', style:'margin-top:16px'}, h('div', {style:'flex:1;min-width:200px'}, note), h('button', {class:'ghost', onclick:say}, 'Add comment'))),
card('Post-mortem', h('div', {class:'stack'}, pm, h('div', {class:'actions form-actions'},
h('button', {class:'ghost', onclick:act(async () => { if (pm.value.trim() && !await confirmDialog({title:'Replace the post-mortem?', body:'The text you have written is replaced by the template. This cannot be undone.', confirm:'Use template'})) return; pm.value = (await api('POST', `${V1}/incidents/${id}/postmortem-template`)).template; })}, 'Fill from template'),
h('button', {class:'btn', onclick:() => patch({postmortem:pm.value}, 'Post-mortem saved.')}, 'Save post-mortem')))),
isAdmin() ? h('div', {class:'actions'}, h('button', {class:'ghost danger', onclick:ask({title:`Delete ${i.title}?`, body:'The incident and its timeline are removed. Linked alerts stay. This cannot be undone.', confirm:'Delete incident'}, async () => { await api('DELETE', `${V1}/incidents/${id}`); toast('Incident deleted.'); location.hash = 'incidents'; })}, 'Delete incident')) : null);
}
// ---------- topics ----------
let stream = null;
views.topics = async () => {
if (stream) { stream.close(); stream = null; }
const topic = h('input', {value:S.topic || 'alerts', 'aria-label':'Topic'}), feed = h('div', {class:'feed', role:'log', 'aria-live':'polite'});
const title = h('input'), msg = h('textarea'), prio = select([['3', 'Default'], ['5', 'Urgent'], ['4', 'High'], ['2', 'Low'], ['1', 'Minimum']], '3'), tags = h('input', {placeholder:'warning, disk'});
const show = m => { feed.prepend(h('div', {class:'msg'}, h('div', {class:'small dim num'}, fmtTime(m.time) + (m.priority >= 4 ? ' · priority ' + m.priority : '')), m.title ? h('strong', {}, m.title) : null, h('div', {}, m.message), (m.tags || []).length ? h('div', {}, m.tags.map(t => h('span', {class:'tag'}, t))) : null)); };
const empty = emptyState('topics', 'No messages yet', 'Messages published to this topic appear here as they arrive.');
const listen = () => {
if (stream) stream.close(); S.topic = topic.value.trim(); feed.replaceChildren(empty);
stream = new EventSource('/' + encodeURIComponent(S.topic) + '/sse?since=all');
stream.onmessage = ev => { if (empty.isConnected) feed.replaceChildren(); show(JSON.parse(ev.data)); };
};
topic.addEventListener('keydown', e => e.key === 'Enter' && listen());
const base = origin(), mine = S.me.topic;
setTimeout(listen);
return h('div', {}, card('Watch a topic', h('div', {class:'stack'}, h('div', {class:'actions'}, h('div', {style:'flex:1;min-width:200px'}, field('Topic', topic)), h('button', {class:'ghost', style:'align-self:flex-end', onclick:listen}, 'Watch')),
h('p', {class:'small dim', style:'margin:0'}, `Every alert is published to "alerts" and every incident update to "incidents". Your own topic is "${mine}": add it in the ntfy app to get paged on your phone.`), feed)),
card('Publish a message', h('div', {class:'stack'}, field('Title', title), field('Message', msg), h('div', {class:'grid'}, field('Priority', prio), field('Tags', tags, 'Separated by commas.')),
h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:act(async () => {
const r = await fetch('/' + encodeURIComponent(topic.value.trim()), {method:'POST', body:msg.value, headers:{Title:title.value, Priority:prio.value, Tags:tags.value}});
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'The message was not sent.');
msg.value = ''; toast('Message published.'); })}, 'Publish message')))),
card('From the command line', cmdBox(`curl -H "Authorization: Bearer <api key>" -H "Title: Disk full" -H "Priority: urgent" -d "web-1 is at 98%" ${base}/alerts\ncurl -H "Authorization: Bearer <api key>" -N ${base}/alerts/sse`)));
};
// ---------- channels ----------
views.channels = async () => {
S.headerAction = isAdmin() ? ['Add channel', () => channelDialog()] : null;
const list = await api('GET', V1 + '/channels');
if (!list.length) return emptyState('channels', 'No channels yet', 'A channel sends every alert and incident update to an email address or a chat webhook such as Slack, Discord or ntfy.', isAdmin() ? h('button', {class:'btn', onclick:() => channelDialog()}, 'Add channel') : null);
return table(['Name', 'Type', 'Status', ''], list.map(c => h('tr', {}, h('td', {}, c.name), h('td', {}, c.kind === 'email' ? 'Email' : 'Webhook (' + c.format + ')'), h('td', {}, c.enabled ? badge('Active', 'success') : badge('Paused')),
h('td', {}, isAdmin() ? h('div', {class:'actions'}, h('button', {class:'ghost', onclick:act(async () => { await api('POST', `${V1}/channels/${c.id}/test`); toast('Test notification sent.'); })}, 'Send test'),
h('button', {class:'ghost', onclick:() => channelDialog(c)}, 'Edit'),
h('button', {class:'ghost danger', onclick:ask({title:`Delete ${c.name}?`, body:'It stops receiving alerts and incident updates. This cannot be undone.', confirm:'Delete channel'}, async () => { await api('DELETE', `${V1}/channels/${c.id}`); toast('Channel deleted.'); refresh(); })}, 'Delete')) : null))));
};
function channelDialog(c) {
const e = !!c; c = c || {kind:'webhook', format:'slack', enabled:true};
const f = { name: h('input', {value:c.name || ''}), kind: select([['webhook', 'Webhook'], ['email', 'Email']], c.kind), target: h('input', {value:c.target || ''}),
format: select([['slack', 'Slack'], ['discord', 'Discord'], ['ntfy', 'ntfy'], ['json', 'Plain JSON']], c.format || 'slack'), priv: chk('Allow addresses on private networks', c.allowPrivate), on: chk('Send notifications', c.enabled) };
const fmt = field('Webhook format', f.format), tgt = field('Address', f.target, 'Webhook URL, or email addresses separated by commas. Stored encrypted and hidden after saving.');
const sync = () => { fmt.style.display = f.priv.style.display = f.kind.value === 'webhook' ? '' : 'none'; };
f.kind.onchange = sync; sync();
dialogForm(e ? 'Edit ' + c.name : 'Add a channel', null, [field('Name', f.name), field('Type', f.kind), tgt, fmt, f.priv, f.on], e ? 'Save changes' : 'Add channel', async () => {
await api(e ? 'PUT' : 'POST', V1 + '/channels' + (e ? '/' + c.id : ''), {name:f.name.value, kind:f.kind.value, target:f.target.value, format:f.format.value, allowPrivate:f.priv.box.checked, enabled:f.on.box.checked});
toast('Channel saved.'); refresh(); });
}
// ---------- people ----------
views.users = async () => {
S.headerAction = isAdmin() ? ['Add person', () => userDialog()] : null;
return table(['Name', 'Email', 'Role', 'Personal topic', ''], S.users.map(u => h('tr', {}, h('td', {}, u.name), h('td', {}, u.email), h('td', {}, u.role === 'admin' ? badge('Administrator', 'accent') : badge('Member')), h('td', {class:'mono small'}, u.topic),
h('td', {}, isAdmin() ? h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => passwordDialog(u)}, 'Set password'),
u.id !== S.me.id ? h('button', {class:'ghost danger', onclick:ask({title:`Delete ${u.name}?`, body:'They can no longer sign in and their API keys stop working. Schedules and policies that list them need updating. This cannot be undone.', confirm:'Delete person'}, async () => { await api('DELETE', `${V1}/users/${u.id}`); toast('Person deleted.'); refresh(); })}, 'Delete') : null) : null))));
};
function userDialog() {
const f = { name: h('input'), email: h('input', {type:'email'}), pw: h('input', {type:'password'}), role: select([['member', 'Member'], ['admin', 'Administrator']], 'member'), phone: h('input', {type:'tel'}) };
dialogForm('Add a person', null, [field('Name', f.name), field('Email', f.email), field('Phone', f.phone, 'Optional.'), field('Password', f.pw, 'At least 10 characters'), field('Role', f.role, 'Administrators manage monitors, channels, schedules, policies and people.')], 'Add person',
async () => { await api('POST', V1 + '/users', {name:f.name.value, email:f.email.value, phone:f.phone.value, password:f.pw.value, role:f.role.value}); toast('Person added.'); refresh(); });
}
function passwordDialog(u) {
const pw = h('input', {type:'password'});
dialogForm('Set a password for ' + u.name, 'They are signed out everywhere.', [field('New password', pw, 'At least 10 characters')], 'Set password', async () => { await api('POST', `${V1}/users/${u.id}/password`, {password:pw.value}); toast('Password changed.'); });
}
// ---------- websites (analytics) ----------
const RANGES = [['24h', 'Last 24 hours'], ['7d', 'Last 7 days'], ['30d', 'Last 30 days'], ['90d', 'Last 90 days']];
const num = n => Number(n || 0).toLocaleString();
function regionName(code) { try { return new Intl.DisplayNames(undefined, {type:'region'}).of(code) || code; } catch (e) { return code; } }
function languageName(code) { try { return new Intl.DisplayNames(undefined, {type:'language'}).of(code) || code; } catch (e) { return code; } }
views.websites = async () => {
if (S.arg) return websiteDetail(S.arg);
const admin = S.me.role === 'admin';
S.headerAction = admin ? ['Add website', () => websiteDialog()] : null;
const [sites, sum] = await Promise.all([api('GET', V1 + '/websites'), api('GET', V1 + '/website-summary')]);
if (!sites.length) return emptyState('websites', 'No websites yet', 'Add a website, then paste one line of code into its pages. Visitors and page views show up here. No cookies are set and no personal data is kept.',
admin ? h('button', {class:'btn', onclick:() => websiteDialog()}, 'Add website') : null);
return table(['Website', 'Visitors, last 24 hours', 'Page views, last 24 hours', ''], sites.map(w => { const s = sum[w.id] || {};
return h('tr', {}, h('td', {}, h('a', {href:'#websites/' + w.id}, w.name), w.domain ? h('div', {class:'small dim'}, w.domain) : null),
h('td', {class:'num'}, num(s.visitors)), h('td', {class:'num'}, num(s.views)),
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => trackingDialog(w)}, 'Tracking code'),
admin ? h('button', {class:'ghost', onclick:() => websiteDialog(w)}, 'Edit') : null,
admin ? h('button', {class:'ghost danger', onclick:ask({title:`Delete ${w.name}?`, body:'The website and all of its visits and events are removed. This cannot be undone.', confirm:'Delete website'}, async () => { await api('DELETE', `${V1}/websites/${w.id}`); toast('Website deleted.'); refresh(); })}, 'Delete') : null)));
}));
};
function websiteDialog(w) {
const e = !!w, f = { name: h('input', {value:w ? w.name : ''}), domain: h('input', {value:w ? w.domain : '', placeholder:'example.com'}) };
dialogForm(e ? 'Edit ' + w.name : 'Add a website', null, [field('Name', f.name), field('Domain', f.domain, 'Optional. When set, visits from other domains are ignored, which stops others counting against your site.')], e ? 'Save changes' : 'Add website',
async () => { const saved = await api(e ? 'PUT' : 'POST', e ? `${V1}/websites/${w.id}` : V1 + '/websites', {...(w || {}), name:f.name.value, domain:f.domain.value}); toast(e ? 'Website saved.' : 'Website added.'); await refresh(); if (!e) trackingDialog(saved); });
}
function trackingDialog(w) {
const tag = `<script defer src="${origin()}/script.js" data-website="${w.id}"><\/script>`;
const done = modal('Tracking code for ' + w.name, h('div', {class:'stack'},
h('p', {class:'dim', style:'margin:0'}, 'Paste this inside the head of every page you want to count.'), cmdBox(tag),
h('p', {class:'dim', style:'margin:0'}, 'Single-page sites are counted as people move between pages. To count something else, such as a sign-up, call this from your code:'),
cmdBox('sandbar.track("signup", { plan: "pro" })'),
h('p', {class:'small dim', style:'margin:0'}, 'The script sets no cookies, keeps no IP address, and does nothing when the visitor has Do Not Track on. Behind a reverse proxy, set SANDBAR_TRUST_PROXY=1 so visitors are told apart.'),
h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => done()}, 'Done'))));
}
async function websiteDetail(id) {
S.noHeader = true;
const range = S.siteRange || '7d';
const [sites, st] = await Promise.all([api('GET', V1 + '/websites'), api('GET', `${V1}/websites/${id}/stats?range=${range}`)]);
setTimeout(() => { S.noHeader = false; });
const w = sites.find(x => x.id === id);
if (!w) return emptyState('websites', 'That website no longer exists', 'It may have been deleted.', h('a', {class:'btn', href:'#websites'}, 'Back to websites'));
const tabs = h('div', {class:'tabs2', role:'group', 'aria-label':'Time range'}, RANGES.map(([k, l]) => h('button', {class:range === k ? 'on' : '', 'aria-pressed':range === k, onclick:() => { S.siteRange = k; refresh(); }}, l)));
const tile = (n, l) => h('div', {class:'stat'}, h('div', {class:'n'}, n), h('div', {class:'l'}, l));
const stats = h('div', {class:'stats'}, tile(num(st.visitors), 'Visitors'), tile(num(st.views), 'Page views'), tile(num(st.visits), 'Visits'),
tile(st.bounceRate >= 0 ? Math.round(st.bounceRate) + '%' : 'No data', 'Bounce rate'), tile(num(st.online), 'Online now'));
const max = Math.max(1, ...st.series.map(p => p.views));
const fmt = p => new Date(p.t * 1000).toLocaleString(undefined, st.bucket === 'hour' ? {month:'short', day:'numeric', hour:'numeric'} : {month:'short', day:'numeric', year:'numeric'});
const chart = h('div', {class:'chart', role:'img', 'aria-label':`${num(st.views)} page views from ${num(st.visitors)} visitors in the ${RANGES.find(r => r[0] === range)[1].toLowerCase()}`},
st.series.map(p => h('div', {class:'col', title:`${fmt(p)}: ${num(p.visitors)} visitors, ${num(p.views)} page views`},
h('div', {class:'v', style:`height:${p.views ? Math.max(2, p.views * 100 / max) : 0}%`}), h('div', {class:'u', style:`height:${p.visitors ? Math.max(2, p.visitors * 100 / max) : 0}%`}))));
const list = (title, rows, label, nameOf) => {
const top = Math.max(1, ...rows.map(r => r.views));
return h('div', {class:'card'}, h('h2', {}, title), rows.length ? h('table', {class:'top'}, h('thead', {}, h('tr', {}, h('th', {scope:'col'}, label), h('th', {scope:'col'}, 'Visitors'), h('th', {scope:'col'}, 'Views'))),
h('tbody', {}, rows.map(r => h('tr', {}, h('td', {style:`--w:${Math.round(r.views * 100 / top)}%`}, nameOf ? nameOf(r.name) : r.name), h('td', {class:'num'}, num(r.visitors)), h('td', {class:'num'}, num(r.views))))))
: h('p', {class:'dim', style:'margin:0'}, 'Nothing yet.'));
};
const nothing = !st.views && !st.events.length;
return h('div', {}, h('div', {class:'page-h'}, h('div', {}, h('a', {href:'#websites', class:'small'}, 'Back to websites'), h('h1', {}, w.name), h('p', {}, w.domain || 'Any domain')),
h('button', {class:'ghost', onclick:() => trackingDialog(w)}, 'Tracking code')),
tabs, h('div', {style:'height:16px'}), stats,
nothing ? emptyState('websites', 'No visits in this period', 'Paste the tracking code into your pages, then open one. The first visit appears within a few seconds.', h('button', {class:'btn', onclick:() => trackingDialog(w)}, 'Show tracking code')) : h('div', {},
card('Visitors and page views', h('div', {class:'legend'}, h('span', {}, h('i', {style:'background:var(--accent)'}), 'Visitors'), h('span', {}, h('i', {style:'background:color-mix(in srgb, var(--accent) 35%, var(--surface))'}), 'Page views')), chart,
h('div', {class:'axis2', 'aria-hidden':'true'}, h('span', {}, fmt(st.series[0])), h('span', {}, 'Now'))),
h('div', {class:'cols'}, list('Pages', st.pages, 'Path'), list('Referrers', st.referrers, 'Source'), list('Browsers', st.browsers, 'Browser'), list('Operating systems', st.systems, 'System'),
list('Devices', st.devices, 'Device'), list('Countries', st.countries, 'Country', n => n.length === 2 ? regionName(n) : n), list('Languages', st.languages, 'Language', n => n.length <= 3 ? languageName(n) : n),
list('Events', st.events, 'Event'))));
}
// ---------- account ----------
// Administrators see which release is running and can install a newer one. The server restarts
// into the new binary, so the page waits for it to come back.
function updateCard(u) {
const status = h('p', {style:'margin:0'}), detail = h('p', {class:'dim', style:'margin:0'}), actions = h('div', {class:'actions form-actions'});
const paint = u => {
status.replaceChildren(`Running ${u.current}. `, u.error ? 'The newest release could not be checked.' : u.newer ? `${u.latest} is available.` : u.latest ? 'This is the newest release.' : '');
detail.textContent = u.error ? u.error : !u.canInstall ? u.reason : u.autoUpdate ? 'Automatic updates are on. Sandbar checks every few hours.' : 'Automatic updates are off. Set SANDBAR_AUTO_UPDATE=1 and restart to turn them on.';
actions.replaceChildren(...[h('button', {class:'ghost', onclick:act(async () => { paint(await api('GET', V1 + '/system/update?fresh=1')); })}, 'Check again'),
u.newer && u.canInstall ? h('button', {class:'btn', onclick:ask({title:`Update to ${u.latest}?`, danger:false, confirm:'Update now',
body:'Sandbar downloads the release, checks it, and restarts. Monitoring and paging pause for a few seconds. Your data is not changed.'}, async () => {
await api('POST', V1 + '/system/update', {}); wait(u.current); })}, `Update to ${u.latest}`) : null].filter(Boolean));
};
const wait = async from => {
actions.replaceChildren(); status.textContent = 'Updating.';
for (let i = 0; i < 120; i++) {
await new Promise(r => setTimeout(r, 1500));
let s; try { s = await api('GET', V1 + '/system/update'); } catch (e) { detail.textContent = 'Restarting.'; continue; }
if (s.current !== from) { toast(`Updated to ${s.current}.`); return refresh(); }
if (s.failed) { toast(s.failed, true); return paint(s); }
detail.textContent = s.line || s.stage;
}
toast('Sandbar did not come back in time. Check the server.', true);
};
paint(u);
return card('Software update', h('div', {class:'stack'}, status, detail, actions));
}
views.account = async () => {
const keys = await api('GET', V1 + '/apikeys'), pw = h('input', {type:'password', autocomplete:'new-password'});
const upd = S.me.role === 'admin' ? await api('GET', V1 + '/system/update').catch(() => null) : null;
S.headerAction = null;
return h('div', {}, card('Password', h('div', {class:'stack'}, field('New password', pw, 'At least 10 characters. You are signed out on other devices.'),
h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:act(async () => { await api('POST', `${V1}/users/${S.me.id}/password`, {password:pw.value}); pw.value = ''; toast('Password changed. Sign in again.'); S.me = null; boot(); })}, 'Change password')))),
card('API keys', h('p', {class:'dim'}, 'Keys let scripts, monitors and ntfy clients publish messages and raise alerts. A key has your access.'),
keys.length ? h('div', {class:'card flush', style:'margin-bottom:16px'}, h('table', {}, h('tbody', {}, keys.map(k => h('tr', {}, h('td', {}, k.name || 'Unnamed key'), h('td', {class:'num'}, 'Created ' + fmtDate(k.created)),
h('td', {}, h('button', {class:'ghost danger', onclick:ask({title:`Delete ${k.name || 'this key'}?`, body:'Anything using it stops working. This cannot be undone.', confirm:'Delete key'}, async () => { await api('DELETE', `${V1}/apikeys/${k.id}`); refresh(); })}, 'Delete')))))) ) : null,
h('button', {class:'ghost', onclick:() => { const n = h('input'); dialogForm('Create an API key', null, [field('Name', n, 'Where you will use it, such as "prometheus".')], 'Create key', async () => {
const k = await api('POST', V1 + '/apikeys', {name:n.value}); refresh();
const done = modal('Copy your API key', h('div', {class:'stack'}, h('p', {class:'dim', style:'margin:0'}, 'This is the only time it is shown.'), cmdBox(k.token), h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => done()}, 'Done')))); }); }}, icon('add', 16), 'Create API key')),
card('Your personal topic', h('p', {class:'dim'}, 'Subscribe to this topic in the ntfy app, or in a browser on the Topics page, to be paged when an escalation policy reaches you.'), cmdBox(S.me.topic)), upd ? updateCard(upd) : null);
};
boot();
</script>
</body>
</html>