239 lines
8.6 KiB
Svelte
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>
|