Files
electric-cherry/src/lib/ui/components/capacity/CapacityHosts.svelte
T

239 lines
8.6 KiB
Svelte

<script lang="ts">
import type { CapacityHost } from '$lib/capacity.js';
import { hostReadinessLabel } from '$lib/hosts.js';
import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.js';
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 restriction(host: CapacityHost) {
if (!host.connected) return 'Offline';
return host.readiness === 'ready' ? 'Not eligible' : hostReadinessLabel(host.readiness);
}
</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>
<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;
}
</style>