Files
graphql-monitoring-proxy/admin/dashboard.html
T
lukaszraczylo 01d1de1f0b feat(admin): redesign dashboard and make cluster view a real control
Replace the AI-slop visual (indigo->purple gradient, glassmorphism, uniform rounded cards, hover-lift) with a deliberate warm-paper 'instrument panel': monospace tabular telemetry, GraphQL brand magenta accent, flat hairline cards, real type hierarchy, WCAG-AA contrast, focus-visible, prefers-reduced-motion and aria-live status. No new font/CDN deps.

Make the cluster-view toggle functional: the WebSocket stats stream now honours a ?view=local|cluster query param, so toggling streams this-node-only vs aggregated metrics instead of being cosmetic (the stream previously always sent cluster-aware data when an aggregator existed). The toggle is always visible -- disabled with a 'single node' hint when Redis cluster mode is off -- so it is discoverable.

Fix cluster-mode rendering bugs: derive backend health from healthy/total instances (health card no longer stuck on 'Loading'), show 'n/a' for Redis memory instead of a misleading 0 MB, render aggregated CB/retry/coalescing/connections on the polling path too, and surface the proxy version and backend-health detail.

Tests assert the served dashboard against stable markers instead of the cosmetic page title.
2026-06-21 02:48:20 +01:00

1076 lines
49 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>graphql-proxy · console</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
/*
* Deliberate "instrument panel" design for an operational proxy console.
* Warm paper ground, near-black ink, tabular monospace data, and a single
* accent grounded in the GraphQL brand magenta (#E10098). No gradients,
* no card hover-lift, no glassmorphism — this reads as a telemetry tool,
* not a marketing page. Zero external font dependencies (airgap-safe).
*/
:root {
--bg: #f4f2ec;
--surface: #fcfbf8;
--surface-2: #f0ede4;
--line: #e2ddd0;
--line-soft: #ece7db;
--ink: #1a1a17;
--ink-soft: #5c584f;
--ink-faint: #8a857a;
--accent: #e10098; /* GraphQL brand magenta */
--accent-ink:#b10078; /* AA-contrast variant for text */
--ok: #1b7a43;
--ok-bg: #e3f0e6;
--warn: #9a5b00;
--warn-bg: #f6ebd6;
--bad: #c2341d;
--bad-bg: #f7e2dd;
--off-bg: #ebe7dc;
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
font-family: var(--font-sans);
background: var(--bg);
color: var(--ink);
line-height: 1.5;
font-size: 14px;
}
.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
/* ---- top bar ---------------------------------------------------- */
.topbar {
position: sticky;
top: 0;
z-index: 10;
background: var(--surface);
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 24px;
flex-wrap: wrap;
}
.brand { display: flex; align-items: center; gap: 10px; }
.mark {
width: 18px;
height: 18px;
background: var(--accent);
border-radius: 3px;
flex: none;
}
.brand-name {
font-family: var(--font-mono);
font-weight: 600;
font-size: 14px;
letter-spacing: -0.2px;
}
.brand-name .dim { color: var(--ink-faint); }
.brand-version {
font-family: var(--font-mono);
font-size: 11px;
color: var(--ink-faint);
padding: 2px 7px;
border: 1px solid var(--line);
border-radius: 4px;
}
.topbar-right { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 4px 9px;
border-radius: 4px;
border: 1px solid var(--line);
color: var(--ink-soft);
}
.pill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--ink-faint); }
.pill.live .dot { background: var(--ok); }
.pill.live { color: var(--ok); border-color: var(--ok); }
.pill.degraded .dot { background: var(--warn); }
.pill.degraded { color: var(--warn); border-color: var(--warn); }
.pill.down .dot { background: var(--bad); }
.pill.down { color: var(--bad); border-color: var(--bad); }
/* view toggle */
.view-toggle { display: flex; align-items: center; gap: 8px; }
.view-toggle[hidden] { display: none; }
.view-hint {
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--ink-faint);
}
.view-toggle label {
font-family: var(--font-mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--ink-soft);
cursor: pointer;
user-select: none;
}
.switch {
position: relative;
width: 38px;
height: 20px;
flex: none;
}
.switch input { position: absolute; opacity: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; }
.switch .track {
position: absolute;
inset: 0;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 10px;
transition: background 0.15s, border-color 0.15s;
}
.switch .track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: var(--ink-faint);
border-radius: 50%;
transition: transform 0.15s, background 0.15s;
}
.switch input:checked + .track { background: var(--accent); border-color: var(--accent); }
.switch input:checked + .track::after { transform: translateX(18px); background: #fff; }
.switch input:disabled + .track { opacity: 0.45; }
.switch input:focus-visible + .track { outline: 2px solid var(--accent-ink); outline-offset: 2px; }
/* ---- layout ----------------------------------------------------- */
main { max-width: 1320px; margin: 0 auto; padding: 22px 24px 64px; }
.statusbar {
display: flex;
align-items: center;
gap: 10px;
background: var(--surface);
border: 1px solid var(--line);
border-left: 3px solid var(--ink-faint);
border-radius: 6px;
padding: 13px 16px;
margin-bottom: 26px;
}
.statusbar.ok { border-left-color: var(--ok); }
.statusbar.degraded { border-left-color: var(--warn); }
.statusbar.down { border-left-color: var(--bad); }
.statusbar .sb-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--ink-faint); flex: none; }
.statusbar.ok .sb-dot { background: var(--ok); }
.statusbar.degraded .sb-dot { background: var(--warn); }
.statusbar.down .sb-dot { background: var(--bad); }
.statusbar .sb-text { font-size: 13.5px; }
.statusbar .sb-text strong { font-weight: 600; }
.statusbar .sb-text .sep { color: var(--ink-faint); margin: 0 4px; }
h2.section {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--ink-soft);
margin: 30px 0 12px;
display: flex;
align-items: center;
gap: 8px;
}
h2.section::before {
content: '';
width: 14px;
height: 2px;
background: var(--accent);
display: inline-block;
}
h2.section:first-of-type { margin-top: 0; }
.row { display: grid; gap: 14px; }
.row.kpis { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
.row.duo { grid-template-columns: repeat(auto-fit, minmax(330px, 1fr)); }
.row.trio { grid-template-columns: repeat(auto-fit, minmax(290px, 1fr)); }
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 6px;
padding: 18px 20px;
}
.card.disabled { opacity: 0.62; }
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
}
.card-title {
font-family: var(--font-mono);
font-size: 10.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.7px;
color: var(--ink-soft);
}
/* KPI tile */
.kpi .kpi-num {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 30px;
font-weight: 600;
line-height: 1.1;
letter-spacing: -0.5px;
}
.kpi .kpi-sub { font-size: 12px; color: var(--ink-soft); margin-top: 6px; }
.kpi .trend { font-size: 0.7em; color: var(--ink-faint); }
.bar {
height: 6px;
background: var(--line-soft);
border-radius: 3px;
overflow: hidden;
margin-top: 10px;
}
.bar > i { display: block; height: 100%; width: 0; background: var(--accent); transition: width 0.3s ease; }
.bar.ok > i { background: var(--ok); }
.chart-card { padding-bottom: 20px; }
.chart-wrap { position: relative; height: 230px; margin-top: 4px; }
/* metric lines */
.mline {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--line-soft);
}
.mline:last-child { border-bottom: none; }
.mline .k { color: var(--ink-soft); font-size: 13px; }
.mline .v { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 13px; color: var(--ink); text-align: right; }
.mline .v.bad { color: var(--bad); }
.mline .v.ok { color: var(--ok); }
.tag {
display: inline-block;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 3px 8px;
border-radius: 4px;
background: var(--off-bg);
color: var(--ink-soft);
}
.tag.ok { background: var(--ok-bg); color: var(--ok); }
.tag.warn { background: var(--warn-bg); color: var(--warn); }
.tag.bad { background: var(--bad-bg); color: var(--bad); }
.tag.accent { background: rgba(225,0,152,0.10); color: var(--accent-ink); }
.btn {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
color: var(--ink);
background: transparent;
border: 1px solid var(--line);
border-radius: 5px;
padding: 8px 14px;
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
}
.btn:hover { background: var(--surface-2); border-color: var(--ink-faint); }
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline: 2px solid var(--accent-ink); outline-offset: 2px; }
.card-foot { margin-top: 14px; }
/* cluster section + instance table */
#cluster-section[hidden] { display: none; }
.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: 6px; }
table.instances { width: 100%; border-collapse: collapse; background: var(--surface); font-size: 13px; }
table.instances th, table.instances td { text-align: right; padding: 10px 14px; white-space: nowrap; }
table.instances th:first-child, table.instances td:first-child { text-align: left; }
table.instances thead th {
font-family: var(--font-mono);
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--ink-soft);
font-weight: 600;
border-bottom: 1px solid var(--line);
background: var(--surface-2);
}
table.instances tbody td { border-bottom: 1px solid var(--line-soft); }
table.instances tbody tr:last-child td { border-bottom: none; }
table.instances td.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.host { display: inline-flex; align-items: center; gap: 7px; }
.host .hdot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--ink-faint); }
.host .hdot.ok { background: var(--ok); }
.host .hdot.bad { background: var(--bad); }
.host .self { font-size: 10px; }
footer {
margin-top: 36px;
text-align: center;
font-family: var(--font-mono);
font-size: 11px;
color: var(--ink-faint);
}
@media (max-width: 560px) {
main { padding: 16px 14px 48px; }
.topbar { padding: 10px 14px; }
.kpi .kpi-num { font-size: 25px; }
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="mark" aria-hidden="true"></span>
<span class="brand-name">graphql<span class="dim">-proxy</span> · console</span>
<span class="brand-version num" id="brand-version" title="Proxy build version"></span>
</div>
<div class="topbar-right">
<div class="view-toggle" id="view-toggle">
<label for="view-switch" id="view-label">Cluster view</label>
<span class="switch" id="view-switch-wrap" title="Cluster view requires Redis cluster mode (ENABLE_REDIS_CACHE)">
<input type="checkbox" id="view-switch" role="switch" disabled
aria-label="Toggle cluster view (aggregated) versus this node only">
<span class="track" aria-hidden="true"></span>
</span>
<span class="view-hint" id="view-hint">single node</span>
</div>
<span class="pill" id="conn-pill"><span class="dot"></span><span id="conn-pill-text">Connecting</span></span>
</div>
</header>
<main>
<!-- system status summary -->
<div class="statusbar" id="statusbar" role="status" aria-live="polite">
<span class="sb-dot" aria-hidden="true"></span>
<span class="sb-text" id="sb-text">Waiting for first telemetry frame…</span>
</div>
<!-- primary KPIs -->
<h2 class="section"><span id="overview-label">Overview</span></h2>
<div class="row kpis">
<div class="card kpi">
<div class="card-title">Total requests</div>
<div class="kpi-num" id="kpi-requests"></div>
<div class="kpi-sub" id="kpi-requests-sub">since start</div>
</div>
<div class="card kpi">
<div class="card-title">Success rate</div>
<div class="kpi-num" id="kpi-success"></div>
<div class="bar ok"><i id="kpi-success-bar"></i></div>
</div>
<div class="card kpi">
<div class="card-title">Requests / sec</div>
<div class="kpi-num" id="kpi-rps"></div>
<div class="kpi-sub">avg <span class="num" id="kpi-rps-avg"></span> / s</div>
</div>
<div class="card kpi">
<div class="card-title">Cache hit rate</div>
<div class="kpi-num" id="kpi-hitrate"></div>
<div class="bar"><i id="kpi-hitrate-bar"></i></div>
</div>
</div>
<!-- charts -->
<h2 class="section">Live metrics · 2s</h2>
<div class="row duo">
<div class="card chart-card">
<div class="card-title">Requests / sec</div>
<div class="chart-wrap"><canvas id="chart-rps" aria-label="Requests per second over time" role="img"></canvas></div>
</div>
<div class="card chart-card">
<div class="card-title">Cache hit rate</div>
<div class="chart-wrap"><canvas id="chart-cache" aria-label="Cache hit rate over time" role="img"></canvas></div>
</div>
</div>
<!-- traffic + cache detail -->
<h2 class="section">Traffic &amp; cache</h2>
<div class="row duo">
<div class="card">
<div class="card-head"><span class="card-title">Request breakdown</span></div>
<div class="mline"><span class="k">Succeeded</span><span class="v ok" id="t-succeeded"></span></div>
<div class="mline"><span class="k">Failed</span><span class="v" id="t-failed"></span></div>
<div class="mline"><span class="k">Skipped</span><span class="v" id="t-skipped"></span></div>
<div class="mline"><span class="k">Average throughput</span><span class="v" id="t-avgrps"></span></div>
<div class="mline"><span class="k">Uptime</span><span class="v" id="t-uptime"></span></div>
</div>
<div class="card" id="cache-card">
<div class="card-head"><span class="card-title">Cache</span><span class="tag" id="cache-backend"></span></div>
<div class="mline"><span class="k">Hits</span><span class="v" id="c-hits"></span></div>
<div class="mline"><span class="k">Misses</span><span class="v" id="c-misses"></span></div>
<div class="mline"><span class="k">Cached queries</span><span class="v" id="c-cached"></span></div>
<div class="mline"><span class="k">Memory usage</span><span class="v" id="c-memory"></span></div>
<div class="bar"><i id="c-memory-bar"></i></div>
<div class="mline" style="margin-top:8px"><span class="k">TTL</span><span class="v" id="c-ttl"></span></div>
</div>
</div>
<!-- resilience -->
<h2 class="section">Resilience</h2>
<div class="row trio">
<div class="card" id="cb-card">
<div class="card-head"><span class="card-title">Circuit breaker</span><span class="tag" id="cb-badge"></span></div>
<div class="mline"><span class="k" id="cb-l1">Requests</span><span class="v" id="cb-requests"></span></div>
<div class="mline"><span class="k" id="cb-l2">Successes</span><span class="v" id="cb-success"></span></div>
<div class="mline"><span class="k" id="cb-l3">Failures</span><span class="v" id="cb-failures"></span></div>
<div class="mline"><span class="k">Consecutive failures</span><span class="v" id="cb-cons-failures"></span></div>
<div class="mline"><span class="k">Trip threshold</span><span class="v" id="cb-maxfail"></span></div>
<div class="mline"><span class="k">Open timeout</span><span class="v" id="cb-timeout"></span></div>
</div>
<div class="card" id="rb-card">
<div class="card-head"><span class="card-title">Retry budget</span><span class="tag" id="rb-badge"></span></div>
<div class="mline"><span class="k">Tokens</span><span class="v" id="rb-tokens"></span></div>
<div class="mline"><span class="k">Attempts</span><span class="v" id="rb-attempts"></span></div>
<div class="mline"><span class="k">Denied</span><span class="v" id="rb-denied"></span></div>
<div class="mline"><span class="k">Denial rate</span><span class="v" id="rb-denialrate"></span></div>
<div class="card-foot"><button class="btn" type="button" id="rb-reset">Reset stats</button></div>
</div>
<div class="card" id="co-card">
<div class="card-head"><span class="card-title">Request coalescing</span><span class="tag" id="co-badge"></span></div>
<div class="mline"><span class="k">Backend savings</span><span class="v ok" id="co-savings"></span></div>
<div class="mline"><span class="k">Total requests</span><span class="v" id="co-total"></span></div>
<div class="mline"><span class="k">Primary (forwarded)</span><span class="v" id="co-primary"></span></div>
<div class="mline"><span class="k">Coalesced (saved)</span><span class="v" id="co-coalesced"></span></div>
<div class="card-foot"><button class="btn" type="button" id="co-reset">Reset stats</button></div>
</div>
</div>
<!-- connectivity -->
<h2 class="section">Connectivity</h2>
<div class="row trio">
<div class="card kpi">
<div class="card-title">Connection pool</div>
<div class="kpi-num" id="conn-pool"></div>
<div class="kpi-sub">active backend connections</div>
</div>
<div class="card kpi">
<div class="card-title">WebSocket clients</div>
<div class="kpi-num" id="conn-ws"></div>
<div class="kpi-sub">active proxied sockets</div>
</div>
<div class="card" id="backend-card">
<div class="card-head"><span class="card-title">Backend health</span><span class="tag" id="bh-badge"></span></div>
<div class="mline"><span class="k">Consecutive failures</span><span class="v" id="bh-failures"></span></div>
<div class="mline"><span class="k">Last check</span><span class="v" id="bh-lastcheck"></span></div>
</div>
</div>
<!-- cluster (only when aggregated) -->
<section id="cluster-section" hidden>
<h2 class="section">Cluster</h2>
<div class="row kpis" style="margin-bottom:14px">
<div class="card kpi">
<div class="card-title">Instances</div>
<div class="kpi-num" id="cl-total"></div>
<div class="kpi-sub">reporting nodes</div>
</div>
<div class="card kpi">
<div class="card-title">Healthy</div>
<div class="kpi-num" id="cl-healthy"></div>
<div class="kpi-sub">backend-healthy nodes</div>
</div>
<div class="card kpi">
<div class="card-title">Cluster uptime</div>
<div class="kpi-num" id="cl-uptime"></div>
<div class="kpi-sub">youngest node</div>
</div>
</div>
<div class="table-wrap">
<table class="instances">
<thead>
<tr>
<th>Instance</th>
<th>Uptime</th>
<th>Requests</th>
<th>Success</th>
<th>RPS</th>
<th>Hit rate</th>
</tr>
</thead>
<tbody id="instance-tbody"></tbody>
</table>
</div>
</section>
<footer id="footer">Initialising…</footer>
</main>
<script>
'use strict';
(function () {
// ---- state -------------------------------------------------------
var state = {
ws: null,
reconnectTimer: null,
pollTimer: null,
clusterAvailable: false,
view: 'local', // 'local' = this node, 'cluster' = aggregated
mode: 'connecting' // connecting | ws | polling
};
var MAX_POINTS = 60;
var SMOOTH = 5;
var charts = {};
var series = { rps: { l: [], d: [] }, cache: { l: [], d: [] } };
var buf = { rps: [], success: [], hit: [] };
var reduceMotion = window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function $(id) { return document.getElementById(id); }
function setText(id, v, title) {
var el = $(id); if (!el) return;
el.textContent = v;
if (title !== undefined) el.title = title;
}
// ---- formatting --------------------------------------------------
function num(n) {
if (n === undefined || n === null || isNaN(n)) return '0';
var a = Math.abs(n);
if (a >= 1e9) return (n / 1e9).toFixed(1) + 'B';
if (a >= 1e6) return (n / 1e6).toFixed(1) + 'M';
if (a >= 1e3) return (n / 1e3).toFixed(1) + 'K';
return Math.round(n).toLocaleString();
}
function full(n) { return (n || 0).toLocaleString(); }
function pct(n) { return (Number(n) || 0).toFixed(1) + '%'; }
function dur(s) {
s = Math.floor(s || 0);
var d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600),
m = Math.floor((s % 3600) / 60), sec = s % 60;
if (d > 0) return d + 'd ' + h + 'h ' + m + 'm';
if (h > 0) return h + 'h ' + m + 'm';
if (m > 0) return m + 'm ' + sec + 's';
return sec + 's';
}
function smooth(b, v) {
b.push(v); if (b.length > SMOOTH) b.shift();
return b.reduce(function (a, x) { return a + x; }, 0) / b.length;
}
function trend(b) {
if (b.length < 2) return '';
var r = b.slice(-3);
var avg = r.reduce(function (a, x) { return a + x; }, 0) / r.length;
var diff = r[r.length - 1] - r[0];
var ch = Math.abs(diff / (avg || 1)) * 100;
if (ch < 5) return '→';
if (diff > 0) return ch > 15 ? '↑' : '↗';
return ch > 15 ? '↓' : '↘';
}
// ---- charts ------------------------------------------------------
function makeChart(canvasId, color) {
var ctx = $(canvasId).getContext('2d');
return new Chart(ctx, {
type: 'line',
data: { labels: [], datasets: [{
data: [], borderColor: color,
backgroundColor: color.replace('rgb', 'rgba').replace(')', ',0.06)'),
borderWidth: 1.5, tension: 0.35, fill: true,
pointRadius: 0, pointHoverRadius: 3
}] },
options: {
responsive: true, maintainAspectRatio: false,
animation: reduceMotion ? false : { duration: 0 },
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, grid: { color: '#e4dfd3' },
ticks: { precision: 0, color: '#8a857a' }, border: { display: false } },
x: { display: false }
}
}
});
}
function pushChart(name, value) {
var s = series[name], c = charts[name];
s.l.push(''); s.d.push(value);
if (s.d.length > MAX_POINTS) { s.l.shift(); s.d.shift(); }
c.data.labels = s.l; c.data.datasets[0].data = s.d;
c.update('none');
}
function initCharts() {
if (typeof Chart === 'undefined') return;
Chart.defaults.font.family = 'ui-monospace, "SF Mono", Menlo, Consolas, monospace';
Chart.defaults.font.size = 10;
Chart.defaults.color = '#8a857a';
charts.rps = makeChart('chart-rps', 'rgb(225,0,152)');
charts.cache = makeChart('chart-cache', 'rgb(27,122,67)');
}
// ---- connection indicators --------------------------------------
function setConn(kind, label) {
var pill = $('conn-pill');
pill.className = 'pill' + (kind === 'live' ? ' live' : kind === 'down' ? ' down' : '');
setText('conn-pill-text', label);
}
// ---- shared renderers (single + cluster share requests/cache) ----
function renderOverview(stats) {
var req = stats.requests || {};
setText('kpi-requests', num(req.total || 0), full(req.total || 0) + ' requests');
setText('kpi-requests-sub',
full(req.failed || 0) + ' failed · ' + full(req.skipped || 0) + ' skipped');
setText('t-succeeded', full(req.succeeded || 0));
var failEl = $('t-failed');
setText('t-failed', full(req.failed || 0));
if (failEl) failEl.className = 'v' + ((req.failed || 0) > 0 ? ' bad' : '');
setText('t-skipped', full(req.skipped || 0));
setText('t-avgrps', (req.avg_requests_per_second || 0).toFixed(2) + ' /s');
var sr = smooth(buf.success, req.success_rate_pct || 0);
setText('kpi-success', sr.toFixed(1) + '% ', '10s avg ' + sr.toFixed(2)
+ '% · now ' + (req.success_rate_pct || 0).toFixed(2) + '%');
$('kpi-success').insertAdjacentHTML('beforeend',
'<span class="trend">' + trend(buf.success) + '</span>');
$('kpi-success-bar').style.width = Math.min(100, sr) + '%';
var rps = smooth(buf.rps, req.current_requests_per_second || 0);
setText('kpi-rps', rps.toFixed(1) + ' ',
'10s avg ' + rps.toFixed(2) + ' · now ' + (req.current_requests_per_second || 0).toFixed(2));
$('kpi-rps').insertAdjacentHTML('beforeend', '<span class="trend">' + trend(buf.rps) + '</span>');
setText('kpi-rps-avg', (req.avg_requests_per_second || 0).toFixed(2));
pushChart('rps', rps);
var cs = stats.cache_summary || {};
var hr = smooth(buf.hit, cs.hit_rate_pct || 0);
setText('kpi-hitrate', hr.toFixed(1) + '% ',
'10s avg ' + hr.toFixed(2) + '% · now ' + (cs.hit_rate_pct || 0).toFixed(2) + '%');
$('kpi-hitrate').insertAdjacentHTML('beforeend', '<span class="trend">' + trend(buf.hit) + '</span>');
$('kpi-hitrate-bar').style.width = Math.min(100, hr) + '%';
setText('c-hits', full(cs.hits || 0));
setText('c-misses', full(cs.misses || 0));
setText('c-cached', full(cs.total_cached || 0));
pushChart('cache', hr);
}
function renderMemory(mb, pctUsed) {
if (mb === undefined || mb === null) return;
if (mb < 0) {
setText('c-memory', 'n/a', 'Redis cache — per-instance memory not tracked');
$('c-memory-bar').style.width = '0%';
return;
}
setText('c-memory', mb.toFixed(2) + ' MB');
if (pctUsed !== undefined && pctUsed !== null && pctUsed >= 0) {
$('c-memory-bar').style.width = Math.min(100, pctUsed) + '%';
}
}
function badgeEnabled(id, enabled) {
var el = $(id);
el.className = 'tag' + (enabled ? ' ok' : '');
el.textContent = enabled ? 'enabled' : 'disabled';
return enabled;
}
function renderCoalescing(c) {
if (!c) return;
var on = badgeEnabled('co-badge', c.enabled);
$('co-card').classList.toggle('disabled', !on);
var total = c.total_requests !== undefined ? c.total_requests
: (c.total_coalesced_requests || 0) + (c.total_primary_requests || 0);
var primary = c.primary_requests !== undefined ? c.primary_requests : (c.total_primary_requests || 0);
var coalesced = c.coalesced_requests !== undefined ? c.coalesced_requests : (c.total_coalesced_requests || 0);
setText('co-savings', pct(c.backend_savings_pct));
setText('co-total', full(total));
setText('co-primary', full(primary));
setText('co-coalesced', full(coalesced));
}
function renderRetry(r) {
if (!r) return;
var on = badgeEnabled('rb-badge', r.enabled);
$('rb-card').classList.toggle('disabled', !on);
var cur = r.current_tokens !== undefined ? r.current_tokens : '—';
var max = r.max_tokens !== undefined ? r.max_tokens : '—';
setText('rb-tokens', cur + ' / ' + max);
setText('rb-attempts', full(r.total_attempts || 0));
setText('rb-denied', full(r.denied_retries || 0));
setText('rb-denialrate', (r.denial_rate_pct || 0).toFixed(2) + '%');
}
function renderCircuit(cb, clusterCount) {
if (!cb) return;
$('cb-card').classList.toggle('disabled', !cb.enabled);
var badge = $('cb-badge');
if (clusterCount) {
// aggregated: show distribution across nodes
setText('cb-l1', 'Nodes'); setText('cb-l2', 'Closed'); setText('cb-l3', 'Open');
var open = cb.instances_open || 0, closed = cb.instances_closed || 0, half = cb.instances_halfopen || 0;
badge.className = 'tag' + (!cb.enabled ? '' : open > 0 ? ' bad' : half > 0 ? ' warn' : ' ok');
badge.textContent = !cb.enabled ? 'disabled' : (cb.state || 'unknown');
setText('cb-requests', full(open + closed + half));
setText('cb-success', full(closed));
setText('cb-failures', full(open) + (half ? ' (+' + half + ' half)' : ''));
setText('cb-cons-failures', '—');
setText('cb-maxfail', '—');
setText('cb-timeout', '—');
return;
}
setText('cb-l1', 'Requests'); setText('cb-l2', 'Successes'); setText('cb-l3', 'Failures');
var st = cb.state || 'unknown';
badge.className = 'tag' + (!cb.enabled ? '' : st === 'closed' ? ' ok' : st === 'open' ? ' bad' : st === 'half-open' ? ' warn' : '');
badge.textContent = !cb.enabled ? 'disabled' : st;
var counts = cb.counts || {};
setText('cb-requests', full(counts.requests || 0));
setText('cb-success', full(counts.total_successes || 0));
setText('cb-failures', full(counts.total_failures || 0));
setText('cb-cons-failures', full(counts.consecutive_failures || 0));
var cfg = cb.config || {};
setText('cb-maxfail', cfg.max_failures !== undefined ? full(cfg.max_failures) : '—');
setText('cb-timeout', cfg.timeout !== undefined ? cfg.timeout + 's' : '—');
}
function renderCacheConfig(cache) {
if (!cache) return;
var backend = $('cache-backend');
if (!cache.enabled) {
backend.className = 'tag'; backend.textContent = 'disabled';
$('cache-card').classList.add('disabled');
} else {
$('cache-card').classList.remove('disabled');
backend.className = 'tag accent';
backend.textContent = cache.redis_enabled ? 'redis' : 'in-memory';
}
if (cache.ttl_seconds !== undefined) setText('c-ttl', cache.ttl_seconds + 's');
renderMemory(cache.memory_usage_mb, cache.memory_usage_pct);
}
function renderBackendHealth(h) {
var badge = $('bh-badge');
var b = (h && h.backend) || {};
var healthy = !!b.healthy;
var unknown = !h || h.status === 'unknown';
badge.className = 'tag' + (unknown ? '' : healthy ? ' ok' : ' bad');
badge.textContent = unknown ? 'unknown' : healthy ? 'healthy' : 'unhealthy';
setText('bh-failures', b.consecutive_failures !== undefined ? full(b.consecutive_failures) : '—');
if (b.last_check) {
var t = new Date(b.last_check);
setText('bh-lastcheck', isNaN(t) ? '—' : t.toLocaleTimeString());
} else setText('bh-lastcheck', '—');
}
function renderStatusBar(opts) {
var sb = $('statusbar');
sb.className = 'statusbar ' + opts.kind;
$('sb-text').innerHTML = opts.html;
}
function renderConnections(conn, ws) {
var c = conn || {};
var poolVal = c.active_connections !== undefined ? c.active_connections
: (c.total_active !== undefined ? c.total_active : 0);
setText('conn-pool', full(poolVal));
var w = ws || {};
var wsVal = w.active_connections !== undefined ? w.active_connections
: (w.total_connections !== undefined ? w.total_connections : 0);
setText('conn-ws', full(wsVal));
}
// ---- top-level render --------------------------------------------
function render(data) {
if (!data) return;
setText('footer', 'Updated ' + new Date().toLocaleTimeString()
+ ' · ' + (state.mode === 'ws' ? 'streaming' : 'polling')
+ ' · ' + (data.cluster_mode ? 'cluster' : 'single') + ' view');
if (data.cluster_mode && data.stats) renderCluster(data);
else renderSingle(data);
}
function renderSingle(data) {
$('cluster-section').hidden = true;
setText('overview-label', 'Overview · this node');
var stats = data.stats || {};
if (stats.version) setText('brand-version', 'v' + stats.version);
setText('t-uptime', stats.uptime_human || dur(stats.uptime_seconds));
renderOverview(stats);
renderCacheConfig(data.cache);
renderCircuit(data.circuit_breaker, false);
renderRetry(data.retry_budget);
renderCoalescing(data.coalescing);
renderConnections(data.connections, data.websocket);
renderBackendHealth(data.health);
var h = data.health || {};
var b = h.backend || {};
if (h.status === 'healthy') {
renderStatusBar({ kind: 'ok',
html: '<strong>Operational</strong><span class="sep">·</span>backend healthy<span class="sep">·</span>'
+ full((stats.requests || {}).total || 0) + ' requests served' });
} else if (h.status === 'unhealthy') {
renderStatusBar({ kind: 'down',
html: '<strong>Degraded</strong><span class="sep">·</span>backend unhealthy ('
+ full(b.consecutive_failures || 0) + ' consecutive failures)' });
} else {
renderStatusBar({ kind: '',
html: '<strong>Status unknown</strong><span class="sep">·</span>no backend health data' });
}
}
function renderCluster(data) {
$('cluster-section').hidden = false;
setText('overview-label', 'Overview · cluster aggregate');
var stats = data.stats || {};
if (stats.version) setText('brand-version', 'v' + stats.version);
var total = data.total_instances || 0;
var healthy = data.healthy_instances || 0;
setText('t-uptime', dur(stats.cluster_uptime || 0));
renderOverview(stats);
// cache backend/config not aggregated; show memory only
var cacheBackend = $('cache-backend');
cacheBackend.className = 'tag'; cacheBackend.textContent = 'aggregate';
$('cache-card').classList.remove('disabled');
setText('c-ttl', '—');
if (stats.memory) renderMemory(stats.memory.total_usage_mb, -1);
renderCircuit(stats.circuit_breaker, true);
renderRetry(stats.retry_budget);
renderCoalescing(stats.coalescing);
renderConnections(stats.connections, stats.websocket);
// cluster summary cards
setText('cl-total', total);
setText('cl-healthy', healthy + ' / ' + total);
setText('cl-uptime', dur(stats.cluster_uptime || 0));
// derived cluster health (no per-cluster backend health key exists)
var badge = $('bh-badge');
badge.className = 'tag' + (total === 0 ? '' : healthy === total ? ' ok' : healthy > 0 ? ' warn' : ' bad');
badge.textContent = total === 0 ? 'unknown' : healthy + '/' + total + ' healthy';
setText('bh-failures', '—'); setText('bh-lastcheck', '—');
if (healthy === total && total > 0) {
renderStatusBar({ kind: 'ok',
html: '<strong>Cluster operational</strong><span class="sep">·</span>'
+ total + ' node' + (total === 1 ? '' : 's') + ' healthy<span class="sep">·</span>'
+ full((stats.requests || {}).total || 0) + ' requests served' });
} else if (healthy > 0) {
renderStatusBar({ kind: 'degraded',
html: '<strong>Cluster degraded</strong><span class="sep">·</span>'
+ healthy + ' of ' + total + ' nodes healthy' });
} else {
renderStatusBar({ kind: 'down',
html: '<strong>Cluster down</strong><span class="sep">·</span>no healthy nodes ('
+ total + ' reporting)' });
}
renderInstances(data.instances || [], data.current_instance);
}
function renderInstances(instances, current) {
var tb = $('instance-tbody');
tb.innerHTML = '';
if (!instances.length) {
tb.innerHTML = '<tr><td colspan="6" style="color:var(--ink-faint)">No instances reporting.</td></tr>';
return;
}
instances.forEach(function (inst) {
var st = inst.stats || {};
var req = st.requests || {};
var cache = inst.cache_summary || st.cache_summary || {};
var healthy = inst.health && inst.health.status === 'healthy';
var isSelf = current && inst.instance_id === current;
var tr = document.createElement('tr');
tr.innerHTML =
'<td><span class="host"><span class="hdot ' + (healthy ? 'ok' : 'bad') + '"></span>'
+ esc(inst.hostname || 'unknown')
+ (isSelf ? ' <span class="tag accent self">this</span>' : '') + '</span></td>'
+ '<td class="num">' + dur(inst.uptime_seconds || 0) + '</td>'
+ '<td class="num" title="' + full(req.total || 0) + '">' + num(req.total || 0) + '</td>'
+ '<td class="num">' + (req.success_rate_pct || 0).toFixed(1) + '%</td>'
+ '<td class="num">' + (req.current_requests_per_second || 0).toFixed(1) + '</td>'
+ '<td class="num">' + (cache.hit_rate_pct || 0).toFixed(1) + '%</td>';
tb.appendChild(tr);
});
}
function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
// ---- websocket ---------------------------------------------------
function wsUrl() {
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return proto + '//' + location.host + '/admin/ws/stats?view=' + state.view;
}
function connectWS() {
stopPolling();
try {
state.ws = new WebSocket(wsUrl());
} catch (e) { fallbackToPolling(); return; }
state.ws.onopen = function () {
state.mode = 'ws';
setConn('live', 'Live');
if (state.reconnectTimer) { clearInterval(state.reconnectTimer); state.reconnectTimer = null; }
};
state.ws.onmessage = function (ev) {
try { render(JSON.parse(ev.data)); } catch (e) { /* ignore malformed frame */ }
};
state.ws.onclose = function () {
if (state.mode === 'polling') return;
setConn('down', 'Reconnecting');
if (!state.reconnectTimer) {
state.reconnectTimer = setInterval(connectWS, 5000);
}
};
state.ws.onerror = function () { try { state.ws.close(); } catch (e) {} };
}
function reconnectWS() {
if (state.ws) { try { state.ws.onclose = null; state.ws.close(); } catch (e) {} }
if (state.reconnectTimer) { clearInterval(state.reconnectTimer); state.reconnectTimer = null; }
connectWS();
}
// ---- polling fallback (same shapes as the WS frames) -------------
function stopPolling() { if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; } }
function fallbackToPolling() {
if (state.mode === 'ws') return;
state.mode = 'polling';
setConn('', 'Polling');
poll();
state.pollTimer = setInterval(poll, 5000);
}
function getJSON(u) { return fetch(u).then(function (r) { return r.json(); }); }
function poll() {
if (state.view === 'cluster') {
Promise.all([getJSON('/admin/api/cluster/stats'), getJSON('/admin/api/cluster/instances')])
.then(function (res) {
var cs = res[0], inst = res[1];
if (!cs.cluster_mode) { render(localFallbackShape(res)); return; }
render({ cluster_mode: true, total_instances: cs.total_instances,
healthy_instances: cs.healthy_instances, stats: cs.stats || {},
instances: inst.instances || [], current_instance: inst.current_instance });
}).catch(function () {});
} else {
Promise.all([
getJSON('/admin/api/stats'), getJSON('/admin/api/health'),
getJSON('/admin/api/circuit-breaker'), getJSON('/admin/api/cache'),
getJSON('/admin/api/coalescing'), getJSON('/admin/api/retry-budget'),
getJSON('/admin/api/websocket'), getJSON('/admin/api/connections')
]).then(function (r) {
render({ cluster_mode: false,
stats: r[0], health: r[1], circuit_breaker: r[2], cache: r[3],
coalescing: r[4], retry_budget: r[5], websocket: r[6], connections: r[7] });
}).catch(function () {});
}
}
function localFallbackShape() { return { cluster_mode: false, stats: {} }; }
// ---- reset controls ----------------------------------------------
function postReset(url, btn) {
btn.disabled = true;
fetch(url, { method: 'POST' })
.then(function () { if (state.mode === 'polling') poll(); })
.catch(function () {})
.finally(function () { setTimeout(function () { btn.disabled = false; }, 800); });
}
// ---- cluster availability probe ----------------------------------
function probeCluster() {
var sw = $('view-switch'), hint = $('view-hint');
return getJSON('/admin/api/cluster/stats').then(function (d) {
if (d && d.cluster_mode) {
state.clusterAvailable = true;
state.view = 'cluster';
sw.checked = true; sw.disabled = false;
var n = d.total_instances || 0;
hint.textContent = n + ' node' + (n === 1 ? '' : 's');
$('view-switch-wrap').title = 'Toggle aggregated cluster view vs this node only';
return true;
}
// cluster mode not enabled — keep the toggle visible but disabled
state.clusterAvailable = false;
sw.checked = false; sw.disabled = true;
hint.textContent = 'single node';
return false;
}).catch(function () {
state.clusterAvailable = false;
sw.checked = false; sw.disabled = true;
hint.textContent = 'single node';
return false;
});
}
// ---- init --------------------------------------------------------
function init() {
initCharts();
$('rb-reset').addEventListener('click', function () {
postReset('/admin/api/retry-budget/reset', this);
});
$('co-reset').addEventListener('click', function () {
postReset('/admin/api/coalescing/reset', this);
});
$('view-switch').addEventListener('change', function (e) {
state.view = (e.target.checked && state.clusterAvailable) ? 'cluster' : 'local';
if (state.view === 'local') $('cluster-section').hidden = true;
if (state.mode === 'polling') { poll(); }
else { reconnectWS(); }
});
probeCluster().finally(function () {
connectWS();
// fall back to polling if the socket never opens
setTimeout(function () {
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) fallbackToPolling();
}, 3500);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else { init(); }
})();
</script>
</body>
</html>