Make UI language, timestamps and live feedback consistent

This commit is contained in:
2026-10-07 19:46:40 -04:00
parent 0ed3cd6b47
commit 70185eb98c
81 changed files with 656 additions and 332 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
export type GameLibraryGame = 't5' | 't6';
export const GAME_FILE_GAMES = [
{ id: 't5', name: 'Black Ops I (T5)' },
{ id: 't6', name: 'Black Ops II (T6)' }
{ id: 't5', name: 'Black Ops I' },
{ id: 't6', name: 'Black Ops II' }
] as const;
export type GameLibraryState = 'importing' | 'ready' | 'failed';
export type LibraryCacheState = 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated';
+16
View File
@@ -0,0 +1,16 @@
export const hostOperationTitles: Record<string, string> = {
'instance.create': 'Create server container', 'instance.rebuild': 'Rebuild server container',
'instance.start': 'Start server container', 'instance.restart': 'Restart server container',
'instance.stop': 'Stop server container', 'instance.remove': 'Remove server container',
'instance.cancel-create': 'Cancel server creation', 'instance.inspect': 'Check server container',
'host.inspect': 'Check host', 'host.decommission': 'Remove host', 'runtime.pull': 'Prepare container runtime',
'library.download': 'Download game files', 'library.import': 'Import game files',
'plutonium.prepare': 'Prepare game update', 'files.stage': 'Stage server files',
'files.apply': 'Apply server files', 'files.read': 'Read server files', 'files.list': 'List server files',
'files.stat': 'Check server files', 'files.manifest': 'Check file versions', 'files.remove': 'Remove server files',
'rcon.status-batch': 'Check game status', 'rcon.execute': 'Game command', 'logs.read': 'Read server logs'
};
export function hostOperationTitle(kind: string): string {
return hostOperationTitles[kind] ?? 'Host operation';
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { T5_ZOMBIE_MAPS, T6_ZOMBIE_MAPS } from './map-aliases.js';
import { gameTitle } from './ui/server-labels.js';
export interface LibraryVersion {
id: number; version: number; sha256: string; size: number;
@@ -30,7 +31,7 @@ export function mapLabel(canonical: string): string {
return matches.length > 1 ? `${matches[0].friendly} + ${matches.length - 1} variants` : matches[0]?.friendly ?? canonical;
}
export function scopeLabel(s: Pick<LibraryScript, 'scope_type' | 'scope_value'>): string {
return s.scope_type === 'game' ? (s.scope_value ?? '').toUpperCase()
return s.scope_type === 'game' ? gameTitle(s.scope_value)
: s.scope_type === 'map' ? mapLabel(s.scope_value ?? '') : 'Unscoped';
}
export interface BetaSelection { scriptId: number; versionId: number; }
+2 -13
View File
@@ -1,3 +1,4 @@
import { hostOperationTitles } from '../host-operation-labels.js';
import type Database from 'better-sqlite3';
import { normalizeActivityQuery, type ActivityPage, type ActivityQuery, type ActivityRow, type ActivitySource } from '../activity.js';
import { serverTitle } from '../ui/server-labels.js';
@@ -7,18 +8,6 @@ import { importantOperationSql } from './hosts/observations.js';
const initialized = new WeakSet<Database.Database>();
const quote = (value: string) => `'${value.replaceAll("'", "''")}'`;
const hostTitles: Record<string, string> = {
'instance.create': 'Create server container', 'instance.rebuild': 'Rebuild server container',
'instance.start': 'Start server container', 'instance.restart': 'Restart server container',
'instance.stop': 'Stop server container', 'instance.remove': 'Remove server container',
'instance.cancel-create': 'Cancel server creation', 'instance.inspect': 'Check server container',
'host.inspect': 'Check host', 'host.decommission': 'Remove host', 'runtime.pull': 'Prepare container runtime',
'library.download': 'Download game files', 'library.import': 'Import game files',
'plutonium.prepare': 'Prepare game update', 'files.stage': 'Stage server files',
'files.apply': 'Apply server files', 'files.read': 'Read server files', 'files.list': 'List server files',
'files.stat': 'Check server files', 'files.manifest': 'Check file versions', 'files.remove': 'Remove server files',
'rcon.status-batch': 'Check game status', 'rcon.execute': 'Game command', 'logs.read': 'Read server logs'
};
const labelCase = (column: string, labels: Record<string, string>, fallback: string) =>
`CASE ${column} ${Object.entries(labels).map(([key, value]) => `WHEN ${quote(key)} THEN ${quote(value)}`).join(' ')} ELSE ${quote(fallback)} END`;
@@ -115,7 +104,7 @@ items AS (
SELECT 'host:' || o.id,o.created_at,'host','|host|',
CASE WHEN o.state='completed' AND o.routine=1 AND o.observation_success=0 THEN 'observation-failed'
WHEN o.state='completed' AND COALESCE(o.error,'')<>'' THEN 'warning' ELSE o.state END,
${labelCase('o.kind', hostTitles, 'Host operation')},
${labelCase('o.kind', hostOperationTitles, 'Host operation')},
CASE WHEN o.routine=1 AND o.state='completed' AND o.observation_success=0 THEN 'The command completed, but the check reported a problem.'
WHEN o.state='unknown' THEN 'The host has not confirmed the result.' ELSE NULL END,
COALESCE(o.server_name,n.server),s.destroyed_at,o.host_id,NULL,'host',o.id
+2 -2
View File
@@ -120,7 +120,7 @@ function libraryForGame(game: GameLibraryGame, now: string): string {
/** Register a folder on the controller; no host or Docker volume is needed. */
export function createLibraryUpload(input: Record<string, unknown>): GameLibraryVersion {
const game = input.game;
if (game !== 't5' && game !== 't6') throw new Error('Choose T5 or T6 game files.');
if (game !== 't5' && game !== 't6') throw new Error('Choose Black Ops I or Black Ops II game files.');
const files = validateUploadPlan(input.files);
const handle = database();
const id = randomUUID(); const now = new Date().toISOString();
@@ -220,7 +220,7 @@ export async function requestBrowserPublication(id: string) {
export function createLibraryImport(input: Record<string, unknown>): GameLibraryVersion {
const game = input.game;
if (game !== 't5' && game !== 't6') throw new Error('Choose T5 or T6 game files.');
if (game !== 't5' && game !== 't6') throw new Error('Choose Black Ops I or Black Ops II game files.');
const hostId = textField(input.hostId, 'source host', 128);
const host = getHost(hostId);
if (!host || !host.connected || !host.credentialActive || host.mode === 'disabled') throw new Error('Choose an online, enrolled source host.');
+1 -1
View File
@@ -162,7 +162,7 @@ export function hostPreparationStatus(host: Host): HostPreparation {
items.push({ kind: 'library', label: game.name, state: 'blocked', detail: `Upload and publish ${game.name} game files before this host can be enabled.` });
}
} else if (!items.some(item => item.kind === 'library')) items.push({ kind: 'library', label: 'Game files', state: 'waiting',
detail: 'Upload T5 or T6 game files; hosts download the latest available files automatically.' });
detail: 'Upload Black Ops I or Black Ops II game files; hosts download the latest available files automatically.' });
// Cache health is visible, but never makes a usable host ineligible or
// delays re-enabling: a lobby's normal updater handles cache misses.
const required = items.filter(item => item.kind !== 'plutonium');
+30 -2
View File
@@ -17,7 +17,7 @@ export function availableTimeZones(): string[] {
return [...new Set([DEFAULT_TIME_ZONE, 'UTC', ...zones])].sort();
}
type Timestamp = string | number | Date | null | undefined;
export type Timestamp = string | number | Date | null | undefined;
/** Database timestamps without an offset are UTC, never browser-local time. */
function parseTimestamp(value: Exclude<Timestamp, null | undefined>): Date {
@@ -28,6 +28,12 @@ function parseTimestamp(value: Exclude<Timestamp, null | undefined>): Date {
return new Date(value);
}
export function timestampISO(value: Timestamp): string | undefined {
if (value === null || value === undefined || value === '') return undefined;
const parsed = parseTimestamp(value);
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : undefined;
}
export function createTimeFormatter(zone: string) {
const timeZone = validTimeZone(zone) ? zone : DEFAULT_TIME_ZONE;
const date: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' };
@@ -40,12 +46,34 @@ export function createTimeFormatter(zone: string) {
return Number.isFinite(parsed.getTime()) ? format.format(parsed) : '—';
};
}
const absolute = formatter({ ...date, hour: 'numeric', minute: '2-digit', timeZoneName: 'short' });
const clock = formatter({ hour: 'numeric', minute: '2-digit' });
const calendar = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: 'numeric', day: 'numeric' });
const calendarDay = (value: Date) => {
const parts = Object.fromEntries(calendar.formatToParts(value).map(part => [part.type, part.value]));
return Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day));
};
function relative(value: Timestamp, now = Date.now()): string {
if (value === null || value === undefined || value === '') return '—';
const parsed = parseTimestamp(value);
if (!Number.isFinite(parsed.getTime()) || !Number.isFinite(now)) return '—';
const age = now - parsed.getTime();
if (age < 0) return absolute(parsed);
if (age < 5_000) return 'Just now';
if (age < 60_000) return `${Math.floor(age / 1000)} s ago`;
if (age < 3_600_000) return `${Math.floor(age / 60_000)} min ago`;
if (age < 86_400_000) return `${Math.floor(age / 3_600_000)} h ago`;
// Calendar arithmetic keeps Yesterday correct across DST and year changes.
if (calendarDay(new Date(now)) - calendarDay(parsed) === 86_400_000) return `Yesterday, ${clock(parsed)}`;
return absolute(parsed);
}
return {
relative,
dateTime: formatter({ ...date, ...time }),
date: formatter(date),
time: formatter(time),
clockTime: formatter({ hour: 'numeric', minute: '2-digit', second: '2-digit' }),
minuteTime: formatter({ hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }),
period: formatter({ ...date, hour: 'numeric', minute: '2-digit', timeZoneName: 'short' })
period: absolute
};
}
+3 -10
View File
@@ -1,5 +1,5 @@
import { parseActivityQuery, type ActivityQuery, type ActivitySource } from '../activity.js';
import { matchStartedLabel } from './match-history.js';
import { createTimeFormatter } from '../time-zone.js';
export interface ActivityScope { server?: string; source?: ActivitySource }
const keys = ['server', 'host', 'type', 'source', 'state', 'days', 'q', 'limit', 'offset'] as const;
@@ -35,13 +35,6 @@ export function activityHistoryHref(url: URL, changes: Partial<Record<keyof Acti
/** Recent entries scan as relative time; exact timestamps stay on the time element. */
export function activityTimeLabel(value: string, zone: string, now: number): string {
const timestamp = Date.parse(/Z$|[+-]\d\d:\d\d$/.test(value) ? value : value.replace(' ', 'T') + 'Z');
if (!Number.isFinite(timestamp)) return 'Time unavailable';
const age = now - timestamp;
if (age < 0) return matchStartedLabel(value, zone, new Date(now));
if (age < 5_000) return 'Just now';
if (age < 60_000) return `${Math.floor(age / 1000)} s ago`;
if (age < 3_600_000) return `${Math.floor(age / 60_000)} min ago`;
if (age < 86_400_000) return `${Math.floor(age / 3_600_000)} h ago`;
return matchStartedLabel(value, zone, new Date(now));
const label = createTimeFormatter(zone).relative(value, now);
return label === '—' ? 'Time unavailable' : label;
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { statusPresentation, type StatusTone } from './status.js';
import { plural } from './plural.js';
export type CampaignServerGroup = 'attention' | 'processing' | 'queued' | 'complete' | 'inactive';
@@ -29,7 +30,7 @@ export function campaignOutcome(campaign: Pick<CampaignSummary, 'servers' | 'rol
const completed = campaign.servers.filter(server => server.restart_state === 'done').length;
const live = campaign.servers.filter(server => !server.server_deleted_at);
const attention = live.filter(server => ['failed', 'held', 'manual'].includes(server.restart_state));
if (attention.length) return { status: 'attention', label: `${attention.length} need attention`, tone: attention.some(server => server.restart_state === 'failed') ? 'danger' : 'warning', completed };
if (attention.length) return { status: 'attention', label: `${attention.length} ${plural(attention.length, 'needs', 'need')} attention`, tone: attention.some(server => server.restart_state === 'failed') ? 'danger' : 'warning', completed };
if (live.some(server => server.restart_state === 'pending')) return { status: 'progress', label: `${completed}/${campaign.servers.length} complete`, tone: 'info', completed };
if (campaign.rolled_back_by) return { status: 'rolled-back', label: 'Rolled back', tone: 'neutral', completed };
if (campaign.servers.length && completed === campaign.servers.length) return { status: 'complete', label: 'Complete', tone: 'online', completed };
+2 -2
View File
@@ -4,7 +4,7 @@
import { goto } from '$app/navigation';
import { activityTypes, activitySources, activityStates, type ActivityPage, type ActivityQuery, type ActivitySource } from '$lib/activity.js';
import { contextHref } from '$lib/navigation-context.js';
import { activityApiHref, activityHistoryHref, activityQuery, activityTimeLabel } from '$ui/activity-history.js';
import { activityApiHref, activityHistoryHref, activityQuery } from '$ui/activity-history.js';
import { pollVisible } from '$ui/poll.js';
import { serverTitle } from '$ui/server-labels.js';
import { fleet } from '$ui/stores/fleet.js';
@@ -117,7 +117,7 @@
{#if row.hostName}<span class="host-name">{row.hostName}</span>{/if}
</div>
</div>
<div class="outcome"><StatusBadge state={row.state === 'succeeded' ? 'done' : row.state} /><time datetime={row.at} title={$timeFormat.dateTime(row.at)} aria-label={$timeFormat.dateTime(row.at)}>{activityTimeLabel(row.at, $timeZone, $uiNow)}</time>
<div class="outcome"><StatusBadge state={row.state === 'succeeded' ? 'done' : row.state} /><time datetime={row.at} title={$timeFormat.dateTime(row.at)} aria-label={$timeFormat.dateTime(row.at)}>{$timeFormat.relative(row.at, $uiNow)}</time>
<span class="sources">{(row.sources.length ? row.sources : [row.source]).map(item => activitySources[item]).join(' · ')}</span>
</div>
</li>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import Dialog from './primitives/Dialog.svelte';
import Button from './primitives/Button.svelte';
let { open = $bindable(false), onadded }: { open?: boolean; onadded: () => Promise<unknown> } = $props();
@@ -30,7 +31,7 @@
<p role="status">{name} created. Run this command with administrator access on that machine.</p>
<textarea readonly aria-label="Host installation command" rows="6" value={enrollment.command}></textarea>
<div class="actions"><Button onclick={copy}>{copied ? 'Copied' : 'Copy command'}</Button><Button variant="primary" href={`/hosts/${enrollment.hostId}?tab=setup`}>Open host setup</Button></div>
<p class="muted">This single-use command expires {new Date(enrollment.expiresAt).toLocaleString()}. Generate a new installer from host settings if it expires.</p>
<p class="muted">This single-use command expires {$timeFormat.dateTime(enrollment.expiresAt)}. Generate a new installer from host settings if it expires.</p>
<Button variant="ghost" onclick={() => { enrollment = null; name = ''; copied = false; error = ''; }}>Add another host</Button>
{:else}
<p>Use a Debian 12 or 13 amd64 machine. The agent connects over HTTPS and prepares the runtime and game files automatically.</p>
+4 -3
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { serverTitle } from '$ui/server-labels.js';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
import { confirmAction } from '$ui/confirm.js';
@@ -353,7 +354,7 @@
</div></details>
</Card>
{#if installedVersions.length}<details class="installed"><summary>Recorded installed files · {installedVersions.length} servers</summary><p>Last recorded file writes, not a verification of the currently running process.</p><ul>{#each installedVersions as installed}<li><a href={'/servers/' + encodeURIComponent(installed.server)}>{installed.server}</a> · v{installed.version} · {$timeFormat.dateTime(installed.recordedAt)}</li>{/each}</ul></details>{:else if family !== 'config'}<p class="dim">No installed file versions recorded. Eligibility does not mean a file is installed.</p>{/if}
{#if installedVersions.length}<details class="installed"><summary>Recorded installed files · {installedVersions.length} servers</summary><p>Last recorded file writes, not a verification of the currently running process.</p><ul>{#each installedVersions as installed}<li><a href={'/servers/' + encodeURIComponent(installed.server)}>{serverTitle(installed.server)}</a> · v{installed.version} · <RelativeTime value={installed.recordedAt} /></li>{/each}</ul></details>{:else if family !== 'config'}<p class="dim">No installed file versions recorded. Eligibility does not mean a file is installed.</p>{/if}
{#if a.kind === 'config' || a.kind === 'baseline'}
<ConfigEditor {id} kind={a.kind} {versions} currentVersionId={a.current_version_id}
@@ -375,7 +376,7 @@
<span class="v-notes">{v.notes ?? ''}
{#if v.source}<a href={v.source.release_url} target="_blank" rel="noreferrer">GitHub · {v.source.asset_name} ↗</a>{/if}
</span>
<time class="v-when" datetime={v.uploaded_at}>{$timeFormat.dateTime(v.uploaded_at)}</time>
<span class="v-when"><RelativeTime value={v.uploaded_at} /></span>
{#if a.current_version_id === v.id}
<Badge variant="online" size="sm">Promoted</Badge>
{:else}
@@ -1,4 +1,6 @@
<script lang="ts">
import { serverTitle } from '$ui/server-labels.js';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { timeFormat } from '$ui/stores/time-zone.js';
import { tick } from 'svelte';
import { page } from '$app/stores';
@@ -43,7 +45,6 @@
&& (!evidenceKind || (evidenceKind === 'gameplay' ? ['game_start','round_change','heartbeat','game_end'].includes(event.kind) : evidenceKind === 'health' ? ['health','status-sample'].includes(event.kind) : event.kind === evidenceKind))
&& (!evidenceQuery || `${betaEvidenceSummary(event)} ${event.payload}`.toLowerCase().includes(evidenceQuery.toLowerCase()))));
$effect(() => { evidenceServer; evidenceKind; evidenceQuery; evidenceLimit = 25; });
function reportAge(at:string) { const minutes=Math.max(0,Math.floor((Date.now()-Date.parse(at))/60000));return minutes<1?'less than a minute ago':minutes<60?`${minutes}m ago`:`${Math.floor(minutes/60)}h ${minutes%60}m ago`; }
export async function action(action:string, extra:Record<string,string>={}) {
if (action === 'retry' && !await confirmAction({title:'Start a fresh test attempt?',message:`Reset current progress and checklist sign-offs on ${test.servers.length} deployed servers. They will restart when empty and must collect fresh evidence. Previous evidence remains in history. The deadline stays ${deadline ? $timeFormat.dateTime(deadline) : 'unchanged'}.`,confirmLabel:'Reset and restart testing'})) return;
busy = true; error = ''; message = '';
@@ -121,8 +122,8 @@
<summary>Evidence ({test.evidence.length}{test.evidence.length===200 ? ' most recent' : ''})</summary>
{#if evidenceOpen}
<p>Newest first. Includes retained evidence from earlier attempts and recovery cycles; current progress uses only the current attempt.</p>
<div class="filters"><label>Server<select bind:value={evidenceServer}><option value="">All servers</option>{#each [...new Set(test.evidence.map(event=>event.server))] as server}<option value={server}>{server}</option>{/each}</select></label><label>Event type<select bind:value={evidenceKind}><option value="">All events</option><option value="gameplay">Gameplay reports</option><option value="health">Health observations</option><option value="manual-sign-off">Tester sign-offs</option></select></label><label>Search evidence<input type="search" bind:value={evidenceQuery} placeholder="Round, map, tester, observation…"/></label></div>
{#each filteredEvidence.slice(0,evidenceLimit) as event(event.id)}<article class="evidence"><div class="heading"><HistoricalServerIdentity name={event.server} deletedAt={event.server_deleted_at}/><time datetime={event.received_at}>{$timeFormat.dateTime(event.received_at)}</time></div><p>{betaEvidenceSummary(event)}</p><details><summary>Raw payload · {event.kind}</summary><pre>{event.payload}</pre></details></article>{/each}
<div class="filters"><label>Server<select bind:value={evidenceServer}><option value="">All servers</option>{#each [...new Set(test.evidence.map(event=>event.server))] as server}<option value={server}>{serverTitle(server)}</option>{/each}</select></label><label>Event type<select bind:value={evidenceKind}><option value="">All events</option><option value="gameplay">Gameplay reports</option><option value="health">Health observations</option><option value="manual-sign-off">Tester sign-offs</option></select></label><label>Search evidence<input type="search" bind:value={evidenceQuery} placeholder="Round, map, tester, observation…"/></label></div>
{#each filteredEvidence.slice(0,evidenceLimit) as event(event.id)}<article class="evidence"><div class="heading"><HistoricalServerIdentity name={event.server} deletedAt={event.server_deleted_at}/><span class="time"><RelativeTime value={event.received_at} /></span></div><p>{betaEvidenceSummary(event)}</p><details><summary>Raw payload · {event.kind}</summary><pre>{event.payload}</pre></details></article>{/each}
<ListFooter total={filteredEvidence.length} bind:limit={evidenceLimit} label="events"/>
{#if !filteredEvidence.length}<p>{test.evidence.length ? 'No evidence matches these filters.' : 'No evidence recorded yet. Check deployment progress and telemetry diagnostics.'}</p>{/if}
{/if}
@@ -130,7 +131,7 @@
</section>
{#snippet checks(server: BetaTestView['servers'][number])}
{#if test.plan.requirements.some(needsGameTelemetry)}<p class="observation">{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll('_',' ')} · <time datetime={server.observation.lastGameplayAt} title={$timeFormat.dateTime(server.observation.lastGameplayAt)}>{reportAge(server.observation.lastGameplayAt)}</time> · {server.observation.players ?? 'Unknown'} players{#if server.observation.round !== null} · Round {server.observation.round}{/if}{:else}No gameplay reports in this attempt yet.{/if} {#if !server.server_deleted_at}<a href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics#telemetry`}>Telemetry diagnostics</a>{/if}</p>{/if}
{#if test.plan.requirements.some(needsGameTelemetry)}<p class="observation">{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll('_',' ')} · <span class="time"><RelativeTime value={server.observation.lastGameplayAt} /></span> · {server.observation.players ?? 'Unknown'} players{#if server.observation.round !== null} · Round {server.observation.round}{/if}{:else}No gameplay reports in this attempt yet.{/if} {#if !server.server_deleted_at}<a href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics#telemetry`}>Telemetry diagnostics</a>{/if}</p>{/if}
{#each server.progress as progress (progress.id)}
{@const requirement = test.plan.requirements.find(r=>r.id===progress.id)}
{#if requirement}<div class="requirement" class:failed={progress.failed}>
@@ -158,7 +159,7 @@
.deployment-details,.deployment-links { display:flex; flex-wrap:wrap; gap:8px 20px; margin:12px 0; font-size:var(--text-sm); }
@media(max-width:720px) { table { min-width:620px; } }
section { display:grid; gap:16px; } h2,p { margin:0; } h2 { font-size: var(--text-md); }
p,span,time { font-size: var(--text-sm); line-height:1.6; color:var(--text-secondary); } a { color:var(--accent-primary-hover); }
p,span,.time { font-size: var(--text-sm); line-height:1.6; color:var(--text-secondary); } a { color:var(--accent-primary-hover); }
.heading,.actions,.filters { display:flex; flex-wrap:wrap; align-items:center; gap:12px; } .heading { justify-content:space-between; }
.server { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; }
.server > summary { display:flex; flex-wrap:wrap; gap:8px 16px; } summary { cursor:pointer; font-size: var(--text-base); } .server > summary::before { content:'▸'; } .server[open] > summary::before { content:'▾'; }
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import type { CapacitySamplePage } from '$lib/hosts.js';
import Button from './primitives/Button.svelte';
@@ -54,7 +55,7 @@
<table><caption>CPU and memory peaks per container and minute; times are local.</caption>
<thead><tr><th scope="col">Time</th>{#if scope === 'fleet'}<th scope="col">Host</th>{/if}<th scope="col">Server</th><th scope="col">Phase</th><th scope="col">CPU cores</th><th scope="col">Memory</th></tr></thead>
<tbody>{#each result.samples as sample}
<tr><td><time datetime={sample.timestamp}>{new Date(sample.timestamp).toLocaleString()}</time></td>
<tr><td><RelativeTime value={sample.timestamp} /></td>
{#if scope === 'fleet'}<td>{sample.hostName ?? sample.hostId}</td>{/if}
<td>{sample.server ?? sample.instanceId}<small title={sample.instanceId}>Generation {sample.generation}</small></td>
<td>{sample.phase === 'startup' ? 'Starting' : 'Running'}</td><td>{sample.cpuCores.toFixed(3)}</td><td>{(sample.memoryBytes / 1048576).toFixed(1)} MiB</td></tr>
+4 -3
View File
@@ -14,6 +14,7 @@
import ConfigTextEditor from './ConfigTextEditor.svelte';
import ConfigInspector from './ConfigInspector.svelte';
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
import { gameTitle } from '$ui/server-labels.js';
interface Version { id: number; version: number; state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string; }
interface Artifact {
@@ -116,7 +117,7 @@
{:else}
{#if loading || artifacts.length || error}<div class="library-toolbar">
<input type="search" class="search" aria-label="Search configs" bind:value={query} placeholder="Search configs, maps, or servers…" />
<select aria-label="Filter by game" bind:value={game}><option value="">All games</option>{#each gameOptions as option}<option value={option}>{option.toUpperCase()}</option>{/each}</select>
<select aria-label="Filter by game" bind:value={game}><option value="">All games</option>{#each gameOptions as option}<option value={option}>{gameTitle(option)}</option>{/each}</select>
<select aria-label="Filter by scope" bind:value={scope}><option value="">All scopes</option><option value="game">Game</option><option value="map">Map</option></select>
<select aria-label="Filter by status" bind:value={status}><option value="">All statuses</option><option value="pending">Awaiting promotion</option><option value="promoted">Has promoted version</option><option value="beta">Active beta</option><option value="failed">Test failed</option></select>
{#if filtered.length && (query || game || scope || status)}<Button size="sm" variant="ghost" onclick={resetFilters}>Clear</Button>{/if}
@@ -147,7 +148,7 @@
<thead><tr><th>Config</th><th>Scope</th><th>Version / status</th><th>Eligible servers</th></tr></thead>
<tbody>{#each rows as artifact (artifact.id)}<tr>
<td><a class="name" href={contextHref(`/configs/${artifact.id}`, $page.url)}>{artifact.name}</a></td>
<td>{artifact.scope_type === 'game' ? `All ${artifact.scope_value?.toUpperCase()} servers` : artifact.scope_type === 'map' ? mapLabel(artifact.scope_value ?? '') : 'Unscoped'}</td>
<td>{artifact.scope_type === 'game' ? `All ${gameTitle(artifact.scope_value)} servers` : artifact.scope_type === 'map' ? mapLabel(artifact.scope_value ?? '') : 'Unscoped'}</td>
<td><LibraryVersions current={artifact.currentVersion} latest={artifact.latestVersion} betaId={artifact.runningBetaId} /></td>
<td class="numeric">{artifact.servers.length}</td>
</tr>{/each}</tbody>
@@ -158,7 +159,7 @@
<form onsubmit={create}>
<div class="dialog-heading"><div><span class="eyebrow">Create version 1</span><h2 id="new-config-title">New config</h2></div><Button variant="ghost" onclick={() => dialog.close()} disabled={saving} ariaLabel="Close new config">✕</Button></div>
<div class="kind-picker"><button type="button" class:active={kind === 'config'} aria-pressed={kind === 'config'} onclick={() => kind = 'config'} disabled={saving}><strong>Overlay</strong><span>Override selected settings</span></button><button type="button" class:active={kind === 'baseline'} aria-pressed={kind === 'baseline'} onclick={() => kind = 'baseline'} disabled={saving}><strong>Baseline</strong><span>A complete config for a game</span></button></div>
<div class="form-grid"><label>Name<input bind:value={name} placeholder={kind === 'config' ? 'No AFK timeout' : 'T5 baseline'} required disabled={saving} /></label><label>Game<select aria-label="Game" bind:value={scopeGame} onchange={() => scopeMap = ''} required disabled={saving}><option value="" disabled>Choose a game</option>{#each gameOptions as option}<option value={option}>{option.toUpperCase()}</option>{/each}</select></label>
<div class="form-grid"><label>Name<input bind:value={name} placeholder={kind === 'config' ? 'No AFK timeout' : 'Black Ops I baseline'} required disabled={saving} /></label><label>Game<select aria-label="Game" bind:value={scopeGame} onchange={() => scopeMap = ''} required disabled={saving}><option value="" disabled>Choose a game</option>{#each gameOptions as option}<option value={option}>{gameTitle(option)}</option>{/each}</select></label>
{#if kind === 'config'}<label>Apply to<select aria-label="Apply to" bind:value={scopeType} disabled={saving}><option value="game">Whole game</option><option value="map">One map</option></select></label>{#if scopeType === 'map'}<label>Map<select aria-label="Map" bind:value={scopeMap} required disabled={saving || !scopeGame}><option value="" disabled>Choose a map</option>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as option}<option value={option}>{option}</option>{/each}</select></label>{/if}{/if}
</div>
{#if baselineExists}<p class="warning">This game already has a baseline. Open it from the library to save a new version.</p>{/if}
@@ -209,7 +209,7 @@
{#snippet actions()}
<div class="launch-actions">
<p class:ready={canLaunch && !unconfirmedLaunch} role="status">{footerStatus}</p>
<div><Button variant="ghost" disabled={launching} onclick={() => open = false}>Cancel</Button><Button variant="primary" loading={launching} disabled={!canLaunch} onclick={launch}>{unconfirmedLaunch ? 'Retry launch' : 'Launch server'}</Button></div>
<div><Button variant="ghost" disabled={launching} onclick={() => open = false}>Cancel</Button><Button variant="primary" loading={launching} disabled={!canLaunch} onclick={launch}>{unconfirmedLaunch ? 'Retry creation' : 'Create server'}</Button></div>
</div>
{/snippet}
</Dialog>
+8 -5
View File
@@ -1,14 +1,17 @@
<script lang="ts">
import { onMount } from 'svelte';
let { updatedAt = null, error = '', intervalLabel = '', onRetry }: { updatedAt?: number | null; error?: string; intervalLabel?: string; onRetry?: () => void } = $props();
import { timeFormat } from '$ui/stores/time-zone.js';
let { updatedAt = null, error = '', intervalLabel = '', onRetry, paused = false, retryDisabled = false }:
{ updatedAt?: number | null; error?: string; intervalLabel?: string; onRetry?: () => void; paused?: boolean; retryDisabled?: boolean } = $props();
let now = $state(Date.now());
onMount(() => { const timer = setInterval(() => now = Date.now(), 1000); return () => clearInterval(timer); });
const age = $derived(updatedAt === null ? null : Math.max(0, Math.floor((now - updatedAt) / 1000)));
const ago = $derived(age === null ? '' : age < 5 ? 'just now' : age < 60 ? `${age} s ago` : `${Math.floor(age / 60)} min ago`);
// These are local receipt times, so their age uses the browser clock. The
// shared formatter still supplies the same language and exact tooltip.
const known = $derived(updatedAt !== null && Number.isFinite(updatedAt));
</script>
<div class="freshness" class:failed={!!error}>
{#if error}<span>{error}</span>{#if onRetry}<button type="button" onclick={onRetry}>Retry</button>{/if}
{:else}<span>{updatedAt === null ? 'Waiting for update' : `Live · updated ${ago}`}{intervalLabel ? ` · ${intervalLabel}` : ''}</span>{/if}
{#if error}<span>{error}</span>{#if onRetry}<button type="button" disabled={retryDisabled} onclick={onRetry}>Retry</button>{/if}
{:else}<span>{paused ? 'Paused' : known ? 'Live' : 'Waiting for update'}{#if known}{' · updated '}<time datetime={new Date(updatedAt!).toISOString()} title={$timeFormat.dateTime(updatedAt!)}>{$timeFormat.relative(updatedAt!, now)}</time>{/if}{!paused && intervalLabel ? ` · ${intervalLabel}` : ''}</span>{/if}
</div>
<style>
.freshness { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
+1 -1
View File
@@ -27,7 +27,7 @@
{@const host = hosts.find(host => host.id === cache.hostId)}
{@const waiting = latest && cache.status === 'missing'}
<tr>
<td><a href={`/hosts/${cache.hostId}`}>{host?.name ?? cache.hostId}</a>{#if !host?.connected}<span class="muted"> · Offline</span>{/if}</td>
<td><a href={`/hosts/${cache.hostId}`}>{host?.name ?? cache.hostId}</a>{#if !host?.connected}<span class="muted">{' · Offline'}</span>{/if}</td>
<td><Badge variant={cache.status === 'failed' ? 'danger' : cache.status === 'ready' ? 'online' : 'neutral'}>{waiting ? 'Waiting' : cache.status[0].toUpperCase() + cache.status.slice(1)}</Badge></td>
<td>{#if cache.status === 'ready'}<details><summary>Transfer details</summary><GameFileTransferProgress {cache} connected={host?.connected ?? false} /></details>
{:else if cache.status !== 'missing'}<GameFileTransferProgress {cache} connected={host?.connected ?? false} />{/if}
+4 -3
View File
@@ -2,12 +2,13 @@
import type { Host } from '$lib/hosts.js';
import { contextHref } from '$lib/navigation-context.js';
import { hostListStatus, hostReadingsStale, hostCpuUsage, hostUsedSpace } from '$ui/host-overview.js';
import { plural } from '$ui/plural.js';
import Badge from './primitives/Badge.svelte';
let { hosts, now, returnTo = '/hosts' }: { hosts: (Host & { serverCount: number; playerCount: number; unreportedInstances: number })[]; now: number; returnTo?: string } = $props();
</script>
<div class="table-wrap"><table>
<thead><tr><th>Host</th><th>Status</th><th title="Assigned instances, including stopped instances">Instances</th><th title="Players reported by online instances">Players</th><th>CPU</th><th>Memory <span>used / total</span></th><th>Disk <span>used / total</span></th></tr></thead>
<thead><tr><th>Host</th><th>Status</th><th title="Assigned servers, including stopped servers">Servers</th><th title="Players reported by online servers">Players</th><th>CPU</th><th>Memory <span>used / total</span></th><th>Disk <span>used / total</span></th></tr></thead>
<tbody>{#each hosts as host (host.id)}
{@const status = hostListStatus(host, now)}
{@const stale = hostReadingsStale(host, now)}
@@ -15,8 +16,8 @@
<td><a class="name" href={contextHref(`/hosts/${host.id}`, returnTo)}>{host.name}</a>{#if host.hostname && host.hostname !== host.name}<span class="secondary">{host.hostname}</span>{/if}
{#if stale && host.lastHeartbeat}<span class="secondary">Resource readings stale</span>{/if}</td>
<td class="status"><Badge variant={status.tone}>{status.label}</Badge>{#if status.detail}<a class="reason" href={contextHref(`/hosts/${host.id}?tab=${status.tab}`, returnTo)}>{status.detail}</a>{/if}</td>
<td><a href={contextHref(`/hosts/${host.id}?tab=servers`, returnTo)} aria-label={`${host.serverCount} assigned instances on ${host.name}`}>{host.serverCount}</a></td>
<td title={host.unreportedInstances ? `Player counts unavailable for ${host.unreportedInstances} of ${host.serverCount} instances` : 'Players reported by online instances'}>{host.unreportedInstances === host.serverCount && host.serverCount > 0 ? '—' : `${host.playerCount}${host.unreportedInstances ? '+' : ''}`}</td>
<td><a href={contextHref(`/hosts/${host.id}?tab=servers`, returnTo)} aria-label={`${host.serverCount} assigned ${plural(host.serverCount, 'server')} on ${host.name}`}>{host.serverCount}</a></td>
<td title={host.unreportedInstances ? `Player counts unavailable for ${host.unreportedInstances} of ${host.serverCount} ${plural(host.serverCount, 'server')}` : 'Players reported by online servers'}>{host.unreportedInstances === host.serverCount && host.serverCount > 0 ? '—' : `${host.playerCount}${host.unreportedInstances ? '+' : ''}`}</td>
<td class:stale>{hostCpuUsage(host.inventory.cpuUsagePercent)}</td>
<td class:stale>{hostUsedSpace(host.inventory.memoryTotal, host.inventory.memoryAvailable)}</td>
<td class:stale>{hostUsedSpace(host.inventory.storageTotal, host.inventory.storageAvailable)}</td>
+2 -2
View File
@@ -6,7 +6,7 @@
</script>
<div class="preparation" aria-live="polite">
<p>The default runtime and the latest available T5 and T6 game files are prepared automatically. Re-enabling a host requires a fresh runtime pull and verification of both BO1 (T5) and BO2 (T6) libraries before it accepts deployments. Running servers keep their current files.</p>
<p>The default runtime and the latest available Black Ops I and Black Ops II game files are prepared automatically. Re-enabling a host requires a fresh runtime pull and verification of both game libraries before it accepts deployments. Running servers keep their current files.</p>
<p>Supported hosts also cache Plutonium files to speed up new deployments. This optional cache refreshes when a new production release is detected; deployments can proceed while it is unavailable.</p>
{#if host.preparation}
<p class="status" class:blocked={host.preparation.state === 'blocked'}>Automatic setup: {host.preparation.state === 'ready' ? 'Ready' : host.preparation.state === 'preparing' ? 'In progress' : host.preparation.state === 'blocked' ? 'Needs attention' : 'Waiting'}</p>
@@ -25,7 +25,7 @@
{/each}
</ul>
{:else}<p>Setup progress will appear after the host connects.</p>{/if}
<p><a href="/game-files">Upload and view T5 / T6 game files →</a></p>
<p><a href="/game-files">Upload and view game files →</a></p>
</div>
<style>
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import type { LaunchRecipeSummary } from '$lib/deployment-plan.js';
let { recipe, historical = false, selectionStatus = null }: { recipe: LaunchRecipeSummary; historical?: boolean; selectionStatus?: 'queued' | 'attempted' | null } = $props();
</script>
@@ -14,7 +14,7 @@
<details>
<summary>{recipe.files.length} managed file{recipe.files.length === 1 ? '' : 's'} {historical ? 'recorded for this deployment' : 'in the current setup'}</summary>
<ul>{#each recipe.files as file}<li><div><strong>{file.name}</strong><span>{file.version !== null ? `v${file.version}` : 'Generated'} · {file.kind} · {file.size.toLocaleString()} bytes</span></div><code>{file.path}</code><small title={file.sha256}>SHA-256 {file.sha256.slice(0, 12)}</small></li>{/each}</ul>
<p>{historical ? 'Recorded' : 'Previewed'} {$timeFormat.dateTime(recipe.capturedAt)}. {historical ? selectionStatus === 'queued' ? 'Versions are selected again when creation begins. This queued capture does not pin them.' : selectionStatus === 'attempted' ? 'These files were selected for this attempt; this does not confirm installation succeeded. Launch again uses the latest promoted setup.' : 'This records the saved setup for this deployment. Launch again uses the latest promoted setup for this map.' : 'This preview shows the promoted setup at the time above. The deployment history records the files selected when provisioning begins.'}</p>
<p>{historical ? 'Recorded' : 'Previewed'} <RelativeTime value={recipe.capturedAt} />. {historical ? selectionStatus === 'queued' ? 'Versions are selected again when creation begins. This queued capture does not pin them.' : selectionStatus === 'attempted' ? 'These files were selected for this attempt; this does not confirm installation succeeded. Launch again uses the latest promoted setup.' : 'This records the saved setup for this deployment. Launch again uses the latest promoted setup for this map.' : 'This preview shows the promoted setup at the time above. The deployment history records the files selected when provisioning begins.'}</p>
<small class="fingerprint" title={recipe.fingerprint}>Recipe {recipe.fingerprint.slice(0, 16)}</small>
</details>
</div>
+15 -18
View File
@@ -3,8 +3,11 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { createLogRefresh, type LogResponse } from '$ui/log-refresh.js';
import { stripGameColorCodes } from '$ui/log-text.js';
import Tabs from './primitives/Tabs.svelte';
import Button from './primitives/Button.svelte';
import Freshness from './Freshness.svelte';
import RelativeTime from './RelativeTime.svelte';
const uid = $props.id();
interface TabDef {
@@ -22,6 +25,7 @@
const active = $derived(tabs.find(tab => tab.key === $page.url.searchParams.get('log'))?.key ?? tabs[0]?.key ?? '');
let data = $state<Record<string, LogResponse | null>>({});
let checkedAt = $state<Record<string, number>>({});
function chooseLog(key: string) {
const url = new URL($page.url); url.searchParams.set('log', key);
@@ -37,11 +41,12 @@
let stickToBottom = $state(true);
const refresh = createLogRefresh({
onReset: () => { data = {}; refreshError = ''; copyStatus = ''; },
onReset: () => { data = {}; checkedAt = {}; refreshError = ''; copyStatus = ''; },
onLoading: value => { loading = value; },
onError: message => { refreshError = message; },
onData: (key, body) => {
data = { ...data, [key]: body };
checkedAt = { ...checkedAt, [key]: Date.now() };
const currentServer = server;
void tick().then(() => {
if (server === currentServer && active === key && stickToBottom && !query && bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
@@ -97,15 +102,6 @@
untrack(() => refresh.configure(target, auto, interval));
});
function ago(iso: string | null | undefined): string {
if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime();
if (d < 60_000) return `${Math.max(1, Math.floor(d / 1000))}s ago`;
if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
return `${Math.floor(d / 86_400_000)}d ago`;
}
function fmtBytes(n: number | undefined): string {
if (n === undefined || n === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
@@ -115,7 +111,7 @@
}
let current = $derived(data[active] ?? null);
const visibleLines = $derived((current?.lines ?? []).filter(line => !query || line.toLowerCase().includes(query.toLowerCase())));
const visibleLines = $derived((current?.lines ?? []).map(stripGameColorCodes).filter(line => !query || line.toLowerCase().includes(query.toLowerCase())));
</script>
<div class="log-panel">
@@ -123,25 +119,26 @@
<Tabs tabs={tabs.map(t => ({id:t.key,label:t.label}))} value={active} onchange={chooseLog} idPrefix={uid} panelId={uid + '-panel'} ariaLabel="Log files" size="sm" />
<div class="right">
{#if current?.mtime}
<span class="meta">{fmtBytes(current.size)} · updated {ago(current.mtime)}</span>
<span class="meta">{fmtBytes(current.size)} · File changed <RelativeTime value={current.mtime} /></span>
{/if}
<label class="auto">
<input type="checkbox" checked={autoRefresh} onchange={toggleAuto} />
Auto-refresh · {pollMs / 1000}s
Auto-refresh
</label>
<button class="refresh" onclick={() => refresh.refresh()} disabled={loading}>
{#if !autoRefresh}<button class="refresh" onclick={() => refresh.refresh()} disabled={loading}>
{loading ? 'Refreshing…' : 'Refresh'}
</button>
</button>{/if}
</div>
</div>
<div class="log-tools"><label class="log-search">Search loaded lines<input type="search" bind:value={query} placeholder="Find text in this log" /></label><details class="log-actions"><summary>More</summary><div class="action-items"><Button size="sm" onclick={copyLink}>Copy link</Button><Button size="sm" disabled={!current?.lines?.length} onclick={() => copy(visibleLines.join('\n'), 'Log text copied.')}>Copy {query ? 'matches' : 'log'}</Button><Button size="sm" disabled={!current?.lines?.length} onclick={download}>Download tail</Button></div></details></div>
<Freshness updatedAt={checkedAt[active] ?? null} paused={!autoRefresh} intervalLabel={`Updates every ${pollMs / 1000} seconds`} error={refreshError ? 'Log updates interrupted.' : ''} onRetry={() => void refresh.refresh()} retryDisabled={loading} />
<div class="log-tools"><label class="log-search">Search loaded lines<input type="search" bind:value={query} placeholder="Find text in this log" /></label><details class="log-actions"><summary>More</summary><div class="action-items"><Button size="sm" onclick={copyLink}>Copy link</Button><Button size="sm" disabled={!current?.lines?.length} onclick={() => copy(visibleLines.join('\n'), 'Log text copied.')}>Copy {query ? 'matches' : 'log'}</Button><Button size="sm" disabled={!current?.lines?.length} onclick={download}>Download raw tail</Button></div></details></div>
{#if copyStatus}<p class="copy-status" role="status">{copyStatus}</p>{/if}
<div class="follow-state"><span>{!autoRefresh ? 'Refresh paused' : 'Auto-refresh on'} · {query ? `${visibleLines.length} matching lines` : stickToBottom ? 'Following latest lines' : 'Reading earlier lines'}</span>{#if !stickToBottom || query}<Button size="sm" variant="ghost" onclick={jumpToLatest}>Jump to latest</Button>{/if}</div>
<div id={uid + '-panel'} role="tabpanel" aria-labelledby={uid + '-' + active}>
{#if refreshError}<div class="ui-error" role="alert">Couldn’t load logs. {current ? 'Showing the last loaded log.' : ''}<Button size="sm" onclick={() => refresh.refresh()} disabled={loading}>Retry</Button><details><summary>Technical details</summary>{refreshError}</details></div>{/if}
{#if refreshError}<div class="ui-error" role="alert">Couldn’t load logs. {current ? 'Showing the last loaded log.' : ''}<details><summary>Technical details</summary>{refreshError}</details></div>{/if}
{#if !current && !refreshError}
<div class="empty">{loading ? 'loading…' : 'No log loaded. Select Refresh to load it.'}</div>
<div class="empty">{loading ? 'Loading log…' : autoRefresh ? 'Waiting for the first log update.' : 'No log loaded. Select Refresh to load it.'}</div>
{:else if current && !current.ok}
<div class="error">error: {current.reason ?? 'unknown'}</div>
{:else if current?.missing}
+4 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { untrack } from 'svelte';
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
@@ -6,7 +7,7 @@
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
import type { MatchHistoryResponse } from '$lib/match-history.js';
import { matchApiParams, matchHistoryHref, matchPageSize, readMatchQuery, type MatchHistoryQuery, type MatchSort } from '$lib/match-history-query.js';
import { matchInstanceLabel, matchStartedLabel } from '$ui/match-history.js';
import { matchInstanceLabel } from '$ui/match-history.js';
import { pollVisible } from '$ui/poll.js';
import { formatDuration } from '$ui/duration.js';
import Button from './primitives/Button.svelte';
@@ -121,11 +122,11 @@
</tr></thead>
<tbody>{#each data.matches as match (match.id)}
<tr>
<td><time datetime={match.startedAt} title={$timeFormat.dateTime(match.startedAt)}>{matchStartedLabel(match.startedAt, $timeZone)}</time></td>
<td><RelativeTime value={match.startedAt} /></td>
<td class="match">
{#if match.status === 'completed'}<a class="match-link" href={contextHref(`/matches/${match.id}`, $page.url)} aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}>{match.map} · Match #{match.id}</a>
{:else}<span class="match-name">{match.map}</span>{/if}
<small class="mobile-match-context"><time datetime={match.startedAt}>{matchStartedLabel(match.startedAt, $timeZone)}</time> · {match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}{match.durationMs !== null ? ` · ${formatDuration(match.durationMs)}` : ''}</small>
<small class="mobile-match-context"><RelativeTime value={match.startedAt} /> · {match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}{match.durationMs !== null ? ` · ${formatDuration(match.durationMs)}` : ''}</small>
{#if server === undefined}<div class="instance">
<span title={match.server}>{matchInstanceLabel(match)}</span>{#if match.server_deleted_at}<span> · Historical instance</span>{/if}
</div>{/if}
@@ -1,10 +1,11 @@
<script lang="ts">
import { gameTitle } from '$ui/server-labels.js';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import type { Host, NativeDeploymentSelection } from '$lib/hosts.js';
import type { GameLibrary } from '$lib/game-files.js';
import type { ZombieGame } from '$lib/map-aliases.js';
import { pollVisible } from '$ui/poll.js';
import { latestGameFiles, selectLatestGameFiles, type NativeHostSelectionStatus } from '$ui/native-game-files.js';
import { timeFormat } from '$ui/stores/time-zone.js';
let { games, selection = $bindable(), ready = $bindable(false), status = $bindable(), disabled = false, network = false, compact = false, refreshVersion = 0, onchange }:
{ games: ZombieGame[]; selection: NativeDeploymentSelection; ready?: boolean; status?: NativeHostSelectionStatus; disabled?: boolean; network?: boolean; compact?: boolean; refreshVersion?: number; onchange?: () => void } = $props();
let hosts = $state<Host[]>([]); let libraries = $state<GameLibrary[]>([]); let loading = $state(true); let error = $state('');
@@ -103,19 +104,19 @@
{#if error}<p role="alert" class="error">{error}</p>{/if}
{#if loading}<p role="status">Loading host readiness and game files…</p>{/if}
<p>Hosts are assigned when provisioning starts. You can queue launches while hosts are busy or preparing; each server waits for an eligible host and available capacity.</p>
{#if !loading && !hosts.length}<p><a href="/hosts" target="_blank" rel="noopener">Add a host</a>. Its runtime and latest T5 and T6 files will be prepared automatically.</p>{/if}
{#if !loading && !hosts.length}<p><a href="/hosts" target="_blank" rel="noopener">Add a host</a>. Its runtime and latest Black Ops I and Black Ops II files will be prepared automatically.</p>{/if}
{#if !loading}{#each games as game}
<div class="game-files">
<strong>{game === 't5' ? 'Black Ops I (T5)' : 'Black Ops II (T6)'} files</strong>
<strong>{game === 't5' ? 'Black Ops I' : 'Black Ops II'} files</strong>
{#if latest[game]}
<p>Latest upload · {$timeFormat.dateTime(latest[game]?.publishedAt ?? latest[game]?.createdAt)}</p>
<p>Latest upload · <RelativeTime value={latest[game]?.publishedAt ?? latest[game]?.createdAt} /></p>
<p role="status">{cacheReady(game) ? 'Latest files ready on an eligible host.' : 'Launches can be queued now and will wait for a host to prepare the runtime and latest files.'}</p>
{:else}
<p>No files uploaded yet. <a href="/game-files" target="_blank" rel="noopener">Upload {game.toUpperCase()} files</a> to prepare your hosts.</p>
<p>No files uploaded yet. <a href="/game-files" target="_blank" rel="noopener">Upload {gameTitle(game, true)} files</a> to prepare your hosts.</p>
{/if}
</div>
{/each}{/if}
<p>Hosts keep the latest T5 and T6 files ready automatically. <a href="/game-files" target="_blank" rel="noopener">Upload game files</a> when they change. Existing servers keep their current files.</p>
<p>Hosts keep the latest Black Ops I and Black Ops II files ready automatically. <a href="/game-files" target="_blank" rel="noopener">Upload game files</a> when they change. Existing servers keep their current files.</p>
<details><summary>Advanced: runtime override</summary><p>The configured default runtime is prepared automatically. Custom images must first be prepared by an admin from the destination host page.</p>{@render runtimeFields()}</details>
{#if network}<details><summary>Network overrides</summary>{@render networkFields()}</details>{/if}
</section>
@@ -1,5 +1,5 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import type { ActivityEvent, OperationStep } from '$lib/operation-flow.js';
import Button from './primitives/Button.svelte';
import type { Snippet } from 'svelte';
@@ -26,14 +26,14 @@
</section>
{/if}
<section>
{#if showOverview && latest}<p class="latest"><strong>Latest update</strong> {latest.message}<time datetime={latest.created_at}>{$timeFormat.dateTime(latest.created_at)}</time></p>{/if}
{#if showOverview && latest}<p class="latest"><strong>Latest update</strong> {latest.message}<span class="time"><RelativeTime value={latest.created_at} /></span></p>{/if}
<details>
<summary>Activity history · {events.length} events{active ? ' · updates automatically' : ''}</summary>
{#if events.length}
<div class="tools"><label>Search activity<input type="search" bind:value={query} oninput={() => limit = 25} placeholder="Server, stage, or message" /></label><label>Order<select bind:value={newestFirst}><option value={true}>Newest first</option><option value={false}>Oldest first</option></select></label></div>
<ol class="timeline">
{#each visible as event (event.id)}
<li><time datetime={event.created_at}>{$timeFormat.dateTime(event.created_at)}</time><p>{event.message}</p></li>
<li><span class="time"><RelativeTime value={event.created_at} /></span><p>{event.message}</p></li>
{/each}
</ol>
{#if !matching.length}<p class="muted">No matching activity.</p>{/if}
@@ -53,12 +53,12 @@
details[open] summary { margin-bottom: 16px; }
.latest { padding: 12px 16px; background: var(--surface); border-radius: var(--radius-md); font-size: var(--text-sm); }
.latest strong { display: block; margin-bottom: 4px; }
.latest time { display: block; margin-top: 4px; }
.latest .time { display: block; margin-top: 4px; }
.tools { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 20px; }
label { display: grid; gap: 6px; font-size: var(--text-xs); color: var(--text-secondary); }
label:first-child { flex: 1; min-width: 180px; }
input, select { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); padding: 9px 12px; border-radius: var(--radius-sm); font: inherit; }
time, .muted { color: var(--text-secondary); font-size: var(--text-sm); }
.time, .muted { color: var(--text-secondary); font-size: var(--text-sm); }
p { margin: 0; line-height: 1.6; }
.flow { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; }
.flow li { display: flex; align-items: center; gap: 10px; background: var(--surface); border-radius: var(--radius-md); padding: 12px; font-size: var(--text-sm); border: 1px solid var(--border-subtle); color: var(--text-secondary); }
@@ -1,8 +1,9 @@
<script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import type { PlayerMatch } from '$lib/leaderboard.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { serverTitle } from '$ui/server-labels.js';
import { contextHref } from '$lib/navigation-context.js';
import { playerCompletionLabel } from '$ui/player-match.js';
let { matches, returnTo }: { matches: PlayerMatch[]; returnTo?: string } = $props();
const number = new Intl.NumberFormat('en-US');
</script>
@@ -11,7 +12,7 @@
<article>
<h3>{#if match.status === 'completed'}<a href={contextHref(`/matches/${match.id}`, returnTo ?? "/leaderboard")}>{match.mapName} · Match #{match.id}</a>{:else}{match.mapName} · Match #{match.id}{/if}</h3>
<p>{serverTitle(match.server)}{match.server_deleted_at ? ' · Removed server' : ''}</p>
<p><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time> · {match.completed ? 'Completion credited' : 'No completion credit'}</p>
<p><RelativeTime value={match.startedAt} /> · {playerCompletionLabel(match)}</p>
<dl><div><dt>Round</dt><dd>{number.format(match.highestRound)}</dd></div><div><dt>Kills</dt><dd>{number.format(match.kills)}</dd></div><div><dt>Revives</dt><dd>{number.format(match.revives)}</dd></div></dl>
</article>
{/each}
@@ -130,7 +130,7 @@
</select></label>
{#if !pluginId}
<label>Plugin name<input bind:value={name} disabled={busy} placeholder="t6-gsc-utils" /></label>
<label>Deploy scope<select bind:value={game} disabled={busy}><option value="">Unscoped — select servers in a beta</option><option value="t5">T5 · Black Ops I</option><option value="t6">T6 · Black Ops II</option></select></label>
<label>Deploy scope<select bind:value={game} disabled={busy}><option value="">Unscoped — select servers in a beta</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
{/if}
</div>
{#if targetPath}<p>New versions deploy to <code>{targetPath}</code>.</p>{/if}
@@ -1,7 +1,7 @@
<script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { onDestroy } from 'svelte';
import type { ProvisioningDetail, ProvisioningEvent } from '$lib/provisioning-detail.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { isProvisioningEventPage } from '$ui/provisioning-presentation.js';
import Button from './primitives/Button.svelte';
@@ -81,7 +81,7 @@
<div class="heading"><div><span class="eyebrow">Activity</span><h2 id="activity-title">Recent updates</h2></div><span class="count">{detail.activity.total} event{detail.activity.total === 1 ? '' : 's'}</span></div>
<p class="muted">{open ? 'Recent updates stay in place while you read the full history.' : 'Newest first · updates automatically'}</p>
{#if recent.length}<ol class="timeline recent">
{#each recent as event (event.id)}<li><time datetime={event.created_at}>{$timeFormat.dateTime(event.created_at)}</time><p>{event.message}</p></li>{/each}
{#each recent as event (event.id)}<li><span class="time"><RelativeTime value={event.created_at} /></span><p>{event.message}</p></li>{/each}
</ol>{:else}<p class="empty">No detailed activity was recorded. The recorded outcome and timestamps are preserved above.</p>{/if}
<details class="history" ontoggle={toggle}>
@@ -98,7 +98,7 @@
{#if loading && !loaded}<p class="muted" role="status">Loading activity…</p>
{:else if loaded && !entries.length}<p class="empty">{appliedQuery ? 'No activity matches this search.' : 'No detailed activity recorded.'}</p>
{:else}<ol class="timeline">
{#each entries as event (event.id)}<li><time datetime={event.created_at}>{$timeFormat.dateTime(event.created_at)}</time><p>{event.message}</p></li>{/each}
{#each entries as event (event.id)}<li><span class="time"><RelativeTime value={event.created_at} /></span><p>{event.message}</p></li>{/each}
</ol>{/if}
{#if loaded}<div class="footer"><span>Showing {entries.length} of {total} matching events</span>{#if cursor !== null}<Button size="sm" loading={loading} disabled={loading} onclick={() => load(true)}>Show more activity</Button>{/if}</div>{/if}
</div>
@@ -111,7 +111,7 @@
.eyebrow { display: block; font-size: var(--text-xs); color: var(--text-tertiary); margin-bottom: var(--space-2); }
h2 { margin: 0; font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }
p { margin: 0; line-height: 1.65; overflow-wrap: anywhere; font-size: var(--text-sm); }
.muted, .count, time, .footer { font-size: var(--text-xs); color: var(--text-secondary); }
.muted, .count, .time, .footer { font-size: var(--text-xs); color: var(--text-secondary); }
.empty { padding: var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--text-secondary); }
.timeline { list-style: none; margin: 0 0 0 var(--space-1); padding: 0 0 0 var(--space-5); border-left: 1px solid var(--border-strong); }
.timeline li { padding: 0 0 var(--space-5); position: relative; }.timeline li:last-child { padding-bottom: 0; }
@@ -1,7 +1,7 @@
<script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import type { ProvisioningDetail } from '$lib/provisioning-detail.js';
import { provisioningGroups, progressIcons, progressStateLabels } from '$ui/provisioning-presentation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Badge from './primitives/Badge.svelte';
let { detail }: { detail: ProvisioningDetail } = $props();
@@ -37,7 +37,7 @@
{#each group.steps as step (step.key)}
<li aria-current={step.state === 'current' || step.state === 'waiting' ? 'step' : undefined}>
<span class="step-icon state-{step.state}" aria-hidden="true">{progressIcons[step.state]}</span>
<div><strong>{step.label}</strong><span class="step-meta state-{step.state}">{progressStateLabels[step.state]}{#if step.at} · <time datetime={step.at}>{$timeFormat.dateTime(step.at)}</time>{/if}</span></div>
<div><strong>{step.label}</strong><span class="step-meta state-{step.state}">{progressStateLabels[step.state]}{#if step.at} · <span class="time"><RelativeTime value={step.at} /></span>{/if}</span></div>
</li>
{/each}
</ol>
@@ -49,17 +49,17 @@
<div class="command" class:uncertain={progress.command.state === 'uncertain'}>
<h3>{progress.command.state === 'attempted' ? 'Command submission attempted' : progress.command.state === 'accepted' ? 'Remote request accepted' : progress.command.state === 'rejected' ? 'Remote request rejected' : 'Remote response uncertain'}</h3>
<p>{progress.command.message}</p>
<time datetime={progress.command.at}>{$timeFormat.dateTime(progress.command.at)}</time>
<span class="time"><RelativeTime value={progress.command.at} /></span>
</div>
{/if}
{#if progress.checkpoints.some(checkpoint => !groups.some(group => group.steps.some(step => step.key === checkpoint.key)))}
<details class="milestones"><summary>Recorded checkpoints</summary><ul>
{#each progress.checkpoints.filter(checkpoint => !groups.some(group => group.steps.some(step => step.key === checkpoint.key))) as checkpoint (checkpoint.key)}
<li><strong>{checkpoint.label}</strong><time datetime={checkpoint.at}>{$timeFormat.dateTime(checkpoint.at)}</time></li>
<li><strong>{checkpoint.label}</strong><span class="time"><RelativeTime value={checkpoint.at} /></span></li>
{/each}
</ul></details>
{/if}
<div class="evidence-heading"><h3>Latest recorded checks</h3>{#if progress.observedAt}<span>Evaluated <time datetime={progress.observedAt}>{$timeFormat.dateTime(progress.observedAt)}</time></span>{/if}</div>
<div class="evidence-heading"><h3>Latest recorded checks</h3>{#if progress.observedAt}<span>Evaluated <span class="time"><RelativeTime value={progress.observedAt} /></span></span>{/if}</div>
{#if progress.checks.length}
<div class="checks">
{#each progress.checks as check (check.key)}
@@ -70,7 +70,7 @@
<div><dt>Observed</dt><dd>{check.actual ?? 'Unknown'}</dd></div>
</dl>{/if}
{#if check.message}<p>{check.message}</p>{/if}
<div class="evidence-meta"><span>{check.source ? sources[check.source] : 'Source not recorded'}</span><span>Checked <time datetime={check.checkedAt}>{$timeFormat.dateTime(check.checkedAt)}</time></span>{#if check.receivedAt}<span>Evidence received <time datetime={check.receivedAt}>{$timeFormat.dateTime(check.receivedAt)}</time></span>{/if}</div>
<div class="evidence-meta"><span>{check.source ? sources[check.source] : 'Source not recorded'}</span><span>Checked <span class="time"><RelativeTime value={check.checkedAt} /></span></span>{#if check.receivedAt}<span>Evidence received <span class="time"><RelativeTime value={check.receivedAt} /></span></span>{/if}</div>
</article>
{/each}
</div>
@@ -105,11 +105,11 @@
.steps strong { display: block; font-size: var(--text-sm); font-weight: var(--weight-medium); line-height: 1.5; }
.step-meta { display: block; font-size: var(--text-xs); margin-top: var(--space-1); line-height: 1.6; }
.state-current { color: var(--accent-primary-hover); }.state-waiting { color: var(--state-warning); }.state-failed { color: var(--state-danger); }.state-done { color: var(--state-online); }
.state-unknown, .state-pending, .state-cancelled, time { color: var(--text-secondary); }
.state-unknown, .state-pending, .state-cancelled, .time { color: var(--text-secondary); }
.command, .legacy { display: grid; gap: var(--space-2); padding: var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.command.uncertain { border-color: color-mix(in srgb, var(--state-warning) 45%, var(--border-subtle)); }
p { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.65; overflow-wrap: anywhere; }
time, .evidence-meta, .evidence-heading span, .milestones { font-size: var(--text-xs); }
.time, .evidence-meta, .evidence-heading span, .milestones { font-size: var(--text-xs); }
.evidence-heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: var(--space-2); padding-top: var(--space-2); }
.evidence-heading span { color: var(--text-secondary); }
.checks { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
import { browser } from '$app/environment';
import { timestampISO, type Timestamp } from '$lib/time-zone.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { uiNow } from '$ui/stores/servers.js';
let { value, missing = '—', class: className }: { value: Timestamp; missing?: string; class?: string } = $props();
const iso = $derived(timestampISO(value));
const now = $derived(browser ? $uiNow : Date.now());
</script>
{#if iso}<time class={className} datetime={iso} title={$timeFormat.dateTime(value)}>{$timeFormat.relative(value, now)}</time>{:else}<span class={className}>{missing}</span>{/if}
@@ -1,6 +1,8 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { tick } from 'svelte';
import { plural } from '$ui/plural.js';
import { gameTitle } from '$ui/server-labels.js';
import Button from './primitives/Button.svelte';
import { betaSelectionHref, mapLabel, scopeLabel, type BetaSelection, type LibraryScript } from '$lib/script-library.js';
let { scripts, onuploaded }: { scripts: LibraryScript[]; onuploaded: () => Promise<void> } = $props();
@@ -176,7 +178,7 @@
{#if hasNew}
<label>Scope for new scripts<select bind:value={scopeType} disabled={busy} onchange={() => { scopeGame = ''; scopeMap = ''; }}><option value="none">Unscoped · choose beta targets</option><option value="game">Game</option><option value="map">Map</option></select></label>
{#if scopeType !== 'none'}
<label>Game<select bind:value={scopeGame} disabled={busy || !scopeLoaded} onchange={() => scopeMap = ''}><option value="">Choose a game…</option>{#each scopeOptions.games as g}<option value={g}>{g.toUpperCase()}</option>{/each}</select></label>
<label>Game<select bind:value={scopeGame} disabled={busy || !scopeLoaded} onchange={() => scopeMap = ''}><option value="">Choose a game…</option>{#each scopeOptions.games as g}<option value={g}>{gameTitle(g)}</option>{/each}</select></label>
{#if scopeType === 'map'}<label>Map<select bind:value={scopeMap} disabled={busy || !scopeGame}><option value="">Choose a map…</option>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as m}<option value={m}>{mapLabel(m)}</option>{/each}</select></label>{/if}
{#if scopeValue}<p class="note">{matches.length} matching servers: {matches.map(s => s.name).join(', ') || 'None'}</p>{/if}
{#if scopeType === 'map' && scopeGame === 't6'}<p class="note">Map scope includes every server using this engine map, including its Survival, Grief, or other variants.</p>{/if}
@@ -187,7 +189,7 @@
{/if}
{#if message}<p class="notice" role="status">{message}</p>{/if}
<footer>
{#if inspecting || busy}<p class="batch-progress" role="status">{inspecting ? `Checked ${checkedCount} of ${queue.length} files` : `Uploaded ${results.length} of ${queue.length} files`}{activeFile ? ` · ${activeFile}` : ''}</p>{:else if queue.length}<p class="batch-progress">{results.length} saved · {pending.length} remaining{queue.some(q => q.error) ? ` · ${queue.filter(q => q.error).length} need attention` : ''}</p>{/if}
{#if inspecting || busy}<p class="batch-progress" role="status">{inspecting ? `Checked ${checkedCount} of ${queue.length} ${plural(queue.length, 'file')}` : `Uploaded ${results.length} of ${queue.length} ${plural(queue.length, 'file')}`}{activeFile ? ` · ${activeFile}` : ''}</p>{:else if queue.length}{@const failed = queue.filter(q => q.error).length}<p class="batch-progress">{results.length} saved · {pending.length} remaining{failed ? ` · ${failed} ${plural(failed, 'needs', 'need')} attention` : ''}</p>{/if}
{#if pending.length}<Button disabled={!ready} loading={busy} onclick={() => upload(false)}>Upload{results.length ? ' remaining' : ''}</Button><Button variant="primary" disabled={!ready} onclick={() => upload(true)}>Upload & prepare beta →</Button>
{:else if testable.length}<Button variant="primary" href={betaSelectionHref(testable)}>Prepare beta with {testable.length} script{testable.length === 1 ? '' : 's'} →</Button>{/if}
</footer>
@@ -1,11 +1,12 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte';
import { TELEMETRY_REQUEST_RETENTION, type TelemetryRequestLogEntry } from '$lib/telemetry-diagnostics.js';
import { pollVisible } from '$ui/poll.js';
import { serverTitle } from '$ui/server-labels.js';
import Card from './primitives/Card.svelte';
import Badge from './primitives/Badge.svelte';
import Button from './primitives/Button.svelte';
import Freshness from './Freshness.svelte';
import RelativeTime from './RelativeTime.svelte';
import EmptyState from './primitives/EmptyState.svelte';
import ListFooter from './primitives/ListFooter.svelte';
@@ -15,15 +16,18 @@
let refreshing = $state(false);
let loaded = $state(false);
let error = $state('');
let refreshedAt = $state<string | null>(null);
let refreshedAt = $state<number | null>(null);
let limit = $state(25);
let active = false;
let controller: AbortController | null = null;
async function refresh(): Promise<void> {
async function refresh(signal?: AbortSignal): Promise<void | false> {
if (!active || refreshing) return;
const currentServer = server;
const requestController = new AbortController();
const abort = () => requestController.abort();
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) requestController.abort();
controller = requestController;
refreshing = true;
try {
@@ -35,17 +39,19 @@
if (!response.ok || !body?.ok || !Array.isArray(body.requests)) {
throw new Error(body?.error || `Could not load telemetry requests (HTTP ${response.status}).`);
}
if (!active || server !== currentServer) return;
if (!active || server !== currentServer || requestController.signal.aborted) return;
requests = body.requests;
retention = body.retention;
loaded = true;
error = '';
refreshedAt = new Date().toISOString();
refreshedAt = Date.now();
} catch (cause) {
if (active && server === currentServer && !requestController.signal.aborted) {
error = cause instanceof Error ? cause.message : 'Could not load telemetry requests.';
}
return false;
} finally {
signal?.removeEventListener('abort', abort);
if (active && server === currentServer) refreshing = false;
if (controller === requestController) controller = null;
}
@@ -61,10 +67,6 @@
};
});
function timestamp(value: string): string {
return $timeFormat.dateTime(value);
}
function outcome(status: number): string {
return status >= 500 ? 'Failed' : status >= 400 ? 'Rejected' : 'Accepted';
}
@@ -74,15 +76,15 @@
<div class="panel-head">
<div>
<h2>Telemetry API requests</h2>
<p class="server-name"><code>{server}</code></p>
<p class="server-name">{serverTitle(server)}</p>
</div>
<Button size="sm" onclick={refresh} disabled={refreshing} loading={refreshing}>Refresh</Button>
<Freshness updatedAt={refreshedAt} error={error ? 'Telemetry request updates interrupted.' : ''} intervalLabel="Updates every 5 seconds" onRetry={() => void refresh()} retryDisabled={refreshing} />
</div>
{#if error}
<p class="load-error" role="alert">
Couldn’t load telemetry requests. {#if loaded} Previously loaded requests are shown below.{/if}
</p><Button size="sm" onclick={refresh} disabled={refreshing}>Retry</Button><details class="payload"><summary>Technical details</summary>{error}</details>
</p><details class="payload"><summary>Technical details</summary>{error}</details>
{/if}
{#if !loaded && !error}
@@ -103,7 +105,7 @@
</Badge>
<code class="event">{request.event ?? 'Event not provided'}</code>
</div>
<time class="received" datetime={request.receivedAt}>{timestamp(request.receivedAt)}</time>
<span class="received"><RelativeTime value={request.receivedAt} /></span>
</div>
<dl class="request-meta">
<div><dt>Map</dt><dd><code>{request.map ?? 'Not provided'}</code></dd></div>
@@ -124,13 +126,11 @@
{/if}
<details class="request-help"><summary>About request logging</summary> <p class="intro">
Incoming <code>POST /api/telemetry</code> requests with this exact server name.
Incoming <code>POST /api/telemetry</code> requests with the exact server name <code>{server}</code>.
Fresh game telemetry supplies player counts when available; RCON provides a fallback. The scoreboard uses telemetry.
</p>
<p class="retention">
Newest first · {retention.maxPerServer ? `up to ${retention.maxPerServer} requests per server` : 'no per-server retention cap'} · {retention.maxAgeDays ? `last ${retention.maxAgeDays} days` : 'no age expiry'}. Fleet-wide limits also apply.
Refreshes every 5 seconds while visible.
{#if refreshedAt}<span>Last refreshed <time datetime={refreshedAt}>{timestamp(refreshedAt)}</time>.</span>{/if}
</p>
<p class="attribution">
@@ -161,7 +161,6 @@
overflow-wrap: anywhere;
}
.retention { margin-top: var(--space-2); }
.retention span { display: block; }
.attribution { margin-top: var(--space-4); }
.request-list { list-style: none; padding: 0; margin: var(--space-5) 0 0; }
.request { padding: var(--space-4) 0; border-top: 1px solid var(--border-subtle); }
@@ -16,7 +16,7 @@
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
<div class="table-wrap" role="region" aria-label="Host capacity comparison" tabindex="0"><table>
<thead><tr><th>Host</th><th>Additional instances</th><th>Limited by</th><th>CPU available</th><th>Memory available</th><th>Free ports</th><th><span class="sr-only">Details</span></th></tr></thead>
<thead><tr><th>Host</th><th>Additional servers</th><th>Limited by</th><th>CPU available</th><th>Memory available</th><th>Free ports</th><th><span class="sr-only">Details</span></th></tr></thead>
<tbody>{#each hosts as host (host.id)}
<tr>
<td><a class="name" href={contextHref(`/hosts/${host.id}`, returnTo)}>{host.name}</a></td>
@@ -31,12 +31,12 @@
<div class="detail-content">
{#if host.reasons.length}<ul>{#each host.reasons as reason}<li>{reason}</li>{/each}</ul>{/if}
<p>Games ready: {host.games.length ? host.games.map(game => game === 't5' ? 'Black Ops I' : 'Black Ops II').join(', ') : 'None'} · {host.portsAvailable} of {host.totalPorts} ports free.</p>
<p>Instance limits: CPU {count(host.limits.cpu)} · Memory {count(host.limits.memory)} · Ports {host.limits.ports}.</p>
<dl>{#each [{ label: 'Running instance', value: host.calibration.running }, { label: 'Starting instance', value: host.calibration.startup }] as allowance}<div>
<p>Server limits: CPU {count(host.limits.cpu)} · Memory {count(host.limits.memory)} · Ports {host.limits.ports}.</p>
<dl>{#each [{ label: 'Running server', value: host.calibration.running }, { label: 'Starting server', value: host.calibration.startup }] as allowance}<div>
<dt>{allowance.label}</dt><dd>{count(allowance.value.cpuCores)} cores · {memory(allowance.value.memoryBytes)}</dd>
<dd class="secondary">{allowance.value.source === 'bootstrap' ? 'Default allowance' : `${allowance.value.source === 'host' ? 'Host' : 'Fleet'} observations · ${allowance.value.samples} samples`}</dd>
</div>{/each}</dl>
<p>Starting instances may need more resources. Provisioning checks current capacity before reserving a host and port.</p>
<p>Starting servers may need more resources. The controller checks current capacity before reserving a host and port.</p>
<p>{host.updatedAt ? `Reported ${$timeFormat.dateTime(host.updatedAt)}` : 'No host report yet'}</p>
</div>
</td></tr>
@@ -92,7 +92,7 @@
</div>
<p class="period-note">{days === 1 ? 'Hours' : 'Weekdays'} show when each period starts in {timeZone.replaceAll('_', ' ')}.{days === 1 ? '' : ' Daily periods are 24-hour windows.'}</p>
{#if selected}
<div class="selection-summary" aria-label="Selected period summary"><time>{format.period(selected.startAt)}{selected.partial ? ' · Partial period' : ''}</time><span>{captured ? `${metricDuration(selected.active)} with players · ${metricDuration(selected.idle)} idle · ${metricDuration(selected.unknown)} unknown · ${selected.observed ? metricNumber(selected.playerHours) : '—'} player-hours` : 'No captured time · not evidence of idle instances'}</span></div>
<div class="selection-summary" aria-label="Selected period summary"><time>{format.period(selected.startAt)}{selected.partial ? ' · Partial period' : ''}</time><span>{captured ? `${metricDuration(selected.active)} with players · ${metricDuration(selected.idle)} idle · ${metricDuration(selected.unknown)} unknown · ${selected.observed ? metricNumber(selected.playerHours) : '—'} player-hours` : 'No captured time · not evidence of idle servers'}</span></div>
<details class="inspection"><summary>Inspect a period</summary>
<div class="period-controls">
<div class="period-select"><Field label={`Inspect a period · ${timeZone.replaceAll('_', ' ')}`}><Select value={selectedValue} options={periodOptions} onchange={event => selectPeriod(Number((event.currentTarget as HTMLSelectElement).value))} /></Field></div>
@@ -16,7 +16,7 @@
<div class="summary">
<Card><Stat label="Time with players" value={metricPercent(report.summary.active, report.summary.observed)} size="lg" tween={false} />
<p>{occupancyDelta === null ? 'Comparison unavailable' : `${signed(occupancyDelta, ' percentage points')} vs ${previousLabel}`}</p>
<small>{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} observed instance time</small>
<small>{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} observed server time</small>
</Card>
<Card><Stat label="Player-hours" value={report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'} size="lg" tween={false} />
<p>{playerDelta === null ? comparable && report.summary.playerHours > 0 ? 'New · no previous player-hours' : 'Comparison unavailable' : `${signed(playerDelta, '%')} vs ${previousLabel}`}</p>
+9
View File
@@ -2,6 +2,15 @@ import type { Host } from '../hosts.js';
import { hostDeletionMessage } from '../hosts.js';
import { formatGameBytes } from '../game-files.js';
/** Missing samples alone do not mean a current agent needs an upgrade. */
export function hostCalibrationHint(version: string | null | undefined): string {
const parsed = version?.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i);
const parts = parsed?.slice(1, 4).map(Number);
const older = !!parts && (parts[0] < 1 || parts[0] === 1 && (parts[1] < 4 || parts[1] === 4 && parts[2] < 6));
return older ? 'Update this host to agent 1.4.6 or later to collect resource samples.'
: 'Capacity is still calibrating. Temporary allowances remain in use until enough resource samples are available.';
}
export function hostReadingsStale(host: Host, now = Date.now()): boolean {
const reported = Date.parse(host.lastHeartbeat ?? '');
return !host.connected || !Number.isFinite(reported) || now - reported > 90_000;
+4
View File
@@ -0,0 +1,4 @@
/** Hide Call of Duty colour directives in display text without changing raw log data. */
export function stripGameColorCodes(value: string): string {
return value.replace(/\^[0-9]/g, '');
}
+3 -14
View File
@@ -1,3 +1,4 @@
import { createTimeFormatter } from '../time-zone.js';
import { serverIdentity } from './server-labels.js';
import type { ServerMatch } from '../match-history.js';
@@ -8,19 +9,7 @@ export function matchInstanceLabel(match: ServerMatch): string {
return parts.filter(Boolean).join(' · ') || match.server;
}
/** Compare calendar days in the display timezone, including DST boundaries. */
/** Shared history formatting; exact timestamps belong in the time element tooltip. */
export function matchStartedLabel(value: string, zone: string, now = new Date()): string {
const date = new Date(/Z$|[+-]\d\d:\d\d$/.test(value) ? value : value.replace(' ', 'T') + 'Z');
if (!Number.isFinite(date.getTime())) return '—';
const parts = new Intl.DateTimeFormat('en-US', { timeZone: zone, year: 'numeric', month: 'numeric', day: 'numeric' });
const day = (input: Date) => {
const values = Object.fromEntries(parts.formatToParts(input).map(part => [part.type, part.value]));
return Date.UTC(Number(values.year), Number(values.month) - 1, Number(values.day));
};
const difference = day(now) - day(date);
const dateLabel = difference === 0 ? 'Today' : difference === 86400000 ? 'Yesterday'
: new Intl.DateTimeFormat('en-US', { timeZone: zone, month: 'short', day: 'numeric',
...(parts.formatToParts(now).find(p => p.type === 'year')?.value !== parts.formatToParts(date).find(p => p.type === 'year')?.value ? { year: 'numeric' as const } : {}) }).format(date);
const clock = new Intl.DateTimeFormat('en-US', { timeZone: zone, hour: 'numeric', minute: '2-digit' }).format(date);
return `${dateLabel}, ${clock}`;
return createTimeFormatter(zone).relative(value, now.getTime());
}
+8
View File
@@ -0,0 +1,8 @@
import type { PlayerMatch } from '../leaderboard.js';
/** completed records presence in game_end, not a win or uninterrupted survival. */
export function playerCompletionLabel(match: Pick<PlayerMatch, 'status' | 'completed'>): string {
if (match.status === 'active') return 'Match in progress';
if (match.status === 'interrupted') return 'Final report unavailable';
return match.completed ? 'Present in final report' : 'Not present in final report';
}
+11
View File
@@ -0,0 +1,11 @@
const rules = new Intl.PluralRules('en-US');
const numbers = new Intl.NumberFormat('en-US');
/** Use an explicit second form for verbs and irregular nouns. */
export function plural(count: number, one: string, other = `${one}s`): string {
return rules.select(count) === 'one' ? one : other;
}
export function countLabel(count: number, one: string, other = `${one}s`): string {
return `${numbers.format(count)} ${plural(count, one, other)}`;
}
+6
View File
@@ -1,5 +1,11 @@
import { parseServerName, T5_ZOMBIE_MAPS, T6_ZOMBIE_MAPS, zombieMaps } from '../map-aliases.js';
export function gameTitle(game: string | null | undefined, compact = false): string {
if (game?.toLowerCase() === 't5') return compact ? 'BO1' : 'Black Ops I';
if (game?.toLowerCase() === 't6') return compact ? 'BO2' : 'Black Ops II';
return game?.toUpperCase() || 'Unknown game';
}
export function mapTitle(map: string | null | undefined): string {
return [...T5_ZOMBIE_MAPS, ...T6_ZOMBIE_MAPS].find((entry) => entry.canonical === map?.toLowerCase())?.friendly ?? map ?? '—';
}