Compare commits

..
Author SHA1 Message Date
mregosar 5886461a9e Echte Logdatei durch anonymisierte Fassung ersetzen
Die Testdatei enthielt Spielernamen und SteamID eines echten Accounts.
Sie ist jetzt durch eine Kopie mit Platzhaltern ersetzt, der Parser-Test
prüft weiterhin echte CS2-Logzeilen.
2026-10-29 07:38:38 +01:00
mregosar 09e73a9527 Merge docs/reverse-proxy in main 2026-10-29 07:32:41 +01:00
mregosar 3e4a85f76c README: Betrieb hinter einem Reverse-Proxy
Abschnitt mit nginx-Beispiel, Hinweisen zu Subdomain, WebSockets,
PUBLIC_URL und TRUST_PROXY sowie zur Absicherung. TRUST_PROXY steht
jetzt auch in der Variablentabelle.
2026-10-29 07:32:41 +01:00
mregosar 790109405a Merge feature/veto-timer-bar in main 2026-10-26 14:02:46 +01:00
mregosar f24b2bedbc Veto-Timer als Leiste in Teamfarbe
Die verbleibende Zeit des Veto-Schritts erscheint als Leiste mit
Sekundenanzeige, in den letzten 10 Sekunden rot. Der Server schickt
die Schrittdauer mit, damit die Leiste zu VETO_STEP_SECONDS passt.
2026-10-26 14:02:46 +01:00
mregosar 656bfbaadf Merge feature/veto-timer in main 2026-10-26 11:26:34 +01:00
mregosar fcc9371a9e Merge feature/admin-settings in main 2026-10-26 11:26:25 +01:00
mregosar be474ee143 Admin: Reiter Matches und Einstellungen, Discord-Embeds, CSTV-Zugang
Der Admin-Bereich bekommt Reiter. Die Discord-Einstellungen liegen
unter „Einstellungen“. Meldungen sind als Embeds formatiert, und die
Match-Startmeldung enthält den CSTV-Zugang (Passwort und Adresse).
2026-10-26 11:03:54 +01:00
mregosar a7539091ff Veto-Timer: 30 Sekunden pro Schritt, danach automatische Auswahl
Läuft die Frist eines Veto-Schritts ab (VETO_STEP_SECONDS, Standard 30,
0 = aus), wählt das Backend für das Team am Zug eine zufällige Map bzw.
Seite. Die Veto-Anzeige zeigt die verbleibende Zeit.
2026-10-26 11:02:33 +01:00
mregosar 301da179ed Merge feature/discord-webhook in main 2026-10-26 01:12:15 +01:00
mregosar 7bceceb31d Discord-Webhook für Streamer-Meldungen mit wählbaren Ereignissen
Meldungen zu Veto, Match-Start, Map- und Serienende, Matchende und
Serverfehlern gehen an DISCORD_WEBHOOK_URL. Im Admin-Bereich wählt man,
welche Ereignisse gemeldet werden, und kann eine Testmeldung senden.
2026-10-26 01:12:15 +01:00
mregosar eee4ceb315 Merge docs/readme-update in main 2026-10-26 00:53:46 +01:00
mregosar c33185db3e README: Match-Übersicht, Pausen und Turnierregeln dokumentiert 2026-10-26 00:53:46 +01:00
mregosar 1da08b256e Merge Rules in main 2026-10-26 00:50:27 +01:00
mregosar 1d627c212a Merge feature/admin-pause in main 2026-10-26 00:50:27 +01:00
mregosar e7f595176a Turnierregeln für die CS2-Server-Konfiguration
Setzt Freezetime 20, Buytime 20, Startgeld 800, Rundenzeit 1:55 und
Halbzeit 1 fest und erlaubt Pausen mit sv_pausable 1. Die Werte
stammen aus FACEIT- und ESL-Richtwerten, nicht aus einer aktuellen
Regel des konkreten Turniers.
2026-10-26 00:48:26 +01:00
mregosar bfe315d70c Admin: Pausieren und Fortsetzen des laufenden Matches
Neue Admin-Aktionen server/pause und server/resume senden
mp_pause_match bzw. mp_unpause_match an den CS2-Server. Nur bei
konfiguriertem Server verfügbar.
2026-10-26 00:48:25 +01:00
mregosar d2a811f177 Merge feature/organizer-stream-link in main 2026-10-26 00:24:41 +01:00
mregosar c69d5548e5 Organisator: Link zur Übersicht des eigenen Matches
Der Organisator bekommt einen Link auf /stream/match/<id>, die
Stream-Übersicht nur für dieses Match. Der Zuschauercode bleibt
außen vor. Ist STREAM_KEY gesetzt, wird der Schlüssel angehängt.
2026-10-26 00:24:41 +01:00
mregosar af4f036515 Merge feature/stream-match-link in main 2026-10-26 00:15:37 +01:00
mregosar 91213d354f Stream-Übersicht: Matches anklickbar, Einzelansicht pro Match
Karten und Listeneinträge der Stream-Übersicht führen zur Übersicht
des jeweiligen Matches (/stream/match/:matchId). Der Zuschauercode
bleibt geheim, daher wird nur die Match-ID verlinkt.
2026-10-26 00:15:37 +01:00
mregosar 4fa4db3640 Merge feature/round-rollback in main 2026-10-25 23:44:59 +01:00
mregosar a98a34d769 Rollback: Rundensicherung per Startparameter aktivieren
CS2 legt Rundensicherungen nur mit -allow_round_backup_files an (sonst
Antwort 'Round backup files are disabled'). Der Docker-Adapter setzt den
Parameter über CS2_ADDITIONAL_ARGS. Am echten Server geprüft: Sicherungen
für Runde 0 bis 2 vorhanden, Zurückspielen auf Runde 1 funktioniert.
2026-10-25 23:44:40 +01:00
mregosar 3bfe087b7d Rollback: Runden aus CS2-Sicherungen zurückspielen (nur Admin)
Beim Konfigurieren aktiviert CS2 Mapveto die Rundensicherung des Servers
(mp_backup_round_auto, Präfix mit Match-ID). Im Admin-Modus lassen sich
die Sicherungen der laufenden Map auflisten und zurückspielen. Der
Live-Stand wird auf die Runden bis zur Sicherung gekürzt. Organisatoren
haben keinen Zugang zu den Rollback-Routen.

Sicherheit: Dateinamen werden gegen Match-ID und Muster geprüft, damit
keine fremden Dateien oder Befehle in die RCON-Zeile gelangen.
2026-10-25 23:24:49 +01:00
mregosar 43ecb8a1ad Merge feature/admin-mode in main 2026-10-25 23:16:45 +01:00
mregosar 46d1954a6d Admin-Modus: Verwaltung aller Matches unter /admin
Anmeldung mit ADMIN_PASSWORD (12 h, Sitzung nur im Speicher, Sperre nach
5 Fehlversuchen pro Minute). Alle Matches mit Codes, Servern, Spielern,
Veto und Verlauf. Aktionen: Veto starten bzw. Schritte für beide Teams,
Warmup beenden, nächste Map, Server stoppen/neu starten, abschließen,
abbrechen, Spieler entfernen, Codes erneuern, Match löschen.
Ohne ADMIN_PASSWORD ist der Admin-Modus komplett aus.
2026-10-25 23:16:44 +01:00
mregosar 30ec5359b1 AMP und feste Host-Namen aus Doku und Tests entfernen
Der Docker-Host ist beliebig; Beispiele nutzen jetzt DOCKER-HOST-IP bzw.
den SSH-Alias docker-host statt eines Rechnernamens. Testfixture heißt
echte-logs-wingman.log.
2026-10-25 22:54:38 +01:00
mregosar 5527c2ce58 Stack unabhängig vom Host: deploy/compose.yml mit .env
CS2 Mapveto startet jetzt auf jedem Docker-Host (Linux) mit
'docker compose --env-file deploy/.env -f deploy/compose.yml up -d'.
Die Adresse kommt aus HOST_IP, nichts ist mehr fest eingetragen.
Anleitung für einen neuen Server: Image laden oder bauen, Spieldateien
kopieren oder neu laden, konfigurieren, starten.

Geprüft auf amp-cluster mit Demo-Adapter: Stack startet, Webseite
antwortet, Stack wieder entfernt, Spieldateien unverändert.
2026-10-25 22:46:51 +01:00
mregosar 1b7c13b592 CS2 Mapveto als ein Container (Dockerfile, compose.yml, Anleitung)
Ein Image mit Webseite, Backend, SQLite und Docker-Client; startet
Match-Container über /var/run/docker.sock auf demselben Host. Tests
laufen beim Bauen mit. Auf amp-cluster gebaut (301 MB) und geprüft.

Dazu: Testbefehl für Node 22 angepasst, beim Beenden werden ausstehende
Live-Updates verworfen und laufende Server-Aktionen abgewartet, damit nach
dem Schließen der Datenbank nichts mehr darauf zugreift.
2026-10-25 22:40:52 +01:00
mregosar e3a1ed4a63 Docker-Warteschlange: Update-Check per RCON statt nur per Logzeile erkennen
Die Zeile 'Starting CS2 Dedicated Server' rutscht nach dem Serverstart
schnell aus den letzten Logzeilen. Der nächste Match-Container wartete
dadurch bis zur Zeitgrenze (20 min). Jetzt gilt ein Container als fertig,
sobald er per RCON antwortet; die Logzeile bleibt als zweite Erkennung.
2026-10-25 22:16:57 +01:00
mregosar 84dad0993c Docker-Adapter mit echtem Host getestet, zwei Fehler behoben
- CSTV belegt einen Spielerplatz: Container starten jetzt mit 6 bzw. 12
  Plätzen (Spieler + CSTV + Reserve), sonst kam der letzte Spieler nicht drauf
- 'Live-Daten empfangen' wird auch in der Aufwärmphase angezeigt, wenn
  der Server Logs liefert, die den Spielstand noch nicht ändern
- Anleitung: Serverliste, Plätze, Bots zum Testen

Getestet auf amp-cluster (Docker per SSH, LAN-Modus): Container-Start,
Veto, Konfiguration, Live-Runden, automatischer Mapwechsel, Entfernen.
2026-10-25 22:09:43 +01:00
mregosar 3a109883b3 Docker-Adapter: LAN-Modus ohne GSLT, Container starten nacheinander
Ohne CS2_GSLT starten Match-Container mit CS2_LAN=1. Neue Container
warten, bis der vorige seinen SteamCMD-Update-Check abgeschlossen hat,
damit nie zwei gleichzeitig das gemeinsame Volume aktualisieren.
Anleitung: Docker per SSH (DOCKER_HOST), eindeutige Passwort-Beispiele
mit Warnung vor Sonderzeichen (Startskript nutzt eval).
2026-10-25 21:54:30 +01:00
mregosar 85041b2bff Stream-Übersicht aller laufenden Vetos und Matches
/stream zeigt alle aktiven Matches als Karten (Veto mit Map-Board, Match
mit Live-Stand und Serie), dazu offene Lobbys und Ergebnisse. Raster passt
sich der Anzahl an, ab 7 Matches wird geblättert. Live per Socket, optional
mit STREAM_KEY geschützt, ohne Codes oder Verbindungsdaten.
2026-10-25 19:32:53 +01:00
mregosar ea2207899f Aufwärmphase vor mp_warmup_end als beendet markieren
CS2 sendet Match_Start sofort nach mp_warmup_end, oft bevor die
RCON-Antwort da ist. Bei fehlgeschlagenem Befehl wird die Markierung
zurückgenommen.
2026-10-25 19:23:53 +01:00
mregosar 263c7e7b7c Live-Ergebnisse: Stream-Seite, Anzeige, automatischer Mapwechsel
- Stream-Übersicht unter /stream/<Zuschauercode> (1920x1080, Lobby, Veto, Spielstand)
- Live-Stand, Rundenverlauf und Serienstand auf Match-Seite und Übersicht
- Parser mit echten AMP-Logs geprüft: Game Over mit leerer Map-Gruppe,
  Match_Start vor der Aufwärmphase, veraltete Seitenzuordnung nach der Halbzeit
- Nach Map-Ende automatisch nächste Map bzw. Abschluss der entschiedenen Serie
- LOG_DEBUG zur Fehlersuche, README und .env.example ergänzt
2026-10-25 19:20:10 +01:00
mregosar 306b3aa7e5 Live-Ergebnisse aus CS2-Serverlogs (Backend)
Server bekommt beim Konfigurieren logaddress_add_http mit geheimem
Match-Schlüssel. Ein Parser wertet Rundenende, Seitenzuordnung, Matchstart
und Spielende aus; Stand, Rundenverlauf und Sieger werden pro Map gespeichert
und live verteilt. Der Demo-Adapter simuliert nach der Aufwärmphase einen
Matchverlauf über dieselben Logzeilen.
2026-10-25 18:54:23 +01:00
mregosar 34e5fc7d0e Anleitung: CS2-Server mit Portainer und dauerhaftem Volume
docs/portainer-cs2.md beschreibt Stack, einmaligen Download, Updates ohne
Neu-Download, Anbindung per Static- oder Docker-Adapter, mehrere Server
und Fehlersuche. Standard-Image auf joedwards32/cs2 umgestellt, da
cm2network/cs2 nicht mehr gepflegt wird.
2026-10-25 18:07:17 +01:00
mregosar 8be8b3d879 Organisator kann im selben Browser selbst mitspielen
Ein Aufrufer darf Organisator- und Spieler-Zugang kombinieren
(Authorization + X-Player-Token). Berechtigungen prüfen jetzt einzelne
Merkmale statt einer Rangstufe. In der Lobby gibt es „Selbst mitspielen?“,
Teamlinks funktionieren auch im Organisator-Browser. Online-Status wird
direkt nach dem Beitritt aktualisiert.
2026-10-25 17:53:39 +01:00
mregosar 3a74e16bb8 Größere Map-Kacheln und Ban/Pick-Leiste
Ab dem Veto bekommt das Map-Board die volle Breite (4 Spalten), die
Teamkarten rücken darunter. Die Schrittleiste zeigt pro Schritt das Team.
2026-10-25 17:45:41 +01:00
mregosar f1fc1642ab MaxLAN-Branding: Logo in der Kopfzeile, Farben aus dem Logo
Team A in Logo-Blau, Team B in Logo-Rot, schwarze Kopfzeile mit rotem
Abschluss; CT/T-Farben aus den Emblemen, Warnungen in Orange.
2026-10-25 17:42:38 +01:00
mregosar db4a6bedf7 CT/T-Embleme ergänzt, kein Verbindungshinweis beim ersten Laden 2026-10-25 17:34:03 +01:00
mregosar 7be0d85ab5 Map-Bilder und Seitenwahl mit Emblemen
Map-Bilder aus ghostcap-gaming/cs2-map-images lokal als WebP gespeichert
(npm run images lädt und verkleinert sie). Bilder in Veto-Kacheln,
Pool-Vorschau, Serie und Seitenwahl; CT/T-Embleme aus client/public/sides
mit Ersatzsymbol, falls die Dateien fehlen.
2026-10-25 17:23:55 +01:00
mregosar e304556346 Ein bereiter Spieler pro Team reicht standardmäßig für den Veto-Start
REQUIRE_FULL_TEAMS ist jetzt standardmäßig false; vollständige Teams
lassen sich weiterhin per Umgebungsvariable erzwingen.
2026-10-25 17:11:46 +01:00
mregosar bf438511d6 Lobby zeigt konkret, was einem Team zum Veto-Start fehlt 2026-10-25 17:09:31 +01:00
mregosar f7520a6ca7 Frontend, README und Konfigurationsvorlage
React-Oberfläche mit Übersicht, Match-Erstellung, Beitritt über Team- und
Zuschauercodes, Teamkarten, visuellem Map-Veto, Serie und Serverpanel.
README beschreibt Einrichtung, Umgebungsvariablen, Rollen, Veto-Abläufe,
Serveradapter und CSTV-Zuschauerzugang.
2026-10-25 17:02:18 +01:00
mregosar f08297dd0d Backend: Match-Lobbys, Map-Veto, Echtzeit und CS2-Serveradapter
Express + Socket.IO + SQLite. Veto-Engine mit konfigurierbaren Map-Pools
und validierten Abläufen, rollenbasierte Berechtigungen, Server-Lebenszyklus
mit Demo-, Docker- und Static-Pool-Adapter (RCON), automatisches Herunterfahren.
2026-10-25 16:50:22 +01:00
17 changed files with 20 additions and 547 deletions

No files matched your search

-3
View File
@@ -52,9 +52,6 @@ CS2_PORT_RANGE_END=27115
# Gemeinsames Volume für die Spieldateien (~60 GB), damit nicht jedes Match neu herunterlädt # Gemeinsames Volume für die Spieldateien (~60 GB), damit nicht jedes Match neu herunterlädt
CS2_DOCKER_VOLUME=cs2-mapveto-gamefiles CS2_DOCKER_VOLUME=cs2-mapveto-gamefiles
CS2_DOCKER_EXTRA_ARGS= 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 CS2_START_TIMEOUT_MINUTES=20
DOCKER_BIN=docker DOCKER_BIN=docker
-18
View File
@@ -236,24 +236,6 @@ Voraussetzungen:
Container heißen `cs2mv-<matchId>` und tragen das Label `cs2-mapveto=1`. Stoppen bedeutet `docker rm -f`. 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`) ### Option B: Vorhandene Server (`SERVER_ADAPTER=static`)
Für selbst betriebene, dauerhaft laufende CS2-Server: Für selbst betriebene, dauerhaft laufende CS2-Server:
+2 -65
View File
@@ -26,7 +26,6 @@ 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);
@@ -43,7 +42,6 @@ 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) {
@@ -103,7 +101,6 @@ 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>}
@@ -274,9 +271,6 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
</button> </button>
); );
const hasNext = m.series.some((s) => s.number === m.currentMapNumber + 1); const hasNext = m.series.some((s) => s.number === m.currentMapNumber + 1);
const current = m.series.find((s) => s.number === m.currentMapNumber);
const configured = m.server?.status === 'configured';
const paused = !!m.server?.paused;
return ( return (
<section className={`panel admin-card status-${m.status}`}> <section className={`panel admin-card status-${m.status}`}>
@@ -289,22 +283,6 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
{m.modeLabel}, {FORMAT_LABEL[m.format]} · {m.statusLabel} · <span className={`tone-${server.tone}`}>{server.text}</span> {m.modeLabel}, {FORMAT_LABEL[m.format]} · {m.statusLabel} · <span className={`tone-${server.tone}`}>{server.text}</span>
{m.format !== 'bo1' && <> · Serie {m.seriesScore.A}:{m.seriesScore.B}</>} {m.format !== 'bo1' && <> · Serie {m.seriesScore.A}:{m.seriesScore.B}</>}
</div> </div>
{m.status === 'live' && current && (
<div className="admin-live">
<span>Map {current.number}: {current.name}</span>
<strong>{current.score.A} : {current.score.B}</strong>
{current.live?.roundsPlayed != null && <span className="muted">Runde {current.live.roundsPlayed + 1}</span>}
</div>
)}
{m.server?.address && m.server.status !== 'stopped' && (
<div className="admin-address">
<span><span className="muted">Server</span> <code>{m.server.address.host}:{m.server.address.port}</code></span>
{m.server.address.tvPort && (
<span><span className="muted">CSTV</span> <code>{m.server.address.host}:{m.server.address.tvPort}</code></span>
)}
</div>
)}
{paused && <span className="admin-badge is-paused">Pausiert</span>}
</div> </div>
<button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button> <button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button>
</div> </div>
@@ -313,8 +291,8 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
<Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link> <Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link>
{m.status === 'lobby' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')} {m.status === 'lobby' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')}
{(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })} {(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })}
{configured && !paused && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' }, 'btn-warn')} {(m.server?.status === 'configured') && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' })}
{configured && paused && btn('Fortsetzen', { path: '/server/resume' }, 'btn-primary')} {(m.server?.status === 'configured') && btn('Fortsetzen', { path: '/server/resume' })}
{(m.server?.status === 'configured') && hasNext && btn('Nächste Map', { path: '/series/next' })} {(m.server?.status === 'configured') && hasNext && btn('Nächste Map', { path: '/series/next' })}
{m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed && {m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed &&
btn('Server neu starten / wiederholen', { path: '/server/retry' })} btn('Server neu starten / wiederholen', { path: '/server/retry' })}
@@ -397,44 +375,3 @@ 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.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 />} {viewer.role === 'public' && <PublicNotice />}
<div className={`match-grid ${state.status !== 'lobby' ? 'stage-wide' : ''}`}> <div className={`match-grid ${state.status !== 'lobby' && viewer.role !== 'public' ? 'stage-wide' : ''}`}>
<TeamCard team={state.teams.A} side="A" {...ctx} /> <TeamCard team={state.teams.A} side="A" {...ctx} />
<section className="stage"> <section className="stage">
<Stage {...ctx} /> <Stage {...ctx} />
@@ -156,8 +156,8 @@ function describeViewer(viewer, teams) {
function Stage(ctx) { function Stage(ctx) {
const { state, isOrg, act, busy } = ctx; const { state, isOrg, act, busy } = ctx;
if (state.viewer.role === 'public' && state.status === 'lobby') { if (state.viewer.role === 'public') {
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>; return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Details sehen nur Teilnehmer.</p></div>;
} }
if (state.status === 'lobby') { if (state.status === 'lobby') {
const { A, B } = state.teams; const { A, B } = state.teams;
@@ -230,8 +230,8 @@ function MapPoolPreview({ maps }) {
function PublicNotice() { function PublicNotice() {
return ( return (
<div className="alert"> <div className="alert">
Du siehst dieses Match ohne Zugang. Veto, Serienstand und Ergebnisse sind öffentlich. Du siehst diese Lobby ohne Zugang. Spieler treten über den Einladungslink ihres Teams bei,
Teamdetails und Verbindungsdaten gibt es nur für Teilnehmer. Zuschauer über den Zuschauerlink. Beides bekommst du vom Organisator.
</div> </div>
); );
} }
+1 -34
View File
@@ -4,7 +4,6 @@ import { api } from '../lib/api.js';
import { FORMAT_LABEL, serverLabel, timeAgo } from '../lib/labels.js'; import { FORMAT_LABEL, serverLabel, timeAgo } from '../lib/labels.js';
import { getAccess } from '../lib/session.js'; import { getAccess } from '../lib/session.js';
import { useOverview } from '../lib/useMatch.js'; import { useOverview } from '../lib/useMatch.js';
import MapImage from '../components/MapImage.jsx';
const GROUPS = [ const GROUPS = [
{ key: 'open', title: 'Offen', empty: 'Keine offenen Lobbys. Erstelle ein Match, um zu starten.' }, { key: 'open', title: 'Offen', empty: 'Keine offenen Lobbys. Erstelle ein Match, um zu starten.' },
@@ -33,11 +32,7 @@ export default function Overview() {
return ( return (
<section key={g.key} className={`group group-${g.key}`}> <section key={g.key} className={`group group-${g.key}`}>
<h2>{g.title} <span className="count">{list.length}</span></h2> <h2>{g.title} <span className="count">{list.length}</span></h2>
{list.length === 0 ? <p className="muted small">{g.empty}</p> : g.key === 'running' ? ( {list.length === 0 ? <p className="muted small">{g.empty}</p> : (
<div className="run-grid">
{list.map((m) => <RunningCard key={m.id} match={m} />)}
</div>
) : (
<ul className="match-list"> <ul className="match-list">
{list.map((m) => <MatchRow key={m.id} match={m} />)} {list.map((m) => <MatchRow key={m.id} match={m} />)}
</ul> </ul>
@@ -84,34 +79,6 @@ 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() { function CodeEntry() {
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [error, setError] = useState(null); const [error, setError] = useState(null);
-41
View File
@@ -529,44 +529,3 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.turn-seconds { min-width: 3ch; text-align: right; color: var(--muted); font-size: .875rem; font-variant-numeric: tabular-nums; } .turn-seconds { min-width: 3ch; text-align: right; color: var(--muted); font-size: .875rem; font-variant-numeric: tabular-nums; }
.turn-timer.is-low .turn-bar span { background: var(--error); } .turn-timer.is-low .turn-bar span { background: var(--error); }
.turn-timer.is-low .turn-seconds { color: var(--error); font-weight: 600; } .turn-timer.is-low .turn-seconds { color: var(--error); font-weight: 600; }
.admin-live { display: flex; gap: 10px; align-items: baseline; margin-top: 4px; font-size: .9rem; }
.admin-live strong { font-variant-numeric: tabular-nums; }
.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; }
.admin-address { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 8px; font-size: .85rem; }
.admin-address code { font: 500 .85rem ui-monospace, SFMono-Regular, Consolas, monospace; padding: 1px 6px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); }
-44
View File
@@ -1,44 +0,0 @@
#!/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) // Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
router.get('/matches', requireAdmin, wrap(async () => { 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))), hosts: await manager.hostUsage() }; return { matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))) };
})); }));
const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => { const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => {
+1 -3
View File
@@ -20,9 +20,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
const service = new MatchService({ db, pools, config, notifier }); const service = new MatchService({ db, pools, config, notifier });
let manager; let manager;
const adapter = adapterOverride ?? createServerAdapter(config, { const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() });
getUsedPorts: (host) => (host ? manager.usedPortsOn(host) : manager.usedPorts()),
});
manager = new ServerManager({ db, service, adapter, config, logger }); manager = new ServerManager({ db, service, adapter, config, logger });
manager.init(); manager.init();
service.startVetoTimer(); service.startVetoTimer();
-29
View File
@@ -22,32 +22,6 @@ function bool(name, fallback) {
return ['1', 'true', 'yes', 'ja', 'on'].includes(raw.toLowerCase()); 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 = '') { function str(name, fallback = '') {
const raw = process.env[name]; const raw = process.env[name];
return raw === undefined || raw === '' ? fallback : raw; return raw === undefined || raw === '' ? fallback : raw;
@@ -111,9 +85,6 @@ export function loadConfig(overrides = {}) {
volume: str('CS2_DOCKER_VOLUME', 'cs2-mapveto-gamefiles'), volume: str('CS2_DOCKER_VOLUME', 'cs2-mapveto-gamefiles'),
extraArgs: str('CS2_DOCKER_EXTRA_ARGS'), extraArgs: str('CS2_DOCKER_EXTRA_ARGS'),
startTimeoutMinutes: int('CS2_START_TIMEOUT_MINUTES', 20), 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: { staticPool: {
-2
View File
@@ -128,6 +128,4 @@ function migrate(db) {
for (const [name, type] of add) { for (const [name, type] of add) {
if (!seriesColumns.has(name)) db.exec(`ALTER TABLE series_maps ADD COLUMN ${name} ${type}`); if (!seriesColumns.has(name)) db.exec(`ALTER TABLE series_maps ADD COLUMN ${name} ${type}`);
} }
const serverColumns = columns('servers');
if (!serverColumns.has('paused')) db.exec('ALTER TABLE servers ADD COLUMN paused INTEGER NOT NULL DEFAULT 0');
} }
+2 -13
View File
@@ -791,14 +791,7 @@ export class MatchService extends EventEmitter {
return { return {
seriesScore: maps.length ? seriesScore(maps) : null, seriesScore: maps.length ? seriesScore(maps) : null,
currentMap: current && (current.status === 'live' || current.status === 'done') 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, : null,
id: m.id, id: m.id,
teamA: m.team_a_name, teamA: m.team_a_name,
@@ -892,7 +885,7 @@ export class MatchService extends EventEmitter {
requireFullTeams: this.config.requireFullTeams, requireFullTeams: this.config.requireFullTeams,
autoAdvanceSeconds: this.config.autoAdvanceSeconds, autoAdvanceSeconds: this.config.autoAdvanceSeconds,
maps: pool.maps, maps: pool.maps,
veto: publicView && match.status === 'lobby' veto: publicView
? null ? null
: { : {
started: match.status !== 'lobby', started: match.status !== 'lobby',
@@ -951,14 +944,10 @@ export class MatchService extends EventEmitter {
status: row.status, status: row.status,
message: row.message, message: row.message,
playerCount: row.player_count, playerCount: row.player_count,
paused: !!row.paused,
attempts: row.attempts, attempts: row.attempts,
updatedAt: row.updated_at, updatedAt: row.updated_at,
idleTimeoutMinutes: this.config.idleTimeoutMinutes, idleTimeoutMinutes: this.config.idleTimeoutMinutes,
}; };
if (viewer.isOrganizer && row.host) {
view.address = { host: row.host, port: row.port, tvPort: row.tv_port };
}
const configured = row.status === 'configured'; const configured = row.status === 'configured';
if (!configured || viewer.role === 'public') return view; if (!configured || viewer.role === 'public') return view;
+2 -2
View File
@@ -29,7 +29,7 @@
*/ */
import { DemoServerAdapter } from './demoAdapter.js'; import { DemoServerAdapter } from './demoAdapter.js';
import { DockerHostPool } from './dockerAdapter.js'; import { DockerServerAdapter } from './dockerAdapter.js';
import { StaticPoolServerAdapter } from './staticPoolAdapter.js'; import { StaticPoolServerAdapter } from './staticPoolAdapter.js';
export function createServerAdapter(config, deps) { export function createServerAdapter(config, deps) {
@@ -37,7 +37,7 @@ export function createServerAdapter(config, deps) {
case 'demo': case 'demo':
return new DemoServerAdapter(config.demo); return new DemoServerAdapter(config.demo);
case 'docker': case 'docker':
return new DockerHostPool(config.docker, deps); return new DockerServerAdapter(config.docker, deps);
case 'static': case 'static':
return new StaticPoolServerAdapter(config.staticPool, deps); return new StaticPoolServerAdapter(config.staticPool, deps);
default: default:
+2 -155
View File
@@ -4,16 +4,6 @@ import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from
import { runRcon } from './rcon.js'; import { runRcon } from './rcon.js';
const exec = promisify(execFile); 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)); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Diese Zeile schreibt das Image, sobald SteamCMD fertig ist und der Spielserver startet. // Diese Zeile schreibt das Image, sobald SteamCMD fertig ist und der Spielserver startet.
@@ -47,8 +37,7 @@ export class DockerServerAdapter {
async docker(args, timeout = 60_000) { async docker(args, timeout = 60_000) {
if (this.runDocker) return this.runDocker(args); if (this.runDocker) return this.runDocker(args);
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 });
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env });
return stdout.trim(); return stdout.trim();
} }
@@ -148,14 +137,7 @@ export class DockerServerAdapter {
await this.docker(args, 15 * 60_000); await this.docker(args, 15 * 60_000);
return { return {
handle: { handle: { container, rconHost: this.options.rconHost, port, startedAt: Date.now(), rconPassword: secrets.rconPassword },
container,
hostName: this.options.name,
rconHost: this.options.rconHost,
port,
startedAt: Date.now(),
rconPassword: secrets.rconPassword,
},
host: this.options.host, host: this.options.host,
port, port,
tvPort, tvPort,
@@ -210,138 +192,3 @@ export class DockerServerAdapter {
await this.docker(['rm', '-f', handle.container]).catch(() => {}); 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 auf einem Host, per SSH: dort, wo das Gameserver-Volume liegt. 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 "$(docker volume inspect ${host.volume} --format '{{with .Options}}{{.device}}{{end}}' 2>/dev/null | grep . || docker volume inspect ${host.volume} --format '{{.Mountpoint}}' 2>/dev/null || echo /var/lib/docker)" 2>/dev/null || 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);
}
}
+3 -33
View File
@@ -160,35 +160,6 @@ 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. */
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() { usedPorts() {
const used = new Set(); 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()) { for (const r of this.db.prepare(`SELECT host, port, tv_port FROM servers WHERE status != 'stopped' AND port IS NOT NULL`).all()) {
@@ -337,8 +308,7 @@ export class ServerManager {
if (!seriesMap) throw new HttpError(409, 'Das Veto ist noch nicht abgeschlossen', 'veto_pending'); if (!seriesMap) throw new HttpError(409, 'Das Veto ist noch nicht abgeschlossen', 'veto_pending');
const mapName = pool.maps.find((m) => m.id === seriesMap.map)?.name ?? seriesMap.map; const mapName = pool.maps.find((m) => m.id === seriesMap.map)?.name ?? seriesMap.map;
// Ein Mapwechsel beendet die Pause, CS2 lädt die Map neu this.update(matchId, { status: 'configuring', message: `Lade Map ${seriesMap.number}: ${mapName}` });
this.update(matchId, { status: 'configuring', paused: 0, message: `Lade Map ${seriesMap.number}: ${mapName}` });
this.service.log(matchId, 'server', `Konfiguration für Map ${seriesMap.number} (${mapName}) gestartet`); this.service.log(matchId, 'server', `Konfiguration für Map ${seriesMap.number} (${mapName}) gestartet`);
const plan = buildMapCommands({ const plan = buildMapCommands({
@@ -448,8 +418,8 @@ export class ServerManager {
} catch (err) { } catch (err) {
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error'); throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
} }
this.update(viewer.matchId, { paused: paused ? 1 : 0 });
this.service.log(viewer.matchId, 'server', paused ? 'Match pausiert (Admin)' : 'Match fortgesetzt (Admin)'); this.service.log(viewer.matchId, 'server', paused ? 'Match pausiert (Admin)' : 'Match fortgesetzt (Admin)');
this.service.changed(viewer.matchId);
} }
/** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */ /** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */
@@ -493,7 +463,7 @@ export class ServerManager {
const row = this.row(matchId); const row = this.row(matchId);
if (!row || row.status === 'stopped') return; if (!row || row.status === 'stopped') return;
if (row.handle) await this.safeStop(row); if (row.handle) await this.safeStop(row);
this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null, paused: 0 }); this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null });
this.service.log(matchId, 'server', `Server gestoppt: ${reason}`); this.service.log(matchId, 'server', `Server gestoppt: ${reason}`);
} }
-98
View File
@@ -1,98 +0,0 @@
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);
});