@@ -2654,6 +2667,7 @@
}
if (msg.type === 'node_stats') {
const p = msg.payload || {};
+ if (_cardLoadStreams.has(p.instanceId)) onCardLoadStats(p);
if (_nodeStatsTarget && p.instanceId === _nodeStatsTarget) {
const el = document.getElementById('node-stats-smi');
if (el) el.textContent = p.nvidiaSmi || '(leer)';
@@ -5309,6 +5323,110 @@
}, 60);
}
+ // ── Kartenauslastung (alle Boxen, alle Karten) ─────────
+ // Pro Box EIN Dienst, der streamt: bevorzugt der LLM-Adapter (sieht alle
+ // Karten mit der echten Nummer). Hat eine Box keinen, streamt jeder GPU-
+ // Dienst seine eigene Karte (dort ist die Nummer im Container immer 0 —
+ // die echte steht im worker.gpus).
+ const _cardLoadStreams = new Map(); // instanceId → {node, gpus, single}
+ const _cardLoad = new Map(); // node|uuid → {node, index, name, util, ..., hist: []}
+ let _cardLoadTimer = null;
+ function openCardLoad() {
+ document.getElementById('card-load-modal').style.display = 'flex';
+ _cardLoad.clear();
+ _cardLoadStreams.clear();
+ const byNode = {};
+ for (const w of workers) {
+ if (!w.online || !['llm', 'whisper', 'f5tts', 'voxtral'].includes(w.service)) continue;
+ (byNode[w.node] = byNode[w.node] || []).push(w);
+ }
+ for (const [node, list] of Object.entries(byNode)) {
+ const llm = list.find(w => w.service === 'llm');
+ const streamers = llm ? [llm] : list;
+ for (const w of streamers) {
+ _cardLoadStreams.set(w.instanceId, { node, gpus: String(w.gpus || ''), single: !llm });
+ send({ action: 'node_stats_stream_start', targetInstance: w.instanceId });
+ }
+ }
+ renderCardLoad();
+ // Streams laufen auf der Box max. 5 min — solange das Fenster offen ist, erneuern
+ clearInterval(_cardLoadTimer);
+ _cardLoadTimer = setInterval(() => {
+ for (const id of _cardLoadStreams.keys()) send({ action: 'node_stats_stream_start', targetInstance: id });
+ }, 240000);
+ }
+ function closeCardLoad() {
+ document.getElementById('card-load-modal').style.display = 'none';
+ clearInterval(_cardLoadTimer); _cardLoadTimer = null;
+ for (const id of _cardLoadStreams.keys()) send({ action: 'node_stats_stream_stop', targetInstance: id });
+ _cardLoadStreams.clear();
+ }
+ function onCardLoadStats(p) {
+ const src = _cardLoadStreams.get(p.instanceId);
+ if (!src || !Array.isArray(p.cards)) return;
+ for (const c of p.cards) {
+ // Einzel-Dienst: Container-Nummer (0) durch die echte Karte ersetzen
+ const index = src.single && src.gpus && !src.gpus.includes(',') ? src.gpus : c.index;
+ const key = src.node + '|' + (c.uuid || index);
+ const cur = _cardLoad.get(key) || { hist: [] };
+ cur.hist.push(c.util || 0);
+ if (cur.hist.length > 120) cur.hist.shift();
+ _cardLoad.set(key, { ...cur, ...c, index, node: src.node, ts: Date.now() });
+ }
+ renderCardLoad();
+ }
+ /** Was laeuft auf Karte von : LLM-Modell + Dienste (whisper/f5tts …) */
+ function _cardJobs(node, index) {
+ const out = [];
+ for (const w of workers) {
+ if (w.node !== node || !w.online) continue;
+ if (w.service === 'llm') {
+ for (const sl of (w.slots || [])) if (String(sl.gpu) === String(index) && sl.model) out.push('🧠 ' + sl.model + (sl.ctx ? ' · ' + Math.round(sl.ctx / 1024) + 'k' : ''));
+ } else if (String(w.gpus || '').split(',').map(x => x.trim()).includes(String(index))) {
+ out.push({ whisper: '🎙️ Whisper (Sprache→Text)', f5tts: '🔊 F5-TTS (Sprachausgabe)', voxtral: '🎙️ Voxtral' }[w.service] || w.service);
+ }
+ }
+ return out;
+ }
+ function _bar(pct, color) {
+ const v = Math.max(0, Math.min(100, pct || 0));
+ return '';
+ }
+ function renderCardLoad() {
+ const body = document.getElementById('card-load-body');
+ if (!body) return;
+ if (!_cardLoadStreams.size) { body.innerHTML = 'Keine KI-Box mit Grafikkarte online.
'; return; }
+ const nodes = [...new Set([..._cardLoadStreams.values()].map(v => v.node))].sort();
+ body.innerHTML = nodes.map(node => {
+ const cards = [..._cardLoad.values()].filter(c => c.node === node)
+ .sort((a, b) => Number(a.index) - Number(b.index));
+ const stale = cards.length && cards.every(c => Date.now() - c.ts > 5000);
+ const inner = cards.length ? cards.map(c => {
+ const memPct = c.memTotal ? c.memUsed / c.memTotal * 100 : 0;
+ const utilColor = c.util >= 85 ? '#FF6B6B' : c.util >= 50 ? '#FFB020' : '#3FFF9F';
+ const memColor = memPct >= 92 ? '#FF6B6B' : memPct >= 75 ? '#FFB020' : '#0096FF';
+ const jobs = _cardJobs(node, c.index);
+ return '' +
+ '
' +
+ '
Karte ' + escapeHtml(String(c.index)) + ' ' + escapeHtml(c.name || '') + '
' +
+ '
' + (c.temp != null ? '🌡 ' + Math.round(c.temp) + ' °C' : '') +
+ (c.power != null ? ' · ⚡ ' + Math.round(c.power) + (c.powerLimit ? ' / ' + Math.round(c.powerLimit) : '') + ' W' : '') + '
' +
+ '
' +
+ '
' +
+ 'Rechenlast' + _bar(c.util, utilColor) + '' + Math.round(c.util || 0) + ' %' +
+ 'Speicher' + _bar(memPct, memColor) +
+ '' + ((c.memUsed || 0) / 1024).toFixed(1) + ' / ' + ((c.memTotal || 0) / 1024).toFixed(0) + ' GB' +
+ '
' +
+ '
' + sparkline(c.hist, { max: 100, color: utilColor, fmt: v => v + ' %' }).replace('height:90px', 'height:40px') + '
' +
+ '
' + (jobs.length ? jobs.map(escapeHtml).join(' · ') : 'nichts zugewiesen') + '
' +
+ '
';
+ }).join('') : 'warte auf Werte … (Box mit altem Stand? Dann liefert sie noch keine Werte pro Karte.)
';
+ return '🖥 ' + escapeHtml(node) +
+ (stale ? ' · keine neuen Werte' : '') + '
' +
+ '
' + inner + '
';
+ }).join('');
+ }
+
// ── Auslastungs-Monitor (Stage E) ───────────────────────
let _nodeStatsTarget = '';
function openNodeStats(instanceId, node) {
diff --git a/xtts/f5tts/node_stats.py b/xtts/f5tts/node_stats.py
index f5a7de3..3e0074a 100644
--- a/xtts/f5tts/node_stats.py
+++ b/xtts/f5tts/node_stats.py
@@ -96,6 +96,31 @@ class NodeStats:
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
+ async def _query_cards(self) -> list:
+ """Pro Karte: Index, Name, Last, Speicher, Temperatur, Strom.
+ uuid, damit die Diagnostic dieselbe Karte erkennt, wenn mehrere
+ Dienste einer Box sie melden."""
+ out = await _run_cmd(
+ "nvidia-smi",
+ "--query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,"
+ "temperature.gpu,power.draw,power.limit",
+ "--format=csv,noheader,nounits")
+ cards = []
+ for line in out.strip().splitlines():
+ p = [x.strip() for x in line.split(",")]
+ if len(p) < 9:
+ continue
+
+ def num(v):
+ try:
+ return float(v)
+ except ValueError:
+ return None
+ cards.append({"index": p[0], "uuid": p[1], "name": p[2], "util": num(p[3]),
+ "memUsed": num(p[4]), "memTotal": num(p[5]), "temp": num(p[6]),
+ "power": num(p[7]), "powerLimit": num(p[8])})
+ return cards
+
async def _nvidia_smi_text(self) -> str:
txt = await _run_cmd("nvidia-smi")
return txt or "nvidia-smi nicht verfuegbar"
@@ -128,9 +153,10 @@ class NodeStats:
while time.time() - t0 < STREAM_MAX_SEC:
g = await self._query_gpu()
smi = await self._nvidia_smi_text()
+ cards = await self._query_cards()
await send(ws, "node_stats", {
"instanceId": self.instance_id, "node": self.node_name,
- "nvidiaSmi": smi, **g,
+ "nvidiaSmi": smi, "cards": cards, **g,
})
await asyncio.sleep(1)
except asyncio.CancelledError:
diff --git a/xtts/llm-adapter/node_stats.py b/xtts/llm-adapter/node_stats.py
index f5a7de3..3e0074a 100644
--- a/xtts/llm-adapter/node_stats.py
+++ b/xtts/llm-adapter/node_stats.py
@@ -96,6 +96,31 @@ class NodeStats:
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
+ async def _query_cards(self) -> list:
+ """Pro Karte: Index, Name, Last, Speicher, Temperatur, Strom.
+ uuid, damit die Diagnostic dieselbe Karte erkennt, wenn mehrere
+ Dienste einer Box sie melden."""
+ out = await _run_cmd(
+ "nvidia-smi",
+ "--query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,"
+ "temperature.gpu,power.draw,power.limit",
+ "--format=csv,noheader,nounits")
+ cards = []
+ for line in out.strip().splitlines():
+ p = [x.strip() for x in line.split(",")]
+ if len(p) < 9:
+ continue
+
+ def num(v):
+ try:
+ return float(v)
+ except ValueError:
+ return None
+ cards.append({"index": p[0], "uuid": p[1], "name": p[2], "util": num(p[3]),
+ "memUsed": num(p[4]), "memTotal": num(p[5]), "temp": num(p[6]),
+ "power": num(p[7]), "powerLimit": num(p[8])})
+ return cards
+
async def _nvidia_smi_text(self) -> str:
txt = await _run_cmd("nvidia-smi")
return txt or "nvidia-smi nicht verfuegbar"
@@ -128,9 +153,10 @@ class NodeStats:
while time.time() - t0 < STREAM_MAX_SEC:
g = await self._query_gpu()
smi = await self._nvidia_smi_text()
+ cards = await self._query_cards()
await send(ws, "node_stats", {
"instanceId": self.instance_id, "node": self.node_name,
- "nvidiaSmi": smi, **g,
+ "nvidiaSmi": smi, "cards": cards, **g,
})
await asyncio.sleep(1)
except asyncio.CancelledError:
diff --git a/xtts/voxtral/node_stats.py b/xtts/voxtral/node_stats.py
index f5a7de3..3e0074a 100644
--- a/xtts/voxtral/node_stats.py
+++ b/xtts/voxtral/node_stats.py
@@ -96,6 +96,31 @@ class NodeStats:
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
+ async def _query_cards(self) -> list:
+ """Pro Karte: Index, Name, Last, Speicher, Temperatur, Strom.
+ uuid, damit die Diagnostic dieselbe Karte erkennt, wenn mehrere
+ Dienste einer Box sie melden."""
+ out = await _run_cmd(
+ "nvidia-smi",
+ "--query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,"
+ "temperature.gpu,power.draw,power.limit",
+ "--format=csv,noheader,nounits")
+ cards = []
+ for line in out.strip().splitlines():
+ p = [x.strip() for x in line.split(",")]
+ if len(p) < 9:
+ continue
+
+ def num(v):
+ try:
+ return float(v)
+ except ValueError:
+ return None
+ cards.append({"index": p[0], "uuid": p[1], "name": p[2], "util": num(p[3]),
+ "memUsed": num(p[4]), "memTotal": num(p[5]), "temp": num(p[6]),
+ "power": num(p[7]), "powerLimit": num(p[8])})
+ return cards
+
async def _nvidia_smi_text(self) -> str:
txt = await _run_cmd("nvidia-smi")
return txt or "nvidia-smi nicht verfuegbar"
@@ -128,9 +153,10 @@ class NodeStats:
while time.time() - t0 < STREAM_MAX_SEC:
g = await self._query_gpu()
smi = await self._nvidia_smi_text()
+ cards = await self._query_cards()
await send(ws, "node_stats", {
"instanceId": self.instance_id, "node": self.node_name,
- "nvidiaSmi": smi, **g,
+ "nvidiaSmi": smi, "cards": cards, **g,
})
await asyncio.sleep(1)
except asyncio.CancelledError:
diff --git a/xtts/whisper/node_stats.py b/xtts/whisper/node_stats.py
index f5a7de3..3e0074a 100644
--- a/xtts/whisper/node_stats.py
+++ b/xtts/whisper/node_stats.py
@@ -96,6 +96,31 @@ class NodeStats:
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
+ async def _query_cards(self) -> list:
+ """Pro Karte: Index, Name, Last, Speicher, Temperatur, Strom.
+ uuid, damit die Diagnostic dieselbe Karte erkennt, wenn mehrere
+ Dienste einer Box sie melden."""
+ out = await _run_cmd(
+ "nvidia-smi",
+ "--query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,"
+ "temperature.gpu,power.draw,power.limit",
+ "--format=csv,noheader,nounits")
+ cards = []
+ for line in out.strip().splitlines():
+ p = [x.strip() for x in line.split(",")]
+ if len(p) < 9:
+ continue
+
+ def num(v):
+ try:
+ return float(v)
+ except ValueError:
+ return None
+ cards.append({"index": p[0], "uuid": p[1], "name": p[2], "util": num(p[3]),
+ "memUsed": num(p[4]), "memTotal": num(p[5]), "temp": num(p[6]),
+ "power": num(p[7]), "powerLimit": num(p[8])})
+ return cards
+
async def _nvidia_smi_text(self) -> str:
txt = await _run_cmd("nvidia-smi")
return txt or "nvidia-smi nicht verfuegbar"
@@ -128,9 +153,10 @@ class NodeStats:
while time.time() - t0 < STREAM_MAX_SEC:
g = await self._query_gpu()
smi = await self._nvidia_smi_text()
+ cards = await self._query_cards()
await send(ws, "node_stats", {
"instanceId": self.instance_id, "node": self.node_name,
- "nvidiaSmi": smi, **g,
+ "nvidiaSmi": smi, "cards": cards, **g,
})
await asyncio.sleep(1)
except asyncio.CancelledError: