@syncular/server-hono 0.3.1 → 0.4.1

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.
@@ -15,9 +15,12 @@
15
15
  * works under whatever prefix the host mounts the routes at, and the host's
16
16
  * `authorize` guard applies to the page's own XHRs (same cookies/headers).
17
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).
18
+ * Panels cover the whole read surface: a metrics statusbar (ring-derived
19
+ * rates + sparkline), the fleet view (`/partitions`, doubling as the
20
+ * partition picker), horizon, store stats, clients (with cursor lag and a
21
+ * per-client drill-down: subscriptions, lease, event slice), commits (with
22
+ * a table filter), the row inspector, scope activity, and the event tail —
23
+ * live over SSE (`/events/stream`) when the ring is wired, falling back to
24
+ * the 2-second poll otherwise.
22
25
  */
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>";
26
+ 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 #statusbar { display: flex; gap: 1.4rem; align-items: baseline; flex-wrap: wrap;\n padding: .5rem 1.25rem; border-bottom: 1px solid var(--border);\n font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--dim); }\n #statusbar .m b { color: var(--amber); font-weight: 400; }\n #statusbar .spark { color: var(--amber); letter-spacing: 0; text-transform: none; }\n #statusbar .spark.flat { color: var(--faint); }\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 tr.pick { cursor: pointer; }\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 #modal { position: fixed; inset: 0; z-index: 10; background: rgba(0,0,0,.82);\n display: flex; align-items: center; justify-content: center; padding: 2rem; }\n #modal[hidden] { display: none; }\n #modal .box { background: var(--void); border: 1px solid var(--border-strong);\n width: min(820px, 100%); max-height: 85vh; display: flex; flex-direction: column;\n position: relative; }\n #modal .box::before, #modal .box::after { color: var(--faint); position: absolute; font-size: .72rem; }\n #modal .box::before { content: '+'; top: -0.6em; left: -0.28em; }\n #modal .box::after { content: '+'; bottom: -0.6em; right: -0.28em; }\n #modal h3 { 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 #modal h3 .count { color: var(--amber); letter-spacing: .04em; text-transform: none; }\n #modal h3 .spacer { flex: 1; }\n #modal .body { overflow: auto; }\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)\" list=\"partitions-list\" />\n </label>\n <datalist id=\"partitions-list\"></datalist>\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<div id=\"statusbar\">\n <span class=\"m\">push <b id=\"m-push\">\u2013</b>/min</span>\n <span class=\"m\">conflicts <b id=\"m-conflicts\">\u2013</b></span>\n <span class=\"m\">err <b id=\"m-err\">\u2013</b></span>\n <span class=\"m\">p95 <b id=\"m-p95\">\u2013</b></span>\n <span class=\"spark\" id=\"m-spark\"></span>\n <span class=\"m\" id=\"m-window\"></span>\n</div>\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>\n <h2>\u2500\u2500 fleet <span class=\"count\" id=\"fleet-count\"></span></h2>\n <div class=\"body\" id=\"fleet\"></div>\n </section>\n <section>\n <h2>\u2500\u2500 clients <span class=\"count\" id=\"clients-count\"></span></h2>\n <div class=\"body\" id=\"clients\"></div>\n </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-live\"></span> <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 id=\"modal\" hidden>\n <div class=\"box\">\n <h3>\u2500\u2500 client drill-down <span class=\"count\" id=\"modal-title\"></span> <span class=\"spacer\"></span> <button id=\"detail-close\">[ CLOSE ]</button></h3>\n <div class=\"body\" id=\"detail-body\"></div>\n </div>\n</div>\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 qs(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 return parts.length ? '?' + parts.join('&') : '';\n }\n function get(path, params) {\n return fetch(base + path + qs(params), { 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 until(ms) {\n var d = ms - Date.now();\n if (d <= 0) return 'expired';\n if (d < 60000) return 'in ' + Math.round(d / 1000) + 's';\n if (d < 3600000) return 'in ' + Math.round(d / 60000) + 'm';\n return 'in ' + 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, rowAttrs) {\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, i) {\n var attrs = rowAttrs ? rowAttrs(i) : '';\n return '<tr' + (attrs ? ' ' + attrs : '') + '>' + 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 var SPARK = '\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588';\n function spark(counts) {\n var max = 0;\n counts.forEach(function (n) { if (n > max) max = n; });\n return counts.map(function (n) {\n var level = max === 0 ? 0 : Math.round((n / max) * (SPARK.length - 1));\n return SPARK.charAt(level);\n }).join('');\n }\n\n function renderMetrics(m) {\n el('m-push').textContent = m.requests.count === 0 ? '0' :\n (m.requests.perMinute >= 10 ? Math.round(m.requests.perMinute) : m.requests.perMinute.toFixed(1));\n el('m-conflicts').textContent = m.pushes.conflicted;\n el('m-err').textContent = (m.requests.errorRate * 100).toFixed(1) + '%';\n el('m-p95').textContent = Math.round(m.requests.p95Ms) + 'ms';\n var sp = el('m-spark');\n sp.textContent = spark(m.buckets.counts);\n sp.className = 'spark' + (m.requests.count === 0 ? ' flat' : '');\n el('m-window').textContent = 'last ' + Math.round(m.windowMs / 60000) + 'min';\n }\n function renderFleet(list) {\n el('fleet-count').textContent = list.length + ' partitions';\n var picker = el('partitions-list');\n picker.innerHTML = list.map(function (p) {\n return '<option value=\"' + esc(p.partition) + '\"></option>';\n }).join('');\n el('fleet').innerHTML = table(\n ['partition', 'max seq', 'horizon', 'retained', 'clients', ''],\n list.map(function (p) {\n var rec = p.recommendation === 'prune-recommended'\n ? chip('prune', 'warn') : chip('ok', 'ok');\n return ['<span class=\"v\">' + esc(p.partition) + '</span>', esc(p.maxCommitSeq),\n esc(p.horizonSeq), esc(p.retainedCommits),\n esc(p.activeClients) + '/' + esc(p.knownClients) + ' active', rec];\n }),\n function (i) { return 'class=\"pick\" data-partition=\"' + esc(list[i].partition) + '\"'; });\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', 'lag', 'seen', 'subs', ''],\n list.map(function (c) {\n var pill = c.active ? chip('active', 'ok') : chip('idle', '');\n var lag = c.lag > 0 ? '<span class=\"v\">' + esc(c.lag) + '</span>' : esc(c.lag);\n return [esc(c.clientId), esc(c.actorId), esc(c.cursor), lag, ago(c.updatedAtMs), esc(c.subscriptions.length), pill];\n }),\n function (i) { return 'class=\"pick\" data-client=\"' + esc(list[i].clientId) + '\"'; });\n }\n function renderDetail(d) {\n if (!d.exists) {\n el('detail-body').innerHTML = empty('no record for ' + d.clientId);\n return;\n }\n var c = d.client;\n var h = '<dl class=\"kv\">' +\n '<dt>client</dt><dd class=\"v\">' + esc(c.clientId) + '</dd>' +\n '<dt>actor</dt><dd>' + esc(c.actorId) + '</dd>' +\n '<dt>cursor / lag</dt><dd>' + esc(c.cursor) + ' / ' + esc(c.lag) + '</dd>' +\n '<dt>seen</dt><dd>' + ago(c.updatedAtMs) + ' ago ' + (c.active ? chip('active', 'ok') : chip('idle', '')) + '</dd>';\n if (d.lease) {\n h += '<dt>lease</dt><dd class=\"wrap\">' + esc(d.lease.leaseId) + ' \u00B7 ' +\n (d.lease.revoked ? chip('revoked', 'warn') : chip(until(d.lease.expiresAtMs), 'ok')) + '</dd>';\n } else {\n h += '<dt>lease</dt><dd>none</dd>';\n }\n h += '</dl>';\n h += table(['sub', 'table', 'scopes'], c.subscriptions.map(function (s) {\n return [esc(s.id), esc(s.table), '<span class=\"wrap\">' + esc(JSON.stringify(s.scopes)) + '</span>'];\n }));\n h += table(['when', 'type', 'detail'], d.events.map(function (e) {\n return [ago(e.atMs), '<span class=\"evt-type\">' + esc(e.type) + '</span>',\n '<span class=\"wrap\">' + esc(evtDetail(e)) + '</span>'];\n }));\n el('detail-body').innerHTML = h;\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 evtDetail(e) {\n return 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 }\n var MAX_TAIL = 200;\n function renderEventList(list) {\n el('events-count').textContent = list.length + ' events';\n el('events').innerHTML = table(['when', 'type', 'detail'],\n list.map(function (e) {\n return [ago(e.atMs), '<span class=\"evt-type\">' + esc(e.type) + '</span>',\n '<span class=\"wrap\">' + esc(evtDetail(e)) + '</span>'];\n }));\n }\n\n // -- the event tail: SSE when the ring is wired, 2 s poll otherwise -------\n var tail = { source: null, list: [] };\n function connectStream() {\n if (tail.source) { tail.source.close(); tail.source = null; }\n tail.list = [];\n var source = new EventSource(base + '/events/stream' + qs({ type: el('events-filter').value.trim(), limit: MAX_TAIL }));\n tail.source = source;\n source.onopen = function () { el('events-live').innerHTML = chip('live', 'ok'); };\n source.onmessage = function (msg) {\n tail.list.unshift(JSON.parse(msg.data));\n if (tail.list.length > MAX_TAIL) tail.list.length = MAX_TAIL;\n renderEventList(tail.list);\n };\n source.onerror = function () {\n // Auth or transport failure: fall back to the poll for good.\n source.close();\n tail.source = null;\n el('events-live').innerHTML = chip('poll', '');\n };\n }\n function loadEvents() {\n if (tail.source) return Promise.resolve(); // the stream owns the panel\n return get('/events', { type: el('events-filter').value.trim() }).then(function (res) {\n if (!res.hasEventStream) {\n el('events-count').textContent = 'no ring buffer wired';\n el('events').innerHTML = empty('no RingBufferEvents wired on this admin');\n return;\n }\n if (typeof EventSource === 'function') { connectStream(); return; }\n renderEventList(res.events);\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 // The fleet read is optional server surface \u2014 it must not fail the load.\n get('/partitions').then(function (r) { renderFleet(r.partitions); })\n .catch(function () { el('fleet').innerHTML = empty('listPartitions not supported by this storage'); });\n Promise.all([\n get('/horizon').then(function (r) { renderHorizon(r.horizon); }),\n get('/stats').then(function (r) { renderStats(r.stats); }),\n get('/metrics').then(function (r) { renderMetrics(r.metrics); }),\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 loadEvents()\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 openDetail(clientId) {\n el('modal').hidden = false;\n el('modal-title').textContent = clientId;\n el('detail-body').innerHTML = empty('loading ' + clientId + '\u2026');\n get('/clients/' + encodeURIComponent(clientId))\n .then(function (r) { renderDetail(r.detail); })\n .catch(function (err) { el('detail-body').innerHTML = empty('error: ' + err.message); });\n }\n function closeDetail() { el('modal').hidden = true; }\n function onEnter(id, fn) {\n el(id).addEventListener('keydown', function (e) { if (e.key === 'Enter') fn(); });\n }\n function pickedRow(target, attr) {\n var row = target.closest ? target.closest('tr[data-' + attr + ']') : null;\n return row ? row.getAttribute('data-' + attr) : null;\n }\n\n el('refresh').addEventListener('click', loadAll);\n el('partition').addEventListener('change', function () {\n if (tail.source) { tail.source.close(); tail.source = null; }\n closeDetail();\n loadAll();\n });\n el('commits-filter').addEventListener('change', loadAll);\n el('events-filter').addEventListener('change', function () {\n if (tail.source) connectStream(); else loadAll();\n });\n el('clients').addEventListener('click', function (e) {\n var id = pickedRow(e.target, 'client');\n if (id !== null) openDetail(id);\n });\n el('detail-close').addEventListener('click', closeDetail);\n el('modal').addEventListener('click', function (e) {\n if (e.target === el('modal')) closeDetail();\n });\n document.addEventListener('keydown', function (e) {\n if (e.key === 'Escape') closeDetail();\n });\n el('fleet').addEventListener('click', function (e) {\n var p = pickedRow(e.target, 'partition');\n if (p !== null) { el('partition').value = p; el('partition').dispatchEvent(new Event('change')); }\n });\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>";
@@ -15,10 +15,13 @@
15
15
  * works under whatever prefix the host mounts the routes at, and the host's
16
16
  * `authorize` guard applies to the page's own XHRs (same cookies/headers).
17
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).
18
+ * Panels cover the whole read surface: a metrics statusbar (ring-derived
19
+ * rates + sparkline), the fleet view (`/partitions`, doubling as the
20
+ * partition picker), horizon, store stats, clients (with cursor lag and a
21
+ * per-client drill-down: subscriptions, lease, event slice), commits (with
22
+ * a table filter), the row inspector, scope activity, and the event tail —
23
+ * live over SSE (`/events/stream`) when the ring is wired, falling back to
24
+ * the 2-second poll otherwise.
22
25
  */
23
26
  export const ADMIN_CONSOLE_HTML = `<!doctype html>
24
27
  <html lang="en">
@@ -61,6 +64,13 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
61
64
  #status { color: var(--dim); font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; }
62
65
  #status.err { color: var(--amber); }
63
66
 
67
+ #statusbar { display: flex; gap: 1.4rem; align-items: baseline; flex-wrap: wrap;
68
+ padding: .5rem 1.25rem; border-bottom: 1px solid var(--border);
69
+ font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--dim); }
70
+ #statusbar .m b { color: var(--amber); font-weight: 400; }
71
+ #statusbar .spark { color: var(--amber); letter-spacing: 0; text-transform: none; }
72
+ #statusbar .spark.flat { color: var(--faint); }
73
+
64
74
  main { padding: 1.25rem; display: grid; gap: 1.2rem;
65
75
  grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); }
66
76
  section { border: 1px solid var(--border); background: var(--void); position: relative; }
@@ -84,6 +94,7 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
84
94
  text-transform: uppercase; position: sticky; top: 0; background: var(--panel); z-index: 1; }
85
95
  td.wrap, .wrap { white-space: normal; word-break: break-all; }
86
96
  tr:hover td { background: var(--panel); }
97
+ tr.pick { cursor: pointer; }
87
98
 
88
99
  .chip { font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; color: var(--dim); }
89
100
  .chip::before { content: '[ '; color: var(--faint); }
@@ -101,6 +112,23 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
101
112
  .evt-type { color: var(--amber); }
102
113
  .full { grid-column: 1 / -1; }
103
114
 
115
+ #modal { position: fixed; inset: 0; z-index: 10; background: rgba(0,0,0,.82);
116
+ display: flex; align-items: center; justify-content: center; padding: 2rem; }
117
+ #modal[hidden] { display: none; }
118
+ #modal .box { background: var(--void); border: 1px solid var(--border-strong);
119
+ width: min(820px, 100%); max-height: 85vh; display: flex; flex-direction: column;
120
+ position: relative; }
121
+ #modal .box::before, #modal .box::after { color: var(--faint); position: absolute; font-size: .72rem; }
122
+ #modal .box::before { content: '+'; top: -0.6em; left: -0.28em; }
123
+ #modal .box::after { content: '+'; bottom: -0.6em; right: -0.28em; }
124
+ #modal h3 { font-size: .72rem; margin: 0; padding: .55rem .9rem;
125
+ border-bottom: 1px solid var(--border); font-weight: 400; color: var(--faint);
126
+ letter-spacing: .12em; text-transform: uppercase;
127
+ display: flex; justify-content: space-between; align-items: center; gap: .6rem; }
128
+ #modal h3 .count { color: var(--amber); letter-spacing: .04em; text-transform: none; }
129
+ #modal h3 .spacer { flex: 1; }
130
+ #modal .body { overflow: auto; }
131
+
104
132
  footer { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
105
133
  padding: .8rem 1.25rem 1.4rem; color: var(--faint); font-size: .68rem; letter-spacing: .1em; }
106
134
  .rule { color: var(--faint); font-size: .72rem; white-space: nowrap; overflow: hidden;
@@ -112,17 +140,33 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
112
140
  <header>
113
141
  <h1>SYNCULAR<span class="d"> · </span>CONSOLE<span class="blink">_</span></h1>
114
142
  <label>partition
115
- <input id="partition" type="text" placeholder="(default)" />
143
+ <input id="partition" type="text" placeholder="(default)" list="partitions-list" />
116
144
  </label>
145
+ <datalist id="partitions-list"></datalist>
117
146
  <button id="refresh" class="primary">[ REFRESH ]</button>
118
147
  <button id="auto">[ AUTO OFF ]</button>
119
148
  <span class="spacer"></span>
120
149
  <span id="status">idle</span>
121
150
  </header>
151
+ <div id="statusbar">
152
+ <span class="m">push <b id="m-push">–</b>/min</span>
153
+ <span class="m">conflicts <b id="m-conflicts">–</b></span>
154
+ <span class="m">err <b id="m-err">–</b></span>
155
+ <span class="m">p95 <b id="m-p95">–</b></span>
156
+ <span class="spark" id="m-spark"></span>
157
+ <span class="m" id="m-window"></span>
158
+ </div>
122
159
  <main>
123
160
  <section><h2>── horizon <span class="count" id="horizon-rec"></span></h2><div class="body" id="horizon"></div></section>
124
161
  <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>
162
+ <section>
163
+ <h2>── fleet <span class="count" id="fleet-count"></span></h2>
164
+ <div class="body" id="fleet"></div>
165
+ </section>
166
+ <section>
167
+ <h2>── clients <span class="count" id="clients-count"></span></h2>
168
+ <div class="body" id="clients"></div>
169
+ </section>
126
170
  <section>
127
171
  <h2>── commits <span class="count" id="commits-count"></span></h2>
128
172
  <div class="tools"><input id="commits-filter" type="text" placeholder="filter by table" /></div>
@@ -147,11 +191,17 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
147
191
  <div class="body" id="scope"></div>
148
192
  </section>
149
193
  <section class="full">
150
- <h2>── event stream <span class="count" id="events-count"></span></h2>
194
+ <h2>── event stream <span class="count" id="events-live"></span> <span class="count" id="events-count"></span></h2>
151
195
  <div class="tools"><input id="events-filter" type="text" placeholder="filter by type e.g. push.applied" /></div>
152
196
  <div class="body" id="events"></div>
153
197
  </section>
154
198
  </main>
199
+ <div id="modal" hidden>
200
+ <div class="box">
201
+ <h3>── client drill-down <span class="count" id="modal-title"></span> <span class="spacer"></span> <button id="detail-close">[ CLOSE ]</button></h3>
202
+ <div class="body" id="detail-body"></div>
203
+ </div>
204
+ </div>
155
205
  <div class="rule">================================================================================================================================================================</div>
156
206
  <footer>
157
207
  <span>SYNCULAR · ADMIN</span>
@@ -167,7 +217,7 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
167
217
  // trailing slash so 'base + /clients' hits '…/admin/clients' regardless of
168
218
  // whether the URL had the trailing slash.
169
219
  var base = location.pathname.replace(/\\/+$/, '');
170
- function get(path, params) {
220
+ function qs(params) {
171
221
  var parts = [];
172
222
  var p = el('partition').value.trim();
173
223
  if (p) parts.push('partition=' + encodeURIComponent(p));
@@ -177,8 +227,10 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
177
227
  parts.push(k + '=' + encodeURIComponent(v));
178
228
  }
179
229
  });
180
- var qs = parts.length ? '?' + parts.join('&') : '';
181
- return fetch(base + path + qs, { headers: { accept: 'application/json' } })
230
+ return parts.length ? '?' + parts.join('&') : '';
231
+ }
232
+ function get(path, params) {
233
+ return fetch(base + path + qs(params), { headers: { accept: 'application/json' } })
182
234
  .then(function (r) {
183
235
  if (!r.ok) return r.json().then(function (b) { throw new Error(b.code || ('HTTP ' + r.status)); });
184
236
  return r.json();
@@ -196,6 +248,13 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
196
248
  if (d < 3600000) return Math.round(d / 60000) + 'm';
197
249
  return Math.round(d / 3600000) + 'h';
198
250
  }
251
+ function until(ms) {
252
+ var d = ms - Date.now();
253
+ if (d <= 0) return 'expired';
254
+ if (d < 60000) return 'in ' + Math.round(d / 1000) + 's';
255
+ if (d < 3600000) return 'in ' + Math.round(d / 60000) + 'm';
256
+ return 'in ' + Math.round(d / 3600000) + 'h';
257
+ }
199
258
  function bytes(n) {
200
259
  if (n == null) return '-';
201
260
  if (n < 1024) return n + ' B';
@@ -203,16 +262,54 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
203
262
  return (n / 1048576).toFixed(1) + ' MiB';
204
263
  }
205
264
  function empty(text) { return '<div class="empty">— ' + esc(text || 'none') + ' —</div>'; }
206
- function table(head, rows) {
265
+ function table(head, rows, rowAttrs) {
207
266
  if (!rows.length) return empty('none');
208
267
  var h = '<table><thead><tr>' + head.map(function (c) { return '<th>' + esc(c) + '</th>'; }).join('') + '</tr></thead><tbody>';
209
- h += rows.map(function (r) {
210
- return '<tr>' + r.map(function (c) { return '<td>' + c + '</td>'; }).join('') + '</tr>';
268
+ h += rows.map(function (r, i) {
269
+ var attrs = rowAttrs ? rowAttrs(i) : '';
270
+ return '<tr' + (attrs ? ' ' + attrs : '') + '>' + r.map(function (c) { return '<td>' + c + '</td>'; }).join('') + '</tr>';
211
271
  }).join('');
212
272
  return h + '</tbody></table>';
213
273
  }
214
274
  function chip(text, cls) { return '<span class="chip ' + cls + '">' + esc(text) + '</span>'; }
275
+ var SPARK = '▁▂▃▄▅▆▇█';
276
+ function spark(counts) {
277
+ var max = 0;
278
+ counts.forEach(function (n) { if (n > max) max = n; });
279
+ return counts.map(function (n) {
280
+ var level = max === 0 ? 0 : Math.round((n / max) * (SPARK.length - 1));
281
+ return SPARK.charAt(level);
282
+ }).join('');
283
+ }
215
284
 
285
+ function renderMetrics(m) {
286
+ el('m-push').textContent = m.requests.count === 0 ? '0' :
287
+ (m.requests.perMinute >= 10 ? Math.round(m.requests.perMinute) : m.requests.perMinute.toFixed(1));
288
+ el('m-conflicts').textContent = m.pushes.conflicted;
289
+ el('m-err').textContent = (m.requests.errorRate * 100).toFixed(1) + '%';
290
+ el('m-p95').textContent = Math.round(m.requests.p95Ms) + 'ms';
291
+ var sp = el('m-spark');
292
+ sp.textContent = spark(m.buckets.counts);
293
+ sp.className = 'spark' + (m.requests.count === 0 ? ' flat' : '');
294
+ el('m-window').textContent = 'last ' + Math.round(m.windowMs / 60000) + 'min';
295
+ }
296
+ function renderFleet(list) {
297
+ el('fleet-count').textContent = list.length + ' partitions';
298
+ var picker = el('partitions-list');
299
+ picker.innerHTML = list.map(function (p) {
300
+ return '<option value="' + esc(p.partition) + '"></option>';
301
+ }).join('');
302
+ el('fleet').innerHTML = table(
303
+ ['partition', 'max seq', 'horizon', 'retained', 'clients', ''],
304
+ list.map(function (p) {
305
+ var rec = p.recommendation === 'prune-recommended'
306
+ ? chip('prune', 'warn') : chip('ok', 'ok');
307
+ return ['<span class="v">' + esc(p.partition) + '</span>', esc(p.maxCommitSeq),
308
+ esc(p.horizonSeq), esc(p.retainedCommits),
309
+ esc(p.activeClients) + '/' + esc(p.knownClients) + ' active', rec];
310
+ }),
311
+ function (i) { return 'class="pick" data-partition="' + esc(list[i].partition) + '"'; });
312
+ }
216
313
  function renderHorizon(h) {
217
314
  el('horizon').innerHTML =
218
315
  '<dl class="kv">' +
@@ -240,11 +337,40 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
240
337
  }
241
338
  function renderClients(list) {
242
339
  el('clients-count').textContent = list.length + ' known';
243
- el('clients').innerHTML = table(['client', 'actor', 'cursor', 'seen', 'subs', ''],
340
+ el('clients').innerHTML = table(['client', 'actor', 'cursor', 'lag', 'seen', 'subs', ''],
244
341
  list.map(function (c) {
245
342
  var pill = c.active ? chip('active', 'ok') : chip('idle', '');
246
- return [esc(c.clientId), esc(c.actorId), esc(c.cursor), ago(c.updatedAtMs), esc(c.subscriptions.length), pill];
247
- }));
343
+ var lag = c.lag > 0 ? '<span class="v">' + esc(c.lag) + '</span>' : esc(c.lag);
344
+ return [esc(c.clientId), esc(c.actorId), esc(c.cursor), lag, ago(c.updatedAtMs), esc(c.subscriptions.length), pill];
345
+ }),
346
+ function (i) { return 'class="pick" data-client="' + esc(list[i].clientId) + '"'; });
347
+ }
348
+ function renderDetail(d) {
349
+ if (!d.exists) {
350
+ el('detail-body').innerHTML = empty('no record for ' + d.clientId);
351
+ return;
352
+ }
353
+ var c = d.client;
354
+ var h = '<dl class="kv">' +
355
+ '<dt>client</dt><dd class="v">' + esc(c.clientId) + '</dd>' +
356
+ '<dt>actor</dt><dd>' + esc(c.actorId) + '</dd>' +
357
+ '<dt>cursor / lag</dt><dd>' + esc(c.cursor) + ' / ' + esc(c.lag) + '</dd>' +
358
+ '<dt>seen</dt><dd>' + ago(c.updatedAtMs) + ' ago ' + (c.active ? chip('active', 'ok') : chip('idle', '')) + '</dd>';
359
+ if (d.lease) {
360
+ h += '<dt>lease</dt><dd class="wrap">' + esc(d.lease.leaseId) + ' · ' +
361
+ (d.lease.revoked ? chip('revoked', 'warn') : chip(until(d.lease.expiresAtMs), 'ok')) + '</dd>';
362
+ } else {
363
+ h += '<dt>lease</dt><dd>none</dd>';
364
+ }
365
+ h += '</dl>';
366
+ h += table(['sub', 'table', 'scopes'], c.subscriptions.map(function (s) {
367
+ return [esc(s.id), esc(s.table), '<span class="wrap">' + esc(JSON.stringify(s.scopes)) + '</span>'];
368
+ }));
369
+ h += table(['when', 'type', 'detail'], d.events.map(function (e) {
370
+ return [ago(e.atMs), '<span class="evt-type">' + esc(e.type) + '</span>',
371
+ '<span class="wrap">' + esc(evtDetail(e)) + '</span>'];
372
+ }));
373
+ el('detail-body').innerHTML = h;
248
374
  }
249
375
  function renderCommits(list) {
250
376
  el('commits-count').textContent = list.length + ' shown';
@@ -275,34 +401,71 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
275
401
  return [esc(a.commitSeq), esc(a.table), esc(a.actorId), esc(a.changeCount), ago(a.createdAtMs)];
276
402
  }));
277
403
  }
278
- function renderEvents(res) {
279
- el('events-count').textContent = res.hasEventStream
280
- ? (res.events.length + ' events')
281
- : 'no ring buffer wired';
282
- if (!res.hasEventStream) { el('events').innerHTML = empty('no RingBufferEvents wired on this admin'); return; }
404
+ function evtDetail(e) {
405
+ return Object.keys(e)
406
+ .filter(function (k) { return k !== 'type' && k !== 'atMs'; })
407
+ .map(function (k) { return k + '=' + JSON.stringify(e[k]); })
408
+ .join(' ');
409
+ }
410
+ var MAX_TAIL = 200;
411
+ function renderEventList(list) {
412
+ el('events-count').textContent = list.length + ' events';
283
413
  el('events').innerHTML = table(['when', 'type', 'detail'],
284
- res.events.map(function (e) {
285
- var detail = Object.keys(e)
286
- .filter(function (k) { return k !== 'type' && k !== 'atMs'; })
287
- .map(function (k) { return k + '=' + JSON.stringify(e[k]); })
288
- .join(' ');
414
+ list.map(function (e) {
289
415
  return [ago(e.atMs), '<span class="evt-type">' + esc(e.type) + '</span>',
290
- '<span class="wrap">' + esc(detail) + '</span>'];
416
+ '<span class="wrap">' + esc(evtDetail(e)) + '</span>'];
291
417
  }));
292
418
  }
293
419
 
420
+ // -- the event tail: SSE when the ring is wired, 2 s poll otherwise -------
421
+ var tail = { source: null, list: [] };
422
+ function connectStream() {
423
+ if (tail.source) { tail.source.close(); tail.source = null; }
424
+ tail.list = [];
425
+ var source = new EventSource(base + '/events/stream' + qs({ type: el('events-filter').value.trim(), limit: MAX_TAIL }));
426
+ tail.source = source;
427
+ source.onopen = function () { el('events-live').innerHTML = chip('live', 'ok'); };
428
+ source.onmessage = function (msg) {
429
+ tail.list.unshift(JSON.parse(msg.data));
430
+ if (tail.list.length > MAX_TAIL) tail.list.length = MAX_TAIL;
431
+ renderEventList(tail.list);
432
+ };
433
+ source.onerror = function () {
434
+ // Auth or transport failure: fall back to the poll for good.
435
+ source.close();
436
+ tail.source = null;
437
+ el('events-live').innerHTML = chip('poll', '');
438
+ };
439
+ }
440
+ function loadEvents() {
441
+ if (tail.source) return Promise.resolve(); // the stream owns the panel
442
+ return get('/events', { type: el('events-filter').value.trim() }).then(function (res) {
443
+ if (!res.hasEventStream) {
444
+ el('events-count').textContent = 'no ring buffer wired';
445
+ el('events').innerHTML = empty('no RingBufferEvents wired on this admin');
446
+ return;
447
+ }
448
+ if (typeof EventSource === 'function') { connectStream(); return; }
449
+ renderEventList(res.events);
450
+ });
451
+ }
452
+
294
453
  var loading = false;
295
454
  function loadAll() {
296
455
  if (loading) return;
297
456
  loading = true;
298
457
  status.className = '';
299
458
  status.textContent = 'loading…';
459
+ // The fleet read is optional server surface — it must not fail the load.
460
+ get('/partitions').then(function (r) { renderFleet(r.partitions); })
461
+ .catch(function () { el('fleet').innerHTML = empty('listPartitions not supported by this storage'); });
300
462
  Promise.all([
301
463
  get('/horizon').then(function (r) { renderHorizon(r.horizon); }),
302
464
  get('/stats').then(function (r) { renderStats(r.stats); }),
465
+ get('/metrics').then(function (r) { renderMetrics(r.metrics); }),
303
466
  get('/clients').then(function (r) { renderClients(r.clients); }),
304
467
  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)
468
+ loadEvents()
306
469
  ]).then(function () {
307
470
  status.textContent = 'updated ' + new Date().toLocaleTimeString();
308
471
  }).catch(function (err) {
@@ -327,14 +490,48 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
327
490
  .then(function (r) { renderScope(r.activity); })
328
491
  .catch(function (err) { el('scope').innerHTML = empty('error: ' + err.message); });
329
492
  }
493
+ function openDetail(clientId) {
494
+ el('modal').hidden = false;
495
+ el('modal-title').textContent = clientId;
496
+ el('detail-body').innerHTML = empty('loading ' + clientId + '…');
497
+ get('/clients/' + encodeURIComponent(clientId))
498
+ .then(function (r) { renderDetail(r.detail); })
499
+ .catch(function (err) { el('detail-body').innerHTML = empty('error: ' + err.message); });
500
+ }
501
+ function closeDetail() { el('modal').hidden = true; }
330
502
  function onEnter(id, fn) {
331
503
  el(id).addEventListener('keydown', function (e) { if (e.key === 'Enter') fn(); });
332
504
  }
505
+ function pickedRow(target, attr) {
506
+ var row = target.closest ? target.closest('tr[data-' + attr + ']') : null;
507
+ return row ? row.getAttribute('data-' + attr) : null;
508
+ }
333
509
 
334
510
  el('refresh').addEventListener('click', loadAll);
335
- el('partition').addEventListener('change', loadAll);
511
+ el('partition').addEventListener('change', function () {
512
+ if (tail.source) { tail.source.close(); tail.source = null; }
513
+ closeDetail();
514
+ loadAll();
515
+ });
336
516
  el('commits-filter').addEventListener('change', loadAll);
337
- el('events-filter').addEventListener('change', loadAll);
517
+ el('events-filter').addEventListener('change', function () {
518
+ if (tail.source) connectStream(); else loadAll();
519
+ });
520
+ el('clients').addEventListener('click', function (e) {
521
+ var id = pickedRow(e.target, 'client');
522
+ if (id !== null) openDetail(id);
523
+ });
524
+ el('detail-close').addEventListener('click', closeDetail);
525
+ el('modal').addEventListener('click', function (e) {
526
+ if (e.target === el('modal')) closeDetail();
527
+ });
528
+ document.addEventListener('keydown', function (e) {
529
+ if (e.key === 'Escape') closeDetail();
530
+ });
531
+ el('fleet').addEventListener('click', function (e) {
532
+ var p = pickedRow(e.target, 'partition');
533
+ if (p !== null) { el('partition').value = p; el('partition').dispatchEvent(new Event('change')); }
534
+ });
338
535
  el('inspect-go').addEventListener('click', inspect);
339
536
  onEnter('inspect-table', inspect);
340
537
  onEnter('inspect-row', inspect);
package/dist/admin.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * commit metadata, and scope activity) and SPEC.md deliberately says nothing
11
11
  * about it — authorization is entirely the host's, and mandatory.
12
12
  */
13
- import { errorBody, SyncError, } from '@syncular/server';
13
+ import { errorBody, matchesRingQuery, SyncError, } from '@syncular/server';
14
14
  import { Hono } from 'hono';
15
15
  import { ADMIN_CONSOLE_HTML } from './admin-page.js';
16
16
  function jsonError(error) {
@@ -68,6 +68,37 @@ export function createSyncularAdminRoutes(admin, options) {
68
68
  return jsonError(error);
69
69
  }
70
70
  });
71
+ app.get('/clients/:clientId', async (c) => {
72
+ try {
73
+ const detail = await admin.clientDetail(partitionOf(c), c.req.param('clientId'), { eventLimit: intParam(c.req.query('eventLimit'), 100) });
74
+ return Response.json({ detail });
75
+ }
76
+ catch (error) {
77
+ return jsonError(error);
78
+ }
79
+ });
80
+ app.get('/metrics', async (c) => {
81
+ try {
82
+ const metrics = admin.metrics(partitionOf(c), {
83
+ windowMs: intParam(c.req.query('windowMs'), 5 * 60 * 1000),
84
+ });
85
+ return Response.json({ metrics, hasEventStream: admin.hasEventStream });
86
+ }
87
+ catch (error) {
88
+ return jsonError(error);
89
+ }
90
+ });
91
+ // Cross-partition fleet view: the ONE endpoint that is not partition-
92
+ // scoped (the guard still runs, with `partition` empty unless passed).
93
+ app.get('/partitions', async () => {
94
+ try {
95
+ const partitions = await admin.partitionsOverview();
96
+ return Response.json({ partitions });
97
+ }
98
+ catch (error) {
99
+ return jsonError(error);
100
+ }
101
+ });
71
102
  app.get('/commits', async (c) => {
72
103
  try {
73
104
  const table = c.req.query('table');
@@ -123,12 +154,22 @@ export function createSyncularAdminRoutes(admin, options) {
123
154
  return jsonError(error);
124
155
  }
125
156
  });
157
+ /** The ring filters shared by the polled tail and the SSE stream. */
158
+ function eventFilterOf(c) {
159
+ const type = c.req.query('type');
160
+ const clientId = c.req.query('clientId');
161
+ const actorId = c.req.query('actorId');
162
+ return {
163
+ ...(type !== undefined ? { type } : {}),
164
+ ...(clientId !== undefined ? { clientId } : {}),
165
+ ...(actorId !== undefined ? { actorId } : {}),
166
+ };
167
+ }
126
168
  app.get('/events', async (c) => {
127
169
  try {
128
- const type = c.req.query('type');
129
170
  const sinceMs = c.req.query('sinceMs');
130
171
  const events = admin.events({
131
- ...(type !== undefined ? { type } : {}),
172
+ ...eventFilterOf(c),
132
173
  ...(sinceMs !== undefined ? { sinceMs: intParam(sinceMs, 0) } : {}),
133
174
  limit: intParam(c.req.query('limit'), 200),
134
175
  });
@@ -138,5 +179,72 @@ export function createSyncularAdminRoutes(admin, options) {
138
179
  return jsonError(error);
139
180
  }
140
181
  });
182
+ // Live event tail over SSE, fed by the ring's subscribe hook. Plain
183
+ // web-streams (no filesystem, no Node API), so it serves identically on
184
+ // Bun, Node, and Workers. A recent backlog is replayed first (oldest →
185
+ // newest), then events stream as they land; a comment ping every 5 s
186
+ // keeps the connection under runtime idle timeouts (Bun.serve drops an
187
+ // idle response after 10 s by default) and intermediaries alive.
188
+ app.get('/events/stream', (c) => {
189
+ try {
190
+ if (!admin.hasEventStream) {
191
+ throw new SyncError('sync.invalid_request', 'no RingBufferEvents wired on this admin — the event stream is off');
192
+ }
193
+ const filter = eventFilterOf(c);
194
+ const backlogLimit = intParam(c.req.query('limit'), 50);
195
+ const encoder = new TextEncoder();
196
+ let unsubscribe;
197
+ let heartbeat;
198
+ const stop = () => {
199
+ unsubscribe?.();
200
+ unsubscribe = undefined;
201
+ if (heartbeat !== undefined)
202
+ clearInterval(heartbeat);
203
+ heartbeat = undefined;
204
+ };
205
+ const stream = new ReadableStream({
206
+ start(controller) {
207
+ const send = (event) => {
208
+ try {
209
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
210
+ }
211
+ catch {
212
+ stop(); // the consumer went away mid-enqueue
213
+ }
214
+ };
215
+ controller.enqueue(encoder.encode('retry: 2000\n\n'));
216
+ for (const event of admin
217
+ .events({ ...filter, limit: backlogLimit })
218
+ .reverse()) {
219
+ send(event);
220
+ }
221
+ unsubscribe = admin.subscribeEvents((event) => {
222
+ if (matchesRingQuery(event, filter))
223
+ send(event);
224
+ });
225
+ heartbeat = setInterval(() => {
226
+ try {
227
+ controller.enqueue(encoder.encode(': ping\n\n'));
228
+ }
229
+ catch {
230
+ stop();
231
+ }
232
+ }, 5_000);
233
+ },
234
+ cancel() {
235
+ stop();
236
+ },
237
+ });
238
+ return new Response(stream, {
239
+ headers: {
240
+ 'Content-Type': 'text/event-stream',
241
+ 'Cache-Control': 'no-store',
242
+ },
243
+ });
244
+ }
245
+ catch (error) {
246
+ return jsonError(error);
247
+ }
248
+ });
141
249
  return app;
142
250
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/server-hono",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Hono adapter for the Syncular sync server",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -45,10 +45,10 @@
45
45
  "!dist/**/*.test.d.ts"
46
46
  ],
47
47
  "dependencies": {
48
- "@syncular/server": "0.2.1",
48
+ "@syncular/server": "0.4.1",
49
49
  "hono": "^4.11.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@syncular/core": "0.2.1"
52
+ "@syncular/core": "0.4.1"
53
53
  }
54
54
  }
package/src/admin-page.ts CHANGED
@@ -15,10 +15,13 @@
15
15
  * works under whatever prefix the host mounts the routes at, and the host's
16
16
  * `authorize` guard applies to the page's own XHRs (same cookies/headers).
17
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).
18
+ * Panels cover the whole read surface: a metrics statusbar (ring-derived
19
+ * rates + sparkline), the fleet view (`/partitions`, doubling as the
20
+ * partition picker), horizon, store stats, clients (with cursor lag and a
21
+ * per-client drill-down: subscriptions, lease, event slice), commits (with
22
+ * a table filter), the row inspector, scope activity, and the event tail —
23
+ * live over SSE (`/events/stream`) when the ring is wired, falling back to
24
+ * the 2-second poll otherwise.
22
25
  */
23
26
  export const ADMIN_CONSOLE_HTML = `<!doctype html>
24
27
  <html lang="en">
@@ -61,6 +64,13 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
61
64
  #status { color: var(--dim); font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; }
62
65
  #status.err { color: var(--amber); }
63
66
 
67
+ #statusbar { display: flex; gap: 1.4rem; align-items: baseline; flex-wrap: wrap;
68
+ padding: .5rem 1.25rem; border-bottom: 1px solid var(--border);
69
+ font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--dim); }
70
+ #statusbar .m b { color: var(--amber); font-weight: 400; }
71
+ #statusbar .spark { color: var(--amber); letter-spacing: 0; text-transform: none; }
72
+ #statusbar .spark.flat { color: var(--faint); }
73
+
64
74
  main { padding: 1.25rem; display: grid; gap: 1.2rem;
65
75
  grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); }
66
76
  section { border: 1px solid var(--border); background: var(--void); position: relative; }
@@ -84,6 +94,7 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
84
94
  text-transform: uppercase; position: sticky; top: 0; background: var(--panel); z-index: 1; }
85
95
  td.wrap, .wrap { white-space: normal; word-break: break-all; }
86
96
  tr:hover td { background: var(--panel); }
97
+ tr.pick { cursor: pointer; }
87
98
 
88
99
  .chip { font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; color: var(--dim); }
89
100
  .chip::before { content: '[ '; color: var(--faint); }
@@ -101,6 +112,23 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
101
112
  .evt-type { color: var(--amber); }
102
113
  .full { grid-column: 1 / -1; }
103
114
 
115
+ #modal { position: fixed; inset: 0; z-index: 10; background: rgba(0,0,0,.82);
116
+ display: flex; align-items: center; justify-content: center; padding: 2rem; }
117
+ #modal[hidden] { display: none; }
118
+ #modal .box { background: var(--void); border: 1px solid var(--border-strong);
119
+ width: min(820px, 100%); max-height: 85vh; display: flex; flex-direction: column;
120
+ position: relative; }
121
+ #modal .box::before, #modal .box::after { color: var(--faint); position: absolute; font-size: .72rem; }
122
+ #modal .box::before { content: '+'; top: -0.6em; left: -0.28em; }
123
+ #modal .box::after { content: '+'; bottom: -0.6em; right: -0.28em; }
124
+ #modal h3 { font-size: .72rem; margin: 0; padding: .55rem .9rem;
125
+ border-bottom: 1px solid var(--border); font-weight: 400; color: var(--faint);
126
+ letter-spacing: .12em; text-transform: uppercase;
127
+ display: flex; justify-content: space-between; align-items: center; gap: .6rem; }
128
+ #modal h3 .count { color: var(--amber); letter-spacing: .04em; text-transform: none; }
129
+ #modal h3 .spacer { flex: 1; }
130
+ #modal .body { overflow: auto; }
131
+
104
132
  footer { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
105
133
  padding: .8rem 1.25rem 1.4rem; color: var(--faint); font-size: .68rem; letter-spacing: .1em; }
106
134
  .rule { color: var(--faint); font-size: .72rem; white-space: nowrap; overflow: hidden;
@@ -112,17 +140,33 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
112
140
  <header>
113
141
  <h1>SYNCULAR<span class="d"> · </span>CONSOLE<span class="blink">_</span></h1>
114
142
  <label>partition
115
- <input id="partition" type="text" placeholder="(default)" />
143
+ <input id="partition" type="text" placeholder="(default)" list="partitions-list" />
116
144
  </label>
145
+ <datalist id="partitions-list"></datalist>
117
146
  <button id="refresh" class="primary">[ REFRESH ]</button>
118
147
  <button id="auto">[ AUTO OFF ]</button>
119
148
  <span class="spacer"></span>
120
149
  <span id="status">idle</span>
121
150
  </header>
151
+ <div id="statusbar">
152
+ <span class="m">push <b id="m-push">–</b>/min</span>
153
+ <span class="m">conflicts <b id="m-conflicts">–</b></span>
154
+ <span class="m">err <b id="m-err">–</b></span>
155
+ <span class="m">p95 <b id="m-p95">–</b></span>
156
+ <span class="spark" id="m-spark"></span>
157
+ <span class="m" id="m-window"></span>
158
+ </div>
122
159
  <main>
123
160
  <section><h2>── horizon <span class="count" id="horizon-rec"></span></h2><div class="body" id="horizon"></div></section>
124
161
  <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>
162
+ <section>
163
+ <h2>── fleet <span class="count" id="fleet-count"></span></h2>
164
+ <div class="body" id="fleet"></div>
165
+ </section>
166
+ <section>
167
+ <h2>── clients <span class="count" id="clients-count"></span></h2>
168
+ <div class="body" id="clients"></div>
169
+ </section>
126
170
  <section>
127
171
  <h2>── commits <span class="count" id="commits-count"></span></h2>
128
172
  <div class="tools"><input id="commits-filter" type="text" placeholder="filter by table" /></div>
@@ -147,11 +191,17 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
147
191
  <div class="body" id="scope"></div>
148
192
  </section>
149
193
  <section class="full">
150
- <h2>── event stream <span class="count" id="events-count"></span></h2>
194
+ <h2>── event stream <span class="count" id="events-live"></span> <span class="count" id="events-count"></span></h2>
151
195
  <div class="tools"><input id="events-filter" type="text" placeholder="filter by type e.g. push.applied" /></div>
152
196
  <div class="body" id="events"></div>
153
197
  </section>
154
198
  </main>
199
+ <div id="modal" hidden>
200
+ <div class="box">
201
+ <h3>── client drill-down <span class="count" id="modal-title"></span> <span class="spacer"></span> <button id="detail-close">[ CLOSE ]</button></h3>
202
+ <div class="body" id="detail-body"></div>
203
+ </div>
204
+ </div>
155
205
  <div class="rule">================================================================================================================================================================</div>
156
206
  <footer>
157
207
  <span>SYNCULAR · ADMIN</span>
@@ -167,7 +217,7 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
167
217
  // trailing slash so 'base + /clients' hits '…/admin/clients' regardless of
168
218
  // whether the URL had the trailing slash.
169
219
  var base = location.pathname.replace(/\\/+$/, '');
170
- function get(path, params) {
220
+ function qs(params) {
171
221
  var parts = [];
172
222
  var p = el('partition').value.trim();
173
223
  if (p) parts.push('partition=' + encodeURIComponent(p));
@@ -177,8 +227,10 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
177
227
  parts.push(k + '=' + encodeURIComponent(v));
178
228
  }
179
229
  });
180
- var qs = parts.length ? '?' + parts.join('&') : '';
181
- return fetch(base + path + qs, { headers: { accept: 'application/json' } })
230
+ return parts.length ? '?' + parts.join('&') : '';
231
+ }
232
+ function get(path, params) {
233
+ return fetch(base + path + qs(params), { headers: { accept: 'application/json' } })
182
234
  .then(function (r) {
183
235
  if (!r.ok) return r.json().then(function (b) { throw new Error(b.code || ('HTTP ' + r.status)); });
184
236
  return r.json();
@@ -196,6 +248,13 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
196
248
  if (d < 3600000) return Math.round(d / 60000) + 'm';
197
249
  return Math.round(d / 3600000) + 'h';
198
250
  }
251
+ function until(ms) {
252
+ var d = ms - Date.now();
253
+ if (d <= 0) return 'expired';
254
+ if (d < 60000) return 'in ' + Math.round(d / 1000) + 's';
255
+ if (d < 3600000) return 'in ' + Math.round(d / 60000) + 'm';
256
+ return 'in ' + Math.round(d / 3600000) + 'h';
257
+ }
199
258
  function bytes(n) {
200
259
  if (n == null) return '-';
201
260
  if (n < 1024) return n + ' B';
@@ -203,16 +262,54 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
203
262
  return (n / 1048576).toFixed(1) + ' MiB';
204
263
  }
205
264
  function empty(text) { return '<div class="empty">— ' + esc(text || 'none') + ' —</div>'; }
206
- function table(head, rows) {
265
+ function table(head, rows, rowAttrs) {
207
266
  if (!rows.length) return empty('none');
208
267
  var h = '<table><thead><tr>' + head.map(function (c) { return '<th>' + esc(c) + '</th>'; }).join('') + '</tr></thead><tbody>';
209
- h += rows.map(function (r) {
210
- return '<tr>' + r.map(function (c) { return '<td>' + c + '</td>'; }).join('') + '</tr>';
268
+ h += rows.map(function (r, i) {
269
+ var attrs = rowAttrs ? rowAttrs(i) : '';
270
+ return '<tr' + (attrs ? ' ' + attrs : '') + '>' + r.map(function (c) { return '<td>' + c + '</td>'; }).join('') + '</tr>';
211
271
  }).join('');
212
272
  return h + '</tbody></table>';
213
273
  }
214
274
  function chip(text, cls) { return '<span class="chip ' + cls + '">' + esc(text) + '</span>'; }
275
+ var SPARK = '▁▂▃▄▅▆▇█';
276
+ function spark(counts) {
277
+ var max = 0;
278
+ counts.forEach(function (n) { if (n > max) max = n; });
279
+ return counts.map(function (n) {
280
+ var level = max === 0 ? 0 : Math.round((n / max) * (SPARK.length - 1));
281
+ return SPARK.charAt(level);
282
+ }).join('');
283
+ }
215
284
 
285
+ function renderMetrics(m) {
286
+ el('m-push').textContent = m.requests.count === 0 ? '0' :
287
+ (m.requests.perMinute >= 10 ? Math.round(m.requests.perMinute) : m.requests.perMinute.toFixed(1));
288
+ el('m-conflicts').textContent = m.pushes.conflicted;
289
+ el('m-err').textContent = (m.requests.errorRate * 100).toFixed(1) + '%';
290
+ el('m-p95').textContent = Math.round(m.requests.p95Ms) + 'ms';
291
+ var sp = el('m-spark');
292
+ sp.textContent = spark(m.buckets.counts);
293
+ sp.className = 'spark' + (m.requests.count === 0 ? ' flat' : '');
294
+ el('m-window').textContent = 'last ' + Math.round(m.windowMs / 60000) + 'min';
295
+ }
296
+ function renderFleet(list) {
297
+ el('fleet-count').textContent = list.length + ' partitions';
298
+ var picker = el('partitions-list');
299
+ picker.innerHTML = list.map(function (p) {
300
+ return '<option value="' + esc(p.partition) + '"></option>';
301
+ }).join('');
302
+ el('fleet').innerHTML = table(
303
+ ['partition', 'max seq', 'horizon', 'retained', 'clients', ''],
304
+ list.map(function (p) {
305
+ var rec = p.recommendation === 'prune-recommended'
306
+ ? chip('prune', 'warn') : chip('ok', 'ok');
307
+ return ['<span class="v">' + esc(p.partition) + '</span>', esc(p.maxCommitSeq),
308
+ esc(p.horizonSeq), esc(p.retainedCommits),
309
+ esc(p.activeClients) + '/' + esc(p.knownClients) + ' active', rec];
310
+ }),
311
+ function (i) { return 'class="pick" data-partition="' + esc(list[i].partition) + '"'; });
312
+ }
216
313
  function renderHorizon(h) {
217
314
  el('horizon').innerHTML =
218
315
  '<dl class="kv">' +
@@ -240,11 +337,40 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
240
337
  }
241
338
  function renderClients(list) {
242
339
  el('clients-count').textContent = list.length + ' known';
243
- el('clients').innerHTML = table(['client', 'actor', 'cursor', 'seen', 'subs', ''],
340
+ el('clients').innerHTML = table(['client', 'actor', 'cursor', 'lag', 'seen', 'subs', ''],
244
341
  list.map(function (c) {
245
342
  var pill = c.active ? chip('active', 'ok') : chip('idle', '');
246
- return [esc(c.clientId), esc(c.actorId), esc(c.cursor), ago(c.updatedAtMs), esc(c.subscriptions.length), pill];
247
- }));
343
+ var lag = c.lag > 0 ? '<span class="v">' + esc(c.lag) + '</span>' : esc(c.lag);
344
+ return [esc(c.clientId), esc(c.actorId), esc(c.cursor), lag, ago(c.updatedAtMs), esc(c.subscriptions.length), pill];
345
+ }),
346
+ function (i) { return 'class="pick" data-client="' + esc(list[i].clientId) + '"'; });
347
+ }
348
+ function renderDetail(d) {
349
+ if (!d.exists) {
350
+ el('detail-body').innerHTML = empty('no record for ' + d.clientId);
351
+ return;
352
+ }
353
+ var c = d.client;
354
+ var h = '<dl class="kv">' +
355
+ '<dt>client</dt><dd class="v">' + esc(c.clientId) + '</dd>' +
356
+ '<dt>actor</dt><dd>' + esc(c.actorId) + '</dd>' +
357
+ '<dt>cursor / lag</dt><dd>' + esc(c.cursor) + ' / ' + esc(c.lag) + '</dd>' +
358
+ '<dt>seen</dt><dd>' + ago(c.updatedAtMs) + ' ago ' + (c.active ? chip('active', 'ok') : chip('idle', '')) + '</dd>';
359
+ if (d.lease) {
360
+ h += '<dt>lease</dt><dd class="wrap">' + esc(d.lease.leaseId) + ' · ' +
361
+ (d.lease.revoked ? chip('revoked', 'warn') : chip(until(d.lease.expiresAtMs), 'ok')) + '</dd>';
362
+ } else {
363
+ h += '<dt>lease</dt><dd>none</dd>';
364
+ }
365
+ h += '</dl>';
366
+ h += table(['sub', 'table', 'scopes'], c.subscriptions.map(function (s) {
367
+ return [esc(s.id), esc(s.table), '<span class="wrap">' + esc(JSON.stringify(s.scopes)) + '</span>'];
368
+ }));
369
+ h += table(['when', 'type', 'detail'], d.events.map(function (e) {
370
+ return [ago(e.atMs), '<span class="evt-type">' + esc(e.type) + '</span>',
371
+ '<span class="wrap">' + esc(evtDetail(e)) + '</span>'];
372
+ }));
373
+ el('detail-body').innerHTML = h;
248
374
  }
249
375
  function renderCommits(list) {
250
376
  el('commits-count').textContent = list.length + ' shown';
@@ -275,34 +401,71 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
275
401
  return [esc(a.commitSeq), esc(a.table), esc(a.actorId), esc(a.changeCount), ago(a.createdAtMs)];
276
402
  }));
277
403
  }
278
- function renderEvents(res) {
279
- el('events-count').textContent = res.hasEventStream
280
- ? (res.events.length + ' events')
281
- : 'no ring buffer wired';
282
- if (!res.hasEventStream) { el('events').innerHTML = empty('no RingBufferEvents wired on this admin'); return; }
404
+ function evtDetail(e) {
405
+ return Object.keys(e)
406
+ .filter(function (k) { return k !== 'type' && k !== 'atMs'; })
407
+ .map(function (k) { return k + '=' + JSON.stringify(e[k]); })
408
+ .join(' ');
409
+ }
410
+ var MAX_TAIL = 200;
411
+ function renderEventList(list) {
412
+ el('events-count').textContent = list.length + ' events';
283
413
  el('events').innerHTML = table(['when', 'type', 'detail'],
284
- res.events.map(function (e) {
285
- var detail = Object.keys(e)
286
- .filter(function (k) { return k !== 'type' && k !== 'atMs'; })
287
- .map(function (k) { return k + '=' + JSON.stringify(e[k]); })
288
- .join(' ');
414
+ list.map(function (e) {
289
415
  return [ago(e.atMs), '<span class="evt-type">' + esc(e.type) + '</span>',
290
- '<span class="wrap">' + esc(detail) + '</span>'];
416
+ '<span class="wrap">' + esc(evtDetail(e)) + '</span>'];
291
417
  }));
292
418
  }
293
419
 
420
+ // -- the event tail: SSE when the ring is wired, 2 s poll otherwise -------
421
+ var tail = { source: null, list: [] };
422
+ function connectStream() {
423
+ if (tail.source) { tail.source.close(); tail.source = null; }
424
+ tail.list = [];
425
+ var source = new EventSource(base + '/events/stream' + qs({ type: el('events-filter').value.trim(), limit: MAX_TAIL }));
426
+ tail.source = source;
427
+ source.onopen = function () { el('events-live').innerHTML = chip('live', 'ok'); };
428
+ source.onmessage = function (msg) {
429
+ tail.list.unshift(JSON.parse(msg.data));
430
+ if (tail.list.length > MAX_TAIL) tail.list.length = MAX_TAIL;
431
+ renderEventList(tail.list);
432
+ };
433
+ source.onerror = function () {
434
+ // Auth or transport failure: fall back to the poll for good.
435
+ source.close();
436
+ tail.source = null;
437
+ el('events-live').innerHTML = chip('poll', '');
438
+ };
439
+ }
440
+ function loadEvents() {
441
+ if (tail.source) return Promise.resolve(); // the stream owns the panel
442
+ return get('/events', { type: el('events-filter').value.trim() }).then(function (res) {
443
+ if (!res.hasEventStream) {
444
+ el('events-count').textContent = 'no ring buffer wired';
445
+ el('events').innerHTML = empty('no RingBufferEvents wired on this admin');
446
+ return;
447
+ }
448
+ if (typeof EventSource === 'function') { connectStream(); return; }
449
+ renderEventList(res.events);
450
+ });
451
+ }
452
+
294
453
  var loading = false;
295
454
  function loadAll() {
296
455
  if (loading) return;
297
456
  loading = true;
298
457
  status.className = '';
299
458
  status.textContent = 'loading…';
459
+ // The fleet read is optional server surface — it must not fail the load.
460
+ get('/partitions').then(function (r) { renderFleet(r.partitions); })
461
+ .catch(function () { el('fleet').innerHTML = empty('listPartitions not supported by this storage'); });
300
462
  Promise.all([
301
463
  get('/horizon').then(function (r) { renderHorizon(r.horizon); }),
302
464
  get('/stats').then(function (r) { renderStats(r.stats); }),
465
+ get('/metrics').then(function (r) { renderMetrics(r.metrics); }),
303
466
  get('/clients').then(function (r) { renderClients(r.clients); }),
304
467
  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)
468
+ loadEvents()
306
469
  ]).then(function () {
307
470
  status.textContent = 'updated ' + new Date().toLocaleTimeString();
308
471
  }).catch(function (err) {
@@ -327,14 +490,48 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
327
490
  .then(function (r) { renderScope(r.activity); })
328
491
  .catch(function (err) { el('scope').innerHTML = empty('error: ' + err.message); });
329
492
  }
493
+ function openDetail(clientId) {
494
+ el('modal').hidden = false;
495
+ el('modal-title').textContent = clientId;
496
+ el('detail-body').innerHTML = empty('loading ' + clientId + '…');
497
+ get('/clients/' + encodeURIComponent(clientId))
498
+ .then(function (r) { renderDetail(r.detail); })
499
+ .catch(function (err) { el('detail-body').innerHTML = empty('error: ' + err.message); });
500
+ }
501
+ function closeDetail() { el('modal').hidden = true; }
330
502
  function onEnter(id, fn) {
331
503
  el(id).addEventListener('keydown', function (e) { if (e.key === 'Enter') fn(); });
332
504
  }
505
+ function pickedRow(target, attr) {
506
+ var row = target.closest ? target.closest('tr[data-' + attr + ']') : null;
507
+ return row ? row.getAttribute('data-' + attr) : null;
508
+ }
333
509
 
334
510
  el('refresh').addEventListener('click', loadAll);
335
- el('partition').addEventListener('change', loadAll);
511
+ el('partition').addEventListener('change', function () {
512
+ if (tail.source) { tail.source.close(); tail.source = null; }
513
+ closeDetail();
514
+ loadAll();
515
+ });
336
516
  el('commits-filter').addEventListener('change', loadAll);
337
- el('events-filter').addEventListener('change', loadAll);
517
+ el('events-filter').addEventListener('change', function () {
518
+ if (tail.source) connectStream(); else loadAll();
519
+ });
520
+ el('clients').addEventListener('click', function (e) {
521
+ var id = pickedRow(e.target, 'client');
522
+ if (id !== null) openDetail(id);
523
+ });
524
+ el('detail-close').addEventListener('click', closeDetail);
525
+ el('modal').addEventListener('click', function (e) {
526
+ if (e.target === el('modal')) closeDetail();
527
+ });
528
+ document.addEventListener('keydown', function (e) {
529
+ if (e.key === 'Escape') closeDetail();
530
+ });
531
+ el('fleet').addEventListener('click', function (e) {
532
+ var p = pickedRow(e.target, 'partition');
533
+ if (p !== null) { el('partition').value = p; el('partition').dispatchEvent(new Event('change')); }
534
+ });
338
535
  el('inspect-go').addEventListener('click', inspect);
339
536
  onEnter('inspect-table', inspect);
340
537
  onEnter('inspect-row', inspect);
package/src/admin.ts CHANGED
@@ -12,9 +12,11 @@
12
12
  */
13
13
  import {
14
14
  errorBody,
15
+ matchesRingQuery,
15
16
  type RingEventQuery,
16
17
  SyncError,
17
18
  type SyncularAdmin,
19
+ type SyncularServerEvent,
18
20
  } from '@syncular/server';
19
21
  import { Hono } from 'hono';
20
22
  import { ADMIN_CONSOLE_HTML } from './admin-page';
@@ -108,6 +110,41 @@ export function createSyncularAdminRoutes(
108
110
  }
109
111
  });
110
112
 
113
+ app.get('/clients/:clientId', async (c) => {
114
+ try {
115
+ const detail = await admin.clientDetail(
116
+ partitionOf(c),
117
+ c.req.param('clientId'),
118
+ { eventLimit: intParam(c.req.query('eventLimit'), 100) },
119
+ );
120
+ return Response.json({ detail });
121
+ } catch (error) {
122
+ return jsonError(error);
123
+ }
124
+ });
125
+
126
+ app.get('/metrics', async (c) => {
127
+ try {
128
+ const metrics = admin.metrics(partitionOf(c), {
129
+ windowMs: intParam(c.req.query('windowMs'), 5 * 60 * 1000),
130
+ });
131
+ return Response.json({ metrics, hasEventStream: admin.hasEventStream });
132
+ } catch (error) {
133
+ return jsonError(error);
134
+ }
135
+ });
136
+
137
+ // Cross-partition fleet view: the ONE endpoint that is not partition-
138
+ // scoped (the guard still runs, with `partition` empty unless passed).
139
+ app.get('/partitions', async () => {
140
+ try {
141
+ const partitions = await admin.partitionsOverview();
142
+ return Response.json({ partitions });
143
+ } catch (error) {
144
+ return jsonError(error);
145
+ }
146
+ });
147
+
111
148
  app.get('/commits', async (c) => {
112
149
  try {
113
150
  const table = c.req.query('table');
@@ -174,12 +211,25 @@ export function createSyncularAdminRoutes(
174
211
  }
175
212
  });
176
213
 
214
+ /** The ring filters shared by the polled tail and the SSE stream. */
215
+ function eventFilterOf(c: {
216
+ req: { query: (k: string) => string | undefined };
217
+ }): Omit<RingEventQuery, 'limit' | 'sinceMs'> {
218
+ const type = c.req.query('type') as RingEventQuery['type'];
219
+ const clientId = c.req.query('clientId');
220
+ const actorId = c.req.query('actorId');
221
+ return {
222
+ ...(type !== undefined ? { type } : {}),
223
+ ...(clientId !== undefined ? { clientId } : {}),
224
+ ...(actorId !== undefined ? { actorId } : {}),
225
+ };
226
+ }
227
+
177
228
  app.get('/events', async (c) => {
178
229
  try {
179
- const type = c.req.query('type') as RingEventQuery['type'];
180
230
  const sinceMs = c.req.query('sinceMs');
181
231
  const events = admin.events({
182
- ...(type !== undefined ? { type } : {}),
232
+ ...eventFilterOf(c),
183
233
  ...(sinceMs !== undefined ? { sinceMs: intParam(sinceMs, 0) } : {}),
184
234
  limit: intParam(c.req.query('limit'), 200),
185
235
  });
@@ -189,5 +239,73 @@ export function createSyncularAdminRoutes(
189
239
  }
190
240
  });
191
241
 
242
+ // Live event tail over SSE, fed by the ring's subscribe hook. Plain
243
+ // web-streams (no filesystem, no Node API), so it serves identically on
244
+ // Bun, Node, and Workers. A recent backlog is replayed first (oldest →
245
+ // newest), then events stream as they land; a comment ping every 5 s
246
+ // keeps the connection under runtime idle timeouts (Bun.serve drops an
247
+ // idle response after 10 s by default) and intermediaries alive.
248
+ app.get('/events/stream', (c) => {
249
+ try {
250
+ if (!admin.hasEventStream) {
251
+ throw new SyncError(
252
+ 'sync.invalid_request',
253
+ 'no RingBufferEvents wired on this admin — the event stream is off',
254
+ );
255
+ }
256
+ const filter = eventFilterOf(c);
257
+ const backlogLimit = intParam(c.req.query('limit'), 50);
258
+ const encoder = new TextEncoder();
259
+ let unsubscribe: (() => void) | undefined;
260
+ let heartbeat: ReturnType<typeof setInterval> | undefined;
261
+ const stop = (): void => {
262
+ unsubscribe?.();
263
+ unsubscribe = undefined;
264
+ if (heartbeat !== undefined) clearInterval(heartbeat);
265
+ heartbeat = undefined;
266
+ };
267
+ const stream = new ReadableStream<Uint8Array>({
268
+ start(controller) {
269
+ const send = (event: SyncularServerEvent): void => {
270
+ try {
271
+ controller.enqueue(
272
+ encoder.encode(`data: ${JSON.stringify(event)}\n\n`),
273
+ );
274
+ } catch {
275
+ stop(); // the consumer went away mid-enqueue
276
+ }
277
+ };
278
+ controller.enqueue(encoder.encode('retry: 2000\n\n'));
279
+ for (const event of admin
280
+ .events({ ...filter, limit: backlogLimit })
281
+ .reverse()) {
282
+ send(event);
283
+ }
284
+ unsubscribe = admin.subscribeEvents((event) => {
285
+ if (matchesRingQuery(event, filter)) send(event);
286
+ });
287
+ heartbeat = setInterval(() => {
288
+ try {
289
+ controller.enqueue(encoder.encode(': ping\n\n'));
290
+ } catch {
291
+ stop();
292
+ }
293
+ }, 5_000);
294
+ },
295
+ cancel() {
296
+ stop();
297
+ },
298
+ });
299
+ return new Response(stream, {
300
+ headers: {
301
+ 'Content-Type': 'text/event-stream',
302
+ 'Cache-Control': 'no-store',
303
+ },
304
+ });
305
+ } catch (error) {
306
+ return jsonError(error);
307
+ }
308
+ });
309
+
192
310
  return app;
193
311
  }