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 +1 -0
- package/package.json +1 -1
- package/src/Gateway.js +121 -6
- package/src/debug.html +796 -89
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
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
|
-
|
|
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
|
-
<
|
|
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
|
|
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 (${
|
|
305
|
-
<
|
|
306
|
-
|
|
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
|
-
|
|
345
|
-
|
|
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 (${
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
</
|
|
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
|
// -----------------------------------------------------------------------
|