220 lines
7.8 KiB
Svelte
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>
|