Skip to content

Commit 9a07f45

Browse files
authored
Feature/total vram (#62)
* total vram on the overview page * total vram on the overview page * minor bugfixes * test: introduce pytest and Vitest unit tests - Backend: API, config, handlers, monitor, hub, metrics, NVML utilities - Frontend: chart config/manager, GPU cards, UI tabs, WebSocket helpers, app.js * update the version 1.8.0 --------- Co-authored-by: Panos <>
1 parent 21619a6 commit 9a07f45

33 files changed

Lines changed: 3146 additions & 51 deletions

.gitignore

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,8 @@ instance/
5050

5151
# Testing
5252
.pytest_cache/
53-
.coverage
54-
.coverage.*
55-
htmlcov/
53+
node_modules/
54+
5655
.tox/
5756
.nox/
5857
.hypothesis/

run_tests.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
echo "=== gpu-hot Unit Tests ==="
5+
echo "Building test container..."
6+
7+
docker compose -f tests/docker-compose.unittest.yml build
8+
9+
echo "Running tests..."
10+
docker compose -f tests/docker-compose.unittest.yml run --rm unittest
11+
12+
echo "=== Tests Complete ==="

static/css/components.css

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,51 @@
9393
max-height: 48px !important;
9494
}
9595

96+
/* ============================================
97+
Aggregate VRAM Row
98+
============================================ */
99+
100+
.agg-vram-wrap {
101+
padding: var(--space-sm) 0 var(--space-md);
102+
border-bottom: 1px solid var(--border-subtle);
103+
margin-bottom: var(--space-md);
104+
}
105+
106+
.agg-vram-inner {
107+
display: inline-block;
108+
}
109+
110+
.agg-vram-row {
111+
display: flex;
112+
align-items: baseline;
113+
gap: var(--space-lg);
114+
}
115+
116+
.agg-vram-value {
117+
font-family: var(--font-mono);
118+
font-size: 16px;
119+
font-weight: 700;
120+
color: var(--text-primary);
121+
font-variant-numeric: tabular-nums;
122+
letter-spacing: -0.3px;
123+
}
124+
125+
.agg-vram-bar {
126+
height: 3px;
127+
margin-top: 6px;
128+
background: var(--border-subtle);
129+
border-radius: 2px;
130+
overflow: hidden;
131+
}
132+
133+
.agg-vram-bar-fill {
134+
height: 100%;
135+
width: 0%;
136+
background: var(--text-secondary);
137+
border-radius: 2px;
138+
transition: width 0.3s ease;
139+
}
140+
96141
/* ============================================
97142
Enhanced Overview Card
98143
============================================ */

static/js/chart-manager.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,35 @@ function initOverviewMiniChart(gpuId, currentValue) {
376376
}
377377
}
378378

379+
// ============================================
380+
// Aggregate Stats — Summary across all GPUs
381+
// ============================================
382+
383+
function initAggregateChart() { }
384+
385+
function updateAggregateStats(gpusMap) {
386+
let totalUsedMiB = 0, totalCapMiB = 0;
387+
388+
Object.values(gpusMap).forEach(gpu => {
389+
totalUsedMiB += gpu.memory_used || 0;
390+
totalCapMiB += gpu.memory_total || 0;
391+
});
392+
393+
const usedGB = totalUsedMiB / 1024;
394+
const totalGB = totalCapMiB / 1024;
395+
396+
const el = document.getElementById('agg-vram-value');
397+
if (el) el.textContent = `${usedGB.toFixed(1)} / ${totalGB.toFixed(1)} GB`;
398+
399+
const bar = document.getElementById('agg-vram-bar');
400+
if (bar) {
401+
const pct = totalGB > 0 ? Math.min((usedGB / totalGB) * 100, 100) : 0;
402+
bar.style.width = `${pct}%`;
403+
}
404+
}
405+
406+
function destroyAggregateChart() { }
407+
379408
// ============================================
380409
// System Charts — Sidebar (mini) + Per-GPU (sparklines)
381410
// ============================================

static/js/gpu-cards.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,21 @@ function bulletClass(value, warnThreshold, dangerThreshold) {
4242
return '';
4343
}
4444

45+
// Aggregate VRAM summary card (shown when 2+ GPUs)
46+
function createAggregateCard() {
47+
return `
48+
<div id="aggregate-card" class="agg-vram-wrap">
49+
<div class="agg-vram-inner">
50+
<div class="agg-vram-row">
51+
<span class="node-label">Total VRAM</span>
52+
<span class="agg-vram-value" id="agg-vram-value">0 / 0 GB</span>
53+
</div>
54+
<div class="agg-vram-bar"><div class="agg-vram-bar-fill" id="agg-vram-bar"></div></div>
55+
</div>
56+
</div>
57+
`;
58+
}
59+
4560
// Update overview card — delegates to enhanced updater
4661
function updateOverviewCard(gpuId, gpuInfo, shouldUpdateDOM = true) {
4762
updateEnhancedOverviewCard(gpuId, gpuInfo, shouldUpdateDOM);
@@ -198,7 +213,7 @@ function updateEnhancedOverviewCard(gpuId, gpuInfo, shouldUpdateDOM = true) {
198213
const powerPercent = (power_draw / power_limit) * 100;
199214

200215
if (shouldUpdateDOM) {
201-
// Hero metrics
216+
// Hero metrics (single-node enhanced overview: sgo-* IDs)
202217
const utilEl = document.getElementById(`sgo-util-${gpuId}`);
203218
const tempEl = document.getElementById(`sgo-temp-${gpuId}`);
204219
const memEl = document.getElementById(`sgo-mem-${gpuId}`);
@@ -214,6 +229,17 @@ function updateEnhancedOverviewCard(gpuId, gpuInfo, shouldUpdateDOM = true) {
214229
const memUnitEl = document.getElementById(`sgo-mem-unit-${gpuId}`);
215230
if (memUnitEl) memUnitEl.textContent = formatMemoryUnit(memory_used);
216231

232+
// Cluster/hub overview cards (overview-* IDs)
233+
const clUtilEl = document.getElementById(`overview-util-${gpuId}`);
234+
const clTempEl = document.getElementById(`overview-temp-${gpuId}`);
235+
const clMemEl = document.getElementById(`overview-mem-${gpuId}`);
236+
const clPowerEl = document.getElementById(`overview-power-${gpuId}`);
237+
238+
if (clUtilEl) clUtilEl.textContent = `${utilization}%`;
239+
if (clTempEl) clTempEl.textContent = `${temperature}°`;
240+
if (clMemEl) clMemEl.textContent = `${Math.round(memPercent)}%`;
241+
if (clPowerEl) clPowerEl.textContent = `${power_draw.toFixed(0)}W`;
242+
217243
// Bullet bars
218244
const utilBar = document.getElementById(`sgo-util-bar-${gpuId}`);
219245
const tempBar = document.getElementById(`sgo-temp-bar-${gpuId}`);

static/js/socket-handlers.js

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ function setupScrollDetection() {
132132
// Initialize scroll detection
133133
setupScrollDetection();
134134

135+
// Track whether the aggregate summary card has been injected
136+
let aggregateCardInjected = false;
137+
135138
// Performance: Batched rendering system using requestAnimationFrame
136139
// Batches all DOM updates into a single frame to minimize reflows/repaints
137140
let pendingUpdates = new Map(); // Queue of pending GPU/system updates
@@ -155,6 +158,7 @@ function handleSocketMessage(event) {
155158
// Clear loading state
156159
if (overviewContainer.querySelector('.loading')) {
157160
overviewContainer.innerHTML = '';
161+
aggregateCardInjected = false;
158162
}
159163

160164
const gpuCount = Object.keys(data.gpus).length;
@@ -241,6 +245,21 @@ function handleSocketMessage(event) {
241245
}
242246
});
243247

248+
// Aggregate summary card (2+ GPUs)
249+
if (gpuCount >= 2) {
250+
if (!aggregateCardInjected) {
251+
overviewContainer.insertAdjacentHTML('afterbegin', createAggregateCard());
252+
aggregateCardInjected = true;
253+
initAggregateChart();
254+
}
255+
pendingUpdates.set('_aggregate', { gpus: data.gpus });
256+
} else if (aggregateCardInjected) {
257+
const aggCard = document.getElementById('aggregate-card');
258+
if (aggCard) aggCard.remove();
259+
destroyAggregateChart();
260+
aggregateCardInjected = false;
261+
}
262+
244263
// Queue system updates (processes/CPU/RAM) for batching
245264
if (!lastDOMUpdate.system || (now - lastDOMUpdate.system) >= DOM_UPDATE_INTERVAL) {
246265
pendingUpdates.set('_system', {
@@ -273,7 +292,10 @@ function processBatchedUpdates() {
273292

274293
// Execute all queued updates in a single batch
275294
pendingUpdates.forEach((update, gpuId) => {
276-
if (gpuId === '_system') {
295+
if (gpuId === '_aggregate') {
296+
updateAggregateStats(update.gpus);
297+
return;
298+
} else if (gpuId === '_system') {
277299
// System updates (CPU, RAM, processes)
278300
updateProcesses(update.processes);
279301
updateSystemInfo(update.system);
@@ -416,6 +438,7 @@ function handleClusterData(data) {
416438
// Clear loading state
417439
if (overviewContainer.querySelector('.loading')) {
418440
overviewContainer.innerHTML = '';
441+
aggregateCardInjected = false;
419442
}
420443

421444
// Skip DOM updates during scrolling
@@ -439,7 +462,6 @@ function handleClusterData(data) {
439462
});
440463
}
441464
updateAllChartDataOnly(fullGpuId, gpuInfo);
442-
// Also update system chart data during scroll (per-node)
443465
if (nodeData.system) {
444466
updateGPUSystemCharts(fullGpuId, nodeData.system, nodeName, false);
445467
}
@@ -525,6 +547,30 @@ function handleClusterData(data) {
525547
}
526548
});
527549

550+
// Aggregate summary card (2+ GPUs across cluster)
551+
const clusterGpusFlat = {};
552+
Object.entries(data.nodes).forEach(([nodeName, nodeData]) => {
553+
if (nodeData.status === 'online') {
554+
Object.entries(nodeData.gpus).forEach(([gpuId, gpuInfo]) => {
555+
clusterGpusFlat[`${nodeName}-${gpuId}`] = gpuInfo;
556+
});
557+
}
558+
});
559+
const clusterGpuCount = Object.keys(clusterGpusFlat).length;
560+
if (clusterGpuCount >= 2) {
561+
if (!aggregateCardInjected) {
562+
overviewContainer.insertAdjacentHTML('afterbegin', createAggregateCard());
563+
aggregateCardInjected = true;
564+
initAggregateChart();
565+
}
566+
pendingUpdates.set('_aggregate', { gpus: clusterGpusFlat });
567+
} else if (aggregateCardInjected) {
568+
const aggCard = document.getElementById('aggregate-card');
569+
if (aggCard) aggCard.remove();
570+
destroyAggregateChart();
571+
aggregateCardInjected = false;
572+
}
573+
528574
// Update processes and system info (use first online node)
529575
const firstOnlineNode = Object.values(data.nodes).find(n => n.status === 'online');
530576
if (firstOnlineNode) {

tests/Dockerfile.unittest

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
FROM python:3.11-slim
2+
3+
WORKDIR /app
4+
5+
# Install Node.js 18 and system deps
6+
RUN apt-get update && apt-get install -y --no-install-recommends \
7+
curl gcc python3-dev \
8+
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
9+
&& apt-get install -y nodejs \
10+
&& rm -rf /var/lib/apt/lists/*
11+
12+
# Python dependencies
13+
COPY requirements.txt .
14+
RUN pip install --no-cache-dir -r requirements.txt \
15+
pytest \
16+
pytest-asyncio \
17+
httpx
18+
19+
# Node dependencies (frontend unit tests)
20+
COPY tests/package.json tests/
21+
RUN npm install --prefix tests
22+
23+
# Copy project
24+
COPY . .
25+
26+
CMD ["sh", "-c", "\
27+
echo '=== Python Backend Tests ===' && \
28+
python -m pytest -c tests/pytest.ini --tb=short -v && \
29+
echo '' && \
30+
echo '=== JavaScript Frontend Tests ===' && \
31+
VITE_CJS_IGNORE_WARNING=1 npm test --prefix tests && \
32+
echo '' && \
33+
echo '=== All Tests Passed ==='"]

0 commit comments

Comments
 (0)