feat(webui): combined GPU charts (load/memload/power/temp all GPUs per chart)
Replace per-GPU cards with 4 combined charts showing all GPUs as separate series. Add gpu-all-load/memload/power/temp endpoints. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -392,12 +392,6 @@ func renderMetrics() string {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-bottom:16px">
|
|
||||||
<div class="card-head">Temperature — GPUs</div>
|
|
||||||
<div class="card-body" style="padding:8px">
|
|
||||||
<img id="chart-server-temp-gpu" src="/api/metrics/chart/server-temp-gpu.svg" style="width:100%;display:block;border-radius:6px" alt="GPU temperature">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" style="margin-bottom:16px">
|
<div class="card" style="margin-bottom:16px">
|
||||||
<div class="card-head">Temperature — Ambient Sensors</div>
|
<div class="card-head">Temperature — Ambient Sensors</div>
|
||||||
@@ -421,50 +415,47 @@ func renderMetrics() string {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="gpu-charts"></div>
|
<div class="card" style="margin-bottom:16px">
|
||||||
|
<div class="card-head">GPU — Compute Load</div>
|
||||||
|
<div class="card-body" style="padding:8px">
|
||||||
|
<img id="chart-gpu-all-load" src="/api/metrics/chart/gpu-all-load.svg" style="width:100%;display:block;border-radius:6px" alt="GPU compute load">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-bottom:16px">
|
||||||
|
<div class="card-head">GPU — Memory Load</div>
|
||||||
|
<div class="card-body" style="padding:8px">
|
||||||
|
<img id="chart-gpu-all-memload" src="/api/metrics/chart/gpu-all-memload.svg" style="width:100%;display:block;border-radius:6px" alt="GPU memory load">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-bottom:16px">
|
||||||
|
<div class="card-head">GPU — Power</div>
|
||||||
|
<div class="card-body" style="padding:8px">
|
||||||
|
<img id="chart-gpu-all-power" src="/api/metrics/chart/gpu-all-power.svg" style="width:100%;display:block;border-radius:6px" alt="GPU power">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-bottom:16px">
|
||||||
|
<div class="card-head">GPU — Temperature</div>
|
||||||
|
<div class="card-body" style="padding:8px">
|
||||||
|
<img id="chart-gpu-all-temp" src="/api/metrics/chart/gpu-all-temp.svg" style="width:100%;display:block;border-radius:6px" alt="GPU temperature">
|
||||||
|
<div id="gpu-table" style="margin-top:8px;font-size:12px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let knownGPUs = [];
|
|
||||||
|
|
||||||
function refreshCharts() {
|
function refreshCharts() {
|
||||||
const t = '?t=' + Date.now();
|
const t = '?t=' + Date.now();
|
||||||
['chart-server-load','chart-server-temp-cpu','chart-server-temp-gpu','chart-server-temp-ambient','chart-server-power','chart-server-fans'].forEach(id => {
|
['chart-server-load','chart-server-temp-cpu','chart-server-temp-gpu','chart-server-temp-ambient','chart-server-power','chart-server-fans',
|
||||||
|
'chart-gpu-all-load','chart-gpu-all-memload','chart-gpu-all-power','chart-gpu-all-temp'].forEach(id => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) el.src = el.src.split('?')[0] + t;
|
if (el) el.src = el.src.split('?')[0] + t;
|
||||||
});
|
});
|
||||||
knownGPUs.forEach(idx => {
|
|
||||||
['load','power'].forEach(kind => {
|
|
||||||
const el = document.getElementById('chart-gpu-' + idx + '-' + kind);
|
|
||||||
if (el) el.src = el.src.split('?')[0] + t;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
setInterval(refreshCharts, 2000);
|
setInterval(refreshCharts, 3000);
|
||||||
|
|
||||||
const es = new EventSource('/api/metrics/stream');
|
const es = new EventSource('/api/metrics/stream');
|
||||||
es.addEventListener('metrics', e => {
|
es.addEventListener('metrics', e => {
|
||||||
const d = JSON.parse(e.data);
|
const d = JSON.parse(e.data);
|
||||||
|
|
||||||
// Add GPU chart cards as GPUs appear
|
|
||||||
(d.gpus||[]).forEach(g => {
|
|
||||||
if (knownGPUs.includes(g.index)) return;
|
|
||||||
knownGPUs.push(g.index);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = 'card';
|
|
||||||
div.style.marginBottom = '16px';
|
|
||||||
div.innerHTML =
|
|
||||||
'<div class="card-head">GPU ' + g.index + ' — Load</div>' +
|
|
||||||
'<div class="card-body" style="padding:8px">' +
|
|
||||||
'<img id="chart-gpu-' + g.index + '-load" src="/api/metrics/chart/gpu/' + g.index + '-load.svg" style="width:100%;display:block;border-radius:6px" alt="GPU ' + g.index + ' load">' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="card-head">GPU ' + g.index + ' — Power</div>' +
|
|
||||||
'<div class="card-body" style="padding:8px">' +
|
|
||||||
'<img id="chart-gpu-' + g.index + '-power" src="/api/metrics/chart/gpu/' + g.index + '-power.svg" style="width:100%;display:block;border-radius:6px" alt="GPU ' + g.index + ' power">' +
|
|
||||||
'<div id="gpu-table-' + g.index + '" style="margin-top:8px;font-size:12px"></div>' +
|
|
||||||
'</div>';
|
|
||||||
document.getElementById('gpu-charts').appendChild(div);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update numeric tables
|
// Update numeric tables
|
||||||
let sysHTML = '';
|
let sysHTML = '';
|
||||||
(d.temps||[]).filter(t => t.group === 'cpu').forEach(t => {
|
(d.temps||[]).filter(t => t.group === 'cpu').forEach(t => {
|
||||||
@@ -477,15 +468,16 @@ es.addEventListener('metrics', e => {
|
|||||||
const st = document.getElementById('sys-table');
|
const st = document.getElementById('sys-table');
|
||||||
if (st) st.innerHTML = sysHTML ? '<table>'+sysHTML+'</table>' : '<p style="color:var(--muted)">No sensor data (ipmitool/sensors required)</p>';
|
if (st) st.innerHTML = sysHTML ? '<table>'+sysHTML+'</table>' : '<p style="color:var(--muted)">No sensor data (ipmitool/sensors required)</p>';
|
||||||
|
|
||||||
|
let gpuHTML = '';
|
||||||
(d.gpus||[]).forEach(g => {
|
(d.gpus||[]).forEach(g => {
|
||||||
const t = document.getElementById('gpu-table-' + g.index);
|
gpuHTML += '<tr><td>GPU '+g.index+'</td>' +
|
||||||
if (!t) return;
|
'<td>'+g.temp_c+'°C</td>' +
|
||||||
t.innerHTML = '<table>' +
|
'<td>'+g.usage_pct+'% load</td>' +
|
||||||
'<tr><td>Temp</td><td>'+g.temp_c+'°C</td>' +
|
'<td>'+g.mem_usage_pct+'% mem</td>' +
|
||||||
'<td>Load</td><td>'+g.usage_pct+'%</td>' +
|
'<td>'+g.power_w+' W</td></tr>';
|
||||||
'<td>Mem</td><td>'+g.mem_usage_pct+'%</td>' +
|
|
||||||
'<td>Power</td><td>'+g.power_w+' W</td></tr></table>';
|
|
||||||
});
|
});
|
||||||
|
const gt = document.getElementById('gpu-table');
|
||||||
|
if (gt) gt.innerHTML = gpuHTML ? '<table>'+gpuHTML+'</table>' : '';
|
||||||
});
|
});
|
||||||
es.onerror = () => {};
|
es.onerror = () => {};
|
||||||
</script>`
|
</script>`
|
||||||
|
|||||||
@@ -449,7 +449,80 @@ func (h *handler) handleMetricsChartSVG(w http.ResponseWriter, r *http.Request)
|
|||||||
yMin = floatPtr(0)
|
yMin = floatPtr(0)
|
||||||
yMax = autoMax120(datasets...)
|
yMax = autoMax120(datasets...)
|
||||||
|
|
||||||
// ── GPU sub-charts ────────────────────────────────────────────────────
|
// ── Combined GPU charts (all GPUs on one chart) ───────────────────────
|
||||||
|
case path == "gpu-all-load":
|
||||||
|
title = "GPU Compute Load"
|
||||||
|
h.ringsMu.Lock()
|
||||||
|
for idx, gr := range h.gpuRings {
|
||||||
|
if gr == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vUtil, l := gr.Util.snapshot()
|
||||||
|
datasets = append(datasets, vUtil)
|
||||||
|
names = append(names, fmt.Sprintf("GPU %d %%", idx))
|
||||||
|
if len(labels) == 0 {
|
||||||
|
labels = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.ringsMu.Unlock()
|
||||||
|
yMin = floatPtr(0)
|
||||||
|
yMax = floatPtr(100)
|
||||||
|
|
||||||
|
case path == "gpu-all-memload":
|
||||||
|
title = "GPU Memory Load"
|
||||||
|
h.ringsMu.Lock()
|
||||||
|
for idx, gr := range h.gpuRings {
|
||||||
|
if gr == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vMem, l := gr.MemUtil.snapshot()
|
||||||
|
datasets = append(datasets, vMem)
|
||||||
|
names = append(names, fmt.Sprintf("GPU %d %%", idx))
|
||||||
|
if len(labels) == 0 {
|
||||||
|
labels = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.ringsMu.Unlock()
|
||||||
|
yMin = floatPtr(0)
|
||||||
|
yMax = floatPtr(100)
|
||||||
|
|
||||||
|
case path == "gpu-all-power":
|
||||||
|
title = "GPU Power"
|
||||||
|
h.ringsMu.Lock()
|
||||||
|
for idx, gr := range h.gpuRings {
|
||||||
|
if gr == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vPow, l := gr.Power.snapshot()
|
||||||
|
datasets = append(datasets, vPow)
|
||||||
|
names = append(names, fmt.Sprintf("GPU %d W", idx))
|
||||||
|
if len(labels) == 0 {
|
||||||
|
labels = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.ringsMu.Unlock()
|
||||||
|
yMin = floatPtr(0)
|
||||||
|
yMax = autoMax120(datasets...)
|
||||||
|
|
||||||
|
case path == "gpu-all-temp":
|
||||||
|
title = "GPU Temperature"
|
||||||
|
h.ringsMu.Lock()
|
||||||
|
for idx, gr := range h.gpuRings {
|
||||||
|
if gr == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vTemp, l := gr.Temp.snapshot()
|
||||||
|
datasets = append(datasets, vTemp)
|
||||||
|
names = append(names, fmt.Sprintf("GPU %d °C", idx))
|
||||||
|
if len(labels) == 0 {
|
||||||
|
labels = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.ringsMu.Unlock()
|
||||||
|
yMin = floatPtr(0)
|
||||||
|
yMax = autoMax120(datasets...)
|
||||||
|
|
||||||
|
// ── Per-GPU sub-charts ────────────────────────────────────────────────
|
||||||
case strings.HasPrefix(path, "gpu/"):
|
case strings.HasPrefix(path, "gpu/"):
|
||||||
rest := strings.TrimPrefix(path, "gpu/")
|
rest := strings.TrimPrefix(path, "gpu/")
|
||||||
// rest is either "{idx}-load", "{idx}-temp", "{idx}-power", or legacy "{idx}"
|
// rest is either "{idx}-load", "{idx}-temp", "{idx}-power", or legacy "{idx}"
|
||||||
|
|||||||
Reference in New Issue
Block a user