From 91f979945e83b188cac74cb14163fef5c6875a34 Mon Sep 17 00:00:00 2001 From: MrEgosar Date: Thu, 29 Oct 2026 10:12:17 +0100 Subject: [PATCH] Admin: Auslastung der Docker-Hosts mit den laufenden Matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Admin-Übersicht zeigt pro Docker-Host belegte und freie Plätze und die Matches, die dort laufen. Ohne konfigurierte Hosts bleibt die Karte aus. --- client/src/pages/AdminPage.jsx | 36 +++++++++++++++++++++++++++++ client/src/styles.css | 12 ++++++++++ server/src/adminRoutes.js | 2 +- server/src/servers/dockerAdapter.js | 8 +++++++ server/src/servers/serverManager.js | 16 +++++++++++++ server/test/dockerHostPool.test.js | 8 +++++++ 6 files changed, 81 insertions(+), 1 deletion(-) diff --git a/client/src/pages/AdminPage.jsx b/client/src/pages/AdminPage.jsx index 19ef711..8f072cc 100644 --- a/client/src/pages/AdminPage.jsx +++ b/client/src/pages/AdminPage.jsx @@ -26,6 +26,7 @@ const storeToken = (t) => { export default function AdminPage() { const [token, setToken] = useState(getToken); const [matches, setMatches] = useState(null); + const [hosts, setHosts] = useState([]); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [busy, setBusy] = useState(null); @@ -42,6 +43,7 @@ export default function AdminPage() { try { const res = await api('/admin/matches', { token }); setMatches(res.matches); + setHosts(res.hosts ?? []); setError(null); } catch (err) { if (err.status === 401) { @@ -101,6 +103,7 @@ export default function AdminPage() { {tab === 'settings' && token && } {notice &&
{notice.text}
} {error &&
{error}
} + {tab === 'matches' && } {tab === 'matches' && matches === null && !error &&

Lade Matches …

} {tab === 'matches' && matches && matches.length === 0 &&

Noch keine Matches.

} @@ -386,3 +389,36 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) { ); } +/** Auslastung der Docker-Hosts: wie viele Gameserver läuft auf welchem Host, mit Liste der Matches. */ +function HostUsage({ hosts }) { + if (hosts.length === 0) return null; + return ( +
+

Docker-Hosts

+
+ {hosts.map((h) => { + const percent = h.total ? Math.round((h.used / h.total) * 100) : 0; + return ( +
= 90 ? 'is-full' : ''}`}> +
+ {h.name} + {h.publicHost} + {h.used} / {h.total} belegt +
+
+ +
+ {h.matches.length === 0 ? ( +

Keine Gameserver

+ ) : ( +
    + {h.matches.map((m) =>
  • {m.title} {m.status}
  • )} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/client/src/styles.css b/client/src/styles.css index eb73b81..bd3e419 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -554,3 +554,15 @@ input:focus, select:focus { border-color: var(--link); outline: none; } .run-vs { font-size: 1rem; color: var(--muted); font-style: normal; } .run-foot { display: flex; justify-content: space-between; gap: 8px; font-size: .875rem; color: var(--muted); } .run-series { font-variant-numeric: tabular-nums; } +.host-usage { margin-bottom: 20px; } +.host-usage h2 { font-size: 1.1rem; margin: 0 0 10px; } +.host-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; } +.host-card { border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius); padding: 12px 14px; } +.host-card.is-full { border-color: var(--pending); } +.host-head { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; margin-bottom: 8px; } +.host-count { margin-left: auto; font-variant-numeric: tabular-nums; font-size: .875rem; } +.host-bar { height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; margin-bottom: 8px; } +.host-bar span { display: block; height: 100%; background: var(--primary); transition: width .3s ease; } +.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); } diff --git a/server/src/adminRoutes.js b/server/src/adminRoutes.js index cdec94e..2aa1e1e 100644 --- a/server/src/adminRoutes.js +++ b/server/src/adminRoutes.js @@ -91,7 +91,7 @@ export function createAdminRouter({ service, manager, config }) { // Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse) router.get('/matches', requireAdmin, wrap(() => { 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))) }; + return { matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))), hosts: 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 5804f5a..e6adbeb 100644 --- a/server/src/servers/dockerAdapter.js +++ b/server/src/servers/dockerAdapter.js @@ -248,6 +248,14 @@ export class DockerHostPool { return free; } + /** Auslastung pro Host: Anzahl Port-Paare insgesamt und belegt. */ + hostStats() { + return this.hosts.map((h) => { + const total = Math.floor((h.portRangeEnd - h.portRangeStart + 1) / 2); + return { name: h.name, publicHost: h.publicHost, total, used: total - this.freeSlots(h) }; + }); + } + getUsedPorts(publicHost) { return this.depsGetUsedPorts?.(publicHost) ?? new Set(); } diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js index 5a82c8d..43f99bd 100644 --- a/server/src/servers/serverManager.js +++ b/server/src/servers/serverManager.js @@ -160,6 +160,22 @@ export class ServerManager { this.service.changed(matchId); } + /** Auslastung der Docker-Hosts mit den Matches, die dort laufen. Leer, wenn der Adapter keine Hosts kennt. */ + 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) => ({ + ...h, + 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 })), + })); + } + /** Ports, die auf einem bestimmten Docker-Host belegt sind. */ usedPortsOn(host) { const used = new Set(); diff --git a/server/test/dockerHostPool.test.js b/server/test/dockerHostPool.test.js index 7ee4223..d2559ac 100644 --- a/server/test/dockerHostPool.test.js +++ b/server/test/dockerHostPool.test.js @@ -80,3 +80,11 @@ test('Alte Handles ohne Host-Namen laufen auf dem ersten Host', () => { const { pool: p } = pool([hostA, hostB]); assert.equal(p.adapterFor({ container: 'x' }), p.adapters.get('a')); }); + +test('Auslastung pro Host: Port-Paare insgesamt und belegt', () => { + const { pool: p } = pool([hostA, hostB], { '10.0.0.1': [27100] }); + assert.deepEqual(p.hostStats(), [ + { name: 'a', publicHost: '10.0.0.1', total: 2, used: 1 }, + { name: 'b', publicHost: '10.0.0.2', total: 20, used: 0 }, + ]); +});