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
+721 -72
View File
@@ -21,27 +21,64 @@
let cancellationAccepted = $state(false);
let requestNumber = 0;
let collection = $derived(detail?.collection);
const launching = (server: { state: string; server_deleted_at?: string | null }) => !server.server_deleted_at && ['running', 'awaiting-health'].includes(server.state);
const needsAttention = (server: { state: string }) => ['failed', 'interrupted', 'warning'].includes(server.state);
const cleanupNeedsAttention = (cancellation: NonNullable<CollectionServer['cancellation']>) => needsAttention(cancellation) || cancellation.state === 'cancelled';
const canOperate = $derived($page.data.user?.role === 'admin' || $page.data.user?.role === 'operator');
const launching = (server: { state: string; server_deleted_at?: string | null }) =>
!server.server_deleted_at && ['running', 'awaiting-health'].includes(server.state);
const needsAttention = (server: { state: string }) =>
['failed', 'interrupted', 'warning'].includes(server.state);
const cleanupNeedsAttention = (cancellation: NonNullable<CollectionServer['cancellation']>) =>
needsAttention(cancellation) || cancellation.state === 'cancelled';
const canOperate = $derived(
$page.data.user?.role === 'admin' || $page.data.user?.role === 'operator'
);
const cancellationRequested = $derived(!!collection?.cancelled_at || cancellationAccepted);
const inProgress = $derived(!!collection && (collection.active > 0 || collection.cleanupActive > 0));
const inProgress = $derived(
!!collection && (collection.active > 0 || collection.cleanupActive > 0)
);
const exact = $derived(collection?.mode === 'exact');
const currentServers = $derived(detail?.servers.filter(launching) ?? []);
const heading = $derived.by(() => {
if (!collection) return 'Collection launch';
if (cancellationRequested) return inProgress ? 'Cancellation requested' : collection.failed || collection.cleanupFailed ? 'Cancellation needs attention' : 'Collection launch cancelled';
if (cancellationRequested)
return inProgress
? 'Cancellation requested'
: collection.failed || collection.cleanupFailed
? 'Cancellation needs attention'
: 'Collection launch cancelled';
if (collection.cleanupActive) return 'Cleaning up cancelled launches';
if (collection.active) return exact ? 'Applying your collection' : currentServers.length ? 'Bringing your lineup online' : 'Your lineup is queued';
if (collection.failed || collection.cleanupFailed) return exact ? 'Collection finished with issues' : 'Launch complete with issues';
if (collection.active)
return exact
? 'Applying your collection'
: currentServers.length
? 'Bringing your lineup online'
: 'Your lineup is queued';
if (collection.failed || collection.cleanupFailed)
return exact ? 'Collection finished with issues' : 'Launch complete with issues';
if (collection.cancelled) return 'Collection finished with cancellations';
return exact ? 'Collection applied' : 'Your lineup is ready';
});
const otherLaunches = $derived(detail?.queue.active.filter(active => !detail?.servers.some(server => server.id === active.id)) ?? []);
const firstQueued = $derived(detail?.servers.find(server => !server.server_deleted_at && server.state === 'queued'));
const labels: Record<string, string> = { queued: 'Queued', running: 'Launching', 'awaiting-health': 'Checking readiness', done: 'Ready', warning: 'Needs review', failed: 'Failed', interrupted: 'Interrupted', waiting: 'Waiting', cancelled: 'Cancelled' };
const waitingForPort = (server: CollectionServer) => server.state === 'queued' && (server.stage.startsWith('Waiting for removal') || server.stage.startsWith('Waiting for port'));
const otherLaunches = $derived(
detail?.queue.active.filter(
(active) => !detail?.servers.some((server) => server.id === active.id)
) ?? []
);
const firstQueued = $derived(
detail?.servers.find((server) => !server.server_deleted_at && server.state === 'queued')
);
const labels: Record<string, string> = {
queued: 'Queued',
running: 'Launching',
'awaiting-health': 'Checking readiness',
done: 'Ready',
warning: 'Needs review',
failed: 'Failed',
interrupted: 'Interrupted',
waiting: 'Waiting',
cancelled: 'Cancelled'
};
const waitingForPort = (server: CollectionServer) =>
server.state === 'queued' &&
(server.stage.startsWith('Waiting for removal') ||
server.stage.startsWith('Waiting for port'));
function stateLabel(server: CollectionServer) {
if (waitingForPort(server)) return 'Waiting for removal';
if (server.action === 'destroy' && server.state === 'done') return 'Removed';
@@ -52,7 +89,8 @@
function queueHint(server: CollectionServer) {
const reason = server.stage || 'Waiting for a startup slot and available host capacity.';
return server.queuePosition && server.queuePosition > 1
? `${reason} ${server.queuePosition - 1} queued server${server.queuePosition === 2 ? '' : 's'} ahead.` : reason;
? `${reason} ${server.queuePosition - 1} queued server${server.queuePosition === 2 ? '' : 's'} ahead.`
: reason;
}
async function load() {
@@ -62,15 +100,22 @@
const res = await fetch(`/api/deployment-collections/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error('Could not refresh this collection.');
const body = await res.json();
if (current === requestNumber && id === $page.params.id) { detail = body; error = ''; }
} catch (err) { if (current === requestNumber) error = (err as Error).message; }
if (current === requestNumber && id === $page.params.id) {
detail = body;
error = '';
}
} catch (err) {
if (current === requestNumber) error = (err as Error).message;
}
}
async function cancelLaunch() {
if (!collection || !detail?.canCancel || cancellationRequested || cancelling || !canOperate) return;
if (!collection || !detail?.canCancel || cancellationRequested || cancelling || !canOperate)
return;
const id = collection.id;
const accepted = await confirmAction({
title: 'Cancel collection launch?',
message: 'Stops unfinished server creations, including eligible failed or interrupted launches, and deletes their containers and instance files. This can disconnect active players, even when player status is unknown. Shared game files are kept. Keys and ports are released only after removal is confirmed.\n\nCompleted launches and retained servers are kept. Pending removals are cancelled where safe; removals that have already started or are shared with another collection may still finish.',
message:
'Stops unfinished server creations, including eligible failed or interrupted launches, and deletes their containers and instance files. This can disconnect active players, even when player status is unknown. Shared game files are kept. Keys and ports are released only after removal is confirmed.\n\nCompleted launches and retained servers are kept. Pending removals are cancelled where safe; removals that have already started or are shared with another collection may still finish.',
confirmLabel: 'Cancel launch and clean up',
danger: true
});
@@ -78,7 +123,10 @@
cancelling = true;
cancelError = '';
try {
const res = await fetch(`/api/deployment-collections/${encodeURIComponent(id)}/cancel`, { method: 'POST' });
const res = await fetch(
`/api/deployment-collections/${encodeURIComponent(id)}/cancel`,
{ method: 'POST' }
);
const body = await res.json();
if (!res.ok) throw new Error(body.error || 'Could not cancel this collection launch.');
if (id !== $page.params.id) return;
@@ -89,48 +137,139 @@
cancelError = (err as Error).message;
await load();
}
} finally { if (id === $page.params.id) cancelling = false; }
} finally {
if (id === $page.params.id) cancelling = false;
}
}
$effect(() => { $page.params.id; detail = null; cancelError = ''; cancelling = false; cancellationAccepted = false; void load(); });
onMount(() => { const stop = pollVisible(load, 5000, false); return () => { stop(); requestNumber++; }; });
$effect(() => {
void $page.params.id;
detail = null;
cancelError = '';
cancelling = false;
cancellationAccepted = false;
void load();
});
onMount(() => {
const stop = pollVisible(load, 5000, false);
return () => {
stop();
requestNumber++;
};
});
</script>
<svelte:head><title>{collection?.name ?? 'Collection launch'} | Electric Cherry</title></svelte:head>
<svelte:head><title>{collection?.name ?? 'Collection launch'} | Electric Cherry</title></svelte:head
>
<div class="page">
<SectionHeader title={collection?.name ?? 'Collection launch'}>
{#snippet crumbs()}<Breadcrumbs fallback="/collections?view=active" items={[{ label: 'Collections', href: '/collections' }, { label: 'Collection launch' }]} />{/snippet}
{#snippet crumbs()}<Breadcrumbs
fallback="/collections?view=active"
items={[
{ label: 'Collections', href: '/collections' },
{ label: 'Collection launch' }
]}
/>{/snippet}
</SectionHeader>
{#if error}<p class="error" role="alert">{error} <button onclick={load}>Retry</button></p>{/if}
{#if collection && detail}
<section class="summary" aria-label="Collection progress">
<div class="summary-heading">
<div><span class="eyebrow">Collection progress</span><h2>{heading}</h2></div>
<span class="completion"><strong>{collection.done + collection.failed + collection.cancelled}</strong> / {collection.total} finished</span>
<div>
<span class="eyebrow">Collection progress</span>
<h2>{heading}</h2>
</div>
<span class="completion"
><strong>{collection.done + collection.failed + collection.cancelled}</strong> / {collection.total}
finished</span
>
</div>
<div class="progress" role="progressbar" aria-label="Finished fleet operations" aria-valuemin={0} aria-valuemax={collection.total || 1} aria-valuenow={collection.done + collection.failed + collection.cancelled} aria-valuetext={`${collection.done} completed, ${collection.failed} ${plural(collection.failed, 'needs', 'need')} attention, ${collection.cancelled} cancelled, ${collection.active} remaining, ${collection.cleanupActive} cleaning up`}>
<span class="progress-ready" style:width={`${collection.done / (collection.total || 1) * 100}%`}></span>
<span class="progress-failed" style:width={`${collection.failed / (collection.total || 1) * 100}%`}></span>
<span class="progress-cancelled" style:width={`${collection.cancelled / (collection.total || 1) * 100}%`}></span>
<div
class="progress"
role="progressbar"
aria-label="Finished fleet operations"
aria-valuemin={0}
aria-valuemax={collection.total || 1}
aria-valuenow={collection.done + collection.failed + collection.cancelled}
aria-valuetext={`${collection.done} completed, ${collection.failed} ${plural(collection.failed, 'needs', 'need')} attention, ${collection.cancelled} cancelled, ${collection.active} remaining, ${collection.cleanupActive} cleaning up`}
>
<span
class="progress-ready"
style:width={`${(collection.done / (collection.total || 1)) * 100}%`}
></span>
<span
class="progress-failed"
style:width={`${(collection.failed / (collection.total || 1)) * 100}%`}
></span>
<span
class="progress-cancelled"
style:width={`${(collection.cancelled / (collection.total || 1)) * 100}%`}
></span>
</div>
<dl class="counts" class:has-cancellations={cancellationRequested || collection.cancelled > 0}>
<div class="ready-count"><dt>{exact ? 'Completed' : 'Ready'}</dt><dd>{collection.done}</dd></div>
<div><dt>{exact ? 'In progress' : 'Launching'}</dt><dd>{collection.provisioning + collection.verifying}</dd></div>
<div><dt>Queued</dt><dd>{collection.queued}</dd></div>
<div class:attention-count={collection.failed > 0}><dt>Need attention</dt><dd>{collection.failed}</dd></div>
{#if cancellationRequested || collection.cancelled > 0}<div><dt>Cancelled</dt><dd>{collection.cancelled}</dd></div>{/if}
<dl
class="counts"
class:has-cancellations={cancellationRequested || collection.cancelled > 0}
>
<div class="ready-count">
<dt>{exact ? 'Completed' : 'Ready'}</dt>
<dd>{collection.done}</dd>
</div>
<div>
<dt>{exact ? 'In progress' : 'Launching'}</dt>
<dd>{collection.provisioning + collection.verifying}</dd>
</div>
<div>
<dt>Queued</dt>
<dd>{collection.queued}</dd>
</div>
<div class:attention-count={collection.failed > 0}>
<dt>Need attention</dt>
<dd>{collection.failed}</dd>
</div>
{#if cancellationRequested || collection.cancelled > 0}<div>
<dt>Cancelled</dt>
<dd>{collection.cancelled}</dd>
</div>{/if}
</dl>
<p class="screen-reader" role="status">{collection.done} completed, {collection.provisioning + collection.verifying} in progress, {collection.queued} queued, {collection.failed} {plural(collection.failed, 'needs', 'need')} attention, {collection.cancelled} cancelled, {collection.cleanupActive} cleaning up, {collection.cleanupFailed} {plural(collection.cleanupFailed, 'cleanup')} {plural(collection.cleanupFailed, 'needs', 'need')} attention.</p>
<p class="screen-reader" role="status">
{collection.done} completed, {collection.provisioning + collection.verifying} in progress,
{collection.queued} queued, {collection.failed}
{plural(collection.failed, 'needs', 'need')} attention, {collection.cancelled} cancelled,
{collection.cleanupActive} cleaning up, {collection.cleanupFailed}
{plural(collection.cleanupFailed, 'cleanup')}
{plural(collection.cleanupFailed, 'needs', 'need')} attention.
</p>
{#if cancellationRequested}
<p class="cancellation-note">Completed launches and retained servers are kept. Removals that already started or are shared with another collection may still finish. Follow each cancelled server's cleanup below until removal is confirmed.</p>
<p class="cancellation-note">
Completed launches and retained servers are kept. Removals that already started
or are shared with another collection may still finish. Follow each cancelled
server's cleanup below until removal is confirmed.
</p>
{/if}
{#if collection.cleanupActive || collection.cleanupFailed}
<div class="cleanup-summary" role="status">
{#if collection.cleanupActive}<p>{collection.cleanupActive} server cleanup{collection.cleanupActive === 1 ? '' : 's'} in progress. Keys and ports remain reserved until removal is confirmed.</p>{/if}
{#if collection.cleanupFailed}<p class="error">{collection.cleanupFailed} cleanup{collection.cleanupFailed === 1 ? ' needs' : 's need'} attention. Open the cleanup operation below to review and recover.</p>{/if}
{#if collection.cleanupActive}<p>
{collection.cleanupActive} server cleanup{collection.cleanupActive === 1
? ''
: 's'} in progress. Keys and ports remain reserved until removal is confirmed.
</p>{/if}
{#if collection.cleanupFailed}<p class="error">
{collection.cleanupFailed} cleanup{collection.cleanupFailed === 1
? ' needs'
: 's need'} attention. Open the cleanup operation below to review and
recover.
</p>{/if}
</div>
{/if}
{#if canOperate && detail.canCancel && !cancellationRequested}
<div class="cancel-action"><Button variant="danger" loading={cancelling} onclick={cancelLaunch}>Cancel collection launch</Button><p>Stops unfinished launches and removes their containers and instance files. Completed launches and retained servers are kept.</p></div>
<div class="cancel-action">
<Button variant="danger" loading={cancelling} onclick={cancelLaunch}
>Cancel collection launch</Button
>
<p>
Stops unfinished launches and removes their containers and instance files.
Completed launches and retained servers are kept.
</p>
</div>
{/if}
{#if cancelError}<p class="error" role="alert">{cancelError}</p>{/if}
</section>
@@ -139,49 +278,170 @@
<section class="current-launches" aria-label="In progress">
<span class="eyebrow">In progress</span>
{#each currentServers as server (server.id)}
<a class="current-launch" href={contextHref(`/provisioning/${server.id}`, $page.url)}>
<a
class="current-launch"
href={contextHref(`/provisioning/${server.id}`, $page.url)}
>
<span class="activity" aria-hidden="true"></span>
<div><strong>{server.action === 'destroy' ? 'Removing: ' : ''}<HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} /></strong><p>{server.state === 'awaiting-health' ? 'Checking the container, connection, and expected map.' : server.stage}</p></div>
<span class="operation-link">Follow progress <span aria-hidden="true">→</span></span>
<div>
<strong
>{server.action === 'destroy'
? 'Removing: '
: ''}<HistoricalServerIdentity
name={server.server}
deletedAt={server.server_deleted_at}
/></strong
>
<p>
{server.state === 'awaiting-health'
? 'Checking the container, connection, and expected map.'
: server.stage}
</p>
</div>
<span class="operation-link"
>Follow progress <span aria-hidden="true">→</span></span
>
</a>
{/each}
</section>
{:else if firstQueued}
<aside class="waiting-note">
<span class="waiting-icon" aria-hidden="true">◷</span>
<div><strong>{waitingForPort(firstQueued) ? 'Waiting for a removal to release capacity' : 'Your collection is queued'}</strong>
<p>{queueHint(firstQueued)} Provisioning starts each server when its host and capacity are ready. Progress updates automatically.</p>
<div>
<strong
>{waitingForPort(firstQueued)
? 'Waiting for a removal to release capacity'
: 'Your collection is queued'}</strong
>
<p>
{queueHint(firstQueued)} Provisioning starts each server when its host and capacity
are ready. Progress updates automatically.
</p>
{#if otherLaunches.length}
<p>Other servers currently launching:</p>
<div class="other-launches">{#each otherLaunches as active (active.id)}<a href={contextHref(`/provisioning/${active.id}`, $page.url)}><ServerIdentity name={active.server} /> <span aria-hidden="true">→</span></a>{/each}</div>
<div class="other-launches">
{#each otherLaunches as active (active.id)}<a
href={contextHref(`/provisioning/${active.id}`, $page.url)}
><ServerIdentity name={active.server} />
<span aria-hidden="true">→</span></a
>{/each}
</div>
{/if}
</div>
</aside>
{/if}
{#if exact && detail.retainedServers.length}
<details><summary>{detail.retainedServers.length} retained server{detail.retainedServers.length === 1 ? '' : 's'} (unchanged)</summary>
<ul>{#each detail.retainedServers as name}<li><HistoricalServerIdentity {name} deletedAt={detail.retainedServerDeletedAt?.[name]} /> <span class="muted">{name}</span></li>{/each}</ul>
<details>
<summary
>{detail.retainedServers.length} retained server{detail.retainedServers
.length === 1
? ''
: 's'} (unchanged)</summary
>
<ul>
{#each detail.retainedServers as name}<li>
<HistoricalServerIdentity
{name}
deletedAt={detail.retainedServerDeletedAt?.[name]}
/> <span class="muted">{name}</span>
</li>{/each}
</ul>
</details>
{/if}
<section class="queue" aria-labelledby="queue-title">
<div class="queue-heading"><h2 id="queue-title">{exact ? 'Fleet changes' : 'Launch order'}</h2><span class="muted">{collection.total} operation{collection.total === 1 ? '' : 's'}</span></div>
<div class="queue-heading">
<h2 id="queue-title">{exact ? 'Fleet changes' : 'Launch order'}</h2>
<span class="muted"
>{collection.total} operation{collection.total === 1 ? '' : 's'}</span
>
</div>
<ol class="servers">
{#each detail.servers as server, index (server.id)}
<li class:launching={launching(server)} class:attention={needsAttention(server) || (server.cancellation && cleanupNeedsAttention(server.cancellation))}>
<a class="server" href={contextHref(`/provisioning/${server.id}`, $page.url)}>
<span class="order" class:ready={server.state === 'done'} aria-hidden="true">{server.state === 'done' ? '✓' : String(index + 1).padStart(2, '0')}</span>
<li
class:launching={launching(server)}
class:attention={needsAttention(server) ||
(server.cancellation && cleanupNeedsAttention(server.cancellation))}
>
<a
class="server"
href={contextHref(`/provisioning/${server.id}`, $page.url)}
>
<span
class="order"
class:ready={server.state === 'done'}
aria-hidden="true"
>{server.state === 'done'
? '✓'
: String(index + 1).padStart(2, '0')}</span
>
<div class="server-content">
<div class="server-heading"><strong><HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} /></strong><Badge variant={needsAttention(server) ? 'danger' : server.state === 'done' ? 'online' : launching(server) ? 'info' : 'neutral'}>{stateLabel(server)}</Badge></div>
<p class:error={!!server.error}>{server.error ?? (server.state === 'queued' ? queueHint(server) : server.state === 'done' ? server.action === 'destroy' ? server.stage : 'Startup checks passed.' : server.stage)}</p>
<div class="server-meta"><span>{server.action === 'destroy' ? 'Destroy server' : server.port === null ? 'Create server · Port not assigned yet' : `Create server · Port ${server.port}`}</span><span>{needsAttention(server) ? 'Review & recover' : 'View operation'} <span aria-hidden="true">→</span></span></div>
<div class="server-heading">
<strong
><HistoricalServerIdentity
name={server.server}
deletedAt={server.server_deleted_at}
/></strong
><Badge
variant={needsAttention(server)
? 'danger'
: server.state === 'done'
? 'online'
: launching(server)
? 'info'
: 'neutral'}>{stateLabel(server)}</Badge
>
</div>
<p class:error={!!server.error}>
{server.error ??
(server.state === 'queued'
? queueHint(server)
: server.state === 'done'
? server.action === 'destroy'
? server.stage
: 'Startup checks passed.'
: server.stage)}
</p>
<div class="server-meta">
<span
>{server.action === 'destroy'
? 'Destroy server'
: server.port === null
? 'Create server · Port not assigned yet'
: `Create server · Port ${server.port}`}</span
><span
>{needsAttention(server)
? 'Review & recover'
: 'View operation'}
<span aria-hidden="true">→</span></span
>
</div>
</div>
</a>
{#if server.cancellation}
<a class="cleanup-link" class:error={cleanupNeedsAttention(server.cancellation)} href={contextHref(`/provisioning/${server.cancellation.cleanupId}`, $page.url)}>
<strong>{server.cancellation.state === 'done' ? 'Cleanup completed' : cleanupNeedsAttention(server.cancellation) ? 'Cleanup needs attention' : 'Cleanup in progress'}</strong>
<span>{server.cancellation.error ?? server.cancellation.stage}</span>
<span class="cleanup-operation">{cleanupNeedsAttention(server.cancellation) ? 'Review cleanup' : 'View cleanup progress'} <span aria-hidden="true">→</span></span>
<a
class="cleanup-link"
class:error={cleanupNeedsAttention(server.cancellation)}
href={contextHref(
`/provisioning/${server.cancellation.cleanupId}`,
$page.url
)}
>
<strong
>{server.cancellation.state === 'done'
? 'Cleanup completed'
: cleanupNeedsAttention(server.cancellation)
? 'Cleanup needs attention'
: 'Cleanup in progress'}</strong
>
<span>{server.cancellation.error ?? server.cancellation.stage}</span
>
<span class="cleanup-operation"
>{cleanupNeedsAttention(server.cancellation)
? 'Review cleanup'
: 'View cleanup progress'}
<span aria-hidden="true">→</span></span
>
</a>
{/if}
</li>
@@ -192,17 +452,406 @@
</div>
<style>
.page { display: grid; gap: 28px; }
h2, p { margin: 0; }h2 { font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }.eyebrow { display: block; font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .1em; color: var(--text-secondary); margin-bottom: 9px; }
a { color: var(--accent-primary-hover); }a:focus-visible, button:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }.muted { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; }.error { color: var(--state-danger); }
.summary { display: grid; gap: 24px; padding: 28px; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); }.summary-heading, .queue-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }.completion { color: var(--text-secondary); font-size: var(--text-sm); white-space: nowrap; font-variant-numeric: tabular-nums; }.completion strong { color: var(--text-primary); font-weight: 600; }
.progress { display: flex; height: 6px; background: var(--surface-raised); overflow: hidden; border-radius: var(--radius-pill); }.progress span { transition: width var(--dur-base) var(--ease-standard); }.progress-ready { background: var(--state-online); }.progress-failed { background: var(--state-danger); }.progress-cancelled { background: var(--text-tertiary); }
.counts.has-cancellations { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.cancellation-note, .cancel-action p, .cleanup-summary { font-size: var(--text-sm); line-height: 1.6; color: var(--text-secondary); }.cancel-action { display: grid; gap: 10px; justify-items: start; }.cleanup-summary { display: grid; gap: 8px; }
.cleanup-link { display: grid; gap: 6px; margin: 0 24px 20px 68px; padding: 14px 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); color: var(--text-secondary); text-decoration: none; font-size: var(--text-xs); line-height: 1.6; overflow-wrap: anywhere; }.cleanup-link:hover { background: var(--surface-raised); }.cleanup-link strong { color: var(--text-primary); }.cleanup-link.error, .cleanup-link.error strong { color: var(--state-danger); }.cleanup-operation { color: var(--accent-primary-hover); }
.counts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin: 0; }.counts > div + div { padding-left: 24px; border-left: 1px solid var(--border-subtle); }dt { color: var(--text-secondary); font-size: var(--text-xs); }dd { font-variant-numeric: tabular-nums; font-size: var(--text-2xl); font-weight: 600; margin: 6px 0 0; letter-spacing: -.025em; }.ready-count dd { color: var(--state-online); }.attention-count dd { color: var(--state-danger); }
.current-launches { display: grid; gap: 12px; }.current-launches > .eyebrow { margin: 0; color: var(--accent-primary-hover); }.current-launch { display: flex; align-items: center; gap: 16px; color: inherit; text-decoration: none; padding: 20px 24px; border: 1px solid color-mix(in srgb, var(--accent-primary) 35%, transparent); border-radius: var(--radius-md); background: color-mix(in srgb, var(--accent-primary) 7%, var(--surface)); }.current-launch > div { min-width: 0; flex: 1; }.current-launch strong { font-size: var(--text-md); }.current-launch p { margin-top: 7px; color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; overflow-wrap: anywhere; }.current-launch:hover { border-color: var(--accent-primary); }.operation-link { font-size: var(--text-xs); color: var(--accent-primary-hover); white-space: nowrap; }.activity { flex: 0 0 9px; height: 9px; border-radius: 50%; background: var(--accent-primary-hover); box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent-primary) 12%, transparent); }
.waiting-note { display: flex; gap: 16px; padding: 0 4px; }.waiting-icon { color: var(--text-secondary); font-size: var(--text-xl); line-height: 1; }.waiting-note strong { font-size: var(--text-base); }.waiting-note p { margin-top: 6px; color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }.other-launches { display: grid; gap: 10px; margin-top: 12px; }.other-launches a { display: flex; align-items: baseline; gap: 10px; width: fit-content; font-size: var(--text-sm); text-decoration: none; }.other-launches a:hover { text-decoration: underline; }
.queue { display: grid; gap: 16px; }.queue-heading h2 { font-size: var(--text-md); }.servers { padding: 0; margin: 0; list-style: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; background: var(--surface); }.servers li + li { border-top: 1px solid var(--border-subtle); }.servers li { position: relative; }.servers li.launching::before, .servers li.attention::before { content: ''; width: 2px; position: absolute; inset: 0 auto 0 0; background: var(--accent-primary); pointer-events: none; }.servers li.attention::before { background: var(--state-danger); }.server { display: flex; gap: 20px; padding: 22px 24px; color: inherit; text-decoration: none; }.server:hover { background: var(--surface-raised); }.server:focus-visible { outline-offset: -3px; }.order { padding-top: 2px; color: var(--text-tertiary); font-size: var(--text-xs); font-family: var(--font-numeric); flex: 0 0 24px; font-variant-numeric: tabular-nums; }.order.ready { color: var(--state-online); font-family: var(--font-body); font-size: var(--text-md); padding-top: 0; }.server-content { flex: 1; min-width: 0; }.server-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }.server-heading strong { font-size: var(--text-base); }.server p { margin-top: 9px; color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; overflow-wrap: anywhere; }.server p.error { color: var(--state-danger); }.server-meta { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 12px; color: var(--text-tertiary); font-size: var(--text-xs); }.server-meta span:last-child { color: var(--accent-primary-hover); }.screen-reader { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
@media (max-width: 600px) { .page { gap: 24px; }.summary { padding: 20px; gap: 20px; }h2 { font-size: var(--text-lg); }.counts, .counts.has-cancellations { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }.counts > div + div { padding-left: 0; border-left: 0; }.counts > div:nth-child(even) { padding-left: 20px; border-left: 1px solid var(--border-subtle); }dd { font-size: var(--text-xl); }.current-launch { padding: 20px; flex-wrap: wrap; gap: 12px; }.current-launch > div { flex-basis: calc(100% - 24px); }.operation-link { margin-left: 21px; }.server { gap: 12px; padding: 20px 16px; }.order { flex-basis: 20px; font-variant-numeric: tabular-nums; }.server-heading { row-gap: 10px; }.server-heading strong { flex-basis: 100%; }.completion { font-size: var(--text-xs); }.cleanup-link { margin: 0 16px 16px; } }
.page {
display: grid;
gap: 28px;
}
h2,
p {
margin: 0;
}
h2 {
font-size: var(--text-lg);
letter-spacing: var(--tracking-tight);
}
.eyebrow {
display: block;
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-secondary);
margin-bottom: 9px;
}
a {
color: var(--accent-primary-hover);
}
a:focus-visible,
button:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 3px;
}
.muted {
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.6;
}
.error {
color: var(--state-danger);
}
.summary {
display: grid;
gap: 24px;
padding: 28px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
background: var(--surface);
}
.summary-heading,
.queue-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.completion {
color: var(--text-secondary);
font-size: var(--text-sm);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.completion strong {
color: var(--text-primary);
font-weight: 600;
}
.progress {
display: flex;
height: 6px;
background: var(--surface-raised);
overflow: hidden;
border-radius: var(--radius-pill);
}
.progress span {
transition: width var(--dur-base) var(--ease-standard);
}
.progress-ready {
background: var(--state-online);
}
.progress-failed {
background: var(--state-danger);
}
.progress-cancelled {
background: var(--text-tertiary);
}
.counts.has-cancellations {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.cancellation-note,
.cancel-action p,
.cleanup-summary {
font-size: var(--text-sm);
line-height: 1.6;
color: var(--text-secondary);
}
.cancel-action {
display: grid;
gap: 10px;
justify-items: start;
}
.cleanup-summary {
display: grid;
gap: 8px;
}
.cleanup-link {
display: grid;
gap: 6px;
margin: 0 24px 20px 68px;
padding: 14px 16px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-secondary);
text-decoration: none;
font-size: var(--text-xs);
line-height: 1.6;
overflow-wrap: anywhere;
}
.cleanup-link:hover {
background: var(--surface-raised);
}
.cleanup-link strong {
color: var(--text-primary);
}
.cleanup-link.error,
.cleanup-link.error strong {
color: var(--state-danger);
}
.cleanup-operation {
color: var(--accent-primary-hover);
}
.counts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin: 0;
}
.counts > div + div {
padding-left: 24px;
border-left: 1px solid var(--border-subtle);
}
dt {
color: var(--text-secondary);
font-size: var(--text-xs);
}
dd {
font-variant-numeric: tabular-nums;
font-size: var(--text-2xl);
font-weight: 600;
margin: 6px 0 0;
letter-spacing: -0.025em;
}
.ready-count dd {
color: var(--state-online);
}
.attention-count dd {
color: var(--state-danger);
}
.current-launches {
display: grid;
gap: 12px;
}
.current-launches > .eyebrow {
margin: 0;
color: var(--accent-primary-hover);
}
.current-launch {
display: flex;
align-items: center;
gap: 16px;
color: inherit;
text-decoration: none;
padding: 20px 24px;
border: 1px solid color-mix(in srgb, var(--accent-primary) 35%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--accent-primary) 7%, var(--surface));
}
.current-launch > div {
min-width: 0;
flex: 1;
}
.current-launch strong {
font-size: var(--text-md);
}
.current-launch p {
margin-top: 7px;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.6;
overflow-wrap: anywhere;
}
.current-launch:hover {
border-color: var(--accent-primary);
}
.operation-link {
font-size: var(--text-xs);
color: var(--accent-primary-hover);
white-space: nowrap;
}
.activity {
flex: 0 0 9px;
height: 9px;
border-radius: 50%;
background: var(--accent-primary-hover);
box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent-primary) 12%, transparent);
}
.waiting-note {
display: flex;
gap: 16px;
padding: 0 4px;
}
.waiting-icon {
color: var(--text-secondary);
font-size: var(--text-xl);
line-height: 1;
}
.waiting-note strong {
font-size: var(--text-base);
}
.waiting-note p {
margin-top: 6px;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.6;
}
.other-launches {
display: grid;
gap: 10px;
margin-top: 12px;
}
.other-launches a {
display: flex;
align-items: baseline;
gap: 10px;
width: fit-content;
font-size: var(--text-sm);
text-decoration: none;
}
.other-launches a:hover {
text-decoration: underline;
}
.queue {
display: grid;
gap: 16px;
}
.queue-heading h2 {
font-size: var(--text-md);
}
.servers {
padding: 0;
margin: 0;
list-style: none;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--surface);
}
.servers li + li {
border-top: 1px solid var(--border-subtle);
}
.servers li {
position: relative;
}
.servers li.launching::before,
.servers li.attention::before {
content: '';
width: 2px;
position: absolute;
inset: 0 auto 0 0;
background: var(--accent-primary);
pointer-events: none;
}
.servers li.attention::before {
background: var(--state-danger);
}
.server {
display: flex;
gap: 20px;
padding: 22px 24px;
color: inherit;
text-decoration: none;
}
.server:hover {
background: var(--surface-raised);
}
.server:focus-visible {
outline-offset: -3px;
}
.order {
padding-top: 2px;
color: var(--text-tertiary);
font-size: var(--text-xs);
font-family: var(--font-numeric);
flex: 0 0 24px;
font-variant-numeric: tabular-nums;
}
.order.ready {
color: var(--state-online);
font-family: var(--font-body);
font-size: var(--text-md);
padding-top: 0;
}
.server-content {
flex: 1;
min-width: 0;
}
.server-heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.server-heading strong {
font-size: var(--text-base);
}
.server p {
margin-top: 9px;
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.6;
overflow-wrap: anywhere;
}
.server p.error {
color: var(--state-danger);
}
.server-meta {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-top: 12px;
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.server-meta span:last-child {
color: var(--accent-primary-hover);
}
.screen-reader {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 600px) {
.page {
gap: 24px;
}
.summary {
padding: 20px;
gap: 20px;
}
h2 {
font-size: var(--text-lg);
}
.counts,
.counts.has-cancellations {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.counts > div + div {
padding-left: 0;
border-left: 0;
}
.counts > div:nth-child(even) {
padding-left: 20px;
border-left: 1px solid var(--border-subtle);
}
dd {
font-size: var(--text-xl);
}
.current-launch {
padding: 20px;
flex-wrap: wrap;
gap: 12px;
}
.current-launch > div {
flex-basis: calc(100% - 24px);
}
.operation-link {
margin-left: 21px;
}
.server {
gap: 12px;
padding: 20px 16px;
}
.order {
flex-basis: 20px;
font-variant-numeric: tabular-nums;
}
.server-heading {
row-gap: 10px;
}
.server-heading strong {
flex-basis: 100%;
}
.completion {
font-size: var(--text-xs);
}
.cleanup-link {
margin: 0 16px 16px;
}
}
</style>