Files
electric-cherry/src/lib/ui/components/CapacitySamples.svelte
T
2026-10-10 00:19:48 -04:00

220 lines
7.8 KiB
Svelte

<script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte';
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">
<Field label="Source">
<Select bind:value={scope} onchange={() => (page = 1)}
><option value="host">This host</option><option value="fleet"
>Entire fleet</option
></Select
></Field
>
<Field 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
></Field
>
<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><RelativeTime value={sample.timestamp} /></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;
}
.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>