Admin: freier Speicher pro Docker-Host anzeigen
Die Karte Docker-Hosts zeigt den freien Speicher unter /var/lib/docker, per SSH mit df gemessen und eine Minute zwischengespeichert. Unter 20 GB wird die Zeile rot.
This commit is contained in:
1 parent
844da4713d
commit
1243e9c852
6 files changed
+66
-4
No files matched your search
@@ -408,6 +408,11 @@ function HostUsage({ hosts }) {
|
||||
<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>
|
||||
) : (
|
||||
@@ -422,3 +427,6 @@ function HostUsage({ hosts }) {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function gb(bytes) {
|
||||
return `${Math.round(bytes / 1e9)} GB`;
|
||||
}
|
||||
@@ -566,3 +566,5 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
|
||||
.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)
|
||||
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))), hosts: manager.hostUsage() };
|
||||
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) => {
|
||||
|
||||
@@ -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.
|
||||
@@ -236,6 +246,37 @@ export class DockerHostPool {
|
||||
getUsedPorts: () => (getUsedPorts ? getUsedPorts(h.publicHost) : new Set()),
|
||||
runDocker,
|
||||
})]));
|
||||
this.diskCache = new Map();
|
||||
this.diskInflight = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Freier Speicher unter /var/lib/docker auf einem Host, per SSH. Ergebnis wird eine Minute lang
|
||||
* zwischengespeichert, weil die Admin-Seite alle paar Sekunden abfragt. Ohne ssh:// gibt es null.
|
||||
*/
|
||||
async diskUsage(name) {
|
||||
const host = this.hosts.find((h) => h.name === name);
|
||||
if (!host?.dockerHost?.startsWith('ssh://')) return null;
|
||||
const cached = this.diskCache.get(name);
|
||||
if (cached && Date.now() - cached.at < DISK_CACHE_MS) return cached.value;
|
||||
if (!this.diskInflight.has(name)) {
|
||||
this.diskInflight.set(name, (async () => {
|
||||
let value = null;
|
||||
try {
|
||||
const { stdout } = await exec('ssh', [
|
||||
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', host.dockerHost.slice('ssh://'.length),
|
||||
'df -B1 /var/lib/docker 2>/dev/null || df -B1 /',
|
||||
], { timeout: 10_000 });
|
||||
value = parseDf(stdout.trim().split('\n').pop());
|
||||
} catch {
|
||||
value = null;
|
||||
}
|
||||
this.diskCache.set(name, { at: Date.now(), value });
|
||||
this.diskInflight.delete(name);
|
||||
return value;
|
||||
})());
|
||||
}
|
||||
return this.diskInflight.get(name);
|
||||
}
|
||||
|
||||
/** Freie Port-Paare auf einem Host. */
|
||||
|
||||
@@ -161,15 +161,18 @@ export class ServerManager {
|
||||
}
|
||||
|
||||
/** Auslastung der Docker-Hosts mit den Matches, die dort laufen. Leer, wenn der Adapter keine Hosts kennt. */
|
||||
hostUsage() {
|
||||
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();
|
||||
return this.adapter.hostStats().map((h) => ({
|
||||
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 })),
|
||||
|
||||
@@ -88,3 +88,11 @@ test('Auslastung pro Host: Port-Paare insgesamt und belegt', () => {
|
||||
{ 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