Add persistent match history to server pages
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
export interface ServerMatch {
|
||||||
|
id: number;
|
||||||
|
map: string;
|
||||||
|
startedAt: string;
|
||||||
|
status: 'active' | 'completed' | 'interrupted';
|
||||||
|
startPlayers: number | null;
|
||||||
|
uniquePlayers: number;
|
||||||
|
endPlayers: number | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
finalRound: number | null;
|
||||||
|
}
|
||||||
@@ -260,6 +260,7 @@ export function db(): Database.Database {
|
|||||||
backfillMultiScriptJoins(handle);
|
backfillMultiScriptJoins(handle);
|
||||||
assertSnapshotSchemeSafe(handle);
|
assertSnapshotSchemeSafe(handle);
|
||||||
migrateGameTelemetry(handle);
|
migrateGameTelemetry(handle);
|
||||||
|
migrateTable(handle, 'leaderboard_matches', [{ name: 'start_players', ddl: 'ALTER TABLE leaderboard_matches ADD COLUMN start_players INTEGER' }]);
|
||||||
backfillLeaderboard(handle);
|
backfillLeaderboard(handle);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handle.close();
|
handle.close();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS leaderboard_matches (
|
|||||||
last_received_at TEXT NOT NULL,
|
last_received_at TEXT NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'active', -- active | completed | interrupted
|
status TEXT NOT NULL DEFAULT 'active', -- active | completed | interrupted
|
||||||
start_observed INTEGER NOT NULL DEFAULT 0,
|
start_observed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
start_players INTEGER,
|
||||||
highest_round INTEGER NOT NULL DEFAULT 0,
|
highest_round INTEGER NOT NULL DEFAULT 0,
|
||||||
last_event TEXT NOT NULL,
|
last_event TEXT NOT NULL,
|
||||||
last_fingerprint TEXT NOT NULL
|
last_fingerprint TEXT NOT NULL
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
|
|||||||
}
|
}
|
||||||
if (!match) return;
|
if (!match) return;
|
||||||
|
|
||||||
|
if (isStart) {
|
||||||
|
handle.prepare('UPDATE leaderboard_matches SET start_players = COALESCE(start_players, ?) WHERE id = ?')
|
||||||
|
.run(report.players.length, match.id);
|
||||||
|
}
|
||||||
|
|
||||||
const previous = new Map((handle.prepare('SELECT guid, last_round, present FROM leaderboard_match_players WHERE match_id = ?')
|
const previous = new Map((handle.prepare('SELECT guid, last_round, present FROM leaderboard_match_players WHERE match_id = ?')
|
||||||
.all(match.id) as Participation[]).map(player => [player.guid, player]));
|
.all(match.id) as Participation[]).map(player => [player.guid, player]));
|
||||||
handle.prepare('UPDATE leaderboard_match_players SET present = 0 WHERE match_id = ?').run(match.id);
|
handle.prepare('UPDATE leaderboard_match_players SET present = 0 WHERE match_id = ?').run(match.id);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { db } from '../db/index.js';
|
||||||
|
import type { ServerMatch } from '../../match-history.js';
|
||||||
|
|
||||||
|
export function serverMatchHistory(server: string, limit = 25): { matches: ServerMatch[]; total: number } {
|
||||||
|
const handle = db();
|
||||||
|
const total = (handle.prepare('SELECT COUNT(*) AS total FROM leaderboard_matches WHERE server = ?').get(server) as { total: number }).total;
|
||||||
|
const matches = handle.prepare(`SELECT m.id, m.map_name AS map, m.started_at AS startedAt, m.status,
|
||||||
|
m.start_players AS startPlayers,
|
||||||
|
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id) AS uniquePlayers,
|
||||||
|
CASE WHEN m.status = 'completed' THEN
|
||||||
|
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id AND p.present = 1)
|
||||||
|
END AS endPlayers,
|
||||||
|
CASE WHEN m.status = 'completed' AND m.start_observed = 1 THEN
|
||||||
|
MAX(0, ROUND((julianday(m.ended_at) - julianday(m.started_at)) * 86400000)) END AS durationMs,
|
||||||
|
CASE WHEN m.status = 'completed' THEN m.highest_round END AS finalRound
|
||||||
|
FROM leaderboard_matches m WHERE m.server = ? ORDER BY m.id DESC LIMIT ?`)
|
||||||
|
.all(server, limit) as ServerMatch[];
|
||||||
|
return { matches, total };
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ServerMatch } from '$lib/match-history.js';
|
||||||
|
import { pollVisible } from '$ui/poll.js';
|
||||||
|
import { formatDuration } from '$ui/duration.js';
|
||||||
|
import Card from './primitives/Card.svelte';
|
||||||
|
import Button from './primitives/Button.svelte';
|
||||||
|
import EmptyState from './primitives/EmptyState.svelte';
|
||||||
|
import ListFooter from './primitives/ListFooter.svelte';
|
||||||
|
|
||||||
|
let { server }: { server: string } = $props();
|
||||||
|
let matches = $state<ServerMatch[]>([]);
|
||||||
|
let total = $state(0);
|
||||||
|
let limit = $state(25);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
async function refresh(currentServer: string, currentLimit: number) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/servers/${encodeURIComponent(currentServer)}/matches?limit=${currentLimit}`);
|
||||||
|
if (!response.ok) throw new Error('Could not load match history.');
|
||||||
|
const body = await response.json();
|
||||||
|
if (server !== currentServer || limit !== currentLimit) return;
|
||||||
|
matches = body.matches;
|
||||||
|
total = body.total;
|
||||||
|
error = '';
|
||||||
|
} catch (cause) {
|
||||||
|
if (server === currentServer && limit === currentLimit) error = (cause as Error).message;
|
||||||
|
} finally {
|
||||||
|
if (server === currentServer && limit === currentLimit) loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$effect(() => {
|
||||||
|
const currentServer = server;
|
||||||
|
const currentLimit = limit;
|
||||||
|
return pollVisible(() => refresh(currentServer, currentLimit), 10000);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card padding="lg">
|
||||||
|
<h2>Match history</h2>
|
||||||
|
<p>Newest matches first. Unique players are counted across all match reports. — means the start or end wasn’t recorded, or the match is still in progress.</p>
|
||||||
|
{#if error}
|
||||||
|
<div role="alert">{error} <Button onclick={() => refresh(server, limit)}>Retry</Button></div>
|
||||||
|
{/if}
|
||||||
|
{#if loading}
|
||||||
|
<EmptyState title="Loading matches…" />
|
||||||
|
{:else if matches.length === 0 && !error}
|
||||||
|
<EmptyState title="No matches recorded yet" description="Matches appear here when this server reports gameplay." />
|
||||||
|
{:else if matches.length}
|
||||||
|
<!-- Keyboard focus lets users scroll the wide table horizontally. -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<div class="table-scroll" tabindex="0" role="region" aria-label="Match history">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Started</th><th>Map</th><th>Status</th><th>Players at start</th><th>Unique players</th><th>Players at end</th><th>Game time</th><th>Final round</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{#each matches as match (match.id)}
|
||||||
|
<tr>
|
||||||
|
<td><time datetime={match.startedAt}>{new Date(match.startedAt).toLocaleString()}</time></td>
|
||||||
|
<td>{match.map}</td>
|
||||||
|
<td>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</td>
|
||||||
|
<td>{match.startPlayers ?? '—'}</td><td>{match.uniquePlayers}</td><td>{match.endPlayers ?? '—'}</td>
|
||||||
|
<td>{match.durationMs === null ? '—' : formatDuration(match.durationMs)}</td><td>{match.finalRound ?? '—'}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<ListFooter total={Math.min(total, 10000)} bind:limit label="matches" />
|
||||||
|
{#if total > 10000}<p>Showing the latest 10,000 matches.</p>{/if}
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h2 { margin: 0; font-size: var(--text-md); }
|
||||||
|
p { color: var(--text-secondary); font-size: var(--text-sm); }
|
||||||
|
.table-scroll { overflow-x: auto; }
|
||||||
|
.table-scroll:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
|
||||||
|
th, td { padding: var(--space-3); text-align: left; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; }
|
||||||
|
th { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||||
|
td { font-variant-numeric: tabular-nums; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
|
||||||
|
|
||||||
|
export function GET({ params, url }: { params: { name: string }; url: URL }) {
|
||||||
|
const limit = Number(url.searchParams.get('limit') ?? 25);
|
||||||
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) {
|
||||||
|
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
|
||||||
|
}
|
||||||
|
return json(serverMatchHistory(params.name, limit));
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import MatchHistory from '$ui/components/MatchHistory.svelte';
|
||||||
import RoundTelemetry from '$ui/components/RoundTelemetry.svelte';
|
import RoundTelemetry from '$ui/components/RoundTelemetry.svelte';
|
||||||
import TelemetryDiagnostics from '$ui/components/TelemetryDiagnostics.svelte';
|
import TelemetryDiagnostics from '$ui/components/TelemetryDiagnostics.svelte';
|
||||||
import { pollVisible } from '$ui/poll.js';
|
import { pollVisible } from '$ui/poll.js';
|
||||||
@@ -102,7 +103,7 @@
|
|||||||
let rconProbe = $state<RconProbeResult | null>(null);
|
let rconProbe = $state<RconProbeResult | null>(null);
|
||||||
let probingRcon = $state(false);
|
let probingRcon = $state(false);
|
||||||
let destroyOpen = $state(false);
|
let destroyOpen = $state(false);
|
||||||
const sectionIds = ['scoreboard', 'chat', 'logs', 'config', 'inventory', 'diagnostics', 'history'];
|
const sectionIds = ['scoreboard', 'matches', 'chat', 'logs', 'config', 'inventory', 'diagnostics', 'history'];
|
||||||
const activeTab = $derived(sectionIds.includes($page.url.searchParams.get('tab') ?? '') ? $page.url.searchParams.get('tab')! : 'scoreboard');
|
const activeTab = $derived(sectionIds.includes($page.url.searchParams.get('tab') ?? '') ? $page.url.searchParams.get('tab')! : 'scoreboard');
|
||||||
function changeTab(tab: string) {
|
function changeTab(tab: string) {
|
||||||
const url = new URL($page.url);
|
const url = new URL($page.url);
|
||||||
@@ -214,6 +215,7 @@
|
|||||||
let tabs = $derived([
|
let tabs = $derived([
|
||||||
{ id: 'scoreboard', label: 'Scoreboard', badge: scoreboard?.players.length ?? null },
|
{ id: 'scoreboard', label: 'Scoreboard', badge: scoreboard?.players.length ?? null },
|
||||||
{ id: 'chat', label: 'Chat' },
|
{ id: 'chat', label: 'Chat' },
|
||||||
|
{ id: 'matches', label: 'Matches' },
|
||||||
{ id: 'logs', label: 'Logs' },
|
{ id: 'logs', label: 'Logs' },
|
||||||
{ id: 'config', label: 'Config' },
|
{ id: 'config', label: 'Config' },
|
||||||
{ id: 'inventory', label: 'Inventory', badge: inventory.length || null },
|
{ id: 'inventory', label: 'Inventory', badge: inventory.length || null },
|
||||||
@@ -516,6 +518,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<div id="telemetry">{#key name}<TelemetryDiagnostics server={name} />{/key}</div>
|
<div id="telemetry">{#key name}<TelemetryDiagnostics server={name} />{/key}</div>
|
||||||
|
{:else if activeTab === 'matches'}
|
||||||
|
{#key name}<MatchHistory server={name} />{/key}
|
||||||
{:else if activeTab === 'history'}
|
{:else if activeTab === 'history'}
|
||||||
{#if operations.length}
|
{#if operations.length}
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
|
|||||||
@@ -34,6 +34,42 @@ async function matches() {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it('records server match counts, unique rejoins, duration, and final round durably', async () => {
|
||||||
|
await report('game_start', 0, [player('a'), player('b')]);
|
||||||
|
await report('round_change', 2, [player('b'), player('c')]);
|
||||||
|
await report('heartbeat', 2, [player('a'), player('c')]);
|
||||||
|
await report('game_end', 7, [player('c')]);
|
||||||
|
await report('game_end', 7, [player('c')]);
|
||||||
|
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
|
closeDb();
|
||||||
|
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
||||||
|
expect(serverMatchHistory(server)).toEqual({ total: 1, matches: [expect.objectContaining({
|
||||||
|
status: 'completed', startPlayers: 2, uniquePlayers: 3, endPlayers: 1,
|
||||||
|
durationMs: 3000, finalRound: 7
|
||||||
|
})] });
|
||||||
|
expect(serverMatchHistory('other')).toEqual({ total: 0, matches: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves unknown boundaries and distinguishes empty rosters from missing observations', async () => {
|
||||||
|
await report('heartbeat', 5);
|
||||||
|
await report('game_end', 6, []);
|
||||||
|
await report('game_start', 0, []);
|
||||||
|
await report('round_change', 2);
|
||||||
|
await report('game_start');
|
||||||
|
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
||||||
|
expect(serverMatchHistory(server).matches).toEqual([
|
||||||
|
expect.objectContaining({ status: 'active', startPlayers: 1, endPlayers: null, durationMs: null, finalRound: null }),
|
||||||
|
expect.objectContaining({ status: 'interrupted', startPlayers: 0, endPlayers: null, durationMs: null, finalRound: null }),
|
||||||
|
expect.objectContaining({ status: 'completed', startPlayers: null, uniquePlayers: 1, endPlayers: 0, durationMs: null, finalRound: 6 })
|
||||||
|
]);
|
||||||
|
const { GET } = await import('../../src/routes/api/servers/[name]/matches/+server.js');
|
||||||
|
const response = GET({ params: { name: server }, url: new URL('http://localhost/?limit=1') });
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.total).toBe(3);
|
||||||
|
expect(body.matches).toHaveLength(1);
|
||||||
|
expect(GET({ params: { name: server }, url: new URL('http://localhost/?limit=-1') }).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
async function records(guid = '2741626') {
|
async function records(guid = '2741626') {
|
||||||
const { db } = await import('../../src/lib/server/db/index.js');
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
return db().prepare('SELECT * FROM leaderboard_match_players WHERE guid = ? ORDER BY match_id').all(guid) as Array<{
|
return db().prepare('SELECT * FROM leaderboard_match_players WHERE guid = ? ORDER BY match_id').all(guid) as Array<{
|
||||||
|
|||||||
Reference in New Issue
Block a user