mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-06-22 04:11:29 +00:00
01d1de1f0b
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.
1076 lines
49 KiB
HTML
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 & 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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|