Admin: Einstellungen mit Seitenleiste, Docker-Hosts und Messer-Server in die Einstellungen
This commit is contained in:
1 parent
ea06237846
commit
75b1701d8a
4 files changed
+139
-18
No files matched your search
+112
-13
@@ -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() {
|
||||
<button className={`admin-tab ${tab === 'settings' ? 'is-active' : ''}`} onClick={() => setTab('settings')}>Einstellungen</button>
|
||||
</nav>
|
||||
|
||||
{tab === 'settings' && token && <MapPoolsPanel token={token} setNotice={setNotice} />}
|
||||
{tab === 'settings' && token && <ServerSettingsPanel token={token} setNotice={setNotice} />}
|
||||
{tab === 'settings' && token && <DockerHostsPanel token={token} setNotice={setNotice} />}
|
||||
{tab === 'settings' && token && <NotificationSettings token={token} setNotice={setNotice} />}
|
||||
{tab === 'settings' && token && <SettingsLayout 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' && <KnifePanel token={token} setNotice={setNotice} />}
|
||||
{tab === 'matches' && matches === null && !error && <p className="muted">Lade Matches …</p>}
|
||||
{tab === 'matches' && (
|
||||
<MatchToolbar
|
||||
@@ -479,6 +472,22 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
/** 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 <HostUsage hosts={hosts} />;
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<section className="admin-settings">
|
||||
<h2>Gameserver</h2>
|
||||
<h2>{title}</h2>
|
||||
<p className="small muted">
|
||||
Markierte Werte (live) gehen beim Speichern sofort an alle laufenden Server. Die übrigen gelten für neue Server und Vetos.
|
||||
</p>
|
||||
{SETTING_GROUPS.map((g) => (
|
||||
{SETTING_GROUPS.filter((g) => groups.includes(g.key)).map((g) => (
|
||||
<div key={g.key} className="setting-group">
|
||||
<h3>{g.title}</h3>
|
||||
{groups.length > 1 && <h3>{g.title}</h3>}
|
||||
<div className="setting-grid">
|
||||
{fields.filter((f) => f.group === g.key).map((f) => (
|
||||
<div key={f.key} className="setting-item">
|
||||
@@ -667,7 +676,14 @@ function DockerHostsPanel({ token, setNotice }) {
|
||||
}, [pulling, load]);
|
||||
|
||||
if (!data) return null;
|
||||
if (!data.editable) return null;
|
||||
if (!data.editable) {
|
||||
return (
|
||||
<section className="admin-settings">
|
||||
<h2>Docker-Hosts</h2>
|
||||
<p className="muted">Docker-Hosts und Image-Update gibt es nur mit dem Docker-Adapter (<code>SERVER_ADAPTER=docker</code>).</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div className="settings-layout">
|
||||
<nav className="settings-nav" aria-label="Einstellungen">
|
||||
{SETTINGS_MENU.map((g) => (
|
||||
<div key={g.title} className="settings-nav-group">
|
||||
<div className="settings-nav-title">{g.title}</div>
|
||||
{g.items.map((i) => (
|
||||
<button
|
||||
key={i.key}
|
||||
className={`settings-nav-item ${section === i.key ? 'is-active' : ''}`}
|
||||
aria-current={section === i.key ? 'page' : undefined}
|
||||
onClick={() => setSection(i.key)}
|
||||
>
|
||||
<span>{i.label}</span>
|
||||
<small>{i.hint}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="settings-content">
|
||||
{section === 'maps' && <MapPoolsPanel token={token} setNotice={setNotice} />}
|
||||
{['match', 'tv', 'times'].includes(section) && (
|
||||
<ServerSettingsPanel key={section} token={token} setNotice={setNotice} groups={[section]} title={current.label} />
|
||||
)}
|
||||
{section === 'knife' && <KnifePanel token={token} setNotice={setNotice} />}
|
||||
{section === 'hosts' && <HostUsageLive token={token} />}
|
||||
{section === 'hosts' && <DockerHostsPanel token={token} setNotice={setNotice} />}
|
||||
{section === 'discord' && <NotificationSettings token={token} setNotice={setNotice} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
Reference in new issue
Block a user