Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed65b92be1 | ||
|
|
beaf635c23 | ||
|
|
f6735824cc | ||
|
|
2873b5ba3a | ||
|
|
9bb878c641 | ||
|
|
1243e9c852 | ||
|
|
844da4713d | ||
|
|
91f979945e | ||
|
|
c1c9250126 | ||
|
|
eda15bc585 | ||
|
|
1a304cb398 | ||
|
|
cd7f7e0468 | ||
|
|
e1ee009063 | ||
|
|
f90351dd8d | ||
|
|
d2625d23ab | ||
|
|
8b6abdb8b2 | ||
|
|
90996d1b0a |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -271,6 +274,9 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
||||
</button>
|
||||
);
|
||||
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 (
|
||||
<section className={`panel admin-card status-${m.status}`}>
|
||||
@@ -283,6 +289,22 @@ 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.format !== 'bo1' && <> · Serie {m.seriesScore.A}:{m.seriesScore.B}</>}
|
||||
</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>
|
||||
<button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button>
|
||||
</div>
|
||||
@@ -291,8 +313,8 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
||||
<Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link>
|
||||
{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('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' })}
|
||||
{(m.server?.status === 'configured') && btn('Fortsetzen', { path: '/server/resume' })}
|
||||
{configured && !paused && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' }, 'btn-warn')}
|
||||
{configured && paused && btn('Fortsetzen', { path: '/server/resume' }, 'btn-primary')}
|
||||
{(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 &&
|
||||
btn('Server neu starten / wiederholen', { path: '/server/retry' })}
|
||||
@@ -375,3 +397,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`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -529,3 +529,44 @@ 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-timer.is-low .turn-bar span { background: var(--error); }
|
||||
.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); }
|
||||
@@ -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
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -128,4 +128,6 @@ function migrate(db) {
|
||||
for (const [name, type] of add) {
|
||||
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');
|
||||
}
|
||||
@@ -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',
|
||||
@@ -944,10 +951,14 @@ export class MatchService extends EventEmitter {
|
||||
status: row.status,
|
||||
message: row.message,
|
||||
playerCount: row.player_count,
|
||||
paused: !!row.paused,
|
||||
attempts: row.attempts,
|
||||
updatedAt: row.updated_at,
|
||||
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';
|
||||
if (!configured || viewer.role === 'public') return view;
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 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);
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
@@ -308,7 +337,8 @@ export class ServerManager {
|
||||
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;
|
||||
this.update(matchId, { status: 'configuring', message: `Lade Map ${seriesMap.number}: ${mapName}` });
|
||||
// Ein Mapwechsel beendet die Pause, CS2 lädt die Map neu
|
||||
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`);
|
||||
|
||||
const plan = buildMapCommands({
|
||||
@@ -418,8 +448,8 @@ export class ServerManager {
|
||||
} catch (err) {
|
||||
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.changed(viewer.matchId);
|
||||
}
|
||||
|
||||
/** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */
|
||||
@@ -463,7 +493,7 @@ export class ServerManager {
|
||||
const row = this.row(matchId);
|
||||
if (!row || row.status === 'stopped') return;
|
||||
if (row.handle) await this.safeStop(row);
|
||||
this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null });
|
||||
this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null, paused: 0 });
|
||||
this.service.log(matchId, 'server', `Server gestoppt: ${reason}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user