@syncular/server-hono 0.15.48 → 0.16.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.
- package/dist/admin-page.d.ts +4 -1
- package/dist/admin-page.js +30 -2
- package/package.json +3 -3
- package/src/admin-page.ts +30 -2
package/dist/admin-page.d.ts
CHANGED
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
* All fetches are same-origin and relative ('./clients', …), so the page
|
|
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
|
+
* `?transport=parent` sends the same route paths to a same-origin parent
|
|
18
|
+
* frame. Static demos can reuse this page while their admin routes run in a
|
|
19
|
+
* Web Worker.
|
|
17
20
|
*
|
|
18
21
|
* Panels cover the whole read surface: a metrics statusbar (ring-derived
|
|
19
22
|
* rates + sparkline), the fleet view (`/partitions`, doubling as the
|
|
@@ -23,4 +26,4 @@
|
|
|
23
26
|
* live over SSE (`/events/stream`) when the ring is wired, falling back to
|
|
24
27
|
* the 2-second poll otherwise.
|
|
25
28
|
*/
|
|
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, '&').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 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> 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>";
|
|
29
|
+
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 var parentTransport = new URLSearchParams(location.search).get('transport') === 'parent';\n var nextRequestId = 1;\n var parentRequests = {};\n if (parentTransport) {\n window.addEventListener('message', function (event) {\n if (event.source !== window.parent || event.origin !== location.origin) return;\n var message = event.data;\n if (!message || message.kind !== 'syncular-admin-response' || typeof message.id !== 'number') return;\n var request = parentRequests[message.id];\n if (!request) return;\n delete parentRequests[message.id];\n if (message.ok) request.resolve(message.body);\n else request.reject(new Error((message.body && message.body.code) || ('HTTP ' + message.status)));\n });\n }\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 var query = qs(params);\n if (parentTransport) {\n return new Promise(function (resolve, reject) {\n var id = nextRequestId++;\n parentRequests[id] = { resolve: resolve, reject: reject };\n window.parent.postMessage({\n kind: 'syncular-admin-request', id: id, path: path + query\n }, location.origin);\n });\n }\n return fetch(base + path + query, { 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 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> 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 (!parentTransport && 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>";
|
package/dist/admin-page.js
CHANGED
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
* All fetches are same-origin and relative ('./clients', …), so the page
|
|
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
|
+
* `?transport=parent` sends the same route paths to a same-origin parent
|
|
18
|
+
* frame. Static demos can reuse this page while their admin routes run in a
|
|
19
|
+
* Web Worker.
|
|
17
20
|
*
|
|
18
21
|
* Panels cover the whole read surface: a metrics statusbar (ring-derived
|
|
19
22
|
* rates + sparkline), the fleet view (`/partitions`, doubling as the
|
|
@@ -217,6 +220,21 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
|
|
|
217
220
|
// trailing slash so 'base + /clients' hits '…/admin/clients' regardless of
|
|
218
221
|
// whether the URL had the trailing slash.
|
|
219
222
|
var base = location.pathname.replace(/\\/+$/, '');
|
|
223
|
+
var parentTransport = new URLSearchParams(location.search).get('transport') === 'parent';
|
|
224
|
+
var nextRequestId = 1;
|
|
225
|
+
var parentRequests = {};
|
|
226
|
+
if (parentTransport) {
|
|
227
|
+
window.addEventListener('message', function (event) {
|
|
228
|
+
if (event.source !== window.parent || event.origin !== location.origin) return;
|
|
229
|
+
var message = event.data;
|
|
230
|
+
if (!message || message.kind !== 'syncular-admin-response' || typeof message.id !== 'number') return;
|
|
231
|
+
var request = parentRequests[message.id];
|
|
232
|
+
if (!request) return;
|
|
233
|
+
delete parentRequests[message.id];
|
|
234
|
+
if (message.ok) request.resolve(message.body);
|
|
235
|
+
else request.reject(new Error((message.body && message.body.code) || ('HTTP ' + message.status)));
|
|
236
|
+
});
|
|
237
|
+
}
|
|
220
238
|
function qs(params) {
|
|
221
239
|
var parts = [];
|
|
222
240
|
var p = el('partition').value.trim();
|
|
@@ -230,7 +248,17 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
|
|
|
230
248
|
return parts.length ? '?' + parts.join('&') : '';
|
|
231
249
|
}
|
|
232
250
|
function get(path, params) {
|
|
233
|
-
|
|
251
|
+
var query = qs(params);
|
|
252
|
+
if (parentTransport) {
|
|
253
|
+
return new Promise(function (resolve, reject) {
|
|
254
|
+
var id = nextRequestId++;
|
|
255
|
+
parentRequests[id] = { resolve: resolve, reject: reject };
|
|
256
|
+
window.parent.postMessage({
|
|
257
|
+
kind: 'syncular-admin-request', id: id, path: path + query
|
|
258
|
+
}, location.origin);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return fetch(base + path + query, { headers: { accept: 'application/json' } })
|
|
234
262
|
.then(function (r) {
|
|
235
263
|
if (!r.ok) return r.json().then(function (b) { throw new Error(b.code || ('HTTP ' + r.status)); });
|
|
236
264
|
return r.json();
|
|
@@ -445,7 +473,7 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
|
|
|
445
473
|
el('events').innerHTML = empty('no RingBufferEvents wired on this admin');
|
|
446
474
|
return;
|
|
447
475
|
}
|
|
448
|
-
if (typeof EventSource === 'function') { connectStream(); return; }
|
|
476
|
+
if (!parentTransport && typeof EventSource === 'function') { connectStream(); return; }
|
|
449
477
|
renderEventList(res.events);
|
|
450
478
|
});
|
|
451
479
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/server-hono",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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.
|
|
48
|
+
"@syncular/server": "0.16.1",
|
|
49
49
|
"hono": "^4.12.34"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@syncular/core": "0.
|
|
52
|
+
"@syncular/core": "0.16.1"
|
|
53
53
|
}
|
|
54
54
|
}
|
package/src/admin-page.ts
CHANGED
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
* All fetches are same-origin and relative ('./clients', …), so the page
|
|
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
|
+
* `?transport=parent` sends the same route paths to a same-origin parent
|
|
18
|
+
* frame. Static demos can reuse this page while their admin routes run in a
|
|
19
|
+
* Web Worker.
|
|
17
20
|
*
|
|
18
21
|
* Panels cover the whole read surface: a metrics statusbar (ring-derived
|
|
19
22
|
* rates + sparkline), the fleet view (`/partitions`, doubling as the
|
|
@@ -217,6 +220,21 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
|
|
|
217
220
|
// trailing slash so 'base + /clients' hits '…/admin/clients' regardless of
|
|
218
221
|
// whether the URL had the trailing slash.
|
|
219
222
|
var base = location.pathname.replace(/\\/+$/, '');
|
|
223
|
+
var parentTransport = new URLSearchParams(location.search).get('transport') === 'parent';
|
|
224
|
+
var nextRequestId = 1;
|
|
225
|
+
var parentRequests = {};
|
|
226
|
+
if (parentTransport) {
|
|
227
|
+
window.addEventListener('message', function (event) {
|
|
228
|
+
if (event.source !== window.parent || event.origin !== location.origin) return;
|
|
229
|
+
var message = event.data;
|
|
230
|
+
if (!message || message.kind !== 'syncular-admin-response' || typeof message.id !== 'number') return;
|
|
231
|
+
var request = parentRequests[message.id];
|
|
232
|
+
if (!request) return;
|
|
233
|
+
delete parentRequests[message.id];
|
|
234
|
+
if (message.ok) request.resolve(message.body);
|
|
235
|
+
else request.reject(new Error((message.body && message.body.code) || ('HTTP ' + message.status)));
|
|
236
|
+
});
|
|
237
|
+
}
|
|
220
238
|
function qs(params) {
|
|
221
239
|
var parts = [];
|
|
222
240
|
var p = el('partition').value.trim();
|
|
@@ -230,7 +248,17 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
|
|
|
230
248
|
return parts.length ? '?' + parts.join('&') : '';
|
|
231
249
|
}
|
|
232
250
|
function get(path, params) {
|
|
233
|
-
|
|
251
|
+
var query = qs(params);
|
|
252
|
+
if (parentTransport) {
|
|
253
|
+
return new Promise(function (resolve, reject) {
|
|
254
|
+
var id = nextRequestId++;
|
|
255
|
+
parentRequests[id] = { resolve: resolve, reject: reject };
|
|
256
|
+
window.parent.postMessage({
|
|
257
|
+
kind: 'syncular-admin-request', id: id, path: path + query
|
|
258
|
+
}, location.origin);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return fetch(base + path + query, { headers: { accept: 'application/json' } })
|
|
234
262
|
.then(function (r) {
|
|
235
263
|
if (!r.ok) return r.json().then(function (b) { throw new Error(b.code || ('HTTP ' + r.status)); });
|
|
236
264
|
return r.json();
|
|
@@ -445,7 +473,7 @@ export const ADMIN_CONSOLE_HTML = `<!doctype html>
|
|
|
445
473
|
el('events').innerHTML = empty('no RingBufferEvents wired on this admin');
|
|
446
474
|
return;
|
|
447
475
|
}
|
|
448
|
-
if (typeof EventSource === 'function') { connectStream(); return; }
|
|
476
|
+
if (!parentTransport && typeof EventSource === 'function') { connectStream(); return; }
|
|
449
477
|
renderEventList(res.events);
|
|
450
478
|
});
|
|
451
479
|
}
|