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
+36
View File
@@ -71,3 +71,39 @@ Use the shared `--text-*` scale, with 12 px as the minimum. Mobile inputs use
in `npm run check`, rejects raw pixel font sizes. Use Inter with tabular figures
for counts, scores, durations and charts; reserve mono for code, IDs, paths and
logs. Static values use text or state colors; the accent marks links and controls.
### Product language
| Concept | Visible label |
| --- | --- |
| A managed game instance | Server; use the friendly map and lobby name |
| Creating a server | Create server |
| Removing a server | Remove server |
| Favourite server | Pin |
| Game does not answer | Not responding |
| Games | Black Ops I / Black Ops II; BO1 / BO2 in compact labels |
| Releasing file changes | Campaigns |
| Player completion evidence | Present in final report / Not present in final report |
Keep internal values such as `t5`, `t6`, `destroy`, instance IDs, and filenames in
API fields and technical details. User-facing actions use the labels above.
Player presence in the final report does not establish a win or continuous
survival. A missing timestamp is **Not recorded**, not **Never**.
Use `RelativeTime` for observed history: seconds, minutes or hours for the last
24 hours, Yesterday for the preceding calendar day, then the shared absolute
format. It includes the full timestamp in a tooltip and follows the account's
timezone. Scheduled future actions use `timeFormat.dateTime` directly. Database
timestamps without an offset are UTC; never parse them as browser-local time.
Receipt freshness uses the browser clock and shows Paused or a retry reason
when updates are unavailable. These clocks are not live regions.
Use `plural` or `countLabel` from `src/lib/ui/plural.ts` for count-dependent
language, including verbs: `plural(count, 'needs', 'need')`. The helper uses
`Intl.PluralRules`; supply irregular plural forms explicitly. Keep separators
inside string expressions when a Svelte conditional would trim their leading
space, for example `{offline ? ' · Offline' : ''}`.
Recommend an agent upgrade only when its reported version is known to lack a
needed capability. Missing calibration samples can simply mean the host is
still collecting measurements.
+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 ?? '—';
}
+4 -3
View File
@@ -25,6 +25,7 @@
import type { ZombieGame } from '$lib/map-aliases.js';
import { elapsedSince } from '$ui/duration.js';
import { serverTitle } from '$ui/server-labels.js';
import { plural } from '$ui/plural.js';
let query = $state('');
let filter = $state<FleetFilter>('all');
@@ -177,7 +178,7 @@
{/if}
{#if servers.length}
<div class="overview" aria-label="Fleet summary">
<span><strong>{healthy.length ? players : '—'}</strong> players · <strong>{liveCount}</strong> live {liveCount === 1 ? 'match' : 'matches'}</span>
<span><strong>{healthy.length ? players : '—'}</strong> {plural(players, 'player')} · <strong>{liveCount}</strong> live {plural(liveCount, 'match', 'matches')}</span>
<span><strong>{healthy.length}/{servers.length}</strong> healthy</span>
<button class="metric-link" onclick={() => { resetFilters(); filter = 'empty'; }}>{empty} empty {empty === 1 ? 'lobby' : 'lobbies'}</button>
{#if campaigns}<a class="metric-link" href="/campaigns">{campaigns} active {campaigns === 1 ? 'campaign' : 'campaigns'}</a>{/if}
@@ -227,7 +228,7 @@
<div class="chips" role="radiogroup" aria-label="Filter servers">{#each filters as item}
{@const count = servers.filter(server => matchesView(server, '', item.id)).length}
<label class="filter-chip" class:selected={filter === item.id}>
<input type="radio" name="fleet-filter" value={item.id} bind:group={filter} aria-label={`${item.label}, ${count} servers`} />
<input type="radio" name="fleet-filter" value={item.id} bind:group={filter} aria-label={`${item.label}, ${count} ${plural(count, 'server')}`} />
{item.label}<span> {count}</span>
</label>
{/each}</div>
@@ -276,7 +277,7 @@
</tbody>{/each}
</table></div>
{/if}
<footer class="fleet-footer"><span>{filtered.length} of {servers.length} servers</span><a href="/maintenance?view=versions">Plutonium versions →</a></footer>
<footer class="fleet-footer"><span>{filtered.length} of {servers.length} {plural(servers.length, 'server')}</span><a href="/maintenance?view=versions">Plutonium versions →</a></footer>
{/if}
</section>
+1 -1
View File
@@ -21,7 +21,7 @@ export async function GET() {
const checks: SetupCheck[] = [
{ id: 'hosts', title: 'Host agents', state: readyHosts.length ? 'ready' : hosts.length ? 'attention' : 'unconfigured', detail: `${connected.length} connected, ${readyHosts.length} deployment-ready, ${hosts.length} enrolled or awaiting enrollment.`, href: '/hosts', action: hosts.length ? 'Review hosts' : 'Add host' },
{ id: 'runtime', title: 'Plutainer runtime', state: runtimes.length ? 'ready' : 'attention', detail: runtimes.length ? `${runtimes.length} connected hosts report cached runtime images.` : 'Hosts prepare the configured Plutainer runtime automatically.', href: '/hosts', action: 'View host setup' },
{ id: 'game-files', title: 'T5 / T6 game files', state: cached.length ? 'ready' : 'attention', detail: `${versions.length} games have uploaded files; ${cached.length} have their latest files verified on a ready host.`, href: '/game-files', action: 'Manage game files' },
{ id: 'game-files', title: 'Game files', state: cached.length ? 'ready' : 'attention', detail: `${versions.length} games have uploaded files; ${cached.length} have their latest files verified on a ready host.`, href: '/game-files', action: 'Manage game files' },
{ id: 'rcon', title: 'Agent-local game connection', state: rconServers ? 'ready' : 'unverified', detail: rconServers ? `${rconServers} servers have fresh RCON responses.` : 'The host agent checks RCON locally after deployment. Controller access to game ports is not required.', href: servers.length ? '/' : '/hosts', action: servers.length ? 'Review fleet' : 'Review hosts' },
{ id: 'keys', title: 'Saved server keys', state: savedKeys ? 'ready' : 'attention', detail: `${savedKeys} saved ${savedKeys === 1 ? 'key' : 'keys'}. Validity is checked with Plutonium at startup.`, href: '/server-keys', action: 'Manage keys' },
{ id: 'first-server', title: 'First server ready', state: readyServers ? 'ready' : 'attention', detail: readyServers ? `${readyServers} of ${servers.length} servers are responding.` : servers.length ? 'Registered servers are awaiting fresh healthy reports. Inspect their host and diagnostics.' : 'Choose a host to launch your first server with the latest game files.', href: servers.length ? '/' : '/provisioning', action: servers.length ? 'Review fleet' : 'Create a server' }
+10 -3
View File
@@ -7,6 +7,7 @@
import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import PolicyCard from '$ui/components/autoscaling/PolicyCard.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import { zombieMaps, zombieMapBySlug } from '$lib/map-aliases.js';
import type { ScalingSettings, ScalingPolicy, ScalingReport } from '$lib/autoscaling.js';
let { data }: { data: import('./$types').PageData } = $props();
@@ -18,7 +19,10 @@
let editing = $state<ScalingPolicy | null>(null);
let editorOpen = $state(false);
let busy = $state(false), actionError = $state(''), refreshError = $state(''), notice = $state('');
let updatedAt = $state(Date.now());
let refreshing = $state(false);
let generation = 0;
let pendingRefreshes = 0;
$effect(() => {
if (!notice) return;
const timer = setTimeout(() => notice = '', 6000);
@@ -26,12 +30,14 @@
});
async function refresh(signal?: AbortSignal) {
const version = ++generation;
pendingRefreshes++; refreshing = true;
try {
const response = await fetch('/api/autoscaling', { signal });
if (!response.ok) throw new Error('Could not refresh policies. The last successful report is shown.');
const result = await response.json();
if (version === generation && !signal?.aborted) { snapshot = result; refreshError = ''; }
} catch (error) { if (version === generation && !signal?.aborted) refreshError = (error as Error).message; }
if (version === generation && !signal?.aborted) { snapshot = result; refreshError = ''; updatedAt = Date.now(); }
} catch (error) { if (version === generation && !signal?.aborted) refreshError = (error as Error).message; return false; }
finally { refreshing = --pendingRefreshes > 0; }
}
onMount(() => pollVisible(refresh, 15_000, false));
function edit(policy: ScalingPolicy | null) {
@@ -86,8 +92,9 @@
<svelte:head><title>Autoscaling | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Autoscaling" subtitle="Keep empty lobbies available for new groups, within the limits you choose.">
{#snippet actions()}<Button onclick={() => refresh()}>Refresh</Button>{#if canOperate && report.policies.length}<Button disabled={busy} onclick={pauseAll}>{report.globalPaused ? 'Resume autoscaling' : 'Pause all'}</Button><Button variant="primary" disabled={busy} onclick={() => edit(null)}>Add policy</Button>{/if}{/snippet}
{#snippet actions()}{#if canOperate && report.policies.length}<Button disabled={busy} onclick={pauseAll}>{report.globalPaused ? 'Resume autoscaling' : 'Pause all'}</Button><Button variant="primary" disabled={busy} onclick={() => edit(null)}>Add policy</Button>{/if}{/snippet}
</SectionHeader>
<Freshness {updatedAt} error={refreshError ? 'Policy updates interrupted.' : ''} intervalLabel="Updates every 15 seconds" onRetry={() => void refresh()} retryDisabled={refreshing || busy} />
{#if report.globalPaused}<p class="banner">Autoscaling is paused globally. Outstanding jobs are still tracked and may finish.</p>{/if}
{#if refreshError}<p role="alert">{refreshError}</p>{/if}
{#if actionError}<p role="alert">{actionError}</p>{/if}
+2 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
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';
@@ -44,7 +45,6 @@
const tabs = $derived([{id:'active',label:'Active',badge:counts.active},{id:'history',label:'History',badge:counts.history}]);
let now = $state(Date.now());
function age(date:string) { const minutes=Math.max(0,Math.floor((now-Date.parse(date))/60000));return minutes<60?`${minutes}m ago`:minutes<1440?`${Math.floor(minutes/60)}h ago`:`${Math.floor(minutes/1440)}d ago`; }
function deadlineLabel(beta: BetaListRow) {
if (!beta.deadlineAt || !active(beta) || beta.testOutcome === 'passed') return '';
const minutes = Math.ceil((Date.parse(beta.deadlineAt) - now) / 60000);
@@ -121,7 +121,7 @@
<td><a class="beta-link" href={contextHref('/betas/' + beta.id, $page.url)}>{beta.notes || `Beta #${beta.id}`}</a>{#if beta.notes}<span class="beta-id">#{beta.id}</span>{/if}</td>
<td class="files">{#if beta.scripts.length === 1}{beta.scripts[0].name} <span>{artifactVersionLabel(beta.scripts[0].version)}</span>{:else}{beta.scripts.length} files{/if}</td>
<td><Badge variant={presentation.tone} size="md">{presentation.label}</Badge>{#if expiry}<span class="expiry">{expiry}</span>{/if}</td>
<td class="started"><button type="button" class="timestamp" aria-label={`Started ${$timeFormat.dateTime(beta.opened_at)}`}><time datetime={beta.opened_at}>{age(beta.opened_at)}</time><span class="exact-time" aria-hidden="true">{$timeFormat.dateTime(beta.opened_at)}</span></button></td>
<td class="started"><button type="button" class="timestamp" aria-label={`Started ${$timeFormat.dateTime(beta.opened_at)}`}><RelativeTime value={beta.opened_at} /><span class="exact-time" aria-hidden="true">{$timeFormat.dateTime(beta.opened_at)}</span></button></td>
</tr>
{/each}</tbody>
</table></div>
+5 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { countLabel, plural } from '$ui/plural.js';
import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte';
@@ -123,7 +124,9 @@
throw new Error(body.error ?? 'Promotion could not be started.');
}
reviewOpen = false;
success = `Promotion created. ${body.result.awaitingActivation ?? 0} servers await activation${body.result.failedServers?.length ? `; ${body.result.failedServers.length} servers need attention` : ''}.`;
const awaiting = body.result.awaitingActivation ?? 0;
const failed = body.result.failedServers?.length ?? 0;
success = `Promotion created. ${countLabel(awaiting, 'server')} ${plural(awaiting, 'awaits', 'await')} activation${failed ? `; ${countLabel(failed, 'server')} ${plural(failed, 'needs', 'need')} attention` : ''}.`;
await load();
} catch (error) { reviewError = (error as Error).message; }
finally { acting = null; }
@@ -192,7 +195,7 @@
</div>
{#if blocker}<p class:reason={presentation.key === 'attention'}>{blocker}</p>{/if}
{#if retryBlocked && !closed && ['paused','failed'].includes(test?.outcome ?? '')}<p class="muted">Deployment was incomplete. Roll back before opening a fresh beta.</p>{/if}
{#if promotion && (promotion.pending || promotion.failed)}<p class="muted">{#if promotion.pending}{promotion.pending} awaiting activation{/if}{#if promotion.pending && promotion.failed} · {/if}{#if promotion.failed}{promotion.failed} need attention{/if}</p>{/if}
{#if promotion && (promotion.pending || promotion.failed)}<p class="muted">{#if promotion.pending}{promotion.pending} awaiting activation{/if}{#if promotion.pending && promotion.failed}{' · '}{/if}{#if promotion.failed}{promotion.failed} {plural(promotion.failed, 'needs', 'need')} attention{/if}</p>{/if}
{#if deadline && !closed && test?.outcome !== 'passed'}
<details class="deadline" class:urgent={remaining <= 120}><summary>{deadlineText}</summary><p>Deadline: <time datetime={deadline.toISOString()}>{$timeFormat.dateTime(deadline)}</time>. Waiting, pausing, and retrying use the same testing window. Deploying a new revision starts a fresh window.</p></details>
{/if}
+5 -4
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { countLabel } from '$ui/plural.js';
import { page } from '$app/stores';
import { contextHref, safeReturnHref } from '$lib/navigation-context.js';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
@@ -75,7 +76,7 @@
function versionsFor(pick:Pick) { return pick.scriptId===null ? [] : (detailById[pick.scriptId]?.versions??[]).filter(v=>v.id!==detailById[pick.scriptId!]?.script.current_version_id); }
function eligibleFor(index:number) { return eligibleFiles.filter(file=>!picks.some((p,i)=>i!==index&&p.scriptId===file.id)); }
function gameLabel(game:string) { return game==='t5'?'Black Ops I (T5)':game==='t6'?'Black Ops II (T6)':game; }
function gameLabel(game:string) { return game==='t5'?'Black Ops I':game==='t6'?'Black Ops II':game; }
function scopeText(file:FileRow) { return !file.scope_type ? 'Any registered server' : file.scope_type==='map' ? `Map: ${mapLabel(file.scope_value??'')}` : `Game: ${file.scope_value==='t5'?'Black Ops I':file.scope_value==='t6'?'Black Ops II':file.scope_value}`; }
function scopeCount(file:FileRow) { return fleetServers.filter(server=>!file.scope_type || (file.scope_type==='game'?server.game:server.map)===file.scope_value).length; }
function reconcileSelection() {
@@ -229,7 +230,7 @@
<h3>Instances that must pass</h3>
<p>At launch, Electric Cherry chooses compatible servers with the fewest current players. Every requested instance must pass the test plan.</p>
<label class="instance-count">Instances per game<input type="number" min="1" max="1000" step="1" value={instanceCount??''} oninput={event=>instanceCount=event.currentTarget.value===''?null:event.currentTarget.valueAsNumber}/><span class="muted">The same number applies to every game in this beta.</span></label>
<p class="muted">{applicableGames.map(game=>`${gameLabel(game)}: ${candidates.filter(server=>server.game===game).length} compatible registered servers`).join(' · ')}</p>
<p class="muted">{applicableGames.map(game=>`${gameLabel(game)}: ${countLabel(candidates.filter(server=>server.game===game).length, 'compatible registered server')}`).join(' · ')}</p>
<p class="muted">Servers are selected when you start the beta, using fresh player counts and availability checks. Restarts wait for confirmed empty lobbies.</p>
</div>
{:else}<p><strong>{pickedServers.size} selected</strong> · {candidateServers.length} compatible servers. Restarts wait for confirmed empty lobbies.</p>{/if}
@@ -244,7 +245,7 @@
{#if selectionMode==='manual' && candidateServers.length}
<div class="filters"><label>Find servers<input type="search" bind:value={search} placeholder="Name, map, lobby…"/></label><label>Game<select bind:value={serverGame}><option value="">All games</option>{#each [...new Set(fleetServers.map(s=>s.game).filter(Boolean))] as game}<option value={game}>{game==='t5'?'Black Ops I':game==='t6'?'Black Ops II':game}</option>{/each}</select></label><label>Availability<select bind:value={availability}><option value="">All servers</option><option value="empty">Confirmed empty</option><option value="occupied">Occupied</option><option value="unknown">Unknown / stale / offline</option></select></label></div>
<div class="actions"><span>{visibleServers.length} visible</span><Button size="sm" onclick={()=>selectVisible(true)} disabled={!visibleServers.length}>Select visible</Button><Button size="sm" onclick={()=>selectVisible(false)} disabled={!visibleServers.some(s=>pickedServers.has(s.name))}>Deselect visible</Button></div>
{#each groups as group}<fieldset><legend>{fleetIdentity(group.servers[0]).map} · {group.servers[0].game==='t5'?'Black Ops I':'Black Ops II'}</legend><div class="server-picker">{#each group.servers as server}<label class="server-choice"><input type="checkbox" checked={pickedServers.has(server.name)} onchange={()=>toggleServer(server.name)}/><span><ServerIdentity name={server.name}/><small>{freshServer(server,now)&&server.last_players!==null ? `${server.last_players} players · ${server.last_players===0?'Confirmed empty':'Occupied'}` : `Players unknown · ${server.health}`}{#if server.held.length} · In beta #{server.held[0].betaId}{/if}</small></span></label>{/each}</div></fieldset>{/each}
{#each groups as group}<fieldset><legend>{fleetIdentity(group.servers[0]).map} · {group.servers[0].game==='t5'?'Black Ops I':'Black Ops II'}</legend><div class="server-picker">{#each group.servers as server}<label class="server-choice"><input type="checkbox" checked={pickedServers.has(server.name)} onchange={()=>toggleServer(server.name)}/><span><ServerIdentity name={server.name}/><small>{freshServer(server,now)&&server.last_players!==null ? `${countLabel(server.last_players, 'player')} · ${server.last_players===0?'Confirmed empty':'Occupied'}` : `Players unknown · ${server.health}`}{#if server.held.length} · In beta #{server.held[0].betaId}{/if}</small></span></label>{/each}</div></fieldset>{/each}
{#if !visibleServers.length}<p>No servers match these filters. <button type="button" class="text-action" onclick={()=>{search='';serverGame='';availability='';}}>Clear filters</button></p>{/if}
{/if}
</div></details>
@@ -254,7 +255,7 @@
<ul>{#each picks as pick}<li><strong>{scripts.find(file=>file.id===pick.scriptId)?.name}</strong> · {artifactVersionLabel(versionsFor(pick).find(v=>v.id===pick.versionId))} · {versionsFor(pick).find(v=>v.id===pick.versionId)?.sha256.slice(0,12)}{#if pick.scriptId!==null && detailById[pick.scriptId]}<br/><span class="muted">{scopeText(detailById[pick.scriptId].script)}</span>{/if}</li>{/each}</ul>
<div class="review-heading"><h3>Test instances ({serverCount})</h3><Button size="sm" onclick={()=>move(1)}>Edit servers</Button></div>
{#if selectionMode==='automatic'}
<ul>{#each Object.entries(automaticCounts) as [game,count]}<li><strong>{gameLabel(game)}:</strong> {count} instance{count===1?'':'s'} must pass</li>{/each}</ul>
<ul>{#each Object.entries(automaticCounts) as [game,count]}<li><strong>{gameLabel(game)}:</strong> {countLabel(count, 'server')} must pass</li>{/each}</ul>
<p>The least-used available servers are chosen when you start the beta. Selection uses current player counts and ensures every file has a matching participant. If there are not enough available servers, the beta will not start.</p>
{:else}<ul>{#each [...pickedServers] as name}<li><ServerIdentity {name}/></li>{/each}</ul>{/if}
<div class="review-heading"><h3>Pass criteria</h3><Button size="sm" onclick={()=>move(2)}>Edit plan</Button></div><p>{coverageSummary(testPlan)} · {testPlan.timeoutHours}-hour window, starting when this beta opens.</p><ul>{#each testPlan.requirements as requirement}<li>{requirementSummary(requirement)}</li>{/each}</ul><h3>Waves and failure handling</h3><p>{testPlan.waveSize?`${testPlan.waveSize} servers per wave.`:'Deploy to all selected servers.'} {testPlan.failureAction==='rollback'?'Failed checks trigger automatic rollback.':testPlan.failureAction==='pause'?'Failed checks pause testing.':'Failed checks hold the beta for review.'} {testPlan.rollbackRestart==='when-empty'?'Rollback restarts wait for empty servers.':'Rollback restores files; restart servers manually.'}</p><p>Starting installs each selected version only on matching test servers and restarts them when empty. Waiting, pausing, and retrying use the original testing window. Promotion is a separate reviewed action after testing.</p>
+2 -9
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { page } from '$app/stores';
@@ -69,14 +70,6 @@
return pollVisible(loadCampaigns, 15000);
});
function ago(iso: string): string {
const elapsed = refreshedAt - new Date(iso).getTime();
if (elapsed < 60_000) return 'Just now';
if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)} min ago`;
if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)} hr ago`;
const days = Math.floor(elapsed / 86_400_000);
return `${days} day${days === 1 ? '' : 's'} ago`;
}
</script>
<svelte:head><title>Campaigns | Electric Cherry</title></svelte:head>
@@ -138,7 +131,7 @@
<td><div class="status"><Badge variant={outcome.tone} size="md">{outcome.label}</Badge>
{#if showProgress}<progress max={campaign.servers.length || 1} value={outcome.completed} aria-label={`${outcome.completed} of ${campaign.servers.length} servers complete`}></progress>{/if}
</div></td>
<td class="started"><button type="button" class="timestamp" aria-label={`Started ${$timeFormat.dateTime(campaign.started_at)}`}><time datetime={campaign.started_at}>{ago(campaign.started_at)}</time><span class="exact-time" aria-hidden="true">{$timeFormat.dateTime(campaign.started_at)}</span></button></td>
<td class="started"><button type="button" class="timestamp" aria-label={`Started ${$timeFormat.dateTime(campaign.started_at)}`}><RelativeTime value={campaign.started_at} /><span class="exact-time" aria-hidden="true">{$timeFormat.dateTime(campaign.started_at)}</span></button></td>
</tr>
{/each}
</tbody>
+8 -10
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { plural } from '$ui/plural.js';
import RelativeTime from '$ui/components/RelativeTime.svelte';
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 Dialog from '$ui/components/primitives/Dialog.svelte';
@@ -163,9 +164,6 @@
v === 'held' ? 'warning' :
'neutral';
}
function fmt(iso: string | null): string {
return $timeFormat.dateTime(iso);
}
</script>
<svelte:head><title>{detail ? `${detail.deployment.action} campaign · ${detail.deployment.id}` : 'Campaign'} | Electric Cherry</title></svelte:head>
@@ -191,14 +189,14 @@
</SectionHeader>
{#if rollbackError}<p class="err" role="alert">{rollbackError}</p>{/if}
<div class="metadata">
<span>Started {fmt(detail.deployment.started_at)}</span>
<span>Started <RelativeTime value={detail.deployment.started_at} /></span>
{#if detail.deployment.beta_id}<span>Source: <a href={contextHref(`/betas/${detail.deployment.beta_id}`, $page.url)}>Beta #{detail.deployment.beta_id}</a></span>{/if}
{#if detail.watchdogIncidentId}<span>Source: <a href={contextHref(`/maintenance/${encodeURIComponent(detail.watchdogIncidentId)}`, $page.url)}>Maintenance incident</a></span>{/if}
<details class="campaign-details">
<summary>Campaign details</summary>
<div class="campaign-details-body">
<div class="campaign-id"><span class="mono">{detail.deployment.id}</span><Button size="sm" onclick={copyId}>Copy ID</Button><span role="status">{copyStatus}</span></div>
{#if detail.deployment.finished_at}<p>Finished {fmt(detail.deployment.finished_at)}</p>{/if}
{#if detail.deployment.finished_at}<p>Finished <RelativeTime value={detail.deployment.finished_at} /></p>{/if}
{#if detail.deployment.triggered_by && detail.deployment.triggered_by !== 'ui'}<p>Triggered by {detail.deployment.triggered_by}</p>{/if}
<p>{detail.watchdogIncidentId ? 'Maintenance rebuilds instance files with the latest promoted setup after checking that the server is safe to update.' : 'Changes wait for an empty server and restart capacity. Rollback snapshots are captured before applying files, then servers restart and verify health. Tested servers retain their pre-beta snapshots.'}</p>
</div>
@@ -216,7 +214,7 @@
<div class="progress-counts">
{#if counts.processing}<span>{counts.processing} processing</span>{/if}
{#if counts.queued}<span>{counts.queued} queued</span>{/if}
{#if counts.attention}<span class="attention">{counts.attention} need attention</span>{/if}
{#if counts.attention}<span class="attention">{counts.attention} {plural(counts.attention, 'needs', 'need')} attention</span>{/if}
{#if counts.inactive}<span>{counts.inactive} inactive</span>{/if}
{#if detail.servers.length === 0}<span>No servers recorded</span>
{:else if counts.complete === detail.servers.length}<span>All servers activated</span>{/if}
@@ -266,9 +264,9 @@
<div><dt>Server ID</dt><dd class="mono">{s.server}</dd></div>
{#if s.players !== null}<div><dt>Players at last check</dt><dd>{s.players}</dd></div>{/if}
<div><dt>Attempts</dt><dd>{s.attempts}</dd></div>
{#if s.checked_at}<div><dt>Last checked</dt><dd>{fmt(s.checked_at)}</dd></div>{/if}
{#if s.restarted_at}<div><dt>Restart requested</dt><dd>{fmt(s.restarted_at)}</dd></div>{/if}
{#if s.server_deleted_at}<div><dt>Deleted</dt><dd>{fmt(s.server_deleted_at)}</dd></div>{/if}
{#if s.checked_at}<div><dt>Last checked</dt><dd><RelativeTime value={s.checked_at} /></dd></div>{/if}
{#if s.restarted_at}<div><dt>Restart requested</dt><dd><RelativeTime value={s.restarted_at} /></dd></div>{/if}
{#if s.server_deleted_at}<div><dt>Deleted</dt><dd><RelativeTime value={s.server_deleted_at} /></dd></div>{/if}
</dl>
<div class="detail-actions">
{#if !s.server_deleted_at}<a href={contextHref(`/servers/${encodeURIComponent(s.server)}?tab=logs`, $page.url)}>Open logs</a><a href={contextHref(`/servers/${encodeURIComponent(s.server)}?tab=diagnostics`, $page.url)}>Diagnostics</a>{/if}
+8 -7
View File
@@ -13,6 +13,7 @@
import CapacityHosts from '$ui/components/capacity/CapacityHosts.svelte';
import CapacityChart from '$ui/components/capacity/CapacityChart.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import { plural } from '$ui/plural.js';
let { data }: { data: CapacityData } = $props();
let snapshot = $state<CapacityData | null>(null);
@@ -81,16 +82,16 @@
{#if refreshError || navigationError}<div class="refresh-error" role="alert"><strong>{navigationError || refreshError}</strong><span>Showing the last successfully loaded capacity report.</span>{#if refreshError}<Button size="sm" disabled={refreshing || navigating} onclick={() => void refresh()}>Retry</Button>{/if}</div>{/if}
<div class="toolbar">
<div class="filters"><Field label="Host"><Select value={report.query.hostId ?? ''} options={hostOptions} disabled={navigating} onchange={event => void changeScope({ hostId: (event.currentTarget as HTMLSelectElement).value || null })} /></Field>
<Field label="Launch game"><Select value={report.query.game} options={gameOptions} disabled={navigating} onchange={event => void changeScope({ game: (event.currentTarget as HTMLSelectElement).value as CapacityGame })} /></Field></div>
<Field label="Game"><Select value={report.query.game} options={gameOptions} disabled={navigating} onchange={event => void changeScope({ game: (event.currentTarget as HTMLSelectElement).value as CapacityGame })} /></Field></div>
<div class="freshness">{#if navigating}<span role="status">Updating capacity…</span>{:else if !refreshError}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 30 seconds" />{/if}</div>
</div>
<section class="overview" aria-labelledby="capacity-now-title">
<div class="overview-heading"><div>
<h2 id="capacity-now-title">{#if summary.estimatedServers === null}Capacity estimate unavailable{:else}Estimated room for <strong>{count(summary.estimatedServers)}</strong> more {summary.estimatedServers === 1 ? 'instance' : 'instances'}{/if}</h2>
<h2 id="capacity-now-title">{#if summary.estimatedServers === null}Capacity estimate unavailable{:else}Estimated room for <strong>{count(summary.estimatedServers)}</strong> more {plural(summary.estimatedServers, 'server')}{/if}</h2>
<p>{scopeName} · {summary.eligibleHosts} eligible {summary.eligibleHosts === 1 ? 'host' : 'hosts'}{report.query.game === 'all' ? '' : ` · ${report.query.game === 't5' ? 'Black Ops I' : 'Black Ops II'}`}</p>
</div><div class="queue-context">
{#if summary.queued !== null && summary.queued > 0}<a href="/provisioning"><strong>{count(summary.queued)} {summary.queued === 1 ? 'instance' : 'instances'} queued →</strong></a>
{:else}<span>{summary.queued === null ? 'Queue unavailable' : 'No queued instances'}</span>{/if}<small>Fleet-wide</small>
{#if summary.queued !== null && summary.queued > 0}<a href="/provisioning"><strong>{count(summary.queued)} {plural(summary.queued, 'server')} queued →</strong></a>
{:else}<span>{summary.queued === null ? 'Queue unavailable' : 'No queued servers'}</span>{/if}<small>Fleet-wide</small>
</div></div>
<dl class="resources" aria-label="Current resource headroom">
<div><dt>CPU available</dt><dd>{count(summary.cpuCoresAvailable)} cores</dd></div>
@@ -106,10 +107,10 @@
{:else}<CapacityHosts hosts={report.current.hosts} returnTo={page.url.pathname + page.url.search} />{/if}
</section>
<details class="methodology"><summary>Capacity details</summary>
<p>Additional instances are estimated from each eligible host’s available CPU, memory, and unreserved ports, using its learned running-instance allowance. The smallest resource allowance sets that host’s limit. With All games selected, hosts ready for either T5 or T6 are counted once.</p>
<p>Additional servers are estimated from each eligible host’s available CPU, memory, and unreserved ports, using its learned running-server allowance. The smallest resource allowance sets that host’s limit. With All games selected, hosts ready for either Black Ops I or Black Ops II are counted once.</p>
<p>CPU and memory availability account for the operating-system reserve and pending starts. Free ports are counted on eligible hosts.</p>
<p>This is a planning estimate. Queued launches do not reserve capacity, and starting instances may require a larger allowance. Placement also checks current host readiness, game files, runtime, and startup slots.</p>
<p>{count(summary.activeStartups)} instances starting across the fleet. Launches wait for capacity and startup slots automatically.</p>
<p>This is a planning estimate. Queued launches do not reserve capacity, and starting servers may require a larger allowance. Placement also checks current host readiness, game files, runtime, and concurrent start limits.</p>
<p>{summary.activeStartups === null ? 'The number of starting servers is unavailable.' : `${count(summary.activeStartups)} ${plural(summary.activeStartups, 'server')} starting across the fleet.`} Servers wait for capacity and a concurrent start slot automatically.</p>
<p>{report.current.updatedAt ? `Report updated ${$timeFormat.dateTime(report.current.updatedAt)}.` : 'Waiting for host reports.'} Refreshes every 30 seconds while visible.</p>
</details>
<CapacityChart history={report.history} scope={scopeName}>
+4 -3
View File
@@ -1,11 +1,12 @@
<script lang="ts">
import { plural } from '$ui/plural.js';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { confirmAction } from '$ui/confirm.js';
import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
@@ -152,9 +153,9 @@
<div class="launches">{#each visibleLaunches.slice(0,launchLimit) as collection (collection.id)}
<a class="collection-row" href={contextHref(`/collections/launches/${collection.id}`, $page.url)}>
<div class="row-head"><strong>{collection.name}</strong><Badge variant={launchVariant(collection)}>{launchLabel(collection)}</Badge></div>
<div class="collection-counts"><span><strong>{collection.done} / {collection.total}</strong> {collection.mode === 'exact' ? 'completed' : 'ready'}</span>{#if collection.provisioning + collection.verifying}<span>{collection.provisioning + collection.verifying} {collection.mode === 'exact' ? 'in progress' : 'launching'}</span>{/if}{#if collection.queued}<span>{collection.queued} queued</span>{/if}{#if collection.failed}<span class="error">{collection.failed} need attention</span>{/if}{#if collection.cancelled}<span>{collection.cancelled} cancelled</span>{/if}{#if collection.cleanupActive}<span>{collection.cleanupActive} cleaning up</span>{/if}{#if collection.cleanupFailed}<span class="error">{collection.cleanupFailed} cleanup{collection.cleanupFailed === 1 ? ' needs' : 's need'} attention</span>{/if}</div>
<div class="collection-counts"><span><strong>{collection.done} / {collection.total}</strong> {collection.mode === 'exact' ? 'completed' : 'ready'}</span>{#if collection.provisioning + collection.verifying}<span>{collection.provisioning + collection.verifying} {collection.mode === 'exact' ? 'in progress' : 'launching'}</span>{/if}{#if collection.queued}<span>{collection.queued} queued</span>{/if}{#if collection.failed}<span class="error">{collection.failed} {plural(collection.failed, 'needs', 'need')} attention</span>{/if}{#if collection.cancelled}<span>{collection.cancelled} cancelled</span>{/if}{#if collection.cleanupActive}<span>{collection.cleanupActive} cleaning up</span>{/if}{#if collection.cleanupFailed}<span class="error">{collection.cleanupFailed} {plural(collection.cleanupFailed, 'cleanup')} {plural(collection.cleanupFailed, 'needs', 'need')} attention</span>{/if}</div>
<div class="collection-meter" aria-hidden="true"><span class="verified" style:width={`${collection.done / (collection.total || 1) * 100}%`}></span><span class="unsuccessful" style:width={`${collection.failed / (collection.total || 1) * 100}%`}></span><span class="cancelled" style:width={`${collection.cancelled / (collection.total || 1) * 100}%`}></span></div>
<div class="collection-footer"><span class="meta">Queued {$timeFormat.dateTime(collection.created_at)}</span><span class="collection-link">{inProgress(collection) ? 'Follow progress' : 'View results'} →</span></div>
<div class="collection-footer"><span class="meta">Queued <RelativeTime value={collection.created_at} /></span><span class="collection-link">{inProgress(collection) ? 'Follow progress' : 'View results'} →</span></div>
</a>
{/each}</div>
{:else if !launchError}
@@ -1,4 +1,5 @@
<script lang="ts">
import { plural } from '$ui/plural.js';
import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
@@ -106,7 +107,7 @@
<div><span class="eyebrow">Collection progress</span><h2>{heading}</h2></div>
<span class="completion"><strong>{collection.done + collection.failed + collection.cancelled}</strong> / {collection.total} finished</span>
</div>
<div class="progress" role="progressbar" aria-label="Finished fleet operations" aria-valuemin={0} aria-valuemax={collection.total || 1} aria-valuenow={collection.done + collection.failed + collection.cancelled} aria-valuetext={`${collection.done} completed, ${collection.failed} need attention, ${collection.cancelled} cancelled, ${collection.active} remaining, ${collection.cleanupActive} cleaning up`}>
<div class="progress" role="progressbar" aria-label="Finished fleet operations" aria-valuemin={0} aria-valuemax={collection.total || 1} aria-valuenow={collection.done + collection.failed + collection.cancelled} aria-valuetext={`${collection.done} completed, ${collection.failed} ${plural(collection.failed, 'needs', 'need')} attention, ${collection.cancelled} cancelled, ${collection.active} remaining, ${collection.cleanupActive} cleaning up`}>
<span class="progress-ready" style:width={`${collection.done / (collection.total || 1) * 100}%`}></span>
<span class="progress-failed" style:width={`${collection.failed / (collection.total || 1) * 100}%`}></span>
<span class="progress-cancelled" style:width={`${collection.cancelled / (collection.total || 1) * 100}%`}></span>
@@ -118,7 +119,7 @@
<div class:attention-count={collection.failed > 0}><dt>Need attention</dt><dd>{collection.failed}</dd></div>
{#if cancellationRequested || collection.cancelled > 0}<div><dt>Cancelled</dt><dd>{collection.cancelled}</dd></div>{/if}
</dl>
<p class="screen-reader" role="status">{collection.done} completed, {collection.provisioning + collection.verifying} in progress, {collection.queued} queued, {collection.failed} need attention, {collection.cancelled} cancelled, {collection.cleanupActive} cleaning up, {collection.cleanupFailed} cleanups need attention.</p>
<p class="screen-reader" role="status">{collection.done} completed, {collection.provisioning + collection.verifying} in progress, {collection.queued} queued, {collection.failed} {plural(collection.failed, 'needs', 'need')} attention, {collection.cancelled} cancelled, {collection.cleanupActive} cleaning up, {collection.cleanupFailed} {plural(collection.cleanupFailed, 'cleanup')} {plural(collection.cleanupFailed, 'needs', 'need')} attention.</p>
{#if cancellationRequested}
<p class="cancellation-note">Completed launches and retained servers are kept. Removals that already started or are shared with another collection may still finish. Follow each cancelled server's cleanup below until removal is confirmed.</p>
{/if}
+11 -10
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { gameTitle } from '$ui/server-labels.js';
import BackLink from '$ui/components/BackLink.svelte';
import { onMount, tick } from 'svelte';
import { page } from '$app/stores';
@@ -37,8 +39,7 @@
const progressBytes = $derived(Math.min(totalBytes, acceptedBytes + sendingBytes));
const transferring = $derived(uploadBusy && (phase === 'preparing' || phase === 'uploading'));
const hostName = (id: string | null) => id ? hosts.find(host => host.id === id)?.name ?? id : 'Browser upload';
const gameName = (game: 't5' | 't6') => game === 't5' ? 'Black Ops I (T5)' : 'Black Ops II (T6)';
const uploadDate = (item: OverviewLibrary['versions'][number]) => new Date(item.publishedAt ?? item.createdAt).toLocaleString();
const gameName = (game: 't5' | 't6') => game === 't5' ? 'Black Ops I' : 'Black Ops II';
async function responseBody(response: Response) {
let body;
@@ -102,7 +103,7 @@
}
if (disposed) return;
if (status.status !== 'published') throw new Error(status.error || 'Verification did not finish. Retry this upload to continue.');
phase = 'published'; folder = null; notice = `${game.toUpperCase()} game files are ready. Active hosts will download and verify the latest files automatically.`;
phase = 'published'; folder = null; notice = `${gameTitle(game, true)} game files are ready. Active hosts will download and verify the latest files automatically.`;
if (folderInput) folderInput.value = '';
await load();
}
@@ -209,7 +210,7 @@
{#if !canManage}<p class="muted">Administrators upload game files. Active hosts prepare verified copies automatically.</p>{/if}
{#if canManage && showUpload}
<section class="card" id="game-upload">
<div class="heading"><h2>{phase === 'published' ? `${game.toUpperCase()} files uploaded` : uploadId ? `Continue ${game.toUpperCase()} upload` : `Upload ${game.toUpperCase()} game folder`}</h2><Button size="sm" variant="ghost" disabled={uploadBusy} onclick={() => showUpload = false}>Close</Button></div>
<div class="heading"><h2>{phase === 'published' ? `${gameTitle(game, true)} files uploaded` : uploadId ? `Continue ${gameTitle(game, true)} upload` : `Upload ${gameTitle(game, true)} game folder`}</h2><Button size="sm" variant="ghost" disabled={uploadBusy} onclick={() => showUpload = false}>Close</Button></div>
<p>Select your complete {gameName(game)} game folder, including its installed DLC. Once verified, this upload becomes the latest files and hosts download it automatically. Running servers keep using their existing files.</p>
{#if uploadError}<p class="ui-error" role="alert">{uploadError}</p>{/if}
<form onsubmit={startUpload}>
@@ -227,7 +228,7 @@
</div>{/if}
<div class="upload-actions">
{#if transferring}<Button onclick={pauseUpload} disabled={pauseRequested}>{pauseRequested ? 'Pausing…' : 'Pause upload'}</Button>
{:else if phase !== 'verifying' && phase !== 'published'}<Button type="submit" variant="primary" disabled={uploadBusy || !folder}>{uploadId ? 'Resume upload' : `Upload ${game.toUpperCase()} files`}</Button>{/if}
{:else if phase !== 'verifying' && phase !== 'published'}<Button type="submit" variant="primary" disabled={uploadBusy || !folder}>{uploadId ? 'Resume upload' : `Upload ${gameTitle(game, true)} files`}</Button>{/if}
{#if phase === 'idle' || phase === 'uploading'}<span class="muted">Keep this page open while files transfer. Interrupted uploads can be resumed below.</span>{/if}
</div>
</form>
@@ -241,13 +242,13 @@
<section class="card">
<div class="heading">
<div><h2>{gameName(library.game)}</h2>{#if latest}<p class="muted">{formatGameBytes(latest.sizeBytes)} · {latest.fileCount.toLocaleString()} files</p>{/if}</div>
{#if canManage}<Button size="sm" disabled={uploadBusy} onclick={() => newUpload(library)}>{latest ? `Update ${library.game.toUpperCase()} files` : `Upload ${library.game.toUpperCase()} files`}</Button>{/if}
{#if canManage}<Button size="sm" disabled={uploadBusy} onclick={() => newUpload(library)}>{latest ? `Update ${gameTitle(library.game, true)} files` : `Upload ${gameTitle(library.game, true)} files`}</Button>{/if}
</div>
{#if latest}
<div class="heading"><span class="muted">Latest verified upload</span><Badge variant={latest.caches.some(cache => cache.status === 'failed') ? 'danger' : latest.caches.length && latest.caches.every(cache => cache.status === 'ready') ? 'online' : 'neutral'}>{latest.caches.length ? `${latest.caches.filter(cache => cache.status === 'ready').length} / ${latest.caches.length} host copies ready` : 'No hosts'}</Badge></div>
<GameFileHosts caches={latest.caches} {hosts} latest />
{:else}
<p class="muted">Upload the complete {library.game.toUpperCase()} game folder once. Hosts will download and verify it automatically as part of setup.</p>
<p class="muted">Upload the complete {gameName(library.game)} game folder once. Hosts will download and verify it automatically as part of setup.</p>
{/if}
{#if unfinished.length}
<div class="uploads">
@@ -255,7 +256,7 @@
{#each unfinished as item (item.id)}
{@const browserUpload = !item.sourceHostId}
<article class="upload-entry">
<div class="heading"><p>Started {new Date(item.createdAt).toLocaleString()} · {formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files</p><Badge variant={item.state === 'failed' ? 'danger' : 'info'}>{item.state === 'failed' ? 'Failed' : browserUpload ? item.id === uploadId && phase === 'verifying' ? 'Verifying' : 'Uploading' : 'Importing'}</Badge></div>
<div class="heading"><p>Started <RelativeTime value={item.createdAt} /> · {formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files</p><Badge variant={item.state === 'failed' ? 'danger' : 'info'}>{item.state === 'failed' ? 'Failed' : browserUpload ? item.id === uploadId && phase === 'verifying' ? 'Verifying' : 'Uploading' : 'Importing'}</Badge></div>
{#if item.error}<p class="ui-error">{item.error}</p>{/if}
{#if browserUpload}
<p class="muted">Resume with the original unchanged folder. The latest files stay available while this upload completes.</p>
@@ -266,14 +267,14 @@
</div>
{/if}
{#if latest || history.length}<details class="library-details"><summary>Details</summary><div class="detail-content">
{#if latest}<p class="muted">Uploaded {uploadDate(latest)}</p>{@render uploadDetails(latest)}{@render downloadControls(latest)}{/if}
{#if latest}<p class="muted">Uploaded <RelativeTime value={latest.publishedAt ?? latest.createdAt} /></p>{@render uploadDetails(latest)}{@render downloadControls(latest)}{/if}
{#if history.length}
<details class="history">
<summary>Previous uploads ({history.length})</summary>
<p class="muted">Old host copies are removed once no instances use them. Controller files are removed after all host copies are gone; upload history is kept.</p>
{#each history as item (item.id)}
<article class="upload-entry">
<h3>Uploaded {uploadDate(item)}</h3>
<h3>Uploaded <RelativeTime value={item.publishedAt ?? item.createdAt} /></h3>
{#if item.filesRemoved}<p class="muted">Files removed from controller · upload history retained</p>{:else if item.filesRemoving}<p class="muted">Removing unused controller files · upload history retained</p>{/if}
<p>{formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files</p>
{@render uploadDetails(item)}
+2 -1
View File
@@ -3,6 +3,7 @@
import { page } from '$app/stores';
import { pollVisible } from '$ui/poll.js';
import { hostListStatus, stableHostOrder } from '$ui/host-overview.js';
import { countLabel, plural } from '$ui/plural.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import AddHostDrawer from '$ui/components/AddHostDrawer.svelte';
@@ -52,7 +53,7 @@
</SectionHeader>
{#if error}<p role="alert" class="ui-error">{error} Showing the last loaded hosts. <Button variant="ghost" size="sm" onclick={() => load()}>Retry</Button></p>{/if}
{#if hosts.length}
<div class="summary"><span>{hosts.length} host{hosts.length === 1 ? '' : 's'} · {ready === hosts.length ? 'All ready' : `${ready} ready`}</span>{#if attention}<button onclick={() => { status = 'attention'; search = ''; }}>{attention} need attention</button>{/if}</div>
<div class="summary"><span>{countLabel(hosts.length, 'host')} · {ready === hosts.length ? 'All ready' : `${ready} ready`}</span>{#if attention}<button onclick={() => { status = 'attention'; search = ''; }}>{attention} {plural(attention, 'needs', 'need')} attention</button>{/if}</div>
{#if filtersVisible}<div class="filters">
<input type="search" aria-label="Search hosts" placeholder="Search hosts…" bind:value={search} />
<select aria-label="Filter hosts by status" bind:value={status}><option value="">All statuses</option><option value="attention">Needs attention</option><option value="offline">Offline</option><option value="preparing">Preparing</option><option value="draining">Draining</option><option value="disabled">Disabled</option><option value="deleting">Deleting</option><option value="ready">Ready</option></select>
+12 -8
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import { contextHref } from '$lib/navigation-context.js';
@@ -8,6 +10,8 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import type { Host, CapacityCalibration } from '$lib/hosts.js';
import { hostCalibrationHint } from '$ui/host-overview.js';
import { countLabel } from '$ui/plural.js';
import { hostAgentLaneLabel, hostAgentLaneState, hostAgentUpdateLabel, hostAgentUpdateMessage, hostAgentUpdatesEnabled, hostDeletionMessage, hostReadinessLabel } from '$lib/hosts.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
@@ -74,8 +78,8 @@
</SectionHeader>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if}
{#if loading}<p role="status">Loading host…</p>{:else if host}
{#if host.decommission}<section class="panel" aria-live="polite"><h2>Host cleanup</h2><p>{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}<p>Requested {new Date(host.decommission.requestedAt).toLocaleString()}. This page closes only after the cleanup worker confirms removal.</p>{#if host.decommission.stage === 'cleaned'}<p>The agent is expected to go offline during uninstall. If final confirmation never arrives, inspect the machine; loss of contact alone does not prove completion.</p>{/if}</section>{/if}
<section class="panel"><div class="row"><h2>{host.connectionState} · {hostReadinessLabel(host.readiness)}</h2><span>Last heartbeat: {host.lastHeartbeat ? new Date(host.lastHeartbeat).toLocaleString() : 'Never'}</span></div>
{#if host.decommission}<section class="panel" aria-live="polite"><h2>Host cleanup</h2><p>{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}<p>Requested <RelativeTime value={host.decommission.requestedAt} />. This page closes only after the cleanup worker confirms removal.</p>{#if host.decommission.stage === 'cleaned'}<p>The agent is expected to go offline during uninstall. If final confirmation never arrives, inspect the machine; loss of contact alone does not prove completion.</p>{/if}</section>{/if}
<section class="panel"><div class="row"><h2>{host.connectionState} · {hostReadinessLabel(host.readiness)}</h2><span>Last heartbeat: <RelativeTime value={host.lastHeartbeat} missing="Not yet reported" /></span></div>
{#if host.readinessReasons.length}<ul>{#each host.readinessReasons as reason}<li>{reason}</li>{/each}</ul>{:else}<p>Connected and ready. Each deployment also checks its latest game files, runtime, and ports.</p>{/if}
{#if host.readiness !== 'ready' && tab !== 'setup'}<Button size="sm" onclick={() => changeTab('setup')}>Review setup</Button>{/if}
</section>
@@ -84,7 +88,7 @@
{#if tab === 'overview'}
<section class="panel"><h2>Agent workers</h2>
{#if host.inventory.agentLanes?.length}<dl>{#each host.inventory.agentLanes as lane (lane.name)}
<dt>{hostAgentLaneLabel(lane.name)}</dt><dd>{hostAgentLaneState(lane)}{#if !host.connected} (last reported){/if}<br />Last successful poll: {lane.lastPollAt ? new Date(lane.lastPollAt).toLocaleString() : 'Not yet reported'}</dd>
<dt>{hostAgentLaneLabel(lane.name)}</dt><dd>{hostAgentLaneState(lane)}{#if !host.connected} (last reported){/if}<br />Last successful poll: <RelativeTime value={lane.lastPollAt} missing="Not yet reported" /></dd>
{/each}</dl>{:else}<p>This agent has not reported worker status yet.</p>{/if}
</section>
<section class="panel"><h2>Inventory</h2><dl>
@@ -93,15 +97,15 @@
<p>Allowances update from observed container usage. Hosts reserve 5% CPU and 500 MB of memory. Startup and running usage are tracked separately.</p>
<dl>{#each ['startup', 'running'] as phase}{@const allowance = calibration[phase as keyof CapacityCalibration]}
<dt>{phase === 'startup' ? 'Starting a server' : 'Running a server'}</dt>
<dd>{allowance.cpuCores.toFixed(2)} CPU cores · {bytes(allowance.memoryBytes)} · {allowance.source === 'host' ? 'Learned on this host' : allowance.source === 'fleet' ? 'Learned from the fleet' : 'Calibrating; temporary allowance'} ({allowance.samples} samples)</dd>
<dd>{allowance.cpuCores.toFixed(2)} CPU cores · {bytes(allowance.memoryBytes)} · {allowance.source === 'host' ? 'Learned on this host' : allowance.source === 'fleet' ? 'Learned from the fleet' : 'Calibrating; temporary allowance'} ({countLabel(allowance.samples, 'sample')})</dd>
{/each}</dl>
{#if host.inventory.instanceResourcesError}<p role="status">{host.inventory.instanceResourcesError}</p>{/if}
{#if calibration.startup.source === 'bootstrap' || calibration.running.source === 'bootstrap'}<p>Use agent 1.4.6 or later for resource sampling. Measurements arrive with agent heartbeats and are combined into one peak sample per container per minute. The first five minutes after a container starts count toward startup; later measurements count toward running. Temporary allowances stay in use until enough measurements are available.</p>{/if}
{#if calibration.startup.source === 'bootstrap' || calibration.running.source === 'bootstrap'}<p>{hostCalibrationHint(host.inventory.agentVersion)} Measurements arrive with agent heartbeats and are combined into one peak sample per container per minute. The first five minutes after a container starts count toward startup; later measurements count toward running.</p>{/if}
{#key host.id}<CapacitySamples hostId={host.id} />{/key}
</section>{/if}
{:else if tab === 'servers'}
<section class="panel" aria-labelledby="running-instances-heading">
<h2 id="running-instances-heading">Running instances · {instances.length}</h2>
<h2 id="running-instances-heading">Running servers · {instances.length}</h2>
<p>Current instances on this host, including any that are starting or unavailable.</p>
{#if !host.connected && instances.length}<p role="status">The host is offline. Instance details reflect the latest reports.</p>{/if}
{#if instances.length}
@@ -119,7 +123,7 @@
{:else if tab === 'setup'}
{#if !cleaning}<section class="panel"><h2>Automatic host setup</h2><HostPreparation {host} /></section>{/if}
<section class="panel" aria-live="polite"><h2>Automatic agent updates · {host.enrolled ? hostAgentUpdateLabel(host.inventory) : 'Awaiting installation'}</h2>
<dl><dt>Running version</dt><dd>{host.inventory.agentVersion ?? host.inventory.agentUpdate?.currentVersion ?? 'Not reported'}</dd><dt>Target version</dt><dd>{host.inventory.agentUpdate?.targetVersion ?? 'None reported'}</dd><dt>Last update check</dt><dd>{host.inventory.agentUpdate?.checkedAt ? new Date(host.inventory.agentUpdate.checkedAt).toLocaleString() : 'Not reported'}</dd></dl>
<dl><dt>Running version</dt><dd>{host.inventory.agentVersion ?? host.inventory.agentUpdate?.currentVersion ?? 'Not reported'}</dd><dt>Target version</dt><dd>{host.inventory.agentUpdate?.targetVersion ?? 'None reported'}</dd><dt>Last update check</dt><dd><RelativeTime value={host.inventory.agentUpdate?.checkedAt} missing="Not reported" /></dd></dl>
{#if !host.connected && host.enrolled}<p>This is the last reported update status. The host is offline.</p>{/if}
{#if host.inventory.agentUpdate?.error}<p class="ui-error">{host.inventory.agentUpdate.error}</p>{/if}
{#if host.enrolled}<p>{hostAgentUpdateMessage(host.inventory)}</p>{:else}<p>The installer enables automatic updates when this host first enrolls.</p>{/if}
@@ -137,7 +141,7 @@
<section class="panel"><h2>Host configuration</h2>{#if canManage}<form onsubmit={save}><fieldset disabled={busy || cleaning}>
<label>Friendly name<input required maxlength="200" bind:value={name} /></label><div class="fields"><label>First game port<input type="number" min="1" max="65535" required bind:value={portMin} /></label><label>Last game port<input type="number" min={portMin} max="65535" required bind:value={portMax} /></label></div><label>Local bind address<input required bind:value={bindAddress} /></label><label>Public / NAT address<input bind:value={publicAddress} placeholder="Optional public hostname or address" /></label><label>Minimum free game storage (GiB)<input type="number" min="0" step="0.1" required bind:value={minimumFreeGB} /></label><Button type="submit" disabled={busy}>Save settings</Button>
</fieldset></form>{:else}<dl><dt>Friendly name</dt><dd>{host.name}</dd><dt>Game ports</dt><dd>{host.portMin}–{host.portMax}</dd><dt>Local bind address</dt><dd>{host.bindAddress}</dd><dt>Public / NAT address</dt><dd>{host.publicAddress || 'Not configured'}</dd><dt>Minimum free storage</dt><dd>{bytes(host.minimumFreeBytes)}</dd></dl><p>Host configuration is managed by administrators.</p>{/if}<p>Public addressing is separate from local port reservations. Configure your firewall and NAT for the game-port range.</p></section><section class="panel" id="agent-access"><h2>Agent access</h2><p>Credentials: {host.credentialActive ? 'Active' : 'Not active'}. Enrollment links expire after 15 minutes and work once.</p>{#if canManage}<div class="actions"><Button disabled={busy || (cleaning && !host.decommission?.retryable)} onclick={() => action('/enrollment', 'POST')}>Generate installer</Button><Button disabled={busy || cleaning || !host.credentialActive || !host.connected} onclick={() => action('/credentials', 'POST', undefined, 'Credential rotation queued.')}>Rotate credential</Button><Button disabled={busy || cleaning || !host.credentialActive} onclick={() => action('/credentials', 'DELETE', undefined, 'Host credentials revoked. Generate a new installer to enroll again.')}>Revoke credential</Button></div>
{#if enrollment}<p>Expires {new Date(enrollment.expiresAt).toLocaleString()}</p><pre>{enrollment.command}</pre>{/if}{:else}<p>Administrators manage enrollment and agent credentials.</p>{/if}
{#if enrollment}<p>Expires {$timeFormat.dateTime(enrollment.expiresAt)}</p><pre>{enrollment.command}</pre>{/if}{:else}<p>Administrators manage enrollment and agent credentials.</p>{/if}
</section>
{/if}
</div>
+17 -13
View File
@@ -5,16 +5,21 @@
import type { HostOperationFilter, HostOperationHistoryPage } from '$lib/hosts.js';
import type { PageData } from './$types';
import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import BackLink from '$ui/components/BackLink.svelte';
import { contextHref } from '$lib/navigation-context.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import { hostOperationTitle } from '$lib/host-operation-labels.js';
import { serverTitle } from '$ui/server-labels.js';
let { data }: { data: PageData } = $props();
let snapshot = $state.raw<{ source: PageData; history: HostOperationHistoryPage } | null>(null);
let refreshing = $state(false);
let refreshError = $state('');
let updatedAt = $state(Date.now());
let request: AbortController | null = null;
let sequence = 0;
const history = $derived(snapshot?.source === data ? snapshot.history : data.history);
@@ -54,7 +59,7 @@
});
const body = await response.json();
if (!response.ok) throw new Error('Could not refresh operation history.');
if (current === sequence && data === source && !$navigating) { snapshot = { source, history: body }; refreshError = ''; }
if (current === sequence && data === source && !$navigating) { snapshot = { source, history: body }; refreshError = ''; updatedAt = Date.now(); }
}
catch {
if (current === sequence && data === source && !signal?.aborted && !controller.signal.aborted) {
@@ -64,7 +69,7 @@
} finally { if (current === sequence) { refreshing = false; request = null; } }
}
beforeNavigate(cancel);
$effect(() => { void data; untrack(() => { cancel(); snapshot = null; refreshError = ''; }); });
$effect(() => { void data; untrack(() => { cancel(); snapshot = null; refreshError = ''; updatedAt = Date.now(); }); });
onMount(() => { const stop = pollVisible(refresh, 15000, false); return () => { stop(); cancel(); }; });
</script>
@@ -72,11 +77,11 @@
<section class="page" aria-busy={changingPage}>
<SectionHeader title="Host operation history" eyebrow="Operations" subtitle="Remote operations, newest first. Filtered views retain their host and server context.">
{#snippet actions()}<Button disabled={refreshing || changingPage} onclick={() => void refresh()}>{refreshing ? 'Refreshing…' : 'Refresh'}</Button>{/snippet}
{#snippet actions()}<Freshness {updatedAt} error={refreshError} intervalLabel="Updates every 15 seconds" onRetry={() => void refresh()} />{/snippet}
</SectionHeader>
{#if data.scope.operation || $page.url.searchParams.has('returnTo')}<BackLink fallback={data.scope.operation ? '/activity' : '/hosts'} />{/if}
{#if data.scope.operation}<div class="selected-operation"><div><strong>Selected host operation</strong><code>{data.scope.operation}</code></div><a href={allOperationsHref}>View all operations</a></div>{/if}
{#if data.scope.host || data.scope.server}<div class="scope" role="status"><span>Showing {data.hostName ? `host: ${data.hostName}` : ''}{data.scope.host && data.scope.server ? ' · ' : ''}{data.scope.server ? `server: ${data.scope.server}` : ''}</span><a href={`/hosts/operations?filter=${history.filter}`}>Clear scope</a></div>{/if}
{#if data.scope.host || data.scope.server}<div class="scope" role="status"><span>Showing {data.hostName ? `host: ${data.hostName}` : ''}{data.scope.host && data.scope.server ? ' · ' : ''}{data.scope.server ? `server: ${serverTitle(data.scope.server)}` : ''}</span><a href={`/hosts/operations?filter=${history.filter}`}>Clear scope</a></div>{/if}
<div class="history-filters">
<nav class="filter-links" aria-label="Operation history filters">
{#each filters as filter}
@@ -86,7 +91,6 @@
</nav>
<p>{selectedFilter.description}</p>
</div>
{#if refreshError}<p class="ui-error" role="alert">{refreshError}</p>{/if}
<section class="panel" aria-label="Host operation history">
{#if !history.operations.length}<p class="empty" role="status">{data.scope.operation ? 'This operation is unavailable in the selected history. It may have expired or be excluded by the current filters.' : selectedFilter.empty}</p>
{:else}
@@ -96,15 +100,16 @@
<thead><tr><th scope="col">Operation</th><th scope="col">Host</th><th scope="col">Target</th><th scope="col">Source</th><th scope="col">State</th><th scope="col">Updated</th><th scope="col">Result</th></tr></thead>
<tbody>{#each history.operations as operation (operation.id)}
<tr>
<td class="operation">{operation.kind === 'plutonium.prepare' ? 'Prepare Plutonium cache' : operation.kind}<small>{operation.id}</small></td>
<td class="operation">{hostOperationTitle(operation.kind)}<details><summary>Technical details</summary><small>{operation.kind}</small><small>{operation.id}</small></details></td>
<td>{#if operation.hostName !== null}<a href={contextHref(`/hosts/${operation.hostId}`, $page.url)}>{operation.hostName}</a>{:else}Deleted host<small>{operation.hostId}</small>{/if}</td>
<td class="target">
<span>{operation.serverName ?? operation.instanceId ?? 'Host-level'}</span>
<span>{operation.serverName ? serverTitle(operation.serverName) : operation.instanceId ? 'Server container' : 'Host-level'}</span>
{#if operation.instanceId && !operation.serverName}<small>{operation.instanceId}</small>{/if}
{#if operation.targetPath}<small class="target-path">{operation.targetPath}</small>{/if}
</td>
<td class="source">{operation.source ?? 'Not recorded'}</td>
<td class="state" class:failed={operation.state === 'failed'}>{operation.state}</td>
<td class="updated">{$timeFormat.dateTime(operation.updatedAt)}</td>
<td><StatusBadge state={operation.state === 'completed' ? 'done' : operation.state === 'pending' ? 'queued' : operation.state === 'accepted' ? 'running' : operation.state} /></td>
<td class="updated"><RelativeTime value={operation.updatedAt} /></td>
<td class="result">{operation.error ?? '—'}</td>
</tr>
{/each}</tbody>
@@ -137,7 +142,7 @@
.filter-links a.active { color: var(--text-primary); background: var(--surface-raised); }
.filter-links a:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -2px; }
.panel { min-width: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
.table-wrap { overflow: auto; }
.table-wrap { position: relative; overflow: auto; }
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -2px; }
table { width: 100%; min-width: 1050px; border-collapse: collapse; text-align: left; font-size: var(--text-sm); }
td, th { padding: 16px; border-bottom: 1px solid var(--border-subtle); vertical-align: top; }
@@ -148,8 +153,7 @@
.target { min-width: 180px; max-width: 320px; }
.target-path { font-family: var(--font-mono); }
.source { min-width: 120px; max-width: 220px; }
.state { text-transform: capitalize; }
.failed { color: var(--state-danger); }
summary { cursor: pointer; color: var(--text-secondary); font-size: var(--text-xs); }
.updated { white-space: nowrap; }
.result { min-width: 160px; max-width: 420px; }
.pagination { padding: 16px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; color: var(--text-secondary); font-size: var(--text-xs); }
+2 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
let { data: initial }: { data: import('./$types').PageData } = $props();
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { page, navigating } from '$app/stores';
@@ -298,7 +298,7 @@
<p>Only completed games contribute to records. Games in progress and interrupted games stay out of totals and rankings. Players who left a completed game keep their recorded stats. Game, map, and starting squad-size filters choose which matches contribute; player search preserves ranks. Player totals count each GUID once. The top stats cover all recorded players in the selected game and map; player search and ranking qualification do not change those totals.</p>
<p>Highest round includes mid-game joins. Total rounds counts round progression during observed participation, excluding earlier rounds. Joining at round 8 records a highest round of 8; reaching round 9 adds one to total rounds. The fleet total adds all players’ rounds.</p>
<p>Kills, headshots, revives, and downs accumulate across recorded games. Highest reported score is the largest observed points balance, not the total points a player has earned. Equal values share a rank.</p>
<p>Records cover telemetry saved since tracking began; older unrecorded games cannot be included.{#if data?.summary.firstTrackedAt}{' '}Tracking began <time datetime={data.summary.firstTrackedAt}>{$timeFormat.dateTime(data.summary.firstTrackedAt)}</time> for this selection.{/if}{#if data?.summary.updatedAt}{' '}Latest report: <time datetime={data.summary.updatedAt}>{$timeFormat.dateTime(data.summary.updatedAt)}</time>.{/if}</p>
<p>Records cover telemetry saved since tracking began; older unrecorded games cannot be included.{#if data?.summary.firstTrackedAt}{' '}Tracking began <RelativeTime value={data.summary.firstTrackedAt} /> for this selection.{/if}{#if data?.summary.updatedAt}{' '}Latest report: <RelativeTime value={data.summary.updatedAt} />.{/if}</p>
</div>
</details>
</section>
@@ -1,4 +1,6 @@
<script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { playerCompletionLabel } from '$ui/player-match.js';
import { onMount } from 'svelte';
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
@@ -39,7 +41,6 @@
if (scope.players) params.set('players', scope.players);
return `/leaderboard?${params}`;
});
const date = (value: string) => $timeFormat.dateTime(value);
const records = [{ key: 'highestRound', label: 'Best round' }, { key: 'bestKills', label: 'Best kills' }, { key: 'bestScore', label: 'Best score' }] as const;
const overviewMetrics = playerStatMetrics.filter(metric => ['kills', 'roundsSurvived', 'revives', 'gamesCompleted'].includes(metric.key));
const otherMetrics = playerStatMetrics.filter(metric => ['headshots', 'downs', 'bestKills', 'bestScore'].includes(metric.key));
@@ -90,7 +91,7 @@
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable records for keyboard users.) -->
<div class="table-scroll desktop-records" role="region" aria-label="Per-map player records" tabindex="0">
<table><thead><tr><th scope="col">Map</th>{#each records as record}<th scope="col">{record.label}</th>{/each}<th scope="col">Games</th><th scope="col">Total kills</th><th scope="col">Total rounds</th></tr></thead>
<tbody>{#each profile.maps as map (`${map.game}:${map.key}`)}<tr><th scope="row"><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric: scope.metric })}`}>{map.name}</a><small>{gameName(map.game)}</small></th>{#each records as record}<td><a href={contextHref(`/matches/${map.records[record.key].matchId}`, $page.url)} aria-label={`${map.name} ${record.label}: ${number.format(map[record.key])}, Match #${map.records[record.key].matchId}`} title={map.records[record.key].endedAt ? date(map.records[record.key].endedAt!) : 'Completed match'}>{number.format(map[record.key])}</a></td>{/each}<td>{number.format(map.gamesPlayed)}</td><td>{number.format(map.kills)}</td><td>{number.format(map.roundsSurvived)}</td></tr>{/each}</tbody>
<tbody>{#each profile.maps as map (`${map.game}:${map.key}`)}<tr><th scope="row"><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric: scope.metric })}`}>{map.name}</a><small>{gameName(map.game)}</small></th>{#each records as record}<td><a href={contextHref(`/matches/${map.records[record.key].matchId}`, $page.url)} aria-label={`${map.name} ${record.label}: ${number.format(map[record.key])}, Match #${map.records[record.key].matchId}`} title={map.records[record.key].endedAt ? $timeFormat.dateTime(map.records[record.key].endedAt!) : 'Completed match'}>{number.format(map[record.key])}</a></td>{/each}<td>{number.format(map.gamesPlayed)}</td><td>{number.format(map.kills)}</td><td>{number.format(map.roundsSurvived)}</td></tr>{/each}</tbody>
</table>
</div>
</section>
@@ -100,13 +101,13 @@
<div class="mobile-records"><PlayerMatchCards matches={profile.matches} {returnTo} /></div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable match history for keyboard users.) -->
<div class="table-scroll desktop-records" role="region" aria-label="Player match history" tabindex="0">
<table><thead><tr><th scope="col">Match / server</th><th scope="col">First observed</th><th scope="col">Round</th><th scope="col">Kills</th><th scope="col">Revives</th><th scope="col">Downs</th><th scope="col">Completion</th></tr></thead><tbody>{#each profile.matches as match (match.id)}<tr><th scope="row"><a href={contextHref(`/matches/${match.id}`, $page.url)}>{match.mapName} · Match #{match.id}</a><small>{serverTitle(match.server)}{match.server_deleted_at ? ' · Removed server' : ''}</small></th><td>{date(match.startedAt)}</td><td>{number.format(match.highestRound)}</td><td>{number.format(match.kills)}</td><td>{number.format(match.revives)}</td><td>{number.format(match.downs)}</td><td>{match.completed ? 'Credited' : 'Not credited'}</td></tr>{/each}</tbody></table>
<table><thead><tr><th scope="col">Match / server</th><th scope="col">First observed</th><th scope="col">Round</th><th scope="col">Kills</th><th scope="col">Revives</th><th scope="col">Downs</th><th scope="col">Final report</th></tr></thead><tbody>{#each profile.matches as match (match.id)}<tr><th scope="row"><a href={contextHref(`/matches/${match.id}`, $page.url)}>{match.mapName} · Match #{match.id}</a><small>{serverTitle(match.server)}{match.server_deleted_at ? ' · Removed server' : ''}</small></th><td><RelativeTime value={match.startedAt} /></td><td>{number.format(match.highestRound)}</td><td>{number.format(match.kills)}</td><td>{number.format(match.revives)}</td><td>{number.format(match.downs)}</td><td>{playerCompletionLabel(match)}</td></tr>{/each}</tbody></table>
</div>
<div class="pagination"><span>{number.format(profile.offset + 1)}–{number.format(profile.offset + profile.matches.length)} of {number.format(profile.matchesTotal)} {profile.matchesTotal === 1 ? 'game' : 'games'}</span><div><Button size="sm" href={`${profileViewHref($page.url, { offset: String(Math.max(0, profile.offset - profile.limit)) })}#history-title`} disabled={profile.offset === 0}>Previous</Button><Button size="sm" href={`${profileViewHref($page.url, { offset: String(profile.offset + profile.limit) })}#history-title`} disabled={profile.offset + profile.limit >= profile.matchesTotal}>Next</Button></div></div>
{:else}<EmptyState headingLevel={3} title="No completed matches in this selection" description="Choose another game, map, or squad size to explore this player's history.">{#snippet actions()}<Button size="sm" onclick={() => void updateScope({ game: '', map: '', players: '' })}>Show all completed matches</Button>{/snippet}</EmptyState>{/if}
</section>
<section class="panel" aria-labelledby="metrics-title"><div class="panel-heading"><h2 id="metrics-title">Lifetime statistics</h2><p>All games, maps and squad sizes. Only completed matches contribute.</p></div><dl class="metric-list">{#each otherMetrics as metric}<div><dt>{metric.label}</dt><dd>{number.format(stats[metric.key])}</dd></div>{/each}{#each efficiencyMetrics as metric}<div><dt>{metric.label}</dt><dd>{formatEfficiencyValue(efficiencyValue(stats, metric.key), metric.key)}</dd><small>{efficiencySample(stats, metric.key)}</small><small>{efficiencyEligible(stats, metric.key) ? 'Qualified for lifetime ranking' : `${efficiencyRequirement(metric.key)} to rank`}</small></div>{/each}</dl></section>
<details class="note"><summary>Identity and how these stats work</summary><div class="identity"><CopyValue value={profile.guid} label="Player GUID" /><span>First seen {date(profile.firstSeen)}</span><span>Last seen {date(profile.lastSeen)}</span></div><p>Lifetime stats combine completed games from Black Ops I and Black Ops II. Games in progress and interrupted games do not contribute. Highest round includes mid-game joins; total rounds counts observed progression. Kills, headshots, revives and downs use each match's highest reported counters.</p><p>Highest reported score is the largest observed points balance, not total points earned. Completion requires the player to appear in the final report. Records follow the same GUID across name changes. Recorded presence does not establish continuous survival.</p></details>
<details class="note"><summary>Identity and how these stats work</summary><div class="identity"><CopyValue value={profile.guid} label="Player GUID" /><span>First seen <RelativeTime value={profile.firstSeen} /></span><span>Last seen <RelativeTime value={profile.lastSeen} /></span></div><p>Lifetime stats combine completed games from Black Ops I and Black Ops II. Games in progress and interrupted games do not contribute. Highest round includes mid-game joins; total rounds counts observed progression. Kills, headshots, revives and downs use each match's highest reported counters.</p><p>Highest reported score is the largest observed points balance, not total points earned. Games completed counts matches where the player appears in the final report; it does not indicate a win or uninterrupted survival. Records follow the same GUID across name changes. Recorded presence does not establish continuous survival.</p></details>
</section>
<Dialog bind:open={filterOpen} title="Player filters" description="Choose the leaderboard ranking and match history. Lifetime records stay unchanged.">
<div class="scope-controls" aria-busy={filtering}>
+10 -8
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import ActivityLog from '$ui/components/ActivityLog.svelte';
import BackLink from '$ui/components/BackLink.svelte';
import { contextHref } from '$lib/navigation-context.js';
@@ -23,6 +24,7 @@
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import { groupMaintenanceScanErrors, groupMaintenanceVersions } from '$ui/maintenance-presentation.js';
import { countLabel, plural } from '$ui/plural.js';
let data = $state<MaintenanceOverview | null>(null);
let error = $state('');
@@ -182,14 +184,14 @@
<p class="muted">Use <a href="/maintenance?view=overview#activity">active work and attention</a> to cancel waiting maintenance or retry a failed verification after inspection.</p>
{:else}
{#if view === 'overview'}
<div class="summary"><span><strong>{attention}</strong> {attention === 1 ? 'needs' : 'need'} attention</span><span><strong>{waiting}</strong> active</span><span><strong>{outdated.length}</strong> outdated</span><span><strong>{unknown.length}</strong> {unknown.length === 1 ? 'version' : 'versions'} unverified</span></div>
<div class="summary"><span><strong>{attention}</strong> {plural(attention, 'needs', 'need')} attention</span><span><strong>{waiting}</strong> active</span><span><strong>{outdated.length}</strong> outdated</span><span><strong>{unknown.length}</strong> {plural(unknown.length, 'version')} unverified</span></div>
{#if data.runtime.schedule.nextAt}<p class="muted">Next scheduled restart: {$timeFormat.dateTime(data.runtime.schedule.nextAt)} · <a href="/maintenance?view=policies#schedule">Manage schedule</a></p>{/if}
{#if data.recovery.lastScanAt}<p class="muted">Health and drift checked {$timeFormat.dateTime(data.recovery.lastScanAt)}</p>{/if}
{#if data.recovery.lastScanAt}<p class="muted">Health and drift checked <RelativeTime value={data.recovery.lastScanAt} /></p>{/if}
{#if scanErrors.length}
<section class="scan-errors" aria-labelledby="scan-errors-heading">
<div class="row-heading"><h2 id="scan-errors-heading">Checks need attention</h2><Button size="sm" disabled={busy} onclick={() => act({ action: 'scan' })}>Retry checks</Button></div>
<ul class="error-groups">{#each scanErrors as group (group.key)}
<li><h3><StatusPip tone="warning" />{group.title}{group.servers.length ? ` · ${group.servers.length} ${group.servers.length === 1 ? 'server' : 'servers'}` : ''}</h3>
<li><h3><StatusPip tone="warning" />{group.title}{group.servers.length ? ` · ${countLabel(group.servers.length, 'server')}` : ''}</h3>
<p>{group.description}</p>
{#if group.servers.length}<ul class="affected-servers">{#each group.servers as name}<li><a href={contextHref(`/servers/${encodeURIComponent(name)}?tab=diagnostics`, $page.url)}>{serverTitle(name)}</a></li>{/each}</ul>{/if}
<details><summary>Technical details</summary><ul class="technical-details">{#each group.details as detail}<li>{#if detail.server}<strong>{serverTitle(detail.server)}</strong>{/if}<pre>{detail.message}</pre></li>{/each}</ul></details>
@@ -207,15 +209,15 @@
<section class="card" id="updates">
<div class="row-heading"><h2>Plutonium updates</h2>{#if updatableServers.length}<Button disabled={busy || data.policies.paused} onclick={() => act({ action: 'update', servers: updatableServers })}>Update outdated servers on restart</Button>{/if}</div>
<p>Published revision: <strong>{data.runtime.release.revision ? `r${data.runtime.release.revision}` : 'Unknown'}</strong> · <a href={PLUTONIUM_MANIFEST_URL} target="_blank" rel="noreferrer">Production manifest</a> · <a href="https://plutonium.pw/docs/changelog/" target="_blank" rel="noreferrer">Changelog</a></p>
{#if data.runtime.release.checkedAt}<p class="muted">Checked {$timeFormat.dateTime(data.runtime.release.checkedAt)}</p>{/if}
{#if data.runtime.release.checkedAt}<p class="muted">Checked <RelativeTime value={data.runtime.release.checkedAt} /></p>{/if}
{#if data.runtime.release.error}<p class="ui-error" role="alert">{data.runtime.release.error} Displayed release information is cached.</p>{/if}
<p class="muted">Plutainer applies the available release at startup when auto-update is enabled. Maintenance verifies the running revision after recovery. Unknown versions require a successful check before automatic updates.</p>
<div class="version-groups">{#each versionGroups as group (group.key)}
<article class="version-group">
<div class="row-heading">
<div class="version-summary"><strong>{group.versions.length} {group.versions.length === 1 ? 'server' : 'servers'} · {group.revision ? `reported r${group.revision}` : 'revision unknown'}</strong><Badge variant={group.tone}><StatusPip tone={group.tone} size="sm" />{group.label}</Badge></div>
<div class="version-summary"><strong>{countLabel(group.versions.length, 'server')} · {group.revision ? `reported r${group.revision}` : 'revision unknown'}</strong><Badge variant={group.tone}><StatusPip tone={group.tone} size="sm" />{group.label}</Badge></div>
<div class="version-actions">
{#if group.pending.length}<span>{group.pending.length} {group.pending.length === 1 ? 'restart' : 'restarts'} pending</span>{/if}
{#if group.pending.length}<span>{countLabel(group.pending.length, 'restart')} pending</span>{/if}
{#if group.status === 'unknown'}<Button size="sm" disabled={busy} onclick={() => act({ action: 'scan' })}>Retry version check</Button>
{:else if group.updatable.length}<Button size="sm" disabled={busy || data.policies.paused} onclick={() => act({ action: 'update', servers: group.updatable })}>Update {group.updatable.length === 1 ? 'server' : `${group.updatable.length} servers`} on restart</Button>{/if}
</div>
@@ -226,7 +228,7 @@
<li><div class="row-heading"><a href={contextHref(`/servers/${encodeURIComponent(version.server)}`, $page.url)}><ServerIdentity name={version.server} /></a>
{#if pending}<span class="pending-job"><StatusBadge state={pending.state} />{#if pending.provisioning_id}<a href={contextHref(`/provisioning/${encodeURIComponent(pending.provisioning_id)}`, $page.url)}>View restart</a>{:else}<a href="#activity">View pending restart</a>{/if}</span>{/if}
</div>
{#if version.checked_at}<small>Last checked <time datetime={version.checked_at}>{$timeFormat.dateTime(version.checked_at)}</time></small>{/if}
{#if version.checked_at}<small>Last checked <RelativeTime value={version.checked_at} /></small>{/if}
{#if version.error}<pre class="version-error">{version.error}</pre>{/if}
</li>
{/each}</ul>
@@ -242,7 +244,7 @@
<article class="card" id={item.key}>
<div class="row-heading"><h3>{item.title} · <HistoricalServerIdentity name={item.server} deletedAt={item.server_deleted_at} /></h3><Badge variant={item.state === 'attention' ? 'danger' : item.state === 'active' ? 'info' : 'neutral'}>{item.label}</Badge></div>
{#if item.detail}<p>{item.detail}</p>{/if}
<p class="muted">{$timeFormat.dateTime(item.at)}{item.job?.target_revision ? ` · Expected r${item.job.target_revision} or newer` : ''}</p>
<p class="muted"><RelativeTime value={item.at} />{item.job?.target_revision ? ` · Expected r${item.job.target_revision} or newer` : ''}</p>
<div class="links">{#if item.href}<a href={contextHref(item.href, $page.url)}>{item.job ? 'View restart operation' : 'View evidence and recovery'}</a>{/if}
{#if item.job?.state === 'queued' && !item.server_deleted_at}<Button size="sm" disabled={busy} onclick={() => act({ action: 'cancel', id: item.job!.id })}>Cancel restart</Button>{/if}
{#if item.job?.state === 'failed' && !item.server_deleted_at && item.job.operation_state === 'done'}<Button size="sm" disabled={busy || data.policies.paused} onclick={() => act({ action: 'retry', id: item.job!.id })}>Retry after inspection</Button>{/if}
+4 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { contextHref } from '$lib/navigation-context.js';
import { onMount, untrack } from 'svelte';
import { page } from '$app/stores';
@@ -133,7 +134,7 @@
<div class="row-heading"><h3>{action.action === 'restart' ? 'Restart server' : 'Rebuild server'}</h3>{#if action.operation_state}<StatusBadge state={action.operation_state} />{/if}</div>
{#if action.operation_stage}<p>{action.operation_stage}</p>{/if}
{#if action.operation_error}<p class="error">{action.operation_error}</p>{/if}
<time datetime={action.created_at}>{$timeFormat.dateTime(action.created_at)}</time>
<span class="time"><RelativeTime value={action.created_at} /></span>
<div class="links"><a href={contextHref(`/provisioning/${encodeURIComponent(action.operation_id)}`, $page.url)}>View provisioning</a>{#if action.campaign_id}<a href={contextHref(`/campaigns/${encodeURIComponent(action.campaign_id)}`, $page.url)}>View repair campaign</a>{/if}</div>
</article>
{/each}
@@ -165,7 +166,7 @@
{#if !events.length}<EmptyState title="No activity recorded yet" />
{:else}<ol class="timeline">
{#each events.slice(0, eventLimit) 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><ListFooter total={events.length} bind:limit={eventLimit} label="events" />{/if}
</section>
@@ -190,7 +191,7 @@
.links, .row-heading a { font-size: var(--text-sm); }
a { color: var(--accent-primary-hover); }
dl { display: flex; gap: 16px 32px; flex-wrap: wrap; margin: 0; }
dt, .muted, time, summary, .notice { color: var(--text-secondary); font-size: var(--text-xs); }
dt, .muted, .time, summary, .notice { color: var(--text-secondary); font-size: var(--text-xs); }
dd { margin: 6px 0 0; font-size: var(--text-sm); overflow-wrap: anywhere; }
code { font-family: var(--font-mono); font-size: var(--text-xs); overflow-wrap: anywhere; }
dd code { display: block; margin-top: 8px; color: var(--text-secondary); }
+4 -4
View File
@@ -1,10 +1,10 @@
<script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import type { MatchDetail } from '$lib/match-history.js';
import { playerProfileHref } from '$lib/leaderboard.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { formatDuration } from '$ui/duration.js';
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
@@ -48,8 +48,8 @@
<h2>Match details</h2>
<dl class="details">
<div class="server"><dt>Server</dt><dd><HistoricalServerIdentity name={match.server} deletedAt={match.server_deleted_at} href={`/servers/${encodeURIComponent(match.server)}?tab=matches`} /></dd></div>
<div><dt>{match.startObserved ? 'Started' : 'First observed'}</dt><dd><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time></dd></div>
<div><dt>Ended</dt><dd>{#if match.endedAt}<time datetime={match.endedAt}>{$timeFormat.dateTime(match.endedAt)}</time>{:else}—{/if}</dd></div>
<div><dt>{match.startObserved ? 'Started' : 'First observed'}</dt><dd><RelativeTime value={match.startedAt} /></dd></div>
<div><dt>Ended</dt><dd>{#if match.endedAt}<RelativeTime value={match.endedAt} />{:else}—{/if}</dd></div>
<div><dt>Players at start</dt><dd>{match.startPlayers ?? '—'}</dd></div>
</dl>
{#if !match.startObserved}<p class="note">The match start was not recorded. The first observation is shown above; the full game time is unknown.</p>{/if}
@@ -76,7 +76,7 @@
</th>
<td class="participation"><Badge variant={player.presentAtEnd ? 'online' : 'neutral'}>{player.presentAtEnd ? 'At match end' : 'Not at match end'}</Badge>{#if !player.hasStats}<small>No stats recorded</small>{/if}</td>
{#each stats as stat}<td class:round={stat.key === 'highestRound'}>{player[stat.key] === null ? '—' : number.format(player[stat.key]!)}</td>{/each}
<td><time datetime={player.lastSeenAt}>{$timeFormat.dateTime(player.lastSeenAt)}</time></td>
<td><RelativeTime value={player.lastSeenAt} /></td>
</tr>
{/each}
</tbody>
+49 -34
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import BackLink from '$ui/components/BackLink.svelte';
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte';
import { onMount, untrack } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { pollVisible } from '$ui/poll.js';
@@ -13,6 +13,8 @@
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
import HousePartyDialog from '$ui/components/HousePartyDialog.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import LifecycleDialog from '$ui/components/LifecycleDialog.svelte';
import { provisioningListStatuses, provisioningListSources, provisioningPageSize, provisioningPageOffset, type ProvisioningListRow as Deployment, type ProvisioningListPage } from '$lib/provisioning-list.js';
@@ -45,7 +47,10 @@
let expanded = $state<Record<string, boolean>>({});
let fleetActionsOpen = $state(false);
let now = $state(Date.now());
let historyUpdatedAt = $state<string | null>(null);
let updatedAt = $state<number | null>(null);
let historyUpdatedAt = $state<number | null>(null);
let previousHistoryQuery = '';
let historyResults = $state<HTMLDivElement | null>(null);
let cancelling = $state(false);
let cancellationNotice = $state('');
$effect(() => {
@@ -84,7 +89,7 @@
const tabs = [{id:'active',label:'Active'},{id:'history',label:'History'}];
let action = $state('all');
let search = $state('');
let refreshing = false;
let refreshing = $state(false);
async function load(signal?: AbortSignal) {
if (refreshing) return;
refreshing = true;
@@ -92,40 +97,49 @@
const res = await fetch('/api/provisioning?view=active', { signal, cache: 'no-store' });
if (!res.ok) throw new Error('Could not load provisioning activity.');
const result: ProvisioningListPage = await res.json();
active = result.deployments; now = Date.now();
if (signal?.aborted) return;
active = result.deployments; now = Date.now(); updatedAt = now;
needsReview = result.needsReview;
error = '';
} catch (err) { if (!signal?.aborted) error = (err as Error).message; return false; }
finally { loading = false; refreshing = false; }
}
// History is fetched on navigation/filter changes or explicit refresh, never by the active poll.
// History has its own visible-page poll; filter changes discard incompatible
// rows, while refreshes keep the current page available until replacement.
$effect(() => {
if (tab !== 'history') return;
const params = new URLSearchParams({ view: 'history', action, status: historyState, source,
server: search, limit: String(limit), offset: String(offset) });
void historyRefresh;
const controller = new AbortController();
historyLoading = true;
historyError = '';
history = [];
historyTotal = 0;
void (async () => {
try {
const response = await fetch(`/api/provisioning?${params}`, { signal: controller.signal, cache: 'no-store' });
if (!response.ok) throw new Error('Could not load provisioning history.');
const result: ProvisioningListPage = await response.json();
if (!controller.signal.aborted) { history = result.deployments; historyTotal = result.total; historyUpdatedAt = new Date().toISOString(); now = Date.now(); }
} catch (err) { if (!controller.signal.aborted) historyError = (err as Error).message; }
finally { if (!controller.signal.aborted) historyLoading = false; }
})();
return () => controller.abort();
return untrack(() => {
if (previousHistoryQuery !== params.toString()) {
history = []; historyTotal = 0; historyUpdatedAt = null; historyError = '';
previousHistoryQuery = params.toString();
}
let disposed = false;
let first = true;
const stop = pollVisible(async signal => {
// A new record can shift this page. Keep keyboard focus stable
// until it leaves the results; an explicit Retry still runs.
if (!first && historyResults?.contains(document.activeElement)) return;
first = false;
historyLoading = true;
try {
const response = await fetch(`/api/provisioning?${params}`, { signal, cache: 'no-store' });
if (!response.ok) throw new Error('Could not load provisioning history.');
const result: ProvisioningListPage = await response.json();
if (!signal.aborted) {
history = result.deployments; historyTotal = result.total;
historyUpdatedAt = Date.now(); now = Date.now(); historyError = '';
}
} catch (err) { if (!signal.aborted) historyError = (err as Error).message; return false; }
finally { if (!disposed) historyLoading = false; }
}, 15000);
return () => { disposed = true; stop(); };
});
});
onMount(() => { const stop = pollVisible(load, 5000); const clock = setInterval(() => now = Date.now(), 30000); return () => { stop(); clearInterval(clock); }; });
function fmt(value: string) { return $timeFormat.dateTime(value); }
function age(value: string) {
const minutes = Math.max(0, Math.floor((now - Date.parse(value)) / 60000));
return minutes < 1 ? 'Just now' : minutes < 60 ? `${minutes} min ago` : minutes < 1440 ? `${Math.floor(minutes / 60)} hr ago` : `${Math.floor(minutes / 1440)}d ago`;
}
const filtered = $derived(action !== 'all' || source !== 'all' || historyState !== 'all' || Boolean(search));
const visibleActive = $derived(active.filter(row => {
if (action !== 'all' && row.action !== action) return false;
@@ -173,18 +187,18 @@
</div>
<LifecycleDialog bind:open={lifecycleOpen} />
<DeployServerDialog bind:open />
<HousePartyDialog bind:open={housePartyOpen} onqueued={count => { housePartyNotice = `Destruction queued for ${count} server${count === 1 ? '' : 's'}.`; tab = 'active'; clearFilters(); void load(); }} />
<HousePartyDialog bind:open={housePartyOpen} onqueued={count => { housePartyNotice = `Removal queued for ${count} server${count === 1 ? '' : 's'}.`; tab = 'active'; clearFilters(); void load(); }} />
{#if housePartyNotice}<p role="status">{housePartyNotice}</p>{/if}
{#if cancellationNotice}<p role="status">{cancellationNotice}</p>{/if}
<div id="deployment-panel" role="tabpanel" aria-labelledby={"deployment-tabs-" + tab} class="panels">
{#if error}<p role="alert" class="error">{error} <button onclick={() => void load()}>Retry</button></p>{/if}
{#if error && tab === 'active'}<p role="alert" class="error">{error} {#if updatedAt}Showing the last loaded operations.{/if}</p>{/if}
<div class="queue-heading">
{#if tab === 'active'}<p class="queue-summary"><strong>{filtered ? `${visibleActive.length} of ${active.length}` : active.length} active</strong>{#if queueSummary}<span> · {queueSummary}</span>{/if}</p>
{:else}<h2>History <span>{historyTotal}</span></h2>{/if}
<div class="queue-tools">
{#if needsReview}<Button size="sm" variant="ghost" onclick={reviewAttention}>Needs attention ({needsReview})</Button>{/if}
{#if tab === 'active'}<span class="muted">{error ? 'Updates interrupted' : 'Updates every 5s'}</span>
{:else}<span class="muted">{historyUpdatedAt ? `Updated ${age(historyUpdatedAt).toLowerCase()}` : ''}</span><Button size="sm" variant="ghost" disabled={historyLoading} onclick={() => historyRefresh++}>Refresh</Button>{/if}
{#if tab === 'active'}<Freshness {updatedAt} error={error ? 'Operation updates interrupted.' : ''} intervalLabel="Updates every 5 seconds" onRetry={() => void load()} retryDisabled={refreshing} />
{:else}<Freshness updatedAt={historyUpdatedAt} error={historyError ? 'History updates interrupted.' : ''} intervalLabel="Updates every 15 seconds" onRetry={() => historyRefresh++} retryDisabled={historyLoading} />{/if}
</div>
</div>
{#if tab === 'active'}
@@ -192,14 +206,16 @@
{:else if !visibleActive.length}<EmptyState title={filtered ? 'No matching active operations' : 'No active operations'} description={filtered ? 'Try another search or clear the filters.' : undefined} />
{:else}{@render operationTable(visibleActive)}{/if}
{:else}
{#if historyError}<p role="alert" class="error">{historyError} <button onclick={() => historyRefresh++}>Retry</button></p>{/if}
{#if historyLoading}<p class="muted">Loading provisioning history…</p>
<div class="history-results" bind:this={historyResults}>
{#if historyError}<p role="alert" class="error">{historyError} {#if historyUpdatedAt}Showing the last loaded history.{/if}</p>{/if}
{#if historyLoading && !historyUpdatedAt}<p class="muted">Loading provisioning history…</p>
{:else if !historyError && !history.length}<EmptyState title="No matching operations" description="Try another search or clear the filters." />
{:else}{@render operationTable(history)}{/if}
<nav class="pagination" aria-label="Provisioning history pages">
<span role="status">{history.length ? `${offset + 1}–${offset + history.length}` : '0'} of {historyTotal} operations</span>
{#if historyTotal > limit || offset > 0}<div class="queue-tools"><Button size="sm" disabled={historyLoading || offset === 0} onclick={() => { offset = Math.max(0, offset - limit); saveContext(); }}>Previous</Button><Button size="sm" disabled={historyLoading || offset + limit >= historyTotal} onclick={() => { offset += limit; saveContext(); }}>Next</Button></div>{/if}
</nav>
</div>
{/if}
</div>
</div>
@@ -217,7 +233,7 @@
<td><a href={operationHref}>{actionLabels[row.action]}</a></td>
<td><Badge variant={status.tone} size="md">{status.label}</Badge>{#if ['queued','waiting'].includes(status.group) && row.phaseStartedAt && now - Date.parse(row.phaseStartedAt) >= 600000}<small>In this stage {elapsedTime(row.phaseStartedAt, now)}</small>{/if}</td>
<td class="source-cell">{#if primarySource?.href}<a href={contextHref(primarySource.href, $page.url)}>{primarySource.label}</a>{:else}<span>{primarySource?.label ?? (row.triggered_by === 'ui' ? 'Manual' : 'System')}</span>{/if}{#if (row.sources?.length ?? 0) > 1}<small>+{row.sources!.length - 1} more</small>{/if}</td>
<td><button type="button" class="timestamp" aria-label={`Started ${fmt(row.started_at)}`}><time datetime={row.started_at}>{age(row.started_at)}</time><span class="exact-time" aria-hidden="true">{fmt(row.started_at)}</span></button></td>
<td><RelativeTime value={row.started_at} /></td>
<td class="align-right"><button class="text-button" aria-expanded={isExpanded} aria-controls={`operation-${row.id}`} aria-label={`${isExpanded ? 'Hide' : 'Show'} ${actionLabels[row.action].toLowerCase()} details for ${serverTitle(row.server)}`} onclick={() => expanded = { ...expanded, [row.id]: !isExpanded }}>{isExpanded ? 'Hide' : 'Details'} <span aria-hidden="true">{isExpanded ? '−' : '+'}</span></button></td>
</tr>
<tr hidden={!isExpanded} id={`operation-${row.id}`} class="detail-row"><td colspan="6">
@@ -234,7 +250,7 @@
{/snippet}
<style>
.page,.panels { display:grid; gap:24px; min-width:0; }
.page,.panels,.history-results { display:grid; gap:24px; min-width:0; }
.toolbar { display:flex; align-items:flex-end; flex-wrap:wrap; gap:12px; }.view-tabs { max-width:100%; }
label { display:grid; gap:6px; font-size:var(--text-xs); color:var(--text-secondary); }.search { flex:1; min-width:220px; }
input,select { padding:10px 12px; min-height:40px; background:var(--surface-sunken); color:var(--text-primary); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; font-size:var(--text-sm); }
@@ -246,8 +262,7 @@
td { padding:16px 12px; border-bottom:1px solid var(--border-subtle); vertical-align:middle; }tbody tr:hover { background:var(--surface); }
a { color:var(--accent-primary-hover); }.server-cell { min-width:160px; font-weight:var(--weight-semibold); }.source-cell { max-width:240px; overflow-wrap:anywhere; }
small { display:block; margin-top:6px; color:var(--text-tertiary); font-size:var(--text-xs); }.align-right { text-align:right; }
.text-button,.timestamp { border:0; background:transparent; color:var(--text-secondary); font:inherit; padding:6px 0; cursor:pointer; white-space:nowrap; }
.timestamp { position:relative; cursor:help; }.exact-time { display:none; position:absolute; right:0; bottom:100%; padding:6px 10px; z-index:1; background:var(--surface-overlay); border:1px solid var(--border-strong); border-radius:var(--radius-sm); color:var(--text-primary); font-size:var(--text-xs); }.timestamp:hover .exact-time,.timestamp:focus .exact-time { display:block; }
.text-button { border:0; background:transparent; color:var(--text-secondary); font:inherit; padding:6px 0; cursor:pointer; white-space:nowrap; }
.detail-row { background:var(--surface); }.operation-details { display:grid; gap:16px; line-height:1.6; overflow-wrap:anywhere; }
dl { display:flex; flex-wrap:wrap; gap:12px 32px; margin:0; }dt { color:var(--text-tertiary); font-size:var(--text-xs); }dd { margin:0; }.mono { font-family:var(--font-mono); }
.detail-links { display:flex; align-items:center; flex-wrap:wrap; gap:16px; }.error { color:var(--state-danger); }.warning { color:var(--state-warning); }
+19 -20
View File
@@ -5,7 +5,6 @@
import type { PageData } from './$types';
import type { ProvisioningDetail } from '$lib/provisioning-detail.js';
import { actionLabels } from '$lib/provisioning.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { pollVisible } from '$ui/poll.js';
import { serverTitle } from '$ui/server-labels.js';
import { createProvisioningRefresh, type ProvisioningRefreshState } from '$ui/provisioning-refresh.js';
@@ -22,6 +21,8 @@
import ProvisioningActivity from '$ui/components/ProvisioningActivity.svelte';
import LaunchRecipe from '$ui/components/LaunchRecipe.svelte';
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte';
let { data }: { data: PageData } = $props();
const initial = untrack(() => data.detail);
@@ -58,7 +59,6 @@
});
onMount(() => pollVisible(() => { now = Date.now(); return controller.refresh(); }, 5000, false));
onDestroy(() => controller.dispose());
function fmt(value: string | null | undefined) { return value ? $timeFormat.dateTime(value) : 'Not recorded'; }
async function overrideSafety() {
if (!deployment || !canOverride || view.busy || detail?.safetyOverrideUnavailableReason) return;
const id = deployment.id;
@@ -95,17 +95,18 @@
<div class="identity-links"><a href={returnTo.href}>← {returnTo.label}</a><Button size="sm" variant="ghost" onclick={copyLink}>Copy link</Button>{#if copyStatus}<span role="status">{copyStatus}</span>{/if}</div>
</div>
{#if view.loadError}<div class="refresh-error" role="alert"><div><strong>Refresh unavailable</strong><p>{view.loadError} {detail ? 'Showing the last successfully loaded record.' : ''}</p></div><Button size="sm" disabled={view.refreshing || !!view.busy} onclick={() => controller.forceRefresh()}>Retry refresh</Button></div>{/if}
<Freshness updatedAt={view.refreshedAt ? Date.parse(view.refreshedAt) : null} error={view.loadError ? 'Operation updates interrupted.' : ''} intervalLabel="Updates every 5 seconds" onRetry={() => void controller.forceRefresh()} retryDisabled={view.refreshing || !!view.busy} />
{#if view.loadError}<div class="refresh-error" role="alert"><div><strong>Updates unavailable</strong><p>{view.loadError} {detail ? 'Showing the last successfully loaded record.' : ''}</p></div></div>{/if}
{#if deployment && detail && outcome}
<section class="outcome" class:attention={['failed', 'interrupted', 'warning'].includes(deployment.state)} aria-labelledby="outcome-title">
<div class="outcome-top"><div class="badges"><StatusBadge state={deployment.state} />{#if deployment.server_deleted_at}<Badge>Deleted identity</Badge>{/if}{#if detail.progress?.recovery?.state === 'held'}<Badge variant="warning">Recovery capacity held</Badge>{/if}</div><span class="record-label">Operation outcome</span></div>
<div class="outcome-message" aria-live="polite" aria-atomic="true"><h2 id="outcome-title">{outcome.headline}</h2><p>{outcome.description}</p></div>
{#if deployment.error}<div class="failure" role="alert"><strong>Recorded error</strong><p>{deployment.error}</p></div>{/if}
{#if detail.activity.failureExcerpt && detail.activity.failureExcerpt.message !== deployment.error && ['failed', 'interrupted'].includes(deployment.state)}<p class="failure-excerpt"><span>Last failure event · {fmt(detail.activity.failureExcerpt.created_at)}</span>{detail.activity.failureExcerpt.message}</p>{/if}
{#if detail.activity.failureExcerpt && detail.activity.failureExcerpt.message !== deployment.error && ['failed', 'interrupted'].includes(deployment.state)}<p class="failure-excerpt"><span>Last failure event · <RelativeTime value={detail.activity.failureExcerpt.created_at} /></span>{detail.activity.failureExcerpt.message}</p>{/if}
{#if detail.warningMessages.length}<div class="warnings"><h3>Recorded warnings</h3><ul>{#each detail.warningMessages as warning}<li>{warning}</li>{/each}</ul></div>{/if}
{#if detail.safetyOverride}<div class="warnings"><h3>Admin override enabled</h3><p>Player safety checks bypassed for this operation. Confirmed by {detail.safetyOverride.actorName} · {fmt(detail.safetyOverride.confirmedAt)}.</p></div>{/if}
{#if detail.safetyOverride}<div class="warnings"><h3>Admin override enabled</h3><p>Player safety checks bypassed for this operation. Confirmed by {detail.safetyOverride.actorName} · <RelativeTime value={detail.safetyOverride.confirmedAt} />.</p></div>{/if}
{#if detail.canResumeVerification || detail.canResumeCleanup || detail.canResumeRebuild || detail.canCancel || detail.canCancelCreation || canOverride}
<div class="recovery-actions">
{#if canOverride}<div><Button variant="danger" disabled={!!view.busy || !!detail.safetyOverrideUnavailableReason} loading={view.busy === 'override'} onclick={overrideSafety}>Admin override…</Button><p class="action-help">{detail.safetyOverrideUnavailableReason ?? 'Confirm to bypass player safety checks for this operation.'}</p></div>{/if}
@@ -120,14 +121,13 @@
{#if view.actionError}<div class="action-error" role="alert"><strong>Request not confirmed</strong><p>{view.actionError}</p></div>{/if}
{#if view.notice}<p class="notice" role="status">{view.notice}</p>{/if}
{#if detail.progress?.blocker?.href}<a class="dependency" href={detail.progress.blocker.href}>{detail.progress.blocker.label ?? 'View blocking work'} →</a>{/if}
{#if detail.progress?.recovery}<div class="recovery-note"><strong>{detail.progress.recovery.state === 'held' ? 'Recovery remains in progress' : detail.progress.recovery.state === 'released' ? 'Recovery capacity released' : detail.progress.recovery.state === 'transferred' ? 'Recovery transferred' : 'Recovery state not confirmed'}</strong><p>{detail.progress.recovery.message}</p><span>Recorded {fmt(detail.progress.recovery.at)}</span>{#if detail.progress.recovery.operationId && detail.progress.recovery.operationId !== deployment.id}<a href={`/provisioning/${encodeURIComponent(detail.progress.recovery.operationId)}`}>View recovery operation →</a>{/if}</div>{/if}
{#if detail.progress?.recovery}<div class="recovery-note"><strong>{detail.progress.recovery.state === 'held' ? 'Recovery remains in progress' : detail.progress.recovery.state === 'released' ? 'Recovery capacity released' : detail.progress.recovery.state === 'transferred' ? 'Recovery transferred' : 'Recovery state not confirmed'}</strong><p>{detail.progress.recovery.message}</p><span>Recorded <RelativeTime value={detail.progress.recovery.at} missing="Not recorded" /></span>{#if detail.progress.recovery.operationId && detail.progress.recovery.operationId !== deployment.id}<a href={`/provisioning/${encodeURIComponent(detail.progress.recovery.operationId)}`}>View recovery operation →</a>{/if}</div>{/if}
{#if serverHref && !['done', 'cancelled'].includes(deployment.state)}<div class="diagnostics"><a href={`${serverHref}?tab=logs`}>Open server logs</a><a href={`${serverHref}?tab=diagnostics`}>Diagnose connection</a>{#if detail.progress?.phase === 'verify-map' || detail.progress?.checks.some(check => check.key === 'map' && check.result !== 'pass')}<a href={`${serverHref}?tab=config`}>Review map configuration</a>{/if}</div>{/if}
<div class="timing">
<div><span>{outcome.active ? 'Total elapsed' : 'Recorded duration'}</span><strong>{elapsedTime(deployment.started_at, deployment.finished_at ? Date.parse(deployment.finished_at) : now)}</strong></div>
{#if outcome.active}<div><span>Time in phase</span><strong>{elapsedTime(detail.progress?.phaseStartedAt, now)}</strong></div>{/if}
<div><span>Last evaluated</span><time datetime={detail.progress?.observedAt ?? undefined}>{fmt(detail.progress?.observedAt)}</time></div>
<div><span>Last evaluated</span><RelativeTime value={detail.progress?.observedAt} missing="Not recorded" /></div>
</div>
<div class="freshness"><span>Page updated <time datetime={view.refreshedAt ?? undefined}>{fmt(view.refreshedAt)}</time> · updates automatically</span><Button size="sm" variant="ghost" disabled={view.refreshing || !!view.busy} loading={view.refreshing} onclick={() => controller.forceRefresh()}>Refresh</Button></div>
</section>
{#key deployment.id}
@@ -139,8 +139,8 @@
<h2>Host agent execution</h2>
<dl class="metadata">
<div><dt>Agent state</dt><dd>{detail.agentOperation.state}</dd></div>
<div><dt>Command queued</dt><dd>{fmt(detail.agentOperation.createdAt)}</dd></div>
<div><dt>Latest agent report</dt><dd>{fmt(detail.agentOperation.updatedAt)}</dd></div>
<div><dt>Command queued</dt><dd><RelativeTime value={detail.agentOperation.createdAt} missing="Not recorded" /></dd></div>
<div><dt>Latest agent report</dt><dd><RelativeTime value={detail.agentOperation.updatedAt} missing="Not recorded" /></dd></div>
<div><dt>Command ID</dt><dd><code>{detail.agentOperation.id}</code></dd></div>
</dl>
{#if detail.agentOperation.error}<p>{detail.agentOperation.error}</p>{/if}
@@ -160,10 +160,10 @@
<Card as="section" padding="md" ariaLabel="Operation details">
<div class="context-section"><h2>Operation details</h2><dl class="metadata">
<div><dt>Requested by</dt><dd>{deployment.triggered_by}</dd></div>
<div><dt>Requested at</dt><dd><time datetime={deployment.started_at}>{fmt(deployment.started_at)}</time></dd></div>
<div><dt>Submission attempted</dt><dd>{fmt(deployment.submitted_at)}</dd></div>
<div><dt>Finished</dt><dd>{deployment.finished_at ? fmt(deployment.finished_at) : outcome.active ? 'In progress' : 'Not recorded'}</dd></div>
{#if deployment.server_deleted_at}<div><dt>Identity deleted</dt><dd>{fmt(deployment.server_deleted_at)}</dd></div>{/if}
<div><dt>Requested at</dt><dd><RelativeTime value={deployment.started_at} /></dd></div>
<div><dt>Submission attempted</dt><dd><RelativeTime value={deployment.submitted_at} missing="Not recorded" /></dd></div>
<div><dt>Finished</dt><dd><RelativeTime value={deployment.finished_at} missing={outcome.active ? 'In progress' : 'Not recorded'} /></dd></div>
{#if deployment.server_deleted_at}<div><dt>Identity deleted</dt><dd><RelativeTime value={deployment.server_deleted_at} /></dd></div>{/if}
</dl></div>
</Card>
{#if detail.sources.length || detail.collection}<Card as="section" padding="md" ariaLabel="Related work"><div class="context-section"><h2>Related work</h2><p>The requesting work may have further checks after this operation finishes.</p><ul class="related">
@@ -175,13 +175,13 @@
{#if pool.ownsSlot}<p>This operation holds a slot.</p>{/if}
{#if pool.requestOrder !== null}<p>Request order: {pool.requestOrder}. Eligible requests may pass blocked work; this is not a guaranteed start order.</p>{/if}
{#if pool.holders.length}<details><summary>Current slot holders</summary><ul>{#each pool.holders as holder}<li>{#if holder.id}<a href={`/provisioning/${encodeURIComponent(holder.id)}`}>{serverTitle(holder.server)}</a>{:else}<span>{serverTitle(holder.server)} · operation not recorded</span>{/if}</li>{/each}</ul></details>{/if}
</div>{/each}<p class="small">Capacity snapshot {fmt(detail.capacity.evaluatedAt)}</p>
</div>{/each}<p class="small">Capacity snapshot <RelativeTime value={detail.capacity.evaluatedAt} missing="Not recorded" /></p>
</div></Card>{/if}
<Card as="section" padding="md" ariaLabel="Recorded launch inputs"><div class="context-section recipe"><h2>{recipeHeading}</h2>
{#if detail.recipe}
{#if detail.blueprintName}<p>From {detail.blueprintName}</p>{/if}
<p>{recipeStatus === 'queued' ? 'This capture does not pin versions. The latest promoted setup is selected again when creation begins.' : recipeStatus === 'attempted' ? 'These files were selected when creation began. Selection alone does not confirm successful installation.' : 'This is the saved recipe for this operation. The point at which it was selected was not recorded.'}</p>
<p class="small">Captured <time datetime={detail.recipe.capturedAt}>{fmt(detail.recipe.capturedAt)}</time></p>
<p class="small">Captured <RelativeTime value={detail.recipe.capturedAt} /></p>
<LaunchRecipe recipe={detail.recipe} historical selectionStatus={recipeStatus} />
<div class="launch-another"><Button variant="ghost" onclick={() => launchOpen = true}>Launch another server →</Button><p>Creates a separate server with a fresh ID and port using the latest promoted files. It does not repair this instance.</p></div>
{:else}<p>{['create', 'rebuild'].includes(deployment.action) ? 'No launch recipe was recorded for this operation.' : 'This operation uses the existing instance. No new launch files were selected.'}</p>{/if}
@@ -200,7 +200,7 @@
h2, h3, p { margin: 0; }a { color: var(--accent-primary-hover); text-underline-offset: 3px; }
a:focus-visible, summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; border-radius: 2px; }
p { line-height: 1.65; overflow-wrap: anywhere; }
.identity-row, .operation-id, .identity-links, .badges, .outcome-top, .diagnostics, .freshness, .recovery-actions { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.identity-row, .operation-id, .identity-links, .badges, .outcome-top, .diagnostics, .recovery-actions { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.identity-row { justify-content: space-between; margin-top: calc(-1 * var(--space-3)); gap: var(--space-2) var(--space-5); }.operation-id { gap: var(--space-2); min-width: 0; }.operation-id > span, .identity-links { font-size: var(--text-xs); color: var(--text-tertiary); }.identity-links { gap: var(--space-4); }
.outcome { display: grid; gap: var(--space-5); padding: var(--space-6); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); }
.outcome.attention { border-color: color-mix(in srgb, var(--state-warning) 35%, var(--border-strong)); }.outcome-top { justify-content: space-between; }.record-label { color: var(--text-tertiary); font-size: var(--text-xs); }.outcome h2 { font-size: var(--text-xl); line-height: 1.35; letter-spacing: var(--tracking-tight); overflow-wrap: anywhere; }
@@ -210,8 +210,7 @@
.warnings { border-left: 3px solid var(--state-warning); padding-left: var(--space-4); font-size: var(--text-sm); }.warnings h3 { font-size: var(--text-sm); color: var(--state-warning); }.warnings ul { margin: var(--space-2) 0 0; padding-left: var(--space-4); line-height: 1.6; }.warnings li { overflow-wrap: anywhere; }
.notice { color: var(--state-online); font-size: var(--text-sm); }.action-help { color: var(--text-secondary); font-size: var(--text-xs); margin-top: var(--space-2); }.dependency, .diagnostics { font-size: var(--text-sm); }.diagnostics { gap: var(--space-3) var(--space-5); }
.recovery-note { display: grid; gap: var(--space-2); padding: var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); font-size: var(--text-sm); }.recovery-note p, .recovery-note span { color: var(--text-secondary); }.recovery-note span { font-size: var(--text-xs); }
.timing { border-top: 1px solid var(--border-subtle); padding-top: var(--space-5); display: flex; flex-wrap: wrap; gap: var(--space-4) var(--space-8); }.timing > div { display: grid; gap: var(--space-2); }.timing span { font-size: var(--text-xs); color: var(--text-tertiary); }.timing strong { font-size: var(--text-md); font-variant-numeric: tabular-nums; font-weight: var(--weight-medium); }.timing time { font-size: var(--text-sm); color: var(--text-secondary); }
.freshness { justify-content: space-between; color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.65; margin-top: calc(-1 * var(--space-2)); }
.timing { border-top: 1px solid var(--border-subtle); padding-top: var(--space-5); display: flex; flex-wrap: wrap; gap: var(--space-4) var(--space-8); }.timing > div { display: grid; gap: var(--space-2); }.timing span { font-size: var(--text-xs); color: var(--text-tertiary); }.timing strong { font-size: var(--text-md); font-variant-numeric: tabular-nums; font-weight: var(--weight-medium); }.timing :global(time) { font-size: var(--text-sm); color: var(--text-secondary); }
.detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 340px); gap: var(--space-8); align-items: start; }.execution-column { display: grid; gap: var(--space-8); min-width: 0; }.context-column { display: grid; gap: var(--space-5); min-width: 0; }.context-section { display: grid; gap: var(--space-4); min-width: 0; }.context-section h2 { font-size: var(--text-md); }.context-section p { font-size: var(--text-sm); color: var(--text-secondary); }.context-section p.small { font-size: var(--text-xs); }
.metadata { display: grid; gap: var(--space-4); margin: 0; }.metadata dt { color: var(--text-tertiary); font-size: var(--text-xs); }.metadata dd { margin: var(--space-1) 0 0; font-size: var(--text-sm); line-height: 1.6; overflow-wrap: anywhere; }
.related, .capacity-pool ul { list-style: none; padding: 0; margin: 0; display: grid; gap: var(--space-4); }.related li { display: grid; gap: var(--space-1); font-size: var(--text-sm); overflow-wrap: anywhere; }.related span, .related code { color: var(--text-tertiary); font-size: var(--text-xs); }.related strong { font-weight: var(--weight-medium); }code { font-family: var(--font-mono); }
@@ -219,5 +218,5 @@
.launch-another { display: grid; gap: var(--space-2); justify-items: start; border-top: 1px solid var(--border-subtle); padding-top: var(--space-4); }.launch-another p { font-size: var(--text-xs); }
.loading { min-height: 240px; padding: var(--space-6); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); color: var(--text-secondary); }
@media (max-width: 1000px) { .detail-grid { grid-template-columns: minmax(0, 1fr); }.context-column { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 600px) { .page { gap: var(--space-5); }.outcome { padding: var(--space-4); gap: var(--space-4); }.outcome h2 { font-size: var(--text-lg); }.outcome-message p { font-size: var(--text-sm); }.context-column { grid-template-columns: minmax(0, 1fr); }.identity-row, .identity-links { align-items: flex-start; }.operation-id { display: block; }.operation-id > span { display: block; margin-bottom: var(--space-1); }.outcome-top { align-items: flex-start; }.record-label { display: none; }.timing { gap: var(--space-4); }.timing > div:last-child { flex-basis: 100%; }.freshness { align-items: flex-start; }.freshness > span { flex: 1 1 170px; }.detail-grid { gap: var(--space-6); } }
@media (max-width: 600px) { .page { gap: var(--space-5); }.outcome { padding: var(--space-4); gap: var(--space-4); }.outcome h2 { font-size: var(--text-lg); }.outcome-message p { font-size: var(--text-sm); }.context-column { grid-template-columns: minmax(0, 1fr); }.identity-row, .identity-links { align-items: flex-start; }.operation-id { display: block; }.operation-id > span { display: block; margin-bottom: var(--space-1); }.outcome-top { align-items: flex-start; }.record-label { display: none; }.timing { gap: var(--space-4); }.timing > div:last-child { flex-basis: 100%; }.detail-grid { gap: var(--space-6); } }
</style>
+1 -1
View File
@@ -101,7 +101,7 @@
<section class="library" aria-label="Script library">
{#if loading || scripts.length || error}<div class="library-toolbar">
<label class="search"><span class="sr-only">Search scripts</span><input type="search" bind:value={search} placeholder="Search names, files, or maps…" /></label>
<label><span class="sr-only">Filter by game</span><select bind:value={game} onchange={() => map = ''}><option value="">All games</option><option value="t5">Black Ops I · T5</option><option value="t6">Black Ops II · T6</option></select></label>
<label><span class="sr-only">Filter by game</span><select bind:value={game} onchange={() => map = ''}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
<label><span class="sr-only">Filter by map</span><select bind:value={map}><option value="">All maps</option>{#each maps as m}<option value={m}>{mapLabel(m)}</option>{/each}</select></label>
<label><span class="sr-only">Filter by status</span><select bind:value={status}>{#each tabs as tab}<option value={tab.value}>{tab.value === 'all' ? 'All statuses' : tab.label}</option>{/each}<option value="empty">No versions</option></select></label>
<label><span class="sr-only">Sort scripts</span><select bind:value={sort}><option value="attention">Needs attention first</option><option value="recent">Recently uploaded</option><option value="name">Name A–Z</option></select></label>
+3 -3
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 { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
@@ -112,7 +112,7 @@
<tr>
<td><strong>{key.game.toUpperCase()}</strong><span class="key-label">{key.label || 'Unlabeled key'}</span></td>
<td>{#if key.assigned_servers.length}<span>Assigned</span>{#each key.assigned_servers as name}<div class="assignment"><ServerIdentity {name} /><code>{name}</code></div>{/each}{:else}<span class="available">Available</span>{/if}</td>
<td>{$timeFormat.dateTime(key.updated_at)}</td>
<td><RelativeTime value={key.updated_at} /></td>
<td><Button ariaLabel={`Remove ${key.label || key.game.toUpperCase() + ' key'}`} disabled={busy || !!key.assigned_servers.length} onclick={() => remove(key)}>Remove</Button>{#if key.assigned_servers.length}<small>In use</small>{/if}</td>
</tr>
{/each}</tbody>
@@ -123,7 +123,7 @@
<Dialog bind:open title="Add key" busy={busy}>
{#if error}<p role="alert" class="error">{error}</p>{/if}
<form onsubmit={save}>
<label>Game<select bind:value={game} disabled={busy}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label>
<label>Game<select bind:value={game} disabled={busy}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
<Field label="Label (optional)" hint="Record the hostname you chose in Plutonium, or another name that helps identify this key.">
<Input bind:value={label} placeholder="e.g. Electric Cherry 5" disabled={busy} />
</Field>
+3 -3
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { tick } from 'svelte';
import ActivityLog from '$ui/components/ActivityLog.svelte';
import { timeFormat } from '$ui/stores/time-zone.js';
import { freshTelemetry } from '$lib/server-observation.js';
import { freshStatusTimestamp } from '$lib/server-freshness.js';
import { serverSection, serverSectionAnchor } from '$lib/server-sections.js';
@@ -257,7 +257,7 @@
{#if tabError}<div class="ui-error" role="alert">{tabError}<Button size="sm" onclick={() => loadTab(name, activeSection)}>Retry</Button></div>{/if}
{#if activeSection === 'live'}
<section id="scoreboard" aria-label="Scoreboard">
{#if !scoreboard || !fresh || !freshTelemetry(scoreboard, $uiNow, server.telemetry_after)}<p class="live-context">Players: {effectivePlayers ?? '—'} · {fleetIdentity(server).map}{#if !fresh} · Current count unavailable{/if}</p>{/if}
{#if !scoreboard || !fresh || !freshTelemetry(scoreboard, $uiNow, server.telemetry_after)}<p class="live-context">Players: {effectivePlayers ?? '—'} · {fleetIdentity(server).map}{!fresh ? ' · Current count unavailable' : ''}</p>{/if}
<Card padding="lg">
{#key name}<PlayerScoreboard matchElapsed={gameElapsed} activity={resetElapsed ? `Resetting · ${resetElapsed}` : activeElapsed ? `Players present for ${activeElapsed}` : inactiveElapsed ? `Empty for ${inactiveElapsed}` : undefined} current={fresh} after={server?.telemetry_after ?? null} report={scoreboard} mapLabel={scoreboard ? fleetIdentity({ ...server, last_map: scoreboard.map }).map : undefined} />{/key}
</Card>
@@ -285,7 +285,7 @@
{:else}
<div class="table-scroll"><table class="detail-table"><thead><tr><th>File</th><th>Type</th><th>Recorded version</th><th>Details</th></tr></thead><tbody>
{#each inventory.slice(0, inventoryLimit) as f}
<tr><td>{#if f.artifactId}<a href={contextHref(`/${f.kind === 'plugin' ? 'plugins' : f.kind === 'config' || f.kind === 'baseline' ? 'configs' : 'scripts'}/${f.artifactId}`, $page.url)}>{f.relPath}</a>{:else}{f.relPath}{/if}</td><td>{f.kind ?? 'file'}</td><td>{f.releaseVersion ?? (f.version ? `v${f.version}` : '—')}</td><td><details><summary><span class="sr-only">{f.relPath} </span>Details</summary><dl class="file-details"><dt>Size</dt><dd>{new Intl.NumberFormat().format(f.size)} B</dd><dt>SHA-256</dt><dd><code>{f.sha256}</code></dd><dt>Recorded</dt><dd>{$timeFormat.dateTime(f.updatedAt)}</dd><dt>Source</dt><dd>{#if f.source?.startsWith('promote:') || f.source?.startsWith('rollback:')}<a href={contextHref(`/campaigns/${f.source.slice(f.source.indexOf(':') + 1)}`, $page.url)}>View campaign</a>{:else}{f.source === 'native-create' ? 'Instance creation' : f.source ?? 'Unknown'}{/if}</dd></dl></details></td></tr>
<tr><td>{#if f.artifactId}<a href={contextHref(`/${f.kind === 'plugin' ? 'plugins' : f.kind === 'config' || f.kind === 'baseline' ? 'configs' : 'scripts'}/${f.artifactId}`, $page.url)}>{f.relPath}</a>{:else}{f.relPath}{/if}</td><td>{f.kind ?? 'file'}</td><td>{f.releaseVersion ?? (f.version ? `v${f.version}` : '—')}</td><td><details><summary><span class="sr-only">{f.relPath} </span>Details</summary><dl class="file-details"><dt>Size</dt><dd>{new Intl.NumberFormat().format(f.size)} B</dd><dt>SHA-256</dt><dd><code>{f.sha256}</code></dd><dt>Recorded</dt><dd><RelativeTime value={f.updatedAt} /></dd><dt>Source</dt><dd>{#if f.source?.startsWith('promote:') || f.source?.startsWith('rollback:')}<a href={contextHref(`/campaigns/${f.source.slice(f.source.indexOf(':') + 1)}`, $page.url)}>View campaign</a>{:else}{f.source === 'native-create' ? 'Instance creation' : f.source ?? 'Unknown'}{/if}</dd></dl></details></td></tr>
{/each}
</tbody></table></div>
{/if}
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { invalidateAll } from '$app/navigation';
@@ -100,7 +101,7 @@
<div class="user-list" aria-label="User accounts">{#each users as user (user.id)}<article class="user-row">
<div class="user-name"><h2>{user.displayName} {#if user.id === $page.data.user?.id}<span class="you">You</span>{/if}</h2><p>@{user.username}</p></div>
<div class="access"><span class="role">{user.role === 'admin' ? 'Administrator' : user.role}</span><span class:disabled={user.disabled}>{user.disabled ? 'Disabled' : 'Enabled'}</span>{#if user.mustChangePassword && !user.disabled}<small>Password change required</small>{/if}</div>
<div class="last-login"><span>Last sign-in</span><p>{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : 'Never'}</p></div>
<div class="last-login"><span>Last sign-in</span><p><RelativeTime value={user.lastLoginAt} missing={user.id === $page.data.user?.id ? 'Signed in · time not recorded' : 'Not recorded'} /></p></div>
<div class="actions"><Button size="sm" disabled={busy} onclick={() => openEdit(user)}>Edit access</Button><Button size="sm" variant="ghost" disabled={busy} onclick={() => revoke(user)}>Sign out sessions</Button></div>
</article>{/each}</div>
{/if}
@@ -110,7 +111,7 @@
{#if auditLoading && !events.length}<p class="muted" role="status">Loading account activity…</p>
{:else if !events.length}<p class="muted">No account events recorded.</p>
{:else}<div class="table-wrap"><table><thead><tr><th>Time</th><th>Event</th><th>By</th><th>Account</th><th>Details</th></tr></thead><tbody>
{#each events as event (event.id)}{@const details = auditDetails(event.details)}<tr><td class="event-time">{new Date(event.createdAt).toLocaleString()}</td><td>{actionLabels[event.action] ?? event.action}</td><td>{event.actor ? `@${event.actor}` : 'Unauthenticated'}</td><td>{event.targetId ? users.find(user => user.id === event.targetId)?.username ?? event.targetId : '—'}</td><td>{#if details}<details><summary>View details</summary><pre>{details}</pre></details>{:else}—{/if}</td></tr>{/each}
{#each events as event (event.id)}{@const details = auditDetails(event.details)}<tr><td class="event-time"><RelativeTime value={event.createdAt} /></td><td>{actionLabels[event.action] ?? event.action}</td><td>{event.actor ? `@${event.actor}` : 'Unauthenticated'}</td><td>{event.targetId ? users.find(user => user.id === event.targetId)?.username ?? event.targetId : '—'}</td><td>{#if details}<details><summary>View details</summary><pre>{details}</pre></details>{:else}—{/if}</td></tr>{/each}
</tbody></table></div>{/if}
</section>
{/if}
+1 -2
View File
@@ -32,13 +32,12 @@
<label for="setup-confirm">Confirm password<input id="setup-confirm" name="confirmPassword" type="password" required minlength="8" maxlength="128" autocomplete="new-password" bind:value={confirmPassword} /></label>
<Button type="submit" variant="primary" disabled={busy} loading={busy}>Create administrator</Button>
</fieldset></form>
<a class="login-link" href="/login">Already have an account? Sign in</a>
</section>
<style>
.auth-card { padding:32px; border:1px solid var(--border-strong); border-radius:var(--radius-lg); background:var(--surface); box-shadow:0 18px 50px #0002; }
form { margin-top:24px; } fieldset { display:grid; gap:16px; border:0; margin:0; padding:0; min-width:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; padding:12px; background:var(--surface-sunken); color:var(--text-primary); border:1px solid var(--border-control); border-radius:var(--radius-md); font:inherit; }
.help,.hint,.login-link { font-size:var(--text-sm); color:var(--text-secondary); line-height:1.6; } .help { margin:20px 0 0; } .hint { margin:-8px 0 0; font-size:var(--text-xs); } .login-link { display:block; margin-top:24px; color:var(--accent-primary-hover); }
.help,.hint { font-size:var(--text-sm); color:var(--text-secondary); line-height:1.6; } .help { margin:20px 0 0; } .hint { margin:-8px 0 0; font-size:var(--text-xs); }
.error { color:var(--state-danger); font-size:var(--text-sm); padding:12px; border:1px solid var(--state-danger); border-radius:var(--radius-md); overflow-wrap:anywhere; }
@media(max-width:540px) { .auth-card { padding:24px; } }
</style>
+1 -1
View File
@@ -170,7 +170,7 @@ describe('browser folder uploads', () => {
expect(next.version).not.toBe(first.version);
expect(next.libraryId).toBe(first.libraryId);
expect(otherGame.libraryId).not.toBe(first.libraryId);
expect(listLibraries().map(library => library.name)).toEqual(['Black Ops I (T5)', 'Black Ops II (T6)']);
expect(listLibraries().map(library => library.name)).toEqual(['Black Ops I', 'Black Ops II']);
});
it('follows completion order and preserves the latest successful files during failed or unfinished uploads', async () => {
+2 -2
View File
@@ -43,8 +43,8 @@ function legacyVersion(id: string, libraryId: string, createdAt: string, publish
describe('fixed game file slots', () => {
it('always lists exactly T5 and T6, including before any files are uploaded', () => {
expect(listLibraries()).toEqual([
{ id: 't5', game: 't5', name: 'Black Ops I (T5)', description: '', createdAt: '', latestVersionId: null, versions: [] },
{ id: 't6', game: 't6', name: 'Black Ops II (T6)', description: '', createdAt: '', latestVersionId: null, versions: [] }
{ id: 't5', game: 't5', name: 'Black Ops I', description: '', createdAt: '', latestVersionId: null, versions: [] },
{ id: 't6', game: 't6', name: 'Black Ops II', description: '', createdAt: '', latestVersionId: null, versions: [] }
]);
expect(latestPublishedLibraries()).toEqual([]);
expect(newestReadyLibrary('t5')).toBeNull();
+3 -3
View File
@@ -137,7 +137,7 @@ describe('automatic host preparation', () => {
hosts.updateHost(id, { mode: 'disabled' });
hosts.updateHost(id, { mode: 'active' });
const status = preparation.reconcileHostPreparation(id);
expect(status.items).toContainEqual(expect.objectContaining({ label: 'Black Ops II (T6)', state: 'blocked' }));
expect(status.items).toContainEqual(expect.objectContaining({ label: 'Black Ops II', state: 'blocked' }));
expect(hosts.getHost(id)?.mode).toBe('disabled');
});
@@ -510,8 +510,8 @@ describe('automatic host preparation', () => {
hosts.updateHost(id, { minimumFreeBytes: 1_000 });
const status = preparation.reconcileHostPreparation(id);
expect(status.items).toEqual(expect.arrayContaining([
expect.objectContaining({ label: 'Black Ops I (T5)', state: 'blocked' }),
expect.objectContaining({ label: 'Black Ops II (T6)', state: 'preparing' })
expect.objectContaining({ label: 'Black Ops I', state: 'blocked' }),
expect.objectContaining({ label: 'Black Ops II', state: 'preparing' })
]));
expect(operations(id)).toHaveLength(1);
expect(operations(id)[0].payload.libraryVersionId).toBe(small.id);
+21 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { get } from 'svelte/store';
import { availableTimeZones, createTimeFormatter, DEFAULT_TIME_ZONE, validTimeZone } from '../../src/lib/time-zone.js';
import { availableTimeZones, createTimeFormatter, DEFAULT_TIME_ZONE, validTimeZone, timestampISO } from '../../src/lib/time-zone.js';
import { startTimeZonePreference, timeFormat, timeZone, timeZonePersistenceError, TIME_ZONE_STORAGE_KEY } from '../../src/lib/ui/stores/time-zone.js';
let stop: (() => void) | undefined;
@@ -52,9 +52,29 @@ describe('timezone formatting', () => {
it('renders missing or invalid timestamps without crashing the page', () => {
for (const value of [null, undefined, '', 'bad timestamp', NaN, new Date(NaN)]) {
expect(ny.dateTime(value)).toBe('—');
expect(ny.relative(value)).toBe('—');
expect(timestampISO(value)).toBeUndefined();
}
});
it('shares compact recent times, exact older dates, and UTC normalization', () => {
const now = Date.parse('2026-10-07T18:00:00Z');
expect(ny.relative(now - 2_000, now)).toBe('Just now');
expect(ny.relative('2026-10-07 17:59:48', now)).toBe('12 s ago');
expect(ny.relative(now - 120_000, now)).toBe('2 min ago');
expect(ny.relative(now - 23 * 3_600_000, now)).toBe('23 h ago');
expect(ny.relative('2026-10-06T17:00:00Z', now)).toBe('Yesterday, 1:00 PM');
expect(ny.relative('2026-10-05T17:00:00Z', now)).toBe('Oct 5, 2026, 1:00 PM EDT');
expect(ny.relative('2026-10-08T18:00:00Z', now)).toBe('Oct 8, 2026, 2:00 PM EDT');
expect(timestampISO('2026-10-07 17:59:48')).toBe('2026-10-07T17:59:48.000Z');
});
it('uses timezone calendar dates for yesterday across DST and year boundaries', () => {
expect(ny.relative('2026-11-01T04:30:00Z', Date.parse('2026-11-02T05:15:00Z'))).toBe('Yesterday, 12:30 AM');
expect(ny.relative('2025-12-31T05:00:00Z', Date.parse('2026-01-01T06:00:00Z'))).toBe('Yesterday, 12:00 AM');
expect(createTimeFormatter('invalid').relative('2026-10-05T17:00:00Z', Date.parse('2026-10-07T18:00:00Z'))).toBe('Oct 5, 2026, 1:00 PM EDT');
});
it('offers valid zones including New York and UTC, with an older-browser fallback', () => {
expect(availableTimeZones()).toContain(DEFAULT_TIME_ZONE);
expect(availableTimeZones()).toContain('UTC');
+1 -1
View File
@@ -44,7 +44,7 @@ it('shows relative recent timestamps and preserves accurate older dates and inva
expect(activityTimeLabel('2026-10-07T16:00:00Z', 'America/New_York', now)).toBe('2 h ago');
expect(activityTimeLabel('2026-10-06T17:00:00Z', 'America/New_York', now)).toBe('Yesterday, 1:00 PM');
expect(activityTimeLabel('not a date', 'UTC', now)).toBe('Time unavailable');
expect(activityTimeLabel('2026-10-08T18:00:00Z', 'America/New_York', now)).toBe('Oct 8, 2:00 PM');
expect(activityTimeLabel('2026-10-08T18:00:00Z', 'America/New_York', now)).toBe('Oct 8, 2026, 2:00 PM EDT');
});
it('renders one page heading and keeps management tools available only to operators', () => {
+4 -4
View File
@@ -36,7 +36,7 @@ it('keeps current estimates and host comparison before history with scoped histo
expect(body.indexOf('aria-label="History range"')).toBeGreaterThan(body.indexOf('id="capacity-history-title"'));
expect(body).toContain('range=7d&amp;game=t5&amp;host=ec-1');
expect(body).toContain('3 of 3 available');
expect(body).toContain('No queued instances');
expect(body).toContain('No queued servers');
expect(body).not.toContain('href="/provisioning"');
expect(body).not.toContain('Manage hosts');
expect(body).toContain('Updates every 30 seconds');
@@ -48,13 +48,13 @@ it('keeps fleet-wide queue scope explicit and preserves unavailable estimates',
const body = render(CapacityPage, { props: { data: report } }).body;
expect(body).toContain('Capacity estimate unavailable');
expect(body).toContain('href="/provisioning"');
expect(body).toContain('2 instances queued');
expect(body).toContain('2 servers queued');
expect(body).toContain('Fleet-wide');
});
it('keeps ineligible hosts and their reasons visible while collapsing detailed assumptions', () => {
const body = render(CapacityHosts, { props: { returnTo: '/capacity', hosts: [host(), host({ id: 'missing-files', name: 'Missing files', eligible: false, estimatedServers: 0, limitingResource: null, reasons: ['Latest game files are not ready.'] }), host({ id: 'offline', name: 'Offline host', connected: false, eligible: false, estimatedServers: 0, cpuCoresAvailable: null, memoryBytesAvailable: null, limitingResource: null, reasons: ['Waiting for a fresh host report.'] })] } }).body;
expect(body).toContain('Additional instances</th>');
expect(body).toContain('Additional servers</th>');
expect(body).toContain('Not eligible');
expect(body).toContain('Latest game files are not ready.');
expect(body).toContain('Offline');
@@ -62,7 +62,7 @@ it('keeps ineligible hosts and their reasons visible while collapsing detailed a
expect(body).not.toContain('>Ready<');
expect(body).toMatch(/<tr hidden[^>]*id="capacity-host-ec-1"/);
expect(body).toContain('aria-expanded="false"');
expect(body).toContain('Running instance');
expect(body).toContain('Running server');
expect(body).toContain('aria-label="Host capacity comparison" tabindex="0"');
});
+51
View File
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import Freshness from '../../src/lib/ui/components/Freshness.svelte';
import { stripGameColorCodes } from '../../src/lib/ui/log-text.js';
afterEach(() => vi.useRealTimers());
describe('quiet refresh feedback', () => {
it('shows relative receipt time with an exact tooltip without announcing every tick', () => {
vi.useFakeTimers(); vi.setSystemTime('2026-10-07T12:03:00Z');
const body = render(Freshness, { props: { updatedAt: Date.parse('2026-10-07T12:00:00Z'), intervalLabel: 'Updates every 5 seconds' } }).body;
expect(body).toContain('Live');
expect(body).toContain('3 min ago');
expect(body).toContain('datetime="2026-10-07T12:00:00.000Z"');
expect(body).toMatch(/<time[^>]+title="[^"]+"/);
expect(body).toContain('Updates every 5 seconds');
expect(body).not.toMatch(/aria-live|role="status"|>Retry</);
});
it('does not claim live updates or an automatic schedule while paused', () => {
const body = render(Freshness, { props: { paused: true, updatedAt: Date.now(), intervalLabel: 'Updates every 5 seconds' } }).body;
expect(body).toContain('Paused');
expect(body).not.toMatch(/Live|Updates every/);
});
it('replaces live claims with a retry on failures and treats invalid timestamps as unknown', () => {
const body = render(Freshness, { props: { updatedAt: Date.now(), error: 'Log updates interrupted.', intervalLabel: 'Updates every 5 seconds', onRetry: () => {}, retryDisabled: true } }).body;
expect(body).toContain('Log updates interrupted.');
expect(body).toMatch(/<button[^>]+disabled[^>]*>Retry<\/button>/);
expect(body).not.toMatch(/Live|Updates every/);
const waiting = render(Freshness, { props: { updatedAt: Number.NaN } }).body;
expect(waiting).toContain('Waiting for update');
expect(waiting).not.toContain('<time');
});
});
describe('readable game logs', () => {
it('strips digit colour directives while preserving log whitespace and other carets', () => {
const raw = ' [12:30] ^1Red^7 player\t^2joined^0\r\npath ^name, exponent x^y, trailing ^';
expect(stripGameColorCodes(raw)).toBe(' [12:30] Red player\tjoined\r\npath ^name, exponent x^y, trailing ^');
expect(raw).toContain('^1Red^7');
});
it('allows searches across embedded colour changes without mutating downloadable source lines', () => {
const raw = ['^1Con^2nection^7 accepted', ' Player ^3Jamie^7 joined '];
const displayed = raw.map(stripGameColorCodes);
expect(displayed.filter(line => line.toLowerCase().includes('connection'))).toEqual(['Connection accepted']);
expect(displayed.join('\n')).toBe('Connection accepted\n Player Jamie joined ');
expect(raw.join('\n')).toBe('^1Con^2nection^7 accepted\n Player ^3Jamie^7 joined ');
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import { writable } from 'svelte/store';
import HostOperations from '../../src/routes/hosts/operations/+page.svelte';
import type { HostOperationSummary } from '../../src/lib/hosts.js';
vi.mock('$app/stores', () => ({ page: writable({ url: new URL('http://localhost/hosts/operations') }), navigating: writable(null) }));
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn() }));
const operation: HostOperationSummary = {
id: 'retained-op', hostId: 'host-1', hostName: 'Dallas 1', kind: 'instance.inspect',
instanceId: 'container-1', generation: 1, state: 'completed', error: null,
serverName: 't6_zombie_town_1', targetPath: null, source: 'maintenance', routine: true,
createdAt: '2026-10-07 17:00:00', updatedAt: '2026-10-07 17:00:00'
};
describe('host operation history presentation', () => {
it('uses readable operation, server and state labels while keeping identifiers in technical detail', () => {
const body = render(HostOperations, { props: { data: {
history: { operations: [operation], page: 1, pageSize: 25, totalPages: 1, total: 1, filter: 'all' },
scope: {}, hostName: null
} as any } }).body;
expect(body).toContain('Check server container');
expect(body).toContain('Town Survival · Lobby 1');
expect(body).toContain('Completed');
expect(body).toContain('Technical details</summary>');
expect(body.indexOf('instance.inspect')).toBeGreaterThan(body.indexOf('Technical details</summary>'));
expect(body).toContain('retained-op');
expect(body).toContain('datetime="2026-10-07T17:00:00.000Z"');
expect(body).toContain('title="Oct 7, 2026, 1:00:00 PM EDT"');
expect(body).toContain('Updates every 15 seconds');
expect(body).not.toContain('>Refresh</button>');
});
});
+3 -3
View File
@@ -57,8 +57,8 @@ it('distinguishes real zero usage from missing or invalid metrics', () => {
it('renders compact host links and workload without duplicate identity, setup metadata or deletion actions', () => {
const body = render(HostList, { props: { hosts: [{ ...host(), serverCount: 3, playerCount: 12, unreportedInstances: 0 }], now } }).body;
expect(body).toContain('3 assigned instances on ec-1');
expect(body).toContain('>Instances</th>');
expect(body).toContain('3 assigned servers on ec-1');
expect(body).toContain('>Servers</th>');
expect(body).toContain('>Players</th>');
expect(body).toContain('>12</td>');
expect(body).toContain('0.0%');
@@ -76,7 +76,7 @@ it('renders compact host links and workload without duplicate identity, setup me
it('marks partial player totals and distinguishes unavailable counts from empty hosts', () => {
const partial = render(HostList, { props: { hosts: [{ ...host(), serverCount: 3, playerCount: 4, unreportedInstances: 1 }], now } }).body;
expect(partial).toContain('>4+</td>');
expect(partial).toContain('Player counts unavailable for 1 of 3 instances');
expect(partial).toContain('Player counts unavailable for 1 of 3 servers');
const unknown = render(HostList, { props: { hosts: [{ ...host(), serverCount: 3, playerCount: 0, unreportedInstances: 3 }], now } }).body;
expect(unknown).toContain('>—</td>');
const empty = render(HostList, { props: { hosts: [{ ...host(), serverCount: 0, playerCount: 0, unreportedInstances: 0 }], now } }).body;
+72
View File
@@ -0,0 +1,72 @@
import { expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import { writable } from 'svelte/store';
import { plural, countLabel } from '../../src/lib/ui/plural.js';
import { gameTitle } from '../../src/lib/ui/server-labels.js';
import { playerCompletionLabel } from '../../src/lib/ui/player-match.js';
import { hostCalibrationHint } from '../../src/lib/ui/host-overview.js';
import { scopeLabel } from '../../src/lib/script-library.js';
import Setup from '../../src/routes/setup/+page.svelte';
import Hosts from '../../src/routes/hosts/+page.svelte';
import PlayerMatchCards from '../../src/lib/ui/components/PlayerMatchCards.svelte';
import GameFileHosts from '../../src/lib/ui/components/GameFileHosts.svelte';
import type { Host } from '../../src/lib/hosts.js';
import type { PlayerMatch } from '../../src/lib/leaderboard.js';
vi.mock('$app/stores', () => ({ page: writable({ url: new URL('http://localhost/hosts'), data: { user: { id: 'admin', role: 'admin' } } }) }));
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn(), afterNavigate: vi.fn(), invalidateAll: vi.fn() }));
const now = Date.parse('2026-10-07T12:00:00Z');
const host: Host = { id: 'dallas', name: 'Dallas 1', hostname: 'dallas.example', mode: 'active', portMin: 28960, portMax: 28980,
bindAddress: '0.0.0.0', publicAddress: '', minimumFreeBytes: 100, createdAt: new Date(now).toISOString(),
lastHeartbeat: new Date(now).toISOString(), connected: false, connectionState: 'offline', readiness: 'ready', readinessReasons: [],
inventory: {}, enrolled: true, credentialActive: true, decommission: null };
const match: PlayerMatch = { id: 17, game: 't5', server: 't5_zombie_theater_1', mapName: 'Kino Der Toten', startedAt: new Date(now).toISOString(), lastSeen: new Date(now).toISOString(), status: 'completed', completed: 1, highestRound: 30, roundsSurvived: 20, kills: 1200, headshots: 200, revives: 2, downs: 1, bestScore: 45000 };
it('pluralizes zero, one, multiple, fractional and irregular counts consistently', () => {
expect(countLabel(0, 'server')).toBe('0 servers');
expect(countLabel(1, 'player')).toBe('1 player');
expect(countLabel(2000, 'host')).toBe('2,000 hosts');
expect(countLabel(1, 'match', 'matches')).toBe('1 match');
expect(countLabel(2, 'match', 'matches')).toBe('2 matches');
expect(plural(1, 'needs', 'need')).toBe('needs');
expect(plural(0, 'needs', 'need')).toBe('need');
expect(plural(1.5, 'hour')).toBe('hours');
});
it('recommends a sampling upgrade only for a known older version', () => {
for (const version of ['1.0.0', '1.4.5', 'v1.4.0']) expect(hostCalibrationHint(version)).toContain('Update this host to agent 1.4.6');
for (const version of ['1.4.6', '1.7.0', '1.10.0', '2.0.0', 'v2.0.0+build', null, undefined, 'unknown', '1.4']) {
expect(hostCalibrationHint(version)).toContain('still calibrating');
expect(hostCalibrationHint(version)).not.toContain('Update');
}
});
it('describes per-player completion as final-report evidence without inventing unfinished results', () => {
expect(playerCompletionLabel(match)).toBe('Present in final report');
expect(playerCompletionLabel({ ...match, completed: 0 })).toBe('Not present in final report');
expect(playerCompletionLabel({ ...match, status: 'active', completed: 0 })).toBe('Match in progress');
expect(playerCompletionLabel({ ...match, status: 'interrupted', completed: 0 })).toBe('Final report unavailable');
const body = render(PlayerMatchCards, { props: { matches: [match, { ...match, id: 18, completed: 0 }] } }).body;
expect(body).toContain('Present in final report'); expect(body).toContain('Not present in final report');
expect(body).not.toContain('Completion credited'); expect(body).not.toContain('No completion credit');
});
it('uses product game names while preserving unsupported game identifiers', () => {
expect(gameTitle('t5')).toBe('Black Ops I'); expect(gameTitle('T6')).toBe('Black Ops II');
expect(gameTitle('t5', true)).toBe('BO1'); expect(gameTitle('t6', true)).toBe('BO2');
expect(gameTitle('custom')).toBe('CUSTOM'); expect(gameTitle(null)).toBe('Unknown game');
expect(scopeLabel({ scope_type: 'game', scope_value: 't5' })).toBe('Black Ops I');
});
it('does not offer an existing-account sign-in path on first administrator setup', () => {
const body = render(Setup).body;
expect(body).toContain('Create the first administrator'); expect(body).not.toContain('Already have an account');
expect(body).not.toContain('href="/login"');
});
it('renders singular host attention and explicit whitespace before an offline suffix', () => {
const body = render(Hosts, { props: { data: { hosts: [{ ...host, serverCount: 0, playerCount: 0, unreportedInstances: 0 }], serverNow: now, user: null, setupRequired: false } } }).body;
expect(body).toContain('1 host'); expect(body).toContain('1 needs attention'); expect(body).not.toContain('1 need attention');
const files = render(GameFileHosts, { props: { hosts: [host], caches: [{ hostId: host.id, libraryVersionId: 'files', status: 'missing', bytesDownloaded: 0, bytesTotal: 0, manifestSha256: null, error: null, updatedAt: new Date(now).toISOString(), operationId: null }] } }).body;
expect(files).toMatch(/Dallas 1<\/a>.*?> · Offline<\/span>/);
});
+4 -4
View File
@@ -17,7 +17,7 @@ it('renders one page heading, six columns, a combined identity, and compact date
const body = render(MatchHistoryPage, { props: { data: { history: report(), user: null, setupRequired: false } } }).body;
expect(body.match(/<h1[ >]/g)).toHaveLength(1);
expect(body.match(/scope="col"/g)).toHaveLength(6);
expect(body).toContain('Today, 1:26 PM'); expect(body).toContain('Lobby 1 · Black Ops II');
expect(body).toContain('2 h ago'); expect(body).toContain('Lobby 1 · Black Ops II');
expect(body).not.toContain('>Server</th>'); expect(body).not.toContain('Show 25 more');
expect(body).toContain('matchPage=2');
});
@@ -60,7 +60,7 @@ it('offers filter reset for an empty selection and retains the scoped instance v
});
it('keeps custom instance names and calculates yesterday across DST and year boundaries', () => {
expect(matchInstanceLabel({ ...match, server: 'Weekend lobby' })).toBe('Weekend lobby · Black Ops II');
expect(matchStartedLabel('2026-03-08T06:30:00Z', 'America/New_York', new Date('2026-03-09T04:15:00Z'))).toBe('Yesterday, 1:30 AM');
expect(matchStartedLabel('2025-10-06T17:26:00Z', 'America/New_York', new Date('2026-10-06T20:00:00Z'))).toBe('Oct 6, 2025, 1:26 PM');
expect(matchStartedLabel('2026-10-06 17:26:00', 'America/New_York', new Date('2026-10-06T20:00:00Z'))).toBe('Today, 1:26 PM');
expect(matchStartedLabel('2026-03-08T06:30:00Z', 'America/New_York', new Date('2026-03-09T04:15:00Z'))).toBe('21 h ago');
expect(matchStartedLabel('2025-10-06T17:26:00Z', 'America/New_York', new Date('2026-10-06T20:00:00Z'))).toBe('Oct 6, 2025, 1:26 PM EDT');
expect(matchStartedLabel('2026-10-06 17:26:00', 'America/New_York', new Date('2026-10-06T20:00:00Z'))).toBe('2 h ago');
});
+1 -1
View File
@@ -116,7 +116,7 @@ it('keeps accessible chart selection, partial periods and gaps while collapsing
data.points.push({ at: data.now, startAt: data.now, endAt: data.now + 1000, partial: false, observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 });
const body = render(MetricsChart, { props: { points: data.points, days: data.days, timeZone: data.scope.timeZone } }).body;
expect(body).toContain('aria-label="Selected period summary"');
expect(body).toContain('No captured time · not evidence of idle instances');
expect(body).toContain('No captured time · not evidence of idle servers');
expect(body).toContain('tabindex="0"');
expect(body).toContain('Inspect a period');
expect(body).toContain('Partial period');