Files
electric-cherry/src/lib/ui/components/capacity/CapacityChart.svelte
T
2026-10-14 19:31:26 -04:00

550 lines
18 KiB
Svelte

<script lang="ts">
import type { Snippet } from 'svelte';
import type { CapacityHistory, CapacityHistoryPoint } from '$lib/capacity.js';
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
let {
history,
scope,
controls
}: { history: CapacityHistory; scope: string; controls?: Snippet } = $props();
let metric = $state<'cpu' | 'memory'>('cpu');
let selectedAt = $state<number | null>(null);
let tableOpen = $state(false);
const points = $derived(history.points);
const label = $derived(metric === 'cpu' ? 'CPU cores' : 'Memory (GiB)');
const selectedIndex = $derived.by(() => {
if (selectedAt !== null && points.length) {
let closest = 0;
for (let index = 1; index < points.length; index++) {
if (
Math.abs(points[index].at - selectedAt) <
Math.abs(points[closest].at - selectedAt)
)
closest = index;
}
return closest;
}
for (let index = points.length - 1; index >= 0; index--)
if (value(points[index]) !== null) return index;
return Math.max(0, points.length - 1);
});
const selected = $derived(points[selectedIndex]);
const measured = $derived(points.filter((point) => value(point) !== null));
const ceiling = $derived.by(() => {
const max = Math.max(1, ...measured.map((point) => value(point)!));
const step = 10 ** Math.floor(Math.log10(max)) / 2;
return Math.ceil(max / step) * step;
});
const segments = $derived.by(() => {
const result: { x: number; y: number }[][] = [];
let segment: { x: number; y: number }[] = [];
for (const point of points) {
const amount = value(point);
if (amount === null) {
if (segment.length) result.push(segment);
segment = [];
} else segment.push({ x: x(point.at), y: y(amount) });
}
if (segment.length) result.push(segment);
return result;
});
const ticks = [1, 0.75, 0.5, 0.25, 0];
const numbers = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
function value(point: CapacityHistoryPoint): number | null {
return metric === 'cpu'
? point.cpuCoresAvailable
: point.memoryBytesAvailable === null
? null
: point.memoryBytesAvailable / 1024 ** 3;
}
function x(at: number) {
return Math.max(
0,
Math.min(
1000,
((at - history.startAt) / Math.max(1, history.endAt - history.startAt)) * 1000
)
);
}
function y(amount: number) {
return 230 - Math.max(0, Math.min(1, amount / ceiling)) * 220;
}
function amount(value: number | null, unit: string) {
return value === null ? 'No complete report' : `${numbers.format(value)} ${unit}`;
}
function describe(point: CapacityHistoryPoint) {
return `${$timeFormat.period(point.at)}. ${amount(value(point), metric === 'cpu' ? 'cores available' : 'GiB available')}. ${point.reportingHosts} of ${point.expectedHosts} hosts reporting.`;
}
function inspect(event: PointerEvent) {
if (!points.length || (event.type === 'pointermove' && event.pointerType === 'touch'))
return;
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
const at =
history.startAt +
Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)) *
(history.endAt - history.startAt);
let closest = points[0];
for (const point of points)
if (Math.abs(point.at - at) < Math.abs(closest.at - at)) closest = point;
selectedAt = closest.at;
}
function move(event: KeyboardEvent) {
if (!points.length || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key))
return;
event.preventDefault();
const index =
event.key === 'Home'
? 0
: event.key === 'End'
? points.length - 1
: Math.max(
0,
Math.min(
points.length - 1,
selectedIndex + (event.key === 'ArrowLeft' ? -1 : 1)
)
);
selectedAt = points[index].at;
}
</script>
<section class="panel" aria-labelledby="capacity-history-title">
<div class="heading">
<div>
<h2 id="capacity-history-title">Available resources over time</h2>
<p>{scope} · All workloads</p>
</div>
<div class="controls">
{@render controls?.()}
<div class="mode" role="group" aria-label="Chart resource">
<button
type="button"
aria-pressed={metric === 'cpu'}
onclick={() => (metric = 'cpu')}>CPU</button
>
<button
type="button"
aria-pressed={metric === 'memory'}
onclick={() => (metric = 'memory')}>Memory</button
>
</div>
</div>
</div>
<p class="scope-note">The game filter applies to current launch estimates only.</p>
<div class="axis-title">{label} available</div>
<div class="chart-grid">
<div class="y-axis" aria-hidden="true">
{#each ticks as tick}<span style:top={`${(1 - tick) * 100}%`}
>{numbers.format(ceiling * tick)}</span
>{/each}
</div>
<div
class="plot"
role="slider"
tabindex="0"
aria-label={`${label} history. Use left and right arrows to inspect a period.`}
aria-valuemin="0"
aria-valuemax={Math.max(0, points.length - 1)}
aria-valuenow={selectedIndex}
aria-valuetext={selected ? describe(selected) : 'No history available yet'}
onpointermove={inspect}
onpointerdown={inspect}
onkeydown={move}
>
<svg viewBox="0 0 1000 240" preserveAspectRatio="none" aria-hidden="true">
{#each ticks as tick}<line
class="grid-line"
x1="0"
x2="1000"
y1={y(ceiling * tick)}
y2={y(ceiling * tick)}
/>{/each}
{#each segments as segment}
{#if segment.length > 1}<path
class="area"
d={`M${segment[0].x},230 ${segment.map((point) => `L${point.x},${point.y}`).join(' ')} L${segment[segment.length - 1].x},230 Z`}
/>{/if}
<path
class="trend"
d={segment
.map((point, index) => `${index ? 'L' : 'M'}${point.x},${point.y}`)
.join(' ')}
/>
{#if segment.length === 1}<circle
class="point"
cx={segment[0].x}
cy={segment[0].y}
r="3"
/>{/if}
{/each}
{#if selected}<line
class="cursor"
x1={x(selected.at)}
x2={x(selected.at)}
y1="5"
y2="230"
/>
{#if value(selected) !== null}<circle
class="selected-point"
cx={x(selected.at)}
cy={y(value(selected)!)}
r="4"
/>{/if}
{/if}
</svg>
{#if !measured.length}<div class="empty-chart">
<strong>Waiting for capacity history</strong><span
>Fresh host reports build this timeline automatically.</span
>
</div>{/if}
</div>
<div class="x-axis" aria-hidden="true">
<span>{$timeFormat.period(history.startAt)}</span><span
>{$timeFormat.period(history.endAt)}</span
>
</div>
</div>
{#if selected}
<div class="readout" aria-label="Selected history period">
<div class="selected-time"><strong>{$timeFormat.period(selected.at)}</strong></div>
<dl>
<div>
<dt>CPU available</dt>
<dd>{amount(selected.cpuCoresAvailable, 'cores')}</dd>
</div>
<div>
<dt>Memory available</dt>
<dd>
{amount(
selected.memoryBytesAvailable === null
? null
: selected.memoryBytesAvailable / 1024 ** 3,
'GiB'
)}
</dd>
</div>
<div>
<dt>Reporting hosts</dt>
<dd>{selected.reportingHosts} / {selected.expectedHosts}</dd>
</div>
</dl>
</div>
{/if}
<details class="about">
<summary>About this chart</summary>
<p class="note">
Available resources from currently active hosts after the operating-system reserve,
averaged per period. Disabled and draining hosts are excluded. Gaps mean reporting is
incomplete. Reporting-host coverage is shown for each selected period. This history
includes all workloads on the selected hosts; it does not reconstruct historical launch
eligibility, queued jobs, or port availability.
</p>
<details bind:open={tableOpen}>
<summary>View history data · {$timeZone.replaceAll('_', ' ')}</summary>
{#if tableOpen}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable data must be reachable by keyboard.) -->
<div
class="table-wrap"
role="region"
aria-label="Capacity history data"
tabindex="0"
>
<table>
<caption
>Measured headroom; a dash means a complete measurement is unavailable.</caption
>
<thead
><tr
><th scope="col">Period</th><th scope="col">CPU cores</th><th
scope="col">Memory (GiB)</th
><th scope="col">Reporting hosts</th></tr
></thead
>
<tbody
>{#each points as point (point.at)}<tr
><th scope="row">{$timeFormat.period(point.at)}</th><td
>{point.cpuCoresAvailable === null
? '—'
: numbers.format(point.cpuCoresAvailable)}</td
><td
>{point.memoryBytesAvailable === null
? '—'
: numbers.format(
point.memoryBytesAvailable / 1024 ** 3
)}</td
><td>{point.reportingHosts} / {point.expectedHosts}</td></tr
>{/each}</tbody
>
</table>
</div>
{/if}
</details>
</details>
</section>
<style>
.panel {
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: var(--space-5);
min-width: 0;
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
.heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-4);
flex-wrap: wrap;
}
.heading > div:first-child {
flex: 1 1 240px;
min-width: 0;
}
.heading p {
margin: var(--space-2) 0 0;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
min-width: 0;
max-width: 100%;
}
.scope-note {
margin: 8px 0 0;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.mode {
display: flex;
gap: 3px;
padding: 3px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.mode button {
padding: 7px 14px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: var(--text-xs);
cursor: pointer;
}
.mode button[aria-pressed='true'] {
color: var(--text-primary);
background: var(--surface-raised);
}
.mode button:focus-visible,
.plot:focus-visible,
.table-wrap:focus-visible,
summary:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
.axis-title {
margin-top: var(--space-4);
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.chart-grid {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 10px;
margin-top: 14px;
}
.y-axis {
position: relative;
height: 176px;
margin-top: 8px;
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.y-axis span {
position: absolute;
right: 0;
transform: translateY(-50%);
}
.plot {
position: relative;
height: 192px;
border-radius: 3px;
cursor: crosshair;
touch-action: pan-y;
}
svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
}
svg path,
svg line {
vector-effect: non-scaling-stroke;
}
.grid-line {
stroke: var(--border-subtle);
stroke-width: 1;
}
.area {
fill: color-mix(in oklab, var(--accent-primary) 10%, transparent);
}
.trend {
fill: none;
stroke: var(--accent-primary);
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
}
.point,
.selected-point {
fill: var(--accent-primary-hover);
}
.selected-point {
stroke: var(--surface);
stroke-width: 2;
}
.cursor {
stroke: var(--text-tertiary);
stroke-width: 1;
stroke-dasharray: 4 5;
}
.x-axis {
grid-column: 2;
display: flex;
justify-content: space-between;
gap: var(--space-4);
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.x-axis span:last-child {
text-align: right;
}
.empty-chart {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
text-align: center;
padding: var(--space-4);
pointer-events: none;
}
.empty-chart strong {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
}
.empty-chart span {
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.readout {
display: flex;
flex-wrap: wrap;
gap: 12px 24px;
align-items: center;
margin: 16px 0;
font-size: var(--text-xs);
}
.selected-time {
color: var(--text-secondary);
}
.selected-time strong {
font-size: var(--text-xs);
font-weight: 400;
}
dl {
display: flex;
flex-wrap: wrap;
gap: 12px 24px;
margin: 0;
}
dl > div {
display: flex;
gap: 6px;
}
dt {
color: var(--text-secondary);
}
dd {
margin: 0;
font-variant-numeric: tabular-nums;
}
.note {
color: var(--text-tertiary);
font-size: var(--text-xs);
line-height: 1.65;
margin: var(--space-4) 0;
max-width: 115ch;
}
summary {
cursor: pointer;
color: var(--text-secondary);
font-size: var(--text-xs);
padding: 4px 0;
}
.table-wrap {
position: relative;
overflow: auto;
max-height: 400px;
margin-top: var(--space-3);
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-xs);
}
caption {
text-align: left;
color: var(--text-tertiary);
margin-bottom: var(--space-3);
}
th,
td {
text-align: right;
padding: 12px;
white-space: nowrap;
border-bottom: 1px solid var(--border-subtle);
font-variant-numeric: tabular-nums;
}
th:first-child {
text-align: left;
}
tbody th {
font-weight: var(--weight-regular);
}
thead th {
color: var(--text-secondary);
}
@media (max-width: 640px) {
.panel {
padding: var(--space-4);
}
.chart-grid {
grid-template-columns: 32px minmax(0, 1fr);
gap: 8px;
}
.readout {
gap: var(--space-4);
}
dl {
gap: var(--space-4);
}
.x-axis {
font-size: var(--text-xs);
}
}
</style>