Admin: Auslastung der Docker-Hosts mit den laufenden Matches

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.
This commit is contained in:
mregosar committed 2026-10-29 14:37:05 +01:00
1 parent 50dcb2537d
commit 69fb27f44b
6 files changed
+81 -1

No files matched your search

+36
View File
@@ -26,6 +26,7 @@ const storeToken = (t) => {
export default function AdminPage() { export default function AdminPage() {
const [token, setToken] = useState(getToken); const [token, setToken] = useState(getToken);
const [matches, setMatches] = useState(null); const [matches, setMatches] = useState(null);
const [hosts, setHosts] = useState([]);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [notice, setNotice] = useState(null); const [notice, setNotice] = useState(null);
const [busy, setBusy] = useState(null); const [busy, setBusy] = useState(null);
@@ -42,6 +43,7 @@ export default function AdminPage() {
try { try {
const res = await api('/admin/matches', { token }); const res = await api('/admin/matches', { token });
setMatches(res.matches); setMatches(res.matches);
setHosts(res.hosts ?? []);
setError(null); setError(null);
} catch (err) { } catch (err) {
if (err.status === 401) { if (err.status === 401) {
@@ -101,6 +103,7 @@ export default function AdminPage() {
{tab === 'settings' && token && <NotificationSettings token={token} setNotice={setNotice} />} {tab === 'settings' && token && <NotificationSettings token={token} setNotice={setNotice} />}
{notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>} {notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>}
{error && <div className="alert alert-error">{error}</div>} {error && <div className="alert alert-error">{error}</div>}
{tab === 'matches' && <HostUsage hosts={hosts} />}
{tab === 'matches' && matches === null && !error && <p className="muted">Lade Matches …</p>} {tab === 'matches' && matches === null && !error && <p className="muted">Lade Matches …</p>}
{tab === 'matches' && matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>} {tab === 'matches' && matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>}
@@ -386,3 +389,36 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
</section> </section>
); );
} }
/** 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 (
<section className="host-usage">
<h2>Docker-Hosts</h2>
<div className="host-grid">
{hosts.map((h) => {
const percent = h.total ? Math.round((h.used / h.total) * 100) : 0;
return (
<div key={h.name} className={`host-card ${percent >= 90 ? 'is-full' : ''}`}>
<div className="host-head">
<strong>{h.name}</strong>
<span className="muted small">{h.publicHost}</span>
<span className="host-count">{h.used} / {h.total} belegt</span>
</div>
<div className="host-bar" role="progressbar" aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${percent}%` }} />
</div>
{h.matches.length === 0 ? (
<p className="muted small">Keine Gameserver</p>
) : (
<ul className="host-matches">
{h.matches.map((m) => <li key={m.id}>{m.title} <span className="muted small">{m.status}</span></li>)}
</ul>
)}
</div>
);
})}
</div>
</section>
);
}
+12
View File
@@ -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-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-foot { display: flex; justify-content: space-between; gap: 8px; font-size: .875rem; color: var(--muted); }
.run-series { font-variant-numeric: tabular-nums; } .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); }
+1 -1
View File
@@ -91,7 +91,7 @@ export function createAdminRouter({ service, manager, config }) {
// Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse) // Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
router.get('/matches', requireAdmin, wrap(() => { router.get('/matches', requireAdmin, wrap(() => {
const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all(); 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) => { const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => {
+8
View File
@@ -248,6 +248,14 @@ export class DockerHostPool {
return free; 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) { getUsedPorts(publicHost) {
return this.depsGetUsedPorts?.(publicHost) ?? new Set(); return this.depsGetUsedPorts?.(publicHost) ?? new Set();
} }
+16
View File
@@ -160,6 +160,22 @@ export class ServerManager {
this.service.changed(matchId); 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. */ /** Ports, die auf einem bestimmten Docker-Host belegt sind. */
usedPortsOn(host) { usedPortsOn(host) {
const used = new Set(); const used = new Set();
+8
View File
@@ -80,3 +80,11 @@ test('Alte Handles ohne Host-Namen laufen auf dem ersten Host', () => {
const { pool: p } = pool([hostA, hostB]); const { pool: p } = pool([hostA, hostB]);
assert.equal(p.adapterFor({ container: 'x' }), p.adapters.get('a')); 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 },
]);
});