From 0c926c2690d0ee3609893deba72dcce9053af227 Mon Sep 17 00:00:00 2001
From: RickZ <121943721+zhy1658858023@users.noreply.github.com>
Date: Mon, 20 Jul 2026 10:48:31 +0800
Subject: [PATCH 1/3] feat(proxy-ui): add ONOS-style topology and instance
dashboard
---
UI/proxy_ui/static/app.js | 255 +++++++++++++++++++++++++-----
UI/proxy_ui/static/style.css | 210 ++++++++++++++++++++----
UI/proxy_ui/test_proxy_ui_pure.js | 76 +++++++++
3 files changed, 471 insertions(+), 70 deletions(-)
create mode 100644 UI/proxy_ui/test_proxy_ui_pure.js
diff --git a/UI/proxy_ui/static/app.js b/UI/proxy_ui/static/app.js
index f16e0d4..2c259a3 100644
--- a/UI/proxy_ui/static/app.js
+++ b/UI/proxy_ui/static/app.js
@@ -5,7 +5,7 @@ const state = {
timer: null,
paused: false,
intervalMs: 3000,
- theme: localStorage.getItem('proxy-ui-theme') || 'dark',
+ theme: (typeof localStorage !== 'undefined' && localStorage.getItem('proxy-ui-theme')) || 'dark',
rawExpanded: false,
sortBy: 'state',
filters: {
@@ -443,51 +443,217 @@ function renderResourceTable(resources) {
}
-function renderSystemTopology(instances, scheduler) {
+
+function stateLabel(instance) {
+ if (instance?.is_alive === true) return 'alive';
+ if (instance?.is_alive === false) return 'stale';
+ return 'unknown';
+}
+
+function shortText(value, max = 18) {
+ const text = String(value ?? '—');
+ return text.length > max ? `${text.slice(0, Math.max(1, max - 1))}…` : text;
+}
+
+function finiteNumber(value) {
+ const number = Number(value);
+ return Number.isFinite(number) ? number : null;
+}
+
+function percentOf(value, max) {
+ const current = finiteNumber(value);
+ const total = finiteNumber(max);
+ if (current === null || total === null || total <= 0) return null;
+ return clampPercent((current / total) * 100);
+}
+
+function displayNumber(value, digits = 1) {
+ const number = finiteNumber(value);
+ if (number === null) return '—';
+ return Number.isInteger(number) ? String(number) : number.toFixed(digits);
+}
+
+function buildTopologyModel(instances, scheduler, proxyState = {}) {
const [schedulerLabel, schedulerTone] = schedulerText(scheduler);
- const proxyId = state.config?.proxy_id || state.latest.status?.proxy_id || 'current proxy';
- const instanceNodes = instances.map((instance) => `
-
No connected instances reported.
';
- $('systemTopology').innerHTML = `
- No connected instances reported.
'}`;
}
function renderInstanceDetail(instances) {
const panel = $('instanceDetailView');
- if (!state.selectedInstanceId) {
- panel.classList.add('hidden');
- return;
- }
+ if (!state.selectedInstanceId) { panel.classList.add('hidden'); return; }
const instance = instances.find((item) => String(item.instance_id) === String(state.selectedInstanceId));
if (!instance) {
panel.classList.remove('hidden');
- panel.innerHTML = `Selected instance is not present in the current payload.
`;
+ panel.innerHTML = `Selected instance ${escapeHtml(state.selectedInstanceId)} is not present in the current payload.
`;
return;
}
const resource = instance.resource || {};
- const hw = hardwareSummary(instance);
- const staleNote = instance.is_alive === false ? 'This Instance is stale; values below may reflect the last known report.
' : '';
+ const load = instance.load || {};
+ const memPercent = percentOf(resource.memory_used_mb, resource.memory_total_mb);
+ const gpuMemPercent = percentOf(resource.gpu_mem_used_mb, resource.gpu_mem_total_mb);
+ const networkMax = Math.max(finiteNumber(resource.network_rx_mbps) || 0, finiteNumber(resource.network_tx_mbps) || 0, 1);
+ const queues = extractQueueMetrics(instance);
+ const maxQueue = Math.max(1, ...queues.map((item) => item.value));
+ const hw = hardwareDetails(instance);
+ const staleNote = instance.is_alive === false ? 'This Instance is stale; values may reflect the last known report.
' : '';
panel.classList.remove('hidden');
- panel.innerHTML = `
-
-
Identity
${escapeHtml(instance.host)}:${escapeHtml(instance.port)}
${escapeHtml(stableJson({ endpoints: instance.endpoints, tags: instance.tags, weight: instance.weight, metadata: instance.meta }))}
-
Liveness
${instanceStateBadge(instance)} ${badge(`registered: ${formatTimestamp(instance.registered_at)}`, 'muted')} ${badge(`last seen age: ${formatAge(instance.last_seen_at)}`, instance.is_alive === false ? 'bad' : 'muted')}
-
Current Load
inflight ${fmt(instance.load?.inflight, 0)} · qps ${fmt(instance.load?.qps_1m, 2)} · heartbeat GPU ${fmt(instance.load?.gpu_util)}%
-
Resource Snapshot
CPU ${fmt(resource.cpu_util)}% · memory ${fmt(resource.memory_used_mb,0)}/${fmt(resource.memory_total_mb,0)} MB · GPU ${fmt(resource.gpu_util_avg)}% · GPU memory ${fmt(resource.gpu_mem_used_mb,0)}/${fmt(resource.gpu_mem_total_mb,0)} MB · RX ${fmt(resource.network_rx_mbps,2)} Mbps · TX ${fmt(resource.network_tx_mbps,2)} Mbps · admission ${escapeHtml(resource.admission_state || '—')} · ${resourceAgeTone(resource)[0]}
-
Hardware Summary
${escapeHtml([hw.cpu, hw.gpu, hw.nic].filter(Boolean).join(' · ') || 'device: unknown')}
-
Raw Instance JSON
${escapeHtml(stableJson(instance))}
+ panel.innerHTML = `
+
+ ${[['Status', stateLabel(instance)], ['Instance ID', instance.instance_id], ['Host:port', `${instance.host || '—'}:${instance.port || '—'}`], ['Admission', resource.admission_state || '—'], ['Registered', formatTimestamp(instance.registered_at)], ['Last seen age', formatAge(instance.last_seen_at)], ['Resource age', formatAge(resource.resource_reported_at)], ['Inflight', displayNumber(load.inflight, 0)], ['QPS 1m', displayNumber(load.qps_1m, 2)]].map(([k,v]) => `
${escapeHtml(k)}${escapeHtml(v)}`).join('')}
- `;
+
+
Resource utilization
${renderMetricBar({ label:'CPU utilization', value:resource.cpu_util, max:100, unit:'%', secondaryText:'reported resource.cpu_util' })}${renderMetricBar({ label:'System memory', value:resource.memory_used_mb, max:resource.memory_total_mb, unit:' MB', secondaryText:`${displayNumber(resource.memory_used_mb,0)} / ${displayNumber(resource.memory_total_mb,0)} MB (${memPercent === null ? '—' : memPercent.toFixed(1)+'%'})` })}${renderMetricBar({ label:'GPU utilization', value:resource.gpu_util_avg, max:100, unit:'%', secondaryText:'reported resource.gpu_util_avg' })}${renderMetricBar({ label:'GPU memory', value:resource.gpu_mem_used_mb, max:resource.gpu_mem_total_mb, unit:' MB', secondaryText:`${displayNumber(resource.gpu_mem_used_mb,0)} / ${displayNumber(resource.gpu_mem_total_mb,0)} MB (${gpuMemPercent === null ? '—' : gpuMemPercent.toFixed(1)+'%'})` })}${renderMetricBar({ label:'Network RX', value:resource.network_rx_mbps, max:networkMax, unit:' Mbps', secondaryText:'relative to current RX/TX max' })}${renderMetricBar({ label:'Network TX', value:resource.network_tx_mbps, max:networkMax, unit:' Mbps', secondaryText:'relative to current RX/TX max' })}
+
Resource composition
${renderDonutChart({ label:'System memory', used:resource.memory_used_mb, total:resource.memory_total_mb, unit:' MB' })}${renderDonutChart({ label:'GPU memory', used:resource.gpu_mem_used_mb, total:resource.gpu_mem_total_mb, unit:' MB' })}
+
Queue and load
Known load fields: inflight ${escapeHtml(displayNumber(load.inflight,0))}, qps_1m ${escapeHtml(displayNumber(load.qps_1m,2))}, gpu_util ${escapeHtml(displayNumber(load.gpu_util,1))}%.
${queues.length ? queues.map((q) => renderMetricBar({ label:q.label, value:q.value, max:maxQueue, unit:'', secondaryText:q.key })).join('') : 'No detailed queue counters are exposed by the current Instance payload.
'}
+
Hardware
CPU
${escapeHtml(firstString(hw.cpu.model, hw.cpu.model_name, hw.cpu.name, instance.meta?.cpu_model) || 'No CPU details')}
${hw.cpu.cores || hw.cpu.logical_cpus ? `${escapeHtml(`cores ${hw.cpu.cores || '—'} · logical ${hw.cpu.logical_cpus || '—'}`)}` : ''}GPU
${hw.gpus.length ? hw.gpus.map((gpu, i) => `${escapeHtml(`#${i} ${firstString(gpu.model, gpu.name, gpu.product_name) || 'GPU'}`)} · mem ${escapeHtml(displayNumber(gpu.memory_used_mb,0))}/${escapeHtml(displayNumber(gpu.memory_total_mb,0))} MB · util ${escapeHtml(displayNumber(gpu.utilization_gpu_pct ?? gpu.gpu_util,1))}%
`).join('') : 'No GPU details
'}NIC
${hw.nics.length ? hw.nics.map((nic) => `${escapeHtml(firstString(nic.interface, nic.name, nic.model) || 'NIC')}${nic.driver ? ` · ${escapeHtml(nic.driver)}` : ''}${nic.speed ? ` · ${escapeHtml(nic.speed)}` : ''}
`).join('') : 'No NIC details
'}
+
Raw Instance JSON
${escapeHtml(stableJson(instance))}
+
`;
}
function renderTopology(payload) {
@@ -664,7 +830,7 @@ function restartTimer() {
function setTheme(theme) {
state.theme = theme;
document.documentElement.dataset.theme = theme;
- localStorage.setItem('proxy-ui-theme', theme);
+ if (typeof localStorage !== 'undefined') localStorage.setItem('proxy-ui-theme', theme);
$('themeBtn').textContent = theme === 'dark' ? 'Light theme' : 'Dark theme';
}
@@ -725,7 +891,7 @@ function setupEventHandlers() {
tab.addEventListener('click', () => activateTab(tab.dataset.tab));
});
document.addEventListener('click', (event) => {
- const card = event.target.closest('.instance-card[data-instance-id]');
+ const card = event.target.closest('.instance-card[data-instance-id], .topology-svg-node[data-instance-id]');
if (card) {
window.location.hash = `#/instances/${encodeURIComponent(card.dataset.instanceId)}`;
return;
@@ -734,6 +900,13 @@ function setupEventHandlers() {
window.location.hash = '#/';
return;
}
+ const instanceCopy = event.target.closest('[data-instance-copy]');
+ if (instanceCopy) {
+ const instances = Array.isArray(state.latest.instances) ? state.latest.instances : [];
+ const selected = instances.find((item) => String(item.instance_id) === String(instanceCopy.dataset.instanceCopy));
+ copyText(stableJson(selected || {}));
+ return;
+ }
const copyButton = event.target.closest('[data-copy], [data-copy-key]');
if (!copyButton) {
return;
@@ -752,7 +925,7 @@ function setupEventHandlers() {
copyText(stableJson(payloads[key] || state.latest));
});
document.addEventListener('keydown', (event) => {
- const card = event.target.closest?.('.instance-card[data-instance-id]');
+ const card = event.target.closest?.('.instance-card[data-instance-id], .topology-svg-node[data-instance-id]');
if (card && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
window.location.hash = `#/instances/${encodeURIComponent(card.dataset.instanceId)}`;
@@ -785,6 +958,10 @@ async function copyText(text) {
}
}
-setTheme(state.theme);
-setupEventHandlers();
-refresh().then(restartTimer);
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = { buildTopologyModel, computeTopologyLayout, renderMetricBar, renderDonutChart, extractQueueMetrics, clampPercent, percentOf, fmt, stateLabel };
+} else {
+ setTheme(state.theme);
+ setupEventHandlers();
+ refresh().then(restartTimer);
+}
diff --git a/UI/proxy_ui/static/style.css b/UI/proxy_ui/static/style.css
index 546b6c6..c605be0 100644
--- a/UI/proxy_ui/static/style.css
+++ b/UI/proxy_ui/static/style.css
@@ -605,65 +605,213 @@ summary {
font-weight: 800;
}
+
+:root {
+ --topology-scheduler: var(--accent-2);
+ --topology-proxy: var(--accent);
+ --topology-instance: var(--ok);
+ --topology-link: color-mix(in srgb, var(--accent) 58%, var(--line));
+ --chart-free: color-mix(in srgb, var(--muted) 30%, transparent);
+}
+
.topology-diagram {
+ min-height: 360px;
+}
+
+.topology-scroll {
+ width: 100%;
+ overflow: auto;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: radial-gradient(circle at 50% 10%, color-mix(in srgb, var(--accent) 10%, transparent), transparent 24rem), var(--surface-2);
+}
+
+.topology-svg {
+ display: block;
+ width: 100%;
+ min-width: 720px;
+ height: auto;
+}
+
+.topology-link {
+ fill: none;
+ stroke: var(--topology-link);
+ stroke-width: 3;
+ stroke-linecap: round;
+ stroke-dasharray: 10 8;
+ animation: topology-flow 2.8s linear infinite;
+ opacity: 0.86;
+}
+
+.topology-link.inactive,
+.topology-link.unknown {
+ stroke: var(--muted);
+ stroke-dasharray: 4 8;
+ animation: none;
+ opacity: 0.48;
+}
+
+.topology-link.stale {
+ stroke: var(--bad);
+ animation: none;
+ opacity: 0.62;
+}
+
+.topology-diagram.paused .topology-link {
+ animation-play-state: paused;
+}
+
+@keyframes topology-flow {
+ to { stroke-dashoffset: -36; }
+}
+
+.topology-svg-node {
+ cursor: default;
+}
+
+.topology-svg-node[role='button'] {
+ cursor: pointer;
+}
+
+.topology-svg-node[role='button']:focus-visible .node-halo,
+.topology-svg-node[role='button']:hover .node-halo {
+ stroke-width: 4;
+ filter: drop-shadow(0 0 10px color-mix(in srgb, var(--accent) 70%, transparent));
+}
+
+.topology-svg-node .node-halo {
+ fill: var(--surface);
+ stroke: var(--line);
+ stroke-width: 2;
+}
+
+.topology-svg-node.scheduler .node-halo { stroke: var(--topology-scheduler); }
+.topology-svg-node.proxy .node-halo { stroke: var(--topology-proxy); }
+.topology-svg-node.instance.ok .node-halo { stroke: var(--topology-instance); }
+.topology-svg-node.instance.bad .node-halo { stroke: var(--bad); }
+.topology-svg-node.instance.warn .node-halo { stroke: var(--warn); }
+
+.topology-svg-node use {
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 3;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.topology-svg-node.scheduler { color: var(--topology-scheduler); }
+.topology-svg-node.proxy { color: var(--topology-proxy); }
+.topology-svg-node.instance.ok { color: var(--topology-instance); }
+.topology-svg-node.instance.bad { color: var(--bad); }
+.topology-svg-node.instance.warn { color: var(--warn); }
+
+.node-label,
+.node-status {
+ fill: var(--text);
+ font-weight: 800;
+ font-size: 13px;
+ text-anchor: middle;
+}
+
+.node-status {
+ fill: var(--muted);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.kpi-grid {
display: grid;
- justify-items: center;
- gap: 8px;
- min-height: 260px;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: 12px;
+ margin-bottom: 14px;
}
-.topology-node {
- width: min(100%, 320px);
+.kpi-card,
+.donut-card,
+.hardware-grid article {
padding: 12px;
- text-align: center;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 14px;
}
-.topology-node strong,
-.topology-node span {
+.kpi-card span,
+.viz-metric small,
+.donut-card p,
+.hardware-grid small {
+ color: var(--muted);
+}
+
+.kpi-card strong {
display: block;
+ margin-top: 6px;
+ overflow-wrap: anywhere;
}
-.topology-node span {
- color: var(--muted);
- font-size: 12px;
+.viz-metric {
+ margin-top: 12px;
}
-.topology-node.proxy {
- border-color: color-mix(in srgb, var(--accent) 65%, var(--line));
+.viz-metric.bad .progress span { background: linear-gradient(90deg, var(--bad), var(--warn)); }
+
+.donut-grid,
+.hardware-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 12px;
}
-.topology-node.alive,
-.topology-node.ok {
- border-color: color-mix(in srgb, var(--ok) 65%, var(--line));
+.donut {
+ width: 150px;
+ max-width: 100%;
+ margin: 10px auto;
+ display: block;
}
-.topology-node.stale,
-.topology-node.bad {
- border-color: color-mix(in srgb, var(--bad) 65%, var(--line));
+.donut-free,
+.donut-used {
+ fill: none;
+ stroke-width: 5;
}
-.topology-node.warn,
-.topology-node.unknown {
- border-color: color-mix(in srgb, var(--warn) 65%, var(--line));
+.donut-free { stroke: var(--chart-free); }
+.donut-used {
+ stroke: var(--accent);
+ stroke-linecap: round;
+ transform: rotate(-90deg);
+ transform-origin: 50% 50%;
}
-.topology-edge {
- color: var(--muted);
+.donut text {
+ fill: var(--text);
+ font-size: 8px;
font-weight: 900;
+ text-anchor: middle;
}
-.topology-instances {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
- gap: 10px;
- width: 100%;
+.dot {
+ display: inline-block;
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
}
-.topology-instances .topology-node {
- width: 100%;
+.dot.used { background: var(--accent); }
+.dot.free { background: var(--chart-free); }
+
+.empty-state.compact {
+ padding: 14px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .topology-link,
+ .progress span,
+ button,
+ .button-link,
+ .instance-card {
+ animation: none !important;
+ transition: none !important;
+ }
}
@media (max-width: 1200px) {
diff --git a/UI/proxy_ui/test_proxy_ui_pure.js b/UI/proxy_ui/test_proxy_ui_pure.js
new file mode 100644
index 0000000..7339502
--- /dev/null
+++ b/UI/proxy_ui/test_proxy_ui_pure.js
@@ -0,0 +1,76 @@
+const assert = require('assert');
+const ui = require('./static/app.js');
+
+function inst(id, alive, extra = {}) {
+ return {
+ instance_id: id,
+ host: '127.0.0.1',
+ port: 9000,
+ is_alive: alive,
+ last_seen_at: Date.now() / 1000,
+ load: { inflight: 2, qps_1m: 1.5, gpu_util: 30, ...(extra.load || {}) },
+ resource: { ...(extra.resource || {}) },
+ meta: { ...(extra.meta || {}) },
+ };
+}
+
+function testTopologyScenarios() {
+ let model = ui.buildTopologyModel([], { error: 'proxy_id_not_configured' }, { proxy_id: 'proxy-a' });
+ assert.strictEqual(model.instances.length, 0);
+ assert.strictEqual(model.links[0].status, 'inactive');
+
+ model = ui.buildTopologyModel([inst('one', true)], { ok: true }, { proxy_id: 'proxy-a' });
+ assert.strictEqual(model.links[0].status, 'active');
+ assert.strictEqual(model.links[1].status, 'active');
+ assert.strictEqual(ui.stateLabel(model.instances[0]), 'alive');
+
+ model = ui.buildTopologyModel([inst('alive', true), inst('stale', false), inst('unknown', undefined)], { ok: true }, {});
+ assert.deepStrictEqual(model.instances.map((i) => i.instance_id), ['alive', 'stale', 'unknown']);
+ assert.deepStrictEqual(model.links.slice(1).map((l) => l.status), ['active', 'stale', 'unknown']);
+
+ const many = Array.from({ length: 24 }, (_, i) => inst(`node-${String(i).padStart(2, '0')}`, i % 2 === 0));
+ const layoutA = ui.computeTopologyLayout(ui.buildTopologyModel(many, { ok: true }, {}));
+ const layoutB = ui.computeTopologyLayout(ui.buildTopologyModel([...many].reverse(), { ok: true }, {}));
+ assert.deepStrictEqual(layoutA.positions['instance:node-03'], layoutB.positions['instance:node-03']);
+ assert(layoutA.height >= 360);
+}
+
+function testMetricsAndMissingData() {
+ assert.strictEqual(ui.clampPercent(-5), 0);
+ assert.strictEqual(ui.clampPercent(105), 100);
+ assert.strictEqual(ui.clampPercent('bad'), null);
+ assert.strictEqual(ui.percentOf(5, 0), null);
+ assert.strictEqual(ui.percentOf(150, 100), 100);
+ assert(!ui.renderMetricBar({ label: 'Memory', value: 1, max: 0, unit: ' MB' }).includes('NaN'));
+ assert(ui.renderMetricBar({ label: 'GPU', value: undefined }).includes('No data'));
+ assert(ui.renderDonutChart({ label: 'GPU memory', used: undefined, total: undefined }).includes('No data'));
+ assert(ui.renderDonutChart({ label: 'Memory', used: 120, total: 100, unit: ' MB' }).includes('120'));
+}
+
+function testQueueExtraction() {
+ assert.deepStrictEqual(ui.extractQueueMetrics(inst('none', true)).map((q) => q.key), ['load.inflight']);
+ const nested = inst('nested', true, {
+ meta: { queues: { ready: 3, waiting: 2, port: 7000, decode_time_ms: 55, gpu_util: 90 } },
+ resource: { cpu_util: 70, memory_total_mb: 0 },
+ });
+ const keys = ui.extractQueueMetrics(nested).map((q) => q.key);
+ assert(keys.includes('meta.queues.ready'));
+ assert(keys.includes('meta.queues.waiting'));
+ assert(!keys.includes('meta.queues.port'));
+ assert(!keys.includes('meta.queues.decode_time_ms'));
+ assert(!keys.includes('resource.cpu_util'));
+}
+
+function testThemeAndReducedMotionStaticHooks() {
+ const fs = require('fs');
+ const css = fs.readFileSync(require('path').join(__dirname, 'static/style.css'), 'utf8');
+ assert(css.includes("[data-theme='light']"));
+ assert(css.includes('@media (prefers-reduced-motion: reduce)'));
+ assert(css.includes('--topology-scheduler'));
+}
+
+testTopologyScenarios();
+testMetricsAndMissingData();
+testQueueExtraction();
+testThemeAndReducedMotionStaticHooks();
+console.log('proxy-ui pure function tests passed');
From 39c16bb7297c7941515e5783f18289753aa7e584 Mon Sep 17 00:00:00 2001
From: RickZ <121943721+zhy1658858023@users.noreply.github.com>
Date: Mon, 20 Jul 2026 11:03:16 +0800
Subject: [PATCH 2/3] fix(proxy-ui): align topology dashboard with load
semantics
---
UI/proxy_ui/proxy_ui_server.py | 5 ++
UI/proxy_ui/static/app.js | 137 +++++++++++++++++++++++-------
UI/proxy_ui/static/style.css | 12 +++
UI/proxy_ui/test_proxy_ui_pure.js | 83 ++++++++++++++++--
4 files changed, 197 insertions(+), 40 deletions(-)
diff --git a/UI/proxy_ui/proxy_ui_server.py b/UI/proxy_ui/proxy_ui_server.py
index 00afb1f..d2daa20 100644
--- a/UI/proxy_ui/proxy_ui_server.py
+++ b/UI/proxy_ui/proxy_ui_server.py
@@ -75,6 +75,11 @@ async def proxy_topology() -> Any:
return await _get_json(DEFAULT_PROXY_CP_URL, "/v1/topology/kdn_links")
+@app.get("/api/proxy/loads")
+async def proxy_loads() -> Any:
+ return await _get_json(DEFAULT_PROXY_CP_URL, "/debug/instance_loads", {"include_dead": "true"})
+
+
@app.get("/api/scheduler/proxy")
async def scheduler_proxy() -> Dict[str, Any]:
if not DEFAULT_PROXY_ID:
diff --git a/UI/proxy_ui/static/app.js b/UI/proxy_ui/static/app.js
index 2c259a3..fca607a 100644
--- a/UI/proxy_ui/static/app.js
+++ b/UI/proxy_ui/static/app.js
@@ -30,19 +30,19 @@ function $(id) {
}
function clampPercent(value) {
- const number = Number(value);
- if (!Number.isFinite(number)) {
+ const number = finiteNumber(value);
+ if (number === null) {
return null;
}
return Math.max(0, Math.min(100, number));
}
function fmt(value, digits = 1) {
- if (value === null || value === undefined || value === '') {
+ const number = finiteNumber(value);
+ if (number === null) {
return '—';
}
- const number = Number(value);
- return Number.isFinite(number) ? number.toFixed(digits) : String(value);
+ return number.toFixed(digits);
}
function formatTimestamp(value) {
@@ -456,6 +456,12 @@ function shortText(value, max = 18) {
}
function finiteNumber(value) {
+ if (value === null || value === undefined || typeof value === 'boolean') {
+ return null;
+ }
+ if (typeof value === 'string' && value.trim() === '') {
+ return null;
+ }
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
@@ -476,11 +482,12 @@ function displayNumber(value, digits = 1) {
function buildTopologyModel(instances, scheduler, proxyState = {}) {
const [schedulerLabel, schedulerTone] = schedulerText(scheduler);
const proxyId = proxyState.proxy_id || state.config?.proxy_id || state.latest.status?.proxy_id || 'current proxy';
+ const proxyTone = proxyState?.ok === false ? 'bad' : (proxyState ? 'ok' : 'warn');
const sortedInstances = [...(Array.isArray(instances) ? instances : [])]
.sort((a, b) => String(a.instance_id || '').localeCompare(String(b.instance_id || '')));
const nodes = [
{ id: 'scheduler', type: 'scheduler', label: 'Scheduler', status: schedulerLabel, tone: schedulerTone, raw: scheduler || {} },
- { id: 'proxy', type: 'proxy', label: 'Proxy', status: String(proxyId), tone: 'ok', raw: proxyState || {} },
+ { id: 'proxy', type: 'proxy', label: 'Proxy', status: proxyState?.ok === false ? 'error' : String(proxyId), tone: proxyTone, raw: proxyState || {} },
...sortedInstances.map((instance) => ({
id: `instance:${instance.instance_id || 'unknown'}`,
type: 'instance',
@@ -506,26 +513,33 @@ function buildTopologyModel(instances, scheduler, proxyState = {}) {
function computeTopologyLayout(model, options = {}) {
const count = model.instances.length;
const width = Math.max(760, options.width || 980);
- const rows = count > 10 ? Math.ceil(count / 5) : count > 0 ? 1 : 0;
- const height = Math.max(360, 270 + rows * 130);
- const positions = { scheduler: { x: width / 2, y: 58 }, proxy: { x: width / 2, y: 168 } };
- if (count > 0 && count <= 8) {
- const start = -140;
- const step = count === 1 ? 0 : 280 / (count - 1);
- model.instances.forEach((instance, index) => {
- const angle = (start + step * index) * Math.PI / 180;
- positions[`instance:${instance.instance_id || 'unknown'}`] = { x: width / 2 + Math.cos(angle) * 320, y: 210 + Math.sin(angle) * 150 + 150 };
- });
- } else {
- const cols = count > 20 ? 6 : count > 10 ? 5 : Math.max(1, count);
- model.instances.forEach((instance, index) => {
- const row = Math.floor(index / cols);
- const col = index % cols;
- const gapX = width / (cols + 1);
- positions[`instance:${instance.instance_id || 'unknown'}`] = { x: gapX * (col + 1), y: 300 + row * 120 };
+ const nodePadding = Number(options.nodePadding ?? 54);
+ const labelPadding = Number(options.labelPadding ?? 70);
+ const positions = { scheduler: { x: width / 2, y: 64 }, proxy: { x: width / 2, y: 178 } };
+ const cols = count === 0 ? 0 : (count <= 4 ? count : count <= 8 ? 4 : count <= 20 ? 5 : 6);
+ const rowGap = count <= 8 ? 130 : 118;
+ const startY = 314;
+ model.instances.forEach((instance, index) => {
+ const row = Math.floor(index / cols);
+ const col = index % cols;
+ const rowCount = Math.min(cols, count - row * cols);
+ const gapX = (width - nodePadding * 2) / Math.max(1, rowCount + 1);
+ positions[`instance:${instance.instance_id || 'unknown'}`] = {
+ x: nodePadding + gapX * (col + 1),
+ y: startY + row * rowGap,
+ };
+ });
+ const xs = Object.values(positions).map((pos) => pos.x);
+ const ys = Object.values(positions).map((pos) => pos.y);
+ const minX = Math.min(...xs) - nodePadding;
+ const maxX = Math.max(...xs) + nodePadding;
+ if (minX < 0 || maxX > width) {
+ Object.values(positions).forEach((pos) => {
+ pos.x = Math.max(nodePadding, Math.min(width - nodePadding, pos.x));
});
}
- return { width, height, positions };
+ const height = Math.max(360, Math.max(...ys) + labelPadding);
+ return { width, height, positions, nodePadding, labelPadding };
}
function nodeTooltip(node) {
@@ -589,6 +603,62 @@ function hardwareDetails(instance) {
return { cpu, gpus, nics };
}
+function normalizeLoadSnapshots(loadsPayload) {
+ return Array.isArray(loadsPayload?.instances) ? loadsPayload.instances : [];
+}
+
+function loadSnapshotByInstanceId(loadsPayload, instanceId) {
+ return normalizeLoadSnapshots(loadsPayload).find((item) => String(item.instance_id) === String(instanceId)) || null;
+}
+
+function mergedInstanceLoad(instance, loadsPayload = state.latest.loads) {
+ const snapshot = loadSnapshotByInstanceId(loadsPayload, instance?.instance_id) || {};
+ const baseLoad = instance?.load || {};
+ return { ...baseLoad, ...snapshot, heartbeat_load: baseLoad, debug_load: snapshot };
+}
+
+function nestedNumber(payload, path) {
+ return path.split('.').reduce((acc, key) => (acc && typeof acc === 'object' ? acc[key] : undefined), payload);
+}
+
+function explicitQueueMetrics(load) {
+ return [
+ ['inflight', 'Inflight'],
+ ['qps_1m', 'QPS 1m'],
+ ['prepare_queue_depth', 'Prepare queue depth'],
+ ['ready_queue_depth', 'Ready queue depth'],
+ ['active_prepare', 'Active prepare'],
+ ['active_ready', 'Active ready'],
+ ['least_load_score.total', 'Least-load score'],
+ ].map(([key, label]) => ({ key, label, value: finiteNumber(nestedNumber(load, key)) }))
+ .filter((item) => item.value !== null);
+}
+
+function queueMetricsForInstance(instance, loadsPayload = state.latest.loads) {
+ const load = mergedInstanceLoad(instance, loadsPayload);
+ const explicit = explicitQueueMetrics(load);
+ return explicit.length ? explicit : extractQueueMetrics(instance);
+}
+
+function applyPausedTopologyState() {
+ const container = typeof document !== 'undefined' ? $('systemTopology') : null;
+ if (container) {
+ container.classList.toggle('paused', state.paused);
+ }
+}
+
+function gpuName(gpu) {
+ return firstString(gpu.name, gpu.model, gpu.product_name) || 'GPU';
+}
+
+function gpuUtil(gpu) {
+ return finiteNumber(gpu.utilization_pct ?? gpu.utilization_gpu_pct ?? gpu.gpu_util ?? gpu.util);
+}
+
+function nicName(nic) {
+ return firstString(nic.iface, nic.interface, nic.name, nic.model) || 'NIC';
+}
+
function renderSystemTopology(instances, scheduler) {
const model = buildTopologyModel(instances, scheduler, state.latest.status || {});
const layout = computeTopologyLayout(model);
@@ -614,7 +684,8 @@ function renderSystemTopology(instances, scheduler) {
`;
}).join('');
$('systemTopology').classList.toggle('paused', state.paused);
- $('systemTopology').innerHTML = `