feat: add admin console, Redis broker and chaos lab for training demo
This commit is contained in:
+707
@@ -0,0 +1,707 @@
|
||||
<!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; }
|
||||
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">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">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>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>
|
||||
</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>
|
||||
</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">
|
||||
<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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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;
|
||||
$('servedBy').textContent = rt.host;
|
||||
$('appName').textContent = rt.app;
|
||||
setVersion(rt.version);
|
||||
$('tOnline').textContent = online;
|
||||
$('tReplicas').textContent = replicas.length;
|
||||
$('tTotal').textContent = stats.total;
|
||||
$('tToday').textContent = stats.today;
|
||||
$('tPinned').textContent = stats.pinned;
|
||||
$('tHidden').textContent = stats.hidden;
|
||||
renderChart(stats.daily || []);
|
||||
|
||||
$('replicaHint').innerHTML = rt.broker === 'redis'
|
||||
? 'Presence is shared through Redis, so every replica is listed.'
|
||||
: '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>${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)],
|
||||
['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 : ' '}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ---- 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>` : '';
|
||||
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>' : ''}
|
||||
${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 || '';
|
||||
$('bannerCount').textContent = $('banner').value.length + ' / 200';
|
||||
}
|
||||
async function saveSettings() {
|
||||
const r = await req('PUT', '/api/admin/settings', { signing_paused: $('paused').checked, banner: $('banner').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);
|
||||
$('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');
|
||||
}
|
||||
|
||||
$('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>
|
||||
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="156" height="32" viewBox="0 0 156 32" role="img" aria-label="Running on Miabi">
|
||||
<title>Running on Miabi</title>
|
||||
<defs>
|
||||
<linearGradient id="mb-grad" x1="0" y1="0" x2="1024" y2="1024" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#c084fc"/>
|
||||
<stop offset=".33" stop-color="#a855f7"/>
|
||||
<stop offset=".68" stop-color="#9333ea"/>
|
||||
<stop offset="1" stop-color="#7e22ce"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x=".5" y=".5" width="155" height="31" rx="15.5" fill="#101A37" stroke="#ffffff" stroke-opacity=".08"/>
|
||||
<g transform="translate(8 4) scale(0.0234375)" fill="url(#mb-grad)">
|
||||
<path d="M 486 168 C 416 166 306 168 326 168 C 226 168 168 226 168 326 L 168 354 C 168 454 226 486 326 486 L 354 486 C 416 486 458 458 472 472 C 458 458 486 416 486 354 L 486 168 Z" transform="rotate(0 512 512)"/>
|
||||
<path d="M 486 168 C 416 166 306 168 326 168 C 226 168 168 226 168 326 L 168 354 C 168 454 226 486 326 486 L 354 486 C 416 486 458 458 472 472 C 458 458 486 416 486 354 L 486 168 Z" transform="rotate(90 512 512)"/>
|
||||
<path d="M 486 168 C 416 166 306 168 326 168 C 226 168 168 226 168 326 L 168 354 C 168 454 226 486 326 486 L 354 486 C 416 486 458 458 472 472 C 458 458 486 416 486 354 L 486 168 Z" transform="rotate(180 512 512)"/>
|
||||
<path d="M 486 168 C 416 166 306 168 326 168 C 226 168 168 226 168 326 L 168 354 C 168 454 226 486 326 486 L 354 486 C 416 486 458 458 472 472 C 458 458 486 416 486 354 L 486 168 Z" transform="rotate(270 512 512)"/>
|
||||
</g>
|
||||
<text x="34" y="20.8" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif" font-size="12.5">
|
||||
<tspan fill="#94a3b8" font-weight="500">Running on </tspan><tspan fill="#ffffff" font-weight="700">Miabi</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
+74
-29
@@ -143,13 +143,28 @@
|
||||
}
|
||||
.entry .who { font-weight: 600; }
|
||||
.entry .when { color: var(--muted); }
|
||||
.del {
|
||||
position: absolute; top: 14px; right: 14px; cursor: pointer;
|
||||
background: transparent; border: 0; color: var(--muted); font-size: 17px; line-height: 1;
|
||||
padding: 4px 7px; border-radius: 8px; opacity: 0; transition: opacity .15s, color .15s, background .15s;
|
||||
.entry .via {
|
||||
margin-left: auto; font-size: 11.5px; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
background: rgba(0,0,0,.2); border: 1px solid var(--card-border); padding: 2px 8px; border-radius: 999px;
|
||||
max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
li.entry:hover .del { opacity: 1; }
|
||||
.del:hover { color: var(--danger); background: rgba(244, 63, 94, 0.12); }
|
||||
li.entry.pinned { border-color: rgba(139, 92, 246, 0.55); background: rgba(139, 92, 246, 0.08); }
|
||||
.pin {
|
||||
position: absolute; top: 14px; right: 16px; font-size: 11px; font-weight: 700; letter-spacing: .06em;
|
||||
text-transform: uppercase; color: var(--accent-2);
|
||||
}
|
||||
|
||||
.banner {
|
||||
display: flex; gap: 10px; align-items: flex-start; padding: 13px 18px; margin-bottom: 18px;
|
||||
border-color: rgba(245, 158, 11, 0.45); background: rgba(245, 158, 11, 0.1); font-size: 14.5px; line-height: 1.5;
|
||||
}
|
||||
.banner .icon { flex: 0 0 auto; }
|
||||
.paused {
|
||||
text-align: center; padding: 22px 20px; margin-bottom: 14px; color: var(--muted); font-size: 15px;
|
||||
}
|
||||
.paused b { color: var(--text); }
|
||||
.replicas { 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; }
|
||||
|
||||
.empty, .loading { text-align: center; color: var(--muted); padding: 40px 20px; font-size: 15px; }
|
||||
|
||||
@@ -170,6 +185,8 @@
|
||||
footer { text-align: center; margin-top: 46px; color: var(--muted); font-size: 13px; line-height: 1.8; }
|
||||
footer a { color: var(--accent-2); text-decoration: none; }
|
||||
footer a:hover { text-decoration: underline; }
|
||||
.miabi-badge { display: block; width: fit-content; margin: 0 auto 16px; line-height: 0; border-radius: 16px; transition: transform .15s, box-shadow .15s; }
|
||||
.miabi-badge:hover { transform: translateY(-1px); box-shadow: 0 8px 24px -8px rgba(139, 92, 246, 0.6); }
|
||||
.stack { display: inline-flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
|
||||
.pill { background: var(--card); border: 1px solid var(--card-border); padding: 3px 10px; border-radius: 999px; }
|
||||
|
||||
@@ -189,6 +206,7 @@
|
||||
<div class="logo"><span class="dot"></span> Deployed on Miabi</div>
|
||||
<div class="status">
|
||||
<span class="ver" id="ver" title="Serving version">v…</span>
|
||||
<span class="replicas" id="replicas" title="Running replicas" hidden></span>
|
||||
<span class="online"><span class="pulse"></span> <span id="online">0</span> online</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,6 +220,8 @@
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="card banner" id="banner" hidden><span class="icon">📣</span><span id="bannerText"></span></div>
|
||||
|
||||
<!-- Live server clock (new in v2) -->
|
||||
<div class="card clock" id="clock">
|
||||
<div class="clock-left">
|
||||
@@ -213,6 +233,7 @@
|
||||
|
||||
<!-- Home view -->
|
||||
<section id="view-home">
|
||||
<div class="card paused" id="paused" hidden>✋ <b>Signing is paused</b> by a moderator. The wall is read-only for now.</div>
|
||||
<form class="compose card" id="form">
|
||||
<div class="row">
|
||||
<input class="name-field" id="name" type="text" placeholder="Your name (optional)" maxlength="60" autocomplete="off" />
|
||||
@@ -249,14 +270,19 @@
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<a class="miabi-badge" href="https://miabi.io?ref=badge" target="_blank" rel="noopener">
|
||||
<img src="/badges/running-on-miabi-dark.svg" alt="Running on Miabi" width="156" height="32" />
|
||||
</a>
|
||||
<div class="stack">
|
||||
<span class="pill">Okapi</span>
|
||||
<span class="pill">Go</span>
|
||||
<span class="pill">SSE live</span>
|
||||
<span class="pill">Single container</span>
|
||||
<span class="pill" id="brokerPill" hidden></span>
|
||||
</div>
|
||||
<div style="margin-top:12px">
|
||||
The open-source PaaS for Docker · <a href="https://github.com/miabi-io/miabi" target="_blank" rel="noopener">github.com/miabi-io/miabi</a>
|
||||
Ship apps, not infrastructure · <a href="https://github.com/miabi-io/miabi" target="_blank" rel="noopener">github.com/miabi-io/miabi</a>
|
||||
<span id="adminLink" hidden> · <a href="/admin">Admin</a></span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -271,9 +297,9 @@
|
||||
const api = {
|
||||
info: () => fetch('/api/info').then(r => r.json()),
|
||||
time: () => fetch('/api/time').then(r => r.json()),
|
||||
settings: () => fetch('/api/settings').then(r => r.json()),
|
||||
list: (limit, offset) => fetch(`/api/entries?limit=${limit}&offset=${offset}`).then(r => r.json()),
|
||||
create: (body) => fetch('/api/entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
|
||||
remove: (id) => fetch('/api/entries/' + id, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
let total = 0; // total signatures (kept in sync via SSE)
|
||||
@@ -300,13 +326,16 @@
|
||||
}
|
||||
|
||||
function cardHTML(e) {
|
||||
return `<li class="entry card" data-id="${e.id}">
|
||||
<button class="del" title="Delete" aria-label="Delete">✕</button>
|
||||
const via = e.replica && e.replica !== 'seed'
|
||||
? `<span class="via" title="Accepted by replica ${esc(e.replica)}">via ${esc(e.replica)}</span>` : '';
|
||||
return `<li class="entry card${e.pinned ? ' pinned' : ''}" data-id="${e.id}">
|
||||
${e.pinned ? '<span class="pin">📌 Pinned</span>' : ''}
|
||||
<div class="msg">${esc(e.message)}</div>
|
||||
<div class="foot">
|
||||
<span class="avatar" style="background:${colorFor(e.name)}">${esc(initials(e.name))}</span>
|
||||
<span class="who">${esc(e.name)}</span>
|
||||
<span class="when">· ${timeAgo(e.created_at)}</span>
|
||||
${via}
|
||||
</div>
|
||||
</li>`;
|
||||
}
|
||||
@@ -332,7 +361,20 @@
|
||||
let h = 0; for (const c of String(v)) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
||||
el.style.background = `hsl(${h % 360} 70% 52%)`;
|
||||
}
|
||||
function setOnline(n) { $('online').textContent = n; }
|
||||
function setOnline(n) { $('online').textContent = n || 0; }
|
||||
function setReplicas(n) {
|
||||
if (!n) return;
|
||||
$('replicas').hidden = false;
|
||||
$('replicas').textContent = n === 1 ? '1 replica' : n + ' replicas';
|
||||
}
|
||||
function applySettings(st) {
|
||||
const banner = (st && st.banner) || '';
|
||||
$('banner').hidden = !banner;
|
||||
$('bannerText').textContent = banner;
|
||||
const paused = !!(st && st.signing_paused);
|
||||
$('paused').hidden = !paused;
|
||||
$('form').hidden = paused;
|
||||
}
|
||||
|
||||
// ---- views --------------------------------------------------------------
|
||||
async function loadHome() {
|
||||
@@ -383,21 +425,25 @@
|
||||
window.addEventListener('popstate', route);
|
||||
|
||||
// ---- live SSE events ----------------------------------------------------
|
||||
// Pinned entries stay on top, so a new signature goes right after them.
|
||||
function onCreated(entry) {
|
||||
setTotals(total + 1);
|
||||
if (currentView() === 'home') {
|
||||
const ul = $('entries');
|
||||
if (ul.querySelector('.empty')) ul.innerHTML = '';
|
||||
ul.insertAdjacentHTML('afterbegin', cardHTML(entry));
|
||||
const pins = ul.querySelectorAll('.entry.pinned');
|
||||
const html = cardHTML(entry);
|
||||
if (pins.length) pins[pins.length - 1].insertAdjacentHTML('afterend', html);
|
||||
else ul.insertAdjacentHTML('afterbegin', html);
|
||||
while (ul.children.length > HOME_SIZE) ul.lastElementChild.remove();
|
||||
} else if (allOffset === 0) {
|
||||
loadAll();
|
||||
}
|
||||
}
|
||||
function reloadView() { currentView() === 'all' ? loadAll() : loadHome(); }
|
||||
function onDeleted(id) {
|
||||
setTotals(Math.max(0, total - 1));
|
||||
document.querySelectorAll(`.entry[data-id="${id}"]`).forEach((el) => el.remove());
|
||||
if (currentView() === 'all') loadAll();
|
||||
reloadView();
|
||||
}
|
||||
|
||||
// ---- live server clock (v2) --------------------------------------------
|
||||
@@ -415,12 +461,17 @@
|
||||
clockStaleTimer = setTimeout(() => $('clock').classList.add('stale'), 4000);
|
||||
}
|
||||
|
||||
let connectedOnce = false;
|
||||
function connectSSE() {
|
||||
const es = new EventSource('/api/stream');
|
||||
es.addEventListener('welcome', (e) => { const d = JSON.parse(e.data); setVersion(d.version); setOnline(d.online); });
|
||||
es.addEventListener('presence', (e) => { const d = JSON.parse(e.data); setOnline(d.online); });
|
||||
es.addEventListener('created', (e) => { const d = JSON.parse(e.data); if (d.entry) onCreated(d.entry); });
|
||||
es.addEventListener('welcome', (e) => { const d = JSON.parse(e.data); setVersion(d.version); setOnline(d.online); setReplicas(d.replicas); });
|
||||
es.addEventListener('presence', (e) => { const d = JSON.parse(e.data); setOnline(d.online); setReplicas(d.replicas); });
|
||||
es.addEventListener('created', (e) => { const d = JSON.parse(e.data); if (d.entry && !d.entry.hidden) onCreated(d.entry); });
|
||||
es.addEventListener('updated', () => reloadView());
|
||||
es.addEventListener('deleted', (e) => { const d = JSON.parse(e.data); onDeleted(d.id); });
|
||||
es.addEventListener('settings', (e) => { const d = JSON.parse(e.data); applySettings(d.settings); });
|
||||
// Resync after a reconnect: events sent while disconnected were missed.
|
||||
es.addEventListener('open', () => { if (connectedOnce) { reloadView(); api.settings().then(applySettings).catch(() => {}); } connectedOnce = true; });
|
||||
es.addEventListener('tick', (e) => { const d = JSON.parse(e.data); showTime(d); setVersion(d.version); });
|
||||
// EventSource auto-reconnects on error; nothing to do here.
|
||||
}
|
||||
@@ -442,22 +493,16 @@
|
||||
finally { btn.disabled = false; }
|
||||
});
|
||||
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.del');
|
||||
if (!btn) return;
|
||||
const li = btn.closest('.entry');
|
||||
const id = li.dataset.id;
|
||||
li.style.opacity = '.4';
|
||||
const res = await api.remove(id);
|
||||
if (!(res.ok || res.status === 404)) { li.style.opacity = '1'; toast('Could not delete'); }
|
||||
// Removal is reflected via SSE ("deleted").
|
||||
});
|
||||
|
||||
$('prev').addEventListener('click', () => { allOffset = Math.max(0, allOffset - PAGE_SIZE); loadAll(); });
|
||||
$('next').addEventListener('click', () => { allOffset += PAGE_SIZE; loadAll(); });
|
||||
|
||||
// ---- boot ---------------------------------------------------------------
|
||||
api.info().then((d) => { setVersion(d.version); setOnline(d.online); }).catch(() => {});
|
||||
api.info().then((d) => {
|
||||
setVersion(d.version); setOnline(d.online); setReplicas((d.replicas || []).length);
|
||||
$('adminLink').hidden = !d.admin;
|
||||
if (d.broker) { $('brokerPill').hidden = false; $('brokerPill').textContent = d.broker === 'redis' ? 'Redis pub/sub' : 'In-process broker'; }
|
||||
}).catch(() => {});
|
||||
api.settings().then(applySettings).catch(() => {});
|
||||
api.time().then(showTime).catch(() => {}); // immediate clock; SSE ticks keep it live
|
||||
route();
|
||||
connectSSE();
|
||||
|
||||
Reference in New Issue
Block a user