Files
Akili 86215f1eb1 feat: add visitor identity cookie and database reset job
Three connected features for the deployment-strategy demo:

1. Visitor identity cookie (v3). A new visitors table backs a first-party
   gb_visitor cookie: middleware on /api checks the cookie's UUID against
   the database on every request and regenerates + persists it when the id
   is missing, malformed, or unknown (fresh browser, pruned identity, or a
   reset database). The identity remembers the name a visitor signed with
   (pre-filled next time, used for unsigned posts), replaces the standalone
   reaction cookie for reaction de-dupe, and feeds a live "N visiting"
   counter (identities seen in the last 5 minutes) shown on the wall, in
   /api/info, the admin overview, and the presence/tick SSE events. The
   admin console gains Visitors/Visiting tiles and a visitors table, plus a
   GET /api/me endpoint and GET /api/admin/visitors.

2. guestbook reset command. Wipes entries (including soft-deleted rows),
   reactions and visitor identities under the schema advisory lock, with
   --dry-run, --hard (also reset wall settings) and --reseed flags; meant
   to run as a one-off Miabi Job. After a reset every outstanding cookie is
   invalidated, so browsers get a fresh identity on their next request.

3. cleanup learns --prune-visitors (default 30 days): the cron job now also
   forgets idle identities together with their reactions.

Docs (README, DEMO) updated: act 8 covers reset, the visitor-identity
talking point is in act 3, and the API tables list the new endpoints.

Akili-Task: tsk_ptdi5ufndrzwlljk4v5g
Akili-Agent: goldfish
2026-10-07 19:06:07 +02:00

826 lines
46 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Guestbook Admin</title>
<meta name="robots" content="noindex" />
<style>
:root {
--bg: #0b0a12;
--bg-2: #12101d;
--card: rgba(255, 255, 255, 0.04);
--card-border: rgba(255, 255, 255, 0.08);
--text: #ece9f5;
--muted: #9b95b3;
--accent: #8b5cf6;
--accent-2: #a855f7;
--danger: #f43f5e;
--warn: #f59e0b;
--ok: #22c55e;
--radius: 16px;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--text);
background:
radial-gradient(900px 500px at 15% -10%, rgba(139, 92, 246, 0.16), transparent 60%),
radial-gradient(700px 500px at 110% 10%, rgba(168, 85, 247, 0.12), transparent 55%),
linear-gradient(180deg, var(--bg), var(--bg-2));
background-attachment: fixed;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 1080px; margin: 0 auto; padding: 28px 20px 80px; }
[hidden] { display: none !important; }
a { color: var(--accent-2); }
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 22px; }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 750; font-size: 18px; letter-spacing: -0.01em; }
.brand .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 12px 2px var(--accent); }
.brand small { font-weight: 500; color: var(--muted); font-size: 13px; }
.top-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.chip {
display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--muted);
padding: 5px 12px; border-radius: 999px; background: var(--card); border: 1px solid var(--card-border); white-space: nowrap;
}
.chip b { color: var(--text); font-weight: 600; font-family: var(--mono); font-size: 12px; }
.ver { font-size: 12.5px; font-weight: 700; color: #fff; padding: 5px 12px; border-radius: 999px; background: var(--accent); }
.card { background: var(--card); border: 1px solid var(--card-border); border-radius: var(--radius); backdrop-filter: blur(12px); }
.pad { padding: 20px; }
h2 { font-size: 13px; font-weight: 650; color: var(--muted); margin: 0 0 14px; letter-spacing: 0.06em; text-transform: uppercase; }
h3 { font-size: 15px; margin: 0 0 6px; }
p.hint { color: var(--muted); font-size: 13.5px; line-height: 1.55; margin: 0 0 14px; }
code { font-family: var(--mono); font-size: 12.5px; background: rgba(0,0,0,.3); padding: 1px 6px; border-radius: 6px; }
button, .btn {
font: inherit; font-size: 13.5px; font-weight: 600; color: var(--text); cursor: pointer; text-decoration: none;
background: rgba(255,255,255,.06); border: 1px solid var(--card-border); border-radius: 10px; padding: 8px 14px;
transition: border-color .15s, background .15s, opacity .15s; display: inline-flex; align-items: center; gap: 6px;
}
button:hover:not(:disabled), .btn:hover { border-color: var(--accent); }
button:disabled { opacity: .45; cursor: not-allowed; }
button.primary { color: #fff; border: 0; background: linear-gradient(120deg, var(--accent), var(--accent-2)); }
button.danger { color: #fecdd3; border-color: rgba(244, 63, 94, .4); background: rgba(244, 63, 94, .12); }
button.danger:hover:not(:disabled) { border-color: var(--danger); }
button.small { font-size: 12.5px; padding: 5px 10px; border-radius: 8px; }
input, select, textarea {
font: inherit; font-size: 14px; color: var(--text); background: rgba(0, 0, 0, 0.28);
border: 1px solid var(--card-border); border-radius: 10px; padding: 9px 12px; outline: none;
}
input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.22); }
select option { background: var(--bg-2); }
.tabs { display: inline-flex; gap: 4px; padding: 4px; border-radius: 12px; background: var(--card); border: 1px solid var(--card-border); margin-bottom: 20px; flex-wrap: wrap; }
.tabs a { text-decoration: none; color: var(--muted); font-size: 14px; font-weight: 600; padding: 8px 16px; border-radius: 9px; }
.tabs a.active { color: #fff; background: linear-gradient(120deg, var(--accent), var(--accent-2)); }
.tabs a:not(.active):hover { color: var(--text); }
.center-card { max-width: 440px; margin: 60px auto; text-align: center; }
.center-card input { width: 100%; margin: 14px 0 12px; text-align: center; }
.center-card button { width: 100%; justify-content: center; padding: 11px; }
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 16px; }
.tile { padding: 16px 18px; }
.tile .label { font-size: 11.5px; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); }
.tile .value { font-size: 28px; font-weight: 800; margin-top: 4px; font-variant-numeric: tabular-nums; }
.tile.live .value { color: var(--ok); }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 820px) { .grid-2 { grid-template-columns: 1fr; } }
.stack { display: grid; gap: 16px; }
.chart { display: flex; align-items: flex-end; gap: 6px; height: 150px; padding-top: 8px; }
.bar-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; height: 100%; justify-content: flex-end; min-width: 0; }
.bar { width: 100%; max-width: 34px; border-radius: 6px 6px 3px 3px; background: linear-gradient(180deg, var(--accent-2), var(--accent)); min-height: 3px; position: relative; }
.bar.zero { background: rgba(255,255,255,.08); }
.bar-col .n { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
.bar-col .d { font-size: 10.5px; color: var(--muted); white-space: nowrap; }
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
th { text-align: left; font-weight: 600; color: var(--muted); font-size: 12px; padding: 0 8px 8px; border-bottom: 1px solid var(--card-border); }
td { padding: 9px 8px; border-bottom: 1px solid rgba(255,255,255,.04); vertical-align: top; }
td.mono { font-family: var(--mono); font-size: 12.5px; word-break: break-all; }
tr.me td { color: #fff; }
tr.me td:first-child::after { content: " ← you"; color: var(--accent-2); font-family: inherit; font-size: 11.5px; }
td.fresh { color: var(--ok); }
td.fresh::before { content: "● "; font-size: 10px; }
dl.kv { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; font-size: 13.5px; }
dl.kv dt { color: var(--muted); }
dl.kv dd { margin: 0; font-family: var(--mono); font-size: 12.5px; word-break: break-all; }
.dot-s { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: 1px; }
.ok { background: var(--ok); } .bad { background: var(--danger); } .off { background: var(--muted); }
.toolbar { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-bottom: 14px; }
.toolbar input[type=search] { flex: 1 1 220px; }
.seg { display: inline-flex; gap: 2px; padding: 3px; border-radius: 10px; background: rgba(0,0,0,.25); border: 1px solid var(--card-border); }
.seg button { border: 0; background: transparent; padding: 6px 12px; border-radius: 8px; font-size: 13px; color: var(--muted); }
.seg button.on { background: rgba(139, 92, 246, .35); color: #fff; }
.rows { display: grid; gap: 10px; }
.row { padding: 14px 16px; display: grid; grid-template-columns: 1fr auto; gap: 8px 14px; align-items: start; }
.row.is-hidden { opacity: .6; border-style: dashed; }
.row.is-pinned { border-color: rgba(139, 92, 246, 0.55); }
.row .head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 13px; }
.row .who { font-weight: 650; font-size: 14px; }
.row .when, .row .id { color: var(--muted); }
.row .msg { grid-column: 1 / -1; font-size: 14.5px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; }
.row .acts { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.badge { font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; padding: 2px 8px; border-radius: 999px; }
.badge.pin { color: #ddd6fe; background: rgba(139, 92, 246, .3); }
.badge.hid { color: #fde68a; background: rgba(245, 158, 11, .2); }
.ip { font-family: var(--mono); font-size: 11.5px; color: #a5f3fc; background: rgba(34, 211, 238, .1); border: 1px solid rgba(34, 211, 238, .25); padding: 1px 8px; border-radius: 999px; cursor: pointer; }
.rep { font-family: var(--mono); font-size: 11.5px; color: var(--muted); background: rgba(0,0,0,.25); border: 1px solid var(--card-border); padding: 1px 8px; border-radius: 999px; }
@media (max-width: 620px) { .row { grid-template-columns: 1fr; } .row .acts { justify-content: flex-start; } }
.pager { display: flex; align-items: center; justify-content: center; gap: 14px; margin-top: 16px; }
.pager .info { font-size: 13px; color: var(--muted); min-width: 110px; text-align: center; }
.empty { text-align: center; color: var(--muted); padding: 36px 20px; }
.switch { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 4px 0 18px; border-bottom: 1px solid var(--card-border); margin-bottom: 18px; }
.toggle { position: relative; width: 46px; height: 26px; flex: 0 0 auto; }
.toggle input { opacity: 0; width: 0; height: 0; position: absolute; }
.toggle span { position: absolute; inset: 0; border-radius: 999px; background: rgba(255,255,255,.12); cursor: pointer; transition: background .15s; }
.toggle span::after { content: ""; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; transition: transform .15s; }
.toggle input:checked + span { background: var(--warn); }
.toggle input:checked + span::after { transform: translateX(20px); }
.toggle input:focus-visible + span { box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.4); }
label.field { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; }
.field-row { display: flex; gap: 10px; align-items: center; }
.field-row input { flex: 1; }
.chaos-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-bottom: 16px; }
.chaos { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
.chaos p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; flex: 1; }
.chaos .inputs { display: flex; gap: 8px; }
.chaos .inputs input, .chaos .inputs select { width: 100%; min-width: 0; padding: 7px 10px; font-size: 13px; }
.chaos .shows { font-size: 11.5px; color: var(--accent-2); }
.probe-bars { display: grid; gap: 8px; margin-top: 14px; }
.pb { display: grid; grid-template-columns: minmax(120px, 260px) 1fr 96px; gap: 10px; align-items: center; font-size: 13px; }
.pb .k { font-family: var(--mono); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pb .track { height: 12px; border-radius: 999px; background: rgba(255,255,255,.06); overflow: hidden; }
.pb .fill { height: 100%; border-radius: 999px; }
.pb .v { text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
@media (max-width: 620px) { .pb { grid-template-columns: 1fr 60px; } .pb .track { grid-column: 1 / -1; grid-row: 2; } }
.log { font-family: var(--mono); font-size: 12px; line-height: 1.7; max-height: 260px; overflow: auto; background: rgba(0,0,0,.3); border-radius: 10px; padding: 10px 12px; }
.log div { white-space: pre-wrap; word-break: break-word; }
.log .t { color: var(--muted); }
.log .e { color: #fda4af; } .log .s { color: #86efac; } .log .w { color: #fcd34d; }
.toast { position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%) translateY(20px); background: #1f1b2e; border: 1px solid var(--card-border);
color: white; padding: 11px 18px; border-radius: 12px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s; }
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast.err { background: var(--danger); }
</style>
</head>
<body>
<div class="wrap">
<div class="topbar">
<div class="brand"><span class="dot"></span> Guestbook Admin <small id="appName"></small></div>
<div class="top-right">
<span class="chip" title="Replica that answered the last request">served by <b id="servedBy">…</b></span>
<span class="ver" id="ver">v…</span>
<a class="btn" href="/">← Wall</a>
<button id="logout" hidden>Sign out</button>
</div>
</div>
<section id="view-disabled" class="card pad center-card" hidden>
<h3>Admin console is disabled</h3>
<p class="hint">Set an <code>ADMIN_TOKEN</code> secret on the app and redeploy. On Miabi, add it under the app's environment as a <b>secret</b>.</p>
</section>
<section id="view-login" hidden>
<form class="card pad center-card" id="loginForm">
<h3>Sign in</h3>
<p class="hint">Enter the value of the app's <code>ADMIN_TOKEN</code>.</p>
<input id="token" type="password" placeholder="Admin token" autocomplete="current-password" required />
<button class="primary" type="submit">Sign in</button>
</form>
</section>
<section id="view-console" hidden>
<nav class="tabs">
<a href="#overview" data-tab="overview">Overview</a>
<a href="#moderation" data-tab="moderation">Moderation</a>
<a href="#settings" data-tab="settings">Settings</a>
<a href="#chaos" data-tab="chaos">Chaos lab</a>
</nav>
<div id="tab-overview" data-panel>
<div class="tiles">
<div class="card tile live"><div class="label">Online now</div><div class="value" id="tOnline">–</div></div>
<div class="card tile"><div class="label">Visiting</div><div class="value" id="tVisiting" title="Visitor identities seen in the last 5 minutes">–</div></div>
<div class="card tile"><div class="label">Visitors</div><div class="value" id="tVisitors" title="Identity cookies the app has issued (rows in the visitors table)">–</div></div>
<div class="card tile"><div class="label">Replicas</div><div class="value" id="tReplicas">–</div></div>
<div class="card tile"><div class="label">Signatures</div><div class="value" id="tTotal">–</div></div>
<div class="card tile"><div class="label">Today</div><div class="value" id="tToday">–</div></div>
<div class="card tile"><div class="label">Reactions</div><div class="value" id="tReacts">–</div></div>
<div class="card tile"><div class="label">Pinned</div><div class="value" id="tPinned">–</div></div>
<div class="card tile"><div class="label">Hidden</div><div class="value" id="tHidden">–</div></div>
</div>
<div class="stack">
<div class="card pad">
<h2>Signatures · last 14 days</h2>
<div class="chart" id="chart"></div>
</div>
<div class="grid-2">
<div class="card pad">
<h2>Replicas</h2>
<p class="hint" id="replicaHint"></p>
<table>
<thead><tr><th>Host</th><th>Version</th><th>Clients</th><th title="HTTP requests answered since this replica started — a rollout resets them one at a time">Requests</th><th>Seen</th></tr></thead>
<tbody id="replicaRows"></tbody>
</table>
</div>
<div class="card pad">
<h2>This replica</h2>
<dl class="kv" id="runtime"></dl>
</div>
</div>
<div class="card pad">
<h2>Visitors</h2>
<p class="hint">Every browser gets an identity cookie (<code>gb_visitor</code>); the backend accepts it only while its id exists in the database — after a <code>guestbook reset</code> every cookie is regenerated on the next request. Idle identities are pruned by the <code>cleanup</code> job (<code>--prune-visitors</code>).</p>
<table>
<thead><tr><th>Visitor</th><th>Name</th><th>Signatures</th><th>First seen</th><th>Last seen</th></tr></thead>
<tbody id="visitorRows"></tbody>
</table>
</div>
</div>
</div>
<div id="tab-moderation" data-panel hidden>
<div class="toolbar">
<input type="search" id="q" placeholder="Search name, message or IP…" />
<div class="seg" id="statusSeg">
<button data-status="" class="on">All</button>
<button data-status="visible">Visible</button>
<button data-status="hidden">Hidden</button>
<button data-status="pinned">Pinned</button>
</div>
<a class="btn" href="/api/admin/export.csv" download>Export CSV</a>
</div>
<div class="rows" id="rows"></div>
<div class="pager">
<button id="prev">← Newer</button>
<span class="info" id="pageInfo"></span>
<button id="next">Older →</button>
</div>
</div>
<div id="tab-settings" data-panel hidden>
<form class="card pad" id="settingsForm" style="max-width:640px">
<h2>Wall settings</h2>
<div class="switch">
<div>
<h3>Pause signing</h3>
<p class="hint" style="margin:0">Make the wall read-only on every replica. Stored in the database and pushed live to every open tab.</p>
</div>
<label class="toggle"><input type="checkbox" id="paused" /><span></span></label>
</div>
<label class="field" for="banner">Announcement banner <span id="bannerCount">0 / 200</span></label>
<div class="field-row">
<input id="banner" maxlength="200" placeholder="e.g. Welcome to the Miabi training 👋" />
<button class="primary" type="submit">Save</button>
</div>
<label class="field" for="theme" style="margin-top:16px">Wall theme</label>
<div class="field-row">
<select id="theme">
<option value="system">System — follow each visitor's preference</option>
<option value="light">Force light</option>
<option value="dark">Force dark</option>
</select>
</div>
<p class="hint" style="margin:8px 0 0">Pushed live to every open tab. Visitors can toggle light/dark themselves unless you force one here.</p>
</form>
</div>
<div id="tab-chaos" data-panel hidden>
<div class="card pad" id="chaosOff" hidden style="margin-bottom:16px">
<h3>Chaos lab is disabled</h3>
<p class="hint" style="margin:0">Set <code>DEBUG_ENDPOINTS=true</code> on the app to enable failure injection. The traffic probe below works either way.</p>
</div>
<div class="chaos-grid" id="chaosGrid">
<div class="card chaos">
<h3>💥 Crash replica</h3>
<p>Exit the process with status 1. Miabi's restart policy brings it back.</p>
<span class="shows">Shows: restarts · alerts · events</span>
<button class="danger" data-chaos="crash">Crash it</button>
</div>
<div class="card chaos">
<h3>🩺 Fail health check</h3>
<p>Make <code>/healthz</code> return 503 on the replica that receives this.</p>
<span class="shows">Shows: health gating · rollout safety</span>
<div class="inputs">
<button class="danger" data-chaos="unhealthy" style="flex:1;justify-content:center">Unhealthy</button>
<button data-chaos="healthy" style="flex:1;justify-content:center">Healthy</button>
</div>
</div>
<div class="card chaos">
<h3>🔥 CPU burn</h3>
<p>Saturate every core for a while.</p>
<span class="shows">Shows: metrics · resource alerts</span>
<div class="inputs"><input id="cpuSec" type="number" min="1" max="120" value="30" title="Seconds" /><button data-chaos="cpu">Burn</button></div>
</div>
<div class="card chaos">
<h3>🧠 Hold memory</h3>
<p>Allocate MiB for N seconds. Exceed the memory limit to trigger an OOM restart.</p>
<span class="shows">Shows: memory limits · OOM kill</span>
<div class="inputs">
<input id="memMb" type="number" min="1" max="2048" value="256" title="MiB" />
<input id="memSec" type="number" min="1" max="120" value="30" title="Seconds" />
<button data-chaos="memory">Hold</button>
</div>
</div>
<div class="card chaos">
<h3>📜 Log burst</h3>
<p>Write a burst of log lines.</p>
<span class="shows">Shows: live logs · search</span>
<div class="inputs">
<input id="logCount" type="number" min="1" max="1000" value="20" title="Lines" />
<select id="logLevel"><option>error</option><option>warn</option><option>info</option></select>
<button data-chaos="logs">Write</button>
</div>
</div>
</div>
<div class="card pad" style="margin-bottom:16px">
<h2>Traffic probe</h2>
<p class="hint">Fire requests from this browser and tally who answered. With replicas you'll see the load spread; during a canary you'll see the traffic split by version.</p>
<div class="toolbar" style="margin-bottom:0">
<select id="probeTarget">
<option value="/api/time">/api/time (fast)</option>
<option value="/api/debug/slow?ms=400" data-debug>/api/debug/slow?ms=400 (latency)</option>
<option value="/api/debug/error?status=500" data-debug>/api/debug/error (5xx)</option>
</select>
<input id="probeN" type="number" min="1" max="1000" value="100" style="width:90px" title="Requests" />
<input id="probeC" type="number" min="1" max="20" value="6" style="width:70px" title="Concurrency" />
<button class="primary" id="probeRun">Run probe</button>
<span class="chip" id="probeStat" hidden></span>
</div>
<div class="probe-bars" id="probeBars"></div>
</div>
<div class="card pad" style="margin-bottom:16px">
<h2>Rolling-update wave</h2>
<p class="hint">Watch what Miabi does during a <b>rolling update</b>: this tool bounces <i>one replica at a time</i> with the chaos endpoint while polling the replica table. Each restarted replica comes back with its request counter reset and, during a canary, a new version. Needs debug endpoints; run it with Redis attached so every replica is listed.</p>
<div class="toolbar" style="margin-bottom:0">
<button class="primary" id="waveRun">Bounce each replica, one at a time</button>
<span class="chip" id="waveStat" hidden></span>
</div>
</div>
<div class="card pad">
<h2>Activity</h2>
<div class="log" id="log"><div class="t">Actions and their responses appear here.</div></div>
</div>
</div>
</section>
</div>
<div class="toast" id="toast"></div>
<script>
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const PAGE = 15;
let toastTimer;
function toast(msg, err) {
const t = $('toast'); t.textContent = msg; t.classList.toggle('err', !!err); t.classList.add('show');
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 3000);
}
async function req(method, url, body) {
const opts = { method, headers: {}, cache: 'no-store' };
if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
const res = await fetch(url, opts);
const data = res.status === 204 ? null : await res.json().catch(() => null);
if (res.status === 401) showView('login');
return { ok: res.ok, status: res.status, data };
}
function hueFor(s) { let h = 0; for (const c of String(s)) h = (h * 31 + c.charCodeAt(0)) >>> 0; return h % 360; }
function setVersion(v) { const el = $('ver'); el.textContent = 'v' + v; el.style.background = `hsl(${hueFor(v)} 70% 52%)`; }
function ago(iso) {
const d = (Date.now() - new Date(iso).getTime()) / 1000;
if (d < 5) return 'now';
if (d < 60) return Math.floor(d) + 's ago';
if (d < 3600) return Math.floor(d / 60) + 'm ago';
if (d < 86400) return Math.floor(d / 3600) + 'h ago';
return new Date(iso).toLocaleDateString();
}
function uptime(s) {
const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60);
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : m ? `${m}m ${s % 60}s` : `${s}s`;
}
// ---- views + tabs ------------------------------------------------------
function showView(v) {
for (const k of ['disabled', 'login', 'console']) $('view-' + k).hidden = k !== v;
$('logout').hidden = v !== 'console';
if (v === 'console') { route(); connectSSE(); } else { stopPolling(); }
}
function currentTab() { const t = location.hash.slice(1); return ['overview', 'moderation', 'settings', 'chaos'].includes(t) ? t : 'overview'; }
function route() {
const tab = currentTab();
document.querySelectorAll('[data-panel]').forEach((p) => { p.hidden = p.id !== 'tab-' + tab; });
document.querySelectorAll('.tabs a').forEach((a) => a.classList.toggle('active', a.dataset.tab === tab));
stopPolling();
if (tab === 'overview') { loadOverview(); pollTimer = setInterval(loadOverview, 5000); }
if (tab === 'moderation') loadRows();
if (tab === 'settings') loadSettings();
if (tab === 'chaos') loadOverview();
}
let pollTimer;
function stopPolling() { clearInterval(pollTimer); }
window.addEventListener('hashchange', () => { if (!$('view-console').hidden) route(); });
// ---- overview ----------------------------------------------------------
let overview = null;
async function loadOverview() {
const r = await req('GET', '/api/admin/overview');
if (!r.ok || !r.data) return;
overview = r.data;
const { stats, runtime: rt, replicas, online, debug } = overview;
$('waveRun').disabled = !debug;
$('servedBy').textContent = rt.host;
$('appName').textContent = rt.app;
setVersion(rt.version);
$('tOnline').textContent = online;
$('tVisiting').textContent = overview.visitors ?? 0;
$('tVisitors').textContent = stats.visitors ?? 0;
$('tReplicas').textContent = replicas.length;
$('tTotal').textContent = stats.total;
$('tToday').textContent = stats.today;
$('tReacts').textContent = stats.reactions ?? 0;
$('tPinned').textContent = stats.pinned;
$('tHidden').textContent = stats.hidden;
renderChart(stats.daily || []);
loadVisitors();
$('replicaHint').innerHTML = rt.broker === 'redis'
? 'Presence is shared through Redis, so every replica is listed. The requests counter grows per replica and resets when a rollout replaces it.'
: 'In-process broker: only this replica is visible. Attach Redis (<code>REDIS_URL</code>) to share live updates across replicas.';
$('replicaRows').innerHTML = replicas.map((r) => `
<tr class="${r.host === rt.host ? 'me' : ''}">
<td class="mono">${esc(r.host)}</td>
<td><span class="ver" style="background:hsl(${hueFor(r.version)} 70% 52%);font-size:11px;padding:2px 8px">v${esc(r.version)}</span></td>
<td>${r.online}</td>
<td>${r.requests ?? '–'}</td>
<td>${ago(r.seen)}</td>
</tr>`).join('');
const status = (v) => v === 'up' ? '<span class="dot-s ok"></span>up' : v === 'down' ? '<span class="dot-s bad"></span>down' : '<span class="dot-s off"></span>' + esc(v);
const kv = [
['Host', esc(rt.host)],
['Version', esc(rt.version)],
['Health', rt.healthy ? '<span class="dot-s ok"></span>healthy' : '<span class="dot-s bad"></span>forced unhealthy'],
['Database', esc(rt.db)],
['Live updates', rt.broker === 'redis' ? 'Redis pub/sub' : 'in-process'],
['Redis', status(rt.redis)],
['Uptime', uptime(rt.uptime_s)],
['Requests served', rt.requests ?? 0],
['Go', `${esc(rt.go)} · ${rt.cpus} CPU`],
['Goroutines', rt.goroutines],
['Heap / Sys', `${rt.heap_mb.toFixed(1)} / ${rt.sys_mb.toFixed(1)} MiB`],
['Debug endpoints', debug ? 'enabled' : 'disabled'],
];
$('runtime').innerHTML = kv.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('');
$('chaosOff').hidden = debug;
document.querySelectorAll('[data-chaos]').forEach((b) => { b.disabled = !debug; });
document.querySelectorAll('#probeTarget option[data-debug]').forEach((o) => { o.disabled = !debug; });
}
function renderChart(daily) {
const maxN = Math.max(1, ...daily.map((d) => d.count));
$('chart').innerHTML = daily.map((d, i) => {
const pct = Math.round((d.count / maxN) * 100);
const day = new Date(d.day + 'T00:00:00Z');
const label = day.toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' });
const show = i % 2 === (daily.length - 1) % 2;
return `<div class="bar-col" title="${label}: ${d.count}">
<span class="n">${d.count || ''}</span>
<div class="bar ${d.count ? '' : 'zero'}" style="height:${d.count ? pct : 0}%"></div>
<span class="d">${show ? label : '&nbsp;'}</span>
</div>`;
}).join('');
}
// ---- visitors ----------------------------------------------------------
async function loadVisitors() {
const r = await req('GET', '/api/admin/visitors?limit=25');
if (!r.ok || !r.data) return;
const activeSince = new Date(r.data.active_since || 0).getTime();
const rows = r.data.visitors || [];
$('visitorRows').innerHTML = rows.length ? rows.map((v) => `
<tr>
<td class="mono">${esc(String(v.id).slice(0, 8))}…</td>
<td>${esc(v.name || '–')}</td>
<td>${v.signatures}</td>
<td title="${esc(new Date(v.first_seen).toLocaleString())}">${ago(v.first_seen)}</td>
<td class="${new Date(v.last_seen).getTime() >= activeSince ? 'fresh' : ''}" title="${esc(new Date(v.last_seen).toLocaleString())}">${ago(v.last_seen)}</td>
</tr>`).join('') : '<tr><td colspan="5" class="empty">No visitor identities yet — they are created on the first request.</td></tr>';
}
// ---- moderation --------------------------------------------------------
const mod = { status: '', q: '', offset: 0 };
async function loadRows() {
const qs = new URLSearchParams({ status: mod.status, q: mod.q, limit: PAGE, offset: mod.offset });
const r = await req('GET', '/api/admin/entries?' + qs);
if (!r.ok) return;
const { entries, total } = r.data;
if (!entries.length && mod.offset > 0) { mod.offset = Math.max(0, mod.offset - PAGE); return loadRows(); }
$('rows').innerHTML = entries.length ? entries.map(rowHTML).join('') : '<div class="empty card">No entries match.</div>';
const from = total ? mod.offset + 1 : 0, to = Math.min(mod.offset + PAGE, total);
$('pageInfo').textContent = `${from}–${to} of ${total}`;
$('prev').disabled = mod.offset === 0;
$('next').disabled = mod.offset + PAGE >= total;
}
function rowHTML(e) {
const rep = e.replica ? `<span class="rep" title="Accepted by">${esc(e.replica)}</span>` : '';
const ip = e.ip ? `<span class="ip" data-ip="${esc(e.ip)}" title="Submitter IP — click to filter">${esc(e.ip)}</span>` : '';
const reacts = Object.entries(e.reactions || {}).map(([k, n]) => `${({ heart: '❤️', tada: '🎉', thumbsup: '👍', smile: '😄' })[k] || k} ${n}`).join(' · ');
const reactsChip = reacts ? `<span class="rep" title="Reactions">${esc(reacts)}</span>` : '';
return `<div class="card row ${e.hidden ? 'is-hidden' : ''} ${e.pinned ? 'is-pinned' : ''}" data-id="${e.id}">
<div class="head">
<span class="who">${esc(e.name)}</span>
<span class="when" title="${esc(new Date(e.created_at).toLocaleString())}">${ago(e.created_at)}</span>
<span class="id">#${e.id}</span>
${e.pinned ? '<span class="badge pin">Pinned</span>' : ''}
${e.hidden ? '<span class="badge hid">Hidden</span>' : ''}
${reactsChip}
${ip}
${rep}
</div>
<div class="acts">
<button class="small" data-act="pin">${e.pinned ? 'Unpin' : '📌 Pin'}</button>
<button class="small" data-act="hide">${e.hidden ? 'Show' : 'Hide'}</button>
<button class="small danger" data-act="delete">Delete</button>
</div>
<div class="msg">${esc(e.message)}</div>
</div>`;
}
$('rows').addEventListener('click', async (ev) => {
const btn = ev.target.closest('button[data-act]');
if (!btn) return;
const row = btn.closest('.row');
const id = row.dataset.id;
const act = btn.dataset.act;
let r;
if (act === 'delete') {
if (!confirm(`Delete entry #${id}? It will disappear from every open tab.`)) return;
r = await req('DELETE', '/api/admin/entries/' + id);
} else {
const body = act === 'pin' ? { pinned: !row.classList.contains('is-pinned') } : { hidden: !row.classList.contains('is-hidden') };
r = await req('PATCH', '/api/admin/entries/' + id, body);
}
if (!r.ok) toast((r.data && r.data.error) || 'Action failed', true);
loadRows();
});
$('rows').addEventListener('click', (ev) => {
const chip = ev.target.closest('.ip');
if (!chip) return;
$('q').value = chip.dataset.ip; mod.q = chip.dataset.ip; mod.offset = 0; loadRows();
});
let searchTimer;
$('q').addEventListener('input', (e) => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { mod.q = e.target.value; mod.offset = 0; loadRows(); }, 250); });
$('statusSeg').addEventListener('click', (e) => {
const b = e.target.closest('button'); if (!b) return;
document.querySelectorAll('#statusSeg button').forEach((x) => x.classList.toggle('on', x === b));
mod.status = b.dataset.status; mod.offset = 0; loadRows();
});
$('prev').addEventListener('click', () => { mod.offset = Math.max(0, mod.offset - PAGE); loadRows(); });
$('next').addEventListener('click', () => { mod.offset += PAGE; loadRows(); });
// ---- settings ----------------------------------------------------------
async function loadSettings() {
const r = await req('GET', '/api/admin/settings');
if (!r.ok) return;
$('paused').checked = !!r.data.signing_paused;
$('banner').value = r.data.banner || '';
$('theme').value = r.data.theme || 'system';
$('bannerCount').textContent = $('banner').value.length + ' / 200';
}
async function saveSettings() {
const r = await req('PUT', '/api/admin/settings', { signing_paused: $('paused').checked, banner: $('banner').value, theme: $('theme').value });
toast(r.ok ? 'Settings saved and broadcast' : ((r.data && r.data.error) || 'Could not save'), !r.ok);
}
$('banner').addEventListener('input', (e) => { $('bannerCount').textContent = e.target.value.length + ' / 200'; });
$('paused').addEventListener('change', saveSettings);
$('theme').addEventListener('change', saveSettings);
$('settingsForm').addEventListener('submit', (e) => { e.preventDefault(); saveSettings(); });
// ---- chaos lab ---------------------------------------------------------
function log(msg, kind) {
const el = $('log');
if (el.querySelector('.t:only-child')) el.innerHTML = '';
const line = document.createElement('div');
line.innerHTML = `<span class="t">${new Date().toLocaleTimeString()}</span> <span class="${kind || ''}">${esc(msg)}</span>`;
el.prepend(line);
}
const chaos = {
crash: () => ['POST', '/api/debug/crash'],
unhealthy: () => ['POST', '/api/debug/health', { healthy: false }],
healthy: () => ['POST', '/api/debug/health', { healthy: true }],
cpu: () => ['POST', `/api/debug/cpu?seconds=${$('cpuSec').value}`],
memory: () => ['POST', `/api/debug/memory?mb=${$('memMb').value}&seconds=${$('memSec').value}`],
logs: () => ['POST', `/api/debug/logs?count=${$('logCount').value}&level=${$('logLevel').value}`],
};
$('chaosGrid').addEventListener('click', async (e) => {
const b = e.target.closest('button[data-chaos]'); if (!b) return;
const kind = b.dataset.chaos;
if (kind === 'crash' && !confirm('Crash the replica that receives this request?')) return;
const [method, url, body] = chaos[kind]();
b.disabled = true;
try {
const r = await req(method, url, body);
const d = r.data || {};
log(`${method} ${url} → ${r.status}${d.host ? ' from ' + d.host : ''}${d.error ? ' · ' + d.error : ''}`, r.ok ? 's' : 'e');
if (kind === 'crash' && r.ok) watchRecovery(d.host);
} catch (err) { log(`${method} ${url} → network error`, 'e'); }
finally { setTimeout(() => { b.disabled = !(overview && overview.debug); }, 800); }
if (kind === 'unhealthy' || kind === 'healthy') loadOverview();
});
// Polls /healthz after a crash and reports when the crashed host (or a
// replacement) answers again, which is the restart made visible.
async function watchRecovery(crashed) {
const start = Date.now();
let sawDown = false;
log(`watching /healthz for ${crashed} to come back…`, 'w');
while (Date.now() - start < 120000) {
await new Promise((r) => setTimeout(r, 1000));
let status = 0, host = '';
try {
const res = await fetch('/healthz', { cache: 'no-store' });
status = res.status; host = ((await res.json().catch(() => ({}))).host) || '';
} catch { status = 0; }
const secs = Math.round((Date.now() - start) / 1000);
if (status !== 200) { if (!sawDown) log(`healthz → ${status || 'unreachable'} (${secs}s)`, 'e'); sawDown = true; continue; }
if (host && host !== crashed) { log(`served by ${host} after ${secs}s — another replica took the traffic`, 's'); return; }
if (sawDown || secs > 3) { log(`${host || 'app'} is back after ${secs}s`, 's'); loadOverview(); return; }
}
log('gave up watching after 120s', 'w');
}
// ---- rolling-update wave -----------------------------------------------
// Crashes replicas one at a time and waits for each to rejoin (its request
// counter restarts from ~0), mimicking — and explaining — what the platform
// does during a rolling update. Self-healing does the rest.
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
async function fetchOverviewQuiet() {
try {
const res = await fetch('/api/admin/overview', { cache: 'no-store' });
return res.ok ? await res.json() : null;
} catch { return null; }
}
$('waveRun').addEventListener('click', async () => {
if (!overview || !overview.debug) return;
if (!confirm('Restart every replica, one at a time, using the crash endpoint? The app self-heals — this is what a rolling update looks like from the inside.')) return;
$('waveRun').disabled = true;
$('waveStat').hidden = false;
try {
// Snapshot the fleet (in local mode this is just the answering replica).
const first = await fetchOverviewQuiet();
const targets = [...new Set((first ? first.replicas : []).map((r) => r.host))];
if (!targets.length) targets.push((first && first.runtime.host) || '');
let bounced = 0, hops = 0;
while (bounced < targets.length && hops < targets.length * 3 + 3) {
hops++;
const ov = await fetchOverviewQuiet();
if (!ov) { await sleep(1500); continue; }
const host = ov.runtime.host;
const idx = targets.indexOf(host);
if (idx < 0) { // already-bounced or unknown replica answered: keep hunting
$('waveStat').textContent = `looking for ${targets[bounced]}… (at ${host})`;
await req('POST', '/api/debug/crash'); // bounce it too, then move on
await sleep(4000);
continue;
}
targets.splice(idx, 1);
$('waveStat').textContent = `wave ${bounced + 1} → bouncing ${host}`;
log(`wave ${bounced + 1}: crashing ${host} (served ${ov.runtime.requests ?? '?'} requests since boot)`, 'w');
await req('POST', '/api/debug/crash');
// Wait until a *different* replica (or the same name with a fresh
// counter) answers again — that is the restart made visible.
const deadline = Date.now() + 90000;
let back = false;
while (Date.now() < deadline) {
await sleep(1500);
const cur = await fetchOverviewQuiet();
if (!cur) continue;
if (cur.runtime.host !== host || (cur.runtime.requests ?? 0) < (ov.runtime.requests ?? 0)) { back = true; break; }
}
log(back ? `${host} is back (request counter reset)` : `${host} did not come back within 90s`, back ? 's' : 'e');
bounced++;
}
$('waveStat').textContent = `wave done — ${bounced} replica${bounced === 1 ? '' : 's'} bounced`;
log(`rolling wave finished: ${bounced} replica(s) bounced, app stayed up`, 's');
} finally {
$('waveRun').disabled = !(overview && overview.debug);
loadOverview();
}
});
$('probeRun').addEventListener('click', async () => {
const url = $('probeTarget').value;
const n = Math.min(1000, Math.max(1, +$('probeN').value || 1));
const c = Math.min(20, Math.max(1, +$('probeC').value || 1));
const tally = new Map();
const lat = [];
let sent = 0, done = 0;
$('probeRun').disabled = true;
$('probeStat').hidden = false;
const t0 = performance.now();
const worker = async () => {
while (sent < n) {
sent++;
const s = performance.now();
let key;
try {
const res = await fetch(url, { cache: 'no-store' });
const d = await res.json().catch(() => ({}));
key = `${res.status} · v${d.version || '?'} · ${d.host || '?'}`;
if (res.status >= 400 && !d.version) key = `${res.status} · ${d.host || '?'}`;
} catch { key = 'network error'; }
lat.push(performance.now() - s);
tally.set(key, (tally.get(key) || 0) + 1);
done++;
if (done % 5 === 0 || done === n) renderProbe(tally, done, n, lat, t0);
}
};
await Promise.all(Array.from({ length: c }, worker));
renderProbe(tally, done, n, lat, t0);
log(`probe ${url}: ${n} requests, ${tally.size} distinct responders`, 's');
$('probeRun').disabled = false;
});
function renderProbe(tally, done, n, lat, t0) {
const sorted = [...lat].sort((a, b) => a - b);
const p = (q) => sorted.length ? Math.round(sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]) : 0;
const rps = done / ((performance.now() - t0) / 1000);
$('probeStat').textContent = `${done}/${n} · p50 ${p(0.5)}ms · p95 ${p(0.95)}ms · ${rps.toFixed(0)} req/s`;
const rows = [...tally.entries()].sort((a, b) => b[1] - a[1]);
$('probeBars').innerHTML = rows.map(([k, v]) => {
const pct = (v / done) * 100;
const color = k.startsWith('2') ? `hsl(${hueFor(k.split(' · ')[1] || k)} 70% 55%)` : 'var(--danger)';
return `<div class="pb"><span class="k" title="${esc(k)}">${esc(k)}</span>
<div class="track"><div class="fill" style="width:${pct}%;background:${color}"></div></div>
<span class="v">${v} · ${pct.toFixed(0)}%</span></div>`;
}).join('');
}
// ---- live updates ------------------------------------------------------
let es;
let refreshTimer;
function refreshSoon() {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => {
const tab = currentTab();
if (tab === 'moderation') loadRows();
if (tab === 'overview') loadOverview();
if (tab === 'settings') loadSettings();
}, 250);
}
function connectSSE() {
if (es) return;
es = new EventSource('/api/stream');
for (const t of ['created', 'updated', 'deleted', 'settings']) es.addEventListener(t, refreshSoon);
es.addEventListener('presence', (e) => { const d = JSON.parse(e.data); $('tOnline').textContent = d.online || 0; if (d.replicas) $('tReplicas').textContent = d.replicas; });
}
// ---- auth --------------------------------------------------------------
$('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const r = await req('POST', '/api/admin/login', { token: $('token').value });
if (r.ok) { $('token').value = ''; showView('console'); }
else toast((r.data && r.data.error) || 'Sign-in failed', true);
});
$('logout').addEventListener('click', async () => {
await req('POST', '/api/admin/logout');
if (es) { es.close(); es = null; }
showView('login');
});
(async () => {
const r = await req('GET', '/api/admin/session');
fetch('/api/info', { cache: 'no-store' }).then((x) => x.json()).then((d) => {
setVersion(d.version); $('servedBy').textContent = d.host; $('appName').textContent = d.app;
}).catch(() => {});
if (!r.ok || !r.data) return showView('login');
showView(!r.data.enabled ? 'disabled' : r.data.authenticated ? 'console' : 'login');
})();
</script>
</body>
</html>