Admin: Suche, Filter und Archiv in der Matchliste, Fehler direkt in der Karte
This commit is contained in:
1 parent
c50741e49c
commit
7ae3efef60
9 files changed
+204
-10
No files matched your search
@@ -5,8 +5,40 @@ import { FORMAT_LABEL, serverLabel } from '../lib/labels.js';
|
|||||||
import CopyField from '../components/CopyField.jsx';
|
import CopyField from '../components/CopyField.jsx';
|
||||||
|
|
||||||
const KEY = 'cs2mv:admin';
|
const KEY = 'cs2mv:admin';
|
||||||
|
const FILTER_KEY = 'cs2mv:admin:filter';
|
||||||
const REFRESH_MS = 3000;
|
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 = () => {
|
const getToken = () => {
|
||||||
try {
|
try {
|
||||||
return sessionStorage.getItem(KEY);
|
return sessionStorage.getItem(KEY);
|
||||||
@@ -31,6 +63,19 @@ export default function AdminPage() {
|
|||||||
const [notice, setNotice] = useState(null);
|
const [notice, setNotice] = useState(null);
|
||||||
const [busy, setBusy] = useState(null);
|
const [busy, setBusy] = useState(null);
|
||||||
const [tab, setTab] = useState('matches');
|
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(() => {
|
const logout = useCallback(() => {
|
||||||
api('/admin/logout', { method: 'POST', token }).catch(() => {});
|
api('/admin/logout', { method: 'POST', token }).catch(() => {});
|
||||||
@@ -41,8 +86,9 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api('/admin/matches', { token });
|
const res = await api(`/admin/matches${filter === 'archive' ? '?archived=1' : ''}`, { token });
|
||||||
setMatches(res.matches);
|
setMatches(res.matches);
|
||||||
|
setArchivedCount(res.archivedCount ?? 0);
|
||||||
setHosts(res.hosts ?? []);
|
setHosts(res.hosts ?? []);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -53,7 +99,7 @@ export default function AdminPage() {
|
|||||||
setError(err.message);
|
setError(err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [token]);
|
}, [token, filter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return undefined;
|
if (!token) return undefined;
|
||||||
@@ -107,13 +153,31 @@ export default function AdminPage() {
|
|||||||
{error && <div className="alert alert-error">{error}</div>}
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
{tab === 'matches' && <HostUsage hosts={hosts} />}
|
{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' && (
|
||||||
|
<MatchToolbar
|
||||||
{tab === 'matches' && <div className="admin-list">
|
matches={filter === 'archive' ? [] : matches ?? []}
|
||||||
{matches?.map((m) => (
|
archivedCount={archivedCount}
|
||||||
|
filter={filter}
|
||||||
|
setFilter={setFilter}
|
||||||
|
query={query}
|
||||||
|
setQuery={setQuery}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{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 <p className="muted">{filter === 'archive' ? 'Das Archiv ist leer.' : 'Noch keine Matches.'}</p>;
|
||||||
|
}
|
||||||
|
if (shown.length === 0) return <p className="muted">Keine Matches für diesen Filter{query ? ` und „${query}“` : ''}.</p>;
|
||||||
|
return (
|
||||||
|
<div className="admin-list">
|
||||||
|
{shown.map((m) => (
|
||||||
<MatchAdminCard key={m.id} m={m} run={run} busy={busy} token={token} reload={load} setNotice={setNotice} />
|
<MatchAdminCard key={m.id} m={m} run={run} busy={busy} token={token} reload={load} setNotice={setNotice} />
|
||||||
))}
|
))}
|
||||||
</div>}
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -313,6 +377,10 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{hasError(m) && m.server.message && (
|
||||||
|
<p className="admin-error small" role="alert">{m.server.message}</p>
|
||||||
|
)}
|
||||||
|
{m.archivedAt && <span className="admin-badge is-archived">Archiviert {formatDateTime(m.archivedAt)}</span>}
|
||||||
{paused && <span className="admin-badge is-paused">Pausiert</span>}
|
{paused && <span className="admin-badge is-paused">Pausiert</span>}
|
||||||
</div>
|
</div>
|
||||||
<button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button>
|
<button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button>
|
||||||
@@ -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?' })}
|
{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 abschließen', { path: '/finish', confirm: 'Match als abgeschlossen markieren?' })}
|
||||||
{!closed && btn('Match abbrechen', { path: '/cancel', confirm: 'Match abbrechen?' }, 'btn-ghost danger')}
|
{!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')}
|
{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')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -699,3 +769,32 @@ function DockerHostsPanel({ token, setNotice }) {
|
|||||||
function formatDateTime(ms) {
|
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`;
|
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 (
|
||||||
|
<div className="admin-toolbar">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="admin-search"
|
||||||
|
placeholder="Team, Spieler oder Match-ID suchen"
|
||||||
|
aria-label="Matches durchsuchen"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="admin-filters" role="group" aria-label="Matches filtern">
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.key}
|
||||||
|
className={`admin-filter ${filter === f.key ? 'is-active' : ''} ${f.key === 'errors' && count(f) > 0 ? 'has-errors' : ''}`}
|
||||||
|
aria-pressed={filter === f.key}
|
||||||
|
onClick={() => setFilter(f.key)}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
{(f.key !== 'archive' || archivedCount > 0) && <span className="admin-filter-count">{count(f)}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -589,3 +589,13 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
|
|||||||
.pull-running span { color: var(--pending); }
|
.pull-running span { color: var(--pending); }
|
||||||
.pull-ok span { color: var(--ok, #22c55e); }
|
.pull-ok span { color: var(--ok, #22c55e); }
|
||||||
.pull-failed span { color: var(--error); }
|
.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); }
|
||||||
@@ -137,9 +137,18 @@ export function createAdminRouter({ service, manager, config, settings }) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// 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(async () => {
|
// ?archived=1 liefert das Archiv, sonst alle nicht archivierten Matches
|
||||||
const ids = service.db.prepare('SELECT id FROM matches ORDER BY created_at DESC LIMIT 100').all();
|
router.get('/matches', requireAdmin, wrap(async (req) => {
|
||||||
return { matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))), hosts: await manager.hostUsage() };
|
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) => {
|
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/stop', (v) => manager.stopByOrganizer(v));
|
||||||
action('server/retry', (v) => manager.retry(v));
|
action('server/retry', (v) => manager.retry(v));
|
||||||
action('server/warmup-end', (v) => manager.endWarmup(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/pause', (v) => manager.setPaused(v, true));
|
||||||
action('server/resume', (v) => manager.setPaused(v, false));
|
action('server/resume', (v) => manager.setPaused(v, false));
|
||||||
action('series/next', (v) => manager.loadNextMap(v));
|
action('series/next', (v) => manager.loadNextMap(v));
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ export function loadConfig(overrides = {}) {
|
|||||||
reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60),
|
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
|
// Frist pro Veto-Schritt in Sekunden, danach führt das Backend den Schritt automatisch aus. 0 = keine Frist
|
||||||
vetoStepSeconds: int('VETO_STEP_SECONDS', 30),
|
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: {
|
match: {
|
||||||
tvDelaySeconds: int('CS2_TV_DELAY', 90),
|
tvDelaySeconds: int('CS2_TV_DELAY', 90),
|
||||||
|
|||||||
@@ -130,4 +130,6 @@ function migrate(db) {
|
|||||||
}
|
}
|
||||||
const serverColumns = columns('servers');
|
const serverColumns = columns('servers');
|
||||||
if (!serverColumns.has('paused')) db.exec('ALTER TABLE servers ADD COLUMN paused INTEGER NOT NULL DEFAULT 0');
|
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');
|
||||||
}
|
}
|
||||||
@@ -625,6 +625,34 @@ export class MatchService extends EventEmitter {
|
|||||||
.run(matchId, match.current_map_number);
|
.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) {
|
close(viewer, status) {
|
||||||
this.require(viewer, 'organizer');
|
this.require(viewer, 'organizer');
|
||||||
this.closeMatch(viewer.matchId, status);
|
this.closeMatch(viewer.matchId, status);
|
||||||
@@ -881,6 +909,8 @@ export class MatchService extends EventEmitter {
|
|||||||
status: match.status,
|
status: match.status,
|
||||||
statusLabel: MATCH_STATUS[match.status],
|
statusLabel: MATCH_STATUS[match.status],
|
||||||
createdAt: match.created_at,
|
createdAt: match.created_at,
|
||||||
|
finishedAt: match.finished_at ?? null,
|
||||||
|
archivedAt: match.archived_at ?? null,
|
||||||
updatedAt: match.updated_at,
|
updatedAt: match.updated_at,
|
||||||
vetoStart: match.veto_start,
|
vetoStart: match.veto_start,
|
||||||
currentMapNumber: match.current_map_number,
|
currentMapNumber: match.current_map_number,
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export const SERVER_SETTING_FIELDS = [
|
|||||||
get: (c) => c.idleTimeoutMinutes, set: (c, v) => { c.idleTimeoutMinutes = v; } },
|
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,
|
{ 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; } },
|
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,
|
{ 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; } },
|
get: (c) => c.docker.startTimeoutMinutes, set: (c, v) => { c.docker.startTimeoutMinutes = v; } },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -578,6 +578,7 @@ export class ServerManager {
|
|||||||
// ---------- Automatisches Herunterfahren ----------
|
// ---------- Automatisches Herunterfahren ----------
|
||||||
|
|
||||||
async reap(now = Date.now()) {
|
async reap(now = Date.now()) {
|
||||||
|
this.service.archiveClosed(now);
|
||||||
const timeoutMs = this.config.idleTimeoutMinutes * 60_000;
|
const timeoutMs = this.config.idleTimeoutMinutes * 60_000;
|
||||||
const rows = this.db.prepare(`SELECT * FROM servers WHERE status IN (${ACTIVE.map(() => '?').join(',')})`).all(...ACTIVE);
|
const rows = this.db.prepare(`SELECT * FROM servers WHERE status IN (${ACTIVE.map(() => '?').join(',')})`).all(...ACTIVE);
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
|||||||
@@ -508,3 +508,40 @@ test('Neustart im laufenden Match: Map wird wieder geladen, Server läuft die vo
|
|||||||
await close();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user