From 75b1701d8a647afe9edb7d660067bc0e997abf99 Mon Sep 17 00:00:00 2001 From: MrEgosar Date: Thu, 29 Oct 2026 22:02:14 +0100 Subject: [PATCH] Admin: Einstellungen mit Seitenleiste, Docker-Hosts und Messer-Server in die Einstellungen --- README.md | 9 +-- client/src/pages/AdminPage.jsx | 125 +++++++++++++++++++++++++++++---- client/src/styles.css | 19 +++++ server/src/adminRoutes.js | 4 +- 4 files changed, 139 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b521e68..10ba4bf 100644 --- a/README.md +++ b/README.md @@ -271,7 +271,7 @@ Jeder Host braucht eigene Spieldateien (rund 60 bis 90 GB). Die Ports müssen au ### Messer-Server für die Seitenwahl -Im Admin-Bereich (Reiter „Matches“) startet „Messer-Server starten“ einen Gameserver, der keinem Match gehört und dauerhaft läuft. Teams verbinden sich, spielen eine Messerrunde und gehen wieder. +Im Admin-Bereich (Einstellungen → Gameserver → Messer-Server) startet „Messer-Server starten“ einen Gameserver, der keinem Match gehört und dauerhaft läuft. Teams verbinden sich, spielen eine Messerrunde und gehen wieder. - Modus wählbar: **Competitive 5v5** (12 Plätze, Maps aus dem Competitive-Pool) oder **Wingman 2v2** (6 Plätze, Maps aus dem Wingman-Pool). - Offen, ohne Passwort, sichtbar im LAN-Reiter von Steam (`Maxlan | Messer 1 5v5 (Seitenwahl)`) und auf der Startseite mit „Verbinden“. @@ -459,8 +459,6 @@ Unter `/admin` mit dem Passwort aus `ADMIN_PASSWORD` (leer = Admin-Bereich aus). ### Reiter „Matches“ -- **Docker-Hosts:** pro Host belegte und freie Plätze, freier Speicher dort, wo das Gameserver-Volume liegt, und die Matches bzw. Messer-Server darauf. -- **Messer-Server (Seitenwahl):** starten (Modus 5v5 oder 2v2 und Map wählen), Runde starten, zurücksetzen, neu starten, entfernen. Siehe „Messer-Server für die Seitenwahl“. - **Suche und Filter:** Suche nach Team, Spieler oder Match-ID. Filter Alle, Laufend, Fehler (Zahl rot, wenn es Fehler gibt), Abgeschlossen und Archiv. Der Filter bleibt im Browser gespeichert. - **Matchkarte:** Teams, Modus, Format, Status, Live-Stand, Server- und CSTV-Adresse (`Server 192.168.178.45:27015 · CSTV …:27115`), Zeitpunkt der Server-Anforderung, Pausen-Abzeichen. Bei einem Serverfehler steht die Ursache rot in der Karte. - **Aktionen:** Ansehen, Veto starten, Aufwärmphase beenden, Pausieren/Fortsetzen, Nächste Map, Server neu starten / wiederholen, Server stoppen, Match abschließen, Match abbrechen, Archivieren / Aus dem Archiv holen, Löschen. Destruktive Aktionen fragen nach. @@ -471,9 +469,12 @@ Startet man den Server eines laufenden Matches neu, lädt die Anwendung danach d ### Reiter „Einstellungen“ +Die Einstellungen sind in eine Seitenleiste gegliedert: **Veto** (Map-Pools), **Gameserver** (Match-Regeln, CSTV, Zeiten, Messer-Server), **Server-Betrieb** (Docker-Hosts und Image-Update) und **Benachrichtigungen** (Discord). Am Handy stehen die Menüpunkte als Knöpfe über dem Inhalt. Der zuletzt gewählte Punkt bleibt im Browser gespeichert. + +- **Messer-Server:** starten (Modus 5v5 oder 2v2 und Map wählen), Runde starten, zurücksetzen, neu starten, entfernen. Siehe „Messer-Server für die Seitenwahl“. - **Map-Pools:** pro Modus wählen, welche Maps im Veto stehen. Der Veto-Ablauf passt sich der Anzahl an, siehe „Maps im Admin-Bereich wählen“. - **Gameserver:** Runden Competitive und Wingman, Verlängerung, zusätzliche Konsolenbefehle, CSTV an/aus und Verzögerung, Zeit pro Veto-Schritt, Leerlauf-Frist, maximale Laufzeit, Startzeit (inklusive Download) und Archiv-Frist. Die Werte aus der `.env` sind die Vorgabe. Was hier geändert wird, steht in der Datenbank und gilt auch nach einem Neustart. Mit „live“ markierte Werte gehen beim Speichern sofort per RCON an alle laufenden Server. Befehle, die Passwörter ändern oder den Server beenden, sind gesperrt. -- **Docker-Hosts:** Hosts hinzufügen, entfernen und bearbeiten (Name, Docker-Ziel, Adresse, Portbereich, LAN-Ports, Volume). Die Liste ersetzt `CS2_DOCKER_HOSTS`. „Liste aus .env verwenden“ schaltet zurück. Hosts mit laufenden Servern lassen sich nicht entfernen und ihre Adresse nicht ändern. +- **Docker-Hosts:** oben die Auslastung pro Host (belegte und freie Plätze, freier Speicher dort, wo das Gameserver-Volume liegt, Matches und Messer-Server darauf), darunter Hosts hinzufügen, entfernen und bearbeiten (Name, Docker-Ziel, Adresse, Portbereich, LAN-Ports, Volume). Die Liste ersetzt `CS2_DOCKER_HOSTS`. „Liste aus .env verwenden“ schaltet zurück. Hosts mit laufenden Servern lassen sich nicht entfernen und ihre Adresse nicht ändern. - **Image aktualisieren:** lädt das CS2-Image auf allen Hosts gleichzeitig neu (per SSH direkt auf dem Host). Laufende Server laufen weiter. - **Discord-Meldungen:** welche Ereignisse gemeldet werden, plus Testmeldung. diff --git a/client/src/pages/AdminPage.jsx b/client/src/pages/AdminPage.jsx index e7b7913..748f81a 100644 --- a/client/src/pages/AdminPage.jsx +++ b/client/src/pages/AdminPage.jsx @@ -59,7 +59,6 @@ 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); @@ -90,7 +89,6 @@ export default function AdminPage() { const res = await api(`/admin/matches${filter === 'archive' ? '?archived=1' : ''}`, { token }); setMatches(res.matches); setArchivedCount(res.archivedCount ?? 0); - setHosts(res.hosts ?? []); setError(null); } catch (err) { if (err.status === 401) { @@ -147,14 +145,9 @@ export default function AdminPage() { - {tab === 'settings' && token && } - {tab === 'settings' && token && } - {tab === 'settings' && token && } - {tab === 'settings' && token && } + {tab === 'settings' && token && } {notice &&
{notice.text}
} {error &&
{error}
} - {tab === 'matches' && } - {tab === 'matches' && } {tab === 'matches' && matches === null && !error &&

Lade Matches …

} {tab === 'matches' && ( ); } +/** Auslastung der Docker-Hosts, regelmäßig neu geladen (Einstellungen → Docker-Hosts). */ +function HostUsageLive({ token }) { + const [hosts, setHosts] = useState([]); + useEffect(() => { + let alive = true; + const load = () => api('/admin/hosts', { token }).then((res) => alive && setHosts(res.hosts)).catch(() => {}); + load(); + const t = setInterval(load, REFRESH_MS); + return () => { + alive = false; + clearInterval(t); + }; + }, [token]); + return ; +} + /** 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; @@ -534,7 +543,7 @@ const shownDefault = (field) => { }; /** Gameserver-Einstellungen: Vorgaben aus der .env, im Admin-Bereich änderbar. */ -function ServerSettingsPanel({ token, setNotice }) { +function ServerSettingsPanel({ token, setNotice, groups = SETTING_GROUPS.map((g) => g.key), title = 'Gameserver' }) { const [fields, setFields] = useState(null); const [values, setValues] = useState({}); const [busy, setBusy] = useState(false); @@ -598,13 +607,13 @@ function ServerSettingsPanel({ token, setNotice }) { return (
-

Gameserver

+

{title}

Markierte Werte (live) gehen beim Speichern sofort an alle laufenden Server. Die übrigen gelten für neue Server und Vetos.

- {SETTING_GROUPS.map((g) => ( + {SETTING_GROUPS.filter((g) => groups.includes(g.key)).map((g) => (
-

{g.title}

+ {groups.length > 1 &&

{g.title}

}
{fields.filter((f) => f.group === g.key).map((f) => (
@@ -667,7 +676,14 @@ function DockerHostsPanel({ token, setNotice }) { }, [pulling, load]); if (!data) return null; - if (!data.editable) return null; + if (!data.editable) { + return ( +
+

Docker-Hosts

+

Docker-Hosts und Image-Update gibt es nur mit dem Docker-Adapter (SERVER_ADAPTER=docker).

+
+ ); + } const original = JSON.stringify(data.hosts.map((h) => ({ ...EMPTY_HOST, ...h, dockerHost: h.dockerHost ?? '' }))); const dirty = JSON.stringify(rows) !== original; @@ -1000,3 +1016,86 @@ function MapPoolsPanel({ token, setNotice }) {
); } +const SETTINGS_KEY = 'cs2mv:admin:settings'; + +/** Menüpunkte der Einstellungen, in Gruppen für die Seitenleiste. */ +const SETTINGS_MENU = [ + { + title: 'Veto', + items: [{ key: 'maps', label: 'Map-Pools', hint: 'Maps im Veto' }], + }, + { + title: 'Gameserver', + items: [ + { key: 'match', label: 'Match-Regeln', hint: 'Runden, Verlängerung, Befehle' }, + { key: 'tv', label: 'CSTV', hint: 'an/aus, Verzögerung' }, + { key: 'times', label: 'Zeiten', hint: 'Veto, Leerlauf, Start, Archiv' }, + { key: 'knife', label: 'Messer-Server', hint: 'Seitenwahl, 5v5 und 2v2' }, + ], + }, + { + title: 'Server-Betrieb', + items: [{ key: 'hosts', label: 'Docker-Hosts', hint: 'Hosts und Image-Update' }], + }, + { + title: 'Benachrichtigungen', + items: [{ key: 'discord', label: 'Discord', hint: 'Meldungen und Test' }], + }, +]; +const SETTINGS_KEYS = SETTINGS_MENU.flatMap((g) => g.items.map((i) => i.key)); + +const readSettingsSection = () => { + try { + const v = localStorage.getItem(SETTINGS_KEY); + return SETTINGS_KEYS.includes(v) ? v : SETTINGS_KEYS[0]; + } catch { + return SETTINGS_KEYS[0]; + } +}; + +/** Einstellungen mit Seitenleiste: links die Menüpunkte, rechts der gewählte Bereich. */ +function SettingsLayout({ token, setNotice }) { + const [section, setSectionState] = useState(readSettingsSection); + const setSection = (key) => { + setSectionState(key); + try { + localStorage.setItem(SETTINGS_KEY, key); + } catch { + /* ignorieren */ + } + }; + const current = SETTINGS_MENU.flatMap((g) => g.items).find((i) => i.key === section); + + return ( +
+ +
+ {section === 'maps' && } + {['match', 'tv', 'times'].includes(section) && ( + + )} + {section === 'knife' && } + {section === 'hosts' && } + {section === 'hosts' && } + {section === 'discord' && } +
+
+ ); +} diff --git a/client/src/styles.css b/client/src/styles.css index 58af54d..c20b91d 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -626,3 +626,22 @@ input:focus, select:focus { border-color: var(--link); outline: none; } .flow-steps .flow-ban { color: var(--error); border-color: color-mix(in srgb, var(--error) 50%, transparent); } .flow-steps .flow-pick { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 50%, transparent); } .flow-steps .flow-decider { color: var(--text); border-color: var(--primary); } +/* Admin-Einstellungen mit Seitenleiste */ +.settings-layout { display: grid; grid-template-columns: 230px minmax(0, 1fr); gap: 20px; align-items: start; } +.settings-nav { position: sticky; top: 16px; display: flex; flex-direction: column; gap: 14px; } +.settings-nav-title { font-size: .72rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); margin: 0 0 4px 10px; } +.settings-nav-group { display: flex; flex-direction: column; gap: 2px; } +.settings-nav-item { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; width: 100%; text-align: left; font: inherit; color: var(--text); background: transparent; border: 1px solid transparent; border-left: 3px solid transparent; border-radius: 4px; padding: 7px 10px; cursor: pointer; } +.settings-nav-item small { font-size: .75rem; color: var(--muted); } +.settings-nav-item:hover { background: var(--surface); } +.settings-nav-item.is-active { background: var(--surface); border-color: var(--line); border-left-color: var(--primary); } +.settings-content > .admin-settings { margin-bottom: 0; } +@media (max-width: 760px) { + .settings-layout { grid-template-columns: minmax(0, 1fr); gap: 12px; } + .settings-nav { position: static; flex-direction: row; flex-wrap: wrap; gap: 6px; } + .settings-nav-group { flex-direction: row; flex-wrap: wrap; gap: 6px; } + .settings-nav-title { display: none; } + .settings-nav-item { width: auto; white-space: nowrap; border: 1px solid var(--line); border-radius: 999px; padding: 5px 12px; } + .settings-nav-item small { display: none; } + .settings-nav-item.is-active { border-color: var(--primary); } +} diff --git a/server/src/adminRoutes.js b/server/src/adminRoutes.js index 9244729..3331474 100644 --- a/server/src/adminRoutes.js +++ b/server/src/adminRoutes.js @@ -164,6 +164,9 @@ export function createAdminRouter({ service, manager, config, settings, knife, p })); // Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse) + // Auslastung und Speicher der Docker-Hosts mit den Servern darauf + router.get('/hosts', requireAdmin, wrap(async () => ({ hosts: await manager.hostUsage() }))); + // ?archived=1 liefert das Archiv, sonst alle nicht archivierten Matches router.get('/matches', requireAdmin, wrap(async (req) => { const archived = req.query.archived === '1'; @@ -174,7 +177,6 @@ export function createAdminRouter({ service, manager, config, settings, knife, p return { matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))), archivedCount, - hosts: await manager.hostUsage(), }; }));