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 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 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. 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 type GameLibraryGame = 't5' | 't6';
export const GAME_FILE_GAMES = [ export const GAME_FILE_GAMES = [
{ id: 't5', name: 'Black Ops I (T5)' }, { id: 't5', name: 'Black Ops I' },
{ id: 't6', name: 'Black Ops II (T6)' } { id: 't6', name: 'Black Ops II' }
] as const; ] as const;
export type GameLibraryState = 'importing' | 'ready' | 'failed'; export type GameLibraryState = 'importing' | 'ready' | 'failed';
export type LibraryCacheState = 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated'; 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 { T5_ZOMBIE_MAPS, T6_ZOMBIE_MAPS } from './map-aliases.js';
import { gameTitle } from './ui/server-labels.js';
export interface LibraryVersion { export interface LibraryVersion {
id: number; version: number; sha256: string; size: number; 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; 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 { 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'; : s.scope_type === 'map' ? mapLabel(s.scope_value ?? '') : 'Unscoped';
} }
export interface BetaSelection { scriptId: number; versionId: number; } 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 type Database from 'better-sqlite3';
import { normalizeActivityQuery, type ActivityPage, type ActivityQuery, type ActivityRow, type ActivitySource } from '../activity.js'; import { normalizeActivityQuery, type ActivityPage, type ActivityQuery, type ActivityRow, type ActivitySource } from '../activity.js';
import { serverTitle } from '../ui/server-labels.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 initialized = new WeakSet<Database.Database>();
const quote = (value: string) => `'${value.replaceAll("'", "''")}'`; 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) => 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`; `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|', 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' 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, 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.' 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, 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 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. */ /** Register a folder on the controller; no host or Docker volume is needed. */
export function createLibraryUpload(input: Record<string, unknown>): GameLibraryVersion { export function createLibraryUpload(input: Record<string, unknown>): GameLibraryVersion {
const game = input.game; 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 files = validateUploadPlan(input.files);
const handle = database(); const handle = database();
const id = randomUUID(); const now = new Date().toISOString(); 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 { export function createLibraryImport(input: Record<string, unknown>): GameLibraryVersion {
const game = input.game; 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 hostId = textField(input.hostId, 'source host', 128);
const host = getHost(hostId); const host = getHost(hostId);
if (!host || !host.connected || !host.credentialActive || host.mode === 'disabled') throw new Error('Choose an online, enrolled source host.'); 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.` }); 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', } 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 // Cache health is visible, but never makes a usable host ineligible or
// delays re-enabling: a lobby's normal updater handles cache misses. // delays re-enabling: a lobby's normal updater handles cache misses.
const required = items.filter(item => item.kind !== 'plutonium'); 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(); 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. */ /** Database timestamps without an offset are UTC, never browser-local time. */
function parseTimestamp(value: Exclude<Timestamp, null | undefined>): Date { function parseTimestamp(value: Exclude<Timestamp, null | undefined>): Date {
@@ -28,6 +28,12 @@ function parseTimestamp(value: Exclude<Timestamp, null | undefined>): Date {
return new Date(value); 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) { export function createTimeFormatter(zone: string) {
const timeZone = validTimeZone(zone) ? zone : DEFAULT_TIME_ZONE; const timeZone = validTimeZone(zone) ? zone : DEFAULT_TIME_ZONE;
const date: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' }; 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) : '—'; 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 { return {
relative,
dateTime: formatter({ ...date, ...time }), dateTime: formatter({ ...date, ...time }),
date: formatter(date), date: formatter(date),
time: formatter(time), time: formatter(time),
clockTime: formatter({ hour: 'numeric', minute: '2-digit', second: '2-digit' }), clockTime: formatter({ hour: 'numeric', minute: '2-digit', second: '2-digit' }),
minuteTime: formatter({ hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }), 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 { 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 } export interface ActivityScope { server?: string; source?: ActivitySource }
const keys = ['server', 'host', 'type', 'source', 'state', 'days', 'q', 'limit', 'offset'] as const; 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. */ /** Recent entries scan as relative time; exact timestamps stay on the time element. */
export function activityTimeLabel(value: string, zone: string, now: number): string { 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'); const label = createTimeFormatter(zone).relative(value, now);
if (!Number.isFinite(timestamp)) return 'Time unavailable'; return label === '—' ? 'Time unavailable' : label;
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));
} }
+2 -1
View File
@@ -1,4 +1,5 @@
import { statusPresentation, type StatusTone } from './status.js'; import { statusPresentation, type StatusTone } from './status.js';
import { plural } from './plural.js';
export type CampaignServerGroup = 'attention' | 'processing' | 'queued' | 'complete' | 'inactive'; 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 completed = campaign.servers.filter(server => server.restart_state === 'done').length;
const live = campaign.servers.filter(server => !server.server_deleted_at); const live = campaign.servers.filter(server => !server.server_deleted_at);
const attention = live.filter(server => ['failed', 'held', 'manual'].includes(server.restart_state)); 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 (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.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 }; 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 { goto } from '$app/navigation';
import { activityTypes, activitySources, activityStates, type ActivityPage, type ActivityQuery, type ActivitySource } from '$lib/activity.js'; import { activityTypes, activitySources, activityStates, type ActivityPage, type ActivityQuery, type ActivitySource } from '$lib/activity.js';
import { contextHref } from '$lib/navigation-context.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 { pollVisible } from '$ui/poll.js';
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
import { fleet } from '$ui/stores/fleet.js'; import { fleet } from '$ui/stores/fleet.js';
@@ -117,7 +117,7 @@
{#if row.hostName}<span class="host-name">{row.hostName}</span>{/if} {#if row.hostName}<span class="host-name">{row.hostName}</span>{/if}
</div> </div>
</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> <span class="sources">{(row.sources.length ? row.sources : [row.source]).map(item => activitySources[item]).join(' · ')}</span>
</div> </div>
</li> </li>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import Dialog from './primitives/Dialog.svelte'; import Dialog from './primitives/Dialog.svelte';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
let { open = $bindable(false), onadded }: { open?: boolean; onadded: () => Promise<unknown> } = $props(); 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> <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> <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> <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> <Button variant="ghost" onclick={() => { enrollment = null; name = ''; copied = false; error = ''; }}>Add another host</Button>
{:else} {:else}
<p>Use a Debian 12 or 13 amd64 machine. The agent connects over HTTPS and prepares the runtime and game files automatically.</p> <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"> <script lang="ts">
import { serverTitle } from '$ui/server-labels.js';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { confirmAction } from '$ui/confirm.js'; import { confirmAction } from '$ui/confirm.js';
@@ -353,7 +354,7 @@
</div></details> </div></details>
</Card> </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'} {#if a.kind === 'config' || a.kind === 'baseline'}
<ConfigEditor {id} kind={a.kind} {versions} currentVersionId={a.current_version_id} <ConfigEditor {id} kind={a.kind} {versions} currentVersionId={a.current_version_id}
@@ -375,7 +376,7 @@
<span class="v-notes">{v.notes ?? ''} <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} {#if v.source}<a href={v.source.release_url} target="_blank" rel="noreferrer">GitHub · {v.source.asset_name} ↗</a>{/if}
</span> </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} {#if a.current_version_id === v.id}
<Badge variant="online" size="sm">Promoted</Badge> <Badge variant="online" size="sm">Promoted</Badge>
{:else} {:else}
@@ -1,4 +1,6 @@
<script lang="ts"> <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 { timeFormat } from '$ui/stores/time-zone.js';
import { tick } from 'svelte'; import { tick } from 'svelte';
import { page } from '$app/stores'; 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)) && (!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())))); && (!evidenceQuery || `${betaEvidenceSummary(event)} ${event.payload}`.toLowerCase().includes(evidenceQuery.toLowerCase()))));
$effect(() => { evidenceServer; evidenceKind; evidenceQuery; evidenceLimit = 25; }); $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>={}) { 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; 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 = ''; busy = true; error = ''; message = '';
@@ -121,8 +122,8 @@
<summary>Evidence ({test.evidence.length}{test.evidence.length===200 ? ' most recent' : ''})</summary> <summary>Evidence ({test.evidence.length}{test.evidence.length===200 ? ' most recent' : ''})</summary>
{#if evidenceOpen} {#if evidenceOpen}
<p>Newest first. Includes retained evidence from earlier attempts and recovery cycles; current progress uses only the current attempt.</p> <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> <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}/><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} {#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"/> <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 !filteredEvidence.length}<p>{test.evidence.length ? 'No evidence matches these filters.' : 'No evidence recorded yet. Check deployment progress and telemetry diagnostics.'}</p>{/if}
{/if} {/if}
@@ -130,7 +131,7 @@
</section> </section>
{#snippet checks(server: BetaTestView['servers'][number])} {#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)} {#each server.progress as progress (progress.id)}
{@const requirement = test.plan.requirements.find(r=>r.id===progress.id)} {@const requirement = test.plan.requirements.find(r=>r.id===progress.id)}
{#if requirement}<div class="requirement" class:failed={progress.failed}> {#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); } .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; } } @media(max-width:720px) { table { min-width:620px; } }
section { display:grid; gap:16px; } h2,p { margin:0; } h2 { font-size: var(--text-md); } 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; } .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 { 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:'▾'; } .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"> <script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import type { CapacitySamplePage } from '$lib/hosts.js'; import type { CapacitySamplePage } from '$lib/hosts.js';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
@@ -54,7 +55,7 @@
<table><caption>CPU and memory peaks per container and minute; times are local.</caption> <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> <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} <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} {#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.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> <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 ConfigTextEditor from './ConfigTextEditor.svelte';
import ConfigInspector from './ConfigInspector.svelte'; import ConfigInspector from './ConfigInspector.svelte';
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js'; 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 Version { id: number; version: number; state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string; }
interface Artifact { interface Artifact {
@@ -116,7 +117,7 @@
{:else} {:else}
{#if loading || artifacts.length || error}<div class="library-toolbar"> {#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…" /> <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 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> <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} {#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> <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> <tbody>{#each rows as artifact (artifact.id)}<tr>
<td><a class="name" href={contextHref(`/configs/${artifact.id}`, $page.url)}>{artifact.name}</a></td> <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><LibraryVersions current={artifact.currentVersion} latest={artifact.latestVersion} betaId={artifact.runningBetaId} /></td>
<td class="numeric">{artifact.servers.length}</td> <td class="numeric">{artifact.servers.length}</td>
</tr>{/each}</tbody> </tr>{/each}</tbody>
@@ -158,7 +159,7 @@
<form onsubmit={create}> <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="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="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} {#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> </div>
{#if baselineExists}<p class="warning">This game already has a baseline. Open it from the library to save a new version.</p>{/if} {#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()} {#snippet actions()}
<div class="launch-actions"> <div class="launch-actions">
<p class:ready={canLaunch && !unconfirmedLaunch} role="status">{footerStatus}</p> <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> </div>
{/snippet} {/snippet}
</Dialog> </Dialog>
+8 -5
View File
@@ -1,14 +1,17 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; 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()); let now = $state(Date.now());
onMount(() => { const timer = setInterval(() => now = Date.now(), 1000); return () => clearInterval(timer); }); 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))); // These are local receipt times, so their age uses the browser clock. The
const ago = $derived(age === null ? '' : age < 5 ? 'just now' : age < 60 ? `${age} s ago` : `${Math.floor(age / 60)} min ago`); // shared formatter still supplies the same language and exact tooltip.
const known = $derived(updatedAt !== null && Number.isFinite(updatedAt));
</script> </script>
<div class="freshness" class:failed={!!error}> <div class="freshness" class:failed={!!error}>
{#if error}<span>{error}</span>{#if onRetry}<button type="button" onclick={onRetry}>Retry</button>{/if} {#if error}<span>{error}</span>{#if onRetry}<button type="button" disabled={retryDisabled} onclick={onRetry}>Retry</button>{/if}
{:else}<span>{updatedAt === null ? 'Waiting for update' : `Live · updated ${ago}`}{intervalLabel ? ` · ${intervalLabel}` : ''}</span>{/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> </div>
<style> <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); } .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 host = hosts.find(host => host.id === cache.hostId)}
{@const waiting = latest && cache.status === 'missing'} {@const waiting = latest && cache.status === 'missing'}
<tr> <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><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> <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} {: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 type { Host } from '$lib/hosts.js';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { hostListStatus, hostReadingsStale, hostCpuUsage, hostUsedSpace } from '$ui/host-overview.js'; import { hostListStatus, hostReadingsStale, hostCpuUsage, hostUsedSpace } from '$ui/host-overview.js';
import { plural } from '$ui/plural.js';
import Badge from './primitives/Badge.svelte'; import Badge from './primitives/Badge.svelte';
let { hosts, now, returnTo = '/hosts' }: { hosts: (Host & { serverCount: number; playerCount: number; unreportedInstances: number })[]; now: number; returnTo?: string } = $props(); let { hosts, now, returnTo = '/hosts' }: { hosts: (Host & { serverCount: number; playerCount: number; unreportedInstances: number })[]; now: number; returnTo?: string } = $props();
</script> </script>
<div class="table-wrap"><table> <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)} <tbody>{#each hosts as host (host.id)}
{@const status = hostListStatus(host, now)} {@const status = hostListStatus(host, now)}
{@const stale = hostReadingsStale(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} <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> {#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 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><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} instances` : 'Players reported by online instances'}>{host.unreportedInstances === host.serverCount && host.serverCount > 0 ? '—' : `${host.playerCount}${host.unreportedInstances ? '+' : ''}`}</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>{hostCpuUsage(host.inventory.cpuUsagePercent)}</td>
<td class:stale>{hostUsedSpace(host.inventory.memoryTotal, host.inventory.memoryAvailable)}</td> <td class:stale>{hostUsedSpace(host.inventory.memoryTotal, host.inventory.memoryAvailable)}</td>
<td class:stale>{hostUsedSpace(host.inventory.storageTotal, host.inventory.storageAvailable)}</td> <td class:stale>{hostUsedSpace(host.inventory.storageTotal, host.inventory.storageAvailable)}</td>
+2 -2
View File
@@ -6,7 +6,7 @@
</script> </script>
<div class="preparation" aria-live="polite"> <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> <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} {#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> <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} {/each}
</ul> </ul>
{:else}<p>Setup progress will appear after the host connects.</p>{/if} {: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> </div>
<style> <style>
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <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'; import type { LaunchRecipeSummary } from '$lib/deployment-plan.js';
let { recipe, historical = false, selectionStatus = null }: { recipe: LaunchRecipeSummary; historical?: boolean; selectionStatus?: 'queued' | 'attempted' | null } = $props(); let { recipe, historical = false, selectionStatus = null }: { recipe: LaunchRecipeSummary; historical?: boolean; selectionStatus?: 'queued' | 'attempted' | null } = $props();
</script> </script>
@@ -14,7 +14,7 @@
<details> <details>
<summary>{recipe.files.length} managed file{recipe.files.length === 1 ? '' : 's'} {historical ? 'recorded for this deployment' : 'in the current setup'}</summary> <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> <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> <small class="fingerprint" title={recipe.fingerprint}>Recipe {recipe.fingerprint.slice(0, 16)}</small>
</details> </details>
</div> </div>
+15 -18
View File
@@ -3,8 +3,11 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { createLogRefresh, type LogResponse } from '$ui/log-refresh.js'; import { createLogRefresh, type LogResponse } from '$ui/log-refresh.js';
import { stripGameColorCodes } from '$ui/log-text.js';
import Tabs from './primitives/Tabs.svelte'; import Tabs from './primitives/Tabs.svelte';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
import Freshness from './Freshness.svelte';
import RelativeTime from './RelativeTime.svelte';
const uid = $props.id(); const uid = $props.id();
interface TabDef { interface TabDef {
@@ -22,6 +25,7 @@
const active = $derived(tabs.find(tab => tab.key === $page.url.searchParams.get('log'))?.key ?? tabs[0]?.key ?? ''); 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 data = $state<Record<string, LogResponse | null>>({});
let checkedAt = $state<Record<string, number>>({});
function chooseLog(key: string) { function chooseLog(key: string) {
const url = new URL($page.url); url.searchParams.set('log', key); const url = new URL($page.url); url.searchParams.set('log', key);
@@ -37,11 +41,12 @@
let stickToBottom = $state(true); let stickToBottom = $state(true);
const refresh = createLogRefresh({ const refresh = createLogRefresh({
onReset: () => { data = {}; refreshError = ''; copyStatus = ''; }, onReset: () => { data = {}; checkedAt = {}; refreshError = ''; copyStatus = ''; },
onLoading: value => { loading = value; }, onLoading: value => { loading = value; },
onError: message => { refreshError = message; }, onError: message => { refreshError = message; },
onData: (key, body) => { onData: (key, body) => {
data = { ...data, [key]: body }; data = { ...data, [key]: body };
checkedAt = { ...checkedAt, [key]: Date.now() };
const currentServer = server; const currentServer = server;
void tick().then(() => { void tick().then(() => {
if (server === currentServer && active === key && stickToBottom && !query && bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight; if (server === currentServer && active === key && stickToBottom && !query && bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
@@ -97,15 +102,6 @@
untrack(() => refresh.configure(target, auto, interval)); 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 { function fmtBytes(n: number | undefined): string {
if (n === undefined || n === 0) return '0 B'; if (n === undefined || n === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB']; const units = ['B', 'KB', 'MB', 'GB'];
@@ -115,7 +111,7 @@
} }
let current = $derived(data[active] ?? null); 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> </script>
<div class="log-panel"> <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" /> <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"> <div class="right">
{#if current?.mtime} {#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} {/if}
<label class="auto"> <label class="auto">
<input type="checkbox" checked={autoRefresh} onchange={toggleAuto} /> <input type="checkbox" checked={autoRefresh} onchange={toggleAuto} />
Auto-refresh · {pollMs / 1000}s Auto-refresh
</label> </label>
<button class="refresh" onclick={() => refresh.refresh()} disabled={loading}> {#if !autoRefresh}<button class="refresh" onclick={() => refresh.refresh()} disabled={loading}>
{loading ? 'Refreshing…' : 'Refresh'} {loading ? 'Refreshing…' : 'Refresh'}
</button> </button>{/if}
</div> </div>
</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} {#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 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}> <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} {#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} {:else if current && !current.ok}
<div class="error">error: {current.reason ?? 'unknown'}</div> <div class="error">error: {current.reason ?? 'unknown'}</div>
{:else if current?.missing} {:else if current?.missing}
+4 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
@@ -6,7 +7,7 @@
import { timeFormat, timeZone } from '$ui/stores/time-zone.js'; import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
import type { MatchHistoryResponse } from '$lib/match-history.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 { 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 { pollVisible } from '$ui/poll.js';
import { formatDuration } from '$ui/duration.js'; import { formatDuration } from '$ui/duration.js';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
@@ -121,11 +122,11 @@
</tr></thead> </tr></thead>
<tbody>{#each data.matches as match (match.id)} <tbody>{#each data.matches as match (match.id)}
<tr> <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"> <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> {#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} {: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"> {#if server === undefined}<div class="instance">
<span title={match.server}>{matchInstanceLabel(match)}</span>{#if match.server_deleted_at}<span> · Historical instance</span>{/if} <span title={match.server}>{matchInstanceLabel(match)}</span>{#if match.server_deleted_at}<span> · Historical instance</span>{/if}
</div>{/if} </div>{/if}
@@ -1,10 +1,11 @@
<script lang="ts"> <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 { Host, NativeDeploymentSelection } from '$lib/hosts.js';
import type { GameLibrary } from '$lib/game-files.js'; import type { GameLibrary } from '$lib/game-files.js';
import type { ZombieGame } from '$lib/map-aliases.js'; import type { ZombieGame } from '$lib/map-aliases.js';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { latestGameFiles, selectLatestGameFiles, type NativeHostSelectionStatus } from '$ui/native-game-files.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 }: 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(); { 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(''); 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 error}<p role="alert" class="error">{error}</p>{/if}
{#if loading}<p role="status">Loading host readiness and game files…</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> <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} {#if !loading}{#each games as game}
<div class="game-files"> <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]} {#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> <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} {: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} {/if}
</div> </div>
{/each}{/if} {/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> <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} {#if network}<details><summary>Network overrides</summary>{@render networkFields()}</details>{/if}
</section> </section>
@@ -1,5 +1,5 @@
<script lang="ts"> <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 type { ActivityEvent, OperationStep } from '$lib/operation-flow.js';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
@@ -26,14 +26,14 @@
</section> </section>
{/if} {/if}
<section> <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> <details>
<summary>Activity history · {events.length} events{active ? ' · updates automatically' : ''}</summary> <summary>Activity history · {events.length} events{active ? ' · updates automatically' : ''}</summary>
{#if events.length} {#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> <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"> <ol class="timeline">
{#each visible as event (event.id)} {#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} {/each}
</ol> </ol>
{#if !matching.length}<p class="muted">No matching activity.</p>{/if} {#if !matching.length}<p class="muted">No matching activity.</p>{/if}
@@ -53,12 +53,12 @@
details[open] summary { margin-bottom: 16px; } details[open] summary { margin-bottom: 16px; }
.latest { padding: 12px 16px; background: var(--surface); border-radius: var(--radius-md); font-size: var(--text-sm); } .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 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; } .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 { display: grid; gap: 6px; font-size: var(--text-xs); color: var(--text-secondary); }
label:first-child { flex: 1; min-width: 180px; } 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; } 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; } p { margin: 0; line-height: 1.6; }
.flow { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; } .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); } .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"> <script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import type { PlayerMatch } from '$lib/leaderboard.js'; import type { PlayerMatch } from '$lib/leaderboard.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { playerCompletionLabel } from '$ui/player-match.js';
let { matches, returnTo }: { matches: PlayerMatch[]; returnTo?: string } = $props(); let { matches, returnTo }: { matches: PlayerMatch[]; returnTo?: string } = $props();
const number = new Intl.NumberFormat('en-US'); const number = new Intl.NumberFormat('en-US');
</script> </script>
@@ -11,7 +12,7 @@
<article> <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> <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>{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> <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> </article>
{/each} {/each}
@@ -130,7 +130,7 @@
</select></label> </select></label>
{#if !pluginId} {#if !pluginId}
<label>Plugin name<input bind:value={name} disabled={busy} placeholder="t6-gsc-utils" /></label> <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} {/if}
</div> </div>
{#if targetPath}<p>New versions deploy to <code>{targetPath}</code>.</p>{/if} {#if targetPath}<p>New versions deploy to <code>{targetPath}</code>.</p>{/if}
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import type { ProvisioningDetail, ProvisioningEvent } from '$lib/provisioning-detail.js'; 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 { isProvisioningEventPage } from '$ui/provisioning-presentation.js';
import Button from './primitives/Button.svelte'; 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> <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> <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"> {#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} </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}> <details class="history" ontoggle={toggle}>
@@ -98,7 +98,7 @@
{#if loading && !loaded}<p class="muted" role="status">Loading activity…</p> {#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 if loaded && !entries.length}<p class="empty">{appliedQuery ? 'No activity matches this search.' : 'No detailed activity recorded.'}</p>
{:else}<ol class="timeline"> {: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} </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} {#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> </div>
@@ -111,7 +111,7 @@
.eyebrow { display: block; font-size: var(--text-xs); color: var(--text-tertiary); margin-bottom: var(--space-2); } .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); } 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); } 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); } .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 { 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; } .timeline li { padding: 0 0 var(--space-5); position: relative; }.timeline li:last-child { padding-bottom: 0; }
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import type { ProvisioningDetail } from '$lib/provisioning-detail.js'; import type { ProvisioningDetail } from '$lib/provisioning-detail.js';
import { provisioningGroups, progressIcons, progressStateLabels } from '$ui/provisioning-presentation.js'; import { provisioningGroups, progressIcons, progressStateLabels } from '$ui/provisioning-presentation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Badge from './primitives/Badge.svelte'; import Badge from './primitives/Badge.svelte';
let { detail }: { detail: ProvisioningDetail } = $props(); let { detail }: { detail: ProvisioningDetail } = $props();
@@ -37,7 +37,7 @@
{#each group.steps as step (step.key)} {#each group.steps as step (step.key)}
<li aria-current={step.state === 'current' || step.state === 'waiting' ? 'step' : undefined}> <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> <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> </li>
{/each} {/each}
</ol> </ol>
@@ -49,17 +49,17 @@
<div class="command" class:uncertain={progress.command.state === 'uncertain'}> <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> <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> <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> </div>
{/if} {/if}
{#if progress.checkpoints.some(checkpoint => !groups.some(group => group.steps.some(step => step.key === checkpoint.key)))} {#if progress.checkpoints.some(checkpoint => !groups.some(group => group.steps.some(step => step.key === checkpoint.key)))}
<details class="milestones"><summary>Recorded checkpoints</summary><ul> <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)} {#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} {/each}
</ul></details> </ul></details>
{/if} {/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} {#if progress.checks.length}
<div class="checks"> <div class="checks">
{#each progress.checks as check (check.key)} {#each progress.checks as check (check.key)}
@@ -70,7 +70,7 @@
<div><dt>Observed</dt><dd>{check.actual ?? 'Unknown'}</dd></div> <div><dt>Observed</dt><dd>{check.actual ?? 'Unknown'}</dd></div>
</dl>{/if} </dl>{/if}
{#if check.message}<p>{check.message}</p>{/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> </article>
{/each} {/each}
</div> </div>
@@ -105,11 +105,11 @@
.steps strong { display: block; font-size: var(--text-sm); font-weight: var(--weight-medium); line-height: 1.5; } .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; } .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-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, .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)); } .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; } 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 { 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); } .evidence-heading span { color: var(--text-secondary); }
.checks { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; } .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"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { tick } from 'svelte'; import { tick } from 'svelte';
import { plural } from '$ui/plural.js';
import { gameTitle } from '$ui/server-labels.js';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
import { betaSelectionHref, mapLabel, scopeLabel, type BetaSelection, type LibraryScript } from '$lib/script-library.js'; import { betaSelectionHref, mapLabel, scopeLabel, type BetaSelection, type LibraryScript } from '$lib/script-library.js';
let { scripts, onuploaded }: { scripts: LibraryScript[]; onuploaded: () => Promise<void> } = $props(); let { scripts, onuploaded }: { scripts: LibraryScript[]; onuploaded: () => Promise<void> } = $props();
@@ -176,7 +178,7 @@
{#if hasNew} {#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> <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'} {#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 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 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} {#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}
{#if message}<p class="notice" role="status">{message}</p>{/if} {#if message}<p class="notice" role="status">{message}</p>{/if}
<footer> <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> {#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} {:else if testable.length}<Button variant="primary" href={betaSelectionHref(testable)}>Prepare beta with {testable.length} script{testable.length === 1 ? '' : 's'} →</Button>{/if}
</footer> </footer>
@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { TELEMETRY_REQUEST_RETENTION, type TelemetryRequestLogEntry } from '$lib/telemetry-diagnostics.js'; import { TELEMETRY_REQUEST_RETENTION, type TelemetryRequestLogEntry } from '$lib/telemetry-diagnostics.js';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { serverTitle } from '$ui/server-labels.js';
import Card from './primitives/Card.svelte'; import Card from './primitives/Card.svelte';
import Badge from './primitives/Badge.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 EmptyState from './primitives/EmptyState.svelte';
import ListFooter from './primitives/ListFooter.svelte'; import ListFooter from './primitives/ListFooter.svelte';
@@ -15,15 +16,18 @@
let refreshing = $state(false); let refreshing = $state(false);
let loaded = $state(false); let loaded = $state(false);
let error = $state(''); let error = $state('');
let refreshedAt = $state<string | null>(null); let refreshedAt = $state<number | null>(null);
let limit = $state(25); let limit = $state(25);
let active = false; let active = false;
let controller: AbortController | null = null; let controller: AbortController | null = null;
async function refresh(): Promise<void> { async function refresh(signal?: AbortSignal): Promise<void | false> {
if (!active || refreshing) return; if (!active || refreshing) return;
const currentServer = server; const currentServer = server;
const requestController = new AbortController(); const requestController = new AbortController();
const abort = () => requestController.abort();
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) requestController.abort();
controller = requestController; controller = requestController;
refreshing = true; refreshing = true;
try { try {
@@ -35,17 +39,19 @@
if (!response.ok || !body?.ok || !Array.isArray(body.requests)) { if (!response.ok || !body?.ok || !Array.isArray(body.requests)) {
throw new Error(body?.error || `Could not load telemetry requests (HTTP ${response.status}).`); 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; requests = body.requests;
retention = body.retention; retention = body.retention;
loaded = true; loaded = true;
error = ''; error = '';
refreshedAt = new Date().toISOString(); refreshedAt = Date.now();
} catch (cause) { } catch (cause) {
if (active && server === currentServer && !requestController.signal.aborted) { if (active && server === currentServer && !requestController.signal.aborted) {
error = cause instanceof Error ? cause.message : 'Could not load telemetry requests.'; error = cause instanceof Error ? cause.message : 'Could not load telemetry requests.';
} }
return false;
} finally { } finally {
signal?.removeEventListener('abort', abort);
if (active && server === currentServer) refreshing = false; if (active && server === currentServer) refreshing = false;
if (controller === requestController) controller = null; if (controller === requestController) controller = null;
} }
@@ -61,10 +67,6 @@
}; };
}); });
function timestamp(value: string): string {
return $timeFormat.dateTime(value);
}
function outcome(status: number): string { function outcome(status: number): string {
return status >= 500 ? 'Failed' : status >= 400 ? 'Rejected' : 'Accepted'; return status >= 500 ? 'Failed' : status >= 400 ? 'Rejected' : 'Accepted';
} }
@@ -74,15 +76,15 @@
<div class="panel-head"> <div class="panel-head">
<div> <div>
<h2>Telemetry API requests</h2> <h2>Telemetry API requests</h2>
<p class="server-name"><code>{server}</code></p> <p class="server-name">{serverTitle(server)}</p>
</div> </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> </div>
{#if error} {#if error}
<p class="load-error" role="alert"> <p class="load-error" role="alert">
Couldn’t load telemetry requests. {#if loaded} Previously loaded requests are shown below.{/if} 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}
{#if !loaded && !error} {#if !loaded && !error}
@@ -103,7 +105,7 @@
</Badge> </Badge>
<code class="event">{request.event ?? 'Event not provided'}</code> <code class="event">{request.event ?? 'Event not provided'}</code>
</div> </div>
<time class="received" datetime={request.receivedAt}>{timestamp(request.receivedAt)}</time> <span class="received"><RelativeTime value={request.receivedAt} /></span>
</div> </div>
<dl class="request-meta"> <dl class="request-meta">
<div><dt>Map</dt><dd><code>{request.map ?? 'Not provided'}</code></dd></div> <div><dt>Map</dt><dd><code>{request.map ?? 'Not provided'}</code></dd></div>
@@ -124,13 +126,11 @@
{/if} {/if}
<details class="request-help"><summary>About request logging</summary> <p class="intro"> <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. Fresh game telemetry supplies player counts when available; RCON provides a fallback. The scoreboard uses telemetry.
</p> </p>
<p class="retention"> <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. 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>
<p class="attribution"> <p class="attribution">
@@ -161,7 +161,6 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.retention { margin-top: var(--space-2); } .retention { margin-top: var(--space-2); }
.retention span { display: block; }
.attribution { margin-top: var(--space-4); } .attribution { margin-top: var(--space-4); }
.request-list { list-style: none; padding: 0; margin: var(--space-5) 0 0; } .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); } .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.) --> <!-- 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> <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)} <tbody>{#each hosts as host (host.id)}
<tr> <tr>
<td><a class="name" href={contextHref(`/hosts/${host.id}`, returnTo)}>{host.name}</a></td> <td><a class="name" href={contextHref(`/hosts/${host.id}`, returnTo)}>{host.name}</a></td>
@@ -31,12 +31,12 @@
<div class="detail-content"> <div class="detail-content">
{#if host.reasons.length}<ul>{#each host.reasons as reason}<li>{reason}</li>{/each}</ul>{/if} {#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>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> <p>Server 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> <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> <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> <dd class="secondary">{allowance.value.source === 'bootstrap' ? 'Default allowance' : `${allowance.value.source === 'host' ? 'Host' : 'Fleet'} observations · ${allowance.value.samples} samples`}</dd>
</div>{/each}</dl> </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> <p>{host.updatedAt ? `Reported ${$timeFormat.dateTime(host.updatedAt)}` : 'No host report yet'}</p>
</div> </div>
</td></tr> </td></tr>
@@ -92,7 +92,7 @@
</div> </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> <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} {#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> <details class="inspection"><summary>Inspect a period</summary>
<div class="period-controls"> <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> <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"> <div class="summary">
<Card><Stat label="Time with players" value={metricPercent(report.summary.active, report.summary.observed)} size="lg" tween={false} /> <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> <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>
<Card><Stat label="Player-hours" value={report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'} size="lg" tween={false} /> <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> <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 { hostDeletionMessage } from '../hosts.js';
import { formatGameBytes } from '../game-files.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 { export function hostReadingsStale(host: Host, now = Date.now()): boolean {
const reported = Date.parse(host.lastHeartbeat ?? ''); const reported = Date.parse(host.lastHeartbeat ?? '');
return !host.connected || !Number.isFinite(reported) || now - reported > 90_000; 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 { serverIdentity } from './server-labels.js';
import type { ServerMatch } from '../match-history.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; 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 { 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'); return createTimeFormatter(zone).relative(value, now.getTime());
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}`;
} }
+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'; 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 { export function mapTitle(map: string | null | undefined): string {
return [...T5_ZOMBIE_MAPS, ...T6_ZOMBIE_MAPS].find((entry) => entry.canonical === map?.toLowerCase())?.friendly ?? map ?? '—'; 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 type { ZombieGame } from '$lib/map-aliases.js';
import { elapsedSince } from '$ui/duration.js'; import { elapsedSince } from '$ui/duration.js';
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
import { plural } from '$ui/plural.js';
let query = $state(''); let query = $state('');
let filter = $state<FleetFilter>('all'); let filter = $state<FleetFilter>('all');
@@ -177,7 +178,7 @@
{/if} {/if}
{#if servers.length} {#if servers.length}
<div class="overview" aria-label="Fleet summary"> <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> <span><strong>{healthy.length}/{servers.length}</strong> healthy</span>
<button class="metric-link" onclick={() => { resetFilters(); filter = 'empty'; }}>{empty} empty {empty === 1 ? 'lobby' : 'lobbies'}</button> <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} {#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} <div class="chips" role="radiogroup" aria-label="Filter servers">{#each filters as item}
{@const count = servers.filter(server => matchesView(server, '', item.id)).length} {@const count = servers.filter(server => matchesView(server, '', item.id)).length}
<label class="filter-chip" class:selected={filter === item.id}> <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> {item.label}<span> {count}</span>
</label> </label>
{/each}</div> {/each}</div>
@@ -276,7 +277,7 @@
</tbody>{/each} </tbody>{/each}
</table></div> </table></div>
{/if} {/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} {/if}
</section> </section>
+1 -1
View File
@@ -21,7 +21,7 @@ export async function GET() {
const checks: SetupCheck[] = [ 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: '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: '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: '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: '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' } { 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 Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import PolicyCard from '$ui/components/autoscaling/PolicyCard.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 { zombieMaps, zombieMapBySlug } from '$lib/map-aliases.js';
import type { ScalingSettings, ScalingPolicy, ScalingReport } from '$lib/autoscaling.js'; import type { ScalingSettings, ScalingPolicy, ScalingReport } from '$lib/autoscaling.js';
let { data }: { data: import('./$types').PageData } = $props(); let { data }: { data: import('./$types').PageData } = $props();
@@ -18,7 +19,10 @@
let editing = $state<ScalingPolicy | null>(null); let editing = $state<ScalingPolicy | null>(null);
let editorOpen = $state(false); let editorOpen = $state(false);
let busy = $state(false), actionError = $state(''), refreshError = $state(''), notice = $state(''); let busy = $state(false), actionError = $state(''), refreshError = $state(''), notice = $state('');
let updatedAt = $state(Date.now());
let refreshing = $state(false);
let generation = 0; let generation = 0;
let pendingRefreshes = 0;
$effect(() => { $effect(() => {
if (!notice) return; if (!notice) return;
const timer = setTimeout(() => notice = '', 6000); const timer = setTimeout(() => notice = '', 6000);
@@ -26,12 +30,14 @@
}); });
async function refresh(signal?: AbortSignal) { async function refresh(signal?: AbortSignal) {
const version = ++generation; const version = ++generation;
pendingRefreshes++; refreshing = true;
try { try {
const response = await fetch('/api/autoscaling', { signal }); const response = await fetch('/api/autoscaling', { signal });
if (!response.ok) throw new Error('Could not refresh policies. The last successful report is shown.'); if (!response.ok) throw new Error('Could not refresh policies. The last successful report is shown.');
const result = await response.json(); const result = await response.json();
if (version === generation && !signal?.aborted) { snapshot = result; refreshError = ''; } if (version === generation && !signal?.aborted) { snapshot = result; refreshError = ''; updatedAt = Date.now(); }
} catch (error) { if (version === generation && !signal?.aborted) refreshError = (error as Error).message; } } catch (error) { if (version === generation && !signal?.aborted) refreshError = (error as Error).message; return false; }
finally { refreshing = --pendingRefreshes > 0; }
} }
onMount(() => pollVisible(refresh, 15_000, false)); onMount(() => pollVisible(refresh, 15_000, false));
function edit(policy: ScalingPolicy | null) { function edit(policy: ScalingPolicy | null) {
@@ -86,8 +92,9 @@
<svelte:head><title>Autoscaling | Electric Cherry</title></svelte:head> <svelte:head><title>Autoscaling | Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<SectionHeader title="Autoscaling" subtitle="Keep empty lobbies available for new groups, within the limits you choose."> <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> </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 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 refreshError}<p role="alert">{refreshError}</p>{/if}
{#if actionError}<p role="alert">{actionError}</p>{/if} {#if actionError}<p role="alert">{actionError}</p>{/if}
+2 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.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}]); const tabs = $derived([{id:'active',label:'Active',badge:counts.active},{id:'history',label:'History',badge:counts.history}]);
let now = $state(Date.now()); 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) { function deadlineLabel(beta: BetaListRow) {
if (!beta.deadlineAt || !active(beta) || beta.testOutcome === 'passed') return ''; if (!beta.deadlineAt || !active(beta) || beta.testOutcome === 'passed') return '';
const minutes = Math.ceil((Date.parse(beta.deadlineAt) - now) / 60000); 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><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 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><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> </tr>
{/each}</tbody> {/each}</tbody>
</table></div> </table></div>
+5 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { countLabel, plural } from '$ui/plural.js';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -123,7 +124,9 @@
throw new Error(body.error ?? 'Promotion could not be started.'); throw new Error(body.error ?? 'Promotion could not be started.');
} }
reviewOpen = false; 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(); await load();
} catch (error) { reviewError = (error as Error).message; } } catch (error) { reviewError = (error as Error).message; }
finally { acting = null; } finally { acting = null; }
@@ -192,7 +195,7 @@
</div> </div>
{#if blocker}<p class:reason={presentation.key === 'attention'}>{blocker}</p>{/if} {#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 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'} {#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> <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} {/if}
+5 -4
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { countLabel } from '$ui/plural.js';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { contextHref, safeReturnHref } from '$lib/navigation-context.js'; import { contextHref, safeReturnHref } from '$lib/navigation-context.js';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte'; 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 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 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 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 scopeCount(file:FileRow) { return fleetServers.filter(server=>!file.scope_type || (file.scope_type==='game'?server.game:server.map)===file.scope_value).length; }
function reconcileSelection() { function reconcileSelection() {
@@ -229,7 +230,7 @@
<h3>Instances that must pass</h3> <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> <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> <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> <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> </div>
{:else}<p><strong>{pickedServers.size} selected</strong> · {candidateServers.length} compatible servers. Restarts wait for confirmed empty lobbies.</p>{/if} {: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} {#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="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> <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 !visibleServers.length}<p>No servers match these filters. <button type="button" class="text-action" onclick={()=>{search='';serverGame='';availability='';}}>Clear filters</button></p>{/if}
{/if} {/if}
</div></details> </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> <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> <div class="review-heading"><h3>Test instances ({serverCount})</h3><Button size="sm" onclick={()=>move(1)}>Edit servers</Button></div>
{#if selectionMode==='automatic'} {#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> <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} {: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> <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"> <script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { page } from '$app/stores'; import { page } from '$app/stores';
@@ -69,14 +70,6 @@
return pollVisible(loadCampaigns, 15000); 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> </script>
<svelte:head><title>Campaigns | Electric Cherry</title></svelte:head> <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> <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} {#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> </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> </tr>
{/each} {/each}
</tbody> </tbody>
+8 -10
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { plural } from '$ui/plural.js';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import Dialog from '$ui/components/primitives/Dialog.svelte'; import Dialog from '$ui/components/primitives/Dialog.svelte';
@@ -163,9 +164,6 @@
v === 'held' ? 'warning' : v === 'held' ? 'warning' :
'neutral'; 'neutral';
} }
function fmt(iso: string | null): string {
return $timeFormat.dateTime(iso);
}
</script> </script>
<svelte:head><title>{detail ? `${detail.deployment.action} campaign · ${detail.deployment.id}` : 'Campaign'} | Electric Cherry</title></svelte:head> <svelte:head><title>{detail ? `${detail.deployment.action} campaign · ${detail.deployment.id}` : 'Campaign'} | Electric Cherry</title></svelte:head>
@@ -191,14 +189,14 @@
</SectionHeader> </SectionHeader>
{#if rollbackError}<p class="err" role="alert">{rollbackError}</p>{/if} {#if rollbackError}<p class="err" role="alert">{rollbackError}</p>{/if}
<div class="metadata"> <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.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} {#if detail.watchdogIncidentId}<span>Source: <a href={contextHref(`/maintenance/${encodeURIComponent(detail.watchdogIncidentId)}`, $page.url)}>Maintenance incident</a></span>{/if}
<details class="campaign-details"> <details class="campaign-details">
<summary>Campaign details</summary> <summary>Campaign details</summary>
<div class="campaign-details-body"> <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> <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} {#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> <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> </div>
@@ -216,7 +214,7 @@
<div class="progress-counts"> <div class="progress-counts">
{#if counts.processing}<span>{counts.processing} processing</span>{/if} {#if counts.processing}<span>{counts.processing} processing</span>{/if}
{#if counts.queued}<span>{counts.queued} queued</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 counts.inactive}<span>{counts.inactive} inactive</span>{/if}
{#if detail.servers.length === 0}<span>No servers recorded</span> {#if detail.servers.length === 0}<span>No servers recorded</span>
{:else if counts.complete === detail.servers.length}<span>All servers activated</span>{/if} {: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> <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} {#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> <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.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>{fmt(s.restarted_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>{fmt(s.server_deleted_at)}</dd></div>{/if} {#if s.server_deleted_at}<div><dt>Deleted</dt><dd><RelativeTime value={s.server_deleted_at} /></dd></div>{/if}
</dl> </dl>
<div class="detail-actions"> <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} {#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 CapacityHosts from '$ui/components/capacity/CapacityHosts.svelte';
import CapacityChart from '$ui/components/capacity/CapacityChart.svelte'; import CapacityChart from '$ui/components/capacity/CapacityChart.svelte';
import Freshness from '$ui/components/Freshness.svelte'; import Freshness from '$ui/components/Freshness.svelte';
import { plural } from '$ui/plural.js';
let { data }: { data: CapacityData } = $props(); let { data }: { data: CapacityData } = $props();
let snapshot = $state<CapacityData | null>(null); 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} {#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="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> <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 class="freshness">{#if navigating}<span role="status">Updating capacity…</span>{:else if !refreshError}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 30 seconds" />{/if}</div>
</div> </div>
<section class="overview" aria-labelledby="capacity-now-title"> <section class="overview" aria-labelledby="capacity-now-title">
<div class="overview-heading"><div> <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> <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"> </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> {#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 instances'}</span>{/if}<small>Fleet-wide</small> {:else}<span>{summary.queued === null ? 'Queue unavailable' : 'No queued servers'}</span>{/if}<small>Fleet-wide</small>
</div></div> </div></div>
<dl class="resources" aria-label="Current resource headroom"> <dl class="resources" aria-label="Current resource headroom">
<div><dt>CPU available</dt><dd>{count(summary.cpuCoresAvailable)} cores</dd></div> <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} {:else}<CapacityHosts hosts={report.current.hosts} returnTo={page.url.pathname + page.url.search} />{/if}
</section> </section>
<details class="methodology"><summary>Capacity details</summary> <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>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>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>{count(summary.activeStartups)} instances starting across the fleet. Launches wait for capacity and startup slots automatically.</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> <p>{report.current.updatedAt ? `Report updated ${$timeFormat.dateTime(report.current.updatedAt)}.` : 'Waiting for host reports.'} Refreshes every 30 seconds while visible.</p>
</details> </details>
<CapacityChart history={report.history} scope={scopeName}> <CapacityChart history={report.history} scope={scopeName}>
+4 -3
View File
@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import { plural } from '$ui/plural.js';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { confirmAction } from '$ui/confirm.js'; import { confirmAction } from '$ui/confirm.js';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.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)} <div class="launches">{#each visibleLaunches.slice(0,launchLimit) as collection (collection.id)}
<a class="collection-row" href={contextHref(`/collections/launches/${collection.id}`, $page.url)}> <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="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-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> </a>
{/each}</div> {/each}</div>
{:else if !launchError} {:else if !launchError}
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { plural } from '$ui/plural.js';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
@@ -106,7 +107,7 @@
<div><span class="eyebrow">Collection progress</span><h2>{heading}</h2></div> <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> <span class="completion"><strong>{collection.done + collection.failed + collection.cancelled}</strong> / {collection.total} finished</span>
</div> </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-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-failed" style:width={`${collection.failed / (collection.total || 1) * 100}%`}></span>
<span class="progress-cancelled" style:width={`${collection.cancelled / (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> <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} {#if cancellationRequested || collection.cancelled > 0}<div><dt>Cancelled</dt><dd>{collection.cancelled}</dd></div>{/if}
</dl> </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} {#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> <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} {/if}
+11 -10
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <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 BackLink from '$ui/components/BackLink.svelte';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
@@ -37,8 +39,7 @@
const progressBytes = $derived(Math.min(totalBytes, acceptedBytes + sendingBytes)); const progressBytes = $derived(Math.min(totalBytes, acceptedBytes + sendingBytes));
const transferring = $derived(uploadBusy && (phase === 'preparing' || phase === 'uploading')); 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 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 gameName = (game: 't5' | 't6') => game === 't5' ? 'Black Ops I' : 'Black Ops II';
const uploadDate = (item: OverviewLibrary['versions'][number]) => new Date(item.publishedAt ?? item.createdAt).toLocaleString();
async function responseBody(response: Response) { async function responseBody(response: Response) {
let body; let body;
@@ -102,7 +103,7 @@
} }
if (disposed) return; if (disposed) return;
if (status.status !== 'published') throw new Error(status.error || 'Verification did not finish. Retry this upload to continue.'); 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 = ''; if (folderInput) folderInput.value = '';
await load(); 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}<p class="muted">Administrators upload game files. Active hosts prepare verified copies automatically.</p>{/if}
{#if canManage && showUpload} {#if canManage && showUpload}
<section class="card" id="game-upload"> <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> <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} {#if uploadError}<p class="ui-error" role="alert">{uploadError}</p>{/if}
<form onsubmit={startUpload}> <form onsubmit={startUpload}>
@@ -227,7 +228,7 @@
</div>{/if} </div>{/if}
<div class="upload-actions"> <div class="upload-actions">
{#if transferring}<Button onclick={pauseUpload} disabled={pauseRequested}>{pauseRequested ? 'Pausing…' : 'Pause upload'}</Button> {#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} {#if phase === 'idle' || phase === 'uploading'}<span class="muted">Keep this page open while files transfer. Interrupted uploads can be resumed below.</span>{/if}
</div> </div>
</form> </form>
@@ -241,13 +242,13 @@
<section class="card"> <section class="card">
<div class="heading"> <div class="heading">
<div><h2>{gameName(library.game)}</h2>{#if latest}<p class="muted">{formatGameBytes(latest.sizeBytes)} · {latest.fileCount.toLocaleString()} files</p>{/if}</div> <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> </div>
{#if latest} {#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> <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 /> <GameFileHosts caches={latest.caches} {hosts} latest />
{:else} {: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}
{#if unfinished.length} {#if unfinished.length}
<div class="uploads"> <div class="uploads">
@@ -255,7 +256,7 @@
{#each unfinished as item (item.id)} {#each unfinished as item (item.id)}
{@const browserUpload = !item.sourceHostId} {@const browserUpload = !item.sourceHostId}
<article class="upload-entry"> <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 item.error}<p class="ui-error">{item.error}</p>{/if}
{#if browserUpload} {#if browserUpload}
<p class="muted">Resume with the original unchanged folder. The latest files stay available while this upload completes.</p> <p class="muted">Resume with the original unchanged folder. The latest files stay available while this upload completes.</p>
@@ -266,14 +267,14 @@
</div> </div>
{/if} {/if}
{#if latest || history.length}<details class="library-details"><summary>Details</summary><div class="detail-content"> {#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} {#if history.length}
<details class="history"> <details class="history">
<summary>Previous uploads ({history.length})</summary> <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> <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)} {#each history as item (item.id)}
<article class="upload-entry"> <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} {#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> <p>{formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files</p>
{@render uploadDetails(item)} {@render uploadDetails(item)}
+2 -1
View File
@@ -3,6 +3,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { hostListStatus, stableHostOrder } from '$ui/host-overview.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 SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import AddHostDrawer from '$ui/components/AddHostDrawer.svelte'; import AddHostDrawer from '$ui/components/AddHostDrawer.svelte';
@@ -52,7 +53,7 @@
</SectionHeader> </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 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} {#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"> {#if filtersVisible}<div class="filters">
<input type="search" aria-label="Search hosts" placeholder="Search hosts…" bind:value={search} /> <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> <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"> <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 Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte'; import Tabs from '$ui/components/primitives/Tabs.svelte';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
@@ -8,6 +10,8 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import type { Host, CapacityCalibration } from '$lib/hosts.js'; 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 { hostAgentLaneLabel, hostAgentLaneState, hostAgentUpdateLabel, hostAgentUpdateMessage, hostAgentUpdatesEnabled, hostDeletionMessage, hostReadinessLabel } from '$lib/hosts.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
@@ -74,8 +78,8 @@
</SectionHeader> </SectionHeader>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if} {#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 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} {#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: {host.lastHeartbeat ? new Date(host.lastHeartbeat).toLocaleString() : 'Never'}</span></div> <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.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} {#if host.readiness !== 'ready' && tab !== 'setup'}<Button size="sm" onclick={() => changeTab('setup')}>Review setup</Button>{/if}
</section> </section>
@@ -84,7 +88,7 @@
{#if tab === 'overview'} {#if tab === 'overview'}
<section class="panel"><h2>Agent workers</h2> <section class="panel"><h2>Agent workers</h2>
{#if host.inventory.agentLanes?.length}<dl>{#each host.inventory.agentLanes as lane (lane.name)} {#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} {/each}</dl>{:else}<p>This agent has not reported worker status yet.</p>{/if}
</section> </section>
<section class="panel"><h2>Inventory</h2><dl> <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> <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]} <dl>{#each ['startup', 'running'] as phase}{@const allowance = calibration[phase as keyof CapacityCalibration]}
<dt>{phase === 'startup' ? 'Starting a server' : 'Running a server'}</dt> <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> {/each}</dl>
{#if host.inventory.instanceResourcesError}<p role="status">{host.inventory.instanceResourcesError}</p>{/if} {#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} {#key host.id}<CapacitySamples hostId={host.id} />{/key}
</section>{/if} </section>{/if}
{:else if tab === 'servers'} {:else if tab === 'servers'}
<section class="panel" aria-labelledby="running-instances-heading"> <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> <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 !host.connected && instances.length}<p role="status">The host is offline. Instance details reflect the latest reports.</p>{/if}
{#if instances.length} {#if instances.length}
@@ -119,7 +123,7 @@
{:else if tab === 'setup'} {:else if tab === 'setup'}
{#if !cleaning}<section class="panel"><h2>Automatic host setup</h2><HostPreparation {host} /></section>{/if} {#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> <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.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.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} {#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}> <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> <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> </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> </section>
{/if} {/if}
</div> </div>
+17 -13
View File
@@ -5,16 +5,21 @@
import type { HostOperationFilter, HostOperationHistoryPage } from '$lib/hosts.js'; import type { HostOperationFilter, HostOperationHistoryPage } from '$lib/hosts.js';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import BackLink from '$ui/components/BackLink.svelte'; import BackLink from '$ui/components/BackLink.svelte';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.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 { data }: { data: PageData } = $props();
let snapshot = $state.raw<{ source: PageData; history: HostOperationHistoryPage } | null>(null); let snapshot = $state.raw<{ source: PageData; history: HostOperationHistoryPage } | null>(null);
let refreshing = $state(false); let refreshing = $state(false);
let refreshError = $state(''); let refreshError = $state('');
let updatedAt = $state(Date.now());
let request: AbortController | null = null; let request: AbortController | null = null;
let sequence = 0; let sequence = 0;
const history = $derived(snapshot?.source === data ? snapshot.history : data.history); const history = $derived(snapshot?.source === data ? snapshot.history : data.history);
@@ -54,7 +59,7 @@
}); });
const body = await response.json(); const body = await response.json();
if (!response.ok) throw new Error('Could not refresh operation history.'); 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 { catch {
if (current === sequence && data === source && !signal?.aborted && !controller.signal.aborted) { if (current === sequence && data === source && !signal?.aborted && !controller.signal.aborted) {
@@ -64,7 +69,7 @@
} finally { if (current === sequence) { refreshing = false; request = null; } } } finally { if (current === sequence) { refreshing = false; request = null; } }
} }
beforeNavigate(cancel); 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(); }; }); onMount(() => { const stop = pollVisible(refresh, 15000, false); return () => { stop(); cancel(); }; });
</script> </script>
@@ -72,11 +77,11 @@
<section class="page" aria-busy={changingPage}> <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."> <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> </SectionHeader>
{#if data.scope.operation || $page.url.searchParams.has('returnTo')}<BackLink fallback={data.scope.operation ? '/activity' : '/hosts'} />{/if} {#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.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"> <div class="history-filters">
<nav class="filter-links" aria-label="Operation history filters"> <nav class="filter-links" aria-label="Operation history filters">
{#each filters as filter} {#each filters as filter}
@@ -86,7 +91,6 @@
</nav> </nav>
<p>{selectedFilter.description}</p> <p>{selectedFilter.description}</p>
</div> </div>
{#if refreshError}<p class="ui-error" role="alert">{refreshError}</p>{/if}
<section class="panel" aria-label="Host operation history"> <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> {#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} {: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> <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)} <tbody>{#each history.operations as operation (operation.id)}
<tr> <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>{#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"> <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} {#if operation.targetPath}<small class="target-path">{operation.targetPath}</small>{/if}
</td> </td>
<td class="source">{operation.source ?? 'Not recorded'}</td> <td class="source">{operation.source ?? 'Not recorded'}</td>
<td class="state" class:failed={operation.state === 'failed'}>{operation.state}</td> <td><StatusBadge state={operation.state === 'completed' ? 'done' : operation.state === 'pending' ? 'queued' : operation.state === 'accepted' ? 'running' : operation.state} /></td>
<td class="updated">{$timeFormat.dateTime(operation.updatedAt)}</td> <td class="updated"><RelativeTime value={operation.updatedAt} /></td>
<td class="result">{operation.error ?? '—'}</td> <td class="result">{operation.error ?? '—'}</td>
</tr> </tr>
{/each}</tbody> {/each}</tbody>
@@ -137,7 +142,7 @@
.filter-links a.active { color: var(--text-primary); background: var(--surface-raised); } .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; } .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; } .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-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); } 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; } 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 { min-width: 180px; max-width: 320px; }
.target-path { font-family: var(--font-mono); } .target-path { font-family: var(--font-mono); }
.source { min-width: 120px; max-width: 220px; } .source { min-width: 120px; max-width: 220px; }
.state { text-transform: capitalize; } summary { cursor: pointer; color: var(--text-secondary); font-size: var(--text-xs); }
.failed { color: var(--state-danger); }
.updated { white-space: nowrap; } .updated { white-space: nowrap; }
.result { min-width: 160px; max-width: 420px; } .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); } .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"> <script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
let { data: initial }: { data: import('./$types').PageData } = $props(); let { data: initial }: { data: import('./$types').PageData } = $props();
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page, navigating } from '$app/stores'; 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>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>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>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> </div>
</details> </details>
</section> </section>
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { playerCompletionLabel } from '$ui/player-match.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto, invalidateAll } from '$app/navigation'; import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
@@ -39,7 +41,6 @@
if (scope.players) params.set('players', scope.players); if (scope.players) params.set('players', scope.players);
return `/leaderboard?${params}`; 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 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 overviewMetrics = playerStatMetrics.filter(metric => ['kills', 'roundsSurvived', 'revives', 'gamesCompleted'].includes(metric.key));
const otherMetrics = playerStatMetrics.filter(metric => ['headshots', 'downs', 'bestKills', 'bestScore'].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.) --> <!-- 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"> <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> <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> </table>
</div> </div>
</section> </section>
@@ -100,13 +101,13 @@
<div class="mobile-records"><PlayerMatchCards matches={profile.matches} {returnTo} /></div> <div class="mobile-records"><PlayerMatchCards matches={profile.matches} {returnTo} /></div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable match history for keyboard users.) --> <!-- 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"> <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>
<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> <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} {: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>
<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> <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> </section>
<Dialog bind:open={filterOpen} title="Player filters" description="Choose the leaderboard ranking and match history. Lifetime records stay unchanged."> <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}> <div class="scope-controls" aria-busy={filtering}>
+10 -8
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import ActivityLog from '$ui/components/ActivityLog.svelte'; import ActivityLog from '$ui/components/ActivityLog.svelte';
import BackLink from '$ui/components/BackLink.svelte'; import BackLink from '$ui/components/BackLink.svelte';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
@@ -23,6 +24,7 @@
import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte'; import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import { groupMaintenanceScanErrors, groupMaintenanceVersions } from '$ui/maintenance-presentation.js'; import { groupMaintenanceScanErrors, groupMaintenanceVersions } from '$ui/maintenance-presentation.js';
import { countLabel, plural } from '$ui/plural.js';
let data = $state<MaintenanceOverview | null>(null); let data = $state<MaintenanceOverview | null>(null);
let error = $state(''); 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> <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} {:else}
{#if view === 'overview'} {#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.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} {#if scanErrors.length}
<section class="scan-errors" aria-labelledby="scan-errors-heading"> <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> <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)} <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> <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} {#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> <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"> <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> <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> <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} {#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> <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)} <div class="version-groups">{#each versionGroups as group (group.key)}
<article class="version-group"> <article class="version-group">
<div class="row-heading"> <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"> <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> {#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} {: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> </div>
@@ -226,7 +228,7 @@
<li><div class="row-heading"><a href={contextHref(`/servers/${encodeURIComponent(version.server)}`, $page.url)}><ServerIdentity name={version.server} /></a> <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} {#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> </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} {#if version.error}<pre class="version-error">{version.error}</pre>{/if}
</li> </li>
{/each}</ul> {/each}</ul>
@@ -242,7 +244,7 @@
<article class="card" id={item.key}> <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> <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} {#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} <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 === '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} {#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"> <script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { page } from '$app/stores'; 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> <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_stage}<p>{action.operation_stage}</p>{/if}
{#if action.operation_error}<p class="error">{action.operation_error}</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> <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> </article>
{/each} {/each}
@@ -165,7 +166,7 @@
{#if !events.length}<EmptyState title="No activity recorded yet" /> {#if !events.length}<EmptyState title="No activity recorded yet" />
{:else}<ol class="timeline"> {:else}<ol class="timeline">
{#each events.slice(0, eventLimit) as event (event.id)} {#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} {/each}
</ol><ListFooter total={events.length} bind:limit={eventLimit} label="events" />{/if} </ol><ListFooter total={events.length} bind:limit={eventLimit} label="events" />{/if}
</section> </section>
@@ -190,7 +191,7 @@
.links, .row-heading a { font-size: var(--text-sm); } .links, .row-heading a { font-size: var(--text-sm); }
a { color: var(--accent-primary-hover); } a { color: var(--accent-primary-hover); }
dl { display: flex; gap: 16px 32px; flex-wrap: wrap; margin: 0; } 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; } 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; } 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); } dd code { display: block; margin-top: 8px; color: var(--text-secondary); }
+4 -4
View File
@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte'; import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import type { MatchDetail } from '$lib/match-history.js'; import type { MatchDetail } from '$lib/match-history.js';
import { playerProfileHref } from '$lib/leaderboard.js'; import { playerProfileHref } from '$lib/leaderboard.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { formatDuration } from '$ui/duration.js'; import { formatDuration } from '$ui/duration.js';
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte'; import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
@@ -48,8 +48,8 @@
<h2>Match details</h2> <h2>Match details</h2>
<dl class="details"> <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 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>{match.startObserved ? 'Started' : 'First observed'}</dt><dd><RelativeTime value={match.startedAt} /></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>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> <div><dt>Players at start</dt><dd>{match.startPlayers ?? '—'}</dd></div>
</dl> </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} {#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> </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> <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} {#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> </tr>
{/each} {/each}
</tbody> </tbody>
+49 -34
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import BackLink from '$ui/components/BackLink.svelte'; import BackLink from '$ui/components/BackLink.svelte';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount, untrack } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
@@ -13,6 +13,8 @@
import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte'; import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
import HousePartyDialog from '$ui/components/HousePartyDialog.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 LifecycleDialog from '$ui/components/LifecycleDialog.svelte';
import { provisioningListStatuses, provisioningListSources, provisioningPageSize, provisioningPageOffset, type ProvisioningListRow as Deployment, type ProvisioningListPage } from '$lib/provisioning-list.js'; 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 expanded = $state<Record<string, boolean>>({});
let fleetActionsOpen = $state(false); let fleetActionsOpen = $state(false);
let now = $state(Date.now()); 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 cancelling = $state(false);
let cancellationNotice = $state(''); let cancellationNotice = $state('');
$effect(() => { $effect(() => {
@@ -84,7 +89,7 @@
const tabs = [{id:'active',label:'Active'},{id:'history',label:'History'}]; const tabs = [{id:'active',label:'Active'},{id:'history',label:'History'}];
let action = $state('all'); let action = $state('all');
let search = $state(''); let search = $state('');
let refreshing = false; let refreshing = $state(false);
async function load(signal?: AbortSignal) { async function load(signal?: AbortSignal) {
if (refreshing) return; if (refreshing) return;
refreshing = true; refreshing = true;
@@ -92,40 +97,49 @@
const res = await fetch('/api/provisioning?view=active', { signal, cache: 'no-store' }); const res = await fetch('/api/provisioning?view=active', { signal, cache: 'no-store' });
if (!res.ok) throw new Error('Could not load provisioning activity.'); if (!res.ok) throw new Error('Could not load provisioning activity.');
const result: ProvisioningListPage = await res.json(); 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; needsReview = result.needsReview;
error = ''; error = '';
} catch (err) { if (!signal?.aborted) error = (err as Error).message; return false; } } catch (err) { if (!signal?.aborted) error = (err as Error).message; return false; }
finally { loading = false; refreshing = 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(() => { $effect(() => {
if (tab !== 'history') return; if (tab !== 'history') return;
const params = new URLSearchParams({ view: 'history', action, status: historyState, source, const params = new URLSearchParams({ view: 'history', action, status: historyState, source,
server: search, limit: String(limit), offset: String(offset) }); server: search, limit: String(limit), offset: String(offset) });
void historyRefresh; void historyRefresh;
const controller = new AbortController(); return untrack(() => {
historyLoading = true; if (previousHistoryQuery !== params.toString()) {
historyError = ''; history = []; historyTotal = 0; historyUpdatedAt = null; historyError = '';
history = []; previousHistoryQuery = params.toString();
historyTotal = 0; }
void (async () => { let disposed = false;
try { let first = true;
const response = await fetch(`/api/provisioning?${params}`, { signal: controller.signal, cache: 'no-store' }); const stop = pollVisible(async signal => {
if (!response.ok) throw new Error('Could not load provisioning history.'); // A new record can shift this page. Keep keyboard focus stable
const result: ProvisioningListPage = await response.json(); // until it leaves the results; an explicit Retry still runs.
if (!controller.signal.aborted) { history = result.deployments; historyTotal = result.total; historyUpdatedAt = new Date().toISOString(); now = Date.now(); } if (!first && historyResults?.contains(document.activeElement)) return;
} catch (err) { if (!controller.signal.aborted) historyError = (err as Error).message; } first = false;
finally { if (!controller.signal.aborted) historyLoading = false; } historyLoading = true;
})(); try {
return () => controller.abort(); 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); }; }); 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 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 filtered = $derived(action !== 'all' || source !== 'all' || historyState !== 'all' || Boolean(search));
const visibleActive = $derived(active.filter(row => { const visibleActive = $derived(active.filter(row => {
if (action !== 'all' && row.action !== action) return false; if (action !== 'all' && row.action !== action) return false;
@@ -173,18 +187,18 @@
</div> </div>
<LifecycleDialog bind:open={lifecycleOpen} /> <LifecycleDialog bind:open={lifecycleOpen} />
<DeployServerDialog bind:open /> <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 housePartyNotice}<p role="status">{housePartyNotice}</p>{/if}
{#if cancellationNotice}<p role="status">{cancellationNotice}</p>{/if} {#if cancellationNotice}<p role="status">{cancellationNotice}</p>{/if}
<div id="deployment-panel" role="tabpanel" aria-labelledby={"deployment-tabs-" + tab} class="panels"> <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"> <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> {#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} {:else}<h2>History <span>{historyTotal}</span></h2>{/if}
<div class="queue-tools"> <div class="queue-tools">
{#if needsReview}<Button size="sm" variant="ghost" onclick={reviewAttention}>Needs attention ({needsReview})</Button>{/if} {#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> {#if tab === 'active'}<Freshness {updatedAt} error={error ? 'Operation updates interrupted.' : ''} intervalLabel="Updates every 5 seconds" onRetry={() => void load()} retryDisabled={refreshing} />
{:else}<span class="muted">{historyUpdatedAt ? `Updated ${age(historyUpdatedAt).toLowerCase()}` : ''}</span><Button size="sm" variant="ghost" disabled={historyLoading} onclick={() => historyRefresh++}>Refresh</Button>{/if} {:else}<Freshness updatedAt={historyUpdatedAt} error={historyError ? 'History updates interrupted.' : ''} intervalLabel="Updates every 15 seconds" onRetry={() => historyRefresh++} retryDisabled={historyLoading} />{/if}
</div> </div>
</div> </div>
{#if tab === 'active'} {#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 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}{@render operationTable(visibleActive)}{/if}
{:else} {:else}
{#if historyError}<p role="alert" class="error">{historyError} <button onclick={() => historyRefresh++}>Retry</button></p>{/if} <div class="history-results" bind:this={historyResults}>
{#if historyLoading}<p class="muted">Loading provisioning history…</p> {#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 if !historyError && !history.length}<EmptyState title="No matching operations" description="Try another search or clear the filters." />
{:else}{@render operationTable(history)}{/if} {:else}{@render operationTable(history)}{/if}
<nav class="pagination" aria-label="Provisioning history pages"> <nav class="pagination" aria-label="Provisioning history pages">
<span role="status">{history.length ? `${offset + 1}–${offset + history.length}` : '0'} of {historyTotal} operations</span> <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} {#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> </nav>
</div>
{/if} {/if}
</div> </div>
</div> </div>
@@ -217,7 +233,7 @@
<td><a href={operationHref}>{actionLabels[row.action]}</a></td> <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><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 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> <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>
<tr hidden={!isExpanded} id={`operation-${row.id}`} class="detail-row"><td colspan="6"> <tr hidden={!isExpanded} id={`operation-${row.id}`} class="detail-row"><td colspan="6">
@@ -234,7 +250,7 @@
{/snippet} {/snippet}
<style> <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%; } .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; } 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); } 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); } 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; } 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; } 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; } .text-button { 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; }
.detail-row { background:var(--surface); }.operation-details { display:grid; gap:16px; line-height:1.6; overflow-wrap:anywhere; } .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); } 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); } .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 { PageData } from './$types';
import type { ProvisioningDetail } from '$lib/provisioning-detail.js'; import type { ProvisioningDetail } from '$lib/provisioning-detail.js';
import { actionLabels } from '$lib/provisioning.js'; import { actionLabels } from '$lib/provisioning.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
import { createProvisioningRefresh, type ProvisioningRefreshState } from '$ui/provisioning-refresh.js'; import { createProvisioningRefresh, type ProvisioningRefreshState } from '$ui/provisioning-refresh.js';
@@ -22,6 +21,8 @@
import ProvisioningActivity from '$ui/components/ProvisioningActivity.svelte'; import ProvisioningActivity from '$ui/components/ProvisioningActivity.svelte';
import LaunchRecipe from '$ui/components/LaunchRecipe.svelte'; import LaunchRecipe from '$ui/components/LaunchRecipe.svelte';
import DeployServerDialog from '$ui/components/DeployServerDialog.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(); let { data }: { data: PageData } = $props();
const initial = untrack(() => data.detail); const initial = untrack(() => data.detail);
@@ -58,7 +59,6 @@
}); });
onMount(() => pollVisible(() => { now = Date.now(); return controller.refresh(); }, 5000, false)); onMount(() => pollVisible(() => { now = Date.now(); return controller.refresh(); }, 5000, false));
onDestroy(() => controller.dispose()); onDestroy(() => controller.dispose());
function fmt(value: string | null | undefined) { return value ? $timeFormat.dateTime(value) : 'Not recorded'; }
async function overrideSafety() { async function overrideSafety() {
if (!deployment || !canOverride || view.busy || detail?.safetyOverrideUnavailableReason) return; if (!deployment || !canOverride || view.busy || detail?.safetyOverrideUnavailableReason) return;
const id = deployment.id; 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 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> </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} {#if deployment && detail && outcome}
<section class="outcome" class:attention={['failed', 'interrupted', 'warning'].includes(deployment.state)} aria-labelledby="outcome-title"> <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-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> <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 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.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} {#if detail.canResumeVerification || detail.canResumeCleanup || detail.canResumeRebuild || detail.canCancel || detail.canCancelCreation || canOverride}
<div class="recovery-actions"> <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} {#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.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 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?.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} {#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 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> <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} {#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>
<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> </section>
{#key deployment.id} {#key deployment.id}
@@ -139,8 +139,8 @@
<h2>Host agent execution</h2> <h2>Host agent execution</h2>
<dl class="metadata"> <dl class="metadata">
<div><dt>Agent state</dt><dd>{detail.agentOperation.state}</dd></div> <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>Command queued</dt><dd><RelativeTime value={detail.agentOperation.createdAt} missing="Not recorded" /></dd></div>
<div><dt>Latest agent report</dt><dd>{fmt(detail.agentOperation.updatedAt)}</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> <div><dt>Command ID</dt><dd><code>{detail.agentOperation.id}</code></dd></div>
</dl> </dl>
{#if detail.agentOperation.error}<p>{detail.agentOperation.error}</p>{/if} {#if detail.agentOperation.error}<p>{detail.agentOperation.error}</p>{/if}
@@ -160,10 +160,10 @@
<Card as="section" padding="md" ariaLabel="Operation details"> <Card as="section" padding="md" ariaLabel="Operation details">
<div class="context-section"><h2>Operation details</h2><dl class="metadata"> <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 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>Requested at</dt><dd><RelativeTime value={deployment.started_at} /></dd></div>
<div><dt>Submission attempted</dt><dd>{fmt(deployment.submitted_at)}</dd></div> <div><dt>Submission attempted</dt><dd><RelativeTime value={deployment.submitted_at} missing="Not recorded" /></dd></div>
<div><dt>Finished</dt><dd>{deployment.finished_at ? fmt(deployment.finished_at) : outcome.active ? 'In progress' : '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>{fmt(deployment.server_deleted_at)}</dd></div>{/if} {#if deployment.server_deleted_at}<div><dt>Identity deleted</dt><dd><RelativeTime value={deployment.server_deleted_at} /></dd></div>{/if}
</dl></div> </dl></div>
</Card> </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"> {#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.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.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} {#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} </div></Card>{/if}
<Card as="section" padding="md" ariaLabel="Recorded launch inputs"><div class="context-section recipe"><h2>{recipeHeading}</h2> <Card as="section" padding="md" ariaLabel="Recorded launch inputs"><div class="context-section recipe"><h2>{recipeHeading}</h2>
{#if detail.recipe} {#if detail.recipe}
{#if detail.blueprintName}<p>From {detail.blueprintName}</p>{/if} {#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>{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} /> <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> <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} {: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; } 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; } 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; } 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); } .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 { 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; } .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; } .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); } .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); } .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); } .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); }
.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)); }
.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); } .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; } .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); } .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); } .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); } .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: 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> </style>
+1 -1
View File
@@ -101,7 +101,7 @@
<section class="library" aria-label="Script library"> <section class="library" aria-label="Script library">
{#if loading || scripts.length || error}<div class="library-toolbar"> {#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 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 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">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> <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"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -112,7 +112,7 @@
<tr> <tr>
<td><strong>{key.game.toUpperCase()}</strong><span class="key-label">{key.label || 'Unlabeled key'}</span></td> <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>{#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> <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> </tr>
{/each}</tbody> {/each}</tbody>
@@ -123,7 +123,7 @@
<Dialog bind:open title="Add key" busy={busy}> <Dialog bind:open title="Add key" busy={busy}>
{#if error}<p role="alert" class="error">{error}</p>{/if} {#if error}<p role="alert" class="error">{error}</p>{/if}
<form onsubmit={save}> <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."> <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} /> <Input bind:value={label} placeholder="e.g. Electric Cherry 5" disabled={busy} />
</Field> </Field>
+3 -3
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { tick } from 'svelte'; import { tick } from 'svelte';
import ActivityLog from '$ui/components/ActivityLog.svelte'; import ActivityLog from '$ui/components/ActivityLog.svelte';
import { timeFormat } from '$ui/stores/time-zone.js';
import { freshTelemetry } from '$lib/server-observation.js'; import { freshTelemetry } from '$lib/server-observation.js';
import { freshStatusTimestamp } from '$lib/server-freshness.js'; import { freshStatusTimestamp } from '$lib/server-freshness.js';
import { serverSection, serverSectionAnchor } from '$lib/server-sections.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 tabError}<div class="ui-error" role="alert">{tabError}<Button size="sm" onclick={() => loadTab(name, activeSection)}>Retry</Button></div>{/if}
{#if activeSection === 'live'} {#if activeSection === 'live'}
<section id="scoreboard" aria-label="Scoreboard"> <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"> <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} {#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> </Card>
@@ -285,7 +285,7 @@
{:else} {: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> <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} {#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} {/each}
</tbody></table></div> </tbody></table></div>
{/if} {/if}
+3 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { invalidateAll } from '$app/navigation'; 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-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="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="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> <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> </article>{/each}</div>
{/if} {/if}
@@ -110,7 +111,7 @@
{#if auditLoading && !events.length}<p class="muted" role="status">Loading account activity…</p> {#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 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> {: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} </tbody></table></div>{/if}
</section> </section>
{/if} {/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> <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> <Button type="submit" variant="primary" disabled={busy} loading={busy}>Create administrator</Button>
</fieldset></form> </fieldset></form>
<a class="login-link" href="/login">Already have an account? Sign in</a>
</section> </section>
<style> <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; } .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; } 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; } .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; } } @media(max-width:540px) { .auth-card { padding:24px; } }
</style> </style>
+1 -1
View File
@@ -170,7 +170,7 @@ describe('browser folder uploads', () => {
expect(next.version).not.toBe(first.version); expect(next.version).not.toBe(first.version);
expect(next.libraryId).toBe(first.libraryId); expect(next.libraryId).toBe(first.libraryId);
expect(otherGame.libraryId).not.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 () => { 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', () => { describe('fixed game file slots', () => {
it('always lists exactly T5 and T6, including before any files are uploaded', () => { it('always lists exactly T5 and T6, including before any files are uploaded', () => {
expect(listLibraries()).toEqual([ expect(listLibraries()).toEqual([
{ id: 't5', game: 't5', name: 'Black Ops I (T5)', 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 (T6)', description: '', createdAt: '', latestVersionId: null, versions: [] } { id: 't6', game: 't6', name: 'Black Ops II', description: '', createdAt: '', latestVersionId: null, versions: [] }
]); ]);
expect(latestPublishedLibraries()).toEqual([]); expect(latestPublishedLibraries()).toEqual([]);
expect(newestReadyLibrary('t5')).toBeNull(); 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: 'disabled' });
hosts.updateHost(id, { mode: 'active' }); hosts.updateHost(id, { mode: 'active' });
const status = preparation.reconcileHostPreparation(id); 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'); expect(hosts.getHost(id)?.mode).toBe('disabled');
}); });
@@ -510,8 +510,8 @@ describe('automatic host preparation', () => {
hosts.updateHost(id, { minimumFreeBytes: 1_000 }); hosts.updateHost(id, { minimumFreeBytes: 1_000 });
const status = preparation.reconcileHostPreparation(id); const status = preparation.reconcileHostPreparation(id);
expect(status.items).toEqual(expect.arrayContaining([ expect(status.items).toEqual(expect.arrayContaining([
expect.objectContaining({ label: 'Black Ops I (T5)', state: 'blocked' }), expect.objectContaining({ label: 'Black Ops I', state: 'blocked' }),
expect.objectContaining({ label: 'Black Ops II (T6)', state: 'preparing' }) expect.objectContaining({ label: 'Black Ops II', state: 'preparing' })
])); ]));
expect(operations(id)).toHaveLength(1); expect(operations(id)).toHaveLength(1);
expect(operations(id)[0].payload.libraryVersionId).toBe(small.id); 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 { afterEach, describe, expect, it, vi } from 'vitest';
import { get } from 'svelte/store'; 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'; import { startTimeZonePreference, timeFormat, timeZone, timeZonePersistenceError, TIME_ZONE_STORAGE_KEY } from '../../src/lib/ui/stores/time-zone.js';
let stop: (() => void) | undefined; let stop: (() => void) | undefined;
@@ -52,9 +52,29 @@ describe('timezone formatting', () => {
it('renders missing or invalid timestamps without crashing the page', () => { it('renders missing or invalid timestamps without crashing the page', () => {
for (const value of [null, undefined, '', 'bad timestamp', NaN, new Date(NaN)]) { for (const value of [null, undefined, '', 'bad timestamp', NaN, new Date(NaN)]) {
expect(ny.dateTime(value)).toBe('—'); 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', () => { it('offers valid zones including New York and UTC, with an older-browser fallback', () => {
expect(availableTimeZones()).toContain(DEFAULT_TIME_ZONE); expect(availableTimeZones()).toContain(DEFAULT_TIME_ZONE);
expect(availableTimeZones()).toContain('UTC'); 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-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('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('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', () => { 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.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('range=7d&amp;game=t5&amp;host=ec-1');
expect(body).toContain('3 of 3 available'); 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('href="/provisioning"');
expect(body).not.toContain('Manage hosts'); expect(body).not.toContain('Manage hosts');
expect(body).toContain('Updates every 30 seconds'); 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; const body = render(CapacityPage, { props: { data: report } }).body;
expect(body).toContain('Capacity estimate unavailable'); expect(body).toContain('Capacity estimate unavailable');
expect(body).toContain('href="/provisioning"'); expect(body).toContain('href="/provisioning"');
expect(body).toContain('2 instances queued'); expect(body).toContain('2 servers queued');
expect(body).toContain('Fleet-wide'); expect(body).toContain('Fleet-wide');
}); });
it('keeps ineligible hosts and their reasons visible while collapsing detailed assumptions', () => { 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; 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('Not eligible');
expect(body).toContain('Latest game files are not ready.'); expect(body).toContain('Latest game files are not ready.');
expect(body).toContain('Offline'); 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).not.toContain('>Ready<');
expect(body).toMatch(/<tr hidden[^>]*id="capacity-host-ec-1"/); expect(body).toMatch(/<tr hidden[^>]*id="capacity-host-ec-1"/);
expect(body).toContain('aria-expanded="false"'); 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"'); 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', () => { 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; 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('3 assigned servers on ec-1');
expect(body).toContain('>Instances</th>'); expect(body).toContain('>Servers</th>');
expect(body).toContain('>Players</th>'); expect(body).toContain('>Players</th>');
expect(body).toContain('>12</td>'); expect(body).toContain('>12</td>');
expect(body).toContain('0.0%'); 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', () => { 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; const partial = render(HostList, { props: { hosts: [{ ...host(), serverCount: 3, playerCount: 4, unreportedInstances: 1 }], now } }).body;
expect(partial).toContain('>4+</td>'); 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; const unknown = render(HostList, { props: { hosts: [{ ...host(), serverCount: 3, playerCount: 0, unreportedInstances: 3 }], now } }).body;
expect(unknown).toContain('>—</td>'); expect(unknown).toContain('>—</td>');
const empty = render(HostList, { props: { hosts: [{ ...host(), serverCount: 0, playerCount: 0, unreportedInstances: 0 }], now } }).body; 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; const body = render(MatchHistoryPage, { props: { data: { history: report(), user: null, setupRequired: false } } }).body;
expect(body.match(/<h1[ >]/g)).toHaveLength(1); expect(body.match(/<h1[ >]/g)).toHaveLength(1);
expect(body.match(/scope="col"/g)).toHaveLength(6); 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).not.toContain('>Server</th>'); expect(body).not.toContain('Show 25 more');
expect(body).toContain('matchPage=2'); 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', () => { 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(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('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'); 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('Today, 1:26 PM'); 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 }); 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; 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('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('tabindex="0"');
expect(body).toContain('Inspect a period'); expect(body).toContain('Inspect a period');
expect(body).toContain('Partial period'); expect(body).toContain('Partial period');