147 lines
13 KiB
Svelte
147 lines
13 KiB
Svelte
<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>
|