Simplify capacity summary and host comparison

This commit is contained in:
2026-10-06 17:30:12 -04:00
parent 5ad76e6590
commit f4094b7f5d
5 changed files with 213 additions and 69 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ export async function readCapacityCurrent(query: CapacityQuery): Promise<Capacit
const limitingResource: CapacityHost['limitingResource'] = estimatedServers === null || !eligible ? null const limitingResource: CapacityHost['limitingResource'] = estimatedServers === null || !eligible ? null
: limits.ports === estimatedServers ? 'Ports' : limits.memory === estimatedServers ? 'Memory' : 'CPU'; : limits.ports === estimatedServers ? 'Ports' : limits.memory === estimatedServers ? 'Memory' : 'CPU';
if (eligible && (limits.cpu === null || limits.memory === null)) reasons.push('Waiting for valid CPU and memory measurements.'); if (eligible && (limits.cpu === null || limits.memory === null)) reasons.push('Waiting for valid CPU and memory measurements.');
else if (eligible && estimatedServers === 0) reasons.push(`No additional servers currently fit: ${limitingResource?.toLowerCase()} capacity is full.`); else if (eligible && estimatedServers === 0) reasons.push(`No additional instances currently fit: ${limitingResource?.toLowerCase()} capacity is full.`);
return { id: host.id, name: host.name, readiness: host.readiness, connected, updatedAt: host.lastHeartbeat, return { id: host.id, name: host.name, readiness: host.readiness, connected, updatedAt: host.lastHeartbeat,
eligible, games, reasons: [...new Set(reasons)], cpuCoresAvailable, memoryBytesAvailable, eligible, games, reasons: [...new Set(reasons)], cpuCoresAvailable, memoryBytesAvailable,
portsAvailable, totalPorts: host.portMax - host.portMin + 1, estimatedServers, limits, limitingResource, calibration }; portsAvailable, totalPorts: host.portMax - host.portMin + 1, estimatedServers, limits, limitingResource, calibration };
@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte';
import type { CapacityHistory, CapacityHistoryPoint } from '$lib/capacity.js'; import type { CapacityHistory, CapacityHistoryPoint } from '$lib/capacity.js';
import { timeFormat, timeZone } from '$ui/stores/time-zone.js'; import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
let { history, scope }: { history: CapacityHistory; scope: string } = $props(); let { history, scope, controls }: { history: CapacityHistory; scope: string; controls?: Snippet } = $props();
let metric = $state<'cpu' | 'memory'>('cpu'); let metric = $state<'cpu' | 'memory'>('cpu');
let selectedAt = $state<number | null>(null); let selectedAt = $state<number | null>(null);
let tableOpen = $state(false); let tableOpen = $state(false);
@@ -67,12 +68,13 @@
<section class="panel" aria-labelledby="capacity-history-title"> <section class="panel" aria-labelledby="capacity-history-title">
<div class="heading"> <div class="heading">
<div><h2 id="capacity-history-title">Capacity over time</h2><p>Measured resource headroom · {scope}</p></div> <div><h2 id="capacity-history-title">Available resources over time</h2><p>{scope} · All workloads</p></div>
<div class="mode" role="group" aria-label="Chart resource"> <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 === 'cpu'} onclick={() => metric = 'cpu'}>CPU</button>
<button type="button" aria-pressed={metric === 'memory'} onclick={() => metric = 'memory'}>Memory</button> <button type="button" aria-pressed={metric === 'memory'} onclick={() => metric = 'memory'}>Memory</button>
</div> </div></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="axis-title">{label} available</div>
<div class="chart-grid"> <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="y-axis" aria-hidden="true">{#each ticks as tick}<span style:top={`${(1 - tick) * 100}%`}>{numbers.format(ceiling * tick)}</span>{/each}</div>
@@ -97,13 +99,14 @@
</div> </div>
{#if selected} {#if selected}
<div class="readout" aria-label="Selected history period"> <div class="readout" aria-label="Selected history period">
<div class="selected-time"><span>Selected period</span><strong>{$timeFormat.period(selected.at)}</strong></div> <div class="selected-time"><strong>{$timeFormat.period(selected.at)}</strong></div>
<dl><div><dt>CPU available</dt><dd>{amount(selected.cpuCoresAvailable, 'cores')}</dd></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>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><dt>Reporting hosts</dt><dd>{selected.reportingHosts} / {selected.expectedHosts}</dd></div></dl>
</div> </div>
{/if} {/if}
<p class="note">Host headroom after the operating-system reserve, averaged per period. Gaps mean reporting is incomplete. The game filter changes current launch estimates; this history includes all workloads on the selected hosts.</p> <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> <details bind:open={tableOpen}><summary>View history data · {$timeZone.replaceAll('_', ' ')}</summary>
{#if tableOpen} {#if tableOpen}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable data must be reachable by keyboard.) --> <!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable data must be reachable by keyboard.) -->
@@ -114,21 +117,24 @@
</table></div> </table></div>
{/if} {/if}
</details> </details>
</details>
</section> </section>
<style> <style>
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-6); min-width: 0; } .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; } 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 p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); } .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; }
.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 { 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 { 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[aria-pressed='true'] { color: var(--text-primary); background: var(--surface-raised); }
.mode button:focus-visible, .plot:focus-visible, .table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; } .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-6); color: var(--text-tertiary); font-size: 11px; } .axis-title { margin-top: var(--space-4); color: var(--text-tertiary); font-size: 11px; }
.chart-grid { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 10px; margin-top: 14px; } .chart-grid { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 10px; margin-top: 14px; }
.y-axis { position: relative; height: 220px; margin-top: 10px; color: var(--text-tertiary); font-size: 11px; } .y-axis { position: relative; height: 176px; margin-top: 8px; color: var(--text-tertiary); font-size: 11px; }
.y-axis span { position: absolute; right: 0; transform: translateY(-50%); } .y-axis span { position: absolute; right: 0; transform: translateY(-50%); }
.plot { position: relative; height: 240px; border-radius: 3px; cursor: crosshair; touch-action: pan-y; } .plot { position: relative; height: 192px; border-radius: 3px; cursor: crosshair; touch-action: pan-y; }
svg { display: block; width: 100%; height: 100%; overflow: visible; } svg { display: block; width: 100%; height: 100%; overflow: visible; }
svg path, svg line { vector-effect: non-scaling-stroke; } 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); } .grid-line { stroke: var(--border-subtle); stroke-width: 1; } .area { fill: color-mix(in oklab, var(--accent-primary) 10%, transparent); }
@@ -139,9 +145,9 @@
.x-axis span:last-child { text-align: right; } .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 { 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); } .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: var(--space-6); align-items: center; background: var(--surface-sunken); padding: var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); margin-top: var(--space-5); } .readout { display:flex; flex-wrap:wrap; gap:12px 24px; align-items:center; margin:16px 0; font-size:var(--text-xs); }
.selected-time { display: grid; gap: 6px; flex: 1 1 180px; } .selected-time span, dt { color: var(--text-tertiary); font-size: 11px; } .selected-time strong { font-size: var(--text-xs); font-weight: var(--weight-medium); } .selected-time { color:var(--text-secondary); } .selected-time strong { font-size:var(--text-xs); font-weight:400; }
dl { display: flex; flex-wrap: wrap; gap: var(--space-6); margin: 0; } dd { margin: 6px 0 0; font-size: var(--text-sm); font-variant-numeric: tabular-nums; } 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; } .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; } summary { cursor: pointer; color: var(--text-secondary); font-size: var(--text-xs); padding: 4px 0; }
.table-wrap { overflow: auto; max-height: 400px; margin-top: var(--space-3); } table { width: 100%; border-collapse: collapse; font-size: var(--text-xs); } .table-wrap { overflow: auto; max-height: 400px; margin-top: var(--space-3); } table { width: 100%; border-collapse: collapse; font-size: var(--text-xs); }
@@ -0,0 +1,62 @@
<script lang="ts">
import type { CapacityHost } from '$lib/capacity.js';
import { hostReadinessLabel } from '$lib/hosts.js';
import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.js';
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 restriction(host: CapacityHost) {
if (!host.connected) return 'Offline';
return host.readiness === 'ready' ? 'Not eligible' : hostReadinessLabel(host.readiness);
}
</script>
<div class="table-wrap"><table>
<thead><tr><th>Host</th><th>Additional instances</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>Instance limits: CPU {count(host.limits.cpu)} · Memory {count(host.limits.memory)} · Ports {host.limits.ports}.</p>
<dl>{#each [{ label: 'Running instance', value: host.calibration.running }, { label: 'Starting instance', 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 instances may need more resources. Provisioning 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 { overflow-x:auto; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
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>
+52 -54
View File
@@ -1,17 +1,16 @@
<script lang="ts"> <script lang="ts">
import BackLink from '$ui/components/BackLink.svelte'; import BackLink from '$ui/components/BackLink.svelte';
import { contextHref } from '$lib/navigation-context.js';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { beforeNavigate, afterNavigate, goto } from '$app/navigation'; import { beforeNavigate, afterNavigate, goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import type { CapacityData, CapacityGame, CapacityQuery, CapacityRange } from '$lib/capacity.js'; import type { CapacityData, CapacityGame, CapacityQuery, CapacityRange } from '$lib/capacity.js';
import { hostReadinessLabel } from '$lib/hosts.js';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import Field from '$ui/components/primitives/Field.svelte'; import Field from '$ui/components/primitives/Field.svelte';
import Select from '$ui/components/primitives/Select.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 CapacityChart from '$ui/components/capacity/CapacityChart.svelte';
let { data }: { data: CapacityData } = $props(); let { data }: { data: CapacityData } = $props();
@@ -75,71 +74,70 @@
<svelte:head><title>Capacity · Electric Cherry</title><meta name="description" content="Available game-server capacity, resource history, and host limits." /></svelte:head> <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}> <div class="page" aria-busy={navigating}>
<SectionHeader title="Capacity" subtitle="See what your hosts can run, what is limiting them, and how headroom changes."> <SectionHeader title="Capacity" />
{#snippet actions()}<Button href="/hosts">Manage hosts</Button><Button disabled={refreshing || navigating} onclick={() => void refresh()}>{refreshing ? 'Refreshing…' : 'Refresh'}</Button>{/snippet}
</SectionHeader>
{#if page.url.searchParams.has('returnTo')}<BackLink fallback="/hosts" />{/if} {#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></div>{/if} {#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="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> <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> <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 class="freshness"><span>{report.current.updatedAt ? `Updated ${$timeFormat.minuteTime(report.current.updatedAt)}` : 'Waiting for host reports'}</span><Button variant="ghost" size="sm" disabled={refreshing || navigating} onclick={() => void refresh()}>{refreshing ? 'Refreshing…' : 'Refresh'}</Button></div>
</div> </div>
<section class="overview" aria-labelledby="capacity-now-title"> <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> <div class="overview-heading"><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> <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>
<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> <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>Startup slots · Fleet-wide</dt><dd>{summary.startupSlots === null ? 'Unavailable' : `${count(summary.startupSlots)}${summary.startupLimit === null ? '' : ` of ${summary.startupLimit}`} available`}</dd></div>
</dl>
</section> </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} {#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"> <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> <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> {#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"> {:else}<CapacityHosts hosts={report.current.hosts} returnTo={page.url.pathname + page.url.search} />{/if}
<div class="host-title"><h3><a href={contextHref(`/hosts/${host.id}`, page.url)}>{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> </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> <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> </div>
<style> <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); } .page { display:grid; gap:var(--space-5); min-width:0; }
.filters { display: grid; grid-template-columns: minmax(180px, 260px) minmax(180px, 260px); gap: var(--space-3); } .toolbar,.overview-heading,.section-heading { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:16px; }
.ranges { display: flex; gap: 3px; padding: 4px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); } .toolbar { align-items:end; }
.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; } .filters { display:grid; grid-template-columns: minmax(180px,260px) minmax(180px,260px); gap:12px; }
.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; } .freshness { display:flex; align-items:center; gap:8px; color:var(--text-secondary); font-size:var(--text-xs); }
.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); } .overview { padding:20px 24px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); }
.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; } h2 { margin:0; font-size:var(--text-lg); font-weight:500; } .overview h2 { font-size:var(--text-xl); } .overview strong { font-weight:600; }
.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; } .overview p { margin:8px 0 0; color:var(--text-secondary); font-size:var(--text-xs); }
.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); } .queue-context { display:grid; gap:4px; font-size:var(--text-sm); color:var(--text-secondary); }
.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); } .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); }
.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; } .resources { display:flex; flex-wrap:wrap; gap:16px 40px; padding-top:16px; margin:16px 0 0; border-top:1px solid var(--border-subtle); }
.freshness { display: flex; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2); color: var(--text-tertiary); font-size: 11px; margin-top: -6px; } 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: 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); } .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; }
.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; } .warnings p { margin:0; } .warnings p+p { margin-top:8px; } .refresh-error { display:grid; gap:4px; }
.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); } .hosts { display:grid; gap:14px; } .section-heading>span { color:var(--text-secondary); font-size:var(--text-xs); }
.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); } .empty-hosts { background:var(--surface); border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:20px; }
.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; } .empty-hosts h3 { margin:0; font-size:var(--text-md); } .empty-hosts p { color:var(--text-secondary); font-size:var(--text-sm); }
.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; } summary { cursor:pointer; color:var(--text-secondary); font-size:var(--text-xs); padding:4px 0; }
.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); } .methodology p { color:var(--text-secondary); font-size:var(--text-xs); line-height:1.7; max-width:110ch; }
.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; } .ranges { display:flex; gap:3px; padding:3px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
.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; } .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; }
@media (max-width: 900px) { .resources { grid-template-columns: repeat(2, minmax(0, 1fr)); } .host-grid { grid-template-columns: 1fr; } } .ranges a[aria-current] { color:var(--text-primary); background:var(--surface-raised); }
@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; } } 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:1fr 1fr; } .overview { padding:16px; } .resources { gap:16px 24px; } }
</style> </style>
+78
View File
@@ -0,0 +1,78 @@
import { expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import { writable } from 'svelte/store';
import type { CapacityData, CapacityHost, CapacityHistory } from '../../src/lib/capacity.js';
import CapacityPage from '../../src/routes/capacity/+page.svelte';
import CapacityHosts from '../../src/lib/ui/components/capacity/CapacityHosts.svelte';
import CapacityChart from '../../src/lib/ui/components/capacity/CapacityChart.svelte';
vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/capacity?host=ec-1&game=t5') } }));
vi.mock('$app/stores', () => ({ page: writable({ url: new URL('http://localhost/capacity') }) }));
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), afterNavigate: vi.fn(), goto: vi.fn() }));
const allowance = { cpuCores: 0.5, memoryBytes: 1024 ** 3, source: 'host' as const, samples: 20, observedAt: '2026-10-06T12:00:00Z' };
const host = (changes: Partial<CapacityHost> = {}): CapacityHost => ({
id: 'ec-1', name: 'ec-1', connected: true, readiness: 'ready', updatedAt: '2026-10-06T12:00:00Z', eligible: true,
games: ['t5', 't6'], reasons: [], cpuCoresAvailable: 2, memoryBytesAvailable: 4 * 1024 ** 3,
portsAvailable: 1, totalPorts: 10, estimatedServers: 1, limits: { cpu: 4, memory: 4, ports: 1 }, limitingResource: 'Ports',
calibration: { running: allowance, startup: allowance }, ...changes
});
const history: CapacityHistory = { range: '24h', startAt: 1000, endAt: 5000, bucketMs: 1000, points: [
{ at: 1000, cpuCoresAvailable: 2, memoryBytesAvailable: 1024 ** 3, reportingHosts: 1, expectedHosts: 1 },
{ at: 2000, cpuCoresAvailable: null, memoryBytesAvailable: null, reportingHosts: 0, expectedHosts: 1 },
{ at: 3000, cpuCoresAvailable: 3, memoryBytesAvailable: 2 * 1024 ** 3, reportingHosts: 1, expectedHosts: 1 }
] };
const data = (): CapacityData => ({ query: { range: '24h', game: 't5', hostId: 'ec-1' }, history,
current: { updatedAt: '2026-10-06T12:00:00Z', hosts: [host()], hostOptions: [{ id: 'ec-1', name: 'ec-1' }], warnings: [], summary: {
estimatedServers: 1, cpuCoresAvailable: 2, memoryBytesAvailable: 4 * 1024 ** 3, portsAvailable: 1,
eligibleHosts: 1, totalHosts: 1, startupSlots: 3, startupLimit: 3, activeStartups: 0, queued: 0
} }
});
it('keeps current estimates and host comparison before history with scoped history controls', () => {
const body = render(CapacityPage, { props: { data: data() } }).body;
expect(body.indexOf('id="capacity-now-title"')).toBeLessThan(body.indexOf('id="host-capacity-title"'));
expect(body.indexOf('id="host-capacity-title"')).toBeLessThan(body.indexOf('id="capacity-history-title"'));
expect(body.indexOf('aria-label="History range"')).toBeGreaterThan(body.indexOf('id="capacity-history-title"'));
expect(body).toContain('range=7d&amp;game=t5&amp;host=ec-1');
expect(body).toContain('3 of 3 available');
expect(body).toContain('No queued instances');
expect(body).not.toContain('href="/provisioning"');
expect(body).not.toContain('Manage hosts');
});
it('keeps fleet-wide queue scope explicit and preserves unavailable estimates', () => {
const report = data(); report.current.summary.queued = 2; report.current.summary.estimatedServers = null;
const body = render(CapacityPage, { props: { data: report } }).body;
expect(body).toContain('Capacity estimate unavailable');
expect(body).toContain('href="/provisioning"');
expect(body).toContain('2 instances queued');
expect(body).toContain('Fleet-wide');
});
it('keeps ineligible hosts and their reasons visible while collapsing detailed assumptions', () => {
const body = render(CapacityHosts, { props: { returnTo: '/capacity', hosts: [host(), host({ id: 'missing-files', name: 'Missing files', eligible: false, estimatedServers: 0, limitingResource: null, reasons: ['Latest game files are not ready.'] }), host({ id: 'offline', name: 'Offline host', connected: false, eligible: false, estimatedServers: 0, cpuCoresAvailable: null, memoryBytesAvailable: null, limitingResource: null, reasons: ['Waiting for a fresh host report.'] })] } }).body;
expect(body).toContain('Additional instances</th>');
expect(body).toContain('Not eligible');
expect(body).toContain('Latest game files are not ready.');
expect(body).toContain('Offline');
expect(body).toContain('>—</td>');
expect(body).not.toContain('>Ready<');
expect(body).toMatch(/<tr hidden[^>]*id="capacity-host-ec-1"/);
expect(body).toContain('aria-expanded="false"');
expect(body).toContain('Running instance');
});
it('preserves chart gaps, keyboard inspection and reporting coverage in the compact chart', () => {
const body = render(CapacityChart, { props: { history, scope: 'All hosts' } }).body;
expect(body.match(/class="trend /g)).toHaveLength(2);
expect(body).toContain('role="slider"');
expect(body).toContain('1 of 1 hosts reporting');
expect(body).toContain('Reporting hosts');
expect(body).toContain('The game filter applies to current launch estimates only.');
expect(body).toContain('About this chart');
expect(body).not.toMatch(/<details[^>]*open/);
const empty = render(CapacityChart, { props: { history: { ...history, points: [history.points[1]] }, scope: 'All hosts' } }).body;
expect(empty).toContain('Waiting for capacity history');
expect(empty).toContain('No complete report');
});