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
+206
-12
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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; } },
|
||||
];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user