Add capacity overview with host resource history

This commit is contained in:
2026-10-05 12:06:29 -04:00
parent 7c76851252
commit 09ef8c6ec8
19 changed files with 845 additions and 10 deletions
+63
View File
@@ -0,0 +1,63 @@
import type { CapacityCalibration, HostReadiness } from './hosts.js';
export type CapacityRange = '1h' | '24h' | '7d';
export type CapacityGame = 'all' | 't5' | 't6';
export interface CapacityQuery { range: CapacityRange; hostId: string | null; game: CapacityGame }
export function parseCapacityQuery(params: URLSearchParams): CapacityQuery {
const range = params.get('range');
const game = params.get('game');
return { range: range === '1h' || range === '7d' ? range : '24h',
game: game === 't5' || game === 't6' ? game : 'all', hostId: params.get('host')?.trim().slice(0, 100) || null };
}
export interface CapacityHistoryPoint {
at: number;
cpuCoresAvailable: number | null;
memoryBytesAvailable: number | null;
reportingHosts: number;
expectedHosts: number;
}
export interface CapacityHistory {
range: CapacityRange;
startAt: number;
endAt: number;
bucketMs: number;
points: CapacityHistoryPoint[];
}
export interface CapacityHost {
id: string;
name: string;
readiness: HostReadiness;
connected: boolean;
updatedAt: string | null;
eligible: boolean;
games: ('t5' | 't6')[];
reasons: string[];
cpuCoresAvailable: number | null;
memoryBytesAvailable: number | null;
portsAvailable: number;
totalPorts: number;
estimatedServers: number | null;
limits: { cpu: number | null; memory: number | null; ports: number };
limitingResource: 'CPU' | 'Memory' | 'Ports' | null;
calibration: CapacityCalibration;
}
export interface CapacityCurrent {
updatedAt: string | null;
summary: {
estimatedServers: number | null;
cpuCoresAvailable: number | null;
memoryBytesAvailable: number | null;
portsAvailable: number | null;
eligibleHosts: number;
totalHosts: number;
startupSlots: number | null;
startupLimit: number | null;
activeStartups: number | null;
queued: number | null;
};
hosts: CapacityHost[];
hostOptions: { id: string; name: string }[];
warnings: string[];
}
export interface CapacityData { query: CapacityQuery; current: CapacityCurrent; history: CapacityHistory }
+11 -2
View File
@@ -3,7 +3,7 @@ import { fleetSettings } from '../settings.js';
import { subscribe } from '../pipeline/bus.js';
import { capacityCalibration } from './calibration.js';
import { HEARTBEAT_MAX_AGE_MS, listHosts } from './index.js';
import type { Host, NativePlacement } from '../../hosts.js';
import type { CapacityCalibration, Host, NativePlacement } from '../../hosts.js';
import type { LaunchAvailability } from '../../deployment-plan.js';
interface HostAvailability {
@@ -12,6 +12,7 @@ interface HostAvailability {
freeMemory: number | null;
runningCpu: number;
runningMemory: number;
calibration: CapacityCalibration;
ports: { port: number; bind_address: string }[];
}
interface Snapshot {
@@ -58,6 +59,7 @@ function observeHost(host: Host): HostAvailability {
const freeMemory = memoryTotal && memoryAvailable !== undefined && memoryAvailable <= memoryTotal
? Math.max(0, memoryAvailable - 500_000_000 - unmeasured * startup.memoryBytes) : null;
return { host, freeCpu, freeMemory, runningCpu: running.cpuCores, runningMemory: running.memoryBytes,
calibration: { running, startup },
ports: handle.prepare('SELECT port,bind_address FROM ec_host_ports WHERE host_id=?').all(host.id) as HostAvailability['ports'] };
}
@@ -90,13 +92,20 @@ subscribe(event => {
if (event.type === 'fleet-invalidated' || event.type === 'server-status') scheduleAvailabilityRefresh();
});
function freePorts(snapshot: HostAvailability, bindAddress?: string): number {
export function freePorts(snapshot: HostAvailability, bindAddress?: string): number {
const bind = bindAddress ?? snapshot.host.bindAddress;
const used = new Set(snapshot.ports.filter(row => row.port >= snapshot.host.portMin && row.port <= snapshot.host.portMax
&& (row.bind_address === bind || ['0.0.0.0', '::'].includes(bind) || ['0.0.0.0', '::'].includes(row.bind_address))).map(row => row.port));
return Math.max(0, snapshot.host.portMax - snapshot.host.portMin + 1 - used.size);
}
/** Share the launch estimate's already observed state without rerunning placement. */
export function readAvailabilitySnapshot(): Snapshot | null {
const snapshot = snapshots.get(db());
if (!snapshot) scheduleAvailabilityRefresh();
return snapshot ?? null;
}
/** Estimates only: no placement, historical scans, reservations, or live host requests. */
export function launchAvailability(selections: NativePlacement[]): { availability: LaunchAvailability; warnings: string[] } {
const snapshot = snapshots.get(db());
+66
View File
@@ -0,0 +1,66 @@
import { db } from '../db/index.js';
import { ensureHostSchema } from './schema.js';
import type { CapacityHistory, CapacityQuery, CapacityRange } from '../../capacity.js';
const windows: Record<CapacityRange, { duration: number; bucketMs: number }> = {
'1h': { duration: 3_600_000, bucketMs: 60_000 },
'24h': { duration: 86_400_000, bucketMs: 600_000 },
'7d': { duration: 7 * 86_400_000, bucketMs: 3_600_000 }
};
/** Read on the report worker: heartbeat history can contain millions of rows. */
export function readCapacityHistory(query: CapacityQuery, now = Date.now()): CapacityHistory {
const handle = db();
ensureHostSchema(handle);
const { duration, bucketMs } = windows[query.range];
const startAt = now - duration;
const count = duration / bucketMs;
const hosts = handle.prepare(`SELECT id,created_at FROM ec_hosts
WHERE deleted_at IS NULL ${query.hostId ? 'AND id=?' : ''} ORDER BY id`)
.all(...(query.hostId ? [query.hostId] : [])) as { id: string; created_at: string }[];
// The primary key bounds each host scan to the selected interval. Aggregate
// before crossing the worker boundary; do not return raw heartbeat rows.
const samples = handle.prepare(`SELECT
MIN(?, CAST(((CAST(strftime('%s',sampled_at) AS INTEGER)*1000
+ CAST(substr(strftime('%f',sampled_at),4,3) AS INTEGER)) - ?) / ? AS INTEGER)) AS bucket,
AVG(MAX(0,cpu_count * (0.95 - cpu_usage_percent / 100.0))) AS cpu,
AVG(MAX(0,memory_available - 500000000)) AS memory
FROM ec_host_resource_samples
WHERE host_id=? AND sampled_at>=? AND sampled_at<=?
AND cpu_count>0 AND cpu_usage_percent BETWEEN 0 AND 100
AND memory_total>0 AND memory_available BETWEEN 0 AND memory_total
GROUP BY bucket`);
const points = Array.from({ length: count }, (_, index) => ({
at: startAt + index * bucketMs,
cpuCoresAvailable: null as number | null,
memoryBytesAvailable: null as number | null,
reportingHosts: 0,
expectedHosts: 0
}));
for (const host of hosts) {
const createdAt = Date.parse(host.created_at);
for (const point of points) {
if (createdAt <= Math.min(now, point.at + bucketMs)) point.expectedHosts++;
}
const buckets = samples.all(count - 1, startAt, bucketMs, host.id,
new Date(startAt).toISOString(), new Date(now).toISOString()) as { bucket: number; cpu: number; memory: number }[];
for (const bucket of buckets) {
const point = points[bucket.bucket];
if (!point || createdAt > Math.min(now, point.at + bucketMs)) continue;
// Average each host first so one host reporting more frequently
// cannot change another host's contribution to fleet headroom.
point.reportingHosts++;
point.cpuCoresAvailable = (point.cpuCoresAvailable ?? 0) + bucket.cpu;
point.memoryBytesAvailable = (point.memoryBytesAvailable ?? 0) + bucket.memory;
}
}
for (const point of points) {
// A missing host is unknown, not zero available capacity. Keep the gap
// visible instead of presenting a partial sum as the fleet total.
if (point.reportingHosts !== point.expectedHosts) {
point.cpuCoresAvailable = null;
point.memoryBytesAvailable = null;
}
}
return { range: query.range, startAt, endAt: now, bucketMs, points };
}
+69
View File
@@ -0,0 +1,69 @@
import { setImmediate as yieldToController } from 'node:timers/promises';
import { config } from '../config.js';
import { newestReadyLibraryId } from '../game-files/index.js';
import { HEARTBEAT_MAX_AGE_MS } from './index.js';
import { freePorts, readAvailabilitySnapshot } from './availability.js';
import type { CapacityCurrent, CapacityHost, CapacityQuery } from '../../capacity.js';
/** The page and collection preview share heartbeat/lifecycle capacity snapshots. */
export async function readCapacityCurrent(query: CapacityQuery): Promise<CapacityCurrent> {
let snapshot = readAvailabilitySnapshot();
if (!snapshot) {
// The initial snapshot is populated after the request yields, without
// historical scans, placement, or a request to any host.
await yieldToController();
snapshot = readAvailabilitySnapshot();
}
if (!snapshot) return { updatedAt: null, hosts: [], hostOptions: [], warnings: ['Capacity measurements are updating.'],
summary: { estimatedServers: null, cpuCoresAvailable: null, memoryBytesAvailable: null, portsAvailable: null,
eligibleHosts: 0, totalHosts: 0, startupSlots: null, startupLimit: null, activeStartups: null, queued: null } };
const libraries = { t5: newestReadyLibraryId('t5'), t6: newestReadyLibraryId('t6') };
const requestedGames = query.game === 'all' ? ['t5', 't6'] as const : [query.game];
const now = Date.now();
const hosts: CapacityHost[] = [...snapshot.hosts.values()].filter(({ host }) => !query.hostId || host.id === query.hostId).map(observation => {
const { host, calibration } = observation;
const fresh = !!host.lastHeartbeat && now - Date.parse(host.lastHeartbeat) <= HEARTBEAT_MAX_AGE_MS;
const connected = host.connected && fresh;
const games = (['t5', 't6'] as const).filter(game => libraries[game] && host.inventory.libraries?.some(
library => library.id === libraries[game] && ['ready', 'outdated'].includes(library.state)));
const reasons: string[] = [];
if (!connected) reasons.push(host.enrolled ? 'Waiting for a fresh host report.' : 'This host has not enrolled yet.');
if (host.mode !== 'active') reasons.push(host.mode === 'draining' ? 'Host is draining.' : 'Host is disabled.');
if (host.decommission) reasons.push('Host is being removed.');
if (!host.credentialActive && host.enrolled) reasons.push('Host credentials are inactive.');
if (host.readiness !== 'ready') reasons.push(...host.readinessReasons);
if (!host.inventory.runtimeImages?.includes(config.komodo.defaultImage)) reasons.push('The default runtime is not ready.');
if (!requestedGames.some(game => games.includes(game))) reasons.push('Latest game files for this selection are not ready.');
if ((host.inventory.storageAvailable ?? 0) < host.minimumFreeBytes + 1024 ** 3) reasons.push('Storage is below the launch reserve.');
const eligible = connected && host.readiness === 'ready' && reasons.length === 0;
const cpuCoresAvailable = fresh ? observation.freeCpu : null;
const memoryBytesAvailable = fresh ? observation.freeMemory : null;
const portsAvailable = freePorts(observation);
const limits = { cpu: cpuCoresAvailable === null ? null : Math.max(0, Math.floor(cpuCoresAvailable / calibration.running.cpuCores)),
memory: memoryBytesAvailable === null ? null : Math.max(0, Math.floor(memoryBytesAvailable / calibration.running.memoryBytes)),
ports: portsAvailable };
const estimatedServers = !eligible ? 0 : limits.cpu === null || limits.memory === null ? null : Math.min(limits.cpu, limits.memory, limits.ports);
const limitingResource: CapacityHost['limitingResource'] = estimatedServers === null || !eligible ? null
: 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.');
else if (eligible && estimatedServers === 0) reasons.push(`No additional servers currently fit: ${limitingResource?.toLowerCase()} capacity is full.`);
return { id: host.id, name: host.name, readiness: host.readiness, connected, updatedAt: host.lastHeartbeat,
eligible, games, reasons: [...new Set(reasons)], cpuCoresAvailable, memoryBytesAvailable,
portsAvailable, totalPorts: host.portMax - host.portMin + 1, estimatedServers, limits, limitingResource, calibration };
}).sort((first, second) => first.name.localeCompare(second.name));
const eligible = hosts.filter(host => host.eligible);
const sum = (key: 'cpuCoresAvailable' | 'memoryBytesAvailable' | 'estimatedServers') =>
eligible.some(host => host[key] === null) ? null : eligible.reduce((total, host) => total + (host[key] ?? 0), 0);
const warnings: string[] = [];
if (!hosts.length) warnings.push(query.hostId ? 'The selected host is unavailable.' : 'Add a host to start tracking launch capacity.');
else if (!eligible.length) warnings.push('No host is ready for this game selection. Review the host details below.');
else if (eligible.length < hosts.length) warnings.push(`${hosts.length - eligible.length} host${hosts.length - eligible.length === 1 ? ' is' : 's are'} excluded from the available totals.`);
if (eligible.some(host => host.estimatedServers === null)) warnings.push('Some resource measurements are missing; the total estimate is unknown.');
if (!requestedGames.some(game => libraries[game])) warnings.push('Upload game files before launching this selection.');
return { updatedAt: snapshot.updatedAt, hosts,
hostOptions: [...snapshot.hosts.values()].map(({ host }) => ({ id: host.id, name: host.name })).sort((a, b) => a.name.localeCompare(b.name)),
warnings, summary: { estimatedServers: sum('estimatedServers'), cpuCoresAvailable: sum('cpuCoresAvailable'),
memoryBytesAvailable: sum('memoryBytesAvailable'), portsAvailable: eligible.reduce((total, host) => total + host.portsAvailable, 0),
eligibleHosts: eligible.length, totalHosts: hosts.length, startupSlots: Math.max(0, snapshot.startupLimit - snapshot.active),
startupLimit: snapshot.startupLimit, activeStartups: snapshot.active, queued: snapshot.queued } };
}
+10
View File
@@ -0,0 +1,10 @@
import { error } from '@sveltejs/kit';
import { capacityHistoryReport } from '../reports/index.js';
import { readCapacityCurrent } from './capacity-overview.js';
import type { CapacityData, CapacityQuery } from '../../capacity.js';
export async function capacityPage(query: CapacityQuery): Promise<CapacityData> {
const current = await readCapacityCurrent(query);
if (query.hostId && !current.hostOptions.some(host => host.id === query.hostId)) error(404, 'Host not found.');
return { query, current, history: await capacityHistoryReport(query) };
}
+12 -3
View File
@@ -4,6 +4,8 @@ import { config } from '../config.js';
import { db } from '../db/index.js';
import type { MetricsQuery, MetricsData } from '../../metrics.js';
import type { LeaderboardOptions, LeaderboardResponse, PlayerProfile, LeaderboardEntry } from '../../leaderboard.js';
import type { CapacityHistory, CapacityQuery } from '../../capacity.js';
import { ensureHostSchema } from '../hosts/schema.js';
class ReportWorker {
private worker: Worker | null = null;
@@ -17,8 +19,9 @@ class ReportWorker {
await worker?.terminate();
}
run<T>(kind: string, input: unknown): Promise<T> {
const requestInput = input as { query?: MetricsQuery; now?: number };
const key = JSON.stringify(kind === 'metrics' ? [kind, requestInput.query, Math.floor(requestInput.now! / 30_000)] : [kind, input]);
const requestInput = input as { query?: unknown; now?: number };
const timed = kind === 'metrics' || kind === 'capacity';
const key = JSON.stringify(timed ? [kind, requestInput.query, Math.floor(requestInput.now! / 30_000)] : [kind, input]);
const flight = this.flights.get(key);
if (flight) return flight;
if (this.pending.size >= 64) return Promise.reject(new Error('Reports are busy. Please retry shortly.'));
@@ -58,7 +61,13 @@ class ReportWorker {
}
const metrics = new ReportWorker();
const leaderboard = new ReportWorker();
const capacity = new ReportWorker();
export const metricsReport = (query: MetricsQuery) => metrics.run<MetricsData>('metrics', { query, now: Date.now() });
export const leaderboardReport = (query: LeaderboardOptions) => leaderboard.run<LeaderboardResponse>('leaderboard', query);
export const playerReport = (guid: string, offset: number, options: LeaderboardOptions) => leaderboard.run<{ profile: PlayerProfile | null; entry: LeaderboardEntry | null }>('player', { guid, offset, options });
export async function closeReports() { await Promise.all([metrics.close(), leaderboard.close()]); }
export const capacityHistoryReport = (query: CapacityQuery) => {
ensureHostSchema(db());
// Game changes current placement estimates, not measured host resources.
return capacity.run<CapacityHistory>('capacity', { query: { ...query, game: 'all' }, now: Date.now() });
};
export async function closeReports() { await Promise.all([metrics.close(), leaderboard.close(), capacity.close()]); }
+10 -3
View File
@@ -3,6 +3,7 @@ import { db } from '../db/index.js';
import { readMetrics } from '../fleet/metrics.js';
import { getLeaderboard, getPlayerLeaderboardEntry } from '../fleet/leaderboard.js';
import { getPlayerProfile } from '../fleet/player-profile.js';
import { readCapacityHistory } from '../hosts/capacity-history.js';
const cache = new Map<string, { value: unknown; bytes: number }>();
let bytes = 0;
@@ -11,12 +12,18 @@ parentPort!.on('message', ({ id, kind, input }) => {
try {
const result = db().transaction(() => {
const topic = kind === 'metrics' ? 'metrics' : 'leaderboard';
const { revision } = db().prepare('SELECT revision FROM report_revisions WHERE topic=?').get(topic) as { revision: number };
const now = kind === 'metrics' ? input.now : Date.now();
const key = JSON.stringify([kind, revision, kind === 'metrics' ? Math.floor(now / 30_000) : 0, kind === 'metrics' ? input.query : input]);
// Capacity refreshes every 30 seconds without adding write triggers
// to the heartbeat path. It also has its own worker, so large
// history ranges cannot queue ahead of fleet or player reports.
const revision = kind === 'capacity' ? 0
: (db().prepare('SELECT revision FROM report_revisions WHERE topic=?').get(topic) as { revision: number }).revision;
const timed = kind === 'metrics' || kind === 'capacity';
const now = timed ? input.now : Date.now();
const key = JSON.stringify([kind, revision, timed ? Math.floor(now / 30_000) : 0, timed ? input.query : input]);
const saved = cache.get(key);
if (saved) { cache.delete(key); cache.set(key, saved); return { value: saved.value, cached: true }; }
const value = kind === 'metrics' ? readMetrics(input.query, now)
: kind === 'capacity' ? readCapacityHistory(input.query, now)
: kind === 'leaderboard' ? getLeaderboard(input)
: { profile: getPlayerProfile(input.guid, input.offset, input.options), entry: getPlayerLeaderboardEntry(input.guid, input.options) };
const size = Buffer.byteLength(JSON.stringify(value));
@@ -118,6 +118,7 @@
{#if preview.availability && preview.servers.length}
<section class="availability" aria-label="Latest reported capacity">
<h3>Latest reported capacity</h3>
<a href="/capacity" target="_blank" rel="noopener">View capacity and history ↗</a>
<p class="muted">{preview.availability.estimatedAvailable === null ? 'An estimate is not available yet.' : `${preview.availability.estimatedAvailable} additional servers estimated to fit.`} {preview.availability.requested} requested.</p>
<dl>
<div><dt>CPU available</dt><dd>{preview.availability.cpuCoresAvailable === null ? 'Unknown' : `${preview.availability.cpuCoresAvailable.toFixed(1)} cores`}</dd></div>
@@ -0,0 +1,151 @@
<script lang="ts">
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 metric = $state<'cpu' | 'memory'>('cpu');
let selectedAt = $state<number | null>(null);
let tableOpen = $state(false);
const points = $derived(history.points);
const label = $derived(metric === 'cpu' ? 'CPU cores' : 'Memory (GiB)');
const selectedIndex = $derived.by(() => {
if (selectedAt !== null && points.length) {
let closest = 0;
for (let index = 1; index < points.length; index++) {
if (Math.abs(points[index].at - selectedAt) < Math.abs(points[closest].at - selectedAt)) closest = index;
}
return closest;
}
for (let index = points.length - 1; index >= 0; index--) if (value(points[index]) !== null) return index;
return Math.max(0, points.length - 1);
});
const selected = $derived(points[selectedIndex]);
const measured = $derived(points.filter(point => value(point) !== null));
const ceiling = $derived.by(() => {
const max = Math.max(1, ...measured.map(point => value(point)!));
const step = 10 ** Math.floor(Math.log10(max)) / 2;
return Math.ceil(max / step) * step;
});
const segments = $derived.by(() => {
const result: { x: number; y: number }[][] = [];
let segment: { x: number; y: number }[] = [];
for (const point of points) {
const amount = value(point);
if (amount === null) { if (segment.length) result.push(segment); segment = []; }
else segment.push({ x: x(point.at), y: y(amount) });
}
if (segment.length) result.push(segment);
return result;
});
const ticks = [1, .75, .5, .25, 0];
const numbers = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
function value(point: CapacityHistoryPoint): number | null {
return metric === 'cpu' ? point.cpuCoresAvailable : point.memoryBytesAvailable === null ? null : point.memoryBytesAvailable / 1024 ** 3;
}
function x(at: number) { return Math.max(0, Math.min(1000, (at - history.startAt) / Math.max(1, history.endAt - history.startAt) * 1000)); }
function y(amount: number) { return 230 - Math.max(0, Math.min(1, amount / ceiling)) * 220; }
function amount(value: number | null, unit: string) { return value === null ? 'No complete report' : `${numbers.format(value)} ${unit}`; }
function describe(point: CapacityHistoryPoint) {
return `${$timeFormat.period(point.at)}. ${amount(value(point), metric === 'cpu' ? 'cores available' : 'GiB available')}. ${point.reportingHosts} of ${point.expectedHosts} hosts reporting.`;
}
function inspect(event: PointerEvent) {
if (!points.length || (event.type === 'pointermove' && event.pointerType === 'touch')) return;
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
const at = history.startAt + Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)) * (history.endAt - history.startAt);
let closest = points[0];
for (const point of points) if (Math.abs(point.at - at) < Math.abs(closest.at - at)) closest = point;
selectedAt = closest.at;
}
function move(event: KeyboardEvent) {
if (!points.length || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const index = event.key === 'Home' ? 0 : event.key === 'End' ? points.length - 1
: Math.max(0, Math.min(points.length - 1, selectedIndex + (event.key === 'ArrowLeft' ? -1 : 1)));
selectedAt = points[index].at;
}
</script>
<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">
<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 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>
<div class="plot" role="slider" tabindex="0" aria-label={`${label} history. Use left and right arrows to inspect a period.`}
aria-valuemin="0" aria-valuemax={Math.max(0, points.length - 1)} aria-valuenow={selectedIndex}
aria-valuetext={selected ? describe(selected) : 'No history available yet'}
onpointermove={inspect} onpointerdown={inspect} onkeydown={move}>
<svg viewBox="0 0 1000 240" preserveAspectRatio="none" aria-hidden="true">
{#each ticks as tick}<line class="grid-line" x1="0" x2="1000" y1={y(ceiling * tick)} y2={y(ceiling * tick)} />{/each}
{#each segments as segment}
{#if segment.length > 1}<path class="area" d={`M${segment[0].x},230 ${segment.map(point => `L${point.x},${point.y}`).join(' ')} L${segment[segment.length - 1].x},230 Z`} />{/if}
<path class="trend" d={segment.map((point, index) => `${index ? 'L' : 'M'}${point.x},${point.y}`).join(' ')} />
{#if segment.length === 1}<circle class="point" cx={segment[0].x} cy={segment[0].y} r="3" />{/if}
{/each}
{#if selected}<line class="cursor" x1={x(selected.at)} x2={x(selected.at)} y1="5" y2="230" />
{#if value(selected) !== null}<circle class="selected-point" cx={x(selected.at)} cy={y(value(selected)!)} r="4" />{/if}
{/if}
</svg>
{#if !measured.length}<div class="empty-chart"><strong>Waiting for capacity history</strong><span>Fresh host reports build this timeline automatically.</span></div>{/if}
</div>
<div class="x-axis" aria-hidden="true"><span>{$timeFormat.period(history.startAt)}</span><span>{$timeFormat.period(history.endAt)}</span></div>
</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>
<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 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.) -->
<div class="table-wrap" role="region" aria-label="Capacity history data" tabindex="0"><table>
<caption>Measured headroom; a dash means a complete measurement is unavailable.</caption>
<thead><tr><th scope="col">Period</th><th scope="col">CPU cores</th><th scope="col">Memory (GiB)</th><th scope="col">Reporting hosts</th></tr></thead>
<tbody>{#each points as point (point.at)}<tr><th scope="row">{$timeFormat.period(point.at)}</th><td>{point.cpuCoresAvailable === null ? '—' : numbers.format(point.cpuCoresAvailable)}</td><td>{point.memoryBytesAvailable === null ? '—' : numbers.format(point.memoryBytesAvailable / 1024 ** 3)}</td><td>{point.reportingHosts} / {point.expectedHosts}</td></tr>{/each}</tbody>
</table></div>
{/if}
</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; }
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); }
.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; }
.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 span { position: absolute; right: 0; transform: translateY(-50%); }
.plot { position: relative; height: 240px; 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); }
.trend { fill: none; stroke: var(--accent-primary); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.point, .selected-point { fill: var(--accent-primary-hover); } .selected-point { stroke: var(--surface); stroke-width: 2; }
.cursor { stroke: var(--text-tertiary); stroke-width: 1; stroke-dasharray: 4 5; }
.x-axis { grid-column: 2; display: flex; justify-content: space-between; gap: var(--space-4); color: var(--text-tertiary); font-size: 11px; }
.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; }
.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); }
caption { text-align: left; color: var(--text-tertiary); margin-bottom: var(--space-3); } th, td { text-align: right; padding: 12px; white-space: nowrap; border-bottom: 1px solid var(--border-subtle); font-variant-numeric: tabular-nums; }
th:first-child { text-align: left; } tbody th { font-weight: var(--weight-regular); } thead th { color: var(--text-secondary); }
@media (max-width: 640px) { .panel { padding: var(--space-4); } .chart-grid { grid-template-columns: 32px minmax(0, 1fr); gap: 8px; } .readout { gap: var(--space-4); } dl { gap: var(--space-4); } .x-axis { font-size: 10px; } }
</style>