85 lines
5.7 KiB
Svelte
85 lines
5.7 KiB
Svelte
<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, used without extra allowance padding. 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>
|