Files
electric-cherry/src/routes/capacity/+page.svelte
T

147 lines
13 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import BackLink from '$ui/components/BackLink.svelte';
import { onMount, untrack } from 'svelte';
import { beforeNavigate, afterNavigate, goto } from '$app/navigation';
import { page } from '$app/state';
import type { CapacityData, CapacityGame, CapacityQuery, CapacityRange } from '$lib/capacity.js';
import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import CapacityHosts from '$ui/components/capacity/CapacityHosts.svelte';
import CapacityChart from '$ui/components/capacity/CapacityChart.svelte';
import Freshness from '$ui/components/Freshness.svelte';
let { data }: { data: CapacityData } = $props();
let snapshot = $state<CapacityData | null>(null);
let refreshing = $state(false);
let navigating = $state(false);
let refreshError = $state('');
let navigationError = $state('');
let checkedAt = $state(untrack(() => Date.now()));
let request: AbortController | null = null;
let sequence = 0;
const report = $derived(snapshot ?? data);
const summary = $derived(report.current.summary);
const scopeName = $derived(report.query.hostId ? report.current.hostOptions.find(host => host.id === report.query.hostId)?.name ?? 'Selected host' : 'All hosts');
const hostOptions = $derived([{ value: '', label: 'All hosts' }, ...report.current.hostOptions.map(host => ({ value: host.id, label: host.name })),
...(report.query.hostId && !report.current.hostOptions.some(host => host.id === report.query.hostId) ? [{ value: report.query.hostId, label: 'Unavailable host' }] : [])]);
const gameOptions = [{ value: 'all', label: 'All games' }, { value: 't5', label: 'Black Ops I' }, { value: 't6', label: 'Black Ops II' }];
const ranges: { value: CapacityRange; label: string }[] = [{ value: '1h', label: 'Last hour' }, { value: '24h', label: '24 hours' }, { value: '7d', label: '7 days' }];
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); }
function queryParams(query: CapacityQuery) {
const params = new URLSearchParams({ range: query.range, game: query.game });
if (query.hostId) params.set('host', query.hostId);
return params;
}
function scopeUrl(change: Partial<CapacityQuery>) {
const params = queryParams({ ...report.query, ...change });
const returnTo = page.url.searchParams.get('returnTo');
if (returnTo) params.set('returnTo', returnTo);
return `/capacity?${params}`;
}
function cancel() { sequence++; request?.abort(); request = null; refreshing = false; }
async function refresh(signal?: AbortSignal) {
if (navigating || refreshing) return;
const current = ++sequence;
request = new AbortController();
refreshing = true;
try {
const response = await fetch(`/api/capacity?${queryParams(data.query)}`, {
signal: AbortSignal.any([request.signal, AbortSignal.timeout(25_000), ...(signal ? [signal] : [])])
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not refresh capacity.');
if (current === sequence) { snapshot = body; refreshError = ''; checkedAt = Date.now(); }
} catch (cause) {
if (current === sequence && !signal?.aborted && !request?.signal.aborted) refreshError = (cause as Error).message;
return false;
} finally { if (current === sequence) { refreshing = false; request = null; } }
}
async function changeScope(change: Partial<CapacityQuery>) {
navigationError = '';
try { await goto(scopeUrl(change), { keepFocus: true, noScroll: true }); }
catch (cause) { navigating = false; navigationError = (cause as Error).message || 'Could not load that capacity view.'; }
}
beforeNavigate(() => { cancel(); navigating = true; });
afterNavigate(() => { navigating = false; });
$effect(() => { void data; untrack(() => { cancel(); snapshot = null; refreshError = ''; checkedAt = Date.now(); }); });
onMount(() => { const stop = pollVisible(refresh, 30_000, false); return () => { stop(); cancel(); }; });
</script>
<svelte:head><title>Capacity · Electric Cherry</title><meta name="description" content="Available game-server capacity, resource history, and host limits." /></svelte:head>
<div class="page" aria-busy={navigating}>
<SectionHeader title="Capacity" />
{#if page.url.searchParams.has('returnTo')}<BackLink fallback="/hosts" />{/if}
{#if refreshError || navigationError}<div class="refresh-error" role="alert"><strong>{navigationError || refreshError}</strong><span>Showing the last successfully loaded capacity report.</span>{#if refreshError}<Button size="sm" disabled={refreshing || navigating} onclick={() => void refresh()}>Retry</Button>{/if}</div>{/if}
<div class="toolbar">
<div class="filters"><Field label="Host"><Select value={report.query.hostId ?? ''} options={hostOptions} disabled={navigating} onchange={event => void changeScope({ hostId: (event.currentTarget as HTMLSelectElement).value || null })} /></Field>
<Field label="Launch game"><Select value={report.query.game} options={gameOptions} disabled={navigating} onchange={event => void changeScope({ game: (event.currentTarget as HTMLSelectElement).value as CapacityGame })} /></Field></div>
<div class="freshness">{#if navigating}<span role="status">Updating capacity…</span>{:else if !refreshError}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 30 seconds" />{/if}</div>
</div>
<section class="overview" aria-labelledby="capacity-now-title">
<div class="overview-heading"><div>
<h2 id="capacity-now-title">{#if summary.estimatedServers === null}Capacity estimate unavailable{:else}Estimated room for <strong>{count(summary.estimatedServers)}</strong> more {summary.estimatedServers === 1 ? 'instance' : 'instances'}{/if}</h2>
<p>{scopeName} · {summary.eligibleHosts} eligible {summary.eligibleHosts === 1 ? 'host' : 'hosts'}{report.query.game === 'all' ? '' : ` · ${report.query.game === 't5' ? 'Black Ops I' : 'Black Ops II'}`}</p>
</div><div class="queue-context">
{#if summary.queued !== null && summary.queued > 0}<a href="/provisioning"><strong>{count(summary.queued)} {summary.queued === 1 ? 'instance' : 'instances'} queued →</strong></a>
{:else}<span>{summary.queued === null ? 'Queue unavailable' : 'No queued instances'}</span>{/if}<small>Fleet-wide</small>
</div></div>
<dl class="resources" aria-label="Current resource headroom">
<div><dt>CPU available</dt><dd>{count(summary.cpuCoresAvailable)} cores</dd></div>
<div><dt>Memory available</dt><dd>{memory(summary.memoryBytesAvailable)} GiB</dd></div>
<div><dt>Free ports</dt><dd>{count(summary.portsAvailable)}</dd></div>
<div><dt>Concurrent starts · Fleet-wide</dt><dd>{summary.startupSlots === null ? 'Unavailable' : `${count(summary.startupSlots)}${summary.startupLimit === null ? '' : ` of ${summary.startupLimit}`} available`}</dd></div>
</dl>
</section>
{#if report.current.warnings.length}<aside class="warnings" aria-label="Capacity notices">{#each report.current.warnings as warning}<p>{warning}</p>{/each}</aside>{/if}
<section class="hosts" aria-labelledby="host-capacity-title">
<div class="section-heading"><h2 id="host-capacity-title">Host capacity</h2><span>{summary.eligibleHosts} / {summary.totalHosts} eligible</span></div>
{#if !report.current.hosts.length}<div class="empty-hosts"><h3>{report.query.hostId ? 'This host is unavailable' : 'No hosts yet'}</h3><p>{report.query.hostId ? 'Choose another host to view its capacity.' : 'Connect a host to start seeing capacity and history.'}</p><Button href="/hosts">Open hosts</Button></div>
{:else}<CapacityHosts hosts={report.current.hosts} returnTo={page.url.pathname + page.url.search} />{/if}
</section>
<details class="methodology"><summary>Capacity details</summary>
<p>Additional instances are estimated from each eligible host’s available CPU, memory, and unreserved ports, using its learned running-instance allowance. The smallest resource allowance sets that host’s limit. With All games selected, hosts ready for either T5 or T6 are counted once.</p>
<p>CPU and memory availability account for the operating-system reserve and pending starts. Free ports are counted on eligible hosts.</p>
<p>This is a planning estimate. Queued launches do not reserve capacity, and starting instances may require a larger allowance. Placement also checks current host readiness, game files, runtime, and startup slots.</p>
<p>{count(summary.activeStartups)} instances starting across the fleet. Launches wait for capacity and startup slots automatically.</p>
<p>{report.current.updatedAt ? `Report updated ${$timeFormat.dateTime(report.current.updatedAt)}.` : 'Waiting for host reports.'} Refreshes every 30 seconds while visible.</p>
</details>
<CapacityChart history={report.history} scope={scopeName}>
{#snippet controls()}<nav class="ranges" aria-label="History range">{#each ranges as range}<a href={scopeUrl({ range: range.value })} aria-current={report.query.range === range.value ? 'page' : undefined} data-sveltekit-keepfocus data-sveltekit-noscroll>{range.label}</a>{/each}</nav>{/snippet}
</CapacityChart>
</div>
<style>
.page { display:grid; gap:var(--space-5); min-width:0; }
.toolbar,.overview-heading,.section-heading { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:16px; }
.toolbar { align-items:end; }
.filters { display:grid; grid-template-columns: minmax(180px,260px) minmax(180px,260px); gap:12px; }
.freshness { display:flex; align-items:center; gap:8px; color:var(--text-secondary); font-size:var(--text-xs); }
.overview { padding:20px 24px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); }
h2 { margin:0; font-size:var(--text-lg); font-weight:500; } .overview h2 { font-size:var(--text-xl); } .overview strong { font-weight:600; }
.overview p { margin:8px 0 0; color:var(--text-secondary); font-size:var(--text-xs); }
.queue-context { display:grid; gap:4px; font-size:var(--text-sm); color:var(--text-secondary); }
.queue-context a { color:var(--accent-primary-hover); text-decoration:none; } .queue-context a:hover { text-decoration:underline; } .queue-context small { font-size:var(--text-xs); }
.resources { display:flex; flex-wrap:wrap; gap:16px 40px; padding-top:16px; margin:16px 0 0; border-top:1px solid var(--border-subtle); }
dt { color:var(--text-secondary); font-size:var(--text-xs); } dd { margin:5px 0 0; font-size:var(--text-sm); font-variant-numeric:tabular-nums; }
.warnings,.refresh-error { border-left:2px solid var(--state-warning); padding:12px 16px; background:var(--surface); font-size:var(--text-xs); color:var(--text-secondary); line-height:1.6; }
.warnings p { margin:0; } .warnings p+p { margin-top:8px; } .refresh-error { display:grid; gap:4px; }
.hosts { display:grid; gap:14px; } .section-heading>span { color:var(--text-secondary); font-size:var(--text-xs); }
.empty-hosts { background:var(--surface); border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:20px; }
.empty-hosts h3 { margin:0; font-size:var(--text-md); } .empty-hosts p { color:var(--text-secondary); font-size:var(--text-sm); }
summary { cursor:pointer; color:var(--text-secondary); font-size:var(--text-xs); padding:4px 0; }
.methodology p { color:var(--text-secondary); font-size:var(--text-xs); line-height:1.7; max-width:110ch; }
.ranges { display:flex; flex-wrap:wrap; gap:3px; padding:3px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
.ranges a { padding:7px 12px; border-radius:var(--radius-sm); color:var(--text-secondary); text-decoration:none; font-size:var(--text-xs); white-space:nowrap; }
.ranges a[aria-current] { color:var(--text-primary); background:var(--surface-raised); }
a:focus-visible,summary:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
@media(max-width:600px) { .filters { width:100%; grid-template-columns:repeat(2,minmax(0,1fr)); } .overview { padding:16px; } .resources { gap:16px 24px; } }
@media(max-width:360px) { .filters { grid-template-columns:minmax(0,1fr); } }
</style>