Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1243e9c852 | ||
|
|
844da4713d | ||
|
|
91f979945e | ||
|
|
c1c9250126 |
No files matched your search
@@ -26,6 +26,7 @@ const storeToken = (t) => {
|
|||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const [token, setToken] = useState(getToken);
|
const [token, setToken] = useState(getToken);
|
||||||
const [matches, setMatches] = useState(null);
|
const [matches, setMatches] = useState(null);
|
||||||
|
const [hosts, setHosts] = useState([]);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [notice, setNotice] = useState(null);
|
const [notice, setNotice] = useState(null);
|
||||||
const [busy, setBusy] = useState(null);
|
const [busy, setBusy] = useState(null);
|
||||||
@@ -42,6 +43,7 @@ export default function AdminPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await api('/admin/matches', { token });
|
const res = await api('/admin/matches', { token });
|
||||||
setMatches(res.matches);
|
setMatches(res.matches);
|
||||||
|
setHosts(res.hosts ?? []);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 401) {
|
if (err.status === 401) {
|
||||||
@@ -101,6 +103,7 @@ export default function AdminPage() {
|
|||||||
{tab === 'settings' && token && <NotificationSettings token={token} setNotice={setNotice} />}
|
{tab === 'settings' && token && <NotificationSettings token={token} setNotice={setNotice} />}
|
||||||
{notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>}
|
{notice && <div className={`toast toast-${notice.tone}`} role="status">{notice.text}</div>}
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
{tab === 'matches' && <HostUsage hosts={hosts} />}
|
||||||
{tab === 'matches' && matches === null && !error && <p className="muted">Lade Matches …</p>}
|
{tab === 'matches' && matches === null && !error && <p className="muted">Lade Matches …</p>}
|
||||||
{tab === 'matches' && matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>}
|
{tab === 'matches' && matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>}
|
||||||
|
|
||||||
@@ -386,3 +389,44 @@ 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`;
|
||||||
|
}
|
||||||
@@ -554,3 +554,17 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
|
|||||||
.run-vs { font-size: 1rem; color: var(--muted); font-style: normal; }
|
.run-vs { font-size: 1rem; color: var(--muted); font-style: normal; }
|
||||||
.run-foot { display: flex; justify-content: space-between; gap: 8px; font-size: .875rem; color: var(--muted); }
|
.run-foot { display: flex; justify-content: space-between; gap: 8px; font-size: .875rem; color: var(--muted); }
|
||||||
.run-series { font-variant-numeric: tabular-nums; }
|
.run-series { font-variant-numeric: tabular-nums; }
|
||||||
|
.host-usage { margin-bottom: 20px; }
|
||||||
|
.host-usage h2 { font-size: 1.1rem; margin: 0 0 10px; }
|
||||||
|
.host-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||||
|
.host-card { border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius); padding: 12px 14px; }
|
||||||
|
.host-card.is-full { border-color: var(--pending); }
|
||||||
|
.host-head { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; margin-bottom: 8px; }
|
||||||
|
.host-count { margin-left: auto; font-variant-numeric: tabular-nums; font-size: .875rem; }
|
||||||
|
.host-bar { height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; margin-bottom: 8px; }
|
||||||
|
.host-bar span { display: block; height: 100%; background: var(--primary); transition: width .3s ease; }
|
||||||
|
.host-card.is-full .host-bar span { background: var(--pending); }
|
||||||
|
.host-matches { list-style: none; margin: 0; padding: 0; font-size: .9rem; }
|
||||||
|
.host-matches li { padding: 3px 0; border-top: 1px solid var(--line); }
|
||||||
|
.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; }
|
||||||
@@ -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(() => {
|
router.get('/matches', requireAdmin, wrap(async () => {
|
||||||
const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all();
|
const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all();
|
||||||
return { matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))) };
|
return { matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))), hosts: await manager.hostUsage() };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => {
|
const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => {
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ 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.
|
||||||
@@ -236,6 +246,37 @@ export class DockerHostPool {
|
|||||||
getUsedPorts: () => (getUsedPorts ? getUsedPorts(h.publicHost) : new Set()),
|
getUsedPorts: () => (getUsedPorts ? getUsedPorts(h.publicHost) : new Set()),
|
||||||
runDocker,
|
runDocker,
|
||||||
})]));
|
})]));
|
||||||
|
this.diskCache = new Map();
|
||||||
|
this.diskInflight = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freier Speicher unter /var/lib/docker auf einem Host, per SSH. Ergebnis wird eine Minute lang
|
||||||
|
* zwischengespeichert, weil die Admin-Seite alle paar Sekunden abfragt. Ohne ssh:// gibt es null.
|
||||||
|
*/
|
||||||
|
async diskUsage(name) {
|
||||||
|
const host = this.hosts.find((h) => h.name === name);
|
||||||
|
if (!host?.dockerHost?.startsWith('ssh://')) return null;
|
||||||
|
const cached = this.diskCache.get(name);
|
||||||
|
if (cached && Date.now() - cached.at < DISK_CACHE_MS) return cached.value;
|
||||||
|
if (!this.diskInflight.has(name)) {
|
||||||
|
this.diskInflight.set(name, (async () => {
|
||||||
|
let value = null;
|
||||||
|
try {
|
||||||
|
const { stdout } = await exec('ssh', [
|
||||||
|
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', host.dockerHost.slice('ssh://'.length),
|
||||||
|
'df -B1 /var/lib/docker 2>/dev/null || df -B1 /',
|
||||||
|
], { timeout: 10_000 });
|
||||||
|
value = parseDf(stdout.trim().split('\n').pop());
|
||||||
|
} catch {
|
||||||
|
value = null;
|
||||||
|
}
|
||||||
|
this.diskCache.set(name, { at: Date.now(), value });
|
||||||
|
this.diskInflight.delete(name);
|
||||||
|
return value;
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
return this.diskInflight.get(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Freie Port-Paare auf einem Host. */
|
/** Freie Port-Paare auf einem Host. */
|
||||||
@@ -248,6 +289,14 @@ export class DockerHostPool {
|
|||||||
return free;
|
return free;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Auslastung pro Host: Anzahl Port-Paare insgesamt und belegt. */
|
||||||
|
hostStats() {
|
||||||
|
return this.hosts.map((h) => {
|
||||||
|
const total = Math.floor((h.portRangeEnd - h.portRangeStart + 1) / 2);
|
||||||
|
return { name: h.name, publicHost: h.publicHost, total, used: total - this.freeSlots(h) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getUsedPorts(publicHost) {
|
getUsedPorts(publicHost) {
|
||||||
return this.depsGetUsedPorts?.(publicHost) ?? new Set();
|
return this.depsGetUsedPorts?.(publicHost) ?? new Set();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,6 +160,25 @@ 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. */
|
/** Ports, die auf einem bestimmten Docker-Host belegt sind. */
|
||||||
usedPortsOn(host) {
|
usedPortsOn(host) {
|
||||||
const used = new Set();
|
const used = new Set();
|
||||||
|
|||||||
@@ -80,3 +80,19 @@ test('Alte Handles ohne Host-Namen laufen auf dem ersten Host', () => {
|
|||||||
const { pool: p } = pool([hostA, hostB]);
|
const { pool: p } = pool([hostA, hostB]);
|
||||||
assert.equal(p.adapterFor({ container: 'x' }), p.adapters.get('a'));
|
assert.equal(p.adapterFor({ container: 'x' }), p.adapters.get('a'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Auslastung pro Host: Port-Paare insgesamt und belegt', () => {
|
||||||
|
const { pool: p } = pool([hostA, hostB], { '10.0.0.1': [27100] });
|
||||||
|
assert.deepEqual(p.hostStats(), [
|
||||||
|
{ name: 'a', publicHost: '10.0.0.1', total: 2, used: 1 },
|
||||||
|
{ name: 'b', publicHost: '10.0.0.2', total: 20, used: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
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