Simplify host overview and move enrollment into drawer

This commit is contained in:
2026-10-06 17:18:31 -04:00
parent a991198d69
commit ef290ae0ee
11 changed files with 310 additions and 61 deletions
@@ -0,0 +1,61 @@
<script lang="ts">
import Dialog from './primitives/Dialog.svelte';
import Button from './primitives/Button.svelte';
let { open = $bindable(false), onadded }: { open?: boolean; onadded: () => Promise<unknown> } = $props();
let name = $state('');
let portMin = $state(28960); let portMax = $state(28980);
let busy = $state(false); let error = $state(''); let copied = $state(false);
let enrollment = $state<{ command: string; expiresAt: string; hostId: string } | null>(null);
async function add(event: SubmitEvent) {
event.preventDefault(); if (busy) return;
busy = true; error = ''; copied = false;
try {
const response = await fetch('/api/hosts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, portMin, portMax }) });
const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not add host.');
enrollment = body.enrollment;
await onadded();
} catch (cause) { error = (cause as Error).message; } finally { busy = false; }
}
async function copy() {
if (!enrollment) return;
try { await navigator.clipboard.writeText(enrollment.command); copied = true; error = ''; }
catch { error = 'Could not copy automatically. Select and copy the command below.'; }
}
</script>
<Dialog bind:open title={enrollment ? 'Install the host agent' : 'Add host'} {busy} drawer>
<div class="content">
{#if error}<p role="alert" class="ui-error">{error}</p>{/if}
{#if enrollment}
<p role="status">{name} created. Run this command with administrator access on that machine.</p>
<textarea readonly aria-label="Host installation command" rows="6" value={enrollment.command}></textarea>
<div class="actions"><Button onclick={copy}>{copied ? 'Copied' : 'Copy command'}</Button><Button variant="primary" href={`/hosts/${enrollment.hostId}?tab=setup`}>Open host setup</Button></div>
<p class="muted">This single-use command expires {new Date(enrollment.expiresAt).toLocaleString()}. Generate a new installer from host settings if it expires.</p>
<Button variant="ghost" onclick={() => { enrollment = null; name = ''; copied = false; error = ''; }}>Add another host</Button>
{:else}
<p>Use a Debian 12 or 13 amd64 machine. The agent connects over HTTPS and prepares the runtime and game files automatically.</p>
<form onsubmit={add}><fieldset disabled={busy}>
<label>Friendly name<input required maxlength="200" bind:value={name} placeholder="Game host 1" /></label>
<details><summary>Advanced settings</summary><div class="ports">
<label>First game port<input type="number" required min="1" max="65535" bind:value={portMin} /></label>
<label>Last game port<input type="number" required min={portMin} max="65535" bind:value={portMax} /></label>
</div></details>
<Button type="submit" variant="primary" disabled={busy}>{busy ? 'Creating…' : 'Add host'}</Button>
</fieldset></form>
{/if}
</div>
</Dialog>
<style>
.content,fieldset { display:grid; gap:20px; }
fieldset { border:0; padding:0; margin:0; min-width:0; }
p { margin:0; color:var(--text-secondary); font-size:var(--text-sm); line-height:1.6; }
.muted { font-size:var(--text-xs); }
label { display:grid; gap:8px; font-size:var(--text-sm); }
input,textarea { box-sizing:border-box; width:100%; min-width:0; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); }
textarea { font:var(--text-xs)/1.6 var(--font-mono); resize:vertical; }
summary { cursor:pointer; color:var(--text-secondary); font-size:var(--text-sm); }
.ports { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-top:16px; }
.actions { display:flex; flex-wrap:wrap; gap:12px; }
input:focus-visible,textarea:focus-visible,summary:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
</style>
+40
View File
@@ -0,0 +1,40 @@
<script lang="ts">
import type { Host } from '$lib/hosts.js';
import { contextHref } from '$lib/navigation-context.js';
import { hostListStatus, hostReadingsStale, hostCpuUsage, hostUsedSpace } from '$ui/host-overview.js';
import Badge from './primitives/Badge.svelte';
let { hosts, now, returnTo = '/hosts' }: { hosts: (Host & { serverCount: number })[]; now: number; returnTo?: string } = $props();
</script>
<div class="table-wrap"><table>
<thead><tr><th>Host</th><th>Status</th><th title="Assigned servers, including stopped instances">Servers</th><th>CPU</th><th>Memory <span>used / total</span></th><th>Disk <span>used / total</span></th></tr></thead>
<tbody>{#each hosts as host (host.id)}
{@const status = hostListStatus(host, now)}
{@const stale = hostReadingsStale(host, now)}
<tr>
<td><a class="name" href={contextHref(`/hosts/${host.id}`, returnTo)}>{host.name}</a>{#if host.hostname && host.hostname !== host.name}<span class="secondary">{host.hostname}</span>{/if}
{#if stale && host.lastHeartbeat}<span class="secondary">Resource readings stale</span>{/if}</td>
<td class="status"><Badge variant={status.tone}>{status.label}</Badge>{#if status.detail}<a class="reason" href={contextHref(`/hosts/${host.id}?tab=${status.tab}`, returnTo)}>{status.detail}</a>{/if}</td>
<td><a href={contextHref(`/hosts/${host.id}?tab=servers`, returnTo)} aria-label={`${host.serverCount} assigned servers on ${host.name}`}>{host.serverCount}</a></td>
<td class:stale>{hostCpuUsage(host.inventory.cpuUsagePercent)}</td>
<td class:stale>{hostUsedSpace(host.inventory.memoryTotal, host.inventory.memoryAvailable)}</td>
<td class:stale>{hostUsedSpace(host.inventory.storageTotal, host.inventory.storageAvailable)}</td>
</tr>
{/each}</tbody>
</table></div>
<style>
.table-wrap { overflow-x:auto; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
table { width:100%; min-width:850px; border-collapse:collapse; text-align:left; font-size:var(--text-sm); }
th { padding:12px 16px; color:var(--text-secondary); background:var(--surface-sunken); font-weight:500; font-size:var(--text-xs); }
th span { display:block; font-weight:400; margin-top:3px; }
td { padding:16px; background:var(--surface); border-top:1px solid var(--border-subtle); 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; overflow-wrap:anywhere; }
.name:hover { color:var(--accent-primary-hover); text-decoration:underline; }
.status { max-width:330px; min-width:140px; }
.secondary,.reason { display:block; font-size:var(--text-xs); color:var(--text-secondary); margin-top:6px; line-height:1.5; }
.reason { overflow-wrap:anywhere; text-decoration:none; } .reason:hover { text-decoration:underline; color:var(--accent-primary-hover); }
.stale { color:var(--text-secondary); }
a:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
</style>
@@ -2,8 +2,8 @@
import { tick, type Snippet } from 'svelte';
import Button from './Button.svelte';
let { open = $bindable(false), title, description, busy = false, wide = false, children, actions }:
{ open?: boolean; title: string; description?: string; busy?: boolean; wide?: boolean; children?: Snippet; actions?: Snippet } = $props();
let { open = $bindable(false), title, description, busy = false, wide = false, drawer = false, children, actions }:
{ open?: boolean; title: string; description?: string; busy?: boolean; wide?: boolean; drawer?: boolean; children?: Snippet; actions?: Snippet } = $props();
const id = $props.id();
let dialog = $state<HTMLDialogElement>();
let returnFocus: HTMLElement | null = null;
@@ -22,7 +22,7 @@
}
</script>
<dialog bind:this={dialog} class:wide aria-labelledby={`${id}-title`} aria-describedby={description ? `${id}-description` : undefined}
<dialog bind:this={dialog} class:wide class:drawer aria-labelledby={`${id}-title`} aria-describedby={description ? `${id}-description` : undefined}
oncancel={event => { if (busy) event.preventDefault(); }} onclose={closed}
onclick={event => { if (event.target === dialog) { const r = dialog!.getBoundingClientRect(); if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) close(); } }}>
<header><div><h2 id={`${id}-title`}>{title}</h2>{#if description}<p id={`${id}-description`}>{description}</p>{/if}</div><Button variant="ghost" ariaLabel="Close dialog" disabled={busy} onclick={close}>×</Button></header>
@@ -33,6 +33,7 @@
<style>
dialog { width: min(560px, calc(100vw - 32px)); padding: 0; }
dialog.wide { width: min(880px, calc(100vw - 32px)); }
dialog.drawer { position:fixed; inset:0 0 0 auto; margin:0; width:min(560px, 100vw); height:100dvh; max-height:100dvh; max-width:100vw; border-radius:0; border:0; border-left:1px solid var(--border-subtle); }
header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); padding: var(--space-6); border-bottom: 1px solid var(--border-subtle); }
h2 { margin: 0; font-size: var(--text-xl); line-height: var(--leading-tight); }
p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
+42
View File
@@ -0,0 +1,42 @@
import type { Host } from '../hosts.js';
import { hostDeletionMessage } from '../hosts.js';
import { formatGameBytes } from '../game-files.js';
export function hostReadingsStale(host: Host, now = Date.now()): boolean {
const reported = Date.parse(host.lastHeartbeat ?? '');
return !host.connected || !Number.isFinite(reported) || now - reported > 90_000;
}
type HostStatus = { key: 'attention' | 'offline' | 'preparing' | 'draining' | 'disabled' | 'deleting' | 'ready'; label: string; tone: 'danger' | 'warning' | 'neutral' | 'online'; detail: string; tab: 'setup' | 'settings' | 'overview'; priority: number };
export function hostListStatus(host: Host, now = Date.now()): HostStatus {
if (host.decommission) return { key: host.decommission.stage === 'failed' ? 'attention' : 'deleting', label: host.decommission.stage === 'failed' ? 'Cleanup failed' : 'Deleting', tone: host.decommission.stage === 'failed' ? 'danger' : 'neutral', detail: host.decommission.error || hostDeletionMessage(host), tab: 'overview', priority: host.decommission.stage === 'failed' ? 0 : 2 };
if (!host.enrolled) return { key: 'preparing', label: 'Awaiting enrollment', tone: 'neutral', detail: 'Run the installation command on this host.', tab: 'settings', priority: 2 };
if (host.mode === 'disabled' && !host.enablingSince) return { key: 'disabled', label: 'Disabled', tone: 'neutral', detail: '', tab: 'settings', priority: 4 };
if (!host.credentialActive) return { key: 'attention', label: 'Needs attention', tone: 'danger', detail: 'Agent credentials revoked. Generate a new installer.', tab: 'settings', priority: 0 };
if (hostReadingsStale(host, now)) return { key: 'offline', label: 'Offline', tone: 'danger', detail: host.mode === 'draining' ? 'Draining · waiting for the host to reconnect.' : 'Waiting for a fresh agent heartbeat.', tab: 'overview', priority: 0 };
const blocked = host.preparation?.items.find(item => item.state === 'blocked');
if (blocked || host.readiness === 'degraded' || host.inventory.agentUpdate?.state === 'failed') return {
key: 'attention', label: 'Needs attention', tone: 'danger', detail: blocked?.detail || (host.readiness === 'degraded' ? host.readinessReasons[0] : '') || (host.inventory.agentUpdate?.state === 'failed' ? `Agent update failed${host.inventory.agentUpdate.error ? `: ${host.inventory.agentUpdate.error}` : '.'}` : 'Review host setup.'), tab: 'setup', priority: 0
};
if (host.mode === 'draining') return { key: 'draining', label: 'Draining', tone: 'neutral', detail: 'New deployments are blocked.', tab: 'settings', priority: 3 };
if (host.enablingSince || host.readiness !== 'ready' || host.preparation && host.preparation.state !== 'ready') return {
key: 'preparing', label: 'Preparing', tone: 'neutral', detail: host.preparation?.items.find(item => item.state !== 'ready')?.detail || host.readinessReasons[0] || 'Preparing runtime and game files.', tab: 'setup', priority: 2
};
return { key: 'ready', label: 'Ready', tone: 'online', detail: '', tab: 'overview', priority: 5 };
}
export function hostCpuUsage(value: unknown): string {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100 ? `${value.toFixed(1)}%` : 'Not reported';
}
export function hostUsedSpace(total: unknown, available: unknown): string {
if (typeof total !== 'number' || typeof available !== 'number' || !Number.isFinite(total) || !Number.isFinite(available) || total <= 0 || available < 0 || available > total) return 'Not reported';
return `${formatGameBytes(total - available)} / ${formatGameBytes(total)}`;
}
/** Sort once on entry; preserve existing positions across polling, including status changes. */
export function stableHostOrder(previous: string[], hosts: Host[], now = Date.now()): string[] {
const present = new Set(hosts.map(host => host.id));
const known = new Set(previous);
return [...previous.filter(id => present.has(id)), ...hosts.filter(host => !known.has(host.id))
.sort((a, b) => hostListStatus(a, now).priority - hostListStatus(b, now).priority || a.name.localeCompare(b.name) || a.id.localeCompare(b.id)).map(host => host.id)];
}