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,8 +1,9 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { CapacityHistory, CapacityHistoryPoint } from '$lib/capacity.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 selectedAt = $state<number | null>(null);
let tableOpen = $state(false);
@@ -67,12 +68,13 @@
<section class="panel" aria-labelledby="capacity-history-title">
<div class="heading">
<div><h2 id="capacity-history-title">Capacity over time</h2><p>Measured resource headroom · {scope}</p></div>
<div class="mode" role="group" aria-label="Chart resource">
<div><h2 id="capacity-history-title">Available resources over time</h2><p>{scope} · All workloads</p></div>
<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 === 'memory'} onclick={() => metric = 'memory'}>Memory</button>
</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="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>
@@ -97,13 +99,14 @@
</div>
{#if selected}
<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>
<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>
{/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>
{#if tableOpen}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable data must be reachable by keyboard.) -->
@@ -114,21 +117,24 @@
</table></div>
{/if}
</details>
</details>
</section>
<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; }
.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 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:focus-visible, .plot:focus-visible, .table-wrap: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; }
.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-4); color: var(--text-tertiary); font-size: 11px; }
.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%); }
.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 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); }
@@ -139,9 +145,9 @@
.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 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); }
.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); }
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; }
.readout { display:flex; flex-wrap:wrap; gap:12px 24px; align-items:center; margin:16px 0; font-size:var(--text-xs); }
.selected-time { color:var(--text-secondary); } .selected-time strong { font-size:var(--text-xs); font-weight:400; }
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; }
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); }
@@ -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>