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
@@ -79,6 +79,13 @@ export interface ResourceAllowance {
samples: number; observedAt: string | null;
}
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 {
id: string; name: string; hostname: string; mode: HostMode; portMin: number; portMax: number;
bindAddress: string; publicAddress: string; minimumFreeBytes: number; createdAt: string;
+22 -1
View File
@@ -1,6 +1,6 @@
import { db } from '../db/index.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_MEMORY = 1024 ** 3;
@@ -14,6 +14,27 @@ interface AllowanceRow {
}
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 {
if (inventory.instanceResourcesError !== undefined && (typeof inventory.instanceResourcesError !== 'string'
|| 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>