Compare commits

...
Author SHA1 Message Date
mregosar 1243e9c852 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.
2026-10-29 10:30:01 +01:00
mregosar 844da4713d Merge feature/host-usage in main 2026-10-29 10:12:17 +01:00
mregosar 91f979945e 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.
2026-10-29 10:12:17 +01:00
mregosar c1c9250126 Merge feature/multi-docker-host in main 2026-10-29 10:00:20 +01:00
mregosar eda15bc585 Mehrere Docker-Hosts: Matches verteilt auf die Hosts starten
CS2_DOCKER_HOSTS nimmt eine Liste von Docker-Hosts. Jedes Match läuft auf
dem Host mit den meisten freien Ports und merkt sich seinen Host, damit
Status, Konfiguration und Stopp dort landen. Ohne die Variable bleibt alles
beim bisherigen einzelnen Host. deploy/docker-host-einrichten.sh richtet
einen Host per SSH ein.
2026-10-29 10:00:19 +01:00
mregosar 1a304cb398 Merge feature/public-live-view in main 2026-10-29 09:35:35 +01:00
mregosar cd7f7e0468 Merge feature/overview-live-cards in main 2026-10-29 09:35:35 +01:00
mregosar e1ee009063 Gäste sehen laufende Matches: Veto, Serienstand und Map-Reihenfolge
Ohne Zugang sind Veto und Serienstand sichtbar. Spielerlisten, Codes,
Verbindungsdaten und das Ereignisprotokoll bleiben Teilnehmern vorbehalten.
2026-10-29 09:35:35 +01:00
mregosar f90351dd8d Übersicht: laufende Matches als Karten mit Map, Stand und Serie 2026-10-29 09:29:12 +01:00
mregosar d2625d23ab Merge docs/admin-hilfe in main 2026-10-29 09:22:01 +01:00
mregosar 8b6abdb8b2 Merge feature/admin-status in main 2026-10-29 09:22:01 +01:00
mregosar eb80c4845c Docs: Hilfe für Turnier-Admins (PDF mit Screenshots) 2026-10-29 09:21:33 +01:00
16 changed files with 509 additions and 15 deletions

No files matched your search

+3
View File
@@ -52,6 +52,9 @@ CS2_PORT_RANGE_END=27115
# Gemeinsames Volume für die Spieldateien (~60 GB), damit nicht jedes Match neu herunterlädt
CS2_DOCKER_VOLUME=cs2-mapveto-gamefiles
CS2_DOCKER_EXTRA_ARGS=
# Optional: mehrere Docker-Hosts als JSON-Liste (eine Zeile). Leer = nur CS2_PUBLIC_HOST/DOCKER_HOST.
# Einrichtung pro Host: bash deploy/docker-host-einrichten.sh <ssh-ziel>
# CS2_DOCKER_HOSTS=[{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139}]
CS2_START_TIMEOUT_MINUTES=20
DOCKER_BIN=docker
+18
View File
@@ -236,6 +236,24 @@ Voraussetzungen:
Container heißen `cs2mv-<matchId>` und tragen das Label `cs2-mapveto=1`. Stoppen bedeutet `docker rm -f`.
### Mehrere Docker-Hosts
Mit `CS2_DOCKER_HOSTS` verteilt die Anwendung Matches auf mehrere Docker-Hosts. Jedes neue Match läuft auf dem Host mit den meisten freien Ports. Der Host bleibt im Match gespeichert, Status, Konfiguration und Stopp gehen immer an denselben Host.
1. Jeden Host einrichten (SSH-Schlüssel, Docker, Volume, Image):
```bash
bash deploy/docker-host-einrichten.sh cs2@192.168.178.111
```
2. Die Hosts in der `.env` eintragen, als JSON-Liste in einer Zeile:
```
CS2_DOCKER_HOSTS=[{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139},{"name":"host2","dockerHost":"ssh://cs2@192.168.178.112","publicHost":"192.168.178.112","portStart":27100,"portEnd":27139}]
```
Jeder Host braucht eigene Spieldateien (rund 60 bis 90 GB). Die Ports müssen auf jedem Host frei sein.
### Option B: Vorhandene Server (`SERVER_ADAPTER=static`)
Für selbst betriebene, dauerhaft laufende CS2-Server:
+44
View File
@@ -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 && <NotificationSettings token={token} setNotice={setNotice} />}
{notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</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 && matches.length === 0 && <p className="muted">Noch keine Matches.</p>}
@@ -386,3 +389,44 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
</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.disk && (
<div className={`host-disk ${h.disk.freeBytes < 20e9 ? 'is-low' : ''}`}>
Speicher: {gb(h.disk.freeBytes)} frei von {gb(h.disk.totalBytes)}
</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>
);
}
function gb(bytes) {
return `${Math.round(bytes / 1e9)} GB`;
}
+5 -5
View File
@@ -127,7 +127,7 @@ export default function MatchPage() {
{viewer.role === 'public' && <PublicNotice />}
<div className={`match-grid ${state.status !== 'lobby' && viewer.role !== 'public' ? 'stage-wide' : ''}`}>
<div className={`match-grid ${state.status !== 'lobby' ? 'stage-wide' : ''}`}>
<TeamCard team={state.teams.A} side="A" {...ctx} />
<section className="stage">
<Stage {...ctx} />
@@ -156,8 +156,8 @@ function describeViewer(viewer, teams) {
function Stage(ctx) {
const { state, isOrg, act, busy } = ctx;
if (state.viewer.role === 'public') {
return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Details sehen nur Teilnehmer.</p></div>;
if (state.viewer.role === 'public' && state.status === 'lobby') {
return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Das Veto und der Spielstand erscheinen hier, sobald das Match startet.</p></div>;
}
if (state.status === 'lobby') {
const { A, B } = state.teams;
@@ -230,8 +230,8 @@ function MapPoolPreview({ maps }) {
function PublicNotice() {
return (
<div className="alert">
Du siehst diese Lobby ohne Zugang. Spieler treten über den Einladungslink ihres Teams bei,
Zuschauer über den Zuschauerlink. Beides bekommst du vom Organisator.
Du siehst dieses Match ohne Zugang. Veto, Serienstand und Ergebnisse sind öffentlich.
Teamdetails und Verbindungsdaten gibt es nur für Teilnehmer.
</div>
);
}
+34 -1
View File
@@ -4,6 +4,7 @@ import { api } from '../lib/api.js';
import { FORMAT_LABEL, serverLabel, timeAgo } from '../lib/labels.js';
import { getAccess } from '../lib/session.js';
import { useOverview } from '../lib/useMatch.js';
import MapImage from '../components/MapImage.jsx';
const GROUPS = [
{ key: 'open', title: 'Offen', empty: 'Keine offenen Lobbys. Erstelle ein Match, um zu starten.' },
@@ -32,7 +33,11 @@ export default function Overview() {
return (
<section key={g.key} className={`group group-${g.key}`}>
<h2>{g.title} <span className="count">{list.length}</span></h2>
{list.length === 0 ? <p className="muted small">{g.empty}</p> : (
{list.length === 0 ? <p className="muted small">{g.empty}</p> : g.key === 'running' ? (
<div className="run-grid">
{list.map((m) => <RunningCard key={m.id} match={m} />)}
</div>
) : (
<ul className="match-list">
{list.map((m) => <MatchRow key={m.id} match={m} />)}
</ul>
@@ -79,6 +84,34 @@ function MatchRow({ match: m }) {
);
}
/** Karte für ein laufendes Match: Map als Hintergrund, Stand groß, Serie und Status darunter. */
function RunningCard({ match: m }) {
const server = serverLabel(m.server);
const map = m.currentMap;
const live = map && !map.finished;
const bo1 = m.format === 'bo1';
return (
<Link to={`/m/${m.id}`} className={`run-card ${live ? 'is-live' : ''}`} style={{ '--map': map?.color ?? '#3a4150' }}>
{map?.image && <MapImage map={{ image: map.image, color: map.color, name: map.name }} className="run-art" />}
<div className="run-top">
<span className="run-mode">{m.modeLabel}, {FORMAT_LABEL[m.format]}</span>
{live ? <span className="live-badge run-live">Live</span> : <span className={`run-status tone-${server.tone}`}>{m.statusLabel}</span>}
</div>
<div className="run-teams">
<span className="run-team a">{m.teamA}</span>
<span className="run-score">
{map ? <>{map.score.A}<i>:</i>{map.score.B}</> : <i className="run-vs">vs.</i>}
</span>
<span className="run-team b">{m.teamB}</span>
</div>
<div className="run-foot">
<span>{map ? `Map ${map.number}: ${map.name}` : 'Veto läuft'}</span>
{!bo1 && m.seriesScore && <span className="run-series">Serie {m.seriesScore.A}:{m.seriesScore.B}</span>}
</div>
</Link>
);
}
function CodeEntry() {
const [code, setCode] = useState('');
const [error, setError] = useState(null);
+34
View File
@@ -534,3 +534,37 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.admin-badge { display: inline-block; margin-top: 6px; padding: 2px 10px; border-radius: 999px; font-size: .8rem; font-weight: 600; }
.admin-badge.is-paused { background: color-mix(in srgb, var(--pending) 22%, transparent); color: var(--pending); border: 1px solid var(--pending); }
.btn-warn { border-color: var(--pending); color: var(--pending); }
/* Laufende Matches in der Übersicht */
.run-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.run-card { position: relative; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-height: 190px; padding: 16px 18px; border-radius: var(--radius); border: 1px solid var(--line); background: var(--surface); color: var(--text); text-decoration: none; overflow: hidden; isolation: isolate; transition: transform .15s ease, border-color .15s ease; }
.run-card:hover { transform: translateY(-2px); border-color: var(--map); }
.run-card.is-live { box-shadow: inset 3px 0 0 var(--brand-red); }
.run-art { position: absolute; inset: 0; z-index: -1; opacity: .28; }
.run-art::after { background: linear-gradient(180deg, rgba(0,0,0,.2), rgba(0,0,0,.92)); }
.run-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-size: .8rem; color: var(--muted); }
.run-live { animation: run-pulse 1.6s ease-in-out infinite; }
@keyframes run-pulse { 50% { opacity: .55; } }
.run-status { font-weight: 600; }
.run-teams { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px; text-align: center; }
.run-team { font: 700 1.25rem var(--display); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.run-team.a { color: var(--team-a); text-align: right; }
.run-team.b { color: var(--team-b); text-align: left; }
.run-score { font: 700 2.25rem var(--display); font-variant-numeric: tabular-nums; }
.run-score i { font-style: normal; color: var(--muted); padding: 0 4px; }
.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); }
.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; }
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Richtet einen Docker-Host für CS2 Mapveto ein. Läuft vom eigenen Rechner aus per SSH.
#
# Aufruf: bash deploy/docker-host-einrichten.sh <ssh-ziel> [volume]
# Beispiel: bash deploy/docker-host-einrichten.sh cs2@192.168.178.111
#
# Voraussetzungen: SSH-Schlüssel hinterlegt (ohne Passwortabfrage), Docker auf dem Host installiert.
set -euo pipefail
TARGET="${1:?Aufruf: $0 <ssh-ziel> [volume]}"
VOLUME="${2:-cs2-mapveto-gamefiles}"
IMAGE="joedwards32/cs2:latest"
HOST_PART="${TARGET#*@}"
echo "→ Verbindung zu $TARGET prüfen"
ssh -o BatchMode=yes -o ConnectTimeout=10 "$TARGET" true
echo "→ Docker prüfen"
if ! ssh "$TARGET" "command -v docker >/dev/null 2>&1"; then
echo "Docker fehlt auf $HOST_PART. Installation: https://docs.docker.com/engine/install/" >&2
exit 1
fi
ssh "$TARGET" "docker info >/dev/null"
echo "→ Freier Platz für Docker (CS2 braucht rund 90 GB)"
ssh "$TARGET" "df -BG /var/lib/docker 2>/dev/null | tail -1 || df -BG / | tail -1"
echo "→ Volume $VOLUME anlegen (falls nicht vorhanden)"
ssh "$TARGET" "docker volume inspect $VOLUME >/dev/null 2>&1 || docker volume create $VOLUME >/dev/null"
echo "→ Image $IMAGE laden"
ssh "$TARGET" "docker pull $IMAGE"
cat <<EOF
Fertig. Diesen Eintrag in CS2_DOCKER_HOSTS (JSON-Liste in der .env) aufnehmen:
{"name":"$HOST_PART","dockerHost":"ssh://$TARGET","publicHost":"$HOST_PART","portStart":27100,"portEnd":27139}
Hinweise:
- name muss pro Host eindeutig sein.
- Ports 27100–27139 dürfen auf dem Host frei sein. Pro Host sind bis zu 20 Matches möglich.
- Der erste Server-Start lädt die CS2-Spieldateien (rund 60 bis 90 GB). Das dauert lange.
EOF
Binary file not shown.
+2 -2
View File
@@ -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))) };
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) => {
+3 -1
View File
@@ -20,7 +20,9 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
const service = new MatchService({ db, pools, config, notifier });
let manager;
const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() });
const adapter = adapterOverride ?? createServerAdapter(config, {
getUsedPorts: (host) => (host ? manager.usedPortsOn(host) : manager.usedPorts()),
});
manager = new ServerManager({ db, service, adapter, config, logger });
manager.init();
service.startVetoTimer();
+29
View File
@@ -22,6 +22,32 @@ function bool(name, fallback) {
return ['1', 'true', 'yes', 'ja', 'on'].includes(raw.toLowerCase());
}
function parseDockerHosts(raw) {
if (!raw) return null;
let list;
try {
list = JSON.parse(raw);
} catch {
throw new Error('CS2_DOCKER_HOSTS ist kein gültiges JSON');
}
if (!Array.isArray(list) || list.length === 0) throw new Error('CS2_DOCKER_HOSTS braucht mindestens einen Eintrag');
const names = new Set();
return list.map((h) => {
if (!h.name || !h.publicHost) throw new Error('Jeder Docker-Host braucht name und publicHost');
if (names.has(h.name)) throw new Error(`Docker-Host-Name "${h.name}" kommt doppelt vor`);
names.add(h.name);
return {
name: String(h.name),
dockerHost: h.dockerHost ?? null,
publicHost: String(h.publicHost),
rconHost: h.rconHost ?? h.publicHost,
portRangeStart: h.portStart == null ? undefined : Number(h.portStart),
portRangeEnd: h.portEnd == null ? undefined : Number(h.portEnd),
volume: h.volume ?? undefined,
};
});
}
function str(name, fallback = '') {
const raw = process.env[name];
return raw === undefined || raw === '' ? fallback : raw;
@@ -85,6 +111,9 @@ export function loadConfig(overrides = {}) {
volume: str('CS2_DOCKER_VOLUME', 'cs2-mapveto-gamefiles'),
extraArgs: str('CS2_DOCKER_EXTRA_ARGS'),
startTimeoutMinutes: int('CS2_START_TIMEOUT_MINUTES', 20),
// Optional: mehrere Docker-Hosts als JSON-Liste, z. B.
// [{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139}]
hosts: parseDockerHosts(str('CS2_DOCKER_HOSTS')),
},
staticPool: {
+9 -2
View File
@@ -791,7 +791,14 @@ export class MatchService extends EventEmitter {
return {
seriesScore: maps.length ? seriesScore(maps) : null,
currentMap: current && (current.status === 'live' || current.status === 'done')
? { number: current.number, name: this.mapName(m, current.map), score: { A: current.score_a, B: current.score_b }, finished: current.status === 'done' }
? {
number: current.number,
name: this.mapName(m, current.map),
image: JSON.parse(m.maps).find((x) => x.id === current.map)?.image ?? null,
color: JSON.parse(m.maps).find((x) => x.id === current.map)?.color ?? null,
score: { A: current.score_a, B: current.score_b },
finished: current.status === 'done',
}
: null,
id: m.id,
teamA: m.team_a_name,
@@ -885,7 +892,7 @@ export class MatchService extends EventEmitter {
requireFullTeams: this.config.requireFullTeams,
autoAdvanceSeconds: this.config.autoAdvanceSeconds,
maps: pool.maps,
veto: publicView
veto: publicView && match.status === 'lobby'
? null
: {
started: match.status !== 'lobby',
+2 -2
View File
@@ -29,7 +29,7 @@
*/
import { DemoServerAdapter } from './demoAdapter.js';
import { DockerServerAdapter } from './dockerAdapter.js';
import { DockerHostPool } from './dockerAdapter.js';
import { StaticPoolServerAdapter } from './staticPoolAdapter.js';
export function createServerAdapter(config, deps) {
@@ -37,7 +37,7 @@ export function createServerAdapter(config, deps) {
case 'demo':
return new DemoServerAdapter(config.demo);
case 'docker':
return new DockerServerAdapter(config.docker, deps);
return new DockerHostPool(config.docker, deps);
case 'static':
return new StaticPoolServerAdapter(config.staticPool, deps);
default:
+155 -2
View File
@@ -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.
@@ -37,7 +47,8 @@ export class DockerServerAdapter {
async docker(args, timeout = 60_000) {
if (this.runDocker) return this.runDocker(args);
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024 });
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env });
return stdout.trim();
}
@@ -137,7 +148,14 @@ export class DockerServerAdapter {
await this.docker(args, 15 * 60_000);
return {
handle: { container, rconHost: this.options.rconHost, port, startedAt: Date.now(), rconPassword: secrets.rconPassword },
handle: {
container,
hostName: this.options.name,
rconHost: this.options.rconHost,
port,
startedAt: Date.now(),
rconPassword: secrets.rconPassword,
},
host: this.options.host,
port,
tvPort,
@@ -192,3 +210,138 @@ export class DockerServerAdapter {
await this.docker(['rm', '-f', handle.container]).catch(() => {});
}
}
/**
* Mehrere Docker-Hosts. Jedes Match läuft auf dem Host mit den meisten freien Port-Paaren,
* der Host steht im Handle und damit im Match. Start, Status, Konfiguration und Stopp gehen
* immer an denselben Host. Ohne CS2_DOCKER_HOSTS gibt es genau einen Host: den bisherigen.
*/
export class DockerHostPool {
constructor(options, { getUsedPorts, runDocker } = {}) {
this.name = 'docker';
this.simulated = false;
this.depsGetUsedPorts = getUsedPorts;
const hosts = options.hosts ?? [{
name: 'default',
dockerHost: process.env.DOCKER_HOST || null,
publicHost: options.host,
rconHost: options.rconHost,
portRangeStart: options.portRangeStart,
portRangeEnd: options.portRangeEnd,
volume: options.volume,
}];
// Fehlende Angaben kommen aus den allgemeinen Docker-Einstellungen
this.hosts = hosts.map((h) => {
const host = {
name: h.name,
dockerHost: h.dockerHost ?? null,
publicHost: h.publicHost ?? options.host,
rconHost: h.rconHost ?? options.rconHost,
portRangeStart: h.portRangeStart ?? options.portRangeStart,
portRangeEnd: h.portRangeEnd ?? options.portRangeEnd,
volume: h.volume ?? options.volume,
};
return { ...options, ...host, host: host.publicHost };
});
this.adapters = new Map(this.hosts.map((h) => [h.name, new DockerServerAdapter(h, {
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. */
freeSlots(host) {
const used = this.getUsedPorts(host.publicHost);
let free = 0;
for (let p = host.portRangeStart; p + 1 <= host.portRangeEnd; p += 2) {
if (!used.has(p) && !used.has(p + 1)) free += 1;
}
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();
}
pickHost() {
const best = this.hosts
.map((h) => ({ host: h, free: this.freeSlots(h) }))
.filter((x) => x.free > 0)
.sort((a, b) => b.free - a.free)[0];
if (!best) throw new Error('Kein Docker-Host mit freien Ports');
return best.host;
}
async start(args) {
return this.adapters.get(this.pickHost().name).start(args);
}
adapterFor(handle) {
const name = handle?.hostName ?? this.hosts[0].name;
const adapter = this.adapters.get(name);
if (!adapter) throw new Error(`Docker-Host "${name}" ist nicht (mehr) konfiguriert`);
return adapter;
}
async status(handle) {
return this.adapterFor(handle).status(handle);
}
async configure(handle, plan) {
return this.adapterFor(handle).configure(handle, plan);
}
async listRoundBackups(handle, matchId) {
return this.adapterFor(handle).listRoundBackups(handle, matchId);
}
async loadRoundBackup(handle, file) {
return this.adapterFor(handle).loadRoundBackup(handle, file);
}
async sendCommands(handle, commands) {
return this.adapterFor(handle).sendCommands(handle, commands);
}
async stop(handle) {
return this.adapterFor(handle).stop(handle);
}
}
+29
View File
@@ -160,6 +160,35 @@ export class ServerManager {
this.service.changed(matchId);
}
/** Auslastung der Docker-Hosts mit den Matches, die dort laufen. Leer, wenn der Adapter keine Hosts kennt. */
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();
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 })),
}));
}
/** Ports, die auf einem bestimmten Docker-Host belegt sind. */
usedPortsOn(host) {
const used = new Set();
for (const r of this.db.prepare(`SELECT port, tv_port FROM servers WHERE host = ? AND status != 'stopped' AND port IS NOT NULL`).all(host)) {
used.add(r.port);
used.add(r.tv_port);
}
return used;
}
usedPorts() {
const used = new Set();
for (const r of this.db.prepare(`SELECT host, port, tv_port FROM servers WHERE status != 'stopped' AND port IS NOT NULL`).all()) {
+98
View File
@@ -0,0 +1,98 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DockerHostPool } from '../src/servers/dockerAdapter.js';
const secrets = { rconPassword: 'Rcon123', serverPassword: 'Pw123', tvPassword: 'Tv123' };
const hostA = { name: 'a', publicHost: '10.0.0.1', portRangeStart: 27100, portRangeEnd: 27103 }; // 2 Paare
const hostB = { name: 'b', publicHost: '10.0.0.2', portRangeStart: 27100, portRangeEnd: 27139 }; // 20 Paare
function options(hosts) {
return {
bin: 'docker', image: 'joedwards32/cs2:latest', host: '10.0.0.9', rconHost: '10.0.0.9',
portRangeStart: 27015, portRangeEnd: 27115, gslt: '', volume: 'cs2-mapveto-gamefiles', extraArgs: '',
startTimeoutMinutes: 1, hosts,
};
}
/** Docker-Aufrufe merken, Container gelten sofort als fertig. */
function fakeDocker() {
const calls = [];
const run = async (args) => {
calls.push(args);
if (args[0] === 'inspect') return 'running';
if (args[0] === 'logs') return 'Starting CS2 Dedicated Server';
return '';
};
return { run, calls };
}
/** Nur die Konfiguration der Hosts, ohne auf echte Container zu warten. */
function pool(hosts, used = {}) {
const docker = fakeDocker();
const p = new DockerHostPool(options(hosts), {
getUsedPorts: (publicHost) => new Set(used[publicHost] ?? []),
runDocker: docker.run,
});
for (const a of p.adapters.values()) a.waitForUpdateCheck = async () => {};
return { pool: p, calls: docker.calls };
}
test('Ohne CS2_DOCKER_HOSTS gibt es genau einen Host, wie bisher', () => {
const { pool: p } = pool(null);
assert.equal(p.hosts.length, 1);
assert.equal(p.hosts[0].publicHost, '10.0.0.9');
});
test('Ein Match landet auf dem Host mit den meisten freien Ports', async () => {
const { pool: p } = pool([hostA, hostB], { '10.0.0.1': [27100] }); // A hat ein Paar belegt, B ist frei
const r = await p.start({ matchId: 'm1', mode: 'competitive', secrets });
assert.equal(r.handle.hostName, 'b');
assert.equal(r.host, '10.0.0.2');
});
test('Ein voller Host wird übersprungen', async () => {
const fullA = Array.from({ length: 4 }, (_, i) => 27100 + i); // alle Ports von A belegt
const { pool: p } = pool([hostA, hostB], { '10.0.0.1': fullA });
const r = await p.start({ matchId: 'm2', mode: 'wingman', secrets });
assert.equal(r.handle.hostName, 'b');
});
test('Sind alle Hosts voll, gibt es einen klaren Fehler', async () => {
const fullA = Array.from({ length: 4 }, (_, i) => 27100 + i);
const fullB = Array.from({ length: 40 }, (_, i) => 27100 + i);
const { pool: p } = pool([hostA, hostB], { '10.0.0.1': fullA, '10.0.0.2': fullB });
await assert.rejects(p.start({ matchId: 'm3', mode: 'wingman', secrets }), /Kein Docker-Host mit freien Ports/);
});
test('Status, Stopp und Befehle gehen an den Host des Matches', async () => {
const { pool: p, calls } = pool([hostA, hostB], { '10.0.0.1': [27100] });
const r = await p.start({ matchId: 'm4', mode: 'competitive', secrets });
calls.length = 0;
await p.stop(r.handle);
assert.deepEqual(calls.at(-1)[0], 'rm');
assert.equal(calls.at(-1).at(-1), r.handle.container);
// Ein Handle von einem Host, der nicht mehr konfiguriert ist, wird abgelehnt
await assert.rejects(p.status({ ...r.handle, hostName: 'gelöscht' }), /nicht \(mehr\) konfiguriert/);
});
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 },
]);
});
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);
});