Components can be put in groups and given public names. Public incidents carry posts written for customers (the internal timeline stays internal), the components they affect, and a page for each incident plus paged history. Scheduled maintenance has its own window, shows affected components as under maintenance, and is announced when scheduled, started, ended or canceled. Branding (title, logo, accent checked for contrast, contact link, footer) and a custom domain that serves only the status page. Visitors follow by RSS, by confirmed email, or by webhook that must answer a test request. Admin UI for all of it, tests, README and design notes. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1003 lines
91 KiB
HTML
1003 lines
91 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.'],
|
|
statuspage:['Status page', 'What customers see: components, incidents, scheduled maintenance, and who follows along.'],
|
|
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', [['statuspage', 'Status page', 'status'], ['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'), 'View 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, groups] = await Promise.all([api('GET', V1 + '/monitors'), api('GET', V1 + '/monitor-states'), api('GET', V1 + '/status-groups')]);
|
|
S.sgroups = groups;
|
|
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'),
|
|
group: select([['', 'No group'], ...(S.sgroups || []).map(g => [g.id, g.name])], m.group || ''), pubname: h('input', {value:m.publicName || ''}),
|
|
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,
|
|
h('div', {class:'grid'}, field('Status page group', f.group, 'Groups are managed under Status page.'), field('Name on the status page', f.pubname, 'Leave empty to use the monitor name.')), 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, group:f.group.value, publicName:f.pubname.value.trim(), 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)))));
|
|
};
|
|
// monitors that are on the status page, which are the components an incident can affect
|
|
const publicMonitors = async () => (await api('GET', V1 + '/monitors')).filter(m => m.public && m.enabled);
|
|
const monName = m => m.publicName || m.name;
|
|
// a list of checkboxes, one per component; values() gives the ids that are ticked
|
|
function componentPicker(mons, selected) {
|
|
const boxes = mons.map(m => chk(monName(m), (selected || []).includes(m.id)));
|
|
const box = h('div', {class:'stack'}, boxes.length ? boxes : h('p', {class:'small dim', style:'margin:0'}, 'No monitors are on the status page yet. Tick "Show on the public status page" on a monitor.'));
|
|
return Object.assign(box, {values: () => mons.filter((m, i) => boxes[i].box.checked).map(m => m.id)});
|
|
}
|
|
async function declareDialog(alertIds, title) {
|
|
const mons = await publicMonitors(), picker = componentPicker(mons, []);
|
|
const f = { title: h('input', {value:title || ''}), sev: select(SEVS, 'sev2'), sum: h('textarea'), lead: select(userOpts('Me'), ''), pub: chk('Post it on the status page and tell subscribers', false) };
|
|
const comps = h('div', {class:'stack'}, field('Affected components', picker, 'Shown on the status page.'));
|
|
f.pub.box.onchange = () => { comps.style.display = f.pub.box.checked ? '' : 'none'; }; comps.style.display = 'none';
|
|
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, 'If you post it on the status page, customers see this text.'), field('Incident lead', f.lead), f.pub, comps], '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 || [], public:f.pub.box.checked, components:picker.values()}); 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 mons = await publicMonitors(), picker = componentPicker(mons, i.components);
|
|
const pub = chk('Show on the public status page', i.public, v => patch({public:v}));
|
|
const post = () => {
|
|
const f = { status: select(STATUSES, i.status), text: h('textarea', {placeholder:'What customers should know'}) };
|
|
dialogForm('Post an update', 'Customers and subscribers see this text and the status. The timeline and names stay internal.', [field('Status', f.status), field('Update', f.text, 'Leave empty to use the standard wording for the status.')], 'Post update',
|
|
async () => { await api('PATCH', `${V1}/incidents/${id}`, {status:f.status.value, note:f.text.value}); toast('Update posted.'); refresh(); });
|
|
};
|
|
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))),
|
|
card('Status page', h('div', {class:'stack'}, pub,
|
|
i.public ? h('p', {class:'small dim', style:'margin:0'}, 'Changing the status above, or posting an update, adds a post to the public page and emails subscribers.') : h('p', {class:'small dim', style:'margin:0'}, 'Turning this on posts the summary below and tells subscribers.'),
|
|
field('Affected components', picker, 'The page shows these as affected until the incident is resolved.'),
|
|
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => patch({components:picker.values()}, 'Components saved.')}, 'Save components'),
|
|
i.public ? h('button', {class:'btn', onclick:post}, 'Post an update') : null))),
|
|
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`)));
|
|
};
|
|
|
|
// ---------- status page ----------
|
|
const MSTATE = {scheduled:['Scheduled', ''], in_progress:['In progress', 'accent'], completed:['Completed', 'success'], canceled:['Canceled', '']};
|
|
views.statuspage = async () => {
|
|
const which = S.spTab || 'page';
|
|
const tabs = h('div', {class:'tabs2', role:'group', 'aria-label':'Status page sections'}, [['page', 'Page'], ['groups', 'Groups'], ['maintenance', 'Maintenance'], ['subscribers', 'Subscribers']].map(([k, l]) =>
|
|
h('button', {class:which === k ? 'on' : '', 'aria-pressed':which === k, onclick:() => { S.spTab = k; refresh(); }}, l)));
|
|
const admin = isAdmin();
|
|
if (which === 'groups') {
|
|
S.headerAction = admin ? ['Add group', () => groupDialog()] : null;
|
|
const [groups, mons] = await Promise.all([api('GET', V1 + '/status-groups'), api('GET', V1 + '/monitors')]);
|
|
S.sgroups = groups;
|
|
if (!groups.length) return h('div', {}, tabs, emptyState('status', 'No groups yet', 'Groups put related components under one heading, such as Website, API and Infrastructure. Components without a group are listed first.', admin ? h('button', {class:'btn', onclick:() => groupDialog()}, 'Add group') : null));
|
|
return h('div', {}, tabs, table(['Group', 'Components', 'Order', ''], groups.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name)).map(g => {
|
|
const inside = mons.filter(m => m.public && m.group === g.id);
|
|
return h('tr', {}, h('td', {}, g.name, g.description ? h('div', {class:'small dim'}, g.description) : null), h('td', {}, inside.length ? inside.map(monName).join(', ') : 'None yet'), h('td', {class:'num'}, g.order),
|
|
h('td', {}, admin ? h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => groupDialog(g)}, 'Edit'),
|
|
h('button', {class:'ghost danger', onclick:ask({title:`Delete ${g.name}?`, body:'Its components move to the top of the page, ungrouped. The monitors are not changed. This cannot be undone.', confirm:'Delete group'}, async () => { await api('DELETE', `${V1}/status-groups/${g.id}`); toast('Group deleted.'); refresh(); })}, 'Delete')) : null));
|
|
})));
|
|
}
|
|
if (which === 'maintenance') {
|
|
S.headerAction = admin ? ['Schedule maintenance', () => maintenanceDialog()] : null;
|
|
const [list, mons] = await Promise.all([api('GET', V1 + '/maintenance'), api('GET', V1 + '/monitors')]);
|
|
const name = id => { const m = mons.find(x => x.id === id); return m ? monName(m) : null; };
|
|
if (!list.length) return h('div', {}, tabs, emptyState('status', 'No maintenance scheduled', 'Planned work is announced on the status page and sent to subscribers when it is scheduled, when it starts and when it ends. Affected components show as under maintenance meanwhile.', admin ? h('button', {class:'btn', onclick:() => maintenanceDialog()}, 'Schedule maintenance') : null));
|
|
return h('div', {}, tabs, table(['Maintenance', 'Window', 'Status', ''], list.map(m => { const st = MSTATE[m.state] || [m.state, ''], open = m.state === 'scheduled' || m.state === 'in_progress';
|
|
return h('tr', {}, h('td', {}, m.title, h('div', {class:'small dim'}, m.components.map(name).filter(Boolean).join(', ') || 'No components named')), h('td', {class:'num'}, fmtTime(m.start) + ' to ' + fmtTime(m.end)), h('td', {}, badge(st[0], st[1])),
|
|
h('td', {}, admin ? h('div', {class:'actions'}, open ? h('button', {class:'ghost', onclick:() => maintenanceDialog(m)}, 'Edit') : null,
|
|
open ? h('button', {class:'ghost danger', onclick:ask({title:`Cancel ${m.title}?`, body:'Subscribers are told it is canceled. It stays in the history.', confirm:'Cancel maintenance', cancel:'Keep it'}, async () => { await api('POST', `${V1}/maintenance/${m.id}/cancel`); toast('Maintenance canceled.'); refresh(); })}, 'Cancel') : null,
|
|
h('button', {class:'ghost danger', onclick:ask({title:`Delete ${m.title}?`, body:'It is removed from the status page and its history. This cannot be undone.', confirm:'Delete maintenance'}, async () => { await api('DELETE', `${V1}/maintenance/${m.id}`); toast('Maintenance deleted.'); refresh(); })}, 'Delete')) : null));
|
|
})));
|
|
}
|
|
if (which === 'subscribers') {
|
|
S.headerAction = null;
|
|
if (!admin) return h('div', {}, tabs, emptyState('users', 'Administrators only', 'Ask an administrator to see who follows the status page.'));
|
|
const list = await api('GET', V1 + '/status-subscribers');
|
|
if (!list.length) return h('div', {}, tabs, emptyState('users', 'No subscribers yet', 'People subscribe with the button on the public page. Confirmed email addresses and webhooks appear here.'));
|
|
return h('div', {}, tabs, table(['Type', 'Address', 'Status', 'Subscribed', ''], list.map(s => h('tr', {}, h('td', {}, s.kind === 'email' ? 'Email' : 'Webhook'), h('td', {class:'mono small'}, s.target),
|
|
h('td', {}, s.confirmed ? badge('Confirmed', 'success') : badge('Waiting for confirmation')), h('td', {class:'num'}, fmtTime(s.created)),
|
|
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost danger', onclick:ask({title:`Remove ${s.target}?`, body:'They stop getting updates. They can subscribe again from the page.', confirm:'Remove subscriber'}, async () => { await api('DELETE', `${V1}/status-subscribers/${s.id}`); toast('Subscriber removed.'); refresh(); })}, 'Remove')))))));
|
|
}
|
|
S.headerAction = null;
|
|
const r = await api('GET', V1 + '/statuspage'), c = r.settings;
|
|
const f = { title: h('input', {value:c.title}), desc: h('textarea', {}, c.description), logo: h('input', {type:'url', value:c.logoUrl, placeholder:'https://example.com/logo.svg'}),
|
|
accent: h('input', {value:c.accent, placeholder:'#1f5c5c'}), support: h('input', {value:c.supportUrl, placeholder:'https://example.com/support or mailto:help@example.com'}),
|
|
footer: h('input', {value:c.footer}), domain: h('input', {value:c.domain, placeholder:'status.example.com'}) };
|
|
const save = act(async () => {
|
|
await api('PUT', V1 + '/statuspage', {title:f.title.value, description:f.desc.value, logoUrl:f.logo.value, accent:f.accent.value, supportUrl:f.support.value, footer:f.footer.value, domain:f.domain.value});
|
|
toast('Status page saved.'); refresh();
|
|
});
|
|
if (!admin) [...Object.values(f)].forEach(i => i.disabled = true);
|
|
return h('div', {}, tabs,
|
|
card('Address', h('div', {class:'stack'}, h('p', {class:'small dim', style:'margin:0'}, 'Monitors and incidents appear here when you mark them to show on the public status page.'),
|
|
h('div', {}, h('div', {class:'small dim'}, 'Status page'), h('a', {href:r.pageUrl, target:'_blank', rel:'noopener', class:'mono'}, r.pageUrl)),
|
|
h('div', {}, h('div', {class:'small dim'}, 'RSS feed'), h('a', {href:r.feedUrl, target:'_blank', rel:'noopener', class:'mono'}, r.feedUrl)),
|
|
r.email ? null : h('p', {class:'small dim', style:'margin:0'}, 'Email subscriptions are off until an SMTP server is set (SANDBAR_SMTP_HOST). Webhooks and RSS work without it.'))),
|
|
card('Branding', h('div', {class:'stack'}, field('Title', f.title), field('Description', f.desc, 'Shown under the title.'), h('div', {class:'grid'}, field('Logo address', f.logo, 'An https:// address of an image.'),
|
|
field('Accent color', f.accent, 'Buttons and links, as #rrggbb. Leave empty for the default. White text must stay readable on it.')),
|
|
h('div', {class:'grid'}, field('Contact link', f.support, 'Adds a Contact us button.'), field('Footer text', f.footer)))),
|
|
card('Custom domain', h('div', {class:'stack'}, field('Host name', f.domain, 'Serve the page at the root of this host name, such as status.example.com. Leave empty to use the address above only.'),
|
|
h('p', {class:'small dim', style:'margin:0'}, 'Point a DNS record for it at the server that runs Sandbar, and have your reverse proxy forward that host name to Sandbar with the Host header unchanged. Sandbar serves only the status page on that host. The proxy provides the certificate.'))),
|
|
admin ? h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:save}, 'Save status page')) : null);
|
|
};
|
|
function groupDialog(g) {
|
|
const e = !!g; g = g || {order:(S.sgroups || []).length + 1};
|
|
const f = { name: h('input', {value:g.name || ''}), desc: h('input', {value:g.description || ''}), order: h('input', {type:'number', value:g.order})};
|
|
dialogForm(e ? 'Edit ' + g.name : 'Add a group', null, [field('Name', f.name), field('Description', f.desc, 'Optional, shown under the name.'), field('Order', f.order, 'Groups are listed from the lowest number to the highest.')], e ? 'Save changes' : 'Add group',
|
|
async () => { await api(e ? 'PUT' : 'POST', V1 + '/status-groups' + (e ? '/' + g.id : ''), {name:f.name.value, description:f.desc.value, order:+f.order.value || 0}); toast('Group saved.'); refresh(); });
|
|
}
|
|
async function maintenanceDialog(m) {
|
|
const e = !!m, mons = await publicMonitors(), soon = Math.ceil(Date.now() / 3600000) * 3600 + 3600;
|
|
m = m || {start:soon, end:soon + 3600, components:[]};
|
|
const picker = componentPicker(mons, m.components);
|
|
const f = { title: h('input', {value:m.title || ''}), msg: h('textarea', {}, m.message || ''), start: h('input', {type:'datetime-local', value:fromUnix(m.start)}), end: h('input', {type:'datetime-local', value:fromUnix(m.end)}) };
|
|
dialogForm(e ? 'Edit ' + m.title : 'Schedule maintenance', 'Subscribers are told now, when it starts and when it ends. Times are in your time zone.',
|
|
[field('Title', f.title, 'What is happening, in a few words.'), field('Details', f.msg, 'What customers should expect.'), h('div', {class:'grid'}, field('Starts', f.start), field('Ends', f.end)), field('Affected components', picker, 'They show as under maintenance during the window.')],
|
|
e ? 'Save changes' : 'Schedule maintenance',
|
|
async () => { await api(e ? 'PUT' : 'POST', V1 + '/maintenance' + (e ? '/' + m.id : ''), {title:f.title.value, message:f.msg.value, start:toUnix(f.start.value), end:toUnix(f.end.value), components:picker.values()}); toast('Maintenance saved.'); refresh(); });
|
|
}
|
|
|
|
// ---------- 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>
|