Add capacity overview with host resource history
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { parseCapacityQuery } from '$lib/capacity.js';
|
||||
import { capacityPage } from '$lib/server/hosts/capacity-page.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = ({ url }) => capacityPage(parseCapacityQuery(url.searchParams));
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
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 { hostReadinessLabel } from '$lib/hosts.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 CapacityChart from '$ui/components/capacity/CapacityChart.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 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 (T5)' }, { value: 't6', label: 'Black Ops II (T6)' }];
|
||||
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>) { return `/capacity?${queryParams({ ...report.query, ...change })}`; }
|
||||
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 = ''; }
|
||||
} 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 = ''; }); });
|
||||
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" subtitle="See what your hosts can run, what is limiting them, and how headroom changes.">
|
||||
{#snippet actions()}<Button href="/hosts">Manage hosts</Button><Button disabled={refreshing || navigating} onclick={() => void refresh()}>{refreshing ? 'Refreshing…' : 'Refresh'}</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
{#if refreshError || navigationError}<div class="refresh-error" role="alert"><strong>{navigationError || refreshError}</strong><span>Showing the last successfully loaded capacity report.</span></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>
|
||||
<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>
|
||||
</div>
|
||||
<section class="overview" aria-labelledby="capacity-now-title">
|
||||
<div class="estimate"><div class="eyebrow" id="capacity-now-title">Available now · {scopeName}</div><div class="estimate-number">{count(summary.estimatedServers)}<span>additional servers</span></div>
|
||||
<p>{summary.estimatedServers === null ? 'Waiting for enough host information to estimate capacity.' : `Estimated to fit across ${summary.eligibleHosts} eligible ${summary.eligibleHosts === 1 ? 'host' : 'hosts'}.`} {report.query.game === 'all' ? 'Hosts ready for either T5 or T6 are counted once.' : `For ${report.query.game === 't5' ? 'Black Ops I' : 'Black Ops II'} launches.`}</p></div>
|
||||
<div class="queue-context"><a href="/provisioning"><strong>{count(summary.queued)}</strong><span>servers queued <span aria-hidden="true">→</span></span></a><p>Across the fleet. Launches wait for capacity and startup slots automatically.</p></div>
|
||||
</section>
|
||||
<div class="resources" aria-label="Current resource headroom">
|
||||
<article><span class="resource-label">CPU available</span><div class="resource-value">{count(summary.cpuCoresAvailable)}<small>cores</small></div><p>After OS reserve and pending starts</p></article>
|
||||
<article><span class="resource-label">Memory available</span><div class="resource-value">{memory(summary.memoryBytesAvailable)}<small>GiB</small></div><p>After OS reserve and pending starts</p></article>
|
||||
<article><span class="resource-label">Ports available</span><div class="resource-value">{count(summary.portsAvailable)}<small>ports</small></div><p>Free game ports on eligible hosts</p></article>
|
||||
<article><span class="resource-label">Startup slots</span><div class="resource-value">{count(summary.startupSlots)}<small>{summary.startupLimit === null ? 'available' : `/ ${summary.startupLimit}`}</small></div><p>{count(summary.activeStartups)} starting · controller-wide</p></article>
|
||||
</div>
|
||||
<div class="freshness"><span>{report.current.updatedAt ? `Capacity updated ${$timeFormat.dateTime(report.current.updatedAt)}` : 'Waiting for host reports'}</span><span>Refreshes every 30 seconds while visible</span></div>
|
||||
{#if report.current.warnings.length}<aside class="warnings" aria-label="Capacity notices">{#each report.current.warnings as warning}<p>{warning}</p>{/each}</aside>{/if}
|
||||
<CapacityChart history={report.history} scope={scopeName} />
|
||||
<section class="hosts" aria-labelledby="host-capacity-title">
|
||||
<div class="section-heading"><div><h2 id="host-capacity-title">Host capacity</h2><p>Find the resource that limits the next launch.</p></div><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}<div class="host-grid">{#each report.current.hosts as host (host.id)}<article class="host-card">
|
||||
<div class="host-title"><h3><a href={`/hosts/${host.id}`}>{host.name} <span aria-hidden="true">↗</span></a></h3><span class="status" class:eligible={host.eligible}>{!host.connected ? 'Offline' : hostReadinessLabel(host.readiness)}</span></div>
|
||||
<div class="host-estimate"><strong>{count(host.estimatedServers)}</strong><span>additional servers{#if host.eligible && host.limitingResource}<small>Limited by {host.limitingResource.toLowerCase()}</small>{/if}</span></div>
|
||||
<div class="game-tags">{#if host.games.length}{#each host.games as game}<span>{game === 't5' ? 'Black Ops I' : 'Black Ops II'}</span>{/each}{:else}<span>No games ready</span>{/if}</div>
|
||||
<dl class="host-resources"><div><dt>CPU</dt><dd>{count(host.cpuCoresAvailable)} <small>cores</small></dd><span>{count(host.limits.cpu)} servers fit</span></div><div><dt>Memory</dt><dd>{memory(host.memoryBytesAvailable)} <small>GiB</small></dd><span>{count(host.limits.memory)} servers fit</span></div><div><dt>Ports</dt><dd>{host.portsAvailable} <small>/ {host.totalPorts}</small></dd><span>{host.limits.ports} servers fit</span></div></dl>
|
||||
{#if host.reasons.length}<ul class="host-reasons">{#each host.reasons as reason}<li>{reason}</li>{/each}</ul>{/if}
|
||||
<details class="allowances"><summary>Per-server resource estimates</summary>
|
||||
<div class="allowance-grid">{#each [{ label: 'Running', value: host.calibration.running }, { label: 'Starting', value: host.calibration.startup }] as allowance}<div><strong>{allowance.label}</strong><p>{count(allowance.value.cpuCores)} cores · {memory(allowance.value.memoryBytes)} GiB</p><span>{allowance.value.source === 'bootstrap' ? 'Default allowance' : `${allowance.value.source === 'host' ? 'Host' : 'Fleet'} observations · ${allowance.value.samples} samples`}</span></div>{/each}</div>
|
||||
<p>Starts may need more headroom than running servers. Provisioning checks actual capacity before reserving a host and port.</p>
|
||||
</details>
|
||||
<p class="host-updated">{host.updatedAt ? `Reported ${$timeFormat.dateTime(host.updatedAt)}` : 'No host report yet'}</p>
|
||||
</article>{/each}</div>{/if}
|
||||
</section>
|
||||
<details class="methodology"><summary>How capacity is estimated</summary><p>Additional servers are estimated from each eligible host’s available CPU, memory, and unreserved ports, using its learned running-server allowance. The smallest resource allowance sets that host’s limit. Shared hosts are counted once.</p><p>This is a planning estimate. Queued launches do not reserve capacity, and starting servers may require a larger allowance. Actual placement also checks current host readiness, game files, runtime, and available startup slots.</p><p>The timeline shows measured host resource headroom after the operating-system reserve. It does not reconstruct historical launch eligibility, queued jobs, or port availability.</p></details>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page { display: grid; gap: var(--space-5); min-width: 0; } .toolbar { display: flex; align-items: end; justify-content: space-between; flex-wrap: wrap; gap: var(--space-4); }
|
||||
.filters { display: grid; grid-template-columns: minmax(180px, 260px) minmax(180px, 260px); gap: var(--space-3); }
|
||||
.ranges { display: flex; gap: 3px; padding: 4px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); }
|
||||
.ranges a { padding: 9px 13px; border-radius: var(--radius-sm); text-decoration: none; font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; }
|
||||
.ranges a[aria-current] { color: var(--text-primary); background: var(--surface-raised); } .ranges a:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.overview { display: flex; justify-content: space-between; align-items: center; gap: var(--space-8); background: var(--surface); border: 1px solid var(--border-subtle); border-left: 3px solid var(--accent-primary); border-radius: var(--radius-lg); padding: var(--space-6) var(--space-8); }
|
||||
.eyebrow { font-size: var(--text-xs); color: var(--text-secondary); font-weight: var(--weight-medium); } .estimate-number { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: var(--space-4); margin-top: var(--space-3); font-size: 56px; font-weight: var(--weight-semibold); line-height: 1.1; font-variant-numeric: tabular-nums; letter-spacing: -.035em; }
|
||||
.estimate-number span { font-size: var(--text-md); font-weight: var(--weight-regular); color: var(--text-secondary); letter-spacing: normal; } .estimate p, .queue-context p { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.6; margin: var(--space-3) 0 0; }
|
||||
.queue-context { flex: 0 0 210px; border-left: 1px solid var(--border-strong); padding-left: var(--space-6); } .queue-context a { color: var(--text-primary); text-decoration: none; display: flex; align-items: baseline; gap: var(--space-3); } .queue-context a:hover span { color: var(--accent-primary-hover); } .queue-context strong { font-size: var(--text-2xl); font-weight: var(--weight-medium); font-variant-numeric: tabular-nums; } .queue-context span { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.resources { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-3); } .resources article { background: var(--surface); padding: var(--space-5); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.resource-label { color: var(--text-secondary); font-size: var(--text-xs); } .resource-value { font-size: var(--text-2xl); font-weight: var(--weight-medium); margin-top: var(--space-3); font-variant-numeric: tabular-nums; display: flex; align-items: baseline; flex-wrap: wrap; gap: 7px; } .resource-value small { font-size: var(--text-sm); color: var(--text-tertiary); font-weight: var(--weight-regular); } .resources p { color: var(--text-tertiary); font-size: 11px; line-height: 1.5; margin: var(--space-2) 0 0; }
|
||||
.freshness { display: flex; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2); color: var(--text-tertiary); font-size: 11px; margin-top: -6px; }
|
||||
.warnings, .refresh-error { border-left: 2px solid var(--state-warning); padding: var(--space-3) var(--space-4); background: var(--surface); border-radius: 0 var(--radius-sm) var(--radius-sm) 0; font-size: var(--text-xs); color: var(--text-secondary); line-height: 1.6; } .warnings p { margin: 0; } .warnings p + p { margin-top: var(--space-2); } .refresh-error { display: grid; gap: var(--space-1); }
|
||||
.hosts { display: grid; gap: var(--space-4); } .section-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--space-4); } h2 { font-size: var(--text-lg); margin: 0; } .section-heading p { font-size: var(--text-sm); color: var(--text-tertiary); margin: 6px 0 0; } .section-heading > span { color: var(--text-secondary); font-size: var(--text-xs); white-space: nowrap; }
|
||||
.host-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); } .host-card, .empty-hosts { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); min-width: 0; } .host-title { display: flex; justify-content: space-between; gap: var(--space-3); align-items: center; } h3 { font-size: var(--text-md); margin: 0; } .host-title h3 { overflow-wrap: anywhere; } .host-title a { text-decoration: none; color: var(--text-primary); } .host-title a:hover { color: var(--accent-primary-hover); } .host-title a span { color: var(--text-tertiary); font-size: var(--text-sm); }
|
||||
.status { padding: 3px 8px; font-size: 11px; background: var(--surface-raised); color: var(--text-secondary); border: 1px solid var(--border-subtle); border-radius: var(--radius-pill); white-space: nowrap; } .status.eligible { color: var(--state-online); }
|
||||
.host-estimate { display: flex; align-items: center; gap: var(--space-3); margin-top: var(--space-5); } .host-estimate strong { font-size: var(--text-2xl); font-weight: var(--weight-medium); font-variant-numeric: tabular-nums; } .host-estimate span { font-size: var(--text-xs); color: var(--text-secondary); } .host-estimate small { display: block; color: var(--text-tertiary); margin-top: 4px; font-size: 11px; }
|
||||
.game-tags { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-3); } .game-tags span { font-size: 10px; color: var(--text-secondary); background: var(--surface-sunken); padding: 4px 7px; border-radius: 4px; }
|
||||
.host-resources { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); margin: var(--space-5) 0 0; padding: var(--space-4) 0; border-top: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle); } .host-resources dt { font-size: 11px; color: var(--text-tertiary); } .host-resources dd { margin: 8px 0 5px; font-size: var(--text-md); font-variant-numeric: tabular-nums; } .host-resources dd small, .host-resources div > span { font-size: 11px; color: var(--text-tertiary); }
|
||||
.host-reasons { padding-left: 16px; font-size: var(--text-xs); color: var(--text-secondary); line-height: 1.7; margin: var(--space-3) 0 0; } .allowances { margin-top: var(--space-4); } summary { cursor: pointer; font-size: var(--text-xs); color: var(--text-secondary); padding: 4px 0; } .allowance-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); background: var(--surface-sunken); border-radius: var(--radius-sm); padding: var(--space-3); margin-top: var(--space-3); } .allowance-grid strong { font-size: var(--text-xs); font-weight: var(--weight-medium); } .allowance-grid p { font-size: var(--text-xs); margin: 8px 0 4px; color: var(--text-secondary); } .allowance-grid span { font-size: 10px; color: var(--text-tertiary); } .allowances > p, .host-updated { font-size: 11px; line-height: 1.6; color: var(--text-tertiary); margin: var(--space-3) 0 0; }
|
||||
.methodology { padding: 0 2px; } .methodology p { font-size: var(--text-xs); color: var(--text-tertiary); line-height: 1.7; max-width: 115ch; } .empty-hosts p { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }
|
||||
@media (max-width: 900px) { .resources { grid-template-columns: repeat(2, minmax(0, 1fr)); } .host-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 600px) { .page { gap: var(--space-4); } .filters { grid-template-columns: 1fr 1fr; width: 100%; } .overview { padding: var(--space-5); flex-direction: column; align-items: stretch; gap: var(--space-4); } .estimate-number { font-size: 48px; } .queue-context { flex-basis: auto; border-left: 0; border-top: 1px solid var(--border-subtle); padding: var(--space-3) 0 0; } .queue-context p { margin-top: 4px; } .queue-context strong { font-size: var(--text-xl); } .resources article { padding: var(--space-4); } .resource-value { font-size: 28px; } .host-card { padding: var(--space-4); } .section-heading { align-items: flex-start; } .ranges { width: 100%; } .ranges a { text-align: center; flex: 1; } }
|
||||
</style>
|
||||
Reference in New Issue
Block a user