From 1243e9c8527323a364cc3cfdd1fa6d05b50b7471 Mon Sep 17 00:00:00 2001 From: MrEgosar Date: Thu, 29 Oct 2026 10:30:01 +0100 Subject: [PATCH] Admin: freier Speicher pro Docker-Host anzeigen Die Karte Docker-Hosts zeigt den freien Speicher unter /var/lib/docker, per SSH mit df gemessen und eine Minute zwischengespeichert. Unter 20 GB wird die Zeile rot. --- client/src/pages/AdminPage.jsx | 8 ++++++ client/src/styles.css | 2 ++ server/src/adminRoutes.js | 4 +-- server/src/servers/dockerAdapter.js | 41 +++++++++++++++++++++++++++++ server/src/servers/serverManager.js | 7 +++-- server/test/dockerHostPool.test.js | 8 ++++++ 6 files changed, 66 insertions(+), 4 deletions(-) diff --git a/client/src/pages/AdminPage.jsx b/client/src/pages/AdminPage.jsx index 8f072cc..cf4b62e 100644 --- a/client/src/pages/AdminPage.jsx +++ b/client/src/pages/AdminPage.jsx @@ -408,6 +408,11 @@ function HostUsage({ hosts }) {
+ {h.disk && ( +
+ Speicher: {gb(h.disk.freeBytes)} frei von {gb(h.disk.totalBytes)} +
+ )} {h.matches.length === 0 ? (

Keine Gameserver

) : ( @@ -422,3 +427,6 @@ function HostUsage({ hosts }) { ); } +function gb(bytes) { + return `${Math.round(bytes / 1e9)} GB`; +} diff --git a/client/src/styles.css b/client/src/styles.css index bd3e419..87abb8c 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -566,3 +566,5 @@ input:focus, select:focus { border-color: var(--link); outline: none; } .host-card.is-full .host-bar span { background: var(--pending); } .host-matches { list-style: none; margin: 0; padding: 0; font-size: .9rem; } .host-matches li { padding: 3px 0; border-top: 1px solid var(--line); } +.host-disk { font-size: .85rem; color: var(--muted); margin: -2px 0 8px; font-variant-numeric: tabular-nums; } +.host-disk.is-low { color: var(--error); font-weight: 600; } diff --git a/server/src/adminRoutes.js b/server/src/adminRoutes.js index 2aa1e1e..ee3d38b 100644 --- a/server/src/adminRoutes.js +++ b/server/src/adminRoutes.js @@ -89,9 +89,9 @@ export function createAdminRouter({ service, manager, config }) { })); // Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse) - router.get('/matches', requireAdmin, wrap(() => { + router.get('/matches', requireAdmin, wrap(async () => { const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all(); - return { matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))), hosts: manager.hostUsage() }; + return { matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))), hosts: await manager.hostUsage() }; })); const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => { diff --git a/server/src/servers/dockerAdapter.js b/server/src/servers/dockerAdapter.js index e6adbeb..261e680 100644 --- a/server/src/servers/dockerAdapter.js +++ b/server/src/servers/dockerAdapter.js @@ -4,6 +4,16 @@ import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from import { runRcon } from './rcon.js'; const exec = promisify(execFile); +const DISK_CACHE_MS = 60_000; + +/** Liest Gesamt- und freien Speicher aus einer Zeile von `df -B1` (Spalten: Filesystem, 1B-blocks, Used, Available …). */ +export function parseDf(output) { + const parts = String(output).trim().split(/\s+/); + const totalBytes = Number(parts[1]); + const freeBytes = Number(parts[3]); + if (!Number.isFinite(totalBytes) || !Number.isFinite(freeBytes)) return null; + return { totalBytes, freeBytes }; +} const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // Diese Zeile schreibt das Image, sobald SteamCMD fertig ist und der Spielserver startet. @@ -236,6 +246,37 @@ export class DockerHostPool { getUsedPorts: () => (getUsedPorts ? getUsedPorts(h.publicHost) : new Set()), runDocker, })])); + this.diskCache = new Map(); + this.diskInflight = new Map(); + } + + /** + * Freier Speicher unter /var/lib/docker auf einem Host, per SSH. Ergebnis wird eine Minute lang + * zwischengespeichert, weil die Admin-Seite alle paar Sekunden abfragt. Ohne ssh:// gibt es null. + */ + async diskUsage(name) { + const host = this.hosts.find((h) => h.name === name); + if (!host?.dockerHost?.startsWith('ssh://')) return null; + const cached = this.diskCache.get(name); + if (cached && Date.now() - cached.at < DISK_CACHE_MS) return cached.value; + if (!this.diskInflight.has(name)) { + this.diskInflight.set(name, (async () => { + let value = null; + try { + const { stdout } = await exec('ssh', [ + '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', host.dockerHost.slice('ssh://'.length), + 'df -B1 /var/lib/docker 2>/dev/null || df -B1 /', + ], { timeout: 10_000 }); + value = parseDf(stdout.trim().split('\n').pop()); + } catch { + value = null; + } + this.diskCache.set(name, { at: Date.now(), value }); + this.diskInflight.delete(name); + return value; + })()); + } + return this.diskInflight.get(name); } /** Freie Port-Paare auf einem Host. */ diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js index 43f99bd..0cc11a3 100644 --- a/server/src/servers/serverManager.js +++ b/server/src/servers/serverManager.js @@ -161,15 +161,18 @@ export class ServerManager { } /** Auslastung der Docker-Hosts mit den Matches, die dort laufen. Leer, wenn der Adapter keine Hosts kennt. */ - hostUsage() { + async hostUsage() { if (!this.adapter.hostStats) return []; const rows = this.db.prepare(` SELECT s.host, s.status, m.id AS match_id, m.team_a_name, m.team_b_name FROM servers s JOIN matches m ON m.id = s.match_id WHERE s.status != 'stopped' AND s.host IS NOT NULL `).all(); - return this.adapter.hostStats().map((h) => ({ + const stats = this.adapter.hostStats(); + const disks = await Promise.all(stats.map((h) => this.adapter.diskUsage?.(h.name) ?? null)); + return stats.map((h, i) => ({ ...h, + disk: disks[i], matches: rows .filter((r) => r.host === h.publicHost) .map((r) => ({ id: r.match_id, title: `${r.team_a_name} vs. ${r.team_b_name}`, status: r.status })), diff --git a/server/test/dockerHostPool.test.js b/server/test/dockerHostPool.test.js index d2559ac..f1c458a 100644 --- a/server/test/dockerHostPool.test.js +++ b/server/test/dockerHostPool.test.js @@ -88,3 +88,11 @@ test('Auslastung pro Host: Port-Paare insgesamt und belegt', () => { { name: 'b', publicHost: '10.0.0.2', total: 20, used: 0 }, ]); }); +test('df-Ausgabe wird in Gesamt- und freien Speicher zerlegt', async () => { + const { parseDf } = await import('../src/servers/dockerAdapter.js'); + assert.deepEqual(parseDf('/dev/sda1 1000000000000 400000000000 600000000000 40% /var/lib/docker'), { + totalBytes: 1000000000000, + freeBytes: 600000000000, + }); + assert.equal(parseDf('kaputt'), null); +});