Add host capacity sample viewer with filtering and pagination

This commit is contained in:
2026-10-04 22:22:01 -04:00
parent 148c74063b
commit 9c6d7cc347
7 changed files with 190 additions and 1 deletions
+7
View File
@@ -162,6 +162,13 @@ headroom**. Each calculation considers up to the latest 10,000 minute peaks.
Host-local evidence is preferred to account for different hardware; new hosts Host-local evidence is preferred to account for different hardware; new hosts
can borrow the fleet's learned allowance. Startup allowances are never lower can borrow the fleet's learned allowance. Startup allowances are never lower
than running allowances. The host page shows both allowances and their source. than running allowances. The host page shows both allowances and their source.
Expand **View samples** in Learned server capacity to inspect retained minute
peaks for the last 24 hours. Filter by startup/running phase or switch from this
host to the entire fleet. Rows show time, server and generation, CPU cores, and
memory in MiB before headroom; CPU and memory peaks may come from different
heartbeats. Results are paginated, and **Refresh samples** loads the latest stored
measurements without contacting agents. The allowance count may lag the table
until the next calculation or reflect a different source.
Lower allowances require at least three startup minute samples spanning two Lower allowances require at least three startup minute samples spanning two
minutes, or twenty running minute samples spanning five minutes. Until then, minutes, or twenty running minute samples spanning five minutes. Until then,
+7
View File
@@ -79,6 +79,13 @@ export interface ResourceAllowance {
samples: number; observedAt: string | null; samples: number; observedAt: string | null;
} }
export interface CapacityCalibration { running: ResourceAllowance; startup: ResourceAllowance } export interface CapacityCalibration { running: ResourceAllowance; startup: ResourceAllowance }
export interface CapacitySample {
hostId: string; hostName: string | null; instanceId: string; server: string | null;
generation: number; phase: 'startup' | 'running'; timestamp: string; cpuCores: number; memoryBytes: number;
}
export interface CapacitySamplePage {
samples: CapacitySample[]; total: number; page: number; pageSize: number;
}
export interface Host { export interface Host {
id: string; name: string; hostname: string; mode: HostMode; portMin: number; portMax: number; id: string; name: string; hostname: string; mode: HostMode; portMin: number; portMax: number;
bindAddress: string; publicAddress: string; minimumFreeBytes: number; createdAt: string; bindAddress: string; publicAddress: string; minimumFreeBytes: number; createdAt: string;
+22 -1
View File
@@ -1,6 +1,6 @@
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { ensureHostSchema } from './schema.js'; import { ensureHostSchema } from './schema.js';
import type { CapacityCalibration, HostInventory, ResourceAllowance } from '../../hosts.js'; import type { CapacityCalibration, CapacitySample, CapacitySamplePage, HostInventory, ResourceAllowance } from '../../hosts.js';
const BOOTSTRAP_CPU = 0.5; const BOOTSTRAP_CPU = 0.5;
const BOOTSTRAP_MEMORY = 1024 ** 3; const BOOTSTRAP_MEMORY = 1024 ** 3;
@@ -14,6 +14,27 @@ interface AllowanceRow {
} }
function database() { const handle = db(); ensureHostSchema(handle); return handle; } function database() { const handle = db(); ensureHostSchema(handle); return handle; }
/** Read retained minute peaks without contacting agents or recalculating allowances. */
export function capacitySamples(hostId: string, options: { scope: 'host' | 'fleet'; phase: 'all' | Phase; page: number }): CapacitySamplePage {
const handle = database();
const clauses = ['s.bucket >= ?'];
const values: (string | number)[] = [new Date(Date.now() - WINDOW_MS).toISOString()];
if (options.scope === 'host') { clauses.push('s.host_id = ?'); values.push(hostId); }
if (options.phase !== 'all') { clauses.push('s.phase = ?'); values.push(options.phase); }
const where = clauses.join(' AND ');
const total = (handle.prepare(`SELECT COUNT(*) AS count FROM ec_instance_resource_samples s WHERE ${where}`).get(...values) as { count: number }).count;
const pageSize = 50;
const page = Math.min(options.page, Math.max(1, Math.ceil(total / pageSize)));
const samples = handle.prepare(`SELECT s.host_id AS hostId, h.name AS hostName, s.instance_id AS instanceId,
i.server, s.generation, s.phase, s.bucket AS timestamp, s.cpu_cores AS cpuCores, s.memory_bytes AS memoryBytes
FROM ec_instance_resource_samples s
LEFT JOIN ec_hosts h ON h.id=s.host_id
LEFT JOIN native_instances i ON i.id=s.instance_id AND i.host_id=s.host_id
WHERE ${where} ORDER BY s.bucket DESC, s.host_id, s.instance_id, s.generation, s.phase LIMIT ? OFFSET ?`)
.all(...values, pageSize, (page - 1) * pageSize) as CapacitySample[];
return { samples, total, page, pageSize };
}
export function validateInstanceResources(inventory: HostInventory): void { export function validateInstanceResources(inventory: HostInventory): void {
if (inventory.instanceResourcesError !== undefined && (typeof inventory.instanceResourcesError !== 'string' if (inventory.instanceResourcesError !== undefined && (typeof inventory.instanceResourcesError !== 'string'
|| inventory.instanceResourcesError.length > 500)) throw new Error('Invalid instance resource sampling error.'); || inventory.instanceResourcesError.length > 500)) throw new Error('Invalid instance resource sampling error.');
@@ -0,0 +1,84 @@
<script lang="ts">
import type { CapacitySamplePage } from '$lib/hosts.js';
import Button from './primitives/Button.svelte';
let { hostId }: { hostId: string } = $props();
let open = $state(false);
let scope = $state('host');
let phase = $state('all');
let page = $state(1);
let revision = $state(0);
let result = $state<CapacitySamplePage | null>(null);
let loading = $state(false);
let error = $state('');
$effect(() => {
if (!open) return;
const url = `/api/hosts/${encodeURIComponent(hostId)}/samples?` + new URLSearchParams({ scope, phase, page: String(page) });
void revision;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
let active = true;
loading = true; error = ''; result = null;
void (async () => {
try {
const response = await fetch(url, { signal: controller.signal });
const body = await response.json();
if (!response.ok) throw new Error(body.message ?? body.error ?? 'Could not load samples.');
if (active) result = body;
} catch (cause) {
if (active) error = controller.signal.aborted ? 'Sample request timed out. Try refreshing.' : (cause as Error).message;
} finally { clearTimeout(timer); if (active) loading = false; }
})();
return () => { active = false; clearTimeout(timer); controller.abort(); };
});
</script>
<details bind:open>
<summary>View samples</summary>
{#if open}
<p>Retained minute peaks from the last 24 hours, before the 25% allowance headroom. CPU and memory peaks may come from different heartbeats. Choose the fleet to inspect measurements from other hosts used by fleet allowances.</p>
<div class="controls">
<label>Source<select bind:value={scope} onchange={() => page = 1}><option value="host">This host</option><option value="fleet">Entire fleet</option></select></label>
<label>Phase<select bind:value={phase} onchange={() => page = 1}><option value="all">All phases</option><option value="startup">Starting</option><option value="running">Running</option></select></label>
<Button size="sm" disabled={loading} onclick={() => { page = 1; revision++; }}>Refresh samples</Button>
</div>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if loading}<p role="status">Loading samples…</p>
{:else if result}
{#if result.total === 0}<p role="status">No {phase === 'all' ? '' : phase === 'startup' ? 'startup ' : 'running '}samples recorded for this source in the last 24 hours.</p>
{:else}
<p>{result.total.toLocaleString()} samples · newest first. Counts can differ from the allowance above, which is recalculated periodically.</p>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The overflow region needs keyboard focus for horizontal scrolling.) -->
<div class="table-wrap" tabindex="0" role="region" aria-label="Container resource samples">
<table><caption>CPU and memory peaks per container and minute; times are local.</caption>
<thead><tr><th scope="col">Time</th>{#if scope === 'fleet'}<th scope="col">Host</th>{/if}<th scope="col">Server</th><th scope="col">Phase</th><th scope="col">CPU cores</th><th scope="col">Memory</th></tr></thead>
<tbody>{#each result.samples as sample}
<tr><td><time datetime={sample.timestamp}>{new Date(sample.timestamp).toLocaleString()}</time></td>
{#if scope === 'fleet'}<td>{sample.hostName ?? sample.hostId}</td>{/if}
<td>{sample.server ?? sample.instanceId}<small title={sample.instanceId}>Generation {sample.generation}</small></td>
<td>{sample.phase === 'startup' ? 'Starting' : 'Running'}</td><td>{sample.cpuCores.toFixed(3)}</td><td>{(sample.memoryBytes / 1048576).toFixed(1)} MiB</td></tr>
{/each}</tbody>
</table>
</div>
<nav aria-label="Sample pages">
<Button size="sm" disabled={result.page <= 1} onclick={() => page = result!.page - 1}>Previous</Button>
<span>Page {result.page} of {Math.ceil(result.total / result.pageSize)}</span>
<Button size="sm" disabled={result.page * result.pageSize >= result.total} onclick={() => page = result!.page + 1}>Next</Button>
</nav>
{/if}
{/if}
{/if}
</details>
<style>
details { margin-top:20px; min-width:0; } summary { cursor:pointer; color:var(--accent-primary); font-weight:600; }
p, caption, small, nav { color:var(--text-secondary); font-size:var(--text-sm); line-height:1.6; }
.controls, nav { display:flex; flex-wrap:wrap; align-items:end; gap:12px; margin:16px 0; }
nav { align-items:center; } label { display:grid; gap:6px; font-size:var(--text-sm); }
select { padding:8px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); }
.table-wrap { max-width:100%; overflow:auto; } .table-wrap:focus-visible, summary:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
table { width:100%; border-collapse:collapse; text-align:left; font-size:var(--text-sm); }
caption { text-align:left; padding-bottom:8px; } td,th { padding:10px; border-bottom:1px solid var(--border-subtle); }
td { white-space:nowrap; } small { display:block; font-size:var(--text-xs); }
</style>
@@ -0,0 +1,17 @@
import { error, json } from '@sveltejs/kit';
import { getHost } from '$lib/server/hosts/index.js';
import { requireManagementOrigin } from '$lib/server/hosts/http.js';
import { capacitySamples } from '$lib/server/hosts/calibration.js';
export function GET({ request, params }: { request: Request; params: { id: string } }) {
requireManagementOrigin(request);
if (!getHost(params.id)) error(404, 'Host not found.');
const search = new URL(request.url).searchParams;
const scope = search.get('scope') ?? 'host';
const phase = search.get('phase') ?? 'all';
const page = Number(search.get('page') ?? '1');
if (scope !== 'host' && scope !== 'fleet') error(400, 'Invalid sample scope.');
if (phase !== 'all' && phase !== 'startup' && phase !== 'running') error(400, 'Invalid sample phase.');
if (!Number.isSafeInteger(page) || page < 1) error(400, 'Invalid sample page.');
return json(capacitySamples(params.id, { scope, phase, page }), { headers: { 'Cache-Control': 'no-store' } });
}
+2
View File
@@ -10,6 +10,7 @@
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte'; import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte';
import HostPreparation from '$ui/components/HostPreparation.svelte'; import HostPreparation from '$ui/components/HostPreparation.svelte';
import CapacitySamples from '$ui/components/CapacitySamples.svelte';
let host = $state<Host | null>(null); let operations = $state<HostOperation[]>([]); let host = $state<Host | null>(null); let operations = $state<HostOperation[]>([]);
let calibration = $state<CapacityCalibration | null>(null); let calibration = $state<CapacityCalibration | null>(null);
let loading = $state(true); let busy = $state(false); let error = $state(''); let notice = $state(''); let loading = $state(true); let busy = $state(false); let error = $state(''); let notice = $state('');
@@ -64,6 +65,7 @@
{/each}</dl> {/each}</dl>
{#if host.inventory.instanceResourcesError}<p role="status">{host.inventory.instanceResourcesError}</p>{/if} {#if host.inventory.instanceResourcesError}<p role="status">{host.inventory.instanceResourcesError}</p>{/if}
{#if calibration.startup.source === 'bootstrap' || calibration.running.source === 'bootstrap'}<p>Use agent 1.4.6 or later for resource sampling. Measurements arrive with agent heartbeats and are combined into one peak sample per container per minute. The first five minutes after a container starts count toward startup; later measurements count toward running. Temporary allowances stay in use until enough measurements are available.</p>{/if} {#if calibration.startup.source === 'bootstrap' || calibration.running.source === 'bootstrap'}<p>Use agent 1.4.6 or later for resource sampling. Measurements arrive with agent heartbeats and are combined into one peak sample per container per minute. The first five minutes after a container starts count toward startup; later measurements count toward running. Temporary allowances stay in use until enough measurements are available.</p>{/if}
{#key host.id}<CapacitySamples hostId={host.id} />{/key}
</section>{/if} </section>{/if}
<div class="grid"><section class="panel"><h2>Inventory</h2><dl> <div class="grid"><section class="panel"><h2>Inventory</h2><dl>
<dt>Hostname</dt><dd>{host.hostname || 'Pending'}</dd><dt>Agent</dt><dd>{host.inventory.agentVersion ?? 'Unknown'}</dd><dt>Platform</dt><dd>{host.inventory.os ?? 'Unknown'} · {host.inventory.architecture ?? 'Unknown'}</dd><dt>CPU</dt><dd>{host.inventory.cpu ?? 'Unknown'} ({host.inventory.cpuCount ?? '?'} cores)</dd><dt>CPU usage</dt><dd title="Average across all cores since the previous inventory sample">{host.inventory.cpuUsagePercent === undefined ? 'Not reported' : `${host.inventory.cpuUsagePercent.toFixed(1)}%`}{#if !host.connected && host.inventory.cpuUsagePercent !== undefined} (last reported){/if}</dd><dt>Memory available / total</dt><dd>{bytes(host.inventory.memoryAvailable)} / {bytes(host.inventory.memoryTotal)}</dd><dt>Game storage available / total</dt><dd>{bytes(host.inventory.storageAvailable)} / {bytes(host.inventory.storageTotal)}</dd><dt>Docker</dt><dd>{host.inventory.dockerVersion ?? 'Unknown'} · {host.inventory.dockerRunning ? 'Running' : 'Unavailable'}</dd><dt>Current operation</dt><dd>{host.inventory.currentOperation ?? 'None reported'}</dd> <dt>Hostname</dt><dd>{host.hostname || 'Pending'}</dd><dt>Agent</dt><dd>{host.inventory.agentVersion ?? 'Unknown'}</dd><dt>Platform</dt><dd>{host.inventory.os ?? 'Unknown'} · {host.inventory.architecture ?? 'Unknown'}</dd><dt>CPU</dt><dd>{host.inventory.cpu ?? 'Unknown'} ({host.inventory.cpuCount ?? '?'} cores)</dd><dt>CPU usage</dt><dd title="Average across all cores since the previous inventory sample">{host.inventory.cpuUsagePercent === undefined ? 'Not reported' : `${host.inventory.cpuUsagePercent.toFixed(1)}%`}{#if !host.connected && host.inventory.cpuUsagePercent !== undefined} (last reported){/if}</dd><dt>Memory available / total</dt><dd>{bytes(host.inventory.memoryAvailable)} / {bytes(host.inventory.memoryTotal)}</dd><dt>Game storage available / total</dt><dd>{bytes(host.inventory.storageAvailable)} / {bytes(host.inventory.storageTotal)}</dd><dt>Docker</dt><dd>{host.inventory.dockerVersion ?? 'Unknown'} · {host.inventory.dockerRunning ? 'Running' : 'Unavailable'}</dd><dt>Current operation</dt><dd>{host.inventory.currentOperation ?? 'None reported'}</dd>
@@ -38,6 +38,57 @@ async function learned() {
} }
describe('measured instance capacity', () => { describe('measured instance capacity', () => {
it('lists retained samples with phase filters, host scope, stable ordering, and bounded pages', async () => {
for (let minute = 0; minute < 101; minute++) await report(minute, { uptimeSeconds: minute < 5 ? minute * 60 : 600 });
const { capacitySamples } = await import('../../src/lib/server/hosts/calibration.js');
const first = capacitySamples(hostId, { scope: 'host', phase: 'all', page: 1 });
const second = capacitySamples(hostId, { scope: 'host', phase: 'all', page: 2 });
expect(first).toMatchObject({ total: 101, page: 1, pageSize: 50 });
expect(first.samples).toHaveLength(50);
expect(first.samples[0]).toMatchObject({ hostId, hostName: 'First', instanceId: sample.instanceId,
server: 't5_zombie_theater_instance-1', generation: 1, phase: 'running',
cpuCores: 0.1, memoryBytes: GiB / 4, timestamp: new Date(epoch + 100 * 60_000).toISOString() });
expect(new Set([...first.samples, ...second.samples].map(row => row.timestamp)).size).toBe(100);
expect(capacitySamples(hostId, { scope: 'host', phase: 'all', page: 999 })).toMatchObject({ page: 3, samples: [expect.any(Object)] });
const startup = capacitySamples(hostId, { scope: 'host', phase: 'startup', page: 1 });
expect(startup.total).toBe(5);
expect(startup.samples.every(row => row.phase === 'startup')).toBe(true);
expect(capacitySamples(hostId, { scope: 'host', phase: 'running', page: 1 }).total).toBe(96);
vi.setSystemTime(epoch + 26 * 60 * 60_000);
expect(capacitySamples(hostId, { scope: 'host', phase: 'all', page: 1 })).toMatchObject({ total: 0, samples: [] });
});
it('supports fleet samples and retains instance identity after retirement', async () => {
await report(0);
const hosts = await import('../../src/lib/server/hosts/index.js');
const other = hosts.createHost({ name: 'Second' });
hosts.enrollHost(hosts.createEnrollment(other.id).token, inventory);
await instance(other.id, 'instance-2');
await report(1, { instanceId: 'instance-2' }, other.id);
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE native_instances SET retired_at=? WHERE id=?').run(new Date().toISOString(), sample.instanceId);
const { capacitySamples } = await import('../../src/lib/server/hosts/calibration.js');
expect(capacitySamples(hostId, { scope: 'host', phase: 'all', page: 1 }).samples.map(row => row.instanceId)).toEqual([sample.instanceId]);
const fleet = capacitySamples(hostId, { scope: 'fleet', phase: 'all', page: 1 });
expect(fleet.total).toBe(2);
expect(fleet.samples.map(row => row.hostName)).toEqual(['Second', 'First']);
});
it('validates the samples API and rejects agent credentials and cross-origin reads', async () => {
await report(0);
const { GET } = await import('../../src/routes/api/hosts/[id]/samples/+server.js');
const request = (query = '', headers = {}, id = hostId) => GET({ request: new Request(`https://ec.test/api/hosts/${id}/samples${query}`, { headers }), params: { id } });
const response = request();
expect(response.headers.get('cache-control')).toBe('no-store');
expect(await response.json()).toMatchObject({ total: 1, samples: [expect.objectContaining({ cpuCores: 0.1 })] });
for (const query of ['?scope=wrong', '?phase=wrong', '?page=-1', '?page=1.5', '?page=NaN', '?page=Infinity']) {
expect(() => request(query)).toThrow(expect.objectContaining({ status: 400 }));
}
expect(() => request('', {}, 'missing')).toThrow(expect.objectContaining({ status: 404 }));
expect(() => request('', { origin: 'https://other.test' })).toThrow(expect.objectContaining({ status: 403 }));
expect(() => request('', { authorization: 'Bearer ec_host_test' })).toThrow(expect.objectContaining({ status: 403 }));
});
it('reports sampling failure without inventing measurements and clears it after recovery', async () => { it('reports sampling failure without inventing measurements and clears it after recovery', async () => {
const { heartbeat, getHost } = await import('../../src/lib/server/hosts/index.js'); const { heartbeat, getHost } = await import('../../src/lib/server/hosts/index.js');
const { capacityCalibration } = await import('../../src/lib/server/hosts/calibration.js'); const { capacityCalibration } = await import('../../src/lib/server/hosts/calibration.js');