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 f16e0d4..bcb5449 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: { @@ -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) { @@ -136,7 +136,15 @@ async function settleJson(path, options = {}) { function hasResourceReport(instance) { const resource = instance.resource || {}; - return Object.values(resource).some((value) => value !== null && value !== undefined); + if (finiteNumber(resource.resource_reported_at) !== null) { + return true; + } + const scalarKeys = [ + 'cpu_util', 'memory_used_mb', 'memory_total_mb', 'memory_free_mb', + 'gpu_util_avg', 'gpu_mem_used_mb', 'gpu_mem_total_mb', + 'network_rx_mbps', 'network_tx_mbps', + ]; + return scalarKeys.some((key) => finiteNumber(resource[key]) !== null); } @@ -294,15 +302,15 @@ function average(values) { } function computeMetrics(instances) { - const cpu = average(instances.map((item) => Number(item.resource?.cpu_util))); + const cpu = average(instances.map((item) => finiteNumber(item.resource?.cpu_util))); const memory = average(instances.map((item) => { - const used = Number(item.resource?.memory_used_mb); - const total = Number(item.resource?.memory_total_mb); - return Number.isFinite(used) && Number.isFinite(total) && total > 0 ? (used / total) * 100 : NaN; + const used = finiteNumber(item.resource?.memory_used_mb); + const total = finiteNumber(item.resource?.memory_total_mb); + return used !== null && total !== null && total > 0 ? (used / total) * 100 : null; })); - const gpu = average(instances.map((item) => Number(item.resource?.gpu_util_avg))); - const rx = average(instances.map((item) => Number(item.resource?.network_rx_mbps))); - const tx = average(instances.map((item) => Number(item.resource?.network_tx_mbps))); + const gpu = average(instances.map((item) => finiteNumber(item.resource?.gpu_util_avg))); + const rx = average(instances.map((item) => finiteNumber(item.resource?.network_rx_mbps))); + const tx = average(instances.map((item) => finiteNumber(item.resource?.network_tx_mbps))); return { cpu, memory, gpu, rx, tx }; } @@ -443,51 +451,307 @@ 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) { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return null; + const numberPattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i; + if (!numberPattern.test(trimmed)) return null; + const number = Number(trimmed); + return Number.isFinite(number) ? number : null; + } + return 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) => ` -
- ${escapeHtml(instance.instance_id || 'unknown')} - ${instance.is_alive === false ? 'stale' : instance.is_alive === true ? 'alive' : 'unknown'} -
- `).join('') || '
No connected instances reported.
'; - $('systemTopology').innerHTML = ` -
Scheduler${escapeHtml(schedulerLabel)}
-
-
Proxy (observed/local)${escapeHtml(proxyId)}
-
-
${instanceNodes}
- `; + 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: proxyState?.ok === false ? 'error' : String(proxyId), tone: proxyTone, raw: proxyState || {} }, + ...sortedInstances.map((instance) => ({ + id: `instance:${instance.instance_id || 'unknown'}`, + type: 'instance', + instanceId: String(instance.instance_id || 'unknown'), + label: String(instance.instance_id || 'unknown'), + status: stateLabel(instance), + tone: stateLabel(instance) === 'alive' ? 'ok' : stateLabel(instance) === 'stale' ? 'bad' : 'warn', + raw: instance, + })), + ]; + const links = [ + { id: 'scheduler-proxy', source: 'scheduler', target: 'proxy', status: scheduler?.ok ? 'active' : 'inactive' }, + ...sortedInstances.map((instance) => ({ + id: `proxy-${instance.instance_id || 'unknown'}`, + source: 'proxy', + target: `instance:${instance.instance_id || 'unknown'}`, + status: stateLabel(instance) === 'alive' ? 'active' : stateLabel(instance) === 'stale' ? 'stale' : 'unknown', + })), + ]; + return { nodes, links, schedulerLabel, schedulerTone, proxyId, instances: sortedInstances }; +} + +function computeTopologyLayout(model, options = {}) { + const count = model.instances.length; + const width = Math.max(760, options.width || 980); + 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)); + }); + } + const height = Math.max(360, Math.max(...ys) + labelPadding); + return { width, height, positions, nodePadding, labelPadding }; +} + +function nodeTooltip(node) { + const raw = node.raw || {}; + const resource = raw.resource || {}; + const address = raw.host ? `${raw.host}:${raw.port ?? '—'}` : (node.type === 'proxy' ? String(node.status) : '—'); + const cpu = resource.cpu_util !== null && resource.cpu_util !== undefined ? `CPU ${fmt(resource.cpu_util)}%` : null; + const gpu = resource.gpu_util_avg !== null && resource.gpu_util_avg !== undefined ? `GPU ${fmt(resource.gpu_util_avg)}%` : null; + return [`Type: ${node.type}`, `ID: ${node.instanceId || node.label}`, `Address: ${address}`, `State: ${node.status}`, `Last seen: ${formatTimestamp(raw.last_seen_at)}`, cpu, gpu].filter(Boolean).join('\n'); +} + +function renderMetricBar({ label, value, max = 100, unit = '', tone = 'accent', secondaryText = '' }) { + const current = finiteNumber(value); + const total = finiteNumber(max); + const percent = current === null ? null : (total && total > 0 ? clampPercent((current / total) * 100) : null); + const width = percent === null ? 0 : percent; + const primary = current === null ? 'No data' : `${displayNumber(current, 2)}${unit}`; + return `
${escapeHtml(label)}${escapeHtml(primary)}
${escapeHtml(secondaryText || (percent === null ? '—' : `${percent.toFixed(1)}% of ${displayNumber(total, 2)}${unit}`))}
`; +} + +function renderDonutChart({ label, used, total, unit = '' }) { + const rawUsed = finiteNumber(used); + const rawTotal = finiteNumber(total); + if (rawUsed === null || rawTotal === null || rawTotal <= 0) { + return `

${escapeHtml(label)}

No data
`; + } + const safeUsed = Math.max(0, Math.min(rawUsed, rawTotal)); + const percent = (safeUsed / rawTotal) * 100; + const free = Math.max(0, rawTotal - safeUsed); + const dash = `${percent.toFixed(2)} ${Math.max(0, 100 - percent).toFixed(2)}`; + return `

${escapeHtml(label)}

${escapeHtml(label)}: ${displayNumber(rawUsed, 1)} / ${displayNumber(rawTotal, 1)} ${unit}${percent.toFixed(0)}%

used ${escapeHtml(displayNumber(rawUsed, 1))}${escapeHtml(unit)} · free ${escapeHtml(displayNumber(free, 1))}${escapeHtml(unit)}

`; +} + +function extractQueueMetrics(instance) { + const paths = [['load', instance?.load], ['resource', instance?.resource], ['meta', instance?.meta], ['queue', instance?.queue], ['queues', instance?.queues], ['stats', instance?.stats]]; + const keywords = /ready|waiting|queued|pending|running|inflight|prepare|prefill|decode|kv|kvcache|transfer|inject/i; + const reject = /time|timestamp|ts|port|util|ratio|percent|age|memory|mem|gpu_util|cpu/i; + const seen = new Set(); + const found = []; + function visit(prefix, value, depth = 0) { + if (!value || typeof value !== 'object' || depth > 3) return; + Object.entries(value).forEach(([key, child]) => { + const path = prefix ? `${prefix}.${key}` : key; + if (child && typeof child === 'object' && !Array.isArray(child)) visit(path, child, depth + 1); + const number = finiteNumber(child); + if (number === null || number < 0 || !keywords.test(key) || reject.test(key)) return; + if (seen.has(path)) return; + seen.add(path); + found.push({ key: path, label: key.replaceAll('_', ' '), value: number }); + }); + } + paths.forEach(([prefix, value]) => visit(prefix, value)); + return found.sort((a, b) => a.key.localeCompare(b.key)); +} + +function hardwareDetails(instance) { + const devices = instance.resource?.raw_resource?.devices || {}; + const cpu = devices.cpu && typeof devices.cpu === 'object' ? devices.cpu : {}; + const gpus = Array.isArray(devices.gpu) ? devices.gpu : []; + const nics = Array.isArray(devices.network) ? devices.network : (Array.isArray(devices.nic) ? devices.nic : []); + 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 explicitLoadMetrics(load) { + return [ + ['inflight', 'Inflight'], + ['qps_1m', 'QPS 1m'], + ['gpu_util', 'Heartbeat GPU util'], + ['least_load_score.total', 'Least-load score'], + ].map(([key, label]) => ({ key, label, value: finiteNumber(nestedNumber(load, key)) })) + .filter((item) => item.value !== null); +} + +function explicitDetailedQueueMetrics(load) { + return [ + ['prepare_queue_depth', 'Prepare queue depth'], + ['ready_queue_depth', 'Ready queue depth'], + ['active_prepare', 'Active prepare'], + ['active_ready', 'Active ready'], + ].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 = explicitDetailedQueueMetrics(load); + const explicitKeys = new Set(explicit.map((item) => item.key)); + const loadOnlyKeys = new Set(['load.inflight', 'load.qps_1m', 'load.gpu_util', 'inflight', 'qps_1m', 'gpu_util', 'least_load_score.total']); + const heuristic = extractQueueMetrics(instance) + .filter((item) => !explicitKeys.has(item.key) && !loadOnlyKeys.has(item.key)); + return [...explicit, ...heuristic]; +} + + +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); + const linkHtml = model.links.map((link) => { + const a = layout.positions[link.source]; + const b = layout.positions[link.target]; + if (!a || !b) return ''; + const midY = (a.y + b.y) / 2; + const d = `M ${a.x} ${a.y + 28} C ${a.x} ${midY}, ${b.x} ${midY}, ${b.x} ${b.y - 30}`; + return `${escapeHtml(link.source)} to ${escapeHtml(link.target)}: ${escapeHtml(link.status)}`; + }).join(''); + const nodeHtml = model.nodes.map((node) => { + const pos = layout.positions[node.id]; + if (!pos) return ''; + const isInstance = node.type === 'instance'; + const aria = `${node.type} ${node.instanceId || node.label} ${node.status}`; + return ` + ${escapeHtml(nodeTooltip(node))} + + + ${escapeHtml(shortText(node.label, 18))} + ${escapeHtml(shortText(node.status, 22))} + `; + }).join(''); + $('systemTopology').classList.toggle('paused', state.paused); + $('systemTopology').innerHTML = `
+ CacheRoute Proxy topologyScheduler, local Proxy, and registered Instance nodes. Instance nodes are keyboard selectable. + + + + + + ${linkHtml}${nodeHtml} +
${model.instances.length ? '' : '
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 = mergedInstanceLoad(instance); + 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 loadMetrics = explicitLoadMetrics(load); + const queues = queueMetricsForInstance(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 = ` -

Instance Detail: ${escapeHtml(instance.instance_id)}

${staleNote}
-
-

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 = `

Instance Detail: ${escapeHtml(instance.instance_id)}

${escapeHtml(instance.host || '—')}:${escapeHtml(instance.port || '—')} · ${instanceStateBadge(instance)}

${staleNote}
+
+ ${[['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)], ['GPU util', displayNumber(load.gpu_util, 1)], ['Least-load score', displayNumber(nestedNumber(load, 'least_load_score.total'), 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' })}
+

Load metrics

${loadMetrics.length ? loadMetrics.map((metric) => `
${escapeHtml(metric.label)}${escapeHtml(displayNumber(metric.value, 2))}${escapeHtml(metric.key)}
`).join('') : '
No load metrics are exposed by the current Instance load snapshot.
'}
+

Detailed queue counters

${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 load snapshot.
'}
+

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(`#${gpu.index ?? i} ${gpuName(gpu) || 'GPU'}`)}${gpu.uuid ? ` · ${escapeHtml(gpu.uuid)}` : ''} · mem ${escapeHtml(displayNumber(gpu.memory_used_mb,0))}/${escapeHtml(displayNumber(gpu.memory_total_mb,0))} MB${gpu.memory_free_mb !== undefined ? ` · free ${escapeHtml(displayNumber(gpu.memory_free_mb,0))} MB` : ''} · util ${escapeHtml(displayNumber(gpuUtil(gpu),1))}%${gpu.temperature_c !== undefined ? ` · ${escapeHtml(displayNumber(gpu.temperature_c,1))}°C` : ''}${gpu.power_w !== undefined ? ` · ${escapeHtml(displayNumber(gpu.power_w,1))} W` : ''}

`).join('') : '

No GPU details

'}

NIC

${hw.nics.length ? hw.nics.map((nic) => `

${escapeHtml(nicName(nic) || 'NIC')}${nic.driver ? ` · ${escapeHtml(nic.driver)}` : ''}${nic.speed_mbps ? ` · ${escapeHtml(displayNumber(nic.speed_mbps,0))} Mbps` : (nic.speed ? ` · ${escapeHtml(nic.speed)}` : '')}${nic.rx_mbps !== undefined ? ` · RX ${escapeHtml(displayNumber(nic.rx_mbps,2))} Mbps` : ''}${nic.tx_mbps !== undefined ? ` · TX ${escapeHtml(displayNumber(nic.tx_mbps,2))} Mbps` : ''}

`).join('') : '

No NIC details

'}
+
Raw Instance JSON
${escapeHtml(stableJson(instance))}
+
`; } function renderTopology(payload) { @@ -508,6 +772,7 @@ function renderRawPanels() { ['Proxy status', state.latest.status], ['Instances', state.latest.instances], ['Resources', state.latest.resources], + ['Loads', state.latest.loads], ['Topology', state.latest.topology], ['Scheduler', state.latest.scheduler], ]; @@ -639,16 +904,17 @@ async function refresh() { } } - const [health, status, instances, resources, topology, scheduler] = await Promise.all([ + const [health, status, instances, resources, loads, topology, scheduler] = await Promise.all([ settleJson('/api/proxy/healthz'), settleJson('/api/proxy/status'), settleJson('/api/proxy/instances?include_dead=true'), settleJson('/api/proxy/resources?include_dead=true'), + settleJson('/api/proxy/loads', { optional: true }), settleJson('/api/proxy/topology', { optional: true }), settleJson('/api/scheduler/proxy', { optional: true }), ]); - state.latest = { health, status, instances, resources, topology, scheduler }; + state.latest = { health, status, instances, resources, loads, topology, scheduler }; pushHistory(Array.isArray(instances) ? instances : [], normalizeResources(resources), topology); $('lastUpdated').textContent = `Last refresh: ${new Date().toLocaleTimeString()}`; renderAll(); @@ -664,7 +930,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'; } @@ -674,6 +940,7 @@ function setupEventHandlers() { state.paused = !state.paused; $('pauseBtn').textContent = state.paused ? 'Resume polling' : 'Pause polling'; $('pollingState').outerHTML = badge(state.paused ? 'Paused' : 'Polling', state.paused ? 'warn' : 'ok').replace(' 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 +1001,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; @@ -746,13 +1020,14 @@ function setupEventHandlers() { 'Proxy status': state.latest.status, Instances: state.latest.instances, Resources: state.latest.resources, + Loads: state.latest.loads, Topology: state.latest.topology, Scheduler: state.latest.scheduler, }; 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 +1060,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, queueMetricsForInstance, computeMetrics, hasResourceReport, finiteNumber, clampPercent, percentOf, fmt, stateLabel, hardwareDetails, mergedInstanceLoad, applyPausedTopologyState, renderInstanceDetail, renderSystemTopology, state }; +} 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..ca77575 100644 --- a/UI/proxy_ui/static/style.css +++ b/UI/proxy_ui/static/style.css @@ -605,65 +605,225 @@ 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.scheduler.ok .node-halo { stroke: var(--ok); } +.topology-svg-node.scheduler.bad .node-halo { stroke: var(--bad); } +.topology-svg-node.scheduler.warn .node-halo { stroke: var(--warn); } +.topology-svg-node.scheduler.muted .node-halo { stroke: var(--muted); } +.topology-svg-node.proxy .node-halo { stroke: var(--topology-proxy); } +.topology-svg-node.proxy.bad .node-halo { stroke: var(--bad); } +.topology-svg-node.proxy.warn .node-halo { stroke: var(--warn); } +.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.scheduler.ok { color: var(--ok); } +.topology-svg-node.scheduler.bad { color: var(--bad); } +.topology-svg-node.scheduler.warn { color: var(--warn); } +.topology-svg-node.scheduler.muted { color: var(--muted); } +.topology-svg-node.proxy { color: var(--topology-proxy); } +.topology-svg-node.proxy.bad { color: var(--bad); } +.topology-svg-node.proxy.warn { color: var(--warn); } +.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..5f4298b --- /dev/null +++ b/UI/proxy_ui/test_proxy_ui_pure.js @@ -0,0 +1,177 @@ +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, + registered_at: 1700000000, + 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 testNullSemantics() { + const cases = [null, undefined, '', ' ', true, false, NaN, Infinity, -Infinity, [], [1], {}, new Date()]; + for (const value of cases) assert.strictEqual(ui.finiteNumber(value), null, `expected null for ${String(value)}`); + assert.strictEqual(ui.finiteNumber(0), 0); + assert.strictEqual(ui.finiteNumber(-3), -3); + assert.strictEqual(ui.finiteNumber('0'), 0); + assert.strictEqual(ui.finiteNumber('42'), 42); + assert.strictEqual(ui.finiteNumber(' 42.5 '), 42.5); + assert.strictEqual(ui.fmt(null), '—'); + assert.strictEqual(ui.percentOf('', 100), null); + assert(!ui.renderMetricBar({ label: 'null', value: null, max: 100 }).includes('0.00')); +} + + +function testComputeMetricsNullAverages() { + let metrics = ui.computeMetrics([ + inst('missing', true, { resource: { cpu_util: null, gpu_util_avg: null, network_rx_mbps: null, network_tx_mbps: null, memory_used_mb: null, memory_total_mb: null } }), + inst('valid', true, { resource: { cpu_util: 80, gpu_util_avg: 60, network_rx_mbps: 10, network_tx_mbps: 5, memory_used_mb: 50, memory_total_mb: 100 } }), + ]); + assert.strictEqual(metrics.cpu, 80); + assert.strictEqual(metrics.gpu, 60); + assert.strictEqual(metrics.rx, 10); + assert.strictEqual(metrics.tx, 5); + assert.strictEqual(metrics.memory, 50); + + metrics = ui.computeMetrics([ + inst('all-null-a', true, { resource: { cpu_util: null, gpu_util_avg: null, network_rx_mbps: null, network_tx_mbps: null, memory_used_mb: null, memory_total_mb: null } }), + inst('all-null-b', true, { resource: { cpu_util: '', gpu_util_avg: undefined, network_rx_mbps: false, network_tx_mbps: {}, memory_used_mb: 1, memory_total_mb: 0 } }), + ]); + assert.strictEqual(metrics.cpu, null); + assert.strictEqual(metrics.gpu, null); + assert.strictEqual(metrics.rx, null); + assert.strictEqual(metrics.tx, null); + assert.strictEqual(metrics.memory, null); +} + +function testHasResourceReport() { + assert.strictEqual(ui.hasResourceReport(inst('raw-empty', true, { resource: { raw_resource: {} } })), false); + assert.strictEqual(ui.hasResourceReport(inst('reported-null', true, { resource: { resource_reported_at: null, raw_resource: { devices: {} } } })), false); + assert.strictEqual(ui.hasResourceReport(inst('reported', true, { resource: { resource_reported_at: 1700000000, raw_resource: {} } })), true); + assert.strictEqual(ui.hasResourceReport(inst('scalar', true, { resource: { cpu_util: 0, resource_reported_at: null } })), true); + assert.strictEqual(ui.hasResourceReport(inst('unavailable', true, { resource: { cpu_util: null, memory_total_mb: '', gpu_util_avg: false, network_rx_mbps: {}, raw_resource: {} } })), false); +} + +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', ok: true }); + assert.strictEqual(model.links[0].status, 'active'); + assert.strictEqual(model.links[1].status, 'active'); + assert.strictEqual(model.nodes.find((n) => n.type === 'proxy').tone, 'ok'); + assert.strictEqual(ui.stateLabel(model.instances[0]), 'alive'); + + model = ui.buildTopologyModel([inst('alive', true), inst('stale', false), inst('unknown', undefined)], { ok: true }, { ok: false }); + 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']); + assert.strictEqual(model.nodes.find((n) => n.type === 'proxy').tone, 'bad'); + + for (let count = 0; count <= 30; count += 1) { + const items = Array.from({ length: count }, (_, i) => inst(`node-${String(i).padStart(2, '0')}`, i % 2 === 0)); + const layout = ui.computeTopologyLayout(ui.buildTopologyModel(items, { ok: true }, { ok: true })); + const nodePadding = layout.nodePadding; + const labelPadding = layout.labelPadding; + for (const pos of Object.values(layout.positions)) { + assert(pos.x - nodePadding >= 0, `count=${count} left bound`); + assert(pos.x + nodePadding <= layout.width, `count=${count} right bound`); + assert(pos.y - nodePadding >= 0, `count=${count} top bound`); + assert(pos.y + labelPadding <= layout.height, `count=${count} bottom bound`); + } + } + for (const count of [2, 3, 4, 5, 6, 7, 8]) { + const layout = ui.computeTopologyLayout(ui.buildTopologyModel(Array.from({ length: count }, (_, i) => inst(`arc-${i}`, true)), { ok: true }, {})); + assert(layout.height >= 360, `count=${count} height`); + } +} + +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 testQueueExtractionAndLoadMerge() { + const loads = { ok: true, metric_source: { queue_depth: 'proxy_queue_manager' }, instances: [{ instance_id: 'known', inflight: null, qps_1m: '2.5', prepare_queue_depth: 3, ready_queue_depth: 4, active_prepare: 1, active_ready: 0, least_load_score: { total: 9.25 } }] }; + const metrics = ui.queueMetricsForInstance(inst('known', true, { load: { inflight: null, qps_1m: null } }), loads); + assert(metrics.some((q) => q.key === 'prepare_queue_depth' && q.value === 3)); + assert(metrics.some((q) => q.key === 'active_ready' && q.value === 0)); + assert(!metrics.some((q) => q.key === 'least_load_score.total')); + assert(!metrics.some((q) => q.key === 'inflight')); + assert.deepStrictEqual(ui.queueMetricsForInstance(inst('only-load', true)).map((q) => q.key), []); + + 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 withFakeDocument(elements, fn) { + const original = global.document; + global.document = { getElementById: (id) => elements[id] || null }; + try { fn(); } finally { global.document = original; } +} + +function testHardwareAndPausedDom() { + const panel = { classList: { add() {}, remove() {} }, innerHTML: '' }; + const instance = inst('gpu-real', true, { resource: { raw_resource: { devices: { gpu: [{ index: 0, name: 'NVIDIA H100', uuid: 'GPU-1', utilization_pct: 77, memory_used_mb: 40960, memory_total_mb: 81920, memory_free_mb: 40960, temperature_c: 61, power_w: 350 }], network: [{ iface: 'eth0', rx_mbps: 12.5, tx_mbps: 7.25, speed_mbps: 100000 }] } } } }); + ui.state.selectedInstanceId = 'gpu-real'; + ui.state.latest.loads = { instances: [] }; + withFakeDocument({ instanceDetailView: panel }, () => ui.renderInstanceDetail([instance])); + assert(panel.innerHTML.includes('NVIDIA H100')); + assert(panel.innerHTML.includes('40960/81920')); + assert(panel.innerHTML.includes('util 77%')); + assert(panel.innerHTML.includes('GPU-1')); + assert(panel.innerHTML.includes('61°C')); + assert(panel.innerHTML.includes('350 W')); + assert(panel.innerHTML.includes('eth0')); + assert(panel.innerHTML.includes('100000 Mbps')); + assert(panel.innerHTML.includes('No detailed queue counters are exposed by the current Instance load snapshot.')); + + const container = { classList: { value: false, toggle(cls, on) { this.value = cls === 'paused' && on; } } }; + ui.state.paused = true; + withFakeDocument({ systemTopology: container }, () => ui.applyPausedTopologyState()); + assert.strictEqual(container.classList.value, true); + ui.state.paused = false; + withFakeDocument({ systemTopology: container }, () => ui.applyPausedTopologyState()); + assert.strictEqual(container.classList.value, false); +} + +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')); +} + +testNullSemantics(); +testComputeMetricsNullAverages(); +testHasResourceReport(); +testTopologyScenarios(); +testMetricsAndMissingData(); +testQueueExtractionAndLoadMerge(); +testHardwareAndPausedDom(); +testThemeAndReducedMotionStaticHooks(); +console.log('proxy-ui pure function tests passed');