geonix 1.33.2 → 1.35.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/README.md CHANGED
@@ -298,6 +298,7 @@ Each returned part has:
298
298
  | `GX_HEALTH_TIMEOUT` | `2000` | Registry health-probe timeout per advertised address (ms) |
299
299
  | `GX_SECRET` | — | Encryption key: AES-256-GCM payloads + HMAC-SHA256 subjects. Services without the same key cannot communicate. |
300
300
  | `GX_DEBUG_ENDPOINT` | — | Mount path for the debug router (e.g. `/_debug`). Disabled when unset. |
301
+ | `GX_DEBUG_ENDPOINT_AUTH` | — | Basic-auth credentials for the debug router as `username:password`. When set, all debug routes (and the UI) require `Authorization: Basic …`. OPTIONS requests bypass to keep CORS preflight working. |
301
302
 
302
303
  ## Local transport (`GX_TRANSPORT=local://`)
303
304
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "geonix",
3
- "version": "1.33.2",
3
+ "version": "1.35.1",
4
4
  "type": "module",
5
5
  "description": "",
6
6
  "bin": {
package/src/Gateway.js CHANGED
@@ -10,6 +10,7 @@ import { logger } from "./Logger.js";
10
10
  import { readFileSync } from "node:fs";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { dirname, join } from "node:path";
13
+ import { timingSafeEqual } from "node:crypto";
13
14
 
14
15
  const DEBUG_HTML = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "debug.html"), "utf8");
15
16
 
@@ -43,12 +44,46 @@ const requestLogger = (req, res, next) => {
43
44
  };
44
45
 
45
46
  const stats = {
46
- requests: 0,
47
- proxied: 0,
48
- proxied_over_nats: 0,
49
- debug_requests: 0,
47
+ requests: 0, // total HTTP requests seen by the gateway
48
+ proxied: 0, // requests forwarded to a backend via HTTP
49
+ proxied_over_nats: 0, // requests forwarded via NATS tunnel (no entry.a)
50
+ errors: 0, // proxyHttp threw (network error to backend)
51
+ debug_requests: 0, // count of /<debug>/* hits
52
+ startedAt: Date.now(), // gateway boot timestamp (ms)
53
+ routerRebuilds: 0, // how many times the route table was rebuilt
54
+ byMethod: {}, // { GET: N, POST: N, ... }
55
+ byStatus: { "2xx": 0, "3xx": 0, "4xx": 0, "5xx": 0, other: 0 },
56
+ byBackend: {}, // { "host:port": count }
57
+ ws: { active: 0, total: 0, errors: 0 },
50
58
  };
51
59
 
60
+ // Latency ring buffer — 1000 most recent request durations in ms. ~8 KB. Reads
61
+ // are O(N log N) for percentile compute; writes are O(1). Cheap enough to leave
62
+ // always-on.
63
+ const LATENCY_BUF_SIZE = 1000;
64
+ const _latencyBuf = new Float64Array(LATENCY_BUF_SIZE);
65
+ let _latencyIdx = 0;
66
+ let _latencyCount = 0;
67
+ function recordLatency(ms) {
68
+ _latencyBuf[_latencyIdx] = ms;
69
+ _latencyIdx = (_latencyIdx + 1) % LATENCY_BUF_SIZE;
70
+ if (_latencyCount < LATENCY_BUF_SIZE) { _latencyCount++; }
71
+ }
72
+ function latencyPercentiles() {
73
+ if (_latencyCount === 0) { return null; }
74
+ const slice = Array.from(_latencyBuf.slice(0, _latencyCount)).sort((a, b) => a - b);
75
+ const pick = (q) => slice[Math.min(Math.floor(slice.length * q), slice.length - 1)];
76
+ return {
77
+ sampleSize: _latencyCount,
78
+ bufferSize: LATENCY_BUF_SIZE,
79
+ p50: pick(0.50),
80
+ p95: pick(0.95),
81
+ p99: pick(0.99),
82
+ max: slice[slice.length - 1],
83
+ mean: slice.reduce((a, b) => a + b, 0) / slice.length,
84
+ };
85
+ }
86
+
52
87
  const defaultOpts = {
53
88
  beforeRequest: (_req, _res) => {},
54
89
  afterRequest: (_req, _res) => {},
@@ -159,6 +194,20 @@ export class Gateway {
159
194
  next();
160
195
  });
161
196
 
197
+ // per-request tracking: method count + status bucket + duration on response finish
198
+ this.#api.use((req, res, next) => {
199
+ const t0 = process.hrtime.bigint();
200
+ stats.byMethod[req.method] = (stats.byMethod[req.method] || 0) + 1;
201
+ res.on("finish", () => {
202
+ const ms = Number(process.hrtime.bigint() - t0) / 1e6;
203
+ recordLatency(ms);
204
+ const code = res.statusCode || 0;
205
+ const bucket = code >= 500 ? "5xx" : code >= 400 ? "4xx" : code >= 300 ? "3xx" : code >= 200 ? "2xx" : "other";
206
+ stats.byStatus[bucket] = (stats.byStatus[bucket] || 0) + 1;
207
+ });
208
+ next();
209
+ });
210
+
162
211
  // handle mapped endpoints as service calls
163
212
  this.#api.use((req, res, next) => {
164
213
  stats.requests++;
@@ -294,6 +343,39 @@ export class Gateway {
294
343
  #debugRouter() {
295
344
  const router = Router();
296
345
 
346
+ // Basic Auth gate: when GX_DEBUG_ENDPOINT_AUTH is set as "user:pass", all
347
+ // debug routes require a matching Authorization: Basic <base64> header.
348
+ // OPTIONS preflight bypasses so cross-origin CORS still works.
349
+ router.use((req, res, next) => {
350
+ const expected = process.env.GX_DEBUG_ENDPOINT_AUTH;
351
+ if (!expected) {
352
+ return next();
353
+ }
354
+ if (req.method === "OPTIONS") {
355
+ return next();
356
+ }
357
+ const challenge = (msg) => {
358
+ res.set("WWW-Authenticate", 'Basic realm="Geonix Debug", charset="UTF-8"');
359
+ res.status(401).json({ error: msg });
360
+ };
361
+ const header = req.headers.authorization;
362
+ if (!header || !header.startsWith("Basic ")) {
363
+ return challenge("authentication required");
364
+ }
365
+ let provided;
366
+ try {
367
+ provided = Buffer.from(header.slice(6).trim(), "base64").toString("utf8");
368
+ } catch {
369
+ return challenge("invalid Authorization header");
370
+ }
371
+ const a = Buffer.from(expected);
372
+ const b = Buffer.from(provided);
373
+ if (a.length !== b.length || !timingSafeEqual(a, b)) {
374
+ return challenge("invalid credentials");
375
+ }
376
+ next();
377
+ });
378
+
297
379
  router.use((req, res, next) => {
298
380
  stats.debug_requests++;
299
381
 
@@ -322,6 +404,24 @@ export class Gateway {
322
404
  res.send(stats);
323
405
  });
324
406
 
407
+ // Aggregated gateway view — counters + computed latency percentiles + live
408
+ // gauges that aren't in the global stats object (session table, active proxies,
409
+ // endpoint count). Single endpoint to back the UI's Gateway tab.
410
+ router.get("/gateway", (req, res) => {
411
+ const activeProxies = Object.values(this.#registry).filter((e) => e.proxy).length;
412
+ res.json({
413
+ ...stats,
414
+ uptimeSec: Math.floor((Date.now() - stats.startedAt) / 1000),
415
+ latency: latencyPercentiles(),
416
+ sessions: this.#sessions.size,
417
+ maxSessions: MAX_SESSIONS,
418
+ activeNatsProxies: activeProxies,
419
+ endpoints: this.#endpoints.length,
420
+ port: this.#port,
421
+ pid: process.pid,
422
+ });
423
+ });
424
+
325
425
  router.get("/ui", (req, res) => {
326
426
  res.set("Content-Type", "text/html; charset=utf-8");
327
427
  res.send(DEBUG_HTML);
@@ -427,20 +527,32 @@ export class Gateway {
427
527
  #proxyWebsocket(target, inbound, req) {
428
528
  try {
429
529
  const backend = new WebSocket(target, { headers: { ...req.headers } });
530
+ stats.ws.total++;
531
+ stats.ws.active++;
532
+ let decremented = false;
533
+ const decrement = () => {
534
+ if (decremented) { return; }
535
+ decremented = true;
536
+ stats.ws.active = Math.max(0, stats.ws.active - 1);
537
+ };
430
538
 
431
539
  backend.on("error", (e) => {
540
+ stats.ws.errors++;
432
541
  logger.error("proxy.ws.backend.error:", e);
433
542
  inbound.close();
543
+ decrement();
434
544
  });
435
545
 
436
546
  backend.on("open", () => {
437
547
  backend.on("message", (data, isBinary) => inbound.send(isBinary ? data : data.toString()));
438
548
  inbound.on("message", (data) => backend.send(data));
439
549
 
440
- backend.on("close", () => inbound.close());
441
- inbound.on("close", () => backend.close());
550
+ backend.on("close", () => { inbound.close(); decrement(); });
551
+ inbound.on("close", () => { backend.close(); decrement(); });
442
552
  });
443
553
  } catch (e) {
554
+ stats.ws.errors++;
555
+ stats.ws.active = Math.max(0, stats.ws.active - 1);
444
556
  logger.error("proxy.ws.backend.error:", e);
445
557
  inbound.close();
446
558
  }
@@ -455,6 +567,7 @@ export class Gateway {
455
567
 
456
568
  this.#rebuildRouter = false;
457
569
  this.#buildRouterRunning = true;
570
+ stats.routerRebuilds++;
458
571
 
459
572
  try {
460
573
  const router = Router();
@@ -566,11 +679,13 @@ export class Gateway {
566
679
  router[verb](uri, async (req, res, _next) => {
567
680
  stats.proxied++;
568
681
  const backend = endpoint.backend[endpoint.requests++ % endpoint.backend.length];
682
+ stats.byBackend[backend] = (stats.byBackend[backend] || 0) + 1;
569
683
 
570
684
  try {
571
685
  logger.debug("proxy.web.to:", backend + req.originalUrl);
572
686
  await proxyHttp(`http://${backend}`, req, res);
573
687
  } catch (e) {
688
+ stats.errors++;
574
689
  logger.error("proxy.web.error:", e);
575
690
  }
576
691
  });
package/src/debug.html CHANGED
@@ -132,6 +132,40 @@
132
132
  .hint { color: var(--muted); font-size: 11px; }
133
133
  .invoke-grid { display: grid; grid-template-columns: 380px 1fr; gap: 16px; align-items: start; }
134
134
  @media (max-width: 900px) { .invoke-grid { grid-template-columns: 1fr; } }
135
+ /* Sortable tables: th becomes click-to-sort; matrix headers stay text and excluded. */
136
+ table.sortable th[data-key] { cursor: pointer; user-select: none; }
137
+ table.sortable th[data-key]:hover { color: var(--text); }
138
+ table.sortable th.sort-asc::after { content: " ▲"; color: var(--accent); font-size: 9px; }
139
+ table.sortable th.sort-desc::after { content: " ▼"; color: var(--accent); font-size: 9px; }
140
+ .filter {
141
+ background: var(--bg); color: var(--text);
142
+ border: 1px solid var(--border); border-radius: 6px;
143
+ padding: 6px 10px; font: inherit; font-family: var(--mono); font-size: 12px;
144
+ width: 100%; max-width: 360px; margin-bottom: 12px;
145
+ }
146
+ .filter:focus { outline: none; border-color: var(--accent); }
147
+ /* Version-color pills — hue derived from a hash of the version string */
148
+ .vpill {
149
+ display: inline-block; padding: 2px 8px; border-radius: 12px;
150
+ font-family: var(--mono); font-size: 11px; font-weight: 500;
151
+ border: 1px solid; cursor: default;
152
+ }
153
+ /* Cluster-health card on Overview — clickable tile leading to a relevant tab */
154
+ .health-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin-bottom: 16px; }
155
+ .health-tile {
156
+ background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
157
+ padding: 12px 16px; cursor: pointer;
158
+ display: flex; align-items: center; gap: 12px;
159
+ }
160
+ .health-tile:hover { border-color: var(--accent); }
161
+ .health-tile .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
162
+ .health-tile .dot.ok { background: var(--green); box-shadow: 0 0 8px rgba(63,185,80,0.4); }
163
+ .health-tile .dot.warn { background: var(--amber); box-shadow: 0 0 8px rgba(210,153,34,0.4); }
164
+ .health-tile .dot.err { background: var(--red); box-shadow: 0 0 8px rgba(248,81,73,0.4); }
165
+ .health-tile .dot.muted { background: var(--muted); }
166
+ .health-tile .body { display: flex; flex-direction: column; flex: 1; }
167
+ .health-tile .lbl { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
168
+ .health-tile .val { font-family: var(--mono); font-size: 14px; }
135
169
  </style>
136
170
  </head>
137
171
  <body>
@@ -139,9 +173,12 @@
139
173
  <h1>Geonix Debug<span class="gv" id="gv"></span></h1>
140
174
  <nav class="tabs">
141
175
  <button data-tab="overview" class="active">Overview</button>
176
+ <button data-tab="gateway">Gateway</button>
142
177
  <button data-tab="registry">Registry</button>
143
178
  <button data-tab="reachability">Reachability</button>
144
179
  <button data-tab="endpoints">Endpoints</button>
180
+ <button data-tab="versions">Versions</button>
181
+ <button data-tab="stats">Stats</button>
145
182
  <button data-tab="invoke">Invoke</button>
146
183
  <button data-tab="raw">Raw JSON</button>
147
184
  </nav>
@@ -153,9 +190,12 @@
153
190
 
154
191
  <main>
155
192
  <section id="overview" class="tab active"><div class="loading">Loading…</div></section>
193
+ <section id="gateway" class="tab"><div class="loading">Loading…</div></section>
156
194
  <section id="registry" class="tab"><div class="loading">Loading…</div></section>
157
195
  <section id="reachability" class="tab"><div class="loading">Loading…</div></section>
158
196
  <section id="endpoints" class="tab"><div class="loading">Loading…</div></section>
197
+ <section id="versions" class="tab"><div class="loading">Loading…</div></section>
198
+ <section id="stats" class="tab"><div class="loading">Loading…</div></section>
159
199
  <section id="invoke" class="tab"><div class="loading">Loading…</div></section>
160
200
  <section id="raw" class="tab"><div class="loading">Loading…</div></section>
161
201
  <section id="instance" class="tab"><div class="loading">Loading…</div></section>
@@ -194,6 +234,117 @@
194
234
  return `${Math.round(d / 3_600_000)}h`;
195
235
  };
196
236
 
237
+ // -----------------------------------------------------------------------
238
+ // Version → color helper. Stable hash to a small palette so the same string
239
+ // always renders with the same color across tabs. Purely visual — version
240
+ // mismatches are informational, not flagged as problems.
241
+ // -----------------------------------------------------------------------
242
+
243
+ const _vPalette = [
244
+ ["rgba(88,166,255,0.15)", "rgba(88,166,255,0.5)", "#79c0ff"], // blue
245
+ ["rgba(166,114,255,0.15)", "rgba(166,114,255,0.5)", "#bc8cff"], // purple
246
+ ["rgba(63,185,80,0.15)", "rgba(63,185,80,0.5)", "#7ee787"], // green
247
+ ["rgba(210,153,34,0.15)", "rgba(210,153,34,0.5)", "#e3b341"], // amber
248
+ ["rgba(255,123,114,0.15)", "rgba(255,123,114,0.5)", "#ff9492"], // red
249
+ ["rgba(86,211,200,0.15)", "rgba(86,211,200,0.5)", "#76e3da"], // teal
250
+ ["rgba(255,153,204,0.15)", "rgba(255,153,204,0.5)", "#ffadd5"], // pink
251
+ ["rgba(160,160,160,0.15)", "rgba(160,160,160,0.5)", "#c9d1d9"], // grey
252
+ ];
253
+ function _hashIdx(s, mod) {
254
+ let h = 0;
255
+ for (let i = 0; i < s.length; i++) { h = ((h << 5) - h + s.charCodeAt(i)) | 0; }
256
+ return Math.abs(h) % mod;
257
+ }
258
+ function vpill(value) {
259
+ if (value == null || value === "") { return '<span class="pill muted">—</span>'; }
260
+ const [bg, border, fg] = _vPalette[_hashIdx(String(value), _vPalette.length)];
261
+ return `<span class="vpill" style="background:${bg};border-color:${border};color:${fg};" title="${esc(value)}">${esc(value)}</span>`;
262
+ }
263
+
264
+ // -----------------------------------------------------------------------
265
+ // Sortable + filterable table helper. Sort state is module-level so it
266
+ // survives auto-refresh and tab switches. `columns` is an array of
267
+ // { key, label, type, sortable, format }; `type` selects the comparator.
268
+ // -----------------------------------------------------------------------
269
+
270
+ const sortStates = new Map();
271
+
272
+ function compareFor(type) {
273
+ if (type === "number" || type === "date") {
274
+ return (a, b) => (Number(a) || 0) - (Number(b) || 0);
275
+ }
276
+ if (type === "version") {
277
+ return (a, b) => {
278
+ const pa = String(a ?? "").split(".").map((n) => parseInt(n, 10) || 0);
279
+ const pb = String(b ?? "").split(".").map((n) => parseInt(n, 10) || 0);
280
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
281
+ const d = (pa[i] || 0) - (pb[i] || 0);
282
+ if (d !== 0) { return d; }
283
+ }
284
+ return 0;
285
+ };
286
+ }
287
+ if (type === "addresses" || type === "length") {
288
+ return (a, b) => (a?.length ?? 0) - (b?.length ?? 0);
289
+ }
290
+ return (a, b) => String(a ?? "").localeCompare(String(b ?? ""));
291
+ }
292
+
293
+ function applySort(rows, tableId, columns) {
294
+ const state = sortStates.get(tableId);
295
+ if (!state) { return rows; }
296
+ const col = columns.find((c) => c.key === state.key);
297
+ if (!col) { return rows; }
298
+ const cmp = compareFor(col.type || "string");
299
+ const mult = state.dir === "desc" ? -1 : 1;
300
+ return [...rows].sort((a, b) => cmp(a[state.key], b[state.key]) * mult);
301
+ }
302
+
303
+ function renderTable(tableId, rows, columns, renderRow) {
304
+ const state = sortStates.get(tableId);
305
+ const headers = columns.map((c) => {
306
+ const sortable = c.sortable !== false;
307
+ const cls = state?.key === c.key ? `sort-${state.dir}` : "";
308
+ return sortable
309
+ ? `<th data-key="${esc(c.key)}" class="${cls}">${esc(c.label)}</th>`
310
+ : `<th>${esc(c.label)}</th>`;
311
+ }).join("");
312
+ const body = rows.map(renderRow).join("");
313
+ return `<table class="sortable" data-table-id="${esc(tableId)}">
314
+ <thead><tr>${headers}</tr></thead>
315
+ <tbody>${body || ""}</tbody>
316
+ </table>`;
317
+ }
318
+
319
+ function attachSortHandlers(scopeEl, tableId, onChange) {
320
+ scopeEl.querySelectorAll(`table[data-table-id="${tableId}"] th[data-key]`).forEach((th) => {
321
+ th.addEventListener("click", () => {
322
+ const cur = sortStates.get(tableId);
323
+ const key = th.dataset.key;
324
+ const dir = cur?.key === key && cur.dir === "asc" ? "desc" : "asc";
325
+ sortStates.set(tableId, { key, dir });
326
+ onChange();
327
+ });
328
+ });
329
+ }
330
+
331
+ function attachRowClicks(scopeEl) {
332
+ scopeEl.querySelectorAll("tr.clickable[data-id]").forEach((tr) => {
333
+ tr.addEventListener("click", () => { window.location.hash = `instance=${encodeURIComponent(tr.dataset.id)}`; });
334
+ });
335
+ }
336
+
337
+ function applyFilter(rows, query, keys) {
338
+ const q = (query || "").trim().toLowerCase();
339
+ if (!q) { return rows; }
340
+ return rows.filter((r) => keys.some((k) => {
341
+ const v = r[k];
342
+ if (v == null) { return false; }
343
+ if (Array.isArray(v)) { return v.some((x) => String(x).toLowerCase().includes(q)); }
344
+ return String(v).toLowerCase().includes(q);
345
+ }));
346
+ }
347
+
197
348
  // -----------------------------------------------------------------------
198
349
  // Routing — `#tab` for tabs, `#instance=<id>` for instance detail
199
350
  // -----------------------------------------------------------------------
@@ -219,9 +370,12 @@
219
370
  document.getElementById(tab)?.classList.add("active");
220
371
  document.querySelector(`nav.tabs button[data-tab="${tab}"]`)?.classList.add("active");
221
372
  if (tab === "overview") { return renderOverview(); }
373
+ if (tab === "gateway") { return renderGateway(); }
222
374
  if (tab === "registry") { return renderRegistry(); }
223
375
  if (tab === "reachability") { return renderReachability(); }
224
376
  if (tab === "endpoints") { return renderEndpoints(); }
377
+ if (tab === "versions") { return renderVersions(); }
378
+ if (tab === "stats") { return renderStats(); }
225
379
  if (tab === "invoke") { return renderInvoke(); }
226
380
  if (tab === "raw") { return renderRaw(); }
227
381
  }
@@ -256,9 +410,63 @@
256
410
  document.getElementById("gv").textContent = ` v${info.geonix}`;
257
411
  const instanceCount = Object.keys(reg).length;
258
412
  const emptyAddrCount = Object.values(reg).filter((e) => !e.a?.length).length;
413
+ const gxVersions = new Set(Object.values(reg).map((e) => e.gx ?? "—"));
414
+
415
+ // Cluster-health tiles. Version diversity is INFORMATIONAL — neutral dot, not amber.
416
+ const healthTiles = [
417
+ {
418
+ label: "Geonix versions",
419
+ val: `${gxVersions.size} detected`,
420
+ dotClass: gxVersions.size <= 1 ? "ok" : "muted",
421
+ href: "#versions",
422
+ },
423
+ {
424
+ label: "Instances with empty .a",
425
+ val: String(emptyAddrCount),
426
+ dotClass: emptyAddrCount === 0 ? "ok" : "warn",
427
+ href: "#registry",
428
+ },
429
+ {
430
+ label: "Services with >1 version",
431
+ val: (() => {
432
+ const counts = new Map();
433
+ for (const e of Object.values(reg)) {
434
+ const c = counts.get(e.n) ?? new Set();
435
+ c.add(e.v);
436
+ counts.set(e.n, c);
437
+ }
438
+ return [...counts.values()].filter((s) => s.size > 1).length.toString();
439
+ })(),
440
+ dotClass: "muted", // informational, not a problem
441
+ href: "#versions",
442
+ },
443
+ ];
444
+
445
+ // Service-count overview table — sortable
446
+ const svcCounts = services.map((s) => {
447
+ const count = Object.values(reg).filter((e) => `${e.n}@${e.v}` === s).length;
448
+ return { sv: s, count };
449
+ });
450
+ const svcColumns = [
451
+ { key: "sv", label: "Name@Version", type: "string" },
452
+ { key: "count", label: "Instances", type: "number" },
453
+ ];
454
+ if (!sortStates.has("overview-services")) { sortStates.set("overview-services", { key: "sv", dir: "asc" }); }
455
+ const renderSvcRow = (r) => `<tr><td>${esc(r.sv)}</td><td>${esc(r.count)}</td></tr>`;
259
456
 
260
457
  el.innerHTML = `
261
458
  <h2>Overview</h2>
459
+ <div class="health-row">
460
+ ${healthTiles.map((t) => `
461
+ <a class="health-tile" href="${esc(t.href)}">
462
+ <span class="dot ${t.dotClass}"></span>
463
+ <div class="body">
464
+ <span class="lbl">${esc(t.label)}</span>
465
+ <span class="val">${esc(t.val)}</span>
466
+ </div>
467
+ </a>
468
+ `).join("")}
469
+ </div>
262
470
  <div class="cards">
263
471
  <div class="card"><div class="label">Geonix</div><div class="value">${esc(info.geonix)}</div></div>
264
472
  <div class="card"><div class="label">Node</div><div class="value small">${esc(info.node.version)} / ${esc(info.node.platform)} ${esc(info.node.arch)}</div></div>
@@ -268,23 +476,186 @@
268
476
  <div class="card"><div class="label">Endpoints</div><div class="value">${endpoints.length}</div></div>
269
477
  <div class="card"><div class="label">Empty .a</div><div class="value" style="color:${emptyAddrCount > 0 ? "var(--amber)" : "var(--green)"}">${emptyAddrCount}</div></div>
270
478
  </div>
271
- <h3>Stats</h3>
479
+ <h3>Gateway Stats</h3>
272
480
  <div class="cards">
273
481
  ${Object.entries(stats).map(([k, v]) => `
274
482
  <div class="card"><div class="label">${esc(k)}</div><div class="value">${esc(v)}</div></div>
275
483
  `).join("")}
276
484
  </div>
277
485
  <h3>Services</h3>
278
- <table>
279
- <thead><tr><th>Name@Version</th><th>Instances</th></tr></thead>
280
- <tbody>
281
- ${services.map((s) => {
282
- const count = Object.values(reg).filter((e) => `${e.n}@${e.v}` === s).length;
283
- return `<tr><td>${esc(s)}</td><td>${count}</td></tr>`;
284
- }).join("")}
285
- </tbody>
286
- </table>
486
+ <div id="overview-svc-wrap"></div>
287
487
  `;
488
+
489
+ const drawSvc = () => {
490
+ const sorted = applySort(svcCounts, "overview-services", svcColumns);
491
+ document.getElementById("overview-svc-wrap").innerHTML = renderTable("overview-services", sorted, svcColumns, renderSvcRow);
492
+ attachSortHandlers(el, "overview-services", drawSvc);
493
+ };
494
+ drawSvc();
495
+ } catch (e) {
496
+ el.innerHTML = `<div class="err-banner">${esc(e.message)}</div>`;
497
+ }
498
+ }
499
+
500
+ // -----------------------------------------------------------------------
501
+ // Gateway — counters, latency percentiles, and live gauges from /gateway
502
+ // -----------------------------------------------------------------------
503
+
504
+ function fmtDuration(sec) {
505
+ if (sec == null) { return "—"; }
506
+ const s = Math.floor(sec);
507
+ const days = Math.floor(s / 86400);
508
+ const hrs = Math.floor((s % 86400) / 3600);
509
+ const min = Math.floor((s % 3600) / 60);
510
+ const ss = s % 60;
511
+ if (days > 0) { return `${days}d ${hrs}h ${min}m`; }
512
+ if (hrs > 0) { return `${hrs}h ${min}m ${ss}s`; }
513
+ if (min > 0) { return `${min}m ${ss}s`; }
514
+ return `${ss}s`;
515
+ }
516
+
517
+ async function renderGateway() {
518
+ const el = document.getElementById("gateway");
519
+ el.innerHTML = '<div class="loading">Loading…</div>';
520
+ try {
521
+ const gw = await api("/gateway");
522
+
523
+ // Top summary cards
524
+ const httpTotal = gw.requests || 0;
525
+ const proxiedShare = httpTotal > 0 ? ((gw.proxied / httpTotal) * 100).toFixed(1) : "—";
526
+ const errRate = (gw.proxied + gw.proxied_over_nats) > 0
527
+ ? ((gw.errors / (gw.proxied + gw.proxied_over_nats)) * 100).toFixed(2)
528
+ : "—";
529
+
530
+ // Status code rows
531
+ const statusOrder = ["2xx", "3xx", "4xx", "5xx", "other"];
532
+ const statusRows = statusOrder.map((k) => ({
533
+ bucket: k,
534
+ count: gw.byStatus?.[k] ?? 0,
535
+ }));
536
+ const statusColumns = [
537
+ { key: "bucket", label: "Bucket", type: "string" },
538
+ { key: "count", label: "Count", type: "number" },
539
+ ];
540
+ if (!sortStates.has("gw-status")) { sortStates.set("gw-status", { key: "bucket", dir: "asc" }); }
541
+ const renderStatusRow = (r) => {
542
+ const cls = r.bucket === "2xx" ? "ok"
543
+ : r.bucket === "3xx" ? "muted"
544
+ : r.bucket === "4xx" ? "warn"
545
+ : r.bucket === "5xx" ? "err" : "muted";
546
+ return `<tr><td><span class="pill ${cls}">${esc(r.bucket)}</span></td><td>${esc(r.count)}</td></tr>`;
547
+ };
548
+
549
+ // Methods
550
+ const methodRows = Object.entries(gw.byMethod || {}).map(([m, c]) => ({ method: m, count: c }));
551
+ const methodColumns = [
552
+ { key: "method", label: "Method", type: "string" },
553
+ { key: "count", label: "Count", type: "number" },
554
+ ];
555
+ if (!sortStates.has("gw-methods")) { sortStates.set("gw-methods", { key: "count", dir: "desc" }); }
556
+ const renderMethodRow = (r) => `<tr><td><span class="pill">${esc(r.method)}</span></td><td>${esc(r.count)}</td></tr>`;
557
+
558
+ // Backends — most-used first
559
+ const backendEntries = Object.entries(gw.byBackend || {}).map(([addr, count]) => ({ addr, count }));
560
+ const backendColumns = [
561
+ { key: "addr", label: "Backend", type: "string" },
562
+ { key: "count", label: "Requests", type: "number" },
563
+ ];
564
+ if (!sortStates.has("gw-backends")) { sortStates.set("gw-backends", { key: "count", dir: "desc" }); }
565
+ const renderBackendRow = (r) => `<tr>
566
+ <td><span class="address healthy">${esc(r.addr)}</span></td>
567
+ <td>${esc(r.count)}</td>
568
+ </tr>`;
569
+
570
+ // Latency
571
+ const lat = gw.latency;
572
+ const latencyPanel = lat
573
+ ? `<div class="kv">
574
+ <div class="k">Sample size</div> <div class="v">${esc(lat.sampleSize)} / ${esc(lat.bufferSize)}</div>
575
+ <div class="k">Mean</div> <div class="v">${lat.mean.toFixed(2)} ms</div>
576
+ <div class="k">p50</div> <div class="v">${lat.p50.toFixed(2)} ms</div>
577
+ <div class="k">p95</div> <div class="v">${lat.p95.toFixed(2)} ms</div>
578
+ <div class="k">p99</div> <div class="v">${lat.p99.toFixed(2)} ms</div>
579
+ <div class="k">Max</div> <div class="v">${lat.max.toFixed(2)} ms</div>
580
+ </div>`
581
+ : `<div class="empty">No samples yet.</div>`;
582
+
583
+ // WebSocket
584
+ const wsPanel = `<div class="kv">
585
+ <div class="k">Active</div> <div class="v">${esc(gw.ws?.active ?? 0)}</div>
586
+ <div class="k">Total opened</div><div class="v">${esc(gw.ws?.total ?? 0)}</div>
587
+ <div class="k">Errors</div> <div class="v" style="color: ${(gw.ws?.errors ?? 0) > 0 ? 'var(--red)' : 'inherit'};">${esc(gw.ws?.errors ?? 0)}</div>
588
+ </div>`;
589
+
590
+ // Process & gauges
591
+ const gaugesPanel = `<div class="kv">
592
+ <div class="k">PID</div> <div class="v">${esc(gw.pid)}</div>
593
+ <div class="k">Port</div> <div class="v">${esc(gw.port)}</div>
594
+ <div class="k">Uptime</div> <div class="v">${esc(fmtDuration(gw.uptimeSec))}</div>
595
+ <div class="k">Endpoints mounted</div><div class="v">${esc(gw.endpoints)}</div>
596
+ <div class="k">Sessions cached</div><div class="v">${esc(gw.sessions)} / ${esc(gw.maxSessions)}</div>
597
+ <div class="k">Active NATS proxies</div><div class="v" style="color: ${(gw.activeNatsProxies > 0) ? 'var(--amber)' : 'inherit'};">${esc(gw.activeNatsProxies)}</div>
598
+ <div class="k">Router rebuilds</div><div class="v">${esc(gw.routerRebuilds)}</div>
599
+ </div>`;
600
+
601
+ el.innerHTML = `
602
+ <h2>Gateway</h2>
603
+ <p style="color: var(--muted); margin: 0 0 16px; max-width: 800px;">
604
+ Live counters and gauges from this Gateway instance. Latency percentiles are
605
+ computed over a rolling window of the most recent ${esc(lat?.bufferSize ?? 1000)} requests.
606
+ All tracking is in-process and ~free; nothing is persisted.
607
+ </p>
608
+ <div class="cards" style="margin-bottom: 16px;">
609
+ <div class="card"><div class="label">Total requests</div><div class="value">${esc(httpTotal)}</div></div>
610
+ <div class="card"><div class="label">Proxied (HTTP)</div><div class="value" style="color: var(--green);">${esc(gw.proxied)}</div></div>
611
+ <div class="card"><div class="label">Proxied (NATS tunnel)</div><div class="value" style="color: ${gw.proxied_over_nats > 0 ? 'var(--amber)' : 'inherit'};">${esc(gw.proxied_over_nats)}</div></div>
612
+ <div class="card"><div class="label">Proxy errors</div><div class="value" style="color: ${gw.errors > 0 ? 'var(--red)' : 'inherit'};">${esc(gw.errors)}</div></div>
613
+ <div class="card"><div class="label">Proxied share</div><div class="value">${proxiedShare}${proxiedShare === "—" ? "" : "%"}</div></div>
614
+ <div class="card"><div class="label">Error rate</div><div class="value">${errRate}${errRate === "—" ? "" : "%"}</div></div>
615
+ </div>
616
+
617
+ <div class="panel-grid">
618
+ <div class="panel"><h3>Process</h3>${gaugesPanel}</div>
619
+ <div class="panel"><h3>Latency (rolling)</h3>${latencyPanel}</div>
620
+ <div class="panel"><h3>WebSocket sessions</h3>${wsPanel}</div>
621
+ </div>
622
+
623
+ <h3>Status code buckets</h3>
624
+ <div id="gw-status-wrap"></div>
625
+
626
+ <h3>HTTP methods</h3>
627
+ <div id="gw-methods-wrap"></div>
628
+
629
+ <h3>Backends (by request count)</h3>
630
+ <div id="gw-backends-wrap"></div>
631
+ `;
632
+
633
+ const drawStatus = () => {
634
+ const sorted = applySort(statusRows, "gw-status", statusColumns);
635
+ document.getElementById("gw-status-wrap").innerHTML = renderTable("gw-status", sorted, statusColumns, renderStatusRow);
636
+ attachSortHandlers(el, "gw-status", drawStatus);
637
+ };
638
+ const drawMethods = () => {
639
+ if (methodRows.length === 0) {
640
+ document.getElementById("gw-methods-wrap").innerHTML = '<div class="empty">No requests yet.</div>';
641
+ return;
642
+ }
643
+ const sorted = applySort(methodRows, "gw-methods", methodColumns);
644
+ document.getElementById("gw-methods-wrap").innerHTML = renderTable("gw-methods", sorted, methodColumns, renderMethodRow);
645
+ attachSortHandlers(el, "gw-methods", drawMethods);
646
+ };
647
+ const drawBackends = () => {
648
+ if (backendEntries.length === 0) {
649
+ document.getElementById("gw-backends-wrap").innerHTML = '<div class="empty">No backend traffic yet.</div>';
650
+ return;
651
+ }
652
+ const sorted = applySort(backendEntries, "gw-backends", backendColumns);
653
+ document.getElementById("gw-backends-wrap").innerHTML = renderTable("gw-backends", sorted, backendColumns, renderBackendRow);
654
+ attachSortHandlers(el, "gw-backends", drawBackends);
655
+ };
656
+ drawStatus();
657
+ drawMethods();
658
+ drawBackends();
288
659
  } catch (e) {
289
660
  el.innerHTML = `<div class="err-banner">${esc(e.message)}</div>`;
290
661
  }
@@ -294,56 +665,89 @@
294
665
  // Registry
295
666
  // -----------------------------------------------------------------------
296
667
 
668
+ // sticky filter state per tab
669
+ const filterStates = { registry: "", stats: "" };
670
+
297
671
  async function renderRegistry() {
298
672
  const el = document.getElementById("registry");
299
673
  el.innerHTML = '<div class="loading">Loading…</div>';
300
674
  try {
301
675
  const reg = await api("/registry");
302
- const entries = Object.values(reg).sort((a, b) => `${a.n}@${a.v}`.localeCompare(`${b.n}@${b.v}`));
676
+ const rows = Object.values(reg).map((e) => ({
677
+ ...e,
678
+ _aCount: e.a?.length ?? 0,
679
+ _advCount: e.advertised?.length ?? 0,
680
+ _methodCount: e.m?.length ?? 0,
681
+ _expiresIn: Math.max(0, e.timeout - Date.now()),
682
+ }));
683
+
684
+ const columns = [
685
+ { key: "n", label: "Name", type: "string" },
686
+ { key: "v", label: "Version", type: "version" },
687
+ { key: "i", label: "Instance", type: "string" },
688
+ { key: "_aCount", label: "Healthy .a", type: "number" },
689
+ { key: "_advCount", label: "Advertised", type: "number" },
690
+ { key: "_methodCount", label: "Methods", type: "number" },
691
+ { key: "gx", label: "gx", type: "version" },
692
+ { key: "_expiresIn", label: "Expires in", type: "number" },
693
+ ];
694
+
695
+ // default sort: Name asc
696
+ if (!sortStates.has("registry")) { sortStates.set("registry", { key: "n", dir: "asc" }); }
697
+
698
+ const renderRow = (e) => {
699
+ const aPill = e._aCount === 0
700
+ ? `<span class="pill err">0</span>`
701
+ : `<span class="pill ok">${e._aCount}</span>`;
702
+ return `<tr class="clickable" data-id="${esc(e.i)}">
703
+ <td>${esc(e.n)}</td>
704
+ <td>${vpill(e.v)}</td>
705
+ <td><span class="link">${esc(e.i)}</span></td>
706
+ <td>${aPill}
707
+ <div class="addresses">${(e.a || []).map((a) => `<span class="address healthy">${esc(a)}</span>`).join("")}</div>
708
+ </td>
709
+ <td><span class="pill muted">${e._advCount}</span>
710
+ <details><summary>show</summary>
711
+ <div class="addresses">${(e.advertised || []).map((a) => {
712
+ const healthy = (e.a || []).includes(a);
713
+ return `<span class="address ${healthy ? "healthy" : "dead"}">${esc(a)}</span>`;
714
+ }).join("")}</div>
715
+ </details>
716
+ </td>
717
+ <td><span class="pill muted">${e._methodCount}</span></td>
718
+ <td>${vpill(e.gx)}</td>
719
+ <td>${e._expiresIn > 0 ? `${Math.round(e._expiresIn / 1000)}s` : `<span class="pill err">expired</span>`}</td>
720
+ </tr>`;
721
+ };
722
+
723
+ const filterKeys = ["n", "v", "i", "gx"];
724
+
303
725
  el.innerHTML = `
304
- <h2>Registry — Gateway's view (${entries.length} instance${entries.length === 1 ? "" : "s"})</h2>
305
- <table>
306
- <thead><tr>
307
- <th>Name</th><th>Version</th><th>Instance</th>
308
- <th>Healthy .a</th><th>Advertised</th>
309
- <th>Methods</th><th>gx</th><th>Expires in</th>
310
- </tr></thead>
311
- <tbody>
312
- ${entries.map((e) => {
313
- const aCount = e.a?.length ?? 0;
314
- const advCount = e.advertised?.length ?? 0;
315
- const aPill = aCount === 0
316
- ? `<span class="pill err">0</span>`
317
- : `<span class="pill ok">${aCount}</span>`;
318
- const expiresMs = e.timeout - Date.now();
319
- return `
320
- <tr class="clickable" data-id="${esc(e.i)}">
321
- <td>${esc(e.n)}</td>
322
- <td>${esc(e.v)}</td>
323
- <td><span class="link">${esc(e.i)}</span></td>
324
- <td>${aPill}
325
- <div class="addresses">${(e.a || []).map((a) => `<span class="address healthy">${esc(a)}</span>`).join("")}</div>
326
- </td>
327
- <td><span class="pill muted">${advCount}</span>
328
- <details><summary>show</summary>
329
- <div class="addresses">${(e.advertised || []).map((a) => {
330
- const healthy = (e.a || []).includes(a);
331
- return `<span class="address ${healthy ? "healthy" : "dead"}">${esc(a)}</span>`;
332
- }).join("")}</div>
333
- </details>
334
- </td>
335
- <td><span class="pill muted">${e.m?.length ?? 0}</span></td>
336
- <td>${esc(e.gx ?? "—")}</td>
337
- <td>${expiresMs > 0 ? `${Math.round(expiresMs / 1000)}s` : `<span class="pill err">expired</span>`}</td>
338
- </tr>
339
- `;
340
- }).join("")}
341
- </tbody>
342
- </table>
726
+ <h2>Registry — Gateway's view (${rows.length} instance${rows.length === 1 ? "" : "s"})</h2>
727
+ <input class="filter" id="reg-filter" type="search" placeholder="Filter by name, version, instance id, gx, address…" value="${esc(filterStates.registry)}">
728
+ <div id="reg-table-wrap"></div>
343
729
  `;
344
- el.querySelectorAll("tr.clickable").forEach((tr) => {
345
- tr.addEventListener("click", () => { window.location.hash = `instance=${encodeURIComponent(tr.dataset.id)}`; });
730
+
731
+ const draw = () => {
732
+ const q = filterStates.registry;
733
+ const filtered = applyFilter(rows, q, filterKeys).filter((r) =>
734
+ // also match against addresses (not on the row top level for filter helper)
735
+ !q || filterKeys.some((k) => String(r[k] ?? "").toLowerCase().includes(q.toLowerCase()))
736
+ || (r.a || []).some((a) => a.toLowerCase().includes(q.toLowerCase()))
737
+ || (r.advertised || []).some((a) => a.toLowerCase().includes(q.toLowerCase())),
738
+ );
739
+ const sorted = applySort(filtered, "registry", columns);
740
+ document.getElementById("reg-table-wrap").innerHTML = renderTable("registry", sorted, columns, renderRow);
741
+ attachSortHandlers(el, "registry", draw);
742
+ attachRowClicks(el);
743
+ };
744
+
745
+ document.getElementById("reg-filter").addEventListener("input", (ev) => {
746
+ filterStates.registry = ev.target.value;
747
+ draw();
346
748
  });
749
+
750
+ draw();
347
751
  } catch (e) {
348
752
  el.innerHTML = `<div class="err-banner">${esc(e.message)}</div>`;
349
753
  }
@@ -427,24 +831,317 @@
427
831
  el.innerHTML = '<div class="loading">Loading…</div>';
428
832
  try {
429
833
  const eps = await api("/endpoints");
834
+ const rows = eps.map((e) => ({
835
+ _raw: e,
836
+ verb: e.endpoint?.verb ?? "",
837
+ url: e.endpoint?.url ?? "",
838
+ version: e.version ?? "",
839
+ backendCount: (e.backend || []).length,
840
+ order: e.options?.order ?? 100,
841
+ requests: e.requests ?? 0,
842
+ }));
843
+
844
+ const columns = [
845
+ { key: "verb", label: "Verb", type: "string" },
846
+ { key: "url", label: "URL", type: "string" },
847
+ { key: "version", label: "Version", type: "version" },
848
+ { key: "backendCount", label: "Backends", type: "number" },
849
+ { key: "order", label: "Order", type: "number" },
850
+ { key: "requests", label: "Requests", type: "number" },
851
+ ];
852
+
853
+ if (!sortStates.has("endpoints")) { sortStates.set("endpoints", { key: "url", dir: "asc" }); }
854
+
855
+ const renderRow = (r) => `<tr>
856
+ <td><span class="pill">${esc(r.verb)}</span></td>
857
+ <td>${esc(r.url)}</td>
858
+ <td>${vpill(r.version)}</td>
859
+ <td><div class="addresses">${(r._raw.backend || []).map((b) => `<span class="address healthy">${esc(b)}</span>`).join("")}</div></td>
860
+ <td>${esc(r.order)}</td>
861
+ <td>${esc(r.requests)}</td>
862
+ </tr>`;
863
+
430
864
  el.innerHTML = `
431
- <h2>HTTP Endpoints (${eps.length})</h2>
432
- <table>
433
- <thead><tr><th>Verb</th><th>URL</th><th>Version</th><th>Backends</th><th>Order</th><th>Requests</th></tr></thead>
434
- <tbody>
435
- ${eps.map((e) => `
436
- <tr>
437
- <td><span class="pill">${esc(e.endpoint.verb)}</span></td>
438
- <td>${esc(e.endpoint.url)}</td>
439
- <td>${esc(e.version)}</td>
440
- <td><div class="addresses">${(e.backend || []).map((b) => `<span class="address healthy">${esc(b)}</span>`).join("")}</div></td>
441
- <td>${esc(e.options?.order ?? "")}</td>
442
- <td>${esc(e.requests ?? 0)}</td>
443
- </tr>
444
- `).join("")}
445
- </tbody>
446
- </table>
865
+ <h2>HTTP Endpoints (${rows.length})</h2>
866
+ <div id="ep-table-wrap"></div>
447
867
  `;
868
+ const draw = () => {
869
+ const sorted = applySort(rows, "endpoints", columns);
870
+ document.getElementById("ep-table-wrap").innerHTML = renderTable("endpoints", sorted, columns, renderRow);
871
+ attachSortHandlers(el, "endpoints", draw);
872
+ };
873
+ draw();
874
+ } catch (e) {
875
+ el.innerHTML = `<div class="err-banner">${esc(e.message)}</div>`;
876
+ }
877
+ }
878
+
879
+ // -----------------------------------------------------------------------
880
+ // Versions — informational view of detected geonix and service versions.
881
+ // Version diversity is NOT flagged as a problem; it's surfaced because it
882
+ // can be a useful clue when investigating an actual issue.
883
+ // -----------------------------------------------------------------------
884
+
885
+ async function renderVersions() {
886
+ const el = document.getElementById("versions");
887
+ el.innerHTML = '<div class="loading">Loading…</div>';
888
+ try {
889
+ const reg = await api("/registry");
890
+ const entries = Object.values(reg);
891
+
892
+ // Geonix versions
893
+ const gxAgg = new Map();
894
+ for (const e of entries) {
895
+ const k = e.gx ?? "—";
896
+ const cur = gxAgg.get(k) ?? { gx: k, instances: 0, services: new Set() };
897
+ cur.instances++;
898
+ cur.services.add(e.n);
899
+ gxAgg.set(k, cur);
900
+ }
901
+ const gxRows = [...gxAgg.values()].map((r) => ({
902
+ gx: r.gx,
903
+ instances: r.instances,
904
+ serviceCount: r.services.size,
905
+ services: [...r.services].sort().join(", "),
906
+ }));
907
+
908
+ // Service versions (one row per Name@Version)
909
+ const svcAgg = new Map();
910
+ for (const e of entries) {
911
+ const key = `${e.n}@@${e.v}`;
912
+ const cur = svcAgg.get(key) ?? { n: e.n, v: e.v, instances: 0, gxs: new Set() };
913
+ cur.instances++;
914
+ cur.gxs.add(e.gx ?? "—");
915
+ svcAgg.set(key, cur);
916
+ }
917
+ const svcRows = [...svcAgg.values()].map((r) => ({
918
+ n: r.n,
919
+ v: r.v,
920
+ instances: r.instances,
921
+ gxCount: r.gxs.size,
922
+ gxs: [...r.gxs].sort().join(", "),
923
+ }));
924
+
925
+ // How many distinct versions per service name? Useful clue, not flagged as wrong.
926
+ const versionCountByName = new Map();
927
+ for (const r of svcRows) {
928
+ versionCountByName.set(r.n, (versionCountByName.get(r.n) ?? 0) + 1);
929
+ }
930
+
931
+ const gxColumns = [
932
+ { key: "gx", label: "Geonix version", type: "version" },
933
+ { key: "instances", label: "Instances", type: "number" },
934
+ { key: "serviceCount", label: "Distinct services", type: "number" },
935
+ { key: "services", label: "Services", type: "string" },
936
+ ];
937
+ const svcColumns = [
938
+ { key: "n", label: "Service", type: "string" },
939
+ { key: "v", label: "Version", type: "version" },
940
+ { key: "instances", label: "Instances", type: "number" },
941
+ { key: "gxCount", label: "gx variety", type: "number" },
942
+ { key: "gxs", label: "Geonix versions on this row", type: "string" },
943
+ ];
944
+
945
+ if (!sortStates.has("versions-gx")) { sortStates.set("versions-gx", { key: "instances", dir: "desc" }); }
946
+ if (!sortStates.has("versions-svc")) { sortStates.set("versions-svc", { key: "n", dir: "asc" }); }
947
+
948
+ const renderGxRow = (r) => `<tr>
949
+ <td>${vpill(r.gx)}</td>
950
+ <td>${esc(r.instances)}</td>
951
+ <td>${esc(r.serviceCount)}</td>
952
+ <td style="color: var(--muted); font-size: 12px;">${esc(r.services)}</td>
953
+ </tr>`;
954
+ const renderSvcRow = (r) => {
955
+ const distinct = versionCountByName.get(r.n) ?? 1;
956
+ const distinctBadge = distinct > 1
957
+ ? ` <span class="pill muted" title="this service has ${distinct} distinct versions in the registry">${distinct} versions</span>`
958
+ : "";
959
+ return `<tr>
960
+ <td>${esc(r.n)}${distinctBadge}</td>
961
+ <td>${vpill(r.v)}</td>
962
+ <td>${esc(r.instances)}</td>
963
+ <td>${esc(r.gxCount)}</td>
964
+ <td style="color: var(--muted); font-size: 12px;">${(r.gxs || "").split(", ").map((g) => vpill(g)).join(" ")}</td>
965
+ </tr>`;
966
+ };
967
+
968
+ const gxCount = gxRows.length;
969
+ const svcWithMultipleVersions = [...versionCountByName.values()].filter((c) => c > 1).length;
970
+
971
+ el.innerHTML = `
972
+ <h2>Versions</h2>
973
+ <p style="color: var(--muted); margin: 0 0 16px; max-width: 800px;">
974
+ Snapshot of every Geonix and service version detected in the cluster.
975
+ Diversity is informational — multiple versions are not inherently a problem,
976
+ but knowing what's running where helps when triaging an actual issue.
977
+ </p>
978
+ <div class="cards" style="margin-bottom: 24px;">
979
+ <div class="card"><div class="label">Geonix versions detected</div><div class="value">${gxCount}</div></div>
980
+ <div class="card"><div class="label">Services in registry</div><div class="value">${versionCountByName.size}</div></div>
981
+ <div class="card"><div class="label">Services with multiple versions</div><div class="value">${svcWithMultipleVersions}</div></div>
982
+ </div>
983
+ <h3>Geonix versions</h3>
984
+ <div id="ver-gx-wrap"></div>
985
+ <h3>Service versions</h3>
986
+ <div id="ver-svc-wrap"></div>
987
+ `;
988
+
989
+ const drawGx = () => {
990
+ const sorted = applySort(gxRows, "versions-gx", gxColumns);
991
+ document.getElementById("ver-gx-wrap").innerHTML = renderTable("versions-gx", sorted, gxColumns, renderGxRow);
992
+ attachSortHandlers(el, "versions-gx", drawGx);
993
+ };
994
+ const drawSvc = () => {
995
+ const sorted = applySort(svcRows, "versions-svc", svcColumns);
996
+ document.getElementById("ver-svc-wrap").innerHTML = renderTable("versions-svc", sorted, svcColumns, renderSvcRow);
997
+ attachSortHandlers(el, "versions-svc", drawSvc);
998
+ };
999
+ drawGx();
1000
+ drawSvc();
1001
+ } catch (e) {
1002
+ el.innerHTML = `<div class="err-banner">${esc(e.message)}</div>`;
1003
+ }
1004
+ }
1005
+
1006
+ // -----------------------------------------------------------------------
1007
+ // Stats — cluster-wide aggregation of $getRpcStats across all instances.
1008
+ // -----------------------------------------------------------------------
1009
+
1010
+ async function renderStats() {
1011
+ const el = document.getElementById("stats");
1012
+ el.innerHTML = '<div class="loading">Loading $getRpcStats from every instance…</div>';
1013
+ try {
1014
+ const reg = await api("/registry");
1015
+ const ids = Object.keys(reg);
1016
+ if (ids.length === 0) { el.innerHTML = '<div class="empty">No instances registered.</div>'; return; }
1017
+
1018
+ const fetches = await Promise.all(ids.map((id) =>
1019
+ api(`/services/${id}/$getRpcStats`)
1020
+ .then((s) => ({ id, ok: true, stats: s }))
1021
+ .catch((err) => ({ id, ok: false, error: err.message })),
1022
+ ));
1023
+
1024
+ const rows = fetches.map((f) => {
1025
+ const entry = reg[f.id] || {};
1026
+ const s = f.ok ? f.stats : null;
1027
+ const httpAttempts = s?.http?.attempts ?? 0;
1028
+ const httpSuccess = s?.http?.success ?? 0;
1029
+ return {
1030
+ id: f.id,
1031
+ label: entry.n ? `${entry.n}@${entry.v}` : "—",
1032
+ n: entry.n ?? "",
1033
+ v: entry.v ?? "",
1034
+ ok: f.ok,
1035
+ error: f.error || "",
1036
+ httpAttempts,
1037
+ httpSuccess,
1038
+ httpFailures: s?.http?.failures ?? 0,
1039
+ httpNonOk: s?.http?.nonOk ?? 0,
1040
+ httpFetchErrors: s?.http?.fetchErrors ?? 0,
1041
+ httpDecodeErrors: s?.http?.decodeErrors ?? 0,
1042
+ natsAttempts: s?.nats?.attempts ?? 0,
1043
+ natsSuccess: s?.nats?.success ?? 0,
1044
+ natsFailures: s?.nats?.failures ?? 0,
1045
+ noUsableInstance: s?.noUsableInstance ?? 0,
1046
+ rate: httpAttempts > 0 ? (httpSuccess / httpAttempts) : null,
1047
+ lastFailAt: s?.lastHttpFailure?.at ?? 0,
1048
+ lastFail: s?.lastHttpFailure ?? null,
1049
+ };
1050
+ });
1051
+
1052
+ // Cluster totals
1053
+ const sum = (k) => rows.reduce((a, r) => a + (r[k] || 0), 0);
1054
+ const totals = {
1055
+ httpAttempts: sum("httpAttempts"),
1056
+ httpSuccess: sum("httpSuccess"),
1057
+ httpFailures: sum("httpFailures"),
1058
+ httpNonOk: sum("httpNonOk"),
1059
+ httpFetchErrors: sum("httpFetchErrors"),
1060
+ httpDecodeErrors: sum("httpDecodeErrors"),
1061
+ natsAttempts: sum("natsAttempts"),
1062
+ natsSuccess: sum("natsSuccess"),
1063
+ natsFailures: sum("natsFailures"),
1064
+ noUsableInstance: sum("noUsableInstance"),
1065
+ };
1066
+ const rate = totals.httpAttempts > 0
1067
+ ? (100 * totals.httpSuccess / totals.httpAttempts).toFixed(2)
1068
+ : "—";
1069
+
1070
+ const columns = [
1071
+ { key: "label", label: "Service", type: "string" },
1072
+ { key: "id", label: "Instance", type: "string" },
1073
+ { key: "httpAttempts", label: "HTTP attempts", type: "number" },
1074
+ { key: "httpSuccess", label: "HTTP success", type: "number" },
1075
+ { key: "httpFailures", label: "HTTP failures", type: "number" },
1076
+ { key: "httpNonOk", label: "non-OK", type: "number" },
1077
+ { key: "httpFetchErrors", label: "fetch err", type: "number" },
1078
+ { key: "httpDecodeErrors",label: "decode err", type: "number" },
1079
+ { key: "natsAttempts", label: "NATS attempts", type: "number" },
1080
+ { key: "natsFailures", label: "NATS fail", type: "number" },
1081
+ { key: "noUsableInstance",label: "No usable", type: "number" },
1082
+ { key: "rate", label: "HTTP success rate", type: "number" },
1083
+ { key: "lastFailAt", label: "Last HTTP fail", type: "number" },
1084
+ ];
1085
+ if (!sortStates.has("stats-instances")) { sortStates.set("stats-instances", { key: "httpAttempts", dir: "desc" }); }
1086
+
1087
+ const renderRow = (r) => {
1088
+ if (!r.ok) {
1089
+ return `<tr class="clickable" data-id="${esc(r.id)}">
1090
+ <td>${esc(r.label)}</td>
1091
+ <td><span class="link">${esc(r.id.slice(0, 12))}…</span></td>
1092
+ <td colspan="11" style="color: var(--amber);">${esc(r.error)}</td>
1093
+ </tr>`;
1094
+ }
1095
+ const rateCell = r.rate == null
1096
+ ? `<span class="pill muted">—</span>`
1097
+ : `<span class="pill ${r.rate >= 0.99 ? "ok" : (r.rate >= 0.95 ? "warn" : "err")}">${(r.rate * 100).toFixed(1)}%</span>`;
1098
+ const lastFailCell = r.lastFailAt > 0
1099
+ ? `<span title="${esc(JSON.stringify(r.lastFail))}">${esc(since(r.lastFailAt))} ago</span>`
1100
+ : `<span class="pill muted">none</span>`;
1101
+ return `<tr class="clickable" data-id="${esc(r.id)}">
1102
+ <td>${esc(r.label)}</td>
1103
+ <td><span class="link">${esc(r.id.slice(0, 12))}…</span></td>
1104
+ <td>${esc(r.httpAttempts)}</td>
1105
+ <td style="color: var(--green);">${esc(r.httpSuccess)}</td>
1106
+ <td style="color: ${r.httpFailures > 0 ? "var(--red)" : "inherit"};">${esc(r.httpFailures)}</td>
1107
+ <td>${esc(r.httpNonOk)}</td>
1108
+ <td>${esc(r.httpFetchErrors)}</td>
1109
+ <td>${esc(r.httpDecodeErrors)}</td>
1110
+ <td>${esc(r.natsAttempts)}</td>
1111
+ <td style="color: ${r.natsFailures > 0 ? "var(--red)" : "inherit"};">${esc(r.natsFailures)}</td>
1112
+ <td>${esc(r.noUsableInstance)}</td>
1113
+ <td>${rateCell}</td>
1114
+ <td>${lastFailCell}</td>
1115
+ </tr>`;
1116
+ };
1117
+
1118
+ el.innerHTML = `
1119
+ <h2>Outgoing RPC stats — cluster-wide aggregation</h2>
1120
+ <p style="color: var(--muted); margin: 0 0 16px; max-width: 800px;">
1121
+ Counts of <code>directRequest</code> dispatches summed across every instance.
1122
+ HTTP success near 100% with NATS attempts plateauing means same-host calls are
1123
+ taking the direct HTTP path. A rising NATS attempts column or growing
1124
+ <code>noUsableInstance</code> count points at the caller side of a routing issue.
1125
+ </p>
1126
+ <div class="cards" style="margin-bottom: 24px;">
1127
+ <div class="card"><div class="label">HTTP attempts</div><div class="value">${totals.httpAttempts}</div></div>
1128
+ <div class="card"><div class="label">HTTP success</div><div class="value" style="color: var(--green);">${totals.httpSuccess}</div></div>
1129
+ <div class="card"><div class="label">HTTP failures</div><div class="value" style="color: ${totals.httpFailures > 0 ? "var(--red)" : "inherit"};">${totals.httpFailures}</div></div>
1130
+ <div class="card"><div class="label">HTTP success rate</div><div class="value">${rate}${rate === "—" ? "" : "%"}</div></div>
1131
+ <div class="card"><div class="label">NATS attempts</div><div class="value">${totals.natsAttempts}</div></div>
1132
+ <div class="card"><div class="label">No usable instance</div><div class="value">${totals.noUsableInstance}</div></div>
1133
+ </div>
1134
+ <h3>Breakdown by instance</h3>
1135
+ <div id="stats-table-wrap"></div>
1136
+ `;
1137
+
1138
+ const draw = () => {
1139
+ const sorted = applySort(rows, "stats-instances", columns);
1140
+ document.getElementById("stats-table-wrap").innerHTML = renderTable("stats-instances", sorted, columns, renderRow);
1141
+ attachSortHandlers(el, "stats-instances", draw);
1142
+ attachRowClicks(el);
1143
+ };
1144
+ draw();
448
1145
  } catch (e) {
449
1146
  el.innerHTML = `<div class="err-banner">${esc(e.message)}</div>`;
450
1147
  }
@@ -746,27 +1443,33 @@ ${esc(argsStr)}</pre>
746
1443
  if (!view) { return '<div class="empty">no entries</div>'; }
747
1444
  const ids = Object.keys(view);
748
1445
  if (ids.length === 0) { return '<div class="empty">no entries</div>'; }
749
- return `
750
- <table>
751
- <thead><tr><th>Name</th><th>Ver</th><th>Instance</th><th>Healthy .a</th><th>Advertised</th></tr></thead>
752
- <tbody>
753
- ${ids.map((iid) => {
754
- const e = view[iid];
755
- const aCount = e.a?.length ?? 0;
756
- const isSelf = iid === id;
757
- return `
758
- <tr class="clickable" data-id="${esc(iid)}">
759
- <td>${esc(e.n)}${isSelf ? ' <span class="pill muted">self</span>' : ""}</td>
760
- <td>${esc(e.v)}</td>
761
- <td><span class="link">${esc(iid.slice(0, 12))}…</span></td>
762
- <td>${aCount === 0 ? '<span class="pill err">0</span>' : `<span class="pill ok">${aCount}</span>`}</td>
763
- <td>${e.advertised?.length ?? 0}</td>
764
- </tr>
765
- `;
766
- }).join("")}
767
- </tbody>
768
- </table>
769
- `;
1446
+ const rows = ids.map((iid) => {
1447
+ const e = view[iid];
1448
+ return {
1449
+ n: e.n, v: e.v, gx: e.gx ?? "",
1450
+ iid,
1451
+ aCount: e.a?.length ?? 0,
1452
+ advCount: e.advertised?.length ?? 0,
1453
+ isSelf: iid === id,
1454
+ };
1455
+ });
1456
+ const columns = [
1457
+ { key: "n", label: "Name", type: "string" },
1458
+ { key: "v", label: "Ver", type: "version" },
1459
+ { key: "iid", label: "Instance", type: "string" },
1460
+ { key: "aCount", label: "Healthy .a", type: "number" },
1461
+ { key: "advCount", label: "Advertised", type: "number" },
1462
+ ];
1463
+ if (!sortStates.has("instance-regview")) { sortStates.set("instance-regview", { key: "n", dir: "asc" }); }
1464
+ const renderRow = (r) => `<tr class="clickable" data-id="${esc(r.iid)}">
1465
+ <td>${esc(r.n)}${r.isSelf ? ' <span class="pill muted">self</span>' : ""}</td>
1466
+ <td>${vpill(r.v)}</td>
1467
+ <td><span class="link">${esc(r.iid.slice(0, 12))}…</span></td>
1468
+ <td>${r.aCount === 0 ? '<span class="pill err">0</span>' : `<span class="pill ok">${r.aCount}</span>`}</td>
1469
+ <td>${esc(r.advCount)}</td>
1470
+ </tr>`;
1471
+ const sorted = applySort(rows, "instance-regview", columns);
1472
+ return `<div id="instance-regview-wrap">${renderTable("instance-regview", sorted, columns, renderRow)}</div>`;
770
1473
  };
771
1474
 
772
1475
  const panelRegView = regView.ok
@@ -794,6 +1497,10 @@ ${esc(argsStr)}</pre>
794
1497
  el.querySelectorAll("tr.clickable").forEach((tr) => {
795
1498
  tr.addEventListener("click", () => { window.location.hash = `instance=${encodeURIComponent(tr.dataset.id)}`; });
796
1499
  });
1500
+ // Wire sort handlers for the embedded "registry as seen by this instance" table.
1501
+ if (regView.ok && document.getElementById("instance-regview-wrap")) {
1502
+ attachSortHandlers(el, "instance-regview", () => renderInstance(id));
1503
+ }
797
1504
  }
798
1505
 
799
1506
  // -----------------------------------------------------------------------