From 7ae3efef601e5888de912723adc0f1d76ae7aa93 Mon Sep 17 00:00:00 2001 From: MrEgosar Date: Thu, 29 Oct 2026 19:47:42 +0100 Subject: [PATCH] Admin: Suche, Filter und Archiv in der Matchliste, Fehler direkt in der Karte --- client/src/pages/AdminPage.jsx | 117 +++++++++++++++++++++++++--- client/src/styles.css | 10 +++ server/src/adminRoutes.js | 17 +++- server/src/config.js | 2 + server/src/db.js | 2 + server/src/matchService.js | 30 +++++++ server/src/serverSettings.js | 2 + server/src/servers/serverManager.js | 1 + server/test/api.test.js | 37 +++++++++ 9 files changed, 206 insertions(+), 12 deletions(-) diff --git a/client/src/pages/AdminPage.jsx b/client/src/pages/AdminPage.jsx index cda2d2f..a6fcd46 100644 --- a/client/src/pages/AdminPage.jsx +++ b/client/src/pages/AdminPage.jsx @@ -5,8 +5,40 @@ import { FORMAT_LABEL, serverLabel } from '../lib/labels.js'; import CopyField from '../components/CopyField.jsx'; const KEY = 'cs2mv:admin'; +const FILTER_KEY = 'cs2mv:admin:filter'; const REFRESH_MS = 3000; +const RUNNING = ['lobby', 'veto', 'server_setup', 'live']; +const CLOSED = ['finished', 'cancelled']; +const SERVER_ERRORS = ['start_failed', 'failed', 'config_failed']; +const hasError = (m) => SERVER_ERRORS.includes(m.server?.status); + +/** Filter der Matchliste. "archive" lädt eine eigene Liste vom Server. */ +const FILTERS = [ + { key: 'all', label: 'Alle', test: () => true }, + { key: 'running', label: 'Laufend', test: (m) => RUNNING.includes(m.status) }, + { key: 'errors', label: 'Fehler', test: hasError }, + { key: 'closed', label: 'Abgeschlossen', test: (m) => CLOSED.includes(m.status) }, + { key: 'archive', label: 'Archiv', test: () => true }, +]; + +const readFilter = () => { + try { + const f = localStorage.getItem(FILTER_KEY); + return FILTERS.some((x) => x.key === f) ? f : 'all'; + } catch { + return 'all'; + } +}; + +/** Suche nach Teamnamen, Spielern und Match-ID, ohne Groß-/Kleinschreibung. */ +function matchesSearch(m, query) { + const q = query.trim().toLowerCase(); + if (!q) return true; + const hay = [m.id, m.teams.A.name, m.teams.B.name, ...['A', 'B'].flatMap((t) => m.teams[t].players.map((p) => p.nickname))]; + return hay.some((x) => String(x ?? '').toLowerCase().includes(q)); +} + const getToken = () => { try { return sessionStorage.getItem(KEY); @@ -31,6 +63,19 @@ export default function AdminPage() { const [notice, setNotice] = useState(null); const [busy, setBusy] = useState(null); const [tab, setTab] = useState('matches'); + const [filter, setFilterState] = useState(readFilter); + const [query, setQuery] = useState(''); + const [archivedCount, setArchivedCount] = useState(0); + const setFilter = (f) => { + setFilterState(f); + // Beim Wechsel zwischen Archiv und normaler Liste neu laden + if ((f === 'archive') !== (filter === 'archive')) setMatches(null); + try { + localStorage.setItem(FILTER_KEY, f); + } catch { + /* ignorieren */ + } + }; const logout = useCallback(() => { api('/admin/logout', { method: 'POST', token }).catch(() => {}); @@ -41,8 +86,9 @@ export default function AdminPage() { const load = useCallback(async () => { try { - const res = await api('/admin/matches', { token }); + 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) { @@ -53,7 +99,7 @@ export default function AdminPage() { setError(err.message); } } - }, [token]); + }, [token, filter]); useEffect(() => { if (!token) return undefined; @@ -107,13 +153,31 @@ export default function AdminPage() { {error &&
{error}
} {tab === 'matches' && } {tab === 'matches' && matches === null && !error &&

Lade Matches …

} - {tab === 'matches' && matches && matches.length === 0 &&

Noch keine Matches.

} - - {tab === 'matches' &&
- {matches?.map((m) => ( - - ))} -
} + {tab === 'matches' && ( + + )} + {tab === 'matches' && matches && (() => { + const active = FILTERS.find((f) => f.key === filter); + const shown = matches.filter((m) => active.test(m) && matchesSearch(m, query)); + if (matches.length === 0) { + return

{filter === 'archive' ? 'Das Archiv ist leer.' : 'Noch keine Matches.'}

; + } + if (shown.length === 0) return

Keine Matches für diesen Filter{query ? ` und „${query}“` : ''}.

; + return ( +
+ {shown.map((m) => ( + + ))} +
+ ); + })()} ); } @@ -313,6 +377,10 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) { )} )} + {hasError(m) && m.server.message && ( +

{m.server.message}

+ )} + {m.archivedAt && Archiviert {formatDateTime(m.archivedAt)}} {paused && Pausiert} @@ -330,6 +398,8 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) { {m.server && !['stopped'].includes(m.server.status) && btn('Server stoppen', { path: '/server/stop', confirm: 'Server stoppen?' })} {!closed && btn('Match abschließen', { path: '/finish', confirm: 'Match als abgeschlossen markieren?' })} {!closed && btn('Match abbrechen', { path: '/cancel', confirm: 'Match abbrechen?' }, 'btn-ghost danger')} + {closed && !m.archivedAt && btn('Archivieren', { path: '/archive', success: 'Match archiviert' })} + {m.archivedAt && btn('Aus dem Archiv holen', { path: '/unarchive', success: 'Match ist wieder in der Liste' })} {btn('Löschen', { method: 'DELETE', path: '', confirm: `Match ${m.teams.A.name} vs. ${m.teams.B.name} endgültig löschen?`, success: 'Match gelöscht' }, 'btn-ghost danger')} @@ -699,3 +769,32 @@ function DockerHostsPanel({ token, setNotice }) { function formatDateTime(ms) { return `${new Date(ms).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })} Uhr`; } +/** Suche und Filter über der Matchliste, mit Anzahl pro Filter. */ +function MatchToolbar({ matches, archivedCount, filter, setFilter, query, setQuery }) { + const count = (f) => (f.key === 'archive' ? archivedCount : matches.filter(f.test).length); + return ( +
+ setQuery(e.target.value)} + /> +
+ {FILTERS.map((f) => ( + + ))} +
+
+ ); +} diff --git a/client/src/styles.css b/client/src/styles.css index 27f0be7..69580ea 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -589,3 +589,13 @@ input:focus, select:focus { border-color: var(--link); outline: none; } .pull-running span { color: var(--pending); } .pull-ok span { color: var(--ok, #22c55e); } .pull-failed span { color: var(--error); } +.admin-toolbar { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center; margin: 0 0 14px; } +.admin-search { flex: 1 1 240px; min-width: 0; font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--line); border-radius: 4px; padding: 7px 10px; } +.admin-filters { display: flex; flex-wrap: wrap; gap: 6px; } +.admin-filter { display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: .875rem; color: var(--muted); background: transparent; border: 1px solid var(--line); border-radius: 999px; padding: 4px 12px; cursor: pointer; } +.admin-filter:hover { color: var(--text); } +.admin-filter.is-active { color: var(--text); border-color: var(--primary); background: color-mix(in srgb, var(--primary) 16%, transparent); } +.admin-filter-count { font-variant-numeric: tabular-nums; font-size: .75rem; opacity: .8; } +.admin-filter.has-errors .admin-filter-count { color: var(--error); font-weight: 700; opacity: 1; } +.admin-error { margin: 8px 0 0; color: var(--error); } +.admin-badge.is-archived { background: transparent; color: var(--muted); border: 1px solid var(--line); } diff --git a/server/src/adminRoutes.js b/server/src/adminRoutes.js index c43adae..e262551 100644 --- a/server/src/adminRoutes.js +++ b/server/src/adminRoutes.js @@ -137,9 +137,18 @@ export function createAdminRouter({ service, manager, config, settings }) { })); // Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse) - 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: 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'; + const ids = service.db.prepare(archived + ? 'SELECT id FROM matches WHERE archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT 300' + : 'SELECT id FROM matches WHERE archived_at IS NULL ORDER BY created_at DESC LIMIT 150').all(); + const archivedCount = service.db.prepare('SELECT COUNT(*) AS n FROM matches WHERE archived_at IS NOT NULL').get().n; + return { + matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))), + archivedCount, + hosts: await manager.hostUsage(), + }; })); const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => { @@ -152,6 +161,8 @@ export function createAdminRouter({ service, manager, config, settings }) { action('server/stop', (v) => manager.stopByOrganizer(v)); action('server/retry', (v) => manager.retry(v)); action('server/warmup-end', (v) => manager.endWarmup(v)); + action('archive', (v) => service.setArchived(v.matchId, true)); + action('unarchive', (v) => service.setArchived(v.matchId, false)); action('server/pause', (v) => manager.setPaused(v, true)); action('server/resume', (v) => manager.setPaused(v, false)); action('series/next', (v) => manager.loadNextMap(v)); diff --git a/server/src/config.js b/server/src/config.js index d13c48a..f33d547 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -109,6 +109,8 @@ export function loadConfig(overrides = {}) { reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60), // Frist pro Veto-Schritt in Sekunden, danach führt das Backend den Schritt automatisch aus. 0 = keine Frist vetoStepSeconds: int('VETO_STEP_SECONDS', 30), + // Abgeschlossene oder abgebrochene Matches nach so vielen Tagen automatisch archivieren. 0 = nie + archiveAfterDays: int('ARCHIVE_AFTER_DAYS', 3), match: { tvDelaySeconds: int('CS2_TV_DELAY', 90), diff --git a/server/src/db.js b/server/src/db.js index 621a140..4767eaf 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -130,4 +130,6 @@ function migrate(db) { } const serverColumns = columns('servers'); if (!serverColumns.has('paused')) db.exec('ALTER TABLE servers ADD COLUMN paused INTEGER NOT NULL DEFAULT 0'); + // Abgeschlossene Matches lassen sich archivieren; sie verschwinden dann aus der normalen Admin-Liste + if (!columns('matches').has('archived_at')) db.exec('ALTER TABLE matches ADD COLUMN archived_at INTEGER'); } diff --git a/server/src/matchService.js b/server/src/matchService.js index 6a9f698..03f52ec 100644 --- a/server/src/matchService.js +++ b/server/src/matchService.js @@ -625,6 +625,34 @@ export class MatchService extends EventEmitter { .run(matchId, match.current_map_number); } + /** Abgeschlossenes Match ins Archiv legen bzw. zurückholen. */ + setArchived(matchId, archived) { + const match = this.getMatch(matchId); + if (archived && !CLOSED_STATUSES.includes(match.status)) { + throw new HttpError(409, 'Nur abgeschlossene oder abgebrochene Matches lassen sich archivieren', 'not_closed'); + } + this.db.prepare('UPDATE matches SET archived_at = ? WHERE id = ?').run(archived ? this.now() : null, matchId); + this.log(matchId, 'status', archived ? 'Match archiviert (Admin)' : 'Match aus dem Archiv geholt (Admin)'); + this.changed(matchId); + } + + /** Abgeschlossene Matches nach `archiveAfterDays` Tagen automatisch archivieren. Liefert die Anzahl. */ + archiveClosed(now = this.now()) { + const days = this.config.archiveAfterDays; + if (!days) return 0; + const cutoff = now - days * 86_400_000; + const ids = this.db.prepare(` + SELECT id FROM matches + WHERE archived_at IS NULL AND status IN ('finished', 'cancelled') AND COALESCE(finished_at, updated_at) < ? + `).all(cutoff).map((r) => r.id); + const stmt = this.db.prepare('UPDATE matches SET archived_at = ? WHERE id = ?'); + for (const id of ids) { + stmt.run(now, id); + this.log(id, 'status', `Match automatisch archiviert (nach ${days} Tagen)`); + } + return ids.length; + } + close(viewer, status) { this.require(viewer, 'organizer'); this.closeMatch(viewer.matchId, status); @@ -881,6 +909,8 @@ export class MatchService extends EventEmitter { status: match.status, statusLabel: MATCH_STATUS[match.status], createdAt: match.created_at, + finishedAt: match.finished_at ?? null, + archivedAt: match.archived_at ?? null, updatedAt: match.updated_at, vetoStart: match.veto_start, currentMapNumber: match.current_map_number, diff --git a/server/src/serverSettings.js b/server/src/serverSettings.js index 56cb51c..e4dac10 100644 --- a/server/src/serverSettings.js +++ b/server/src/serverSettings.js @@ -30,6 +30,8 @@ export const SERVER_SETTING_FIELDS = [ get: (c) => c.idleTimeoutMinutes, set: (c, v) => { c.idleTimeoutMinutes = v; } }, { key: 'maxLifetimeHours', group: 'times', label: 'Maximale Laufzeit eines Servers in Stunden', type: 'int', min: 1, max: 72, live: false, get: (c) => c.maxLifetimeHours, set: (c, v) => { c.maxLifetimeHours = v; } }, + { key: 'archiveAfterDays', group: 'times', label: 'Abgeschlossene Matches archivieren nach Tagen (0 = nie)', type: 'int', min: 0, max: 365, live: false, + get: (c) => c.archiveAfterDays, set: (c, v) => { c.archiveAfterDays = v; } }, { key: 'startTimeoutMinutes', group: 'times', label: 'Startzeit eines Servers in Minuten (inkl. Download)', type: 'int', min: 5, max: 240, live: false, get: (c) => c.docker.startTimeoutMinutes, set: (c, v) => { c.docker.startTimeoutMinutes = v; } }, ]; diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js index 111725a..994676b 100644 --- a/server/src/servers/serverManager.js +++ b/server/src/servers/serverManager.js @@ -578,6 +578,7 @@ export class ServerManager { // ---------- Automatisches Herunterfahren ---------- async reap(now = Date.now()) { + this.service.archiveClosed(now); const timeoutMs = this.config.idleTimeoutMinutes * 60_000; const rows = this.db.prepare(`SELECT * FROM servers WHERE status IN (${ACTIVE.map(() => '?').join(',')})`).all(...ACTIVE); for (const row of rows) { diff --git a/server/test/api.test.js b/server/test/api.test.js index 1fb51a2..36d1c9f 100644 --- a/server/test/api.test.js +++ b/server/test/api.test.js @@ -508,3 +508,40 @@ test('Neustart im laufenden Match: Map wird wieder geladen, Server läuft die vo await close(); } }); +test('Archiv: nur abgeschlossene Matches, eigene Liste, automatisch nach Frist', async () => { + const { app, call, close } = await setup({ adminPassword: 'admin-geheim', archiveAfterDays: 3 }); + try { + const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token; + const mk = async (a, b) => (await call('POST', '/matches', { body: { teamA: a, teamB: b, mode: 'wingman', format: 'bo1' } })).body; + const open = await mk('Offen A', 'Offen B'); + const done = await mk('Fertig A', 'Fertig B'); + const old = await mk('Alt A', 'Alt B'); + + // Laufende Matches lassen sich nicht archivieren + assert.equal((await call('POST', `/admin/matches/${open.id}/archive`, { token })).status, 409); + + await call('POST', `/admin/matches/${done.id}/finish`, { token }); + assert.equal((await call('POST', `/admin/matches/${done.id}/archive`, { token })).status, 200); + let list = (await call('GET', '/admin/matches', { token })).body; + assert.ok(!list.matches.some((m) => m.id === done.id), 'archiviertes Match fehlt in der normalen Liste'); + assert.equal(list.archivedCount, 1); + const archive = (await call('GET', '/admin/matches?archived=1', { token })).body; + assert.deepEqual(archive.matches.map((m) => m.id), [done.id]); + assert.ok(archive.matches[0].archivedAt > 0); + + // Zurückholen + await call('POST', `/admin/matches/${done.id}/unarchive`, { token }); + list = (await call('GET', '/admin/matches', { token })).body; + assert.ok(list.matches.some((m) => m.id === done.id)); + + // Automatisch: abgebrochenes Match, älter als 3 Tage + await call('POST', `/admin/matches/${old.id}/cancel`, { token }); + assert.equal(app.service.archiveClosed(Date.now() + 2 * 86_400_000), 0, 'nach 2 Tagen noch nicht'); + await app.manager.reap(Date.now() + 4 * 86_400_000); + assert.ok(app.service.getMatch(old.id).archived_at > 0, 'nach 4 Tagen archiviert'); + assert.ok(app.service.getMatch(done.id).archived_at > 0, 'zurückgeholtes Match wird nach Ablauf der Frist wieder archiviert'); + assert.equal(app.service.getMatch(open.id).archived_at, null, 'offene Matches bleiben'); + } finally { + await close(); + } +});