@syncular/server-hono 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,23 @@
1
1
  /**
2
2
  * The single static console page (TODO §2.5). Zero framework, no build
3
3
  * step, ~one file: it fetches the sibling JSON endpoints (relative to its
4
- * own mount path), renders tables, and offers an auto-refresh toggle. This
5
- * is the v2 answer to v1's full React console app 5% of the code, the
6
- * 80% operator value.
4
+ * own mount path), renders tables, and offers an auto-refresh toggle
5
+ * 5% of the code a full console app would cost, the 80% operator value.
6
+ *
7
+ * Styling follows docs/DESIGN.md (the teletype theme): everything
8
+ * monospace, pure black, a single amber accent, 1px borders, sharp
9
+ * corners, bracketed labels, inverse-video hover. The page ships as one
10
+ * self-contained string, so it uses the system mono fallback stack.
7
11
  *
8
12
  * The HTML is a single exported string so the routes module can serve it
9
13
  * with no filesystem read (works identically on Bun, Node, and Workers).
10
14
  * All fetches are same-origin and relative ('./clients', …), so the page
11
15
  * works under whatever prefix the host mounts the routes at, and the host's
12
16
  * `authorize` guard applies to the page's own XHRs (same cookies/headers).
17
+ *
18
+ * Panels cover the whole read surface: horizon, store stats, clients,
19
+ * commits (with a table filter), the row inspector (`/rows/:table/:rowId`),
20
+ * scope activity (`/scope-activity`), and the event tail (with a type
21
+ * filter).
13
22
  */
14
- export declare const ADMIN_CONSOLE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<title>Syncular console</title>\n<style>\n :root {\n --bg: #0f1115; --panel: #171a21; --border: #262b36; --fg: #d7dce5;\n --muted: #8a93a6; --accent: #5b9dff; --ok: #43c17a; --warn: #e0a83a;\n --mono: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace;\n --sans: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;\n }\n * { box-sizing: border-box; }\n body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 var(--sans); }\n header { display: flex; align-items: center; gap: 16px; flex-wrap: wrap;\n padding: 14px 20px; border-bottom: 1px solid var(--border); background: var(--panel); }\n header h1 { font-size: 15px; margin: 0; font-weight: 600; letter-spacing: .02em; }\n header .spacer { flex: 1; }\n header label { color: var(--muted); font-size: 13px; display: inline-flex; gap: 6px; align-items: center; }\n header input[type=text] { background: var(--bg); border: 1px solid var(--border); color: var(--fg);\n border-radius: 6px; padding: 5px 8px; font: 13px var(--mono); width: 140px; }\n header button { background: var(--bg); border: 1px solid var(--border); color: var(--fg);\n border-radius: 6px; padding: 5px 11px; cursor: pointer; font: 13px var(--sans); }\n header button:hover { border-color: var(--accent); }\n #status { color: var(--muted); font: 12px var(--mono); }\n main { padding: 18px 20px; display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); }\n section { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }\n section > h2 { font-size: 13px; margin: 0; padding: 10px 14px; border-bottom: 1px solid var(--border);\n font-weight: 600; color: var(--fg); display: flex; justify-content: space-between; align-items: center; }\n section > h2 .count { color: var(--muted); font: 12px var(--mono); font-weight: 400; }\n .body { max-height: 360px; overflow: auto; }\n table { width: 100%; border-collapse: collapse; font: 12px var(--mono); }\n th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }\n th { color: var(--muted); font-weight: 500; position: sticky; top: 0; background: var(--panel); z-index: 1; }\n td.wrap { white-space: normal; word-break: break-all; }\n tr:hover td { background: #1c212b; }\n .pill { display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 11px; }\n .pill.ok { background: rgba(67,193,122,.15); color: var(--ok); }\n .pill.warn { background: rgba(224,168,58,.15); color: var(--warn); }\n .pill.idle { background: rgba(138,147,166,.15); color: var(--muted); }\n .kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; padding: 10px 14px; font: 12px var(--mono); }\n .kv dt { color: var(--muted); }\n .empty { color: var(--muted); padding: 14px; font-style: italic; }\n .evt-type { color: var(--accent); }\n .full { grid-column: 1 / -1; }\n</style>\n</head>\n<body>\n<header>\n <h1>Syncular console</h1>\n <label>partition\n <input id=\"partition\" type=\"text\" placeholder=\"(default)\" />\n </label>\n <button id=\"refresh\">Refresh</button>\n <label><input id=\"auto\" type=\"checkbox\" /> auto</label>\n <span class=\"spacer\"></span>\n <span id=\"status\">idle</span>\n</header>\n<main>\n <section><h2>Horizon <span class=\"count\" id=\"horizon-rec\"></span></h2><div class=\"body\" id=\"horizon\"></div></section>\n <section><h2>Store stats</h2><div class=\"body\" id=\"stats\"></div></section>\n <section><h2>Clients <span class=\"count\" id=\"clients-count\"></span></h2><div class=\"body\" id=\"clients\"></div></section>\n <section><h2>Recent commits <span class=\"count\" id=\"commits-count\"></span></h2><div class=\"body\" id=\"commits\"></div></section>\n <section class=\"full\"><h2>Event stream <span class=\"count\" id=\"events-count\"></span></h2><div class=\"body\" id=\"events\"></div></section>\n</main>\n<script>\n(function () {\n var el = function (id) { return document.getElementById(id); };\n var status = el('status');\n\n function q() {\n var p = el('partition').value.trim();\n return p ? ('?partition=' + encodeURIComponent(p)) : '';\n }\n // The endpoints are siblings of the page under the SAME mount prefix. The\n // page is served at the mount root (\u2026/admin or \u2026/admin/); strip any\n // trailing slash so 'base + /clients' hits '\u2026/admin/clients' regardless of\n // whether the URL had the trailing slash.\n var base = location.pathname.replace(/\\/+$/, '');\n function get(path) {\n return fetch(base + path + q(), { headers: { accept: 'application/json' } })\n .then(function (r) {\n if (!r.ok) return r.json().then(function (b) { throw new Error(b.code || ('HTTP ' + r.status)); });\n return r.json();\n });\n }\n function esc(v) {\n return String(v == null ? '' : v)\n .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\n }\n function ago(ms) {\n var d = Date.now() - ms;\n if (d < 0) d = 0;\n if (d < 1000) return d + 'ms';\n if (d < 60000) return Math.round(d / 1000) + 's';\n if (d < 3600000) return Math.round(d / 60000) + 'm';\n return Math.round(d / 3600000) + 'h';\n }\n function bytes(n) {\n if (n == null) return '-';\n if (n < 1024) return n + ' B';\n if (n < 1048576) return (n / 1024).toFixed(1) + ' KiB';\n return (n / 1048576).toFixed(1) + ' MiB';\n }\n function table(head, rows) {\n if (!rows.length) return '<div class=\"empty\">none</div>';\n var h = '<table><thead><tr>' + head.map(function (c) { return '<th>' + esc(c) + '</th>'; }).join('') + '</tr></thead><tbody>';\n h += rows.map(function (r) {\n return '<tr>' + r.map(function (c) { return '<td>' + c + '</td>'; }).join('') + '</tr>';\n }).join('');\n return h + '</tbody></table>';\n }\n\n function renderHorizon(h) {\n el('horizon').innerHTML =\n '<dl class=\"kv\">' +\n '<dt>maxCommitSeq</dt><dd>' + esc(h.maxCommitSeq) + '</dd>' +\n '<dt>horizonSeq</dt><dd>' + esc(h.horizonSeq) + '</dd>' +\n '<dt>retained commits</dt><dd>' + esc(h.retainedCommits) + '</dd>' +\n '<dt>active cursor floor</dt><dd>' + esc(h.activeCursorFloor == null ? 'none' : h.activeCursorFloor) + '</dd>' +\n '<dt>recommended horizon</dt><dd>' + esc(h.recommendedHorizonSeq) + '</dd>' +\n '</dl>';\n var rec = el('horizon-rec');\n if (h.recommendation === 'prune-recommended') {\n rec.innerHTML = '<span class=\"pill warn\">prune recommended</span>';\n } else {\n rec.innerHTML = '<span class=\"pill ok\">up to date</span>';\n }\n }\n function renderStats(s) {\n var seg = s.segments, blob = s.blobs;\n var rows = '<dl class=\"kv\">';\n if (seg) {\n rows += '<dt>segments</dt><dd>' + esc(seg.count) + ' (' + bytes(seg.bytes) + ')</dd>' +\n '<dt>&nbsp;rows / sqlite</dt><dd>' + esc(seg.rowsSegments) + ' / ' + esc(seg.sqliteSegments) + '</dd>';\n } else { rows += '<dt>segments</dt><dd>n/a</dd>'; }\n if (blob) {\n rows += '<dt>blobs</dt><dd>' + esc(blob.count) + ' (' + bytes(blob.bytes) + ')</dd>';\n } else { rows += '<dt>blobs</dt><dd>n/a</dd>'; }\n el('stats').innerHTML = rows + '</dl>';\n }\n function renderClients(list) {\n el('clients-count').textContent = list.length + ' known';\n el('clients').innerHTML = table(['client', 'actor', 'cursor', 'seen', 'subs', ''],\n list.map(function (c) {\n var pill = c.active ? '<span class=\"pill ok\">active</span>' : '<span class=\"pill idle\">idle</span>';\n return [esc(c.clientId), esc(c.actorId), esc(c.cursor), ago(c.updatedAtMs), esc(c.subscriptions.length), pill];\n }));\n }\n function renderCommits(list) {\n el('commits-count').textContent = list.length + ' shown';\n el('commits').innerHTML = table(['seq', 'actor', 'client', 'changes', 'tables', 'when'],\n list.map(function (c) {\n return [esc(c.commitSeq), esc(c.actorId), esc(c.clientId), esc(c.changeCount),\n '<span class=\"wrap\">' + esc(c.tables.join(', ')) + '</span>', ago(c.createdAtMs)];\n }));\n }\n function renderEvents(res) {\n el('events-count').textContent = res.hasEventStream\n ? (res.events.length + ' events')\n : 'no ring buffer wired';\n if (!res.hasEventStream) { el('events').innerHTML = '<div class=\"empty\">no RingBufferEvents wired on this admin</div>'; return; }\n el('events').innerHTML = table(['when', 'type', 'detail'],\n res.events.map(function (e) {\n var detail = Object.keys(e)\n .filter(function (k) { return k !== 'type' && k !== 'atMs'; })\n .map(function (k) { return k + '=' + JSON.stringify(e[k]); })\n .join(' ');\n return [ago(e.atMs), '<span class=\"evt-type\">' + esc(e.type) + '</span>',\n '<span class=\"wrap\">' + esc(detail) + '</span>'];\n }));\n }\n\n var loading = false;\n function loadAll() {\n if (loading) return;\n loading = true;\n status.textContent = 'loading\u2026';\n Promise.all([\n get('/horizon').then(function (r) { renderHorizon(r.horizon); }),\n get('/stats').then(function (r) { renderStats(r.stats); }),\n get('/clients').then(function (r) { renderClients(r.clients); }),\n get('/commits').then(function (r) { renderCommits(r.commits); }),\n get('/events').then(renderEvents)\n ]).then(function () {\n status.textContent = 'updated ' + new Date().toLocaleTimeString();\n }).catch(function (err) {\n status.textContent = 'error: ' + err.message;\n }).finally(function () { loading = false; });\n }\n\n el('refresh').addEventListener('click', loadAll);\n el('partition').addEventListener('change', loadAll);\n var timer = null;\n el('auto').addEventListener('change', function () {\n if (this.checked) { timer = setInterval(loadAll, 2000); loadAll(); }\n else if (timer) { clearInterval(timer); timer = null; }\n });\n loadAll();\n})();\n</script>\n</body>\n</html>";
23
+ export declare const ADMIN_CONSOLE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<title>Syncular console</title>\n<style>\n :root {\n --void: #000000; --panel: #0a0908; --ink: #f4efe4; --dim: #9a948a;\n --faint: #756f64; --border: rgba(154,148,138,.35);\n --border-strong: rgba(154,148,138,.6); --amber: #ffb000;\n --mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n }\n * { box-sizing: border-box; }\n ::selection { background: var(--amber); color: #000; }\n body { margin: 0; background: var(--void); color: var(--ink); font: 14px/1.6 var(--mono); }\n @keyframes blink { 50% { opacity: 0; } }\n .blink { animation: blink 1.1s steps(1) infinite; color: var(--amber); }\n @media (prefers-reduced-motion: reduce) { .blink { animation: none; } }\n\n header { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;\n padding: .8rem 1.25rem; border-bottom: 1px solid var(--border); }\n header h1 { font-size: .85rem; margin: 0; font-weight: 700; letter-spacing: .18em; }\n header h1 .d { color: var(--faint); }\n header .spacer { flex: 1; }\n header label { color: var(--dim); font-size: .68rem; letter-spacing: .1em;\n text-transform: uppercase; display: inline-flex; gap: .5rem; align-items: center; }\n input[type=text] { background: var(--void); border: 1px solid var(--border); color: var(--ink);\n border-radius: 0; padding: .3rem .5rem; font: .78rem var(--mono); }\n input[type=text]:focus { outline: none; border-color: var(--amber); }\n input[type=text]::placeholder { color: var(--faint); }\n button { background: var(--void); border: 1px solid var(--ink); color: var(--ink);\n border-radius: 0; padding: .3rem .7rem; cursor: pointer;\n font: .72rem var(--mono); letter-spacing: .06em; }\n button:hover { background: var(--ink); color: #000; }\n button.primary { border-color: var(--amber); color: var(--amber); }\n button.primary:hover { background: var(--amber); color: #000; }\n button.on { background: var(--amber); border-color: var(--amber); color: #000; }\n #status { color: var(--dim); font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; }\n #status.err { color: var(--amber); }\n\n main { padding: 1.25rem; display: grid; gap: 1.2rem;\n grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); }\n section { border: 1px solid var(--border); background: var(--void); position: relative; }\n section::before, section::after { color: var(--faint); position: absolute; font-size: .72rem; }\n section::before { content: '+'; top: -0.6em; left: -0.28em; }\n section::after { content: '+'; bottom: -0.6em; right: -0.28em; }\n section > h2 { font-size: .72rem; margin: 0; padding: .55rem .9rem;\n border-bottom: 1px solid var(--border); font-weight: 400; color: var(--faint);\n letter-spacing: .12em; text-transform: uppercase;\n display: flex; justify-content: space-between; align-items: center; gap: .6rem; }\n section > h2 .count { color: var(--dim); letter-spacing: .04em; }\n .tools { display: flex; gap: .5rem; padding: .55rem .9rem; flex-wrap: wrap;\n border-bottom: 1px solid var(--border); }\n .tools input { flex: 1; min-width: 8rem; }\n .body { max-height: 360px; overflow: auto; }\n\n table { width: 100%; border-collapse: collapse; font-size: .78rem; }\n th, td { text-align: left; padding: .35rem .9rem; border-bottom: 1px solid var(--border);\n white-space: nowrap; vertical-align: top; }\n th { color: var(--dim); font-weight: 700; font-size: .68rem; letter-spacing: .06em;\n text-transform: uppercase; position: sticky; top: 0; background: var(--panel); z-index: 1; }\n td.wrap, .wrap { white-space: normal; word-break: break-all; }\n tr:hover td { background: var(--panel); }\n\n .chip { font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; color: var(--dim); }\n .chip::before { content: '[ '; color: var(--faint); }\n .chip::after { content: ' ]'; color: var(--faint); }\n .chip.ok { color: var(--amber); }\n .chip.warn { background: var(--amber); color: #000; }\n .chip.warn::before, .chip.warn::after { color: #000; }\n\n .kv { display: grid; grid-template-columns: auto 1fr; gap: .2rem .9rem;\n padding: .6rem .9rem; font-size: .78rem; margin: 0; }\n .kv dt { color: var(--dim); }\n .kv dd { margin: 0; }\n .kv .v { color: var(--amber); }\n .empty { color: var(--faint); padding: .8rem .9rem; font-style: italic; font-size: .78rem; }\n .evt-type { color: var(--amber); }\n .full { grid-column: 1 / -1; }\n\n footer { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap;\n padding: .8rem 1.25rem 1.4rem; color: var(--faint); font-size: .68rem; letter-spacing: .1em; }\n .rule { color: var(--faint); font-size: .72rem; white-space: nowrap; overflow: hidden;\n user-select: none; padding: 0 1.25rem; }\n footer .sq { color: var(--amber); }\n</style>\n</head>\n<body>\n<header>\n <h1>SYNCULAR<span class=\"d\"> \u00B7 </span>CONSOLE<span class=\"blink\">_</span></h1>\n <label>partition\n <input id=\"partition\" type=\"text\" placeholder=\"(default)\" />\n </label>\n <button id=\"refresh\" class=\"primary\">[ REFRESH ]</button>\n <button id=\"auto\">[ AUTO OFF ]</button>\n <span class=\"spacer\"></span>\n <span id=\"status\">idle</span>\n</header>\n<main>\n <section><h2>\u2500\u2500 horizon <span class=\"count\" id=\"horizon-rec\"></span></h2><div class=\"body\" id=\"horizon\"></div></section>\n <section><h2>\u2500\u2500 store stats</h2><div class=\"body\" id=\"stats\"></div></section>\n <section><h2>\u2500\u2500 clients <span class=\"count\" id=\"clients-count\"></span></h2><div class=\"body\" id=\"clients\"></div></section>\n <section>\n <h2>\u2500\u2500 commits <span class=\"count\" id=\"commits-count\"></span></h2>\n <div class=\"tools\"><input id=\"commits-filter\" type=\"text\" placeholder=\"filter by table\" /></div>\n <div class=\"body\" id=\"commits\"></div>\n </section>\n <section>\n <h2>\u2500\u2500 row inspector</h2>\n <div class=\"tools\">\n <input id=\"inspect-table\" type=\"text\" placeholder=\"table\" />\n <input id=\"inspect-row\" type=\"text\" placeholder=\"rowId\" />\n <button id=\"inspect-go\">[ INSPECT ]</button>\n </div>\n <div class=\"body\" id=\"inspect\"></div>\n </section>\n <section>\n <h2>\u2500\u2500 scope activity <span class=\"count\" id=\"scope-count\"></span></h2>\n <div class=\"tools\">\n <input id=\"scope-variable\" type=\"text\" placeholder=\"variable e.g. project\" />\n <input id=\"scope-value\" type=\"text\" placeholder=\"value e.g. p1\" />\n <button id=\"scope-go\">[ QUERY ]</button>\n </div>\n <div class=\"body\" id=\"scope\"></div>\n </section>\n <section class=\"full\">\n <h2>\u2500\u2500 event stream <span class=\"count\" id=\"events-count\"></span></h2>\n <div class=\"tools\"><input id=\"events-filter\" type=\"text\" placeholder=\"filter by type e.g. push.applied\" /></div>\n <div class=\"body\" id=\"events\"></div>\n </section>\n</main>\n<div class=\"rule\">================================================================================================================================================================</div>\n<footer>\n <span>SYNCULAR \u00B7 ADMIN</span>\n <span>END OF TRANSMISSION <span class=\"sq\">\u25A0</span></span>\n</footer>\n<script>\n(function () {\n var el = function (id) { return document.getElementById(id); };\n var status = el('status');\n\n // The endpoints are siblings of the page under the SAME mount prefix. The\n // page is served at the mount root (\u2026/admin or \u2026/admin/); strip any\n // trailing slash so 'base + /clients' hits '\u2026/admin/clients' regardless of\n // whether the URL had the trailing slash.\n var base = location.pathname.replace(/\\/+$/, '');\n function get(path, params) {\n var parts = [];\n var p = el('partition').value.trim();\n if (p) parts.push('partition=' + encodeURIComponent(p));\n if (params) Object.keys(params).forEach(function (k) {\n var v = params[k];\n if (v !== undefined && v !== null && v !== '') {\n parts.push(k + '=' + encodeURIComponent(v));\n }\n });\n var qs = parts.length ? '?' + parts.join('&') : '';\n return fetch(base + path + qs, { headers: { accept: 'application/json' } })\n .then(function (r) {\n if (!r.ok) return r.json().then(function (b) { throw new Error(b.code || ('HTTP ' + r.status)); });\n return r.json();\n });\n }\n function esc(v) {\n return String(v == null ? '' : v)\n .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\n }\n function ago(ms) {\n var d = Date.now() - ms;\n if (d < 0) d = 0;\n if (d < 1000) return d + 'ms';\n if (d < 60000) return Math.round(d / 1000) + 's';\n if (d < 3600000) return Math.round(d / 60000) + 'm';\n return Math.round(d / 3600000) + 'h';\n }\n function bytes(n) {\n if (n == null) return '-';\n if (n < 1024) return n + ' B';\n if (n < 1048576) return (n / 1024).toFixed(1) + ' KiB';\n return (n / 1048576).toFixed(1) + ' MiB';\n }\n function empty(text) { return '<div class=\"empty\">\u2014 ' + esc(text || 'none') + ' \u2014</div>'; }\n function table(head, rows) {\n if (!rows.length) return empty('none');\n var h = '<table><thead><tr>' + head.map(function (c) { return '<th>' + esc(c) + '</th>'; }).join('') + '</tr></thead><tbody>';\n h += rows.map(function (r) {\n return '<tr>' + r.map(function (c) { return '<td>' + c + '</td>'; }).join('') + '</tr>';\n }).join('');\n return h + '</tbody></table>';\n }\n function chip(text, cls) { return '<span class=\"chip ' + cls + '\">' + esc(text) + '</span>'; }\n\n function renderHorizon(h) {\n el('horizon').innerHTML =\n '<dl class=\"kv\">' +\n '<dt>maxCommitSeq</dt><dd class=\"v\">' + esc(h.maxCommitSeq) + '</dd>' +\n '<dt>horizonSeq</dt><dd class=\"v\">' + esc(h.horizonSeq) + '</dd>' +\n '<dt>retained commits</dt><dd>' + esc(h.retainedCommits) + '</dd>' +\n '<dt>active cursor floor</dt><dd>' + esc(h.activeCursorFloor == null ? 'none' : h.activeCursorFloor) + '</dd>' +\n '<dt>recommended horizon</dt><dd>' + esc(h.recommendedHorizonSeq) + '</dd>' +\n '</dl>';\n el('horizon-rec').innerHTML = h.recommendation === 'prune-recommended'\n ? chip('prune recommended', 'warn')\n : chip('up to date', 'ok');\n }\n function renderStats(s) {\n var seg = s.segments, blob = s.blobs;\n var rows = '<dl class=\"kv\">';\n if (seg) {\n rows += '<dt>segments</dt><dd>' + esc(seg.count) + ' (' + bytes(seg.bytes) + ')</dd>' +\n '<dt>&nbsp;rows / sqlite</dt><dd>' + esc(seg.rowsSegments) + ' / ' + esc(seg.sqliteSegments) + '</dd>';\n } else { rows += '<dt>segments</dt><dd>n/a</dd>'; }\n if (blob) {\n rows += '<dt>blobs</dt><dd>' + esc(blob.count) + ' (' + bytes(blob.bytes) + ')</dd>';\n } else { rows += '<dt>blobs</dt><dd>n/a</dd>'; }\n el('stats').innerHTML = rows + '</dl>';\n }\n function renderClients(list) {\n el('clients-count').textContent = list.length + ' known';\n el('clients').innerHTML = table(['client', 'actor', 'cursor', 'seen', 'subs', ''],\n list.map(function (c) {\n var pill = c.active ? chip('active', 'ok') : chip('idle', '');\n return [esc(c.clientId), esc(c.actorId), esc(c.cursor), ago(c.updatedAtMs), esc(c.subscriptions.length), pill];\n }));\n }\n function renderCommits(list) {\n el('commits-count').textContent = list.length + ' shown';\n el('commits').innerHTML = table(['seq', 'actor', 'client', 'changes', 'tables', 'when'],\n list.map(function (c) {\n return [esc(c.commitSeq), esc(c.actorId), esc(c.clientId), esc(c.changeCount),\n '<span class=\"wrap\">' + esc(c.tables.join(', ')) + '</span>', ago(c.createdAtMs)];\n }));\n }\n function renderInspection(row) {\n var h = '<dl class=\"kv\">' +\n '<dt>table</dt><dd>' + esc(row.table) + '</dd>' +\n '<dt>rowId</dt><dd>' + esc(row.rowId) + '</dd>' +\n '<dt>exists</dt><dd>' + (row.exists ? chip('yes', 'ok') : chip('no', '')) + '</dd>';\n if (row.exists) {\n h += '<dt>server_version</dt><dd class=\"v\">' + esc(row.serverVersion) + '</dd>' +\n '<dt>scopes</dt><dd class=\"wrap\">' + esc(JSON.stringify(row.scopes)) + '</dd>';\n if (row.referencedBlobIds) {\n h += '<dt>blob refs</dt><dd class=\"wrap\">' + esc(row.referencedBlobIds.join(', ') || 'none') + '</dd>';\n }\n }\n el('inspect').innerHTML = h + '</dl>';\n }\n function renderScope(list) {\n el('scope-count').textContent = list.length + ' shown';\n el('scope').innerHTML = table(['seq', 'table', 'actor', 'changes', 'when'],\n list.map(function (a) {\n return [esc(a.commitSeq), esc(a.table), esc(a.actorId), esc(a.changeCount), ago(a.createdAtMs)];\n }));\n }\n function renderEvents(res) {\n el('events-count').textContent = res.hasEventStream\n ? (res.events.length + ' events')\n : 'no ring buffer wired';\n if (!res.hasEventStream) { el('events').innerHTML = empty('no RingBufferEvents wired on this admin'); return; }\n el('events').innerHTML = table(['when', 'type', 'detail'],\n res.events.map(function (e) {\n var detail = Object.keys(e)\n .filter(function (k) { return k !== 'type' && k !== 'atMs'; })\n .map(function (k) { return k + '=' + JSON.stringify(e[k]); })\n .join(' ');\n return [ago(e.atMs), '<span class=\"evt-type\">' + esc(e.type) + '</span>',\n '<span class=\"wrap\">' + esc(detail) + '</span>'];\n }));\n }\n\n var loading = false;\n function loadAll() {\n if (loading) return;\n loading = true;\n status.className = '';\n status.textContent = 'loading\u2026';\n Promise.all([\n get('/horizon').then(function (r) { renderHorizon(r.horizon); }),\n get('/stats').then(function (r) { renderStats(r.stats); }),\n get('/clients').then(function (r) { renderClients(r.clients); }),\n get('/commits', { table: el('commits-filter').value.trim() }).then(function (r) { renderCommits(r.commits); }),\n get('/events', { type: el('events-filter').value.trim() }).then(renderEvents)\n ]).then(function () {\n status.textContent = 'updated ' + new Date().toLocaleTimeString();\n }).catch(function (err) {\n status.className = 'err';\n status.textContent = 'error: ' + err.message;\n }).finally(function () { loading = false; });\n }\n\n function inspect() {\n var t = el('inspect-table').value.trim();\n var id = el('inspect-row').value.trim();\n if (!t || !id) { el('inspect').innerHTML = empty('table and rowId required'); return; }\n get('/rows/' + encodeURIComponent(t) + '/' + encodeURIComponent(id))\n .then(function (r) { renderInspection(r.row); })\n .catch(function (err) { el('inspect').innerHTML = empty('error: ' + err.message); });\n }\n function scopeQuery() {\n var variable = el('scope-variable').value.trim();\n var value = el('scope-value').value.trim();\n if (!variable || !value) { el('scope').innerHTML = empty('variable and value required'); return; }\n get('/scope-activity', { variable: variable, value: value })\n .then(function (r) { renderScope(r.activity); })\n .catch(function (err) { el('scope').innerHTML = empty('error: ' + err.message); });\n }\n function onEnter(id, fn) {\n el(id).addEventListener('keydown', function (e) { if (e.key === 'Enter') fn(); });\n }\n\n el('refresh').addEventListener('click', loadAll);\n el('partition').addEventListener('change', loadAll);\n el('commits-filter').addEventListener('change', loadAll);\n el('events-filter').addEventListener('change', loadAll);\n el('inspect-go').addEventListener('click', inspect);\n onEnter('inspect-table', inspect);\n onEnter('inspect-row', inspect);\n el('scope-go').addEventListener('click', scopeQuery);\n onEnter('scope-variable', scopeQuery);\n onEnter('scope-value', scopeQuery);\n var timer = null;\n el('auto').addEventListener('click', function () {\n if (timer) {\n clearInterval(timer); timer = null;\n this.classList.remove('on'); this.textContent = '[ AUTO OFF ]';\n } else {\n timer = setInterval(loadAll, 2000);\n this.classList.add('on'); this.textContent = '[ AUTO ON ]';\n loadAll();\n }\n });\n loadAll();\n})();\n</script>\n</body>\n</html>";
@@ -1,15 +1,24 @@
1
1
  /**
2
2
  * The single static console page (TODO §2.5). Zero framework, no build
3
3
  * step, ~one file: it fetches the sibling JSON endpoints (relative to its
4
- * own mount path), renders tables, and offers an auto-refresh toggle. This
5
- * is the v2 answer to v1's full React console app 5% of the code, the
6
- * 80% operator value.
4
+ * own mount path), renders tables, and offers an auto-refresh toggle
5
+ * 5% of the code a full console app would cost, the 80% operator value.
6
+ *
7
+ * Styling follows docs/DESIGN.md (the teletype theme): everything
8
+ * monospace, pure black, a single amber accent, 1px borders, sharp
9
+ * corners, bracketed labels, inverse-video hover. The page ships as one
10
+ * self-contained string, so it uses the system mono fallback stack.
7
11
  *
8
12
  * The HTML is a single exported string so the routes module can serve it
9
13
  * with no filesystem read (works identically on Bun, Node, and Workers).
10
14
  * All fetches are same-origin and relative ('./clients', …), so the page
11
15
  * works under whatever prefix the host mounts the routes at, and the host's
12
16
  * `authorize` guard applies to the page's own XHRs (same cookies/headers).
17
+ *
18
+ * Panels cover the whole read surface: horizon, store stats, clients,
19
+ * commits (with a table filter), the row inspector (`/rows/:table/:rowId`),
20
+ * scope activity (`/scope-activity`), and the event tail (with a type
21
+ * filter).
13
22
  */
14
23
  export const ADMIN_CONSOLE_HTML = `<!doctype html>
15
24
  <html lang="en">
@@ -19,80 +28,157 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
19
28
  <title>Syncular console</title>
20
29
  <style>
21
30
  :root {
22
- --bg: #0f1115; --panel: #171a21; --border: #262b36; --fg: #d7dce5;
23
- --muted: #8a93a6; --accent: #5b9dff; --ok: #43c17a; --warn: #e0a83a;
24
- --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
25
- --sans: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
31
+ --void: #000000; --panel: #0a0908; --ink: #f4efe4; --dim: #9a948a;
32
+ --faint: #756f64; --border: rgba(154,148,138,.35);
33
+ --border-strong: rgba(154,148,138,.6); --amber: #ffb000;
34
+ --mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
26
35
  }
27
36
  * { box-sizing: border-box; }
28
- body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 var(--sans); }
29
- header { display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
30
- padding: 14px 20px; border-bottom: 1px solid var(--border); background: var(--panel); }
31
- header h1 { font-size: 15px; margin: 0; font-weight: 600; letter-spacing: .02em; }
37
+ ::selection { background: var(--amber); color: #000; }
38
+ body { margin: 0; background: var(--void); color: var(--ink); font: 14px/1.6 var(--mono); }
39
+ @keyframes blink { 50% { opacity: 0; } }
40
+ .blink { animation: blink 1.1s steps(1) infinite; color: var(--amber); }
41
+ @media (prefers-reduced-motion: reduce) { .blink { animation: none; } }
42
+
43
+ header { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
44
+ padding: .8rem 1.25rem; border-bottom: 1px solid var(--border); }
45
+ header h1 { font-size: .85rem; margin: 0; font-weight: 700; letter-spacing: .18em; }
46
+ header h1 .d { color: var(--faint); }
32
47
  header .spacer { flex: 1; }
33
- header label { color: var(--muted); font-size: 13px; display: inline-flex; gap: 6px; align-items: center; }
34
- header input[type=text] { background: var(--bg); border: 1px solid var(--border); color: var(--fg);
35
- border-radius: 6px; padding: 5px 8px; font: 13px var(--mono); width: 140px; }
36
- header button { background: var(--bg); border: 1px solid var(--border); color: var(--fg);
37
- border-radius: 6px; padding: 5px 11px; cursor: pointer; font: 13px var(--sans); }
38
- header button:hover { border-color: var(--accent); }
39
- #status { color: var(--muted); font: 12px var(--mono); }
40
- main { padding: 18px 20px; display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); }
41
- section { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
42
- section > h2 { font-size: 13px; margin: 0; padding: 10px 14px; border-bottom: 1px solid var(--border);
43
- font-weight: 600; color: var(--fg); display: flex; justify-content: space-between; align-items: center; }
44
- section > h2 .count { color: var(--muted); font: 12px var(--mono); font-weight: 400; }
48
+ header label { color: var(--dim); font-size: .68rem; letter-spacing: .1em;
49
+ text-transform: uppercase; display: inline-flex; gap: .5rem; align-items: center; }
50
+ input[type=text] { background: var(--void); border: 1px solid var(--border); color: var(--ink);
51
+ border-radius: 0; padding: .3rem .5rem; font: .78rem var(--mono); }
52
+ input[type=text]:focus { outline: none; border-color: var(--amber); }
53
+ input[type=text]::placeholder { color: var(--faint); }
54
+ button { background: var(--void); border: 1px solid var(--ink); color: var(--ink);
55
+ border-radius: 0; padding: .3rem .7rem; cursor: pointer;
56
+ font: .72rem var(--mono); letter-spacing: .06em; }
57
+ button:hover { background: var(--ink); color: #000; }
58
+ button.primary { border-color: var(--amber); color: var(--amber); }
59
+ button.primary:hover { background: var(--amber); color: #000; }
60
+ button.on { background: var(--amber); border-color: var(--amber); color: #000; }
61
+ #status { color: var(--dim); font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; }
62
+ #status.err { color: var(--amber); }
63
+
64
+ main { padding: 1.25rem; display: grid; gap: 1.2rem;
65
+ grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); }
66
+ section { border: 1px solid var(--border); background: var(--void); position: relative; }
67
+ section::before, section::after { color: var(--faint); position: absolute; font-size: .72rem; }
68
+ section::before { content: '+'; top: -0.6em; left: -0.28em; }
69
+ section::after { content: '+'; bottom: -0.6em; right: -0.28em; }
70
+ section > h2 { font-size: .72rem; margin: 0; padding: .55rem .9rem;
71
+ border-bottom: 1px solid var(--border); font-weight: 400; color: var(--faint);
72
+ letter-spacing: .12em; text-transform: uppercase;
73
+ display: flex; justify-content: space-between; align-items: center; gap: .6rem; }
74
+ section > h2 .count { color: var(--dim); letter-spacing: .04em; }
75
+ .tools { display: flex; gap: .5rem; padding: .55rem .9rem; flex-wrap: wrap;
76
+ border-bottom: 1px solid var(--border); }
77
+ .tools input { flex: 1; min-width: 8rem; }
45
78
  .body { max-height: 360px; overflow: auto; }
46
- table { width: 100%; border-collapse: collapse; font: 12px var(--mono); }
47
- th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
48
- th { color: var(--muted); font-weight: 500; position: sticky; top: 0; background: var(--panel); z-index: 1; }
49
- td.wrap { white-space: normal; word-break: break-all; }
50
- tr:hover td { background: #1c212b; }
51
- .pill { display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 11px; }
52
- .pill.ok { background: rgba(67,193,122,.15); color: var(--ok); }
53
- .pill.warn { background: rgba(224,168,58,.15); color: var(--warn); }
54
- .pill.idle { background: rgba(138,147,166,.15); color: var(--muted); }
55
- .kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; padding: 10px 14px; font: 12px var(--mono); }
56
- .kv dt { color: var(--muted); }
57
- .empty { color: var(--muted); padding: 14px; font-style: italic; }
58
- .evt-type { color: var(--accent); }
79
+
80
+ table { width: 100%; border-collapse: collapse; font-size: .78rem; }
81
+ th, td { text-align: left; padding: .35rem .9rem; border-bottom: 1px solid var(--border);
82
+ white-space: nowrap; vertical-align: top; }
83
+ th { color: var(--dim); font-weight: 700; font-size: .68rem; letter-spacing: .06em;
84
+ text-transform: uppercase; position: sticky; top: 0; background: var(--panel); z-index: 1; }
85
+ td.wrap, .wrap { white-space: normal; word-break: break-all; }
86
+ tr:hover td { background: var(--panel); }
87
+
88
+ .chip { font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; color: var(--dim); }
89
+ .chip::before { content: '[ '; color: var(--faint); }
90
+ .chip::after { content: ' ]'; color: var(--faint); }
91
+ .chip.ok { color: var(--amber); }
92
+ .chip.warn { background: var(--amber); color: #000; }
93
+ .chip.warn::before, .chip.warn::after { color: #000; }
94
+
95
+ .kv { display: grid; grid-template-columns: auto 1fr; gap: .2rem .9rem;
96
+ padding: .6rem .9rem; font-size: .78rem; margin: 0; }
97
+ .kv dt { color: var(--dim); }
98
+ .kv dd { margin: 0; }
99
+ .kv .v { color: var(--amber); }
100
+ .empty { color: var(--faint); padding: .8rem .9rem; font-style: italic; font-size: .78rem; }
101
+ .evt-type { color: var(--amber); }
59
102
  .full { grid-column: 1 / -1; }
103
+
104
+ footer { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
105
+ padding: .8rem 1.25rem 1.4rem; color: var(--faint); font-size: .68rem; letter-spacing: .1em; }
106
+ .rule { color: var(--faint); font-size: .72rem; white-space: nowrap; overflow: hidden;
107
+ user-select: none; padding: 0 1.25rem; }
108
+ footer .sq { color: var(--amber); }
60
109
  </style>
61
110
  </head>
62
111
  <body>
63
112
  <header>
64
- <h1>Syncular console</h1>
113
+ <h1>SYNCULAR<span class="d"> · </span>CONSOLE<span class="blink">_</span></h1>
65
114
  <label>partition
66
115
  <input id="partition" type="text" placeholder="(default)" />
67
116
  </label>
68
- <button id="refresh">Refresh</button>
69
- <label><input id="auto" type="checkbox" /> auto</label>
117
+ <button id="refresh" class="primary">[ REFRESH ]</button>
118
+ <button id="auto">[ AUTO OFF ]</button>
70
119
  <span class="spacer"></span>
71
120
  <span id="status">idle</span>
72
121
  </header>
73
122
  <main>
74
- <section><h2>Horizon <span class="count" id="horizon-rec"></span></h2><div class="body" id="horizon"></div></section>
75
- <section><h2>Store stats</h2><div class="body" id="stats"></div></section>
76
- <section><h2>Clients <span class="count" id="clients-count"></span></h2><div class="body" id="clients"></div></section>
77
- <section><h2>Recent commits <span class="count" id="commits-count"></span></h2><div class="body" id="commits"></div></section>
78
- <section class="full"><h2>Event stream <span class="count" id="events-count"></span></h2><div class="body" id="events"></div></section>
123
+ <section><h2>── horizon <span class="count" id="horizon-rec"></span></h2><div class="body" id="horizon"></div></section>
124
+ <section><h2>── store stats</h2><div class="body" id="stats"></div></section>
125
+ <section><h2>── clients <span class="count" id="clients-count"></span></h2><div class="body" id="clients"></div></section>
126
+ <section>
127
+ <h2>── commits <span class="count" id="commits-count"></span></h2>
128
+ <div class="tools"><input id="commits-filter" type="text" placeholder="filter by table" /></div>
129
+ <div class="body" id="commits"></div>
130
+ </section>
131
+ <section>
132
+ <h2>── row inspector</h2>
133
+ <div class="tools">
134
+ <input id="inspect-table" type="text" placeholder="table" />
135
+ <input id="inspect-row" type="text" placeholder="rowId" />
136
+ <button id="inspect-go">[ INSPECT ]</button>
137
+ </div>
138
+ <div class="body" id="inspect"></div>
139
+ </section>
140
+ <section>
141
+ <h2>── scope activity <span class="count" id="scope-count"></span></h2>
142
+ <div class="tools">
143
+ <input id="scope-variable" type="text" placeholder="variable e.g. project" />
144
+ <input id="scope-value" type="text" placeholder="value e.g. p1" />
145
+ <button id="scope-go">[ QUERY ]</button>
146
+ </div>
147
+ <div class="body" id="scope"></div>
148
+ </section>
149
+ <section class="full">
150
+ <h2>── event stream <span class="count" id="events-count"></span></h2>
151
+ <div class="tools"><input id="events-filter" type="text" placeholder="filter by type e.g. push.applied" /></div>
152
+ <div class="body" id="events"></div>
153
+ </section>
79
154
  </main>
155
+ <div class="rule">================================================================================================================================================================</div>
156
+ <footer>
157
+ <span>SYNCULAR · ADMIN</span>
158
+ <span>END OF TRANSMISSION <span class="sq">■</span></span>
159
+ </footer>
80
160
  <script>
81
161
  (function () {
82
162
  var el = function (id) { return document.getElementById(id); };
83
163
  var status = el('status');
84
164
 
85
- function q() {
86
- var p = el('partition').value.trim();
87
- return p ? ('?partition=' + encodeURIComponent(p)) : '';
88
- }
89
165
  // The endpoints are siblings of the page under the SAME mount prefix. The
90
166
  // page is served at the mount root (…/admin or …/admin/); strip any
91
167
  // trailing slash so 'base + /clients' hits '…/admin/clients' regardless of
92
168
  // whether the URL had the trailing slash.
93
169
  var base = location.pathname.replace(/\\/+$/, '');
94
- function get(path) {
95
- return fetch(base + path + q(), { headers: { accept: 'application/json' } })
170
+ function get(path, params) {
171
+ var parts = [];
172
+ var p = el('partition').value.trim();
173
+ if (p) parts.push('partition=' + encodeURIComponent(p));
174
+ if (params) Object.keys(params).forEach(function (k) {
175
+ var v = params[k];
176
+ if (v !== undefined && v !== null && v !== '') {
177
+ parts.push(k + '=' + encodeURIComponent(v));
178
+ }
179
+ });
180
+ var qs = parts.length ? '?' + parts.join('&') : '';
181
+ return fetch(base + path + qs, { headers: { accept: 'application/json' } })
96
182
  .then(function (r) {
97
183
  if (!r.ok) return r.json().then(function (b) { throw new Error(b.code || ('HTTP ' + r.status)); });
98
184
  return r.json();
@@ -116,30 +202,29 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
116
202
  if (n < 1048576) return (n / 1024).toFixed(1) + ' KiB';
117
203
  return (n / 1048576).toFixed(1) + ' MiB';
118
204
  }
205
+ function empty(text) { return '<div class="empty">— ' + esc(text || 'none') + ' —</div>'; }
119
206
  function table(head, rows) {
120
- if (!rows.length) return '<div class="empty">none</div>';
207
+ if (!rows.length) return empty('none');
121
208
  var h = '<table><thead><tr>' + head.map(function (c) { return '<th>' + esc(c) + '</th>'; }).join('') + '</tr></thead><tbody>';
122
209
  h += rows.map(function (r) {
123
210
  return '<tr>' + r.map(function (c) { return '<td>' + c + '</td>'; }).join('') + '</tr>';
124
211
  }).join('');
125
212
  return h + '</tbody></table>';
126
213
  }
214
+ function chip(text, cls) { return '<span class="chip ' + cls + '">' + esc(text) + '</span>'; }
127
215
 
128
216
  function renderHorizon(h) {
129
217
  el('horizon').innerHTML =
130
218
  '<dl class="kv">' +
131
- '<dt>maxCommitSeq</dt><dd>' + esc(h.maxCommitSeq) + '</dd>' +
132
- '<dt>horizonSeq</dt><dd>' + esc(h.horizonSeq) + '</dd>' +
219
+ '<dt>maxCommitSeq</dt><dd class="v">' + esc(h.maxCommitSeq) + '</dd>' +
220
+ '<dt>horizonSeq</dt><dd class="v">' + esc(h.horizonSeq) + '</dd>' +
133
221
  '<dt>retained commits</dt><dd>' + esc(h.retainedCommits) + '</dd>' +
134
222
  '<dt>active cursor floor</dt><dd>' + esc(h.activeCursorFloor == null ? 'none' : h.activeCursorFloor) + '</dd>' +
135
223
  '<dt>recommended horizon</dt><dd>' + esc(h.recommendedHorizonSeq) + '</dd>' +
136
224
  '</dl>';
137
- var rec = el('horizon-rec');
138
- if (h.recommendation === 'prune-recommended') {
139
- rec.innerHTML = '<span class="pill warn">prune recommended</span>';
140
- } else {
141
- rec.innerHTML = '<span class="pill ok">up to date</span>';
142
- }
225
+ el('horizon-rec').innerHTML = h.recommendation === 'prune-recommended'
226
+ ? chip('prune recommended', 'warn')
227
+ : chip('up to date', 'ok');
143
228
  }
144
229
  function renderStats(s) {
145
230
  var seg = s.segments, blob = s.blobs;
@@ -157,7 +242,7 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
157
242
  el('clients-count').textContent = list.length + ' known';
158
243
  el('clients').innerHTML = table(['client', 'actor', 'cursor', 'seen', 'subs', ''],
159
244
  list.map(function (c) {
160
- var pill = c.active ? '<span class="pill ok">active</span>' : '<span class="pill idle">idle</span>';
245
+ var pill = c.active ? chip('active', 'ok') : chip('idle', '');
161
246
  return [esc(c.clientId), esc(c.actorId), esc(c.cursor), ago(c.updatedAtMs), esc(c.subscriptions.length), pill];
162
247
  }));
163
248
  }
@@ -169,11 +254,32 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
169
254
  '<span class="wrap">' + esc(c.tables.join(', ')) + '</span>', ago(c.createdAtMs)];
170
255
  }));
171
256
  }
257
+ function renderInspection(row) {
258
+ var h = '<dl class="kv">' +
259
+ '<dt>table</dt><dd>' + esc(row.table) + '</dd>' +
260
+ '<dt>rowId</dt><dd>' + esc(row.rowId) + '</dd>' +
261
+ '<dt>exists</dt><dd>' + (row.exists ? chip('yes', 'ok') : chip('no', '')) + '</dd>';
262
+ if (row.exists) {
263
+ h += '<dt>server_version</dt><dd class="v">' + esc(row.serverVersion) + '</dd>' +
264
+ '<dt>scopes</dt><dd class="wrap">' + esc(JSON.stringify(row.scopes)) + '</dd>';
265
+ if (row.referencedBlobIds) {
266
+ h += '<dt>blob refs</dt><dd class="wrap">' + esc(row.referencedBlobIds.join(', ') || 'none') + '</dd>';
267
+ }
268
+ }
269
+ el('inspect').innerHTML = h + '</dl>';
270
+ }
271
+ function renderScope(list) {
272
+ el('scope-count').textContent = list.length + ' shown';
273
+ el('scope').innerHTML = table(['seq', 'table', 'actor', 'changes', 'when'],
274
+ list.map(function (a) {
275
+ return [esc(a.commitSeq), esc(a.table), esc(a.actorId), esc(a.changeCount), ago(a.createdAtMs)];
276
+ }));
277
+ }
172
278
  function renderEvents(res) {
173
279
  el('events-count').textContent = res.hasEventStream
174
280
  ? (res.events.length + ' events')
175
281
  : 'no ring buffer wired';
176
- if (!res.hasEventStream) { el('events').innerHTML = '<div class="empty">no RingBufferEvents wired on this admin</div>'; return; }
282
+ if (!res.hasEventStream) { el('events').innerHTML = empty('no RingBufferEvents wired on this admin'); return; }
177
283
  el('events').innerHTML = table(['when', 'type', 'detail'],
178
284
  res.events.map(function (e) {
179
285
  var detail = Object.keys(e)
@@ -189,26 +295,62 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
189
295
  function loadAll() {
190
296
  if (loading) return;
191
297
  loading = true;
298
+ status.className = '';
192
299
  status.textContent = 'loading…';
193
300
  Promise.all([
194
301
  get('/horizon').then(function (r) { renderHorizon(r.horizon); }),
195
302
  get('/stats').then(function (r) { renderStats(r.stats); }),
196
303
  get('/clients').then(function (r) { renderClients(r.clients); }),
197
- get('/commits').then(function (r) { renderCommits(r.commits); }),
198
- get('/events').then(renderEvents)
304
+ get('/commits', { table: el('commits-filter').value.trim() }).then(function (r) { renderCommits(r.commits); }),
305
+ get('/events', { type: el('events-filter').value.trim() }).then(renderEvents)
199
306
  ]).then(function () {
200
307
  status.textContent = 'updated ' + new Date().toLocaleTimeString();
201
308
  }).catch(function (err) {
309
+ status.className = 'err';
202
310
  status.textContent = 'error: ' + err.message;
203
311
  }).finally(function () { loading = false; });
204
312
  }
205
313
 
314
+ function inspect() {
315
+ var t = el('inspect-table').value.trim();
316
+ var id = el('inspect-row').value.trim();
317
+ if (!t || !id) { el('inspect').innerHTML = empty('table and rowId required'); return; }
318
+ get('/rows/' + encodeURIComponent(t) + '/' + encodeURIComponent(id))
319
+ .then(function (r) { renderInspection(r.row); })
320
+ .catch(function (err) { el('inspect').innerHTML = empty('error: ' + err.message); });
321
+ }
322
+ function scopeQuery() {
323
+ var variable = el('scope-variable').value.trim();
324
+ var value = el('scope-value').value.trim();
325
+ if (!variable || !value) { el('scope').innerHTML = empty('variable and value required'); return; }
326
+ get('/scope-activity', { variable: variable, value: value })
327
+ .then(function (r) { renderScope(r.activity); })
328
+ .catch(function (err) { el('scope').innerHTML = empty('error: ' + err.message); });
329
+ }
330
+ function onEnter(id, fn) {
331
+ el(id).addEventListener('keydown', function (e) { if (e.key === 'Enter') fn(); });
332
+ }
333
+
206
334
  el('refresh').addEventListener('click', loadAll);
207
335
  el('partition').addEventListener('change', loadAll);
336
+ el('commits-filter').addEventListener('change', loadAll);
337
+ el('events-filter').addEventListener('change', loadAll);
338
+ el('inspect-go').addEventListener('click', inspect);
339
+ onEnter('inspect-table', inspect);
340
+ onEnter('inspect-row', inspect);
341
+ el('scope-go').addEventListener('click', scopeQuery);
342
+ onEnter('scope-variable', scopeQuery);
343
+ onEnter('scope-value', scopeQuery);
208
344
  var timer = null;
209
- el('auto').addEventListener('change', function () {
210
- if (this.checked) { timer = setInterval(loadAll, 2000); loadAll(); }
211
- else if (timer) { clearInterval(timer); timer = null; }
345
+ el('auto').addEventListener('click', function () {
346
+ if (timer) {
347
+ clearInterval(timer); timer = null;
348
+ this.classList.remove('on'); this.textContent = '[ AUTO OFF ]';
349
+ } else {
350
+ timer = setInterval(loadAll, 2000);
351
+ this.classList.add('on'); this.textContent = '[ AUTO ON ]';
352
+ loadAll();
353
+ }
212
354
  });
213
355
  loadAll();
214
356
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server-hono",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Hono adapter for the Syncular sync server",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -28,7 +28,7 @@
28
28
  "exports": {
29
29
  ".": {
30
30
  "bun": "./src/index.ts",
31
- "browser": "./src/index.ts",
31
+ "browser": "./dist/index.js",
32
32
  "import": {
33
33
  "types": "./dist/index.d.ts",
34
34
  "default": "./dist/index.js"
@@ -45,10 +45,10 @@
45
45
  "!dist/**/*.test.d.ts"
46
46
  ],
47
47
  "dependencies": {
48
- "@syncular/server": "0.2.0",
48
+ "@syncular/server": "0.2.1",
49
49
  "hono": "^4.11.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@syncular/core": "0.2.0"
52
+ "@syncular/core": "0.2.1"
53
53
  }
54
54
  }
package/src/admin-page.ts CHANGED
@@ -1,15 +1,24 @@
1
1
  /**
2
2
  * The single static console page (TODO §2.5). Zero framework, no build
3
3
  * step, ~one file: it fetches the sibling JSON endpoints (relative to its
4
- * own mount path), renders tables, and offers an auto-refresh toggle. This
5
- * is the v2 answer to v1's full React console app 5% of the code, the
6
- * 80% operator value.
4
+ * own mount path), renders tables, and offers an auto-refresh toggle
5
+ * 5% of the code a full console app would cost, the 80% operator value.
6
+ *
7
+ * Styling follows docs/DESIGN.md (the teletype theme): everything
8
+ * monospace, pure black, a single amber accent, 1px borders, sharp
9
+ * corners, bracketed labels, inverse-video hover. The page ships as one
10
+ * self-contained string, so it uses the system mono fallback stack.
7
11
  *
8
12
  * The HTML is a single exported string so the routes module can serve it
9
13
  * with no filesystem read (works identically on Bun, Node, and Workers).
10
14
  * All fetches are same-origin and relative ('./clients', …), so the page
11
15
  * works under whatever prefix the host mounts the routes at, and the host's
12
16
  * `authorize` guard applies to the page's own XHRs (same cookies/headers).
17
+ *
18
+ * Panels cover the whole read surface: horizon, store stats, clients,
19
+ * commits (with a table filter), the row inspector (`/rows/:table/:rowId`),
20
+ * scope activity (`/scope-activity`), and the event tail (with a type
21
+ * filter).
13
22
  */
14
23
  export const ADMIN_CONSOLE_HTML = `<!doctype html>
15
24
  <html lang="en">
@@ -19,80 +28,157 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
19
28
  <title>Syncular console</title>
20
29
  <style>
21
30
  :root {
22
- --bg: #0f1115; --panel: #171a21; --border: #262b36; --fg: #d7dce5;
23
- --muted: #8a93a6; --accent: #5b9dff; --ok: #43c17a; --warn: #e0a83a;
24
- --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
25
- --sans: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
31
+ --void: #000000; --panel: #0a0908; --ink: #f4efe4; --dim: #9a948a;
32
+ --faint: #756f64; --border: rgba(154,148,138,.35);
33
+ --border-strong: rgba(154,148,138,.6); --amber: #ffb000;
34
+ --mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
26
35
  }
27
36
  * { box-sizing: border-box; }
28
- body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 var(--sans); }
29
- header { display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
30
- padding: 14px 20px; border-bottom: 1px solid var(--border); background: var(--panel); }
31
- header h1 { font-size: 15px; margin: 0; font-weight: 600; letter-spacing: .02em; }
37
+ ::selection { background: var(--amber); color: #000; }
38
+ body { margin: 0; background: var(--void); color: var(--ink); font: 14px/1.6 var(--mono); }
39
+ @keyframes blink { 50% { opacity: 0; } }
40
+ .blink { animation: blink 1.1s steps(1) infinite; color: var(--amber); }
41
+ @media (prefers-reduced-motion: reduce) { .blink { animation: none; } }
42
+
43
+ header { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
44
+ padding: .8rem 1.25rem; border-bottom: 1px solid var(--border); }
45
+ header h1 { font-size: .85rem; margin: 0; font-weight: 700; letter-spacing: .18em; }
46
+ header h1 .d { color: var(--faint); }
32
47
  header .spacer { flex: 1; }
33
- header label { color: var(--muted); font-size: 13px; display: inline-flex; gap: 6px; align-items: center; }
34
- header input[type=text] { background: var(--bg); border: 1px solid var(--border); color: var(--fg);
35
- border-radius: 6px; padding: 5px 8px; font: 13px var(--mono); width: 140px; }
36
- header button { background: var(--bg); border: 1px solid var(--border); color: var(--fg);
37
- border-radius: 6px; padding: 5px 11px; cursor: pointer; font: 13px var(--sans); }
38
- header button:hover { border-color: var(--accent); }
39
- #status { color: var(--muted); font: 12px var(--mono); }
40
- main { padding: 18px 20px; display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); }
41
- section { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
42
- section > h2 { font-size: 13px; margin: 0; padding: 10px 14px; border-bottom: 1px solid var(--border);
43
- font-weight: 600; color: var(--fg); display: flex; justify-content: space-between; align-items: center; }
44
- section > h2 .count { color: var(--muted); font: 12px var(--mono); font-weight: 400; }
48
+ header label { color: var(--dim); font-size: .68rem; letter-spacing: .1em;
49
+ text-transform: uppercase; display: inline-flex; gap: .5rem; align-items: center; }
50
+ input[type=text] { background: var(--void); border: 1px solid var(--border); color: var(--ink);
51
+ border-radius: 0; padding: .3rem .5rem; font: .78rem var(--mono); }
52
+ input[type=text]:focus { outline: none; border-color: var(--amber); }
53
+ input[type=text]::placeholder { color: var(--faint); }
54
+ button { background: var(--void); border: 1px solid var(--ink); color: var(--ink);
55
+ border-radius: 0; padding: .3rem .7rem; cursor: pointer;
56
+ font: .72rem var(--mono); letter-spacing: .06em; }
57
+ button:hover { background: var(--ink); color: #000; }
58
+ button.primary { border-color: var(--amber); color: var(--amber); }
59
+ button.primary:hover { background: var(--amber); color: #000; }
60
+ button.on { background: var(--amber); border-color: var(--amber); color: #000; }
61
+ #status { color: var(--dim); font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; }
62
+ #status.err { color: var(--amber); }
63
+
64
+ main { padding: 1.25rem; display: grid; gap: 1.2rem;
65
+ grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); }
66
+ section { border: 1px solid var(--border); background: var(--void); position: relative; }
67
+ section::before, section::after { color: var(--faint); position: absolute; font-size: .72rem; }
68
+ section::before { content: '+'; top: -0.6em; left: -0.28em; }
69
+ section::after { content: '+'; bottom: -0.6em; right: -0.28em; }
70
+ section > h2 { font-size: .72rem; margin: 0; padding: .55rem .9rem;
71
+ border-bottom: 1px solid var(--border); font-weight: 400; color: var(--faint);
72
+ letter-spacing: .12em; text-transform: uppercase;
73
+ display: flex; justify-content: space-between; align-items: center; gap: .6rem; }
74
+ section > h2 .count { color: var(--dim); letter-spacing: .04em; }
75
+ .tools { display: flex; gap: .5rem; padding: .55rem .9rem; flex-wrap: wrap;
76
+ border-bottom: 1px solid var(--border); }
77
+ .tools input { flex: 1; min-width: 8rem; }
45
78
  .body { max-height: 360px; overflow: auto; }
46
- table { width: 100%; border-collapse: collapse; font: 12px var(--mono); }
47
- th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
48
- th { color: var(--muted); font-weight: 500; position: sticky; top: 0; background: var(--panel); z-index: 1; }
49
- td.wrap { white-space: normal; word-break: break-all; }
50
- tr:hover td { background: #1c212b; }
51
- .pill { display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 11px; }
52
- .pill.ok { background: rgba(67,193,122,.15); color: var(--ok); }
53
- .pill.warn { background: rgba(224,168,58,.15); color: var(--warn); }
54
- .pill.idle { background: rgba(138,147,166,.15); color: var(--muted); }
55
- .kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; padding: 10px 14px; font: 12px var(--mono); }
56
- .kv dt { color: var(--muted); }
57
- .empty { color: var(--muted); padding: 14px; font-style: italic; }
58
- .evt-type { color: var(--accent); }
79
+
80
+ table { width: 100%; border-collapse: collapse; font-size: .78rem; }
81
+ th, td { text-align: left; padding: .35rem .9rem; border-bottom: 1px solid var(--border);
82
+ white-space: nowrap; vertical-align: top; }
83
+ th { color: var(--dim); font-weight: 700; font-size: .68rem; letter-spacing: .06em;
84
+ text-transform: uppercase; position: sticky; top: 0; background: var(--panel); z-index: 1; }
85
+ td.wrap, .wrap { white-space: normal; word-break: break-all; }
86
+ tr:hover td { background: var(--panel); }
87
+
88
+ .chip { font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; color: var(--dim); }
89
+ .chip::before { content: '[ '; color: var(--faint); }
90
+ .chip::after { content: ' ]'; color: var(--faint); }
91
+ .chip.ok { color: var(--amber); }
92
+ .chip.warn { background: var(--amber); color: #000; }
93
+ .chip.warn::before, .chip.warn::after { color: #000; }
94
+
95
+ .kv { display: grid; grid-template-columns: auto 1fr; gap: .2rem .9rem;
96
+ padding: .6rem .9rem; font-size: .78rem; margin: 0; }
97
+ .kv dt { color: var(--dim); }
98
+ .kv dd { margin: 0; }
99
+ .kv .v { color: var(--amber); }
100
+ .empty { color: var(--faint); padding: .8rem .9rem; font-style: italic; font-size: .78rem; }
101
+ .evt-type { color: var(--amber); }
59
102
  .full { grid-column: 1 / -1; }
103
+
104
+ footer { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
105
+ padding: .8rem 1.25rem 1.4rem; color: var(--faint); font-size: .68rem; letter-spacing: .1em; }
106
+ .rule { color: var(--faint); font-size: .72rem; white-space: nowrap; overflow: hidden;
107
+ user-select: none; padding: 0 1.25rem; }
108
+ footer .sq { color: var(--amber); }
60
109
  </style>
61
110
  </head>
62
111
  <body>
63
112
  <header>
64
- <h1>Syncular console</h1>
113
+ <h1>SYNCULAR<span class="d"> · </span>CONSOLE<span class="blink">_</span></h1>
65
114
  <label>partition
66
115
  <input id="partition" type="text" placeholder="(default)" />
67
116
  </label>
68
- <button id="refresh">Refresh</button>
69
- <label><input id="auto" type="checkbox" /> auto</label>
117
+ <button id="refresh" class="primary">[ REFRESH ]</button>
118
+ <button id="auto">[ AUTO OFF ]</button>
70
119
  <span class="spacer"></span>
71
120
  <span id="status">idle</span>
72
121
  </header>
73
122
  <main>
74
- <section><h2>Horizon <span class="count" id="horizon-rec"></span></h2><div class="body" id="horizon"></div></section>
75
- <section><h2>Store stats</h2><div class="body" id="stats"></div></section>
76
- <section><h2>Clients <span class="count" id="clients-count"></span></h2><div class="body" id="clients"></div></section>
77
- <section><h2>Recent commits <span class="count" id="commits-count"></span></h2><div class="body" id="commits"></div></section>
78
- <section class="full"><h2>Event stream <span class="count" id="events-count"></span></h2><div class="body" id="events"></div></section>
123
+ <section><h2>── horizon <span class="count" id="horizon-rec"></span></h2><div class="body" id="horizon"></div></section>
124
+ <section><h2>── store stats</h2><div class="body" id="stats"></div></section>
125
+ <section><h2>── clients <span class="count" id="clients-count"></span></h2><div class="body" id="clients"></div></section>
126
+ <section>
127
+ <h2>── commits <span class="count" id="commits-count"></span></h2>
128
+ <div class="tools"><input id="commits-filter" type="text" placeholder="filter by table" /></div>
129
+ <div class="body" id="commits"></div>
130
+ </section>
131
+ <section>
132
+ <h2>── row inspector</h2>
133
+ <div class="tools">
134
+ <input id="inspect-table" type="text" placeholder="table" />
135
+ <input id="inspect-row" type="text" placeholder="rowId" />
136
+ <button id="inspect-go">[ INSPECT ]</button>
137
+ </div>
138
+ <div class="body" id="inspect"></div>
139
+ </section>
140
+ <section>
141
+ <h2>── scope activity <span class="count" id="scope-count"></span></h2>
142
+ <div class="tools">
143
+ <input id="scope-variable" type="text" placeholder="variable e.g. project" />
144
+ <input id="scope-value" type="text" placeholder="value e.g. p1" />
145
+ <button id="scope-go">[ QUERY ]</button>
146
+ </div>
147
+ <div class="body" id="scope"></div>
148
+ </section>
149
+ <section class="full">
150
+ <h2>── event stream <span class="count" id="events-count"></span></h2>
151
+ <div class="tools"><input id="events-filter" type="text" placeholder="filter by type e.g. push.applied" /></div>
152
+ <div class="body" id="events"></div>
153
+ </section>
79
154
  </main>
155
+ <div class="rule">================================================================================================================================================================</div>
156
+ <footer>
157
+ <span>SYNCULAR · ADMIN</span>
158
+ <span>END OF TRANSMISSION <span class="sq">■</span></span>
159
+ </footer>
80
160
  <script>
81
161
  (function () {
82
162
  var el = function (id) { return document.getElementById(id); };
83
163
  var status = el('status');
84
164
 
85
- function q() {
86
- var p = el('partition').value.trim();
87
- return p ? ('?partition=' + encodeURIComponent(p)) : '';
88
- }
89
165
  // The endpoints are siblings of the page under the SAME mount prefix. The
90
166
  // page is served at the mount root (…/admin or …/admin/); strip any
91
167
  // trailing slash so 'base + /clients' hits '…/admin/clients' regardless of
92
168
  // whether the URL had the trailing slash.
93
169
  var base = location.pathname.replace(/\\/+$/, '');
94
- function get(path) {
95
- return fetch(base + path + q(), { headers: { accept: 'application/json' } })
170
+ function get(path, params) {
171
+ var parts = [];
172
+ var p = el('partition').value.trim();
173
+ if (p) parts.push('partition=' + encodeURIComponent(p));
174
+ if (params) Object.keys(params).forEach(function (k) {
175
+ var v = params[k];
176
+ if (v !== undefined && v !== null && v !== '') {
177
+ parts.push(k + '=' + encodeURIComponent(v));
178
+ }
179
+ });
180
+ var qs = parts.length ? '?' + parts.join('&') : '';
181
+ return fetch(base + path + qs, { headers: { accept: 'application/json' } })
96
182
  .then(function (r) {
97
183
  if (!r.ok) return r.json().then(function (b) { throw new Error(b.code || ('HTTP ' + r.status)); });
98
184
  return r.json();
@@ -116,30 +202,29 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
116
202
  if (n < 1048576) return (n / 1024).toFixed(1) + ' KiB';
117
203
  return (n / 1048576).toFixed(1) + ' MiB';
118
204
  }
205
+ function empty(text) { return '<div class="empty">— ' + esc(text || 'none') + ' —</div>'; }
119
206
  function table(head, rows) {
120
- if (!rows.length) return '<div class="empty">none</div>';
207
+ if (!rows.length) return empty('none');
121
208
  var h = '<table><thead><tr>' + head.map(function (c) { return '<th>' + esc(c) + '</th>'; }).join('') + '</tr></thead><tbody>';
122
209
  h += rows.map(function (r) {
123
210
  return '<tr>' + r.map(function (c) { return '<td>' + c + '</td>'; }).join('') + '</tr>';
124
211
  }).join('');
125
212
  return h + '</tbody></table>';
126
213
  }
214
+ function chip(text, cls) { return '<span class="chip ' + cls + '">' + esc(text) + '</span>'; }
127
215
 
128
216
  function renderHorizon(h) {
129
217
  el('horizon').innerHTML =
130
218
  '<dl class="kv">' +
131
- '<dt>maxCommitSeq</dt><dd>' + esc(h.maxCommitSeq) + '</dd>' +
132
- '<dt>horizonSeq</dt><dd>' + esc(h.horizonSeq) + '</dd>' +
219
+ '<dt>maxCommitSeq</dt><dd class="v">' + esc(h.maxCommitSeq) + '</dd>' +
220
+ '<dt>horizonSeq</dt><dd class="v">' + esc(h.horizonSeq) + '</dd>' +
133
221
  '<dt>retained commits</dt><dd>' + esc(h.retainedCommits) + '</dd>' +
134
222
  '<dt>active cursor floor</dt><dd>' + esc(h.activeCursorFloor == null ? 'none' : h.activeCursorFloor) + '</dd>' +
135
223
  '<dt>recommended horizon</dt><dd>' + esc(h.recommendedHorizonSeq) + '</dd>' +
136
224
  '</dl>';
137
- var rec = el('horizon-rec');
138
- if (h.recommendation === 'prune-recommended') {
139
- rec.innerHTML = '<span class="pill warn">prune recommended</span>';
140
- } else {
141
- rec.innerHTML = '<span class="pill ok">up to date</span>';
142
- }
225
+ el('horizon-rec').innerHTML = h.recommendation === 'prune-recommended'
226
+ ? chip('prune recommended', 'warn')
227
+ : chip('up to date', 'ok');
143
228
  }
144
229
  function renderStats(s) {
145
230
  var seg = s.segments, blob = s.blobs;
@@ -157,7 +242,7 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
157
242
  el('clients-count').textContent = list.length + ' known';
158
243
  el('clients').innerHTML = table(['client', 'actor', 'cursor', 'seen', 'subs', ''],
159
244
  list.map(function (c) {
160
- var pill = c.active ? '<span class="pill ok">active</span>' : '<span class="pill idle">idle</span>';
245
+ var pill = c.active ? chip('active', 'ok') : chip('idle', '');
161
246
  return [esc(c.clientId), esc(c.actorId), esc(c.cursor), ago(c.updatedAtMs), esc(c.subscriptions.length), pill];
162
247
  }));
163
248
  }
@@ -169,11 +254,32 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
169
254
  '<span class="wrap">' + esc(c.tables.join(', ')) + '</span>', ago(c.createdAtMs)];
170
255
  }));
171
256
  }
257
+ function renderInspection(row) {
258
+ var h = '<dl class="kv">' +
259
+ '<dt>table</dt><dd>' + esc(row.table) + '</dd>' +
260
+ '<dt>rowId</dt><dd>' + esc(row.rowId) + '</dd>' +
261
+ '<dt>exists</dt><dd>' + (row.exists ? chip('yes', 'ok') : chip('no', '')) + '</dd>';
262
+ if (row.exists) {
263
+ h += '<dt>server_version</dt><dd class="v">' + esc(row.serverVersion) + '</dd>' +
264
+ '<dt>scopes</dt><dd class="wrap">' + esc(JSON.stringify(row.scopes)) + '</dd>';
265
+ if (row.referencedBlobIds) {
266
+ h += '<dt>blob refs</dt><dd class="wrap">' + esc(row.referencedBlobIds.join(', ') || 'none') + '</dd>';
267
+ }
268
+ }
269
+ el('inspect').innerHTML = h + '</dl>';
270
+ }
271
+ function renderScope(list) {
272
+ el('scope-count').textContent = list.length + ' shown';
273
+ el('scope').innerHTML = table(['seq', 'table', 'actor', 'changes', 'when'],
274
+ list.map(function (a) {
275
+ return [esc(a.commitSeq), esc(a.table), esc(a.actorId), esc(a.changeCount), ago(a.createdAtMs)];
276
+ }));
277
+ }
172
278
  function renderEvents(res) {
173
279
  el('events-count').textContent = res.hasEventStream
174
280
  ? (res.events.length + ' events')
175
281
  : 'no ring buffer wired';
176
- if (!res.hasEventStream) { el('events').innerHTML = '<div class="empty">no RingBufferEvents wired on this admin</div>'; return; }
282
+ if (!res.hasEventStream) { el('events').innerHTML = empty('no RingBufferEvents wired on this admin'); return; }
177
283
  el('events').innerHTML = table(['when', 'type', 'detail'],
178
284
  res.events.map(function (e) {
179
285
  var detail = Object.keys(e)
@@ -189,26 +295,62 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
189
295
  function loadAll() {
190
296
  if (loading) return;
191
297
  loading = true;
298
+ status.className = '';
192
299
  status.textContent = 'loading…';
193
300
  Promise.all([
194
301
  get('/horizon').then(function (r) { renderHorizon(r.horizon); }),
195
302
  get('/stats').then(function (r) { renderStats(r.stats); }),
196
303
  get('/clients').then(function (r) { renderClients(r.clients); }),
197
- get('/commits').then(function (r) { renderCommits(r.commits); }),
198
- get('/events').then(renderEvents)
304
+ get('/commits', { table: el('commits-filter').value.trim() }).then(function (r) { renderCommits(r.commits); }),
305
+ get('/events', { type: el('events-filter').value.trim() }).then(renderEvents)
199
306
  ]).then(function () {
200
307
  status.textContent = 'updated ' + new Date().toLocaleTimeString();
201
308
  }).catch(function (err) {
309
+ status.className = 'err';
202
310
  status.textContent = 'error: ' + err.message;
203
311
  }).finally(function () { loading = false; });
204
312
  }
205
313
 
314
+ function inspect() {
315
+ var t = el('inspect-table').value.trim();
316
+ var id = el('inspect-row').value.trim();
317
+ if (!t || !id) { el('inspect').innerHTML = empty('table and rowId required'); return; }
318
+ get('/rows/' + encodeURIComponent(t) + '/' + encodeURIComponent(id))
319
+ .then(function (r) { renderInspection(r.row); })
320
+ .catch(function (err) { el('inspect').innerHTML = empty('error: ' + err.message); });
321
+ }
322
+ function scopeQuery() {
323
+ var variable = el('scope-variable').value.trim();
324
+ var value = el('scope-value').value.trim();
325
+ if (!variable || !value) { el('scope').innerHTML = empty('variable and value required'); return; }
326
+ get('/scope-activity', { variable: variable, value: value })
327
+ .then(function (r) { renderScope(r.activity); })
328
+ .catch(function (err) { el('scope').innerHTML = empty('error: ' + err.message); });
329
+ }
330
+ function onEnter(id, fn) {
331
+ el(id).addEventListener('keydown', function (e) { if (e.key === 'Enter') fn(); });
332
+ }
333
+
206
334
  el('refresh').addEventListener('click', loadAll);
207
335
  el('partition').addEventListener('change', loadAll);
336
+ el('commits-filter').addEventListener('change', loadAll);
337
+ el('events-filter').addEventListener('change', loadAll);
338
+ el('inspect-go').addEventListener('click', inspect);
339
+ onEnter('inspect-table', inspect);
340
+ onEnter('inspect-row', inspect);
341
+ el('scope-go').addEventListener('click', scopeQuery);
342
+ onEnter('scope-variable', scopeQuery);
343
+ onEnter('scope-value', scopeQuery);
208
344
  var timer = null;
209
- el('auto').addEventListener('change', function () {
210
- if (this.checked) { timer = setInterval(loadAll, 2000); loadAll(); }
211
- else if (timer) { clearInterval(timer); timer = null; }
345
+ el('auto').addEventListener('click', function () {
346
+ if (timer) {
347
+ clearInterval(timer); timer = null;
348
+ this.classList.remove('on'); this.textContent = '[ AUTO OFF ]';
349
+ } else {
350
+ timer = setInterval(loadAll, 2000);
351
+ this.classList.add('on'); this.textContent = '[ AUTO ON ]';
352
+ loadAll();
353
+ }
212
354
  });
213
355
  loadAll();
214
356
  })();