Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -3,7 +3,11 @@
|
||||
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 {
|
||||
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);
|
||||
@@ -13,17 +17,22 @@
|
||||
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;
|
||||
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;
|
||||
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 measured = $derived(points.filter((point) => value(point) !== null));
|
||||
const ceiling = $derived.by(() => {
|
||||
const max = Math.max(1, ...measured.map(point => value(point)!));
|
||||
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;
|
||||
});
|
||||
@@ -32,127 +41,508 @@
|
||||
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 (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, .75, .5, .25, 0];
|
||||
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;
|
||||
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 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;
|
||||
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);
|
||||
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;
|
||||
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;
|
||||
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)));
|
||||
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>
|
||||
<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}
|
||||
<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}>
|
||||
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 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}
|
||||
{#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 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}
|
||||
{#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 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>
|
||||
<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 host resources after the operating-system reserve, averaged per period. 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 class="about">
|
||||
<summary>About this chart</summary>
|
||||
<p class="note">
|
||||
Available host resources after the operating-system reserve, averaged per period. 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); } }
|
||||
.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>
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
let { hosts, returnTo }: { hosts: CapacityHost[]; returnTo: string } = $props();
|
||||
let expanded = $state<string[]>([]);
|
||||
const numbers = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
|
||||
function count(value: number | null) { return value === null ? '—' : numbers.format(value); }
|
||||
function memory(value: number | null) { return value === null ? '—' : `${numbers.format(value / 1024 ** 3)} GiB`; }
|
||||
function count(value: number | null) {
|
||||
return value === null ? '—' : numbers.format(value);
|
||||
}
|
||||
function memory(value: number | null) {
|
||||
return value === null ? '—' : `${numbers.format(value / 1024 ** 3)} GiB`;
|
||||
}
|
||||
function restriction(host: CapacityHost) {
|
||||
if (!host.connected) return 'Offline';
|
||||
return host.readiness === 'ready' ? 'Not eligible' : hostReadinessLabel(host.readiness);
|
||||
@@ -15,50 +19,220 @@
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Host capacity comparison" tabindex="0"><table>
|
||||
<thead><tr><th>Host</th><th>Additional servers</th><th>Limited by</th><th>CPU available</th><th>Memory available</th><th>Free ports</th><th><span class="sr-only">Details</span></th></tr></thead>
|
||||
<tbody>{#each hosts as host (host.id)}
|
||||
<tr>
|
||||
<td><a class="name" href={contextHref(`/hosts/${host.id}`, returnTo)}>{host.name}</a></td>
|
||||
<td class="estimate">{count(host.estimatedServers)}</td>
|
||||
<td class="limitation">{#if host.eligible}<strong>{host.limitingResource ?? 'Measurements pending'}</strong>{:else}<span>{restriction(host)}</span>{#if host.reasons[0]}<a class="reason" href={contextHref(`/hosts/${host.id}?tab=setup`, returnTo)}>{host.reasons[0]}</a>{/if}{/if}</td>
|
||||
<td>{count(host.cpuCoresAvailable)}{host.cpuCoresAvailable === null ? '' : ' cores'}</td>
|
||||
<td>{memory(host.memoryBytesAvailable)}</td>
|
||||
<td>{host.portsAvailable}</td>
|
||||
<td><button aria-label={`Details for ${host.name}`} aria-expanded={expanded.includes(host.id)} aria-controls={`capacity-host-${host.id}`} onclick={() => expanded = expanded.includes(host.id) ? expanded.filter(id => id !== host.id) : [...expanded, host.id]}>Details</button></td>
|
||||
</tr>
|
||||
<tr hidden={!expanded.includes(host.id)} id={`capacity-host-${host.id}`}><td colspan="7" class="detail-cell">
|
||||
<div class="detail-content">
|
||||
{#if host.reasons.length}<ul>{#each host.reasons as reason}<li>{reason}</li>{/each}</ul>{/if}
|
||||
<p>Games ready: {host.games.length ? host.games.map(game => game === 't5' ? 'Black Ops I' : 'Black Ops II').join(', ') : 'None'} · {host.portsAvailable} of {host.totalPorts} ports free.</p>
|
||||
<p>Server limits: CPU {count(host.limits.cpu)} · Memory {count(host.limits.memory)} · Ports {host.limits.ports}.</p>
|
||||
<dl>{#each [{ label: 'Running server', value: host.calibration.running }, { label: 'Starting server', value: host.calibration.startup }] as allowance}<div>
|
||||
<dt>{allowance.label}</dt><dd>{count(allowance.value.cpuCores)} cores · {memory(allowance.value.memoryBytes)}</dd>
|
||||
<dd class="secondary">{allowance.value.source === 'bootstrap' ? 'Default allowance' : `${allowance.value.source === 'host' ? 'Host' : 'Fleet'} observations · ${allowance.value.samples} samples`}</dd>
|
||||
</div>{/each}</dl>
|
||||
<p>Starting servers may need more resources. The controller checks current capacity before reserving a host and port.</p>
|
||||
<p>{host.updatedAt ? `Reported ${$timeFormat.dateTime(host.updatedAt)}` : 'No host report yet'}</p>
|
||||
</div>
|
||||
</td></tr>
|
||||
{/each}</tbody>
|
||||
</table></div>
|
||||
<div class="table-wrap" role="region" aria-label="Host capacity comparison" tabindex="0">
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
><th>Host</th><th>Additional servers</th><th>Limited by</th><th>CPU available</th
|
||||
><th>Memory available</th><th>Free ports</th><th
|
||||
><span class="sr-only">Details</span></th
|
||||
></tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each hosts as host (host.id)}
|
||||
<tr>
|
||||
<td
|
||||
><a class="name" href={contextHref(`/hosts/${host.id}`, returnTo)}
|
||||
>{host.name}</a
|
||||
></td
|
||||
>
|
||||
<td class="estimate">{count(host.estimatedServers)}</td>
|
||||
<td class="limitation"
|
||||
>{#if host.eligible}<strong
|
||||
>{host.limitingResource ?? 'Measurements pending'}</strong
|
||||
>{:else}<span>{restriction(host)}</span>{#if host.reasons[0]}<a
|
||||
class="reason"
|
||||
href={contextHref(`/hosts/${host.id}?tab=setup`, returnTo)}
|
||||
>{host.reasons[0]}</a
|
||||
>{/if}{/if}</td
|
||||
>
|
||||
<td
|
||||
>{count(host.cpuCoresAvailable)}{host.cpuCoresAvailable === null
|
||||
? ''
|
||||
: ' cores'}</td
|
||||
>
|
||||
<td>{memory(host.memoryBytesAvailable)}</td>
|
||||
<td>{host.portsAvailable}</td>
|
||||
<td
|
||||
><button
|
||||
aria-label={`Details for ${host.name}`}
|
||||
aria-expanded={expanded.includes(host.id)}
|
||||
aria-controls={`capacity-host-${host.id}`}
|
||||
onclick={() =>
|
||||
(expanded = expanded.includes(host.id)
|
||||
? expanded.filter((id) => id !== host.id)
|
||||
: [...expanded, host.id])}>Details</button
|
||||
></td
|
||||
>
|
||||
</tr>
|
||||
<tr hidden={!expanded.includes(host.id)} id={`capacity-host-${host.id}`}
|
||||
><td colspan="7" class="detail-cell">
|
||||
<div class="detail-content">
|
||||
{#if host.reasons.length}<ul>
|
||||
{#each host.reasons as reason}<li>{reason}</li>{/each}
|
||||
</ul>{/if}
|
||||
<p>
|
||||
Games ready: {host.games.length
|
||||
? host.games
|
||||
.map((game) =>
|
||||
game === 't5' ? 'Black Ops I' : 'Black Ops II'
|
||||
)
|
||||
.join(', ')
|
||||
: 'None'} · {host.portsAvailable} of {host.totalPorts} ports free.
|
||||
</p>
|
||||
<p>
|
||||
Server limits: CPU {count(host.limits.cpu)} · Memory {count(
|
||||
host.limits.memory
|
||||
)} · Ports {host.limits.ports}.
|
||||
</p>
|
||||
<dl>
|
||||
{#each [{ label: 'Running server', value: host.calibration.running }, { label: 'Starting server', value: host.calibration.startup }] as allowance}<div
|
||||
>
|
||||
<dt>{allowance.label}</dt>
|
||||
<dd>
|
||||
{count(allowance.value.cpuCores)} cores · {memory(
|
||||
allowance.value.memoryBytes
|
||||
)}
|
||||
</dd>
|
||||
<dd class="secondary">
|
||||
{allowance.value.source === 'bootstrap'
|
||||
? 'Default allowance'
|
||||
: `${allowance.value.source === 'host' ? 'Host' : 'Fleet'} observations · ${allowance.value.samples} samples`}
|
||||
</dd>
|
||||
</div>{/each}
|
||||
</dl>
|
||||
<p>
|
||||
Starting servers may need more resources. The controller checks
|
||||
current capacity before reserving a host and port.
|
||||
</p>
|
||||
<p>
|
||||
{host.updatedAt
|
||||
? `Reported ${$timeFormat.dateTime(host.updatedAt)}`
|
||||
: 'No host report yet'}
|
||||
</p>
|
||||
</div>
|
||||
</td></tr
|
||||
>
|
||||
{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.table-wrap { position:relative; overflow-x:auto; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
|
||||
.table-wrap:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
|
||||
table { width:100%; min-width:800px; border-collapse:collapse; text-align:left; font-size:var(--text-sm); }
|
||||
th { padding:12px 16px; color:var(--text-secondary); font-size:var(--text-xs); font-weight:500; background:var(--surface-sunken); }
|
||||
td { padding:14px 16px; border-top:1px solid var(--border-subtle); background:var(--surface); font-variant-numeric:tabular-nums; }
|
||||
tr:hover>td { background:var(--surface-raised); }
|
||||
a { color:var(--accent-primary-hover); } .name { color:var(--text-primary); font-weight:600; text-decoration:none; }
|
||||
.name:hover { color:var(--accent-primary-hover); text-decoration:underline; }
|
||||
.estimate { font-weight:600; } .limitation { max-width:280px; } .limitation strong { font-weight:500; }
|
||||
.reason { display:block; font-size:var(--text-xs); color:var(--text-secondary); line-height:1.5; margin-top:4px; }
|
||||
button { border:0; background:none; color:var(--accent-primary-hover); font:inherit; cursor:pointer; padding:4px; }
|
||||
button:focus-visible,a:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
|
||||
.detail-cell { background:var(--surface-sunken); } .detail-content { display:grid; gap:12px; font-size:var(--text-xs); color:var(--text-secondary); }
|
||||
p,dl,ul { margin:0; } ul { padding-left:18px; line-height:1.7; }
|
||||
dl { display:flex; gap:40px; flex-wrap:wrap; } dt { color:var(--text-primary); font-weight:500; } dd { margin:6px 0 0; }
|
||||
.secondary { color:var(--text-tertiary); }
|
||||
.sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
|
||||
.table-wrap {
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.table-wrap:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 800px;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
th {
|
||||
padding: 12px 16px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
td {
|
||||
padding: 14px 16px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
background: var(--surface);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
tr:hover > td {
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
a {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
.name {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.name:hover {
|
||||
color: var(--accent-primary-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.estimate {
|
||||
font-weight: 600;
|
||||
}
|
||||
.limitation {
|
||||
max-width: 280px;
|
||||
}
|
||||
.limitation strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
.reason {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-top: 4px;
|
||||
}
|
||||
button {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--accent-primary-hover);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
button:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.detail-cell {
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
.detail-content {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
p,
|
||||
dl,
|
||||
ul {
|
||||
margin: 0;
|
||||
}
|
||||
ul {
|
||||
padding-left: 18px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
dl {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
dt {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
dd {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
.secondary {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import type { RebalancePreview, RebalanceReport, RebalanceRun, RebalanceSettings } from '$lib/rebalancing.js';
|
||||
import type {
|
||||
RebalancePreview,
|
||||
RebalanceReport,
|
||||
RebalanceRun,
|
||||
RebalanceSettings
|
||||
} from '$lib/rebalancing.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { rebalanceRunLabels } from '$ui/rebalancing-presentation.js';
|
||||
@@ -9,8 +14,14 @@
|
||||
import RebalancePreviewView from './RebalancePreviewView.svelte';
|
||||
import RebalanceRunView from './RebalanceRunView.svelte';
|
||||
|
||||
let { canManage, preferredHost = null, preferredRun = null }: {
|
||||
canManage: boolean; preferredHost?: string | null; preferredRun?: string | null;
|
||||
let {
|
||||
canManage,
|
||||
preferredHost = null,
|
||||
preferredRun = null
|
||||
}: {
|
||||
canManage: boolean;
|
||||
preferredHost?: string | null;
|
||||
preferredRun?: string | null;
|
||||
} = $props();
|
||||
let report = $state<RebalanceReport | null>(null);
|
||||
let selectedHosts = $state<string[]>([]);
|
||||
@@ -29,132 +40,472 @@
|
||||
let sequence = 0;
|
||||
let loadRequest: AbortController | null = null;
|
||||
let actionRequest: AbortController | null = null;
|
||||
const activeRun = $derived(report?.runs.find(run => run.state === 'running' || run.state === 'stopping') ?? null);
|
||||
const run = $derived(report?.runs.find(item => item.id === selectedRun) ?? activeRun ?? report?.runs[0] ?? null);
|
||||
const validSettings = $derived(selectedHosts.length >= 2 && selectedHosts.length <= 100 && Number.isInteger(maxMoves) && maxMoves! >= 1 && maxMoves! <= 20);
|
||||
const activeRun = $derived(
|
||||
report?.runs.find((run) => run.state === 'running' || run.state === 'stopping') ?? null
|
||||
);
|
||||
const run = $derived(
|
||||
report?.runs.find((item) => item.id === selectedRun) ?? activeRun ?? report?.runs[0] ?? null
|
||||
);
|
||||
const validSettings = $derived(
|
||||
selectedHosts.length >= 2 &&
|
||||
selectedHosts.length <= 100 &&
|
||||
Number.isInteger(maxMoves) &&
|
||||
maxMoves! >= 1 &&
|
||||
maxMoves! <= 20
|
||||
);
|
||||
const expired = $derived(!!preview && Date.parse(preview.expiresAt) <= now);
|
||||
function editSettings() { preview = null; actionError = ''; notice = ''; }
|
||||
function editSettings() {
|
||||
preview = null;
|
||||
actionError = '';
|
||||
notice = '';
|
||||
}
|
||||
function toggleHost(id: string, checked: boolean) {
|
||||
selectedHosts = checked ? [...selectedHosts, id] : selectedHosts.filter(value => value !== id);
|
||||
selectedHosts = checked
|
||||
? [...selectedHosts, id]
|
||||
: selectedHosts.filter((value) => value !== id);
|
||||
editSettings();
|
||||
}
|
||||
function cancelLoad() { sequence++; loadRequest?.abort(); loadRequest = null; loading = false; }
|
||||
function cancelLoad() {
|
||||
sequence++;
|
||||
loadRequest?.abort();
|
||||
loadRequest = null;
|
||||
loading = false;
|
||||
}
|
||||
async function load(signal?: AbortSignal) {
|
||||
if (busy || loading) return;
|
||||
const current = ++sequence;
|
||||
const controller = new AbortController();
|
||||
loadRequest = controller; loading = true;
|
||||
loadRequest = controller;
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch('/api/rebalancing', { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(20_000), ...(signal ? [signal] : [])]) });
|
||||
const response = await fetch('/api/rebalancing', {
|
||||
signal: AbortSignal.any([
|
||||
controller.signal,
|
||||
AbortSignal.timeout(20_000),
|
||||
...(signal ? [signal] : [])
|
||||
])
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not refresh rebalancing.');
|
||||
if (current !== sequence) return;
|
||||
report = body as RebalanceReport; refreshError = ''; checkedAt = Date.now(); now = Date.now();
|
||||
report = body as RebalanceReport;
|
||||
refreshError = '';
|
||||
checkedAt = Date.now();
|
||||
now = Date.now();
|
||||
if (!initialized) {
|
||||
selectedHosts = preferredHost ? report.hosts.filter(host => host.id === preferredHost && host.eligible).map(host => host.id)
|
||||
: report.hosts.filter(host => host.eligible).map(host => host.id).slice(0, 100);
|
||||
selectedHosts = preferredHost
|
||||
? report.hosts
|
||||
.filter((host) => host.id === preferredHost && host.eligible)
|
||||
.map((host) => host.id)
|
||||
: report.hosts
|
||||
.filter((host) => host.eligible)
|
||||
.map((host) => host.id)
|
||||
.slice(0, 100);
|
||||
initialized = true;
|
||||
} else {
|
||||
const eligible = new Set(report.hosts.filter(host => host.eligible).map(host => host.id));
|
||||
const available = selectedHosts.filter(id => eligible.has(id));
|
||||
const eligible = new Set(
|
||||
report.hosts.filter((host) => host.eligible).map((host) => host.id)
|
||||
);
|
||||
const available = selectedHosts.filter((id) => eligible.has(id));
|
||||
if (available.length !== selectedHosts.length) {
|
||||
selectedHosts = available; preview = null;
|
||||
notice = 'Host availability changed. Unavailable hosts were removed from your selection; preview again before starting.';
|
||||
selectedHosts = available;
|
||||
preview = null;
|
||||
notice =
|
||||
'Host availability changed. Unavailable hosts were removed from your selection; preview again before starting.';
|
||||
}
|
||||
}
|
||||
} catch (cause) {
|
||||
if (current === sequence && !controller.signal.aborted && !signal?.aborted) refreshError = (cause as Error).message;
|
||||
if (current === sequence && !controller.signal.aborted && !signal?.aborted)
|
||||
refreshError = (cause as Error).message;
|
||||
return false;
|
||||
} finally { if (current === sequence) { loadRequest = null; loading = false; } }
|
||||
} finally {
|
||||
if (current === sequence) {
|
||||
loadRequest = null;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
async function act(action: 'preview' | 'start' | 'stop') {
|
||||
if (busy || !canManage) return;
|
||||
const payload = action === 'preview' ? { action, settings: { hostIds: selectedHosts, game, maxMoves } }
|
||||
: action === 'start' ? { action, previewId: preview?.id } : { action, id: run?.id };
|
||||
cancelLoad(); busy = action; actionError = ''; notice = '';
|
||||
const payload =
|
||||
action === 'preview'
|
||||
? { action, settings: { hostIds: selectedHosts, game, maxMoves } }
|
||||
: action === 'start'
|
||||
? { action, previewId: preview?.id }
|
||||
: { action, id: run?.id };
|
||||
cancelLoad();
|
||||
busy = action;
|
||||
actionError = '';
|
||||
notice = '';
|
||||
if (action === 'preview') preview = null;
|
||||
const controller = new AbortController(); actionRequest = controller;
|
||||
const controller = new AbortController();
|
||||
actionRequest = controller;
|
||||
try {
|
||||
const response = await fetch('/api/rebalancing', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) });
|
||||
const response = await fetch('/api/rebalancing', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Rebalance request failed.');
|
||||
if (action === 'preview') preview = body.preview;
|
||||
else {
|
||||
const updated = body.run as RebalanceRun;
|
||||
if (report) report = { ...report, runs: [updated, ...report.runs.filter(item => item.id !== updated.id)] };
|
||||
selectedRun = updated.id; preview = null;
|
||||
notice = action === 'start' ? 'Rebalance started. Moves run one at a time.' : 'Unstarted moves have been withdrawn. Any move already in progress will finish.';
|
||||
if (report)
|
||||
report = {
|
||||
...report,
|
||||
runs: [updated, ...report.runs.filter((item) => item.id !== updated.id)]
|
||||
};
|
||||
selectedRun = updated.id;
|
||||
preview = null;
|
||||
notice =
|
||||
action === 'start'
|
||||
? 'Rebalance started. Moves run one at a time.'
|
||||
: 'Unstarted moves have been withdrawn. Any move already in progress will finish.';
|
||||
}
|
||||
now = Date.now();
|
||||
} catch (cause) {
|
||||
if (!controller.signal.aborted) actionError = `${(cause as Error).message}${action === 'preview' ? '' : ' Refresh the run status before retrying an unconfirmed request.'}`;
|
||||
if (!controller.signal.aborted)
|
||||
actionError = `${(cause as Error).message}${action === 'preview' ? '' : ' Refresh the run status before retrying an unconfirmed request.'}`;
|
||||
} finally {
|
||||
if (!controller.signal.aborted) { busy = null; actionRequest = null; await load(); }
|
||||
if (!controller.signal.aborted) {
|
||||
busy = null;
|
||||
actionRequest = null;
|
||||
await load();
|
||||
}
|
||||
}
|
||||
}
|
||||
onMount(() => {
|
||||
const stop = pollVisible(load, 10_000);
|
||||
const timer = setInterval(() => now = Date.now(), 5_000);
|
||||
return () => { stop(); clearInterval(timer); cancelLoad(); actionRequest?.abort(); };
|
||||
const timer = setInterval(() => (now = Date.now()), 5_000);
|
||||
return () => {
|
||||
stop();
|
||||
clearInterval(timer);
|
||||
cancelLoad();
|
||||
actionRequest?.abort();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="panel" aria-labelledby="rebalance-title">
|
||||
<div class="heading"><div><h2 id="rebalance-title">Rebalance instances</h2><p>Spread existing instances across hosts according to CPU and memory use relative to each host’s capacity.</p></div>
|
||||
{#if checkedAt && !refreshError}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 10 seconds" />{/if}
|
||||
<div class="heading">
|
||||
<div>
|
||||
<h2 id="rebalance-title">Rebalance instances</h2>
|
||||
<p>
|
||||
Spread existing instances across hosts according to CPU and memory use relative to
|
||||
each host’s capacity.
|
||||
</p>
|
||||
</div>
|
||||
{#if checkedAt && !refreshError}<Freshness
|
||||
updatedAt={checkedAt}
|
||||
intervalLabel="Updates every 10 seconds"
|
||||
/>{/if}
|
||||
</div>
|
||||
<p class="help">Only healthy, empty lobbies can move. Each move rebuilds the lobby on another host, causing a temporary outage; its address or port may change. The server identity, map, key, and recorded deployed files are retained. Local mutable files are not copied.</p>
|
||||
{#if refreshError}<div class="error" role="alert"><p>{refreshError}{report ? ' Showing the last loaded rebalancing status.' : ''}</p><Button size="sm" disabled={loading || !!busy} onclick={() => void load()}>Retry</Button></div>{/if}
|
||||
<p class="help">
|
||||
Only healthy, empty lobbies can move. Each move rebuilds the lobby on another host, causing
|
||||
a temporary outage; its address or port may change. The server identity, map, key, and
|
||||
recorded deployed files are retained. Local mutable files are not copied.
|
||||
</p>
|
||||
{#if refreshError}<div class="error" role="alert">
|
||||
<p>{refreshError}{report ? ' Showing the last loaded rebalancing status.' : ''}</p>
|
||||
<Button size="sm" disabled={loading || !!busy} onclick={() => void load()}>Retry</Button
|
||||
>
|
||||
</div>{/if}
|
||||
{#if actionError}<p class="error" role="alert">{actionError}</p>{/if}
|
||||
{#if notice}<p class="notice" role="status">{notice}</p>{/if}
|
||||
{#if !report && loading}<p role="status">Loading hosts and rebalance runs…</p>{/if}
|
||||
{#if report}
|
||||
{#if canManage}
|
||||
<form onsubmit={event => { event.preventDefault(); void act('preview'); }}>
|
||||
<form
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void act('preview');
|
||||
}}
|
||||
>
|
||||
<fieldset disabled={!!busy || !!activeRun}>
|
||||
<legend>Participating hosts</legend>
|
||||
<p class="help">Choose at least two hosts. Existing lobbies may move between any selected hosts.</p>
|
||||
{#if preferredHost && !report.hosts.some(host => host.id === preferredHost && host.eligible)}<p class="help">The host opened from the shortcut is currently unavailable. Choose eligible hosts below.</p>{/if}
|
||||
<div class="host-selection">{#each report.hosts as host (host.id)}
|
||||
<label class:unavailable={!host.eligible}><input type="checkbox" checked={selectedHosts.includes(host.id)} disabled={!host.eligible} onchange={event => toggleHost(host.id, event.currentTarget.checked)} />
|
||||
<span><strong>{host.name}</strong>{#if host.reason}<small>{host.reason}</small>{/if}</span></label>
|
||||
{/each}</div>
|
||||
<div class="settings"><label>Game<select bind:value={game} onchange={editSettings}><option value="all">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
|
||||
<label>Maximum moves<input type="number" min="1" max="20" step="1" required bind:value={maxMoves} oninput={editSettings} /></label>
|
||||
<Button type="submit" disabled={!validSettings || !!busy || !!activeRun || !!refreshError} loading={busy === 'preview'}>Preview rebalance</Button>
|
||||
<p class="help">
|
||||
Choose at least two hosts. Existing lobbies may move between any selected
|
||||
hosts.
|
||||
</p>
|
||||
{#if preferredHost && !report.hosts.some((host) => host.id === preferredHost && host.eligible)}<p
|
||||
class="help"
|
||||
>
|
||||
The host opened from the shortcut is currently unavailable. Choose
|
||||
eligible hosts below.
|
||||
</p>{/if}
|
||||
<div class="host-selection">
|
||||
{#each report.hosts as host (host.id)}
|
||||
<label class:unavailable={!host.eligible}
|
||||
><input
|
||||
type="checkbox"
|
||||
checked={selectedHosts.includes(host.id)}
|
||||
disabled={!host.eligible}
|
||||
onchange={(event) =>
|
||||
toggleHost(host.id, event.currentTarget.checked)}
|
||||
/>
|
||||
<span
|
||||
><strong>{host.name}</strong>{#if host.reason}<small
|
||||
>{host.reason}</small
|
||||
>{/if}</span
|
||||
></label
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="settings">
|
||||
<label
|
||||
>Game<select bind:value={game} onchange={editSettings}
|
||||
><option value="all">All games</option><option value="t5"
|
||||
>Black Ops I</option
|
||||
><option value="t6">Black Ops II</option></select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Maximum moves<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
step="1"
|
||||
required
|
||||
bind:value={maxMoves}
|
||||
oninput={editSettings}
|
||||
/></label
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!validSettings || !!busy || !!activeRun || !!refreshError}
|
||||
loading={busy === 'preview'}>Preview rebalance</Button
|
||||
>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
{#if activeRun}<p class="help">A rebalance is {activeRun.state === 'stopping' ? 'finishing its current move' : 'already in progress'}. A new run can start after it finishes.{#if activeRun.id !== run?.id} <button class="text-button" onclick={() => selectedRun = activeRun.id}>View active run</button>{/if}</p>{/if}
|
||||
{:else}<p class="help">Operators and administrators can preview, start, and stop rebalance runs.</p>{/if}
|
||||
{#if preview}<RebalancePreviewView {preview} {expired} disabled={!!busy || !!activeRun || !!refreshError} starting={busy === 'start'} onstart={() => void act('start')} />{/if}
|
||||
{#if activeRun}<p class="help">
|
||||
A rebalance is {activeRun.state === 'stopping'
|
||||
? 'finishing its current move'
|
||||
: 'already in progress'}. A new run can start after it finishes.{#if activeRun.id !== run?.id}
|
||||
<button class="text-button" onclick={() => (selectedRun = activeRun.id)}
|
||||
>View active run</button
|
||||
>{/if}
|
||||
</p>{/if}
|
||||
{:else}<p class="help">
|
||||
Operators and administrators can preview, start, and stop rebalance runs.
|
||||
</p>{/if}
|
||||
{#if preview}<RebalancePreviewView
|
||||
{preview}
|
||||
{expired}
|
||||
disabled={!!busy || !!activeRun || !!refreshError}
|
||||
starting={busy === 'start'}
|
||||
onstart={() => void act('start')}
|
||||
/>{/if}
|
||||
<section class="runs" aria-labelledby="rebalance-runs-title">
|
||||
<div class="heading"><h3 id="rebalance-runs-title">Rebalance runs</h3>{#if report.runs.length}<label class="run-picker"><span class="sr-only">Select rebalance run</span><select value={run?.id ?? ''} onchange={event => selectedRun = event.currentTarget.value}>{#each report.runs as item (item.id)}<option value={item.id}>{$timeFormat.dateTime(item.createdAt)} · {rebalanceRunLabels[item.state]}</option>{/each}</select></label>{/if}</div>
|
||||
{#if preferredRun && !report.runs.some(item => item.id === preferredRun)}<p class="help">The linked run is not in the recent history.</p>{/if}
|
||||
{#if run}<RebalanceRunView {run} {canManage} busy={!!busy} stopping={busy === 'stop'} onstop={() => void act('stop')} />
|
||||
<div class="heading">
|
||||
<h3 id="rebalance-runs-title">Rebalance runs</h3>
|
||||
{#if report.runs.length}<label class="run-picker"
|
||||
><span class="sr-only">Select rebalance run</span><select
|
||||
value={run?.id ?? ''}
|
||||
onchange={(event) => (selectedRun = event.currentTarget.value)}
|
||||
>{#each report.runs as item (item.id)}<option value={item.id}
|
||||
>{$timeFormat.dateTime(item.createdAt)} · {rebalanceRunLabels[
|
||||
item.state
|
||||
]}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>{/if}
|
||||
</div>
|
||||
{#if preferredRun && !report.runs.some((item) => item.id === preferredRun)}<p
|
||||
class="help"
|
||||
>
|
||||
The linked run is not in the recent history.
|
||||
</p>{/if}
|
||||
{#if run}<RebalanceRunView
|
||||
{run}
|
||||
{canManage}
|
||||
busy={!!busy}
|
||||
stopping={busy === 'stop'}
|
||||
onstop={() => void act('stop')}
|
||||
/>
|
||||
{:else}<p class="help">No rebalance runs yet.</p>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { display:grid; gap:var(--space-5); padding:24px; border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); min-width:0; }
|
||||
h2,h3,p { margin:0; } h2 { font-size:var(--text-lg); } h3 { font-size:var(--text-md); } p { font-size:var(--text-sm); line-height:1.6; } .heading>div>p { margin-top:8px; color:var(--text-secondary); }
|
||||
.heading,.settings { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; }
|
||||
.help { font-size:var(--text-xs); color:var(--text-secondary); line-height:1.6; }
|
||||
fieldset { border:0; padding:0; margin:0; display:grid; gap:12px; min-width:0; } legend { font-size:var(--text-sm); font-weight:600; padding:0 0 8px; }
|
||||
.host-selection { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,220px),1fr)); gap:8px; }
|
||||
.host-selection label { display:flex; align-items:flex-start; gap:10px; padding:12px; border:1px solid var(--border-subtle); border-radius:var(--radius-sm); cursor:pointer; }
|
||||
.host-selection input { margin:3px 0 0; accent-color:var(--accent-primary); }
|
||||
.host-selection span { display:grid; gap:4px; min-width:0; } .host-selection strong { font-size:var(--text-sm); font-weight:500; overflow-wrap:anywhere; }
|
||||
.host-selection small { font-size:var(--text-xs); color:var(--text-secondary); line-height:1.5; } .host-selection .unavailable { opacity:0.65; cursor:default; }
|
||||
.settings { justify-content:flex-start; align-items:end; } .settings label { display:grid; gap:8px; font-size:var(--text-xs); } .settings input { width:110px; }
|
||||
select,input[type='number'] { box-sizing:border-box; padding:9px 12px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:var(--text-sm) var(--font-body); min-width:0; }
|
||||
input:disabled,select:disabled { opacity:0.6; } .run-picker { max-width:100%; } .run-picker select { max-width:100%; }
|
||||
.runs { display:grid; grid-template-columns:minmax(0,1fr); gap:16px; border-top:1px solid var(--border-subtle); padding-top:20px; min-width:0; }
|
||||
.error { display:flex; gap:12px; align-items:center; justify-content:space-between; padding:12px 16px; color:var(--state-danger); border:1px solid var(--state-danger); border-radius:var(--radius-sm); font-size:var(--text-sm); overflow-wrap:anywhere; } .notice { color:var(--state-online); }
|
||||
.text-button { color:var(--accent-primary-hover); } .text-button { border:0; padding:0; background:none; font:inherit; cursor:pointer; text-decoration:underline; }
|
||||
button:focus-visible,input:focus-visible,select:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
|
||||
.sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
|
||||
@media(max-width:600px) { .panel { padding:16px; } }
|
||||
.panel {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
padding: 24px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
min-width: 0;
|
||||
}
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
p {
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.heading > div > p {
|
||||
margin-top: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.heading,
|
||||
.settings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.help {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
legend {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
.host-selection {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.host-selection label {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.host-selection input {
|
||||
margin: 3px 0 0;
|
||||
accent-color: var(--accent-primary);
|
||||
}
|
||||
.host-selection span {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.host-selection strong {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.host-selection small {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.host-selection .unavailable {
|
||||
opacity: 0.65;
|
||||
cursor: default;
|
||||
}
|
||||
.settings {
|
||||
justify-content: flex-start;
|
||||
align-items: end;
|
||||
}
|
||||
.settings label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.settings input {
|
||||
width: 110px;
|
||||
}
|
||||
select,
|
||||
input[type='number'] {
|
||||
box-sizing: border-box;
|
||||
padding: 9px 12px;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-sm);
|
||||
font: var(--text-sm) var(--font-body);
|
||||
min-width: 0;
|
||||
}
|
||||
input:disabled,
|
||||
select:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.run-picker {
|
||||
max-width: 100%;
|
||||
}
|
||||
.run-picker select {
|
||||
max-width: 100%;
|
||||
}
|
||||
.runs {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
.error {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
color: var(--state-danger);
|
||||
border: 1px solid var(--state-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.notice {
|
||||
color: var(--state-online);
|
||||
}
|
||||
.text-button {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
.text-button {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.panel {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,38 +4,207 @@
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { plural } from '$ui/plural.js';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
let { preview, expired, disabled = false, starting = false, onstart }: {
|
||||
preview: RebalancePreview; expired: boolean; disabled?: boolean; starting?: boolean; onstart: () => void;
|
||||
let {
|
||||
preview,
|
||||
expired,
|
||||
disabled = false,
|
||||
starting = false,
|
||||
onstart
|
||||
}: {
|
||||
preview: RebalancePreview;
|
||||
expired: boolean;
|
||||
disabled?: boolean;
|
||||
starting?: boolean;
|
||||
onstart: () => void;
|
||||
} = $props();
|
||||
const numbers = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
|
||||
function percent(value: number) { return `${numbers.format(value)}%`; }
|
||||
function percent(value: number) {
|
||||
return `${numbers.format(value)}%`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="preview" aria-labelledby="rebalance-preview-title">
|
||||
<div class="heading"><h3 id="rebalance-preview-title">Preview · {preview.moves.length} proposed {plural(preview.moves.length, 'move')}</h3><span class="help">Created {$timeFormat.dateTime(preview.createdAt)}</span></div>
|
||||
<div class="heading">
|
||||
<h3 id="rebalance-preview-title">
|
||||
Preview · {preview.moves.length} proposed {plural(preview.moves.length, 'move')}
|
||||
</h3>
|
||||
<span class="help">Created {$timeFormat.dateTime(preview.createdAt)}</span>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Projected host distribution" tabindex="0"><table><thead><tr><th>Host</th><th>Instances</th><th>CPU</th><th>Memory</th></tr></thead><tbody>
|
||||
{#each preview.hosts as host (host.id)}<tr><td>{host.name}</td><td>{host.before.instances} → {host.after.instances}</td><td>{percent(host.before.cpuPercent)} → {percent(host.after.cpuPercent)}</td><td>{percent(host.before.memoryPercent)} → {percent(host.after.memoryPercent)}</td></tr>{/each}
|
||||
</tbody></table></div>
|
||||
<p class="help">Current → projected distribution. CPU and memory estimates include the full load on each participating host.</p>
|
||||
{#if preview.moves.length}<ol class="moves">{#each preview.moves as move}<li><a href={contextHref(`/servers/${encodeURIComponent(move.server)}`, '/capacity?rebalance=1')}>{move.server}</a><span>{move.sourceHostName} → {move.targetHostName}</span></li>{/each}</ol>
|
||||
{:else}<p>No eligible moves would improve the distribution enough. Occupied lobbies and other excluded instances stay on their current hosts.</p>{/if}
|
||||
{#if preview.warnings.length}<ul class="warnings">{#each preview.warnings as warning}<li>{warning}</li>{/each}</ul>{/if}
|
||||
{#if preview.exclusions.length}<details><summary>{preview.exclusions.length} excluded {plural(preview.exclusions.length, 'instance')}</summary><ul class="exclusions">{#each preview.exclusions as exclusion}<li><strong>{exclusion.server}</strong><span>{exclusion.reason}</span></li>{/each}</ul></details>{/if}
|
||||
<div class="start"><Button variant="primary" disabled={!preview.moves.length || disabled || expired} loading={starting} onclick={onstart}>Start rebalance</Button><p class="help">{expired ? 'This preview has expired. Preview again before starting.' : 'The controller rechecks occupancy and capacity before each move. Moves that no longer help are skipped.'}</p></div>
|
||||
<div class="table-wrap" role="region" aria-label="Projected host distribution" tabindex="0">
|
||||
<table>
|
||||
<thead><tr><th>Host</th><th>Instances</th><th>CPU</th><th>Memory</th></tr></thead><tbody
|
||||
>
|
||||
{#each preview.hosts as host (host.id)}<tr
|
||||
><td>{host.name}</td><td
|
||||
>{host.before.instances} → {host.after.instances}</td
|
||||
><td
|
||||
>{percent(host.before.cpuPercent)} → {percent(
|
||||
host.after.cpuPercent
|
||||
)}</td
|
||||
><td
|
||||
>{percent(host.before.memoryPercent)} → {percent(
|
||||
host.after.memoryPercent
|
||||
)}</td
|
||||
></tr
|
||||
>{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="help">
|
||||
Current → projected distribution. CPU and memory estimates include the full load on each
|
||||
participating host.
|
||||
</p>
|
||||
{#if preview.moves.length}<ol class="moves">
|
||||
{#each preview.moves as move}<li>
|
||||
<a
|
||||
href={contextHref(
|
||||
`/servers/${encodeURIComponent(move.server)}`,
|
||||
'/capacity?rebalance=1'
|
||||
)}>{move.server}</a
|
||||
><span>{move.sourceHostName} → {move.targetHostName}</span>
|
||||
</li>{/each}
|
||||
</ol>
|
||||
{:else}<p>
|
||||
No eligible moves would improve the distribution enough. Occupied lobbies and other
|
||||
excluded instances stay on their current hosts.
|
||||
</p>{/if}
|
||||
{#if preview.warnings.length}<ul class="warnings">
|
||||
{#each preview.warnings as warning}<li>{warning}</li>{/each}
|
||||
</ul>{/if}
|
||||
{#if preview.exclusions.length}<details>
|
||||
<summary
|
||||
>{preview.exclusions.length} excluded {plural(
|
||||
preview.exclusions.length,
|
||||
'instance'
|
||||
)}</summary
|
||||
>
|
||||
<ul class="exclusions">
|
||||
{#each preview.exclusions as exclusion}<li>
|
||||
<strong>{exclusion.server}</strong><span>{exclusion.reason}</span>
|
||||
</li>{/each}
|
||||
</ul>
|
||||
</details>{/if}
|
||||
<div class="start">
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!preview.moves.length || disabled || expired}
|
||||
loading={starting}
|
||||
onclick={onstart}>Start rebalance</Button
|
||||
>
|
||||
<p class="help">
|
||||
{expired
|
||||
? 'This preview has expired. Preview again before starting.'
|
||||
: 'The controller rechecks occupancy and capacity before each move. Moves that no longer help are skipped.'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.preview { display:grid; gap:16px; border-top:1px solid var(--border-subtle); padding-top:20px; min-width:0; }
|
||||
h3,p { margin:0; } h3 { font-size:var(--text-md); } p { font-size:var(--text-sm); line-height:1.6; }
|
||||
.heading,.start { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; }
|
||||
.help { font-size:var(--text-xs); color:var(--text-secondary); line-height:1.6; }
|
||||
.table-wrap { overflow-x:auto; border:1px solid var(--border-subtle); border-radius:var(--radius-sm); }
|
||||
table { width:100%; min-width:520px; border-collapse:collapse; text-align:left; font-size:var(--text-sm); font-variant-numeric:tabular-nums; }
|
||||
th,td { padding:12px 16px; } th { background:var(--surface-sunken); color:var(--text-secondary); font-size:var(--text-xs); font-weight:500; } td { border-top:1px solid var(--border-subtle); }
|
||||
.moves,.exclusions { margin:0; padding-left:20px; display:grid; gap:12px; font-size:var(--text-sm); } .moves span,.exclusions span { display:block; margin-top:4px; color:var(--text-secondary); font-size:var(--text-xs); } .exclusions { margin-top:12px; } .exclusions strong { font-weight:500; }
|
||||
.warnings { border-left:2px solid var(--state-warning); padding:0 0 0 22px; margin:0; color:var(--text-secondary); font-size:var(--text-xs); line-height:1.7; }
|
||||
summary { cursor:pointer; color:var(--text-secondary); font-size:var(--text-sm); }
|
||||
.start { justify-content:flex-start; } .start p { flex:1 1 280px; }
|
||||
a { color:var(--accent-primary-hover); } a:focus-visible,summary:focus-visible,.table-wrap:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
|
||||
.preview {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
p {
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.heading,
|
||||
.start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.help {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 520px;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: var(--text-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
th {
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
td {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.moves,
|
||||
.exclusions {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.moves span,
|
||||
.exclusions span {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.exclusions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.exclusions strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
.warnings {
|
||||
border-left: 2px solid var(--state-warning);
|
||||
padding: 0 0 0 22px;
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.7;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.start {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.start p {
|
||||
flex: 1 1 280px;
|
||||
}
|
||||
a {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
a:focus-visible,
|
||||
summary:focus-visible,
|
||||
.table-wrap:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,150 @@
|
||||
<script lang="ts">
|
||||
import type { RebalanceRun } from '$lib/rebalancing.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { rebalanceMoveLabels, rebalanceRunLabels, rebalanceRunHref } from '$ui/rebalancing-presentation.js';
|
||||
import {
|
||||
rebalanceMoveLabels,
|
||||
rebalanceRunLabels,
|
||||
rebalanceRunHref
|
||||
} from '$ui/rebalancing-presentation.js';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
let { run, canManage, busy = false, stopping = false, onstop }: {
|
||||
run: RebalanceRun; canManage: boolean; busy?: boolean; stopping?: boolean; onstop: () => void;
|
||||
let {
|
||||
run,
|
||||
canManage,
|
||||
busy = false,
|
||||
stopping = false,
|
||||
onstop
|
||||
}: {
|
||||
run: RebalanceRun;
|
||||
canManage: boolean;
|
||||
busy?: boolean;
|
||||
stopping?: boolean;
|
||||
onstop: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="heading"><div class="run-status"><Badge variant={run.state === 'failed' ? 'danger' : run.state === 'done' ? 'online' : run.state === 'stopped' ? 'neutral' : 'info'}>{rebalanceRunLabels[run.state]}</Badge><span>{run.moves.filter(move => move.state === 'done').length} of {run.moves.length} moves completed</span><a href={rebalanceRunHref(run.id)}>Link to run</a></div>
|
||||
{#if canManage && run.state === 'running'}<Button disabled={busy} loading={stopping} onclick={onstop}>Stop remaining moves</Button>{/if}</div>
|
||||
<div class="heading">
|
||||
<div class="run-status">
|
||||
<Badge
|
||||
variant={run.state === 'failed'
|
||||
? 'danger'
|
||||
: run.state === 'done'
|
||||
? 'online'
|
||||
: run.state === 'stopped'
|
||||
? 'neutral'
|
||||
: 'info'}>{rebalanceRunLabels[run.state]}</Badge
|
||||
><span
|
||||
>{run.moves.filter((move) => move.state === 'done').length} of {run.moves.length} moves completed</span
|
||||
><a href={rebalanceRunHref(run.id)}>Link to run</a>
|
||||
</div>
|
||||
{#if canManage && run.state === 'running'}<Button
|
||||
disabled={busy}
|
||||
loading={stopping}
|
||||
onclick={onstop}>Stop remaining moves</Button
|
||||
>{/if}
|
||||
</div>
|
||||
<p role="status">{run.message}</p>
|
||||
{#if run.state === 'running' || run.state === 'stopping'}<p class="help">Stopping withdraws unstarted moves. A move already in progress continues through health verification.</p>{/if}
|
||||
<ol class="run-moves">{#each run.moves as move (move.id)}<li><div class="heading"><div class="move-identity"><strong>{move.server}</strong><span>{move.sourceHostName} → {move.targetHostName}</span></div><Badge variant={move.state === 'failed' ? 'danger' : move.state === 'done' ? 'online' : move.state === 'moving' ? 'info' : 'neutral'}>{rebalanceMoveLabels[move.state]}</Badge></div><p>{move.message}</p>{#if move.operationId}<a href={contextHref(`/provisioning/${encodeURIComponent(move.operationId)}`, rebalanceRunHref(run.id))}>View provisioning →</a>{/if}</li>{/each}</ol>
|
||||
{#if run.state === 'running' || run.state === 'stopping'}<p class="help">
|
||||
Stopping withdraws unstarted moves. A move already in progress continues through health
|
||||
verification.
|
||||
</p>{/if}
|
||||
<ol class="run-moves">
|
||||
{#each run.moves as move (move.id)}<li>
|
||||
<div class="heading">
|
||||
<div class="move-identity">
|
||||
<strong>{move.server}</strong><span
|
||||
>{move.sourceHostName} → {move.targetHostName}</span
|
||||
>
|
||||
</div>
|
||||
<Badge
|
||||
variant={move.state === 'failed'
|
||||
? 'danger'
|
||||
: move.state === 'done'
|
||||
? 'online'
|
||||
: move.state === 'moving'
|
||||
? 'info'
|
||||
: 'neutral'}>{rebalanceMoveLabels[move.state]}</Badge
|
||||
>
|
||||
</div>
|
||||
<p>{move.message}</p>
|
||||
{#if move.operationId}<a
|
||||
href={contextHref(
|
||||
`/provisioning/${encodeURIComponent(move.operationId)}`,
|
||||
rebalanceRunHref(run.id)
|
||||
)}>View provisioning →</a
|
||||
>{/if}
|
||||
</li>{/each}
|
||||
</ol>
|
||||
|
||||
<style>
|
||||
p { margin:0; font-size:var(--text-sm); line-height:1.6; }
|
||||
.heading,.run-status { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; }
|
||||
.help { font-size:var(--text-xs); color:var(--text-secondary); line-height:1.6; }
|
||||
.run-status { justify-content:flex-start; font-size:var(--text-xs); color:var(--text-secondary); } .run-moves { margin:0; padding:0; list-style:none; display:grid; gap:12px; }
|
||||
.run-moves li { display:grid; gap:8px; padding:16px; border:1px solid var(--border-subtle); border-radius:var(--radius-sm); } .run-moves p,.run-moves a { font-size:var(--text-xs); } .run-moves p { color:var(--text-secondary); }
|
||||
.move-identity { display:grid; gap:5px; font-size:var(--text-sm); min-width:0; overflow-wrap:anywhere; } .move-identity strong { font-weight:500; } .move-identity span { font-size:var(--text-xs); color:var(--text-secondary); }
|
||||
a { color:var(--accent-primary-hover); } a:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
|
||||
@media(max-width:600px) { .run-moves li { padding:12px; } }
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.heading,
|
||||
.run-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.help {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.run-status {
|
||||
justify-content: flex-start;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.run-moves {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.run-moves li {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.run-moves p,
|
||||
.run-moves a {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.run-moves p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.move-identity {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
font-size: var(--text-sm);
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.move-identity strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
.move-identity span {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
a {
|
||||
color: var(--accent-primary-hover);
|
||||
}
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.run-moves li {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user