Add Prettier and ESLint tooling (Refs #77)

This commit is contained in:
2026-10-09 15:56:13 -04:00
parent 8ed059b8d3
commit 16469e2d43
711 changed files with 99737 additions and 28286 deletions
+16 -5
View File
@@ -1,7 +1,12 @@
import { parseActivityQuery, type ActivityQuery, type ActivitySource } from '../activity.js';
import { createTimeFormatter } from '../time-zone.js';
export interface ActivityScope { server?: string; source?: ActivitySource; excludeProvisioning?: boolean; excludeMaintenance?: boolean }
export interface ActivityScope {
server?: string;
source?: ActivitySource;
excludeProvisioning?: boolean;
excludeMaintenance?: boolean;
}
const keys = ['server', 'host', 'type', 'source', 'state', 'days', 'q', 'limit', 'offset'] as const;
/** Scoped views always retain their server/source, including after reset or a pasted URL. */
@@ -9,8 +14,10 @@ export function activityQuery(url: URL, scope: ActivityScope = {}): ActivityQuer
const params = new URLSearchParams(url.searchParams);
if (scope.server !== undefined) params.set('server', scope.server);
if (scope.source !== undefined) params.set('source', scope.source);
if (scope.excludeProvisioning !== undefined) params.set('excludeProvisioning', String(scope.excludeProvisioning));
if (scope.excludeMaintenance !== undefined) params.set('excludeMaintenance', String(scope.excludeMaintenance));
if (scope.excludeProvisioning !== undefined)
params.set('excludeProvisioning', String(scope.excludeProvisioning));
if (scope.excludeMaintenance !== undefined)
params.set('excludeMaintenance', String(scope.excludeMaintenance));
return parseActivityQuery(params);
}
@@ -23,13 +30,17 @@ export function activityApiHref(url: URL, scope: ActivityScope = {}): string {
}
/** Preserve the containing tab and return context while paging or changing filters. */
export function activityHistoryHref(url: URL, changes: Partial<Record<keyof ActivityQuery, string>>): string {
export function activityHistoryHref(
url: URL,
changes: Partial<Record<keyof ActivityQuery, string>>
): string {
const next = new URL(url);
if (!Object.hasOwn(changes, 'offset')) next.searchParams.delete('offset');
for (const key of keys) {
if (changes[key] === undefined) continue;
const value = changes[key]?.trim();
if (value && !(['offset', 'days'].includes(key) && value === '0')) next.searchParams.set(key, value);
if (value && !(['offset', 'days'].includes(key) && value === '0'))
next.searchParams.set(key, value);
else next.searchParams.delete(key);
}
return next.pathname + next.search + next.hash;
+96 -27
View File
@@ -15,29 +15,78 @@ export function scalingReason(reason: string): string {
}
export function policyStatus(view: ScalingView, globallyPaused: boolean) {
if (view.policy.pausedReason) return { label: 'Needs attention', reason: scalingReason(view.policy.pausedReason), tone: 'warning' as const };
if (globallyPaused || view.policy.mode === 'off') return {
label: globallyPaused ? 'Globally paused' : 'Paused',
reason: view.unsettled.length ? 'No new scaling requests. Outstanding jobs are still tracked.' : 'No new scaling requests.',
tone: 'neutral' as const
if (view.policy.pausedReason)
return {
label: 'Needs attention',
reason: scalingReason(view.policy.pausedReason),
tone: 'warning' as const
};
if (globallyPaused || view.policy.mode === 'off')
return {
label: globallyPaused ? 'Globally paused' : 'Paused',
reason: view.unsettled.length
? 'No new scaling requests. Outstanding jobs are still tracked.'
: 'No new scaling requests.',
tone: 'neutral' as const
};
if (view.decision.action === 'create')
return {
label: 'Adding capacity',
reason: scalingReason(view.decision.reason),
tone: 'info' as const
};
if (view.decision.action === 'destroy')
return {
label: 'Removing surplus',
reason: scalingReason(view.decision.reason),
tone: 'info' as const
};
if (
view.decision.reason.endsWith('Empty spare target is satisfied.') &&
view.decision.deficit === 0
)
return { label: 'Spare targets satisfied', reason: '', tone: 'online' as const };
if (view.decision.reason.endsWith('Waiting for a continuous empty interval.'))
return {
label: 'Waiting to remove surplus',
reason: `Lobbies must stay empty for ${view.policy.emptyMinutes} minutes.`,
tone: 'neutral' as const
};
return {
label: 'Scaling on hold',
reason: scalingReason(view.decision.reason),
tone: 'warning' as const
};
if (view.decision.action === 'create') return { label: 'Adding capacity', reason: scalingReason(view.decision.reason), tone: 'info' as const };
if (view.decision.action === 'destroy') return { label: 'Removing surplus', reason: scalingReason(view.decision.reason), tone: 'info' as const };
if (view.decision.reason.endsWith('Empty spare target is satisfied.') && view.decision.deficit === 0) return { label: 'Spare targets satisfied', reason: '', tone: 'online' as const };
if (view.decision.reason.endsWith('Waiting for a continuous empty interval.')) return { label: 'Waiting to remove surplus', reason: `Lobbies must stay empty for ${view.policy.emptyMinutes} minutes.`, tone: 'neutral' as const };
return { label: 'Scaling on hold', reason: scalingReason(view.decision.reason), tone: 'warning' as const };
}
export function lobbyGroups(view: ScalingView) {
const maps = view.policy.mapSlug === '*' ? zombieMaps(view.policy.game) : zombieMaps(view.policy.game).filter(map => map.slug === view.policy.mapSlug);
const maps =
view.policy.mapSlug === '*'
? zombieMaps(view.policy.game)
: zombieMaps(view.policy.game).filter((map) => map.slug === view.policy.mapSlug);
const assigned = new Set<ScalingMember>();
const groups = maps.map(map => {
const members = view.snapshot.members.filter(member => (member.mapSlug ?? parseServerName(member.name)?.mapSlug) === map.slug);
members.forEach(member => assigned.add(member));
return { slug: map.slug, label: map.friendly, members, ready: members.filter(member => member.state === 'empty').length, target: view.policy.spares };
const groups = maps.map((map) => {
const members = view.snapshot.members.filter(
(member) => (member.mapSlug ?? parseServerName(member.name)?.mapSlug) === map.slug
);
members.forEach((member) => assigned.add(member));
return {
slug: map.slug,
label: map.friendly,
members,
ready: members.filter((member) => member.state === 'empty').length,
target: view.policy.spares
};
});
const unassigned = view.snapshot.members.filter(member => !assigned.has(member));
if (unassigned.length) groups.push({ slug: 'unassigned', label: 'Other lobbies', members: unassigned, ready: unassigned.filter(member => member.state === 'empty').length, target: 0 });
const unassigned = view.snapshot.members.filter((member) => !assigned.has(member));
if (unassigned.length)
groups.push({
slug: 'unassigned',
label: 'Other lobbies',
members: unassigned,
ready: unassigned.filter((member) => member.state === 'empty').length,
target: 0
});
return groups;
}
@@ -54,12 +103,18 @@ export interface ScalingActivity {
}
export function scalingActivity(view: ScalingView): ScalingActivity[] {
const operationIds = new Set([...view.history.map(item => item.operationId), ...view.unsettled.map(item => item.id)].filter((id): id is string => !!id));
const operationIds = new Set(
[
...view.history.map((item) => item.operationId),
...view.unsettled.map((item) => item.id)
].filter((id): id is string => !!id)
);
const groups = new Map<string, ScalingActivity>();
for (const item of view.history) {
// Older wait decisions store the job reference in their reason rather than operationId.
const reference = item.decision.reason.match(/Provisioning ([\w-]+):/)?.[1];
const operationId = item.operationId ?? (reference && operationIds.has(reference) ? reference : null);
const operationId =
item.operationId ?? (reference && operationIds.has(reference) ? reference : null);
const key = operationId ?? item.id;
const existing = groups.get(key);
if (existing) {
@@ -72,11 +127,18 @@ export function scalingActivity(view: ScalingView): ScalingActivity[] {
existing.operationState = item.operationState;
existing.reason = scalingReason(item.decision.reason);
}
} else groups.set(key, {
id: key, at: item.at, lastSeenAt: item.lastSeenAt, operationId,
operationState: item.operationState, action: item.decision.action,
server: item.decision.server, reason: scalingReason(item.decision.reason), evaluations: 1
});
} else
groups.set(key, {
id: key,
at: item.at,
lastSeenAt: item.lastSeenAt,
operationId,
operationState: item.operationState,
action: item.decision.action,
server: item.decision.server,
reason: scalingReason(item.decision.reason),
evaluations: 1
});
}
return [...groups.values()].sort((a, b) => b.lastSeenAt - a.lastSeenAt);
}
@@ -84,10 +146,17 @@ export function scalingActivity(view: ScalingView): ScalingActivity[] {
/** Advance observation ages even when polling fails, using the fleet freshness window. */
export function observationAge(at: number, now: number): { label: string; stale: boolean } {
const age = now - at;
if (!Number.isFinite(at) || !Number.isFinite(age) || age < -1000) return { label: 'Check observation time', stale: true };
if (!Number.isFinite(at) || !Number.isFinite(age) || age < -1000)
return { label: 'Check observation time', stale: true };
const seconds = Math.max(0, Math.floor(age / 1000));
const elapsed = seconds < 60 ? `${seconds}s` : seconds < 3600 ? `${Math.floor(seconds / 60)}m`
: seconds < 86400 ? `${Math.floor(seconds / 3600)}h` : `${Math.floor(seconds / 86400)}d`;
const elapsed =
seconds < 60
? `${seconds}s`
: seconds < 3600
? `${Math.floor(seconds / 60)}m`
: seconds < 86400
? `${Math.floor(seconds / 3600)}h`
: `${Math.floor(seconds / 86400)}d`;
const stale = !freshStatusTimestamp(new Date(at).toISOString(), now);
return { label: `${stale ? 'Stale · ' : ''}${elapsed} ago`, stale };
}
+21 -8
View File
@@ -1,27 +1,40 @@
/** Coalesce pushes, serialize refreshes, and recover missed events after reconnect or tab activation. */
export function betaUpdates(load: () => Promise<unknown>, betaId?: () => number): () => void {
let timer: ReturnType<typeof setTimeout> | undefined;
let stopped = false, running = false, pending = false;
let stopped = false,
running = false,
pending = false;
const schedule = () => {
pending = true;
if (stopped || running || timer || document.hidden) return;
timer = setTimeout(async () => {
timer = undefined;
if (stopped || document.hidden) return;
running = true; pending = false;
try { await load(); }
catch { /* The page owns its error state; later events can retry. */ }
finally { running = false; if (pending) schedule(); }
running = true;
pending = false;
try {
await load();
} catch {
/* The page owns its error state; later events can retry. */
} finally {
running = false;
if (pending) schedule();
}
}, 200);
};
const changed = (event: Event) => { if (!betaId || (event as CustomEvent).detail?.betaId === betaId()) schedule(); };
const visible = () => { if (!document.hidden) schedule(); };
const changed = (event: Event) => {
if (!betaId || (event as CustomEvent).detail?.betaId === betaId()) schedule();
};
const visible = () => {
if (!document.hidden) schedule();
};
window.addEventListener('ec:beta-changed', changed);
window.addEventListener('ec:pipeline-connected', schedule);
window.addEventListener('ec:fleet-changed', schedule);
document.addEventListener('visibilitychange', visible);
return () => {
stopped = true; clearTimeout(timer);
stopped = true;
clearTimeout(timer);
window.removeEventListener('ec:beta-changed', changed);
window.removeEventListener('ec:pipeline-connected', schedule);
window.removeEventListener('ec:fleet-changed', schedule);
+34 -10
View File
@@ -1,21 +1,45 @@
import type { StatusTone } from './status.js';
export interface BetaDeploymentServer {
server: string; server_deleted_at?: string | null; stage: string; error: string | null;
deployed_at: string | null; restarted_at: string | null; healthy_at: string | null;
match_seen_at: string | null; provisioning_id: string | null;
server: string;
server_deleted_at?: string | null;
stage: string;
error: string | null;
deployed_at: string | null;
restarted_at: string | null;
healthy_at: string | null;
match_seen_at: string | null;
provisioning_id: string | null;
}
export function betaStatus(beta: { state: string; testOutcome?: string | null;
servers: { stage: string; server_deleted_at?: string | null }[] }): {
key: string; label: string; tone: StatusTone;
export function betaStatus(beta: {
state: string;
testOutcome?: string | null;
servers: { stage: string; server_deleted_at?: string | null }[];
}): {
key: string;
label: string;
tone: StatusTone;
} {
if (beta.state === 'promoted') return { key: 'promoted', label: 'Promoted', tone: 'online' };
if (beta.state === 'rolled-back') return { key: 'rolled-back', label: 'Rolled back', tone: 'neutral' };
if (beta.servers.some(server => server.server_deleted_at || server.stage === 'failed') || ['failed', 'inconclusive'].includes(beta.testOutcome ?? '')) {
return { key: 'attention', label: 'Needs attention', tone: beta.testOutcome === 'failed' || beta.servers.some(server => server.stage === 'failed') ? 'danger' : 'warning' };
if (beta.state === 'rolled-back')
return { key: 'rolled-back', label: 'Rolled back', tone: 'neutral' };
if (
beta.servers.some((server) => server.server_deleted_at || server.stage === 'failed') ||
['failed', 'inconclusive'].includes(beta.testOutcome ?? '')
) {
return {
key: 'attention',
label: 'Needs attention',
tone:
beta.testOutcome === 'failed' ||
beta.servers.some((server) => server.stage === 'failed')
? 'danger'
: 'warning'
};
}
if (beta.testOutcome === 'paused') return { key: 'paused', label: 'Paused', tone: 'warning' };
if (beta.state === 'ready-promote') return { key: 'ready', label: 'Ready to promote', tone: 'online' };
if (beta.state === 'ready-promote')
return { key: 'ready', label: 'Ready to promote', tone: 'online' };
return { key: 'testing', label: 'Testing', tone: 'info' };
}
+77 -25
View File
@@ -14,53 +14,105 @@ export interface CampaignSummary {
}
export function campaignTitle(campaign: CampaignSummary): string {
const action = campaign.action === 'drift' ? 'Maintenance repair'
: campaign.action.charAt(0).toUpperCase() + campaign.action.slice(1);
const action =
campaign.action === 'drift'
? 'Maintenance repair'
: campaign.action.charAt(0).toUpperCase() + campaign.action.slice(1);
if (campaign.action === 'drift') return action;
if (campaign.action === 'rollback' && campaign.beta_id) return `${action} · Beta #${campaign.beta_id}`;
if (campaign.action === 'rollback' && campaign.beta_id)
return `${action} · Beta #${campaign.beta_id}`;
if (campaign.scripts.length > 1) return `${action} · ${campaign.scripts.length} scripts`;
const script = campaign.scripts[0] ?? campaign;
if (script.script_name) return `${action} · ${script.script_name}${script.script_version !== null ? ` v${script.script_version}` : ''}`;
if (script.script_name)
return `${action} · ${script.script_name}${script.script_version !== null ? ` v${script.script_version}` : ''}`;
return campaign.beta_id ? `${action} · Beta #${campaign.beta_id}` : action;
}
export function campaignOutcome(campaign: Pick<CampaignSummary, 'servers' | 'rolled_back_by'>): {
status: 'attention' | 'progress' | 'rolled-back' | 'complete' | 'inactive'; label: string; tone: StatusTone; completed: number;
status: 'attention' | 'progress' | 'rolled-back' | 'complete' | 'inactive';
label: string;
tone: StatusTone;
completed: number;
} {
const completed = campaign.servers.filter(server => server.restart_state === 'done').length;
const live = campaign.servers.filter(server => !server.server_deleted_at);
const attention = live.filter(server => ['failed', 'held', 'manual'].includes(server.restart_state));
if (attention.length) return { status: 'attention', label: `${attention.length} ${plural(attention.length, 'needs', 'need')} attention`, tone: attention.some(server => server.restart_state === 'failed') ? 'danger' : 'warning', completed };
if (live.some(server => server.restart_state === 'pending')) return { status: 'progress', label: `${completed}/${campaign.servers.length} complete`, tone: 'info', completed };
if (campaign.rolled_back_by) return { status: 'rolled-back', label: 'Rolled back', tone: 'neutral', completed };
if (campaign.servers.length && completed === campaign.servers.length) return { status: 'complete', label: 'Complete', tone: 'online', completed };
return { status: 'inactive', label: completed ? `${completed}/${campaign.servers.length} complete` : 'Inactive', tone: 'neutral', completed };
const completed = campaign.servers.filter((server) => server.restart_state === 'done').length;
const live = campaign.servers.filter((server) => !server.server_deleted_at);
const attention = live.filter((server) =>
['failed', 'held', 'manual'].includes(server.restart_state)
);
if (attention.length)
return {
status: 'attention',
label: `${attention.length} ${plural(attention.length, 'needs', 'need')} attention`,
tone: attention.some((server) => server.restart_state === 'failed')
? 'danger'
: 'warning',
completed
};
if (live.some((server) => server.restart_state === 'pending'))
return {
status: 'progress',
label: `${completed}/${campaign.servers.length} complete`,
tone: 'info',
completed
};
if (campaign.rolled_back_by)
return { status: 'rolled-back', label: 'Rolled back', tone: 'neutral', completed };
if (campaign.servers.length && completed === campaign.servers.length)
return { status: 'complete', label: 'Complete', tone: 'online', completed };
return {
status: 'inactive',
label: completed ? `${completed}/${campaign.servers.length} complete` : 'Inactive',
tone: 'neutral',
completed
};
}
/** Progress comes from recorded state, not the error field, which also contains routine stage messages. */
export function campaignServerStatus(server: {
restart_state: string; server_deleted_at?: string | null; restarted_at: string | null;
}, files: { apply_state: string }[] = []): { group: CampaignServerGroup; label: string; tone: StatusTone } {
if (server.restart_state === 'done') return { group: 'complete', label: 'Complete', tone: 'online' };
if (server.server_deleted_at) return { group: 'inactive', label: 'Deleted before activation', tone: 'neutral' };
if (['skipped', 'cancelled'].includes(server.restart_state)) return { group: 'inactive', ...statusPresentation(server.restart_state) };
export function campaignServerStatus(
server: {
restart_state: string;
server_deleted_at?: string | null;
restarted_at: string | null;
},
files: { apply_state: string }[] = []
): { group: CampaignServerGroup; label: string; tone: StatusTone } {
if (server.restart_state === 'done')
return { group: 'complete', label: 'Complete', tone: 'online' };
if (server.server_deleted_at)
return { group: 'inactive', label: 'Deleted before activation', tone: 'neutral' };
if (['skipped', 'cancelled'].includes(server.restart_state))
return { group: 'inactive', ...statusPresentation(server.restart_state) };
if (['failed', 'manual', 'held'].includes(server.restart_state)) {
return { group: 'attention', ...statusPresentation(server.restart_state), tone: server.restart_state === 'failed' ? 'danger' : 'warning' };
return {
group: 'attention',
...statusPresentation(server.restart_state),
tone: server.restart_state === 'failed' ? 'danger' : 'warning'
};
}
if (server.restarted_at) return { group: 'processing', label: 'Verifying health', tone: 'info' };
if (files.some(file => file.apply_state === 'applying')) return { group: 'processing', label: 'Applying files', tone: 'info' };
if (server.restarted_at)
return { group: 'processing', label: 'Verifying health', tone: 'info' };
if (files.some((file) => file.apply_state === 'applying'))
return { group: 'processing', label: 'Applying files', tone: 'info' };
return { group: 'queued', label: 'Queued', tone: 'neutral' };
}
/** Load only the requested history, respecting the API's per-request page bound. */
export async function fetchCampaigns<T>(filters: Record<string, string>, count: number, signal?: AbortSignal): Promise<{ deployments: T[]; total: number }> {
export async function fetchCampaigns<T>(
filters: Record<string, string>,
count: number,
signal?: AbortSignal
): Promise<{ deployments: T[]; total: number }> {
const deployments: T[] = [];
let total = Infinity;
while (deployments.length < Math.min(count, total)) {
const params = new URLSearchParams({ ...filters, limit: String(Math.min(500, count - deployments.length)), offset: String(deployments.length) });
const params = new URLSearchParams({
...filters,
limit: String(Math.min(500, count - deployments.length)),
offset: String(deployments.length)
});
const response = await fetch(`/api/campaigns?${params}`, { signal });
if (!response.ok) throw new Error('Could not refresh campaigns.');
const page = await response.json() as { deployments: T[]; total: number };
const page = (await response.json()) as { deployments: T[]; total: number };
total = page.total;
deployments.push(...page.deployments);
if (!page.deployments.length) break;
+61 -23
View File
@@ -17,35 +17,49 @@ export interface CollectionLaunchState {
function changesSince(previous: CollectionPreview | null, next: CollectionPreview): string {
if (!previous) return '';
const sorted = (values: unknown[]) => JSON.stringify(values.map(value => JSON.stringify(value)).sort());
const removals = (preview: CollectionPreview) => sorted(preview.removals.map(server => [server.name, server.reason]));
const creations = (preview: CollectionPreview) => sorted(preview.servers.map(server => {
const recipe = preview.entries[server.entryIndex]?.recipe;
return [server.name, recipe?.game, recipe?.mapSlug];
}));
const sorted = (values: unknown[]) =>
JSON.stringify(values.map((value) => JSON.stringify(value)).sort());
const removals = (preview: CollectionPreview) =>
sorted(preview.removals.map((server) => [server.name, server.reason]));
const creations = (preview: CollectionPreview) =>
sorted(
preview.servers.map((server) => {
const recipe = preview.entries[server.entryIndex]?.recipe;
return [server.name, recipe?.game, recipe?.mapSlug];
})
);
const retained = (preview: CollectionPreview) => sorted([...new Set(preview.retainedServers)]);
if (removals(previous) !== removals(next)) {
return 'Servers to remove changed. Review the updated removal list before applying this collection.';
}
const createsChanged = creations(previous) !== creations(next);
const keepsChanged = retained(previous) !== retained(next);
if (createsChanged && keepsChanged) return 'Servers to create and keep changed. Review the updated fleet changes before applying.';
if (createsChanged) return 'Servers to create changed. Review the updated launch list before applying.';
if (keepsChanged) return 'Servers to keep changed. Review the updated fleet changes before applying.';
if (createsChanged && keepsChanged)
return 'Servers to create and keep changed. Review the updated fleet changes before applying.';
if (createsChanged)
return 'Servers to create changed. Review the updated launch list before applying.';
if (keepsChanged)
return 'Servers to keep changed. Review the updated fleet changes before applying.';
return '';
}
/** Refresh collection plans without retaining a launchable preview while its inputs are changing. */
export function createCollectionLaunchController(
onChange: (state: CollectionLaunchState) => void,
dependencies: { fetch?: typeof fetch; debounceMs?: number } = {},
dependencies: { fetch?: typeof fetch; debounceMs?: number } = {}
) {
const fetchPreview = dependencies.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args));
const fetchPreview =
dependencies.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args));
const debounceMs = dependencies.debounceMs ?? 200;
let selection: string | null = null;
let context: string | null = null;
let previous: CollectionPreview | null = null;
let state: CollectionLaunchState = { preview: null, checking: false, error: '', changeNotice: '' };
let state: CollectionLaunchState = {
preview: null,
checking: false,
error: '',
changeNotice: ''
};
let revision = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
let request: AbortController | undefined;
@@ -53,8 +67,13 @@ export function createCollectionLaunchController(
let disposed = false;
function publish(next: CollectionLaunchState) {
if (state.preview === next.preview && state.checking === next.checking
&& state.error === next.error && state.changeNotice === next.changeNotice) return;
if (
state.preview === next.preview &&
state.checking === next.checking &&
state.error === next.error &&
state.changeNotice === next.changeNotice
)
return;
state = next;
onChange(next);
}
@@ -74,14 +93,19 @@ export function createCollectionLaunchController(
request = controller;
try {
const response = await fetchPreview('/api/deployment-collections/preview', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: selection, signal: controller.signal,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: selection,
signal: controller.signal
});
const body = await response.json();
if (disposed || suspended || current !== revision) return;
if (!response.ok) throw new Error(body.error ?? 'Could not check collection readiness. Try again.');
if (!response.ok)
throw new Error(body.error ?? 'Could not check collection readiness. Try again.');
const preview = body as CollectionPreview;
const expiresAt = Date.parse(preview.expiresAt);
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) throw new Error('The collection check expired. Try again.');
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now())
throw new Error('The collection check expired. Try again.');
const changeNotice = changesSince(previous, preview);
previous = preview;
publish({ preview, checking: false, error: '', changeNotice });
@@ -90,8 +114,15 @@ export function createCollectionLaunchController(
}
} catch (error) {
if (!disposed && !suspended && current === revision) {
publish({ preview: null, checking: false, changeNotice: '',
error: error instanceof Error ? error.message : 'Could not check collection readiness. Try again.' });
publish({
preview: null,
checking: false,
changeNotice: '',
error:
error instanceof Error
? error.message
: 'Could not check collection readiness. Try again.'
});
}
} finally {
if (current === revision) request = undefined;
@@ -117,10 +148,17 @@ export function createCollectionLaunchController(
/** A null selection pauses checks during host reloads while preserving the last plan for comparison. */
select(input: CollectionLaunchInput | null) {
if (disposed) return;
if (!input) { invalidate(); return; }
if (!input) {
invalidate();
return;
}
const nextContext = JSON.stringify([input.blueprintId, input.mode]);
const next = JSON.stringify({ blueprintId: input.blueprintId, mode: input.mode,
libraryIds: { t5: input.libraryIds.t5, t6: input.libraryIds.t6 }, image: input.image });
const next = JSON.stringify({
blueprintId: input.blueprintId,
mode: input.mode,
libraryIds: { t5: input.libraryIds.t5, t6: input.libraryIds.t6 },
image: input.image
});
if (selection === next) return;
cancel();
if (context !== nextContext) previous = null;
@@ -152,6 +190,6 @@ export function createCollectionLaunchController(
previous = null;
context = null;
disposed = true;
},
}
};
}
+51 -12
View File
@@ -33,7 +33,13 @@ export function collectionKeyCoverage(preview: CollectionPreview): CollectionKey
const coverage = new Map<ZombieGame, CollectionKeyCoverage>();
for (const server of preview.servers) {
const game = preview.entries[server.entryIndex].recipe.game;
const pool = coverage.get(game) ?? { game, label: gameLabels[game], required: 0, available: 0, unassigned: 0 };
const pool = coverage.get(game) ?? {
game,
label: gameLabels[game],
required: 0,
available: 0,
unassigned: 0
};
pool.required++;
if (!server.name) pool.unassigned++;
else if (server.hasSavedKey) pool.available++;
@@ -49,10 +55,19 @@ function removalMap(name: string): { game: ZombieGame | null; key: string; mapNa
if (parsed && supportedGame && (!parsed.gameMode || parsed.gameMode === 'zombie')) {
// Resolve friendly legacy aliases without merging T6 variants that share an engine map.
const map = selectedZombieMap(supportedGame, parsed.mapCanonical, parsed.mapSlug);
if (map) return { game: supportedGame, key: `${supportedGame}:${map.slug}`, mapName: map.friendly };
if (map)
return {
game: supportedGame,
key: `${supportedGame}:${map.slug}`,
mapName: map.friendly
};
}
// A raw identity is preferable to assigning an unknown server to the wrong map.
return { game: supportedGame, key: `${supportedGame ?? 'other'}:server:${name}`, mapName: name };
return {
game: supportedGame,
key: `${supportedGame ?? 'other'}:server:${name}`,
mapName: name
};
}
/** Summarize affected inventory, including exact-mode removals outside the saved collection. */
@@ -62,7 +77,11 @@ export function collectionMapGroups(preview: CollectionPreview): CollectionMapGr
const existingNames = new Map<string, Set<string>>();
const serverRows = new Map<string, CollectionMapRow>();
const addRow = (game: ZombieGame | null, row: CollectionMapRow) => {
const group = groups.get(game) ?? { game, label: game ? gameLabels[game] : 'Other servers', rows: [] };
const group = groups.get(game) ?? {
game,
label: game ? gameLabels[game] : 'Other servers',
rows: []
};
group.rows.push(row);
groups.set(game, group);
rows.set(row.key, row);
@@ -72,8 +91,14 @@ export function collectionMapGroups(preview: CollectionPreview): CollectionMapGr
for (const entry of preview.entries) {
const { game, mapSlug, mapName } = entry.recipe;
const row = addRow(game, { key: `${game}:${mapSlug}`, mapName, target: entry.count,
existing: entry.existing, create: entry.missing, remove: 0 });
const row = addRow(game, {
key: `${game}:${mapSlug}`,
mapName,
target: entry.count,
existing: entry.existing,
create: entry.missing,
remove: 0
});
for (const name of entry.existingServers) {
existingNames.get(row.key)!.add(name);
// Backend coverage knows stored map selections even when a name is nonstandard.
@@ -84,14 +109,25 @@ export function collectionMapGroups(preview: CollectionPreview): CollectionMapGr
if (preview.mode === 'exact') {
for (const { name } of preview.removals) {
const identity = removalMap(name);
const row = serverRows.get(name) ?? rows.get(identity.key) ?? addRow(identity.game, {
key: identity.key, mapName: identity.mapName, target: 0, existing: 0, create: 0, remove: 0,
});
const row =
serverRows.get(name) ??
rows.get(identity.key) ??
addRow(identity.game, {
key: identity.key,
mapName: identity.mapName,
target: 0,
existing: 0,
create: 0,
remove: 0
});
row.remove++;
const counted = existingNames.get(row.key)!;
// Coverage excludes pending removals. Include those identities in displayed inventory
// while counting ordinary excess servers only once.
if (!counted.has(name)) { row.existing++; counted.add(name); }
if (!counted.has(name)) {
row.existing++;
counted.add(name);
}
}
}
@@ -101,9 +137,12 @@ export function collectionMapGroups(preview: CollectionPreview): CollectionMapGr
/** Capacity remains an estimate: insufficient capacity can wait in the startup queue. */
export function collectionCapacitySummary(availability?: LaunchAvailability): string {
if (availability?.requested === 0) return 'No new server capacity needed.';
if (!availability || availability.estimatedAvailable === null) return 'Capacity estimate unavailable; launches can wait for capacity.';
if (!availability || availability.estimatedAvailable === null)
return 'Capacity estimate unavailable; launches can wait for capacity.';
if (availability.estimatedAvailable >= availability.requested) {
return availability.requested === 1 ? 'Estimated capacity for 1 server.' : `Estimated capacity for all ${availability.requested} servers.`;
return availability.requested === 1
? 'Estimated capacity for 1 server.'
: `Estimated capacity for all ${availability.requested} servers.`;
}
return `Estimated capacity for ${availability.estimatedAvailable} of ${availability.requested} server${availability.requested === 1 ? '' : 's'}; launches can wait for capacity.`;
}
+183 -33
View File
@@ -4,35 +4,127 @@ import { playerProfileHref } from '../leaderboard.js';
import { serverTitle } from './server-labels.js';
export type PaletteGroup = 'Pages' | 'Actions' | 'Servers' | 'Hosts' | 'Players' | 'Matches';
export interface PaletteItem { key: string; group: PaletteGroup; label: string; detail: string; href: string }
export interface PaletteServer { name: string; map?: string | null; last_map?: string | null; host?: { name: string } | null }
export interface PaletteHost { id: string; name: string; hostname?: string; connectionState?: string }
export interface PaletteDirectory { servers: PaletteServer[]; hosts: PaletteHost[]; unavailable: string[] }
export interface PaletteItem {
key: string;
group: PaletteGroup;
label: string;
detail: string;
href: string;
}
export interface PaletteServer {
name: string;
map?: string | null;
last_map?: string | null;
host?: { name: string } | null;
}
export interface PaletteHost {
id: string;
name: string;
hostname?: string;
connectionState?: string;
}
export interface PaletteDirectory {
servers: PaletteServer[];
hosts: PaletteHost[];
unavailable: string[];
}
type PaletteFetch = (input: string, init: RequestInit) => Promise<Response>;
export const paletteGroups: PaletteGroup[] = ['Actions', 'Pages', 'Servers', 'Hosts', 'Players', 'Matches'];
export const paletteGroups: PaletteGroup[] = [
'Actions',
'Pages',
'Servers',
'Hosts',
'Players',
'Matches'
];
function matches(query: string, ...values: Array<string | null | undefined>) {
const text = values.filter(Boolean).join(' ').toLocaleLowerCase();
return query.trim().toLocaleLowerCase().split(/\s+/).every(word => text.includes(word));
return query
.trim()
.toLocaleLowerCase()
.split(/\s+/)
.every((word) => text.includes(word));
}
export function palettePageResults(role: UserRole, query: string): PaletteItem[] {
const actions: PaletteItem[] = role === 'viewer' ? [] : [{ key: 'action:create', group: 'Actions', label: 'Create server', detail: 'Choose a map and host', href: '/?action=create' }, { key: 'action:manage', group: 'Actions', label: 'Manage servers', detail: 'Server queues and lifecycle actions', href: '/provisioning' }];
if (role === 'admin') actions.push({ key: 'action:add-host', group: 'Actions', label: 'Add host', detail: 'Enroll a host agent', href: '/hosts?action=add' });
return [...actions.filter(item => matches(query, item.label, item.detail)),
...visibleNavigation(role).flatMap(group => group.items.filter(item => matches(query, item.label, group.label)).map(item => ({
key: `page:${item.href}`, group: 'Pages' as const, label: item.label, detail: group.label, href: item.href
})))];
const actions: PaletteItem[] =
role === 'viewer'
? []
: [
{
key: 'action:create',
group: 'Actions',
label: 'Create server',
detail: 'Choose a map and host',
href: '/?action=create'
},
{
key: 'action:manage',
group: 'Actions',
label: 'Manage servers',
detail: 'Server queues and lifecycle actions',
href: '/provisioning'
}
];
if (role === 'admin')
actions.push({
key: 'action:add-host',
group: 'Actions',
label: 'Add host',
detail: 'Enroll a host agent',
href: '/hosts?action=add'
});
return [
...actions.filter((item) => matches(query, item.label, item.detail)),
...visibleNavigation(role).flatMap((group) =>
group.items
.filter((item) => matches(query, item.label, group.label))
.map((item) => ({
key: `page:${item.href}`,
group: 'Pages' as const,
label: item.label,
detail: group.label,
href: item.href
}))
)
];
}
export function paletteDirectoryResults(query: string, directory: PaletteDirectory | null): PaletteItem[] {
export function paletteDirectoryResults(
query: string,
directory: PaletteDirectory | null
): PaletteItem[] {
if (!query.trim() || !directory) return [];
const servers: PaletteItem[] = directory.servers.filter(server => matches(query, serverTitle(server.name), server.name, server.map, server.last_map, server.host?.name))
.slice(0, 6).map(server => ({ key: `server:${server.name}`, group: 'Servers', label: serverTitle(server.name),
detail: server.host?.name ?? 'Game server', href: `/servers/${encodeURIComponent(server.name)}` }));
const hosts: PaletteItem[] = directory.hosts.filter(host => matches(query, host.name, host.hostname, host.id))
.slice(0, 6).map(host => ({ key: `host:${host.id}`, group: 'Hosts', label: host.name,
detail: host.connectionState === 'offline' ? 'Host agent offline' : 'Host', href: `/hosts/${encodeURIComponent(host.id)}` }));
const servers: PaletteItem[] = directory.servers
.filter((server) =>
matches(
query,
serverTitle(server.name),
server.name,
server.map,
server.last_map,
server.host?.name
)
)
.slice(0, 6)
.map((server) => ({
key: `server:${server.name}`,
group: 'Servers',
label: serverTitle(server.name),
detail: server.host?.name ?? 'Game server',
href: `/servers/${encodeURIComponent(server.name)}`
}));
const hosts: PaletteItem[] = directory.hosts
.filter((host) => matches(query, host.name, host.hostname, host.id))
.slice(0, 6)
.map((host) => ({
key: `host:${host.id}`,
group: 'Hosts',
label: host.name,
detail: host.connectionState === 'offline' ? 'Host agent offline' : 'Host',
href: `/hosts/${encodeURIComponent(host.id)}`
}));
return [...servers, ...hosts];
}
@@ -40,49 +132,107 @@ export function paletteDirectoryResults(query: string, directory: PaletteDirecto
export function paletteMatchJump(query: string): PaletteItem[] {
const id = /^(?:match\s*#?\s*|#)?([1-9]\d*)$/i.exec(query.trim())?.[1];
if (!id || !Number.isSafeInteger(Number(id))) return [];
return [{ key: `match:${id}`, group: 'Matches', label: `Open match #${id}`, detail: 'Go to a recorded match', href: `/matches/${id}` }];
return [
{
key: `match:${id}`,
group: 'Matches',
label: `Open match #${id}`,
detail: 'Go to a recorded match',
href: `/matches/${id}`
}
];
}
async function read(url: string, signal: AbortSignal, fetcher: PaletteFetch) {
const response = await fetcher(url, { signal, cache: 'no-store' });
if (!response.ok) throw new Error('Search request failed.');
const body: unknown = await response.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Search response is unavailable.');
if (!body || typeof body !== 'object' || Array.isArray(body))
throw new Error('Search response is unavailable.');
return body as Record<string, unknown>;
}
/** Load only the current directory; historical entities use bounded queries below. */
export async function loadPaletteDirectory(signal: AbortSignal, fetcher: PaletteFetch = fetch): Promise<PaletteDirectory> {
export async function loadPaletteDirectory(
signal: AbortSignal,
fetcher: PaletteFetch = fetch
): Promise<PaletteDirectory> {
const [servers, hosts] = await Promise.allSettled([
read('/api/servers', signal, fetcher), read('/api/hosts', signal, fetcher)
read('/api/servers', signal, fetcher),
read('/api/hosts', signal, fetcher)
]);
return {
servers: servers.status === 'fulfilled' && Array.isArray(servers.value.servers) ? servers.value.servers.filter(server => server && typeof server.name === 'string') : [],
hosts: hosts.status === 'fulfilled' && Array.isArray(hosts.value.hosts) ? hosts.value.hosts.filter(host => host && typeof host.id === 'string' && typeof host.name === 'string') : [],
unavailable: [servers.status === 'rejected' ? 'Server' : '', hosts.status === 'rejected' ? 'Host' : ''].filter(Boolean)
servers:
servers.status === 'fulfilled' && Array.isArray(servers.value.servers)
? servers.value.servers.filter(
(server) => server && typeof server.name === 'string'
)
: [],
hosts:
hosts.status === 'fulfilled' && Array.isArray(hosts.value.hosts)
? hosts.value.hosts.filter(
(host) => host && typeof host.id === 'string' && typeof host.name === 'string'
)
: [],
unavailable: [
servers.status === 'rejected' ? 'Server' : '',
hosts.status === 'rejected' ? 'Host' : ''
].filter(Boolean)
};
}
export async function searchPaletteRecords(query: string, signal: AbortSignal, fetcher: PaletteFetch = fetch): Promise<{ items: PaletteItem[]; unavailable: string[] }> {
export async function searchPaletteRecords(
query: string,
signal: AbortSignal,
fetcher: PaletteFetch = fetch
): Promise<{ items: PaletteItem[]; unavailable: string[] }> {
const term = query.trim().slice(0, 100);
if (term.length < 2) return { items: [], unavailable: [] };
const playersParams = new URLSearchParams({ q: term, limit: '5', metric: 'highestRound' });
const matchesParams = new URLSearchParams({ q: term, limit: '5', status: 'completed' });
const [players, recordedMatches] = await Promise.allSettled([
read(`/api/leaderboard?${playersParams}`, signal, fetcher), read(`/api/matches?${matchesParams}`, signal, fetcher)
read(`/api/leaderboard?${playersParams}`, signal, fetcher),
read(`/api/matches?${matchesParams}`, signal, fetcher)
]);
const items: PaletteItem[] = [];
if (players.status === 'fulfilled' && Array.isArray(players.value.entries)) {
for (const player of players.value.entries.slice(0, 5)) {
if (!player || typeof player.guid !== 'string' || typeof player.name !== 'string') continue;
items.push({ key: `player:${player.guid}`, group: 'Players', label: player.name, detail: 'Player profile', href: playerProfileHref(player.guid) });
if (!player || typeof player.guid !== 'string' || typeof player.name !== 'string')
continue;
items.push({
key: `player:${player.guid}`,
group: 'Players',
label: player.name,
detail: 'Player profile',
href: playerProfileHref(player.guid)
});
}
}
if (recordedMatches.status === 'fulfilled' && Array.isArray(recordedMatches.value.matches)) {
for (const match of recordedMatches.value.matches.slice(0, 5)) {
if (!match || !Number.isSafeInteger(match.id) || match.id < 1 || match.status !== 'completed' || typeof match.map !== 'string' || typeof match.server !== 'string') continue;
items.push({ key: `match:${match.id}`, group: 'Matches', label: `${match.map} · Match #${match.id}`, detail: serverTitle(match.server), href: `/matches/${match.id}` });
if (
!match ||
!Number.isSafeInteger(match.id) ||
match.id < 1 ||
match.status !== 'completed' ||
typeof match.map !== 'string' ||
typeof match.server !== 'string'
)
continue;
items.push({
key: `match:${match.id}`,
group: 'Matches',
label: `${match.map} · Match #${match.id}`,
detail: serverTitle(match.server),
href: `/matches/${match.id}`
});
}
}
return { items, unavailable: [players.status === 'rejected' ? 'Player' : '', recordedMatches.status === 'rejected' ? 'Match' : ''].filter(Boolean) };
return {
items,
unavailable: [
players.status === 'rejected' ? 'Player' : '',
recordedMatches.status === 'rejected' ? 'Match' : ''
].filter(Boolean)
};
}
+548 -110
View File
@@ -2,7 +2,14 @@
import { untrack } from 'svelte';
import { page } from '$app/stores';
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 { activityApiHref, activityHistoryHref, activityQuery } from '$ui/activity-history.js';
import { pollVisible } from '$ui/poll.js';
@@ -17,7 +24,19 @@
import Freshness from './Freshness.svelte';
import HistoricalServerIdentity from './HistoricalServerIdentity.svelte';
let { server, source, initial, excludeProvisioning = false, excludeMaintenance = false }: { server?: string; source?: ActivitySource; initial?: ActivityPage; excludeProvisioning?: boolean; excludeMaintenance?: boolean } = $props();
let {
server,
source,
initial,
excludeProvisioning = false,
excludeMaintenance = false
}: {
server?: string;
source?: ActivitySource;
initial?: ActivityPage;
excludeProvisioning?: boolean;
excludeMaintenance?: boolean;
} = $props();
const id = $props.id();
const headingId = `${id}-activity-heading`;
let data = $state<ActivityPage | undefined>(untrack(() => initial));
@@ -25,169 +44,588 @@
let error = $state('');
let retry = $state(0);
let filtersOpen = $state(false);
let checkedAt = $state<number | null>(untrack(() => initial ? Date.now() : null));
let checkedAt = $state<number | null>(untrack(() => (initial ? Date.now() : null)));
let results = $state<HTMLOListElement>();
let search = $state(untrack(() => activityQuery($page.url, { server, source, excludeProvisioning, excludeMaintenance }).q ?? ''));
let search = $state(
untrack(
() =>
activityQuery($page.url, {
server,
source,
excludeProvisioning,
excludeMaintenance
}).q ?? ''
)
);
let pendingUrl: URL | undefined;
let navigationId = 0;
const filters = $derived(activityQuery($page.url, { server, source, excludeProvisioning, excludeMaintenance }));
const apiUrl = $derived(activityApiHref($page.url, { server, source, excludeProvisioning, excludeMaintenance }));
let loadedUrl = $state(untrack(() => initial ? apiUrl : ''));
const typeOptions = $derived(Object.entries(activityTypes).filter(([key]) =>
(!excludeProvisioning || !['create', 'start', 'restart', 'rebuild', 'stop', 'remove'].includes(key))
&& (!excludeMaintenance || !['update', 'health'].includes(key))));
const sourceOptions = $derived(Object.entries(activitySources).filter(([key]) =>
(!excludeProvisioning || !['provisioning', 'autoscaling'].includes(key))
&& (!excludeMaintenance || !['maintenance', 'recovery'].includes(key))));
const historyDescription = $derived(excludeMaintenance
? 'File changes and host work appear here.'
: excludeProvisioning ? 'File changes, maintenance, and host work appear here.'
: 'Server operations, file changes, maintenance, and host work appear here.');
const filterCount = $derived([filters.type, filters.state, source === undefined ? filters.source : '', server === undefined ? filters.server : '', filters.host, filters.days, filters.q].filter(Boolean).length);
const filters = $derived(
activityQuery($page.url, { server, source, excludeProvisioning, excludeMaintenance })
);
const apiUrl = $derived(
activityApiHref($page.url, { server, source, excludeProvisioning, excludeMaintenance })
);
let loadedUrl = $state(untrack(() => (initial ? apiUrl : '')));
const typeOptions = $derived(
Object.entries(activityTypes).filter(
([key]) =>
(!excludeProvisioning ||
!['create', 'start', 'restart', 'rebuild', 'stop', 'remove'].includes(key)) &&
(!excludeMaintenance || !['update', 'health'].includes(key))
)
);
const sourceOptions = $derived(
Object.entries(activitySources).filter(
([key]) =>
(!excludeProvisioning || !['provisioning', 'autoscaling'].includes(key)) &&
(!excludeMaintenance || !['maintenance', 'recovery'].includes(key))
)
);
const historyDescription = $derived(
excludeMaintenance
? 'File changes and host work appear here.'
: excludeProvisioning
? 'File changes, maintenance, and host work appear here.'
: 'Server operations, file changes, maintenance, and host work appear here.'
);
const filterCount = $derived(
[
filters.type,
filters.state,
source === undefined ? filters.source : '',
server === undefined ? filters.server : '',
filters.host,
filters.days,
filters.q
].filter(Boolean).length
);
const serverOptions = $derived.by(() => {
const names = new Set([...$fleet.servers.map(item => item.name), ...(data?.rows.flatMap(row => row.server ? [row.server] : []) ?? [])]);
const names = new Set([
...$fleet.servers.map((item) => item.name),
...(data?.rows.flatMap((row) => (row.server ? [row.server] : [])) ?? [])
]);
if (filters.server) names.add(filters.server);
return [...names].sort((a, b) => serverTitle(a).localeCompare(serverTitle(b)));
});
const hostOptions = $derived.by(() => {
const hosts = new Map<string, string>();
for (const item of $fleet.servers) if (item.host) hosts.set(item.host.id, item.host.name);
for (const row of data?.rows ?? []) if (row.hostId) hosts.set(row.hostId, row.hostName ?? 'Historical host');
for (const row of data?.rows ?? [])
if (row.hostId) hosts.set(row.hostId, row.hostName ?? 'Historical host');
if (filters.host && !hosts.has(filters.host)) hosts.set(filters.host, 'Selected host');
return [...hosts].sort((a, b) => a[1].localeCompare(b[1]));
});
$effect(() => { search = filters.q ?? ''; });
$effect(() => {
search = filters.q ?? '';
});
$effect(() => {
const report = initial;
if (report) untrack(() => { data = report; loadedUrl = apiUrl; loading = false; error = ''; checkedAt = Date.now(); });
if (report)
untrack(() => {
data = report;
loadedUrl = apiUrl;
loading = false;
error = '';
checkedAt = Date.now();
});
});
$effect(() => {
const url = apiUrl;
retry;
untrack(() => { if (loadedUrl !== url) loading = true; error = ''; });
return pollVisible(async signal => {
try {
const response = await fetch(url, { signal, cache: 'no-store' });
if (!response.ok) throw new Error('Could not load activity.');
const body: ActivityPage = await response.json();
if (signal.aborted || apiUrl !== url) return;
// Keep a focused operation link in place when newer rows arrive.
if (loadedUrl !== url || !results?.contains(document.activeElement)) { data = body; checkedAt = Date.now(); }
loadedUrl = url; loading = false; error = '';
} catch (cause) {
if (signal.aborted || apiUrl !== url) return;
error = cause instanceof Error ? cause.message : 'Could not load activity.';
loading = false;
return false;
}
}, 10_000, untrack(() => loadedUrl !== url || retry > 0));
void retry;
untrack(() => {
if (loadedUrl !== url) loading = true;
error = '';
});
return pollVisible(
async (signal) => {
try {
const response = await fetch(url, { signal, cache: 'no-store' });
if (!response.ok) throw new Error('Could not load activity.');
const body: ActivityPage = await response.json();
if (signal.aborted || apiUrl !== url) return;
// Keep a focused operation link in place when newer rows arrive.
if (loadedUrl !== url || !results?.contains(document.activeElement)) {
data = body;
checkedAt = Date.now();
}
loadedUrl = url;
loading = false;
error = '';
} catch (cause) {
if (signal.aborted || apiUrl !== url) return;
error = cause instanceof Error ? cause.message : 'Could not load activity.';
loading = false;
return false;
}
},
10_000,
untrack(() => loadedUrl !== url || retry > 0)
);
});
function update(changes: Partial<Record<keyof ActivityQuery, string>>) {
const href = activityHistoryHref(pendingUrl ?? $page.url, changes);
pendingUrl = new URL(href, $page.url);
const id = ++navigationId;
void goto(href, { noScroll: true, keepFocus: true })
.catch(() => { error = 'Could not update activity filters. Please try again.'; })
.finally(() => { if (id === navigationId) pendingUrl = undefined; });
.catch(() => {
error = 'Could not update activity filters. Please try again.';
})
.finally(() => {
if (id === navigationId) pendingUrl = undefined;
});
}
function reset() {
update({ type: '', state: '', source: '', server: '', host: '', days: '', q: '' });
}
function submitSearch() {
update({ q: search.trim() });
}
function reset() { update({ type: '', state: '', source: '', server: '', host: '', days: '', q: '' }); }
function submitSearch() { update({ q: search.trim() }); }
</script>
<section class="activity-log" aria-labelledby={headingId}>
<h2 class="sr-only" id={headingId}>Activity history</h2>
{#snippet filterFields(suffix: string)}
<div class="filters">
<label for={`activity-type${suffix}`}>Type<select id={`activity-type${suffix}`} value={filters.type ?? ''} onchange={event => update({ type: event.currentTarget.value })}><option value="">All types</option>{#each typeOptions as [value, label]}<option {value}>{label}</option>{/each}</select></label>
<label for={`activity-state${suffix}`}>State<select id={`activity-state${suffix}`} value={filters.state ?? ''} onchange={event => update({ state: event.currentTarget.value })}><option value="">All states</option>{#each Object.entries(activityStates) as [value, label]}<option {value}>{label}</option>{/each}</select></label>
{#if source === undefined}<label for={`activity-source${suffix}`}>Source<select id={`activity-source${suffix}`} value={filters.source ?? ''} onchange={event => update({ source: event.currentTarget.value })}><option value="">All sources</option>{#each sourceOptions as [value, label]}<option {value}>{label}</option>{/each}</select></label>{/if}
{#if server === undefined}<label for={`activity-server${suffix}`}>Server<select id={`activity-server${suffix}`} value={filters.server ?? ''} onchange={event => update({ server: event.currentTarget.value })}><option value="">All servers</option>{#each serverOptions as name}<option value={name}>{serverTitle(name)}</option>{/each}</select></label>{/if}
<label for={`activity-host${suffix}`}>Host<select id={`activity-host${suffix}`} value={filters.host ?? ''} onchange={event => update({ host: event.currentTarget.value })}><option value="">All hosts</option>{#each hostOptions as [value, label]}<option {value}>{label}</option>{/each}</select></label>
<label for={`activity-days${suffix}`}>Date range<select id={`activity-days${suffix}`} value={filters.days ?? 0} onchange={event => update({ days: event.currentTarget.value })}><option value="0">All retained history</option><option value="1">Last 24 hours</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option>{#if filters.days && ![1, 7, 30, 90].includes(filters.days)}<option value={filters.days}>Last {filters.days} days</option>{/if}</select></label>
<form class="search" role="search" onsubmit={event => { event.preventDefault(); submitSearch(); }}><label for={`activity-search${suffix}`}>Search activity<input id={`activity-search${suffix}`} type="search" bind:value={search} maxlength="200" placeholder="Server, host, or activity…" /></label><Button type="submit" size="sm">Search</Button></form>
<label for={`activity-type${suffix}`}
>Type<select
id={`activity-type${suffix}`}
value={filters.type ?? ''}
onchange={(event) => update({ type: event.currentTarget.value })}
><option value="">All types</option>{#each typeOptions as [value, label]}<option
{value}>{label}</option
>{/each}</select
></label
>
<label for={`activity-state${suffix}`}
>State<select
id={`activity-state${suffix}`}
value={filters.state ?? ''}
onchange={(event) => update({ state: event.currentTarget.value })}
><option value="">All states</option
>{#each Object.entries(activityStates) as [value, label]}<option {value}
>{label}</option
>{/each}</select
></label
>
{#if source === undefined}<label for={`activity-source${suffix}`}
>Source<select
id={`activity-source${suffix}`}
value={filters.source ?? ''}
onchange={(event) => update({ source: event.currentTarget.value })}
><option value="">All sources</option
>{#each sourceOptions as [value, label]}<option {value}>{label}</option
>{/each}</select
></label
>{/if}
{#if server === undefined}<label for={`activity-server${suffix}`}
>Server<select
id={`activity-server${suffix}`}
value={filters.server ?? ''}
onchange={(event) => update({ server: event.currentTarget.value })}
><option value="">All servers</option>{#each serverOptions as name}<option
value={name}>{serverTitle(name)}</option
>{/each}</select
></label
>{/if}
<label for={`activity-host${suffix}`}
>Host<select
id={`activity-host${suffix}`}
value={filters.host ?? ''}
onchange={(event) => update({ host: event.currentTarget.value })}
><option value="">All hosts</option>{#each hostOptions as [value, label]}<option
{value}>{label}</option
>{/each}</select
></label
>
<label for={`activity-days${suffix}`}
>Date range<select
id={`activity-days${suffix}`}
value={filters.days ?? 0}
onchange={(event) => update({ days: event.currentTarget.value })}
><option value="0">All retained history</option><option value="1"
>Last 24 hours</option
><option value="7">Last 7 days</option><option value="30">Last 30 days</option
><option value="90">Last 90 days</option
>{#if filters.days && ![1, 7, 30, 90].includes(filters.days)}<option
value={filters.days}>Last {filters.days} days</option
>{/if}</select
></label
>
<form
class="search"
role="search"
onsubmit={(event) => {
event.preventDefault();
submitSearch();
}}
>
<label for={`activity-search${suffix}`}
>Search activity<input
id={`activity-search${suffix}`}
type="search"
bind:value={search}
maxlength="200"
placeholder="Server, host, or activity…"
/></label
><Button type="submit" size="sm">Search</Button>
</form>
</div>
{/snippet}
<div class="desktop-filters">{@render filterFields('')}</div>
<div class="mobile-filters"><span>{data?.total ?? '—'} {data?.total === 1 ? 'activity' : 'activities'}{source ? ` · ${activitySources[source]}` : ''}</span><Button size="sm" onclick={() => filtersOpen = true}>Filters{filterCount ? ` · ${filterCount}` : ''}</Button></div>
<Dialog bind:open={filtersOpen} title="Activity filters" description="Choose the activity to show." drawer>
<div class="mobile-filters">
<span
>{data?.total ?? '—'}
{data?.total === 1 ? 'activity' : 'activities'}{source
? ` · ${activitySources[source]}`
: ''}</span
><Button size="sm" onclick={() => (filtersOpen = true)}
>Filters{filterCount ? ` · ${filterCount}` : ''}</Button
>
</div>
<Dialog
bind:open={filtersOpen}
title="Activity filters"
description="Choose the activity to show."
drawer
>
{@render filterFields('-mobile')}
{#snippet actions()}<Button variant="ghost" onclick={reset}>Clear filters</Button><Button variant="primary" onclick={() => { if (search.trim() !== (filters.q ?? '')) submitSearch(); filtersOpen = false; }}>Show results</Button>{/snippet}
{#snippet actions()}<Button variant="ghost" onclick={reset}>Clear filters</Button><Button
variant="primary"
onclick={() => {
if (search.trim() !== (filters.q ?? '')) submitSearch();
filtersOpen = false;
}}>Show results</Button
>{/snippet}
</Dialog>
<div class="context"><span>Newest first · Times in {$timeZone}</span>{#if filterCount}<button class="text-button" onclick={reset}>Clear filters</button>{/if}{#if !error}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 10 seconds" />{/if}</div>
{#if error}<div class="error" role="alert"><span>{error}{loadedUrl === apiUrl && data ? ' Showing the last successful update.' : ''}</span><Button size="sm" onclick={() => retry++}>Retry</Button></div>{/if}
<div class="context">
<span>Newest first · Times in {$timeZone}</span>{#if filterCount}<button
class="text-button"
onclick={reset}>Clear filters</button
>{/if}{#if !error}<Freshness
updatedAt={checkedAt}
intervalLabel="Updates every 10 seconds"
/>{/if}
</div>
{#if error}<div class="error" role="alert">
<span
>{error}{loadedUrl === apiUrl && data
? ' Showing the last successful update.'
: ''}</span
><Button size="sm" onclick={() => retry++}>Retry</Button>
</div>{/if}
{#if loading}<EmptyState title="Loading activity…" />
{:else if loadedUrl !== apiUrl && error}<EmptyState title="Activity unavailable" description="Retry to load this selection." />
{:else if loadedUrl !== apiUrl && error}<EmptyState
title="Activity unavailable"
description="Retry to load this selection."
/>
{:else if data?.rows.length}
<ol class="activity-list" bind:this={results} aria-label="Newest activity first">
{#each data.rows as row (row.id)}
<li class="activity-row">
<div class="event"><h3><a href={contextHref(row.href, $page.url)} aria-label={`${row.title}${row.server ? ` · ${serverTitle(row.server)}` : row.hostName ? ` · ${row.hostName}` : ''} · ${$timeFormat.period(row.at)}`}>{row.title}</a></h3>{#if row.detail}<p>{row.detail}</p>{/if}
<div class="resources">{#if row.server && server === undefined}<HistoricalServerIdentity name={row.server} deletedAt={row.serverDeletedAt} href={contextHref(`/servers/${encodeURIComponent(row.server)}`, $page.url)} />{/if}
<div class="event">
<h3>
<a
href={contextHref(row.href, $page.url)}
aria-label={`${row.title}${row.server ? ` · ${serverTitle(row.server)}` : row.hostName ? ` · ${row.hostName}` : ''} · ${$timeFormat.period(row.at)}`}
>{row.title}</a
>
</h3>
{#if row.detail}<p>{row.detail}</p>{/if}
<div class="resources">
{#if row.server && server === undefined}<HistoricalServerIdentity
name={row.server}
deletedAt={row.serverDeletedAt}
href={contextHref(
`/servers/${encodeURIComponent(row.server)}`,
$page.url
)}
/>{/if}
{#if row.hostName}<span class="host-name">{row.hostName}</span>{/if}
</div>
</div>
<div class="outcome"><StatusBadge state={row.state === 'succeeded' ? 'done' : row.state} /><time datetime={row.at} title={$timeFormat.dateTime(row.at)} aria-label={$timeFormat.dateTime(row.at)}>{$timeFormat.relative(row.at, $uiNow)}</time>
<span class="sources">{(row.sources.length ? row.sources : [row.source]).map(item => activitySources[item]).join(' · ')}</span>
<div class="outcome">
<StatusBadge state={row.state === 'succeeded' ? 'done' : row.state} /><time
datetime={row.at}
title={$timeFormat.dateTime(row.at)}
aria-label={$timeFormat.dateTime(row.at)}
>{$timeFormat.relative(row.at, $uiNow)}</time
>
<span class="sources"
>{(row.sources.length ? row.sources : [row.source])
.map((item) => activitySources[item])
.join(' · ')}</span
>
</div>
</li>
{/each}
</ol>
{:else if !error}<EmptyState title={filterCount ? 'No matching activity' : 'No activity recorded yet'} description={filterCount ? 'Try another type, state, server, host, or date range.' : historyDescription}>
{#snippet actions()}{#if filterCount}<Button size="sm" onclick={reset}>Clear filters</Button>{/if}{/snippet}
</EmptyState>{/if}
{:else if !error}<EmptyState
title={filterCount ? 'No matching activity' : 'No activity recorded yet'}
description={filterCount
? 'Try another type, state, server, host, or date range.'
: historyDescription}
>
{#snippet actions()}{#if filterCount}<Button size="sm" onclick={reset}
>Clear filters</Button
>{/if}{/snippet}
</EmptyState>{/if}
{#if data && !loading && loadedUrl === apiUrl && (data.total > 0 || data.offset > 0)}
<div class="pagination"><span>{data.rows.length ? `${data.offset + 1}–${data.offset + data.rows.length} of ${data.total}` : 'No activities on this page'}</span>
<label>Per page<select aria-label="Activities per page" value={data.limit} onchange={event => update({ limit: event.currentTarget.value })}>{#each [...new Set([25, 50, 100, data.limit])].sort((a, b) => a - b) as size}<option value={size}>{size}</option>{/each}</select></label>
{#if data.total > data.limit || data.offset > 0}<nav aria-label="Activity pages"><Button size="sm" disabled={data.offset === 0} href={activityHistoryHref($page.url, { offset: String(Math.max(0, data.offset - data.limit)) })}>Previous</Button><Button size="sm" disabled={data.offset + data.limit >= data.total} href={activityHistoryHref($page.url, { offset: String(data.offset + data.limit) })}>Next</Button></nav>{/if}
<div class="pagination">
<span
>{data.rows.length
? `${data.offset + 1}–${data.offset + data.rows.length} of ${data.total}`
: 'No activities on this page'}</span
>
<label
>Per page<select
aria-label="Activities per page"
value={data.limit}
onchange={(event) => update({ limit: event.currentTarget.value })}
>{#each [...new Set( [25, 50, 100, data.limit] )].sort((a, b) => a - b) as size}<option
value={size}>{size}</option
>{/each}</select
></label
>
{#if data.total > data.limit || data.offset > 0}<nav aria-label="Activity pages">
<Button
size="sm"
disabled={data.offset === 0}
href={activityHistoryHref($page.url, {
offset: String(Math.max(0, data.offset - data.limit))
})}>Previous</Button
><Button
size="sm"
disabled={data.offset + data.limit >= data.total}
href={activityHistoryHref($page.url, {
offset: String(data.offset + data.limit)
})}>Next</Button
>
</nav>{/if}
</div>
{/if}
</section>
<style>
.activity-log { display: grid; gap: var(--space-4); min-width: 0; }
.filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); align-items: end; gap: var(--space-3); }
label { display: grid; gap: 6px; color: var(--text-secondary); font-size: var(--text-xs); min-width: 0; }
select, input { width: 100%; min-height: 40px; padding: 8px 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; font-size: var(--text-sm); }
.search { grid-column: span 2; display: flex; align-items: end; gap: var(--space-2); min-width: 0; }
.search label { flex: 1; }
.context, .pagination { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-4); font-size: var(--text-xs); color: var(--text-secondary); }
.context > :last-child { margin-left: auto; }
.text-button { padding: 4px 0; border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font: inherit; }
.activity-list { padding: 0; margin: 0; list-style: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
.activity-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(180px, auto); gap: var(--space-3); padding: var(--space-4); }
.activity-row + .activity-row { border-top: 1px solid var(--border-subtle); }
.activity-row:focus-within { background: var(--surface-raised); }
h3, p { margin: 0; }
h3 { font-size: var(--text-sm); font-weight: var(--weight-medium); }
a { color: var(--accent-primary-hover); text-decoration: none; }
a:hover { text-decoration: underline; }
a:focus-visible, button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
.event { min-width: 0; overflow-wrap: anywhere; }
.event p { margin-top: var(--space-1); color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
.resources { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1) var(--space-3); margin-top: var(--space-2); font-size: var(--text-xs); }
.host-name, .sources, time { color: var(--text-secondary); font-size: var(--text-xs); }
.outcome { display: flex; flex-direction: column; align-items: flex-end; gap: var(--space-2); text-align: right; }
time { font-variant-numeric: tabular-nums; }
.sources { max-width: 32ch; overflow-wrap: anywhere; }
.pagination { justify-content: space-between; }
.pagination label { display: flex; align-items: center; gap: var(--space-2); }
.pagination select { width: auto; min-height: 36px; }
.pagination nav { display: flex; gap: var(--space-2); }
.error { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); color: var(--state-warning); font-size: var(--text-sm); }
.mobile-filters { display: none; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
.activity-log {
display: grid;
gap: var(--space-4);
min-width: 0;
}
.filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
align-items: end;
gap: var(--space-3);
}
label {
display: grid;
gap: 6px;
color: var(--text-secondary);
font-size: var(--text-xs);
min-width: 0;
}
select,
input {
width: 100%;
min-height: 40px;
padding: 8px 10px;
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
background: var(--surface-sunken);
color: var(--text-primary);
font: inherit;
font-size: var(--text-sm);
}
.search {
grid-column: span 2;
display: flex;
align-items: end;
gap: var(--space-2);
min-width: 0;
}
.search label {
flex: 1;
}
.context,
.pagination {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2) var(--space-4);
font-size: var(--text-xs);
color: var(--text-secondary);
}
.context > :last-child {
margin-left: auto;
}
.text-button {
padding: 4px 0;
border: 0;
background: none;
color: var(--accent-primary-hover);
cursor: pointer;
font: inherit;
}
.activity-list {
padding: 0;
margin: 0;
list-style: none;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
overflow: hidden;
}
.activity-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(180px, auto);
gap: var(--space-3);
padding: var(--space-4);
}
.activity-row + .activity-row {
border-top: 1px solid var(--border-subtle);
}
.activity-row:focus-within {
background: var(--surface-raised);
}
h3,
p {
margin: 0;
}
h3 {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
}
a {
color: var(--accent-primary-hover);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
a:focus-visible,
button:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
.event {
min-width: 0;
overflow-wrap: anywhere;
}
.event p {
margin-top: var(--space-1);
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: var(--leading-body);
}
.resources {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-1) var(--space-3);
margin-top: var(--space-2);
font-size: var(--text-xs);
}
.host-name,
.sources,
time {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.outcome {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--space-2);
text-align: right;
}
time {
font-variant-numeric: tabular-nums;
}
.sources {
max-width: 32ch;
overflow-wrap: anywhere;
}
.pagination {
justify-content: space-between;
}
.pagination label {
display: flex;
align-items: center;
gap: var(--space-2);
}
.pagination select {
width: auto;
min-height: 36px;
}
.pagination nav {
display: flex;
gap: var(--space-2);
}
.error {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
color: var(--state-warning);
font-size: var(--text-sm);
}
.mobile-filters {
display: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 640px) {
.desktop-filters { display: none; }
.mobile-filters { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); font-size: var(--text-sm); }
.filters { grid-template-columns: minmax(0, 1fr); }
.search { grid-column: auto; }
.context { gap: var(--space-1) var(--space-2); }
.context > :last-child { margin-left: 0; }
.activity-row { grid-template-columns: minmax(0, 1fr); padding: var(--space-3); gap: var(--space-2); }
.outcome { flex-direction: row; flex-wrap: wrap; align-items: center; text-align: left; }
.sources { max-width: none; }
.pagination { gap: var(--space-3); }
.pagination nav { margin-left: auto; }
.desktop-filters {
display: none;
}
.mobile-filters {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
font-size: var(--text-sm);
}
.filters {
grid-template-columns: minmax(0, 1fr);
}
.search {
grid-column: auto;
}
.context {
gap: var(--space-1) var(--space-2);
}
.context > :last-child {
margin-left: 0;
}
.activity-row {
grid-template-columns: minmax(0, 1fr);
padding: var(--space-3);
gap: var(--space-2);
}
.outcome {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
text-align: left;
}
.sources {
max-width: none;
}
.pagination {
gap: var(--space-3);
}
.pagination nav {
margin-left: auto;
}
}
</style>
+163 -35
View File
@@ -2,25 +2,46 @@
import { timeFormat } from '$ui/stores/time-zone.js';
import Dialog from './primitives/Dialog.svelte';
import Button from './primitives/Button.svelte';
let { open = $bindable(false), onadded }: { open?: boolean; onadded: () => Promise<unknown> } = $props();
let { open = $bindable(false), onadded }: { open?: boolean; onadded: () => Promise<unknown> } =
$props();
let name = $state('');
let portMin = $state(28960); let portMax = $state(28980);
let busy = $state(false); let error = $state(''); let copied = $state(false);
let portMin = $state(28960);
let portMax = $state(28980);
let busy = $state(false);
let error = $state('');
let copied = $state(false);
let enrollment = $state<{ command: string; expiresAt: string; hostId: string } | null>(null);
async function add(event: SubmitEvent) {
event.preventDefault(); if (busy) return;
busy = true; error = ''; copied = false;
event.preventDefault();
if (busy) return;
busy = true;
error = '';
copied = false;
try {
const response = await fetch('/api/hosts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, portMin, portMax }) });
const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not add host.');
const response = await fetch('/api/hosts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, portMin, portMax })
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not add host.');
enrollment = body.enrollment;
await onadded();
} catch (cause) { error = (cause as Error).message; } finally { busy = false; }
} catch (cause) {
error = (cause as Error).message;
} finally {
busy = false;
}
}
async function copy() {
if (!enrollment) return;
try { await navigator.clipboard.writeText(enrollment.command); copied = true; error = ''; }
catch { error = 'Could not copy automatically. Select and copy the command below.'; }
try {
await navigator.clipboard.writeText(enrollment.command);
copied = true;
error = '';
} catch {
error = 'Could not copy automatically. Select and copy the command below.';
}
}
</script>
@@ -28,35 +49,142 @@
<div class="content">
{#if error}<p role="alert" class="ui-error">{error}</p>{/if}
{#if enrollment}
<p role="status">{name} created. Run this command with administrator access on that machine.</p>
<textarea readonly aria-label="Host installation command" rows="6" value={enrollment.command}></textarea>
<div class="actions"><Button onclick={copy}>{copied ? 'Copied' : 'Copy command'}</Button><Button variant="primary" href={`/hosts/${enrollment.hostId}?tab=setup`}>Open host setup</Button></div>
<p class="muted">This single-use command expires {$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>
<p role="status">
{name} created. Run this command with administrator access on that machine.
</p>
<textarea
readonly
aria-label="Host installation command"
rows="6"
value={enrollment.command}
></textarea>
<div class="actions">
<Button onclick={copy}>{copied ? 'Copied' : 'Copy command'}</Button><Button
variant="primary"
href={`/hosts/${enrollment.hostId}?tab=setup`}>Open host setup</Button
>
</div>
<p class="muted">
This single-use command expires {$timeFormat.dateTime(enrollment.expiresAt)}.
Generate a new installer from host settings if it expires.
</p>
<Button
variant="ghost"
onclick={() => {
enrollment = null;
name = '';
copied = false;
error = '';
}}>Add another host</Button
>
{:else}
<p>Use a Debian 12 or 13 amd64 machine. The agent connects over HTTPS and prepares the runtime and game files automatically.</p>
<form onsubmit={add}><fieldset disabled={busy}>
<label>Friendly name<input required maxlength="200" bind:value={name} placeholder="Game host 1" /></label>
<details><summary>Advanced settings</summary><div class="ports">
<label>First game port<input type="number" required min="1" max="65535" bind:value={portMin} /></label>
<label>Last game port<input type="number" required min={portMin} max="65535" bind:value={portMax} /></label>
</div></details>
<Button type="submit" variant="primary" disabled={busy}>{busy ? 'Creating…' : 'Add host'}</Button>
</fieldset></form>
<p>
Use a Debian 12 or 13 amd64 machine. The agent connects over HTTPS and prepares the
runtime and game files automatically.
</p>
<form onsubmit={add}>
<fieldset disabled={busy}>
<label
>Friendly name<input
required
maxlength="200"
bind:value={name}
placeholder="Game host 1"
/></label
>
<details>
<summary>Advanced settings</summary>
<div class="ports">
<label
>First game port<input
type="number"
required
min="1"
max="65535"
bind:value={portMin}
/></label
>
<label
>Last game port<input
type="number"
required
min={portMin}
max="65535"
bind:value={portMax}
/></label
>
</div>
</details>
<Button type="submit" variant="primary" disabled={busy}
>{busy ? 'Creating…' : 'Add host'}</Button
>
</fieldset>
</form>
{/if}
</div>
</Dialog>
<style>
.content,fieldset { display:grid; gap:20px; }
fieldset { border:0; padding:0; margin:0; min-width:0; }
p { margin:0; color:var(--text-secondary); font-size:var(--text-sm); line-height:1.6; }
.muted { font-size:var(--text-xs); }
label { display:grid; gap:8px; font-size:var(--text-sm); }
input,textarea { box-sizing:border-box; width:100%; min-width:0; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); }
textarea { font:var(--text-xs)/1.6 var(--font-mono); resize:vertical; }
summary { cursor:pointer; color:var(--text-secondary); font-size:var(--text-sm); }
.ports { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-top:16px; }
.actions { display:flex; flex-wrap:wrap; gap:12px; }
input:focus-visible,textarea:focus-visible,summary:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
.content,
fieldset {
display: grid;
gap: 20px;
}
fieldset {
border: 0;
padding: 0;
margin: 0;
min-width: 0;
}
p {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.6;
}
.muted {
font-size: var(--text-xs);
}
label {
display: grid;
gap: 8px;
font-size: var(--text-sm);
}
input,
textarea {
box-sizing: border-box;
width: 100%;
min-width: 0;
padding: 10px;
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
}
textarea {
font: var(--text-xs)/1.6 var(--font-mono);
resize: vertical;
}
summary {
cursor: pointer;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.ports {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 16px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
input:focus-visible,
textarea:focus-visible,
summary:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
</style>
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -8,6 +8,15 @@
<a class="back-link" {href}>← {returnLabel(href)}</a>
<style>
.back-link { display: inline-block; align-self: start; width: fit-content; font-size: var(--text-sm); color: var(--accent-primary-hover); text-decoration: none; }
.back-link:hover { text-decoration: underline; }
.back-link {
display: inline-block;
align-self: start;
width: fit-content;
font-size: var(--text-sm);
color: var(--accent-primary-hover);
text-decoration: none;
}
.back-link:hover {
text-decoration: underline;
}
</style>
File diff suppressed because it is too large Load Diff
+199 -52
View File
@@ -5,96 +5,243 @@
import ServerIdentity from './ServerIdentity.svelte';
import { artifactVersionLabel } from '$lib/artifact-version.js';
interface FileEntry { id: number; name: string; kind: string; target_rel_path: string;
version: { version: number; release_version: string | null; sha256: string } | null }
let { open = $bindable(false), betaId, revision, files, servers, timeoutHours, onUpdate }:
{ open?: boolean; betaId: number; revision: number; files: FileEntry[]; servers: string[];
timeoutHours?: number; onUpdate: () => Promise<void> } = $props();
interface Replacement { file: File; sha: string; before: string | null; after: string | null }
interface FileEntry {
id: number;
name: string;
kind: string;
target_rel_path: string;
version: { version: number; release_version: string | null; sha256: string } | null;
}
let {
open = $bindable(false),
betaId,
revision,
files,
servers,
timeoutHours,
onUpdate
}: {
open?: boolean;
betaId: number;
revision: number;
files: FileEntry[];
servers: string[];
timeoutHours?: number;
onUpdate: () => Promise<void>;
} = $props();
interface Replacement {
file: File;
sha: string;
before: string | null;
after: string | null;
}
let replacements = $state<Record<number, Replacement>>({});
let notes = $state(''), error = $state(''), reviewing = $state(false), busy = $state(false);
let notes = $state(''),
error = $state(''),
reviewing = $state(false),
busy = $state(false);
const count = $derived(Object.keys(replacements).length);
$effect(() => { if (open) { replacements = {}; notes = ''; error = ''; reviewing = false; } });
function fileUrl(scriptId: number) { return `/api/betas/${betaId}/revisions/${revision}/files/${scriptId}`; }
$effect(() => {
if (open) {
replacements = {};
notes = '';
error = '';
reviewing = false;
}
});
function fileUrl(scriptId: number) {
return `/api/betas/${betaId}/revisions/${revision}/files/${scriptId}`;
}
async function choose(entry: FileEntry, file?: File) {
delete replacements[entry.id]; error = '';
delete replacements[entry.id];
error = '';
if (!file) return;
busy = true;
try {
if (!file.size) throw new Error('Choose a non-empty file.');
const bytes = await file.arrayBuffer();
const inspection = new FormData(); inspection.set('content', file);
const inspected = await fetch('/api/scripts/inspect', { method: 'POST', body: inspection });
const inspection = new FormData();
inspection.set('content', file);
const inspected = await fetch('/api/scripts/inspect', {
method: 'POST',
body: inspection
});
const inspectionBody = await inspected.json();
if (!inspected.ok) throw new Error(inspectionBody.error ?? 'Could not inspect replacement file.');
if (!inspected.ok)
throw new Error(inspectionBody.error ?? 'Could not inspect replacement file.');
const sha = inspectionBody.sha256 as string;
if (sha === entry.version?.sha256) throw new Error(`${entry.name}: this file is identical to the current version.`);
let before: string | null = null, after: string | null = null;
if (sha === entry.version?.sha256)
throw new Error(`${entry.name}: this file is identical to the current version.`);
let before: string | null = null,
after: string | null = null;
if (entry.kind !== 'plugin' && bytes.byteLength <= 128 * 1024) {
const response = await fetch(fileUrl(entry.id));
if (!response.ok) throw new Error('Could not load the current file for review. Reload the beta and try again.');
if (!response.ok)
throw new Error(
'Could not load the current file for review. Reload the beta and try again.'
);
const old = await response.arrayBuffer();
if (old.byteLength <= 128 * 1024 && !new Uint8Array(old).includes(0) && !new Uint8Array(bytes).includes(0)) {
before = new TextDecoder().decode(old); after = new TextDecoder().decode(bytes);
if (
old.byteLength <= 128 * 1024 &&
!new Uint8Array(old).includes(0) &&
!new Uint8Array(bytes).includes(0)
) {
before = new TextDecoder().decode(old);
after = new TextDecoder().decode(bytes);
}
}
replacements[entry.id] = { file, sha, before, after };
} catch (err) { error = (err as Error).message; }
finally { busy = false; }
} catch (err) {
error = (err as Error).message;
} finally {
busy = false;
}
}
async function deploy() {
busy = true; error = '';
busy = true;
error = '';
try {
const form = new FormData();
form.set('expectedRevision', String(revision)); form.set('notes', notes);
for (const [scriptId, replacement] of Object.entries(replacements)) form.set(`file:${scriptId}`, replacement.file);
const response = await fetch(`/api/betas/${betaId}/revisions`, { method: 'POST', body: form });
form.set('expectedRevision', String(revision));
form.set('notes', notes);
for (const [scriptId, replacement] of Object.entries(replacements))
form.set(`file:${scriptId}`, replacement.file);
const response = await fetch(`/api/betas/${betaId}/revisions`, {
method: 'POST',
body: form
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not update beta.');
open = false; await onUpdate();
} catch (err) { error = (err as Error).message; }
finally { busy = false; }
open = false;
await onUpdate();
} catch (err) {
error = (err as Error).message;
} finally {
busy = false;
}
}
</script>
<Dialog bind:open title={reviewing ? `Review revision ${revision + 1}` : 'Update beta'}
description={`Replace files in beta #${betaId} and repeat its tests on the same servers.`} {busy} wide>
<Dialog
bind:open
title={reviewing ? `Review revision ${revision + 1}` : 'Update beta'}
description={`Replace files in beta #${betaId} and repeat its tests on the same servers.`}
{busy}
wide
>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if !reviewing}
<p>Choose replacements for the files you changed.</p>
<div class="files">{#each files as entry}
<label><strong>{entry.name} · {artifactVersionLabel(entry.version)}</strong><code>{entry.target_rel_path}</code>
<input type="file" disabled={busy} onchange={event => choose(entry, event.currentTarget.files?.[0])} />
{#if replacements[entry.id]}<span>Replacement ready for review</span>{/if}
</label>
{/each}</div>
<label class="notes">Change note (optional)<textarea bind:value={notes} maxlength="1000" rows="3" disabled={busy}></textarea></label>
<div class="files">
{#each files as entry}
<label
><strong>{entry.name} · {artifactVersionLabel(entry.version)}</strong><code
>{entry.target_rel_path}</code
>
<input
type="file"
disabled={busy}
onchange={(event) => choose(entry, event.currentTarget.files?.[0])}
/>
{#if replacements[entry.id]}<span>Replacement ready for review</span>{/if}
</label>
{/each}
</div>
<label class="notes"
>Change note (optional)<textarea
bind:value={notes}
maxlength="1000"
rows="3"
disabled={busy}
></textarea></label
>
{:else}
<p><strong>{count} file{count === 1 ? '' : 's'} changed.</strong> All {servers.length} selected servers will repeat the test policy, using its existing wave order. Each server installs the bundle and restarts when empty.</p>
<p>Previous results move to revision history. Promotion requires this revision to pass.
{#if timeoutHours}A fresh {timeoutHours}-hour test window starts when you deploy, including time waiting for empty servers.{/if}
Rejecting the beta still restores the files from before revision 1.</p>
<details open><summary>Servers ({servers.length})</summary><ul>{#each servers as name}<li><ServerIdentity {name}/></li>{/each}</ul></details>
{#each files.filter(entry => replacements[entry.id]) as entry}
<p>
<strong>{count} file{count === 1 ? '' : 's'} changed.</strong> All {servers.length} selected
servers will repeat the test policy, using its existing wave order. Each server installs the
bundle and restarts when empty.
</p>
<p>
Previous results move to revision history. Promotion requires this revision to pass.
{#if timeoutHours}A fresh {timeoutHours}-hour test window starts when you deploy,
including time waiting for empty servers.{/if}
Rejecting the beta still restores the files from before revision 1.
</p>
<details open>
<summary>Servers ({servers.length})</summary>
<ul>
{#each servers as name}<li><ServerIdentity {name} /></li>{/each}
</ul>
</details>
{#each files.filter((entry) => replacements[entry.id]) as entry}
{@const replacement = replacements[entry.id]}
<section><h3>{entry.name}</h3><p><code>{entry.target_rel_path}</code> ← {replacement.file.name} ({replacement.file.size.toLocaleString()} bytes)</p>
<p class="hash">SHA {entry.version?.sha256.slice(0,12)} → {replacement.sha.slice(0,12)}</p>
{#if replacement.after !== null}<DiffViewer before={replacement.before} after={replacement.after} verb="updated" relPath={entry.target_rel_path}/>
<section>
<h3>{entry.name}</h3>
<p>
<code>{entry.target_rel_path}</code> ← {replacement.file.name} ({replacement.file.size.toLocaleString()}
bytes)
</p>
<p class="hash">
SHA {entry.version?.sha256.slice(0, 12)} → {replacement.sha.slice(0, 12)}
</p>
{#if replacement.after !== null}<DiffViewer
before={replacement.before}
after={replacement.after}
verb="updated"
relPath={entry.target_rel_path}
/>
{:else}<p>Binary or large file: review the uploaded file and checksum.</p>{/if}
</section>
{/each}
{#if notes}<p>Change note: {notes}</p>{/if}
{/if}
{#snippet actions()}
<Button onclick={() => open = false} disabled={busy}>Cancel</Button>
{#if reviewing}<Button onclick={() => reviewing = false} disabled={busy}>Back</Button><Button variant="primary" onclick={deploy} disabled={busy} loading={busy}>Deploy revision {revision + 1}</Button>
{:else}<Button variant="primary" onclick={() => reviewing = true} disabled={busy || !count}>Review update</Button>{/if}
<Button onclick={() => (open = false)} disabled={busy}>Cancel</Button>
{#if reviewing}<Button onclick={() => (reviewing = false)} disabled={busy}>Back</Button
><Button variant="primary" onclick={deploy} disabled={busy} loading={busy}
>Deploy revision {revision + 1}</Button
>
{:else}<Button
variant="primary"
onclick={() => (reviewing = true)}
disabled={busy || !count}>Review update</Button
>{/if}
{/snippet}
</Dialog>
<style>
.files, label { display:grid; gap:12px; } .files { margin:20px 0; }
.files label { padding:16px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
p { line-height:1.6; } code, .hash { overflow-wrap:anywhere; } .hash, label span { color:var(--text-secondary); font-size: var(--text-sm); }
li { margin:8px 0; } textarea { width:100%; } section { margin-top:24px; }
.files,
label {
display: grid;
gap: 12px;
}
.files {
margin: 20px 0;
}
.files label {
padding: 16px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
p {
line-height: 1.6;
}
code,
.hash {
overflow-wrap: anywhere;
}
.hash,
label span {
color: var(--text-secondary);
font-size: var(--text-sm);
}
li {
margin: 8px 0;
}
textarea {
width: 100%;
}
section {
margin-top: 24px;
}
</style>
+681 -120
View File
@@ -12,162 +12,723 @@
import ListFooter from './primitives/ListFooter.svelte';
import Button from './primitives/Button.svelte';
import { confirmAction } from '$ui/confirm.js';
import { coverageSummary, requirementSummary, needsGameTelemetry } from '$lib/beta-plan-summary.js';
import {
coverageSummary,
requirementSummary,
needsGameTelemetry
} from '$lib/beta-plan-summary.js';
import { betaWaitingCondition, betaEvidenceSummary } from '$lib/beta-evidence.js';
import type { BetaRequirement, BetaTestView } from '$lib/beta-plan.js';
let { test, betaId, closed, deadline, retryBlocked, deletedParticipants = false, deployments, busy = $bindable(false), onUpdate }: { test:BetaTestView; betaId:number; closed:boolean; deadline?:string; retryBlocked?:string; deletedParticipants?:boolean; deployments?:BetaDeploymentServer[]; busy?:boolean; onUpdate:()=>Promise<void> } = $props();
let notes = $state<Record<string,string>>({});
let evidenceOpen = $state(false), evidenceLimit = $state(25), error = $state(''), message = $state('');
let evidenceServer = $state(''), evidenceKind = $state(''), evidenceQuery = $state('');
let {
test,
betaId,
closed,
deadline,
retryBlocked,
deletedParticipants = false,
deployments,
busy = $bindable(false),
onUpdate
}: {
test: BetaTestView;
betaId: number;
closed: boolean;
deadline?: string;
retryBlocked?: string;
deletedParticipants?: boolean;
deployments?: BetaDeploymentServer[];
busy?: boolean;
onUpdate: () => Promise<void>;
} = $props();
let notes = $state<Record<string, string>>({});
let evidenceOpen = $state(false),
evidenceLimit = $state(25),
error = $state(''),
message = $state('');
let evidenceServer = $state(''),
evidenceKind = $state(''),
evidenceQuery = $state('');
let evidenceSection = $state<HTMLDetailsElement>();
const hasDeletedServers = $derived(deletedParticipants || test.servers.some(server => server.server_deleted_at) || deployments?.some(server => server.server_deleted_at));
const canSign = $derived(!closed && !hasDeletedServers && ['active','passed'].includes(test.outcome));
const expired = $derived(test.outcome === 'inconclusive' || (!!deadline && test.outcome !== 'passed' && Date.parse(deadline) <= Date.now()));
const ranked = $derived([...test.servers].sort((a,b) => (a.progress.some(r => r.failed) ? 0 : a.passed ? 2 : 1) - (b.progress.some(r => r.failed) ? 0 : b.passed ? 2 : 1) || a.server.localeCompare(b.server)));
const hasDeletedServers = $derived(
deletedParticipants ||
test.servers.some((server) => server.server_deleted_at) ||
deployments?.some((server) => server.server_deleted_at)
);
const canSign = $derived(
!closed && !hasDeletedServers && ['active', 'passed'].includes(test.outcome)
);
const expired = $derived(
test.outcome === 'inconclusive' ||
(!!deadline && test.outcome !== 'passed' && Date.parse(deadline) <= Date.now())
);
const ranked = $derived(
[...test.servers].sort(
(a, b) =>
(a.progress.some((r) => r.failed) ? 0 : a.passed ? 2 : 1) -
(b.progress.some((r) => r.failed) ? 0 : b.passed ? 2 : 1) ||
a.server.localeCompare(b.server)
)
);
let expanded = $state<Record<string, boolean>>({});
const combined = $derived.by(() => {
const rows = [...(deployments ?? [])];
for (const server of test.servers) {
if (!rows.some(row => row.server === server.server)) rows.push({ server: server.server, server_deleted_at: server.server_deleted_at,
stage: 'unknown', error: null, deployed_at: null, restarted_at: null, healthy_at: null, match_seen_at: null, provisioning_id: null });
if (!rows.some((row) => row.server === server.server))
rows.push({
server: server.server,
server_deleted_at: server.server_deleted_at,
stage: 'unknown',
error: null,
deployed_at: null,
restarted_at: null,
healthy_at: null,
match_seen_at: null,
provisioning_id: null
});
}
return rows.map(deployment => {
const result = test.servers.find(server => server.server === deployment.server);
const deletedAt = deployment.server_deleted_at ?? result?.server_deleted_at ?? null;
return { deployment: { ...deployment, server_deleted_at: deletedAt }, result: result ? { ...result, server_deleted_at: deletedAt } : undefined };
})
.sort((a,b) => rank(a) - rank(b) || a.deployment.server.localeCompare(b.deployment.server));
return rows
.map((deployment) => {
const result = test.servers.find((server) => server.server === deployment.server);
const deletedAt = deployment.server_deleted_at ?? result?.server_deleted_at ?? null;
return {
deployment: { ...deployment, server_deleted_at: deletedAt },
result: result ? { ...result, server_deleted_at: deletedAt } : undefined
};
})
.sort(
(a, b) =>
rank(a) - rank(b) || a.deployment.server.localeCompare(b.deployment.server)
);
});
function rank(row: { deployment: BetaDeploymentServer; result?: BetaTestView['servers'][number] }) {
return row.deployment.stage === 'failed' || row.result?.progress.some(check => check.failed) ? 0 : row.result?.passed ? 2 : 1;
function rank(row: {
deployment: BetaDeploymentServer;
result?: BetaTestView['servers'][number];
}) {
return row.deployment.stage === 'failed' ||
row.result?.progress.some((check) => check.failed)
? 0
: row.result?.passed
? 2
: 1;
}
const filteredEvidence = $derived(test.evidence.filter(event => (!evidenceServer || event.server === evidenceServer)
&& (!evidenceKind || (evidenceKind === 'gameplay' ? ['game_start','round_change','heartbeat','game_end'].includes(event.kind) : evidenceKind === 'health' ? ['health','status-sample'].includes(event.kind) : event.kind === evidenceKind))
&& (!evidenceQuery || `${betaEvidenceSummary(event)} ${event.payload}`.toLowerCase().includes(evidenceQuery.toLowerCase()))));
$effect(() => { evidenceServer; evidenceKind; evidenceQuery; evidenceLimit = 25; });
export async function action(action:string, extra:Record<string,string>={}) {
if (action === 'retry' && !await confirmAction({title:'Start a fresh test attempt?',message:`Reset current progress and checklist sign-offs on ${test.servers.length} deployed servers. They will restart when empty and must collect fresh evidence. Previous evidence remains in history. The deadline stays ${deadline ? $timeFormat.dateTime(deadline) : 'unchanged'}.`,confirmLabel:'Reset and restart testing'})) return;
busy = true; error = ''; message = '';
const filteredEvidence = $derived(
test.evidence.filter(
(event) =>
(!evidenceServer || event.server === evidenceServer) &&
(!evidenceKind ||
(evidenceKind === 'gameplay'
? ['game_start', 'round_change', 'heartbeat', 'game_end'].includes(
event.kind
)
: evidenceKind === 'health'
? ['health', 'status-sample'].includes(event.kind)
: event.kind === evidenceKind)) &&
(!evidenceQuery ||
`${betaEvidenceSummary(event)} ${event.payload}`
.toLowerCase()
.includes(evidenceQuery.toLowerCase()))
)
);
$effect(() => {
void evidenceServer;
void evidenceKind;
void evidenceQuery;
evidenceLimit = 25;
});
export async function action(action: string, extra: Record<string, string> = {}) {
if (
action === 'retry' &&
!(await confirmAction({
title: 'Start a fresh test attempt?',
message: `Reset current progress and checklist sign-offs on ${test.servers.length} deployed servers. They will restart when empty and must collect fresh evidence. Previous evidence remains in history. The deadline stays ${deadline ? $timeFormat.dateTime(deadline) : 'unchanged'}.`,
confirmLabel: 'Reset and restart testing'
}))
)
return;
busy = true;
error = '';
message = '';
try {
const response = await fetch(`/api/betas/${betaId}/test`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,...extra})}), body = await response.json();
const response = await fetch(`/api/betas/${betaId}/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, ...extra })
}),
body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not update the test.');
await onUpdate();
message = action === 'sign-off' ? 'Observation recorded.' : action === 'retry' ? 'Fresh attempt queued. Deployed servers restart when empty; the deadline is unchanged.' : action === 'pause' ? 'Testing paused. The original deadline still applies.' : 'Testing resumed. Continuous timing checks restart from fresh observations.';
} catch(e) { error = (e as Error).message; }
finally { busy = false; }
message =
action === 'sign-off'
? 'Observation recorded.'
: action === 'retry'
? 'Fresh attempt queued. Deployed servers restart when empty; the deadline is unchanged.'
: action === 'pause'
? 'Testing paused. The original deadline still applies.'
: 'Testing resumed. Continuous timing checks restart from fresh observations.';
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
async function showEvidence(server:string, requirement:BetaRequirement) {
evidenceServer = server; evidenceKind = needsGameTelemetry(requirement) ? 'gameplay' : requirement.kind === 'manual' ? 'manual-sign-off' : ['failure-budget','healthy-minutes'].includes(requirement.kind) ? 'health' : '';
evidenceQuery = ''; evidenceOpen = true; await tick(); evidenceSection?.scrollIntoView({block:'start',behavior:'smooth'}); evidenceSection?.querySelector('summary')?.focus();
async function showEvidence(server: string, requirement: BetaRequirement) {
evidenceServer = server;
evidenceKind = needsGameTelemetry(requirement)
? 'gameplay'
: requirement.kind === 'manual'
? 'manual-sign-off'
: ['failure-budget', 'healthy-minutes'].includes(requirement.kind)
? 'health'
: '';
evidenceQuery = '';
evidenceOpen = true;
await tick();
evidenceSection?.scrollIntoView({ block: 'start', behavior: 'smooth' });
evidenceSection?.querySelector('summary')?.focus();
}
</script>
<section aria-label="Test plan progress">
<div class="heading"><h2>{deployments ? 'Test servers' : 'Test results'}</h2>{#if !deployments}<StatusBadge state={test.outcome}/>{/if}</div>
<div class="heading">
<h2>{deployments ? 'Test servers' : 'Test results'}</h2>
{#if !deployments}<StatusBadge state={test.outcome} />{/if}
</div>
{#if !closed && !deployments}
{#if !hasDeletedServers}<div class="actions">
{#if test.outcome === 'paused' && !expired}<Button size="sm" disabled={busy} onclick={()=>action('resume')}>Resume testing</Button>{/if}
{#if test.outcome === 'active'}<Button size="sm" disabled={busy} onclick={()=>action('pause')}>Pause testing</Button>{/if}
{#if ['paused','failed'].includes(test.outcome) && !expired}<Button size="sm" disabled={busy || !!retryBlocked} onclick={()=>action('retry')}>Start fresh attempt</Button>{/if}
</div>{/if}
{#if retryBlocked && ['paused','failed'].includes(test.outcome)}<p>{retryBlocked}</p>{/if}
{#if expired}<p class="guidance">The testing window ended. Use “Update beta” to test changed files in a fresh revision, or roll back and reuse the plan with a longer window.</p>
{:else if test.outcome === 'paused' && !hasDeletedServers}<p class="guidance">Resume retains completed milestones; continuous health and in-progress timing intervals start fresh. Pausing does not extend the deadline.</p>{/if}
{#if !hasDeletedServers}<div class="actions">
{#if test.outcome === 'paused' && !expired}<Button
size="sm"
disabled={busy}
onclick={() => action('resume')}>Resume testing</Button
>{/if}
{#if test.outcome === 'active'}<Button
size="sm"
disabled={busy}
onclick={() => action('pause')}>Pause testing</Button
>{/if}
{#if ['paused', 'failed'].includes(test.outcome) && !expired}<Button
size="sm"
disabled={busy || !!retryBlocked}
onclick={() => action('retry')}>Start fresh attempt</Button
>{/if}
</div>{/if}
{#if retryBlocked && ['paused', 'failed'].includes(test.outcome)}<p>{retryBlocked}</p>{/if}
{#if expired}<p class="guidance">
The testing window ended. Use “Update beta” to test changed files in a fresh
revision, or roll back and reuse the plan with a longer window.
</p>
{:else if test.outcome === 'paused' && !hasDeletedServers}<p class="guidance">
Resume retains completed milestones; continuous health and in-progress timing
intervals start fresh. Pausing does not extend the deadline.
</p>{/if}
{/if}
{#if error}<p role="alert" class="failed">{error}</p>{/if}
{#if message}<p role="status">{message}</p>{/if}
{#if !test.servers.length}<p>{closed ? 'Test results were not recorded.' : hasDeletedServers ? 'No test run was recorded before a participating server was deleted.' : 'Awaiting the first test run.'}</p>{/if}
{#if !test.servers.length}<p>
{closed
? 'Test results were not recorded.'
: hasDeletedServers
? 'No test run was recorded before a participating server was deleted.'
: 'Awaiting the first test run.'}
</p>{/if}
{#if deployments}
<div class="table-scroll"><table>
<thead><tr><th scope="col">Server</th><th scope="col">Deployment</th><th scope="col">Test result</th><th scope="col" class="align-right">Details</th></tr></thead>
<tbody>{#each combined as { deployment, result } (deployment.server)}
{@const failed = deployment.stage === 'failed' || result?.progress.some(check => check.failed)}
{@const open = expanded[deployment.server] ?? Boolean(failed)}
<tr>
<td><HistoricalServerIdentity name={deployment.server} deletedAt={deployment.server_deleted_at} href={contextHref(`/servers/${encodeURIComponent(deployment.server)}`, $page.url)} /></td>
<td><StatusBadge state={deployment.stage === 'observing' ? 'ready' : deployment.stage} /></td>
<td>{#if result?.passed}<Badge variant="online">Passed</Badge>
{:else if result?.progress.some(check => check.failed)}<Badge variant="danger">Failed</Badge>
{:else if !result}<span>{closed || deployment.server_deleted_at ? 'Not recorded' : 'Awaiting test run'}</span>
{:else}<Badge variant="neutral">{closed ? 'Incomplete' : test.outcome === 'paused' ? 'Paused' : 'Waiting'}</Badge>{/if}
</td>
<td class="align-right"><button class="disclosure" aria-expanded={open} aria-controls={`beta-server-${betaId}-${deployment.server}`} aria-label={`${open ? 'Hide' : 'Show'} details for ${deployment.server}`} onclick={() => expanded = { ...expanded, [deployment.server]: !open }}>{open ? 'Hide' : 'Details'} <span aria-hidden="true">{open ? '−' : '+'}</span></button></td>
</tr>
<tr hidden={!open} id={`beta-server-${betaId}-${deployment.server}`} class="server-detail"><td colspan="4">
{#if deployment.error}<p class:failed={deployment.stage === 'failed'}>{deployment.error}</p>{/if}
<div class="deployment-details"><span>Files: {deployment.deployed_at ? 'installed' : 'pending'}</span><span>Restart: {deployment.restarted_at ? 'submitted' : 'pending'}</span><span>Health: {deployment.healthy_at ? 'verified' : 'not recorded'}</span>
{#if deployment.match_seen_at}<span>Match observed {$timeFormat.dateTime(deployment.match_seen_at)}</span>{/if}
</div>
<div class="deployment-links">
{#if !deployment.server_deleted_at}<a href={contextHref(`/servers/${encodeURIComponent(deployment.server)}?tab=diagnostics`, $page.url)}>Diagnostics</a><a href={contextHref(`/servers/${encodeURIComponent(deployment.server)}?tab=logs`, $page.url)}>Open logs</a>{/if}
{#if deployment.provisioning_id}<a href={contextHref(`/provisioning/${encodeURIComponent(deployment.provisioning_id)}`, $page.url)}>View provisioning</a>{/if}
</div>
{#if result}
{#if result.progress.length}<p>{result.progress.filter(check => check.passed).length} of {result.progress.length} checks passed{test.plan.requirements.some(requirement => requirement.kind === 'recovery') ? ` · Recovery cycle ${result.phase}` : ''}</p>{/if}
{@render checks(result)}
{:else}<p>{closed || deployment.server_deleted_at ? 'No test results were recorded for this server.' : 'Checks become available after the server restarts.'}</p>{/if}
</td></tr>
{/each}</tbody>
</table></div>
<div class="table-scroll">
<table>
<thead
><tr
><th scope="col">Server</th><th scope="col">Deployment</th><th scope="col"
>Test result</th
><th scope="col" class="align-right">Details</th></tr
></thead
>
<tbody
>{#each combined as { deployment, result } (deployment.server)}
{@const failed =
deployment.stage === 'failed' ||
result?.progress.some((check) => check.failed)}
{@const open = expanded[deployment.server] ?? Boolean(failed)}
<tr>
<td
><HistoricalServerIdentity
name={deployment.server}
deletedAt={deployment.server_deleted_at}
href={contextHref(
`/servers/${encodeURIComponent(deployment.server)}`,
$page.url
)}
/></td
>
<td
><StatusBadge
state={deployment.stage === 'observing'
? 'ready'
: deployment.stage}
/></td
>
<td
>{#if result?.passed}<Badge variant="online">Passed</Badge>
{:else if result?.progress.some((check) => check.failed)}<Badge
variant="danger">Failed</Badge
>
{:else if !result}<span
>{closed || deployment.server_deleted_at
? 'Not recorded'
: 'Awaiting test run'}</span
>
{:else}<Badge variant="neutral"
>{closed
? 'Incomplete'
: test.outcome === 'paused'
? 'Paused'
: 'Waiting'}</Badge
>{/if}
</td>
<td class="align-right"
><button
class="disclosure"
aria-expanded={open}
aria-controls={`beta-server-${betaId}-${deployment.server}`}
aria-label={`${open ? 'Hide' : 'Show'} details for ${deployment.server}`}
onclick={() =>
(expanded = { ...expanded, [deployment.server]: !open })}
>{open ? 'Hide' : 'Details'}
<span aria-hidden="true">{open ? '−' : '+'}</span></button
></td
>
</tr>
<tr
hidden={!open}
id={`beta-server-${betaId}-${deployment.server}`}
class="server-detail"
><td colspan="4">
{#if deployment.error}<p
class:failed={deployment.stage === 'failed'}
>
{deployment.error}
</p>{/if}
<div class="deployment-details">
<span
>Files: {deployment.deployed_at
? 'installed'
: 'pending'}</span
><span
>Restart: {deployment.restarted_at
? 'submitted'
: 'pending'}</span
><span
>Health: {deployment.healthy_at
? 'verified'
: 'not recorded'}</span
>
{#if deployment.match_seen_at}<span
>Match observed {$timeFormat.dateTime(
deployment.match_seen_at
)}</span
>{/if}
</div>
<div class="deployment-links">
{#if !deployment.server_deleted_at}<a
href={contextHref(
`/servers/${encodeURIComponent(deployment.server)}?tab=diagnostics`,
$page.url
)}>Diagnostics</a
><a
href={contextHref(
`/servers/${encodeURIComponent(deployment.server)}?tab=logs`,
$page.url
)}>Open logs</a
>{/if}
{#if deployment.provisioning_id}<a
href={contextHref(
`/provisioning/${encodeURIComponent(deployment.provisioning_id)}`,
$page.url
)}>View provisioning</a
>{/if}
</div>
{#if result}
{#if result.progress.length}<p>
{result.progress.filter((check) => check.passed).length} of
{result.progress.length} checks passed{test.plan.requirements.some(
(requirement) => requirement.kind === 'recovery'
)
? ` · Recovery cycle ${result.phase}`
: ''}
</p>{/if}
{@render checks(result)}
{:else}<p>
{closed || deployment.server_deleted_at
? 'No test results were recorded for this server.'
: 'Checks become available after the server restarts.'}
</p>{/if}
</td></tr
>
{/each}</tbody
>
</table>
</div>
{:else}
{#each ranked as server (server.server)}
<details class="server" open={!server.passed}>
<summary><HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at}/><span>{server.progress.filter(p=>p.passed).length} / {server.progress.length} checks passed</span>{#if test.plan.requirements.some(r=>r.kind==='recovery')}<span>Recovery cycles: {server.phase}</span>{/if}</summary>
{@render checks(server)}
</details>
{/each}
{#each ranked as server (server.server)}
<details class="server" open={!server.passed}>
<summary
><HistoricalServerIdentity
name={server.server}
deletedAt={server.server_deleted_at}
/><span
>{server.progress.filter((p) => p.passed).length} / {server.progress.length} checks
passed</span
>{#if test.plan.requirements.some((r) => r.kind === 'recovery')}<span
>Recovery cycles: {server.phase}</span
>{/if}</summary
>
{@render checks(server)}
</details>
{/each}
{/if}
<details><summary>Test policy</summary><p>{coverageSummary(test.plan)} · Every requirement must pass on each qualifying server.</p><p>{test.plan.waveSize ? `${test.plan.waveSize} servers per wave.` : 'All selected servers deployed together.'} {test.plan.failureAction === 'rollback' ? 'Failed checks trigger automatic rollback.' : test.plan.failureAction === 'pause' ? 'Failed checks pause testing for investigation.' : 'Failed checks hold testing for review.'} {test.plan.rollbackRestart === 'when-empty' ? 'Rollback restarts wait for empty servers.' : 'Rollback changes wait until the rollback campaign is resumed.'} Health checks continue after passing until promotion.</p></details>
<details>
<summary>Test policy</summary>
<p>{coverageSummary(test.plan)} · Every requirement must pass on each qualifying server.</p>
<p>
{test.plan.waveSize
? `${test.plan.waveSize} servers per wave.`
: 'All selected servers deployed together.'}
{test.plan.failureAction === 'rollback'
? 'Failed checks trigger automatic rollback.'
: test.plan.failureAction === 'pause'
? 'Failed checks pause testing for investigation.'
: 'Failed checks hold testing for review.'}
{test.plan.rollbackRestart === 'when-empty'
? 'Rollback restarts wait for empty servers.'
: 'Rollback changes wait until the rollback campaign is resumed.'} Health checks continue
after passing until promotion.
</p>
</details>
<details bind:this={evidenceSection} bind:open={evidenceOpen} class="evidence-section">
<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}
<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}>{serverTitle(server)}</option>{/each}</select></label><label>Event type<select bind:value={evidenceKind}><option value="">All events</option><option value="gameplay">Gameplay reports</option><option value="health">Health observations</option><option value="manual-sign-off">Tester sign-offs</option></select></label><label>Search evidence<input type="search" bind:value={evidenceQuery} placeholder="Round, map, tester, observation…"/></label></div>
{#each filteredEvidence.slice(0,evidenceLimit) as event(event.id)}<article class="evidence"><div class="heading"><HistoricalServerIdentity name={event.server} deletedAt={event.server_deleted_at}/><span class="time"><RelativeTime value={event.received_at} /></span></div><p>{betaEvidenceSummary(event)}</p><details><summary>Raw payload · {event.kind}</summary><pre>{event.payload}</pre></details></article>{/each}
<ListFooter total={filteredEvidence.length} bind:limit={evidenceLimit} label="events"/>
{#if !filteredEvidence.length}<p>{test.evidence.length ? 'No evidence matches these filters.' : 'No evidence recorded yet. Check deployment progress and telemetry diagnostics.'}</p>{/if}
<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}>{serverTitle(server)}</option
>{/each}</select
></label
><label
>Event type<select bind:value={evidenceKind}
><option value="">All events</option><option value="gameplay"
>Gameplay reports</option
><option value="health">Health observations</option><option
value="manual-sign-off">Tester sign-offs</option
></select
></label
><label
>Search evidence<input
type="search"
bind:value={evidenceQuery}
placeholder="Round, map, tester, observation…"
/></label
>
</div>
{#each filteredEvidence.slice(0, evidenceLimit) as event (event.id)}<article
class="evidence"
>
<div class="heading">
<HistoricalServerIdentity
name={event.server}
deletedAt={event.server_deleted_at}
/><span class="time"><RelativeTime value={event.received_at} /></span>
</div>
<p>{betaEvidenceSummary(event)}</p>
<details>
<summary>Raw payload · {event.kind}</summary>
<pre>{event.payload}</pre>
</details>
</article>{/each}
<ListFooter total={filteredEvidence.length} bind:limit={evidenceLimit} label="events" />
{#if !filteredEvidence.length}<p>
{test.evidence.length
? 'No evidence matches these filters.'
: 'No evidence recorded yet. Check deployment progress and telemetry diagnostics.'}
</p>{/if}
{/if}
</details>
</section>
{#snippet checks(server: BetaTestView['servers'][number])}
{#if test.plan.requirements.some(needsGameTelemetry)}<p class="observation">{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll('_',' ')} · <span class="time"><RelativeTime value={server.observation.lastGameplayAt} /></span> · {server.observation.players ?? 'Unknown'} players{#if server.observation.round !== null} · Round {server.observation.round}{/if}{:else}No gameplay reports in this attempt yet.{/if} {#if !server.server_deleted_at}<a href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics#telemetry`}>Telemetry diagnostics</a>{/if}</p>{/if}
{#each server.progress as progress (progress.id)}
{@const requirement = test.plan.requirements.find(r=>r.id===progress.id)}
{#if requirement}<div class="requirement" class:failed={progress.failed}>
<div class="heading"><strong><span class="result">{progress.passed ? 'Passed' : progress.failed ? 'Failed' : 'Waiting'}</span> {requirementSummary(requirement)}</strong><span>{progress.detail}</span></div>
{#if !progress.passed && !closed && !hasDeletedServers}<p>{betaWaitingCondition(requirement,server.observation)}</p>{/if}
{#if !['manual','failure-budget'].includes(requirement.kind)}<progress aria-label={requirementSummary(requirement)} value={Math.min(progress.current,progress.target)} max={progress.target}></progress>{/if}
{#if test.plan.requirements.some(needsGameTelemetry)}<p class="observation">
{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll(
'_',
' '
)} ·
<span class="time"><RelativeTime value={server.observation.lastGameplayAt} /></span>
· {server.observation.players ?? 'Unknown'} players{#if server.observation.round !== null}
· Round {server.observation.round}{/if}{:else}No gameplay reports in this
attempt yet.{/if}
{#if !server.server_deleted_at}<a
href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics#telemetry`}
>Telemetry diagnostics</a
>{/if}
</p>{/if}
{#each server.progress as progress (progress.id)}
{@const requirement = test.plan.requirements.find((r) => r.id === progress.id)}
{#if requirement}<div class="requirement" class:failed={progress.failed}>
<div class="heading">
<strong
><span class="result"
>{progress.passed
? 'Passed'
: progress.failed
? 'Failed'
: 'Waiting'}</span
>
{requirementSummary(requirement)}</strong
><span>{progress.detail}</span>
</div>
{#if !progress.passed && !closed && !hasDeletedServers}<p>
{betaWaitingCondition(requirement, server.observation)}
</p>{/if}
{#if !['manual', 'failure-budget'].includes(requirement.kind)}<progress
aria-label={requirementSummary(requirement)}
value={Math.min(progress.current, progress.target)}
max={progress.target}
></progress>{/if}
{#if requirement.kind === 'manual' && !progress.passed && !closed && !hasDeletedServers}
{#if canSign && server.observingAt}<div class="signoff"><label>Tester name and observation<textarea aria-label={`Tester and observation for ${progress.label} on ${server.server}`} rows="2" maxlength="1000" bind:value={notes[`${server.server}:${progress.id}`]} placeholder="Name · what you checked and observed"></textarea></label><Button size="sm" disabled={busy || !notes[`${server.server}:${progress.id}`]?.trim()} onclick={()=>action('sign-off',{server:server.server,requirementId:progress.id,note:notes[`${server.server}:${progress.id}`]})}>Sign off</Button></div>
{:else if canSign}<p>Waiting for this server's restart and health verification to finish before tester sign-off.</p>
{:else}<p>Sign-off is unavailable while this test is {test.outcome}. {test.outcome === 'paused' ? 'Resume testing first.' : 'Use the recovery instructions above.'}</p>{/if}
{#if canSign && server.observingAt}<div class="signoff">
<label
>Tester name and observation<textarea
aria-label={`Tester and observation for ${progress.label} on ${server.server}`}
rows="2"
maxlength="1000"
bind:value={notes[`${server.server}:${progress.id}`]}
placeholder="Name · what you checked and observed"
></textarea></label
><Button
size="sm"
disabled={busy || !notes[`${server.server}:${progress.id}`]?.trim()}
onclick={() =>
action('sign-off', {
server: server.server,
requirementId: progress.id,
note: notes[`${server.server}:${progress.id}`]
})}>Sign off</Button
>
</div>
{:else if canSign}<p>
Waiting for this server's restart and health verification to finish
before tester sign-off.
</p>
{:else}<p>
Sign-off is unavailable while this test is {test.outcome}. {test.outcome ===
'paused'
? 'Resume testing first.'
: 'Use the recovery instructions above.'}
</p>{/if}
{/if}
<button class="evidence-link" onclick={()=>showEvidence(server.server,requirement)}>View relevant evidence</button>
<button
class="evidence-link"
onclick={() => showEvidence(server.server, requirement)}
>View relevant evidence</button
>
</div>{/if}
{/each}
{/each}
{/snippet}
<style>
.table-scroll { overflow-x:auto; }
table { width:100%; border-collapse:collapse; font-size:var(--text-sm); text-align:left; }
th { padding:10px 12px; border-bottom:1px solid var(--border-strong); color:var(--text-tertiary); font-size:var(--text-xs); font-weight:var(--weight-medium); }
td { padding:16px 12px; border-bottom:1px solid var(--border-subtle); }
.align-right { text-align:right; }
.disclosure { background:transparent; border:0; padding:8px 0; color:var(--text-secondary); cursor:pointer; font:inherit; white-space:nowrap; }
.server-detail { background:var(--surface); }
.deployment-details,.deployment-links { display:flex; flex-wrap:wrap; gap:8px 20px; margin:12px 0; font-size:var(--text-sm); }
@media(max-width:720px) { table { min-width:620px; } }
section { display:grid; gap:16px; } h2,p { margin:0; } h2 { font-size: var(--text-md); }
p,span,.time { font-size: var(--text-sm); line-height:1.6; color:var(--text-secondary); } a { color:var(--accent-primary-hover); }
.heading,.actions,.filters { display:flex; flex-wrap:wrap; align-items:center; gap:12px; } .heading { justify-content:space-between; }
.server { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; }
.server > summary { display:flex; flex-wrap:wrap; gap:8px 16px; } summary { cursor:pointer; font-size: var(--text-base); } .server > summary::before { content:'▸'; } .server[open] > summary::before { content:'▾'; }
.requirement { display:grid; gap:8px; margin-top:20px; border-top:1px solid var(--border-subtle); padding-top:16px; } .result { font-weight:600; margin-right:6px; color:inherit; }
progress { width:100%; height:8px; accent-color:var(--state-online); } .failed { color:var(--state-danger); }
.observation,.guidance { margin-top:12px; } .evidence-link { justify-self:start; padding:6px 0; color:var(--accent-primary-hover); border:0; background:transparent; cursor:pointer; font-size: var(--text-xs); }
.signoff { display:flex; gap:12px; align-items:end; flex-wrap:wrap; } .signoff label { flex:1 1 240px; }
label { display:grid; gap:6px; font-size: var(--text-sm); color:var(--text-secondary); } input,select,textarea { min-height:40px; padding:8px 10px; border:1px solid var(--border-control); border-radius:6px; background:var(--surface-sunken); color:var(--text-primary); font:inherit; max-width:100%; } textarea { resize:vertical; width:100%; box-sizing:border-box; }
.filters { margin:16px 0; align-items:end; } .filters label { flex:1 1 180px; min-width:0; }
.evidence { display:grid; gap:8px; padding:16px 0; border-bottom:1px solid var(--border-subtle); } .evidence-section { scroll-margin-top:80px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; font-size: var(--text-xs); } .evidence-section > p { margin-top:12px; }
.table-scroll {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
text-align: left;
}
th {
padding: 10px 12px;
border-bottom: 1px solid var(--border-strong);
color: var(--text-tertiary);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
}
td {
padding: 16px 12px;
border-bottom: 1px solid var(--border-subtle);
}
.align-right {
text-align: right;
}
.disclosure {
background: transparent;
border: 0;
padding: 8px 0;
color: var(--text-secondary);
cursor: pointer;
font: inherit;
white-space: nowrap;
}
.server-detail {
background: var(--surface);
}
.deployment-details,
.deployment-links {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
margin: 12px 0;
font-size: var(--text-sm);
}
@media (max-width: 720px) {
table {
min-width: 620px;
}
}
section {
display: grid;
gap: 16px;
}
h2,
p {
margin: 0;
}
h2 {
font-size: var(--text-md);
}
p,
span,
.time {
font-size: var(--text-sm);
line-height: 1.6;
color: var(--text-secondary);
}
a {
color: var(--accent-primary-hover);
}
.heading,
.actions,
.filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.heading {
justify-content: space-between;
}
.server {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 16px;
}
.server > summary {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
}
summary {
cursor: pointer;
font-size: var(--text-base);
}
.server > summary::before {
content: '▸';
}
.server[open] > summary::before {
content: '▾';
}
.requirement {
display: grid;
gap: 8px;
margin-top: 20px;
border-top: 1px solid var(--border-subtle);
padding-top: 16px;
}
.result {
font-weight: 600;
margin-right: 6px;
color: inherit;
}
progress {
width: 100%;
height: 8px;
accent-color: var(--state-online);
}
.failed {
color: var(--state-danger);
}
.observation,
.guidance {
margin-top: 12px;
}
.evidence-link {
justify-self: start;
padding: 6px 0;
color: var(--accent-primary-hover);
border: 0;
background: transparent;
cursor: pointer;
font-size: var(--text-xs);
}
.signoff {
display: flex;
gap: 12px;
align-items: end;
flex-wrap: wrap;
}
.signoff label {
flex: 1 1 240px;
}
label {
display: grid;
gap: 6px;
font-size: var(--text-sm);
color: var(--text-secondary);
}
input,
select,
textarea {
min-height: 40px;
padding: 8px 10px;
border: 1px solid var(--border-control);
border-radius: 6px;
background: var(--surface-sunken);
color: var(--text-primary);
font: inherit;
max-width: 100%;
}
textarea {
resize: vertical;
width: 100%;
box-sizing: border-box;
}
.filters {
margin: 16px 0;
align-items: end;
}
.filters label {
flex: 1 1 180px;
min-width: 0;
}
.evidence {
display: grid;
gap: 8px;
padding: 16px 0;
border-bottom: 1px solid var(--border-subtle);
}
.evidence-section {
scroll-margin-top: 80px;
}
pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: var(--text-xs);
}
.evidence-section > p {
margin-top: 12px;
}
</style>
+400 -49
View File
@@ -4,8 +4,15 @@
import type { BlueprintSummary } from '$lib/deployment-plan.js';
import type { ZombieGame, ZombieMapEntry } from '$lib/map-aliases.js';
let { open = $bindable(false), blueprint = null, onsaved }:
{ open?: boolean; blueprint?: BlueprintSummary | null; onsaved: (blueprint: BlueprintSummary, launch: boolean) => void } = $props();
let {
open = $bindable(false),
blueprint = null,
onsaved
}: {
open?: boolean;
blueprint?: BlueprintSummary | null;
onsaved: (blueprint: BlueprintSummary, launch: boolean) => void;
} = $props();
let dialog: HTMLDialogElement | undefined = $state();
let name = $state('');
let description = $state('');
@@ -15,25 +22,73 @@
let busy = $state(false);
let error = $state('');
let total = $derived(rows.reduce((sum, row) => sum + (Number(row.count) || 0), 0));
const duplicate = (row: typeof rows[number]) => rows.some(other => other.key !== row.key && other.game === row.game && other.mapSlug === row.mapSlug);
let valid = $derived(name.trim().length > 0 && rows.length > 0 && total <= 100 && rows.every(row => row.mapSlug && Number.isInteger(row.count) && row.count >= 1 && row.count <= 15 && !duplicate(row)));
let reason = $derived(!name.trim() ? 'Enter a collection name to save.' : total > 100 ? `Reduce the total by ${total - 100} to stay within 100 target lobbies.` : rows.some(duplicate) ? 'Choose each game and map once; increase its lobby count instead.' : !valid ? 'Choose a map and 1–15 target lobbies for every row.' : '');
let canAdd = $derived((['t5', 't6'] as const).some(game => catalogs[game].some(map => !rows.some(row => row.game === game && row.mapSlug === map.slug))));
const duplicate = (row: (typeof rows)[number]) =>
rows.some(
(other) =>
other.key !== row.key && other.game === row.game && other.mapSlug === row.mapSlug
);
let valid = $derived(
name.trim().length > 0 &&
rows.length > 0 &&
total <= 100 &&
rows.every(
(row) =>
row.mapSlug &&
Number.isInteger(row.count) &&
row.count >= 1 &&
row.count <= 15 &&
!duplicate(row)
)
);
let reason = $derived(
!name.trim()
? 'Enter a collection name to save.'
: total > 100
? `Reduce the total by ${total - 100} to stay within 100 target lobbies.`
: rows.some(duplicate)
? 'Choose each game and map once; increase its lobby count instead.'
: !valid
? 'Choose a map and 1–15 target lobbies for every row.'
: ''
);
let canAdd = $derived(
(['t5', 't6'] as const).some((game) =>
catalogs[game].some(
(map) => !rows.some((row) => row.game === game && row.mapSlug === map.slug)
)
)
);
let wasOpen = false;
let session = 0;
let nextKey = 0;
$effect(() => {
if (!dialog) return;
if (open && !wasOpen) { wasOpen = true; dialog.showModal(); void initialize(); }
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
if (open && !wasOpen) {
wasOpen = true;
dialog.showModal();
void initialize();
} else if (!open && wasOpen) {
wasOpen = false;
session++;
dialog.close();
}
});
async function initialize() {
const current = ++session;
name = blueprint?.name ?? ''; description = blueprint?.description ?? '';
rows = blueprint?.entries.map(entry => ({ key: nextKey++, game: entry.game, mapSlug: entry.mapSlug, count: entry.count })) ?? [];
error = ''; loaded = false; busy = false;
name = blueprint?.name ?? '';
description = blueprint?.description ?? '';
rows =
blueprint?.entries.map((entry) => ({
key: nextKey++,
game: entry.game,
mapSlug: entry.mapSlug,
count: entry.count
})) ?? [];
error = '';
loaded = false;
busy = false;
try {
const res = await fetch('/api/maps');
if (!res.ok) throw new Error('Could not load maps. Try again.');
@@ -42,77 +97,373 @@
catalogs = { t5: body.t5Zombie, t6: body.t6Zombie };
if (!rows.length) addMap();
loaded = true;
} catch (err) { if (current === session) error = (err as Error).message; }
} catch (err) {
if (current === session) error = (err as Error).message;
}
}
function addMap() {
for (const game of ['t5', 't6'] as const) {
const map = catalogs[game].find(map => !rows.some(row => row.game === game && row.mapSlug === map.slug));
if (map) { rows = [...rows, { key: nextKey++, game, mapSlug: map.slug, count: 1 }]; return; }
const map = catalogs[game].find(
(map) => !rows.some((row) => row.game === game && row.mapSlug === map.slug)
);
if (map) {
rows = [...rows, { key: nextKey++, game, mapSlug: map.slug, count: 1 }];
return;
}
}
}
async function save(launch: boolean) {
if (busy || !valid) return;
busy = true; error = '';
busy = true;
error = '';
try {
const res = await fetch(blueprint ? `/api/blueprints/${encodeURIComponent(blueprint.id)}` : '/api/blueprints', {
method: blueprint ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, entries: rows.map(({ game, mapSlug, count }) => ({ game, mapSlug, count })) }) });
const res = await fetch(
blueprint
? `/api/blueprints/${encodeURIComponent(blueprint.id)}`
: '/api/blueprints',
{
method: blueprint ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
description,
entries: rows.map(({ game, mapSlug, count }) => ({ game, mapSlug, count }))
})
}
);
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not save collection.');
open = false; onsaved(body.blueprint, launch);
} catch (err) { error = (err as Error).message; }
finally { busy = false; }
open = false;
onsaved(body.blueprint, launch);
} catch (err) {
error = (err as Error).message;
} finally {
busy = false;
}
}
</script>
<dialog bind:this={dialog} aria-label="Collection builder" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
<dialog
bind:this={dialog}
aria-label="Collection builder"
onclick={(event) => {
if (isDialogBackdrop(event, dialog) && !busy) open = false;
}}
oncancel={(event) => {
if (busy) event.preventDefault();
}}
onclose={() => (open = false)}
>
<div class="builder">
<header><div><span class="eyebrow">One collection. Your whole lineup.</span><h2>{blueprint ? 'Edit your collection' : 'Build a server collection'}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close collection builder">×</button></header>
<p class="muted">Choose the total lobbies you want for each map. Existing and queued servers count; only missing lobbies are created. Mix Black Ops I and II in the same collection.</p>
<header>
<div>
<span class="eyebrow">One collection. Your whole lineup.</span>
<h2>{blueprint ? 'Edit your collection' : 'Build a server collection'}</h2>
</div>
<button
class="close"
disabled={busy}
onclick={() => (open = false)}
aria-label="Close collection builder">×</button
>
</header>
<p class="muted">
Choose the total lobbies you want for each map. Existing and queued servers count; only
missing lobbies are created. Mix Black Ops I and II in the same collection.
</p>
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if !loaded}<p class="muted">Loading maps…</p>{#if error}<Button onclick={initialize}>Try again</Button>{/if}
{#if !loaded}<p class="muted">Loading maps…</p>
{#if error}<Button onclick={initialize}>Try again</Button>{/if}
{:else}
<form onsubmit={event => { event.preventDefault(); void save(true); }}>
<div class="identity"><label>Collection name<input bind:value={name} maxlength="80" placeholder="Friday Zombies" required disabled={busy} /></label><label>Description (optional)<input bind:value={description} maxlength="500" placeholder="Our weekend lineup" disabled={busy} /></label></div>
<form
onsubmit={(event) => {
event.preventDefault();
void save(true);
}}
>
<div class="identity">
<label
>Collection name<input
bind:value={name}
maxlength="80"
placeholder="Friday Zombies"
required
disabled={busy}
/></label
><label
>Description (optional)<input
bind:value={description}
maxlength="500"
placeholder="Our weekend lineup"
disabled={busy}
/></label
>
</div>
<div class="lineup">
<div class="section-title"><h3>Your lineup</h3><span class="count" class:error={total > 100} role="status">{total} / 100 target lobbies</span></div>
<div class="section-title">
<h3>Your lineup</h3>
<span class="count" class:error={total > 100} role="status"
>{total} / 100 target lobbies</span
>
</div>
{#each rows as row, index (row.key)}
<div class="map-row">
<span class="number">{String(index + 1).padStart(2, '0')}</span>
<label>Game<select aria-label={`Game ${index + 1}`} bind:value={row.game} disabled={busy} onchange={event => row.mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
<label>Map<select aria-label={`Map ${index + 1}`} aria-invalid={duplicate(row) || undefined} aria-describedby={duplicate(row) ? `map-error-${row.key}` : undefined} bind:value={row.mapSlug} disabled={busy}>{#each catalogs[row.game] as map}<option value={map.slug} disabled={rows.some(other => other.key !== row.key && other.game === row.game && other.mapSlug === map.slug)}>{map.friendly}</option>{/each}</select>{#if duplicate(row)}<span class="error" id={`map-error-${row.key}`}>Already selected. Choose another map.</span>{/if}</label>
<label>Target lobbies<input aria-label={`Lobbies ${index + 1}`} aria-invalid={!(Number.isInteger(row.count) && row.count >= 1 && row.count <= 15) || undefined} aria-describedby={`count-hint-${row.key}`} type="number" min="1" max="15" step="1" bind:value={row.count} required disabled={busy} /><span id={`count-hint-${row.key}`}>1–15</span></label>
<button type="button" class="remove" aria-label={`Remove map ${index + 1}`} disabled={busy || rows.length === 1} onclick={() => rows = rows.filter(item => item.key !== row.key)}>×</button>
<label
>Game<select
aria-label={`Game ${index + 1}`}
bind:value={row.game}
disabled={busy}
onchange={(event) =>
(row.mapSlug =
catalogs[event.currentTarget.value as ZombieGame][0]
?.slug ?? '')}
><option value="t5">Black Ops I</option><option value="t6"
>Black Ops II</option
></select
></label
>
<label
>Map<select
aria-label={`Map ${index + 1}`}
aria-invalid={duplicate(row) || undefined}
aria-describedby={duplicate(row)
? `map-error-${row.key}`
: undefined}
bind:value={row.mapSlug}
disabled={busy}
>{#each catalogs[row.game] as map}<option
value={map.slug}
disabled={rows.some(
(other) =>
other.key !== row.key &&
other.game === row.game &&
other.mapSlug === map.slug
)}>{map.friendly}</option
>{/each}</select
>{#if duplicate(row)}<span class="error" id={`map-error-${row.key}`}
>Already selected. Choose another map.</span
>{/if}</label
>
<label
>Target lobbies<input
aria-label={`Lobbies ${index + 1}`}
aria-invalid={!(
Number.isInteger(row.count) &&
row.count >= 1 &&
row.count <= 15
) || undefined}
aria-describedby={`count-hint-${row.key}`}
type="number"
min="1"
max="15"
step="1"
bind:value={row.count}
required
disabled={busy}
/><span id={`count-hint-${row.key}`}>1–15</span></label
>
<button
type="button"
class="remove"
aria-label={`Remove map ${index + 1}`}
disabled={busy || rows.length === 1}
onclick={() => (rows = rows.filter((item) => item.key !== row.key))}
>×</button
>
</div>
{/each}
<Button disabled={busy || !canAdd} onclick={addMap}>+ Add another map</Button>
</div>
<p class="muted">Collections save your maps and target lobby counts. Every new server uses the latest promoted scripts, plugins, and configuration. Before launch, you’ll review every new server ID, port, and key.</p>
<p class="muted">
Collections save your maps and target lobby counts. Every new server uses the
latest promoted scripts, plugins, and configuration. Before launch, you’ll
review every new server ID, port, and key.
</p>
{#if reason}<p class="validation" role="status">{reason}</p>{/if}
<div class="actions"><Button disabled={busy || !valid} onclick={() => save(false)}>Save collection</Button><Button type="submit" variant="primary" disabled={busy || !valid} loading={busy}>Save & preview missing servers →</Button></div>
<div class="actions">
<Button disabled={busy || !valid} onclick={() => save(false)}
>Save collection</Button
><Button
type="submit"
variant="primary"
disabled={busy || !valid}
loading={busy}>Save & preview missing servers →</Button
>
</div>
</form>
{/if}
</div>
</dialog>
<style>
.validation { padding: 12px; border-left: 3px solid var(--state-warning); color: var(--text-primary); font-size: var(--text-base); line-height: 1.6; }
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); }
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.builder { padding: 28px; display: grid; gap: 20px; }
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }.actions { justify-content: flex-end; }
.eyebrow { display: block; color: var(--text-secondary); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .12em; margin-bottom: 8px; } h2, h3, p { margin: 0; } h2 { font-size: var(--text-xl); letter-spacing: -.03em; } h3 { font-size: var(--text-md); }.muted { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }
form, .lineup { display: grid; gap: 18px; }.identity { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } label { display: grid; gap: 7px; font-size: var(--text-xs); min-width: 0; } input, select { width: 100%; min-width: 0; box-sizing: border-box; padding: 11px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; font: inherit; }
.lineup { border-block: 1px solid var(--border-subtle); padding: 20px 0; }.map-row { display: grid; grid-template-columns: 24px 1fr 1.5fr 80px 24px; gap: 12px; align-items: end; padding: 14px; background: var(--surface-sunken); border-radius: var(--radius-md); }.number { font-family: var(--font-numeric); font-size: var(--text-xs); color: var(--text-secondary); align-self: center; font-variant-numeric: tabular-nums; }.count { font-size: var(--text-xs); padding: 6px 10px; border: 1px solid var(--border-strong); border-radius: 99px; }.close, .remove { background: none; border: 0; color: var(--text-secondary); font-size: var(--text-xl); cursor: pointer; }.remove { padding: 7px 0; }.error { color: var(--state-danger); font-size: var(--text-sm); }button:disabled { opacity: .5; cursor: default; }
.validation {
padding: 12px;
border-left: 3px solid var(--state-warning);
color: var(--text-primary);
font-size: var(--text-base);
line-height: 1.6;
}
dialog {
width: min(820px, calc(100vw - 32px));
max-height: calc(100dvh - 40px);
padding: 0;
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
}
dialog::backdrop {
background: rgb(0 0 0 / 0.7);
backdrop-filter: blur(5px);
}
.builder {
padding: 28px;
display: grid;
gap: 20px;
}
header,
.section-title,
.actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.actions {
justify-content: flex-end;
}
.eyebrow {
display: block;
color: var(--text-secondary);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.12em;
margin-bottom: 8px;
}
h2,
h3,
p {
margin: 0;
}
h2 {
font-size: var(--text-xl);
letter-spacing: -0.03em;
}
h3 {
font-size: var(--text-md);
}
.muted {
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.6;
}
form,
.lineup {
display: grid;
gap: 18px;
}
.identity {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
label {
display: grid;
gap: 7px;
font-size: var(--text-xs);
min-width: 0;
}
input,
select {
width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 11px;
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
background: var(--surface-sunken);
color: inherit;
font: inherit;
}
.lineup {
border-block: 1px solid var(--border-subtle);
padding: 20px 0;
}
.map-row {
display: grid;
grid-template-columns: 24px 1fr 1.5fr 80px 24px;
gap: 12px;
align-items: end;
padding: 14px;
background: var(--surface-sunken);
border-radius: var(--radius-md);
}
.number {
font-family: var(--font-numeric);
font-size: var(--text-xs);
color: var(--text-secondary);
align-self: center;
font-variant-numeric: tabular-nums;
}
.count {
font-size: var(--text-xs);
padding: 6px 10px;
border: 1px solid var(--border-strong);
border-radius: 99px;
}
.close,
.remove {
background: none;
border: 0;
color: var(--text-secondary);
font-size: var(--text-xl);
cursor: pointer;
}
.remove {
padding: 7px 0;
}
.error {
color: var(--state-danger);
font-size: var(--text-sm);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
@media (max-width: 620px) {
.builder { padding: 20px; }
header { display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: start; }
.close, .remove { min-width: 44px; }
.identity { grid-template-columns: 1fr; }
.map-row { grid-template-columns: minmax(0, 1fr) 44px; }
.number { display: none; font-variant-numeric: tabular-nums; }
.map-row label:first-of-type, .map-row label:nth-of-type(2) { grid-column: 1 / -1; }
.actions { align-items: stretch; flex-direction: column; }
.builder {
padding: 20px;
}
header {
display: grid;
grid-template-columns: minmax(0, 1fr) 44px;
align-items: start;
}
.close,
.remove {
min-width: 44px;
}
.identity {
grid-template-columns: 1fr;
}
.map-row {
grid-template-columns: minmax(0, 1fr) 44px;
}
.number {
display: none;
font-variant-numeric: tabular-nums;
}
.map-row label:first-of-type,
.map-row label:nth-of-type(2) {
grid-column: 1 / -1;
}
.actions {
align-items: stretch;
flex-direction: column;
}
}
</style>
+23 -6
View File
@@ -26,15 +26,32 @@
letter-spacing: 0.12em;
line-height: 1;
}
.mark-sm .word { font-size: var(--text-sm); letter-spacing: 1.5px; }
.mark-md .word { font-size: var(--text-md); }
.mark-lg .word { font-size: var(--text-2xl); letter-spacing: 3px; }
.mark-sm .word {
font-size: var(--text-sm);
letter-spacing: 1.5px;
}
.mark-md .word {
font-size: var(--text-md);
}
.mark-lg .word {
font-size: var(--text-2xl);
letter-spacing: 3px;
}
.bolt {
fill: var(--accent-primary-hover);
filter: drop-shadow(0 0 5px rgba(124, 212, 255, 0.75));
}
.mark-sm .bolt { width: 12px; height: 20px; }
.mark-md .bolt { width: 14px; height: 24px; }
.mark-lg .bolt { width: 22px; height: 36px; }
.mark-sm .bolt {
width: 12px;
height: 20px;
}
.mark-md .bolt {
width: 14px;
height: 24px;
}
.mark-lg .bolt {
width: 22px;
height: 36px;
}
</style>
+175 -32
View File
@@ -15,57 +15,138 @@
$effect(() => {
if (!open) return;
const url = `/api/hosts/${encodeURIComponent(hostId)}/samples?` + new URLSearchParams({ scope, phase, page: String(page) });
const url =
`/api/hosts/${encodeURIComponent(hostId)}/samples?` +
new URLSearchParams({ scope, phase, page: String(page) });
void revision;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
let active = true;
loading = true; error = ''; result = null;
loading = true;
error = '';
result = null;
void (async () => {
try {
const response = await fetch(url, { signal: controller.signal });
const body = await response.json();
if (!response.ok) throw new Error(body.message ?? body.error ?? 'Could not load samples.');
if (!response.ok)
throw new Error(body.message ?? body.error ?? 'Could not load samples.');
if (active) result = body;
} catch (cause) {
if (active) error = controller.signal.aborted ? 'Sample request timed out. Try refreshing.' : (cause as Error).message;
} finally { clearTimeout(timer); if (active) loading = false; }
if (active)
error = controller.signal.aborted
? 'Sample request timed out. Try refreshing.'
: (cause as Error).message;
} finally {
clearTimeout(timer);
if (active) loading = false;
}
})();
return () => { active = false; clearTimeout(timer); controller.abort(); };
return () => {
active = false;
clearTimeout(timer);
controller.abort();
};
});
</script>
<details bind:open>
<summary>View samples</summary>
{#if open}
<p>Retained minute peaks from the last 24 hours, used without extra allowance padding. CPU and memory peaks may come from different heartbeats. Choose the fleet to inspect measurements from other hosts used by fleet allowances.</p>
<p>
Retained minute peaks from the last 24 hours, used without extra allowance padding. CPU
and memory peaks may come from different heartbeats. Choose the fleet to inspect
measurements from other hosts used by fleet allowances.
</p>
<div class="controls">
<label>Source<select bind:value={scope} onchange={() => page = 1}><option value="host">This host</option><option value="fleet">Entire fleet</option></select></label>
<label>Phase<select bind:value={phase} onchange={() => page = 1}><option value="all">All phases</option><option value="startup">Starting</option><option value="running">Running</option></select></label>
<Button size="sm" disabled={loading} onclick={() => { page = 1; revision++; }}>Refresh samples</Button>
<label
>Source<select bind:value={scope} onchange={() => (page = 1)}
><option value="host">This host</option><option value="fleet"
>Entire fleet</option
></select
></label
>
<label
>Phase<select bind:value={phase} onchange={() => (page = 1)}
><option value="all">All phases</option><option value="startup">Starting</option
><option value="running">Running</option></select
></label
>
<Button
size="sm"
disabled={loading}
onclick={() => {
page = 1;
revision++;
}}>Refresh samples</Button
>
</div>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if loading}<p role="status">Loading samples…</p>
{:else if result}
{#if result.total === 0}<p role="status">No {phase === 'all' ? '' : phase === 'startup' ? 'startup ' : 'running '}samples recorded for this source in the last 24 hours.</p>
{#if result.total === 0}<p role="status">
No {phase === 'all' ? '' : phase === 'startup' ? 'startup ' : 'running '}samples
recorded for this source in the last 24 hours.
</p>
{:else}
<p>{result.total.toLocaleString()} samples · newest first. Counts can differ from the allowance above, which is recalculated periodically.</p>
<p>
{result.total.toLocaleString()} samples · newest first. Counts can differ from the
allowance above, which is recalculated periodically.
</p>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The overflow region needs keyboard focus for horizontal scrolling.) -->
<div class="table-wrap" tabindex="0" role="region" aria-label="Container resource samples">
<table><caption>CPU and memory peaks per container and minute; times are local.</caption>
<thead><tr><th scope="col">Time</th>{#if scope === 'fleet'}<th scope="col">Host</th>{/if}<th scope="col">Server</th><th scope="col">Phase</th><th scope="col">CPU cores</th><th scope="col">Memory</th></tr></thead>
<tbody>{#each result.samples as sample}
<tr><td><RelativeTime value={sample.timestamp} /></td>
{#if scope === 'fleet'}<td>{sample.hostName ?? sample.hostId}</td>{/if}
<td>{sample.server ?? sample.instanceId}<small title={sample.instanceId}>Generation {sample.generation}</small></td>
<td>{sample.phase === 'startup' ? 'Starting' : 'Running'}</td><td>{sample.cpuCores.toFixed(3)}</td><td>{(sample.memoryBytes / 1048576).toFixed(1)} MiB</td></tr>
{/each}</tbody>
<div
class="table-wrap"
tabindex="0"
role="region"
aria-label="Container resource samples"
>
<table>
<caption
>CPU and memory peaks per container and minute; times are local.</caption
>
<thead
><tr
><th scope="col">Time</th>{#if scope === 'fleet'}<th scope="col"
>Host</th
>{/if}<th scope="col">Server</th><th scope="col">Phase</th><th
scope="col">CPU cores</th
><th scope="col">Memory</th></tr
></thead
>
<tbody
>{#each result.samples as sample}
<tr
><td><RelativeTime value={sample.timestamp} /></td>
{#if scope === 'fleet'}<td
>{sample.hostName ?? sample.hostId}</td
>{/if}
<td
>{sample.server ?? sample.instanceId}<small
title={sample.instanceId}
>Generation {sample.generation}</small
></td
>
<td>{sample.phase === 'startup' ? 'Starting' : 'Running'}</td
><td>{sample.cpuCores.toFixed(3)}</td><td
>{(sample.memoryBytes / 1048576).toFixed(1)} MiB</td
></tr
>
{/each}</tbody
>
</table>
</div>
<nav aria-label="Sample pages">
<Button size="sm" disabled={result.page <= 1} onclick={() => page = result!.page - 1}>Previous</Button>
<Button
size="sm"
disabled={result.page <= 1}
onclick={() => (page = result!.page - 1)}>Previous</Button
>
<span>Page {result.page} of {Math.ceil(result.total / result.pageSize)}</span>
<Button size="sm" disabled={result.page * result.pageSize >= result.total} onclick={() => page = result!.page + 1}>Next</Button>
<Button
size="sm"
disabled={result.page * result.pageSize >= result.total}
onclick={() => (page = result!.page + 1)}>Next</Button
>
</nav>
{/if}
{/if}
@@ -73,13 +154,75 @@
</details>
<style>
details { margin-top:20px; min-width:0; } summary { cursor:pointer; color:var(--accent-primary); font-weight:600; }
p, caption, small, nav { color:var(--text-secondary); font-size:var(--text-sm); line-height:1.6; }
.controls, nav { display:flex; flex-wrap:wrap; align-items:end; gap:12px; margin:16px 0; }
nav { align-items:center; } label { display:grid; gap:6px; font-size:var(--text-sm); }
select { padding:8px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); }
.table-wrap { max-width:100%; overflow:auto; } .table-wrap:focus-visible, summary:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
table { width:100%; border-collapse:collapse; text-align:left; font-size:var(--text-sm); }
caption { text-align:left; padding-bottom:8px; } td,th { padding:10px; border-bottom:1px solid var(--border-subtle); }
td { white-space:nowrap; } small { display:block; font-size:var(--text-xs); }
details {
margin-top: 20px;
min-width: 0;
}
summary {
cursor: pointer;
color: var(--accent-primary);
font-weight: 600;
}
p,
caption,
small,
nav {
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.6;
}
.controls,
nav {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 12px;
margin: 16px 0;
}
nav {
align-items: center;
}
label {
display: grid;
gap: 6px;
font-size: var(--text-sm);
}
select {
padding: 8px;
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
}
.table-wrap {
max-width: 100%;
overflow: auto;
}
.table-wrap:focus-visible,
summary:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: var(--text-sm);
}
caption {
text-align: left;
padding-bottom: 8px;
}
td,
th {
padding: 10px;
border-bottom: 1px solid var(--border-subtle);
}
td {
white-space: nowrap;
}
small {
display: block;
font-size: var(--text-xs);
}
</style>
+340 -66
View File
@@ -8,12 +8,22 @@
import Button from './primitives/Button.svelte';
import Freshness from './Freshness.svelte';
let { server, history, hostOffline = false, hostName }: { server?: string; history?: ContextualChatEntry[]; hostOffline?: boolean; hostName?: string } = $props();
let {
server,
history,
hostOffline = false,
hostName
}: {
server?: string;
history?: ContextualChatEntry[];
hostOffline?: boolean;
hostName?: string;
} = $props();
let updatedAt = $state<number | null>(null);
let liveEntries = $state<ContextualChatEntry[]>([]);
const entries = $derived(history ?? liveEntries);
let timeline = $derived(groupChatTimeline(entries));
let hasMessages = $derived(entries.some(entry => entry.type === 'message'));
let hasMessages = $derived(entries.some((entry) => entry.type === 'message'));
let liveLoaded = $state(false);
const loaded = $derived(history !== undefined || liveLoaded);
let loading = $state(false);
@@ -36,23 +46,33 @@
const request = new AbortController();
controller = request;
try {
const response = await fetch(`/api/servers/${encodeURIComponent(server)}/chat`, { signal: request.signal });
const response = await fetch(`/api/servers/${encodeURIComponent(server)}/chat`, {
signal: request.signal
});
const data = await response.json().catch(() => null);
if (!response.ok || !data?.ok) throw new Error('Could not refresh chat. Try again shortly.');
if (!response.ok || !data?.ok)
throw new Error('Could not refresh chat. Try again shortly.');
if (request.signal.aborted || hostOffline) return;
if (paused && !manual) return;
liveEntries = data.entries ?? [];
missing = !!data.missing;
liveLoaded = true; updatedAt = Date.now();
liveLoaded = true;
updatedAt = Date.now();
error = '';
await tick();
if (follow && body) body.scrollTop = body.scrollHeight;
} catch (err) {
if (!request.signal.aborted) error = err instanceof Error ? err.message : 'Could not refresh chat.';
} finally { loading = false; manualRefreshing = false; }
if (!request.signal.aborted)
error = err instanceof Error ? err.message : 'Could not refresh chat.';
} finally {
loading = false;
manualRefreshing = false;
}
}
$effect(() => { if (hostOffline) controller?.abort(); });
$effect(() => {
if (hostOffline) controller?.abort();
});
function onScroll() {
if (body) follow = body.scrollHeight - body.scrollTop - body.clientHeight < 40;
@@ -64,8 +84,11 @@
}
onMount(() => {
if (!server) return;
const stop = pollVisible(() => paused ? Promise.resolve() : refresh(), 5000);
return () => { stop(); controller?.abort(); };
const stop = pollVisible(() => (paused ? Promise.resolve() : refresh()), 5000);
return () => {
stop();
controller?.abort();
};
});
</script>
@@ -74,54 +97,128 @@
{/snippet}
{#snippet clockTime(value: string)}
<time datetime={value} title={$timeFormat.dateTime(value)} aria-label={$timeFormat.dateTime(value)}>{$timeFormat.clockTime(value)}</time>
<time
datetime={value}
title={$timeFormat.dateTime(value)}
aria-label={$timeFormat.dateTime(value)}>{$timeFormat.clockTime(value)}</time
>
{/snippet}
{#snippet chatContext(context: ChatContext)}
<dl class="context" aria-label="Message context">
<div><dt>Match</dt><dd>{#if context.matchElapsedMs === null}{@render unavailable()}{:else}{formatDuration(context.matchElapsedMs)}{/if}</dd></div>
<div><dt>Round</dt><dd>{#if context.round === null}{@render unavailable()}{:else}{context.round}{/if}</dd></div>
<div>
<dt>Match</dt>
<dd>
{#if context.matchElapsedMs === null}{@render unavailable()}{:else}{formatDuration(
context.matchElapsedMs
)}{/if}
</dd>
</div>
<div>
<dt>Round</dt>
<dd>
{#if context.round === null}{@render unavailable()}{:else}{context.round}{/if}
</dd>
</div>
</dl>
{/snippet}
{#snippet matchBoundary(entry: ChatBoundaryEntry)}
<div class="match-divider">
<div class="match-label">
<span>{entry.event === 'start' ? 'Match started' : entry.event === 'end' ? 'Match ended' : 'New match'}</span>
{#if entry.context.realTime}<span class="boundary-context">{@render clockTime(entry.context.realTime)}</span>{/if}
{#if entry.context.matchElapsedMs !== null}<span class="boundary-context">Match <span class="number">{formatDuration(entry.context.matchElapsedMs)}</span></span>{/if}
{#if entry.context.round !== null}<span class="boundary-context">Round <span class="number">{entry.context.round}</span></span>{/if}
<span
>{entry.event === 'start'
? 'Match started'
: entry.event === 'end'
? 'Match ended'
: 'New match'}</span
>
{#if entry.context.realTime}<span class="boundary-context"
>{@render clockTime(entry.context.realTime)}</span
>{/if}
{#if entry.context.matchElapsedMs !== null}<span class="boundary-context"
>Match <span class="number">{formatDuration(entry.context.matchElapsedMs)}</span
></span
>{/if}
{#if entry.context.round !== null}<span class="boundary-context"
>Round <span class="number">{entry.context.round}</span></span
>{/if}
</div>
</div>
{/snippet}
<section aria-label="Player chat">
<div class="chat-head">
<div class="chat-title"><h2>Chat</h2>{#if !server}<span class="update-cadence">Match history</span>{:else if hostOffline}<span class="update-cadence">Host offline</span>{:else if paused}<span class="update-cadence">Paused</span>{:else if !error}<Freshness {updatedAt} intervalLabel="every 5 s" />{/if}</div>
<div class="chat-title">
<h2>Chat</h2>
{#if !server}<span class="update-cadence">Match history</span
>{:else if hostOffline}<span class="update-cadence">Host offline</span
>{:else if paused}<span class="update-cadence">Paused</span
>{:else if !error}<Freshness {updatedAt} intervalLabel="every 5 s" />{/if}
</div>
{#if server}
<div class="actions">
{#if hasMessages}<Button size="sm" variant="ghost" onclick={() => paused = !paused}>{paused ? 'Resume' : 'Pause'}</Button>{/if}
{#if !follow}<Button size="sm" variant="ghost" onclick={latest}>Latest messages</Button>{/if}
{#if hasMessages}<Button
size="sm"
variant="ghost"
onclick={() => (paused = !paused)}>{paused ? 'Resume' : 'Pause'}</Button
>{/if}
{#if !follow}<Button size="sm" variant="ghost" onclick={latest}
>Latest messages</Button
>{/if}
</div>
{/if}
</div>
{#if hostOffline}<p class="outage">Chat resumes when {hostName ?? 'the host agent'} reconnects.{loaded ? ' Showing the last loaded messages.' : ''}</p>
{:else if error}<p class="ui-error" role="alert">Chat updates paused. {loaded ? 'Showing the last loaded messages.' : 'Could not load messages.'} <Button size="sm" onclick={() => refresh(true)} loading={manualRefreshing}>Retry</Button></p>{/if}
{#if hostOffline}<p class="outage">
Chat resumes when {hostName ?? 'the host agent'} reconnects.{loaded
? ' Showing the last loaded messages.'
: ''}
</p>
{:else if error}<p class="ui-error" role="alert">
Chat updates paused. {loaded
? 'Showing the last loaded messages.'
: 'Could not load messages.'}
<Button size="sm" onclick={() => refresh(true)} loading={manualRefreshing}>Retry</Button
>
</p>{/if}
{#if !loaded && !error && !hostOffline}
<p class="empty" role="status">Loading chat…</p>
{:else if loaded && !hasMessages}
<p class="empty">{history !== undefined ? 'No chat messages were saved for this match.' : missing ? 'Waiting for the game log. Player messages will appear here.' : 'No player messages in this match yet.'}</p>
<p class="empty">
{history !== undefined
? 'No chat messages were saved for this match.'
: missing
? 'Waiting for the game log. Player messages will appear here.'
: 'No player messages in this match yet.'}
</p>
{:else if hasMessages}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The scrollable chat region must support keyboard scrolling.) -->
<div class="messages" bind:this={body} onscroll={onScroll} tabindex="0" role="region" aria-label="Chat messages">
<div
class="messages"
bind:this={body}
onscroll={onScroll}
tabindex="0"
role="region"
aria-label="Chat messages"
>
<ol>
{#each timeline as item}
{#if item.type === 'boundaries'}
<li class="match-events">
{#if item.entries.length > 1}
{#key JSON.stringify(item.entries)}
<details class="earlier-events" ontoggle={(event) => { if (event.currentTarget.open) follow = false; }}>
<summary>{item.entries.length - 1} earlier match {item.entries.length === 2 ? 'event' : 'events'}</summary>
<details
class="earlier-events"
ontoggle={(event) => {
if (event.currentTarget.open) follow = false;
}}
>
<summary
>{item.entries.length - 1} earlier match {item.entries
.length === 2
? 'event'
: 'events'}</summary
>
<ol aria-label="Earlier match events">
{#each item.entries.slice(0, -1) as entry}
<li>{@render matchBoundary(entry)}</li>
@@ -135,10 +232,17 @@
{:else}
<li class="message">
<span class="message-clock">
{#if item.entry.context.realTime}{@render clockTime(item.entry.context.realTime)}{:else}<span class="sr-only">Time: </span>{@render unavailable()}{/if}
{#if item.entry.context.realTime}{@render clockTime(
item.entry.context.realTime
)}{:else}<span class="sr-only"
>Time:
</span>{@render unavailable()}{/if}
</span>
<div class="message-content">
<div class="message-head"><strong>{item.entry.message.player}</strong>{@render chatContext(item.entry.context)}</div>
<div class="message-head">
<strong>{item.entry.message.player}</strong
>{@render chatContext(item.entry.context)}
</div>
<p>{item.entry.message.message}</p>
</div>
</li>
@@ -150,44 +254,214 @@
</section>
<style>
section { container-type: inline-size; }
.chat-head, .actions, .chat-title { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.chat-head { justify-content: space-between; }
.chat-title { gap: var(--space-2) var(--space-3); }
h2 { margin: 0; font-size: var(--text-lg); }
.update-cadence { color: var(--text-secondary); font-size: var(--text-xs); }
.outage { color: var(--state-warning); font-size: var(--text-sm); line-height: var(--leading-body); }
.empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 8px 0; margin-bottom: 0; }
.messages { max-height: 360px; overflow: auto; margin-top: var(--space-4); }
.messages:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--accent-primary); border-radius: var(--radius-sm); }
ol { list-style: none; padding: 0; margin: 0; }
.message { display: grid; grid-template-columns: 6rem minmax(0, 1fr); align-items: baseline; column-gap: var(--space-4); padding: var(--space-3) 0; }
.message-clock, .number { font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
.message-clock { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; }
.message-content { min-width: 0; }
.message-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--space-1) var(--space-3); }
strong { min-width: 0; color: var(--text-primary); font-size: var(--text-base); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
.context { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--space-1) var(--space-3); margin: 0; color: var(--text-secondary); font-size: var(--text-xs); font-weight: var(--weight-regular); }
.context > div { display: flex; align-items: baseline; gap: var(--space-1); white-space: nowrap; }
.context > div + div::before { content: '·'; margin-right: var(--space-1); color: var(--text-tertiary); }
.context dd { margin: 0; font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
.message p { margin: var(--space-1) 0 0; font-size: var(--text-base); line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
.match-events { padding: var(--space-1) 0; }
.match-divider { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; color: var(--text-secondary); font-size: var(--text-xs); }
.match-divider::before, .match-divider::after { content: ''; height: 1px; flex: 1; min-width: var(--space-3); background: var(--border-subtle); }
.match-label { min-width: 0; display: flex; align-items: baseline; justify-content: center; flex-wrap: wrap; gap: var(--space-1) var(--space-2); }
.boundary-context { white-space: nowrap; }
.boundary-context::before { content: '·'; margin-right: var(--space-2); color: var(--text-tertiary); }
.earlier-events summary { width: fit-content; max-width: 100%; box-sizing: border-box; min-height: 32px; margin-inline: auto; padding: var(--space-2); border-radius: var(--radius-sm); color: var(--text-secondary); font-size: var(--text-xs); cursor: pointer; }
.earlier-events summary:hover { color: var(--text-primary); background: var(--surface-raised); }
.earlier-events summary:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
section {
container-type: inline-size;
}
.chat-head,
.actions,
.chat-title {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.chat-head {
justify-content: space-between;
}
.chat-title {
gap: var(--space-2) var(--space-3);
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
.update-cadence {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.outage {
color: var(--state-warning);
font-size: var(--text-sm);
line-height: var(--leading-body);
}
.empty {
color: var(--text-secondary);
font-size: var(--text-sm);
padding: 8px 0;
margin-bottom: 0;
}
.messages {
max-height: 360px;
overflow: auto;
margin-top: var(--space-4);
}
.messages:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--accent-primary);
border-radius: var(--radius-sm);
}
ol {
list-style: none;
padding: 0;
margin: 0;
}
.message {
display: grid;
grid-template-columns: 6rem minmax(0, 1fr);
align-items: baseline;
column-gap: var(--space-4);
padding: var(--space-3) 0;
}
.message-clock,
.number {
font-family: var(--font-numeric);
font-variant-numeric: tabular-nums;
}
.message-clock {
font-size: var(--text-xs);
color: var(--text-secondary);
white-space: nowrap;
}
.message-content {
min-width: 0;
}
.message-head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-1) var(--space-3);
}
strong {
min-width: 0;
color: var(--text-primary);
font-size: var(--text-base);
font-weight: var(--weight-semibold);
overflow-wrap: anywhere;
}
.context {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-1) var(--space-3);
margin: 0;
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: var(--weight-regular);
}
.context > div {
display: flex;
align-items: baseline;
gap: var(--space-1);
white-space: nowrap;
}
.context > div + div::before {
content: '·';
margin-right: var(--space-1);
color: var(--text-tertiary);
}
.context dd {
margin: 0;
font-family: var(--font-numeric);
font-variant-numeric: tabular-nums;
}
.message p {
margin: var(--space-1) 0 0;
font-size: var(--text-base);
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.match-events {
padding: var(--space-1) 0;
}
.match-divider {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) 0;
color: var(--text-secondary);
font-size: var(--text-xs);
}
.match-divider::before,
.match-divider::after {
content: '';
height: 1px;
flex: 1;
min-width: var(--space-3);
background: var(--border-subtle);
}
.match-label {
min-width: 0;
display: flex;
align-items: baseline;
justify-content: center;
flex-wrap: wrap;
gap: var(--space-1) var(--space-2);
}
.boundary-context {
white-space: nowrap;
}
.boundary-context::before {
content: '·';
margin-right: var(--space-2);
color: var(--text-tertiary);
}
.earlier-events summary {
width: fit-content;
max-width: 100%;
box-sizing: border-box;
min-height: 32px;
margin-inline: auto;
padding: var(--space-2);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: var(--text-xs);
cursor: pointer;
}
.earlier-events summary:hover {
color: var(--text-primary);
background: var(--surface-raised);
}
.earlier-events summary:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@container (max-width: 560px) {
.message { grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-1) var(--space-3); }
.message-clock { grid-column: 2; grid-row: 1; }
.message-content, .message-head { display: contents; }
strong { grid-column: 1; grid-row: 1; }
.context { grid-column: 1 / -1; grid-row: 2; }
.message p { grid-column: 1 / -1; grid-row: 3; margin-top: 0; }
.message {
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-1) var(--space-3);
}
.message-clock {
grid-column: 2;
grid-row: 1;
}
.message-content,
.message-head {
display: contents;
}
strong {
grid-column: 1;
grid-row: 1;
}
.context {
grid-column: 1 / -1;
grid-row: 2;
}
.message p {
grid-column: 1 / -1;
grid-row: 3;
margin-top: 0;
}
}
</style>
+228 -48
View File
@@ -3,8 +3,16 @@
import { goto } from '$app/navigation';
import type { UserRole } from '$lib/auth.js';
import Dialog from './primitives/Dialog.svelte';
import { loadPaletteDirectory, paletteDirectoryResults, paletteGroups, paletteMatchJump, palettePageResults, searchPaletteRecords,
type PaletteDirectory, type PaletteItem } from '$ui/command-palette.js';
import {
loadPaletteDirectory,
paletteDirectoryResults,
paletteGroups,
paletteMatchJump,
palettePageResults,
searchPaletteRecords,
type PaletteDirectory,
type PaletteItem
} from '$ui/command-palette.js';
let { open = $bindable(false), role }: { open?: boolean; role: UserRole } = $props();
const id = $props.id();
@@ -20,54 +28,111 @@
let navigating = $state(false);
const items = $derived.by(() => {
const unique = new Map<string, PaletteItem>();
for (const item of [...palettePageResults(role, query), ...paletteDirectoryResults(query, directory), ...paletteMatchJump(query), ...records]) unique.set(item.key, item);
return paletteGroups.flatMap(group => [...unique.values()].filter(item => item.group === group));
for (const item of [
...palettePageResults(role, query),
...paletteDirectoryResults(query, directory),
...paletteMatchJump(query),
...records
])
unique.set(item.key, item);
return paletteGroups.flatMap((group) =>
[...unique.values()].filter((item) => item.group === group)
);
});
const activeIndex = $derived(Math.max(0, items.findIndex(item => item.key === selected)));
const activeIndex = $derived(
Math.max(
0,
items.findIndex((item) => item.key === selected)
)
);
const unavailable = $derived([...(directory?.unavailable ?? []), ...recordErrors]);
const searching = $derived(directoryLoading || recordsLoading);
$effect(() => {
if (!open) { query = ''; records = []; directory = null; navigationError = ''; return; }
if (!open) {
query = '';
records = [];
directory = null;
navigationError = '';
return;
}
let cancelled = false;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
directoryLoading = true;
void loadPaletteDirectory(controller.signal).then(result => { if (!cancelled) directory = result; })
.finally(() => { clearTimeout(timeout); if (!cancelled) directoryLoading = false; });
void tick().then(() => { if (!cancelled) input?.focus(); });
return () => { cancelled = true; controller.abort(); clearTimeout(timeout); };
void loadPaletteDirectory(controller.signal)
.then((result) => {
if (!cancelled) directory = result;
})
.finally(() => {
clearTimeout(timeout);
if (!cancelled) directoryLoading = false;
});
void tick().then(() => {
if (!cancelled) input?.focus();
});
return () => {
cancelled = true;
controller.abort();
clearTimeout(timeout);
};
});
$effect(() => {
const term = query.trim();
selected = ''; records = []; recordErrors = [];
if (!open || term.length < 2) { recordsLoading = false; return; }
selected = '';
records = [];
recordErrors = [];
if (!open || term.length < 2) {
recordsLoading = false;
return;
}
let cancelled = false;
const controller = new AbortController();
let timeout: ReturnType<typeof setTimeout> | undefined;
recordsLoading = true;
const debounce = setTimeout(() => {
timeout = setTimeout(() => controller.abort(), 15_000);
void searchPaletteRecords(term, controller.signal).then(result => {
if (!cancelled) { records = result.items; recordErrors = result.unavailable; }
}).finally(() => { clearTimeout(timeout); if (!cancelled) recordsLoading = false; });
void searchPaletteRecords(term, controller.signal)
.then((result) => {
if (!cancelled) {
records = result.items;
recordErrors = result.unavailable;
}
})
.finally(() => {
clearTimeout(timeout);
if (!cancelled) recordsLoading = false;
});
}, 220);
return () => { cancelled = true; clearTimeout(debounce); clearTimeout(timeout); controller.abort(); };
return () => {
cancelled = true;
clearTimeout(debounce);
clearTimeout(timeout);
controller.abort();
};
});
$effect(() => {
const option = activeIndex;
if (!open || !items.length) return;
void tick().then(() => document.getElementById(`${id}-option-${option}`)?.scrollIntoView({ block: 'nearest' }));
void tick().then(() =>
document.getElementById(`${id}-option-${option}`)?.scrollIntoView({ block: 'nearest' })
);
});
async function choose(item: PaletteItem) {
if (navigating) return;
navigating = true; navigationError = '';
try { await goto(item.href); open = false; }
catch { navigationError = 'Could not open that result. Try again.'; }
finally { navigating = false; }
navigating = true;
navigationError = '';
try {
await goto(item.href);
open = false;
} catch {
navigationError = 'Could not open that result. Try again.';
} finally {
navigating = false;
}
}
function keydown(event: KeyboardEvent) {
@@ -77,54 +142,169 @@
const movement = event.key === 'ArrowDown' ? 1 : -1;
selected = items[(activeIndex + movement + items.length) % items.length].key;
} else if (event.key === 'Enter') {
event.preventDefault(); void choose(items[activeIndex]);
event.preventDefault();
void choose(items[activeIndex]);
}
}
</script>
<Dialog bind:open title="Search and commands" description="Jump to a page, server, host, player, or match.">
<Dialog
bind:open
title="Search and commands"
description="Jump to a page, server, host, player, or match."
>
<div class="palette">
<label class="sr-only" for={`${id}-search`}>Search Electric Cherry</label>
<input bind:this={input} bind:value={query} id={`${id}-search`} type="search" maxlength="100" placeholder="Search Electric Cherry…"
role="combobox" aria-autocomplete="list" aria-expanded={open} aria-controls={`${id}-results`}
aria-activedescendant={items.length ? `${id}-option-${activeIndex}` : undefined} aria-describedby={`${id}-hint`}
autocomplete="off" onkeydown={keydown} />
<p class="hint" id={`${id}-hint`}>Try a lobby, player name, or #match number. <span>↑ ↓ to choose · Enter to open</span></p>
<div class="results" id={`${id}-results`} role="listbox" aria-label="Search results" aria-busy={searching || navigating}>
<input
bind:this={input}
bind:value={query}
id={`${id}-search`}
type="search"
maxlength="100"
placeholder="Search Electric Cherry…"
role="combobox"
aria-autocomplete="list"
aria-expanded={open}
aria-controls={`${id}-results`}
aria-activedescendant={items.length ? `${id}-option-${activeIndex}` : undefined}
aria-describedby={`${id}-hint`}
autocomplete="off"
onkeydown={keydown}
/>
<p class="hint" id={`${id}-hint`}>
Try a lobby, player name, or #match number. <span>↑ ↓ to choose · Enter to open</span>
</p>
<div
class="results"
id={`${id}-results`}
role="listbox"
aria-label="Search results"
aria-busy={searching || navigating}
>
{#each paletteGroups as group}
{@const groupItems = items.filter(item => item.group === group)}
{@const groupItems = items.filter((item) => item.group === group)}
{#if groupItems.length}
<div role="group" aria-labelledby={`${id}-${group}`}>
<div class="group-label" id={`${id}-${group}`}>{group}</div>
{#each groupItems as item (item.key)}
{@const index = items.indexOf(item)}
<button type="button" id={`${id}-option-${index}`} role="option" tabindex="-1" aria-selected={index === activeIndex}
class:active={index === activeIndex} disabled={navigating} onclick={() => void choose(item)} onpointermove={() => selected = item.key}>
<span class="result-label">{item.label}</span><span class="result-detail">{item.detail}</span>
<button
type="button"
id={`${id}-option-${index}`}
role="option"
tabindex="-1"
aria-selected={index === activeIndex}
class:active={index === activeIndex}
disabled={navigating}
onclick={() => void choose(item)}
onpointermove={() => (selected = item.key)}
>
<span class="result-label">{item.label}</span><span
class="result-detail">{item.detail}</span
>
</button>
{/each}
</div>
{/if}
{/each}
</div>
<p class="status" role="status">{searching ? 'Searching…' : items.length ? `${items.length} results` : 'No matches. Try a different name or a match number.'}</p>
{#if unavailable.length}<p class="error">{unavailable.join(', ')} search is unavailable. Other results are still available.</p>{/if}
<p class="status" role="status">
{searching
? 'Searching…'
: items.length
? `${items.length} results`
: 'No matches. Try a different name or a match number.'}
</p>
{#if unavailable.length}<p class="error">
{unavailable.join(', ')} search is unavailable. Other results are still available.
</p>{/if}
{#if navigationError}<p class="error" role="alert">{navigationError}</p>{/if}
</div>
</Dialog>
<style>
.palette { display:grid; gap:var(--space-3); min-width:0; }
input { width:100%; min-height:44px; padding:var(--space-3); font:inherit; font-size:var(--text-base); color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); }
.hint,.status,.error { margin:0; color:var(--text-secondary); font-size:var(--text-xs); line-height:1.5; }
.hint span { display:block; }
.results { min-height:40px; max-height:min(55dvh,480px); overflow:auto; overscroll-behavior:contain; position:relative; }
.group-label { padding:var(--space-3) var(--space-2) var(--space-1); font-size:var(--text-xs); color:var(--text-tertiary); font-weight:var(--weight-semibold); }
button { display:grid; gap:var(--space-1); width:100%; min-height:48px; text-align:left; border:1px solid transparent; border-radius:var(--radius-sm); padding:var(--space-2) var(--space-3); background:transparent; color:var(--text-primary); font:inherit; cursor:pointer; }
button.active { background:var(--surface-raised); border-color:var(--accent-primary); }
button:disabled { cursor:wait; }
.result-label { font-size:var(--text-sm); overflow-wrap:anywhere; }
.result-detail { color:var(--text-secondary); font-size:var(--text-xs); overflow-wrap:anywhere; }
.error { color:var(--state-warning); }
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; border:0; }
.palette {
display: grid;
gap: var(--space-3);
min-width: 0;
}
input {
width: 100%;
min-height: 44px;
padding: var(--space-3);
font: inherit;
font-size: var(--text-base);
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
}
.hint,
.status,
.error {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.5;
}
.hint span {
display: block;
}
.results {
min-height: 40px;
max-height: min(55dvh, 480px);
overflow: auto;
overscroll-behavior: contain;
position: relative;
}
.group-label {
padding: var(--space-3) var(--space-2) var(--space-1);
font-size: var(--text-xs);
color: var(--text-tertiary);
font-weight: var(--weight-semibold);
}
button {
display: grid;
gap: var(--space-1);
width: 100%;
min-height: 48px;
text-align: left;
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3);
background: transparent;
color: var(--text-primary);
font: inherit;
cursor: pointer;
}
button.active {
background: var(--surface-raised);
border-color: var(--accent-primary);
}
button:disabled {
cursor: wait;
}
.result-label {
font-size: var(--text-sm);
overflow-wrap: anywhere;
}
.result-detail {
color: var(--text-secondary);
font-size: var(--text-xs);
overflow-wrap: anywhere;
}
.error {
color: var(--state-warning);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
</style>
+359 -66
View File
@@ -6,10 +6,29 @@
import ConfigInspector from './ConfigInspector.svelte';
import Button from './primitives/Button.svelte';
import { configWarnings } from '$lib/config-workbench.js';
interface Version { id: number; version: number; notes: string | null; }
let { id, kind, versions, currentVersionId, runningBetaId, scopeType, scopeValue, onsaved }: {
id: number; kind: 'baseline' | 'config'; versions: Version[]; currentVersionId: number | null;
runningBetaId: number | null; scopeType: string | null; scopeValue: string | null; onsaved: () => Promise<void>;
interface Version {
id: number;
version: number;
notes: string | null;
}
let {
id,
kind,
versions,
currentVersionId,
runningBetaId,
scopeType,
scopeValue,
onsaved
}: {
id: number;
kind: 'baseline' | 'config';
versions: Version[];
currentVersionId: number | null;
runningBetaId: number | null;
scopeType: string | null;
scopeValue: string | null;
onsaved: () => Promise<void>;
} = $props();
let selected = $state('');
let text = $state('');
@@ -24,132 +43,406 @@
let loadedVersion = $state('');
let recovered = $state<{ version: string; text: string; notes: string } | null>(null);
const dirty = $derived(text !== original || notes.trim().length > 0);
const ignoredWarnings = $derived(configWarnings(text, kind === 'config').filter(warning => warning.includes('ignored') || warning.includes('no value')));
const ignoredWarnings = $derived(
configWarnings(text, kind === 'config').filter(
(warning) => warning.includes('ignored') || warning.includes('no value')
)
);
const storageKey = $derived(`electric-cherry.config-draft.${id}`);
onMount(() => {
selected = String(versions[0]?.id ?? ''); ready = true;
selected = String(versions[0]?.id ?? '');
ready = true;
try {
const saved = JSON.parse(localStorage.getItem(storageKey) ?? 'null');
if (saved && typeof saved.text === 'string' && typeof saved.version === 'string' && typeof saved.notes === 'string') recovered = saved;
} catch { /* Draft storage is optional. */ }
if (
saved &&
typeof saved.text === 'string' &&
typeof saved.version === 'string' &&
typeof saved.notes === 'string'
)
recovered = saved;
} catch {
/* Draft storage is optional. */
}
});
let allowNavigation = false;
beforeNavigate(({ cancel, to, type }) => {
if (!dirty || allowNavigation || type === 'leave' || !to) return;
cancel();
void confirmAction({title:'Leave this editor?', message:'You have unsaved changes. A draft is kept in this browser when storage is available.', confirmLabel:'Leave editor'}).then(async accepted => {
void confirmAction({
title: 'Leave this editor?',
message:
'You have unsaved changes. A draft is kept in this browser when storage is available.',
confirmLabel: 'Leave editor'
}).then(async (accepted) => {
if (!accepted) return;
allowNavigation = true;
try { await goto(to.url); } finally { allowNavigation = false; }
try {
await goto(to.url);
} finally {
allowNavigation = false;
}
});
});
function beforeUnload(event: BeforeUnloadEvent) { if (dirty) { event.preventDefault(); event.returnValue = ''; } }
function beforeUnload(event: BeforeUnloadEvent) {
if (dirty) {
event.preventDefault();
event.returnValue = '';
}
}
$effect(() => {
const version = selected;
const refresh = retry;
void retry;
if (!ready || !version) return;
const controller = new AbortController();
loading = true; error = ''; loadedVersion = '';
loading = true;
error = '';
loadedVersion = '';
(async () => {
try {
const response = await fetch(`/api/configs/${id}?version=${version}`, { signal: controller.signal });
const response = await fetch(`/api/configs/${id}?version=${version}`, {
signal: controller.signal
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not load config contents.');
if (!controller.signal.aborted) { text = body.text; original = body.text; notes = ''; loadedVersion = version; }
} catch (err) { if (!controller.signal.aborted) error = (err as Error).message; }
finally { if (!controller.signal.aborted) loading = false; }
if (!controller.signal.aborted) {
text = body.text;
original = body.text;
notes = '';
loadedVersion = version;
}
} catch (err) {
if (!controller.signal.aborted) error = (err as Error).message;
} finally {
if (!controller.signal.aborted) loading = false;
}
})();
return () => controller.abort();
});
$effect(() => {
if (!ready || loading || !loadedVersion || !dirty) return;
try { localStorage.setItem(storageKey, JSON.stringify({ version: loadedVersion, text, notes })); } catch { /* Editing still works without storage. */ }
try {
localStorage.setItem(
storageKey,
JSON.stringify({ version: loadedVersion, text, notes })
);
} catch {
/* Editing still works without storage. */
}
});
function clearDraft() { try { localStorage.removeItem(storageKey); } catch { /* Storage may be disabled. */ } recovered = null; }
function clearDraft() {
try {
localStorage.removeItem(storageKey);
} catch {
/* Storage may be disabled. */
}
recovered = null;
}
async function changeVersion(event: Event) {
const target = event.currentTarget as HTMLSelectElement;
const next = target.value; target.value = selected;
if (dirty && !await confirmAction({title:'Discard current edits?',message:'Open the selected version and discard your current changes.',confirmLabel:'Discard edits',danger:true})) return;
clearDraft(); selected = next; success = '';
const next = target.value;
target.value = selected;
if (
dirty &&
!(await confirmAction({
title: 'Discard current edits?',
message: 'Open the selected version and discard your current changes.',
confirmLabel: 'Discard edits',
danger: true
}))
)
return;
clearDraft();
selected = next;
success = '';
}
function restoreDraft() {
if (!recovered) return;
// Keep the current version as the comparison base, including when the
// recovered draft was started from a version that has since been deleted.
text = recovered.text; notes = recovered.notes; recovered = null;
text = recovered.text;
notes = recovered.notes;
recovered = null;
}
async function discard() {
if (
await confirmAction({
title: 'Discard unsaved changes?',
message: 'Restore the version you opened in this editor.',
confirmLabel: 'Discard changes',
danger: true
})
) {
text = original;
notes = '';
clearDraft();
}
}
async function discard() { if (await confirmAction({title:'Discard unsaved changes?',message:'Restore the version you opened in this editor.',confirmLabel:'Discard changes',danger:true})) { text = original; notes = ''; clearDraft(); } }
async function importFile(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (file) { try { text = await file.text(); } catch { error = 'Could not read that file.'; } }
if (file) {
try {
text = await file.text();
} catch {
error = 'Could not read that file.';
}
}
input.value = '';
}
async function save() {
if (saving || loading || runningBetaId !== null || !text.trim()) return;
saving = true; error = ''; success = '';
saving = true;
error = '';
success = '';
const form = new FormData();
form.append('content', new Blob([text], { type: 'text/plain' }), 'config.cfg');
if (notes.trim()) form.append('notes', notes.trim());
try {
const response = await fetch(`/api/scripts/${id}/versions`, { method: 'POST', body: form });
const response = await fetch(`/api/scripts/${id}/versions`, {
method: 'POST',
body: form
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not save this version.');
original = text; notes = ''; clearDraft();
original = text;
notes = '';
clearDraft();
success = `v${body.version.version} saved as untested. Open a beta to test and promote it.`;
await onsaved(); selected = String(body.version.id);
} catch (err) { error = (err as Error).message; }
finally { saving = false; }
await onsaved();
selected = String(body.version.id);
} catch (err) {
error = (err as Error).message;
} finally {
saving = false;
}
}
</script>
<svelte:window onbeforeunload={beforeUnload} />
<section class="workbench" aria-label="Config editor">
<div class="editor-panel">
<div class="heading"><div><span class="eyebrow">Edit · preview · version</span><h2>Configuration editor</h2></div>
<div class="heading">
<div>
<span class="eyebrow">Edit · preview · version</span>
<h2>Configuration editor</h2>
</div>
{#if dirty}<span class="draft-tag">Unsaved draft</span>{/if}
</div>
<div class="version-toolbar"><label>Starting version<select aria-label="Starting version" value={selected} onchange={changeVersion} disabled={saving || loading}>{#each versions as version}<option value={String(version.id)}>v{version.version}{version.id === currentVersionId ? ' · promoted' : ''}{version.id === versions[0]?.id ? ' · latest' : ''}</option>{/each}</select></label>
<label class="file-button">Import .cfg<input type="file" accept=".cfg,text/plain" onchange={importFile} disabled={saving || loading || runningBetaId !== null} /></label>
<div class="version-toolbar">
<label
>Starting version<select
aria-label="Starting version"
value={selected}
onchange={changeVersion}
disabled={saving || loading}
>{#each versions as version}<option value={String(version.id)}
>v{version.version}{version.id === currentVersionId
? ' · promoted'
: ''}{version.id === versions[0]?.id ? ' · latest' : ''}</option
>{/each}</select
></label
>
<label class="file-button"
>Import .cfg<input
type="file"
accept=".cfg,text/plain"
onchange={importFile}
disabled={saving || loading || runningBetaId !== null}
/></label
>
</div>
{#if recovered && !loading}<div class="recovery"><span>A previous draft is available in this browser.</span><Button size="sm" onclick={restoreDraft} disabled={runningBetaId !== null}>Restore draft</Button><Button size="sm" variant="ghost" onclick={clearDraft}>Dismiss</Button></div>{/if}
{#if success}<div class="success" role="status">{success} <a href={"/betas/new?artifact=" + id + ":" + selected}>Test this version →</a></div>{/if}
{#if error}<div class="error" role="alert">{error} {#if !loadedVersion}<Button size="sm" onclick={() => retry++}>Retry</Button>{/if}</div>{/if}
{#if recovered && !loading}<div class="recovery">
<span>A previous draft is available in this browser.</span><Button
size="sm"
onclick={restoreDraft}
disabled={runningBetaId !== null}>Restore draft</Button
><Button size="sm" variant="ghost" onclick={clearDraft}>Dismiss</Button>
</div>{/if}
{#if success}<div class="success" role="status">
{success}
<a href={'/betas/new?artifact=' + id + ':' + selected}>Test this version →</a>
</div>{/if}
{#if error}<div class="error" role="alert">
{error}
{#if !loadedVersion}<Button size="sm" onclick={() => retry++}>Retry</Button>{/if}
</div>{/if}
{#if loading}<p class="muted" role="status">Loading config contents…</p>
{:else if loadedVersion}
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving || runningBetaId !== null} />
<label class="notes-label">Version notes<input bind:value={notes} placeholder="What changed, and why?" disabled={saving || runningBetaId !== null} /></label>
<div class="actions ui-sticky-actions"><span class="muted">{runningBetaId !== null ? 'Finish the active beta to save another version.' : 'A new version must pass through a beta before promotion.'}{#if ignoredWarnings.length}<strong class="save-warning">{ignoredWarnings.length} ignored or incomplete lines — review the open configuration notes before saving.</strong>{/if}</span>
<Button variant="ghost" size="sm" onclick={discard} disabled={!dirty || saving}>Discard</Button>
<Button variant="primary" onclick={save} loading={saving} disabled={!dirty || !text.trim() || runningBetaId !== null}>Save new version</Button>
<ConfigTextEditor
bind:text
overlay={kind === 'config'}
disabled={saving || runningBetaId !== null}
/>
<label class="notes-label"
>Version notes<input
bind:value={notes}
placeholder="What changed, and why?"
disabled={saving || runningBetaId !== null}
/></label
>
<div class="actions ui-sticky-actions">
<span class="muted"
>{runningBetaId !== null
? 'Finish the active beta to save another version.'
: 'A new version must pass through a beta before promotion.'}{#if ignoredWarnings.length}<strong
class="save-warning"
>{ignoredWarnings.length} ignored or incomplete lines — review the open configuration
notes before saving.</strong
>{/if}</span
>
<Button variant="ghost" size="sm" onclick={discard} disabled={!dirty || saving}
>Discard</Button
>
<Button
variant="primary"
onclick={save}
loading={saving}
disabled={!dirty || !text.trim() || runningBetaId !== null}
>Save new version</Button
>
</div>
{/if}
</div>
{#if loadedVersion && !loading}<ConfigInspector artifactId={id} draftText={text} {scopeType} {scopeValue} />{/if}
{#if loadedVersion && !loading}<ConfigInspector
artifactId={id}
draftText={text}
{scopeType}
{scopeValue}
/>{/if}
</section>
<style>
.workbench { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: var(--space-5); align-items: start; }
.editor-panel { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); padding: var(--space-6); min-width: 0; }
.heading, .version-toolbar, .actions, .recovery { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
h2 { font-size: var(--text-lg); margin: 5px 0 0; }
.eyebrow { font-size: var(--text-xs); color: var(--accent-primary-hover); }
.draft-tag { color: var(--state-warning); font-size: var(--text-xs); }
.version-toolbar { margin: 20px 0; align-items: flex-end; }
.version-toolbar > label:first-child { flex: 1; }
label { display: grid; gap: 8px; color: var(--text-secondary); font-size: var(--text-xs); }
select, input:not([type='file']) { width: 100%; box-sizing: border-box; padding: 10px 12px; color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); font: var(--text-sm) var(--font-body); }
input:focus-visible, select:focus-visible, .file-button:focus-within { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.file-button { position: relative; display: block; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 11px; overflow: hidden; cursor: pointer; }
.file-button input { position: absolute; inset: 0; opacity: 0; width: 100%; cursor: pointer; }
.notes-label { margin-top: 20px; }
.actions { margin-top: 16px; }
.actions > span { flex: 1 1 200px; }
.muted { color: var(--text-secondary); font-size: var(--text-xs); }
.save-warning { display: block; color: var(--state-warning); font-weight: 500; margin-top: 6px; }
.recovery, .success, .error { font-size: var(--text-sm); padding: 12px; border-radius: var(--radius-sm); background: var(--surface-raised); margin-bottom: 16px; }
.recovery { color: var(--state-warning); } .success { color: var(--state-online); } .error { color: var(--state-danger); }
@media (max-width: 1100px) { .workbench { grid-template-columns: 1fr; } }
@media (max-width: 600px) { .editor-panel { padding: 16px; } }
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
.workbench {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
gap: var(--space-5);
align-items: start;
}
.editor-panel {
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
padding: var(--space-6);
min-width: 0;
}
.heading,
.version-toolbar,
.actions,
.recovery {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
h2 {
font-size: var(--text-lg);
margin: 5px 0 0;
}
.eyebrow {
font-size: var(--text-xs);
color: var(--accent-primary-hover);
}
.draft-tag {
color: var(--state-warning);
font-size: var(--text-xs);
}
.version-toolbar {
margin: 20px 0;
align-items: flex-end;
}
.version-toolbar > label:first-child {
flex: 1;
}
label {
display: grid;
gap: 8px;
color: var(--text-secondary);
font-size: var(--text-xs);
}
select,
input:not([type='file']) {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
font: var(--text-sm) var(--font-body);
}
input:focus-visible,
select:focus-visible,
.file-button:focus-within {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.file-button {
position: relative;
display: block;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 11px;
overflow: hidden;
cursor: pointer;
}
.file-button input {
position: absolute;
inset: 0;
opacity: 0;
width: 100%;
cursor: pointer;
}
.notes-label {
margin-top: 20px;
}
.actions {
margin-top: 16px;
}
.actions > span {
flex: 1 1 200px;
}
.muted {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.save-warning {
display: block;
color: var(--state-warning);
font-weight: 500;
margin-top: 6px;
}
.recovery,
.success,
.error {
font-size: var(--text-sm);
padding: 12px;
border-radius: var(--radius-sm);
background: var(--surface-raised);
margin-bottom: 16px;
}
.recovery {
color: var(--state-warning);
}
.success {
color: var(--state-online);
}
.error {
color: var(--state-danger);
}
@media (max-width: 1100px) {
.workbench {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.editor-panel {
padding: 16px;
}
}
input:not([type='checkbox']):not([type='file']),
select {
border-color: var(--border-control);
}
</style>
+556 -100
View File
@@ -4,7 +4,19 @@
import type { ConfigInspection, ConfigServer, ConfigSource } from '$lib/config-workbench.js';
import Button from './primitives/Button.svelte';
import { saveLibraryContext } from '$ui/library-context.js';
let { artifactId, draftText, scopeType, scopeValue, fixedServer }: { fixedServer?: string; artifactId?: number; draftText?: string; scopeType?: string | null; scopeValue?: string | null } = $props();
let {
artifactId,
draftText,
scopeType,
scopeValue,
fixedServer
}: {
fixedServer?: string;
artifactId?: number;
draftText?: string;
scopeType?: string | null;
scopeValue?: string | null;
} = $props();
let servers = $state<ConfigServer[]>([]);
let selected = $state('');
let inspection = $state<ConfigInspection | null>(null);
@@ -19,143 +31,587 @@
let reload = $state(0);
let ready = $state(false);
let desiredServer = '';
const eligible = $derived(servers.filter(s => (!fixedServer || s.name === fixedServer) && (!artifactId || (scopeType === 'game' ? s.game === scopeValue : scopeType === 'map' ? s.map === scopeValue : false))));
const settings = $derived((inspection?.settings ?? []).filter(s => (!onlyOverrides || s.history.length > 1) && `${s.key} ${s.value} ${s.source.name}`.toLowerCase().includes(query.toLowerCase())));
const overlaps = $derived(inspection?.settings.filter(s => s.history.length > 1).length ?? 0);
const previousValues = $derived(new Map((promoted?.settings ?? []).map(s => [s.key, s.value])));
const changes = $derived(promoted && inspection ? inspection.settings.filter(s => previousValues.get(s.key) !== s.value) : []);
const removed = $derived(promoted?.settings.filter(s => inspection && !inspection.settings.some(current => current.key === s.key)) ?? []);
function label(source: ConfigSource): string { return `${source.name}${source.draft ? ' · draft' : source.version ? ` · v${source.version}` : ''}`; }
function overriddenCount(source: ConfigSource) { return inspection?.settings.filter(s => (s.source.id !== source.id || s.source.kind !== source.kind) && s.history.some(h => h.source.id === source.id && h.source.kind === source.kind)).length ?? 0; }
const eligible = $derived(
servers.filter(
(s) =>
(!fixedServer || s.name === fixedServer) &&
(!artifactId ||
(scopeType === 'game'
? s.game === scopeValue
: scopeType === 'map'
? s.map === scopeValue
: false))
)
);
const settings = $derived(
(inspection?.settings ?? []).filter(
(s) =>
(!onlyOverrides || s.history.length > 1) &&
`${s.key} ${s.value} ${s.source.name}`.toLowerCase().includes(query.toLowerCase())
)
);
const overlaps = $derived(inspection?.settings.filter((s) => s.history.length > 1).length ?? 0);
const previousValues = $derived(
new Map((promoted?.settings ?? []).map((s) => [s.key, s.value]))
);
const changes = $derived(
promoted && inspection
? inspection.settings.filter((s) => previousValues.get(s.key) !== s.value)
: []
);
const removed = $derived(
promoted?.settings.filter(
(s) => inspection && !inspection.settings.some((current) => current.key === s.key)
) ?? []
);
function label(source: ConfigSource): string {
return `${source.name}${source.draft ? ' · draft' : source.version ? ` · v${source.version}` : ''}`;
}
function overriddenCount(source: ConfigSource) {
return (
inspection?.settings.filter(
(s) =>
(s.source.id !== source.id || s.source.kind !== source.kind) &&
s.history.some(
(h) => h.source.id === source.id && h.source.kind === source.kind
)
).length ?? 0
);
}
async function loadServers() {
loading = true; error = '';
loading = true;
error = '';
try {
const response = await fetch('/api/configs/inspect');
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not load servers.');
servers = body.servers;
ready = true;
} catch (err) { error = (err as Error).message; }
finally { loading = false; }
} catch (err) {
error = (err as Error).message;
} finally {
loading = false;
}
}
onMount(() => { desiredServer = new URLSearchParams(window.location.search).get('server') ?? ''; void loadServers(); });
$effect(() => { if (!eligible.some(s => s.name === selected)) selected = eligible.find(s => s.name === desiredServer)?.name ?? eligible[0]?.name ?? ''; });
$effect(() => { if (ready && selected && !fixedServer) saveLibraryContext({ server: selected }); });
onMount(() => {
desiredServer = new URLSearchParams(window.location.search).get('server') ?? '';
void loadServers();
});
$effect(() => {
if (!eligible.some((s) => s.name === selected))
selected =
eligible.find((s) => s.name === desiredServer)?.name ?? eligible[0]?.name ?? '';
});
$effect(() => {
if (ready && selected && !fixedServer) saveLibraryContext({ server: selected });
});
$effect(() => {
const server = selected;
const text = draftText;
const id = artifactId;
const refresh = reload;
if (!ready || !server) { inspection = null; if (ready) loading = false; return; }
void reload;
if (!ready || !server) {
inspection = null;
if (ready) loading = false;
return;
}
const controller = new AbortController();
loading = true; error = '';
if (inspectedServer !== server) { inspection = null; promoted = null; expanded = []; inspectedServer = server; }
const timer = setTimeout(async () => {
try {
const response = await fetch(id !== undefined && text !== undefined ? '/api/configs/inspect' : `/api/configs/inspect?server=${encodeURIComponent(server)}`, {
signal: controller.signal,
...(id !== undefined && text !== undefined ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server, artifactId: id, text }) } : {})
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not inspect this config.');
if (!controller.signal.aborted) { inspection = body.inspection; promoted = body.promoted ?? null; }
} catch (err) { if (!controller.signal.aborted) error = (err as Error).message; }
finally { if (!controller.signal.aborted) loading = false; }
}, text === undefined ? 0 : 350);
return () => { clearTimeout(timer); controller.abort(); };
loading = true;
error = '';
if (inspectedServer !== server) {
inspection = null;
promoted = null;
expanded = [];
inspectedServer = server;
}
const timer = setTimeout(
async () => {
try {
const response = await fetch(
id !== undefined && text !== undefined
? '/api/configs/inspect'
: `/api/configs/inspect?server=${encodeURIComponent(server)}`,
{
signal: controller.signal,
...(id !== undefined && text !== undefined
? {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server, artifactId: id, text })
}
: {})
}
);
const body = await response.json();
if (!response.ok)
throw new Error(body.error ?? 'Could not inspect this config.');
if (!controller.signal.aborted) {
inspection = body.inspection;
promoted = body.promoted ?? null;
}
} catch (err) {
if (!controller.signal.aborted) error = (err as Error).message;
} finally {
if (!controller.signal.aborted) loading = false;
}
},
text === undefined ? 0 : 350
);
return () => {
clearTimeout(timer);
controller.abort();
};
});
function download() {
if (!inspection) return;
const url = URL.createObjectURL(new Blob([inspection.text], { type: 'text/plain' }));
const anchor = document.createElement('a'); anchor.href = url; anchor.download = `${selected}${artifactId ? '-draft' : ''}.cfg`; anchor.click();
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${selected}${artifactId ? '-draft' : ''}.cfg`;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
</script>
<section class="inspector" aria-label="Effective config inspector" aria-busy={loading}>
<div class="heading"><div>{#if !fixedServer}<span class="eyebrow">{artifactId ? 'Draft preview' : 'Follow every value to its source'}</span>{/if}<h2>{artifactId ? 'On this server' : 'Promoted configuration'}</h2></div>
<Button size="sm" variant="ghost" onclick={() => ready ? reload++ : loadServers()}>Refresh</Button>
<div class="heading">
<div>
{#if !fixedServer}<span class="eyebrow"
>{artifactId ? 'Draft preview' : 'Follow every value to its source'}</span
>{/if}
<h2>{artifactId ? 'On this server' : 'Promoted configuration'}</h2>
</div>
<Button size="sm" variant="ghost" onclick={() => (ready ? reload++ : loadServers())}
>Refresh</Button
>
</div>
<p class="intro">{artifactId ? 'Your draft composed with the other promoted configs. Saving creates an untested version.' : 'The intended file from promoted configs and the selected map. Live files and active betas may differ.'}</p>
{#if !fixedServer}<label class="server-label">Server<select aria-label="Server" bind:value={selected} disabled={!!fixedServer || !eligible.length}>
{#each eligible as server}<option value={server.name}>{serverTitle(server.name)}</option>{/each}
{#if !eligible.length}<option value="">No matching servers</option>{/if}
</select></label>{/if}
{#if error}<div class="error" role="alert">{error}{inspection ? ' Showing the last completed preview.' : ''}</div>{/if}
{#if !fixedServer || loading}<p class="preview-status" role="status">{loading ? inspection ? 'Updating preview… Showing the last completed result.' : 'Composing configuration…' : inspection ? 'Preview up to date.' : ''}</p>{/if}
{#if !eligible.length && !loading}<p class="empty">{artifactId ? 'No registered servers match this scope.' : 'Register a server to inspect its configuration.'}</p>
{:else if inspection?.reason}<p class="empty">{fixedServer && inspection.reason.startsWith('No promoted baseline') ? `No promoted baseline for ${servers.find(item => item.name === fixedServer)?.game === 't6' ? 'Black Ops II' : servers.find(item => item.name === fixedServer)?.game === 't5' ? 'Black Ops I' : 'this game'}.` : inspection.reason}</p>{#if fixedServer}<a href="/configs">Open config library →</a>{/if}
<p class="intro">
{artifactId
? 'Your draft composed with the other promoted configs. Saving creates an untested version.'
: 'The intended file from promoted configs and the selected map. Live files and active betas may differ.'}
</p>
{#if !fixedServer}<label class="server-label"
>Server<select
aria-label="Server"
bind:value={selected}
disabled={!!fixedServer || !eligible.length}
>
{#each eligible as server}<option value={server.name}
>{serverTitle(server.name)}</option
>{/each}
{#if !eligible.length}<option value="">No matching servers</option>{/if}
</select></label
>{/if}
{#if error}<div class="error" role="alert">
{error}{inspection ? ' Showing the last completed preview.' : ''}
</div>{/if}
{#if !fixedServer || loading}<p class="preview-status" role="status">
{loading
? inspection
? 'Updating preview… Showing the last completed result.'
: 'Composing configuration…'
: inspection
? 'Preview up to date.'
: ''}
</p>{/if}
{#if !eligible.length && !loading}<p class="empty">
{artifactId
? 'No registered servers match this scope.'
: 'Register a server to inspect its configuration.'}
</p>
{:else if inspection?.reason}<p class="empty">
{fixedServer && inspection.reason.startsWith('No promoted baseline')
? `No promoted baseline for ${servers.find((item) => item.name === fixedServer)?.game === 't6' ? 'Black Ops II' : servers.find((item) => item.name === fixedServer)?.game === 't5' ? 'Black Ops I' : 'this game'}.`
: inspection.reason}
</p>
{#if fixedServer}<a href="/configs">Open config library →</a>{/if}
{:else if inspection}
<ol class="sources" aria-label="Composition order, later layers take precedence">
{#each inspection.sources as source, index}
<li><span class="layer-number">{index + 1}.</span>{#if source.id}<a class:draft={source.draft} href="/configs/{source.id}">{label(source)}</a>
{:else}<span class="map-source">{source.name}</span>{/if}<span>{overriddenCount(source)} settings superseded later</span></li>
<li>
<span class="layer-number">{index + 1}.</span>{#if source.id}<a
class:draft={source.draft}
href="/configs/{source.id}">{label(source)}</a
>
{:else}<span class="map-source">{source.name}</span>{/if}<span
>{overriddenCount(source)} settings superseded later</span
>
</li>
{/each}
</ol>
<p class="hint">Overlays apply in creation order; later overlays win for the same key. The selected map takes precedence over map rotation.</p>
{#if promoted}<p class="change-summary">Compared with promoted settings: {changes.length} changed or added, {removed.length} removed.</p>{#if removed.length}<details><summary>Removed settings</summary><ul>{#each removed as setting}<li><code>{setting.key}</code> · previously {setting.value}</li>{/each}</ul></details>{/if}{/if}
<div class="toolbar"><div class="switch"><button class:active={!raw} aria-pressed={!raw} onclick={() => raw = false}>Settings <span>{inspection.settings.length}</span></button><button class:active={raw} aria-pressed={raw} onclick={() => raw = true}>Composed .cfg</button></div><Button size="sm" variant="ghost" onclick={download}>Download</Button></div>
{#if raw}<textarea class="composed" readonly aria-label="Composed config" value={inspection.text} rows="18"></textarea>
<p class="hint">
Overlays apply in creation order; later overlays win for the same key. The selected map
takes precedence over map rotation.
</p>
{#if promoted}<p class="change-summary">
Compared with promoted settings: {changes.length} changed or added, {removed.length} removed.
</p>
{#if removed.length}<details>
<summary>Removed settings</summary>
<ul>
{#each removed as setting}<li>
<code>{setting.key}</code> · previously {setting.value}
</li>{/each}
</ul>
</details>{/if}{/if}
<div class="toolbar">
<div class="switch">
<button class:active={!raw} aria-pressed={!raw} onclick={() => (raw = false)}
>Settings <span>{inspection.settings.length}</span></button
><button class:active={raw} aria-pressed={raw} onclick={() => (raw = true)}
>Composed .cfg</button
>
</div>
<Button size="sm" variant="ghost" onclick={download}>Download</Button>
</div>
{#if raw}<textarea
class="composed"
readonly
aria-label="Composed config"
value={inspection.text}
rows="18"
></textarea>
{:else}
<div class="filters"><input aria-label="Search effective settings" bind:value={query} placeholder="Search settings or sources…" /><label class="check"><input type="checkbox" bind:checked={onlyOverrides} /> Overrides ({overlaps})</label></div>
<div class="filters">
<input
aria-label="Search effective settings"
bind:value={query}
placeholder="Search settings or sources…"
/><label class="check"
><input type="checkbox" bind:checked={onlyOverrides} /> Overrides ({overlaps})</label
>
</div>
<div class="settings">
{#each settings as setting (setting.key)}
<details class="setting" open={expanded.includes(setting.key)} ontoggle={event => { const open = event.currentTarget.open; expanded = open ? [...new Set([...expanded, setting.key])] : expanded.filter(key => key !== setting.key); }}><summary><div class="setting-main"><code>{setting.key}</code><strong>{setting.value}</strong></div><span class="source-label">{label(setting.source)} {#if setting.history.length > 1}<b>{setting.history.length - 1} overridden</b>{/if}</span>{#if promoted && previousValues.get(setting.key) !== setting.value}<span class="change-summary">{previousValues.has(setting.key) ? `Changed from ${previousValues.get(setting.key)}` : 'Added by draft'}</span>{/if}</summary>
<ol>{#each setting.history as entry, index}<li class:winner={index === setting.history.length - 1}><div><span>{label(entry.source)}</span><small>{entry.source.kind === 'map' ? 'Automatic' : `Line ${entry.line}`} · {index === setting.history.length - 1 ? 'Final value' : 'Overridden'}</small></div><code>{entry.value}</code></li>{/each}</ol>
<details
class="setting"
open={expanded.includes(setting.key)}
ontoggle={(event) => {
const open = event.currentTarget.open;
expanded = open
? [...new Set([...expanded, setting.key])]
: expanded.filter((key) => key !== setting.key);
}}
>
<summary
><div class="setting-main">
<code>{setting.key}</code><strong>{setting.value}</strong>
</div>
<span class="source-label"
>{label(setting.source)}
{#if setting.history.length > 1}<b
>{setting.history.length - 1} overridden</b
>{/if}</span
>{#if promoted && previousValues.get(setting.key) !== setting.value}<span
class="change-summary"
>{previousValues.has(setting.key)
? `Changed from ${previousValues.get(setting.key)}`
: 'Added by draft'}</span
>{/if}</summary
>
<ol>
{#each setting.history as entry, index}<li
class:winner={index === setting.history.length - 1}
>
<div>
<span>{label(entry.source)}</span><small
>{entry.source.kind === 'map'
? 'Automatic'
: `Line ${entry.line}`} · {index ===
setting.history.length - 1
? 'Final value'
: 'Overridden'}</small
>
</div>
<code>{entry.value}</code>
</li>{/each}
</ol>
</details>
{:else}<p class="empty">No matching settings.</p>{/each}
</div>
<p class="hint">Shows set / seta assignments and map rotation. Other commands remain visible in the composed file.</p>
<p class="hint">
Shows set / seta assignments and map rotation. Other commands remain visible in the
composed file.
</p>
{/if}
{#if inspection.warnings.length}<details class="notes"><summary>{inspection.warnings.length} configuration {inspection.warnings.length === 1 ? 'note' : 'notes'}</summary><ul>{#each inspection.warnings as warning}<li>{warning}</li>{/each}</ul></details>{/if}
{#if inspection.warnings.length}<details class="notes">
<summary
>{inspection.warnings.length} configuration {inspection.warnings.length === 1
? 'note'
: 'notes'}</summary
>
<ul>
{#each inspection.warnings as warning}<li>{warning}</li>{/each}
</ul>
</details>{/if}
{/if}
</section>
<style>
.inspector { min-width: 0; padding: var(--space-6); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); }
.heading, .toolbar, .filters { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.eyebrow { font-size: var(--text-xs); color: var(--accent-primary-hover); }
h2 { margin: 5px 0 0; font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }
.intro { color: var(--text-secondary); font-size: var(--text-sm); margin: 12px 0 20px; }
.server-label { display: grid; gap: 8px; font-size: var(--text-xs); color: var(--text-secondary); }
select, input:not([type='checkbox']) { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 10px 12px; font: var(--text-sm) var(--font-body); }
select:focus-visible, input:focus-visible, button:focus-visible, summary:focus-visible, a:focus-visible, .composed:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.sources { display: grid; gap: 8px; margin: 20px 0 0; padding: 0; border: 0; font-size: var(--text-xs); list-style: none; }
.sources li { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 0; }
.layer-number { color: var(--text-primary); font-weight: 600; }
.preview-status { font-size: var(--text-xs); color: var(--text-secondary); min-height: 20px; }
.change-summary { display: block; font-size: var(--text-xs); color: var(--state-warning); margin-top: 8px; }
.sources a, .map-source { background: var(--surface-raised); border-radius: 6px; padding: 6px 9px; text-decoration: none; }
.sources a.draft { color: var(--state-warning); }
.map-source { color: var(--text-secondary); }
.hint { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; }
.toolbar { border-top: 1px solid var(--border-subtle); padding-top: 12px; margin-top: 20px; }
.switch { display: flex; gap: 4px; }
button { border: 0; border-radius: 6px; background: transparent; color: var(--text-secondary); font: var(--text-sm) var(--font-body); padding: 8px 10px; cursor: pointer; }
button.active { color: var(--text-primary); background: var(--surface-raised); }
button span { color: var(--text-secondary); margin-left: 6px; }
.filters { margin: 16px 0 8px; }
.filters > input { flex: 1 1 180px; }
.check { font-size: var(--text-xs); color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }
.settings { max-height: 620px; overflow: auto; }
.setting { border-bottom: 1px solid var(--border-subtle); padding: 14px 0; }
.setting summary { cursor: pointer; list-style: none; }
.setting summary::-webkit-details-marker { display: none; }
.setting-main { display: flex; gap: 12px; justify-content: space-between; flex-wrap: wrap; }
code, strong { font: var(--text-xs)/1.6 var(--font-mono); overflow-wrap: anywhere; white-space: pre-wrap; }
strong { color: var(--text-primary); }
.source-label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: var(--text-xs); color: var(--text-secondary); margin-top: 6px; }
.source-label::before { content: '▸'; } .setting[open] .source-label::before { content: '▾'; }
b { font-weight: 400; color: var(--state-warning); }
ol { list-style: none; padding: 0 0 0 14px; margin: 16px 0 0 4px; border-left: 2px solid var(--border-strong); }
ol li { display: flex; justify-content: space-between; gap: 16px; padding: 8px 0; color: var(--text-secondary); font-size: var(--text-xs); }
ol li.winner { color: var(--text-primary); }
small { display: block; font-size: var(--text-xs); color: var(--text-secondary); margin-top: 3px; }
.composed { display: block; width: 100%; box-sizing: border-box; margin-top: 16px; padding: 20px; background: var(--surface-sunken); color: var(--text-primary); border: 0; border-radius: var(--radius-sm); min-height: 260px; max-height: 600px; resize: vertical; overflow: auto; font: var(--text-xs)/1.8 var(--font-mono); white-space: pre-wrap; overflow-wrap: anywhere; }
.notes { margin-top: 16px; color: var(--state-warning); font-size: var(--text-xs); } .notes summary { cursor: pointer; } .notes li { margin: 8px 0; }
.empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 8px 0; }
.error { color: var(--state-danger); margin-top: 16px; font-size: var(--text-sm); }
@media (max-width: 600px) { .inspector { padding: 16px; } }
input:not([type='checkbox']):not([type='file']), select, textarea { border-color: var(--border-control); }
.inspector {
min-width: 0;
padding: var(--space-6);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
background: var(--surface);
}
.heading,
.toolbar,
.filters {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.eyebrow {
font-size: var(--text-xs);
color: var(--accent-primary-hover);
}
h2 {
margin: 5px 0 0;
font-size: var(--text-lg);
letter-spacing: var(--tracking-tight);
}
.intro {
color: var(--text-secondary);
font-size: var(--text-sm);
margin: 12px 0 20px;
}
.server-label {
display: grid;
gap: 8px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
select,
input:not([type='checkbox']) {
width: 100%;
box-sizing: border-box;
background: var(--surface-sunken);
color: var(--text-primary);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 10px 12px;
font: var(--text-sm) var(--font-body);
}
select:focus-visible,
input:focus-visible,
button:focus-visible,
summary:focus-visible,
a:focus-visible,
.composed:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.sources {
display: grid;
gap: 8px;
margin: 20px 0 0;
padding: 0;
border: 0;
font-size: var(--text-xs);
list-style: none;
}
.sources li {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 0;
}
.layer-number {
color: var(--text-primary);
font-weight: 600;
}
.preview-status {
font-size: var(--text-xs);
color: var(--text-secondary);
min-height: 20px;
}
.change-summary {
display: block;
font-size: var(--text-xs);
color: var(--state-warning);
margin-top: 8px;
}
.sources a,
.map-source {
background: var(--surface-raised);
border-radius: 6px;
padding: 6px 9px;
text-decoration: none;
}
.sources a.draft {
color: var(--state-warning);
}
.map-source {
color: var(--text-secondary);
}
.hint {
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.6;
}
.toolbar {
border-top: 1px solid var(--border-subtle);
padding-top: 12px;
margin-top: 20px;
}
.switch {
display: flex;
gap: 4px;
}
button {
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
font: var(--text-sm) var(--font-body);
padding: 8px 10px;
cursor: pointer;
}
button.active {
color: var(--text-primary);
background: var(--surface-raised);
}
button span {
color: var(--text-secondary);
margin-left: 6px;
}
.filters {
margin: 16px 0 8px;
}
.filters > input {
flex: 1 1 180px;
}
.check {
font-size: var(--text-xs);
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 6px;
}
.settings {
max-height: 620px;
overflow: auto;
}
.setting {
border-bottom: 1px solid var(--border-subtle);
padding: 14px 0;
}
.setting summary {
cursor: pointer;
list-style: none;
}
.setting summary::-webkit-details-marker {
display: none;
}
.setting-main {
display: flex;
gap: 12px;
justify-content: space-between;
flex-wrap: wrap;
}
code,
strong {
font: var(--text-xs)/1.6 var(--font-mono);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
strong {
color: var(--text-primary);
}
.source-label {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
font-size: var(--text-xs);
color: var(--text-secondary);
margin-top: 6px;
}
.source-label::before {
content: '▸';
}
.setting[open] .source-label::before {
content: '▾';
}
b {
font-weight: 400;
color: var(--state-warning);
}
ol {
list-style: none;
padding: 0 0 0 14px;
margin: 16px 0 0 4px;
border-left: 2px solid var(--border-strong);
}
ol li {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 8px 0;
color: var(--text-secondary);
font-size: var(--text-xs);
}
ol li.winner {
color: var(--text-primary);
}
small {
display: block;
font-size: var(--text-xs);
color: var(--text-secondary);
margin-top: 3px;
}
.composed {
display: block;
width: 100%;
box-sizing: border-box;
margin-top: 16px;
padding: 20px;
background: var(--surface-sunken);
color: var(--text-primary);
border: 0;
border-radius: var(--radius-sm);
min-height: 260px;
max-height: 600px;
resize: vertical;
overflow: auto;
font: var(--text-xs)/1.8 var(--font-mono);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.notes {
margin-top: 16px;
color: var(--state-warning);
font-size: var(--text-xs);
}
.notes summary {
cursor: pointer;
}
.notes li {
margin: 8px 0;
}
.empty {
color: var(--text-secondary);
font-size: var(--text-sm);
padding: 8px 0;
}
.error {
color: var(--state-danger);
margin-top: 16px;
font-size: var(--text-sm);
}
@media (max-width: 600px) {
.inspector {
padding: 16px;
}
}
input:not([type='checkbox']):not([type='file']),
select,
textarea {
border-color: var(--border-control);
}
</style>
+574 -121
View File
@@ -16,14 +16,30 @@
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 {
id: number; name: string; kind: 'baseline' | 'config'; scope_type: 'game' | 'map' | null;
scope_value: string | null; current_version_id: number | null; currentVersion: Version | null;
latestVersion: Version | null; versionCount: number; servers: string[]; runningBetaId: number | null;
id: number;
name: string;
kind: 'baseline' | 'config';
scope_type: 'game' | 'map' | null;
scope_value: string | null;
current_version_id: number | null;
currentVersion: Version | null;
latestVersion: Version | null;
versionCount: number;
servers: string[];
runningBetaId: number | null;
}
let artifacts = $state<Artifact[]>([]);
let scopeOptions = $state<{ games: string[]; mapsByGame: Record<string, string[]> }>({ games: [], mapsByGame: {} });
let scopeOptions = $state<{ games: string[]; mapsByGame: Record<string, string[]> }>({
games: [],
mapsByGame: {}
});
let loading = $state(true);
let error = $state('');
let query = $state('');
@@ -33,10 +49,23 @@
let view = $state('library');
let limit = $state(25);
let mounted = $state(false);
afterNavigate(() => { mounted = true; });
afterNavigate(() => {
mounted = true;
});
let lastFilters = '';
$effect(() => { const key = JSON.stringify([query, game, scope, status]); if (!mounted) return; if (lastFilters && lastFilters !== key) limit = 25; lastFilters = key; });
$effect(() => { if (mounted) saveLibraryContext({ q: query, game, scope, status, view, limit }, { view: 'library', limit: 25 }); });
$effect(() => {
const key = JSON.stringify([query, game, scope, status]);
if (!mounted) return;
if (lastFilters && lastFilters !== key) limit = 25;
lastFilters = key;
});
$effect(() => {
if (mounted)
saveLibraryContext(
{ q: query, game, scope, status, view, limit },
{ view: 'library', limit: 25 }
);
});
let dialog: HTMLDialogElement;
let kind = $state<'baseline' | 'config'>('config');
let name = $state('');
@@ -47,161 +76,585 @@
let notes = $state('');
let saving = $state(false);
let createError = $state('');
const baselines = $derived(artifacts.filter(a => a.kind === 'baseline'));
const gameOptions = $derived([...new Set([...scopeOptions.games, ...artifacts.filter(a => a.scope_type === 'game').map(a => a.scope_value!).filter(Boolean)])].sort());
const baselineExists = $derived(kind === 'baseline' && baselines.some(a => a.scope_type === 'game' && a.scope_value === scopeGame));
const selectedScope = $derived(kind === 'baseline' || scopeType === 'game' ? scopeGame : scopeMap);
const filtered = $derived(artifacts.filter(a => {
if (query && !`${a.name} ${a.scope_value ?? ''} ${a.servers.join(' ')}`.toLowerCase().includes(query.toLowerCase())) return false;
if (game && !(a.scope_type === 'game' ? a.scope_value === game : (scopeOptions.mapsByGame[game] ?? []).includes(a.scope_value ?? ''))) return false;
if (scope && a.scope_type !== scope) return false;
if (status === 'pending' && (!a.latestVersion || a.latestVersion.id === a.current_version_id)) return false;
if (status === 'promoted' && !a.current_version_id) return false;
if (status === 'beta' && !a.runningBetaId) return false;
if (status === 'failed' && a.latestVersion?.state !== 'test-failed') return false;
return true;
}));
const filteredBaselines = $derived(filtered.filter(a => a.kind === 'baseline').sort((a, b) => (a.scope_value ?? '').localeCompare(b.scope_value ?? '')));
const filteredOverlays = $derived(filtered.filter(a => a.kind === 'config'));
const baselines = $derived(artifacts.filter((a) => a.kind === 'baseline'));
const gameOptions = $derived(
[
...new Set([
...scopeOptions.games,
...artifacts
.filter((a) => a.scope_type === 'game')
.map((a) => a.scope_value!)
.filter(Boolean)
])
].sort()
);
const baselineExists = $derived(
kind === 'baseline' &&
baselines.some((a) => a.scope_type === 'game' && a.scope_value === scopeGame)
);
const selectedScope = $derived(
kind === 'baseline' || scopeType === 'game' ? scopeGame : scopeMap
);
const filtered = $derived(
artifacts.filter((a) => {
if (
query &&
!`${a.name} ${a.scope_value ?? ''} ${a.servers.join(' ')}`
.toLowerCase()
.includes(query.toLowerCase())
)
return false;
if (
game &&
!(a.scope_type === 'game'
? a.scope_value === game
: (scopeOptions.mapsByGame[game] ?? []).includes(a.scope_value ?? ''))
)
return false;
if (scope && a.scope_type !== scope) return false;
if (
status === 'pending' &&
(!a.latestVersion || a.latestVersion.id === a.current_version_id)
)
return false;
if (status === 'promoted' && !a.current_version_id) return false;
if (status === 'beta' && !a.runningBetaId) return false;
if (status === 'failed' && a.latestVersion?.state !== 'test-failed') return false;
return true;
})
);
const filteredBaselines = $derived(
filtered
.filter((a) => a.kind === 'baseline')
.sort((a, b) => (a.scope_value ?? '').localeCompare(b.scope_value ?? ''))
);
const filteredOverlays = $derived(filtered.filter((a) => a.kind === 'config'));
async function load() {
loading = true; error = '';
loading = true;
error = '';
try {
const responses = await Promise.all([fetch('/api/configs?kind=baseline'), fetch('/api/configs?kind=config'), fetch('/api/scope-options')]);
if (responses.some(r => !r.ok)) throw new Error('Could not load the config library. Please try again.');
const [base, overlay, scopes] = await Promise.all(responses.map(r => r.json()));
artifacts = [...base.scripts, ...overlay.scripts]; scopeOptions = scopes;
} catch (err) { error = (err as Error).message; }
finally { loading = false; }
const responses = await Promise.all([
fetch('/api/configs?kind=baseline'),
fetch('/api/configs?kind=config'),
fetch('/api/scope-options')
]);
if (responses.some((r) => !r.ok))
throw new Error('Could not load the config library. Please try again.');
const [base, overlay, scopes] = await Promise.all(responses.map((r) => r.json()));
artifacts = [...base.scripts, ...overlay.scripts];
scopeOptions = scopes;
} catch (err) {
error = (err as Error).message;
} finally {
loading = false;
}
}
onMount(() => {
const params = new URLSearchParams(window.location.search);
query = params.get('q') ?? '';
game = params.get('game') ?? '';
scope = params.get('scope') ?? '';
status = params.get('status') ?? '';
view = params.get('view') === 'inspect' ? 'inspect' : 'library';
limit = libraryLimit(params);
void load();
});
function openCreate(type: 'config' | 'baseline' = 'config') {
kind = type;
createError = '';
dialog.showModal();
}
function resetFilters() {
query = '';
game = '';
scope = '';
status = '';
}
onMount(() => { const params = new URLSearchParams(window.location.search); query = params.get('q') ?? ''; game = params.get('game') ?? ''; scope = params.get('scope') ?? ''; status = params.get('status') ?? ''; view = params.get('view') === 'inspect' ? 'inspect' : 'library'; limit = libraryLimit(params); void load(); });
function openCreate(type: 'config' | 'baseline' = 'config') { kind = type; createError = ''; dialog.showModal(); }
function resetFilters() { query = ''; game = ''; scope = ''; status = ''; }
async function importFile(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (file) {
try { text = await file.text(); if (!name) name = file.name.replace(/\.[^.]+$/, ''); }
catch { createError = 'Could not read that file.'; }
try {
text = await file.text();
if (!name) name = file.name.replace(/\.[^.]+$/, '');
} catch {
createError = 'Could not read that file.';
}
}
input.value = '';
}
async function create(event: SubmitEvent) {
event.preventDefault();
if (saving || baselineExists || !name.trim() || !selectedScope || !text.trim()) return;
if (kind === 'config' && !/^\s*set\s+\S+\s+/m.test(text)) { createError = 'Add at least one set KEY VALUE line.'; return; }
saving = true; createError = '';
if (kind === 'config' && !/^\s*set\s+\S+\s+/m.test(text)) {
createError = 'Add at least one set KEY VALUE line.';
return;
}
saving = true;
createError = '';
const form = new FormData();
form.append('name', name.trim()); form.append('kind', kind);
form.append('scopeType', kind === 'baseline' ? 'game' : scopeType); form.append('scopeValue', selectedScope);
form.append('text', text); if (notes.trim()) form.append('notes', notes.trim());
form.append('name', name.trim());
form.append('kind', kind);
form.append('scopeType', kind === 'baseline' ? 'game' : scopeType);
form.append('scopeValue', selectedScope);
form.append('text', text);
if (notes.trim()) form.append('notes', notes.trim());
try {
const response = await fetch('/api/configs', { method: 'POST', body: form });
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not create this config.');
dialog.close(); await goto(`/configs/${body.script.id}`);
} catch (err) { createError = (err as Error).message; }
finally { saving = false; }
dialog.close();
await goto(`/configs/${body.script.id}`);
} catch (err) {
createError = (err as Error).message;
} finally {
saving = false;
}
}
</script>
<div class="page">
<SectionHeader title="Configs" subtitle="Manage game baselines and scoped changes.">
{#snippet actions()}{#if view === 'inspect' || loading || artifacts.length || error}<Button variant="primary" onclick={() => openCreate()}>New config</Button>{/if}{/snippet}
{#snippet actions()}{#if view === 'inspect' || loading || artifacts.length || error}<Button
variant="primary"
onclick={() => openCreate()}>New config</Button
>{/if}{/snippet}
</SectionHeader>
<div class="navigation"><Tabs tabs={[{id:'library',label:'Library'},{id:'inspect',label:'Inspect a server'}]} bind:value={view} ariaLabel="Config views" idPrefix="config-tabs" panelId="config-panel" /><Button variant="ghost" size="sm" onclick={load} loading={loading}>Refresh</Button></div>
<div id="config-panel" role="tabpanel" aria-labelledby={'config-tabs-' + view} class="config-panel">
{#if error}<div class="error" role="alert">{error} <Button size="sm" onclick={load}>Retry</Button></div>{/if}
{#if view === 'inspect'}
<ConfigInspector />
{:else}
{#if loading || artifacts.length || error}<div class="library-toolbar">
<input type="search" class="search" aria-label="Search configs" bind:value={query} placeholder="Search configs, maps, or servers…" />
<select aria-label="Filter by game" bind:value={game}><option value="">All games</option>{#each gameOptions as option}<option value={option}>{gameTitle(option)}</option>{/each}</select>
<select aria-label="Filter by scope" bind:value={scope}><option value="">All scopes</option><option value="game">Game</option><option value="map">Map</option></select>
<select aria-label="Filter by status" bind:value={status}><option value="">All statuses</option><option value="pending">Awaiting promotion</option><option value="promoted">Has promoted version</option><option value="beta">Active beta</option><option value="failed">Test failed</option></select>
{#if filtered.length && (query || game || scope || status)}<Button size="sm" variant="ghost" onclick={resetFilters}>Clear</Button>{/if}
</div>{/if}
{#if loading}<div class="loading" role="status">Loading your config library…</div>
{:else if !artifacts.length && !error}
<EmptyState title="No configs yet" description="Configs store game baselines and scoped changes. Create a baseline to start managing configuration versions.">
{#snippet actions()}<Button variant="primary" onclick={() => openCreate('baseline')}>Create baseline</Button>{/snippet}
</EmptyState>
{:else if !filtered.length && !error}<EmptyState title="No matching configs" description="Try a different search or clear your filters.">
{#snippet actions()}<Button onclick={resetFilters}>Clear filters</Button>{/snippet}
</EmptyState>
<div class="navigation">
<Tabs
tabs={[
{ id: 'library', label: 'Library' },
{ id: 'inspect', label: 'Inspect a server' }
]}
bind:value={view}
ariaLabel="Config views"
idPrefix="config-tabs"
panelId="config-panel"
/><Button variant="ghost" size="sm" onclick={load} {loading}>Refresh</Button>
</div>
<div
id="config-panel"
role="tabpanel"
aria-labelledby={'config-tabs-' + view}
class="config-panel"
>
{#if error}<div class="error" role="alert">
{error}
<Button size="sm" onclick={load}>Retry</Button>
</div>{/if}
{#if view === 'inspect'}
<ConfigInspector />
{:else}
{#if filteredBaselines.length}
<section class="section"><h2>Baselines <span>{filteredBaselines.length}</span></h2>{@render configTable(filteredBaselines)}</section>
{/if}
{#if filteredOverlays.length}
<section class="section"><h2>Overlays <span>{filteredOverlays.length}</span></h2>{@render configTable(filteredOverlays.slice(0,limit))}</section>
{#if loading || artifacts.length || error}<div class="library-toolbar">
<input
type="search"
class="search"
aria-label="Search configs"
bind:value={query}
placeholder="Search configs, maps, or servers…"
/>
<select aria-label="Filter by game" bind:value={game}
><option value="">All games</option>{#each gameOptions as option}<option
value={option}>{gameTitle(option)}</option
>{/each}</select
>
<select aria-label="Filter by scope" bind:value={scope}
><option value="">All scopes</option><option value="game">Game</option
><option value="map">Map</option></select
>
<select aria-label="Filter by status" bind:value={status}
><option value="">All statuses</option><option value="pending"
>Awaiting promotion</option
><option value="promoted">Has promoted version</option><option value="beta"
>Active beta</option
><option value="failed">Test failed</option></select
>
{#if filtered.length && (query || game || scope || status)}<Button
size="sm"
variant="ghost"
onclick={resetFilters}>Clear</Button
>{/if}
</div>{/if}
{#if loading}<div class="loading" role="status">Loading your config library…</div>
{:else if !artifacts.length && !error}
<EmptyState
title="No configs yet"
description="Configs store game baselines and scoped changes. Create a baseline to start managing configuration versions."
>
{#snippet actions()}<Button
variant="primary"
onclick={() => openCreate('baseline')}>Create baseline</Button
>{/snippet}
</EmptyState>
{:else if !filtered.length && !error}<EmptyState
title="No matching configs"
description="Try a different search or clear your filters."
>
{#snippet actions()}<Button onclick={resetFilters}>Clear filters</Button
>{/snippet}
</EmptyState>
{:else}
{#if filteredBaselines.length}
<section class="section">
<h2>Baselines <span>{filteredBaselines.length}</span></h2>
{@render configTable(filteredBaselines)}
</section>
{/if}
{#if filteredOverlays.length}
<section class="section">
<h2>Overlays <span>{filteredOverlays.length}</span></h2>
{@render configTable(filteredOverlays.slice(0, limit))}
</section>
{/if}
{/if}
<ListFooter total={filteredOverlays.length} bind:limit label="overlays" />
{/if}
<ListFooter total={filteredOverlays.length} bind:limit label="overlays" />
{/if}
</div>
</div>
{#snippet configTable(rows: Artifact[])}
<div class="library-table"><table>
<thead><tr><th>Config</th><th>Scope</th><th>Version / status</th><th>Eligible servers</th></tr></thead>
<tbody>{#each rows as artifact (artifact.id)}<tr>
<td><a class="name" href={contextHref(`/configs/${artifact.id}`, $page.url)}>{artifact.name}</a></td>
<td>{artifact.scope_type === 'game' ? `All ${gameTitle(artifact.scope_value)} servers` : artifact.scope_type === 'map' ? mapLabel(artifact.scope_value ?? '') : 'Unscoped'}</td>
<td><LibraryVersions current={artifact.currentVersion} latest={artifact.latestVersion} betaId={artifact.runningBetaId} /></td>
<td class="numeric">{artifact.servers.length}</td>
</tr>{/each}</tbody>
</table></div>
<div class="library-table">
<table>
<thead
><tr
><th>Config</th><th>Scope</th><th>Version / status</th><th>Eligible servers</th
></tr
></thead
>
<tbody
>{#each rows as artifact (artifact.id)}<tr>
<td
><a
class="name"
href={contextHref(`/configs/${artifact.id}`, $page.url)}
>{artifact.name}</a
></td
>
<td
>{artifact.scope_type === 'game'
? `All ${gameTitle(artifact.scope_value)} servers`
: artifact.scope_type === 'map'
? mapLabel(artifact.scope_value ?? '')
: 'Unscoped'}</td
>
<td
><LibraryVersions
current={artifact.currentVersion}
latest={artifact.latestVersion}
betaId={artifact.runningBetaId}
/></td
>
<td class="numeric">{artifact.servers.length}</td>
</tr>{/each}</tbody
>
</table>
</div>
{/snippet}
<dialog bind:this={dialog} aria-labelledby="new-config-title" oncancel={event => { if (saving) event.preventDefault(); }}>
<dialog
bind:this={dialog}
aria-labelledby="new-config-title"
oncancel={(event) => {
if (saving) event.preventDefault();
}}
>
<form onsubmit={create}>
<div class="dialog-heading"><div><span class="eyebrow">Create version 1</span><h2 id="new-config-title">New config</h2></div><Button variant="ghost" onclick={() => dialog.close()} disabled={saving} ariaLabel="Close new config">✕</Button></div>
<div class="kind-picker"><button type="button" class:active={kind === 'config'} aria-pressed={kind === 'config'} onclick={() => kind = 'config'} disabled={saving}><strong>Overlay</strong><span>Override selected settings</span></button><button type="button" class:active={kind === 'baseline'} aria-pressed={kind === 'baseline'} onclick={() => kind = 'baseline'} disabled={saving}><strong>Baseline</strong><span>A complete config for a game</span></button></div>
<div class="form-grid"><label>Name<input bind:value={name} placeholder={kind === 'config' ? 'No AFK timeout' : 'Black Ops I baseline'} required disabled={saving} /></label><label>Game<select aria-label="Game" bind:value={scopeGame} onchange={() => scopeMap = ''} required disabled={saving}><option value="" disabled>Choose a game</option>{#each gameOptions as option}<option value={option}>{gameTitle(option)}</option>{/each}</select></label>
{#if kind === 'config'}<label>Apply to<select aria-label="Apply to" bind:value={scopeType} disabled={saving}><option value="game">Whole game</option><option value="map">One map</option></select></label>{#if scopeType === 'map'}<label>Map<select aria-label="Map" bind:value={scopeMap} required disabled={saving || !scopeGame}><option value="" disabled>Choose a map</option>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as option}<option value={option}>{option}</option>{/each}</select></label>{/if}{/if}
<div 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>
{#if baselineExists}<p class="warning">This game already has a baseline. Open it from the library to save a new version.</p>{/if}
{#if !gameOptions.length}<p class="warning">Register a server first to make its game and map available here.</p>{/if}
<label class="import">Start from a file <input type="file" accept=".cfg,text/plain" onchange={importFile} disabled={saving} /></label>
<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' : 'Black Ops I baseline'}
required
disabled={saving}
/></label
><label
>Game<select
aria-label="Game"
bind:value={scopeGame}
onchange={() => (scopeMap = '')}
required
disabled={saving}
><option value="" disabled>Choose a game</option
>{#each gameOptions as option}<option value={option}>{gameTitle(option)}</option
>{/each}</select
></label
>
{#if kind === 'config'}<label
>Apply to<select aria-label="Apply to" bind:value={scopeType} disabled={saving}
><option value="game">Whole game</option><option value="map">One map</option
></select
></label
>{#if scopeType === 'map'}<label
>Map<select
aria-label="Map"
bind:value={scopeMap}
required
disabled={saving || !scopeGame}
><option value="" disabled>Choose a map</option
>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as option}<option
value={option}>{option}</option
>{/each}</select
></label
>{/if}{/if}
</div>
{#if baselineExists}<p class="warning">
This game already has a baseline. Open it from the library to save a new version.
</p>{/if}
{#if !gameOptions.length}<p class="warning">
Register a server first to make its game and map available here.
</p>{/if}
<label class="import"
>Start from a file <input
type="file"
accept=".cfg,text/plain"
onchange={importFile}
disabled={saving}
/></label
>
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving} />
<label class="notes">Notes<input bind:value={notes} placeholder="What is this config for?" disabled={saving} /></label>
<label class="notes"
>Notes<input
bind:value={notes}
placeholder="What is this config for?"
disabled={saving}
/></label
>
{#if createError}<div class="error" role="alert">{createError}</div>{/if}
<div class="dialog-actions"><p>Saved as untested. Test in a beta before promotion.</p><Button variant="ghost" onclick={() => dialog.close()} disabled={saving}>Cancel</Button><Button type="submit" variant="primary" loading={saving} disabled={baselineExists || !name.trim() || !selectedScope || !text.trim()}>Create {kind === 'baseline' ? 'baseline' : 'overlay'}</Button></div>
<div class="dialog-actions">
<p>Saved as untested. Test in a beta before promotion.</p>
<Button variant="ghost" onclick={() => dialog.close()} disabled={saving}>Cancel</Button
><Button
type="submit"
variant="primary"
loading={saving}
disabled={baselineExists || !name.trim() || !selectedScope || !text.trim()}
>Create {kind === 'baseline' ? 'baseline' : 'overlay'}</Button
>
</div>
</form>
</dialog>
<style>
.page { display: flex; flex-direction: column; gap: var(--space-6); }
.page {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.navigation { display:flex; align-items:center; gap:12px; justify-content:space-between; }
.navigation { border-bottom: 1px solid var(--border-subtle); padding-bottom: 12px; }
.navigation {
display: flex;
align-items: center;
gap: 12px;
justify-content: space-between;
}
.navigation {
border-bottom: 1px solid var(--border-subtle);
padding-bottom: 12px;
}
button { background: transparent; color: var(--text-secondary); font: var(--text-sm) var(--font-body); border: 1px solid transparent; border-radius: var(--radius-sm); padding: 10px 14px; cursor: pointer; }
button.active { color: var(--text-primary); background: var(--surface-raised); } button span { margin-left: 8px; }
button:disabled { opacity: .5; cursor: default; }
button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
input, select { box-sizing: border-box; min-width: 0; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 11px 12px; font: var(--text-sm) var(--font-body); }
.section { display: flex; flex-direction: column; gap: 14px; }
h2 { font-size: var(--text-md); margin: 0; letter-spacing: var(--tracking-tight); } h2 span { font-size: var(--text-xs); color: var(--text-secondary); font-weight: 400; margin-left: 8px; }
.loading { padding: 60px 0; text-align: center; color: var(--text-secondary); }
.eyebrow { color: var(--accent-primary-hover); font-size: var(--text-xs); }
.error { color: var(--state-danger); padding: 12px; background: var(--surface-raised); border-radius: var(--radius-sm); font-size: var(--text-sm); }
.warning { color: var(--state-warning); font-size: var(--text-sm); }
dialog { box-sizing: border-box; width: min(860px, calc(100vw - 32px)); padding: 0; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); box-shadow: var(--shadow-raised); }
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(4px); }
form { padding: 28px; display: flex; flex-direction: column; gap: 20px; }
.dialog-heading, .dialog-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .dialog-heading h2 { margin-top: 6px; font-size: var(--text-xl); }
.kind-picker { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.kind-picker button { padding: 16px; border: 1px solid var(--border-strong); text-align: left; }
.kind-picker button.active { border-color: var(--accent-primary); } .kind-picker span { display: block; margin: 6px 0 0; font-size: var(--text-xs); color: var(--text-secondary); }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
label { display: grid; gap: 8px; font-size: var(--text-xs); color: var(--text-secondary); }
.import { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; } .import input { flex: 1; }
.dialog-actions { flex-wrap: wrap; } .dialog-actions p { flex: 1 1 240px; font-size: var(--text-xs); color: var(--text-secondary); }
@media (max-width:600px) { .form-grid { grid-template-columns:1fr; } form { padding:18px; } .navigation { gap:4px; flex-wrap:wrap; } }
.config-panel { display:grid; gap:20px; }
.loading { padding:24px 0; }
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
button {
background: transparent;
color: var(--text-secondary);
font: var(--text-sm) var(--font-body);
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 10px 14px;
cursor: pointer;
}
button.active {
color: var(--text-primary);
background: var(--surface-raised);
}
button span {
margin-left: 8px;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
a:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
input,
select {
box-sizing: border-box;
min-width: 0;
background: var(--surface-sunken);
color: var(--text-primary);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 11px 12px;
font: var(--text-sm) var(--font-body);
}
.section {
display: flex;
flex-direction: column;
gap: 14px;
}
h2 {
font-size: var(--text-md);
margin: 0;
letter-spacing: var(--tracking-tight);
}
h2 span {
font-size: var(--text-xs);
color: var(--text-secondary);
font-weight: 400;
margin-left: 8px;
}
.loading {
padding: 60px 0;
text-align: center;
color: var(--text-secondary);
}
.eyebrow {
color: var(--accent-primary-hover);
font-size: var(--text-xs);
}
.error {
color: var(--state-danger);
padding: 12px;
background: var(--surface-raised);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
}
.warning {
color: var(--state-warning);
font-size: var(--text-sm);
}
dialog {
box-sizing: border-box;
width: min(860px, calc(100vw - 32px));
padding: 0;
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-raised);
}
dialog::backdrop {
background: rgb(0 0 0 / 0.7);
backdrop-filter: blur(4px);
}
form {
padding: 28px;
display: flex;
flex-direction: column;
gap: 20px;
}
.dialog-heading,
.dialog-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dialog-heading h2 {
margin-top: 6px;
font-size: var(--text-xl);
}
.kind-picker {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.kind-picker button {
padding: 16px;
border: 1px solid var(--border-strong);
text-align: left;
}
.kind-picker button.active {
border-color: var(--accent-primary);
}
.kind-picker span {
display: block;
margin: 6px 0 0;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
label {
display: grid;
gap: 8px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.import {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.import input {
flex: 1;
}
.dialog-actions {
flex-wrap: wrap;
}
.dialog-actions p {
flex: 1 1 240px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
@media (max-width: 600px) {
.form-grid {
grid-template-columns: 1fr;
}
form {
padding: 18px;
}
.navigation {
gap: 4px;
flex-wrap: wrap;
}
}
.config-panel {
display: grid;
gap: 20px;
}
.loading {
padding: 24px 0;
}
input:not([type='checkbox']):not([type='file']),
select {
border-color: var(--border-control);
}
</style>
+240 -40
View File
@@ -1,74 +1,274 @@
<script lang="ts">
import { addConfigSetting, changeConfigValue, configLines, configWarnings } from '$lib/config-workbench.js';
import {
addConfigSetting,
changeConfigValue,
configLines,
configWarnings
} from '$lib/config-workbench.js';
import Button from './primitives/Button.svelte';
let { text = $bindable(''), overlay = false, disabled = false }: { text?: string; overlay?: boolean; disabled?: boolean } = $props();
let {
text = $bindable(''),
overlay = false,
disabled = false
}: { text?: string; overlay?: boolean; disabled?: boolean } = $props();
let mode = $state<'raw' | 'settings'>('raw');
let search = $state('');
let newKey = $state('');
let newValue = $state('');
let addError = $state('');
function addSetting() {
try { text = addConfigSetting(text, newKey, newValue); search = ''; newKey = ''; newValue = ''; addError = ''; }
catch (error) { addError = (error as Error).message; }
try {
text = addConfigSetting(text, newKey, newValue);
search = '';
newKey = '';
newValue = '';
addError = '';
} catch (error) {
addError = (error as Error).message;
}
}
function addOnEnter(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
if (!disabled && newKey.trim() && newValue.trim()) addSetting();
}
}
function addOnEnter(event: KeyboardEvent) { if (event.key === 'Enter') { event.preventDefault(); if (!disabled && newKey.trim() && newValue.trim()) addSetting(); } }
const entries = $derived(configLines(text));
const visible = $derived(entries.filter(e => `${e.key} ${e.value}`.toLowerCase().includes(search.toLowerCase())));
const visible = $derived(
entries.filter((e) => `${e.key} ${e.value}`.toLowerCase().includes(search.toLowerCase()))
);
const warnings = $derived(configWarnings(text, overlay));
</script>
<div class="editor">
<div class="toolbar">
<div class="switch" aria-label="Editor view">
<button type="button" class:active={mode === 'raw'} aria-pressed={mode === 'raw'} onclick={() => mode = 'raw'}>Raw .cfg</button>
<button type="button" class:active={mode === 'settings'} aria-pressed={mode === 'settings'} onclick={() => mode = 'settings'}>Settings <span>{entries.length}</span></button>
<button
type="button"
class:active={mode === 'raw'}
aria-pressed={mode === 'raw'}
onclick={() => (mode = 'raw')}>Raw .cfg</button
>
<button
type="button"
class:active={mode === 'settings'}
aria-pressed={mode === 'settings'}
onclick={() => (mode = 'settings')}>Settings <span>{entries.length}</span></button
>
</div>
<span class="hint">{text.split(/\r?\n/).length} lines</span>
</div>
{#if mode === 'raw'}
<textarea aria-label="Config contents" bind:value={text} {disabled} spellcheck="false" rows="12" placeholder={'// Add your settings\nset g_inactivity "0"'}></textarea>
<textarea
aria-label="Config contents"
bind:value={text}
{disabled}
spellcheck="false"
rows="12"
placeholder={'// Add your settings\nset g_inactivity "0"'}
></textarea>
{:else}
<div class="settings">
<input class="search" aria-label="Find a setting" bind:value={search} placeholder="Find a setting…" />
<p class="hint">Edit values and inline comments. Other commands and formatting stay in the raw file.</p>
<input
class="search"
aria-label="Find a setting"
bind:value={search}
placeholder="Find a setting…"
/>
<p class="hint">
Edit values and inline comments. Other commands and formatting stay in the raw file.
</p>
{#each visible as entry (entry.index)}
<label class="setting"><span><code>{entry.key}</code><small>Line {entry.index + 1}</small></span>
<input value={entry.value} {disabled} aria-label={`${entry.key}, line ${entry.index + 1}`} oninput={e => text = changeConfigValue(text, entry.index, e.currentTarget.value)} />
<label class="setting"
><span><code>{entry.key}</code><small>Line {entry.index + 1}</small></span>
<input
value={entry.value}
{disabled}
aria-label={`${entry.key}, line ${entry.index + 1}`}
oninput={(e) =>
(text = changeConfigValue(text, entry.index, e.currentTarget.value))}
/>
</label>
{:else}<p class="hint">{entries.length ? 'No matching settings.' : 'Add your first setting below.'}</p>{/each}
<div class="add-setting"><label>Setting name<input bind:value={newKey} onkeydown={addOnEnter} placeholder="g_inactivity" {disabled} /></label><label>Value<input bind:value={newValue} onkeydown={addOnEnter} placeholder="0" {disabled} /></label><Button size="sm" onclick={addSetting} disabled={disabled || !newKey.trim() || !newValue.trim()}>Add setting</Button></div>
{:else}<p class="hint">
{entries.length ? 'No matching settings.' : 'Add your first setting below.'}
</p>{/each}
<div class="add-setting">
<label
>Setting name<input
bind:value={newKey}
onkeydown={addOnEnter}
placeholder="g_inactivity"
{disabled}
/></label
><label
>Value<input
bind:value={newValue}
onkeydown={addOnEnter}
placeholder="0"
{disabled}
/></label
><Button
size="sm"
onclick={addSetting}
disabled={disabled || !newKey.trim() || !newValue.trim()}>Add setting</Button
>
</div>
{#if addError}<p class="warnings" role="alert">{addError}</p>{/if}
</div>
{/if}
</div>
{#if warnings.length}
<details class="warnings" open={warnings.some(w => w.includes('ignored') || w.includes('no value'))}><summary>{warnings.length} configuration {warnings.length === 1 ? 'note' : 'notes'} — review before saving</summary>
<ul>{#each warnings as warning}<li>{warning}</li>{/each}</ul>
<details
class="warnings"
open={warnings.some((w) => w.includes('ignored') || w.includes('no value'))}
>
<summary
>{warnings.length} configuration {warnings.length === 1 ? 'note' : 'notes'} — review before
saving</summary
>
<ul>
{#each warnings as warning}<li>{warning}</li>{/each}
</ul>
</details>
{/if}
<style>
.editor { border: 1px solid var(--border-strong); border-radius: var(--radius-md); overflow: hidden; background: var(--surface-sunken); }
.toolbar { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; gap: 12px; border-bottom: 1px solid var(--border-subtle); }
.switch { display: flex; gap: 4px; }
button { font: inherit; font-size: var(--text-sm); color: var(--text-secondary); border: 0; border-radius: var(--radius-sm); padding: 8px 12px; background: transparent; cursor: pointer; }
button.active { color: var(--text-primary); background: var(--surface-raised); }
button span { margin-left: 8px; color: var(--text-secondary); }
button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -2px; }
textarea { display: block; width: 100%; height: 360px; min-height: 240px; box-sizing: border-box; resize: vertical; border: 0; padding: var(--space-5); background: transparent; color: var(--text-primary); font: var(--text-sm)/1.8 var(--font-mono); tab-size: 4; }
textarea:disabled, input:disabled { opacity: .65; }
.settings { padding: var(--space-4); max-height: 520px; overflow: auto; }
input { box-sizing: border-box; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 9px 12px; font: var(--text-sm) var(--font-mono); width: 100%; }
.setting { display: grid; grid-template-columns: minmax(140px, 1fr) minmax(0, 1.4fr); align-items: center; gap: 16px; padding: 12px 0; border-top: 1px solid var(--border-subtle); }
.setting span { min-width: 0; overflow-wrap: anywhere; }
.add-setting { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; border-top: 1px solid var(--border-strong); padding-top: 16px; }
.add-setting label { flex: 1 1 140px; display: grid; gap: 6px; font-size: var(--text-sm); }
code { font: var(--text-sm) var(--font-mono); }
small { display: block; color: var(--text-secondary); font-size: var(--text-xs); margin-top: 4px; }
.hint { color: var(--text-secondary); font-size: var(--text-xs); }
.warnings { color: var(--state-warning); font-size: var(--text-sm); padding: 12px 0 0; }
summary { cursor: pointer; }
li { margin: 6px 0; }
@media (max-width: 600px) { .setting { grid-template-columns: 1fr; gap: 8px; } .toolbar > .hint { display: none; } }
input:not([type='checkbox']):not([type='file']), textarea { border-color: var(--border-control); }
.editor {
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--surface-sunken);
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
gap: 12px;
border-bottom: 1px solid var(--border-subtle);
}
.switch {
display: flex;
gap: 4px;
}
button {
font: inherit;
font-size: var(--text-sm);
color: var(--text-secondary);
border: 0;
border-radius: var(--radius-sm);
padding: 8px 12px;
background: transparent;
cursor: pointer;
}
button.active {
color: var(--text-primary);
background: var(--surface-raised);
}
button span {
margin-left: 8px;
color: var(--text-secondary);
}
button:focus-visible,
input:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: -2px;
}
textarea {
display: block;
width: 100%;
height: 360px;
min-height: 240px;
box-sizing: border-box;
resize: vertical;
border: 0;
padding: var(--space-5);
background: transparent;
color: var(--text-primary);
font: var(--text-sm)/1.8 var(--font-mono);
tab-size: 4;
}
textarea:disabled,
input:disabled {
opacity: 0.65;
}
.settings {
padding: var(--space-4);
max-height: 520px;
overflow: auto;
}
input {
box-sizing: border-box;
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 9px 12px;
font: var(--text-sm) var(--font-mono);
width: 100%;
}
.setting {
display: grid;
grid-template-columns: minmax(140px, 1fr) minmax(0, 1.4fr);
align-items: center;
gap: 16px;
padding: 12px 0;
border-top: 1px solid var(--border-subtle);
}
.setting span {
min-width: 0;
overflow-wrap: anywhere;
}
.add-setting {
display: flex;
align-items: end;
flex-wrap: wrap;
gap: 12px;
border-top: 1px solid var(--border-strong);
padding-top: 16px;
}
.add-setting label {
flex: 1 1 140px;
display: grid;
gap: 6px;
font-size: var(--text-sm);
}
code {
font: var(--text-sm) var(--font-mono);
}
small {
display: block;
color: var(--text-secondary);
font-size: var(--text-xs);
margin-top: 4px;
}
.hint {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.warnings {
color: var(--state-warning);
font-size: var(--text-sm);
padding: 12px 0 0;
}
summary {
cursor: pointer;
}
li {
margin: 6px 0;
}
@media (max-width: 600px) {
.setting {
grid-template-columns: 1fr;
gap: 8px;
}
.toolbar > .hint {
display: none;
}
}
input:not([type='checkbox']):not([type='file']),
textarea {
border-color: var(--border-control);
}
</style>
+23 -3
View File
@@ -4,11 +4,31 @@
import Button from './primitives/Button.svelte';
</script>
<Dialog bind:open={() => $confirmation !== null, (open) => { if (!open) resolveConfirmation(false); }} title={$confirmation?.title ?? 'Confirm action'}>
<Dialog
bind:open={
() => $confirmation !== null,
(open) => {
if (!open) resolveConfirmation(false);
}
}
title={$confirmation?.title ?? 'Confirm action'}
>
<p>{$confirmation?.message}</p>
{#snippet actions()}
<Button variant="ghost" onclick={() => resolveConfirmation(false)}>Cancel</Button>
<Button variant={$confirmation?.danger ? 'danger' : 'primary'} onclick={() => resolveConfirmation(true)}>{$confirmation?.confirmLabel ?? 'Continue'}</Button>
<Button
variant={$confirmation?.danger ? 'danger' : 'primary'}
onclick={() => resolveConfirmation(true)}
>{$confirmation?.confirmLabel ?? 'Continue'}</Button
>
{/snippet}
</Dialog>
<style>p { margin: 0; white-space: pre-line; color: var(--text-secondary); line-height: var(--leading-body); }</style>
<style>
p {
margin: 0;
white-space: pre-line;
color: var(--text-secondary);
line-height: var(--leading-body);
}
</style>
+34 -5
View File
@@ -2,12 +2,41 @@
import Button from './primitives/Button.svelte';
let { value, label = 'ID' }: { value: string; label?: string } = $props();
let copyStatus = $state('');
$effect(() => { value; copyStatus = ''; });
$effect(() => {
void value;
copyStatus = '';
});
async function copy() {
copyStatus = '';
try { await navigator.clipboard.writeText(value); copyStatus = `${label} copied.`; }
catch { copyStatus = 'Copy unavailable. Select and copy the text directly.'; }
try {
await navigator.clipboard.writeText(value);
copyStatus = `${label} copied.`;
} catch {
copyStatus = 'Copy unavailable. Select and copy the text directly.';
}
}
</script>
<div class="copy"><code>{value}</code><Button variant="ghost" size="sm" ariaLabel={`Copy ${label}`} onclick={copy}>Copy</Button>{#if copyStatus}<span class="copy-status" role="status">{copyStatus}</span>{/if}</div>
<style>.copy-status { font-size: var(--text-xs); } .copy { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); color: var(--text-secondary); } code { font: var(--text-xs) var(--font-mono); user-select: all; overflow-wrap: anywhere; }</style>
<div class="copy">
<code>{value}</code><Button variant="ghost" size="sm" ariaLabel={`Copy ${label}`} onclick={copy}
>Copy</Button
>{#if copyStatus}<span class="copy-status" role="status">{copyStatus}</span>{/if}
</div>
<style>
.copy-status {
font-size: var(--text-xs);
}
.copy {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
color: var(--text-secondary);
}
code {
font: var(--text-xs) var(--font-mono);
user-select: all;
overflow-wrap: anywhere;
}
</style>
+106 -23
View File
@@ -3,20 +3,35 @@
import Button from './primitives/Button.svelte';
import Dialog from './primitives/Dialog.svelte';
let { open = $bindable(false), host, ondeleted }:
{ open?: boolean; host: Pick<Host, 'id' | 'name'> | null; ondeleted: (result: { deleted: boolean; operationId?: string }) => void | Promise<void> } = $props();
let {
open = $bindable(false),
host,
ondeleted
}: {
open?: boolean;
host: Pick<Host, 'id' | 'name'> | null;
ondeleted: (result: { deleted: boolean; operationId?: string }) => void | Promise<void>;
} = $props();
let deletion = $state<HostDeletion | null>(null);
let checking = $state(false); let deleting = $state(false); let error = $state('');
let checking = $state(false);
let deleting = $state(false);
let error = $state('');
async function inspect(id: string, signal?: AbortSignal) {
checking = true; deletion = null; error = '';
checking = true;
deletion = null;
error = '';
try {
const response = await fetch(`/api/hosts/${encodeURIComponent(id)}`, { signal });
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not check this host.');
if (!response.ok)
throw new Error(body.error ?? body.message ?? 'Could not check this host.');
if (!signal?.aborted) deletion = body.deletion;
} catch (cause) { if (!signal?.aborted) error = (cause as Error).message; }
finally { if (!signal?.aborted) checking = false; }
} catch (cause) {
if (!signal?.aborted) error = (cause as Error).message;
} finally {
if (!signal?.aborted) checking = false;
}
}
$effect(() => {
if (!open || !host) return;
@@ -27,9 +42,12 @@
async function remove() {
if (!host || deleting || checking || !deletion?.allowed) return;
const id = host.id;
deleting = true; error = '';
deleting = true;
error = '';
try {
const response = await fetch(`/api/hosts/${encodeURIComponent(id)}`, { method: 'DELETE' });
const response = await fetch(`/api/hosts/${encodeURIComponent(id)}`, {
method: 'DELETE'
});
const body = await response.json();
if (!response.ok) {
await inspect(id);
@@ -37,30 +55,95 @@
}
open = false;
await ondeleted(body);
} catch (cause) { error = (cause as Error).message; }
finally { deleting = false; }
} catch (cause) {
error = (cause as Error).message;
} finally {
deleting = false;
}
}
</script>
<Dialog bind:open title={`Delete ${host?.name ?? 'host'}?`} busy={deleting}
description="Permanently remove Electric Cherry's files from this host, including its downloaded game files, then uninstall the agent.">
<Dialog
bind:open
title={`Delete ${host?.name ?? 'host'}?`}
busy={deleting}
description="Permanently remove Electric Cherry's files from this host, including its downloaded game files, then uninstall the agent."
>
<div class="content">
<p>This deletes the host's game-library copies, server files, agent data, credentials, and service installation. Electric Cherry's uploaded libraries on the controller and its operation history are kept.</p>
<p>The host remains visible until cleanup is confirmed. Offline hosts wait for reconnection. Unsent commands will be cancelled.</p>
<p>
This deletes the host's game-library copies, server files, agent data, credentials, and
service installation. Electric Cherry's uploaded libraries on the controller and its
operation history are kept.
</p>
<p>
The host remains visible until cleanup is confirmed. Offline hosts wait for
reconnection. Unsent commands will be cancelled.
</p>
{#if checking}<p role="status">Checking servers and unfinished operations…</p>
{:else if deletion && !deletion.allowed}<div role="status" class="blocked"><strong>This host still has work to finish:</strong><ul>{#each deletion.blockers as blocker}<li>{blocker}</li>{/each}</ul><p>Review <a href="/hosts/operations" onclick={() => open = false}>operation history</a> or open <a href={`/hosts/${host?.id}`} onclick={() => open = false}>host details</a> to drain its servers.</p></div>
{:else if deletion?.allowed}<p>Cleanup cannot be undone once it starts. Adding this machine again requires installing and enrolling the agent again.</p>{/if}
{:else if deletion && !deletion.allowed}<div role="status" class="blocked">
<strong>This host still has work to finish:</strong>
<ul>
{#each deletion.blockers as blocker}<li>{blocker}</li>{/each}
</ul>
<p>
Review <a href="/hosts/operations" onclick={() => (open = false)}
>operation history</a
>
or open
<a href={`/hosts/${host?.id}`} onclick={() => (open = false)}>host details</a> to
drain its servers.
</p>
</div>
{:else if deletion?.allowed}<p>
Cleanup cannot be undone once it starts. Adding this machine again requires
installing and enrolling the agent again.
</p>{/if}
{#if error}<p role="alert" class="ui-error">{error}</p>{/if}
</div>
{#snippet actions()}
<Button variant="ghost" disabled={deleting} onclick={() => open = false}>Cancel</Button>
{#if !deletion?.allowed}<Button disabled={checking || deleting || !host} onclick={() => { if (host) void inspect(host.id); }}>Check again</Button>{/if}
<Button variant="danger" loading={deleting} disabled={checking || !deletion?.allowed} onclick={remove}>Delete host</Button>
<Button variant="ghost" disabled={deleting} onclick={() => (open = false)}>Cancel</Button>
{#if !deletion?.allowed}<Button
disabled={checking || deleting || !host}
onclick={() => {
if (host) void inspect(host.id);
}}>Check again</Button
>{/if}
<Button
variant="danger"
loading={deleting}
disabled={checking || !deletion?.allowed}
onclick={remove}>Delete host</Button
>
{/snippet}
</Dialog>
<style>
.content { display:grid; gap:16px; } p,li { margin:0; color:var(--text-secondary); font-size:var(--text-sm); line-height:1.65; }
.blocked { display:grid; gap:12px; } strong { font-size:var(--text-sm); } ul { margin:0; padding-left:20px; }
a { color:var(--accent-primary-hover); } .ui-error { color:var(--state-danger); }
.content {
display: grid;
gap: 16px;
}
p,
li {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.65;
}
.blocked {
display: grid;
gap: 12px;
}
strong {
font-size: var(--text-sm);
}
ul {
margin: 0;
padding-left: 20px;
}
a {
color: var(--accent-primary-hover);
}
.ui-error {
color: var(--state-danger);
}
</style>
File diff suppressed because it is too large Load Diff
+449 -105
View File
@@ -11,17 +11,35 @@
import type { NativeDeploymentSelection } from '$lib/hosts.js';
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
let { open = $bindable(false), sourceDeploymentId = '', initialGame = 't5', initialMapSlug = '' }:
{ open?: boolean; sourceDeploymentId?: string; initialGame?: ZombieGame; initialMapSlug?: string } = $props();
let {
open = $bindable(false),
sourceDeploymentId = '',
initialGame = 't5',
initialMapSlug = ''
}: {
open?: boolean;
sourceDeploymentId?: string;
initialGame?: ZombieGame;
initialMapSlug?: string;
} = $props();
let game = $state<ZombieGame>('t5');
let mapSlug = $state('');
let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] });
let gameSelect = $state<HTMLSelectElement>();
let selection = $state<NativeDeploymentSelection>({ libraryIds: {}, image: '' });
let hostReady = $state(false);
let hostStatus = $state<NativeHostSelectionStatus>({ loading: true, error: '', hasHosts: false, filesReady: false, missingGames: [], uploadedAt: null });
let hostStatus = $state<NativeHostSelectionStatus>({
loading: true,
error: '',
hasHosts: false,
filesReady: false,
missingGames: [],
uploadedAt: null
});
let view = $state<ServerLaunchState>({ preview: null, checking: false, error: '' });
const controller = createServerLaunchController(state => { view = state; });
const controller = createServerLaunchController((state) => {
view = state;
});
let loaded = $state(false);
let loadingError = $state('');
let launchError = $state('');
@@ -37,39 +55,89 @@
const preview = $derived(view.preview);
const expired = $derived(!!preview && Date.parse(preview.expiresAt) <= now);
const imageValid = $derived(/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(selection.image));
const checksBlocked = $derived(preview?.checks.filter(check => check.state === 'blocked') ?? []);
const checksBlocked = $derived(
preview?.checks.filter((check) => check.state === 'blocked') ?? []
);
const missingKey = $derived(!!preview?.name && !preview.hasSavedKey);
const canLaunch = $derived(!launching && !!preview?.name && (unconfirmedLaunch ||
(hostReady && !hostStatus.loading && !view.checking && !expired && preview.ready && preview.hasSavedKey)));
const checking = $derived(!loaded && !loadingError || hostStatus.loading || view.checking);
const footerStatus = $derived(launching ? 'Queueing server…' : unconfirmedLaunch ? 'Retry to confirm this launch.'
: loadingError ? 'Could not load launch options.' : checking ? 'Checking availability…'
: missingKey ? 'No game key available.' : canLaunch ? 'Ready to launch' : 'Launch unavailable');
const canLaunch = $derived(
!launching &&
!!preview?.name &&
(unconfirmedLaunch ||
(hostReady &&
!hostStatus.loading &&
!view.checking &&
!expired &&
preview.ready &&
preview.hasSavedKey))
);
const checking = $derived((!loaded && !loadingError) || hostStatus.loading || view.checking);
const footerStatus = $derived(
launching
? 'Queueing server…'
: unconfirmedLaunch
? 'Retry to confirm this launch.'
: loadingError
? 'Could not load launch options.'
: checking
? 'Checking availability…'
: missingKey
? 'No game key available.'
: canLaunch
? 'Ready to launch'
: 'Launch unavailable'
);
$effect(() => {
if (open && !wasOpen) { wasOpen = true; void initialize(); }
else if (!open && wasOpen) {
wasOpen = false; visit++; initialization?.abort(); controller.clear();
if (open && !wasOpen) {
wasOpen = true;
void initialize();
} else if (!open && wasOpen) {
wasOpen = false;
visit++;
initialization?.abort();
controller.clear();
}
});
$effect(() => {
// Selection changes invalidate the old preview before another launch can use it.
const input = open && loaded && hostReady && !hostStatus.loading ? {
game, mapSlug, libraryId: selection.libraryIds[game]!, image: selection.image,
bindAddress: selection.bindAddress || undefined, publicAddress: selection.publicAddress || undefined,
publicPort: selection.publicPort
} : null;
const input =
open && loaded && hostReady && !hostStatus.loading
? {
game,
mapSlug,
libraryId: selection.libraryIds[game]!,
image: selection.image,
bindAddress: selection.bindAddress || undefined,
publicAddress: selection.publicAddress || undefined,
publicPort: selection.publicPort
}
: null;
if (!launching && !unconfirmedLaunch) controller.select(input);
});
async function initialize() {
const current = ++visit;
initialization?.abort(); initialization = new AbortController();
initialization?.abort();
initialization = new AbortController();
const signal = initialization.signal;
controller.clear(); loaded = false; loadingError = ''; launchError = ''; launching = false; unconfirmedLaunch = false;
game = initialGame; mapSlug = initialMapSlug;
selection = { libraryIds: {}, image: '' }; hostReady = false;
hostStatus = { loading: true, error: '', hasHosts: false, filesReady: false, missingGames: [], uploadedAt: null };
controller.clear();
loaded = false;
loadingError = '';
launchError = '';
launching = false;
unconfirmedLaunch = false;
game = initialGame;
mapSlug = initialMapSlug;
selection = { libraryIds: {}, image: '' };
hostReady = false;
hostStatus = {
loading: true,
error: '',
hasHosts: false,
filesReady: false,
missingGames: [],
uploadedAt: null
};
try {
const response = await fetch('/api/maps', { signal });
if (!response.ok) throw new Error('Could not load maps. Try again.');
@@ -77,75 +145,117 @@
if (current !== visit) return;
catalogs = { t5: body.t5Zombie, t6: body.t6Zombie };
if (sourceDeploymentId) {
const sourceResponse = await fetch(`/api/provisioning/${encodeURIComponent(sourceDeploymentId)}`, { signal });
const sourceResponse = await fetch(
`/api/provisioning/${encodeURIComponent(sourceDeploymentId)}`,
{ signal }
);
const source = await sourceResponse.json();
if (current !== visit) return;
if (!sourceResponse.ok || !source.recipe || !['t5', 't6'].includes(source.recipe.game)
|| !catalogs[source.recipe.game as ZombieGame].some(map => map.slug === source.recipe.mapSlug)) {
if (
!sourceResponse.ok ||
!source.recipe ||
!['t5', 't6'].includes(source.recipe.game) ||
!catalogs[source.recipe.game as ZombieGame].some(
(map) => map.slug === source.recipe.mapSlug
)
) {
throw new Error('The original launch map is unavailable.');
}
game = source.recipe.game; mapSlug = source.recipe.mapSlug;
game = source.recipe.game;
mapSlug = source.recipe.mapSlug;
}
if (!catalogs[game].some(map => map.slug === mapSlug)) mapSlug = catalogs[game][0]?.slug ?? '';
if (!catalogs[game].some((map) => map.slug === mapSlug))
mapSlug = catalogs[game][0]?.slug ?? '';
loaded = true;
await tick();
if (current === visit && open) gameSelect?.focus();
} catch (error) {
if (current === visit && !signal.aborted) loadingError = error instanceof Error ? error.message : 'Could not load launch options.';
if (current === visit && !signal.aborted)
loadingError =
error instanceof Error ? error.message : 'Could not load launch options.';
}
}
function refreshChecks() {
if (!open || launching || unconfirmedLaunch) return;
if (!loaded) { void initialize(); return; }
if (!loaded) {
void initialize();
return;
}
launchError = '';
controller.clear();
refreshVersion++;
}
function selectionChanged() { launchError = ''; }
function selectionChanged() {
launchError = '';
}
onMount(() => {
const refreshOnReturn = () => { if (document.visibilityState !== 'hidden') refreshChecks(); };
const refreshOnReturn = () => {
if (document.visibilityState !== 'hidden') refreshChecks();
};
window.addEventListener('focus', refreshOnReturn);
document.addEventListener('visibilitychange', refreshOnReturn);
const timer = setInterval(() => { now = Date.now(); }, 1000);
const timer = setInterval(() => {
now = Date.now();
}, 1000);
return () => {
window.removeEventListener('focus', refreshOnReturn);
document.removeEventListener('visibilitychange', refreshOnReturn);
clearInterval(timer);
};
});
onDestroy(() => { visit++; initialization?.abort(); submission?.abort(); controller.dispose(); });
onDestroy(() => {
visit++;
initialization?.abort();
submission?.abort();
controller.dispose();
});
async function launch() {
if (!canLaunch || !preview) return;
const current = visit;
const reviewed = preview;
controller.suspend(); launching = true; launchError = '';
controller.suspend();
launching = true;
launchError = '';
submission = new AbortController();
try {
const response = await fetch('/api/servers', { method: 'POST', signal: submission.signal,
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
game: reviewed.recipe.game, gameMode: 'zombie', mapSlug: reviewed.recipe.mapSlug,
expectedName: reviewed.name, previewId: reviewed.id
}) });
const response = await fetch('/api/servers', {
method: 'POST',
signal: submission.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
game: reviewed.recipe.game,
gameMode: 'zombie',
mapSlug: reviewed.recipe.mapSlug,
expectedName: reviewed.name,
previewId: reviewed.id
})
});
const body = await response.json();
if (current !== visit) return;
if (!response.ok || !body.ok) {
unconfirmedLaunch = response.status >= 500;
launchError = body.error ?? 'Could not queue this launch.';
if (!unconfirmedLaunch) { controller.clear(); refreshVersion++; }
if (!unconfirmedLaunch) {
controller.clear();
refreshVersion++;
}
return;
}
open = false;
await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`);
} catch (error) {
} catch {
if (current === visit) {
// Retain the preview ID: retrying a possibly accepted launch must be idempotent.
unconfirmedLaunch = true;
launchError = 'Could not confirm the launch. Retry to check the same request, or view Provisioning.';
launchError =
'Could not confirm the launch. Retry to check the same request, or view Provisioning.';
}
} finally { if (current === visit) launching = false; }
} finally {
if (current === visit) launching = false;
}
}
</script>
@@ -158,92 +268,326 @@
<p class="muted" role="status">Loading maps…</p>
{:else}
<div class="fields">
<label>Game<select bind:this={gameSelect} bind:value={game} disabled={launching || unconfirmedLaunch}
onchange={event => { game = event.currentTarget.value as ZombieGame; mapSlug = catalogs[game][0]?.slug ?? ''; selectionChanged(); }}>
<option value="t5">Black Ops I</option><option value="t6">Black Ops II</option>
</select></label>
<label>Map<select bind:value={mapSlug} disabled={launching || unconfirmedLaunch} onchange={selectionChanged}>
{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}
</select></label>
<label
>Game<select
bind:this={gameSelect}
bind:value={game}
disabled={launching || unconfirmedLaunch}
onchange={(event) => {
game = event.currentTarget.value as ZombieGame;
mapSlug = catalogs[game][0]?.slug ?? '';
selectionChanged();
}}
>
<option value="t5">Black Ops I</option><option value="t6"
>Black Ops II</option
>
</select></label
>
<label
>Map<select
bind:value={mapSlug}
disabled={launching || unconfirmedLaunch}
onchange={selectionChanged}
>
{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}
</select></label
>
</div>
<section class="summary" aria-label="Server summary" aria-busy={checking}>
<strong>{preview?.name ? serverTitle(preview.name) : maps.find(map => map.slug === mapSlug)?.friendly ?? 'Choose a map'}</strong>
<strong
>{preview?.name
? serverTitle(preview.name)
: (maps.find((map) => map.slug === mapSlug)?.friendly ??
'Choose a map')}</strong
>
<p class="muted">Uses the current production setup when the server starts.</p>
<div class="readiness" aria-label="Launch readiness">
{#if checking}<span role="status">Checking availability…</span>
{:else}
<span class:ready={hostStatus.filesReady}>{hostStatus.missingGames.length ? 'Game files missing' : hostStatus.filesReady ? 'Game files ready' : 'Game files preparing'}</span>
<span class:ready={hostStatus.filesReady}
>{hostStatus.missingGames.length
? 'Game files missing'
: hostStatus.filesReady
? 'Game files ready'
: 'Game files preparing'}</span
>
{#if preview?.hasSavedKey}<span class="ready">Key available</span>{/if}
<span>Automatic host &amp; port</span>
{/if}
</div>
</section>
{#if launchError}<div class="issue" role="alert"><p>{launchError}</p>{#if unconfirmedLaunch}<a href="/provisioning" target="_blank" rel="noopener">View Provisioning ↗</a>{/if}</div>{/if}
{#if missingKey}<div class="issue" role="status"><p>No unused {game === 't5' ? 'Black Ops I' : 'Black Ops II'} keys are available.</p><a href={`/server-keys?game=${game}`} target="_blank" rel="noopener">Manage keys ↗</a></div>{/if}
{#if launchError}<div class="issue" role="alert">
<p>{launchError}</p>
{#if unconfirmedLaunch}<a href="/provisioning" target="_blank" rel="noopener"
>View Provisioning ↗</a
>{/if}
</div>{/if}
{#if missingKey}<div class="issue" role="status">
<p>
No unused {game === 't5' ? 'Black Ops I' : 'Black Ops II'} keys are available.
</p>
<a href={`/server-keys?game=${game}`} target="_blank" rel="noopener"
>Manage keys ↗</a
>
</div>{/if}
{#if hostStatus.error}<p class="issue" role="alert">{hostStatus.error}</p>
{:else if !hostStatus.loading && hostStatus.missingGames.length}<div class="issue" role="status"><p>Upload game files before launching.</p><a href="/game-files" target="_blank" rel="noopener">Manage game files ↗</a></div>
{:else if !hostStatus.loading && !imageValid}<p class="issue" role="status">Enter a valid runtime image in Advanced settings.</p>
{:else if view.error && hostStatus.hasHosts}<p class="issue" role="alert">{view.error}</p>{/if}
{#if !hostStatus.loading && !hostStatus.error && !hostStatus.hasHosts}<div class="issue" role="status"><p>Add a host to run your server.</p><a href="/hosts" target="_blank" rel="noopener">Manage hosts ↗</a></div>{/if}
{:else if !hostStatus.loading && hostStatus.missingGames.length}<div
class="issue"
role="status"
>
<p>Upload game files before launching.</p>
<a href="/game-files" target="_blank" rel="noopener">Manage game files ↗</a>
</div>
{:else if !hostStatus.loading && !imageValid}<p class="issue" role="status">
Enter a valid runtime image in Advanced settings.
</p>
{:else if view.error && hostStatus.hasHosts}<p class="issue" role="alert">
{view.error}
</p>{/if}
{#if !hostStatus.loading && !hostStatus.error && !hostStatus.hasHosts}<div
class="issue"
role="status"
>
<p>Add a host to run your server.</p>
<a href="/hosts" target="_blank" rel="noopener">Manage hosts ↗</a>
</div>{/if}
{#each checksBlocked as check}<p class="issue" role="status">{check.detail}</p>{/each}
{#if !checking && !unconfirmedLaunch && (view.error || hostStatus.error || missingKey || checksBlocked.length)}<button class="text-button retry" disabled={launching} onclick={refreshChecks}>Retry checks</button>{/if}
{#if !checking && !unconfirmedLaunch && (view.error || hostStatus.error || missingKey || checksBlocked.length)}<button
class="text-button retry"
disabled={launching}
onclick={refreshChecks}>Retry checks</button
>{/if}
{#if open}<NativeHostSelection games={[game]} bind:selection bind:ready={hostReady} bind:status={hostStatus}
compact network {refreshVersion} disabled={launching || unconfirmedLaunch} onchange={selectionChanged} />{/if}
{#if open}<NativeHostSelection
games={[game]}
bind:selection
bind:ready={hostReady}
bind:status={hostStatus}
compact
network
{refreshVersion}
disabled={launching || unconfirmedLaunch}
onchange={selectionChanged}
/>{/if}
{#if preview}<details class="setup-details">
<summary>Setup details</summary>
<dl>
<div><dt>Server ID</dt><dd><code>{preview.name ?? 'No lobby available'}</code></dd></div>
<div><dt>Configuration</dt><dd>{preview.recipe.configSource}</dd></div>
<div><dt>Container image</dt><dd><code>{preview.recipe.image}</code></dd></div>
{#if hostStatus.uploadedAt}<div><dt>Game files uploaded</dt><dd>{$timeFormat.dateTime(hostStatus.uploadedAt)}</dd></div>{/if}
</dl>
<h3>{preview.recipe.files.length} managed files</h3>
<ul class="files">{#each preview.recipe.files as file}<li><div><strong>{file.name}</strong><span>{file.version === null ? 'Generated' : `v${file.version}`} · {file.kind}</span></div><code>{file.path}</code></li>{/each}</ul>
</details>{/if}
<summary>Setup details</summary>
<dl>
<div>
<dt>Server ID</dt>
<dd><code>{preview.name ?? 'No lobby available'}</code></dd>
</div>
<div>
<dt>Configuration</dt>
<dd>{preview.recipe.configSource}</dd>
</div>
<div>
<dt>Container image</dt>
<dd><code>{preview.recipe.image}</code></dd>
</div>
{#if hostStatus.uploadedAt}<div>
<dt>Game files uploaded</dt>
<dd>{$timeFormat.dateTime(hostStatus.uploadedAt)}</dd>
</div>{/if}
</dl>
<h3>{preview.recipe.files.length} managed files</h3>
<ul class="files">
{#each preview.recipe.files as file}<li>
<div>
<strong>{file.name}</strong><span
>{file.version === null ? 'Generated' : `v${file.version}`} ·
{file.kind}</span
>
</div>
<code>{file.path}</code>
</li>{/each}
</ul>
</details>{/if}
{/if}
</div>
{#snippet actions()}
<div class="launch-actions">
<p class:ready={canLaunch && !unconfirmedLaunch} role="status">{footerStatus}</p>
<div><Button variant="ghost" disabled={launching} onclick={() => open = false}>Cancel</Button><Button variant="primary" loading={launching} disabled={!canLaunch} onclick={launch}>{unconfirmedLaunch ? 'Retry creation' : 'Create server'}</Button></div>
<div>
<Button variant="ghost" disabled={launching} onclick={() => (open = false)}
>Cancel</Button
><Button
variant="primary"
loading={launching}
disabled={!canLaunch}
onclick={launch}
>{unconfirmedLaunch ? 'Retry creation' : 'Create server'}</Button
>
</div>
</div>
{/snippet}
</Dialog>
<style>
.launch-content { display: grid; gap: 18px; }
.fields { display: grid; grid-template-columns: 1fr 1.3fr; gap: 14px; }
label { display: grid; gap: 8px; color: var(--text-secondary); font-size: var(--text-sm); }
select { width: 100%; }
.summary { display: grid; gap: 8px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); }
.summary > strong { font-size: var(--text-md); line-height: 1.4; }
p { margin: 0; font-size: var(--text-sm); line-height: 1.5; }
.muted { color: var(--text-secondary); font-size: var(--text-xs); }
.readiness { display: flex; flex-wrap: wrap; gap: 8px 14px; font-size: var(--text-xs); color: var(--text-secondary); }
.ready { color: var(--state-online); }
.issue { display: grid; gap: 8px; padding: 12px; border: 1px solid color-mix(in srgb, var(--state-warning) 40%, transparent); border-radius: var(--radius-md); color: var(--state-warning); font-size: var(--text-sm); }
a, .text-button { color: var(--accent-primary-hover); font-size: var(--text-sm); }
.text-button { border: 0; padding: 0; background: none; text-align: left; cursor: pointer; }
.retry { justify-self: start; }
.setup-details { border-top: 1px solid var(--border-subtle); padding-top: 14px; min-width: 0; }
summary { cursor: pointer; font-size: var(--text-sm); }
dl { display: grid; gap: 14px; margin: 16px 0; }
dt { color: var(--text-secondary); font-size: var(--text-xs); }
dd { margin: 5px 0 0; overflow-wrap: anywhere; font-size: var(--text-sm); line-height: 1.5; }
code { font-family: var(--font-mono); font-size: var(--text-xs); overflow-wrap: anywhere; }
h3 { font-size: var(--text-sm); margin: 18px 0 8px; }
.files { list-style: none; padding: 0; margin: 0; }
.files li { display: grid; gap: 5px; padding: 10px 0; border-bottom: 1px solid var(--border-subtle); }
.files li > div { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
.files strong { font-size: var(--text-sm); font-weight: 500; }
.files span, .files code { font-size: var(--text-xs); color: var(--text-secondary); }
.launch-actions { width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.launch-actions > p { color: var(--text-secondary); font-size: var(--text-xs); }
.launch-actions > p.ready { color: var(--state-online); }
.launch-actions > div { display: flex; align-items: center; gap: 8px; }
@media (max-width: 440px) { .fields { grid-template-columns: 1fr; } .launch-actions { align-items: stretch; flex-direction: column; } .launch-actions > div { justify-content: flex-end; } }
.launch-content {
display: grid;
gap: 18px;
}
.fields {
display: grid;
grid-template-columns: 1fr 1.3fr;
gap: 14px;
}
label {
display: grid;
gap: 8px;
color: var(--text-secondary);
font-size: var(--text-sm);
}
select {
width: 100%;
}
.summary {
display: grid;
gap: 8px;
padding: 16px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface-sunken);
}
.summary > strong {
font-size: var(--text-md);
line-height: 1.4;
}
p {
margin: 0;
font-size: var(--text-sm);
line-height: 1.5;
}
.muted {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.readiness {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.ready {
color: var(--state-online);
}
.issue {
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid color-mix(in srgb, var(--state-warning) 40%, transparent);
border-radius: var(--radius-md);
color: var(--state-warning);
font-size: var(--text-sm);
}
a,
.text-button {
color: var(--accent-primary-hover);
font-size: var(--text-sm);
}
.text-button {
border: 0;
padding: 0;
background: none;
text-align: left;
cursor: pointer;
}
.retry {
justify-self: start;
}
.setup-details {
border-top: 1px solid var(--border-subtle);
padding-top: 14px;
min-width: 0;
}
summary {
cursor: pointer;
font-size: var(--text-sm);
}
dl {
display: grid;
gap: 14px;
margin: 16px 0;
}
dt {
color: var(--text-secondary);
font-size: var(--text-xs);
}
dd {
margin: 5px 0 0;
overflow-wrap: anywhere;
font-size: var(--text-sm);
line-height: 1.5;
}
code {
font-family: var(--font-mono);
font-size: var(--text-xs);
overflow-wrap: anywhere;
}
h3 {
font-size: var(--text-sm);
margin: 18px 0 8px;
}
.files {
list-style: none;
padding: 0;
margin: 0;
}
.files li {
display: grid;
gap: 5px;
padding: 10px 0;
border-bottom: 1px solid var(--border-subtle);
}
.files li > div {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.files strong {
font-size: var(--text-sm);
font-weight: 500;
}
.files span,
.files code {
font-size: var(--text-xs);
color: var(--text-secondary);
}
.launch-actions {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.launch-actions > p {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.launch-actions > p.ready {
color: var(--state-online);
}
.launch-actions > div {
display: flex;
align-items: center;
gap: 8px;
}
@media (max-width: 440px) {
.fields {
grid-template-columns: 1fr;
}
.launch-actions {
align-items: stretch;
flex-direction: column;
}
.launch-actions > div {
justify-content: flex-end;
}
}
</style>
+51 -14
View File
@@ -5,7 +5,12 @@
import Button from './primitives/Button.svelte';
import Dialog from './primitives/Dialog.svelte';
let { open = $bindable(false), name, players, round = null }: { open?: boolean; name: string; players: number | null; round?: number | null } = $props();
let {
open = $bindable(false),
name,
players,
round = null
}: { open?: boolean; name: string; players: number | null; round?: number | null } = $props();
let submitting = $state(false);
let error = $state('');
$effect(() => {
@@ -17,34 +22,66 @@
submitting = true;
error = '';
try {
const res = await fetch(`/api/servers/${encodeURIComponent(name)}`, { method: 'DELETE' });
const res = await fetch(`/api/servers/${encodeURIComponent(name)}`, {
method: 'DELETE'
});
const body = await res.json();
if (!res.ok || !body.ok || !body.deploymentId) throw new Error(body.error ?? 'Could not queue server destruction. Please retry.');
if (!res.ok || !body.ok || !body.deploymentId)
throw new Error(body.error ?? 'Could not queue server destruction. Please retry.');
await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`);
open = false;
} catch (err) { error = (err as Error).message; }
finally { submitting = false; }
} catch (err) {
error = (err as Error).message;
} finally {
submitting = false;
}
}
</script>
<Dialog bind:open title={`Destroy ${serverTitle(name)}?`} busy={submitting}>
<div class="fields">
<p class="notice">{lifecycleImpact('destroy', players, round)}</p>
<p>This stops and removes the container, its writable server files, and registration. Shared game libraries, recorded history, and the saved server key are retained.</p>
<p>
This stops and removes the container, its writable server files, and registration.
Shared game libraries, recorded history, and the saved server key are retained.
</p>
<p>Follow progress or cancel the waiting request on the provisioning page.</p>
<p class="irreversible">You can cancel while waiting. Once destruction begins, it cannot be undone.</p>
<p class="irreversible">
You can cancel while waiting. Once destruction begins, it cannot be undone.
</p>
{#if error}<p role="alert" class="error">{error}</p>{/if}
</div>
{#snippet actions()}
<Button disabled={submitting} onclick={() => open = false}>Cancel</Button>
<Button variant="danger" disabled={submitting} loading={submitting} onclick={destroy}>Destroy server when empty</Button>
<Button disabled={submitting} onclick={() => (open = false)}>Cancel</Button>
<Button variant="danger" disabled={submitting} loading={submitting} onclick={destroy}
>Destroy server when empty</Button
>
{/snippet}
</Dialog>
<style>
.fields { display: grid; gap: 16px; }
p { margin: 0; line-height: 1.6; color: var(--text-secondary); }
.notice { padding: 16px; background: var(--surface-raised); border-radius: var(--radius-md); line-height: 1.6; }
.notice { color: var(--text-primary); }
.irreversible { font-size: var(--text-sm); } .error { color: var(--state-danger); }
.fields {
display: grid;
gap: 16px;
}
p {
margin: 0;
line-height: 1.6;
color: var(--text-secondary);
}
.notice {
padding: 16px;
background: var(--surface-raised);
border-radius: var(--radius-md);
line-height: 1.6;
}
.notice {
color: var(--text-primary);
}
.irreversible {
font-size: var(--text-sm);
}
.error {
color: var(--state-danger);
}
</style>
+29 -11
View File
@@ -31,9 +31,15 @@
)
j++;
const prefix = alines.slice(0, i).map((t) => ({ kind: 'ctx' as const, text: t }));
const suffix = alines.slice(alines.length - j).map((t) => ({ kind: 'ctx' as const, text: t }));
const midA = alines.slice(i, alines.length - j).map((t) => ({ kind: 'del' as const, text: t }));
const midB = blines.slice(i, blines.length - j).map((t) => ({ kind: 'add' as const, text: t }));
const suffix = alines
.slice(alines.length - j)
.map((t) => ({ kind: 'ctx' as const, text: t }));
const midA = alines
.slice(i, alines.length - j)
.map((t) => ({ kind: 'del' as const, text: t }));
const midB = blines
.slice(i, blines.length - j)
.map((t) => ({ kind: 'add' as const, text: t }));
return [...prefix, ...midA, ...midB, ...suffix];
}
</script>
@@ -44,8 +50,8 @@
<span class="path">{relPath}</span>
</header>
<pre>
{#each rows as r, idx (idx)}<span
class="ln ln-{r.kind}">{r.kind === 'add' ? '+' : r.kind === 'del' ? '-' : ' '} {r.text}
{#each rows as r, idx (idx)}<span class="ln ln-{r.kind}"
>{r.kind === 'add' ? '+' : r.kind === 'del' ? '-' : ' '} {r.text}
</span>{/each}</pre>
</div>
@@ -76,12 +82,12 @@
border: 1px solid transparent;
}
.verb-added {
background: rgba(124, 212, 255, 0.10);
background: rgba(124, 212, 255, 0.1);
border-color: rgba(124, 212, 255, 0.3);
color: var(--state-online);
}
.verb-updated {
background: rgba(124, 192, 245, 0.10);
background: rgba(124, 192, 245, 0.1);
border-color: rgba(124, 192, 245, 0.28);
color: var(--text-primary);
}
@@ -95,7 +101,11 @@
border-color: rgba(255, 176, 32, 0.3);
color: var(--state-warning);
}
.path { color: var(--text-primary); font-family: var(--font-mono); font-size: var(--text-sm); }
.path {
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--text-sm);
}
pre {
margin: 0;
padding: 14px 16px;
@@ -106,7 +116,15 @@
max-height: 540px;
overflow: auto;
}
.ln { display: block; }
.ln-add { background: color-mix(in srgb, var(--state-online) 8%, transparent); color: var(--state-online); }
.ln-del { background: rgba(255, 48, 80, 0.08); color: #ff8a9a; }
.ln {
display: block;
}
.ln-add {
background: color-mix(in srgb, var(--state-online) 8%, transparent);
color: var(--state-online);
}
.ln-del {
background: rgba(255, 48, 80, 0.08);
color: #ff8a9a;
}
</style>
+232 -54
View File
@@ -2,7 +2,13 @@
import { untrack } from 'svelte';
import { currentRound, freshServer, type FleetServer } from '$lib/fleet-overview.js';
import { actionLabels } from '$lib/provisioning.js';
import { fleetActions, fleetActionAvailable, queueFleetAction, type FleetAction, type FleetActionFailure } from '$ui/fleet-actions.js';
import {
fleetActions,
fleetActionAvailable,
queueFleetAction,
type FleetAction,
type FleetActionFailure
} from '$ui/fleet-actions.js';
import { lifecycleImpact } from '$ui/lifecycle-actions.js';
import { serverHealth } from '$ui/server-health.js';
import { serverTitle } from '$ui/server-labels.js';
@@ -10,7 +16,10 @@
import Button from './primitives/Button.svelte';
import Dialog from './primitives/Dialog.svelte';
let { open = $bindable(false), onqueued }: { open?: boolean; onqueued: (count: number, action: FleetAction) => void } = $props();
let {
open = $bindable(false),
onqueued
}: { open?: boolean; onqueued: (count: number, action: FleetAction) => void } = $props();
let servers = $state<FleetServer[]>([]);
let action = $state<FleetAction>('restart');
let selected = $state<string[]>([]);
@@ -25,71 +34,121 @@
start: 'Start the selected servers and verify their health.',
restart: 'Restart the selected servers once they are empty, then verify their health.',
stop: 'Stop the selected servers once they are empty. Their files, keys, and registrations are kept.',
rebuild: 'Rebuild the selected servers from the latest promoted setup once they are empty. Their writable files are permanently deleted.',
destroy: 'Destroy the selected servers once they are empty. Their containers, writable files, and registrations are permanently deleted.'
rebuild:
'Rebuild the selected servers from the latest promoted setup once they are empty. Their writable files are permanently deleted.',
destroy:
'Destroy the selected servers once they are empty. Their containers, writable files, and registrations are permanently deleted.'
};
const destructive = $derived(action === 'rebuild' || action === 'destroy');
const eligible = $derived(servers.filter(server => !queued.includes(server.name) && fleetActionAvailable(server, action, $uiNow)));
const targets = $derived(eligible.filter(server => selected.includes(server.name)));
const eligible = $derived(
servers.filter(
(server) =>
!queued.includes(server.name) && fleetActionAvailable(server, action, $uiNow)
)
);
const targets = $derived(eligible.filter((server) => selected.includes(server.name)));
const allSelected = $derived(eligible.length > 0 && targets.length === eligible.length);
$effect(() => {
if (!open) return;
untrack(() => { action = 'restart'; selected = []; queued = []; failures = []; confirmed = false; });
untrack(() => {
action = 'restart';
selected = [];
queued = [];
failures = [];
confirmed = false;
});
});
$effect(() => {
if (!open) return;
refresh;
void refresh;
const controller = new AbortController();
untrack(() => { void load(controller.signal); });
untrack(() => {
void load(controller.signal);
});
return () => controller.abort();
});
async function load(signal: AbortSignal) {
loading = true; error = '';
loading = true;
error = '';
try {
const response = await fetch('/api/servers', { signal, cache: 'no-store' });
if (!response.ok) throw new Error('Could not load servers.');
const body = await response.json();
if (signal.aborted) return;
servers = body.servers.sort((a: FleetServer, b: FleetServer) => serverTitle(a.name).localeCompare(serverTitle(b.name)));
servers = body.servers.sort((a: FleetServer, b: FleetServer) =>
serverTitle(a.name).localeCompare(serverTitle(b.name))
);
syncServerTime(body.serverNow);
} catch (cause) { if (!signal.aborted) error = cause instanceof Error ? cause.message : 'Could not load servers.'; }
finally { if (!signal.aborted) loading = false; }
} catch (cause) {
if (!signal.aborted)
error = cause instanceof Error ? cause.message : 'Could not load servers.';
} finally {
if (!signal.aborted) loading = false;
}
}
function chooseAction() {
selected = [];
confirmed = false;
failures = [];
}
function chooseAction() { selected = []; confirmed = false; failures = []; }
function toggle(name: string, checked: boolean) {
selected = checked ? [...selected, name] : selected.filter(value => value !== name);
selected = checked ? [...selected, name] : selected.filter((value) => value !== name);
confirmed = false;
}
function selectAll(checked: boolean) {
selected = checked ? eligible.map((server) => server.name) : [];
confirmed = false;
}
function selectAll(checked: boolean) { selected = checked ? eligible.map(server => server.name) : []; confirmed = false; }
async function submit() {
if (busy || loading || error || (destructive && !confirmed)) return;
const names = targets.filter(server => fleetActionAvailable(server, action, serverTimeNow())).map(server => server.name);
const names = targets
.filter((server) => fleetActionAvailable(server, action, serverTimeNow()))
.map((server) => server.name);
if (!names.length) return;
busy = true; failures = [];
busy = true;
failures = [];
try {
const result = await queueFleetAction(action, names);
queued = [...queued, ...result.queued.map(item => item.server)];
selected = selected.filter(name => !queued.includes(name));
queued = [...queued, ...result.queued.map((item) => item.server)];
selected = selected.filter((name) => !queued.includes(name));
failures = result.failed;
confirmed = false;
if (result.queued.length) onqueued(result.queued.length, action);
if (!result.failed.length) open = false;
} finally { busy = false; }
} finally {
busy = false;
}
}
</script>
<Dialog bind:open title="Fleet actions" description="Choose an action, then select the servers to apply it to." {busy} wide>
<Dialog
bind:open
title="Fleet actions"
description="Choose an action, then select the servers to apply it to."
{busy}
wide
>
<div class="fields">
<label class="action">Action<select bind:value={action} disabled={busy} onchange={chooseAction}>
{#each fleetActions as value}<option value={value}>{actionLabels[value]}</option>{/each}
</select></label>
<label class="action"
>Action<select bind:value={action} disabled={busy} onchange={chooseAction}>
{#each fleetActions as value}<option {value}>{actionLabels[value]}</option>{/each}
</select></label
>
<p>{descriptions[action]}</p>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if loading}<p role="status">Loading the latest server status…</p>
{:else}
<div class="selection-tools">
<label class="check"><input type="checkbox" checked={allSelected} indeterminate={targets.length > 0 && !allSelected} disabled={busy || !!error || !eligible.length} onchange={event => selectAll(event.currentTarget.checked)} />Select all eligible</label>
<label class="check"
><input
type="checkbox"
checked={allSelected}
indeterminate={targets.length > 0 && !allSelected}
disabled={busy || !!error || !eligible.length}
onchange={(event) => selectAll(event.currentTarget.checked)}
/>Select all eligible</label
>
<span>{targets.length} selected · {eligible.length} eligible</span>
<Button size="sm" disabled={busy} onclick={() => refresh++}>Refresh servers</Button>
</div>
@@ -97,46 +156,165 @@
<fieldset disabled={busy || !!error} class="servers">
<legend>Servers</legend>
{#each servers as server (server.name)}
{@const available = eligible.some(item => item.name === server.name)}
{@const available = eligible.some((item) => item.name === server.name)}
{@const health = serverHealth(server, $uiNow)}
<label class="server" class:unavailable={!available}>
<input type="checkbox" checked={available && selected.includes(server.name)} disabled={!available} onchange={event => toggle(server.name, event.currentTarget.checked)} />
<span><strong>{serverTitle(server.name)}</strong><span class="metadata">{server.host?.name ?? 'Host unavailable'} · {health.label}</span>
{#if queued.includes(server.name)}<span class="metadata">Job queued</span>
{:else if !available}<span class="metadata">{actionLabels[action]} unavailable for the current server status.</span>
{:else}<span class="metadata">{lifecycleImpact(action, health.state === 'online' && freshServer(server, $uiNow) ? server.last_players : null, currentRound(server, server.telemetry, $uiNow))}</span>{/if}
<input
type="checkbox"
checked={available && selected.includes(server.name)}
disabled={!available}
onchange={(event) =>
toggle(server.name, event.currentTarget.checked)}
/>
<span
><strong>{serverTitle(server.name)}</strong><span class="metadata"
>{server.host?.name ?? 'Host unavailable'} · {health.label}</span
>
{#if queued.includes(server.name)}<span class="metadata"
>Job queued</span
>
{:else if !available}<span class="metadata"
>{actionLabels[action]} unavailable for the current server status.</span
>
{:else}<span class="metadata"
>{lifecycleImpact(
action,
health.state === 'online' && freshServer(server, $uiNow)
? server.last_players
: null,
currentRound(server, server.telemetry, $uiNow)
)}</span
>{/if}
</span>
</label>
{/each}
</fieldset>
{:else if !error}<p>No servers are available.</p>{/if}
{/if}
{#if action !== 'start'}<p>Each server waits for a live check confirming it is empty. Follow progress or cancel waiting jobs in Provisioning.</p>{/if}
{#if destructive}<p>Shared game libraries, recorded history, saved keys, and collections are retained.</p>{/if}
{#if destructive}<label class="check"><input type="checkbox" bind:checked={confirmed} disabled={busy || !targets.length} />I understand that the selected servers’ writable files and data will be permanently deleted.</label>{/if}
{#if failures.length}<div role="alert"><p class="ui-error">Some requests could not be queued:</p><ul>{#each failures as failure}<li><strong>{serverTitle(failure.server)}:</strong> {failure.error}</li>{/each}</ul></div>{/if}
{#if busy}<p role="status">Queuing {actionLabels[action].toLowerCase()} for the selected servers…</p>{/if}
{#if action !== 'start'}<p>
Each server waits for a live check confirming it is empty. Follow progress or cancel
waiting jobs in Provisioning.
</p>{/if}
{#if destructive}<p>
Shared game libraries, recorded history, saved keys, and collections are retained.
</p>{/if}
{#if destructive}<label class="check"
><input
type="checkbox"
bind:checked={confirmed}
disabled={busy || !targets.length}
/>I understand that the selected servers’ writable files and data will be
permanently deleted.</label
>{/if}
{#if failures.length}<div role="alert">
<p class="ui-error">Some requests could not be queued:</p>
<ul>
{#each failures as failure}<li>
<strong>{serverTitle(failure.server)}:</strong>
{failure.error}
</li>{/each}
</ul>
</div>{/if}
{#if busy}<p role="status">
Queuing {actionLabels[action].toLowerCase()} for the selected servers…
</p>{/if}
</div>
{#snippet actions()}
<Button disabled={busy} onclick={() => open = false}>Close</Button>
<Button variant={destructive ? 'danger' : 'primary'} disabled={busy || loading || !!error || !targets.length || (destructive && !confirmed)} loading={busy} onclick={submit}>{actionLabels[action]} {targets.length} server{targets.length === 1 ? '' : 's'}{action !== 'start' ? ' when empty' : ''}</Button>
<Button disabled={busy} onclick={() => (open = false)}>Close</Button>
<Button
variant={destructive ? 'danger' : 'primary'}
disabled={busy || loading || !!error || !targets.length || (destructive && !confirmed)}
loading={busy}
onclick={submit}
>{actionLabels[action]}
{targets.length} server{targets.length === 1 ? '' : 's'}{action !== 'start'
? ' when empty'
: ''}</Button
>
{/snippet}
</Dialog>
<style>
.fields, .action { display: grid; gap: var(--space-4); }
.action { gap: var(--space-2); }
select { width: 100%; min-height: 40px; padding: 10px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--text-primary); font: inherit; }
p, ul { margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }
.check, .selection-tools { display: flex; align-items: center; gap: var(--space-3); }
.check { line-height: 1.5; }
input[type='checkbox'] { flex: 0 0 auto; width: 18px; height: 18px; accent-color: var(--accent-primary); }
.selection-tools { flex-wrap: wrap; justify-content: space-between; font-size: var(--text-sm); }
.servers { min-width: 0; margin: 0; padding: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); max-height: 360px; overflow-y: auto; }
legend { margin-left: var(--space-3); padding: 0 var(--space-2); }
.server { display: flex; align-items: flex-start; gap: var(--space-3); padding: var(--space-3); cursor: pointer; }
.server + .server { border-top: 1px solid var(--border-subtle); }
.server > span { min-width: 0; overflow-wrap: anywhere; }
.metadata { display: block; margin-top: var(--space-1); color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.5; }
.unavailable { cursor: default; }
.fields,
.action {
display: grid;
gap: var(--space-4);
}
.action {
gap: var(--space-2);
}
select {
width: 100%;
min-height: 40px;
padding: 10px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text-primary);
font: inherit;
}
p,
ul {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.6;
}
.check,
.selection-tools {
display: flex;
align-items: center;
gap: var(--space-3);
}
.check {
line-height: 1.5;
}
input[type='checkbox'] {
flex: 0 0 auto;
width: 18px;
height: 18px;
accent-color: var(--accent-primary);
}
.selection-tools {
flex-wrap: wrap;
justify-content: space-between;
font-size: var(--text-sm);
}
.servers {
min-width: 0;
margin: 0;
padding: 0;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
max-height: 360px;
overflow-y: auto;
}
legend {
margin-left: var(--space-3);
padding: 0 var(--space-2);
}
.server {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3);
cursor: pointer;
}
.server + .server {
border-top: 1px solid var(--border-subtle);
}
.server > span {
min-width: 0;
overflow-wrap: anywhere;
}
.metadata {
display: block;
margin-top: var(--space-1);
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.5;
}
.unavailable {
cursor: default;
}
</style>
+58 -8
View File
@@ -1,20 +1,70 @@
<script lang="ts">
import { onMount } from 'svelte';
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 {
updatedAt = null,
error = '',
intervalLabel = '',
onRetry,
paused = false,
retryDisabled = false
}: {
updatedAt?: number | null;
error?: string;
intervalLabel?: string;
onRetry?: () => void;
paused?: boolean;
retryDisabled?: boolean;
} = $props();
let now = $state(Date.now());
onMount(() => { const timer = setInterval(() => now = Date.now(), 1000); return () => clearInterval(timer); });
onMount(() => {
const timer = setInterval(() => (now = Date.now()), 1000);
return () => clearInterval(timer);
});
// These are local receipt times, so their age uses the browser clock. The
// shared formatter still supplies the same language and exact tooltip.
const known = $derived(updatedAt !== null && Number.isFinite(updatedAt));
</script>
<div class="freshness" class:failed={!!error}>
{#if error}<span>{error}</span>{#if onRetry}<button type="button" disabled={retryDisabled} onclick={onRetry}>Retry</button>{/if}
{:else}<span>{paused ? 'Paused' : known ? 'Live' : 'Waiting for update'}{#if known}{' · updated '}<time datetime={new Date(updatedAt!).toISOString()} title={$timeFormat.dateTime(updatedAt!)}>{$timeFormat.relative(updatedAt!, now)}</time>{/if}{!paused && intervalLabel ? ` · ${intervalLabel}` : ''}</span>{/if}
{#if error}<span>{error}</span>{#if onRetry}<button
type="button"
disabled={retryDisabled}
onclick={onRetry}>Retry</button
>{/if}
{:else}<span
>{paused
? 'Paused'
: known
? 'Live'
: 'Waiting for update'}{#if known}{' · updated '}<time
datetime={new Date(updatedAt!).toISOString()}
title={$timeFormat.dateTime(updatedAt!)}
>{$timeFormat.relative(updatedAt!, now)}</time
>{/if}{!paused && intervalLabel ? ` · ${intervalLabel}` : ''}</span
>{/if}
</div>
<style>
.freshness { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
.failed { color: var(--state-warning); }
button { background: none; border: 0; padding: var(--space-1); color: var(--accent-primary-hover); font: inherit; text-decoration: underline; cursor: pointer; }
.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);
}
.failed {
color: var(--state-warning);
}
button {
background: none;
border: 0;
padding: var(--space-1);
color: var(--accent-primary-hover);
font: inherit;
text-decoration: underline;
cursor: pointer;
}
</style>
+119 -31
View File
@@ -3,49 +3,137 @@
import type { Host } from '$lib/hosts.js';
import GameFileTransferProgress from './GameFileTransferProgress.svelte';
import Badge from './primitives/Badge.svelte';
let { caches, hosts, latest = false }: { caches: HostLibraryCache[]; hosts: Host[]; latest?: boolean } = $props();
const healthy = $derived(caches.filter(cache => cache.status === 'ready' && hosts.some(host => host.id === cache.hostId && host.connected) && !cache.error));
const attention = $derived(caches.filter(cache => !healthy.includes(cache)));
let {
caches,
hosts,
latest = false
}: { caches: HostLibraryCache[]; hosts: Host[]; latest?: boolean } = $props();
const healthy = $derived(
caches.filter(
(cache) =>
cache.status === 'ready' &&
hosts.some((host) => host.id === cache.hostId && host.connected) &&
!cache.error
)
);
const attention = $derived(caches.filter((cache) => !healthy.includes(cache)));
function automaticDownloadDetail(host: Host | undefined): string {
if (host?.decommission) return 'Automatic setup is paused during host deletion.';
if (!host?.enrolled) return 'Waiting for host enrollment.';
if (host.mode !== 'active') return 'Automatic setup resumes when the host is active.';
if (!host.credentialActive || !host.connected) return 'Automatic setup resumes when the host reconnects.';
if (host.readiness === 'degraded') return host.readinessReasons[0] ?? 'The host needs attention before automatic setup can continue.';
if (!host.credentialActive || !host.connected)
return 'Automatic setup resumes when the host reconnects.';
if (host.readiness === 'degraded')
return (
host.readinessReasons[0] ??
'The host needs attention before automatic setup can continue.'
);
return 'Download starts automatically after runtime preparation and earlier work finish.';
}
</script>
{#if !hosts.length}<p class="muted"><a href="/hosts">Add a host</a> to prepare these files automatically.</p>{/if}
{#if !hosts.length}<p class="muted">
<a href="/hosts">Add a host</a> to prepare these files automatically.
</p>{/if}
{#if attention.length}{@render hostTable(attention)}{/if}
{#if healthy.length}<details><summary>Ready hosts ({healthy.length})</summary>{@render hostTable(healthy)}</details>{/if}
{#if healthy.length}<details>
<summary>Ready hosts ({healthy.length})</summary>{@render hostTable(healthy)}
</details>{/if}
{#snippet hostTable(rows: HostLibraryCache[])}
<div class="table-wrap"><table><thead><tr><th>Host</th><th>File status</th><th>Details</th></tr></thead><tbody>
{#each rows as cache (cache.hostId)}
{@const host = hosts.find(host => host.id === cache.hostId)}
{@const waiting = latest && cache.status === 'missing'}
<tr>
<td><a href={`/hosts/${cache.hostId}`}>{host?.name ?? cache.hostId}</a>{#if !host?.connected}<span class="muted">{' · Offline'}</span>{/if}</td>
<td><Badge variant={cache.status === 'failed' ? 'danger' : cache.status === 'ready' ? 'online' : 'neutral'}>{waiting ? 'Waiting' : cache.status[0].toUpperCase() + cache.status.slice(1)}</Badge></td>
<td>{#if cache.status === 'ready'}<details><summary>Transfer details</summary><GameFileTransferProgress {cache} connected={host?.connected ?? false} /></details>
{:else if cache.status !== 'missing'}<GameFileTransferProgress {cache} connected={host?.connected ?? false} />{/if}
{#if cache.error}<p class="error">{cache.error}</p>{:else if waiting}<p class="muted">{automaticDownloadDetail(host)}</p>{/if}
{#if waiting || cache.status === 'failed'}<a href={`/hosts/${cache.hostId}`}>View host setup</a>{/if}
</td>
</tr>
{/each}
</tbody></table></div>
<div class="table-wrap">
<table>
<thead><tr><th>Host</th><th>File status</th><th>Details</th></tr></thead><tbody>
{#each rows as cache (cache.hostId)}
{@const host = hosts.find((host) => host.id === cache.hostId)}
{@const waiting = latest && cache.status === 'missing'}
<tr>
<td
><a href={`/hosts/${cache.hostId}`}>{host?.name ?? cache.hostId}</a
>{#if !host?.connected}<span class="muted">{' · Offline'}</span
>{/if}</td
>
<td
><Badge
variant={cache.status === 'failed'
? 'danger'
: cache.status === 'ready'
? 'online'
: 'neutral'}
>{waiting
? 'Waiting'
: cache.status[0].toUpperCase() + cache.status.slice(1)}</Badge
></td
>
<td
>{#if cache.status === 'ready'}<details>
<summary>Transfer details</summary><GameFileTransferProgress
{cache}
connected={host?.connected ?? false}
/>
</details>
{:else if cache.status !== 'missing'}<GameFileTransferProgress
{cache}
connected={host?.connected ?? false}
/>{/if}
{#if cache.error}<p class="error">{cache.error}</p>{:else if waiting}<p
class="muted"
>
{automaticDownloadDetail(host)}
</p>{/if}
{#if waiting || cache.status === 'failed'}<a
href={`/hosts/${cache.hostId}`}>View host setup</a
>{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/snippet}
<style>
.table-wrap { overflow-x:auto; margin-top:10px; }
table { width:100%; min-width:520px; border-collapse:collapse; text-align:left; font-size:var(--text-sm); }
th,td { padding:10px; border-bottom:1px solid var(--border-subtle); }
th { color:var(--text-secondary); font-weight:500; font-size:var(--text-xs); }
summary { cursor:pointer; font-size:var(--text-sm); }
a { color:var(--accent-primary-hover); } .muted { color:var(--text-secondary); font-size:var(--text-sm); }
p { margin:6px 0; } .error { color:var(--state-danger); }
summary:focus-visible,a:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
.table-wrap {
overflow-x: auto;
margin-top: 10px;
}
table {
width: 100%;
min-width: 520px;
border-collapse: collapse;
text-align: left;
font-size: var(--text-sm);
}
th,
td {
padding: 10px;
border-bottom: 1px solid var(--border-subtle);
}
th {
color: var(--text-secondary);
font-weight: 500;
font-size: var(--text-xs);
}
summary {
cursor: pointer;
font-size: var(--text-sm);
}
a {
color: var(--accent-primary-hover);
}
.muted {
color: var(--text-secondary);
font-size: var(--text-sm);
}
p {
margin: 6px 0;
}
.error {
color: var(--state-danger);
}
summary:focus-visible,
a:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
</style>
@@ -14,7 +14,12 @@
onMount(() => {
const timer = setInterval(() => {
if (cache.transfer && connected && (cache.status === 'downloading' || cache.status === 'verifying')) now = Date.now();
if (
cache.transfer &&
connected &&
(cache.status === 'downloading' || cache.status === 'verifying')
)
now = Date.now();
}, 1000);
return () => clearInterval(timer);
});
@@ -22,31 +27,92 @@
<div class="transfer-progress">
<ol aria-label="Game file preparation steps">
<li class:active={downloading} class:complete={downloaded}>1. {downloaded ? 'Downloaded' : downloading ? 'Downloading' : 'Download'}</li>
<li class:active={verifying} class:complete={progress.complete}>2. {progress.complete ? 'Verified' : verifying ? 'Verifying' : 'Verify'}</li>
<li class:active={downloading} class:complete={downloaded}>
1. {downloaded ? 'Downloaded' : downloading ? 'Downloading' : 'Download'}
</li>
<li class:active={verifying} class:complete={progress.complete}>
2. {progress.complete ? 'Verified' : verifying ? 'Verifying' : 'Verify'}
</li>
</ol>
{#if progress.phase && !progress.complete}
<progress max={progress.total || 1} value={progress.total ? progress.phaseBytes ?? undefined : undefined} aria-label={progress.phase === 'verifying' ? 'Host verification progress' : 'Host download progress'}></progress>
<span class="bytes">{#if progress.phaseBytes !== null}{formatGameBytes(progress.phaseBytes)} / {formatGameBytes(progress.total)} {progress.phase === 'verifying' ? 'checked' : 'downloaded'}{:else}Checking every file…{/if}</span>
<progress
max={progress.total || 1}
value={progress.total ? (progress.phaseBytes ?? undefined) : undefined}
aria-label={progress.phase === 'verifying'
? 'Host verification progress'
: 'Host download progress'}
></progress>
<span class="bytes"
>{#if progress.phaseBytes !== null}{formatGameBytes(progress.phaseBytes)} / {formatGameBytes(
progress.total
)}
{progress.phase === 'verifying' ? 'checked' : 'downloaded'}{:else}Checking every
file…{/if}</span
>
{/if}
{#if progress.elapsedSeconds !== null}
<div class="timing" aria-live="off">
<span>Elapsed {formatDuration(progress.elapsedSeconds * 1000)}</span>
{#if progress.bytesPerSecond !== null}<span>{formatGameBytes(progress.bytesPerSecond)}/s</span>{/if}
{#if progress.etaSeconds !== null}<span>About {formatDuration(Math.ceil(progress.etaSeconds) * 1000)} {progress.phase === 'verifying' ? 'verification' : 'download'} remaining</span>
{:else if progress.live && progress.phaseBytes !== progress.total}<span>Estimating {progress.phase === 'verifying' ? 'verification' : 'download'} time remaining…</span>{/if}
{#if progress.bytesPerSecond !== null}<span
>{formatGameBytes(progress.bytesPerSecond)}/s</span
>{/if}
{#if progress.etaSeconds !== null}<span
>About {formatDuration(Math.ceil(progress.etaSeconds) * 1000)}
{progress.phase === 'verifying' ? 'verification' : 'download'} remaining</span
>
{:else if progress.live && progress.phaseBytes !== progress.total}<span
>Estimating {progress.phase === 'verifying' ? 'verification' : 'download'} time remaining…</span
>{/if}
</div>
{/if}
{#if progress.active && !connected}<span class="muted">Host offline · waiting to resume updates</span>
{#if progress.active && !connected}<span class="muted"
>Host offline · waiting to resume updates</span
>
{:else if progress.stale}<span class="muted">Waiting for a fresh host report</span>
{:else if downloading}<span class="muted">All files download before verification starts.</span>{/if}
{:else if downloading}<span class="muted">All files download before verification starts.</span
>{/if}
</div>
<style>
.transfer-progress { display:grid; gap:6px; min-width:200px; }
ol { display:flex; flex-wrap:wrap; gap:6px 16px; list-style:none; margin:0; padding:0; font-size:var(--text-xs); color:var(--text-secondary); }
li.active { color:var(--text-primary); font-weight:600; } li.complete { color:var(--accent-primary-hover); }
progress { display:block; width:100%; margin:0; accent-color:var(--accent-primary); }
.bytes,.timing,.muted { font-size:var(--text-xs); color:var(--text-secondary); font-variant-numeric:tabular-nums; line-height:1.5; }
.timing { display:flex; flex-wrap:wrap; gap:2px 12px; }
.transfer-progress {
display: grid;
gap: 6px;
min-width: 200px;
}
ol {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
list-style: none;
margin: 0;
padding: 0;
font-size: var(--text-xs);
color: var(--text-secondary);
}
li.active {
color: var(--text-primary);
font-weight: 600;
}
li.complete {
color: var(--accent-primary-hover);
}
progress {
display: block;
width: 100%;
margin: 0;
accent-color: var(--accent-primary);
}
.bytes,
.timing,
.muted {
font-size: var(--text-xs);
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
line-height: 1.5;
}
.timing {
display: flex;
flex-wrap: wrap;
gap: 2px 12px;
}
</style>
@@ -2,7 +2,8 @@
import ServerIdentity from './ServerIdentity.svelte';
import Badge from './primitives/Badge.svelte';
let { name, deletedAt, href }: { name: string; deletedAt?: string | null; href?: string } = $props();
let { name, deletedAt, href }: { name: string; deletedAt?: string | null; href?: string } =
$props();
</script>
<span class="historical-server">
@@ -12,6 +13,14 @@
</span>
<style>
.historical-server { display: inline-flex; align-items: baseline; flex-wrap: wrap; gap: 6px 8px; min-width: 0; }
a { color: var(--accent-primary-hover); }
.historical-server {
display: inline-flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px 8px;
min-width: 0;
}
a {
color: var(--accent-primary-hover);
}
</style>
+154 -32
View File
@@ -1,42 +1,164 @@
<script lang="ts">
import type { Host } from '$lib/hosts.js';
import { contextHref } from '$lib/navigation-context.js';
import { hostListStatus, hostReadingsStale, hostCpuUsage, hostUsedSpace } from '$ui/host-overview.js';
import {
hostListStatus,
hostReadingsStale,
hostCpuUsage,
hostUsedSpace
} from '$ui/host-overview.js';
import { plural } from '$ui/plural.js';
import Badge from './primitives/Badge.svelte';
let { hosts, now, returnTo = '/hosts' }: { hosts: (Host & { serverCount: number; playerCount: number; unreportedInstances: number })[]; now: number; returnTo?: string } = $props();
let {
hosts,
now,
returnTo = '/hosts'
}: {
hosts: (Host & { serverCount: number; playerCount: number; unreportedInstances: number })[];
now: number;
returnTo?: string;
} = $props();
</script>
<div class="table-wrap"><table>
<thead><tr><th>Host</th><th>Status</th><th title="Assigned servers, including stopped servers">Servers</th><th title="Players reported by online servers">Players</th><th>CPU</th><th>Memory <span>used / total</span></th><th>Disk <span>used / total</span></th></tr></thead>
<tbody>{#each hosts as host (host.id)}
{@const status = hostListStatus(host, now)}
{@const stale = hostReadingsStale(host, now)}
<tr>
<td><a class="name" href={contextHref(`/hosts/${host.id}`, returnTo)}>{host.name}</a>{#if host.hostname && host.hostname !== host.name}<span class="secondary">{host.hostname}</span>{/if}
{#if stale && host.lastHeartbeat}<span class="secondary">Resource readings stale</span>{/if}</td>
<td class="status"><Badge variant={status.tone}>{status.label}</Badge>{#if status.detail}<a class="reason" href={contextHref(`/hosts/${host.id}?tab=${status.tab}`, returnTo)}>{status.detail}</a>{/if}</td>
<td><a href={contextHref(`/hosts/${host.id}?tab=servers`, returnTo)} aria-label={`${host.serverCount} assigned ${plural(host.serverCount, 'server')} on ${host.name}`}>{host.serverCount}</a></td>
<td title={host.unreportedInstances ? `Player counts unavailable for ${host.unreportedInstances} of ${host.serverCount} ${plural(host.serverCount, 'server')}` : 'Players reported by online servers'}>{host.unreportedInstances === host.serverCount && host.serverCount > 0 ? '—' : `${host.playerCount}${host.unreportedInstances ? '+' : ''}`}</td>
<td class:stale>{hostCpuUsage(host.inventory.cpuUsagePercent)}</td>
<td class:stale>{hostUsedSpace(host.inventory.memoryTotal, host.inventory.memoryAvailable)}</td>
<td class:stale>{hostUsedSpace(host.inventory.storageTotal, host.inventory.storageAvailable)}</td>
</tr>
{/each}</tbody>
</table></div>
<div class="table-wrap">
<table>
<thead
><tr
><th>Host</th><th>Status</th><th title="Assigned servers, including stopped servers"
>Servers</th
><th title="Players reported by online servers">Players</th><th>CPU</th><th
>Memory <span>used / total</span></th
><th>Disk <span>used / total</span></th></tr
></thead
>
<tbody
>{#each hosts as host (host.id)}
{@const status = hostListStatus(host, now)}
{@const stale = hostReadingsStale(host, now)}
<tr>
<td
><a class="name" href={contextHref(`/hosts/${host.id}`, returnTo)}
>{host.name}</a
>{#if host.hostname && host.hostname !== host.name}<span class="secondary"
>{host.hostname}</span
>{/if}
{#if stale && host.lastHeartbeat}<span class="secondary"
>Resource readings stale</span
>{/if}</td
>
<td class="status"
><Badge variant={status.tone}>{status.label}</Badge>{#if status.detail}<a
class="reason"
href={contextHref(`/hosts/${host.id}?tab=${status.tab}`, returnTo)}
>{status.detail}</a
>{/if}</td
>
<td
><a
href={contextHref(`/hosts/${host.id}?tab=servers`, returnTo)}
aria-label={`${host.serverCount} assigned ${plural(host.serverCount, 'server')} on ${host.name}`}
>{host.serverCount}</a
></td
>
<td
title={host.unreportedInstances
? `Player counts unavailable for ${host.unreportedInstances} of ${host.serverCount} ${plural(host.serverCount, 'server')}`
: 'Players reported by online servers'}
>{host.unreportedInstances === host.serverCount && host.serverCount > 0
? '—'
: `${host.playerCount}${host.unreportedInstances ? '+' : ''}`}</td
>
<td class:stale>{hostCpuUsage(host.inventory.cpuUsagePercent)}</td>
<td class:stale
>{hostUsedSpace(
host.inventory.memoryTotal,
host.inventory.memoryAvailable
)}</td
>
<td class:stale
>{hostUsedSpace(
host.inventory.storageTotal,
host.inventory.storageAvailable
)}</td
>
</tr>
{/each}</tbody
>
</table>
</div>
<style>
.table-wrap { overflow-x:auto; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
table { width:100%; min-width:850px; border-collapse:collapse; text-align:left; font-size:var(--text-sm); }
th { padding:12px 16px; color:var(--text-secondary); background:var(--surface-sunken); font-weight:500; font-size:var(--text-xs); }
th span { display:block; font-weight:400; margin-top:3px; }
td { padding:16px; background:var(--surface); border-top:1px solid var(--border-subtle); font-variant-numeric:tabular-nums; }
tr:hover td { background:var(--surface-raised); }
a { color:var(--accent-primary-hover); } .name { color:var(--text-primary); font-weight:600; text-decoration:none; overflow-wrap:anywhere; }
.name:hover { color:var(--accent-primary-hover); text-decoration:underline; }
.status { max-width:330px; min-width:140px; }
.secondary,.reason { display:block; font-size:var(--text-xs); color:var(--text-secondary); margin-top:6px; line-height:1.5; }
.reason { overflow-wrap:anywhere; text-decoration:none; } .reason:hover { text-decoration:underline; color:var(--accent-primary-hover); }
.stale { color:var(--text-secondary); }
a:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
.table-wrap {
overflow-x: auto;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
table {
width: 100%;
min-width: 850px;
border-collapse: collapse;
text-align: left;
font-size: var(--text-sm);
}
th {
padding: 12px 16px;
color: var(--text-secondary);
background: var(--surface-sunken);
font-weight: 500;
font-size: var(--text-xs);
}
th span {
display: block;
font-weight: 400;
margin-top: 3px;
}
td {
padding: 16px;
background: var(--surface);
border-top: 1px solid var(--border-subtle);
font-variant-numeric: tabular-nums;
}
tr:hover td {
background: var(--surface-raised);
}
a {
color: var(--accent-primary-hover);
}
.name {
color: var(--text-primary);
font-weight: 600;
text-decoration: none;
overflow-wrap: anywhere;
}
.name:hover {
color: var(--accent-primary-hover);
text-decoration: underline;
}
.status {
max-width: 330px;
min-width: 140px;
}
.secondary,
.reason {
display: block;
font-size: var(--text-xs);
color: var(--text-secondary);
margin-top: 6px;
line-height: 1.5;
}
.reason {
overflow-wrap: anywhere;
text-decoration: none;
}
.reason:hover {
text-decoration: underline;
color: var(--accent-primary-hover);
}
.stale {
color: var(--text-secondary);
}
a:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
</style>
+98 -11
View File
@@ -6,20 +6,53 @@
</script>
<div class="preparation" aria-live="polite">
<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>
The default runtime and the latest available Black Ops I and Black Ops II game files are
prepared automatically. Re-enabling a host requires a fresh runtime pull and verification of
both game libraries before it accepts deployments. Running servers keep their current files.
</p>
<p>
Supported hosts also cache Plutonium files to speed up new deployments. This optional cache
refreshes when a new production release is detected; deployments can proceed while it is
unavailable.
</p>
{#if host.preparation}
<p class="status" class:blocked={host.preparation.state === 'blocked'}>Automatic setup: {host.preparation.state === 'ready' ? 'Ready' : host.preparation.state === 'preparing' ? 'In progress' : host.preparation.state === 'blocked' ? 'Needs attention' : 'Waiting'}</p>
<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>
<ul>
{#each host.preparation.items as item}
<li>
<div class="row"><strong>{item.label}</strong><span>{item.state === 'blocked' ? 'Needs attention' : item.state}</span></div>
<div class="row">
<strong>{item.label}</strong><span
>{item.state === 'blocked' ? 'Needs attention' : item.state}</span
>
</div>
<p class:blocked={item.state === 'blocked'}>{item.detail}</p>
{#if item.cache && item.cache.status !== 'missing'}
<div class="transfer"><GameFileTransferProgress cache={item.cache} connected={host.connected} /></div>
<div class="transfer">
<GameFileTransferProgress
cache={item.cache}
connected={host.connected}
/>
</div>
{:else if item.bytesTotal && item.bytesDownloaded !== undefined && item.state === 'preparing'}
<progress max={item.bytesTotal} value={item.bytesDownloaded} aria-label={`${item.label} download progress`}></progress>
<small>{formatGameBytes(item.bytesDownloaded)} / {formatGameBytes(item.bytesTotal)}</small>
<progress
max={item.bytesTotal}
value={item.bytesDownloaded}
aria-label={`${item.label} download progress`}
></progress>
<small
>{formatGameBytes(item.bytesDownloaded)} / {formatGameBytes(
item.bytesTotal
)}</small
>
{/if}
</li>
{/each}
@@ -29,8 +62,62 @@
</div>
<style>
.preparation { display:grid; gap:12px; } p { margin:0; color:var(--text-secondary); font-size:var(--text-sm); line-height:1.6; } .status { color:var(--text-primary); }
ul { list-style:none; padding:0; margin:0; display:grid; gap:16px; } li { min-width:0; } .row { display:flex; justify-content:space-between; gap:12px; font-size:var(--text-sm); } strong { overflow-wrap:anywhere; } span { text-transform:capitalize; color:var(--text-secondary); }
li p { margin-top:4px; } .blocked { color:var(--state-danger); } progress { width:100%; margin-top:8px; accent-color:var(--accent-primary); } small { display:block; color:var(--text-secondary); font-size:var(--text-xs); } a { color:var(--accent-primary-hover); }
.transfer { margin-top:8px; }
.preparation {
display: grid;
gap: 12px;
}
p {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.6;
}
.status {
color: var(--text-primary);
}
ul {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 16px;
}
li {
min-width: 0;
}
.row {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: var(--text-sm);
}
strong {
overflow-wrap: anywhere;
}
span {
text-transform: capitalize;
color: var(--text-secondary);
}
li p {
margin-top: 4px;
}
.blocked {
color: var(--state-danger);
}
progress {
width: 100%;
margin-top: 8px;
accent-color: var(--accent-primary);
}
small {
display: block;
color: var(--text-secondary);
font-size: var(--text-xs);
}
a {
color: var(--accent-primary-hover);
}
.transfer {
margin-top: 8px;
}
</style>
+142 -20
View File
@@ -1,34 +1,156 @@
<script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import type { LaunchRecipeSummary } from '$lib/deployment-plan.js';
let { recipe, historical = false }: { recipe: LaunchRecipeSummary; historical?: boolean } = $props();
let { recipe, historical = false }: { recipe: LaunchRecipeSummary; historical?: boolean } =
$props();
</script>
<div class="recipe">
<div class="recipe-heading"><div>{#if !historical}<span class="eyebrow">Current promoted setup</span>{/if}<h3>{recipe.mapName}</h3></div><span class="game">{recipe.game === 't6' ? 'Black Ops II' : 'Black Ops I'}</span></div>
<div class="recipe-heading">
<div>
{#if !historical}<span class="eyebrow">Current promoted setup</span>{/if}
<h3>{recipe.mapName}</h3>
</div>
<span class="game">{recipe.game === 't6' ? 'Black Ops II' : 'Black Ops I'}</span>
</div>
<dl>
<div><dt>Configuration</dt><dd>{recipe.configSource}</dd></div>
<div><dt>Container image</dt><dd class="mono">{recipe.image}</dd></div>
<div>
<dt>Configuration</dt>
<dd>{recipe.configSource}</dd>
</div>
<div>
<dt>Container image</dt>
<dd class="mono">{recipe.image}</dd>
</div>
</dl>
{#if !historical}<p>Each server uses the latest promoted scripts, plugins, and configuration when provisioning begins, including promotions made after this preview or while queued.</p>{/if}
{#if !historical}<p>
Each server uses the latest promoted scripts, plugins, and configuration when
provisioning begins, including promotions made after this preview or while queued.
</p>{/if}
<details>
<summary>{recipe.files.length} managed file{recipe.files.length === 1 ? '' : 's'} {historical ? 'recorded for this deployment' : 'in the current setup'}</summary>
<ul>{#each recipe.files as file}<li><div><strong>{file.name}</strong><span>{file.version !== null ? `v${file.version}` : 'Generated'} · {file.kind} · {file.size.toLocaleString()} bytes</span></div><code>{file.path}</code><small title={file.sha256}>SHA-256 {file.sha256.slice(0, 12)}</small></li>{/each}</ul>
{#if !historical}<p>Previewed <RelativeTime value={recipe.capturedAt} />. This preview shows the promoted setup at the time above. The deployment history records the files selected when provisioning begins.</p>{/if}
<small class="fingerprint" title={recipe.fingerprint}>Recipe {recipe.fingerprint.slice(0, 16)}</small>
<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>
{#if !historical}<p>
Previewed <RelativeTime value={recipe.capturedAt} />. This preview shows the
promoted setup at the time above. The deployment history records the files selected
when provisioning begins.
</p>{/if}
<small class="fingerprint" title={recipe.fingerprint}
>Recipe {recipe.fingerprint.slice(0, 16)}</small
>
</details>
</div>
<style>
.recipe { display: grid; gap: 16px; min-width: 0; }
.recipe-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.eyebrow { display: block; font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .12em; color: var(--text-secondary); margin-bottom: 6px; }
h3 { margin: 0; font-size: var(--text-xl); letter-spacing: -.03em; }
.game { border: 1px solid var(--border-strong); border-radius: 99px; padding: 5px 10px; font-size: var(--text-xs); white-space: nowrap; }
dl { display: grid; gap: 12px; margin: 0; } dt { color: var(--text-secondary); font-size: var(--text-xs); } dd { margin: 4px 0 0; font-size: var(--text-sm); overflow-wrap: anywhere; line-height: 1.5; }
.mono, code, .fingerprint { font-family: var(--font-mono); }
details { border-top: 1px solid var(--border-subtle); padding-top: 14px; } summary { cursor: pointer; font-size: var(--text-sm); }
ul { list-style: none; padding: 0; margin: 12px 0; } li { display: grid; gap: 5px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
li > div { display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; } strong { font-size: var(--text-sm); font-weight: 550; } span, code, small { font-size: var(--text-xs); } li span, small { color: var(--text-secondary); }
p { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; }
.recipe {
display: grid;
gap: 16px;
min-width: 0;
}
.recipe-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.eyebrow {
display: block;
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-secondary);
margin-bottom: 6px;
}
h3 {
margin: 0;
font-size: var(--text-xl);
letter-spacing: -0.03em;
}
.game {
border: 1px solid var(--border-strong);
border-radius: 99px;
padding: 5px 10px;
font-size: var(--text-xs);
white-space: nowrap;
}
dl {
display: grid;
gap: 12px;
margin: 0;
}
dt {
color: var(--text-secondary);
font-size: var(--text-xs);
}
dd {
margin: 4px 0 0;
font-size: var(--text-sm);
overflow-wrap: anywhere;
line-height: 1.5;
}
.mono,
code,
.fingerprint {
font-family: var(--font-mono);
}
details {
border-top: 1px solid var(--border-subtle);
padding-top: 14px;
}
summary {
cursor: pointer;
font-size: var(--text-sm);
}
ul {
list-style: none;
padding: 0;
margin: 12px 0;
}
li {
display: grid;
gap: 5px;
padding: 12px 0;
border-bottom: 1px solid var(--border-subtle);
overflow-wrap: anywhere;
}
li > div {
display: flex;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
strong {
font-size: var(--text-sm);
font-weight: 550;
}
span,
code,
small {
font-size: var(--text-xs);
}
li span,
small {
color: var(--text-secondary);
}
p {
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.6;
}
</style>
+59 -10
View File
@@ -1,23 +1,72 @@
<script lang="ts">
import Badge from './primitives/Badge.svelte';
import { artifactVersionLabel } from '$lib/artifact-version.js';
interface Version { id: number; version: number; release_version?: string | null; state?: string }
let { current, latest, betaId = null }: { current: Version | null; latest: Version | null; betaId?: number | null } = $props();
interface Version {
id: number;
version: number;
release_version?: string | null;
state?: string;
}
let {
current,
latest,
betaId = null
}: { current: Version | null; latest: Version | null; betaId?: number | null } = $props();
const newer = $derived(latest && latest.id !== current?.id ? latest : null);
const ambiguous = $derived(!!newer?.release_version && newer.release_version === current?.release_version);
function label(version: Version) { return ambiguous ? artifactVersionLabel(version) : version.release_version || `v${version.version}`; }
const state = $derived(betaId !== null ? 'Beta active' : newer?.state === 'test-failed' ? 'Test failed' : newer?.state === 'tested' ? 'Test passed' : 'Needs testing');
const ambiguous = $derived(
!!newer?.release_version && newer.release_version === current?.release_version
);
function label(version: Version) {
return ambiguous
? artifactVersionLabel(version)
: version.release_version || `v${version.version}`;
}
const state = $derived(
betaId !== null
? 'Beta active'
: newer?.state === 'test-failed'
? 'Test failed'
: newer?.state === 'tested'
? 'Test passed'
: 'Needs testing'
);
</script>
<div class="versions">
{#if newer}<div><span>{label(newer)}</span><Badge size="sm" variant={state === 'Test failed' ? 'danger' : state === 'Test passed' ? 'online' : 'neutral'}>{state}</Badge></div>{/if}
{#if current}<div class:secondary={!!newer}><span>{label(current)}</span>{#if newer}<span>Promoted</span>{:else}<Badge size="sm" variant="online">Promoted</Badge>{/if}</div>
{#if newer}<div>
<span>{label(newer)}</span><Badge
size="sm"
variant={state === 'Test failed'
? 'danger'
: state === 'Test passed'
? 'online'
: 'neutral'}>{state}</Badge
>
</div>{/if}
{#if current}<div class:secondary={!!newer}>
<span>{label(current)}</span>{#if newer}<span>Promoted</span>{:else}<Badge
size="sm"
variant="online">Promoted</Badge
>{/if}
</div>
{:else}<span class="secondary">{latest ? 'Never promoted' : 'No versions'}</span>{/if}
{#if betaId !== null && !newer}<Badge size="sm" variant="neutral">Beta active</Badge>{/if}
</div>
<style>
.versions { display:grid; gap:5px; font-size:var(--text-sm); }
.versions>div { display:flex; align-items:center; flex-wrap:wrap; gap:8px; }
.secondary { color:var(--text-secondary); font-size:var(--text-xs); }
.versions {
display: grid;
gap: 5px;
font-size: var(--text-sm);
}
.versions > div {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.secondary {
color: var(--text-secondary);
font-size: var(--text-xs);
}
</style>
+172 -44
View File
@@ -10,8 +10,21 @@
import Dialog from './primitives/Dialog.svelte';
import Button from './primitives/Button.svelte';
let { open = $bindable(false), initialServer = '', initialAction = 'start', serverContext = null, players = null, round = null }:
{ open?: boolean; initialServer?: string; initialAction?: RuntimeAction; serverContext?: FleetServer | null; players?: number | null; round?: number | null } = $props();
let {
open = $bindable(false),
initialServer = '',
initialAction = 'start',
serverContext = null,
players = null,
round = null
}: {
open?: boolean;
initialServer?: string;
initialAction?: RuntimeAction;
serverContext?: FleetServer | null;
players?: number | null;
round?: number | null;
} = $props();
let servers = $state<FleetServer[]>([]);
let server = $state(untrack(() => initialServer));
let action = $state<RuntimeAction | 'destroy'>(untrack(() => initialAction));
@@ -19,15 +32,35 @@
let loading = $state(false);
let error = $state('');
let confirmed = $state(false);
const selected = $derived(serverContext?.name === server ? serverContext : servers.find(item => item.name === server));
const selected = $derived(
serverContext?.name === server
? serverContext
: servers.find((item) => item.name === server)
);
const available = $derived(selected ? applicableRuntimeActions(selected, $uiNow) : []);
const applicable = $derived(action === 'destroy' || available.includes(action));
const destructive = $derived(action === 'rebuild' || action === 'destroy');
const health = $derived(selected ? serverHealth(selected, $uiNow) : null);
const impactPlayers = $derived(initialServer ? players : selected && health?.state === 'online' && freshServer(selected, $uiNow) ? selected.last_players : null);
const impactRound = $derived(initialServer ? round : selected ? currentRound(selected, selected.telemetry, $uiNow) : null);
const title = $derived(initialServer ? `${actionLabels[action]} ${serverTitle(initialServer)}?` : 'Manage server');
const submitLabel = $derived(action === 'start' ? 'Start server' : action === 'destroy' ? 'Destroy server when empty' : `${actionLabels[action]} when empty`);
const impactPlayers = $derived(
initialServer
? players
: selected && health?.state === 'online' && freshServer(selected, $uiNow)
? selected.last_players
: null
);
const impactRound = $derived(
initialServer ? round : selected ? currentRound(selected, selected.telemetry, $uiNow) : null
);
const title = $derived(
initialServer ? `${actionLabels[action]} ${serverTitle(initialServer)}?` : 'Manage server'
);
const submitLabel = $derived(
action === 'start'
? 'Start server'
: action === 'destroy'
? 'Destroy server when empty'
: `${actionLabels[action]} when empty`
);
$effect(() => {
if (!open) return;
const fixedServer = initialServer;
@@ -36,86 +69,181 @@
// Live context and match reports update the copy and eligibility without
// reopening the dialog or clearing a deliberate confirmation.
untrack(() => {
server = fixedServer; action = requestedAction; confirmed = false; error = ''; servers = [];
server = fixedServer;
action = requestedAction;
confirmed = false;
error = '';
servers = [];
loading = false;
if (!fixedServer || serverContext?.name !== fixedServer) void load(fixedServer, controller.signal);
if (!fixedServer || serverContext?.name !== fixedServer)
void load(fixedServer, controller.signal);
});
return () => controller.abort();
});
async function load(fixedServer: string, signal: AbortSignal) {
loading = true;
try {
const response = await fetch(fixedServer ? `/api/servers/${encodeURIComponent(fixedServer)}` : '/api/servers', { signal });
const response = await fetch(
fixedServer ? `/api/servers/${encodeURIComponent(fixedServer)}` : '/api/servers',
{ signal }
);
if (!response.ok) throw new Error('Could not load servers.');
const body = await response.json();
if (signal.aborted) return;
servers = body.servers;
syncServerTime(body.serverNow);
} catch (err) { if (!signal.aborted) error = (err as Error).message; }
finally { if (!signal.aborted) loading = false; }
} catch (err) {
if (!signal.aborted) error = (err as Error).message;
} finally {
if (!signal.aborted) loading = false;
}
}
function chooseServer(event: Event & { currentTarget: EventTarget & HTMLSelectElement }) {
server = event.currentTarget.value;
confirmed = false; error = '';
const item = servers.find(item => item.name === server);
confirmed = false;
error = '';
const item = servers.find((item) => item.name === server);
const actions = item ? applicableRuntimeActions(item, serverTimeNow()) : [];
action = actions[0] ?? 'destroy';
}
async function submit() {
if (busy || !server || (destructive && !confirmed)) return;
if (action !== 'destroy' && (!selected || !applicableRuntimeActions(selected, serverTimeNow()).includes(action))) {
error = 'The server status has changed. Close this dialog and choose an action from its current status.';
if (
action !== 'destroy' &&
(!selected || !applicableRuntimeActions(selected, serverTimeNow()).includes(action))
) {
error =
'The server status has changed. Close this dialog and choose an action from its current status.';
return;
}
busy = true; error = '';
busy = true;
error = '';
try {
const response = action === 'destroy'
? await fetch(`/api/servers/${encodeURIComponent(server)}`, { method: 'DELETE' })
: await fetch('/api/provisioning', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server, action }) });
const response =
action === 'destroy'
? await fetch(`/api/servers/${encodeURIComponent(server)}`, {
method: 'DELETE'
})
: await fetch('/api/provisioning', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server, action })
});
const body = await response.json();
if (!response.ok || !body.deploymentId) throw new Error(body.error ?? 'Could not queue this operation.');
if (!response.ok || !body.deploymentId)
throw new Error(body.error ?? 'Could not queue this operation.');
await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`);
open = false;
} catch (err) { error = (err as Error).message; }
finally { busy = false; }
} catch (err) {
error = (err as Error).message;
} finally {
busy = false;
}
}
</script>
<Dialog bind:open {title} {busy}>
<div class="fields">
{#if !initialServer}
<label>Server <select bind:value={server} disabled={busy || loading} onchange={chooseServer}>
<option value="">{loading ? 'Loading servers…' : 'Choose a server'}</option>
{#each servers as item (item.name)}<option value={item.name}>{serverTitle(item.name)}</option>{/each}
</select></label>
<label>Action <select bind:value={action} disabled={busy || !server || loading} onchange={() => { confirmed = false; error = ''; }}>
{#if !server}<option value="start">Choose a server first</option>{/if}
{#if server && !applicable}<option value={action} disabled>{actionLabels[action]} unavailable</option>{/if}
{#each available as value}<option value={value}>{actionLabels[value]}</option>{/each}
{#if server}<option value="destroy">Destroy server</option>{/if}
</select></label>
<label
>Server <select
bind:value={server}
disabled={busy || loading}
onchange={chooseServer}
>
<option value="">{loading ? 'Loading servers…' : 'Choose a server'}</option>
{#each servers as item (item.name)}<option value={item.name}
>{serverTitle(item.name)}</option
>{/each}
</select></label
>
<label
>Action <select
bind:value={action}
disabled={busy || !server || loading}
onchange={() => {
confirmed = false;
error = '';
}}
>
{#if !server}<option value="start">Choose a server first</option>{/if}
{#if server && !applicable}<option value={action} disabled
>{actionLabels[action]} unavailable</option
>{/if}
{#each available as value}<option {value}>{actionLabels[value]}</option>{/each}
{#if server}<option value="destroy">Destroy server</option>{/if}
</select></label
>
{/if}
{#if loading}<p role="status">Checking the latest server status…</p>
{:else if server}
{#if applicable}
<p class="impact">{lifecycleImpact(action, impactPlayers, impactRound)}</p>
<p>{action === 'destroy' ? 'Remove the container, its writable server files, and registration after the server is empty. Shared game libraries, recorded history, and the key saved separately in Keys are retained.' : runtimeDescriptions[action]}</p>
{#if action !== 'start'}<p class="help">Follow progress or cancel the waiting request on the provisioning page.</p>{/if}
{:else}<p role="status">{actionLabels[action]} is unavailable. {health?.reason ?? 'The current server status could not be verified.'} Close this dialog and choose an action from its current status.</p>{/if}
{#if applicable && destructive}<label class="confirmation"><input type="checkbox" bind:checked={confirmed} disabled={busy} /> I understand that this instance’s writable files and data will be permanently deleted.</label>{/if}
<p>
{action === 'destroy'
? 'Remove the container, its writable server files, and registration after the server is empty. Shared game libraries, recorded history, and the key saved separately in Keys are retained.'
: runtimeDescriptions[action]}
</p>
{#if action !== 'start'}<p class="help">
Follow progress or cancel the waiting request on the provisioning page.
</p>{/if}
{:else}<p role="status">
{actionLabels[action]} is unavailable. {health?.reason ??
'The current server status could not be verified.'} Close this dialog and choose
an action from its current status.
</p>{/if}
{#if applicable && destructive}<label class="confirmation"
><input type="checkbox" bind:checked={confirmed} disabled={busy} /> I understand that
this instance’s writable files and data will be permanently deleted.</label
>{/if}
{/if}
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
</div>
{#snippet actions()}
<Button disabled={busy} onclick={() => open = false}>Cancel</Button>
<Button variant={destructive ? 'danger' : 'primary'} disabled={busy || loading || !server || !applicable || (destructive && !confirmed)} loading={busy} onclick={submit}>{submitLabel}</Button>
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button>
<Button
variant={destructive ? 'danger' : 'primary'}
disabled={busy || loading || !server || !applicable || (destructive && !confirmed)}
loading={busy}
onclick={submit}>{submitLabel}</Button
>
{/snippet}
</Dialog>
<style>
.fields { display: grid; gap: 16px; } label { display: grid; gap: 8px; }
select { padding: 10px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--text-primary); }
p { margin: 0; line-height: 1.6; color: var(--text-secondary); } .confirmation { display: flex; align-items: flex-start; gap: 12px; line-height: 1.5; }
.impact { padding: var(--space-4); border-radius: var(--radius-md); background: var(--surface-raised); color: var(--text-primary); }
.help { font-size: var(--text-sm); }
.fields {
display: grid;
gap: 16px;
}
label {
display: grid;
gap: 8px;
}
select {
padding: 10px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text-primary);
}
p {
margin: 0;
line-height: 1.6;
color: var(--text-secondary);
}
.confirmation {
display: flex;
align-items: flex-start;
gap: 12px;
line-height: 1.5;
}
.impact {
padding: var(--space-4);
border-radius: var(--radius-md);
background: var(--surface-raised);
color: var(--text-primary);
}
.help {
font-size: var(--text-sm);
}
</style>
+233 -63
View File
@@ -11,8 +11,8 @@
const uid = $props.id();
interface TabDef {
key: string; // matches the log key in KNOWN_LOGS
label: string; // human-facing tab name
key: string; // matches the log key in KNOWN_LOGS
label: string; // human-facing tab name
}
interface Props {
@@ -23,16 +23,21 @@
}
let { server, tabs, lines = 1000, pollMs = 5000 }: Props = $props();
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 checkedAt = $state<Record<string, number>>({});
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);
void goto(url, { noScroll: true, keepFocus: true, replaceState: true });
}
let query = $state('');
onMount(() => { query = $page.url.searchParams.get('logQuery') ?? ''; });
onMount(() => {
query = $page.url.searchParams.get('logQuery') ?? '';
});
let loading = $state(false);
let autoRefresh = $state(true);
let refreshError = $state('');
@@ -41,15 +46,25 @@
let stickToBottom = $state(true);
const refresh = createLogRefresh({
onReset: () => { data = {}; checkedAt = {}; refreshError = ''; copyStatus = ''; },
onLoading: value => { loading = value; },
onError: message => { refreshError = message; },
onReset: () => {
data = {};
checkedAt = {};
refreshError = '';
copyStatus = '';
},
onLoading: (value) => {
loading = value;
},
onError: (message) => {
refreshError = message;
},
onData: (key, body) => {
data = { ...data, [key]: body };
checkedAt = { ...checkedAt, [key]: Date.now() };
const currentServer = server;
void tick().then(() => {
if (server === currentServer && active === key && stickToBottom && !query && bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
if (server === currentServer && active === key && stickToBottom && !query && bodyEl)
bodyEl.scrollTop = bodyEl.scrollHeight;
});
}
});
@@ -68,32 +83,51 @@
refresh.configure({ server, key: active, lines }, autoRefresh, pollMs);
}
async function jumpToLatest() {
query = ''; stickToBottom = true;
await tick(); if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
query = '';
stickToBottom = true;
await tick();
if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
}
async function copy(value: string, message: string) {
copyStatus = '';
try { await navigator.clipboard.writeText(value); copyStatus = message; }
catch { copyStatus = 'Could not copy. Select the log text or browser address to copy manually.'; }
try {
await navigator.clipboard.writeText(value);
copyStatus = message;
} catch {
copyStatus = 'Could not copy. Select the log text or browser address to copy manually.';
}
}
function copyLink() {
const url = new URL(window.location.href); url.searchParams.set('tab', 'logs'); url.searchParams.set('log', active);
if (query) url.searchParams.set('logQuery', query); else url.searchParams.delete('logQuery');
const url = new URL(window.location.href);
url.searchParams.set('tab', 'logs');
url.searchParams.set('log', active);
if (query) url.searchParams.set('logQuery', query);
else url.searchParams.delete('logQuery');
void copy(url.href, 'Log link copied.');
}
function download() {
const blob = new Blob([(current?.lines ?? []).join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a'); link.href = url; link.download = `${server}-${active}.log`; link.click();
const link = document.createElement('a');
link.href = url;
link.download = `${server}-${active}.log`;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function highlight(line: string) {
if (!query) return [{ text: line, match: false }];
const parts: { text: string; match: boolean }[] = []; let start = 0; let index: number;
const parts: { text: string; match: boolean }[] = [];
let start = 0;
let index: number;
while ((index = line.toLowerCase().indexOf(query.toLowerCase(), start)) !== -1) {
parts.push({ text: line.slice(start, index), match: false }, { text: line.slice(index, index + query.length), match: true }); start = index + query.length;
parts.push(
{ text: line.slice(start, index), match: false },
{ text: line.slice(index, index + query.length), match: true }
);
start = index + query.length;
}
parts.push({ text: line.slice(start), match: false }); return parts;
parts.push({ text: line.slice(start), match: false });
return parts;
}
$effect(() => {
const target = { server, key: active, lines };
@@ -111,67 +145,193 @@
}
let current = $derived(data[active] ?? null);
const visibleLines = $derived((current?.lines ?? []).map(stripGameColorCodes).filter(line => !query || line.toLowerCase().includes(query.toLowerCase())));
const visibleLines = $derived(
(current?.lines ?? [])
.map(stripGameColorCodes)
.filter((line) => !query || line.toLowerCase().includes(query.toLowerCase()))
);
</script>
<div class="log-panel">
<div class="tabs">
<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">
{#if current?.mtime}
<span class="meta">{fmtBytes(current.size)} · File changed <RelativeTime value={current.mtime} /></span>
<span class="meta"
>{fmtBytes(current.size)} · File changed <RelativeTime
value={current.mtime}
/></span
>
{/if}
<label class="auto">
<input type="checkbox" checked={autoRefresh} onchange={toggleAuto} />
Auto-refresh
</label>
{#if !autoRefresh}<button class="refresh" onclick={() => refresh.refresh()} disabled={loading}>
{loading ? 'Refreshing…' : 'Refresh'}
</button>{/if}
{#if !autoRefresh}<button
class="refresh"
onclick={() => refresh.refresh()}
disabled={loading}
>
{loading ? 'Refreshing…' : 'Refresh'}
</button>{/if}
</div>
</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>
<Freshness
updatedAt={checkedAt[active] ?? null}
paused={!autoRefresh}
intervalLabel={`Updates every ${pollMs / 1000} seconds`}
error={refreshError ? 'Log updates interrupted.' : ''}
onRetry={() => void refresh.refresh()}
retryDisabled={loading}
/>
<div class="log-tools">
<label class="log-search"
>Search loaded lines<input
type="search"
bind:value={query}
placeholder="Find text in this log"
/></label
>
<details class="log-actions">
<summary>More</summary>
<div class="action-items">
<Button size="sm" onclick={copyLink}>Copy link</Button><Button
size="sm"
disabled={!current?.lines?.length}
onclick={() => copy(visibleLines.join('\n'), 'Log text copied.')}
>Copy {query ? 'matches' : 'log'}</Button
><Button size="sm" disabled={!current?.lines?.length} onclick={download}
>Download raw tail</Button
>
</div>
</details>
</div>
{#if copyStatus}<p class="copy-status" role="status">{copyStatus}</p>{/if}
<div class="follow-state"><span>{!autoRefresh ? 'Refresh paused' : 'Auto-refresh on'} · {query ? `${visibleLines.length} matching lines` : stickToBottom ? 'Following latest lines' : 'Reading earlier lines'}</span>{#if !stickToBottom || query}<Button size="sm" variant="ghost" onclick={jumpToLatest}>Jump to latest</Button>{/if}</div>
<div class="follow-state">
<span
>{!autoRefresh ? 'Refresh paused' : 'Auto-refresh on'} · {query
? `${visibleLines.length} matching lines`
: stickToBottom
? 'Following latest lines'
: 'Reading earlier lines'}</span
>{#if !stickToBottom || query}<Button size="sm" variant="ghost" onclick={jumpToLatest}
>Jump to latest</Button
>{/if}
</div>
<div id={uid + '-panel'} role="tabpanel" aria-labelledby={uid + '-' + active}>
{#if refreshError}<div class="ui-error" role="alert">Couldn’t load logs. {current ? 'Showing the last loaded log.' : ''}<details><summary>Technical details</summary>{refreshError}</details></div>{/if}
{#if !current && !refreshError}
<div class="empty">{loading ? 'Loading log…' : autoRefresh ? 'Waiting for the first log update.' : 'No log loaded. Select Refresh to load it.'}</div>
{:else if current && !current.ok}
<div class="error">error: {current.reason ?? 'unknown'}</div>
{:else if current?.missing}
<div class="empty">
log file not written yet
{#if current.path}<br /><code>{current.path}</code>{/if}
</div>
{:else if current && (!current.lines || current.lines.length === 0)}
<div class="empty">log is empty</div>
{:else if current?.lines}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The log is keyboard-scrollable.) -->
<pre class="body" bind:this={bodyEl} onscroll={onScroll} tabindex="0" aria-label="{active} log contents">{#each visibleLines as line}{#each highlight(line) as part}{#if part.match}<mark>{part.text}</mark>{:else}{part.text}{/if}{/each}{'\n'}{/each}</pre>
{#if query && !visibleLines.length}<p class="empty">No matches in the loaded tail. Try another search or download the tail.</p>{/if}
{#if current.truncated}
<div class="foot">showing last {current.lines.length} lines (file is larger)</div>
{:else}
<div class="foot">{current.lines.length} line{current.lines.length === 1 ? '' : 's'}</div>
{#if refreshError}<div class="ui-error" role="alert">
Couldn’t load logs. {current ? 'Showing the last loaded log.' : ''}
<details><summary>Technical details</summary>{refreshError}</details>
</div>{/if}
{#if !current && !refreshError}
<div class="empty">
{loading
? 'Loading log…'
: autoRefresh
? 'Waiting for the first log update.'
: 'No log loaded. Select Refresh to load it.'}
</div>
{:else if current && !current.ok}
<div class="error">error: {current.reason ?? 'unknown'}</div>
{:else if current?.missing}
<div class="empty">
log file not written yet
{#if current.path}<br /><code>{current.path}</code>{/if}
</div>
{:else if current && (!current.lines || current.lines.length === 0)}
<div class="empty">log is empty</div>
{:else if current?.lines}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The log is keyboard-scrollable.) -->
<pre
class="body"
bind:this={bodyEl}
onscroll={onScroll}
tabindex="0"
aria-label="{active} log contents">{#each visibleLines as line}{#each highlight(line) as part}{#if part.match}<mark
>{part.text}</mark
>{:else}{part.text}{/if}{/each}{'\n'}{/each}</pre>
{#if query && !visibleLines.length}<p class="empty">
No matches in the loaded tail. Try another search or download the tail.
</p>{/if}
{#if current.truncated}
<div class="foot">showing last {current.lines.length} lines (file is larger)</div>
{:else}
<div class="foot">
{current.lines.length} line{current.lines.length === 1 ? '' : 's'}
</div>
{/if}
{/if}
{/if}
</div>
</div>
<style>
.log-tools, .action-items, .follow-state { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.log-actions { position: relative; font-size: var(--text-sm); }
summary { cursor: pointer; }
.action-items { position: absolute; right: 0; z-index: 3; padding: 12px; min-width: 160px; background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); }
.log-tools { align-items: end; }
.log-search { display: grid; gap: 6px; flex: 1; min-width: 180px; font-size: var(--text-xs); color: var(--text-secondary); }
.log-search input { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-sm); padding: 9px 12px; font: inherit; }
.copy-status { margin: 0; font-size: var(--text-xs); color: var(--text-secondary); }
.follow-state { justify-content: space-between; font-size: var(--text-xs); color: var(--text-secondary); }
mark { background: var(--state-warning); color: var(--surface-sunken); }
.log-tools,
.action-items,
.follow-state {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.log-actions {
position: relative;
font-size: var(--text-sm);
}
summary {
cursor: pointer;
}
.action-items {
position: absolute;
right: 0;
z-index: 3;
padding: 12px;
min-width: 160px;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
}
.log-tools {
align-items: end;
}
.log-search {
display: grid;
gap: 6px;
flex: 1;
min-width: 180px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.log-search input {
background: var(--surface-sunken);
color: var(--text-primary);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
padding: 9px 12px;
font: inherit;
}
.copy-status {
margin: 0;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.follow-state {
justify-content: space-between;
font-size: var(--text-xs);
color: var(--text-secondary);
}
mark {
background: var(--state-warning);
color: var(--surface-sunken);
}
.log-panel {
display: flex;
flex-direction: column;
@@ -207,7 +367,9 @@
cursor: pointer;
user-select: none;
}
.auto input { accent-color: var(--accent-primary); }
.auto input {
accent-color: var(--accent-primary);
}
.refresh {
background: var(--surface);
color: var(--text-primary);
@@ -222,7 +384,10 @@
border-color: var(--accent-primary);
box-shadow: var(--focus-ring);
}
.refresh:disabled { opacity: 0.4; cursor: default; }
.refresh:disabled {
opacity: 0.4;
cursor: default;
}
.body {
margin: 0;
@@ -239,7 +404,8 @@
white-space: pre-wrap;
word-break: break-word;
}
.empty, .error {
.empty,
.error {
padding: 28px 16px;
text-align: center;
font-family: var(--font-body);
@@ -267,6 +433,10 @@
text-align: right;
}
@media (max-width: 640px) {
.right { width: 100%; margin-left: 0; flex-wrap: wrap; }
.right {
width: 100%;
margin-left: 0;
flex-wrap: wrap;
}
}
</style>
+566 -114
View File
@@ -6,7 +6,14 @@
import { goto } from '$app/navigation';
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
import type { MatchHistoryResponse } from '$lib/match-history.js';
import { matchApiParams, matchHistoryHref, matchPageSize, readMatchQuery, type MatchHistoryQuery, type MatchSort } from '$lib/match-history-query.js';
import {
matchApiParams,
matchHistoryHref,
matchPageSize,
readMatchQuery,
type MatchHistoryQuery,
type MatchSort
} from '$lib/match-history-query.js';
import { matchInstanceLabel } from '$ui/match-history.js';
import { pollVisible } from '$ui/poll.js';
import { formatDuration } from '$ui/duration.js';
@@ -22,7 +29,7 @@
let search = $state(untrack(() => matchApiParams($page.url).get('q') ?? ''));
let retry = $state(0);
let filtersOpen = $state(false);
let checkedAt = $state<number | null>(untrack(() => initial ? Date.now() : null));
let checkedAt = $state<number | null>(untrack(() => (initial ? Date.now() : null)));
let tableRegion = $state<HTMLDivElement>();
let pendingUrl: URL | undefined;
let navigationId = 0;
@@ -30,32 +37,60 @@
const apiUrl = $derived.by(() => {
const params = matchApiParams($page.url);
params.set('limit', String(matchPageSize));
const endpoint = server === undefined ? '/api/matches' : `/api/servers/${encodeURIComponent(server)}/matches`;
const endpoint =
server === undefined
? '/api/matches'
: `/api/servers/${encodeURIComponent(server)}/matches`;
return `${endpoint}?${params}`;
});
let loadedUrl = $state(untrack(() => initial ? apiUrl : ''));
const filterCount = $derived([filters.game, filters.map, filters.q, filters.days, filters.status].filter(Boolean).length);
const hasFilters = $derived(!!(filters.game || filters.map || filters.q || filters.days || filters.status));
const sorts: Array<{ key: MatchSort; label: string }> = [{ key: 'started', label: 'Started' }, { key: 'players', label: 'Players' }, { key: 'duration', label: 'Duration' }, { key: 'round', label: 'Round' }];
let loadedUrl = $state(untrack(() => (initial ? apiUrl : '')));
const filterCount = $derived(
[filters.game, filters.map, filters.q, filters.days, filters.status].filter(Boolean).length
);
const hasFilters = $derived(
!!(filters.game || filters.map || filters.q || filters.days || filters.status)
);
const sorts: Array<{ key: MatchSort; label: string }> = [
{ key: 'started', label: 'Started' },
{ key: 'players', label: 'Players' },
{ key: 'duration', label: 'Duration' },
{ key: 'round', label: 'Round' }
];
$effect(() => { search = filters.q; });
$effect(() => {
search = filters.q;
});
$effect(() => {
const report = initial;
if (report) untrack(() => { data = report; loadedUrl = apiUrl; loading = false; error = ''; checkedAt = Date.now(); });
if (report)
untrack(() => {
data = report;
loadedUrl = apiUrl;
loading = false;
error = '';
checkedAt = Date.now();
});
});
$effect(() => {
const url = apiUrl;
retry;
untrack(() => { if (loadedUrl !== url) loading = true; error = ''; });
return pollVisible(async signal => {
void retry;
untrack(() => {
if (loadedUrl !== url) loading = true;
error = '';
});
return pollVisible(async (signal) => {
try {
const response = await fetch(url, { signal, cache: 'no-store' });
if (!response.ok) throw new Error('Could not load match history.');
const body: MatchHistoryResponse = await response.json();
if (signal.aborted || url !== apiUrl) return;
// Do not move keyboard users to another match while they are following a link.
if (loadedUrl !== url || !tableRegion?.contains(document.activeElement)) data = body;
loadedUrl = url; loading = false; error = ''; checkedAt = Date.now();
if (loadedUrl !== url || !tableRegion?.contains(document.activeElement))
data = body;
loadedUrl = url;
loading = false;
error = '';
checkedAt = Date.now();
} catch (cause) {
if (signal.aborted || url !== apiUrl) return;
error = cause instanceof Error ? cause.message : 'Could not load match history.';
@@ -69,130 +104,547 @@
pendingUrl = new URL(href, $page.url);
const id = ++navigationId;
void goto(href, { noScroll: true, keepFocus: true })
.catch(() => { error = 'Could not update the match filters. Please try again.'; })
.finally(() => { if (id === navigationId) pendingUrl = undefined; });
.catch(() => {
error = 'Could not update the match filters. Please try again.';
})
.finally(() => {
if (id === navigationId) pendingUrl = undefined;
});
}
function sort(key: MatchSort) {
update({
sort: key,
direction: filters.sort === key && filters.direction === 'desc' ? 'asc' : 'desc'
});
}
function reset() {
update({ game: '', map: '', q: '', days: '', status: '' });
}
function sort(key: MatchSort) { update({ sort: key, direction: filters.sort === key && filters.direction === 'desc' ? 'asc' : 'desc' }); }
function reset() { update({ game: '', map: '', q: '', days: '', status: '' }); }
</script>
<section class="history" aria-label="Match history">
{#snippet filterFields(suffix: string)}
<div class="filters">
{#if server === undefined}<label for={`match-game${suffix}`}>Game<select id={`match-game${suffix}`} value={filters.game} onchange={event => update({ game: event.currentTarget.value, map: '' })}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>{/if}
<label for={`match-map${suffix}`}>Map<select id={`match-map${suffix}`} value={filters.map} onchange={event => update({ map: event.currentTarget.value })}>
<option value="">All maps</option>
{#if filters.map && !data?.maps.some(map => map.key === filters.map)}<option value={filters.map}>{filters.map}</option>{/if}
{#each data?.maps ?? [] as map (map.key)}<option value={map.key}>{map.name}{!filters.game && server === undefined ? ` · ${map.game === 't5' ? 'BO1' : 'BO2'}` : ''}</option>{/each}
</select></label>
{#if server === undefined}<form role="search" onsubmit={event => { event.preventDefault(); update({ q: search.trim() }); if (suffix) filtersOpen = false; }}>
<label for={`match-search${suffix}`}>Server<input id={`match-search${suffix}`} type="search" bind:value={search} maxlength="100" placeholder="Find a server…" aria-describedby={`match-search-help${suffix}`} /></label>
<span id={`match-search-help${suffix}`} class="sr-only">Press Enter to search by server name.</span>
</form>{/if}
<label for={`match-days${suffix}`}>Date range<select id={`match-days${suffix}`} value={filters.days} onchange={event => update({ days: event.currentTarget.value })}><option value="0">All time</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option></select></label>
<label for={`match-status${suffix}`}>Status<select id={`match-status${suffix}`} value={filters.status} onchange={event => update({ status: event.currentTarget.value })}><option value="">All statuses</option><option value="completed">Completed</option><option value="active">In progress</option><option value="interrupted">Interrupted</option></select></label>
{#if server === undefined}<label for={`match-game${suffix}`}
>Game<select
id={`match-game${suffix}`}
value={filters.game}
onchange={(event) => update({ game: event.currentTarget.value, map: '' })}
><option value="">All games</option><option value="t5">Black Ops I</option
><option value="t6">Black Ops II</option></select
></label
>{/if}
<label for={`match-map${suffix}`}
>Map<select
id={`match-map${suffix}`}
value={filters.map}
onchange={(event) => update({ map: event.currentTarget.value })}
>
<option value="">All maps</option>
{#if filters.map && !data?.maps.some((map) => map.key === filters.map)}<option
value={filters.map}>{filters.map}</option
>{/if}
{#each data?.maps ?? [] as map (map.key)}<option value={map.key}
>{map.name}{!filters.game && server === undefined
? ` · ${map.game === 't5' ? 'BO1' : 'BO2'}`
: ''}</option
>{/each}
</select></label
>
{#if server === undefined}<form
role="search"
onsubmit={(event) => {
event.preventDefault();
update({ q: search.trim() });
if (suffix) filtersOpen = false;
}}
>
<label for={`match-search${suffix}`}
>Server<input
id={`match-search${suffix}`}
type="search"
bind:value={search}
maxlength="100"
placeholder="Find a server…"
aria-describedby={`match-search-help${suffix}`}
/></label
>
<span id={`match-search-help${suffix}`} class="sr-only"
>Press Enter to search by server name.</span
>
</form>{/if}
<label for={`match-days${suffix}`}
>Date range<select
id={`match-days${suffix}`}
value={filters.days}
onchange={(event) => update({ days: event.currentTarget.value })}
><option value="0">All time</option><option value="7">Last 7 days</option
><option value="30">Last 30 days</option><option value="90">Last 90 days</option
></select
></label
>
<label for={`match-status${suffix}`}
>Status<select
id={`match-status${suffix}`}
value={filters.status}
onchange={(event) => update({ status: event.currentTarget.value })}
><option value="">All statuses</option><option value="completed"
>Completed</option
><option value="active">In progress</option><option value="interrupted"
>Interrupted</option
></select
></label
>
{#if suffix}
<label for={`match-sort${suffix}`}>Sort by<select id={`match-sort${suffix}`} value={filters.sort} onchange={event => update({ sort: event.currentTarget.value })}>{#each sorts as item}<option value={item.key}>{item.label}</option>{/each}</select></label>
<label for={`match-order${suffix}`}>Order<select id={`match-order${suffix}`} value={filters.direction} onchange={event => update({ direction: event.currentTarget.value })}><option value="desc">Highest or newest first</option><option value="asc">Lowest or oldest first</option></select></label>
<label for={`match-sort${suffix}`}
>Sort by<select
id={`match-sort${suffix}`}
value={filters.sort}
onchange={(event) => update({ sort: event.currentTarget.value })}
>{#each sorts as item}<option value={item.key}>{item.label}</option
>{/each}</select
></label
>
<label for={`match-order${suffix}`}
>Order<select
id={`match-order${suffix}`}
value={filters.direction}
onchange={(event) => update({ direction: event.currentTarget.value })}
><option value="desc">Highest or newest first</option><option value="asc"
>Lowest or oldest first</option
></select
></label
>
{/if}
</div>
{/snippet}
<div class="desktop-filters">{@render filterFields('')}</div>
<div class="mobile-filters"><span>{data?.total ?? '—'} matches{filters.map ? ` · ${data?.maps.find(map => map.key === filters.map)?.name ?? 'Selected map'}` : ''}</span><Button size="sm" onclick={() => filtersOpen = true}>Filters{filterCount ? ` · ${filterCount}` : ''}</Button></div>
<Dialog bind:open={filtersOpen} title="Match filters" description="Choose the matches to show." drawer>
<div class="mobile-filters">
<span
>{data?.total ?? '—'} matches{filters.map
? ` · ${data?.maps.find((map) => map.key === filters.map)?.name ?? 'Selected map'}`
: ''}</span
><Button size="sm" onclick={() => (filtersOpen = true)}
>Filters{filterCount ? ` · ${filterCount}` : ''}</Button
>
</div>
<Dialog
bind:open={filtersOpen}
title="Match filters"
description="Choose the matches to show."
drawer
>
{@render filterFields('-mobile')}
{#snippet actions()}<Button variant="ghost" onclick={reset}>Clear filters</Button><Button variant="primary" onclick={() => filtersOpen = false}>Show results</Button>{/snippet}
{#snippet actions()}<Button variant="ghost" onclick={reset}>Clear filters</Button><Button
variant="primary"
onclick={() => (filtersOpen = false)}>Show results</Button
>{/snippet}
</Dialog>
<div class="context"><span>Times in {$timeZone}{filters.days ? ` · Last ${filters.days} days` : ''}</span>{#if hasFilters}<button class="text-button" onclick={reset}>Clear filters</button>{/if}{#if !error}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 10 seconds" />{/if}</div>
{#if error}<div class="error" role="alert">{error} <Button size="sm" onclick={() => retry++}>Retry</Button></div>{/if}
<div class="context">
<span>Times in {$timeZone}{filters.days ? ` · Last ${filters.days} days` : ''}</span
>{#if hasFilters}<button class="text-button" onclick={reset}>Clear filters</button
>{/if}{#if !error}<Freshness
updatedAt={checkedAt}
intervalLabel="Updates every 10 seconds"
/>{/if}
</div>
{#if error}<div class="error" role="alert">
{error}
<Button size="sm" onclick={() => retry++}>Retry</Button>
</div>{/if}
{#if loading}<EmptyState title="Loading matches…" />
{:else if loadedUrl !== apiUrl && error}<EmptyState title="Match history unavailable" description="Retry to load this selection." />
{:else if loadedUrl !== apiUrl && error}<EmptyState
title="Match history unavailable"
description="Retry to load this selection."
/>
{:else if data?.matches.length}
<!-- Keyboard focus lets users scroll the wide table horizontally. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="table-scroll" bind:this={tableRegion} tabindex="0" role="region" aria-label="Recorded matches">
<div
class="table-scroll"
bind:this={tableRegion}
tabindex="0"
role="region"
aria-label="Recorded matches"
>
<table>
<caption class="sr-only">Matches sorted by {sorts.find(item => item.key === filters.sort)?.label.toLowerCase()}, {filters.direction === 'desc' ? 'highest or newest' : 'lowest or oldest'} first</caption>
<thead><tr>
<th scope="col" aria-sort={filters.sort === 'started' ? filters.direction === 'desc' ? 'descending' : 'ascending' : 'none'}><button onclick={() => sort('started')}>Started{filters.sort === 'started' ? filters.direction === 'desc' ? ' ↓' : ' ↑' : ''}</button></th>
<th scope="col">Match</th><th scope="col">Status</th>
{#each sorts.filter(item => item.key !== 'started') as item}
<th scope="col" class="numeric" aria-sort={filters.sort === item.key ? filters.direction === 'desc' ? 'descending' : 'ascending' : 'none'}><button onclick={() => sort(item.key)} title={item.key === 'players' ? 'Unique players observed throughout the match' : item.key === 'round' ? 'Final round, or highest observed round for unfinished matches' : 'Recorded duration; unknown durations sort last'} aria-describedby={item.key === 'players' ? 'match-player-help' : undefined}>{item.label}{filters.sort === item.key ? filters.direction === 'desc' ? ' ↓' : ' ↑' : ''}</button></th>
{/each}
</tr></thead>
<tbody>{#each data.matches as match (match.id)}
<tr>
<td><RelativeTime value={match.startedAt} /></td>
<td class="match">
{#if match.status === 'completed'}<a class="match-link" href={contextHref(`/matches/${match.id}`, $page.url)} aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}>{match.map} · Match #{match.id}</a>
{:else}<span class="match-name">{match.map}</span>{/if}
<small class="mobile-match-context"><RelativeTime value={match.startedAt} /> · {match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}{match.durationMs !== null ? ` · ${formatDuration(match.durationMs)}` : ''}</small>
{#if server === undefined}<div class="instance">
<span title={match.server}>{matchInstanceLabel(match)}</span>{#if match.server_deleted_at}<span> · Historical instance</span>{/if}
</div>{/if}
</td>
<td><span class="status" class:active={match.status === 'active'} class:interrupted={match.status === 'interrupted'}>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</span></td>
<td class="numeric">{match.uniquePlayers}</td>
<td class="numeric"><span title={match.durationMs === null ? 'Duration was not recorded or the match has not completed.' : undefined}>{match.durationMs === null ? '—' : formatDuration(match.durationMs)}</span></td>
<td class="numeric">{match.status === 'completed' ? match.finalRound ?? '—' : match.observedRound ?? '—'}{#if match.status !== 'completed'}<small>Highest observed</small>{/if}</td>
</tr>
{/each}</tbody>
<caption class="sr-only"
>Matches sorted by {sorts
.find((item) => item.key === filters.sort)
?.label.toLowerCase()}, {filters.direction === 'desc'
? 'highest or newest'
: 'lowest or oldest'} first</caption
>
<thead
><tr>
<th
scope="col"
aria-sort={filters.sort === 'started'
? filters.direction === 'desc'
? 'descending'
: 'ascending'
: 'none'}
><button onclick={() => sort('started')}
>Started{filters.sort === 'started'
? filters.direction === 'desc'
? ' ↓'
: ' ↑'
: ''}</button
></th
>
<th scope="col">Match</th><th scope="col">Status</th>
{#each sorts.filter((item) => item.key !== 'started') as item}
<th
scope="col"
class="numeric"
aria-sort={filters.sort === item.key
? filters.direction === 'desc'
? 'descending'
: 'ascending'
: 'none'}
><button
onclick={() => sort(item.key)}
title={item.key === 'players'
? 'Unique players observed throughout the match'
: item.key === 'round'
? 'Final round, or highest observed round for unfinished matches'
: 'Recorded duration; unknown durations sort last'}
aria-describedby={item.key === 'players'
? 'match-player-help'
: undefined}
>{item.label}{filters.sort === item.key
? filters.direction === 'desc'
? ' ↓'
: ' ↑'
: ''}</button
></th
>
{/each}
</tr></thead
>
<tbody
>{#each data.matches as match (match.id)}
<tr>
<td><RelativeTime value={match.startedAt} /></td>
<td class="match">
{#if match.status === 'completed'}<a
class="match-link"
href={contextHref(`/matches/${match.id}`, $page.url)}
aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}
>{match.map} · Match #{match.id}</a
>
{:else}<span class="match-name">{match.map}</span>{/if}
<small class="mobile-match-context"
><RelativeTime value={match.startedAt} /> · {match.status ===
'active'
? 'In progress'
: match.status === 'completed'
? 'Completed'
: 'Interrupted'}{match.durationMs !== null
? ` · ${formatDuration(match.durationMs)}`
: ''}</small
>
{#if server === undefined}<div class="instance">
<span title={match.server}>{matchInstanceLabel(match)}</span
>{#if match.server_deleted_at}<span>
· Historical instance</span
>{/if}
</div>{/if}
</td>
<td
><span
class="status"
class:active={match.status === 'active'}
class:interrupted={match.status === 'interrupted'}
>{match.status === 'active'
? 'In progress'
: match.status === 'completed'
? 'Completed'
: 'Interrupted'}</span
></td
>
<td class="numeric">{match.uniquePlayers}</td>
<td class="numeric"
><span
title={match.durationMs === null
? 'Duration was not recorded or the match has not completed.'
: undefined}
>{match.durationMs === null
? '—'
: formatDuration(match.durationMs)}</span
></td
>
<td class="numeric"
>{match.status === 'completed'
? (match.finalRound ?? '—')
: (match.observedRound ??
'—')}{#if match.status !== 'completed'}<small
>Highest observed</small
>{/if}</td
>
</tr>
{/each}</tbody
>
</table>
</div>
<div class="pagination"><span>{(data.page - 1) * data.limit + 1}–{(data.page - 1) * data.limit + data.matches.length} of {data.total} matches</span>
{#if data.total > data.limit}<div><Button size="sm" disabled={data.page <= 1} href={matchHistoryHref($page.url, { page: String(data.page - 1) })}>Previous</Button><Button size="sm" disabled={data.page * data.limit >= data.total} href={matchHistoryHref($page.url, { page: String(data.page + 1) })}>Next</Button></div>{/if}
<div class="pagination">
<span
>{(data.page - 1) * data.limit + 1}–{(data.page - 1) * data.limit +
data.matches.length} of {data.total} matches</span
>
{#if data.total > data.limit}<div>
<Button
size="sm"
disabled={data.page <= 1}
href={matchHistoryHref($page.url, { page: String(data.page - 1) })}
>Previous</Button
><Button
size="sm"
disabled={data.page * data.limit >= data.total}
href={matchHistoryHref($page.url, { page: String(data.page + 1) })}
>Next</Button
>
</div>{/if}
</div>
{:else if !error}<EmptyState title={hasFilters ? 'No matches found' : 'No matches recorded yet'} description={hasFilters ? 'Try another game, map, instance, or date range.' : 'Matches appear when instances report gameplay.'}>
{#snippet actions()}{#if hasFilters}<Button size="sm" onclick={reset}>Clear filters</Button>{/if}{/snippet}
</EmptyState>{/if}
<span id="match-player-help" class="sr-only">Players counts unique players observed throughout the match.</span>
<details class="about"><summary>About match history</summary><p>Players counts unique players across all reports. A dash means the value is unavailable, not zero. Duration requires a recorded start and completed ending. Round shows the final round for completed matches and the highest observed round for unfinished matches. Open a completed match for exact timestamps and starting and ending player counts.</p></details>
{:else if !error}<EmptyState
title={hasFilters ? 'No matches found' : 'No matches recorded yet'}
description={hasFilters
? 'Try another game, map, instance, or date range.'
: 'Matches appear when instances report gameplay.'}
>
{#snippet actions()}{#if hasFilters}<Button size="sm" onclick={reset}
>Clear filters</Button
>{/if}{/snippet}
</EmptyState>{/if}
<span id="match-player-help" class="sr-only"
>Players counts unique players observed throughout the match.</span
>
<details class="about">
<summary>About match history</summary>
<p>
Players counts unique players across all reports. A dash means the value is unavailable,
not zero. Duration requires a recorded start and completed ending. Round shows the final
round for completed matches and the highest observed round for unfinished matches. Open
a completed match for exact timestamps and starting and ending player counts.
</p>
</details>
</section>
<style>
.history { display: grid; gap: var(--space-4); min-width: 0; }
.filters { display: flex; align-items: flex-start; flex-wrap: wrap; gap: var(--space-3); }
label, form { flex: 1; min-width: 145px; }
label { display: flex; flex-direction: column; gap: 6px; color: var(--text-secondary); font-size: var(--text-xs); }
input, select { width: 100%; min-height: 40px; padding: 8px 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; font-size: var(--text-sm); }
summary { cursor: pointer; color: var(--text-secondary); }
.context, .pagination { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3); font-size: var(--text-xs); color: var(--text-secondary); }
.pagination > div { display: flex; gap: var(--space-2); }
.table-scroll { position: relative; overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); }
.table-scroll:focus-visible { outline: none; box-shadow: var(--focus-ring); }
table { width: 100%; border-collapse: collapse; background: var(--surface); font-size: var(--text-sm); }
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; }
th { color: var(--text-secondary); font-size: var(--text-xs); background: var(--surface-sunken); }
th button, .text-button { border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; padding: 4px 0; }
.text-button { color: var(--accent-primary-hover); }
td { font-variant-numeric: tabular-nums; }
tr:last-child td { border-bottom: 0; }
tbody tr:hover, tbody tr:focus-within { background: var(--surface-raised); }
.numeric { text-align: right; }
.match { min-width: 200px; white-space: normal; }
.match-link { color: var(--accent-primary-hover); text-decoration: none; }
.match-link, .match-name { font-weight: var(--weight-medium); }
a:hover { text-decoration: underline; }
a:focus-visible, button:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
.instance, small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-secondary); }
.status { color: var(--text-secondary); }
.status.active { color: var(--accent-primary-hover); }
.status.interrupted { color: var(--state-warning); }
.about { font-size: var(--text-xs); color: var(--text-secondary); }
.about p { max-width: 90ch; line-height: 1.6; }
.error { display: flex; align-items: center; gap: var(--space-3); color: var(--state-warning); font-size: var(--text-sm); }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
.mobile-filters, .mobile-match-context { display: none; }
.history {
display: grid;
gap: var(--space-4);
min-width: 0;
}
.filters {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: var(--space-3);
}
label,
form {
flex: 1;
min-width: 145px;
}
label {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--text-secondary);
font-size: var(--text-xs);
}
input,
select {
width: 100%;
min-height: 40px;
padding: 8px 10px;
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
background: var(--surface-sunken);
color: var(--text-primary);
font: inherit;
font-size: var(--text-sm);
}
summary {
cursor: pointer;
color: var(--text-secondary);
}
.context,
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
font-size: var(--text-xs);
color: var(--text-secondary);
}
.pagination > div {
display: flex;
gap: var(--space-2);
}
.table-scroll {
position: relative;
overflow-x: auto;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
}
.table-scroll:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
table {
width: 100%;
border-collapse: collapse;
background: var(--surface);
font-size: var(--text-sm);
}
th,
td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border-subtle);
white-space: nowrap;
}
th {
color: var(--text-secondary);
font-size: var(--text-xs);
background: var(--surface-sunken);
}
th button,
.text-button {
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
padding: 4px 0;
}
.text-button {
color: var(--accent-primary-hover);
}
td {
font-variant-numeric: tabular-nums;
}
tr:last-child td {
border-bottom: 0;
}
tbody tr:hover,
tbody tr:focus-within {
background: var(--surface-raised);
}
.numeric {
text-align: right;
}
.match {
min-width: 200px;
white-space: normal;
}
.match-link {
color: var(--accent-primary-hover);
text-decoration: none;
}
.match-link,
.match-name {
font-weight: var(--weight-medium);
}
a:hover {
text-decoration: underline;
}
a:focus-visible,
button:focus-visible,
summary:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
.instance,
small {
display: block;
margin-top: 4px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.status {
color: var(--text-secondary);
}
.status.active {
color: var(--accent-primary-hover);
}
.status.interrupted {
color: var(--state-warning);
}
.about {
font-size: var(--text-xs);
color: var(--text-secondary);
}
.about p {
max-width: 90ch;
line-height: 1.6;
}
.error {
display: flex;
align-items: center;
gap: var(--space-3);
color: var(--state-warning);
font-size: var(--text-sm);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.mobile-filters,
.mobile-match-context {
display: none;
}
@media (max-width: 640px) {
.desktop-filters { display: none; }
.mobile-match-context { display: block; }
.mobile-filters { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); font-size: var(--text-sm); }
.filters { display: grid; grid-template-columns: minmax(0, 1fr); }
.filters > label, .filters > form { min-width: 0; }
.context { gap: var(--space-1) var(--space-2); }
th, td { padding: 10px 12px; }
th:nth-child(1), td:nth-child(1) { display: none; }
th:nth-child(3), td:nth-child(3), th:nth-child(5), td:nth-child(5) { display: none; }
.match { min-width: 130px; }
.desktop-filters {
display: none;
}
.mobile-match-context {
display: block;
}
.mobile-filters {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
font-size: var(--text-sm);
}
.filters {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
.filters > label,
.filters > form {
min-width: 0;
}
.context {
gap: var(--space-1) var(--space-2);
}
th,
td {
padding: 10px 12px;
}
th:nth-child(1),
td:nth-child(1) {
display: none;
}
th:nth-child(3),
td:nth-child(3),
th:nth-child(5),
td:nth-child(5) {
display: none;
}
.match {
min-width: 130px;
}
}
</style>
+287 -51
View File
@@ -5,31 +5,90 @@
import type { GameLibrary } from '$lib/game-files.js';
import type { ZombieGame } from '$lib/map-aliases.js';
import { pollVisible } from '$ui/poll.js';
import { latestGameFiles, selectLatestGameFiles, type NativeHostSelectionStatus } from '$ui/native-game-files.js';
let { games, selection = $bindable(), ready = $bindable(false), status = $bindable(), disabled = false, network = false, compact = false, refreshVersion = 0, onchange }:
{ games: ZombieGame[]; selection: NativeDeploymentSelection; ready?: boolean; status?: NativeHostSelectionStatus; disabled?: boolean; network?: boolean; compact?: boolean; refreshVersion?: number; onchange?: () => void } = $props();
let hosts = $state<Host[]>([]); let libraries = $state<GameLibrary[]>([]); let loading = $state(true); let error = $state('');
import {
latestGameFiles,
selectLatestGameFiles,
type NativeHostSelectionStatus
} from '$ui/native-game-files.js';
let {
games,
selection = $bindable(),
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-useless-assignment -- Written here and read through parent bindings.
ready = $bindable(false),
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-useless-assignment -- Written here and read through parent bindings.
status = $bindable(),
disabled = false,
network = false,
compact = false,
refreshVersion = 0,
onchange
}: {
games: ZombieGame[];
selection: NativeDeploymentSelection;
ready?: boolean;
status?: NativeHostSelectionStatus;
disabled?: boolean;
network?: boolean;
compact?: boolean;
refreshVersion?: number;
onchange?: () => void;
} = $props();
let hosts = $state<Host[]>([]);
let libraries = $state<GameLibrary[]>([]);
let loading = $state(true);
let error = $state('');
let defaultRuntimeImage = $state('');
let runtimeSelection: NativeDeploymentSelection | null = null;
let requestVersion = 0;
let activeController: AbortController | undefined;
const eligible = $derived(hosts.filter(host => host.readiness === 'ready' && host.inventory.runtimeImages?.includes(selection.image)));
const eligible = $derived(
hosts.filter(
(host) =>
host.readiness === 'ready' &&
host.inventory.runtimeImages?.includes(selection.image)
)
);
const latest = $derived(latestGameFiles(libraries));
function cacheReady(game: ZombieGame) { return !!latest[game]?.caches.some(cache => eligible.some(host => host.id === cache.hostId) && ['ready', 'outdated'].includes(cache.status)); }
$effect(() => { ready = !loading && !error && /^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(selection.image)
&& games.length > 0 && games.every(game => !!latest[game]?.id && selection.libraryIds[game] === latest[game]?.id); });
function cacheReady(game: ZombieGame) {
return !!latest[game]?.caches.some(
(cache) =>
eligible.some((host) => host.id === cache.hostId) &&
['ready', 'outdated'].includes(cache.status)
);
}
$effect(() => {
ready =
!loading &&
!error &&
/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(selection.image) &&
games.length > 0 &&
games.every(
(game) => !!latest[game]?.id && selection.libraryIds[game] === latest[game]?.id
);
});
$effect(() => {
status = {
loading,
error,
hasHosts: hosts.length > 0,
filesReady: games.length > 0 && games.every(cacheReady),
missingGames: games.filter(game => !latest[game]?.id),
uploadedAt: games.map(game => latest[game]?.publishedAt ?? latest[game]?.createdAt)
.filter((value): value is string => !!value).sort().at(-1) ?? null,
gameFiles: Object.fromEntries(games.map(game => [game, { ready: cacheReady(game),
uploadedAt: latest[game]?.publishedAt ?? latest[game]?.createdAt ?? null }])),
missingGames: games.filter((game) => !latest[game]?.id),
uploadedAt:
games
.map((game) => latest[game]?.publishedAt ?? latest[game]?.createdAt)
.filter((value): value is string => !!value)
.sort()
.at(-1) ?? null,
gameFiles: Object.fromEntries(
games.map((game) => [
game,
{
ready: cacheReady(game),
uploadedAt: latest[game]?.publishedAt ?? latest[game]?.createdAt ?? null
}
])
)
};
});
$effect(() => {
@@ -52,14 +111,24 @@
if (signal?.aborted) controller.abort();
else signal?.addEventListener('abort', abort, { once: true });
try {
const [hostResponse, libraryResponse] = await Promise.all([fetch('/api/hosts', { signal: controller.signal }), fetch('/api/game-files', { signal: controller.signal })]);
if (!hostResponse.ok || !libraryResponse.ok) throw new Error('Could not load hosts and game files.');
const [hostBody, libraryBody] = await Promise.all([hostResponse.json(), libraryResponse.json()]);
const [hostResponse, libraryResponse] = await Promise.all([
fetch('/api/hosts', { signal: controller.signal }),
fetch('/api/game-files', { signal: controller.signal })
]);
if (!hostResponse.ok || !libraryResponse.ok)
throw new Error('Could not load hosts and game files.');
const [hostBody, libraryBody] = await Promise.all([
hostResponse.json(),
libraryResponse.json()
]);
if (version !== requestVersion || controller.signal.aborted) return;
hosts = hostBody.hosts; libraries = libraryBody.libraries; error = '';
hosts = hostBody.hosts;
libraries = libraryBody.libraries;
error = '';
defaultRuntimeImage = hostBody.defaultRuntimeImage ?? '';
} catch (cause) {
if (version === requestVersion && !controller.signal.aborted) error = (cause as Error).message;
if (version === requestVersion && !controller.signal.aborted)
error = (cause as Error).message;
} finally {
signal?.removeEventListener('abort', abort);
if (version === requestVersion && !controller.signal.aborted) loading = false;
@@ -67,7 +136,7 @@
}
$effect(() => {
// Restart the poll for a parent refresh, sharing one initial request.
refreshVersion;
void refreshVersion;
loading = true;
const stop = pollVisible(load, 5000);
return () => {
@@ -79,14 +148,45 @@
</script>
{#snippet runtimeFields()}
<label>{compact ? 'Runtime image' : 'Plutainer runtime image'}<input required bind:value={selection.image} disabled={disabled} oninput={() => onchange?.()} /></label>
<label
>{compact ? 'Runtime image' : 'Plutainer runtime image'}<input
required
bind:value={selection.image}
{disabled}
oninput={() => onchange?.()}
/></label
>
{#if compact}<p>Custom images must be prepared on a host before it can run this server.</p>{/if}
{/snippet}
{#snippet networkFields()}
<label>Local bind address<input bind:value={selection.bindAddress} placeholder="Host default" disabled={disabled} oninput={() => onchange?.()} /></label>
<label>Public / NAT address<input bind:value={selection.publicAddress} placeholder="Host default" disabled={disabled} oninput={() => onchange?.()} /></label>
<label>Public / NAT port<input type="number" min="1" max="65535" bind:value={selection.publicPort} placeholder="Same as local port" disabled={disabled} oninput={() => onchange?.()} /></label>
<label
>Local bind address<input
bind:value={selection.bindAddress}
placeholder="Host default"
{disabled}
oninput={() => onchange?.()}
/></label
>
<label
>Public / NAT address<input
bind:value={selection.publicAddress}
placeholder="Host default"
{disabled}
oninput={() => onchange?.()}
/></label
>
<label
>Public / NAT port<input
type="number"
min="1"
max="65535"
bind:value={selection.publicPort}
placeholder="Same as local port"
{disabled}
oninput={() => onchange?.()}
/></label
>
<p>Public mapping does not change the local Docker port reservation.</p>
{/snippet}
@@ -95,39 +195,175 @@
<summary>Advanced settings</summary>
<div class="advanced-fields">
{@render runtimeFields()}
{#if network}<fieldset><legend>Network overrides</legend>{@render networkFields()}</fieldset>{/if}
{#if network}<fieldset>
<legend>Network overrides</legend>{@render networkFields()}
</fieldset>{/if}
</div>
</details>
{:else}
<section class="selection" aria-label="Deployment host and game files">
<div class="title"><h3>Host and game files</h3><button type="button" disabled={disabled || loading} onclick={() => { onchange?.(); void load(); }}>Refresh hosts</button></div>
{#if error}<p role="alert" class="error">{error}</p>{/if}
{#if loading}<p role="status">Loading host readiness and game files…</p>{/if}
<p>Hosts are assigned when provisioning starts. You can queue launches while hosts are busy or preparing; each server waits for an eligible host and available capacity.</p>
{#if !loading && !hosts.length}<p><a href="/hosts" target="_blank" rel="noopener">Add a host</a>. Its runtime and latest Black Ops I and Black Ops II files will be prepared automatically.</p>{/if}
{#if !loading}{#each games as game}
<div class="game-files">
<strong>{game === 't5' ? 'Black Ops I' : 'Black Ops II'} files</strong>
{#if latest[game]}
<p>Latest upload · <RelativeTime value={latest[game]?.publishedAt ?? latest[game]?.createdAt} /></p>
<p role="status">{cacheReady(game) ? 'Latest files ready on an eligible host.' : 'Launches can be queued now and will wait for a host to prepare the runtime and latest files.'}</p>
{:else}
<p>No files uploaded yet. <a href="/game-files" target="_blank" rel="noopener">Upload {gameTitle(game, true)} files</a> to prepare your hosts.</p>
{/if}
<section class="selection" aria-label="Deployment host and game files">
<div class="title">
<h3>Host and game files</h3>
<button
type="button"
disabled={disabled || loading}
onclick={() => {
onchange?.();
void load();
}}>Refresh hosts</button
>
</div>
{/each}{/if}
<p>Hosts keep the latest Black Ops I and Black Ops II files ready automatically. <a href="/game-files" target="_blank" rel="noopener">Upload game files</a> when they change. Existing servers keep their current files.</p>
<details><summary>Advanced: runtime override</summary><p>The configured default runtime is prepared automatically. Custom images must first be prepared by an admin from the destination host page.</p>{@render runtimeFields()}</details>
{#if network}<details><summary>Network overrides</summary>{@render networkFields()}</details>{/if}
</section>
{#if error}<p role="alert" class="error">{error}</p>{/if}
{#if loading}<p role="status">Loading host readiness and game files…</p>{/if}
<p>
Hosts are assigned when provisioning starts. You can queue launches while hosts are busy
or preparing; each server waits for an eligible host and available capacity.
</p>
{#if !loading && !hosts.length}<p>
<a href="/hosts" target="_blank" rel="noopener">Add a host</a>. Its runtime and
latest Black Ops I and Black Ops II files will be prepared automatically.
</p>{/if}
{#if !loading}{#each games as game}
<div class="game-files">
<strong>{game === 't5' ? 'Black Ops I' : 'Black Ops II'} files</strong>
{#if latest[game]}
<p>
Latest upload · <RelativeTime
value={latest[game]?.publishedAt ?? latest[game]?.createdAt}
/>
</p>
<p role="status">
{cacheReady(game)
? 'Latest files ready on an eligible host.'
: 'Launches can be queued now and will wait for a host to prepare the runtime and latest files.'}
</p>
{:else}
<p>
No files uploaded yet. <a
href="/game-files"
target="_blank"
rel="noopener">Upload {gameTitle(game, true)} files</a
> to prepare your hosts.
</p>
{/if}
</div>
{/each}{/if}
<p>
Hosts keep the latest Black Ops I and Black Ops II files ready automatically. <a
href="/game-files"
target="_blank"
rel="noopener">Upload game files</a
> when they change. Existing servers keep their current files.
</p>
<details>
<summary>Advanced: runtime override</summary>
<p>
The configured default runtime is prepared automatically. Custom images must first
be prepared by an admin from the destination host page.
</p>
{@render runtimeFields()}
</details>
{#if network}<details>
<summary>Network overrides</summary>{@render networkFields()}
</details>{/if}
</section>
{/if}
<style>
.game-files { display:grid; gap:5px; } .game-files strong { font-size: var(--text-sm); font-weight:600; }
.advanced { border-top:1px solid var(--border-subtle); padding-top:16px; }
.advanced summary { color:var(--text-secondary); }
.advanced-fields { padding-top:2px; }
fieldset { margin:18px 0 0; padding:0; border:0; min-width:0; }
legend { padding:0; font-size: var(--text-sm); font-weight:600; }
.selection { display:grid; gap:14px; padding:20px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface-sunken); } .title { display:flex; gap:12px; justify-content:space-between; align-items:center; } h3 { font-size:var(--text-sm); margin:0; } label { display:grid; gap:7px; font-size: var(--text-sm); } input { width:100%; box-sizing:border-box; padding:11px; color:var(--text-primary); background:var(--surface); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; } p { color:var(--text-secondary); font-size: var(--text-xs); line-height:1.6; margin:0; } a,button { color:var(--accent-primary-hover); } button { background:none; border:0; cursor:pointer; font:inherit; font-size: var(--text-xs); } button:disabled { opacity:.5; cursor:default; } .error { color:var(--state-danger); } summary { cursor:pointer; font-size: var(--text-sm); } details label { margin-top:14px; } details p { margin-top:12px; }
.game-files {
display: grid;
gap: 5px;
}
.game-files strong {
font-size: var(--text-sm);
font-weight: 600;
}
.advanced {
border-top: 1px solid var(--border-subtle);
padding-top: 16px;
}
.advanced summary {
color: var(--text-secondary);
}
.advanced-fields {
padding-top: 2px;
}
fieldset {
margin: 18px 0 0;
padding: 0;
border: 0;
min-width: 0;
}
legend {
padding: 0;
font-size: var(--text-sm);
font-weight: 600;
}
.selection {
display: grid;
gap: 14px;
padding: 20px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface-sunken);
}
.title {
display: flex;
gap: 12px;
justify-content: space-between;
align-items: center;
}
h3 {
font-size: var(--text-sm);
margin: 0;
}
label {
display: grid;
gap: 7px;
font-size: var(--text-sm);
}
input {
width: 100%;
box-sizing: border-box;
padding: 11px;
color: var(--text-primary);
background: var(--surface);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
font: inherit;
}
p {
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.6;
margin: 0;
}
a,
button {
color: var(--accent-primary-hover);
}
button {
background: none;
border: 0;
cursor: pointer;
font: inherit;
font-size: var(--text-xs);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
.error {
color: var(--state-danger);
}
summary {
cursor: pointer;
font-size: var(--text-sm);
}
details label {
margin-top: 14px;
}
details p {
margin-top: 12px;
}
</style>
+103 -22
View File
@@ -11,12 +11,32 @@
const groups = $derived(visibleNavigation(role));
const attention = $derived.by(() => {
if (!$fleet.receivedAt) return null;
const statuses = $fleetServers.map(server => ({ server, state: serverHealth(server, $uiNow).state }));
const servers = statuses.filter(({ server, state }) => !['online', 'starting', 'provisioning'].includes(state)
|| $fleet.operations.some(operation => operation.server === server.name && operation.state === 'attention')).length;
const hosts = $fleet.hostAttention ?? new Set(statuses.filter(({ server, state }) => server.host && state === 'host-offline').map(({ server }) => server.host!.id)).size;
const maintenance = $fleet.operations.filter(operation => operation.kind === 'maintenance' && operation.state === 'attention').length;
return { '/': servers, '/hosts': hosts, '/maintenance': maintenance } as Record<string, number>;
const statuses = $fleetServers.map((server) => ({
server,
state: serverHealth(server, $uiNow).state
}));
const servers = statuses.filter(
({ server, state }) =>
!['online', 'starting', 'provisioning'].includes(state) ||
$fleet.operations.some(
(operation) =>
operation.server === server.name && operation.state === 'attention'
)
).length;
const hosts =
$fleet.hostAttention ??
new Set(
statuses
.filter(({ server, state }) => server.host && state === 'host-offline')
.map(({ server }) => server.host!.id)
).size;
const maintenance = $fleet.operations.filter(
(operation) => operation.kind === 'maintenance' && operation.state === 'attention'
).length;
return { '/': servers, '/hosts': hosts, '/maintenance': maintenance } as Record<
string,
number
>;
});
</script>
@@ -24,25 +44,86 @@
{#each groups as group (group.id)}
<section aria-labelledby={`${id}-${group.id}`}>
<h2 id={`${id}-${group.id}`}>{group.label}</h2>
<ul>{#each group.items as item (item.href)}
<li><a href={item.href} aria-current={navigationItemActive(item, $page.url.pathname) ? 'page' : undefined}
aria-label={attention && attention[item.href] > 0 ? `${item.label}, ${attention[item.href]} ${attention[item.href] === 1 ? 'needs' : 'need'} attention` : undefined} onclick={() => onNavigate?.()}>
<span>{item.label}</span>
{#if attention && attention[item.href] > 0}<span class="attention" aria-hidden="true">{attention[item.href]}</span>{/if}
</a></li>
{/each}</ul>
<ul>
{#each group.items as item (item.href)}
<li>
<a
href={item.href}
aria-current={navigationItemActive(item, $page.url.pathname)
? 'page'
: undefined}
aria-label={attention && attention[item.href] > 0
? `${item.label}, ${attention[item.href]} ${attention[item.href] === 1 ? 'needs' : 'need'} attention`
: undefined}
onclick={() => onNavigate?.()}
>
<span>{item.label}</span>
{#if attention && attention[item.href] > 0}<span
class="attention"
aria-hidden="true">{attention[item.href]}</span
>{/if}
</a>
</li>
{/each}
</ul>
</section>
{/each}
</nav>
<style>
nav { display: grid; gap: var(--space-4); min-width: 0; }
h2 { margin: 0 0 var(--space-1); padding: 0 var(--space-3); color: var(--text-tertiary); font-size: var(--text-xs); font-weight: var(--weight-semibold); letter-spacing: var(--tracking-label); text-transform: uppercase; }
ul { list-style: none; padding: 0; margin: 0; }
a { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); min-height: 32px; padding: 5px var(--space-3); border-radius: var(--radius-sm); color: var(--text-secondary); font-size: var(--text-sm); text-decoration: none; }
a:hover { color: var(--text-primary); background: var(--surface-raised); }
a[aria-current] { color: var(--text-primary); background: var(--surface-raised); box-shadow: inset 2px 0 var(--accent-primary); font-weight: var(--weight-semibold); }
a:focus-visible { outline-offset: -2px; }
.attention { color: var(--state-warning); font-variant-numeric: tabular-nums; font-size: var(--text-xs); font-weight: var(--weight-semibold); }
@media (max-width: 960px) { a { min-height: 44px; } }
nav {
display: grid;
gap: var(--space-4);
min-width: 0;
}
h2 {
margin: 0 0 var(--space-1);
padding: 0 var(--space-3);
color: var(--text-tertiary);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
letter-spacing: var(--tracking-label);
text-transform: uppercase;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
a {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
min-height: 32px;
padding: 5px var(--space-3);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: var(--text-sm);
text-decoration: none;
}
a:hover {
color: var(--text-primary);
background: var(--surface-raised);
}
a[aria-current] {
color: var(--text-primary);
background: var(--surface-raised);
box-shadow: inset 2px 0 var(--accent-primary);
font-weight: var(--weight-semibold);
}
a:focus-visible {
outline-offset: -2px;
}
.attention {
color: var(--state-warning);
font-variant-numeric: tabular-nums;
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
}
@media (max-width: 960px) {
a {
min-height: 44px;
}
}
</style>
+211 -55
View File
@@ -3,72 +3,228 @@
import type { ActivityEvent, OperationStep } from '$lib/operation-flow.js';
import Button from './primitives/Button.svelte';
import type { Snippet } from 'svelte';
let { steps = [], events, active = false, showOverview = true, footer }: { steps?: OperationStep[]; events: ActivityEvent[]; active?: boolean; showOverview?: boolean; footer?: Snippet } = $props();
let {
steps = [],
events,
active = false,
showOverview = true,
footer
}: {
steps?: OperationStep[];
events: ActivityEvent[];
active?: boolean;
showOverview?: boolean;
footer?: Snippet;
} = $props();
let limit = $state(25);
let query = $state('');
let newestFirst = $state(true);
const latest = $derived(events.at(-1));
const matching = $derived(events.filter(event => event.message.toLowerCase().includes(query.toLowerCase())));
const visible = $derived(newestFirst ? matching.slice(-limit).reverse() : matching.slice(0, limit));
const matching = $derived(
events.filter((event) => event.message.toLowerCase().includes(query.toLowerCase()))
);
const visible = $derived(
newestFirst ? matching.slice(-limit).reverse() : matching.slice(0, limit)
);
</script>
{#if showOverview}
<section>
<h2>Operation flow</h2>
<ol class="flow">
{#each steps as step, index}
<li class={step.state} aria-current={step.state === 'current' ? 'step' : undefined}>
<span>{index + 1}</span>{step.label}
<span class="sr-only">({step.state})</span>
</li>
{/each}
</ol>
</section>
{/if}
<section>
{#if showOverview && latest}<p class="latest"><strong>Latest update</strong> {latest.message}<span class="time"><RelativeTime value={latest.created_at} /></span></p>{/if}
<details>
<summary>Activity history · {events.length} events{active ? ' · updates automatically' : ''}</summary>
{#if events.length}
<div class="tools"><label>Search activity<input type="search" bind:value={query} oninput={() => limit = 25} placeholder="Server, stage, or message" /></label><label>Order<select bind:value={newestFirst}><option value={true}>Newest first</option><option value={false}>Oldest first</option></select></label></div>
<ol class="timeline">
{#each visible as event (event.id)}
<li><span class="time"><RelativeTime value={event.created_at} /></span><p>{event.message}</p></li>
<section>
<h2>Operation flow</h2>
<ol class="flow">
{#each steps as step, index}
<li class={step.state} aria-current={step.state === 'current' ? 'step' : undefined}>
<span>{index + 1}</span>{step.label}
<span class="sr-only">({step.state})</span>
</li>
{/each}
</ol>
{#if !matching.length}<p class="muted">No matching activity.</p>{/if}
{#if matching.length > limit}<Button size="sm" onclick={() => limit += 25}>Show more activity ({matching.length - limit})</Button>{/if}
{:else}
<p class="muted">This operation predates detailed activity tracking. Its recorded status and timestamps are shown on this page.</p>
{/if}
{@render footer?.()}
</section>
{/if}
<section>
{#if showOverview && latest}<p class="latest">
<strong>Latest update</strong>
{latest.message}<span class="time"><RelativeTime value={latest.created_at} /></span>
</p>{/if}
<details>
<summary
>Activity history · {events.length} events{active
? ' · updates automatically'
: ''}</summary
>
{#if events.length}
<div class="tools">
<label
>Search activity<input
type="search"
bind:value={query}
oninput={() => (limit = 25)}
placeholder="Server, stage, or message"
/></label
><label
>Order<select bind:value={newestFirst}
><option value={true}>Newest first</option><option value={false}
>Oldest first</option
></select
></label
>
</div>
<ol class="timeline">
{#each visible as event (event.id)}
<li>
<span class="time"><RelativeTime value={event.created_at} /></span>
<p>{event.message}</p>
</li>
{/each}
</ol>
{#if !matching.length}<p class="muted">No matching activity.</p>{/if}
{#if matching.length > limit}<Button size="sm" onclick={() => (limit += 25)}
>Show more activity ({matching.length - limit})</Button
>{/if}
{:else}
<p class="muted">
This operation predates detailed activity tracking. Its recorded status and
timestamps are shown on this page.
</p>
{/if}
{@render footer?.()}
</details>
</section>
<style>
section { display: grid; gap: 16px; }
h2 { font-size: var(--text-md); margin: 0; }
details { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; }
summary { cursor: pointer; font-size: var(--text-sm); font-weight: var(--weight-semibold); }
details[open] summary { margin-bottom: 16px; }
.latest { padding: 12px 16px; background: var(--surface); border-radius: var(--radius-md); font-size: var(--text-sm); }
.latest strong { display: block; margin-bottom: 4px; }
.latest .time { display: block; margin-top: 4px; }
.tools { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 20px; }
label { display: grid; gap: 6px; font-size: var(--text-xs); color: var(--text-secondary); }
label:first-child { flex: 1; min-width: 180px; }
input, select { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); padding: 9px 12px; border-radius: var(--radius-sm); font: inherit; }
.time, .muted { color: var(--text-secondary); font-size: var(--text-sm); }
p { margin: 0; line-height: 1.6; }
.flow { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; }
.flow li { display: flex; align-items: center; gap: 10px; background: var(--surface); border-radius: var(--radius-md); padding: 12px; font-size: var(--text-sm); border: 1px solid var(--border-subtle); color: var(--text-secondary); }
.flow span { color: var(--text-primary); font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
.flow .done { color: var(--text-primary); }
.flow .current { color: var(--text-primary); border-color: var(--accent-primary); }
.flow .failed { color: var(--state-danger); border-color: var(--state-danger); }
.timeline { list-style: none; padding: 0 0 0 18px; margin: 0; border-left: 2px solid var(--border-strong); }
.timeline li { position: relative; padding: 0 0 24px 12px; overflow-wrap: anywhere; }
.timeline li::before { content: ''; position: absolute; left: -24px; top: 5px; width: 10px; height: 10px; border-radius: 50%; background: var(--accent-primary); }
.timeline p { margin-top: 6px; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
section {
display: grid;
gap: 16px;
}
h2 {
font-size: var(--text-md);
margin: 0;
}
details {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 16px;
}
summary {
cursor: pointer;
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
}
details[open] summary {
margin-bottom: 16px;
}
.latest {
padding: 12px 16px;
background: var(--surface);
border-radius: var(--radius-md);
font-size: var(--text-sm);
}
.latest strong {
display: block;
margin-bottom: 4px;
}
.latest .time {
display: block;
margin-top: 4px;
}
.tools {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
}
label {
display: grid;
gap: 6px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
label:first-child {
flex: 1;
min-width: 180px;
}
input,
select {
background: var(--surface-sunken);
color: var(--text-primary);
border: 1px solid var(--border-control);
padding: 9px 12px;
border-radius: var(--radius-sm);
font: inherit;
}
.time,
.muted {
color: var(--text-secondary);
font-size: var(--text-sm);
}
p {
margin: 0;
line-height: 1.6;
}
.flow {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 0;
margin: 0;
list-style: none;
}
.flow li {
display: flex;
align-items: center;
gap: 10px;
background: var(--surface);
border-radius: var(--radius-md);
padding: 12px;
font-size: var(--text-sm);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
}
.flow span {
color: var(--text-primary);
font-family: var(--font-numeric);
font-variant-numeric: tabular-nums;
}
.flow .done {
color: var(--text-primary);
}
.flow .current {
color: var(--text-primary);
border-color: var(--accent-primary);
}
.flow .failed {
color: var(--state-danger);
border-color: var(--state-danger);
}
.timeline {
list-style: none;
padding: 0 0 0 18px;
margin: 0;
border-left: 2px solid var(--border-strong);
}
.timeline li {
position: relative;
padding: 0 0 24px 12px;
overflow-wrap: anywhere;
}
.timeline li::before {
content: '';
position: absolute;
left: -24px;
top: 5px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent-primary);
}
.timeline p {
margin-top: 6px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
</style>
+14 -3
View File
@@ -3,14 +3,25 @@
import { formatTimeOffset } from '$ui/duration.js';
import { timeFormat } from '$ui/stores/time-zone.js';
let { value, start, missing = 'Not recorded' }: { value: Timestamp; start: Timestamp; missing?: string } = $props();
let {
value,
start,
missing = 'Not recorded'
}: { value: Timestamp; start: Timestamp; missing?: string } = $props();
const iso = $derived(timestampISO(value));
const offset = $derived(formatTimeOffset(start, value));
const absolute = $derived($timeFormat.dateTime(value));
</script>
{#if iso}<time datetime={iso} title={absolute} aria-label={offset ? `${offset} from operation start · ${absolute}` : undefined}>{offset ?? absolute}</time>{:else}<span>{missing}</span>{/if}
{#if iso}<time
datetime={iso}
title={absolute}
aria-label={offset ? `${offset} from operation start · ${absolute}` : undefined}
>{offset ?? absolute}</time
>{:else}<span>{missing}</span>{/if}
<style>
time { font-variant-numeric: tabular-nums; }
time {
font-variant-numeric: tabular-nums;
}
</style>
+32 -9
View File
@@ -25,15 +25,20 @@
<a class="row" href="/campaigns/{dep.id}">
<div class="row-head">
<span class="action">{dep.action || dep.id}</span>
<span class="count">{dep.servers.size} server{dep.servers.size === 1 ? '' : 's'}</span>
<span class="count"
>{dep.servers.size} server{dep.servers.size === 1 ? '' : 's'}</span
>
</div>
<div class="chips">
{#each [...dep.servers.entries()] as [srv, meta] (srv)}
<span
class={stateClass(meta.state)}
title={meta.error ?? (meta.players !== null ? `${meta.players}p` : meta.state)}
title={meta.error ??
(meta.players !== null ? `${meta.players}p` : meta.state)}
>
{srv}{meta.players !== null && meta.players > 0 ? ` ${meta.players}p` : ''}
{srv}{meta.players !== null && meta.players > 0
? ` ${meta.players}p`
: ''}
</span>
{/each}
</div>
@@ -72,7 +77,10 @@
margin: 0;
padding: 14px 0;
}
.rows { display: grid; gap: 10px; }
.rows {
display: grid;
gap: 10px;
}
.row {
display: block;
color: var(--text-primary);
@@ -83,16 +91,31 @@
border: 1px solid var(--border-subtle);
transition: border-color 0.15s;
}
.row:hover { border-color: var(--accent-primary); }
.row:hover {
border-color: var(--accent-primary);
}
.row-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8px;
}
.action { font-family: var(--font-body); font-size: var(--text-sm); color: var(--text-primary); }
.count { color: var(--text-tertiary); font-size: var(--text-xs); font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.action {
font-family: var(--font-body);
font-size: var(--text-sm);
color: var(--text-primary);
}
.count {
color: var(--text-tertiary);
font-size: var(--text-xs);
font-family: var(--font-numeric);
font-variant-numeric: tabular-nums;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
padding: 3px 9px;
border-radius: var(--radius-sm);
@@ -116,7 +139,7 @@
color: var(--state-warning);
}
.chip-held {
background: rgba(124, 192, 245, 0.10);
background: rgba(124, 192, 245, 0.1);
border-color: rgba(124, 192, 245, 0.28);
color: var(--text-primary);
}
+98 -14
View File
@@ -2,27 +2,111 @@
import { type PlayerMapRecord, type LeaderboardMetric } from '$lib/leaderboard.js';
import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.js';
let { maps, metric = 'highestRound', returnTo }: { maps: PlayerMapRecord[]; metric?: LeaderboardMetric; returnTo?: string } = $props();
let {
maps,
metric = 'highestRound',
returnTo
}: { maps: PlayerMapRecord[]; metric?: LeaderboardMetric; returnTo?: string } = $props();
const number = new Intl.NumberFormat('en-US');
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;
</script>
<div class="map-cards">
{#each maps as map (`${map.game}:${map.key}`)}
<article>
<header><h3><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric })}`}>{map.name}</a></h3><span>{map.game === 't5' ? 'Black Ops I' : 'Black Ops II'} · {number.format(map.gamesPlayed)} {map.gamesPlayed === 1 ? 'game' : 'games'}</span></header>
<dl>{#each records as record}<div><dt>{record.label}</dt><dd><a href={contextHref(`/matches/${map.records[record.key].matchId}`, returnTo ?? "/leaderboard")} 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><small aria-label={`${map.name} ${record.label} rank ${map.ranks[record.key]}`}>Rank #{number.format(map.ranks[record.key])}</small></dd></div>{/each}</dl>
<p>{number.format(map.kills)} total kills · {number.format(map.roundsSurvived)} observed rounds</p>
<header>
<h3>
<a
href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric })}`}
>{map.name}</a
>
</h3>
<span
>{map.game === 't5' ? 'Black Ops I' : 'Black Ops II'} · {number.format(
map.gamesPlayed
)}
{map.gamesPlayed === 1 ? 'game' : 'games'}</span
>
</header>
<dl>
{#each records as record}<div>
<dt>{record.label}</dt>
<dd>
<a
href={contextHref(
`/matches/${map.records[record.key].matchId}`,
returnTo ?? '/leaderboard'
)}
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
><small
aria-label={`${map.name} ${record.label} rank ${map.ranks[record.key]}`}
>Rank #{number.format(map.ranks[record.key])}</small
>
</dd>
</div>{/each}
</dl>
<p>
{number.format(map.kills)} total kills · {number.format(map.roundsSurvived)} observed
rounds
</p>
</article>
{/each}
</div>
<style>
article { min-width: 0; padding: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
h3, p, dl, dd { margin: 0; }
h3 { font-size: var(--text-md); }
header span, p, dt { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-2); margin: var(--space-3) 0; }
dd { font-family: var(--font-numeric); font-size: var(--text-lg); font-variant-numeric: tabular-nums; margin-top: var(--space-1); }
dd a { display: inline-flex; align-items: center; min-height: 44px; }
dd small { display: block; color: var(--text-secondary); font-family: var(--font-body); font-size: var(--text-xs); }
a { color: var(--accent-primary-hover); }
article {
min-width: 0;
padding: var(--space-4);
border-top: 1px solid var(--border-subtle);
overflow-wrap: anywhere;
}
h3,
p,
dl,
dd {
margin: 0;
}
h3 {
font-size: var(--text-md);
}
header span,
p,
dt {
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: var(--leading-body);
}
dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-2);
margin: var(--space-3) 0;
}
dd {
font-family: var(--font-numeric);
font-size: var(--text-lg);
font-variant-numeric: tabular-nums;
margin-top: var(--space-1);
}
dd a {
display: inline-flex;
align-items: center;
min-height: 44px;
}
dd small {
display: block;
color: var(--text-secondary);
font-family: var(--font-body);
font-size: var(--text-xs);
}
a {
color: var(--accent-primary-hover);
}
</style>
+62 -9
View File
@@ -7,22 +7,75 @@
let { matches, returnTo }: { matches: PlayerMatch[]; returnTo?: string } = $props();
const number = new Intl.NumberFormat('en-US');
</script>
<div class="match-cards">
{#each matches as match (match.id)}
<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><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>
{/each}
</div>
<style>
article { min-width: 0; padding: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
h3, p, dl, dd { margin: 0; }
h3 { font-size: var(--text-sm); line-height: var(--leading-body); }
h3 a { display: inline-flex; align-items: center; min-height: 44px; color: var(--accent-primary-hover); }
p, dt { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-2); margin-top: var(--space-3); }
dd { margin-top: var(--space-1); font-family: var(--font-numeric); font-size: var(--text-lg); font-variant-numeric: tabular-nums; }
article {
min-width: 0;
padding: var(--space-4);
border-top: 1px solid var(--border-subtle);
overflow-wrap: anywhere;
}
h3,
p,
dl,
dd {
margin: 0;
}
h3 {
font-size: var(--text-sm);
line-height: var(--leading-body);
}
h3 a {
display: inline-flex;
align-items: center;
min-height: 44px;
color: var(--accent-primary-hover);
}
p,
dt {
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: var(--leading-body);
}
dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-2);
margin-top: var(--space-3);
}
dd {
margin-top: var(--space-1);
font-family: var(--font-numeric);
font-size: var(--text-lg);
font-variant-numeric: tabular-nums;
}
</style>
+371 -73
View File
@@ -10,12 +10,32 @@
import Badge from './primitives/Badge.svelte';
import EmptyState from './primitives/EmptyState.svelte';
let { report, mapLabel, current = true, after = null, matchElapsed = null, activity }: { report: GameTelemetry | undefined; mapLabel?: string; current?: boolean; after?: string | null; matchElapsed?: string | null; activity?: string } = $props();
let {
report,
mapLabel,
current = true,
after = null,
matchElapsed = null,
activity
}: {
report: GameTelemetry | undefined;
mapLabel?: string;
current?: boolean;
after?: string | null;
matchElapsed?: string | null;
activity?: string;
} = $props();
const fresh = $derived(current && freshTelemetry(report, $uiNow, after));
const timingNow = $derived(report && (report.event === 'game_end' || !fresh)
? Date.parse(report.receivedAt) : $uiNow);
const displayElapsed = $derived(fresh ? matchElapsed : report?.timing?.server?.gameStartedAt && !report.timing.server.gameEndedAt
? elapsedSince(report.timing.server.gameStartedAt, timingNow) : null);
const timingNow = $derived(
report && (report.event === 'game_end' || !fresh) ? Date.parse(report.receivedAt) : $uiNow
);
const displayElapsed = $derived(
fresh
? matchElapsed
: report?.timing?.server?.gameStartedAt && !report.timing.server.gameEndedAt
? elapsedSince(report.timing.server.gameStartedAt, timingNow)
: null
);
const columns = [
{ key: 'score', label: 'Score' },
{ key: 'kills', label: 'Kills' },
@@ -23,19 +43,30 @@
{ key: 'downs', label: 'Downs' },
{ key: 'revives', label: 'Revives' }
] as const;
type StatKey = typeof columns[number]['key'];
type StatKey = (typeof columns)[number]['key'];
let sortBy = $state<StatKey>('score');
let descending = $state(true);
let expanded = $state<string[]>([]);
const uid = $props.id();
const number = new Intl.NumberFormat('en-US');
const players = $derived(report?.event === 'game_start'
? report.players.map(player => ({ ...player, score: null, kills: null, headshots: null, downs: null, revives: null }))
: report?.players ?? []);
const sorted = $derived([...players].sort((a, b) => {
const difference = (a[sortBy] ?? 0) - (b[sortBy] ?? 0);
return (descending ? -difference : difference) || a.name.localeCompare(b.name);
}));
const players = $derived(
report?.event === 'game_start'
? report.players.map((player) => ({
...player,
score: null,
kills: null,
headshots: null,
downs: null,
revives: null
}))
: (report?.players ?? [])
);
const sorted = $derived(
[...players].sort((a, b) => {
const difference = (a[sortBy] ?? 0) - (b[sortBy] ?? 0);
return (descending ? -difference : difference) || a.name.localeCompare(b.name);
})
);
function sort(key: StatKey) {
descending = sortBy === key ? !descending : true;
@@ -44,21 +75,36 @@
</script>
{#if !report}
<EmptyState title="Waiting for game data" description="The scoreboard will appear when this server reports a game start, round change, heartbeat, or game end." />
<EmptyState
title="Waiting for game data"
description="The scoreboard will appear when this server reports a game start, round change, heartbeat, or game end."
/>
{:else}
<header class="scoreboard-header">
<div class="match">
<span class="eyebrow">{report.event === 'game_end' ? 'Final scoreboard' : fresh ? 'Live scoreboard' : 'Last reported scoreboard'}</span>
<span class="eyebrow"
>{report.event === 'game_end'
? 'Final scoreboard'
: fresh
? 'Live scoreboard'
: 'Last reported scoreboard'}</span
>
<div class="match-title">
<h2 title={report.map}>{mapLabel ?? mapTitle(report.map)}</h2>
{#if report.event !== 'game_start'}<Badge size="md">Round {report.round}</Badge>{/if}
{#if displayElapsed}<span class="match-time">Match {displayElapsed}{fresh ? '' : ' at last report'}</span>{/if}
{#if report.event !== 'game_start'}<Badge size="md">Round {report.round}</Badge
>{/if}
{#if displayElapsed}<span class="match-time"
>Match {displayElapsed}{fresh ? '' : ' at last report'}</span
>{/if}
{#if report.event === 'game_end'}<Badge>Game ended</Badge>
{:else if report.event === 'game_start'}<Badge>Game started</Badge>{/if}
</div>
</div>
<div class="report-meta">
<span>{players.length} {players.length === 1 ? 'player' : 'players'}{fresh ? '' : ' in last report'}</span>
<span
>{players.length}
{players.length === 1 ? 'player' : 'players'}{fresh ? '' : ' in last report'}</span
>
{#if !fresh}<Badge variant="warning">Historical report</Badge>{/if}
<time datetime={report.receivedAt} title={$timeFormat.dateTime(report.receivedAt)}>
Updated {elapsedSince(report.receivedAt, $uiNow)} ago
@@ -67,20 +113,44 @@
</header>
{#if !players.length}
<EmptyState title="No players reported" description="This game report has an empty scoreboard." />
<EmptyState
title="No players reported"
description="This game report has an empty scoreboard."
/>
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll the table.) -->
<div class="table-scroll" role="region" aria-label="Player scoreboard" tabindex="0">
<table>
<caption class="sr-only">{report.event === 'game_end' ? 'Final' : 'Latest'} player scores for {report.server}</caption>
<caption class="sr-only"
>{report.event === 'game_end' ? 'Final' : 'Latest'} player scores for {report.server}</caption
>
<thead>
<tr>
<th scope="col" class="player">Player</th>
{#each columns as column (column.key)}
<th scope="col" class="stat" aria-sort={sortBy === column.key ? descending ? 'descending' : 'ascending' : 'none'}>
<button type="button" onclick={() => sort(column.key)}
aria-label={`Sort by ${column.label.toLowerCase()}, ${sortBy === column.key && descending ? 'ascending' : 'descending'}`}>
{column.label}<span class:active={sortBy === column.key} aria-hidden="true">{sortBy === column.key ? descending ? '↓' : '↑' : '↕'}</span>
<th
scope="col"
class="stat"
aria-sort={sortBy === column.key
? descending
? 'descending'
: 'ascending'
: 'none'}
>
<button
type="button"
onclick={() => sort(column.key)}
aria-label={`Sort by ${column.label.toLowerCase()}, ${sortBy === column.key && descending ? 'ascending' : 'descending'}`}
>
{column.label}<span
class:active={sortBy === column.key}
aria-hidden="true"
>{sortBy === column.key
? descending
? '↓'
: '↑'
: '↕'}</span
>
</button>
</th>
{/each}
@@ -91,73 +161,301 @@
{@const timing = report.timing?.players[player.guid]}
<tr>
<th scope="row" class="player">
<a class="player-name" href={playerProfileHref(player.guid)}>{player.name}</a>
<button class="player-details" aria-label={`Details for ${player.name}`} aria-expanded={expanded.includes(player.guid)} aria-controls={`${uid}-${player.guid}`} onclick={() => expanded = expanded.includes(player.guid) ? expanded.filter(guid => guid !== player.guid) : [...expanded, player.guid]}>Details</button>
<a class="player-name" href={playerProfileHref(player.guid)}
>{player.name}</a
>
<button
class="player-details"
aria-label={`Details for ${player.name}`}
aria-expanded={expanded.includes(player.guid)}
aria-controls={`${uid}-${player.guid}`}
onclick={() =>
(expanded = expanded.includes(player.guid)
? expanded.filter((guid) => guid !== player.guid)
: [...expanded, player.guid])}>Details</button
>
</th>
{#each columns as column (column.key)}
<td class="stat" class:score={column.key === 'score'}>
{#if player[column.key] === null}<span class="unreported" aria-label="Not yet reported">—</span>
{#if player[column.key] === null}<span
class="unreported"
aria-label="Not yet reported">—</span
>
{:else}{number.format(player[column.key]!)}{/if}
</td>
{/each}
</tr>
<tr id={`${uid}-${player.guid}`} hidden={!expanded.includes(player.guid)}><td colspan="6"><div class="player-metadata"><div class="guid"><span>GUID</span><CopyValue value={player.guid} label={`GUID for ${player.name}`} /></div><span>Time on instance: {timing ? elapsedSince(timing.serverJoinedAt, timingNow) : '—'}</span><span>Time in match: {timing ? elapsedSince(timing.matchJoinedAt, timingNow) : '—'}</span><span>Joined round: {timing?.roundStarted ?? '—'}</span></div></td></tr>
<tr id={`${uid}-${player.guid}`} hidden={!expanded.includes(player.guid)}
><td colspan="6"
><div class="player-metadata">
<div class="guid">
<span>GUID</span><CopyValue
value={player.guid}
label={`GUID for ${player.name}`}
/>
</div>
<span
>Time on instance: {timing
? elapsedSince(timing.serverJoinedAt, timingNow)
: '—'}</span
><span
>Time in match: {timing
? elapsedSince(timing.matchJoinedAt, timingNow)
: '—'}</span
><span>Joined round: {timing?.roundStarted ?? '—'}</span>
</div></td
></tr
>
{/each}
</tbody>
</table>
</div>
{/if}
<div class="scoreboard-footer">
<details><summary>About these stats</summary><p class="note">{report.event === 'game_start' ? 'Player stats arrive with the next round report or heartbeat.' : report.event === 'game_end' ? 'Final stats from this game. The scoreboard resets when the next game starts.' : 'Stats update at each round change, heartbeat, and game end.'}</p><p class="timing-note">Times and joining rounds are estimated from player reports. Time on instance carries across matches; ended or stale reports freeze at the last update.</p>{#if activity}<p class="timing-note">{activity}</p>{/if}</details>
<details>
<summary>About these stats</summary>
<p class="note">
{report.event === 'game_start'
? 'Player stats arrive with the next round report or heartbeat.'
: report.event === 'game_end'
? 'Final stats from this game. The scoreboard resets when the next game starts.'
: 'Stats update at each round change, heartbeat, and game end.'}
</p>
<p class="timing-note">
Times and joining rounds are estimated from player reports. Time on instance carries
across matches; ended or stale reports freeze at the last update.
</p>
{#if activity}<p class="timing-note">{activity}</p>{/if}
</details>
<a href="/leaderboard">View lifetime leaderboard →</a>
</div>
{/if}
<style>
.player-metadata { display: flex; flex-wrap: wrap; gap: 12px 24px; font-size: var(--text-xs); color: var(--text-secondary); }
summary { cursor: pointer; color: var(--text-secondary); }
.match-time { font-size: var(--text-sm); color: var(--text-secondary); }
button.player-details { font-size: var(--text-xs); color: var(--text-secondary); }
.scoreboard-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-4); margin-bottom: var(--space-3); }
.match { display: grid; gap: var(--space-2); min-width: 0; }
.eyebrow { font-size: var(--text-xs); font-weight: var(--weight-semibold); letter-spacing: var(--tracking-label); text-transform: uppercase; color: var(--text-tertiary); }
.match-title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); }
h2 { margin: 0; font-size: var(--text-lg); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
.report-meta { display: grid; gap: var(--space-1); text-align: right; font-size: var(--text-sm); color: var(--text-secondary); }
time { font-size: var(--text-xs); color: var(--text-tertiary); }
.table-scroll { max-width: 100%; overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.table-scroll:focus-visible { outline: none; box-shadow: var(--focus-ring); }
table { width: 100%; min-width: 560px; border-collapse: separate; border-spacing: 0; }
th, td { padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border-subtle); }
thead th { font-size: var(--text-xs); color: var(--text-secondary); font-weight: var(--weight-semibold); background: var(--surface-sunken); }
.player { text-align: left; position: sticky; left: 0; z-index: 1; }
thead .player { min-width: 160px; }
tbody .player { background: var(--surface); font-weight: var(--weight-medium); }
.player-name { display: block; max-width: 320px; overflow-wrap: anywhere; font-size: var(--text-sm); color: var(--text-primary); }
.player-name { text-decoration: none; }
.player-name:hover { color: var(--accent-primary-hover); text-decoration: underline; }
.player-name:focus-visible { outline: none; border-radius: var(--radius-sm); box-shadow: var(--focus-ring); }
.timing-note { margin: var(--space-2) 0 0; font-size: var(--text-xs); color: var(--text-tertiary); }
.guid { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin-top: var(--space-1); color: var(--text-tertiary); font-size: var(--text-xs); }
.stat { text-align: right; white-space: nowrap; }
td.stat { font: var(--text-sm) var(--font-numeric); font-variant-numeric: tabular-nums; }
.score { color: var(--text-primary); font-weight: var(--weight-semibold); }
.unreported { color: var(--text-tertiary); }
tbody tr:last-child > * { border-bottom: none; }
tbody tr:hover > * { background: var(--surface-raised); }
button { display: inline-flex; align-items: center; justify-content: flex-end; gap: var(--space-1); border: 0; background: transparent; padding: var(--space-1) 0; color: inherit; font: inherit; cursor: pointer; }
button span { color: var(--text-tertiary); }
button span.active, button:hover { color: var(--accent-primary-hover); }
button:focus-visible { outline: none; border-radius: var(--radius-sm); box-shadow: var(--focus-ring); }
.scoreboard-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; margin-top: var(--space-3); font-size: var(--text-xs); }
.scoreboard-footer a { text-decoration: none; }
.scoreboard-footer a:hover { text-decoration: underline; }
.note { margin: 0; font-size: var(--text-xs); color: var(--text-tertiary); }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.player-metadata {
display: flex;
flex-wrap: wrap;
gap: 12px 24px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
summary {
cursor: pointer;
color: var(--text-secondary);
}
.match-time {
font-size: var(--text-sm);
color: var(--text-secondary);
}
button.player-details {
font-size: var(--text-xs);
color: var(--text-secondary);
}
.scoreboard-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-4);
margin-bottom: var(--space-3);
}
.match {
display: grid;
gap: var(--space-2);
min-width: 0;
}
.eyebrow {
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
letter-spacing: var(--tracking-label);
text-transform: uppercase;
color: var(--text-tertiary);
}
.match-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
h2 {
margin: 0;
font-size: var(--text-lg);
font-weight: var(--weight-semibold);
overflow-wrap: anywhere;
}
.report-meta {
display: grid;
gap: var(--space-1);
text-align: right;
font-size: var(--text-sm);
color: var(--text-secondary);
}
time {
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.table-scroll {
max-width: 100%;
overflow-x: auto;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.table-scroll:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
table {
width: 100%;
min-width: 560px;
border-collapse: separate;
border-spacing: 0;
}
th,
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-subtle);
}
thead th {
font-size: var(--text-xs);
color: var(--text-secondary);
font-weight: var(--weight-semibold);
background: var(--surface-sunken);
}
.player {
text-align: left;
position: sticky;
left: 0;
z-index: 1;
}
thead .player {
min-width: 160px;
}
tbody .player {
background: var(--surface);
font-weight: var(--weight-medium);
}
.player-name {
display: block;
max-width: 320px;
overflow-wrap: anywhere;
font-size: var(--text-sm);
color: var(--text-primary);
}
.player-name {
text-decoration: none;
}
.player-name:hover {
color: var(--accent-primary-hover);
text-decoration: underline;
}
.player-name:focus-visible {
outline: none;
border-radius: var(--radius-sm);
box-shadow: var(--focus-ring);
}
.timing-note {
margin: var(--space-2) 0 0;
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.guid {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-1);
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.stat {
text-align: right;
white-space: nowrap;
}
td.stat {
font: var(--text-sm) var(--font-numeric);
font-variant-numeric: tabular-nums;
}
.score {
color: var(--text-primary);
font-weight: var(--weight-semibold);
}
.unreported {
color: var(--text-tertiary);
}
tbody tr:last-child > * {
border-bottom: none;
}
tbody tr:hover > * {
background: var(--surface-raised);
}
button {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-1);
border: 0;
background: transparent;
padding: var(--space-1) 0;
color: inherit;
font: inherit;
cursor: pointer;
}
button span {
color: var(--text-tertiary);
}
button span.active,
button:hover {
color: var(--accent-primary-hover);
}
button:focus-visible {
outline: none;
border-radius: var(--radius-sm);
box-shadow: var(--focus-ring);
}
.scoreboard-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
margin-top: var(--space-3);
font-size: var(--text-xs);
}
.scoreboard-footer a {
text-decoration: none;
}
.scoreboard-footer a:hover {
text-decoration: underline;
}
.note {
margin: 0;
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 640px) {
.report-meta { text-align: left; }
th, td { padding: var(--space-3); }
thead .player { min-width: 150px; }
.player-name { max-width: 220px; }
.report-meta {
text-align: left;
}
th,
td {
padding: var(--space-3);
}
thead .player {
min-width: 150px;
}
.player-name {
max-width: 220px;
}
}
</style>
+299 -68
View File
@@ -7,8 +7,22 @@
import Button from './primitives/Button.svelte';
import Badge from './primitives/Badge.svelte';
let { pluginId, targetPath, disabled = false, busy = $bindable(false), onimport }: {
pluginId?: number; targetPath?: string; disabled?: boolean; busy?: boolean; onimport?: (result: { pluginId: number; betaUrl: string; alreadyImported: boolean }) => void | Promise<void>;
let {
pluginId,
targetPath,
disabled = false,
busy = $bindable(false),
onimport
}: {
pluginId?: number;
targetPath?: string;
disabled?: boolean;
busy?: boolean;
onimport?: (result: {
pluginId: number;
betaUrl: string;
alreadyImported: boolean;
}) => void | Promise<void>;
} = $props();
let url = $state('');
let name = $state('');
@@ -18,55 +32,89 @@
let update = $state<PluginUpdate | null>(null);
let checking = $state(false);
let error = $state('');
let success = $state<{ pluginId: number; betaUrl: string; sha256: string; alreadyImported: boolean } | null>(null);
let success = $state<{
pluginId: number;
betaUrl: string;
sha256: string;
alreadyImported: boolean;
} | null>(null);
let active = true;
onDestroy(() => { active = false; });
onDestroy(() => {
active = false;
});
function showRelease(value: GithubPluginRelease) {
release = value;
const preferred = update?.source?.asset_name ?? targetPath?.split('/').pop();
assetId = String(value.assets.find(a => a.name === preferred)?.id ?? (value.assets.length === 1 ? value.assets[0].id : ''));
assetId = String(
value.assets.find((a) => a.name === preferred)?.id ??
(value.assets.length === 1 ? value.assets[0].id : '')
);
if (!pluginId && !name) name = value.repository.split('/')[1];
url = value.url;
error = ''; success = null;
error = '';
success = null;
}
async function checkUpdates() {
if (!pluginId || checking) return;
checking = true; error = '';
checking = true;
error = '';
try {
const response = await fetch(`/api/plugins/updates?pluginId=${pluginId}`);
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not check for updates.');
update = body.updates[0];
if (!url && update?.source) url = `https://github.com/${update.source.repository}`;
} catch (e) { error = (e as Error).message; }
finally { checking = false; }
} catch (e) {
error = (e as Error).message;
} finally {
checking = false;
}
}
onMount(() => { void checkUpdates(); });
onMount(() => {
void checkUpdates();
});
async function preview(event: SubmitEvent) {
event.preventDefault();
if (busy || disabled) return;
busy = true; error = ''; release = null; success = null;
busy = true;
error = '';
release = null;
success = null;
try {
const response = await fetch(`/api/plugins/github/release?url=${encodeURIComponent(url.trim())}`);
const response = await fetch(
`/api/plugins/github/release?url=${encodeURIComponent(url.trim())}`
);
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not load release.');
showRelease(body.release);
} catch (e) { error = (e as Error).message; }
finally { busy = false; }
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
async function importRelease(startBeta: boolean) {
if (!release || !assetId || busy || disabled) return;
const startPath = window.location.pathname;
busy = true; error = ''; success = null;
busy = true;
error = '';
success = null;
try {
const response = await fetch('/api/plugins/github/import', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: release.url, assetId: Number(assetId), pluginId,
name: name.trim(), scopeType: game ? 'game' : null, scopeValue: game || null })
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: release.url,
assetId: Number(assetId),
pluginId,
name: name.trim(),
scopeType: game ? 'game' : null,
scopeValue: game || null
})
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Import failed.');
@@ -75,29 +123,57 @@
await onimport?.(body);
if (startBeta && window.location.pathname === startPath) await goto(body.betaUrl);
else if (pluginId) await checkUpdates();
} catch (e) { error = (e as Error).message; }
finally { busy = false; }
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
</script>
<Card padding="lg">
<section class="github" aria-label="GitHub plugin releases">
<div class="heading">
<div><h2>{pluginId ? 'GitHub releases' : 'Import from GitHub'}</h2><p>Choose a release and DLL, then test it with your fleet.</p></div>
{#if pluginId}<Button size="sm" onclick={checkUpdates} loading={checking} disabled={busy}>Check for updates</Button>{/if}
<div>
<h2>{pluginId ? 'GitHub releases' : 'Import from GitHub'}</h2>
<p>Choose a release and DLL, then test it with your fleet.</p>
</div>
{#if pluginId}<Button
size="sm"
onclick={checkUpdates}
loading={checking}
disabled={busy}>Check for updates</Button
>{/if}
</div>
{#if checking}<p role="status">Checking GitHub…</p>{/if}
{#if update?.source}
<p>Following <a href={`https://github.com/${update.source.repository}`} target="_blank" rel="noreferrer">{update.source.repository}</a> · {update.source.asset_name}</p>
<p>
Following <a
href={`https://github.com/${update.source.repository}`}
target="_blank"
rel="noreferrer">{update.source.repository}</a
>
· {update.source.asset_name}
</p>
{/if}
{#if update?.status === 'available' && update.release}
<div class="update">
<Badge variant="info">New release: {update.release.tag}</Badge>
<Button size="sm" disabled={busy || disabled} onclick={() => showRelease(update!.release!)}>Review update</Button>
<Button
size="sm"
disabled={busy || disabled}
onclick={() => showRelease(update!.release!)}>Review update</Button
>
</div>
{:else if update?.status === 'imported' && update.release}
<div class="update"><span>{update.release.tag} is already imported and available for testing.</span>
<Button size="sm" href={`/betas?pluginId=${pluginId}&versionId=${update.versionId}`} disabled={disabled}>Start beta</Button></div>
<div class="update">
<span>{update.release.tag} is already imported and available for testing.</span>
<Button
size="sm"
href={`/betas?pluginId=${pluginId}&versionId=${update.versionId}`}
{disabled}>Start beta</Button
>
</div>
{:else if update?.status === 'current'}
<p role="status">The latest stable release is promoted.</p>
{:else if update?.status === 'ahead'}
@@ -108,44 +184,105 @@
<p>Import a release below to track updates for this plugin.</p>
{/if}
<form onsubmit={preview} class="lookup">
<label>Repository or release URL
<input type="url" bind:value={url} oninput={() => { release = null; success = null; }} required
placeholder="https://github.com/alicealys/t6-gsc-utils/releases/tag/v3.1.0" disabled={busy || disabled} />
<label
>Repository or release URL
<input
type="url"
bind:value={url}
oninput={() => {
release = null;
success = null;
}}
required
placeholder="https://github.com/alicealys/t6-gsc-utils/releases/tag/v3.1.0"
disabled={busy || disabled}
/>
</label>
<Button type="submit" loading={busy && !release} disabled={busy || disabled || !url.trim()}>Preview release</Button>
<Button
type="submit"
loading={busy && !release}
disabled={busy || disabled || !url.trim()}>Preview release</Button
>
</form>
{#if disabled}<p>Finish the running beta before importing a release.</p>{/if}
{#if release}
<div class="preview">
<div class="heading"><h3>{release.name || release.tag}</h3><Badge variant={release.prerelease ? 'warning' : 'neutral'}>{release.prerelease ? 'Prerelease' : 'Release'} · {release.tag}</Badge></div>
<p><a href={release.url} target="_blank" rel="noreferrer">View on GitHub ↗</a> · Published {$timeFormat.date(release.publishedAt)}</p>
<details open><summary>Release notes</summary><pre>{release.notes || 'No release notes provided.'}</pre></details>
<div class="heading">
<h3>{release.name || release.tag}</h3>
<Badge variant={release.prerelease ? 'warning' : 'neutral'}
>{release.prerelease ? 'Prerelease' : 'Release'} · {release.tag}</Badge
>
</div>
<p>
<a href={release.url} target="_blank" rel="noreferrer">View on GitHub ↗</a> ·
Published {$timeFormat.date(release.publishedAt)}
</p>
<details open>
<summary>Release notes</summary>
<pre>{release.notes || 'No release notes provided.'}</pre>
</details>
{#if release.assets.length === 0}
<p>No importable DLL assets found. Choose another release or upload a DLL manually. Direct DLL assets up to 64 MB are supported.</p>
<p>
No importable DLL assets found. Choose another release or upload a DLL
manually. Direct DLL assets up to 64 MB are supported.
</p>
{:else}
<div class="fields">
<label>DLL asset<select bind:value={assetId} disabled={busy || disabled}>
<option value="" disabled>Choose a DLL…</option>
{#each release.assets as asset}<option value={String(asset.id)}>{asset.name} · {(asset.size / 1024).toFixed(1)} KB</option>{/each}
</select></label>
<label
>DLL asset<select bind:value={assetId} disabled={busy || disabled}>
<option value="" disabled>Choose a DLL…</option>
{#each release.assets as asset}<option value={String(asset.id)}
>{asset.name} · {(asset.size / 1024).toFixed(1)} KB</option
>{/each}
</select></label
>
{#if !pluginId}
<label>Plugin name<input bind:value={name} disabled={busy} placeholder="t6-gsc-utils" /></label>
<label>Deploy scope<select bind:value={game} disabled={busy}><option value="">Unscoped — select servers in a beta</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></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">Black Ops I</option><option value="t6"
>Black Ops II</option
></select
></label
>
{/if}
</div>
{#if targetPath}<p>New versions deploy to <code>{targetPath}</code>.</p>{/if}
<div class="actions">
<Button variant="primary" disabled={busy || disabled || !assetId || (!pluginId && !name.trim())} loading={busy} onclick={() => importRelease(true)}>Import and test</Button>
<Button disabled={busy || disabled || !assetId || (!pluginId && !name.trim())} onclick={() => importRelease(false)}>Import only</Button>
<Button
variant="primary"
disabled={busy || disabled || !assetId || (!pluginId && !name.trim())}
loading={busy}
onclick={() => importRelease(true)}>Import and test</Button
>
<Button
disabled={busy || disabled || !assetId || (!pluginId && !name.trim())}
onclick={() => importRelease(false)}>Import only</Button
>
</div>
<p>Imports are saved for testing. Choose servers and a test plan before starting a beta.</p>
<p>
Imports are saved for testing. Choose servers and a test plan before
starting a beta.
</p>
{/if}
</div>
{/if}
{#if success}
<div class="success" role="status">
<strong>{success.alreadyImported ? 'This binary is already imported.' : 'Release imported.'}</strong>
<a href={`/plugins/${success.pluginId}`}>View plugin</a> · <a href={success.betaUrl}>Start beta</a>
<strong
>{success.alreadyImported
? 'This binary is already imported.'
: 'Release imported.'}</strong
>
<a href={`/plugins/${success.pluginId}`}>View plugin</a> ·
<a href={success.betaUrl}>Start beta</a>
<span class="checksum">SHA-256: {success.sha256}</span>
</div>
{/if}
@@ -154,27 +291,121 @@
</Card>
<style>
.github, .preview { display: grid; gap: var(--space-4); }
.heading, .actions, .update { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
.actions { justify-content: flex-start; }
h2, h3, p { margin: 0; }
h2 { font-size: var(--text-md); font-weight: var(--weight-semibold); }
h3 { font-size: var(--text-md); }
p { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }
.heading p { margin-top: var(--space-2); }
.lookup { display: flex; align-items: flex-end; gap: var(--space-3); flex-wrap: wrap; }
.lookup label { flex: 1 1 300px; }
label { display: grid; gap: var(--space-2); font-size: var(--text-sm); }
input, select { width: 100%; min-width: 0; padding: 10px 12px; background: var(--surface-sunken); border: 1px solid var(--border-strong); border-radius: var(--radius-md); color: var(--text-primary); font: inherit; }
input:focus-visible, select:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.fields { display: grid; gap: var(--space-3); grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); }
.preview { padding-top: var(--space-4); border-top: 1px solid var(--border-subtle); }
a { color: var(--accent-primary-hover); }
pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 280px; overflow-y: auto; font: inherit; font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.6; }
summary { cursor: pointer; font-size: var(--text-sm); }
.error { color: var(--state-danger); }
.success { display: block; padding: var(--space-3); background: var(--surface-sunken); border-radius: var(--radius-md); font-size: var(--text-sm); }
.success strong, .checksum { display: block; margin-bottom: var(--space-2); }
.checksum { overflow-wrap: anywhere; font: var(--text-xs) var(--font-mono); margin-top: var(--space-2); }
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
.github,
.preview {
display: grid;
gap: var(--space-4);
}
.heading,
.actions,
.update {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
}
.actions {
justify-content: flex-start;
}
h2,
h3,
p {
margin: 0;
}
h2 {
font-size: var(--text-md);
font-weight: var(--weight-semibold);
}
h3 {
font-size: var(--text-md);
}
p {
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.6;
}
.heading p {
margin-top: var(--space-2);
}
.lookup {
display: flex;
align-items: flex-end;
gap: var(--space-3);
flex-wrap: wrap;
}
.lookup label {
flex: 1 1 300px;
}
label {
display: grid;
gap: var(--space-2);
font-size: var(--text-sm);
}
input,
select {
width: 100%;
min-width: 0;
padding: 10px 12px;
background: var(--surface-sunken);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
color: var(--text-primary);
font: inherit;
}
input:focus-visible,
select:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.fields {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
}
.preview {
padding-top: var(--space-4);
border-top: 1px solid var(--border-subtle);
}
a {
color: var(--accent-primary-hover);
}
pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 280px;
overflow-y: auto;
font: inherit;
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: 1.6;
}
summary {
cursor: pointer;
font-size: var(--text-sm);
}
.error {
color: var(--state-danger);
}
.success {
display: block;
padding: var(--space-3);
background: var(--surface-sunken);
border-radius: var(--radius-md);
font-size: var(--text-sm);
}
.success strong,
.checksum {
display: block;
margin-bottom: var(--space-2);
}
.checksum {
overflow-wrap: anywhere;
font: var(--text-xs) var(--font-mono);
margin-top: var(--space-2);
}
input:not([type='checkbox']):not([type='file']),
select {
border-color: var(--border-control);
}
</style>
+67 -17
View File
@@ -8,41 +8,91 @@
let checking = $state(false);
let checked = $state(false);
let error = $state('');
const tracked = $derived(updates.filter(u => u.status !== 'untracked'));
const tracked = $derived(updates.filter((u) => u.status !== 'untracked'));
async function check() {
if (checking) return;
checking = true; error = '';
checking = true;
error = '';
try {
const response = await fetch('/api/plugins/updates');
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not check updates.');
updates = body.updates; checked = true; onchecked?.(updates);
} catch (e) { error = (e as Error).message; }
finally { checking = false; }
updates = body.updates;
checked = true;
onchecked?.(updates);
} catch (e) {
error = (e as Error).message;
} finally {
checking = false;
}
}
onMount(() => { void check(); });
onMount(() => {
void check();
});
</script>
<Card padding="lg">
<div class="heading"><h2>Release updates</h2><Button size="sm" loading={checking} onclick={check}>Check for updates</Button></div>
<div class="heading">
<h2>Release updates</h2>
<Button size="sm" loading={checking} onclick={check}>Check for updates</Button>
</div>
<p>Latest stable GitHub releases. Checks are cached for five minutes.</p>
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if checked && !tracked.length}<p>Import a plugin from GitHub to start tracking its releases.</p>{/if}
{#if checked && !tracked.length}<p>
Import a plugin from GitHub to start tracking its releases.
</p>{/if}
{#each tracked as update (update.pluginId)}
<div class="row">
<div><a href={`/plugins/${update.pluginId}`}>{update.name}</a>
<p>{update.status === 'available' ? `${update.release?.tag} available` : update.status === 'imported' ? `${update.release?.tag} imported — awaiting promotion` : update.status === 'current' ? `${update.release?.tag} promoted` : update.status === 'ahead' ? 'Ahead of the latest stable release' : update.error}</p>
<div>
<a href={`/plugins/${update.pluginId}`}>{update.name}</a>
<p>
{update.status === 'available'
? `${update.release?.tag} available`
: update.status === 'imported'
? `${update.release?.tag} imported — awaiting promotion`
: update.status === 'current'
? `${update.release?.tag} promoted`
: update.status === 'ahead'
? 'Ahead of the latest stable release'
: update.error}
</p>
</div>
{#if update.status === 'available'}<Button size="sm" href={`/plugins/${update.pluginId}#github-releases`}>Review update</Button>{/if}
{#if update.status === 'available'}<Button
size="sm"
href={`/plugins/${update.pluginId}#github-releases`}>Review update</Button
>{/if}
</div>
{/each}
</Card>
<style>
.heading, .row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
h2 { margin: 0; font-size: var(--text-md); font-weight: var(--weight-semibold); }
p { color: var(--text-secondary); font-size: var(--text-sm); margin: var(--space-2) 0 0; }
.row { padding-top: var(--space-3); margin-top: var(--space-3); border-top: 1px solid var(--border-subtle); }
a { color: var(--accent-primary-hover); }
.error { color: var(--state-danger); }
.heading,
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
}
h2 {
margin: 0;
font-size: var(--text-md);
font-weight: var(--weight-semibold);
}
p {
color: var(--text-secondary);
font-size: var(--text-sm);
margin: var(--space-2) 0 0;
}
.row {
padding-top: var(--space-3);
margin-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
}
a {
color: var(--accent-primary-hover);
}
.error {
color: var(--state-danger);
}
</style>
+264 -43
View File
@@ -23,7 +23,9 @@
let requestNumber = 0;
let controller: AbortController | null = null;
let disposed = false;
const newEvents = $derived(baselineTotal === null ? 0 : Math.max(0, detail.activity.total - baselineTotal));
const newEvents = $derived(
baselineTotal === null ? 0 : Math.max(0, detail.activity.total - baselineTotal)
);
async function load(more = false) {
if (more && (loading || cursor === null)) return;
@@ -33,7 +35,8 @@
const request = ++requestNumber;
const id = detail.deployment.id;
const current = () => !disposed && request === requestNumber && detail.deployment.id === id;
loading = true; error = '';
loading = true;
error = '';
const requestedQuery = more ? appliedQuery : query;
const requestedOrder = more ? appliedOrder : order;
const params = new URLSearchParams({ order: requestedOrder, limit: '25' });
@@ -43,18 +46,44 @@
params.set('watermark', String(watermark));
}
let timedOut = false;
const timeout = setTimeout(() => { timedOut = true; currentController.abort(); }, 15_000);
const timeout = setTimeout(() => {
timedOut = true;
currentController.abort();
}, 15_000);
try {
const response = await fetch(`/api/provisioning/${encodeURIComponent(id)}/events?${params}`, { signal: currentController.signal, cache: 'no-store' });
const response = await fetch(
`/api/provisioning/${encodeURIComponent(id)}/events?${params}`,
{ signal: currentController.signal, cache: 'no-store' }
);
if (!response.ok) throw new Error('Could not load activity history.');
const body: unknown = await response.json();
if (!isProvisioningEventPage(body)) throw new Error('The activity response was incomplete.');
if (!isProvisioningEventPage(body))
throw new Error('The activity response was incomplete.');
if (!current()) return;
entries = more ? [...entries, ...body.events.filter(event => !entries.some(previous => previous.id === event.id))] : body.events;
total = body.total; watermark = body.watermark; cursor = body.nextCursor; loaded = true;
if (!more) { baselineTotal = body.totalEvents; appliedQuery = requestedQuery; appliedOrder = requestedOrder; }
entries = more
? [
...entries,
...body.events.filter(
(event) => !entries.some((previous) => previous.id === event.id)
)
]
: body.events;
total = body.total;
watermark = body.watermark;
cursor = body.nextCursor;
loaded = true;
if (!more) {
baselineTotal = body.totalEvents;
appliedQuery = requestedQuery;
appliedOrder = requestedOrder;
}
} catch (reason) {
if (current() && (!currentController.signal.aborted || timedOut)) error = timedOut ? 'Activity refresh timed out. Try again.' : reason instanceof Error ? reason.message : 'Could not load activity history.';
if (current() && (!currentController.signal.aborted || timedOut))
error = timedOut
? 'Activity refresh timed out. Try again.'
: reason instanceof Error
? reason.message
: 'Could not load activity history.';
} finally {
clearTimeout(timeout);
if (current()) loading = false;
@@ -65,55 +94,247 @@
if (expanded && !loaded && !loading) void load();
}
function jumpToLatest() {
query = appliedQuery; order = 'newest';
query = appliedQuery;
order = 'newest';
void load();
}
onDestroy(() => { disposed = true; requestNumber++; controller?.abort(); });
onDestroy(() => {
disposed = true;
requestNumber++;
controller?.abort();
});
</script>
<section aria-labelledby="activity-title" class="activity">
<h2 id="activity-title">Activity log</h2>
<details class="history" ontoggle={toggle}>
<summary>Full activity history <span>{countLabel(detail.activity.total, 'event')}</span></summary>
<summary
>Full activity history <span>{countLabel(detail.activity.total, 'event')}</span
></summary
>
<div class="history-content">
{#if newEvents > 0}<div class="new-events" role="status"><span>{newEvents} new event{newEvents === 1 ? '' : 's'} since this view was loaded.</span><Button size="sm" onclick={jumpToLatest}>Jump to latest</Button></div>{/if}
<form class="tools" onsubmit={event => { event.preventDefault(); void load(); }}>
<label class="search">Search activity<input type="search" bind:value={query} placeholder="Find a stage or message" /></label>
<label>Order<select bind:value={order} onchange={() => { void load(); }}><option value="newest">Newest first</option><option value="oldest">Oldest first</option></select></label>
{#if newEvents > 0}<div class="new-events" role="status">
<span
>{newEvents} new event{newEvents === 1 ? '' : 's'} since this view was loaded.</span
><Button size="sm" onclick={jumpToLatest}>Jump to latest</Button>
</div>{/if}
<form
class="tools"
onsubmit={(event) => {
event.preventDefault();
void load();
}}
>
<label class="search"
>Search activity<input
type="search"
bind:value={query}
placeholder="Find a stage or message"
/></label
>
<label
>Order<select
bind:value={order}
onchange={() => {
void load();
}}
><option value="newest">Newest first</option><option value="oldest"
>Oldest first</option
></select
></label
>
<Button type="submit" disabled={loading}>Search</Button>
</form>
{#if error}<div class="history-error" role="alert"><p>{error} {loaded ? 'Your previous history view is still shown.' : ''}</p><Button size="sm" onclick={() => load()}>Retry history</Button></div>{/if}
{#if loaded}<p class="muted">{appliedOrder === 'newest' ? 'Newest first' : 'Oldest first'}{appliedQuery ? ` · Matching “${appliedQuery}”` : ' · All messages'}</p>{/if}
{#if error}<div class="history-error" role="alert">
<p>{error} {loaded ? 'Your previous history view is still shown.' : ''}</p>
<Button size="sm" onclick={() => load()}>Retry history</Button>
</div>{/if}
{#if loaded}<p class="muted">
{appliedOrder === 'newest' ? 'Newest first' : 'Oldest first'}{appliedQuery
? ` · Matching “${appliedQuery}”`
: ' · All messages'}
</p>{/if}
{#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">
{#each entries as event (event.id)}<li><time class="time" datetime={timestampISO(event.created_at)}>{$timeFormat.dateTime(event.created_at)}</time><p>{event.message}</p></li>{/each}
</ol>{/if}
{#if loaded}<div class="footer"><span>Showing {entries.length} of {total} matching events</span>{#if cursor !== null}<Button size="sm" loading={loading} disabled={loading} onclick={() => load(true)}>Show more activity</Button>{/if}</div>{/if}
{#each entries as event (event.id)}<li>
<time class="time" datetime={timestampISO(event.created_at)}
>{$timeFormat.dateTime(event.created_at)}</time
>
<p>{event.message}</p>
</li>{/each}
</ol>{/if}
{#if loaded}<div class="footer">
<span>Showing {entries.length} of {total} matching events</span
>{#if cursor !== null}<Button
size="sm"
{loading}
disabled={loading}
onclick={() => load(true)}>Show more activity</Button
>{/if}
</div>{/if}
</div>
</details>
</section>
<style>
.activity { display: grid; gap: var(--space-4); min-width: 0; }
h2 { margin: 0; font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }
p { margin: 0; line-height: 1.65; overflow-wrap: anywhere; font-size: var(--text-sm); }
.muted, .time, .footer { font-size: var(--text-xs); color: var(--text-secondary); }
.empty { padding: var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--text-secondary); }
.timeline { list-style: none; margin: 0 0 0 var(--space-1); padding: 0 0 0 var(--space-5); border-left: 1px solid var(--border-strong); }
.timeline li { padding: 0 0 var(--space-5); position: relative; }.timeline li:last-child { padding-bottom: 0; }
.timeline li::before { content: ''; position: absolute; top: 6px; left: -24px; width: 7px; height: 7px; background: var(--border-control); border-radius: var(--radius-pill); }
.timeline p { margin-top: var(--space-1); }
.history { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); }
summary { cursor: pointer; padding: var(--space-5); font-size: var(--text-sm); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
summary span { margin-left: var(--space-2); color: var(--text-tertiary); font-size: var(--text-xs); font-weight: var(--weight-regular); }
summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: -3px; border-radius: var(--radius-sm); }
.history-content { padding: 0 var(--space-5) var(--space-5); display: grid; gap: var(--space-5); }
.tools { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); }.tools label { display: grid; gap: var(--space-2); font-size: var(--text-xs); color: var(--text-secondary); }.search { flex: 1 1 160px; }
input, select { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-sm); padding: 10px; font: inherit; min-height: 40px; width: 100%; }
input:focus-visible, select:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 2px; }
.new-events { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); }.new-events span { flex: 1; }
.footer { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); flex-wrap: wrap; line-height: 1.6; }
.history-error { display: grid; justify-items: start; gap: var(--space-3); color: var(--state-danger); }
@media (max-width: 600px) { summary { padding: var(--space-4); }.history-content { padding: 0 var(--space-4) var(--space-4); }.tools { align-items: stretch; }.tools .search { flex-basis: 100%; } }
.activity {
display: grid;
gap: var(--space-4);
min-width: 0;
}
h2 {
margin: 0;
font-size: var(--text-lg);
letter-spacing: var(--tracking-tight);
}
p {
margin: 0;
line-height: 1.65;
overflow-wrap: anywhere;
font-size: var(--text-sm);
}
.muted,
.time,
.footer {
font-size: var(--text-xs);
color: var(--text-secondary);
}
.empty {
padding: var(--space-4);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-secondary);
}
.timeline {
list-style: none;
margin: 0 0 0 var(--space-1);
padding: 0 0 0 var(--space-5);
border-left: 1px solid var(--border-strong);
}
.timeline li {
padding: 0 0 var(--space-5);
position: relative;
}
.timeline li:last-child {
padding-bottom: 0;
}
.timeline li::before {
content: '';
position: absolute;
top: 6px;
left: -24px;
width: 7px;
height: 7px;
background: var(--border-control);
border-radius: var(--radius-pill);
}
.timeline p {
margin-top: var(--space-1);
}
.history {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
}
summary {
cursor: pointer;
padding: var(--space-5);
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
overflow-wrap: anywhere;
}
summary span {
margin-left: var(--space-2);
color: var(--text-tertiary);
font-size: var(--text-xs);
font-weight: var(--weight-regular);
}
summary:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: -3px;
border-radius: var(--radius-sm);
}
.history-content {
padding: 0 var(--space-5) var(--space-5);
display: grid;
gap: var(--space-5);
}
.tools {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--space-3);
}
.tools label {
display: grid;
gap: var(--space-2);
font-size: var(--text-xs);
color: var(--text-secondary);
}
.search {
flex: 1 1 160px;
}
input,
select {
background: var(--surface-sunken);
color: var(--text-primary);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
padding: 10px;
font: inherit;
min-height: 40px;
width: 100%;
}
input:focus-visible,
select:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.new-events {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: var(--space-3);
color: var(--text-secondary);
font-size: var(--text-sm);
}
.new-events span {
flex: 1;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
line-height: 1.6;
}
.history-error {
display: grid;
justify-items: start;
gap: var(--space-3);
color: var(--state-danger);
}
@media (max-width: 600px) {
summary {
padding: var(--space-4);
}
.history-content {
padding: 0 var(--space-4) var(--space-4);
}
.tools {
align-items: stretch;
}
.tools .search {
flex-basis: 100%;
}
}
</style>
+452 -81
View File
@@ -1,86 +1,245 @@
<script lang="ts">
import OperationTime from '$ui/components/OperationTime.svelte';
import type { ProvisioningDetail } from '$lib/provisioning-detail.js';
import { activeProvisioningStates, provisioningCheckValue, provisioningGroups, progressIcons, progressStateLabels } from '$ui/provisioning-presentation.js';
import {
activeProvisioningStates,
provisioningCheckValue,
provisioningGroups,
progressIcons,
progressStateLabels
} from '$ui/provisioning-presentation.js';
let { detail, hostHistoryHref }: { detail: ProvisioningDetail; hostHistoryHref?: string } = $props();
let { detail, hostHistoryHref }: { detail: ProvisioningDetail; hostHistoryHref?: string } =
$props();
const groups = $derived(provisioningGroups(detail));
const progress = $derived(detail.progress);
const active = $derived(activeProvisioningStates.includes(detail.deployment.state));
let expanded = $state<Record<string, boolean>>({});
const evidenceLabels = { pass: 'Passed', waiting: 'Waiting', fail: 'Failed', unknown: 'Unknown' };
const sources = { agent: 'Host agent', rcon: 'RCON', telemetry: 'Telemetry', combined: 'RCON and telemetry', manifest: 'Plutonium manifest' };
const evidenceLabels = {
pass: 'Passed',
waiting: 'Waiting',
fail: 'Failed',
unknown: 'Unknown'
};
const sources = {
agent: 'Host agent',
rcon: 'RCON',
telemetry: 'Telemetry',
combined: 'RCON and telemetry',
manifest: 'Plutonium manifest'
};
</script>
<section class="execution" aria-labelledby="execution-title">
<div class="section-heading"><div><span class="eyebrow">Execution</span><h2 id="execution-title">Progress and evidence</h2></div></div>
<div class="section-heading">
<div>
<span class="eyebrow">Execution</span>
<h2 id="execution-title">Progress and evidence</h2>
</div>
</div>
{#if !progress}
<div class="legacy"><h3>{detail.deployment.stage}</h3><p>Detailed checkpoints were not recorded for this operation. Its recorded outcome and original activity remain available below.</p></div>
<div class="legacy">
<h3>{detail.deployment.stage}</h3>
<p>
Detailed checkpoints were not recorded for this operation. Its recorded outcome and
original activity remain available below.
</p>
</div>
{/if}
{#if groups.length}
<ol class="phases">
{#each groups as group (group.key)}
{@const defaultOpen = detail.deployment.state !== 'done' || group.steps.some(step => step.state === 'failed' || step.checks.some(check => check.result !== 'pass'))}
<li class:current={active && (group.state === 'current' || group.state === 'waiting')} class:failed={group.state === 'failed'}>
{@const defaultOpen =
detail.deployment.state !== 'done' ||
group.steps.some(
(step) =>
step.state === 'failed' ||
step.checks.some((check) => check.result !== 'pass')
)}
<li
class:current={active &&
(group.state === 'current' || group.state === 'waiting')}
class:failed={group.state === 'failed'}
>
<details open={expanded[group.key] ?? defaultOpen}>
<summary
onclick={event => { event.preventDefault(); expanded[group.key] = !(expanded[group.key] ?? defaultOpen); }}
onkeydown={event => {
onclick={(event) => {
event.preventDefault();
expanded[group.key] = !(expanded[group.key] ?? defaultOpen);
}}
onkeydown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
if (!event.repeat) expanded[group.key] = !(expanded[group.key] ?? defaultOpen);
if (!event.repeat)
expanded[group.key] = !(expanded[group.key] ?? defaultOpen);
}}
>
<span class="phase-icon state-{group.state}" aria-hidden="true">{progressIcons[group.state]}</span>
<span class="phase-icon state-{group.state}" aria-hidden="true"
>{progressIcons[group.state]}</span
>
<span class="phase-title">{group.label}</span>
<span class="phase-state state-{group.state}">{progressStateLabels[group.state]}</span>
<span class="phase-state state-{group.state}"
>{progressStateLabels[group.state]}</span
>
</summary>
<ol class="steps">
{#each group.steps as step (step.key)}
<li aria-current={active && (step.state === 'current' || step.state === 'waiting') ? 'step' : undefined}>
<span class="step-icon state-{step.state}" aria-hidden="true">{progressIcons[step.state]}</span>
<li
aria-current={active &&
(step.state === 'current' || step.state === 'waiting')
? 'step'
: undefined}
>
<span class="step-icon state-{step.state}" aria-hidden="true"
>{progressIcons[step.state]}</span
>
<div class="step-content">
<strong>{step.label}</strong>
<span class="step-meta state-{step.state}">{progressStateLabels[step.state]}{#if step.at}{' · '}<span class="time"><OperationTime start={detail.deployment.started_at} value={step.at} /></span>{/if}</span>
<span class="step-meta state-{step.state}"
>{progressStateLabels[
step.state
]}{#if step.at}{' · '}<span class="time"
><OperationTime
start={detail.deployment.started_at}
value={step.at}
/></span
>{/if}</span
>
{#each step.checks as check (check.key)}
<div class="evidence">
{#if step.checks.length > 1 || check.key === 'plutonium-release'}<p class="check-label">{check.label}{' · '}{evidenceLabels[check.result]}</p>{/if}
{#if step.checks.length > 1 || check.key === 'plutonium-release'}<p
class="check-label"
>
{check.label}{' · '}{evidenceLabels[
check.result
]}
</p>{/if}
<dl class="values">
{#if check.expected !== null}<div><dt>Expected</dt><dd>{provisioningCheckValue(check.key, check.expected)}</dd></div>{/if}
<div><dt>Observed</dt><dd>{provisioningCheckValue(check.key, check.actual)}</dd></div>
{#if check.expected !== null}<div>
<dt>Expected</dt>
<dd>
{provisioningCheckValue(
check.key,
check.expected
)}
</dd>
</div>{/if}
<div>
<dt>Observed</dt>
<dd>
{provisioningCheckValue(
check.key,
check.actual
)}
</dd>
</div>
</dl>
{#if check.message}<p>{check.message}</p>{/if}
<div class="evidence-meta">
<span>{check.source ? sources[check.source] : 'Source not recorded'}</span>
{#if check.checkedAt !== step.at}<span>Checked <OperationTime start={detail.deployment.started_at} value={check.checkedAt} /></span>{/if}
{#if check.receivedAt}<span>Evidence received <OperationTime start={detail.deployment.started_at} value={check.receivedAt} /></span>{/if}
<span
>{check.source
? sources[check.source]
: 'Source not recorded'}</span
>
{#if check.checkedAt !== step.at}<span
>Checked <OperationTime
start={detail.deployment.started_at}
value={check.checkedAt}
/></span
>{/if}
{#if check.receivedAt}<span
>Evidence received <OperationTime
start={detail.deployment.started_at}
value={check.receivedAt}
/></span
>{/if}
</div>
</div>
{/each}
{#if step.key === 'verify-map' && detail.deployment.expected_map}<p class="map-note">Engine-map verification does not independently verify a variant or start location.</p>{/if}
{#if step.key === 'verify-map' && detail.deployment.expected_map}<p
class="map-note"
>
Engine-map verification does not independently
verify a variant or start location.
</p>{/if}
{#if step.command}
<div class="evidence" class:uncertain={step.command.state === 'uncertain'}>
<p class="command-label">{step.command.state === 'attempted' ? 'Command submission attempted' : step.command.state === 'accepted' ? 'Remote request accepted' : step.command.state === 'rejected' ? 'Remote request rejected' : 'Remote response uncertain'}</p>
<div
class="evidence"
class:uncertain={step.command.state === 'uncertain'}
>
<p class="command-label">
{step.command.state === 'attempted'
? 'Command submission attempted'
: step.command.state === 'accepted'
? 'Remote request accepted'
: step.command.state === 'rejected'
? 'Remote request rejected'
: 'Remote response uncertain'}
</p>
<p>{step.command.message}</p>
<span class="time"><OperationTime start={detail.deployment.started_at} value={step.command.at} /></span>
<span class="time"
><OperationTime
start={detail.deployment.started_at}
value={step.command.at}
/></span
>
</div>
{/if}
{#if step.agentOperation}
<div class="evidence">
<dl class="agent-details">
<div><dt>Agent state</dt><dd>{step.agentOperation.state}</dd></div>
<div><dt>Command queued</dt><dd><OperationTime start={detail.deployment.started_at} value={step.agentOperation.createdAt} missing="Not recorded" /></dd></div>
<div><dt>Latest agent report</dt><dd><OperationTime start={detail.deployment.started_at} value={step.agentOperation.updatedAt} missing="Not recorded" /></dd></div>
<div>
<dt>Agent state</dt>
<dd>{step.agentOperation.state}</dd>
</div>
<div>
<dt>Command queued</dt>
<dd>
<OperationTime
start={detail.deployment.started_at}
value={step.agentOperation
.createdAt}
missing="Not recorded"
/>
</dd>
</div>
<div>
<dt>Latest agent report</dt>
<dd>
<OperationTime
start={detail.deployment.started_at}
value={step.agentOperation
.updatedAt}
missing="Not recorded"
/>
</dd>
</div>
</dl>
{#if step.agentOperation.error}<p class="agent-error">{step.agentOperation.error}</p>{/if}
<a href={hostHistoryHref ?? `/hosts/operations?server=${encodeURIComponent(detail.deployment.server)}`}>View host operation history</a>
{#if step.agentOperation.error}<p
class="agent-error"
>
{step.agentOperation.error}
</p>{/if}
<a
href={hostHistoryHref ??
`/hosts/operations?server=${encodeURIComponent(detail.deployment.server)}`}
>View host operation history</a
>
</div>
{/if}
{#if step.checkpoints.length}<ul class="checkpoints">
{#each step.checkpoints as checkpoint (checkpoint.key)}
<li><span>{checkpoint.label}</span><span class="time"><OperationTime start={detail.deployment.started_at} value={checkpoint.at} /></span></li>
{/each}
</ul>{/if}
{#each step.checkpoints as checkpoint (checkpoint.key)}
<li>
<span>{checkpoint.label}</span><span
class="time"
><OperationTime
start={detail.deployment.started_at}
value={checkpoint.at}
/></span
>
</li>
{/each}
</ul>{/if}
</div>
</li>
{/each}
@@ -93,50 +252,262 @@
</section>
<style>
.execution { display: grid; gap: var(--space-5); min-width: 0; }
h2, h3, p { margin: 0; }
h2 { font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }
h3 { font-size: var(--text-sm); font-weight: var(--weight-semibold); }
.eyebrow { display: block; margin-bottom: var(--space-2); font-size: var(--text-xs); color: var(--text-tertiary); }
.phases, .steps, .checkpoints { list-style: none; padding: 0; margin: 0; }
.phases { display: grid; gap: var(--space-3); }
.phases > li { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
.phases > li.current { border-color: color-mix(in srgb, var(--accent-primary) 60%, var(--border-subtle)); }
.phases > li.failed { border-color: color-mix(in srgb, var(--state-danger) 60%, var(--border-subtle)); }
summary { cursor: pointer; }
.phases summary { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-5); list-style: none; }
.phases summary::-webkit-details-marker { display: none; }
.phases summary::after { content: '+'; color: var(--text-tertiary); font-size: var(--text-md); }
.phases details[open] > summary::after { content: '−'; }
summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: -3px; border-radius: var(--radius-sm); }
.phase-icon { flex: 0 0 26px; display: grid; place-items: center; width: 26px; height: 26px; border: 1px solid var(--border-strong); border-radius: var(--radius-pill); font-size: var(--text-sm); }
.phase-title { flex: 1; font-size: var(--text-sm); font-weight: var(--weight-semibold); }
.phase-state { font-size: var(--text-xs); }
.steps { border-top: 1px solid var(--border-subtle); padding: var(--space-4) var(--space-5); display: grid; gap: var(--space-4); }
.steps > li { display: flex; align-items: flex-start; gap: var(--space-3); }
.step-icon { flex: 0 0 26px; text-align: center; 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; }
.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); }
.legacy { display: grid; gap: var(--space-2); padding: var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
p { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.65; overflow-wrap: anywhere; }
.legacy p { font-size: var(--text-sm); }
.step-content { min-width: 0; flex: 1; overflow-wrap: anywhere; }
.evidence { display: grid; gap: var(--space-1); margin-top: var(--space-2); font-size: var(--text-xs); }
.check-label, .command-label { color: var(--text-primary); font-weight: var(--weight-medium); }
.uncertain .command-label { color: var(--state-warning); }
.agent-error { color: var(--state-danger); }
dl { display: flex; gap: var(--space-2) var(--space-4); margin: 0; flex-wrap: wrap; line-height: 1.6; }
dt { color: var(--text-tertiary); }dd { margin: 0; color: var(--text-secondary); overflow-wrap: anywhere; }
.values > div { display: flex; flex-wrap: wrap; gap: 0 var(--space-1); }
.agent-details { margin-block: var(--space-1); }.agent-details > div { min-width: 0; }
.time, .evidence-meta, .checkpoints { font-size: var(--text-xs); }
.evidence-meta { display: flex; gap: var(--space-1) var(--space-4); flex-wrap: wrap; color: var(--text-tertiary); line-height: 1.6; }
.checkpoints { display: grid; gap: var(--space-2); margin-top: var(--space-3); color: var(--text-secondary); line-height: 1.6; }
.checkpoints li { display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-3); }
.map-note { margin-top: var(--space-2); }
a { color: var(--accent-primary-hover); text-underline-offset: 3px; }
a:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; border-radius: 2px; }
@media (max-width: 600px) { .phases summary { padding: var(--space-4); gap: var(--space-2); flex-wrap: wrap; }.phase-title { flex-basis: calc(100% - 70px); }.phase-state { padding-left: 34px; order: 1; flex-basis: 100%; }.phases summary::after { order: 0; }.steps { padding: var(--space-4); } }
.execution {
display: grid;
gap: var(--space-5);
min-width: 0;
}
h2,
h3,
p {
margin: 0;
}
h2 {
font-size: var(--text-lg);
letter-spacing: var(--tracking-tight);
}
h3 {
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
}
.eyebrow {
display: block;
margin-bottom: var(--space-2);
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.phases,
.steps,
.checkpoints {
list-style: none;
padding: 0;
margin: 0;
}
.phases {
display: grid;
gap: var(--space-3);
}
.phases > li {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
overflow: hidden;
}
.phases > li.current {
border-color: color-mix(in srgb, var(--accent-primary) 60%, var(--border-subtle));
}
.phases > li.failed {
border-color: color-mix(in srgb, var(--state-danger) 60%, var(--border-subtle));
}
summary {
cursor: pointer;
}
.phases summary {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-5);
list-style: none;
}
.phases summary::-webkit-details-marker {
display: none;
}
.phases summary::after {
content: '+';
color: var(--text-tertiary);
font-size: var(--text-md);
}
.phases details[open] > summary::after {
content: '−';
}
summary:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: -3px;
border-radius: var(--radius-sm);
}
.phase-icon {
flex: 0 0 26px;
display: grid;
place-items: center;
width: 26px;
height: 26px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-pill);
font-size: var(--text-sm);
}
.phase-title {
flex: 1;
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
}
.phase-state {
font-size: var(--text-xs);
}
.steps {
border-top: 1px solid var(--border-subtle);
padding: var(--space-4) var(--space-5);
display: grid;
gap: var(--space-4);
}
.steps > li {
display: flex;
align-items: flex-start;
gap: var(--space-3);
}
.step-icon {
flex: 0 0 26px;
text-align: center;
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;
}
.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);
}
.legacy {
display: grid;
gap: var(--space-2);
padding: var(--space-4);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
p {
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.65;
overflow-wrap: anywhere;
}
.legacy p {
font-size: var(--text-sm);
}
.step-content {
min-width: 0;
flex: 1;
overflow-wrap: anywhere;
}
.evidence {
display: grid;
gap: var(--space-1);
margin-top: var(--space-2);
font-size: var(--text-xs);
}
.check-label,
.command-label {
color: var(--text-primary);
font-weight: var(--weight-medium);
}
.uncertain .command-label {
color: var(--state-warning);
}
.agent-error {
color: var(--state-danger);
}
dl {
display: flex;
gap: var(--space-2) var(--space-4);
margin: 0;
flex-wrap: wrap;
line-height: 1.6;
}
dt {
color: var(--text-tertiary);
}
dd {
margin: 0;
color: var(--text-secondary);
overflow-wrap: anywhere;
}
.values > div {
display: flex;
flex-wrap: wrap;
gap: 0 var(--space-1);
}
.agent-details {
margin-block: var(--space-1);
}
.agent-details > div {
min-width: 0;
}
.time,
.evidence-meta,
.checkpoints {
font-size: var(--text-xs);
}
.evidence-meta {
display: flex;
gap: var(--space-1) var(--space-4);
flex-wrap: wrap;
color: var(--text-tertiary);
line-height: 1.6;
}
.checkpoints {
display: grid;
gap: var(--space-2);
margin-top: var(--space-3);
color: var(--text-secondary);
line-height: 1.6;
}
.checkpoints li {
display: flex;
flex-wrap: wrap;
gap: var(--space-1) var(--space-3);
}
.map-note {
margin-top: var(--space-2);
}
a {
color: var(--accent-primary-hover);
text-underline-offset: 3px;
}
a:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 3px;
border-radius: 2px;
}
@media (max-width: 600px) {
.phases summary {
padding: var(--space-4);
gap: var(--space-2);
flex-wrap: wrap;
}
.phase-title {
flex-basis: calc(100% - 70px);
}
.phase-state {
padding-left: 34px;
order: 1;
flex-basis: 100%;
}
.phases summary::after {
order: 0;
}
.steps {
padding: var(--space-4);
}
}
</style>
+36 -4
View File
@@ -2,10 +2,42 @@
import type { RecordEvidence } from '$lib/leaderboard.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { contextHref } from '$lib/navigation-context.js';
let { evidence, returnTo, metric = 'Record' }: { evidence: RecordEvidence; returnTo?: string; metric?: string; disclosure?: boolean } = $props();
let {
evidence,
returnTo,
metric = 'Record'
}: {
evidence: RecordEvidence;
returnTo?: string;
metric?: string;
disclosure?: boolean;
} = $props();
</script>
<span class="record-source"><a href={returnTo ? contextHref(`/matches/${evidence.matchId}`, returnTo) : `/matches/${evidence.matchId}`} aria-label={`${metric}: ${evidence.mapName}, Match #${evidence.matchId}`} title={evidence.endedAt ? `Completed ${$timeFormat.dateTime(evidence.endedAt)}` : 'Completed match; end time unknown'}>Match #{evidence.matchId} · {evidence.mapName}</a></span>
<span class="record-source"
><a
href={returnTo
? contextHref(`/matches/${evidence.matchId}`, returnTo)
: `/matches/${evidence.matchId}`}
aria-label={`${metric}: ${evidence.mapName}, Match #${evidence.matchId}`}
title={evidence.endedAt
? `Completed ${$timeFormat.dateTime(evidence.endedAt)}`
: 'Completed match; end time unknown'}>Match #{evidence.matchId} · {evidence.mapName}</a
></span
>
<style>
.record-source { display: block; margin-top: var(--space-1); white-space: normal; overflow-wrap: anywhere; font-family: var(--font-body); font-size: var(--text-xs); font-weight: normal; line-height: var(--leading-body); }
a { color: var(--accent-primary-hover); }
.record-source {
display: block;
margin-top: var(--space-1);
white-space: normal;
overflow-wrap: anywhere;
font-family: var(--font-body);
font-size: var(--text-xs);
font-weight: normal;
line-height: var(--leading-body);
}
a {
color: var(--accent-primary-hover);
}
</style>
+8 -2
View File
@@ -4,9 +4,15 @@
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();
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}
{#if iso}<time class={className} datetime={iso} title={$timeFormat.dateTime(value)}
>{$timeFormat.relative(value, now)}</time
>{:else}<span class={className}>{missing}</span>{/if}
+180 -37
View File
@@ -5,63 +5,206 @@
let busy = $state(false);
let error = $state('');
let notice = $state('');
let preview = $state<{ policies: { category: string; label: string; eligible: number; payloadBytes: number; records: { id: string }[] }[];
controls: { category: string; id: string; protected: number; resolvedAt: string | null }[] } | null>(null);
let preview = $state<{
policies: {
category: string;
label: string;
eligible: number;
payloadBytes: number;
records: { id: string }[];
}[];
controls: { category: string; id: string; protected: number; resolvedAt: string | null }[];
} | null>(null);
let category = $state('campaign');
let recordId = $state('');
const categories = ['campaign','provisioning','operation','observation','maintenance','recovery','match','beta','file-content','beta-file-content','version','audit','game-file','upload'];
const categories = [
'campaign',
'provisioning',
'operation',
'observation',
'maintenance',
'recovery',
'match',
'beta',
'file-content',
'beta-file-content',
'version',
'audit',
'game-file',
'upload'
];
async function refresh() {
busy = true; error = '';
try { const response = await fetch('/api/settings/retention/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }); if (!response.ok) throw new Error('Could not load cleanup preview.'); preview = await response.json(); }
catch (cause) { error = (cause as Error).message; } finally { busy = false; }
}
async function control(action: { protected?: boolean; resolve?: boolean }, selectedCategory = category, id = recordId) {
busy = true; error = ''; notice = '';
busy = true;
error = '';
try {
const response = await fetch('/api/settings/retention',{ method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ category: selectedCategory,id,...action }) });
const result = await response.json(); if (!response.ok) throw new Error(result.error);
notice = action.resolve ? 'Failure marked resolved. Retention still checks all recovery dependencies.' : action.protected ? 'Record protected from cleanup.' : 'Record protection removed.';
const response = await fetch('/api/settings/retention/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
if (!response.ok) throw new Error('Could not load cleanup preview.');
preview = await response.json();
} catch (cause) {
error = (cause as Error).message;
} finally {
busy = false;
}
}
async function control(
action: { protected?: boolean; resolve?: boolean },
selectedCategory = category,
id = recordId
) {
busy = true;
error = '';
notice = '';
try {
const response = await fetch('/api/settings/retention', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: selectedCategory, id, ...action })
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
notice = action.resolve
? 'Failure marked resolved. Retention still checks all recovery dependencies.'
: action.protected
? 'Record protected from cleanup.'
: 'Record protection removed.';
await refresh();
} catch (cause) { error = (cause as Error).message; } finally { busy = false; }
} catch (cause) {
error = (cause as Error).message;
} finally {
busy = false;
}
}
</script>
<details>
<summary>Cleanup preview and protected records</summary>
<p>Preview uses the values currently entered above, without saving them. Background cleanup runs in batches. Known payload sizes are estimates; database file size may not shrink immediately. Expired data cannot be restored by increasing retention.</p>
<Button disabled={busy} onclick={refresh}>{busy ? 'Loading…' : 'Refresh cleanup preview'}</Button>
<p>
Preview uses the values currently entered above, without saving them. Background cleanup
runs in batches. Known payload sizes are estimates; database file size may not shrink
immediately. Expired data cannot be restored by increasing retention.
</p>
<Button disabled={busy} onclick={refresh}
>{busy ? 'Loading…' : 'Refresh cleanup preview'}</Button
>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if notice}<p role="status">{notice}</p>{/if}
{#if preview}
<div class="table-wrap"><table><thead><tr><th>Category</th><th>Eligible records</th><th>Known payload size</th></tr></thead><tbody>
{#each preview.policies as policy}<tr><td>{policy.label}</td><td>{policy.eligible}</td><td>{policy.payloadBytes ? `${(policy.payloadBytes / 1024 / 1024).toFixed(2)} MiB` : '—'}</td></tr>{/each}
</tbody></table></div>
{#each preview.policies.filter(policy => policy.records.length) as policy}
<details><summary>{policy.label}: eligible IDs (up to 20)</summary>
{#each policy.records as record}<div class="record"><code>{record.id}</code><Button disabled={busy} onclick={() => control({ protected: true },policy.category,record.id)}>Protect</Button></div>{/each}
<div class="table-wrap">
<table>
<thead
><tr><th>Category</th><th>Eligible records</th><th>Known payload size</th></tr
></thead
><tbody>
{#each preview.policies as policy}<tr
><td>{policy.label}</td><td>{policy.eligible}</td><td
>{policy.payloadBytes
? `${(policy.payloadBytes / 1024 / 1024).toFixed(2)} MiB`
: '—'}</td
></tr
>{/each}
</tbody>
</table>
</div>
{#each preview.policies.filter((policy) => policy.records.length) as policy}
<details>
<summary>{policy.label}: eligible IDs (up to 20)</summary>
{#each policy.records as record}<div class="record">
<code>{record.id}</code><Button
disabled={busy}
onclick={() => control({ protected: true }, policy.category, record.id)}
>Protect</Button
>
</div>{/each}
</details>
{/each}
<h3>Protected records and resolved failures</h3>
{#each preview.controls as record}<div class="record"><span>{record.category} · <code>{record.id}</code>{record.resolvedAt ? ' · Resolved' : ''}</span>
{#if record.protected}<Button disabled={busy} onclick={() => control({ protected: false },record.category,record.id)}>Remove protection</Button>{/if}</div>{:else}<p>No explicit record protections or resolutions.</p>{/each}
{#each preview.controls as record}<div class="record">
<span
>{record.category} · <code>{record.id}</code>{record.resolvedAt
? ' · Resolved'
: ''}</span
>
{#if record.protected}<Button
disabled={busy}
onclick={() => control({ protected: false }, record.category, record.id)}
>Remove protection</Button
>{/if}
</div>{:else}<p>No explicit record protections or resolutions.</p>{/each}
{/if}
<h3>Protect a record or resolve a failure</h3>
<p>Use the ID from its detail URL or the preview. Resolving a failed campaign stops automatic retry requests for that campaign; unfinished recovery still prevents cleanup.</p>
<p>
Use the ID from its detail URL or the preview. Resolving a failed campaign stops automatic
retry requests for that campaign; unfinished recovery still prevents cleanup.
</p>
<div class="record">
<label>Category<select bind:value={category}>{#each categories as value}<option value={value}>{value}</option>{/each}</select></label>
<label
>Category<select bind:value={category}
>{#each categories as value}<option {value}>{value}</option>{/each}</select
></label
>
<label>Record ID<input bind:value={recordId} maxlength="200" /></label>
<Button disabled={busy || !recordId} onclick={() => control({ protected: true })}>Protect record</Button>
{#if ['campaign','provisioning','operation'].includes(category)}<Button disabled={busy || !recordId} onclick={() => control({ resolve: true })}>Mark failure resolved</Button>{/if}
<Button disabled={busy || !recordId} onclick={() => control({ protected: true })}
>Protect record</Button
>
{#if ['campaign', 'provisioning', 'operation'].includes(category)}<Button
disabled={busy || !recordId}
onclick={() => control({ resolve: true })}>Mark failure resolved</Button
>{/if}
</div>
</details>
<style>
details { margin-top: 20px; }
summary { cursor: pointer; }
p { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.65; }
.record { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin: 12px 0; }
code { overflow-wrap: anywhere; }
label { display: grid; gap: 8px; }
select,input { font: inherit; color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-control); border-radius: var(--radius-sm); padding: 8px; }
.table-wrap { overflow-x: auto; margin: 16px 0; }
table { width: 100%; border-collapse: collapse; text-align: left; font-size: var(--text-sm); }
th,td { padding: 10px; border-bottom: 1px solid var(--border-strong); }
details {
margin-top: 20px;
}
summary {
cursor: pointer;
}
p {
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.65;
}
.record {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
margin: 12px 0;
}
code {
overflow-wrap: anywhere;
}
label {
display: grid;
gap: 8px;
}
select,
input {
font: inherit;
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
padding: 8px;
}
.table-wrap {
overflow-x: auto;
margin: 16px 0;
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: var(--text-sm);
}
th,
td {
padding: 10px;
border-bottom: 1px solid var(--border-strong);
}
</style>
+28 -7
View File
@@ -4,21 +4,42 @@
import { freshStatusTimestamp } from '$lib/server-freshness.js';
import { elapsedSince } from '$ui/duration.js';
import type { GameTelemetry } from '$lib/telemetry.js';
let { name, since = null, report: observation }: { name: string; since?: string | null; report?: GameTelemetry } = $props();
let {
name,
since = null,
report: observation
}: { name: string; since?: string | null; report?: GameTelemetry } = $props();
let report = $derived(observation ?? $telemetryByServer.get(name));
let fresh = $derived(report && freshStatusTimestamp(report.receivedAt, $uiNow)
&& (!since || Date.parse(report.receivedAt) >= Date.parse(since)));
let fresh = $derived(
report &&
freshStatusTimestamp(report.receivedAt, $uiNow) &&
(!since || Date.parse(report.receivedAt) >= Date.parse(since))
);
</script>
{#if report && (report.event === 'round_change' || report.event === 'heartbeat') && fresh}
<div class="round">
<strong>Round {report.round}</strong>
<span title={$timeFormat.dateTime(report.receivedAt)}>Last reported {elapsedSince(report.receivedAt, $uiNow)} ago</span>
<span title={$timeFormat.dateTime(report.receivedAt)}
>Last reported {elapsedSince(report.receivedAt, $uiNow)} ago</span
>
</div>
{/if}
<style>
.round { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2); }
strong { color: var(--text-primary); font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
span { color: var(--text-tertiary); font-size: var(--text-xs); }
.round {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2);
}
strong {
color: var(--text-primary);
font-family: var(--font-numeric);
font-variant-numeric: tabular-nums;
}
span {
color: var(--text-tertiary);
font-size: var(--text-xs);
}
</style>
+34 -6
View File
@@ -1,18 +1,46 @@
<script lang="ts">
import type { PlutoniumVersion } from '$lib/plutonium.js';
import { uiNow } from '$ui/stores/servers.js';
let { name, version, after, ready = true }: { name: string; version?: PlutoniumVersion; after?: string | null; ready?: boolean } = $props();
const fresh = $derived(ready && version?.checked_at && $uiNow - Date.parse(version.checked_at) < 3_600_000 && (!after || version.checked_at > after));
let {
name,
version,
after,
ready = true
}: {
name: string;
version?: PlutoniumVersion;
after?: string | null;
ready?: boolean;
} = $props();
const fresh = $derived(
ready &&
version?.checked_at &&
$uiNow - Date.parse(version.checked_at) < 3_600_000 &&
(!after || version.checked_at > after)
);
const revision = $derived(fresh ? version?.revision : null);
const outdated = $derived(fresh && version?.update_status === 'outdated');
</script>
<a class:outdated href={`/maintenance?kind=updates&server=${encodeURIComponent(name)}`} title="View Plutonium updates and verification">
<a
class:outdated
href={`/maintenance?kind=updates&server=${encodeURIComponent(name)}`}
title="View Plutonium updates and verification"
>
Plutonium {revision ? `r${revision}` : 'unverified'}{outdated ? ' · Update available' : ''}
</a>
<style>
a { font-size: var(--text-xs); color: var(--text-secondary); text-decoration: none; }
a:hover { color: var(--accent-primary-hover); text-decoration: underline; }
.outdated { color: var(--state-warning); }
a {
font-size: var(--text-xs);
color: var(--text-secondary);
text-decoration: none;
}
a:hover {
color: var(--accent-primary-hover);
text-decoration: underline;
}
.outdated {
color: var(--state-warning);
}
</style>
+512 -102
View File
@@ -4,12 +4,29 @@
import { plural } from '$ui/plural.js';
import { gameTitle } from '$ui/server-labels.js';
import Button from './primitives/Button.svelte';
import { betaSelectionHref, mapLabel, scopeLabel, type BetaSelection, type LibraryScript } from '$lib/script-library.js';
let { scripts, onuploaded }: { scripts: LibraryScript[]; onuploaded: () => Promise<void> } = $props();
interface Duplicate { scriptId: number; versionId: number; version: number; }
import {
betaSelectionHref,
mapLabel,
scopeLabel,
type BetaSelection,
type LibraryScript
} from '$lib/script-library.js';
let { scripts, onuploaded }: { scripts: LibraryScript[]; onuploaded: () => Promise<void> } =
$props();
interface Duplicate {
scriptId: number;
versionId: number;
version: number;
}
interface Upload {
key: number; file: File; target: number | 'new' | ''; name: string;
duplicates: Duplicate[]; inspecting: boolean; inspected: boolean; error: string;
key: number;
file: File;
target: number | 'new' | '';
name: string;
duplicates: Duplicate[];
inspecting: boolean;
inspected: boolean;
error: string;
result: (BetaSelection & { version: number; reused: boolean }) | null;
}
let dialog: HTMLDialogElement;
@@ -20,153 +37,315 @@
let scopeType = $state<'none' | 'game' | 'map'>('none');
let scopeGame = $state('');
let scopeMap = $state('');
let scopeOptions = $state<{ games: string[]; mapsByGame: Record<string, string[]> }>({ games: [], mapsByGame: {} });
let scopeOptions = $state<{ games: string[]; mapsByGame: Record<string, string[]> }>({
games: [],
mapsByGame: {}
});
let servers = $state<Array<{ name: string; game: string | null; map: string | null }>>([]);
let scopeLoaded = $state(false);
let message = $state('');
let preferredTarget = $state<number | null>(null);
let nextKey = 0;
let activeFile = $state('');
const checkedCount = $derived(queue.filter(q => q.inspected).length);
const pending = $derived(queue.filter(q => !q.result));
const inspecting = $derived(queue.some(q => q.inspecting));
const hasNew = $derived(pending.some(q => q.target === 'new'));
const scopeValue = $derived(scopeType === 'game' ? scopeGame : scopeType === 'map' ? scopeMap : '');
const matches = $derived(servers.filter(s => scopeType === 'game' ? s.game === scopeValue : s.map === scopeValue));
const ready = $derived(pending.length > 0 && !busy && !inspecting && pending.every(q => q.inspected && !validation(q))
&& (!hasNew || scopeType === 'none' || scopeLoaded && !!scopeValue));
const results = $derived(queue.flatMap(q => q.result ? [q.result] : []));
const testable = $derived(results.filter(r => {
const s = scripts.find(s => s.id === r.scriptId);
return s && s.runningBetaId === null && s.current_version_id !== r.versionId;
}));
const checkedCount = $derived(queue.filter((q) => q.inspected).length);
const pending = $derived(queue.filter((q) => !q.result));
const inspecting = $derived(queue.some((q) => q.inspecting));
const hasNew = $derived(pending.some((q) => q.target === 'new'));
const scopeValue = $derived(
scopeType === 'game' ? scopeGame : scopeType === 'map' ? scopeMap : ''
);
const matches = $derived(
servers.filter((s) => (scopeType === 'game' ? s.game === scopeValue : s.map === scopeValue))
);
const ready = $derived(
pending.length > 0 &&
!busy &&
!inspecting &&
pending.every((q) => q.inspected && !validation(q)) &&
(!hasNew || scopeType === 'none' || (scopeLoaded && !!scopeValue))
);
const results = $derived(queue.flatMap((q) => (q.result ? [q.result] : [])));
const testable = $derived(
results.filter((r) => {
const s = scripts.find((s) => s.id === r.scriptId);
return s && s.runningBetaId === null && s.current_version_id !== r.versionId;
})
);
export function open(scriptId?: number) {
// Reopening preserves unfinished work; a targeted upload starts a fresh review
// only when there is no pending file to lose.
if (!pending.length) { queue = []; notes = ''; message = ''; preferredTarget = scriptId ?? null; }
if (!pending.length) {
queue = [];
notes = '';
message = '';
preferredTarget = scriptId ?? null;
}
dialog.showModal();
if (!scopeLoaded) void loadScope();
}
async function loadScope() {
try {
const [scopeRes, serversRes] = await Promise.all([fetch('/api/scope-options'), fetch('/api/servers')]);
if (!scopeRes.ok || !serversRes.ok) throw new Error('Scope preview could not be loaded. Reopen the drawer to retry.');
scopeOptions = await scopeRes.json(); servers = (await serversRes.json()).servers;
const [scopeRes, serversRes] = await Promise.all([
fetch('/api/scope-options'),
fetch('/api/servers')
]);
if (!scopeRes.ok || !serversRes.ok)
throw new Error('Scope preview could not be loaded. Reopen the drawer to retry.');
scopeOptions = await scopeRes.json();
servers = (await serversRes.json()).servers;
scopeLoaded = true;
} catch (e) { message = (e as Error).message; }
} catch (e) {
message = (e as Error).message;
}
}
function matchingScripts(file: File) {
return scripts.filter(s => s.target_rel_path.replaceAll('\\', '/').split('/').pop()?.toLowerCase() === file.name.toLowerCase());
return scripts.filter(
(s) =>
s.target_rel_path.replaceAll('\\', '/').split('/').pop()?.toLowerCase() ===
file.name.toLowerCase()
);
}
export async function addFiles(files: File[]) {
if (busy || inspecting || !files.length) return;
if (!dialog.open) open();
const items = files.map(file => {
const items = files.map((file) => {
const matches = matchingScripts(file);
const item: Upload = { key: nextKey++, file, target: preferredTarget ?? (matches.length === 1 ? matches[0].id : matches.length > 1 ? '' : 'new'),
name: file.name.replace(/\.[^.]+$/, ''), duplicates: [], inspecting: true, inspected: false, error: '', result: null };
const item: Upload = {
key: nextKey++,
file,
target:
preferredTarget ??
(matches.length === 1 ? matches[0].id : matches.length > 1 ? '' : 'new'),
name: file.name.replace(/\.[^.]+$/, ''),
duplicates: [],
inspecting: true,
inspected: false,
error: '',
result: null
};
return item;
});
queue = [...queue, ...items];
for (const item of items) await inspect(queue.find(q => q.key === item.key)!);
for (const item of items) await inspect(queue.find((q) => q.key === item.key)!);
preferredTarget = null;
}
async function inspect(item: Upload) {
activeFile = item.file.name;
item.inspecting = true; item.error = '';
item.inspecting = true;
item.error = '';
try {
const form = new FormData(); form.append('content', item.file);
const form = new FormData();
form.append('content', item.file);
const res = await fetch('/api/scripts/inspect', { method: 'POST', body: form });
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not inspect this file.');
item.duplicates = body.duplicates; item.inspected = true;
} catch (e) { item.error = (e as Error).message; }
finally { item.inspecting = false; activeFile = ''; }
item.duplicates = body.duplicates;
item.inspected = true;
} catch (e) {
item.error = (e as Error).message;
} finally {
item.inspecting = false;
activeFile = '';
}
}
function duplicate(item: Upload) {
return item.duplicates.find((d) => d.scriptId === item.target);
}
function duplicate(item: Upload) { return item.duplicates.find(d => d.scriptId === item.target); }
function validation(item: Upload): string {
if (item.target === '') return 'Several scripts use this filename. Choose which script to update.';
if (item.target === '')
return 'Several scripts use this filename. Choose which script to update.';
if (item.target === 'new') {
if (!item.name.trim()) return 'Give this script a name.';
if (scripts.some(s => s.name === item.name.trim())) return 'This name already exists. Choose a new name or update that script.';
if (queue.some(q => q.key !== item.key && !q.result && q.target === 'new' && q.name.trim() === item.name.trim())) return 'Use a distinct name for each new script.';
if (scripts.some((s) => s.name === item.name.trim()))
return 'This name already exists. Choose a new name or update that script.';
if (
queue.some(
(q) =>
q.key !== item.key &&
!q.result &&
q.target === 'new' &&
q.name.trim() === item.name.trim()
)
)
return 'Use a distinct name for each new script.';
} else {
const script = scripts.find(s => s.id === item.target);
const script = scripts.find((s) => s.id === item.target);
if (!script) return 'This script is no longer available.';
if (script.runningBetaId !== null) return 'Finish the active beta before uploading another version.';
if (queue.some(q => q.key !== item.key && !q.result && q.target === item.target)) return 'Choose one file per existing script in this batch.';
if (script.runningBetaId !== null)
return 'Finish the active beta before uploading another version.';
if (queue.some((q) => q.key !== item.key && !q.result && q.target === item.target))
return 'Choose one file per existing script in this batch.';
}
return '';
}
async function upload(prepareBeta: boolean) {
if (!ready) return;
busy = true; message = '';
busy = true;
message = '';
try {
for (const item of queue.filter(q => !q.result)) {
for (const item of queue.filter((q) => !q.result)) {
activeFile = item.file.name;
item.error = '';
try {
const form = new FormData();
form.append('content', item.file); form.append('notes', notes.trim());
form.append('content', item.file);
form.append('notes', notes.trim());
const creating = item.target === 'new';
if (creating) {
form.append('name', item.name.trim()); form.append('scopeType', scopeType);
form.append('name', item.name.trim());
form.append('scopeType', scopeType);
if (scopeValue) form.append('scopeValue', scopeValue);
} else form.append('reuseExisting', 'true');
const res = await fetch(creating ? '/api/scripts' : `/api/scripts/${item.target}/versions`, { method: 'POST', body: form });
const res = await fetch(
creating ? '/api/scripts' : `/api/scripts/${item.target}/versions`,
{ method: 'POST', body: form }
);
const body = await res.json();
if (!res.ok || !body.ok) throw new Error(body.error ?? 'Upload failed. Try again.');
item.result = { scriptId: creating ? body.script.id : item.target as number,
if (!res.ok || !body.ok)
throw new Error(body.error ?? 'Upload failed. Try again.');
item.result = {
scriptId: creating ? body.script.id : (item.target as number),
versionId: creating ? body.versionId : body.version.id,
version: creating ? 1 : body.version.version, reused: !!body.reused };
} catch (e) { item.error = (e as Error).message; }
version: creating ? 1 : body.version.version,
reused: !!body.reused
};
} catch (e) {
item.error = (e as Error).message;
}
}
await onuploaded();
if (queue.some(q => !q.result)) message = 'Some files need attention. Successful uploads are saved; retry only the remaining files.';
if (queue.some((q) => !q.result))
message =
'Some files need attention. Successful uploads are saved; retry only the remaining files.';
else {
message = 'Your files are ready. Choose servers and a test plan in beta setup.';
// Read from refreshed props after Svelte applies the parent update.
await tick();
if (testable.length !== results.length) message = `${results.length - testable.length} version(s) are already promoted or unavailable for a new beta. Review the saved files before continuing.`;
if (prepareBeta && testable.length === results.length && testable.length) { dialog.close(); await goto(betaSelectionHref(testable)); }
if (testable.length !== results.length)
message = `${results.length - testable.length} version(s) are already promoted or unavailable for a new beta. Review the saved files before continuing.`;
if (prepareBeta && testable.length === results.length && testable.length) {
dialog.close();
await goto(betaSelectionHref(testable));
}
}
} catch (e) { message = (e as Error).message; }
finally { busy = false; activeFile = ''; }
} catch (e) {
message = (e as Error).message;
} finally {
busy = false;
activeFile = '';
}
}
function close(event?: Event) {
if (busy || inspecting) {
event?.preventDefault();
return;
}
dialog.close();
}
function close(event?: Event) { if (busy || inspecting) { event?.preventDefault(); return; } dialog.close(); }
</script>
<dialog bind:this={dialog} aria-labelledby="upload-title" oncancel={close}>
<div class="drawer">
<header><div><span class="eyebrow">Prepare your next release</span><h2 id="upload-title">Upload scripts</h2></div><Button variant="ghost" ariaLabel="Close upload drawer" disabled={busy || inspecting} onclick={() => close()}>✕</Button></header>
<p class="intro">Drop files anywhere or choose them below. Review each destination before uploading.</p>
<header>
<div>
<span class="eyebrow">Prepare your next release</span>
<h2 id="upload-title">Upload scripts</h2>
</div>
<Button
variant="ghost"
ariaLabel="Close upload drawer"
disabled={busy || inspecting}
onclick={() => close()}>✕</Button
>
</header>
<p class="intro">
Drop files anywhere or choose them below. Review each destination before uploading.
</p>
<div class="dropzone">
<strong>{preferredTarget ? `New version for ${scripts.find(s => s.id === preferredTarget)?.name ?? 'script'}` : 'One file or a whole batch'}</strong>
<strong
>{preferredTarget
? `New version for ${scripts.find((s) => s.id === preferredTarget)?.name ?? 'script'}`
: 'One file or a whole batch'}</strong
>
<span>Existing filenames are recognized automatically.</span>
<Button disabled={busy || inspecting} onclick={() => input.click()}>Choose files</Button>
<input class="file-input" bind:this={input} type="file" multiple aria-label="Choose script files" onchange={event => { void addFiles([...event.currentTarget.files ?? []]); event.currentTarget.value = ''; }} />
<Button disabled={busy || inspecting} onclick={() => input.click()}>Choose files</Button
>
<input
class="file-input"
bind:this={input}
type="file"
multiple
aria-label="Choose script files"
onchange={(event) => {
void addFiles([...(event.currentTarget.files ?? [])]);
event.currentTarget.value = '';
}}
/>
</div>
<div class="queue" aria-live="polite">
{#each queue as item (item.key)}
<section class="upload-item" aria-label={item.file.name}>
<div class="file-heading"><strong>{item.file.name}</strong><span>{(item.file.size / 1024).toFixed(1)} KB</span>
<Button variant="ghost" size="sm" disabled={busy || inspecting} ariaLabel={`Remove ${item.file.name}`} onclick={() => queue = queue.filter(q => q.key !== item.key)}>Remove</Button>
<div class="file-heading">
<strong>{item.file.name}</strong><span
>{(item.file.size / 1024).toFixed(1)} KB</span
>
<Button
variant="ghost"
size="sm"
disabled={busy || inspecting}
ariaLabel={`Remove ${item.file.name}`}
onclick={() => (queue = queue.filter((q) => q.key !== item.key))}
>Remove</Button
>
</div>
{#if item.result}
<p class="success">{item.result.reused ? 'Already uploaded' : 'Uploaded'} as v{item.result.version} · <a href="/scripts/{item.result.scriptId}">View script →</a></p>
<p class="success">
{item.result.reused ? 'Already uploaded' : 'Uploaded'} as v{item.result
.version} ·
<a href="/scripts/{item.result.scriptId}">View script →</a>
</p>
{:else}
<label>Destination<select bind:value={item.target} disabled={busy || item.inspecting} onchange={() => item.error = ''}>
<option value="" disabled>Choose a script…</option><option value="new">Create a new script</option>
{#each scripts as s}<option value={s.id}>{s.name} · {scopeLabel(s)}{s.runningBetaId !== null ? ' · beta active' : ''}</option>{/each}
</select></label>
{#if item.target === 'new'}<label>Script name<input bind:value={item.name} disabled={busy} /></label>
{:else if item.target !== ''}{@const s = scripts.find(s => s.id === item.target)}<p class="note">Keeps scope: {s ? scopeLabel(s) : '—'}. Target: {s?.target_rel_path}</p>{/if}
<label
>Destination<select
bind:value={item.target}
disabled={busy || item.inspecting}
onchange={() => (item.error = '')}
>
<option value="" disabled>Choose a script…</option><option
value="new">Create a new script</option
>
{#each scripts as s}<option value={s.id}
>{s.name} · {scopeLabel(s)}{s.runningBetaId !== null
? ' · beta active'
: ''}</option
>{/each}
</select></label
>
{#if item.target === 'new'}<label
>Script name<input bind:value={item.name} disabled={busy} /></label
>
{:else if item.target !== ''}{@const s = scripts.find(
(s) => s.id === item.target
)}
<p class="note">
Keeps scope: {s ? scopeLabel(s) : '—'}. Target: {s?.target_rel_path}
</p>{/if}
{#if item.inspecting}<p class="note">Checking file contents…</p>
{:else if duplicate(item)}<p class="success">Already uploaded as v{duplicate(item)!.version}. We’ll reuse that version.</p>
{:else if item.target !== 'new' && item.target !== ''}<p class="note">New version for {scripts.find(s => s.id === item.target)?.name}</p>{/if}
{:else if duplicate(item)}<p class="success">
Already uploaded as v{duplicate(item)!.version}. We’ll reuse that
version.
</p>
{:else if item.target !== 'new' && item.target !== ''}<p class="note">
New version for {scripts.find((s) => s.id === item.target)?.name}
</p>{/if}
{#if validation(item)}<p class="warning">{validation(item)}</p>{/if}
{#if item.error}<p class="error" role="alert">{item.error}</p>{#if !item.inspected}<Button size="sm" disabled={busy || inspecting} onclick={() => inspect(item)}>Retry inspection</Button>{/if}{/if}
{#if item.error}<p class="error" role="alert">{item.error}</p>
{#if !item.inspected}<Button
size="sm"
disabled={busy || inspecting}
onclick={() => inspect(item)}>Retry inspection</Button
>{/if}{/if}
{/if}
</section>
{/each}
@@ -174,46 +353,277 @@
{#if pending.length}
<section class="shared">
<h3>Shared upload details</h3>
<label>Version notes<textarea bind:value={notes} disabled={busy} rows="3" placeholder="What changed in these files?"></textarea></label>
<label
>Version notes<textarea
bind:value={notes}
disabled={busy}
rows="3"
placeholder="What changed in these files?"
></textarea></label
>
{#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'}
<label>Game<select bind:value={scopeGame} disabled={busy || !scopeLoaded} onchange={() => scopeMap = ''}><option value="">Choose a game…</option>{#each scopeOptions.games as g}<option value={g}>{gameTitle(g)}</option>{/each}</select></label>
{#if scopeType === 'map'}<label>Map<select bind:value={scopeMap} disabled={busy || !scopeGame}><option value="">Choose a map…</option>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as m}<option value={m}>{mapLabel(m)}</option>{/each}</select></label>{/if}
{#if scopeValue}<p class="note">{matches.length} matching servers: {matches.map(s => s.name).join(', ') || 'None'}</p>{/if}
{#if scopeType === 'map' && scopeGame === 't6'}<p class="note">Map scope includes every server using this engine map, including its Survival variants.</p>{/if}
{:else}<p class="note">Unscoped scripts deploy only to explicitly selected beta targets.</p>{/if}
<label
>Game<select
bind:value={scopeGame}
disabled={busy || !scopeLoaded}
onchange={() => (scopeMap = '')}
><option value="">Choose a game…</option
>{#each scopeOptions.games as g}<option value={g}
>{gameTitle(g)}</option
>{/each}</select
></label
>
{#if scopeType === 'map'}<label
>Map<select bind:value={scopeMap} disabled={busy || !scopeGame}
><option value="">Choose a map…</option
>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as m}<option
value={m}>{mapLabel(m)}</option
>{/each}</select
></label
>{/if}
{#if scopeValue}<p class="note">
{matches.length} matching servers: {matches
.map((s) => s.name)
.join(', ') || 'None'}
</p>{/if}
{#if scopeType === 'map' && scopeGame === 't6'}<p class="note">
Map scope includes every server using this engine map, including its
Survival variants.
</p>{/if}
{:else}<p class="note">
Unscoped scripts deploy only to explicitly selected beta targets.
</p>{/if}
<p class="note">Existing scripts keep their current scope.</p>
{/if}
</section>
{/if}
{#if message}<p class="notice" role="status">{message}</p>{/if}
<footer>
{#if inspecting || busy}<p class="batch-progress" role="status">{inspecting ? `Checked ${checkedCount} of ${queue.length} ${plural(queue.length, 'file')}` : `Uploaded ${results.length} of ${queue.length} ${plural(queue.length, 'file')}`}{activeFile ? ` · ${activeFile}` : ''}</p>{:else if queue.length}{@const failed = queue.filter(q => q.error).length}<p class="batch-progress">{results.length} saved · {pending.length} remaining{failed ? ` · ${failed} ${plural(failed, 'needs', 'need')} attention` : ''}</p>{/if}
{#if pending.length}<Button disabled={!ready} loading={busy} onclick={() => upload(false)}>Upload{results.length ? ' remaining' : ''}</Button><Button variant="primary" disabled={!ready} onclick={() => upload(true)}>Upload & prepare beta →</Button>
{:else if testable.length}<Button variant="primary" href={betaSelectionHref(testable)}>Prepare beta with {testable.length} script{testable.length === 1 ? '' : 's'} →</Button>{/if}
{#if inspecting || busy}<p class="batch-progress" role="status">
{inspecting
? `Checked ${checkedCount} of ${queue.length} ${plural(queue.length, 'file')}`
: `Uploaded ${results.length} of ${queue.length} ${plural(queue.length, 'file')}`}{activeFile
? ` · ${activeFile}`
: ''}
</p>{:else if queue.length}{@const failed = queue.filter((q) => q.error).length}
<p class="batch-progress">
{results.length} saved · {pending.length} remaining{failed
? ` · ${failed} ${plural(failed, 'needs', 'need')} attention`
: ''}
</p>{/if}
{#if pending.length}<Button
disabled={!ready}
loading={busy}
onclick={() => upload(false)}>Upload{results.length ? ' remaining' : ''}</Button
><Button variant="primary" disabled={!ready} onclick={() => upload(true)}
>Upload & prepare beta →</Button
>
{:else if testable.length}<Button variant="primary" href={betaSelectionHref(testable)}
>Prepare beta with {testable.length} script{testable.length === 1 ? '' : 's'} →</Button
>{/if}
</footer>
</div>
</dialog>
<style>
dialog { position: fixed; inset: 0 0 0 auto; margin: 0; width: min(620px, 100vw); height: 100dvh; max-height: 100dvh; max-width: 100vw; padding: 0; border: 0; border-left: 1px solid var(--border-subtle); background: var(--surface); color: var(--text-primary); }
dialog::backdrop { background: #0009; backdrop-filter: blur(3px); }
.drawer { padding: 28px; display: flex; flex-direction: column; gap: 20px; min-height: 100%; box-sizing: border-box; }
header { display: flex; justify-content: space-between; align-items: center; gap: 16px; } h2 { margin: 6px 0 0; font-size: var(--text-2xl); } h3 { margin: 0; font-size: var(--text-md); }
.eyebrow { text-transform: uppercase; letter-spacing: .1em; color: var(--accent-primary); font-size: var(--text-xs); }
.intro, .note { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; margin: 0; overflow-wrap: anywhere; }
.dropzone { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 12px; border: 1px dashed var(--border-strong); border-radius: var(--radius-md); padding: 28px; background: var(--surface-sunken); }
.dropzone span { font-size: var(--text-xs); color: var(--text-secondary); } .file-input { display: none; }
.queue, .shared { display: flex; flex-direction: column; gap: 16px; } .upload-item { display: flex; flex-direction: column; gap: 12px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.file-heading { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } .file-heading strong { flex: 1; overflow-wrap: anywhere; } .file-heading span { color: var(--text-tertiary); font-size: var(--text-xs); }
label { display: flex; flex-direction: column; gap: 7px; font-size: var(--text-xs); color: var(--text-secondary); }
input, select, textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 10px 12px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--text-primary); background: var(--surface-sunken); font: inherit; font-size: var(--text-sm); } textarea { resize: vertical; }
input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.success, .warning, .error { margin: 0; font-size: var(--text-sm); line-height: 1.5; } .success { color: var(--state-online); } .warning { color: var(--state-warning); } .error { color: var(--state-danger); } a { color: inherit; }
.notice { padding: 14px; background: var(--surface-raised); border-radius: var(--radius-md); font-size: var(--text-sm); line-height: 1.6; }
footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; margin-top: auto; padding: 16px 0; border-top: 1px solid var(--border-subtle); position: sticky; bottom: 0; z-index: 3; background: var(--surface); }
.batch-progress { flex-basis: 100%; margin: 0; color: var(--text-secondary); font-size: var(--text-sm); overflow-wrap: anywhere; }
@media (max-width: 640px) { .drawer { padding: 20px; } }
input:not([type='checkbox']):not([type='file']), select, textarea { border-color: var(--border-control); }
dialog {
position: fixed;
inset: 0 0 0 auto;
margin: 0;
width: min(620px, 100vw);
height: 100dvh;
max-height: 100dvh;
max-width: 100vw;
padding: 0;
border: 0;
border-left: 1px solid var(--border-subtle);
background: var(--surface);
color: var(--text-primary);
}
dialog::backdrop {
background: #0009;
backdrop-filter: blur(3px);
}
.drawer {
padding: 28px;
display: flex;
flex-direction: column;
gap: 20px;
min-height: 100%;
box-sizing: border-box;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
h2 {
margin: 6px 0 0;
font-size: var(--text-2xl);
}
h3 {
margin: 0;
font-size: var(--text-md);
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent-primary);
font-size: var(--text-xs);
}
.intro,
.note {
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.6;
margin: 0;
overflow-wrap: anywhere;
}
.dropzone {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 12px;
border: 1px dashed var(--border-strong);
border-radius: var(--radius-md);
padding: 28px;
background: var(--surface-sunken);
}
.dropzone span {
font-size: var(--text-xs);
color: var(--text-secondary);
}
.file-input {
display: none;
}
.queue,
.shared {
display: flex;
flex-direction: column;
gap: 16px;
}
.upload-item {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.file-heading {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.file-heading strong {
flex: 1;
overflow-wrap: anywhere;
}
.file-heading span {
color: var(--text-tertiary);
font-size: var(--text-xs);
}
label {
display: flex;
flex-direction: column;
gap: 7px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
input,
select,
textarea {
box-sizing: border-box;
width: 100%;
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
background: var(--surface-sunken);
font: inherit;
font-size: var(--text-sm);
}
textarea {
resize: vertical;
}
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.success,
.warning,
.error {
margin: 0;
font-size: var(--text-sm);
line-height: 1.5;
}
.success {
color: var(--state-online);
}
.warning {
color: var(--state-warning);
}
.error {
color: var(--state-danger);
}
a {
color: inherit;
}
.notice {
padding: 14px;
background: var(--surface-raised);
border-radius: var(--radius-md);
font-size: var(--text-sm);
line-height: 1.6;
}
footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
margin-top: auto;
padding: 16px 0;
border-top: 1px solid var(--border-subtle);
position: sticky;
bottom: 0;
z-index: 3;
background: var(--surface);
}
.batch-progress {
flex-basis: 100%;
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
overflow-wrap: anywhere;
}
@media (max-width: 640px) {
.drawer {
padding: 20px;
}
}
input:not([type='checkbox']):not([type='file']),
select,
textarea {
border-color: var(--border-control);
}
</style>
+390 -83
View File
@@ -11,7 +11,10 @@
import Badge from './primitives/Badge.svelte';
import StatusBadge from './primitives/StatusBadge.svelte';
interface Held { target: string; betaId: number; }
interface Held {
target: string;
betaId: number;
}
interface Props {
telemetry?: import('$lib/telemetry.js').GameTelemetry;
name: string;
@@ -38,137 +41,441 @@
longestMatch?: boolean;
}
let {
telemetry, name, players = null, map = null, roster = null, health = 'offline',
lastSeenAt = null, lastError = null, held = [], restarting = false,
inactiveSince = null, gameStartedAt = null, gameEndedAt = null, compactEmpty = true,
mapLabelOverride, round, capacity = null, pinned = false, onPin, longestMatch = false
telemetry,
name,
players = null,
map = null,
roster = null,
health = 'offline',
lastSeenAt = null,
lastError = null,
held = [],
restarting = false,
inactiveSince = null,
gameStartedAt = null,
gameEndedAt = null,
compactEmpty = true,
mapLabelOverride,
round,
capacity = null,
pinned = false,
onPin,
longestMatch = false
}: Props = $props();
const id = $props.id();
const number = new Intl.NumberFormat('en-US');
const identity = $derived(serverIdentity(name));
const title = $derived(mapLabelOverride ?? identity.map);
const game = $derived(identity.game === 'Black Ops I' ? 'BO1' : identity.game === 'Black Ops II' ? 'BO2' : identity.game);
const game = $derived(
identity.game === 'Black Ops I'
? 'BO1'
: identity.game === 'Black Ops II'
? 'BO2'
: identity.game
);
const compact = $derived(compactEmpty && health === 'online' && players === 0 && !restarting);
const state = $derived(restarting ? 'restarting' : compact ? 'ready' : health);
const error = $derived(decodeRconError(lastError));
const mapLabel = $derived(mapTitle(map));
const showMap = $derived(mapLabel !== '—' && mapLabel !== title);
const topRoster = $derived((roster ?? []).slice().sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, 4));
const topRoster = $derived(
(roster ?? [])
.slice()
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
.slice(0, 4)
);
const report = $derived.by(() => {
const streamed = $telemetryByServer.get(name);
return streamed && (!telemetry || Date.parse(streamed.receivedAt) > Date.parse(telemetry.receivedAt)) ? streamed : telemetry;
return streamed &&
(!telemetry || Date.parse(streamed.receivedAt) > Date.parse(telemetry.receivedAt))
? streamed
: telemetry;
});
const reportedRound = $derived(report && (report.event === 'heartbeat' || report.event === 'round_change')
&& freshStatusTimestamp(report.receivedAt, $uiNow)
&& (!gameStartedAt || Date.parse(report.receivedAt) >= Date.parse(gameStartedAt))
? report.round : null);
const currentRound = $derived(!gameEndedAt ? (round !== undefined ? round : reportedRound) : null);
const gameElapsed = $derived(gameStartedAt && !gameEndedAt ? elapsedSince(gameStartedAt, $uiNow) : null);
const reportedRound = $derived(
report &&
(report.event === 'heartbeat' || report.event === 'round_change') &&
freshStatusTimestamp(report.receivedAt, $uiNow) &&
(!gameStartedAt || Date.parse(report.receivedAt) >= Date.parse(gameStartedAt))
? report.round
: null
);
const currentRound = $derived(
!gameEndedAt ? (round !== undefined ? round : reportedRound) : null
);
const gameElapsed = $derived(
gameStartedAt && !gameEndedAt ? elapsedSince(gameStartedAt, $uiNow) : null
);
const emptyElapsed = $derived(elapsedSince(inactiveSince, $uiNow));
// These lobbies have four slots when the live status omits the limit.
const displayCapacity = $derived(capacity !== null && Number.isSafeInteger(capacity) && capacity > 0 ? capacity : 4);
const cardStyle = $derived(`view-transition-name: server-${name.replace(/[^a-z0-9_-]/gi, '-')};`);
const displayCapacity = $derived(
capacity !== null && Number.isSafeInteger(capacity) && capacity > 0 ? capacity : 4
);
const cardStyle = $derived(
`view-transition-name: server-${name.replace(/[^a-z0-9_-]/gi, '-')};`
);
</script>
<Card as="article" padding={compact ? 'sm' : 'md'} class={`server-card ${compact ? 'compact' : ''} ${health === 'offline' ? 'offline' : ''}`} ariaLabelledby={`${id}-title`} style={cardStyle}>
<Card
as="article"
padding={compact ? 'sm' : 'md'}
class={`server-card ${compact ? 'compact' : ''} ${health === 'offline' ? 'offline' : ''}`}
ariaLabelledby={`${id}-title`}
style={cardStyle}
>
<div class="head">
<a class="identity" href={contextHref(`/servers/${encodeURIComponent(name)}`, $page.url)} aria-labelledby={`${id}-title${identity.lobby !== null ? ` ${id}-lobby` : ''}`}>
<a
class="identity"
href={contextHref(`/servers/${encodeURIComponent(name)}`, $page.url)}
aria-labelledby={`${id}-title${identity.lobby !== null ? ` ${id}-lobby` : ''}`}
>
<h3 id={`${id}-title`}>{title}</h3>
<span class="identity-meta">
{#if identity.lobby !== null}<span id={`${id}-lobby`}>Lobby {identity.lobby}</span>{/if}
{#if identity.lobby !== null}<span id={`${id}-lobby`}>Lobby {identity.lobby}</span
>{/if}
{#if game}<span class="game" title={identity.game}>{game}</span>{/if}
{#if compact}<span class="empty-duration">{emptyElapsed ? `Empty for ${emptyElapsed}` : 'Empty lobby'}</span>{/if}
{#if compact}<span class="empty-duration"
>{emptyElapsed ? `Empty for ${emptyElapsed}` : 'Empty lobby'}</span
>{/if}
</span>
</a>
<div class="head-actions">
<StatusBadge {state} />
{#if onPin}<button class="pin" type="button" aria-pressed={pinned} aria-label={`${pinned ? 'Unpin' : 'Pin'} ${serverTitle(name)}`} title={pinned ? 'Unpin server' : 'Pin server'} onclick={onPin}><span aria-hidden="true">{pinned ? '★' : '☆'}</span></button>{/if}
{#if onPin}<button
class="pin"
type="button"
aria-pressed={pinned}
aria-label={`${pinned ? 'Unpin' : 'Pin'} ${serverTitle(name)}`}
title={pinned ? 'Unpin server' : 'Pin server'}
onclick={onPin}><span aria-hidden="true">{pinned ? '★' : '☆'}</span></button
>{/if}
</div>
</div>
{#if !compact}
{#if longestMatch && health === 'online'}<div class="highlight"><Badge>Longest match</Badge></div>{/if}
{#if showMap}<p class="map-note">{health === 'online' ? 'Current map' : 'Last map'}: {mapLabel}</p>{/if}
{#if longestMatch && health === 'online'}<div class="highlight">
<Badge>Longest match</Badge>
</div>{/if}
{#if showMap}<p class="map-note">
{health === 'online' ? 'Current map' : 'Last map'}: {mapLabel}
</p>{/if}
{#if health === 'online'}
<dl class="metrics">
<div><dt>Players</dt><dd title={`${displayCapacity} player capacity`}>{players ?? '—'}<span class="capacity">/{displayCapacity}</span></dd></div>
<div><dt>Round</dt><dd>{currentRound ?? '—'}</dd></div>
<div><dt>Match time</dt><dd>{gameElapsed ?? '—'}</dd></div>
<div>
<dt>Players</dt>
<dd title={`${displayCapacity} player capacity`}>
{players ?? '—'}<span class="capacity">/{displayCapacity}</span>
</dd>
</div>
<div>
<dt>Round</dt>
<dd>{currentRound ?? '—'}</dd>
</div>
<div>
<dt>Match time</dt>
<dd>{gameElapsed ?? '—'}</dd>
</div>
</dl>
{#if gameEndedAt && players !== 0}<p class="match-note">Match ended · waiting for the next match</p>{/if}
{#if gameEndedAt && players !== 0}<p class="match-note">
Match ended · waiting for the next match
</p>{/if}
{:else if health === 'stale'}
<p class="problem-note"><strong>Current player count unknown.</strong> Last reported: {players ?? 'unknown'}{players !== null ? ` ${players === 1 ? 'player' : 'players'}` : ''}{lastSeenAt ? ` · ${elapsedSince(lastSeenAt, $uiNow)} ago` : ''}.</p>
<p class="problem-note">
<strong>Current player count unknown.</strong> Last reported: {players ??
'unknown'}{players !== null
? ` ${players === 1 ? 'player' : 'players'}`
: ''}{lastSeenAt ? ` · ${elapsedSince(lastSeenAt, $uiNow)} ago` : ''}.
</p>
{/if}
{#if health === 'online' || health === 'stale'}
{#if topRoster.length}
<div class="roster">
<table aria-label={health === 'stale' ? 'Last reported players' : 'Current players'}>
<thead><tr><th scope="col">Player</th><th scope="col">Score</th><th scope="col">Ping <span class="ping-unit">(ms)</span></th></tr></thead>
<tbody>{#each topRoster as player (player.guid ?? player.num)}<tr>
<td title={player.name ?? undefined}>{player.name || '—'}</td>
<td>{player.score === null ? '—' : number.format(player.score)}</td>
<td>{player.ping === 999 ? 'Connecting' : player.ping === null ? '—' : number.format(player.ping)}</td>
</tr>{/each}</tbody>
<table
aria-label={health === 'stale'
? 'Last reported players'
: 'Current players'}
>
<thead
><tr
><th scope="col">Player</th><th scope="col">Score</th><th
scope="col">Ping <span class="ping-unit">(ms)</span></th
></tr
></thead
>
<tbody
>{#each topRoster as player (player.guid ?? player.num)}<tr>
<td title={player.name ?? undefined}>{player.name || '—'}</td>
<td
>{player.score === null
? '—'
: number.format(player.score)}</td
>
<td
>{player.ping === 999
? 'Connecting'
: player.ping === null
? '—'
: number.format(player.ping)}</td
>
</tr>{/each}</tbody
>
</table>
</div>
{:else if players !== 0}<p class="roster-unavailable">{health === 'stale' ? 'Waiting for a fresh report' : 'Player roster unavailable'}</p>{/if}
{:else if players !== 0}<p class="roster-unavailable">
{health === 'stale'
? 'Waiting for a fresh report'
: 'Player roster unavailable'}
</p>{/if}
{/if}
{#if health === 'offline'}
<p class="problem-note" title={error ? rconFixHint(error.reason) : undefined}>{error?.detail || 'Server status is unavailable. Open diagnostics for details.'}</p>
{#if lastSeenAt}<p class="last-report">Last report {elapsedSince(lastSeenAt, $uiNow)} ago</p>{/if}
<p class="problem-note" title={error ? rconFixHint(error.reason) : undefined}>
{error?.detail || 'Server status is unavailable. Open diagnostics for details.'}
</p>
{#if lastSeenAt}<p class="last-report">
Last report {elapsedSince(lastSeenAt, $uiNow)} ago
</p>{/if}
{/if}
{/if}
{#if held.length > 0}<div class="held">{#each held as h (h.target)}<Badge variant="info" title={`Held by beta ${h.betaId}`}>β {h.target}</Badge>{/each}</div>{/if}
{#if held.length > 0}<div class="held">
{#each held as h (h.target)}<Badge variant="info" title={`Held by beta ${h.betaId}`}
>β {h.target}</Badge
>{/each}
</div>{/if}
</Card>
<style>
:global(.card.server-card) { gap: var(--space-3); transition: border-color var(--dur-fast) var(--ease-standard); }
:global(.card.server-card:hover) { border-color: var(--border-strong); }
:global(.card.server-card.offline) { border-color: color-mix(in srgb, var(--state-danger) 35%, var(--border-subtle)); }
.head { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-2); }
.identity { min-width: 0; color: inherit; text-decoration: none; display: flex; flex-direction: column; gap: var(--space-1); }
.identity::after { content: ''; position: absolute; inset: 0; border-radius: var(--radius-lg); }
.identity:focus-visible { outline: none; }
.identity:focus-visible::after { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
h3 { margin: 0; font-size: var(--text-md); font-weight: var(--weight-semibold); line-height: var(--leading-tight); overflow-wrap: anywhere; }
.identity-meta { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); font-size: var(--text-xs); color: var(--text-secondary); }
.game { background: var(--surface-raised); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 2px 5px; white-space: nowrap; font-weight: var(--weight-semibold); }
.head-actions { display: flex; align-items: center; flex-shrink: 0; gap: var(--space-1); }
.pin { position: relative; z-index: 1; display: inline-grid; place-items: center; width: 32px; height: 32px; padding: 0; margin: -4px -6px -4px 0; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); cursor: pointer; font-size: var(--text-lg); }
.pin:hover, .pin[aria-pressed='true'] { color: var(--accent-primary-hover); background: var(--surface-raised); }
.pin:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.metrics { display: grid; grid-template-columns: 1fr 1fr minmax(0, 1.5fr); gap: var(--space-3); margin: 0; }
.metrics > div { display: flex; flex-direction: column-reverse; align-items: flex-start; gap: var(--space-1); }
dt { color: var(--text-secondary); font-size: var(--text-xs); }
dd { margin: 0; font-family: var(--font-numeric); font-variant-numeric: tabular-nums; font-size: var(--text-xl); font-weight: var(--weight-semibold); line-height: var(--leading-tight); white-space: nowrap; }
dd[title] { position: relative; z-index: 1; cursor: help; }
.capacity { font-size: var(--text-sm); font-weight: var(--weight-regular); color: var(--text-secondary); }
.roster { border-top: 1px solid var(--border-subtle); padding-top: var(--space-2); }
table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: var(--text-sm); font-variant-numeric: tabular-nums; }
th { font-size: var(--text-xs); color: var(--text-secondary); font-weight: var(--weight-regular); padding: 2px 0 6px; }
td { padding: 3px 0; }
th, td { text-align: right; }
th:first-child, td:first-child { width: 52%; text-align: left; padding-right: var(--space-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
td:not(:first-child) { color: var(--text-secondary); }
.ping-unit { font-size: var(--text-xs); }
.map-note, .problem-note, .last-report, .roster-unavailable, .match-note { margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
.problem-note strong { color: var(--text-primary); font-weight: var(--weight-medium); }
.last-report, .match-note { font-size: var(--text-xs); }
.held { display: flex; flex-wrap: wrap; gap: var(--space-1); }
:global(.compact) .head { align-items: center; }
:global(.compact) .identity { flex-direction: row; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-3); }
:global(.compact) h3 { font-size: var(--text-base); }
:global(.card.server-card) {
gap: var(--space-3);
transition: border-color var(--dur-fast) var(--ease-standard);
}
:global(.card.server-card:hover) {
border-color: var(--border-strong);
}
:global(.card.server-card.offline) {
border-color: color-mix(in srgb, var(--state-danger) 35%, var(--border-subtle));
}
.head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-2);
}
.identity {
min-width: 0;
color: inherit;
text-decoration: none;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.identity::after {
content: '';
position: absolute;
inset: 0;
border-radius: var(--radius-lg);
}
.identity:focus-visible {
outline: none;
}
.identity:focus-visible::after {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
h3 {
margin: 0;
font-size: var(--text-md);
font-weight: var(--weight-semibold);
line-height: var(--leading-tight);
overflow-wrap: anywhere;
}
.identity-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
font-size: var(--text-xs);
color: var(--text-secondary);
}
.game {
background: var(--surface-raised);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: 2px 5px;
white-space: nowrap;
font-weight: var(--weight-semibold);
}
.head-actions {
display: flex;
align-items: center;
flex-shrink: 0;
gap: var(--space-1);
}
.pin {
position: relative;
z-index: 1;
display: inline-grid;
place-items: center;
width: 32px;
height: 32px;
padding: 0;
margin: -4px -6px -4px 0;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: var(--text-lg);
}
.pin:hover,
.pin[aria-pressed='true'] {
color: var(--accent-primary-hover);
background: var(--surface-raised);
}
.pin:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.metrics {
display: grid;
grid-template-columns: 1fr 1fr minmax(0, 1.5fr);
gap: var(--space-3);
margin: 0;
}
.metrics > div {
display: flex;
flex-direction: column-reverse;
align-items: flex-start;
gap: var(--space-1);
}
dt {
color: var(--text-secondary);
font-size: var(--text-xs);
}
dd {
margin: 0;
font-family: var(--font-numeric);
font-variant-numeric: tabular-nums;
font-size: var(--text-xl);
font-weight: var(--weight-semibold);
line-height: var(--leading-tight);
white-space: nowrap;
}
dd[title] {
position: relative;
z-index: 1;
cursor: help;
}
.capacity {
font-size: var(--text-sm);
font-weight: var(--weight-regular);
color: var(--text-secondary);
}
.roster {
border-top: 1px solid var(--border-subtle);
padding-top: var(--space-2);
}
table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
font-size: var(--text-sm);
font-variant-numeric: tabular-nums;
}
th {
font-size: var(--text-xs);
color: var(--text-secondary);
font-weight: var(--weight-regular);
padding: 2px 0 6px;
}
td {
padding: 3px 0;
}
th,
td {
text-align: right;
}
th:first-child,
td:first-child {
width: 52%;
text-align: left;
padding-right: var(--space-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
td:not(:first-child) {
color: var(--text-secondary);
}
.ping-unit {
font-size: var(--text-xs);
}
.map-note,
.problem-note,
.last-report,
.roster-unavailable,
.match-note {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: var(--leading-body);
}
.problem-note strong {
color: var(--text-primary);
font-weight: var(--weight-medium);
}
.last-report,
.match-note {
font-size: var(--text-xs);
}
.held {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
:global(.compact) .head {
align-items: center;
}
:global(.compact) .identity {
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2) var(--space-3);
}
:global(.compact) h3 {
font-size: var(--text-base);
}
@media (max-width: 640px) {
:global(.card.server-card) { padding: var(--space-3); gap: var(--space-2); }
.roster, .roster-unavailable { display: none; }
.pin { width: 44px; height: 44px; margin: -8px -6px -8px 0; }
h3 { font-size: var(--text-base); }
dd { font-size: var(--text-lg); }
.head-actions { gap: 0; }
.metrics { gap: var(--space-2); }
:global(.compact) .identity { flex-direction: column; align-items: flex-start; gap: var(--space-1); }
:global(.card.server-card) {
padding: var(--space-3);
gap: var(--space-2);
}
.roster,
.roster-unavailable {
display: none;
}
.pin {
width: 44px;
height: 44px;
margin: -8px -6px -8px 0;
}
h3 {
font-size: var(--text-base);
}
dd {
font-size: var(--text-lg);
}
.head-actions {
gap: 0;
}
.metrics {
gap: var(--space-2);
}
:global(.compact) .identity {
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
}
}
</style>
+34 -6
View File
@@ -1,12 +1,40 @@
<script lang="ts">
import { serverIdentity } from '../server-labels.js';
let { name, mapLabel, showGame = true }: { name: string; mapLabel?: string; showGame?: boolean } = $props();
let {
name,
mapLabel,
showGame = true
}: { name: string; mapLabel?: string; showGame?: boolean } = $props();
const identity = $derived(serverIdentity(name));
</script>
<span class="identity" title={name}><span class="map">{mapLabel ?? identity.map}</span>{#if identity.lobby !== null}<span class="lobby">Lobby {identity.lobby}</span>{/if}{#if showGame && identity.game}<small>{identity.game}</small>{/if}</span>
<span class="identity" title={name}
><span class="map">{mapLabel ?? identity.map}</span>{#if identity.lobby !== null}<span
class="lobby">Lobby {identity.lobby}</span
>{/if}{#if showGame && identity.game}<small>{identity.game}</small>{/if}</span
>
<style>
.identity { display: inline-flex; align-items: baseline; flex-wrap: wrap; gap: 4px 8px; min-width: 0; }
.map { overflow-wrap: anywhere; }
.lobby { white-space: nowrap; font-size: max(var(--text-xs), .85em); color: var(--text-secondary); font-weight: var(--weight-medium); }
small { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; font-weight: var(--weight-regular); }
.identity {
display: inline-flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 8px;
min-width: 0;
}
.map {
overflow-wrap: anywhere;
}
.lobby {
white-space: nowrap;
font-size: max(var(--text-xs), 0.85em);
color: var(--text-secondary);
font-weight: var(--weight-medium);
}
small {
font-size: var(--text-xs);
color: var(--text-secondary);
white-space: nowrap;
font-weight: var(--weight-regular);
}
</style>
+666 -139
View File
@@ -5,8 +5,16 @@
import Button from './primitives/Button.svelte';
import RetentionTools from './RetentionTools.svelte';
import {
defaultFleetSettings, fleetSettingLabels, settingsGroups, settingHelp, settingMinimum,
settingZeroMode, settingUnit, settingDisplayValue, validateFleetSettings, type FleetSettings
defaultFleetSettings,
fleetSettingLabels,
settingsGroups,
settingHelp,
settingMinimum,
settingZeroMode,
settingUnit,
settingDisplayValue,
validateFleetSettings,
type FleetSettings
} from '$lib/fleet-settings.js';
type Key = keyof FleetSettings;
@@ -16,7 +24,9 @@
const keys = Object.keys(defaultFleetSettings) as Key[];
let baseline = $state<FleetSettings>({ ...untrack(() => initial) });
let draft = $state<Draft>({ ...untrack(() => initial) });
let remembered = Object.fromEntries(keys.filter(key => baseline[key] > 0).map(key => [key, baseline[key]])) as Partial<FleetSettings>;
let remembered = Object.fromEntries(
keys.filter((key) => baseline[key] > 0).map((key) => [key, baseline[key]])
) as Partial<FleetSettings>;
let query = $state('');
let selectedGroup = $state(settingsGroups[0].id);
let reviewInvalid = $state(false);
@@ -24,21 +34,34 @@
let error = $state('');
let notice = $state('');
let allowNavigation = false;
const changed = $derived(keys.filter(key => draft[key] !== baseline[key]));
const invalid = $derived(keys.filter(key => !!validationMessage(key, draft[key])));
const changed = $derived(keys.filter((key) => draft[key] !== baseline[key]));
const invalid = $derived(keys.filter((key) => !!validationMessage(key, draft[key])));
const visibleGroups = $derived.by(() => {
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
return settingsGroups.map(group => ({ ...group, keys: group.keys.filter(key => {
if (reviewInvalid && invalid.length) return invalid.includes(key);
if (!terms.length) return group.id === selectedGroup;
const text = `${group.title} ${group.description} ${fleetSettingLabels[key]} ${settingHelp[key] ?? ''} ${settingZeroMode(key) ?? ''} ${key}`.toLocaleLowerCase();
return terms.every(term => text.includes(term));
}) })).filter(group => group.keys.length);
return settingsGroups
.map((group) => ({
...group,
keys: group.keys.filter((key) => {
if (reviewInvalid && invalid.length) return invalid.includes(key);
if (!terms.length) return group.id === selectedGroup;
const text =
`${group.title} ${group.description} ${fleetSettingLabels[key]} ${settingHelp[key] ?? ''} ${settingZeroMode(key) ?? ''} ${key}`.toLocaleLowerCase();
return terms.every((term) => text.includes(term));
})
}))
.filter((group) => group.keys.length);
});
const shownCount = $derived(visibleGroups.reduce((count, group) => count + group.keys.length, 0));
const shownCount = $derived(
visibleGroups.reduce((count, group) => count + group.keys.length, 0)
);
function validationMessage(key: Key, value: number | undefined): string {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < settingMinimum(key)) return 'Enter a valid whole number.';
if (
typeof value !== 'number' ||
!Number.isSafeInteger(value) ||
value < settingMinimum(key)
)
return 'Enter a valid whole number.';
if (key.endsWith('Days') && value > 36500) return 'Enter 36,500 days or fewer.';
return '';
}
@@ -46,63 +69,132 @@
if (saving) return;
const current = draft[key];
if (enabled) {
if (current !== undefined && Number.isSafeInteger(current) && current > 0) remembered[key] = current;
if (current !== undefined && Number.isSafeInteger(current) && current > 0)
remembered[key] = current;
draft[key] = 0;
} else {
draft[key] = remembered[key] ?? (defaultFleetSettings[key] > 0 ? defaultFleetSettings[key] : 1);
draft[key] =
remembered[key] ?? (defaultFleetSettings[key] > 0 ? defaultFleetSettings[key] : 1);
}
error = ''; notice = '';
error = '';
notice = '';
}
function selectGroup(groupId: string) {
selectedGroup = groupId;
query = '';
reviewInvalid = false;
}
function selectGroup(groupId: string) { selectedGroup = groupId; query = ''; reviewInvalid = false; }
function discard() {
if (saving) return;
draft = { ...baseline };
remembered = Object.fromEntries(keys.filter(key => baseline[key] > 0).map(key => [key, baseline[key]])) as Partial<FleetSettings>;
error = ''; notice = ''; reviewInvalid = false;
remembered = Object.fromEntries(
keys.filter((key) => baseline[key] > 0).map((key) => [key, baseline[key]])
) as Partial<FleetSettings>;
error = '';
notice = '';
reviewInvalid = false;
}
async function save() {
if (saving || !changed.length || invalid.length) return;
saving = true; error = ''; notice = '';
saving = true;
error = '';
notice = '';
try {
const values = validateFleetSettings(draft);
const response = await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) });
const response = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values)
});
const result = await response.json();
if (!response.ok) throw new Error(result.error ?? 'Could not save fleet settings.');
baseline = validateFleetSettings(result);
draft = { ...baseline };
for (const key of keys) if (baseline[key] > 0) remembered[key] = baseline[key];
notice = 'Settings saved. Changes apply to the next queue or cleanup check.';
} catch (cause) { error = cause instanceof Error ? cause.message : 'Could not save fleet settings.'; }
finally { saving = false; }
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Could not save fleet settings.';
} finally {
saving = false;
}
}
beforeNavigate(({ cancel, to, from, type }) => {
if (!changed.length || allowNavigation || type === 'leave' || !to || to.url.pathname === from?.url.pathname) return;
if (
!changed.length ||
allowNavigation ||
type === 'leave' ||
!to ||
to.url.pathname === from?.url.pathname
)
return;
cancel();
void confirmAction({ title: 'Leave Settings?', message: 'Your unsaved settings will be discarded.', confirmLabel: 'Leave settings', danger: true }).then(async accepted => {
void confirmAction({
title: 'Leave Settings?',
message: 'Your unsaved settings will be discarded.',
confirmLabel: 'Leave settings',
danger: true
}).then(async (accepted) => {
if (!accepted) return;
allowNavigation = true;
try { await goto(to.url); } finally { allowNavigation = false; }
try {
await goto(to.url);
} finally {
allowNavigation = false;
}
});
});
function beforeUnload(event: BeforeUnloadEvent) { if (changed.length) { event.preventDefault(); event.returnValue = ''; } }
function beforeUnload(event: BeforeUnloadEvent) {
if (changed.length) {
event.preventDefault();
event.returnValue = '';
}
}
</script>
<svelte:window onbeforeunload={beforeUnload} />
<div class="settings-layout">
<aside class="section-rail">
<label class="search-label" for="settings-search">Find a setting</label>
<input id="settings-search" type="search" placeholder="Search settings" bind:value={query} disabled={saving} oninput={() => reviewInvalid = false} />
<label class="mobile-section">Section
<select aria-label="Settings section" value={selectedGroup} disabled={saving} onchange={event => selectGroup(event.currentTarget.value)}>
{#each settingsGroups as group}<option value={group.id}>{group.title}{changed.some(key => group.keys.includes(key)) ? ` · ${changed.filter(key => group.keys.includes(key)).length} changed` : ''}</option>{/each}
<input
id="settings-search"
type="search"
placeholder="Search settings"
bind:value={query}
disabled={saving}
oninput={() => (reviewInvalid = false)}
/>
<label class="mobile-section"
>Section
<select
aria-label="Settings section"
value={selectedGroup}
disabled={saving}
onchange={(event) => selectGroup(event.currentTarget.value)}
>
{#each settingsGroups as group}<option value={group.id}
>{group.title}{changed.some((key) => group.keys.includes(key))
? ` · ${changed.filter((key) => group.keys.includes(key)).length} changed`
: ''}</option
>{/each}
</select>
</label>
<nav class="group-links" aria-label="Settings sections">
{#each settingsGroups as group}
{@const count = changed.filter(key => group.keys.includes(key)).length}
<a href={`#settings-group-${group.id}`} aria-current={!query.trim() && !reviewInvalid && selectedGroup === group.id ? 'location' : undefined} onclick={event => { event.preventDefault(); if (!saving) selectGroup(group.id); }}>
<span>{group.title}</span>{#if count}<span class="changed-count">{count} changed</span>{/if}
{@const count = changed.filter((key) => group.keys.includes(key)).length}
<a
href={`#settings-group-${group.id}`}
aria-current={!query.trim() && !reviewInvalid && selectedGroup === group.id
? 'location'
: undefined}
onclick={(event) => {
event.preventDefault();
if (!saving) selectGroup(group.id);
}}
>
<span>{group.title}</span>{#if count}<span class="changed-count"
>{count} changed</span
>{/if}
</a>
{/each}
</nav>
@@ -110,39 +202,140 @@
<div class="settings-content">
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if notice}<p class="notice" role="status">{notice}</p>{/if}
{#if query.trim()}<p class="search-results">{shownCount} {shownCount === 1 ? 'setting matches' : 'settings match'} “{query.trim()}” across all sections. <button type="button" class="text-button" onclick={() => query = ''}>Clear search</button></p>{/if}
{#if reviewInvalid && invalid.length}<p class="search-results">Showing invalid settings across all sections. <button type="button" class="text-button" onclick={() => reviewInvalid = false}>Show selected section</button></p>{/if}
<form id={`${id}-form`} onsubmit={event => { event.preventDefault(); void save(); }}>
{#if query.trim()}<p class="search-results">
{shownCount}
{shownCount === 1 ? 'setting matches' : 'settings match'} “{query.trim()}” across
all sections.
<button type="button" class="text-button" onclick={() => (query = '')}
>Clear search</button
>
</p>{/if}
{#if reviewInvalid && invalid.length}<p class="search-results">
Showing invalid settings across all sections. <button
type="button"
class="text-button"
onclick={() => (reviewInvalid = false)}>Show selected section</button
>
</p>{/if}
<form
id={`${id}-form`}
onsubmit={(event) => {
event.preventDefault();
void save();
}}
>
<fieldset disabled={saving}>
<legend class="sr-only">Fleet settings</legend>
{#each visibleGroups as group (group.id)}
<section class="settings-group" id={`settings-group-${group.id}`} aria-labelledby={`settings-heading-${group.id}`}>
<header><h2 id={`settings-heading-${group.id}`}>{group.title}</h2><p>{group.description}</p></header>
<div class="table-wrap"><table>
<thead><tr><th scope="col">Setting</th><th scope="col">Value</th><th scope="col">Default</th><th scope="col">What it affects</th></tr></thead>
<tbody>{#each group.keys as key (key)}
{@const zeroMode = settingZeroMode(key)}
{@const unlimited = !!zeroMode && draft[key] === 0}
{@const validation = validationMessage(key, draft[key])}
<tr data-setting={key} class:changed={changed.includes(key)}>
<th scope="row"><label for={`setting-${key}`}>{fleetSettingLabels[key]}</label>{#if changed.includes(key)}<span class="changed-marker">Changed</span>{/if}</th>
<td class="value-cell">
<div class="numeric-value">
{#if unlimited}<input id={`setting-${key}`} type="number" disabled placeholder="—" aria-describedby={`setting-${key}-help setting-${key}-default`} />
{:else}<input id={`setting-${key}`} type="number" min={Math.max(1, settingMinimum(key))} max={key.endsWith('Days') ? 36500 : undefined} step="1" required bind:value={draft[key]} aria-invalid={!!validation} aria-describedby={`setting-${key}-help setting-${key}-default${validation ? ` setting-${key}-error` : ''}`} oninput={() => { error = ''; notice = ''; }} />{/if}
<span class="unit">{settingUnit(key)}</span>
</div>
{#if zeroMode}<label class="zero-mode" for={`setting-${key}-zero`}><input id={`setting-${key}-zero`} type="checkbox" checked={unlimited} onchange={event => toggleMode(key, event.currentTarget.checked)} aria-label={`${zeroMode}: ${fleetSettingLabels[key]}`} aria-describedby={`setting-${key}-help`} /><span>{zeroMode}</span></label>{/if}
{#if validation}<p class="field-error" id={`setting-${key}-error`}>{validation}</p>{/if}
</td>
<td class="default-value" id={`setting-${key}-default`}><span class="mobile-label">Default: </span>{settingDisplayValue(key, defaultFleetSettings[key])}</td>
<td class="help" id={`setting-${key}-help`}>{settingHelp[key]}</td>
</tr>
{/each}</tbody>
</table></div>
{#if group.id === 'concurrency'}<p class="group-note">Lowering a limit lets active operations finish; queued work waits for capacity. Player safety checks still apply.</p>{/if}
<section
class="settings-group"
id={`settings-group-${group.id}`}
aria-labelledby={`settings-heading-${group.id}`}
>
<header>
<h2 id={`settings-heading-${group.id}`}>{group.title}</h2>
<p>{group.description}</p>
</header>
<div class="table-wrap">
<table>
<thead
><tr
><th scope="col">Setting</th><th scope="col">Value</th><th
scope="col">Default</th
><th scope="col">What it affects</th></tr
></thead
>
<tbody
>{#each group.keys as key (key)}
{@const zeroMode = settingZeroMode(key)}
{@const unlimited = !!zeroMode && draft[key] === 0}
{@const validation = validationMessage(key, draft[key])}
<tr
data-setting={key}
class:changed={changed.includes(key)}
>
<th scope="row"
><label for={`setting-${key}`}
>{fleetSettingLabels[key]}</label
>{#if changed.includes(key)}<span
class="changed-marker">Changed</span
>{/if}</th
>
<td class="value-cell">
<div class="numeric-value">
{#if unlimited}<input
id={`setting-${key}`}
type="number"
disabled
placeholder="—"
aria-describedby={`setting-${key}-help setting-${key}-default`}
/>
{:else}<input
id={`setting-${key}`}
type="number"
min={Math.max(1, settingMinimum(key))}
max={key.endsWith('Days')
? 36500
: undefined}
step="1"
required
bind:value={draft[key]}
aria-invalid={!!validation}
aria-describedby={`setting-${key}-help setting-${key}-default${validation ? ` setting-${key}-error` : ''}`}
oninput={() => {
error = '';
notice = '';
}}
/>{/if}
<span class="unit">{settingUnit(key)}</span>
</div>
{#if zeroMode}<label
class="zero-mode"
for={`setting-${key}-zero`}
><input
id={`setting-${key}-zero`}
type="checkbox"
checked={unlimited}
onchange={(event) =>
toggleMode(
key,
event.currentTarget.checked
)}
aria-label={`${zeroMode}: ${fleetSettingLabels[key]}`}
aria-describedby={`setting-${key}-help`}
/><span>{zeroMode}</span></label
>{/if}
{#if validation}<p
class="field-error"
id={`setting-${key}-error`}
>
{validation}
</p>{/if}
</td>
<td class="default-value" id={`setting-${key}-default`}
><span class="mobile-label"
>Default:
</span>{settingDisplayValue(
key,
defaultFleetSettings[key]
)}</td
>
<td class="help" id={`setting-${key}-help`}
>{settingHelp[key]}</td
>
</tr>
{/each}</tbody
>
</table>
</div>
{#if group.id === 'concurrency'}<p class="group-note">
Lowering a limit lets active operations finish; queued work waits
for capacity. Player safety checks still apply.
</p>{/if}
</section>
{:else}<p class="no-results">No settings match this search. Try a setting name, a section, or “Forever”.</p>{/each}
{:else}<p class="no-results">
No settings match this search. Try a setting name, a section, or “Forever”.
</p>{/each}
</fieldset>
</form>
<div class="retention-tools">
@@ -156,87 +349,421 @@
</div>
<div class="save-bar">
<div class="save-summary">
<strong class:has-changes={changed.length > 0}>{changed.length ? `${changed.length} unsaved ${changed.length === 1 ? 'change' : 'changes'}` : 'All changes saved'}</strong>
<strong class:has-changes={changed.length > 0}
>{changed.length
? `${changed.length} unsaved ${changed.length === 1 ? 'change' : 'changes'}`
: 'All changes saved'}</strong
>
<span>Applies to everyone. No restart needed.</span>
{#if invalid.length}<button type="button" class="invalid-summary text-button" onclick={() => { query = ''; reviewInvalid = true; }}>{invalid.length} invalid {invalid.length === 1 ? 'setting' : 'settings'} · Review invalid settings</button>{/if}
{#if invalid.length}<button
type="button"
class="invalid-summary text-button"
onclick={() => {
query = '';
reviewInvalid = true;
}}
>{invalid.length} invalid {invalid.length === 1 ? 'setting' : 'settings'} · Review invalid
settings</button
>{/if}
</div>
<div class="save-actions">
<Button variant="ghost" disabled={saving || !changed.length} onclick={discard}
>Discard</Button
><button
class="save-button"
type="submit"
form={`${id}-form`}
disabled={saving || !changed.length || invalid.length > 0}
>{saving ? 'Saving…' : 'Save changes'}</button
>
</div>
<div class="save-actions"><Button variant="ghost" disabled={saving || !changed.length} onclick={discard}>Discard</Button><button class="save-button" type="submit" form={`${id}-form`} disabled={saving || !changed.length || invalid.length > 0}>{saving ? 'Saving…' : 'Save changes'}</button></div>
</div>
<style>
.settings-layout { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: var(--space-5); align-items: start; }
.section-rail { position: sticky; top: 80px; display: grid; gap: var(--space-2); min-width: 0; }
.search-label { font-size: var(--text-sm); color: var(--text-secondary); }
#settings-search { width: 100%; }
.group-links { display: grid; gap: var(--space-1); margin-top: var(--space-3); }
.group-links a { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); min-height: 40px; padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm); text-decoration: none; font-size: var(--text-sm); color: var(--text-secondary); }
.group-links a:hover, .group-links a[aria-current] { background: var(--surface-raised); color: var(--text-primary); }
.group-links a[aria-current] { font-weight: var(--weight-semibold); }
.changed-count { font-size: var(--text-xs); color: var(--state-warning); white-space: nowrap; }
.settings-content, fieldset { min-width: 0; }
fieldset { border: 0; padding: 0; margin: 0; }
.settings-group { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); overflow: hidden; margin-bottom: var(--space-4); }
.settings-group header { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2) var(--space-4); padding: var(--space-4); border-bottom: 1px solid var(--border-subtle); }
h2 { margin: 0; font-size: var(--text-base); font-weight: var(--weight-semibold); }
p { color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
header p { margin: 0; font-size: var(--text-xs); }
.table-wrap { position: relative; min-width: 0; overflow-x: auto; }
table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: var(--text-xs); text-align: left; }
th, td { padding: var(--space-3); vertical-align: top; border-bottom: 1px solid var(--border-subtle); }
th { font-weight: var(--weight-medium); }
thead th { color: var(--text-secondary); font-weight: var(--weight-semibold); }
thead th:nth-child(1) { width: 23%; }
thead th:nth-child(2) { width: 25%; }
thead th:nth-child(3) { width: 15%; }
tbody tr:last-child th, tbody tr:last-child td { border-bottom: 0; }
.changed { background: color-mix(in srgb, var(--state-warning) 3%, transparent); }
.changed-marker { display: block; margin-top: var(--space-1); color: var(--state-warning); font-size: var(--text-xs); }
.numeric-value { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-1); }
.numeric-value input { width: 88px; min-height: 32px; padding: 5px var(--space-2); font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
input:disabled { color: var(--text-tertiary); opacity: 0.75; }
.unit { color: var(--text-secondary); font-size: var(--text-xs); }
.zero-mode { display: inline-flex; align-items: center; gap: var(--space-1); margin-top: var(--space-2); color: var(--text-secondary); font-size: var(--text-xs); cursor: pointer; }
.zero-mode input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent-primary); }
.default-value, .help { color: var(--text-secondary); line-height: var(--leading-body); }
.default-value { font-variant-numeric: tabular-nums; }
.field-error { margin: var(--space-1) 0 0; color: var(--state-danger); font-size: var(--text-xs); }
.group-note { margin: 0; padding: var(--space-3) var(--space-4); border-top: 1px solid var(--border-subtle); font-size: var(--text-xs); }
.search-results { margin-top: 0; }
.text-button { padding: 0; background: none; border: 0; color: var(--accent-primary-hover); font: inherit; text-decoration: underline; cursor: pointer; }
.retention-tools { margin-top: var(--space-4); }
.save-bar { position: sticky; bottom: var(--space-3); z-index: 10; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3); padding: var(--space-3) var(--space-4); margin-top: var(--space-5); border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface-raised); box-shadow: var(--shadow-raised); }
.save-summary { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1) var(--space-3); font-size: var(--text-xs); color: var(--text-secondary); }
.save-summary strong { font-weight: var(--weight-semibold); color: var(--text-primary); }
.save-summary strong.has-changes { color: var(--state-warning); }
.save-actions { display: flex; align-items: center; gap: var(--space-2); margin-left: auto; }
.save-button { min-height: 40px; padding: var(--space-2) var(--space-4); border: 1px solid transparent; border-radius: var(--radius-md); background: var(--accent-primary); color: var(--text-on-accent); font-size: var(--text-sm); font-weight: var(--weight-semibold); cursor: pointer; }
.save-button:hover:not(:disabled) { background: var(--accent-primary-hover); }
.save-button:disabled { opacity: 0.5; cursor: not-allowed; }
.invalid-summary { color: var(--state-danger); }
.notice { color: var(--state-online); }
.mobile-section, .mobile-label { display: none; }
@media (max-width: 1100px) { .settings-layout { grid-template-columns: 180px minmax(0, 1fr); gap: var(--space-4); } thead th:nth-child(1) { width: 25%; } thead th:nth-child(2) { width: 27%; } thead th:nth-child(3) { width: 16%; } th, td { padding: var(--space-2); } }
.settings-layout {
display: grid;
grid-template-columns: 200px minmax(0, 1fr);
gap: var(--space-5);
align-items: start;
}
.section-rail {
position: sticky;
top: 80px;
display: grid;
gap: var(--space-2);
min-width: 0;
}
.search-label {
font-size: var(--text-sm);
color: var(--text-secondary);
}
#settings-search {
width: 100%;
}
.group-links {
display: grid;
gap: var(--space-1);
margin-top: var(--space-3);
}
.group-links a {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
min-height: 40px;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
text-decoration: none;
font-size: var(--text-sm);
color: var(--text-secondary);
}
.group-links a:hover,
.group-links a[aria-current] {
background: var(--surface-raised);
color: var(--text-primary);
}
.group-links a[aria-current] {
font-weight: var(--weight-semibold);
}
.changed-count {
font-size: var(--text-xs);
color: var(--state-warning);
white-space: nowrap;
}
.settings-content,
fieldset {
min-width: 0;
}
fieldset {
border: 0;
padding: 0;
margin: 0;
}
.settings-group {
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
overflow: hidden;
margin-bottom: var(--space-4);
}
.settings-group header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2) var(--space-4);
padding: var(--space-4);
border-bottom: 1px solid var(--border-subtle);
}
h2 {
margin: 0;
font-size: var(--text-base);
font-weight: var(--weight-semibold);
}
p {
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: var(--leading-body);
}
header p {
margin: 0;
font-size: var(--text-xs);
}
.table-wrap {
position: relative;
min-width: 0;
overflow-x: auto;
}
table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
font-size: var(--text-xs);
text-align: left;
}
th,
td {
padding: var(--space-3);
vertical-align: top;
border-bottom: 1px solid var(--border-subtle);
}
th {
font-weight: var(--weight-medium);
}
thead th {
color: var(--text-secondary);
font-weight: var(--weight-semibold);
}
thead th:nth-child(1) {
width: 23%;
}
thead th:nth-child(2) {
width: 25%;
}
thead th:nth-child(3) {
width: 15%;
}
tbody tr:last-child th,
tbody tr:last-child td {
border-bottom: 0;
}
.changed {
background: color-mix(in srgb, var(--state-warning) 3%, transparent);
}
.changed-marker {
display: block;
margin-top: var(--space-1);
color: var(--state-warning);
font-size: var(--text-xs);
}
.numeric-value {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-1);
}
.numeric-value input {
width: 88px;
min-height: 32px;
padding: 5px var(--space-2);
font-family: var(--font-numeric);
font-variant-numeric: tabular-nums;
}
input:disabled {
color: var(--text-tertiary);
opacity: 0.75;
}
.unit {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.zero-mode {
display: inline-flex;
align-items: center;
gap: var(--space-1);
margin-top: var(--space-2);
color: var(--text-secondary);
font-size: var(--text-xs);
cursor: pointer;
}
.zero-mode input {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--accent-primary);
}
.default-value,
.help {
color: var(--text-secondary);
line-height: var(--leading-body);
}
.default-value {
font-variant-numeric: tabular-nums;
}
.field-error {
margin: var(--space-1) 0 0;
color: var(--state-danger);
font-size: var(--text-xs);
}
.group-note {
margin: 0;
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--border-subtle);
font-size: var(--text-xs);
}
.search-results {
margin-top: 0;
}
.text-button {
padding: 0;
background: none;
border: 0;
color: var(--accent-primary-hover);
font: inherit;
text-decoration: underline;
cursor: pointer;
}
.retention-tools {
margin-top: var(--space-4);
}
.save-bar {
position: sticky;
bottom: var(--space-3);
z-index: 10;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
margin-top: var(--space-5);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--surface-raised);
box-shadow: var(--shadow-raised);
}
.save-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1) var(--space-3);
font-size: var(--text-xs);
color: var(--text-secondary);
}
.save-summary strong {
font-weight: var(--weight-semibold);
color: var(--text-primary);
}
.save-summary strong.has-changes {
color: var(--state-warning);
}
.save-actions {
display: flex;
align-items: center;
gap: var(--space-2);
margin-left: auto;
}
.save-button {
min-height: 40px;
padding: var(--space-2) var(--space-4);
border: 1px solid transparent;
border-radius: var(--radius-md);
background: var(--accent-primary);
color: var(--text-on-accent);
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
cursor: pointer;
}
.save-button:hover:not(:disabled) {
background: var(--accent-primary-hover);
}
.save-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.invalid-summary {
color: var(--state-danger);
}
.notice {
color: var(--state-online);
}
.mobile-section,
.mobile-label {
display: none;
}
@media (max-width: 1100px) {
.settings-layout {
grid-template-columns: 180px minmax(0, 1fr);
gap: var(--space-4);
}
thead th:nth-child(1) {
width: 25%;
}
thead th:nth-child(2) {
width: 27%;
}
thead th:nth-child(3) {
width: 16%;
}
th,
td {
padding: var(--space-2);
}
}
@media (max-width: 720px) {
.settings-layout { grid-template-columns: minmax(0, 1fr); gap: var(--space-4); }
.section-rail { position: static; }
.group-links { display: none; }
.mobile-section { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: var(--space-3); font-size: var(--text-sm); color: var(--text-secondary); }
.mobile-section select { width: 100%; }
.table-wrap { overflow: visible; }
table, tbody { display: block; }
thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
tbody tr { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-2); padding: var(--space-3); border-bottom: 1px solid var(--border-subtle); }
tbody tr:last-child { border-bottom: 0; }
th, td { display: block; padding: 0; border: 0; }
tbody th { grid-column: 1 / -1; font-size: var(--text-sm); }
.changed-marker { display: inline; margin-left: var(--space-2); }
.value-cell { grid-column: 1 / -1; display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-4); }
.zero-mode { min-height: 44px; margin-top: 0; }
.zero-mode input { width: 20px; height: 20px; }
.default-value, .help { grid-column: 1 / -1; }
.mobile-label { display: inline; }
.save-bar { bottom: max(var(--space-2), env(safe-area-inset-bottom)); padding: var(--space-3); gap: var(--space-2); }
.save-summary { gap: var(--space-1); flex-direction: column; align-items: flex-start; }
.save-actions { width: 100%; justify-content: flex-end; }
.settings-layout {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-4);
}
.section-rail {
position: static;
}
.group-links {
display: none;
}
.mobile-section {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: var(--space-3);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.mobile-section select {
width: 100%;
}
.table-wrap {
overflow: visible;
}
table,
tbody {
display: block;
}
thead {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
tbody tr {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--space-2);
padding: var(--space-3);
border-bottom: 1px solid var(--border-subtle);
}
tbody tr:last-child {
border-bottom: 0;
}
th,
td {
display: block;
padding: 0;
border: 0;
}
tbody th {
grid-column: 1 / -1;
font-size: var(--text-sm);
}
.changed-marker {
display: inline;
margin-left: var(--space-2);
}
.value-cell {
grid-column: 1 / -1;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2) var(--space-4);
}
.zero-mode {
min-height: 44px;
margin-top: 0;
}
.zero-mode input {
width: 20px;
height: 20px;
}
.default-value,
.help {
grid-column: 1 / -1;
}
.mobile-label {
display: inline;
}
.save-bar {
bottom: max(var(--space-2), env(safe-area-inset-bottom));
padding: var(--space-3);
gap: var(--space-2);
}
.save-summary {
gap: var(--space-1);
flex-direction: column;
align-items: flex-start;
}
.save-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>
+196 -57
View File
@@ -1,6 +1,9 @@
<script lang="ts">
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 { serverTitle } from '$ui/server-labels.js';
import Card from './primitives/Card.svelte';
@@ -12,7 +15,9 @@
let { server }: { server: string } = $props();
let requests = $state<TelemetryRequestLogEntry[]>([]);
let retention = $state<{ maxPerServer: number; maxAgeDays: number }>({ ...TELEMETRY_REQUEST_RETENTION });
let retention = $state<{ maxPerServer: number; maxAgeDays: number }>({
...TELEMETRY_REQUEST_RETENTION
});
let refreshing = $state(false);
let loaded = $state(false);
let error = $state('');
@@ -31,13 +36,18 @@
controller = requestController;
refreshing = true;
try {
const response = await fetch(`/api/servers/${encodeURIComponent(currentServer)}/telemetry-requests`, {
cache: 'no-store',
signal: requestController.signal
});
const response = await fetch(
`/api/servers/${encodeURIComponent(currentServer)}/telemetry-requests`,
{
cache: 'no-store',
signal: requestController.signal
}
);
const body = await response.json().catch(() => null);
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 || requestController.signal.aborted) return;
requests = body.requests;
@@ -47,7 +57,8 @@
refreshedAt = Date.now();
} catch (cause) {
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 {
@@ -78,13 +89,21 @@
<h2>Telemetry API requests</h2>
<p class="server-name">{serverTitle(server)}</p>
</div>
<Freshness updatedAt={refreshedAt} error={error ? 'Telemetry request updates interrupted.' : ''} intervalLabel="Updates every 5 seconds" onRetry={() => void refresh()} retryDisabled={refreshing} />
<Freshness
updatedAt={refreshedAt}
error={error ? 'Telemetry request updates interrupted.' : ''}
intervalLabel="Updates every 5 seconds"
onRetry={() => void refresh()}
retryDisabled={refreshing}
/>
</div>
{#if error}
<p class="load-error" role="alert">
Couldn’t load telemetry requests. {#if loaded} Previously loaded requests are shown below.{/if}
</p><details class="payload"><summary>Technical details</summary>{error}</details>
Couldn’t load telemetry requests. {#if loaded}
Previously loaded requests are shown below.{/if}
</p>
<details class="payload"><summary>Technical details</summary>{error}</details>
{/if}
{#if !loaded && !error}
@@ -100,7 +119,13 @@
<li class="request">
<div class="request-head">
<div class="request-result">
<Badge variant={request.status >= 500 ? 'danger' : request.status >= 400 ? 'warning' : 'online'}>
<Badge
variant={request.status >= 500
? 'danger'
: request.status >= 400
? 'warning'
: 'online'}
>
{outcome(request.status)} · HTTP {request.status}
</Badge>
<code class="event">{request.event ?? 'Event not provided'}</code>
@@ -108,16 +133,32 @@
<span class="received"><RelativeTime value={request.receivedAt} /></span>
</div>
<dl class="request-meta">
<div><dt>Map</dt><dd><code>{request.map ?? 'Not provided'}</code></dd></div>
<div><dt>Round</dt><dd>{request.round ?? 'Not provided'}</dd></div>
<div><dt>Players</dt><dd>{request.playerCount ?? 'Not provided'}</dd></div>
<div>
<dt>Map</dt>
<dd><code>{request.map ?? 'Not provided'}</code></dd>
</div>
<div>
<dt>Round</dt>
<dd>{request.round ?? 'Not provided'}</dd>
</div>
<div>
<dt>Players</dt>
<dd>{request.playerCount ?? 'Not provided'}</dd>
</div>
</dl>
{#if request.error}<p class="request-error">{request.error}</p>{/if}
<details class="payload">
<summary>Request payload{#if request.payloadTruncated} (truncated){/if}</summary>
<p class="content-type">Content type: <code>{request.contentType ?? 'Not provided'}</code></p>
<summary
>Request payload{#if request.payloadTruncated}
(truncated){/if}</summary
>
<p class="content-type">
Content type: <code>{request.contentType ?? 'Not provided'}</code>
</p>
<pre>{request.payload}</pre>
{#if request.payloadTruncated}<p class="truncation">Only the beginning of this payload was retained.</p>{/if}
{#if request.payloadTruncated}<p class="truncation">
Only the beginning of this payload was retained.
</p>{/if}
</details>
</li>
{/each}
@@ -125,59 +166,155 @@
<ListFooter total={requests.length} bind:limit label="requests" />
{/if}
<details class="request-help"><summary>About request logging</summary> <p class="intro">
Incoming <code>POST /api/telemetry</code> requests with the exact server name <code>{server}</code>.
Fresh game telemetry supplies player counts when available; RCON provides a fallback. The scoreboard uses telemetry.
</p>
<p class="retention">
Newest first · {retention.maxPerServer ? `up to ${retention.maxPerServer} requests per server` : 'no per-server retention cap'} · {retention.maxAgeDays ? `last ${retention.maxAgeDays} days` : 'no age expiry'}. Fleet-wide limits also apply.
</p>
<details class="request-help">
<summary>About request logging</summary>
<p class="intro">
Incoming <code>POST /api/telemetry</code> requests with the exact server name
<code>{server}</code>. Fresh game telemetry supplies player counts when available; RCON
provides a fallback. The scoreboard uses telemetry.
</p>
<p class="retention">
Newest first · {retention.maxPerServer
? `up to ${retention.maxPerServer} requests per server`
: 'no per-server retention cap'} · {retention.maxAgeDays
? `last ${retention.maxAgeDays} days`
: 'no age expiry'}. Fleet-wide limits also apply.
</p>
<p class="attribution">
Requests can be linked here only when readable JSON contains a matching top-level <code>server</code> field.
Calls that never reach Electric Cherry, malformed JSON, and different server names will not appear here.
</p></details>
<p class="attribution">
Requests can be linked here only when readable JSON contains a matching top-level <code
>server</code
> field. Calls that never reach Electric Cherry, malformed JSON, and different server names
will not appear here.
</p>
</details>
</Card>
<style>
.request-help { margin-top: 16px; font-size: var(--text-xs); }
.panel-head, .request-head, .request-result {
.request-help {
margin-top: 16px;
font-size: var(--text-xs);
}
.panel-head,
.request-head,
.request-result {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
}
.panel-head { margin-bottom: var(--space-4); }
h2 { margin: 0; font-size: var(--text-md); font-weight: var(--weight-semibold); }
p { margin: 0; }
code, pre { font-family: var(--font-mono); }
.server-name { margin-top: var(--space-2); overflow-wrap: anywhere; color: var(--text-secondary); font-size: var(--text-xs); }
.intro { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.6; }
.retention, .attribution, .content-type, .truncation {
.panel-head {
margin-bottom: var(--space-4);
}
h2 {
margin: 0;
font-size: var(--text-md);
font-weight: var(--weight-semibold);
}
p {
margin: 0;
}
code,
pre {
font-family: var(--font-mono);
}
.server-name {
margin-top: var(--space-2);
overflow-wrap: anywhere;
color: var(--text-secondary);
font-size: var(--text-xs);
}
.intro {
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: 1.6;
}
.retention,
.attribution,
.content-type,
.truncation {
font-size: var(--text-xs);
color: var(--text-tertiary);
line-height: 1.6;
overflow-wrap: anywhere;
}
.retention { margin-top: var(--space-2); }
.attribution { margin-top: var(--space-4); }
.request-list { list-style: none; padding: 0; margin: var(--space-5) 0 0; }
.request { padding: var(--space-4) 0; border-top: 1px solid var(--border-subtle); }
.request-result { justify-content: flex-start; min-width: 0; }
.event { font-size: var(--text-sm); overflow-wrap: anywhere; }
.received { color: var(--text-tertiary); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
.request-meta { display: flex; flex-wrap: wrap; gap: var(--space-3) var(--space-6); margin: var(--space-3) 0; font-size: var(--text-xs); }
.request-meta div { display: flex; gap: var(--space-2); min-width: 0; }
dt { color: var(--text-tertiary); }
dd { margin: 0; color: var(--text-secondary); overflow-wrap: anywhere; }
.load-error, .request-error { color: var(--state-danger); font-size: var(--text-sm); overflow-wrap: anywhere; }
.load-error { margin-top: var(--space-4); }
.request-error { margin-bottom: var(--space-3); }
.payload { font-size: var(--text-xs); }
summary { color: var(--text-secondary); cursor: pointer; width: fit-content; }
summary:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); }
.content-type { margin-top: var(--space-3); }
.retention {
margin-top: var(--space-2);
}
.attribution {
margin-top: var(--space-4);
}
.request-list {
list-style: none;
padding: 0;
margin: var(--space-5) 0 0;
}
.request {
padding: var(--space-4) 0;
border-top: 1px solid var(--border-subtle);
}
.request-result {
justify-content: flex-start;
min-width: 0;
}
.event {
font-size: var(--text-sm);
overflow-wrap: anywhere;
}
.received {
color: var(--text-tertiary);
font-size: var(--text-xs);
font-variant-numeric: tabular-nums;
}
.request-meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-3) var(--space-6);
margin: var(--space-3) 0;
font-size: var(--text-xs);
}
.request-meta div {
display: flex;
gap: var(--space-2);
min-width: 0;
}
dt {
color: var(--text-tertiary);
}
dd {
margin: 0;
color: var(--text-secondary);
overflow-wrap: anywhere;
}
.load-error,
.request-error {
color: var(--state-danger);
font-size: var(--text-sm);
overflow-wrap: anywhere;
}
.load-error {
margin-top: var(--space-4);
}
.request-error {
margin-bottom: var(--space-3);
}
.payload {
font-size: var(--text-xs);
}
summary {
color: var(--text-secondary);
cursor: pointer;
width: fit-content;
}
summary:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
border-radius: var(--radius-sm);
}
.content-type {
margin-top: var(--space-3);
}
pre {
margin: var(--space-2) 0 0;
padding: var(--space-3);
@@ -191,5 +328,7 @@
color: var(--text-secondary);
line-height: 1.6;
}
.truncation { margin-top: var(--space-2); }
.truncation {
margin-top: var(--space-2);
}
</style>
+9 -1
View File
@@ -2,7 +2,15 @@
import { watchdogStateLabels, type WatchdogState } from '$lib/watchdog.js';
import Badge from './primitives/Badge.svelte';
let { state }: { state: WatchdogState } = $props();
const tone = $derived(state === 'attention' ? 'warning' : state === 'resolved' ? 'online' : state === 'recovering' ? 'info' : 'neutral');
const tone = $derived(
state === 'attention'
? 'warning'
: state === 'resolved'
? 'online'
: state === 'recovering'
? 'info'
: 'neutral'
);
</script>
<Badge variant={tone}>{watchdogStateLabels[state]}</Badge>
@@ -7,19 +7,69 @@
let dismissed = $state(false);
</script>
<button type="button" class="observation-time" class:stale={age.stale} class:dismissed
aria-label={`${age.label}; observation details`} aria-describedby={id}
onfocus={() => dismissed = false} onpointerenter={() => dismissed = false}
onclick={() => dismissed = false} onkeydown={event => { if (event.key === 'Escape') dismissed = true; }}>
<button
type="button"
class="observation-time"
class:stale={age.stale}
class:dismissed
aria-label={`${age.label}; observation details`}
aria-describedby={id}
onfocus={() => (dismissed = false)}
onpointerenter={() => (dismissed = false)}
onclick={() => (dismissed = false)}
onkeydown={(event) => {
if (event.key === 'Escape') dismissed = true;
}}
>
<time datetime={new Date(at).toISOString()}>{age.label}</time>
<span class="timestamp-detail" role="tooltip" {id}>{$timeFormat.dateTime(at)} · {source ?? 'Source unavailable'}</span>
<span class="timestamp-detail" role="tooltip" {id}
>{$timeFormat.dateTime(at)} · {source ?? 'Source unavailable'}</span
>
</button>
<style>
.observation-time { position: relative; padding: 4px 0; border: 0; border-radius: var(--radius-sm); background: none; color: var(--text-secondary); font: inherit; cursor: help; text-align: left; }
.observation-time.stale { color: var(--state-warning); }
.observation-time:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.timestamp-detail { visibility: hidden; position: absolute; z-index: 5; right: 0; bottom: 100%; width: max-content; max-width: min(280px, 75vw); padding: 8px 10px; color: var(--text-primary); background: var(--surface-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); box-shadow: 0 4px 12px #0004; line-height: 1.5; }
.observation-time:hover:not(.dismissed) .timestamp-detail, .observation-time:focus:not(.dismissed) .timestamp-detail { visibility: visible; }
@media (max-width: 640px) { .timestamp-detail { left: 0; right: auto; } }
.observation-time {
position: relative;
padding: 4px 0;
border: 0;
border-radius: var(--radius-sm);
background: none;
color: var(--text-secondary);
font: inherit;
cursor: help;
text-align: left;
}
.observation-time.stale {
color: var(--state-warning);
}
.observation-time:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.timestamp-detail {
visibility: hidden;
position: absolute;
z-index: 5;
right: 0;
bottom: 100%;
width: max-content;
max-width: min(280px, 75vw);
padding: 8px 10px;
color: var(--text-primary);
background: var(--surface-overlay);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px #0004;
line-height: 1.5;
}
.observation-time:hover:not(.dismissed) .timestamp-detail,
.observation-time:focus:not(.dismissed) .timestamp-detail {
visibility: visible;
}
@media (max-width: 640px) {
.timestamp-detail {
left: 0;
right: auto;
}
}
</style>
@@ -2,7 +2,13 @@
import { onMount } from 'svelte';
import type { ScalingPolicy, ScalingView, ScalingMember } from '$lib/autoscaling.js';
import { parseServerName, zombieMapBySlug } from '$lib/map-aliases.js';
import { lobbyGroups, lobbyLabel, policyStatus, scalingActivity, scalingReason } from '$ui/autoscaling.js';
import {
lobbyGroups,
lobbyLabel,
policyStatus,
scalingActivity,
scalingReason
} from '$ui/autoscaling.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Badge from '../primitives/Badge.svelte';
import Button from '../primitives/Button.svelte';
@@ -11,8 +17,19 @@
import Tabs from '../primitives/Tabs.svelte';
import ObservationTime from './ObservationTime.svelte';
let { view, globallyPaused, canOperate, busy, onedit, ontoggle, ondelete }: {
view: ScalingView; globallyPaused: boolean; canOperate: boolean; busy: boolean;
let {
view,
globallyPaused,
canOperate,
busy,
onedit,
ontoggle,
ondelete
}: {
view: ScalingView;
globallyPaused: boolean;
canOperate: boolean;
busy: boolean;
onedit: (policy: ScalingPolicy) => void;
ontoggle: (policy: ScalingPolicy) => void;
ondelete: (policy: ScalingPolicy) => void;
@@ -21,47 +38,109 @@
let now = $state(Date.now());
onMount(() => {
now = Date.now();
const timer = setInterval(() => now = Date.now(), 1000);
const timer = setInterval(() => (now = Date.now()), 1000);
return () => clearInterval(timer);
});
let menuOpen = $state(false);
let menuElement = $state<HTMLDivElement | null>(null);
const title = $derived(`${view.policy.mapSlug === '*' ? 'Every map' : zombieMapBySlug(view.policy.game, view.policy.mapSlug)?.friendly ?? view.policy.mapSlug} · ${view.policy.game.toUpperCase()}`);
const title = $derived(
`${view.policy.mapSlug === '*' ? 'Every map' : (zombieMapBySlug(view.policy.game, view.policy.mapSlug)?.friendly ?? view.policy.mapSlug)} · ${view.policy.game.toUpperCase()}`
);
const status = $derived(policyStatus(view, globallyPaused));
const groups = $derived(lobbyGroups(view));
const activity = $derived(scalingActivity(view));
const activeIds = $derived(new Set(view.unsettled.map(operation => operation.id)));
const covered = $derived(groups.filter(group => group.target > 0 && group.ready >= group.target).length);
const mapCount = $derived(groups.filter(group => group.target > 0).length);
const healthyCoverage = $derived(status.tone === 'online' && mapCount > 0 && covered === mapCount && view.policy.mapSlug === '*' ? `All ${mapCount} maps meet their spare target.` : null);
const activeIds = $derived(new Set(view.unsettled.map((operation) => operation.id)));
const covered = $derived(
groups.filter((group) => group.target > 0 && group.ready >= group.target).length
);
const mapCount = $derived(groups.filter((group) => group.target > 0).length);
const healthyCoverage = $derived(
status.tone === 'online' &&
mapCount > 0 &&
covered === mapCount &&
view.policy.mapSlug === '*'
? `All ${mapCount} maps meet their spare target.`
: null
);
const dates = (at: number) => $timeFormat.dateTime(new Date(at).toISOString());
const memberStatus = (state: ScalingMember['state']) => ({
occupied: 'Occupied', empty: 'Ready spare', creating: 'Starting', removing: 'Removing', stopped: 'Stopped', unknown: 'Unknown', protected: 'Protected'
})[state];
const memberTone = (state: ScalingMember['state']) => state === 'empty' ? 'online' : state === 'occupied' ? 'neutral' : state === 'creating' || state === 'removing' ? 'info' : 'warning';
const memberStatus = (state: ScalingMember['state']) =>
({
occupied: 'Occupied',
empty: 'Ready spare',
creating: 'Starting',
removing: 'Removing',
stopped: 'Stopped',
unknown: 'Unknown',
protected: 'Protected'
})[state];
const memberTone = (state: ScalingMember['state']) =>
state === 'empty'
? 'online'
: state === 'occupied'
? 'neutral'
: state === 'creating' || state === 'removing'
? 'info'
: 'warning';
function closeMenu(event: MouseEvent) {
if (menuOpen && event.target instanceof Node && !menuElement?.contains(event.target)) menuOpen = false;
if (menuOpen && event.target instanceof Node && !menuElement?.contains(event.target))
menuOpen = false;
}
</script>
<svelte:window onclick={closeMenu} onkeydown={event => { if (event.key === 'Escape' && menuOpen) { menuOpen = false; menuElement?.querySelector('button')?.focus(); } }} />
<svelte:window
onclick={closeMenu}
onkeydown={(event) => {
if (event.key === 'Escape' && menuOpen) {
menuOpen = false;
menuElement?.querySelector('button')?.focus();
}
}}
/>
<section class="policy" id={view.policy.id} aria-labelledby={`title-${view.policy.id}`}>
<header class="heading">
<div class="identity">
<h2 id={`title-${view.policy.id}`}>{title}</h2>
<Badge variant={view.policy.mode === 'off' ? 'neutral' : 'info'}>{view.policy.mode === 'off' ? 'Paused' : 'Enabled'}</Badge>
<Badge variant={view.policy.mode === 'off' ? 'neutral' : 'info'}
>{view.policy.mode === 'off' ? 'Paused' : 'Enabled'}</Badge
>
</div>
{#if canOperate}
<div class="actions">
<Button size="sm" disabled={busy} onclick={() => onedit(view.policy)}>Edit policy</Button>
<Button size="sm" variant="ghost" disabled={busy} onclick={() => ontoggle(view.policy)}>{view.policy.mode === 'off' ? 'Resume' : 'Pause'}</Button>
<Button size="sm" disabled={busy} onclick={() => onedit(view.policy)}
>Edit policy</Button
>
<Button
size="sm"
variant="ghost"
disabled={busy}
onclick={() => ontoggle(view.policy)}
>{view.policy.mode === 'off' ? 'Resume' : 'Pause'}</Button
>
<div class="more" bind:this={menuElement}>
<button class="more-toggle" type="button" aria-label={`More actions for ${title}`} aria-expanded={menuOpen} aria-controls={`actions-${view.policy.id}`} onclick={() => menuOpen = !menuOpen}>•••</button>
<button
class="more-toggle"
type="button"
aria-label={`More actions for ${title}`}
aria-expanded={menuOpen}
aria-controls={`actions-${view.policy.id}`}
onclick={() => (menuOpen = !menuOpen)}>•••</button
>
{#if menuOpen}
<div class="action-menu" id={`actions-${view.policy.id}`}>
<Button size="sm" variant="danger" disabled={busy || view.unsettled.length > 0} onclick={() => { menuOpen = false; ondelete(view.policy); }}>Delete policy</Button>
{#if view.unsettled.length}<p>Pause this policy and wait for outstanding jobs to finish before deleting it.</p>{/if}
<Button
size="sm"
variant="danger"
disabled={busy || view.unsettled.length > 0}
onclick={() => {
menuOpen = false;
ondelete(view.policy);
}}>Delete policy</Button
>
{#if view.unsettled.length}<p>
Pause this policy and wait for outstanding jobs to finish before
deleting it.
</p>{/if}
</div>
{/if}
</div>
@@ -72,12 +151,29 @@
<div class="counts" aria-label="Lobby counts across this policy">
<Stat label="Occupied" value={view.decision.occupied} tween={false} />
<Stat label="Ready spares" value={view.decision.empty} tone="online" tween={false} />
<Stat label="Pending" value={view.decision.pending} tone={view.decision.pending ? 'accent' : 'primary'} tween={false} />
<Stat label="Unknown / stopped" value={view.decision.unknown} tone={view.decision.unknown ? 'warning' : 'primary'} tween={false} />
<Stat
label="Pending"
value={view.decision.pending}
tone={view.decision.pending ? 'accent' : 'primary'}
tween={false}
/>
<Stat
label="Unknown / stopped"
value={view.decision.unknown}
tone={view.decision.unknown ? 'warning' : 'primary'}
tween={false}
/>
</div>
<div class="settings">
<p>{view.policy.mapSlug === '*' ? 'Per map: ' : ''}Min {view.policy.minimum} · Max {view.policy.maximum} · Spare target {view.policy.spares} · Remove after {view.policy.emptyMinutes} minutes empty</p>
<p class:warning={view.snapshot.keyPool.available === 0}>{view.snapshot.keyPool.available} / {view.snapshot.keyPool.total} game keys available <span class="muted">· Shared across {view.policy.game.toUpperCase()}</span></p>
<p>
{view.policy.mapSlug === '*' ? 'Per map: ' : ''}Min {view.policy.minimum} · Max {view
.policy.maximum} · Spare target {view.policy.spares} · Remove after {view.policy
.emptyMinutes} minutes empty
</p>
<p class:warning={view.snapshot.keyPool.available === 0}>
{view.snapshot.keyPool.available} / {view.snapshot.keyPool.total} game keys available
<span class="muted">· Shared across {view.policy.game.toUpperCase()}</span>
</p>
</div>
{#if !view.unsettled.length || globallyPaused || view.policy.mode === 'off' || view.policy.pausedReason}
@@ -87,153 +183,574 @@
</div>
{/if}
{#each view.unsettled as operation (operation.id)}
{@const member = view.snapshot.members.find(member => member.operationId === operation.id)}
{@const needsAttention = ['failed', 'interrupted', 'manual', 'unknown', 'cancelled', 'warning'].includes(operation.state)}
{@const member = view.snapshot.members.find(
(member) => member.operationId === operation.id
)}
{@const needsAttention = [
'failed',
'interrupted',
'manual',
'unknown',
'cancelled',
'warning'
].includes(operation.state)}
<div class="job" class:attention={needsAttention}>
<span class="job-dot" class:working={!needsAttention} aria-hidden="true"></span>
<div class="job-copy">
<div class="job-title"><strong>{needsAttention ? 'Review' : operation.action === 'destroy' ? 'Removing' : 'Creating'} {member ? lobbyLabel(member.name) : 'lobby'}{needsAttention ? ' job' : ''}</strong><StatusBadge state={operation.state} /></div>
<div class="job-title">
<strong
>{needsAttention
? 'Review'
: operation.action === 'destroy'
? 'Removing'
: 'Creating'}
{member ? lobbyLabel(member.name) : 'lobby'}{needsAttention
? ' job'
: ''}</strong
><StatusBadge state={operation.state} />
</div>
<p>{operation.stage || 'Waiting for the next provisioning update.'}</p>
</div>
<Button size="sm" variant="ghost" href={`/provisioning/${operation.id}`}>View job</Button>
<Button size="sm" variant="ghost" href={`/provisioning/${operation.id}`}
>View job</Button
>
</div>
{/each}
<div class="detail-nav">
<Tabs tabs={[{ id: 'lobbies', label: 'Lobbies', badge: view.snapshot.members.length }, { id: 'activity', label: 'Activity' }, { id: 'metrics', label: 'Metrics' }]} bind:value={tab} ariaLabel={`Details for ${title}`} panelId={`panel-${view.policy.id}`} idPrefix={`tab-${view.policy.id}`} size="sm" />
{#if tab === 'lobbies' && view.policy.mapSlug === '*' && !healthyCoverage}<span class="muted">{covered} / {mapCount} maps meet spare target</span>{/if}
<Tabs
tabs={[
{ id: 'lobbies', label: 'Lobbies', badge: view.snapshot.members.length },
{ id: 'activity', label: 'Activity' },
{ id: 'metrics', label: 'Metrics' }
]}
bind:value={tab}
ariaLabel={`Details for ${title}`}
panelId={`panel-${view.policy.id}`}
idPrefix={`tab-${view.policy.id}`}
size="sm"
/>
{#if tab === 'lobbies' && view.policy.mapSlug === '*' && !healthyCoverage}<span
class="muted">{covered} / {mapCount} maps meet spare target</span
>{/if}
</div>
<div class="tab-panel" id={`panel-${view.policy.id}`} role="tabpanel" aria-labelledby={`tab-${view.policy.id}-${tab}`} tabindex="0">
<div
class="tab-panel"
id={`panel-${view.policy.id}`}
role="tabpanel"
aria-labelledby={`tab-${view.policy.id}-${tab}`}
tabindex="0"
>
{#if tab === 'lobbies'}
<div class="table-scroll">
<table>
<thead><tr><th>Map / lobby</th><th>State</th><th>Players</th><th>Latest observation</th></tr></thead>
<thead
><tr
><th>Map / lobby</th><th>State</th><th>Players</th><th
>Latest observation</th
></tr
></thead
>
{#each groups as group (group.slug)}
<tbody>
{#each group.members as member, index (member.name)}
{@const pending = member.state === 'creating' || member.state === 'removing'}
{@const pending =
member.state === 'creating' || member.state === 'removing'}
{@const number = parseServerName(member.name)?.lobby}
<tr>
<td>
<div class="lobby-identity">
<a class="lobby-link" href={`/servers/${encodeURIComponent(member.name)}`} aria-label={lobbyLabel(member.name)} title={member.name}>{`${index === 0 && group.slug !== 'unassigned' ? `${group.label} · ` : ''}${number == null ? member.name : `Lobby ${number}`}`}</a>
{#if index === 0 && group.target}<span class="coverage" class:warning={group.ready < group.target}>{group.ready} / {group.target} spares</span>{/if}
<a
class="lobby-link"
href={`/servers/${encodeURIComponent(member.name)}`}
aria-label={lobbyLabel(member.name)}
title={member.name}
>{`${index === 0 && group.slug !== 'unassigned' ? `${group.label} · ` : ''}${number == null ? member.name : `Lobby ${number}`}`}</a
>
{#if index === 0 && group.target}<span
class="coverage"
class:warning={group.ready < group.target}
>{group.ready} / {group.target} spares</span
>{/if}
</div>
{#if member.blocker && !activeIds.has(member.operationId ?? '')}<span class="member-note">{scalingReason(member.blocker)}</span>{/if}
{#if member.blocker && !activeIds.has(member.operationId ?? '')}<span
class="member-note"
>{scalingReason(member.blocker)}</span
>{/if}
</td>
<td><Badge variant={memberTone(member.state)}>{memberStatus(member.state)}</Badge>{#if member.operationId}<a class="job-link" href={`/provisioning/${member.operationId}`}>View job</a>{/if}</td>
<td
><Badge variant={memberTone(member.state)}
>{memberStatus(member.state)}</Badge
>{#if member.operationId}<a
class="job-link"
href={`/provisioning/${member.operationId}`}
>View job</a
>{/if}</td
>
<td class="players">{member.players ?? '—'}</td>
<td class="observation">{#if member.observedAt !== null}<ObservationTime at={member.observedAt} source={member.source} {now} />{:else}<span class:warning={!pending}>{pending ? 'Awaiting first observation' : 'No recent observation'}</span>{/if}</td>
<td class="observation"
>{#if member.observedAt !== null}<ObservationTime
at={member.observedAt}
source={member.source}
{now}
/>{:else}<span class:warning={!pending}
>{pending
? 'Awaiting first observation'
: 'No recent observation'}</span
>{/if}</td
>
</tr>
{:else}
<tr><td><div class="lobby-identity"><span>{group.label}</span>{#if group.target}<span class="coverage warning">0 / {group.target} spares</span>{/if}</div></td><td colspan="3" class="empty-map">No lobbies yet</td></tr>
<tr
><td
><div class="lobby-identity">
<span>{group.label}</span>{#if group.target}<span
class="coverage warning"
>0 / {group.target} spares</span
>{/if}
</div></td
><td colspan="3" class="empty-map">No lobbies yet</td></tr
>
{/each}
</tbody>
{/each}
</table>
</div>
{#each view.snapshot.blockers.filter(blocker => scalingReason(blocker) !== status.reason && blocker !== 'Autoscaling is globally paused.') as blocker}<p class="blocker">{scalingReason(blocker)}</p>{/each}
{#each view.snapshot.blockers.filter((blocker) => scalingReason(blocker) !== status.reason && blocker !== 'Autoscaling is globally paused.') as blocker}<p
class="blocker"
>
{scalingReason(blocker)}
</p>{/each}
{:else if tab === 'activity'}
<p class="section-note">Recent decision changes · Provisioning updates are grouped by job.</p>
<p class="section-note">
Recent decision changes · Provisioning updates are grouped by job.
</p>
<ol class="activity">
{#each activity as item (item.id)}
<li>
<div class="activity-heading"><strong>{item.action === 'create' ? 'Creation requested' : item.action === 'destroy' ? 'Removal requested' : item.operationId ? 'Provisioning in progress' : 'Scaling evaluated'}{item.server ? ` · ${lobbyLabel(item.server)}` : ''}</strong>{#if item.operationState}<StatusBadge state={item.operationState} />{/if}</div>
{#if !item.operationId || !activeIds.has(item.operationId)}<p>{item.reason}</p>{/if}
<div class="activity-meta"><time datetime={new Date(item.at).toISOString()}>{dates(item.at)}</time>{#if item.evaluations > 1}<span>{item.evaluations} decision changes</span>{/if}{#if item.operationId}<a href={`/provisioning/${item.operationId}`}>View job</a>{/if}</div>
<div class="activity-heading">
<strong
>{item.action === 'create'
? 'Creation requested'
: item.action === 'destroy'
? 'Removal requested'
: item.operationId
? 'Provisioning in progress'
: 'Scaling evaluated'}{item.server
? ` · ${lobbyLabel(item.server)}`
: ''}</strong
>{#if item.operationState}<StatusBadge
state={item.operationState}
/>{/if}
</div>
{#if !item.operationId || !activeIds.has(item.operationId)}<p>
{item.reason}
</p>{/if}
<div class="activity-meta">
<time datetime={new Date(item.at).toISOString()}>{dates(item.at)}</time
>{#if item.evaluations > 1}<span
>{item.evaluations} decision changes</span
>{/if}{#if item.operationId}<a
href={`/provisioning/${item.operationId}`}>View job</a
>{/if}
</div>
</li>
{:else}<li class="muted">No scaling activity yet.</li>{/each}
</ol>
{:else}
<p class="section-note">Last 31 days · {view.totals.observedMinutes.toFixed(1)} minutes observed</p>
<p class="section-note">
Last 31 days · {view.totals.observedMinutes.toFixed(1)} minutes observed
</p>
<div class="metrics">
<Stat label="Empty lobby-hours" value={view.totals.emptyLobbyHours.toFixed(2)} />
<Stat label="Below spare target" value={view.totals.deficitMinutes.toFixed(1)} unit="min" />
<Stat label="Unknown occupancy" value={view.totals.unknownMinutes.toFixed(1)} unit="min" />
<Stat
label="Below spare target"
value={view.totals.deficitMinutes.toFixed(1)}
unit="min"
/>
<Stat
label="Unknown occupancy"
value={view.totals.unknownMinutes.toFixed(1)}
unit="min"
/>
<Stat label="Create requests" value={view.operations.creates} tween={false} />
<Stat label="Removal requests" value={view.operations.removals} tween={false} />
<Stat label="Cancelled / failed" value={`${view.operations.cancelled} / ${view.operations.failed}`} tone={view.operations.failed ? 'warning' : 'primary'} />
<Stat
label="Cancelled / failed"
value={`${view.operations.cancelled} / ${view.operations.failed}`}
tone={view.operations.failed ? 'warning' : 'primary'}
/>
</div>
{#if view.operations.readinessSamples}
<p class="readiness">Request to verified readiness: <strong>{view.operations.readinessMeanSeconds?.toFixed(0)}s average</strong> · {view.operations.readinessMaxSeconds?.toFixed(0)}s maximum · {view.operations.readinessSamples} completed creates</p>
<p class="readiness">
Request to verified readiness: <strong
>{view.operations.readinessMeanSeconds?.toFixed(0)}s average</strong
>
· {view.operations.readinessMaxSeconds?.toFixed(0)}s maximum · {view.operations
.readinessSamples} completed creates
</p>
{/if}
<p class="muted metric-note">Observation gaps are excluded. Readiness includes queue time and does not confirm server browser visibility or successful joins.</p>
<p class="muted metric-note">
Observation gaps are excluded. Readiness includes queue time and does not confirm
server browser visibility or successful joins.
</p>
{/if}
</div>
<footer>Checks every 15 seconds <span>·</span> Last evaluated: {view.lastEvaluatedAt ? dates(view.lastEvaluatedAt) : 'Not evaluated yet'}</footer>
<footer>
Checks every 15 seconds <span>·</span> Last evaluated: {view.lastEvaluatedAt
? dates(view.lastEvaluatedAt)
: 'Not evaluated yet'}
</footer>
</section>
<style>
.policy { margin: 20px 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow-wrap: anywhere; }
.heading { padding: 20px 24px 0; display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
.identity, .actions, .job-title, .activity-heading { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
h2 { font-size: var(--text-lg); margin: 0; }
.more { position: relative; }
.more-toggle { border: 1px solid var(--border-subtle); background: transparent; color: var(--text-secondary); border-radius: var(--radius-sm); min-width: 34px; height: 32px; cursor: pointer; letter-spacing: 2px; }
.more-toggle:focus-visible, .tab-panel:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.action-menu { position: absolute; z-index: 5; right: 0; top: 40px; width: 240px; max-width: calc(100vw - 80px); padding: 12px; background: var(--surface-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); box-shadow: 0 8px 24px #0005; }
.action-menu p { font-size: var(--text-xs); line-height: 1.5; color: var(--text-secondary); margin: 10px 0 0; }
.counts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; padding: 24px; }
.settings { padding: 0 24px; color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }
.settings p { margin: 0 0 4px; }
.muted { color: var(--text-tertiary); font-size: var(--text-xs); }
.warning { color: var(--state-warning); }
.summary, .job { margin: 18px 24px 0; display: flex; align-items: center; gap: 12px; font-size: var(--text-sm); }
.summary { flex-wrap: wrap; color: var(--text-secondary); }
.summary.attention { border-left: 2px solid var(--state-warning); padding-left: 12px; }
.job { background: var(--surface-raised); border-radius: var(--radius-md); padding: 12px 14px; }
.job-copy { flex: 1; min-width: 0; }
.job-copy p { margin: 4px 0 0; color: var(--text-secondary); line-height: 1.5; }
.job-dot { width: 7px; height: 7px; flex-shrink: 0; border-radius: 50%; background: var(--state-warning); }
.job-dot.working { background: var(--accent-primary); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent-primary) 12%, transparent); }
.job.attention { border: 1px solid var(--state-warning); }
.detail-nav { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 24px 24px 14px; }
.tab-panel { min-height: 120px; }
.table-scroll { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; text-align: left; font-size: var(--text-sm); }
thead th { font-size: var(--text-xs); font-weight: 500; color: var(--text-tertiary); padding: 8px 16px 12px; }
th:first-child, td:first-child { padding-left: 24px; }
th:last-child, td:last-child { padding-right: 24px; }
td { padding: 10px 16px; border-bottom: 1px solid var(--border-subtle); vertical-align: middle; }
tbody:last-child tr:last-child td { border-bottom: 0; }
.lobby-identity { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px 12px; }
.coverage { color: var(--text-tertiary); font-size: var(--text-xs); white-space: nowrap; }
.coverage.warning { color: var(--state-warning); }
.lobby-link { font-weight: 500; text-decoration: none; }
.lobby-link:hover, .job-link:hover { text-decoration: underline; }
.member-note { display: block; margin-top: 5px; color: var(--state-warning); font-size: var(--text-xs); }
.job-link { display: block; margin-top: 6px; font-size: var(--text-xs); text-decoration: none; }
.players { font-variant-numeric: tabular-nums; }
.observation { color: var(--text-secondary); font-size: var(--text-xs); }
.empty-map { color: var(--text-tertiary); font-size: var(--text-xs); }
.section-note { margin: 0; padding: 6px 24px 16px; font-size: var(--text-xs); color: var(--text-secondary); }
.activity { margin: 0; padding: 0 24px; list-style: none; }
.activity li { padding: 16px 0; border-top: 1px solid var(--border-subtle); font-size: var(--text-sm); }
.activity-heading strong { font-weight: 500; }
.activity p { color: var(--text-secondary); margin: 8px 0; line-height: 1.5; }
.activity-meta { margin-top: 8px; display: flex; gap: 12px; flex-wrap: wrap; font-size: var(--text-xs); color: var(--text-tertiary); }
.metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; padding: 8px 24px 24px; }
.readiness, .metric-note { margin: 0; padding: 0 24px 16px; line-height: 1.6; }
.readiness { font-size: var(--text-sm); color: var(--text-secondary); }
.blocker { margin: 12px 24px; color: var(--state-warning); font-size: var(--text-sm); }
footer { border-top: 1px solid var(--border-subtle); padding: 12px 24px; color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.7; }
footer span { margin: 0 5px; }
.policy {
margin: 20px 0;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
overflow-wrap: anywhere;
}
.heading {
padding: 20px 24px 0;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.identity,
.actions,
.job-title,
.activity-heading {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
h2 {
font-size: var(--text-lg);
margin: 0;
}
.more {
position: relative;
}
.more-toggle {
border: 1px solid var(--border-subtle);
background: transparent;
color: var(--text-secondary);
border-radius: var(--radius-sm);
min-width: 34px;
height: 32px;
cursor: pointer;
letter-spacing: 2px;
}
.more-toggle:focus-visible,
.tab-panel:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.action-menu {
position: absolute;
z-index: 5;
right: 0;
top: 40px;
width: 240px;
max-width: calc(100vw - 80px);
padding: 12px;
background: var(--surface-raised);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
box-shadow: 0 8px 24px #0005;
}
.action-menu p {
font-size: var(--text-xs);
line-height: 1.5;
color: var(--text-secondary);
margin: 10px 0 0;
}
.counts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 20px;
padding: 24px;
}
.settings {
padding: 0 24px;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.6;
}
.settings p {
margin: 0 0 4px;
}
.muted {
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.warning {
color: var(--state-warning);
}
.summary,
.job {
margin: 18px 24px 0;
display: flex;
align-items: center;
gap: 12px;
font-size: var(--text-sm);
}
.summary {
flex-wrap: wrap;
color: var(--text-secondary);
}
.summary.attention {
border-left: 2px solid var(--state-warning);
padding-left: 12px;
}
.job {
background: var(--surface-raised);
border-radius: var(--radius-md);
padding: 12px 14px;
}
.job-copy {
flex: 1;
min-width: 0;
}
.job-copy p {
margin: 4px 0 0;
color: var(--text-secondary);
line-height: 1.5;
}
.job-dot {
width: 7px;
height: 7px;
flex-shrink: 0;
border-radius: 50%;
background: var(--state-warning);
}
.job-dot.working {
background: var(--accent-primary);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent-primary) 12%, transparent);
}
.job.attention {
border: 1px solid var(--state-warning);
}
.detail-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 24px 24px 14px;
}
.tab-panel {
min-height: 120px;
}
.table-scroll {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: var(--text-sm);
}
thead th {
font-size: var(--text-xs);
font-weight: 500;
color: var(--text-tertiary);
padding: 8px 16px 12px;
}
th:first-child,
td:first-child {
padding-left: 24px;
}
th:last-child,
td:last-child {
padding-right: 24px;
}
td {
padding: 10px 16px;
border-bottom: 1px solid var(--border-subtle);
vertical-align: middle;
}
tbody:last-child tr:last-child td {
border-bottom: 0;
}
.lobby-identity {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px 12px;
}
.coverage {
color: var(--text-tertiary);
font-size: var(--text-xs);
white-space: nowrap;
}
.coverage.warning {
color: var(--state-warning);
}
.lobby-link {
font-weight: 500;
text-decoration: none;
}
.lobby-link:hover,
.job-link:hover {
text-decoration: underline;
}
.member-note {
display: block;
margin-top: 5px;
color: var(--state-warning);
font-size: var(--text-xs);
}
.job-link {
display: block;
margin-top: 6px;
font-size: var(--text-xs);
text-decoration: none;
}
.players {
font-variant-numeric: tabular-nums;
}
.observation {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.empty-map {
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.section-note {
margin: 0;
padding: 6px 24px 16px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.activity {
margin: 0;
padding: 0 24px;
list-style: none;
}
.activity li {
padding: 16px 0;
border-top: 1px solid var(--border-subtle);
font-size: var(--text-sm);
}
.activity-heading strong {
font-weight: 500;
}
.activity p {
color: var(--text-secondary);
margin: 8px 0;
line-height: 1.5;
}
.activity-meta {
margin-top: 8px;
display: flex;
gap: 12px;
flex-wrap: wrap;
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 24px;
padding: 8px 24px 24px;
}
.readiness,
.metric-note {
margin: 0;
padding: 0 24px 16px;
line-height: 1.6;
}
.readiness {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.blocker {
margin: 12px 24px;
color: var(--state-warning);
font-size: var(--text-sm);
}
footer {
border-top: 1px solid var(--border-subtle);
padding: 12px 24px;
color: var(--text-tertiary);
font-size: var(--text-xs);
line-height: 1.7;
}
footer span {
margin: 0 5px;
}
@media (max-width: 640px) {
.heading { align-items: flex-start; padding: 16px 16px 0; }
.counts { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 20px 16px; }
.settings { padding-inline: 16px; }
.summary, .job { margin-inline: 16px; }
.job { flex-wrap: wrap; }
.detail-nav { padding: 20px 16px 12px; }
.table-scroll { overflow: visible; }
table, tbody { display: block; width: 100%; }
thead { display: none; }
tr { display: grid; grid-template-columns: minmax(0, 1fr) auto; padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); gap: 10px; }
td, th:first-child, td:first-child, th:last-child, td:last-child { padding: 0; border: 0; }
td:first-child, .observation { grid-column: 1 / -1; }
.players::before { content: 'Players: '; color: var(--text-tertiary); font-size: var(--text-xs); }
.empty-map { grid-column: 1 / -1; }
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); padding-inline: 16px; }
footer { padding-inline: 16px; }
.heading {
align-items: flex-start;
padding: 16px 16px 0;
}
.counts {
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 20px 16px;
}
.settings {
padding-inline: 16px;
}
.summary,
.job {
margin-inline: 16px;
}
.job {
flex-wrap: wrap;
}
.detail-nav {
padding: 20px 16px 12px;
}
.table-scroll {
overflow: visible;
}
table,
tbody {
display: block;
width: 100%;
}
thead {
display: none;
}
tr {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
gap: 10px;
}
td,
th:first-child,
td:first-child,
th:last-child,
td:last-child {
padding: 0;
border: 0;
}
td:first-child,
.observation {
grid-column: 1 / -1;
}
.players::before {
content: 'Players: ';
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.empty-map {
grid-column: 1 / -1;
}
.metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
padding-inline: 16px;
}
footer {
padding-inline: 16px;
}
}
</style>
@@ -3,7 +3,11 @@
import type { CapacityHistory, CapacityHistoryPoint } from '$lib/capacity.js';
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
let { history, scope, controls }: { history: CapacityHistory; scope: string; controls?: Snippet } = $props();
let {
history,
scope,
controls
}: { history: CapacityHistory; scope: string; controls?: Snippet } = $props();
let metric = $state<'cpu' | 'memory'>('cpu');
let selectedAt = $state<number | null>(null);
let tableOpen = $state(false);
@@ -13,17 +17,22 @@
if (selectedAt !== null && points.length) {
let closest = 0;
for (let index = 1; index < points.length; index++) {
if (Math.abs(points[index].at - selectedAt) < Math.abs(points[closest].at - selectedAt)) closest = index;
if (
Math.abs(points[index].at - selectedAt) <
Math.abs(points[closest].at - selectedAt)
)
closest = index;
}
return closest;
}
for (let index = points.length - 1; index >= 0; index--) if (value(points[index]) !== null) return index;
for (let index = points.length - 1; index >= 0; index--)
if (value(points[index]) !== null) return index;
return Math.max(0, points.length - 1);
});
const selected = $derived(points[selectedIndex]);
const measured = $derived(points.filter(point => value(point) !== null));
const measured = $derived(points.filter((point) => value(point) !== null));
const ceiling = $derived.by(() => {
const max = Math.max(1, ...measured.map(point => value(point)!));
const max = Math.max(1, ...measured.map((point) => value(point)!));
const step = 10 ** Math.floor(Math.log10(max)) / 2;
return Math.ceil(max / step) * step;
});
@@ -32,127 +41,508 @@
let segment: { x: number; y: number }[] = [];
for (const point of points) {
const amount = value(point);
if (amount === null) { if (segment.length) result.push(segment); segment = []; }
else segment.push({ x: x(point.at), y: y(amount) });
if (amount === null) {
if (segment.length) result.push(segment);
segment = [];
} else segment.push({ x: x(point.at), y: y(amount) });
}
if (segment.length) result.push(segment);
return result;
});
const ticks = [1, .75, .5, .25, 0];
const ticks = [1, 0.75, 0.5, 0.25, 0];
const numbers = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
function value(point: CapacityHistoryPoint): number | null {
return metric === 'cpu' ? point.cpuCoresAvailable : point.memoryBytesAvailable === null ? null : point.memoryBytesAvailable / 1024 ** 3;
return metric === 'cpu'
? point.cpuCoresAvailable
: point.memoryBytesAvailable === null
? null
: point.memoryBytesAvailable / 1024 ** 3;
}
function x(at: number) {
return Math.max(
0,
Math.min(
1000,
((at - history.startAt) / Math.max(1, history.endAt - history.startAt)) * 1000
)
);
}
function y(amount: number) {
return 230 - Math.max(0, Math.min(1, amount / ceiling)) * 220;
}
function amount(value: number | null, unit: string) {
return value === null ? 'No complete report' : `${numbers.format(value)} ${unit}`;
}
function x(at: number) { return Math.max(0, Math.min(1000, (at - history.startAt) / Math.max(1, history.endAt - history.startAt) * 1000)); }
function y(amount: number) { return 230 - Math.max(0, Math.min(1, amount / ceiling)) * 220; }
function amount(value: number | null, unit: string) { return value === null ? 'No complete report' : `${numbers.format(value)} ${unit}`; }
function describe(point: CapacityHistoryPoint) {
return `${$timeFormat.period(point.at)}. ${amount(value(point), metric === 'cpu' ? 'cores available' : 'GiB available')}. ${point.reportingHosts} of ${point.expectedHosts} hosts reporting.`;
}
function inspect(event: PointerEvent) {
if (!points.length || (event.type === 'pointermove' && event.pointerType === 'touch')) return;
if (!points.length || (event.type === 'pointermove' && event.pointerType === 'touch'))
return;
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
const at = history.startAt + Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)) * (history.endAt - history.startAt);
const at =
history.startAt +
Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)) *
(history.endAt - history.startAt);
let closest = points[0];
for (const point of points) if (Math.abs(point.at - at) < Math.abs(closest.at - at)) closest = point;
for (const point of points)
if (Math.abs(point.at - at) < Math.abs(closest.at - at)) closest = point;
selectedAt = closest.at;
}
function move(event: KeyboardEvent) {
if (!points.length || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
if (!points.length || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key))
return;
event.preventDefault();
const index = event.key === 'Home' ? 0 : event.key === 'End' ? points.length - 1
: Math.max(0, Math.min(points.length - 1, selectedIndex + (event.key === 'ArrowLeft' ? -1 : 1)));
const index =
event.key === 'Home'
? 0
: event.key === 'End'
? points.length - 1
: Math.max(
0,
Math.min(
points.length - 1,
selectedIndex + (event.key === 'ArrowLeft' ? -1 : 1)
)
);
selectedAt = points[index].at;
}
</script>
<section class="panel" aria-labelledby="capacity-history-title">
<div class="heading">
<div><h2 id="capacity-history-title">Available resources over time</h2><p>{scope} · All workloads</p></div>
<div class="controls">{@render controls?.()}<div class="mode" role="group" aria-label="Chart resource">
<button type="button" aria-pressed={metric === 'cpu'} onclick={() => metric = 'cpu'}>CPU</button>
<button type="button" aria-pressed={metric === 'memory'} onclick={() => metric = 'memory'}>Memory</button>
</div></div>
<div>
<h2 id="capacity-history-title">Available resources over time</h2>
<p>{scope} · All workloads</p>
</div>
<div class="controls">
{@render controls?.()}
<div class="mode" role="group" aria-label="Chart resource">
<button
type="button"
aria-pressed={metric === 'cpu'}
onclick={() => (metric = 'cpu')}>CPU</button
>
<button
type="button"
aria-pressed={metric === 'memory'}
onclick={() => (metric = 'memory')}>Memory</button
>
</div>
</div>
</div>
<p class="scope-note">The game filter applies to current launch estimates only.</p>
<div class="axis-title">{label} available</div>
<div class="chart-grid">
<div class="y-axis" aria-hidden="true">{#each ticks as tick}<span style:top={`${(1 - tick) * 100}%`}>{numbers.format(ceiling * tick)}</span>{/each}</div>
<div class="plot" role="slider" tabindex="0" aria-label={`${label} history. Use left and right arrows to inspect a period.`}
aria-valuemin="0" aria-valuemax={Math.max(0, points.length - 1)} aria-valuenow={selectedIndex}
<div class="y-axis" aria-hidden="true">
{#each ticks as tick}<span style:top={`${(1 - tick) * 100}%`}
>{numbers.format(ceiling * tick)}</span
>{/each}
</div>
<div
class="plot"
role="slider"
tabindex="0"
aria-label={`${label} history. Use left and right arrows to inspect a period.`}
aria-valuemin="0"
aria-valuemax={Math.max(0, points.length - 1)}
aria-valuenow={selectedIndex}
aria-valuetext={selected ? describe(selected) : 'No history available yet'}
onpointermove={inspect} onpointerdown={inspect} onkeydown={move}>
onpointermove={inspect}
onpointerdown={inspect}
onkeydown={move}
>
<svg viewBox="0 0 1000 240" preserveAspectRatio="none" aria-hidden="true">
{#each ticks as tick}<line class="grid-line" x1="0" x2="1000" y1={y(ceiling * tick)} y2={y(ceiling * tick)} />{/each}
{#each ticks as tick}<line
class="grid-line"
x1="0"
x2="1000"
y1={y(ceiling * tick)}
y2={y(ceiling * tick)}
/>{/each}
{#each segments as segment}
{#if segment.length > 1}<path class="area" d={`M${segment[0].x},230 ${segment.map(point => `L${point.x},${point.y}`).join(' ')} L${segment[segment.length - 1].x},230 Z`} />{/if}
<path class="trend" d={segment.map((point, index) => `${index ? 'L' : 'M'}${point.x},${point.y}`).join(' ')} />
{#if segment.length === 1}<circle class="point" cx={segment[0].x} cy={segment[0].y} r="3" />{/if}
{#if segment.length > 1}<path
class="area"
d={`M${segment[0].x},230 ${segment.map((point) => `L${point.x},${point.y}`).join(' ')} L${segment[segment.length - 1].x},230 Z`}
/>{/if}
<path
class="trend"
d={segment
.map((point, index) => `${index ? 'L' : 'M'}${point.x},${point.y}`)
.join(' ')}
/>
{#if segment.length === 1}<circle
class="point"
cx={segment[0].x}
cy={segment[0].y}
r="3"
/>{/if}
{/each}
{#if selected}<line class="cursor" x1={x(selected.at)} x2={x(selected.at)} y1="5" y2="230" />
{#if value(selected) !== null}<circle class="selected-point" cx={x(selected.at)} cy={y(value(selected)!)} r="4" />{/if}
{#if selected}<line
class="cursor"
x1={x(selected.at)}
x2={x(selected.at)}
y1="5"
y2="230"
/>
{#if value(selected) !== null}<circle
class="selected-point"
cx={x(selected.at)}
cy={y(value(selected)!)}
r="4"
/>{/if}
{/if}
</svg>
{#if !measured.length}<div class="empty-chart"><strong>Waiting for capacity history</strong><span>Fresh host reports build this timeline automatically.</span></div>{/if}
{#if !measured.length}<div class="empty-chart">
<strong>Waiting for capacity history</strong><span
>Fresh host reports build this timeline automatically.</span
>
</div>{/if}
</div>
<div class="x-axis" aria-hidden="true">
<span>{$timeFormat.period(history.startAt)}</span><span
>{$timeFormat.period(history.endAt)}</span
>
</div>
<div class="x-axis" aria-hidden="true"><span>{$timeFormat.period(history.startAt)}</span><span>{$timeFormat.period(history.endAt)}</span></div>
</div>
{#if selected}
<div class="readout" aria-label="Selected history period">
<div class="selected-time"><strong>{$timeFormat.period(selected.at)}</strong></div>
<dl><div><dt>CPU available</dt><dd>{amount(selected.cpuCoresAvailable, 'cores')}</dd></div>
<div><dt>Memory available</dt><dd>{amount(selected.memoryBytesAvailable === null ? null : selected.memoryBytesAvailable / 1024 ** 3, 'GiB')}</dd></div>
<div><dt>Reporting hosts</dt><dd>{selected.reportingHosts} / {selected.expectedHosts}</dd></div></dl>
<dl>
<div>
<dt>CPU available</dt>
<dd>{amount(selected.cpuCoresAvailable, 'cores')}</dd>
</div>
<div>
<dt>Memory available</dt>
<dd>
{amount(
selected.memoryBytesAvailable === null
? null
: selected.memoryBytesAvailable / 1024 ** 3,
'GiB'
)}
</dd>
</div>
<div>
<dt>Reporting hosts</dt>
<dd>{selected.reportingHosts} / {selected.expectedHosts}</dd>
</div>
</dl>
</div>
{/if}
<details class="about"><summary>About this chart</summary>
<p class="note">Available host resources after the operating-system reserve, averaged per period. Gaps mean reporting is incomplete. Reporting-host coverage is shown for each selected period. This history includes all workloads on the selected hosts; it does not reconstruct historical launch eligibility, queued jobs, or port availability.</p>
<details bind:open={tableOpen}><summary>View history data · {$timeZone.replaceAll('_', ' ')}</summary>
{#if tableOpen}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable data must be reachable by keyboard.) -->
<div class="table-wrap" role="region" aria-label="Capacity history data" tabindex="0"><table>
<caption>Measured headroom; a dash means a complete measurement is unavailable.</caption>
<thead><tr><th scope="col">Period</th><th scope="col">CPU cores</th><th scope="col">Memory (GiB)</th><th scope="col">Reporting hosts</th></tr></thead>
<tbody>{#each points as point (point.at)}<tr><th scope="row">{$timeFormat.period(point.at)}</th><td>{point.cpuCoresAvailable === null ? '—' : numbers.format(point.cpuCoresAvailable)}</td><td>{point.memoryBytesAvailable === null ? '—' : numbers.format(point.memoryBytesAvailable / 1024 ** 3)}</td><td>{point.reportingHosts} / {point.expectedHosts}</td></tr>{/each}</tbody>
</table></div>
{/if}
</details>
<details class="about">
<summary>About this chart</summary>
<p class="note">
Available host resources after the operating-system reserve, averaged per period. Gaps
mean reporting is incomplete. Reporting-host coverage is shown for each selected period.
This history includes all workloads on the selected hosts; it does not reconstruct
historical launch eligibility, queued jobs, or port availability.
</p>
<details bind:open={tableOpen}>
<summary>View history data · {$timeZone.replaceAll('_', ' ')}</summary>
{#if tableOpen}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable data must be reachable by keyboard.) -->
<div
class="table-wrap"
role="region"
aria-label="Capacity history data"
tabindex="0"
>
<table>
<caption
>Measured headroom; a dash means a complete measurement is unavailable.</caption
>
<thead
><tr
><th scope="col">Period</th><th scope="col">CPU cores</th><th
scope="col">Memory (GiB)</th
><th scope="col">Reporting hosts</th></tr
></thead
>
<tbody
>{#each points as point (point.at)}<tr
><th scope="row">{$timeFormat.period(point.at)}</th><td
>{point.cpuCoresAvailable === null
? '—'
: numbers.format(point.cpuCoresAvailable)}</td
><td
>{point.memoryBytesAvailable === null
? '—'
: numbers.format(
point.memoryBytesAvailable / 1024 ** 3
)}</td
><td>{point.reportingHosts} / {point.expectedHosts}</td></tr
>{/each}</tbody
>
</table>
</div>
{/if}
</details>
</details>
</section>
<style>
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); min-width: 0; }
h2 { margin: 0; font-size: var(--text-lg); } .heading { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-4); flex-wrap: wrap; }
.heading > div:first-child { flex:1 1 240px; min-width:0; }
.heading p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
.controls { display:flex; align-items:center; flex-wrap:wrap; gap:12px; min-width:0; max-width:100%; }
.scope-note { margin:8px 0 0; font-size:var(--text-xs); color:var(--text-secondary); }
.mode { display: flex; gap: 3px; padding: 3px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.mode button { padding: 7px 14px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); cursor: pointer; }
.mode button[aria-pressed='true'] { color: var(--text-primary); background: var(--surface-raised); }
.mode button:focus-visible, .plot:focus-visible, .table-wrap:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
.axis-title { margin-top: var(--space-4); color: var(--text-tertiary); font-size: var(--text-xs); }
.chart-grid { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 10px; margin-top: 14px; }
.y-axis { position: relative; height: 176px; margin-top: 8px; color: var(--text-tertiary); font-size: var(--text-xs); }
.y-axis span { position: absolute; right: 0; transform: translateY(-50%); }
.plot { position: relative; height: 192px; border-radius: 3px; cursor: crosshair; touch-action: pan-y; }
svg { display: block; width: 100%; height: 100%; overflow: visible; }
svg path, svg line { vector-effect: non-scaling-stroke; }
.grid-line { stroke: var(--border-subtle); stroke-width: 1; } .area { fill: color-mix(in oklab, var(--accent-primary) 10%, transparent); }
.trend { fill: none; stroke: var(--accent-primary); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.point, .selected-point { fill: var(--accent-primary-hover); } .selected-point { stroke: var(--surface); stroke-width: 2; }
.cursor { stroke: var(--text-tertiary); stroke-width: 1; stroke-dasharray: 4 5; }
.x-axis { grid-column: 2; display: flex; justify-content: space-between; gap: var(--space-4); color: var(--text-tertiary); font-size: var(--text-xs); }
.x-axis span:last-child { text-align: right; }
.empty-chart { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 10px; text-align: center; padding: var(--space-4); pointer-events: none; }
.empty-chart strong { font-size: var(--text-sm); font-weight: var(--weight-medium); } .empty-chart span { color: var(--text-tertiary); font-size: var(--text-xs); }
.readout { display:flex; flex-wrap:wrap; gap:12px 24px; align-items:center; margin:16px 0; font-size:var(--text-xs); }
.selected-time { color:var(--text-secondary); } .selected-time strong { font-size:var(--text-xs); font-weight:400; }
dl { display:flex; flex-wrap:wrap; gap:12px 24px; margin:0; } dl>div { display:flex; gap:6px; } dt { color:var(--text-secondary); } dd { margin:0; font-variant-numeric:tabular-nums; }
.note { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.65; margin: var(--space-4) 0; max-width: 115ch; }
summary { cursor: pointer; color: var(--text-secondary); font-size: var(--text-xs); padding: 4px 0; }
.table-wrap { position: relative; overflow: auto; max-height: 400px; margin-top: var(--space-3); } table { width: 100%; border-collapse: collapse; font-size: var(--text-xs); }
caption { text-align: left; color: var(--text-tertiary); margin-bottom: var(--space-3); } th, td { text-align: right; padding: 12px; white-space: nowrap; border-bottom: 1px solid var(--border-subtle); font-variant-numeric: tabular-nums; }
th:first-child { text-align: left; } tbody th { font-weight: var(--weight-regular); } thead th { color: var(--text-secondary); }
@media (max-width: 640px) { .panel { padding: var(--space-4); } .chart-grid { grid-template-columns: 32px minmax(0, 1fr); gap: 8px; } .readout { gap: var(--space-4); } dl { gap: var(--space-4); } .x-axis { font-size: var(--text-xs); } }
.panel {
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: var(--space-5);
min-width: 0;
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
.heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-4);
flex-wrap: wrap;
}
.heading > div:first-child {
flex: 1 1 240px;
min-width: 0;
}
.heading p {
margin: var(--space-2) 0 0;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
min-width: 0;
max-width: 100%;
}
.scope-note {
margin: 8px 0 0;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.mode {
display: flex;
gap: 3px;
padding: 3px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.mode button {
padding: 7px 14px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: var(--text-xs);
cursor: pointer;
}
.mode button[aria-pressed='true'] {
color: var(--text-primary);
background: var(--surface-raised);
}
.mode button:focus-visible,
.plot:focus-visible,
.table-wrap:focus-visible,
summary:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
.axis-title {
margin-top: var(--space-4);
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.chart-grid {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 10px;
margin-top: 14px;
}
.y-axis {
position: relative;
height: 176px;
margin-top: 8px;
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.y-axis span {
position: absolute;
right: 0;
transform: translateY(-50%);
}
.plot {
position: relative;
height: 192px;
border-radius: 3px;
cursor: crosshair;
touch-action: pan-y;
}
svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
}
svg path,
svg line {
vector-effect: non-scaling-stroke;
}
.grid-line {
stroke: var(--border-subtle);
stroke-width: 1;
}
.area {
fill: color-mix(in oklab, var(--accent-primary) 10%, transparent);
}
.trend {
fill: none;
stroke: var(--accent-primary);
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
}
.point,
.selected-point {
fill: var(--accent-primary-hover);
}
.selected-point {
stroke: var(--surface);
stroke-width: 2;
}
.cursor {
stroke: var(--text-tertiary);
stroke-width: 1;
stroke-dasharray: 4 5;
}
.x-axis {
grid-column: 2;
display: flex;
justify-content: space-between;
gap: var(--space-4);
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.x-axis span:last-child {
text-align: right;
}
.empty-chart {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
text-align: center;
padding: var(--space-4);
pointer-events: none;
}
.empty-chart strong {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
}
.empty-chart span {
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.readout {
display: flex;
flex-wrap: wrap;
gap: 12px 24px;
align-items: center;
margin: 16px 0;
font-size: var(--text-xs);
}
.selected-time {
color: var(--text-secondary);
}
.selected-time strong {
font-size: var(--text-xs);
font-weight: 400;
}
dl {
display: flex;
flex-wrap: wrap;
gap: 12px 24px;
margin: 0;
}
dl > div {
display: flex;
gap: 6px;
}
dt {
color: var(--text-secondary);
}
dd {
margin: 0;
font-variant-numeric: tabular-nums;
}
.note {
color: var(--text-tertiary);
font-size: var(--text-xs);
line-height: 1.65;
margin: var(--space-4) 0;
max-width: 115ch;
}
summary {
cursor: pointer;
color: var(--text-secondary);
font-size: var(--text-xs);
padding: 4px 0;
}
.table-wrap {
position: relative;
overflow: auto;
max-height: 400px;
margin-top: var(--space-3);
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-xs);
}
caption {
text-align: left;
color: var(--text-tertiary);
margin-bottom: var(--space-3);
}
th,
td {
text-align: right;
padding: 12px;
white-space: nowrap;
border-bottom: 1px solid var(--border-subtle);
font-variant-numeric: tabular-nums;
}
th:first-child {
text-align: left;
}
tbody th {
font-weight: var(--weight-regular);
}
thead th {
color: var(--text-secondary);
}
@media (max-width: 640px) {
.panel {
padding: var(--space-4);
}
.chart-grid {
grid-template-columns: 32px minmax(0, 1fr);
gap: 8px;
}
.readout {
gap: var(--space-4);
}
dl {
gap: var(--space-4);
}
.x-axis {
font-size: var(--text-xs);
}
}
</style>
@@ -6,8 +6,12 @@
let { hosts, returnTo }: { hosts: CapacityHost[]; returnTo: string } = $props();
let expanded = $state<string[]>([]);
const numbers = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
function count(value: number | null) { return value === null ? '—' : numbers.format(value); }
function memory(value: number | null) { return value === null ? '—' : `${numbers.format(value / 1024 ** 3)} GiB`; }
function count(value: number | null) {
return value === null ? '—' : numbers.format(value);
}
function memory(value: number | null) {
return value === null ? '—' : `${numbers.format(value / 1024 ** 3)} GiB`;
}
function restriction(host: CapacityHost) {
if (!host.connected) return 'Offline';
return host.readiness === 'ready' ? 'Not eligible' : hostReadinessLabel(host.readiness);
@@ -15,50 +19,220 @@
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
<div class="table-wrap" role="region" aria-label="Host capacity comparison" tabindex="0"><table>
<thead><tr><th>Host</th><th>Additional servers</th><th>Limited by</th><th>CPU available</th><th>Memory available</th><th>Free ports</th><th><span class="sr-only">Details</span></th></tr></thead>
<tbody>{#each hosts as host (host.id)}
<tr>
<td><a class="name" href={contextHref(`/hosts/${host.id}`, returnTo)}>{host.name}</a></td>
<td class="estimate">{count(host.estimatedServers)}</td>
<td class="limitation">{#if host.eligible}<strong>{host.limitingResource ?? 'Measurements pending'}</strong>{:else}<span>{restriction(host)}</span>{#if host.reasons[0]}<a class="reason" href={contextHref(`/hosts/${host.id}?tab=setup`, returnTo)}>{host.reasons[0]}</a>{/if}{/if}</td>
<td>{count(host.cpuCoresAvailable)}{host.cpuCoresAvailable === null ? '' : ' cores'}</td>
<td>{memory(host.memoryBytesAvailable)}</td>
<td>{host.portsAvailable}</td>
<td><button aria-label={`Details for ${host.name}`} aria-expanded={expanded.includes(host.id)} aria-controls={`capacity-host-${host.id}`} onclick={() => expanded = expanded.includes(host.id) ? expanded.filter(id => id !== host.id) : [...expanded, host.id]}>Details</button></td>
</tr>
<tr hidden={!expanded.includes(host.id)} id={`capacity-host-${host.id}`}><td colspan="7" class="detail-cell">
<div class="detail-content">
{#if host.reasons.length}<ul>{#each host.reasons as reason}<li>{reason}</li>{/each}</ul>{/if}
<p>Games ready: {host.games.length ? host.games.map(game => game === 't5' ? 'Black Ops I' : 'Black Ops II').join(', ') : 'None'} · {host.portsAvailable} of {host.totalPorts} ports free.</p>
<p>Server limits: CPU {count(host.limits.cpu)} · Memory {count(host.limits.memory)} · Ports {host.limits.ports}.</p>
<dl>{#each [{ label: 'Running server', value: host.calibration.running }, { label: 'Starting server', value: host.calibration.startup }] as allowance}<div>
<dt>{allowance.label}</dt><dd>{count(allowance.value.cpuCores)} cores · {memory(allowance.value.memoryBytes)}</dd>
<dd class="secondary">{allowance.value.source === 'bootstrap' ? 'Default allowance' : `${allowance.value.source === 'host' ? 'Host' : 'Fleet'} observations · ${allowance.value.samples} samples`}</dd>
</div>{/each}</dl>
<p>Starting servers may need more resources. The controller checks current capacity before reserving a host and port.</p>
<p>{host.updatedAt ? `Reported ${$timeFormat.dateTime(host.updatedAt)}` : 'No host report yet'}</p>
</div>
</td></tr>
{/each}</tbody>
</table></div>
<div class="table-wrap" role="region" aria-label="Host capacity comparison" tabindex="0">
<table>
<thead
><tr
><th>Host</th><th>Additional servers</th><th>Limited by</th><th>CPU available</th
><th>Memory available</th><th>Free ports</th><th
><span class="sr-only">Details</span></th
></tr
></thead
>
<tbody
>{#each hosts as host (host.id)}
<tr>
<td
><a class="name" href={contextHref(`/hosts/${host.id}`, returnTo)}
>{host.name}</a
></td
>
<td class="estimate">{count(host.estimatedServers)}</td>
<td class="limitation"
>{#if host.eligible}<strong
>{host.limitingResource ?? 'Measurements pending'}</strong
>{:else}<span>{restriction(host)}</span>{#if host.reasons[0]}<a
class="reason"
href={contextHref(`/hosts/${host.id}?tab=setup`, returnTo)}
>{host.reasons[0]}</a
>{/if}{/if}</td
>
<td
>{count(host.cpuCoresAvailable)}{host.cpuCoresAvailable === null
? ''
: ' cores'}</td
>
<td>{memory(host.memoryBytesAvailable)}</td>
<td>{host.portsAvailable}</td>
<td
><button
aria-label={`Details for ${host.name}`}
aria-expanded={expanded.includes(host.id)}
aria-controls={`capacity-host-${host.id}`}
onclick={() =>
(expanded = expanded.includes(host.id)
? expanded.filter((id) => id !== host.id)
: [...expanded, host.id])}>Details</button
></td
>
</tr>
<tr hidden={!expanded.includes(host.id)} id={`capacity-host-${host.id}`}
><td colspan="7" class="detail-cell">
<div class="detail-content">
{#if host.reasons.length}<ul>
{#each host.reasons as reason}<li>{reason}</li>{/each}
</ul>{/if}
<p>
Games ready: {host.games.length
? host.games
.map((game) =>
game === 't5' ? 'Black Ops I' : 'Black Ops II'
)
.join(', ')
: 'None'} · {host.portsAvailable} of {host.totalPorts} ports free.
</p>
<p>
Server limits: CPU {count(host.limits.cpu)} · Memory {count(
host.limits.memory
)} · Ports {host.limits.ports}.
</p>
<dl>
{#each [{ label: 'Running server', value: host.calibration.running }, { label: 'Starting server', value: host.calibration.startup }] as allowance}<div
>
<dt>{allowance.label}</dt>
<dd>
{count(allowance.value.cpuCores)} cores · {memory(
allowance.value.memoryBytes
)}
</dd>
<dd class="secondary">
{allowance.value.source === 'bootstrap'
? 'Default allowance'
: `${allowance.value.source === 'host' ? 'Host' : 'Fleet'} observations · ${allowance.value.samples} samples`}
</dd>
</div>{/each}
</dl>
<p>
Starting servers may need more resources. The controller checks
current capacity before reserving a host and port.
</p>
<p>
{host.updatedAt
? `Reported ${$timeFormat.dateTime(host.updatedAt)}`
: 'No host report yet'}
</p>
</div>
</td></tr
>
{/each}</tbody
>
</table>
</div>
<style>
.table-wrap { position:relative; overflow-x:auto; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
.table-wrap:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
table { width:100%; min-width:800px; border-collapse:collapse; text-align:left; font-size:var(--text-sm); }
th { padding:12px 16px; color:var(--text-secondary); font-size:var(--text-xs); font-weight:500; background:var(--surface-sunken); }
td { padding:14px 16px; border-top:1px solid var(--border-subtle); background:var(--surface); font-variant-numeric:tabular-nums; }
tr:hover>td { background:var(--surface-raised); }
a { color:var(--accent-primary-hover); } .name { color:var(--text-primary); font-weight:600; text-decoration:none; }
.name:hover { color:var(--accent-primary-hover); text-decoration:underline; }
.estimate { font-weight:600; } .limitation { max-width:280px; } .limitation strong { font-weight:500; }
.reason { display:block; font-size:var(--text-xs); color:var(--text-secondary); line-height:1.5; margin-top:4px; }
button { border:0; background:none; color:var(--accent-primary-hover); font:inherit; cursor:pointer; padding:4px; }
button:focus-visible,a:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
.detail-cell { background:var(--surface-sunken); } .detail-content { display:grid; gap:12px; font-size:var(--text-xs); color:var(--text-secondary); }
p,dl,ul { margin:0; } ul { padding-left:18px; line-height:1.7; }
dl { display:flex; gap:40px; flex-wrap:wrap; } dt { color:var(--text-primary); font-weight:500; } dd { margin:6px 0 0; }
.secondary { color:var(--text-tertiary); }
.sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
.table-wrap {
position: relative;
overflow-x: auto;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.table-wrap:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
table {
width: 100%;
min-width: 800px;
border-collapse: collapse;
text-align: left;
font-size: var(--text-sm);
}
th {
padding: 12px 16px;
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: 500;
background: var(--surface-sunken);
}
td {
padding: 14px 16px;
border-top: 1px solid var(--border-subtle);
background: var(--surface);
font-variant-numeric: tabular-nums;
}
tr:hover > td {
background: var(--surface-raised);
}
a {
color: var(--accent-primary-hover);
}
.name {
color: var(--text-primary);
font-weight: 600;
text-decoration: none;
}
.name:hover {
color: var(--accent-primary-hover);
text-decoration: underline;
}
.estimate {
font-weight: 600;
}
.limitation {
max-width: 280px;
}
.limitation strong {
font-weight: 500;
}
.reason {
display: block;
font-size: var(--text-xs);
color: var(--text-secondary);
line-height: 1.5;
margin-top: 4px;
}
button {
border: 0;
background: none;
color: var(--accent-primary-hover);
font: inherit;
cursor: pointer;
padding: 4px;
}
button:focus-visible,
a:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
.detail-cell {
background: var(--surface-sunken);
}
.detail-content {
display: grid;
gap: 12px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
p,
dl,
ul {
margin: 0;
}
ul {
padding-left: 18px;
line-height: 1.7;
}
dl {
display: flex;
gap: 40px;
flex-wrap: wrap;
}
dt {
color: var(--text-primary);
font-weight: 500;
}
dd {
margin: 6px 0 0;
}
.secondary {
color: var(--text-tertiary);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
</style>
@@ -1,6 +1,11 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import type { RebalancePreview, RebalanceReport, RebalanceRun, RebalanceSettings } from '$lib/rebalancing.js';
import type {
RebalancePreview,
RebalanceReport,
RebalanceRun,
RebalanceSettings
} from '$lib/rebalancing.js';
import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { rebalanceRunLabels } from '$ui/rebalancing-presentation.js';
@@ -9,8 +14,14 @@
import RebalancePreviewView from './RebalancePreviewView.svelte';
import RebalanceRunView from './RebalanceRunView.svelte';
let { canManage, preferredHost = null, preferredRun = null }: {
canManage: boolean; preferredHost?: string | null; preferredRun?: string | null;
let {
canManage,
preferredHost = null,
preferredRun = null
}: {
canManage: boolean;
preferredHost?: string | null;
preferredRun?: string | null;
} = $props();
let report = $state<RebalanceReport | null>(null);
let selectedHosts = $state<string[]>([]);
@@ -29,132 +40,472 @@
let sequence = 0;
let loadRequest: AbortController | null = null;
let actionRequest: AbortController | null = null;
const activeRun = $derived(report?.runs.find(run => run.state === 'running' || run.state === 'stopping') ?? null);
const run = $derived(report?.runs.find(item => item.id === selectedRun) ?? activeRun ?? report?.runs[0] ?? null);
const validSettings = $derived(selectedHosts.length >= 2 && selectedHosts.length <= 100 && Number.isInteger(maxMoves) && maxMoves! >= 1 && maxMoves! <= 20);
const activeRun = $derived(
report?.runs.find((run) => run.state === 'running' || run.state === 'stopping') ?? null
);
const run = $derived(
report?.runs.find((item) => item.id === selectedRun) ?? activeRun ?? report?.runs[0] ?? null
);
const validSettings = $derived(
selectedHosts.length >= 2 &&
selectedHosts.length <= 100 &&
Number.isInteger(maxMoves) &&
maxMoves! >= 1 &&
maxMoves! <= 20
);
const expired = $derived(!!preview && Date.parse(preview.expiresAt) <= now);
function editSettings() { preview = null; actionError = ''; notice = ''; }
function editSettings() {
preview = null;
actionError = '';
notice = '';
}
function toggleHost(id: string, checked: boolean) {
selectedHosts = checked ? [...selectedHosts, id] : selectedHosts.filter(value => value !== id);
selectedHosts = checked
? [...selectedHosts, id]
: selectedHosts.filter((value) => value !== id);
editSettings();
}
function cancelLoad() { sequence++; loadRequest?.abort(); loadRequest = null; loading = false; }
function cancelLoad() {
sequence++;
loadRequest?.abort();
loadRequest = null;
loading = false;
}
async function load(signal?: AbortSignal) {
if (busy || loading) return;
const current = ++sequence;
const controller = new AbortController();
loadRequest = controller; loading = true;
loadRequest = controller;
loading = true;
try {
const response = await fetch('/api/rebalancing', { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(20_000), ...(signal ? [signal] : [])]) });
const response = await fetch('/api/rebalancing', {
signal: AbortSignal.any([
controller.signal,
AbortSignal.timeout(20_000),
...(signal ? [signal] : [])
])
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not refresh rebalancing.');
if (current !== sequence) return;
report = body as RebalanceReport; refreshError = ''; checkedAt = Date.now(); now = Date.now();
report = body as RebalanceReport;
refreshError = '';
checkedAt = Date.now();
now = Date.now();
if (!initialized) {
selectedHosts = preferredHost ? report.hosts.filter(host => host.id === preferredHost && host.eligible).map(host => host.id)
: report.hosts.filter(host => host.eligible).map(host => host.id).slice(0, 100);
selectedHosts = preferredHost
? report.hosts
.filter((host) => host.id === preferredHost && host.eligible)
.map((host) => host.id)
: report.hosts
.filter((host) => host.eligible)
.map((host) => host.id)
.slice(0, 100);
initialized = true;
} else {
const eligible = new Set(report.hosts.filter(host => host.eligible).map(host => host.id));
const available = selectedHosts.filter(id => eligible.has(id));
const eligible = new Set(
report.hosts.filter((host) => host.eligible).map((host) => host.id)
);
const available = selectedHosts.filter((id) => eligible.has(id));
if (available.length !== selectedHosts.length) {
selectedHosts = available; preview = null;
notice = 'Host availability changed. Unavailable hosts were removed from your selection; preview again before starting.';
selectedHosts = available;
preview = null;
notice =
'Host availability changed. Unavailable hosts were removed from your selection; preview again before starting.';
}
}
} catch (cause) {
if (current === sequence && !controller.signal.aborted && !signal?.aborted) refreshError = (cause as Error).message;
if (current === sequence && !controller.signal.aborted && !signal?.aborted)
refreshError = (cause as Error).message;
return false;
} finally { if (current === sequence) { loadRequest = null; loading = false; } }
} finally {
if (current === sequence) {
loadRequest = null;
loading = false;
}
}
}
async function act(action: 'preview' | 'start' | 'stop') {
if (busy || !canManage) return;
const payload = action === 'preview' ? { action, settings: { hostIds: selectedHosts, game, maxMoves } }
: action === 'start' ? { action, previewId: preview?.id } : { action, id: run?.id };
cancelLoad(); busy = action; actionError = ''; notice = '';
const payload =
action === 'preview'
? { action, settings: { hostIds: selectedHosts, game, maxMoves } }
: action === 'start'
? { action, previewId: preview?.id }
: { action, id: run?.id };
cancelLoad();
busy = action;
actionError = '';
notice = '';
if (action === 'preview') preview = null;
const controller = new AbortController(); actionRequest = controller;
const controller = new AbortController();
actionRequest = controller;
try {
const response = await fetch('/api/rebalancing', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) });
const response = await fetch('/api/rebalancing', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Rebalance request failed.');
if (action === 'preview') preview = body.preview;
else {
const updated = body.run as RebalanceRun;
if (report) report = { ...report, runs: [updated, ...report.runs.filter(item => item.id !== updated.id)] };
selectedRun = updated.id; preview = null;
notice = action === 'start' ? 'Rebalance started. Moves run one at a time.' : 'Unstarted moves have been withdrawn. Any move already in progress will finish.';
if (report)
report = {
...report,
runs: [updated, ...report.runs.filter((item) => item.id !== updated.id)]
};
selectedRun = updated.id;
preview = null;
notice =
action === 'start'
? 'Rebalance started. Moves run one at a time.'
: 'Unstarted moves have been withdrawn. Any move already in progress will finish.';
}
now = Date.now();
} catch (cause) {
if (!controller.signal.aborted) actionError = `${(cause as Error).message}${action === 'preview' ? '' : ' Refresh the run status before retrying an unconfirmed request.'}`;
if (!controller.signal.aborted)
actionError = `${(cause as Error).message}${action === 'preview' ? '' : ' Refresh the run status before retrying an unconfirmed request.'}`;
} finally {
if (!controller.signal.aborted) { busy = null; actionRequest = null; await load(); }
if (!controller.signal.aborted) {
busy = null;
actionRequest = null;
await load();
}
}
}
onMount(() => {
const stop = pollVisible(load, 10_000);
const timer = setInterval(() => now = Date.now(), 5_000);
return () => { stop(); clearInterval(timer); cancelLoad(); actionRequest?.abort(); };
const timer = setInterval(() => (now = Date.now()), 5_000);
return () => {
stop();
clearInterval(timer);
cancelLoad();
actionRequest?.abort();
};
});
</script>
<section class="panel" aria-labelledby="rebalance-title">
<div class="heading"><div><h2 id="rebalance-title">Rebalance instances</h2><p>Spread existing instances across hosts according to CPU and memory use relative to each host’s capacity.</p></div>
{#if checkedAt && !refreshError}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 10 seconds" />{/if}
<div class="heading">
<div>
<h2 id="rebalance-title">Rebalance instances</h2>
<p>
Spread existing instances across hosts according to CPU and memory use relative to
each host’s capacity.
</p>
</div>
{#if checkedAt && !refreshError}<Freshness
updatedAt={checkedAt}
intervalLabel="Updates every 10 seconds"
/>{/if}
</div>
<p class="help">Only healthy, empty lobbies can move. Each move rebuilds the lobby on another host, causing a temporary outage; its address or port may change. The server identity, map, key, and recorded deployed files are retained. Local mutable files are not copied.</p>
{#if refreshError}<div class="error" role="alert"><p>{refreshError}{report ? ' Showing the last loaded rebalancing status.' : ''}</p><Button size="sm" disabled={loading || !!busy} onclick={() => void load()}>Retry</Button></div>{/if}
<p class="help">
Only healthy, empty lobbies can move. Each move rebuilds the lobby on another host, causing
a temporary outage; its address or port may change. The server identity, map, key, and
recorded deployed files are retained. Local mutable files are not copied.
</p>
{#if refreshError}<div class="error" role="alert">
<p>{refreshError}{report ? ' Showing the last loaded rebalancing status.' : ''}</p>
<Button size="sm" disabled={loading || !!busy} onclick={() => void load()}>Retry</Button
>
</div>{/if}
{#if actionError}<p class="error" role="alert">{actionError}</p>{/if}
{#if notice}<p class="notice" role="status">{notice}</p>{/if}
{#if !report && loading}<p role="status">Loading hosts and rebalance runs…</p>{/if}
{#if report}
{#if canManage}
<form onsubmit={event => { event.preventDefault(); void act('preview'); }}>
<form
onsubmit={(event) => {
event.preventDefault();
void act('preview');
}}
>
<fieldset disabled={!!busy || !!activeRun}>
<legend>Participating hosts</legend>
<p class="help">Choose at least two hosts. Existing lobbies may move between any selected hosts.</p>
{#if preferredHost && !report.hosts.some(host => host.id === preferredHost && host.eligible)}<p class="help">The host opened from the shortcut is currently unavailable. Choose eligible hosts below.</p>{/if}
<div class="host-selection">{#each report.hosts as host (host.id)}
<label class:unavailable={!host.eligible}><input type="checkbox" checked={selectedHosts.includes(host.id)} disabled={!host.eligible} onchange={event => toggleHost(host.id, event.currentTarget.checked)} />
<span><strong>{host.name}</strong>{#if host.reason}<small>{host.reason}</small>{/if}</span></label>
{/each}</div>
<div class="settings"><label>Game<select bind:value={game} onchange={editSettings}><option value="all">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
<label>Maximum moves<input type="number" min="1" max="20" step="1" required bind:value={maxMoves} oninput={editSettings} /></label>
<Button type="submit" disabled={!validSettings || !!busy || !!activeRun || !!refreshError} loading={busy === 'preview'}>Preview rebalance</Button>
<p class="help">
Choose at least two hosts. Existing lobbies may move between any selected
hosts.
</p>
{#if preferredHost && !report.hosts.some((host) => host.id === preferredHost && host.eligible)}<p
class="help"
>
The host opened from the shortcut is currently unavailable. Choose
eligible hosts below.
</p>{/if}
<div class="host-selection">
{#each report.hosts as host (host.id)}
<label class:unavailable={!host.eligible}
><input
type="checkbox"
checked={selectedHosts.includes(host.id)}
disabled={!host.eligible}
onchange={(event) =>
toggleHost(host.id, event.currentTarget.checked)}
/>
<span
><strong>{host.name}</strong>{#if host.reason}<small
>{host.reason}</small
>{/if}</span
></label
>
{/each}
</div>
<div class="settings">
<label
>Game<select bind:value={game} onchange={editSettings}
><option value="all">All games</option><option value="t5"
>Black Ops I</option
><option value="t6">Black Ops II</option></select
></label
>
<label
>Maximum moves<input
type="number"
min="1"
max="20"
step="1"
required
bind:value={maxMoves}
oninput={editSettings}
/></label
>
<Button
type="submit"
disabled={!validSettings || !!busy || !!activeRun || !!refreshError}
loading={busy === 'preview'}>Preview rebalance</Button
>
</div>
</fieldset>
</form>
{#if activeRun}<p class="help">A rebalance is {activeRun.state === 'stopping' ? 'finishing its current move' : 'already in progress'}. A new run can start after it finishes.{#if activeRun.id !== run?.id} <button class="text-button" onclick={() => selectedRun = activeRun.id}>View active run</button>{/if}</p>{/if}
{:else}<p class="help">Operators and administrators can preview, start, and stop rebalance runs.</p>{/if}
{#if preview}<RebalancePreviewView {preview} {expired} disabled={!!busy || !!activeRun || !!refreshError} starting={busy === 'start'} onstart={() => void act('start')} />{/if}
{#if activeRun}<p class="help">
A rebalance is {activeRun.state === 'stopping'
? 'finishing its current move'
: 'already in progress'}. A new run can start after it finishes.{#if activeRun.id !== run?.id}
<button class="text-button" onclick={() => (selectedRun = activeRun.id)}
>View active run</button
>{/if}
</p>{/if}
{:else}<p class="help">
Operators and administrators can preview, start, and stop rebalance runs.
</p>{/if}
{#if preview}<RebalancePreviewView
{preview}
{expired}
disabled={!!busy || !!activeRun || !!refreshError}
starting={busy === 'start'}
onstart={() => void act('start')}
/>{/if}
<section class="runs" aria-labelledby="rebalance-runs-title">
<div class="heading"><h3 id="rebalance-runs-title">Rebalance runs</h3>{#if report.runs.length}<label class="run-picker"><span class="sr-only">Select rebalance run</span><select value={run?.id ?? ''} onchange={event => selectedRun = event.currentTarget.value}>{#each report.runs as item (item.id)}<option value={item.id}>{$timeFormat.dateTime(item.createdAt)} · {rebalanceRunLabels[item.state]}</option>{/each}</select></label>{/if}</div>
{#if preferredRun && !report.runs.some(item => item.id === preferredRun)}<p class="help">The linked run is not in the recent history.</p>{/if}
{#if run}<RebalanceRunView {run} {canManage} busy={!!busy} stopping={busy === 'stop'} onstop={() => void act('stop')} />
<div class="heading">
<h3 id="rebalance-runs-title">Rebalance runs</h3>
{#if report.runs.length}<label class="run-picker"
><span class="sr-only">Select rebalance run</span><select
value={run?.id ?? ''}
onchange={(event) => (selectedRun = event.currentTarget.value)}
>{#each report.runs as item (item.id)}<option value={item.id}
>{$timeFormat.dateTime(item.createdAt)} · {rebalanceRunLabels[
item.state
]}</option
>{/each}</select
></label
>{/if}
</div>
{#if preferredRun && !report.runs.some((item) => item.id === preferredRun)}<p
class="help"
>
The linked run is not in the recent history.
</p>{/if}
{#if run}<RebalanceRunView
{run}
{canManage}
busy={!!busy}
stopping={busy === 'stop'}
onstop={() => void act('stop')}
/>
{:else}<p class="help">No rebalance runs yet.</p>{/if}
</section>
{/if}
</section>
<style>
.panel { display:grid; gap:var(--space-5); padding:24px; border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); min-width:0; }
h2,h3,p { margin:0; } h2 { font-size:var(--text-lg); } h3 { font-size:var(--text-md); } p { font-size:var(--text-sm); line-height:1.6; } .heading>div>p { margin-top:8px; color:var(--text-secondary); }
.heading,.settings { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; }
.help { font-size:var(--text-xs); color:var(--text-secondary); line-height:1.6; }
fieldset { border:0; padding:0; margin:0; display:grid; gap:12px; min-width:0; } legend { font-size:var(--text-sm); font-weight:600; padding:0 0 8px; }
.host-selection { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,220px),1fr)); gap:8px; }
.host-selection label { display:flex; align-items:flex-start; gap:10px; padding:12px; border:1px solid var(--border-subtle); border-radius:var(--radius-sm); cursor:pointer; }
.host-selection input { margin:3px 0 0; accent-color:var(--accent-primary); }
.host-selection span { display:grid; gap:4px; min-width:0; } .host-selection strong { font-size:var(--text-sm); font-weight:500; overflow-wrap:anywhere; }
.host-selection small { font-size:var(--text-xs); color:var(--text-secondary); line-height:1.5; } .host-selection .unavailable { opacity:0.65; cursor:default; }
.settings { justify-content:flex-start; align-items:end; } .settings label { display:grid; gap:8px; font-size:var(--text-xs); } .settings input { width:110px; }
select,input[type='number'] { box-sizing:border-box; padding:9px 12px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:var(--text-sm) var(--font-body); min-width:0; }
input:disabled,select:disabled { opacity:0.6; } .run-picker { max-width:100%; } .run-picker select { max-width:100%; }
.runs { display:grid; grid-template-columns:minmax(0,1fr); gap:16px; border-top:1px solid var(--border-subtle); padding-top:20px; min-width:0; }
.error { display:flex; gap:12px; align-items:center; justify-content:space-between; padding:12px 16px; color:var(--state-danger); border:1px solid var(--state-danger); border-radius:var(--radius-sm); font-size:var(--text-sm); overflow-wrap:anywhere; } .notice { color:var(--state-online); }
.text-button { color:var(--accent-primary-hover); } .text-button { border:0; padding:0; background:none; font:inherit; cursor:pointer; text-decoration:underline; }
button:focus-visible,input:focus-visible,select:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
.sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
@media(max-width:600px) { .panel { padding:16px; } }
.panel {
display: grid;
gap: var(--space-5);
padding: 24px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--surface);
min-width: 0;
}
h2,
h3,
p {
margin: 0;
}
h2 {
font-size: var(--text-lg);
}
h3 {
font-size: var(--text-md);
}
p {
font-size: var(--text-sm);
line-height: 1.6;
}
.heading > div > p {
margin-top: 8px;
color: var(--text-secondary);
}
.heading,
.settings {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.help {
font-size: var(--text-xs);
color: var(--text-secondary);
line-height: 1.6;
}
fieldset {
border: 0;
padding: 0;
margin: 0;
display: grid;
gap: 12px;
min-width: 0;
}
legend {
font-size: var(--text-sm);
font-weight: 600;
padding: 0 0 8px;
}
.host-selection {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
gap: 8px;
}
.host-selection label {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
cursor: pointer;
}
.host-selection input {
margin: 3px 0 0;
accent-color: var(--accent-primary);
}
.host-selection span {
display: grid;
gap: 4px;
min-width: 0;
}
.host-selection strong {
font-size: var(--text-sm);
font-weight: 500;
overflow-wrap: anywhere;
}
.host-selection small {
font-size: var(--text-xs);
color: var(--text-secondary);
line-height: 1.5;
}
.host-selection .unavailable {
opacity: 0.65;
cursor: default;
}
.settings {
justify-content: flex-start;
align-items: end;
}
.settings label {
display: grid;
gap: 8px;
font-size: var(--text-xs);
}
.settings input {
width: 110px;
}
select,
input[type='number'] {
box-sizing: border-box;
padding: 9px 12px;
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
font: var(--text-sm) var(--font-body);
min-width: 0;
}
input:disabled,
select:disabled {
opacity: 0.6;
}
.run-picker {
max-width: 100%;
}
.run-picker select {
max-width: 100%;
}
.runs {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
border-top: 1px solid var(--border-subtle);
padding-top: 20px;
min-width: 0;
}
.error {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
color: var(--state-danger);
border: 1px solid var(--state-danger);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
overflow-wrap: anywhere;
}
.notice {
color: var(--state-online);
}
.text-button {
color: var(--accent-primary-hover);
}
.text-button {
border: 0;
padding: 0;
background: none;
font: inherit;
cursor: pointer;
text-decoration: underline;
}
button:focus-visible,
input:focus-visible,
select:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (max-width: 600px) {
.panel {
padding: 16px;
}
}
</style>
@@ -4,38 +4,207 @@
import { timeFormat } from '$ui/stores/time-zone.js';
import { plural } from '$ui/plural.js';
import Button from '$ui/components/primitives/Button.svelte';
let { preview, expired, disabled = false, starting = false, onstart }: {
preview: RebalancePreview; expired: boolean; disabled?: boolean; starting?: boolean; onstart: () => void;
let {
preview,
expired,
disabled = false,
starting = false,
onstart
}: {
preview: RebalancePreview;
expired: boolean;
disabled?: boolean;
starting?: boolean;
onstart: () => void;
} = $props();
const numbers = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
function percent(value: number) { return `${numbers.format(value)}%`; }
function percent(value: number) {
return `${numbers.format(value)}%`;
}
</script>
<section class="preview" aria-labelledby="rebalance-preview-title">
<div class="heading"><h3 id="rebalance-preview-title">Preview · {preview.moves.length} proposed {plural(preview.moves.length, 'move')}</h3><span class="help">Created {$timeFormat.dateTime(preview.createdAt)}</span></div>
<div class="heading">
<h3 id="rebalance-preview-title">
Preview · {preview.moves.length} proposed {plural(preview.moves.length, 'move')}
</h3>
<span class="help">Created {$timeFormat.dateTime(preview.createdAt)}</span>
</div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
<div class="table-wrap" role="region" aria-label="Projected host distribution" tabindex="0"><table><thead><tr><th>Host</th><th>Instances</th><th>CPU</th><th>Memory</th></tr></thead><tbody>
{#each preview.hosts as host (host.id)}<tr><td>{host.name}</td><td>{host.before.instances} → {host.after.instances}</td><td>{percent(host.before.cpuPercent)} → {percent(host.after.cpuPercent)}</td><td>{percent(host.before.memoryPercent)} → {percent(host.after.memoryPercent)}</td></tr>{/each}
</tbody></table></div>
<p class="help">Current → projected distribution. CPU and memory estimates include the full load on each participating host.</p>
{#if preview.moves.length}<ol class="moves">{#each preview.moves as move}<li><a href={contextHref(`/servers/${encodeURIComponent(move.server)}`, '/capacity?rebalance=1')}>{move.server}</a><span>{move.sourceHostName} → {move.targetHostName}</span></li>{/each}</ol>
{:else}<p>No eligible moves would improve the distribution enough. Occupied lobbies and other excluded instances stay on their current hosts.</p>{/if}
{#if preview.warnings.length}<ul class="warnings">{#each preview.warnings as warning}<li>{warning}</li>{/each}</ul>{/if}
{#if preview.exclusions.length}<details><summary>{preview.exclusions.length} excluded {plural(preview.exclusions.length, 'instance')}</summary><ul class="exclusions">{#each preview.exclusions as exclusion}<li><strong>{exclusion.server}</strong><span>{exclusion.reason}</span></li>{/each}</ul></details>{/if}
<div class="start"><Button variant="primary" disabled={!preview.moves.length || disabled || expired} loading={starting} onclick={onstart}>Start rebalance</Button><p class="help">{expired ? 'This preview has expired. Preview again before starting.' : 'The controller rechecks occupancy and capacity before each move. Moves that no longer help are skipped.'}</p></div>
<div class="table-wrap" role="region" aria-label="Projected host distribution" tabindex="0">
<table>
<thead><tr><th>Host</th><th>Instances</th><th>CPU</th><th>Memory</th></tr></thead><tbody
>
{#each preview.hosts as host (host.id)}<tr
><td>{host.name}</td><td
>{host.before.instances} → {host.after.instances}</td
><td
>{percent(host.before.cpuPercent)} → {percent(
host.after.cpuPercent
)}</td
><td
>{percent(host.before.memoryPercent)} → {percent(
host.after.memoryPercent
)}</td
></tr
>{/each}
</tbody>
</table>
</div>
<p class="help">
Current → projected distribution. CPU and memory estimates include the full load on each
participating host.
</p>
{#if preview.moves.length}<ol class="moves">
{#each preview.moves as move}<li>
<a
href={contextHref(
`/servers/${encodeURIComponent(move.server)}`,
'/capacity?rebalance=1'
)}>{move.server}</a
><span>{move.sourceHostName} → {move.targetHostName}</span>
</li>{/each}
</ol>
{:else}<p>
No eligible moves would improve the distribution enough. Occupied lobbies and other
excluded instances stay on their current hosts.
</p>{/if}
{#if preview.warnings.length}<ul class="warnings">
{#each preview.warnings as warning}<li>{warning}</li>{/each}
</ul>{/if}
{#if preview.exclusions.length}<details>
<summary
>{preview.exclusions.length} excluded {plural(
preview.exclusions.length,
'instance'
)}</summary
>
<ul class="exclusions">
{#each preview.exclusions as exclusion}<li>
<strong>{exclusion.server}</strong><span>{exclusion.reason}</span>
</li>{/each}
</ul>
</details>{/if}
<div class="start">
<Button
variant="primary"
disabled={!preview.moves.length || disabled || expired}
loading={starting}
onclick={onstart}>Start rebalance</Button
>
<p class="help">
{expired
? 'This preview has expired. Preview again before starting.'
: 'The controller rechecks occupancy and capacity before each move. Moves that no longer help are skipped.'}
</p>
</div>
</section>
<style>
.preview { display:grid; gap:16px; border-top:1px solid var(--border-subtle); padding-top:20px; min-width:0; }
h3,p { margin:0; } h3 { font-size:var(--text-md); } p { font-size:var(--text-sm); line-height:1.6; }
.heading,.start { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; }
.help { font-size:var(--text-xs); color:var(--text-secondary); line-height:1.6; }
.table-wrap { overflow-x:auto; border:1px solid var(--border-subtle); border-radius:var(--radius-sm); }
table { width:100%; min-width:520px; border-collapse:collapse; text-align:left; font-size:var(--text-sm); font-variant-numeric:tabular-nums; }
th,td { padding:12px 16px; } th { background:var(--surface-sunken); color:var(--text-secondary); font-size:var(--text-xs); font-weight:500; } td { border-top:1px solid var(--border-subtle); }
.moves,.exclusions { margin:0; padding-left:20px; display:grid; gap:12px; font-size:var(--text-sm); } .moves span,.exclusions span { display:block; margin-top:4px; color:var(--text-secondary); font-size:var(--text-xs); } .exclusions { margin-top:12px; } .exclusions strong { font-weight:500; }
.warnings { border-left:2px solid var(--state-warning); padding:0 0 0 22px; margin:0; color:var(--text-secondary); font-size:var(--text-xs); line-height:1.7; }
summary { cursor:pointer; color:var(--text-secondary); font-size:var(--text-sm); }
.start { justify-content:flex-start; } .start p { flex:1 1 280px; }
a { color:var(--accent-primary-hover); } a:focus-visible,summary:focus-visible,.table-wrap:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
.preview {
display: grid;
gap: 16px;
border-top: 1px solid var(--border-subtle);
padding-top: 20px;
min-width: 0;
}
h3,
p {
margin: 0;
}
h3 {
font-size: var(--text-md);
}
p {
font-size: var(--text-sm);
line-height: 1.6;
}
.heading,
.start {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.help {
font-size: var(--text-xs);
color: var(--text-secondary);
line-height: 1.6;
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
}
table {
width: 100%;
min-width: 520px;
border-collapse: collapse;
text-align: left;
font-size: var(--text-sm);
font-variant-numeric: tabular-nums;
}
th,
td {
padding: 12px 16px;
}
th {
background: var(--surface-sunken);
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: 500;
}
td {
border-top: 1px solid var(--border-subtle);
}
.moves,
.exclusions {
margin: 0;
padding-left: 20px;
display: grid;
gap: 12px;
font-size: var(--text-sm);
}
.moves span,
.exclusions span {
display: block;
margin-top: 4px;
color: var(--text-secondary);
font-size: var(--text-xs);
}
.exclusions {
margin-top: 12px;
}
.exclusions strong {
font-weight: 500;
}
.warnings {
border-left: 2px solid var(--state-warning);
padding: 0 0 0 22px;
margin: 0;
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.7;
}
summary {
cursor: pointer;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.start {
justify-content: flex-start;
}
.start p {
flex: 1 1 280px;
}
a {
color: var(--accent-primary-hover);
}
a:focus-visible,
summary:focus-visible,
.table-wrap:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
</style>
@@ -1,27 +1,150 @@
<script lang="ts">
import type { RebalanceRun } from '$lib/rebalancing.js';
import { contextHref } from '$lib/navigation-context.js';
import { rebalanceMoveLabels, rebalanceRunLabels, rebalanceRunHref } from '$ui/rebalancing-presentation.js';
import {
rebalanceMoveLabels,
rebalanceRunLabels,
rebalanceRunHref
} from '$ui/rebalancing-presentation.js';
import Button from '$ui/components/primitives/Button.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
let { run, canManage, busy = false, stopping = false, onstop }: {
run: RebalanceRun; canManage: boolean; busy?: boolean; stopping?: boolean; onstop: () => void;
let {
run,
canManage,
busy = false,
stopping = false,
onstop
}: {
run: RebalanceRun;
canManage: boolean;
busy?: boolean;
stopping?: boolean;
onstop: () => void;
} = $props();
</script>
<div class="heading"><div class="run-status"><Badge variant={run.state === 'failed' ? 'danger' : run.state === 'done' ? 'online' : run.state === 'stopped' ? 'neutral' : 'info'}>{rebalanceRunLabels[run.state]}</Badge><span>{run.moves.filter(move => move.state === 'done').length} of {run.moves.length} moves completed</span><a href={rebalanceRunHref(run.id)}>Link to run</a></div>
{#if canManage && run.state === 'running'}<Button disabled={busy} loading={stopping} onclick={onstop}>Stop remaining moves</Button>{/if}</div>
<div class="heading">
<div class="run-status">
<Badge
variant={run.state === 'failed'
? 'danger'
: run.state === 'done'
? 'online'
: run.state === 'stopped'
? 'neutral'
: 'info'}>{rebalanceRunLabels[run.state]}</Badge
><span
>{run.moves.filter((move) => move.state === 'done').length} of {run.moves.length} moves completed</span
><a href={rebalanceRunHref(run.id)}>Link to run</a>
</div>
{#if canManage && run.state === 'running'}<Button
disabled={busy}
loading={stopping}
onclick={onstop}>Stop remaining moves</Button
>{/if}
</div>
<p role="status">{run.message}</p>
{#if run.state === 'running' || run.state === 'stopping'}<p class="help">Stopping withdraws unstarted moves. A move already in progress continues through health verification.</p>{/if}
<ol class="run-moves">{#each run.moves as move (move.id)}<li><div class="heading"><div class="move-identity"><strong>{move.server}</strong><span>{move.sourceHostName} → {move.targetHostName}</span></div><Badge variant={move.state === 'failed' ? 'danger' : move.state === 'done' ? 'online' : move.state === 'moving' ? 'info' : 'neutral'}>{rebalanceMoveLabels[move.state]}</Badge></div><p>{move.message}</p>{#if move.operationId}<a href={contextHref(`/provisioning/${encodeURIComponent(move.operationId)}`, rebalanceRunHref(run.id))}>View provisioning →</a>{/if}</li>{/each}</ol>
{#if run.state === 'running' || run.state === 'stopping'}<p class="help">
Stopping withdraws unstarted moves. A move already in progress continues through health
verification.
</p>{/if}
<ol class="run-moves">
{#each run.moves as move (move.id)}<li>
<div class="heading">
<div class="move-identity">
<strong>{move.server}</strong><span
>{move.sourceHostName} → {move.targetHostName}</span
>
</div>
<Badge
variant={move.state === 'failed'
? 'danger'
: move.state === 'done'
? 'online'
: move.state === 'moving'
? 'info'
: 'neutral'}>{rebalanceMoveLabels[move.state]}</Badge
>
</div>
<p>{move.message}</p>
{#if move.operationId}<a
href={contextHref(
`/provisioning/${encodeURIComponent(move.operationId)}`,
rebalanceRunHref(run.id)
)}>View provisioning →</a
>{/if}
</li>{/each}
</ol>
<style>
p { margin:0; font-size:var(--text-sm); line-height:1.6; }
.heading,.run-status { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; }
.help { font-size:var(--text-xs); color:var(--text-secondary); line-height:1.6; }
.run-status { justify-content:flex-start; font-size:var(--text-xs); color:var(--text-secondary); } .run-moves { margin:0; padding:0; list-style:none; display:grid; gap:12px; }
.run-moves li { display:grid; gap:8px; padding:16px; border:1px solid var(--border-subtle); border-radius:var(--radius-sm); } .run-moves p,.run-moves a { font-size:var(--text-xs); } .run-moves p { color:var(--text-secondary); }
.move-identity { display:grid; gap:5px; font-size:var(--text-sm); min-width:0; overflow-wrap:anywhere; } .move-identity strong { font-weight:500; } .move-identity span { font-size:var(--text-xs); color:var(--text-secondary); }
a { color:var(--accent-primary-hover); } a:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
@media(max-width:600px) { .run-moves li { padding:12px; } }
p {
margin: 0;
font-size: var(--text-sm);
line-height: 1.6;
}
.heading,
.run-status {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.help {
font-size: var(--text-xs);
color: var(--text-secondary);
line-height: 1.6;
}
.run-status {
justify-content: flex-start;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.run-moves {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 12px;
}
.run-moves li {
display: grid;
gap: 8px;
padding: 16px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
}
.run-moves p,
.run-moves a {
font-size: var(--text-xs);
}
.run-moves p {
color: var(--text-secondary);
}
.move-identity {
display: grid;
gap: 5px;
font-size: var(--text-sm);
min-width: 0;
overflow-wrap: anywhere;
}
.move-identity strong {
font-weight: 500;
}
.move-identity span {
font-size: var(--text-xs);
color: var(--text-secondary);
}
a {
color: var(--accent-primary-hover);
}
a:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
@media (max-width: 600px) {
.run-moves li {
padding: 12px;
}
}
</style>
+602 -110
View File
@@ -3,19 +3,29 @@
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import { createTimeFormatter } from '$lib/time-zone.js';
import { metricDuration, metricNumber, metricPercent, metricPeriodLabels } from '$ui/metrics.js';
import {
metricDuration,
metricNumber,
metricPercent,
metricPeriodLabels
} from '$ui/metrics.js';
import Button from '$ui/components/primitives/Button.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Select from '$ui/components/primitives/Select.svelte';
let { points, days, timeZone }: { points: MetricPoint[]; days: MetricsDays; timeZone: string } = $props();
let { points, days, timeZone }: { points: MetricPoint[]; days: MetricsDays; timeZone: string } =
$props();
let selectedAt = $state<number | null>(null);
let announcement = $state('');
const format = $derived(createTimeFormatter(timeZone));
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
const mode = $derived(new URLSearchParams(contextSearch).get('chartMode') === 'hours' ? 'hours' : 'percent');
const contextSearch = $derived(
(page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search
);
const mode = $derived(
new URLSearchParams(contextSearch).get('chartMode') === 'hours' ? 'hours' : 'percent'
);
const selectedIndex = $derived.by(() => {
const existing = points.findIndex(point => point.at === selectedAt);
const existing = points.findIndex((point) => point.at === selectedAt);
if (existing >= 0) return existing;
for (let i = points.length - 1; i >= 0; i--) if (!points[i].partial) return i;
return Math.max(0, points.length - 1);
@@ -24,32 +34,43 @@
const selectedValue = $derived(selected ? String(selected.at) : '');
const ceiling = $derived.by(() => {
if (mode === 'percent') return 100;
const max = Math.max(0, ...points.map(point => point.observed + point.unknown));
const max = Math.max(0, ...points.map((point) => point.observed + point.unknown));
if (!max) return 1;
const magnitude = 10 ** Math.floor(Math.log10(max));
return Math.ceil(max / (magnitude / 2)) * (magnitude / 2);
});
const tick = new Intl.NumberFormat('en-US', { maximumSignificantDigits: 3 });
const ticks = $derived([1, .75, .5, .25, 0].map(fraction => ({ fraction, value: `${tick.format(ceiling * fraction)}${mode === 'percent' ? '%' : ''}` })));
const periodOptions = $derived(points.map(point => ({ value: String(point.at), label: `${format.period(point.startAt)}${point.partial ? ' · partial' : ''}` })));
const ticks = $derived(
[1, 0.75, 0.5, 0.25, 0].map((fraction) => ({
fraction,
value: `${tick.format(ceiling * fraction)}${mode === 'percent' ? '%' : ''}`
}))
);
const periodOptions = $derived(
points.map((point) => ({
value: String(point.at),
label: `${format.period(point.startAt)}${point.partial ? ' · partial' : ''}`
}))
);
const periodLabels = $derived(metricPeriodLabels(points, days, timeZone));
const captured = $derived(selected ? selected.observed + selected.unknown : 0);
function setMode(value: 'hours' | 'percent') {
const url = new URL(page.url);
url.search = contextSearch;
if (value === 'percent') url.searchParams.delete('chartMode'); else url.searchParams.set('chartMode', value);
if (value === 'percent') url.searchParams.delete('chartMode');
else url.searchParams.set('chartMode', value);
replaceState(url, { ...page.state, metricsTableSearch: url.search });
}
function selectPeriod(at: number) {
selectedAt = at;
const point = points.find(item => item.at === at);
const point = points.find((item) => item.at === at);
if (!point) return;
announcement = `${format.period(point.startAt)} to ${format.period(point.endAt)}${point.partial ? ', partial period' : ''}. ${point.observed + point.unknown ? `${metricDuration(point.active)} with players, ${metricDuration(point.idle)} idle, ${metricDuration(point.unknown)} unknown.` : 'No captured time.'}`;
}
function segmentHeight(point: MetricPoint, value: number) {
const denominator = mode === 'percent' ? point.observed + point.unknown : ceiling;
return denominator > 0 ? value / denominator * 100 : 0;
return denominator > 0 ? (value / denominator) * 100 : 0;
}
function move(direction: number) {
const next = points[Math.max(0, Math.min(points.length - 1, selectedIndex + direction))];
@@ -58,129 +79,600 @@
function chartKey(event: KeyboardEvent, index: number) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const next = event.key === 'Home' ? 0 : event.key === 'End' ? points.length - 1 : Math.max(0, Math.min(points.length - 1, index + (event.key === 'ArrowLeft' ? -1 : 1)));
const next =
event.key === 'Home'
? 0
: event.key === 'End'
? points.length - 1
: Math.max(
0,
Math.min(points.length - 1, index + (event.key === 'ArrowLeft' ? -1 : 1))
);
selectPeriod(points[next].at);
(event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLButtonElement>('button')[next]?.focus();
(event.currentTarget as HTMLElement).parentElement
?.querySelectorAll<HTMLButtonElement>('button')
[next]?.focus();
}
</script>
<section class="panel" aria-labelledby="metrics-trend-title">
<div class="heading"><div><h2 id="metrics-trend-title">Server activity over time</h2><p>{mode === 'percent' ? 'Share of captured server time, including unknown status.' : `Server-hours per ${days === 1 ? 'hour' : '24-hour period'}.`} Partial periods contain less elapsed time.</p></div>
<div class="mode" role="group" aria-label="Timeline measure"><button type="button" aria-pressed={mode === 'percent'} onclick={() => setMode('percent')}>Percent</button><button type="button" aria-pressed={mode === 'hours'} onclick={() => setMode('hours')}>Hours</button></div>
<div class="heading">
<div>
<h2 id="metrics-trend-title">Server activity over time</h2>
<p>
{mode === 'percent'
? 'Share of captured server time, including unknown status.'
: `Server-hours per ${days === 1 ? 'hour' : '24-hour period'}.`} Partial periods contain
less elapsed time.
</p>
</div>
<div class="mode" role="group" aria-label="Timeline measure">
<button
type="button"
aria-pressed={mode === 'percent'}
onclick={() => setMode('percent')}>Percent</button
><button type="button" aria-pressed={mode === 'hours'} onclick={() => setMode('hours')}
>Hours</button
>
</div>
</div>
<div class="legend">
<span><i class="occupied"></i>With players</span><span><i class="idle"></i>Idle</span><span
><i class="unknown"></i>Unknown</span
><span class="partial-key">* Partial period</span>
</div>
<div class="axis-title">
{mode === 'percent' ? 'Share of captured server time' : 'Server-hours'}
</div>
<div class="legend"><span><i class="occupied"></i>With players</span><span><i class="idle"></i>Idle</span><span><i class="unknown"></i>Unknown</span><span class="partial-key">* Partial period</span></div>
<div class="axis-title">{mode === 'percent' ? 'Share of captured server time' : 'Server-hours'}</div>
<div class="chart-grid">
<div class="y-axis" aria-hidden="true">{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}>{item.value}</span>{/each}</div>
<div class="plot" role="group" aria-label="Server time by period. Select a bar or use the period selector below.">
<div class="grid-lines" aria-hidden="true">{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}></span>{/each}</div>
<div class="y-axis" aria-hidden="true">
{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}
>{item.value}</span
>{/each}
</div>
<div
class="plot"
role="group"
aria-label="Server time by period. Select a bar or use the period selector below."
>
<div class="grid-lines" aria-hidden="true">
{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}
></span>{/each}
</div>
{#each points as point, index (point.at)}
<button class="bar" class:selected={point.at === selected?.at} class:partial={point.partial} type="button" tabindex={index === selectedIndex ? 0 : -1}
<button
class="bar"
class:selected={point.at === selected?.at}
class:partial={point.partial}
type="button"
tabindex={index === selectedIndex ? 0 : -1}
aria-pressed={point.at === selected?.at}
aria-label={`${format.period(point.startAt)}${point.partial ? ', partial period' : ''}: ${point.observed + point.unknown ? `${metricDuration(point.active)} with players, ${metricDuration(point.idle)} idle, ${metricDuration(point.unknown)} unknown` : 'No captured time'}`}
title={`${format.period(point.startAt)} – ${format.period(point.endAt)}${point.partial ? ' · partial period' : ''}`}
onclick={() => selectPeriod(point.at)} onkeydown={event => chartKey(event, index)}>
onclick={() => selectPeriod(point.at)}
onkeydown={(event) => chartKey(event, index)}
>
{#if point.partial}<span class="partial-marker" aria-hidden="true">*</span>{/if}
<span class="segment unknown" style:height={`${segmentHeight(point, point.unknown)}%`}></span>
<span class="segment idle" style:height={`${segmentHeight(point, point.idle)}%`}></span>
<span class="segment occupied" style:height={`${segmentHeight(point, point.active)}%`}></span>
{#if point.observed + point.unknown === 0}<span class="no-time" aria-hidden="true">·</span>{/if}
<span
class="segment unknown"
style:height={`${segmentHeight(point, point.unknown)}%`}
></span>
<span class="segment idle" style:height={`${segmentHeight(point, point.idle)}%`}
></span>
<span
class="segment occupied"
style:height={`${segmentHeight(point, point.active)}%`}
></span>
{#if point.observed + point.unknown === 0}<span
class="no-time"
aria-hidden="true">·</span
>{/if}
</button>
{/each}
</div>
<div class="x-axis" class:with-dates={days === 30} aria-hidden="true">{#each periodLabels as item}<span style:left={`${item.position}%`} title={format.period(points[item.index].startAt)}>{item.label}{#if item.date}<small>{item.date}</small>{/if}</span>{/each}</div>
<div class="x-axis" class:with-dates={days === 30} aria-hidden="true">
{#each periodLabels as item}<span
style:left={`${item.position}%`}
title={format.period(points[item.index].startAt)}
>{item.label}{#if item.date}<small>{item.date}</small>{/if}</span
>{/each}
</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}
<div class="selection-summary" aria-label="Selected period summary"><time>{format.period(selected.startAt)}{selected.partial ? ' · Partial period' : ''}</time><span>{captured ? `${metricDuration(selected.active)} with players · ${metricDuration(selected.idle)} idle · ${metricDuration(selected.unknown)} unknown · ${selected.observed ? metricNumber(selected.playerHours) : '—'} player-hours` : 'No captured time · not evidence of idle servers'}</span></div>
<details class="inspection"><summary>Inspect a period</summary>
<div class="period-controls">
<div class="period-select"><Field label={`Inspect a period · ${timeZone.replaceAll('_', ' ')}`}><Select value={selectedValue} options={periodOptions} onchange={event => selectPeriod(Number((event.currentTarget as HTMLSelectElement).value))} /></Field></div>
<div class="step-buttons"><Button size="sm" disabled={selectedIndex === 0} onclick={() => move(-1)}>← Previous</Button><Button size="sm" disabled={selectedIndex === points.length - 1} onclick={() => move(1)}>Next →</Button></div>
</div>
<div class="readout" aria-label="Selected period details">
<p class="interval">{format.period(selected.startAt)} – {format.period(selected.endAt)} {#if selected.partial}<span class="partial-label">Partial period</span>{/if}</p>
{#if captured === 0}<p class="empty">No captured time in this period. Missing observations do not mean the fleet was idle.</p>
{:else}<dl>
<div><dt>With players</dt><dd>{metricDuration(selected.active)}<small>{metricPercent(selected.active, captured)} captured · {metricPercent(selected.active, selected.observed)} known</small></dd></div>
<div><dt>Idle</dt><dd>{metricDuration(selected.idle)}<small>{metricPercent(selected.idle, captured)} of captured time</small></dd></div>
<div><dt>Unknown</dt><dd>{metricDuration(selected.unknown)}<small>{metricPercent(selected.unknown, captured)} of captured time</small></dd></div>
<div><dt>Player-hours</dt><dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd></div>
</dl><p class="denominator">{metricDuration(captured)} captured server time · {metricDuration(selected.observed)} known. Percentage bars include unknown time; the occupancy KPI excludes it.</p>{/if}
<div class="selection-summary" aria-label="Selected period summary">
<time
>{format.period(selected.startAt)}{selected.partial
? ' · Partial period'
: ''}</time
><span
>{captured
? `${metricDuration(selected.active)} with players · ${metricDuration(selected.idle)} idle · ${metricDuration(selected.unknown)} unknown · ${selected.observed ? metricNumber(selected.playerHours) : '—'} player-hours`
: 'No captured time · not evidence of idle servers'}</span
>
</div>
<details class="inspection">
<summary>Inspect a period</summary>
<div class="period-controls">
<div class="period-select">
<Field label={`Inspect a period · ${timeZone.replaceAll('_', ' ')}`}
><Select
value={selectedValue}
options={periodOptions}
onchange={(event) =>
selectPeriod(
Number((event.currentTarget as HTMLSelectElement).value)
)}
/></Field
>
</div>
<div class="step-buttons">
<Button size="sm" disabled={selectedIndex === 0} onclick={() => move(-1)}
>← Previous</Button
><Button
size="sm"
disabled={selectedIndex === points.length - 1}
onclick={() => move(1)}>Next →</Button
>
</div>
</div>
<div class="readout" aria-label="Selected period details">
<p class="interval">
{format.period(selected.startAt)} – {format.period(selected.endAt)}
{#if selected.partial}<span class="partial-label">Partial period</span>{/if}
</p>
{#if captured === 0}<p class="empty">
No captured time in this period. Missing observations do not mean the fleet
was idle.
</p>
{:else}<dl>
<div>
<dt>With players</dt>
<dd>
{metricDuration(selected.active)}<small
>{metricPercent(selected.active, captured)} captured · {metricPercent(
selected.active,
selected.observed
)} known</small
>
</dd>
</div>
<div>
<dt>Idle</dt>
<dd>
{metricDuration(selected.idle)}<small
>{metricPercent(selected.idle, captured)} of captured time</small
>
</dd>
</div>
<div>
<dt>Unknown</dt>
<dd>
{metricDuration(selected.unknown)}<small
>{metricPercent(selected.unknown, captured)} of captured time</small
>
</dd>
</div>
<div>
<dt>Player-hours</dt>
<dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd>
</div>
</dl>
<p class="denominator">
{metricDuration(captured)} captured server time · {metricDuration(
selected.observed
)} known. Percentage bars include unknown time; the occupancy KPI excludes it.
</p>{/if}
</div>
</details>
{/if}
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
<details class="time-data"><summary>View exact time data</summary>
<details class="time-data">
<summary>View exact time data</summary>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
<div class="table-wrap" role="region" aria-label="Time data" tabindex="0"><table>
<caption>Periods in {timeZone.replaceAll('_', ' ')}. Durations are estimated from captured observations.</caption>
<thead><tr><th scope="col">Period</th><th scope="col">With players</th><th scope="col">Idle</th><th scope="col">Unknown</th><th scope="col">Player-hours</th></tr></thead>
<tbody>{#each points as point (point.at)}<tr><th scope="row">{format.period(point.startAt)}<small>to {format.period(point.endAt)}{point.partial ? ' · Partial' : ''}</small></th><td>{metricDuration(point.active)}</td><td>{metricDuration(point.idle)}</td><td>{metricDuration(point.unknown)}</td><td>{point.observed ? metricNumber(point.playerHours) : '—'}</td></tr>{/each}</tbody>
</table></div>
<div class="table-wrap" role="region" aria-label="Time data" tabindex="0">
<table>
<caption
>Periods in {timeZone.replaceAll('_', ' ')}. Durations are estimated from
captured observations.</caption
>
<thead
><tr
><th scope="col">Period</th><th scope="col">With players</th><th scope="col"
>Idle</th
><th scope="col">Unknown</th><th scope="col">Player-hours</th></tr
></thead
>
<tbody
>{#each points as point (point.at)}<tr
><th scope="row"
>{format.period(point.startAt)}<small
>to {format.period(point.endAt)}{point.partial
? ' · Partial'
: ''}</small
></th
><td>{metricDuration(point.active)}</td><td
>{metricDuration(point.idle)}</td
><td>{metricDuration(point.unknown)}</td><td
>{point.observed ? metricNumber(point.playerHours) : '—'}</td
></tr
>{/each}</tbody
>
</table>
</div>
</details>
</section>
<style>
.selection-summary { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-top: 16px; font-size: var(--text-xs); color: var(--text-secondary); }
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); min-width: 0; }
h2 { margin: 0; font-size: var(--text-lg); }
.heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
.heading > div:first-child { flex: 1 1 260px; min-width: 0; }
.mode { display: flex; border: 1px solid var(--border-subtle); padding: 3px; border-radius: var(--radius-md); }
.mode button { border: 0; border-radius: var(--radius-sm); padding: 7px 10px; background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); cursor: pointer; }
.mode button[aria-pressed='true'] { background: var(--surface-raised); color: var(--text-primary); }
.mode button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.heading p { margin: var(--space-2) 0 var(--space-4); color: var(--text-secondary); font-size: var(--text-sm); }
.legend { display: flex; gap: var(--space-4); flex-wrap: wrap; align-items: center; font-size: var(--text-xs); }
.legend span { display: flex; align-items: center; gap: 6px; }
.legend i { width: 10px; height: 10px; border-radius: 2px; }
.occupied { background: var(--chart-active); }
.idle { background: repeating-linear-gradient(0deg, transparent 0 5px, color-mix(in srgb, var(--text-secondary) 24%, transparent) 5px 6px), var(--chart-idle); }
.unknown { background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 3px, var(--chart-unknown-stripe) 3px 5px); }
.partial-key { color: var(--text-secondary); }
.axis-title { margin-top: var(--space-5); font-size: var(--text-xs); color: var(--text-tertiary); }
.chart-grid { display: grid; grid-template-columns: 44px minmax(0, 1fr); column-gap: 8px; margin-top: 12px; }
.y-axis { position: relative; text-align: right; font-size: var(--text-xs); color: var(--text-tertiary); height: 210px; }
.y-axis span { position: absolute; right: 0; transform: translateY(-50%); }
.plot { display: flex; position: relative; height: 210px; gap: 4px; isolation: isolate; border-bottom: 1px solid var(--border-strong); }
.grid-lines { position: absolute; inset: 0; pointer-events: none; z-index: -1; }
.grid-lines span { position: absolute; left: 0; right: 0; border-top: 1px solid var(--border-subtle); }
.bar { appearance: none; position: relative; flex: 1; min-width: 0; border: 0; margin: 0; padding: 0; background: transparent; display: flex; flex-direction: column; justify-content: flex-end; cursor: pointer; border-radius: 2px 2px 0 0; }
.bar.selected { outline: 1px solid var(--text-secondary); outline-offset: 2px; }
.bar:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 2px; }
.bar:hover { background: color-mix(in oklab, var(--accent-primary) 8%, transparent); }
.segment { display: block; width: 100%; flex-shrink: 0; pointer-events: none; }
.no-time { color: var(--text-tertiary); font-size: var(--text-lg); line-height: 1; }
.bar.partial { border-inline: 1px dashed var(--border-strong); }
.partial-marker { position: absolute; top: -17px; left: 50%; transform: translateX(-50%); font-size: var(--text-base); color: var(--text-secondary); }
.x-axis { grid-column: 2; position: relative; min-width: 0; height: 1.6em; color: var(--text-secondary); font-size: var(--text-xs); margin-top: 10px; }
.x-axis.with-dates { height: 3em; }
.x-axis > span { position: absolute; transform: translateX(-50%); text-align: center; white-space: nowrap; }
.x-axis small { margin: 0; }
.period-note { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); margin: var(--space-2) 0 0; }
.period-controls { display: flex; align-items: flex-end; gap: var(--space-3); flex-wrap: wrap; margin-top: var(--space-5); }
.period-select { flex: 1 1 250px; max-width: 450px; }
.step-buttons { display: flex; gap: var(--space-2); padding-bottom: 3px; }
.readout { margin-top: var(--space-4); padding: var(--space-4); border-radius: var(--radius-md); background: var(--surface-sunken); border: 1px solid var(--border-subtle); }
.interval { color: var(--text-secondary); font-size: var(--text-xs); margin: 0; line-height: 1.6; }
.partial-label { display: inline-block; margin-left: 4px; padding: 1px 6px; border: 1px dashed var(--border-strong); border-radius: 4px; }
dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); margin: var(--space-3) 0 0; }
dt { color: var(--text-tertiary); font-size: var(--text-xs); }
dd { margin: var(--space-1) 0 0; font-variant-numeric: tabular-nums; font-size: var(--text-md); }
small { display: block; color: var(--text-tertiary); font-size: var(--text-xs); font-weight: normal; line-height: 1.5; }
.empty { margin: var(--space-3) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
.denominator { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.5; margin: var(--space-3) 0 0; }
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
details { margin-top: var(--space-4); font-size: var(--text-sm); }
summary { cursor: pointer; color: var(--text-secondary); padding: 4px 0; }
.table-wrap { position: relative; max-width: 100%; overflow-x: auto; margin-top: var(--space-3); }
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
table { width: 100%; border-collapse: collapse; font-size: var(--text-xs); }
caption { text-align: left; color: var(--text-tertiary); margin-bottom: 8px; }
th, td { padding: 12px; border-bottom: 1px solid var(--border-subtle); text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
thead th { color: var(--text-secondary); }
th:first-child { text-align: left; }
tbody th { font-weight: var(--weight-regular); }
@media (max-width: 640px) { .panel { padding: var(--space-4); } .legend { gap: 10px; } .chart-grid { grid-template-columns: 32px minmax(0, 1fr); gap: 6px; } .plot { gap: 3px; } dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } .readout { padding: var(--space-3); } .step-buttons { flex-wrap: wrap; } }
.selection-summary {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
margin-top: 16px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.panel {
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: var(--space-5);
min-width: 0;
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
.heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
}
.heading > div:first-child {
flex: 1 1 260px;
min-width: 0;
}
.mode {
display: flex;
border: 1px solid var(--border-subtle);
padding: 3px;
border-radius: var(--radius-md);
}
.mode button {
border: 0;
border-radius: var(--radius-sm);
padding: 7px 10px;
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: var(--text-xs);
cursor: pointer;
}
.mode button[aria-pressed='true'] {
background: var(--surface-raised);
color: var(--text-primary);
}
.mode button:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.heading p {
margin: var(--space-2) 0 var(--space-4);
color: var(--text-secondary);
font-size: var(--text-sm);
}
.legend {
display: flex;
gap: var(--space-4);
flex-wrap: wrap;
align-items: center;
font-size: var(--text-xs);
}
.legend span {
display: flex;
align-items: center;
gap: 6px;
}
.legend i {
width: 10px;
height: 10px;
border-radius: 2px;
}
.occupied {
background: var(--chart-active);
}
.idle {
background:
repeating-linear-gradient(
0deg,
transparent 0 5px,
color-mix(in srgb, var(--text-secondary) 24%, transparent) 5px 6px
),
var(--chart-idle);
}
.unknown {
background: repeating-linear-gradient(
135deg,
var(--chart-unknown-base) 0 3px,
var(--chart-unknown-stripe) 3px 5px
);
}
.partial-key {
color: var(--text-secondary);
}
.axis-title {
margin-top: var(--space-5);
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.chart-grid {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
column-gap: 8px;
margin-top: 12px;
}
.y-axis {
position: relative;
text-align: right;
font-size: var(--text-xs);
color: var(--text-tertiary);
height: 210px;
}
.y-axis span {
position: absolute;
right: 0;
transform: translateY(-50%);
}
.plot {
display: flex;
position: relative;
height: 210px;
gap: 4px;
isolation: isolate;
border-bottom: 1px solid var(--border-strong);
}
.grid-lines {
position: absolute;
inset: 0;
pointer-events: none;
z-index: -1;
}
.grid-lines span {
position: absolute;
left: 0;
right: 0;
border-top: 1px solid var(--border-subtle);
}
.bar {
appearance: none;
position: relative;
flex: 1;
min-width: 0;
border: 0;
margin: 0;
padding: 0;
background: transparent;
display: flex;
flex-direction: column;
justify-content: flex-end;
cursor: pointer;
border-radius: 2px 2px 0 0;
}
.bar.selected {
outline: 1px solid var(--text-secondary);
outline-offset: 2px;
}
.bar:focus-visible {
outline: 2px solid var(--accent-primary-hover);
outline-offset: 2px;
}
.bar:hover {
background: color-mix(in oklab, var(--accent-primary) 8%, transparent);
}
.segment {
display: block;
width: 100%;
flex-shrink: 0;
pointer-events: none;
}
.no-time {
color: var(--text-tertiary);
font-size: var(--text-lg);
line-height: 1;
}
.bar.partial {
border-inline: 1px dashed var(--border-strong);
}
.partial-marker {
position: absolute;
top: -17px;
left: 50%;
transform: translateX(-50%);
font-size: var(--text-base);
color: var(--text-secondary);
}
.x-axis {
grid-column: 2;
position: relative;
min-width: 0;
height: 1.6em;
color: var(--text-secondary);
font-size: var(--text-xs);
margin-top: 10px;
}
.x-axis.with-dates {
height: 3em;
}
.x-axis > span {
position: absolute;
transform: translateX(-50%);
text-align: center;
white-space: nowrap;
}
.x-axis small {
margin: 0;
}
.period-note {
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: var(--leading-body);
margin: var(--space-2) 0 0;
}
.period-controls {
display: flex;
align-items: flex-end;
gap: var(--space-3);
flex-wrap: wrap;
margin-top: var(--space-5);
}
.period-select {
flex: 1 1 250px;
max-width: 450px;
}
.step-buttons {
display: flex;
gap: var(--space-2);
padding-bottom: 3px;
}
.readout {
margin-top: var(--space-4);
padding: var(--space-4);
border-radius: var(--radius-md);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
}
.interval {
color: var(--text-secondary);
font-size: var(--text-xs);
margin: 0;
line-height: 1.6;
}
.partial-label {
display: inline-block;
margin-left: 4px;
padding: 1px 6px;
border: 1px dashed var(--border-strong);
border-radius: 4px;
}
dl {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-4);
margin: var(--space-3) 0 0;
}
dt {
color: var(--text-tertiary);
font-size: var(--text-xs);
}
dd {
margin: var(--space-1) 0 0;
font-variant-numeric: tabular-nums;
font-size: var(--text-md);
}
small {
display: block;
color: var(--text-tertiary);
font-size: var(--text-xs);
font-weight: normal;
line-height: 1.5;
}
.empty {
margin: var(--space-3) 0 0;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.denominator {
color: var(--text-tertiary);
font-size: var(--text-xs);
line-height: 1.5;
margin: var(--space-3) 0 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
details {
margin-top: var(--space-4);
font-size: var(--text-sm);
}
summary {
cursor: pointer;
color: var(--text-secondary);
padding: 4px 0;
}
.table-wrap {
position: relative;
max-width: 100%;
overflow-x: auto;
margin-top: var(--space-3);
}
.table-wrap:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-xs);
}
caption {
text-align: left;
color: var(--text-tertiary);
margin-bottom: 8px;
}
th,
td {
padding: 12px;
border-bottom: 1px solid var(--border-subtle);
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
thead th {
color: var(--text-secondary);
}
th:first-child {
text-align: left;
}
tbody th {
font-weight: var(--weight-regular);
}
@media (max-width: 640px) {
.panel {
padding: var(--space-4);
}
.legend {
gap: 10px;
}
.chart-grid {
grid-template-columns: 32px minmax(0, 1fr);
gap: 6px;
}
.plot {
gap: 3px;
}
dl {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.readout {
padding: var(--space-3);
}
.step-buttons {
flex-wrap: wrap;
}
}
</style>
@@ -8,25 +8,57 @@
import { HOSTING_RULES, metricRecommendations } from '$lib/metrics-insights.js';
let { report }: { report: MetricsData } = $props();
const recommendations = $derived(metricRecommendations(report.maps));
const highlighted = $derived(recommendations.filter(item => !item.tentative && (item.kind === 'expand' || item.kind === 'consolidate')).slice(0, 3));
const remaining = $derived(recommendations.filter(item => !highlighted.includes(item)));
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
const highlighted = $derived(
recommendations
.filter(
(item) => !item.tentative && (item.kind === 'expand' || item.kind === 'consolidate')
)
.slice(0, 3)
);
const remaining = $derived(recommendations.filter((item) => !highlighted.includes(item)));
const contextSearch = $derived(
(page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search
);
const params = $derived(new URLSearchParams(contextSearch));
const query = $derived((params.get('insightQ') ?? '').slice(0, 100));
const sort = $derived(params.get('insightSort') === 'map' ? 'map' : 'priority');
const visibleRemaining = $derived(remaining.filter(item => `${item.map} ${item.game}`.toLowerCase().includes(query.trim().toLowerCase())).sort((a, b) => sort === 'map' ? a.map.localeCompare(b.map) || a.game.localeCompare(b.game) : 0));
const visibleRemaining = $derived(
remaining
.filter((item) =>
`${item.map} ${item.game}`.toLowerCase().includes(query.trim().toLowerCase())
)
.sort((a, b) =>
sort === 'map' ? a.map.localeCompare(b.map) || a.game.localeCompare(b.game) : 0
)
);
function updateContext(key: string, value: string) {
const url = new URL(page.url); url.search = contextSearch;
if (value) url.searchParams.set(key, value); else url.searchParams.delete(key);
const url = new URL(page.url);
url.search = contextSearch;
if (value) url.searchParams.set(key, value);
else url.searchParams.delete(key);
replaceState(url, { ...page.state, metricsTableSearch: url.search });
}
const limited = $derived(recommendations.filter(item => item.tentative).length);
const limited = $derived(recommendations.filter((item) => item.tentative).length);
</script>
{#snippet finding(suggestion: ReturnType<typeof metricRecommendations>[number])}
<article class="recommendation">
<div><strong>{suggestion.map}</strong><small>{suggestion.game.toUpperCase()}{suggestion.tentative ? ' · Limited evidence' : ''}</small></div>
<div><strong>{suggestion.title}</strong><details><summary>Evidence and considerations</summary><p>{suggestion.evidence}</p><p>{suggestion.reason}</p><p>{suggestion.outcome}</p></details></div>
<div>
<strong>{suggestion.map}</strong><small
>{suggestion.game.toUpperCase()}{suggestion.tentative
? ' · Limited evidence'
: ''}</small
>
</div>
<div>
<strong>{suggestion.title}</strong>
<details>
<summary>Evidence and considerations</summary>
<p>{suggestion.evidence}</p>
<p>{suggestion.reason}</p>
<p>{suggestion.outcome}</p>
</details>
</div>
</article>
{/snippet}
<section class="explore" aria-labelledby="metrics-explore-title">
@@ -34,32 +66,133 @@
<p>Historical signals for hosting trials. Check the current lineup before making a change.</p>
{#if highlighted.length}{#each highlighted as suggestion}{@render finding(suggestion)}{/each}
{:else}<p class="empty">No hosting trials meet the evidence threshold in this period.</p>{/if}
{#if limited}<p class="notice">{limited} of {recommendations.length} maps have limited evidence. Review occupancy reporting, player limits, and observation duration before changing hosting.</p>{/if}
{#if remaining.length}<details class="remaining"><summary>Other findings ({remaining.length})</summary>
<div class="finding-controls"><Field label="Find a map"><Input type="search" value={query} placeholder="Map or game…" oninput={event => updateContext('insightQ', (event.currentTarget as HTMLInputElement).value)} /></Field><Field label="Sort findings"><Select value={sort} options={[{ value: 'priority', label: 'Priority' }, { value: 'map', label: 'Map A–Z' }]} onchange={event => updateContext('insightSort', (event.currentTarget as HTMLSelectElement).value)} /></Field></div>
{#each visibleRemaining as suggestion}{@render finding(suggestion)}{:else}<p>No other findings match this search.</p>{/each}</details>{/if}
<details class="rules"><summary>How Rules v1 chooses a suggestion</summary>
<ul>
<li>Trial an additional lobby when all tracked lobbies are full for at least {HOSTING_RULES.pressureExpansion * 100}% of known-capacity time.</li>
<li>When capacity is unavailable, at least {HOSTING_RULES.occupancyExpansion * 100}% occupied known time can suggest a tentative additional-lobby trial. Occupancy does not establish saturation.</li>
<li>Review idle hosting when occupied known time is at most {HOSTING_RULES.idleUtilization * 100}%, and either measured pressure is below {HOSTING_RULES.quietPressure * 100}% or capacity is unavailable.</li>
<li>Missing occupancy prompts a reporting check. Other cases suggest watching hosting or checking capacity reporting.</li>
</ul>
<p>Less than {HOSTING_RULES.minimumEvidenceHours} hour of relevant known time, missing capacity, or substantial unknown status marks advice as tentative. These are trial policies, not validated demand forecasts. Collection gaps are omitted from all rules.</p>
</details>
{#if limited}<p class="notice">
{limited} of {recommendations.length} maps have limited evidence. Review occupancy reporting,
player limits, and observation duration before changing hosting.
</p>{/if}
{#if remaining.length}<details class="remaining">
<summary>Other findings ({remaining.length})</summary>
<div class="finding-controls">
<Field label="Find a map"
><Input
type="search"
value={query}
placeholder="Map or game…"
oninput={(event) =>
updateContext(
'insightQ',
(event.currentTarget as HTMLInputElement).value
)}
/></Field
><Field label="Sort findings"
><Select
value={sort}
options={[
{ value: 'priority', label: 'Priority' },
{ value: 'map', label: 'Map A–Z' }
]}
onchange={(event) =>
updateContext(
'insightSort',
(event.currentTarget as HTMLSelectElement).value
)}
/></Field
>
</div>
{#each visibleRemaining as suggestion}{@render finding(suggestion)}{:else}<p>
No other findings match this search.
</p>{/each}
</details>{/if}
<details class="rules">
<summary>How Rules v1 chooses a suggestion</summary>
<ul>
<li>
Trial an additional lobby when all tracked lobbies are full for at least {HOSTING_RULES.pressureExpansion *
100}% of known-capacity time.
</li>
<li>
When capacity is unavailable, at least {HOSTING_RULES.occupancyExpansion * 100}%
occupied known time can suggest a tentative additional-lobby trial. Occupancy does
not establish saturation.
</li>
<li>
Review idle hosting when occupied known time is at most {HOSTING_RULES.idleUtilization *
100}%, and either measured pressure is below {HOSTING_RULES.quietPressure *
100}% or capacity is unavailable.
</li>
<li>
Missing occupancy prompts a reporting check. Other cases suggest watching hosting or
checking capacity reporting.
</li>
</ul>
<p>
Less than {HOSTING_RULES.minimumEvidenceHours} hour of relevant known time, missing capacity,
or substantial unknown status marks advice as tentative. These are trial policies, not validated
demand forecasts. Collection gaps are omitted from all rules.
</p>
</details>
</section>
<style>
.finding-controls { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
.explore { padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); }
h2 { margin: 0; font-size: var(--text-lg); }
p { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.6; }
.recommendation { display: grid; grid-template-columns: minmax(140px, .6fr) minmax(0, 2fr); gap: 20px; padding: 14px 0; border-bottom: 1px solid var(--border-subtle); font-size: var(--text-sm); }
small { display: block; color: var(--text-secondary); margin-top: 6px; font-size: var(--text-xs); }
details { font-size: var(--text-xs); margin-top: 10px; }
summary { cursor: pointer; color: var(--text-secondary); }
.remaining, .rules { margin-top: 20px; }
.notice { border-left: 2px solid var(--state-warning); padding-left: 12px; }
li { margin: 8px 0; color: var(--text-secondary); }
@media (max-width: 540px) { .recommendation { grid-template-columns: 1fr; gap: 8px; } }
.finding-controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 16px;
}
.explore {
padding: 20px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
p {
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: 1.6;
}
.recommendation {
display: grid;
grid-template-columns: minmax(140px, 0.6fr) minmax(0, 2fr);
gap: 20px;
padding: 14px 0;
border-bottom: 1px solid var(--border-subtle);
font-size: var(--text-sm);
}
small {
display: block;
color: var(--text-secondary);
margin-top: 6px;
font-size: var(--text-xs);
}
details {
font-size: var(--text-xs);
margin-top: 10px;
}
summary {
cursor: pointer;
color: var(--text-secondary);
}
.remaining,
.rules {
margin-top: 20px;
}
.notice {
border-left: 2px solid var(--state-warning);
padding-left: 12px;
}
li {
margin: 8px 0;
color: var(--text-secondary);
}
@media (max-width: 540px) {
.recommendation {
grid-template-columns: 1fr;
gap: 8px;
}
}
</style>
@@ -7,100 +7,469 @@
type Heatmap = MetricsData['heatmap'];
type Cell = Heatmap['cells'][number];
let { heatmap, disabled = false, onTimeZoneChange }: { heatmap: Heatmap; disabled?: boolean; onTimeZoneChange: (value: string) => void } = $props();
let {
heatmap,
disabled = false,
onTimeZoneChange
}: {
heatmap: Heatmap;
disabled?: boolean;
onTimeZoneChange: (value: string) => void;
} = $props();
const id = $props.id();
const weekdays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const hours = Array.from({ length: 24 }, (_, hour) => hour);
const zones = $derived([...new Set([...availableTimeZones(), heatmap.timeZone])].sort().map(zone => ({ value: zone, label: zone.replaceAll('_', ' ') })));
const zones = $derived(
[...new Set([...availableTimeZones(), heatmap.timeZone])]
.sort()
.map((zone) => ({ value: zone, label: zone.replaceAll('_', ' ') }))
);
const dayOptions = weekdays.map((label, day) => ({ value: String(day), label }));
const hourOptions = hours.map(hour => ({ value: String(hour), label: hourLabel(hour) }));
const hourOptions = hours.map((hour) => ({ value: String(hour), label: hourLabel(hour) }));
let selectedKey = $state<number | null>(null);
let announcement = $state('');
const cells = $derived(new Map(heatmap.cells.map(cell => [cell.weekday * 24 + cell.hour, cell])));
const selected = $derived(cells.get(selectedKey ?? -1) ?? heatmap.cells.find(cell => cell.observed + cell.unknown > 0) ?? heatmap.cells[0]);
function hourLabel(hour: number) { return `${String(hour).padStart(2, '0')}:00–${String((hour + 1) % 24).padStart(2, '0')}:00`; }
function cellAt(day: number, hour: number): Cell { return cells.get(day * 24 + hour) ?? { weekday: day, hour, active: 0, observed: 0, idle: 0, unknown: 0, playerHours: 0, distinctDates: 0 }; }
const cells = $derived(
new Map(heatmap.cells.map((cell) => [cell.weekday * 24 + cell.hour, cell]))
);
const selected = $derived(
cells.get(selectedKey ?? -1) ??
heatmap.cells.find((cell) => cell.observed + cell.unknown > 0) ??
heatmap.cells[0]
);
function hourLabel(hour: number) {
return `${String(hour).padStart(2, '0')}:00–${String((hour + 1) % 24).padStart(2, '0')}:00`;
}
function cellAt(day: number, hour: number): Cell {
return (
cells.get(day * 24 + hour) ?? {
weekday: day,
hour,
active: 0,
observed: 0,
idle: 0,
unknown: 0,
playerHours: 0,
distinctDates: 0
}
);
}
function description(cell: Cell) {
return `${weekdays[cell.weekday]}, ${hourLabel(cell.hour)}: ${cell.observed ? `${metricPercent(cell.active, cell.observed)} with players during ${metricDuration(cell.observed)} known server time` : cell.unknown ? `${metricDuration(cell.unknown)} unknown server time` : 'No captured time'}. ${cell.distinctDates} distinct dates.`;
}
function select(day: number, hour: number) { selectedKey = day * 24 + hour; announcement = description(cellAt(day, hour)); }
function select(day: number, hour: number) {
selectedKey = day * 24 + hour;
announcement = description(cellAt(day, hour));
}
function move(event: KeyboardEvent, cell: Cell) {
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return;
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key))
return;
event.preventDefault();
const day = Math.max(0, Math.min(6, cell.weekday + (event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0)));
const hour = event.key === 'Home' ? 0 : event.key === 'End' ? 23 : Math.max(0, Math.min(23, cell.hour + (event.key === 'ArrowLeft' ? -1 : event.key === 'ArrowRight' ? 1 : 0)));
const day = Math.max(
0,
Math.min(
6,
cell.weekday + (event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0)
)
);
const hour =
event.key === 'Home'
? 0
: event.key === 'End'
? 23
: Math.max(
0,
Math.min(
23,
cell.hour +
(event.key === 'ArrowLeft'
? -1
: event.key === 'ArrowRight'
? 1
: 0)
)
);
const key = day * 24 + hour;
select(day, hour);
(event.currentTarget as HTMLElement).closest('table')?.querySelector<HTMLButtonElement>(`button[data-key="${key}"]`)?.focus();
(event.currentTarget as HTMLElement)
.closest('table')
?.querySelector<HTMLButtonElement>(`button[data-key="${key}"]`)
?.focus();
}
</script>
<section class="heatmap" aria-labelledby={`${id}-heatmap-title`}>
<div class="heading"><div><h2 id={`${id}-heatmap-title`}>Activity by weekday and hour</h2><p>Time with players as a share of known server time, grouped in {heatmap.timeZone.replaceAll('_', ' ')}.</p></div>
<div class="zone"><Field label="Report timezone"><Select value={heatmap.timeZone} options={zones} {disabled} onchange={event => onTimeZoneChange((event.currentTarget as HTMLSelectElement).value)} /></Field></div>
<div class="heading">
<div>
<h2 id={`${id}-heatmap-title`}>Activity by weekday and hour</h2>
<p>
Time with players as a share of known server time, grouped in {heatmap.timeZone.replaceAll(
'_',
' '
)}.
</p>
</div>
<div class="zone">
<Field label="Report timezone"
><Select
value={heatmap.timeZone}
options={zones}
{disabled}
onchange={(event) =>
onTimeZoneChange((event.currentTarget as HTMLSelectElement).value)}
/></Field
>
</div>
</div>
<div class="legend">
<span>0% <i class="scale"></i> 100% with players</span><span
><i class="unknown-example"></i>Unknown only</span
><span><i class="missing-example">·</i>No captured time</span>
</div>
<div class="legend"><span>0% <i class="scale"></i> 100% with players</span><span><i class="unknown-example"></i>Unknown only</span><span><i class="missing-example">·</i>No captured time</span></div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The heatmap table must scroll with the keyboard.) -->
<div class="table-wrap" role="region" aria-label="Activity heatmap; use arrow keys between cells or the selectors below" tabindex="0"><table>
<caption class="sr-only">Weekday and local hour activity. Cell details include known time and distinct recorded dates.</caption>
<thead><tr><th scope="col">Day</th>{#each hours as hour}<th scope="col">{String(hour).padStart(2, '0')}</th>{/each}</tr></thead>
<tbody>{#each weekdays as day, weekday}<tr><th scope="row">{day.slice(0, 3)}</th>{#each hours as hour}{@const cell = cellAt(weekday, hour)}
<td><button type="button" class="cell" class:known={cell.observed > 0} class:unknown={cell.observed === 0 && cell.unknown > 0} class:mixed={cell.observed > 0 && cell.unknown > 0} class:missing={cell.observed + cell.unknown === 0}
style:--activity={`${cell.observed > 0 ? Math.min(100, Math.max(0, 100 * cell.active / cell.observed)) : 0}%`}
data-key={weekday * 24 + hour} tabindex={selected?.weekday === weekday && selected?.hour === hour ? 0 : -1}
aria-pressed={selected?.weekday === weekday && selected?.hour === hour} aria-label={description(cell)} title={description(cell)}
onclick={() => select(weekday, hour)} onkeydown={event => move(event, cell)}><span aria-hidden="true">{cell.observed > 0 ? '' : cell.unknown > 0 ? '?' : '·'}</span></button></td>
{/each}</tr>{/each}</tbody>
</table></div>
<p class="note">A striped corner marks additional unknown time. Color uses known time only. Repeated daylight-saving clock hours share a cell; collection gaps stay uncaptured.</p>
<div
class="table-wrap"
role="region"
aria-label="Activity heatmap; use arrow keys between cells or the selectors below"
tabindex="0"
>
<table>
<caption class="sr-only"
>Weekday and local hour activity. Cell details include known time and distinct
recorded dates.</caption
>
<thead
><tr
><th scope="col">Day</th>{#each hours as hour}<th scope="col"
>{String(hour).padStart(2, '0')}</th
>{/each}</tr
></thead
>
<tbody
>{#each weekdays as day, weekday}<tr
><th scope="row">{day.slice(0, 3)}</th>{#each hours as hour}{@const cell =
cellAt(weekday, hour)}
<td
><button
type="button"
class="cell"
class:known={cell.observed > 0}
class:unknown={cell.observed === 0 && cell.unknown > 0}
class:mixed={cell.observed > 0 && cell.unknown > 0}
class:missing={cell.observed + cell.unknown === 0}
style:--activity={`${cell.observed > 0 ? Math.min(100, Math.max(0, (100 * cell.active) / cell.observed)) : 0}%`}
data-key={weekday * 24 + hour}
tabindex={selected?.weekday === weekday &&
selected?.hour === hour
? 0
: -1}
aria-pressed={selected?.weekday === weekday &&
selected?.hour === hour}
aria-label={description(cell)}
title={description(cell)}
onclick={() => select(weekday, hour)}
onkeydown={(event) => move(event, cell)}
><span aria-hidden="true"
>{cell.observed > 0
? ''
: cell.unknown > 0
? '?'
: '·'}</span
></button
></td
>
{/each}</tr
>{/each}</tbody
>
</table>
</div>
<p class="note">
A striped corner marks additional unknown time. Color uses known time only. Repeated
daylight-saving clock hours share a cell; collection gaps stay uncaptured.
</p>
{#if selected}
<div class="selectors"><Field label="Inspect weekday"><Select value={String(selected.weekday)} options={dayOptions} onchange={event => select(Number((event.currentTarget as HTMLSelectElement).value), selected.hour)} /></Field><Field label="Inspect hour"><Select value={String(selected.hour)} options={hourOptions} onchange={event => select(selected.weekday, Number((event.currentTarget as HTMLSelectElement).value))} /></Field></div>
<div class="readout"><strong>{weekdays[selected.weekday]} · {hourLabel(selected.hour)} · {heatmap.timeZone.replaceAll('_', ' ')}</strong>
{#if selected.observed + selected.unknown === 0}<p>No captured time for this weekday and hour. This does not mean the fleet was idle.</p>
<div class="selectors">
<Field label="Inspect weekday"
><Select
value={String(selected.weekday)}
options={dayOptions}
onchange={(event) =>
select(
Number((event.currentTarget as HTMLSelectElement).value),
selected.hour
)}
/></Field
><Field label="Inspect hour"
><Select
value={String(selected.hour)}
options={hourOptions}
onchange={(event) =>
select(
selected.weekday,
Number((event.currentTarget as HTMLSelectElement).value)
)}
/></Field
>
</div>
<div class="readout">
<strong
>{weekdays[selected.weekday]} · {hourLabel(selected.hour)} · {heatmap.timeZone.replaceAll(
'_',
' '
)}</strong
>
{#if selected.observed + selected.unknown === 0}<p>
No captured time for this weekday and hour. This does not mean the fleet was
idle.
</p>
{:else}<dl>
<div><dt>Time with players</dt><dd>{metricPercent(selected.active, selected.observed)}<small>{metricDuration(selected.active)} of {metricDuration(selected.observed)} known</small></dd></div>
<div><dt>Unknown server time</dt><dd>{metricDuration(selected.unknown)}</dd></div>
<div><dt>Player-hours</dt><dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd></div>
<div><dt>Distinct recorded dates</dt><dd>{selected.distinctDates}</dd></div>
</dl>{/if}
<div>
<dt>Time with players</dt>
<dd>
{metricPercent(selected.active, selected.observed)}<small
>{metricDuration(selected.active)} of {metricDuration(
selected.observed
)} known</small
>
</dd>
</div>
<div>
<dt>Unknown server time</dt>
<dd>{metricDuration(selected.unknown)}</dd>
</div>
<div>
<dt>Player-hours</dt>
<dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd>
</div>
<div>
<dt>Distinct recorded dates</dt>
<dd>{selected.distinctDates}</dd>
</div>
</dl>{/if}
</div>
{/if}
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
</section>
<style>
.heatmap { min-width: 0; padding: var(--space-5); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); }
.heading { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-4); flex-wrap: wrap; }
.heading > div:first-child { flex: 1 1 260px; min-width: 0; }
h2 { margin: 0; font-size: var(--text-lg); }
p { font-size: var(--text-xs); color: var(--text-secondary); margin: var(--space-2) 0 0; line-height: 1.6; }
.zone { flex: 0 1 250px; min-width: 0; max-width: 100%; }
.legend { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; margin: var(--space-4) 0 var(--space-3); font-size: var(--text-xs); color: var(--text-secondary); }
.legend span { display: flex; gap: 5px; align-items: center; }
.scale { width: 64px; height: 9px; background: linear-gradient(to right, var(--surface-raised), var(--chart-active)); border-radius: 3px; }
.unknown-example, .missing-example { width: 14px; height: 14px; display: inline-flex; justify-content: center; align-items: center; font-style: normal; }
.unknown-example, .cell.unknown { background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 3px, var(--chart-unknown-stripe) 3px 5px); }
.missing-example { border: 1px dashed var(--border-control); }
.table-wrap { position: relative; overflow-x: auto; max-width: 100%; padding: 3px; }
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
table { width: 100%; min-width: 750px; border-collapse: separate; border-spacing: 3px; table-layout: fixed; }
th { color: var(--text-tertiary); font-weight: normal; font-size: var(--text-xs); text-align: center; padding: 0 0 3px; }
th:first-child { width: 35px; position: sticky; left: 0; background: var(--surface); z-index: 2; text-align: left; }
td { padding: 0; }
.cell { position: relative; appearance: none; display: block; width: 100%; height: 29px; border: 1px solid var(--border-subtle); border-radius: 3px; background: var(--surface-raised); color: var(--text-secondary); font: inherit; cursor: pointer; overflow: hidden; }
.cell.known { background: color-mix(in oklab, var(--chart-active) var(--activity), var(--surface-raised)); }
.cell.missing { border-style: dashed; background: transparent; }
.cell.mixed::after { content: ''; position: absolute; top: 0; right: 0; width: 7px; height: 7px; background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 2px, var(--chart-unknown-stripe) 2px 4px); }
.cell[aria-pressed='true'] { outline: 1px solid var(--text-primary); outline-offset: 1px; }
.cell:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 1px; }
.note { color: var(--text-tertiary); font-size: var(--text-xs); }
.selectors { display: grid; grid-template-columns: repeat(2, minmax(0, 190px)); gap: var(--space-3); margin-top: var(--space-4); }
.readout { margin-top: var(--space-3); padding: var(--space-4); background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.readout > strong { font-size: var(--text-xs); font-weight: var(--weight-medium); color: var(--text-secondary); }
dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-3); margin: var(--space-3) 0 0; }
dt { font-size: var(--text-xs); color: var(--text-tertiary); }
dd { margin: 5px 0 0; font-size: var(--text-md); font-variant-numeric: tabular-nums; }
small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-tertiary); }
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 640px) { .heatmap { padding: var(--space-4); } .zone { flex-basis: 100%; } dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } .readout { padding: var(--space-3); } .selectors { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.heatmap {
min-width: 0;
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
}
.heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-4);
flex-wrap: wrap;
}
.heading > div:first-child {
flex: 1 1 260px;
min-width: 0;
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
p {
font-size: var(--text-xs);
color: var(--text-secondary);
margin: var(--space-2) 0 0;
line-height: 1.6;
}
.zone {
flex: 0 1 250px;
min-width: 0;
max-width: 100%;
}
.legend {
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
margin: var(--space-4) 0 var(--space-3);
font-size: var(--text-xs);
color: var(--text-secondary);
}
.legend span {
display: flex;
gap: 5px;
align-items: center;
}
.scale {
width: 64px;
height: 9px;
background: linear-gradient(to right, var(--surface-raised), var(--chart-active));
border-radius: 3px;
}
.unknown-example,
.missing-example {
width: 14px;
height: 14px;
display: inline-flex;
justify-content: center;
align-items: center;
font-style: normal;
}
.unknown-example,
.cell.unknown {
background: repeating-linear-gradient(
135deg,
var(--chart-unknown-base) 0 3px,
var(--chart-unknown-stripe) 3px 5px
);
}
.missing-example {
border: 1px dashed var(--border-control);
}
.table-wrap {
position: relative;
overflow-x: auto;
max-width: 100%;
padding: 3px;
}
.table-wrap:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
table {
width: 100%;
min-width: 750px;
border-collapse: separate;
border-spacing: 3px;
table-layout: fixed;
}
th {
color: var(--text-tertiary);
font-weight: normal;
font-size: var(--text-xs);
text-align: center;
padding: 0 0 3px;
}
th:first-child {
width: 35px;
position: sticky;
left: 0;
background: var(--surface);
z-index: 2;
text-align: left;
}
td {
padding: 0;
}
.cell {
position: relative;
appearance: none;
display: block;
width: 100%;
height: 29px;
border: 1px solid var(--border-subtle);
border-radius: 3px;
background: var(--surface-raised);
color: var(--text-secondary);
font: inherit;
cursor: pointer;
overflow: hidden;
}
.cell.known {
background: color-mix(in oklab, var(--chart-active) var(--activity), var(--surface-raised));
}
.cell.missing {
border-style: dashed;
background: transparent;
}
.cell.mixed::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 7px;
height: 7px;
background: repeating-linear-gradient(
135deg,
var(--chart-unknown-base) 0 2px,
var(--chart-unknown-stripe) 2px 4px
);
}
.cell[aria-pressed='true'] {
outline: 1px solid var(--text-primary);
outline-offset: 1px;
}
.cell:focus-visible {
outline: 2px solid var(--accent-primary-hover);
outline-offset: 1px;
}
.note {
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.selectors {
display: grid;
grid-template-columns: repeat(2, minmax(0, 190px));
gap: var(--space-3);
margin-top: var(--space-4);
}
.readout {
margin-top: var(--space-3);
padding: var(--space-4);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.readout > strong {
font-size: var(--text-xs);
font-weight: var(--weight-medium);
color: var(--text-secondary);
}
dl {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-3);
margin: var(--space-3) 0 0;
}
dt {
font-size: var(--text-xs);
color: var(--text-tertiary);
}
dd {
margin: 5px 0 0;
font-size: var(--text-md);
font-variant-numeric: tabular-nums;
}
small {
display: block;
margin-top: 4px;
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 640px) {
.heatmap {
padding: var(--space-4);
}
.zone {
flex-basis: 100%;
}
dl {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.readout {
padding: var(--space-3);
}
.selectors {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -7,36 +7,127 @@
const prior = $derived(report.previous);
const pressure = $derived(report.pooledPressure);
const comparable = $derived(report.summary.observed > 0 && prior.summary.observed > 0);
const occupancyDelta = $derived(comparable ? 100 * (report.summary.active / report.summary.observed - prior.summary.active / prior.summary.observed) : null);
const playerDelta = $derived(comparable && prior.summary.playerHours > 0 ? 100 * (report.summary.playerHours / prior.summary.playerHours - 1) : comparable && report.summary.playerHours === 0 ? 0 : null);
const previousLabel = $derived(`previous ${report.days === 1 ? '24 hours' : `${report.days} days`}`);
function signed(value: number, unit: string) { return `${value > 0 ? '+' : value < 0 ? '−' : ''}${metricNumber(Math.abs(value))}${unit}`; }
const occupancyDelta = $derived(
comparable
? 100 *
(report.summary.active / report.summary.observed -
prior.summary.active / prior.summary.observed)
: null
);
const playerDelta = $derived(
comparable && prior.summary.playerHours > 0
? 100 * (report.summary.playerHours / prior.summary.playerHours - 1)
: comparable && report.summary.playerHours === 0
? 0
: null
);
const previousLabel = $derived(
`previous ${report.days === 1 ? '24 hours' : `${report.days} days`}`
);
function signed(value: number, unit: string) {
return `${value > 0 ? '+' : value < 0 ? '−' : ''}${metricNumber(Math.abs(value))}${unit}`;
}
</script>
<div class="summary">
<Card><Stat label="Time with players" value={metricPercent(report.summary.active, report.summary.observed)} size="lg" tween={false} />
<p>{occupancyDelta === null ? 'Comparison unavailable' : `${signed(occupancyDelta, ' percentage points')} vs ${previousLabel}`}</p>
<small>{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} observed server time</small>
<Card
><Stat
label="Time with players"
value={metricPercent(report.summary.active, report.summary.observed)}
size="lg"
tween={false}
/>
<p>
{occupancyDelta === null
? 'Comparison unavailable'
: `${signed(occupancyDelta, ' percentage points')} vs ${previousLabel}`}
</p>
<small
>{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} observed
server time</small
>
</Card>
<Card><Stat label="Player-hours" value={report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'} size="lg" tween={false} />
<p>{playerDelta === null ? comparable && report.summary.playerHours > 0 ? 'New · no previous player-hours' : 'Comparison unavailable' : `${signed(playerDelta, '%')} vs ${previousLabel}`}</p>
<details><summary>What are player-hours?</summary><p>Players × observed time. One player for one hour equals one player-hour.</p></details>
<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>
<details>
<summary>What are player-hours?</summary>
<p>Players × observed time. One player for one hour equals one player-hour.</p>
</details>
</Card>
<Card><Stat label="Time all lobbies were full" value={metricPercent(pressure.fullMapHours, pressure.knownMapHours)} size="lg" tween={false} />
<p>{pressure.knownMapHours > 0 ? `${pressure.coveredMaps} of ${pressure.totalMaps} maps have capacity data` : 'Capacity data unavailable'}</p>
<Card
><Stat
label="Time all lobbies were full"
value={metricPercent(pressure.fullMapHours, pressure.knownMapHours)}
size="lg"
tween={false}
/>
<p>
{pressure.knownMapHours > 0
? `${pressure.coveredMaps} of ${pressure.totalMaps} maps have capacity data`
: 'Capacity data unavailable'}
</p>
<small>Combined map history · each map measured separately</small>
</Card>
</div>
{#if prior.partialHistory}<p class="notice">Partial previous history. Comparisons use available observations without extrapolation.</p>{/if}
{#if report.maps.some(map => map.capacityObserved <= 0)}<p class="notice">{report.maps.filter(map => map.capacityObserved <= 0).length} of {report.maps.length} maps lack capacity data. Player limits are needed to measure full lobbies; occupancy can still be recorded.</p>{/if}
{#if report.summary.unknown > 0}<p class="quality">{metricDuration(report.summary.unknown)} of captured status is unknown and excluded from “Time with players.” Collection gaps are separate.</p>{/if}
{#if prior.partialHistory}<p class="notice">
Partial previous history. Comparisons use available observations without extrapolation.
</p>{/if}
{#if report.maps.some((map) => map.capacityObserved <= 0)}<p class="notice">
{report.maps.filter((map) => map.capacityObserved <= 0).length} of {report.maps.length} maps lack
capacity data. Player limits are needed to measure full lobbies; occupancy can still be recorded.
</p>{/if}
{#if report.summary.unknown > 0}<p class="quality">
{metricDuration(report.summary.unknown)} of captured status is unknown and excluded from “Time
with players.” Collection gaps are separate.
</p>{/if}
<style>
.summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
p { margin: 10px 0 0; font-size: var(--text-sm); color: var(--text-secondary); }
small, details { display: block; margin-top: 8px; font-size: var(--text-xs); color: var(--text-secondary); line-height: 1.5; }
summary { cursor: pointer; }
.notice { margin: 0; padding-left: 12px; border-left: 2px solid var(--state-warning); }
.quality { margin: 0; font-size: var(--text-xs); }
@media (max-width: 760px) { .summary { grid-template-columns: 1fr; gap: 8px; } }
.summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
p {
margin: 10px 0 0;
font-size: var(--text-sm);
color: var(--text-secondary);
}
small,
details {
display: block;
margin-top: 8px;
font-size: var(--text-xs);
color: var(--text-secondary);
line-height: 1.5;
}
summary {
cursor: pointer;
}
.notice {
margin: 0;
padding-left: 12px;
border-left: 2px solid var(--state-warning);
}
.quality {
margin: 0;
font-size: var(--text-xs);
}
@media (max-width: 760px) {
.summary {
grid-template-columns: 1fr;
gap: 8px;
}
}
</style>
@@ -2,7 +2,13 @@
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import type { MapMetrics, ServerMetrics } from '$lib/metrics.js';
import { metricPage, compareMetricValues, metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
import {
metricPage,
compareMetricValues,
metricDuration,
metricNumber,
metricPercent
} from '$ui/metrics.js';
import { serverTitle } from '$ui/server-labels.js';
import { contextHref } from '$lib/navigation-context.js';
import Input from '$ui/components/primitives/Input.svelte';
@@ -10,45 +16,97 @@
import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
let { maps, servers, view = 'maps' }: { maps: MapMetrics[]; servers: ServerMetrics[]; view?: 'maps' | 'instances' } = $props();
let {
maps,
servers,
view = 'maps'
}: { maps: MapMetrics[]; servers: ServerMetrics[]; view?: 'maps' | 'instances' } = $props();
const mapColumns = [
{ key: 'map', label: 'Map' }, { key: 'playerHours', label: 'Player-hours' },
{ key: 'active', label: 'With players' }, { key: 'full', label: 'All lobbies full' },
{ key: 'map', label: 'Map' },
{ key: 'playerHours', label: 'Player-hours' },
{ key: 'active', label: 'With players' },
{ key: 'full', label: 'All lobbies full' },
{ key: 'details', label: 'Details' }
] as const;
const serverColumns = [
{ key: 'server', label: 'Instance' }, { key: 'utilization', label: 'Time with players' },
{ key: 'observed', label: 'Observed time' }, { key: 'unknown', label: 'Unknown time' }, { key: 'details', label: 'Details' }
{ key: 'server', label: 'Instance' },
{ key: 'utilization', label: 'Time with players' },
{ key: 'observed', label: 'Observed time' },
{ key: 'unknown', label: 'Unknown time' },
{ key: 'details', label: 'Details' }
] as const;
type MapSort = typeof mapColumns[number]['key'];
type ServerSort = typeof serverColumns[number]['key'];
type MapSort = (typeof mapColumns)[number]['key'];
type ServerSort = (typeof serverColumns)[number]['key'];
type Direction = 'asc' | 'desc';
// Shallow navigation changes page.state, not page.url. Store the working
// search string there so filters react immediately and survive Back/Forward.
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
const contextSearch = $derived(
(page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search
);
const params = $derived(new URLSearchParams(contextSearch));
const mapQuery = $derived((params.get('mapQ') ?? '').slice(0, 100));
const serverQuery = $derived((params.get('serverQ') ?? '').slice(0, 100));
const mapSort = $derived(mapColumns.find(column => column.key !== 'details' && column.key === params.get('mapSort'))?.key ?? 'playerHours');
const serverSort = $derived(serverColumns.find(column => column.key !== 'details' && column.key === params.get('serverSort'))?.key ?? 'utilization');
const mapSort = $derived(
mapColumns.find(
(column) => column.key !== 'details' && column.key === params.get('mapSort')
)?.key ?? 'playerHours'
);
const serverSort = $derived(
serverColumns.find(
(column) => column.key !== 'details' && column.key === params.get('serverSort')
)?.key ?? 'utilization'
);
const mapDirection = $derived<Direction>(params.get('mapDirection') === 'asc' ? 'asc' : 'desc');
const serverDirection = $derived<Direction>(params.get('serverDirection') === 'asc' ? 'asc' : 'desc');
const maxPlayerHours = $derived(Math.max(0, ...maps.map(map => map.playerHours)));
const mapRows = $derived(maps.filter(map => `${map.game} ${gameName(map.game)} ${map.map}`.toLowerCase().includes(mapQuery.trim().toLowerCase())).sort((a, b) => {
const result = mapSort === 'map' ? a.map.localeCompare(b.map) * (mapDirection === 'asc' ? 1 : -1)
: compareMetricValues(mapValue(a, mapSort), mapValue(b, mapSort), mapDirection);
return result || a.map.localeCompare(b.map) || a.game.localeCompare(b.game);
}));
const serverRows = $derived(servers.filter(server => `${server.server} ${serverTitle(server.server)}`.toLowerCase().includes(serverQuery.trim().toLowerCase())).sort((a, b) => {
const result = serverSort === 'server' ? serverTitle(a.server).localeCompare(serverTitle(b.server)) * (serverDirection === 'asc' ? 1 : -1)
: compareMetricValues(serverValue(a, serverSort), serverValue(b, serverSort), serverDirection);
return result || b.observed - a.observed || a.server.localeCompare(b.server);
}));
const serverDirection = $derived<Direction>(
params.get('serverDirection') === 'asc' ? 'asc' : 'desc'
);
const maxPlayerHours = $derived(Math.max(0, ...maps.map((map) => map.playerHours)));
const mapRows = $derived(
maps
.filter((map) =>
`${map.game} ${gameName(map.game)} ${map.map}`
.toLowerCase()
.includes(mapQuery.trim().toLowerCase())
)
.sort((a, b) => {
const result =
mapSort === 'map'
? a.map.localeCompare(b.map) * (mapDirection === 'asc' ? 1 : -1)
: compareMetricValues(
mapValue(a, mapSort),
mapValue(b, mapSort),
mapDirection
);
return result || a.map.localeCompare(b.map) || a.game.localeCompare(b.game);
})
);
const serverRows = $derived(
servers
.filter((server) =>
`${server.server} ${serverTitle(server.server)}`
.toLowerCase()
.includes(serverQuery.trim().toLowerCase())
)
.sort((a, b) => {
const result =
serverSort === 'server'
? serverTitle(a.server).localeCompare(serverTitle(b.server)) *
(serverDirection === 'asc' ? 1 : -1)
: compareMetricValues(
serverValue(a, serverSort),
serverValue(b, serverSort),
serverDirection
);
return result || b.observed - a.observed || a.server.localeCompare(b.server);
})
);
const mapPage = $derived(metricPage(mapRows, params.get('mapPage')));
const serverPage = $derived(metricPage(serverRows, params.get('serverPage')));
const currentPage = $derived(view === 'maps' ? mapPage : serverPage);
const pageKey = $derived(view === 'maps' ? 'mapPage' : 'serverPage');
function gameName(game: string) { return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase(); }
function gameName(game: string) {
return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
}
function mapValue(map: MapMetrics, key: Exclude<MapSort, 'map'>): number | null {
if (key === 'details') return null;
if (key === 'full') return map.capacityObserved ? map.full / map.capacityObserved : null;
@@ -56,87 +114,388 @@
}
function serverValue(server: ServerMetrics, key: Exclude<ServerSort, 'server'>): number | null {
if (key === 'details') return null;
if (key === 'utilization') return server.observed > 0 ? server.active / server.observed : null;
if (key === 'utilization')
return server.observed > 0 ? server.active / server.observed : null;
return server[key];
}
function updateContext(changes: Record<string, string>) {
const url = new URL(page.url);
url.search = contextSearch;
for (const [key, value] of Object.entries(changes)) { if (value) url.searchParams.set(key, value); else url.searchParams.delete(key); }
for (const [key, value] of Object.entries(changes)) {
if (value) url.searchParams.set(key, value);
else url.searchParams.delete(key);
}
if ('mapQ' in changes || 'mapSort' in changes) url.searchParams.delete('mapPage');
if ('serverQ' in changes || 'serverSort' in changes) url.searchParams.delete('serverPage');
replaceState(url, { ...page.state, metricsTableSearch: url.search });
}
function sortMap(key: MapSort) { updateContext({ mapSort: key, mapDirection: key === mapSort ? mapDirection === 'asc' ? 'desc' : 'asc' : key === 'map' ? 'asc' : 'desc' }); }
function sortServer(key: ServerSort) { updateContext({ serverSort: key, serverDirection: key === serverSort ? serverDirection === 'asc' ? 'desc' : 'asc' : key === 'server' ? 'asc' : 'desc' }); }
function sortMap(key: MapSort) {
updateContext({
mapSort: key,
mapDirection:
key === mapSort
? mapDirection === 'asc'
? 'desc'
: 'asc'
: key === 'map'
? 'asc'
: 'desc'
});
}
function sortServer(key: ServerSort) {
updateContext({
serverSort: key,
serverDirection:
key === serverSort
? serverDirection === 'asc'
? 'desc'
: 'asc'
: key === 'server'
? 'asc'
: 'desc'
});
}
</script>
<section class="panel" aria-label={view === 'maps' ? 'Map metrics' : 'Instance metrics'}>
<div class="heading">
<h2>{view === 'maps' ? 'Map play time and capacity' : 'Instance utilization'}</h2>
<div class="search"><Field label={view === 'maps' ? 'Find a map' : 'Find an instance'}><Input type="search" value={view === 'maps' ? mapQuery : serverQuery} placeholder={view === 'maps' ? 'Map or game…' : 'Instance name or map…'} oninput={event => updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: (event.currentTarget as HTMLInputElement).value })} /></Field></div>
<div class="search">
<Field label={view === 'maps' ? 'Find a map' : 'Find an instance'}
><Input
type="search"
value={view === 'maps' ? mapQuery : serverQuery}
placeholder={view === 'maps' ? 'Map or game…' : 'Instance name or map…'}
oninput={(event) =>
updateContext({
[view === 'maps' ? 'mapQ' : 'serverQ']: (
event.currentTarget as HTMLInputElement
).value
})}
/></Field
>
</div>
</div>
{#if currentPage.total}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
<div class="table-wrap" role="region" aria-label={view === 'maps' ? 'Map metrics table' : 'Instance metrics table'} tabindex="0"><table>
<thead><tr>{#each view === 'maps' ? mapColumns : serverColumns as column}
<th scope="col" aria-sort={column.key === 'details' ? undefined : (view === 'maps' ? mapSort : serverSort) === column.key ? (view === 'maps' ? mapDirection : serverDirection) === 'asc' ? 'ascending' : 'descending' : undefined}>
{#if column.key === 'details'}Details{:else}<button type="button" onclick={() => view === 'maps' ? sortMap(column.key as MapSort) : sortServer(column.key as ServerSort)}>{column.label}<span aria-hidden="true">{(view === 'maps' ? mapSort : serverSort) === column.key ? (view === 'maps' ? mapDirection : serverDirection) === 'asc' ? '↑' : '↓' : '↕'}</span></button>{/if}
</th>
{/each}</tr></thead>
<tbody>
{#if view === 'maps'}
{#each mapPage.rows as map (JSON.stringify([map.game, map.map]))}<tr>
<th scope="row">{map.map}<small>{gameName(map.game)}{map.observed < 1 || map.capacityObserved < 1 || map.unknown > map.observed / 4 ? ' · Limited evidence' : ''}</small></th>
<td class="player-hours">{map.observed ? metricNumber(map.playerHours) : '—'}<div class="comparison" aria-hidden="true"><i style:width={`${maxPlayerHours ? map.playerHours / maxPlayerHours * 100 : 0}%`}></i></div></td>
<td>{map.observed ? metricDuration(map.active) : '—'}</td>
<td>{metricPercent(map.full, map.capacityObserved)}</td>
<td><details><summary><span class="sr-only">{map.map} </span>Details</summary><dl>
<dt>Observed instance time</dt><dd>{metricDuration(map.observed)}</dd><dt>Unknown status</dt><dd>{metricDuration(map.unknown)}</dd><dt>All lobbies full</dt><dd>{map.capacityObserved ? `${metricDuration(map.full)} of ${metricDuration(map.capacityObserved)} known map time` : 'Capacity data unavailable'}</dd>
</dl></details></td>
</tr>{/each}
{:else}
{#each serverPage.rows as server (server.server)}<tr>
<th scope="row">{#if server.currentRegistered}<a href={contextHref(`/servers/${encodeURIComponent(server.server)}`, `/metrics${contextSearch}`)} title="Open current instance with this name">{serverTitle(server.server)}</a>{:else}{serverTitle(server.server)}<small>Historical</small>{/if}</th>
<td>{metricPercent(server.active, server.observed)}</td><td>{metricDuration(server.observed)}</td><td>{metricDuration(server.unknown)}</td>
<td><details><summary><span class="sr-only">{serverTitle(server.server)} </span>Details</summary><dl><dt>Recorded name</dt><dd><code>{server.server}</code></dd><dt>Time with players</dt><dd>{metricDuration(server.active)}</dd><dt>Idle</dt><dd>{metricPercent(server.idle, server.observed)} · {metricDuration(server.idle)}</dd></dl></details></td>
</tr>{/each}
{/if}
</tbody>
</table></div>
{:else}<EmptyState title={view === 'maps' ? 'No maps match this search' : 'No instances match this search'}>{#snippet actions()}<Button size="sm" onclick={() => updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: '' })}>Clear search</Button>{/snippet}</EmptyState>{/if}
<div class="footer"><span>{currentPage.start}–{currentPage.end} of {currentPage.total} {view === 'maps' ? 'maps' : 'instances'}</span>
{#if currentPage.pages > 1}<div class="paging"><Button size="sm" disabled={currentPage.page === 1} onclick={() => updateContext({ [pageKey]: String(currentPage.page - 1) })}>Previous</Button><span>Page {currentPage.page} of {currentPage.pages}</span><Button size="sm" disabled={currentPage.page === currentPage.pages} onclick={() => updateContext({ [pageKey]: String(currentPage.page + 1) })}>Next</Button></div>{/if}
<div
class="table-wrap"
role="region"
aria-label={view === 'maps' ? 'Map metrics table' : 'Instance metrics table'}
tabindex="0"
>
<table>
<thead
><tr
>{#each view === 'maps' ? mapColumns : serverColumns as column}
<th
scope="col"
aria-sort={column.key === 'details'
? undefined
: (view === 'maps' ? mapSort : serverSort) === column.key
? (view === 'maps' ? mapDirection : serverDirection) === 'asc'
? 'ascending'
: 'descending'
: undefined}
>
{#if column.key === 'details'}Details{:else}<button
type="button"
onclick={() =>
view === 'maps'
? sortMap(column.key as MapSort)
: sortServer(column.key as ServerSort)}
>{column.label}<span aria-hidden="true"
>{(view === 'maps' ? mapSort : serverSort) ===
column.key
? (view === 'maps'
? mapDirection
: serverDirection) === 'asc'
? '↑'
: '↓'
: '↕'}</span
></button
>{/if}
</th>
{/each}</tr
></thead
>
<tbody>
{#if view === 'maps'}
{#each mapPage.rows as map (JSON.stringify([map.game, map.map]))}<tr>
<th scope="row"
>{map.map}<small
>{gameName(map.game)}{map.observed < 1 ||
map.capacityObserved < 1 ||
map.unknown > map.observed / 4
? ' · Limited evidence'
: ''}</small
></th
>
<td class="player-hours"
>{map.observed ? metricNumber(map.playerHours) : '—'}
<div class="comparison" aria-hidden="true">
<i
style:width={`${maxPlayerHours ? (map.playerHours / maxPlayerHours) * 100 : 0}%`}
></i>
</div></td
>
<td>{map.observed ? metricDuration(map.active) : '—'}</td>
<td>{metricPercent(map.full, map.capacityObserved)}</td>
<td
><details>
<summary
><span class="sr-only">{map.map} </span>Details</summary
>
<dl>
<dt>Observed instance time</dt>
<dd>{metricDuration(map.observed)}</dd>
<dt>Unknown status</dt>
<dd>{metricDuration(map.unknown)}</dd>
<dt>All lobbies full</dt>
<dd>
{map.capacityObserved
? `${metricDuration(map.full)} of ${metricDuration(map.capacityObserved)} known map time`
: 'Capacity data unavailable'}
</dd>
</dl>
</details></td
>
</tr>{/each}
{:else}
{#each serverPage.rows as server (server.server)}<tr>
<th scope="row"
>{#if server.currentRegistered}<a
href={contextHref(
`/servers/${encodeURIComponent(server.server)}`,
`/metrics${contextSearch}`
)}
title="Open current instance with this name"
>{serverTitle(server.server)}</a
>{:else}{serverTitle(server.server)}<small>Historical</small
>{/if}</th
>
<td>{metricPercent(server.active, server.observed)}</td><td
>{metricDuration(server.observed)}</td
><td>{metricDuration(server.unknown)}</td>
<td
><details>
<summary
><span class="sr-only"
>{serverTitle(server.server)}
</span>Details</summary
>
<dl>
<dt>Recorded name</dt>
<dd><code>{server.server}</code></dd>
<dt>Time with players</dt>
<dd>{metricDuration(server.active)}</dd>
<dt>Idle</dt>
<dd>
{metricPercent(server.idle, server.observed)} · {metricDuration(
server.idle
)}
</dd>
</dl>
</details></td
>
</tr>{/each}
{/if}
</tbody>
</table>
</div>
{:else}<EmptyState
title={view === 'maps' ? 'No maps match this search' : 'No instances match this search'}
>{#snippet actions()}<Button
size="sm"
onclick={() => updateContext({ [view === 'maps' ? 'mapQ' : 'serverQ']: '' })}
>Clear search</Button
>{/snippet}</EmptyState
>{/if}
<div class="footer">
<span
>{currentPage.start}–{currentPage.end} of {currentPage.total}
{view === 'maps' ? 'maps' : 'instances'}</span
>
{#if currentPage.pages > 1}<div class="paging">
<Button
size="sm"
disabled={currentPage.page === 1}
onclick={() => updateContext({ [pageKey]: String(currentPage.page - 1) })}
>Previous</Button
><span>Page {currentPage.page} of {currentPage.pages}</span><Button
size="sm"
disabled={currentPage.page === currentPage.pages}
onclick={() => updateContext({ [pageKey]: String(currentPage.page + 1) })}
>Next</Button
>
</div>{/if}
</div>
{#if view === 'instances'}<p class="identity-note">Grouped by recorded name; links open the current instance, which may differ from the historical registration.</p>{/if}
<details class="table-help"><summary>About these metrics</summary><p>{view === 'maps' ? 'All lobbies full uses map time when every tracked lobby has known occupancy and capacity. It does not measure failed joins or unmet demand. A dash means unavailable, not zero.' : 'Time with players excludes unknown status and collection gaps. Rows group historical observations by name and may span multiple registrations. Links open the current instance with that name.'}</p></details>
{#if view === 'instances'}<p class="identity-note">
Grouped by recorded name; links open the current instance, which may differ from the
historical registration.
</p>{/if}
<details class="table-help">
<summary>About these metrics</summary>
<p>
{view === 'maps'
? 'All lobbies full uses map time when every tracked lobby has known occupancy and capacity. It does not measure failed joins or unmet demand. A dash means unavailable, not zero.'
: 'Time with players excludes unknown status and collection gaps. Rows group historical observations by name and may span multiple registrations. Links open the current instance with that name.'}
</p>
</details>
</section>
<style>
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); min-width: 0; overflow: hidden; }
.heading { padding: 20px; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
h2 { font-size: var(--text-lg); margin: 0; }
.search { flex: 0 1 240px; }
.table-wrap { position: relative; width: 100%; overflow-x: auto; }
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -3px; }
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
th, td { padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); text-align: right; font-variant-numeric: tabular-nums; vertical-align: top; }
th:first-child { text-align: left; min-width: 160px; }
thead th { color: var(--text-secondary); background: var(--surface-sunken); font-size: var(--text-xs); white-space: nowrap; }
thead button { display: inline-flex; gap: 6px; color: inherit; background: transparent; font: inherit; padding: 5px 0; border: 0; cursor: pointer; white-space: nowrap; }
thead button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
tbody th { font-weight: var(--weight-medium); }
small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-secondary); }
.comparison { height: 3px; width: 90px; margin: 8px 0 0 auto; background: var(--surface-raised); border-radius: 3px; }
.comparison i { display: block; height: 100%; background: var(--chart-active); border-radius: inherit; }
.footer, .paging { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; font-size: var(--text-xs); color: var(--text-secondary); }
.footer { padding: 16px 20px; }
details { font-size: var(--text-xs); color: var(--text-secondary); }
summary { cursor: pointer; }
dl { text-align: left; min-width: 170px; max-width: 300px; }
dt { margin-top: 10px; } dd { margin: 4px 0; color: var(--text-primary); overflow-wrap: anywhere; }
.identity-note { margin: 0; padding: 0 20px 12px; color: var(--text-secondary); font-size: var(--text-xs); }
.table-help { padding: 0 20px 16px; } .table-help p { max-width: 90ch; line-height: 1.6; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
@media (max-width: 640px) { th, td { padding: 10px; } .search { flex: 1 1 100%; } }
.panel {
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
min-width: 0;
overflow: hidden;
}
.heading {
padding: 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
h2 {
font-size: var(--text-lg);
margin: 0;
}
.search {
flex: 0 1 240px;
}
.table-wrap {
position: relative;
width: 100%;
overflow-x: auto;
}
.table-wrap:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: -3px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
th,
td {
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
text-align: right;
font-variant-numeric: tabular-nums;
vertical-align: top;
}
th:first-child {
text-align: left;
min-width: 160px;
}
thead th {
color: var(--text-secondary);
background: var(--surface-sunken);
font-size: var(--text-xs);
white-space: nowrap;
}
thead button {
display: inline-flex;
gap: 6px;
color: inherit;
background: transparent;
font: inherit;
padding: 5px 0;
border: 0;
cursor: pointer;
white-space: nowrap;
}
thead button:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
tbody th {
font-weight: var(--weight-medium);
}
small {
display: block;
margin-top: 4px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.comparison {
height: 3px;
width: 90px;
margin: 8px 0 0 auto;
background: var(--surface-raised);
border-radius: 3px;
}
.comparison i {
display: block;
height: 100%;
background: var(--chart-active);
border-radius: inherit;
}
.footer,
.paging {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.footer {
padding: 16px 20px;
}
details {
font-size: var(--text-xs);
color: var(--text-secondary);
}
summary {
cursor: pointer;
}
dl {
text-align: left;
min-width: 170px;
max-width: 300px;
}
dt {
margin-top: 10px;
}
dd {
margin: 4px 0;
color: var(--text-primary);
overflow-wrap: anywhere;
}
.identity-note {
margin: 0;
padding: 0 20px 12px;
color: var(--text-secondary);
font-size: var(--text-xs);
}
.table-help {
padding: 0 20px 16px;
}
.table-help p {
max-width: 90ch;
line-height: 1.6;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
@media (max-width: 640px) {
th,
td {
padding: 10px;
}
.search {
flex: 1 1 100%;
}
}
</style>
+11 -10
View File
@@ -23,12 +23,7 @@
}: Props = $props();
</script>
<span
class="badge v-{variant} s-{size}"
class:uppercase
class:mono
{title}
>
<span class="badge v-{variant} s-{size}" class:uppercase class:mono {title}>
{@render children?.()}
</span>
@@ -54,8 +49,14 @@
font-family: var(--font-mono);
}
.s-sm { padding: 3px 8px; font-size: var(--text-xs); }
.s-md { padding: 5px 12px; font-size: var(--text-sm); }
.s-sm {
padding: 3px 8px;
font-size: var(--text-xs);
}
.s-md {
padding: 5px 12px;
font-size: var(--text-sm);
}
.v-neutral {
background: var(--surface-raised);
@@ -68,12 +69,12 @@
border-color: color-mix(in srgb, var(--state-online) 25%, transparent);
}
.v-warning {
background: rgba(255, 176, 32, 0.10);
background: rgba(255, 176, 32, 0.1);
color: var(--state-warning);
border-color: rgba(255, 176, 32, 0.28);
}
.v-danger {
background: rgba(255, 86, 112, 0.10);
background: rgba(255, 86, 112, 0.1);
color: var(--state-danger);
border-color: rgba(255, 86, 112, 0.28);
}
@@ -13,19 +13,27 @@
}
let { items, fallback }: Props = $props();
const group = $derived(navigationLocation($page.url.pathname, $page.data.user?.role ?? 'viewer')?.group);
const group = $derived(
navigationLocation($page.url.pathname, $page.data.user?.role ?? 'viewer')?.group
);
const trail = $derived.by(() => {
const parent = items.findIndex(item => item.href);
const parent = items.findIndex((item) => item.href);
const context = $page.url.searchParams.get('returnTo');
const result = [...items];
if (parent >= 0) {
const href = safeReturnHref(context, fallback ?? items[parent].href!);
const original = items[parent];
const label = href.split('?')[0].split('#')[0] === original.href?.split('?')[0]
? original.label : returnLabel(href).replace(/^Back to /, '').replace(/^./, char => char.toUpperCase());
const label =
href.split('?')[0].split('#')[0] === original.href?.split('?')[0]
? original.label
: returnLabel(href)
.replace(/^Back to /, '')
.replace(/^./, (char) => char.toUpperCase());
result[parent] = { label, href };
}
return !context && group && group.label !== result[0]?.label ? [{ label: group.label }, ...result] : result;
return !context && group && group.label !== result[0]?.label
? [{ label: group.label }, ...result]
: result;
});
</script>
@@ -36,7 +44,8 @@
{#if c.href && i < trail.length - 1}
<a href={c.href}>{c.label}</a>
{:else}
<span aria-current={i === trail.length - 1 ? 'page' : undefined}>{c.label}</span>
<span aria-current={i === trail.length - 1 ? 'page' : undefined}>{c.label}</span
>
{/if}
{#if i < trail.length - 1}<span class="sep" aria-hidden="true">›</span>{/if}
</li>
@@ -69,8 +78,18 @@
text-decoration: none;
transition: color var(--dur-fast) var(--ease-standard);
}
.crumbs a:hover { color: var(--text-primary); }
.crumbs a:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); }
.crumbs [aria-current='page'] { color: var(--text-primary); }
.sep { color: var(--text-quaternary); }
.crumbs a:hover {
color: var(--text-primary);
}
.crumbs a:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
border-radius: var(--radius-sm);
}
.crumbs [aria-current='page'] {
color: var(--text-primary);
}
.sep {
color: var(--text-quaternary);
}
</style>
+41 -11
View File
@@ -47,7 +47,10 @@
href={disabled || loading ? undefined : href}
tabindex={disabled || loading ? -1 : undefined}
aria-busy={loading}
onclick={(event) => { if (disabled || loading) event.preventDefault(); else onclick?.(event); }}
onclick={(event) => {
if (disabled || loading) event.preventDefault();
else onclick?.(event);
}}
>
{#if leading}<span class="slot slot-leading">{@render leading()}</span>{/if}
<span class="label">{@render children?.()}</span>
@@ -98,19 +101,36 @@
user-select: none;
position: relative;
}
.btn:focus { outline: none; }
.btn:focus-visible { box-shadow: var(--focus-ring); }
.btn:focus {
outline: none;
}
.btn:focus-visible {
box-shadow: var(--focus-ring);
}
.btn:disabled,
.btn[aria-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}
.btn:active:not(:disabled):not([aria-disabled='true']) { transform: translateY(0.5px); }
.btn:active:not(:disabled):not([aria-disabled='true']) {
transform: translateY(0.5px);
}
/* Sizes */
.s-sm { min-height: 32px; padding: 6px 12px; font-size: var(--text-xs); border-radius: var(--radius-sm); }
.s-md { padding: 9px 16px; font-size: var(--text-sm); }
.s-lg { padding: 12px 22px; font-size: var(--text-base); }
.s-sm {
min-height: 32px;
padding: 6px 12px;
font-size: var(--text-xs);
border-radius: var(--radius-sm);
}
.s-md {
padding: 9px 16px;
font-size: var(--text-sm);
}
.s-lg {
padding: 12px 22px;
font-size: var(--text-base);
}
/* Variants */
.v-primary {
@@ -153,17 +173,27 @@
/* Loading */
.is-loading .label,
.is-loading .slot { opacity: 0; }
.is-loading .slot {
opacity: 0;
}
.spinner {
position: absolute;
width: 14px; height: 14px;
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid currentColor;
border-top-color: transparent;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 720px) {
.btn { white-space: normal; line-height: 1.35; }
.btn {
white-space: normal;
line-height: 1.35;
}
}
</style>
+32 -8
View File
@@ -48,7 +48,16 @@
{tabindex}
{style}
{onclick}
onkeydown={(event: KeyboardEvent) => { if (role === 'button' && event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); (event.currentTarget as HTMLElement).click(); } }}
onkeydown={(event: KeyboardEvent) => {
if (
role === 'button' &&
event.target === event.currentTarget &&
(event.key === 'Enter' || event.key === ' ')
) {
event.preventDefault();
(event.currentTarget as HTMLElement).click();
}
}}
>
{@render children?.()}
</svelte:element>
@@ -84,14 +93,26 @@
backends downgrade poorly, and we don't want blurred noise. */
/* Padding scale */
.p-none { padding: 0; }
.p-sm { padding: var(--space-4); }
.p-md { padding: var(--space-5); }
.p-lg { padding: var(--space-6); }
.p-none {
padding: 0;
}
.p-sm {
padding: var(--space-4);
}
.p-md {
padding: var(--space-5);
}
.p-lg {
padding: var(--space-6);
}
/* Elevation */
.e-flat { box-shadow: none; }
.e-raised { box-shadow: var(--shadow-raised); }
.e-flat {
box-shadow: none;
}
.e-raised {
box-shadow: var(--shadow-raised);
}
.e-interactive {
box-shadow: none;
@@ -106,6 +127,9 @@
box-shadow: var(--focus-ring), var(--shadow-raised);
}
@media (max-width: 480px) {
.p-md, .p-lg { padding: var(--space-4); }
.p-md,
.p-lg {
padding: var(--space-4);
}
}
</style>
+118 -17
View File
@@ -2,19 +2,39 @@
import { tick, type Snippet } from 'svelte';
import Button from './Button.svelte';
let { open = $bindable(false), title, description, busy = false, wide = false, drawer = false, children, actions }:
{ open?: boolean; title: string; description?: string; busy?: boolean; wide?: boolean; drawer?: boolean; children?: Snippet; actions?: Snippet } = $props();
let {
open = $bindable(false),
title,
description,
busy = false,
wide = false,
drawer = false,
children,
actions
}: {
open?: boolean;
title: string;
description?: string;
busy?: boolean;
wide?: boolean;
drawer?: boolean;
children?: Snippet;
actions?: Snippet;
} = $props();
const id = $props.id();
let dialog = $state<HTMLDialogElement>();
let returnFocus: HTMLElement | null = null;
$effect(() => {
if (!dialog) return;
if (open && !dialog.open) {
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
returnFocus =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
dialog.showModal();
} else if (!open && dialog.open) dialog.close();
});
function close() { if (!busy) open = false; }
function close() {
if (!busy) open = false;
}
async function closed() {
open = false;
await tick();
@@ -22,22 +42,103 @@
}
</script>
<dialog bind:this={dialog} class:wide class:drawer aria-labelledby={`${id}-title`} aria-describedby={description ? `${id}-description` : undefined}
oncancel={event => { if (busy) event.preventDefault(); }} onclose={closed}
onclick={event => { if (event.target === dialog) { const r = dialog!.getBoundingClientRect(); if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) close(); } }}>
<header><div><h2 id={`${id}-title`}>{title}</h2>{#if description}<p id={`${id}-description`}>{description}</p>{/if}</div><Button variant="ghost" ariaLabel="Close dialog" disabled={busy} onclick={close}>×</Button></header>
<dialog
bind:this={dialog}
class:wide
class:drawer
aria-labelledby={`${id}-title`}
aria-describedby={description ? `${id}-description` : undefined}
oncancel={(event) => {
if (busy) event.preventDefault();
}}
onclose={closed}
onclick={(event) => {
if (event.target === dialog) {
const r = dialog!.getBoundingClientRect();
if (
event.clientX < r.left ||
event.clientX > r.right ||
event.clientY < r.top ||
event.clientY > r.bottom
)
close();
}
}}
>
<header>
<div>
<h2 id={`${id}-title`}>{title}</h2>
{#if description}<p id={`${id}-description`}>{description}</p>{/if}
</div>
<Button variant="ghost" ariaLabel="Close dialog" disabled={busy} onclick={close}>×</Button>
</header>
<div class="body">{@render children?.()}</div>
{#if actions}<footer>{@render actions()}</footer>{/if}
</dialog>
<style>
dialog { width: min(560px, calc(100vw - 32px)); padding: 0; }
dialog.wide { width: min(880px, calc(100vw - 32px)); }
dialog.drawer { position:fixed; inset:0 0 0 auto; margin:0; width:min(560px, 100vw); height:100dvh; max-height:100dvh; max-width:100vw; border-radius:0; border:0; border-left:1px solid var(--border-subtle); }
header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); padding: var(--space-6); border-bottom: 1px solid var(--border-subtle); }
h2 { margin: 0; font-size: var(--text-xl); line-height: var(--leading-tight); }
p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
.body { padding: var(--space-6); }
footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: var(--space-3); padding: var(--space-4) var(--space-6); border-top: 1px solid var(--border-subtle); background: var(--surface); position: sticky; bottom: 0; z-index: 1; }
@media (max-width: 600px) { header, .body { padding: var(--space-4); } footer { padding: var(--space-4); } h2 { font-size: var(--text-lg); } }
dialog {
width: min(560px, calc(100vw - 32px));
padding: 0;
}
dialog.wide {
width: min(880px, calc(100vw - 32px));
}
dialog.drawer {
position: fixed;
inset: 0 0 0 auto;
margin: 0;
width: min(560px, 100vw);
height: 100dvh;
max-height: 100dvh;
max-width: 100vw;
border-radius: 0;
border: 0;
border-left: 1px solid var(--border-subtle);
}
header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-6);
border-bottom: 1px solid var(--border-subtle);
}
h2 {
margin: 0;
font-size: var(--text-xl);
line-height: var(--leading-tight);
}
p {
margin: var(--space-2) 0 0;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.body {
padding: var(--space-6);
}
footer {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-3);
padding: var(--space-4) var(--space-6);
border-top: 1px solid var(--border-subtle);
background: var(--surface);
position: sticky;
bottom: 0;
z-index: 1;
}
@media (max-width: 600px) {
header,
.body {
padding: var(--space-4);
}
footer {
padding: var(--space-4);
}
h2 {
font-size: var(--text-lg);
}
}
</style>
@@ -10,7 +10,14 @@
headingLevel?: 2 | 3 | 4;
}
let { title, description = null, icon, actions, spacious = false, headingLevel = 2 }: Props = $props();
let {
title,
description = null,
icon,
actions,
spacious = false,
headingLevel = 2
}: Props = $props();
</script>
<div class="empty" class:spacious>
@@ -34,7 +41,9 @@
border-radius: var(--radius-lg);
background: color-mix(in oklab, var(--surface) 60%, transparent);
}
.spacious { padding: var(--space-12) var(--space-6); }
.spacious {
padding: var(--space-12) var(--space-6);
}
.icon {
color: var(--text-tertiary);
margin-bottom: var(--space-2);
+26 -8
View File
@@ -14,15 +14,23 @@
let { label, hint = null, error = null, required = false, forId, children }: Props = $props();
const id = $props.id();
setContext<FieldContext>(fieldContext, {
get labelId() { return `${id}-label`; },
get messageId() { return error || hint ? `${id}-message` : undefined; },
get invalid() { return !!error; }
get labelId() {
return `${id}-label`;
},
get messageId() {
return error || hint ? `${id}-message` : undefined;
},
get invalid() {
return !!error;
}
});
function connectLabel(node: HTMLDivElement) {
const connect = () => {
const label = node.querySelector<HTMLLabelElement>('label.label');
const control = node.querySelector<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>('input:not([type="hidden"]), select, textarea');
const control = node.querySelector<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>('input:not([type="hidden"]), select, textarea');
if (!label || !control || forId) return;
if (!control.id) control.id = `${id}-control`;
label.htmlFor = control.id;
@@ -63,11 +71,21 @@
font-weight: var(--weight-semibold);
color: var(--text-secondary);
}
.req { color: var(--state-danger); margin-left: 3px; }
.control { display: flex; flex-direction: column; }
.req {
color: var(--state-danger);
margin-left: 3px;
}
.control {
display: flex;
flex-direction: column;
}
.msg {
font-size: var(--text-xs);
}
.msg-hint { color: var(--text-tertiary); }
.msg-error { color: var(--state-danger); }
.msg-hint {
color: var(--text-tertiary);
}
.msg-error {
color: var(--state-danger);
}
</style>
@@ -74,10 +74,16 @@
background-color var(--dur-fast) var(--ease-standard),
box-shadow var(--dur-fast) var(--ease-standard);
}
.input.mono { font-family: var(--font-mono); }
.input::placeholder { color: var(--text-quaternary); }
.input.mono {
font-family: var(--font-mono);
}
.input::placeholder {
color: var(--text-quaternary);
}
.input:hover { border-color: var(--border-control-hover); }
.input:hover {
border-color: var(--border-control-hover);
}
.input:focus {
outline: none;
border-color: var(--accent-primary);
@@ -1,8 +1,41 @@
<script lang="ts">
import Button from './Button.svelte';
let { total, limit = $bindable(25), step = 25, label = 'items', onchange }: { total: number; limit?: number; step?: number; label?: string; onchange?: (limit: number) => void } = $props();
let {
total,
limit = $bindable(25),
step = 25,
label = 'items',
onchange
}: {
total: number;
limit?: number;
step?: number;
label?: string;
onchange?: (limit: number) => void;
} = $props();
</script>
{#if total > step}
<div class="list-footer"><span role="status">Showing {Math.min(limit, total)} of {total} {label}</span>{#if limit < total}<Button size="sm" onclick={() => { limit += step; onchange?.(limit); }}>Show {Math.min(step, total - limit)} more</Button>{/if}</div>
<div class="list-footer">
<span role="status">Showing {Math.min(limit, total)} of {total} {label}</span
>{#if limit < total}<Button
size="sm"
onclick={() => {
limit += step;
onchange?.(limit);
}}>Show {Math.min(step, total - limit)} more</Button
>{/if}
</div>
{/if}
<style>.list-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding-block: var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); }</style>
<style>
.list-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding-block: var(--space-3);
color: var(--text-secondary);
font-size: var(--text-sm);
}
</style>
@@ -10,14 +10,7 @@
actions?: Snippet;
}
let {
title,
subtitle = null,
eyebrow = null,
size = 'md',
crumbs,
actions
}: Props = $props();
let { title, subtitle = null, eyebrow = null, size = 'md', crumbs, actions }: Props = $props();
</script>
<header class="section-header s-{size}">
@@ -49,7 +42,12 @@
justify-content: space-between;
gap: var(--space-6);
}
.titles { display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; }
.titles {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.eyebrow {
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
@@ -65,8 +63,12 @@
letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
}
.s-md .title { font-size: var(--text-2xl); }
.s-lg .title { font-size: var(--text-3xl); }
.s-md .title {
font-size: var(--text-2xl);
}
.s-lg .title {
font-size: var(--text-3xl);
}
.subtitle {
margin: 0;
font-size: var(--text-sm);
@@ -81,9 +83,19 @@
}
@media (max-width: 640px) {
.s-lg .title { font-size: var(--text-2xl); }
.s-md .title { font-size: var(--text-xl); }
.row { flex-direction: column; align-items: flex-start; gap: var(--space-4); }
.actions { width: 100%; }
.s-lg .title {
font-size: var(--text-2xl);
}
.s-md .title {
font-size: var(--text-xl);
}
.row {
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
}
.actions {
width: 100%;
}
}
</style>
@@ -81,14 +81,19 @@
background-color var(--dur-fast) var(--ease-standard),
box-shadow var(--dur-fast) var(--ease-standard);
}
.select:hover { border-color: var(--border-control-hover); }
.select:hover {
border-color: var(--border-control-hover);
}
.select:focus {
outline: none;
border-color: var(--accent-primary);
background: var(--surface);
box-shadow: 0 0 0 3px rgba(46, 134, 209, 0.15);
}
.select:disabled { opacity: 0.5; cursor: not-allowed; }
.select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chev {
position: absolute;
right: var(--space-3);
@@ -6,12 +6,7 @@
block?: boolean;
}
let {
width = '100%',
height = '1em',
radius = 'sm',
block = false
}: Props = $props();
let { width = '100%', height = '1em', radius = 'sm', block = false }: Props = $props();
let style = $derived(`width:${width};height:${height};`);
</script>
@@ -21,19 +16,28 @@
<style>
.sk {
display: inline-block;
background:
linear-gradient(
90deg,
var(--surface-raised) 0%,
var(--surface-overlay) 50%,
var(--surface-raised) 100%
);
background: linear-gradient(
90deg,
var(--surface-raised) 0%,
var(--surface-overlay) 50%,
var(--surface-raised) 100%
);
background-size: 200% 100%;
animation: shimmer 1.6s var(--ease-linear) infinite;
}
.block { display: block; }
.r-sm { border-radius: var(--radius-sm); }
.r-md { border-radius: var(--radius-md); }
.r-lg { border-radius: var(--radius-lg); }
.r-pill { border-radius: var(--radius-pill); }
.block {
display: block;
}
.r-sm {
border-radius: var(--radius-sm);
}
.r-md {
border-radius: var(--radius-md);
}
.r-lg {
border-radius: var(--radius-lg);
}
.r-pill {
border-radius: var(--radius-pill);
}
</style>
+30 -10
View File
@@ -87,16 +87,36 @@
color: var(--text-tertiary);
font-weight: var(--weight-medium);
}
.trailing { display: inline-flex; }
.trailing {
display: inline-flex;
}
.s-sm .value { font-size: var(--text-md); }
.s-md .value { font-size: var(--text-xl); }
.s-lg .value { font-size: var(--text-2xl); }
.s-xl .value { font-size: var(--text-3xl); }
.s-sm .value {
font-size: var(--text-md);
}
.s-md .value {
font-size: var(--text-xl);
}
.s-lg .value {
font-size: var(--text-2xl);
}
.s-xl .value {
font-size: var(--text-3xl);
}
.t-primary .value { color: var(--text-primary); }
.t-accent .value { color: var(--accent-primary); }
.t-online .value { color: var(--state-online); }
.t-warning .value { color: var(--state-warning); }
.t-danger .value { color: var(--state-danger); }
.t-primary .value {
color: var(--text-primary);
}
.t-accent .value {
color: var(--accent-primary);
}
.t-online .value {
color: var(--state-online);
}
.t-warning .value {
color: var(--state-warning);
}
.t-danger .value {
color: var(--state-danger);
}
</style>
@@ -5,4 +5,5 @@
let { state, size = 'sm' }: { state: string; size?: 'sm' | 'md' } = $props();
const status = $derived(statusPresentation(state));
</script>
<Badge variant={status.tone} {size}><StatusPip {state} size="sm" />{status.label}</Badge>
@@ -16,7 +16,12 @@
const shape = $derived(statusShapes[resolvedTone]);
</script>
<span class="pip s-{size} t-{resolvedTone} shape-{shape}" role={label ? 'img' : undefined} aria-label={label ?? undefined} aria-hidden={label ? undefined : true}></span>
<span
class="pip s-{size} t-{resolvedTone} shape-{shape}"
role={label ? 'img' : undefined}
aria-label={label ?? undefined}
aria-hidden={label ? undefined : true}
></span>
<style>
.pip {
@@ -24,17 +29,53 @@
flex-shrink: 0;
box-sizing: border-box;
}
.s-sm { width: 10px; height: 10px; }
.s-md { width: 12px; height: 12px; }
.t-online { color: var(--state-online); }
.t-warning { color: var(--state-warning); }
.t-danger { color: var(--state-danger); }
.t-info { color: var(--state-info); }
.t-neutral { color: var(--state-neutral); }
.shape-circle { border-radius: 50%; background: currentColor; }
.shape-square { border-radius: 2px; background: currentColor; }
.shape-triangle { background: currentColor; clip-path: polygon(50% 0, 100% 100%, 0 100%); }
.shape-hollow, .shape-ring { border: 2px solid currentColor; border-radius: 50%; }
.shape-ring { border-right-color: transparent; animation: status-spin 1s linear infinite; }
@keyframes status-spin { to { transform: rotate(360deg); } }
.s-sm {
width: 10px;
height: 10px;
}
.s-md {
width: 12px;
height: 12px;
}
.t-online {
color: var(--state-online);
}
.t-warning {
color: var(--state-warning);
}
.t-danger {
color: var(--state-danger);
}
.t-info {
color: var(--state-info);
}
.t-neutral {
color: var(--state-neutral);
}
.shape-circle {
border-radius: 50%;
background: currentColor;
}
.shape-square {
border-radius: 2px;
background: currentColor;
}
.shape-triangle {
background: currentColor;
clip-path: polygon(50% 0, 100% 100%, 0 100%);
}
.shape-hollow,
.shape-ring {
border: 2px solid currentColor;
border-radius: 50%;
}
.shape-ring {
border-right-color: transparent;
animation: status-spin 1s linear infinite;
}
@keyframes status-spin {
to {
transform: rotate(360deg);
}
}
</style>
+47 -14
View File
@@ -18,7 +18,15 @@
}
const uid = $props.id();
let { tabs, value = $bindable(), onchange, ariaLabel, size = 'md', panelId, idPrefix = uid }: Props = $props();
let {
tabs,
value = $bindable(),
onchange,
ariaLabel,
size = 'md',
panelId,
idPrefix = uid
}: Props = $props();
let tablist = $state<HTMLDivElement | null>(null);
// Keep a deep-linked or keyboard-selected tab visible without scrolling the page.
@@ -36,24 +44,33 @@
const bounds = node.getBoundingClientRect();
const target = tab.getBoundingClientRect();
if (target.left < bounds.left + 4) node.scrollLeft -= bounds.left + 4 - target.left;
else if (target.right > bounds.right - 4) node.scrollLeft += target.right - bounds.right + 4;
else if (target.right > bounds.right - 4)
node.scrollLeft += target.right - bounds.right + 4;
};
reveal();
observer = new ResizeObserver(reveal);
observer.observe(node);
observer.observe(tab);
});
return () => { cancelled = true; observer?.disconnect(); };
return () => {
cancelled = true;
observer?.disconnect();
};
});
function onKey(event: KeyboardEvent) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const enabled = tabs.filter(t => !t.disabled);
const enabled = tabs.filter((t) => !t.disabled);
if (!enabled.length) return;
const index = enabled.findIndex(t => t.id === value);
const next = event.key === 'Home' ? 0 : event.key === 'End' ? enabled.length - 1
: (index + (event.key === 'ArrowRight' ? 1 : -1) + enabled.length) % enabled.length;
const index = enabled.findIndex((t) => t.id === value);
const next =
event.key === 'Home'
? 0
: event.key === 'End'
? enabled.length - 1
: (index + (event.key === 'ArrowRight' ? 1 : -1) + enabled.length) %
enabled.length;
select(enabled[next].id, false);
document.getElementById(`${idPrefix}-${enabled[next].id}`)?.focus();
}
@@ -119,12 +136,26 @@
color var(--dur-fast) var(--ease-standard),
background-color var(--dur-fast) var(--ease-standard);
}
.s-sm .tab { padding: 6px 12px; font-size: var(--text-xs); }
.s-md .tab { padding: 8px 16px; font-size: var(--text-sm); }
.s-sm .tab {
padding: 6px 12px;
font-size: var(--text-xs);
}
.s-md .tab {
padding: 8px 16px;
font-size: var(--text-sm);
}
.tab:hover:not(:disabled):not(.active) { color: var(--text-primary); }
.tab:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.tab:disabled { opacity: 0.4; cursor: not-allowed; }
.tab:hover:not(:disabled):not(.active) {
color: var(--text-primary);
}
.tab:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.tab:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.tab.active {
background: var(--surface-raised);
@@ -144,9 +175,11 @@
color: var(--text-secondary);
font-family: var(--font-numeric);
font-size: var(--text-xs);
font-variant-numeric: tabular-nums; }
font-variant-numeric: tabular-nums;
}
.tab.active .badge {
background: var(--accent-primary);
color: var(--text-on-accent);
font-variant-numeric: tabular-nums; }
font-variant-numeric: tabular-nums;
}
</style>
@@ -66,14 +66,25 @@
background-color var(--dur-fast) var(--ease-standard),
box-shadow var(--dur-fast) var(--ease-standard);
}
.textarea.mono { font-family: var(--font-mono); font-size: var(--text-xs); line-height: var(--leading-mono); }
.textarea::placeholder { color: var(--text-quaternary); }
.textarea:hover { border-color: var(--border-control-hover); }
.textarea.mono {
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: var(--leading-mono);
}
.textarea::placeholder {
color: var(--text-quaternary);
}
.textarea:hover {
border-color: var(--border-control-hover);
}
.textarea:focus {
outline: none;
border-color: var(--accent-primary);
background: var(--surface);
box-shadow: 0 0 0 3px rgba(46, 134, 209, 0.15);
}
.textarea:disabled { opacity: 0.5; cursor: not-allowed; }
.textarea:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
+1 -1
View File
@@ -12,7 +12,7 @@ let resolvePending: ((accepted: boolean) => void) | null = null;
/** Resolve only from a deliberate confirm/cancel action; never default to acceptance. */
export function confirmAction(options: Confirmation): Promise<boolean> {
resolvePending?.(false);
return new Promise(resolve => {
return new Promise((resolve) => {
resolvePending = resolve;
confirmation.set(options);
});
+10 -2
View File
@@ -1,6 +1,14 @@
/** Native dialogs also receive clicks in their padding; only clicks beyond the bounds dismiss. */
export function isDialogBackdrop(event: MouseEvent, dialog: HTMLDialogElement | null | undefined): boolean {
export function isDialogBackdrop(
event: MouseEvent,
dialog: HTMLDialogElement | null | undefined
): boolean {
if (!dialog || event.target !== dialog) return false;
const rect = dialog.getBoundingClientRect();
return event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom;
return (
event.clientX < rect.left ||
event.clientX > rect.right ||
event.clientY < rect.top ||
event.clientY > rect.bottom
);
}
+4 -2
View File
@@ -8,8 +8,10 @@ export function formatTimeOffset(start: Timestamp, value: Timestamp): string | n
const difference = Date.parse(valueISO) - Date.parse(startISO);
const seconds = Math.floor(Math.abs(difference) / 1000);
const minutes = Math.floor(seconds / 60);
const clock = minutes < 60 ? `${minutes}:${String(seconds % 60).padStart(2, '0')}`
: `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
const clock =
minutes < 60
? `${minutes}:${String(seconds % 60).padStart(2, '0')}`
: `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
return `${difference < 0 && seconds > 0 ? '−' : '+'}${clock}`;
}
+32 -8
View File
@@ -4,30 +4,54 @@ import { applicableRuntimeActions } from './lifecycle-actions.js';
export const fleetActions = ['restart', 'start', 'stop', 'rebuild', 'destroy'] as const;
export type FleetAction = RuntimeAction | 'destroy';
export interface FleetActionFailure { server: string; error: string }
export interface FleetActionFailure {
server: string;
error: string;
}
export interface FleetActionResult {
queued: { server: string; deploymentId: string }[];
failed: FleetActionFailure[];
}
export function fleetActionAvailable(server: FleetServer, action: FleetAction, now: number): boolean {
export function fleetActionAvailable(
server: FleetServer,
action: FleetAction,
now: number
): boolean {
return action === 'destroy' || applicableRuntimeActions(server, now).includes(action);
}
/** Submit only the reviewed selection. Each server keeps its own durable job and result. */
export async function queueFleetAction(action: FleetAction, servers: readonly string[], request: typeof fetch = fetch): Promise<FleetActionResult> {
export async function queueFleetAction(
action: FleetAction,
servers: readonly string[],
request: typeof fetch = fetch
): Promise<FleetActionResult> {
const result: FleetActionResult = { queued: [], failed: [] };
for (const server of new Set(servers)) {
try {
const response = action === 'destroy'
? await request(`/api/servers/${encodeURIComponent(server)}`, { method: 'DELETE' })
: await request('/api/provisioning', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server, action }) });
const response =
action === 'destroy'
? await request(`/api/servers/${encodeURIComponent(server)}`, {
method: 'DELETE'
})
: await request('/api/provisioning', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server, action })
});
const body = await response.json();
if (!response.ok || !body.deploymentId) {
result.failed.push({ server, error: body.error ?? 'Could not queue this operation.' });
result.failed.push({
server,
error: body.error ?? 'Could not queue this operation.'
});
} else result.queued.push({ server, deploymentId: body.deploymentId });
} catch {
result.failed.push({ server, error: 'Could not confirm whether the request was queued. Check Provisioning before trying again.' });
result.failed.push({
server,
error: 'Could not confirm whether the request was queued. Check Provisioning before trying again.'
});
}
}
return result;
+32 -8
View File
@@ -9,25 +9,49 @@ const nonnegative = (value: number | null | undefined): number | null =>
export function gameFileTransferProgress(cache: HostLibraryCache, connected: boolean, now: number) {
const active = cache.status === 'downloading' || cache.status === 'verifying';
const complete = cache.status === 'ready' || cache.status === 'outdated';
const phase = active ? cache.status as 'downloading' | 'verifying' : cache.transfer?.phase ?? null;
const phase = active
? (cache.status as 'downloading' | 'verifying')
: (cache.transfer?.phase ?? null);
const total = nonnegative(cache.bytesTotal) ?? 0;
const downloaded = Math.min(total, nonnegative(cache.bytesDownloaded) ?? 0);
const verified = complete ? total : nonnegative(cache.bytesVerified);
const phaseBytes = phase === 'verifying' ? verified === null ? null : Math.min(total, verified) : downloaded;
const phaseBytes =
phase === 'verifying' ? (verified === null ? null : Math.min(total, verified)) : downloaded;
const transfer = cache.transfer;
const samePhase = transfer?.phase === phase;
const reportedAt = Date.parse(cache.updatedAt);
const reportAge = Number.isFinite(reportedAt) && Number.isFinite(now) ? Math.max(0, now - reportedAt) : null;
const stale = active && !!transfer && (reportAge === null || reportAge > TRANSFER_REPORT_FRESH_MS);
const reportAge =
Number.isFinite(reportedAt) && Number.isFinite(now) ? Math.max(0, now - reportedAt) : null;
const stale =
active && !!transfer && (reportAge === null || reportAge > TRANSFER_REPORT_FRESH_MS);
const live = active && connected && samePhase && !stale && reportAge !== null;
const elapsed = nonnegative(transfer?.elapsedSeconds);
// Stop extrapolating when reports stop. A disconnected or finished host uses its saved duration.
const elapsedSeconds = elapsed === null ? null : elapsed + (active && connected && samePhase && reportAge !== null
? Math.min(reportAge, TRANSFER_REPORT_FRESH_MS) / 1000 : 0);
const elapsedSeconds =
elapsed === null
? null
: elapsed +
(active && connected && samePhase && reportAge !== null
? Math.min(reportAge, TRANSFER_REPORT_FRESH_MS) / 1000
: 0);
const rate = nonnegative(transfer?.bytesPerSecond);
const estimate = nonnegative(transfer?.etaSeconds);
const hasProgress = phaseBytes !== null && phaseBytes > 0 && phaseBytes < total;
const bytesPerSecond = live && hasProgress && rate !== null && rate > 0 ? rate : null;
const etaSeconds = bytesPerSecond !== null && estimate !== null && estimate > 0 ? estimate : null;
return { active, complete, phase, total, downloaded, verified, phaseBytes, elapsedSeconds, bytesPerSecond, etaSeconds, stale, live };
const etaSeconds =
bytesPerSecond !== null && estimate !== null && estimate > 0 ? estimate : null;
return {
active,
complete,
phase,
total,
downloaded,
verified,
phaseBytes,
elapsedSeconds,
bytesPerSecond,
etaSeconds,
stale,
live
};
}

Some files were not shown because too many files have changed in this diff Show More