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