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
+16
View File
@@ -0,0 +1,16 @@
import { db } from '../db/index.js';
import { listHosts } from './index.js';
import { presentHostPreparation } from './preparation.js';
import type { Host } from '../../hosts.js';
export interface HostOverview extends Host { serverCount: number }
/** Count assigned servers without loading their telemetry or historical placements. */
export function hostOverview(): HostOverview[] {
const hosts = listHosts();
const counts = new Map((db().prepare(`SELECT i.host_id, COUNT(*) AS count FROM native_instances i
JOIN servers s ON s.name = i.server
WHERE i.retired_at IS NULL AND s.destroyed_at IS NULL
GROUP BY i.host_id`).all() as { host_id: string; count: number }[]).map(row => [row.host_id, row.count]));
return hosts.map(host => ({ ...presentHostPreparation(host), serverCount: counts.get(host.id) ?? 0 }));
}
@@ -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 { tick, type Snippet } from 'svelte';
import Button from './Button.svelte'; import Button from './Button.svelte';
let { open = $bindable(false), title, description, busy = false, wide = false, children, actions }: let { open = $bindable(false), title, description, busy = false, wide = false, drawer = false, children, actions }:
{ open?: boolean; title: string; description?: string; busy?: boolean; wide?: boolean; children?: Snippet; actions?: Snippet } = $props(); { open?: boolean; title: string; description?: string; busy?: boolean; wide?: boolean; drawer?: boolean; children?: Snippet; actions?: Snippet } = $props();
const id = $props.id(); const id = $props.id();
let dialog = $state<HTMLDialogElement>(); let dialog = $state<HTMLDialogElement>();
let returnFocus: HTMLElement | null = null; let returnFocus: HTMLElement | null = null;
@@ -22,7 +22,7 @@
} }
</script> </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} 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(); } }}> 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> <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> <style>
dialog { width: min(560px, calc(100vw - 32px)); padding: 0; } dialog { width: min(560px, calc(100vw - 32px)); padding: 0; }
dialog.wide { width: min(880px, calc(100vw - 32px)); } 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); } 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); } 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); } 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)];
}
+3 -3
View File
@@ -1,10 +1,10 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { createEnrollment, createHost, listHosts } from '$lib/server/hosts/index.js'; import { createEnrollment, createHost } from '$lib/server/hosts/index.js';
import { hostApiError, installerCommand, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js'; import { hostApiError, installerCommand, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
import { presentHostPreparation } from '$lib/server/hosts/preparation.js'; import { hostOverview } from '$lib/server/hosts/overview.js';
import { config } from '$lib/server/config.js'; import { config } from '$lib/server/config.js';
export function GET({ request }: { request: Request }) { requireManagementOrigin(request); return json({ hosts: listHosts().map(presentHostPreparation), defaultRuntimeImage: config.defaultRuntimeImage }); } export function GET({ request }: { request: Request }) { requireManagementOrigin(request); return json({ hosts: hostOverview(), serverNow: Date.now(), defaultRuntimeImage: config.defaultRuntimeImage }, { headers: { 'Cache-Control': 'no-store' } }); }
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
requireManagementOrigin(request); requireManagementOrigin(request);
try { try {
+2 -3
View File
@@ -1,4 +1,3 @@
import { listHosts } from '$lib/server/hosts/index.js'; import { hostOverview } from '$lib/server/hosts/overview.js';
import { presentHostPreparation } from '$lib/server/hosts/preparation.js';
export function load() { return { hosts: listHosts().map(presentHostPreparation) }; } export function load() { return { hosts: hostOverview(), serverNow: Date.now() }; }
+54 -50
View File
@@ -1,70 +1,74 @@
<script lang="ts"> <script lang="ts">
import { contextHref } from '$lib/navigation-context.js';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { pollVisible } from '$ui/poll.js';
let { data }: { data: import('./$types').PageData } = $props();
import { page } from '$app/stores'; import { page } from '$app/stores';
import type { Host } from '$lib/hosts.js'; import { pollVisible } from '$ui/poll.js';
import { hostAgentUpdateLabel, hostDeletionMessage, hostReadinessLabel } from '$lib/hosts.js'; import { hostListStatus, stableHostOrder } from '$ui/host-overview.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte'; import AddHostDrawer from '$ui/components/AddHostDrawer.svelte';
let hosts = $state<Host[]>(untrack(() => data.hosts)); import HostList from '$ui/components/HostList.svelte';
let loading = $state(false); let busy = $state(false); let error = $state(''); let { data }: { data: import('./$types').PageData } = $props();
let notice = $state(''); let deleteOpen = $state(false); let deletingHost = $state<Host | null>(null); let hosts = $state(untrack(() => data.hosts));
let order = $state(untrack(() => stableHostOrder([], data.hosts, data.serverNow)));
let clockOffset = untrack(() => data.serverNow - Date.now());
let now = $state(untrack(() => data.serverNow));
let error = $state(''); let addOpen = $state(false);
let search = $state(''); let status = $state('');
let loadSequence = 0; let loadSequence = 0;
let name = $state(''); let portMin = $state(28960); let portMax = $state(28980);
let enrollment = $state<{ command: string; expiresAt: string; hostId: string } | null>(null);
const canManage = $derived($page.data.user?.role === 'admin'); const canManage = $derived($page.data.user?.role === 'admin');
const attention = $derived(hosts.filter(host => ['attention', 'offline'].includes(hostListStatus(host, now).key)).length);
const ready = $derived(hosts.filter(host => hostListStatus(host, now).key === 'ready').length);
const filtersVisible = $derived(hosts.length > 5 || !!search || !!status);
const visible = $derived.by(() => {
const byId = new Map(hosts.map(host => [host.id, host]));
return order.flatMap(id => {
const host = byId.get(id);
if (!host || !`${host.name} ${host.hostname}`.toLowerCase().includes(search.trim().toLowerCase())) return [];
const state = hostListStatus(host, now).key;
if (status && (status === 'attention' ? !['attention', 'offline'].includes(state) : state !== status)) return [];
return [host];
});
});
async function load(signal?: AbortSignal) { async function load(signal?: AbortSignal) {
const sequence = ++loadSequence; const sequence = ++loadSequence;
try { const response = await fetch('/api/hosts', { signal: signal ?? AbortSignal.timeout(15000) }); const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not load hosts.'); if (sequence === loadSequence) { hosts = body.hosts; error = ''; } }
catch (cause) { if (!signal?.aborted && sequence === loadSequence) error = (cause as Error).message; return false; } finally { if (sequence === loadSequence) loading = false; }
}
async function deleted(result: { deleted: boolean }) {
notice = result.deleted ? `${deletingHost?.name ?? 'Host'} deleted.` : `Cleanup requested for ${deletingHost?.name ?? 'host'}. It remains listed until its files and agent are removed.`;
if (enrollment?.hostId === deletingHost?.id) enrollment = null;
deletingHost = null;
await load();
}
async function add(event: SubmitEvent) {
event.preventDefault(); busy = true; error = ''; enrollment = null;
try { try {
const response = await fetch('/api/hosts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, portMin, portMax }) }); const response = await fetch('/api/hosts', { signal: signal ?? AbortSignal.timeout(15000) });
const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not add host.'); const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not load hosts.');
enrollment = body.enrollment; name = ''; await load(); if (sequence === loadSequence) { hosts = body.hosts; clockOffset = body.serverNow - Date.now(); now = body.serverNow; order = stableHostOrder(order, hosts, now); error = ''; }
} catch (cause) { error = (cause as Error).message; } finally { busy = false; } } catch (cause) { if (!signal?.aborted && sequence === loadSequence) error = (cause as Error).message; return false; }
} }
onMount(() => pollVisible(load, 30000, false)); onMount(() => {
const stop = pollVisible(load, 30000, false);
const timer = setInterval(() => now = Date.now() + clockOffset, 15000);
return () => { stop(); clearInterval(timer); };
});
</script> </script>
<svelte:head><title>Hosts · Electric Cherry</title></svelte:head> <svelte:head><title>Hosts · Electric Cherry</title></svelte:head>
<section class="page"> <section class="page">
<SectionHeader title="Hosts" subtitle="Connect game-server hosts. Runtime and game files are prepared automatically."> <SectionHeader title="Hosts">
{#snippet actions()}<Button href="/capacity">View capacity</Button>{/snippet} {#snippet actions()}{#if canManage}<Button variant="primary" onclick={() => addOpen = true}>Add host</Button>{/if}{/snippet}
</SectionHeader> </SectionHeader>
{#if error}<p role="alert" class="ui-error">{error}</p>{/if} {#if error}<p role="alert" class="ui-error">{error} Showing the last loaded hosts. <Button variant="ghost" size="sm" onclick={() => load()}>Retry</Button></p>{/if}
{#if notice}<p role="status">{notice}</p>{/if} {#if hosts.length}
{#if canManage}<section class="panel"> <div class="summary"><span>{hosts.length} host{hosts.length === 1 ? '' : 's'} · {ready === hosts.length ? 'All ready' : `${ready} ready`}</span>{#if attention}<button onclick={() => { status = 'attention'; search = ''; }}>{attention} need attention</button>{/if}</div>
<h2>Add host</h2><p>Run the installation command on a Debian 12 or 13 amd64 machine. Each host connects to this controller over HTTPS, then prepares its runtime and published game files automatically.</p> {#if filtersVisible}<div class="filters">
<form onsubmit={add}><fieldset disabled={busy}> <input type="search" aria-label="Search hosts" placeholder="Search hosts…" bind:value={search} />
<label>Friendly name<input required maxlength="200" bind:value={name} placeholder="Game host 1" /></label> <select aria-label="Filter hosts by status" bind:value={status}><option value="">All statuses</option><option value="attention">Needs attention</option><option value="offline">Offline</option><option value="preparing">Preparing</option><option value="draining">Draining</option><option value="disabled">Disabled</option><option value="deleting">Deleting</option><option value="ready">Ready</option></select>
<label>First game port<input type="number" required min="1" max="65535" bind:value={portMin} /></label> {#if search || status}<Button variant="ghost" size="sm" onclick={() => { search = ''; status = ''; }}>Clear filters</Button>{/if}
<label>Last game port<input type="number" required min={portMin} max="65535" bind:value={portMax} /></label> </div>{/if}
<Button type="submit" disabled={busy}>{busy ? 'Creating…' : 'Add host'}</Button> {#if visible.length}<HostList hosts={visible} {now} returnTo={$page.url.pathname + $page.url.search} />{:else}<p class="empty">No hosts match these filters.</p>{/if}
</fieldset></form> {:else}<p class="empty">No hosts connected yet.{canManage ? ' Add a host to begin.' : ''}</p>{/if}
{#if enrollment}<div class="enrollment" role="status"><h3>Install the agent</h3><p>This single-use command expires {new Date(enrollment.expiresAt).toLocaleString()}. Run it with administrator access on your game host.</p><pre>{enrollment.command}</pre><a href={`/hosts/${enrollment.hostId}`}>Open host setup →</a></div>{/if}
</section>{:else}<p>Administrators enroll hosts and manage their settings and credentials.</p>{/if}
{#if loading}<p role="status">Loading hosts…</p>{:else if !hosts.length}<p>No hosts connected yet.{canManage ? ' Add a host to begin.' : ''}</p>{:else}
<div class="host-grid">{#each hosts as host (host.id)}<article class="panel host"><div class="row"><h2><a href={contextHref(`/hosts/${host.id}`, $page.url)}>{host.name}</a></h2><span class:ready={host.readiness === 'ready'} class="state">{hostReadinessLabel(host.readiness)}</span></div><p>{host.hostname || 'Awaiting enrollment'} · {host.connectionState}</p><p>{host.inventory.os ?? 'OS pending'} · {host.inventory.architecture ?? 'Architecture pending'}</p><p>Game ports {host.portMin}–{host.portMax}</p>{#if host.enrolled}<p>Agent {host.inventory.agentVersion ?? 'unknown'} · Auto updates: {hostAgentUpdateLabel(host.inventory)}</p>{/if}{#if host.decommission}<p class="reason">{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}{:else}{#if host.preparation}<p>Automatic setup: {host.preparation.state === 'preparing' ? 'In progress' : host.preparation.state === 'blocked' ? 'Needs attention' : host.preparation.state === 'ready' ? 'Ready' : 'Waiting'}</p>{#if host.preparation.state === 'blocked'}<p class="ui-error">{host.preparation.items.find(item => item.state === 'blocked')?.detail}</p>{/if}{/if}{#if host.readinessReasons.length}<p class="reason">{host.readinessReasons[0]}</p>{/if}{/if}<div class="host-actions"><Button size="sm" href={contextHref(`/hosts/${host.id}`, $page.url)}>{host.decommission ? 'View cleanup' : 'Manage host'}</Button>{#if canManage && (!host.decommission || host.decommission.retryable)}<Button size="sm" variant="danger" onclick={() => { deletingHost = host; deleteOpen = true; }}>{host.decommission ? 'Retry cleanup' : 'Delete host'}</Button>{/if}</div></article>{/each}</div>
{/if}
</section> </section>
<DeleteHostDialog bind:open={deleteOpen} host={deletingHost} ondeleted={deleted} /> {#if canManage}<AddHostDrawer bind:open={addOpen} onadded={load} />{/if}
<style> <style>
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; } .page { display:grid; gap:20px; }
h2 { font-size:var(--text-md); margin:0; } h3 { font-size:var(--text-sm); } p { color:var(--text-secondary); line-height:1.6; font-size:var(--text-sm); } .summary,.filters { display:flex; align-items:center; flex-wrap:wrap; gap:12px; }
fieldset { display:flex; gap:16px; flex-wrap:wrap; align-items:end; border:0; padding:0; margin-top:20px; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; max-width:280px; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); } .summary,.empty { color:var(--text-secondary); font-size:var(--text-sm); }
pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-sm); } .enrollment { border-top:1px solid var(--border-subtle); margin-top:24px; padding-top:16px; } .summary button { background:none; border:0; color:var(--state-danger); cursor:pointer; font:inherit; padding:4px; }
.host-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:16px; } .host h2 a { color:inherit; text-decoration:none; } .host h2 a:hover { color:var(--accent-primary); } .host-actions { display:flex; flex-wrap:wrap; gap:12px; margin-top:20px; } .row { display:flex; justify-content:space-between; gap:16px; } .state { font-size:var(--text-xs); text-transform:capitalize; color:var(--text-secondary); } .ready { color:var(--accent-primary); } .reason { margin-bottom:0; } input,select { box-sizing:border-box; padding:10px 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); }
input { flex:1 1 240px; min-width:0; max-width:440px; }
input:focus-visible,select:focus-visible,button:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
.empty { padding:24px; text-align:center; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
</style> </style>
+6 -1
View File
@@ -71,7 +71,7 @@
<svelte:head><title>{host?.name ?? 'Host'} · Electric Cherry</title></svelte:head> <svelte:head><title>{host?.name ?? 'Host'} · Electric Cherry</title></svelte:head>
<section class="page"><SectionHeader title={host?.name ?? 'Host'} subtitle="Running instances, readiness, inventory, settings, and credentials."> <section class="page"><SectionHeader title={host?.name ?? 'Host'} subtitle="Running instances, readiness, inventory, settings, and credentials.">
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Hosts', href: '/hosts' }, { label: host?.name ?? 'Host' }]} />{/snippet} {#snippet crumbs()}<Breadcrumbs items={[{ label: 'Hosts', href: '/hosts' }, { label: host?.name ?? 'Host' }]} />{/snippet}
{#snippet actions()}<Button href={contextHref(`/capacity?host=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>Capacity</Button><Button href={contextHref(`/hosts/operations?host=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>Operation history</Button>{#if canManage && host && (!cleaning || host.decommission?.retryable)}<Button variant="danger" disabled={busy} onclick={() => deleteOpen = true}>{cleaning ? 'Retry cleanup' : 'Delete host'}</Button>{/if}{/snippet} {#snippet actions()}<Button href={contextHref(`/capacity?host=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>Capacity</Button><Button href={contextHref(`/hosts/operations?host=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>Operation history</Button>{#if canManage && host && (!cleaning || host.decommission?.retryable)}<details class="host-menu"><summary>More actions</summary><div><Button variant="danger" disabled={busy} onclick={() => deleteOpen = true}>{cleaning ? 'Retry cleanup' : 'Delete host'}</Button></div></details>{/if}{/snippet}
</SectionHeader> </SectionHeader>
<BackLink fallback="/hosts" /> <BackLink fallback="/hosts" />
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if} {#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if}
@@ -155,6 +155,11 @@
</Dialog> </Dialog>
<style> <style>
.host-menu { position:relative; }
.host-menu summary { cursor:pointer; padding:10px 14px; color:var(--text-secondary); font-size:var(--text-sm); }
.host-menu summary:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
.host-menu>div { position:absolute; right:0; top:100%; z-index:5; padding:12px; min-width:160px; background:var(--surface-raised); border:1px solid var(--border-strong); border-radius:var(--radius-md); }
.instances { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr)); gap:16px; } .instances { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr)); gap:16px; }
.host-panel { display:grid; gap:24px; min-width:0; } .overview-actions { display:flex; flex-wrap:wrap; gap:12px; } .host-panel { display:grid; gap:24px; min-width:0; } .overview-actions { display:flex; flex-wrap:wrap; gap:12px; }
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; min-width:0; } h2 { margin:0 0 16px; font-size:var(--text-md); text-transform:capitalize; } p, li, dd, dt, span { font-size:var(--text-sm); line-height:1.65; } p,li,dt,span { color:var(--text-secondary); } .row,.actions,.fields { display:flex; gap:12px; flex-wrap:wrap; } .row { justify-content:space-between; } .actions { margin-top:20px; } dl { display:grid; grid-template-columns:1fr 1.3fr; gap:12px; margin:0; } dd { margin:0; overflow-wrap:anywhere; } fieldset { display:grid; gap:16px; border:0; padding:0; margin:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { 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); } .fields>* { flex:1; min-width:120px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-xs); } .page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; min-width:0; } h2 { margin:0 0 16px; font-size:var(--text-md); text-transform:capitalize; } p, li, dd, dt, span { font-size:var(--text-sm); line-height:1.65; } p,li,dt,span { color:var(--text-secondary); } .row,.actions,.fields { display:flex; gap:12px; flex-wrap:wrap; } .row { justify-content:space-between; } .actions { margin-top:20px; } dl { display:grid; grid-template-columns:1fr 1.3fr; gap:12px; margin:0; } dd { margin:0; overflow-wrap:anywhere; } fieldset { display:grid; gap:16px; border:0; padding:0; margin:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { 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); } .fields>* { flex:1; min-width:120px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-xs); }
+11 -1
View File
@@ -31,6 +31,14 @@ describe('host detail instances', () => {
.run(host.id, now, now); .run(host.id, now, now);
db().prepare("UPDATE servers SET container_state='exited' WHERE name='unavailable'").run(); db().prepare("UPDATE servers SET container_state='exited' WHERE name='unavailable'").run();
const { hostOverview } = await import('../../src/lib/server/hosts/overview.js');
const overview = hostOverview();
expect(overview.find(row => row.id === host.id)?.serverCount).toBe(2);
expect(overview.find(row => row.id === other.id)?.serverCount).toBe(1);
const { GET: list } = await import('../../src/routes/api/hosts/+server.js');
const listed = await list({ request: new Request('https://ec.test/api/hosts') }).json();
expect(listed.serverNow).toEqual(expect.any(Number));
expect(listed.hosts.find((row: { id: string }) => row.id === host.id)).toMatchObject({ serverCount: 2 });
const { GET } = await import('../../src/routes/api/hosts/[id]/+server.js'); const { GET } = await import('../../src/routes/api/hosts/[id]/+server.js');
const read = async (id: string) => (await GET({ request: new Request(`https://ec.test/api/hosts/${id}`), params: { id } }).json()); const read = async (id: string) => (await GET({ request: new Request(`https://ec.test/api/hosts/${id}`), params: { id } }).json());
const body = await read(host.id); const body = await read(host.id);
@@ -42,6 +50,8 @@ describe('host detail instances', () => {
db().prepare("UPDATE servers SET last_players=0 WHERE name='running'").run(); db().prepare("UPDATE servers SET last_players=0 WHERE name='running'").run();
expect((await read(host.id)).instances[0].last_players).toBe(0); expect((await read(host.id)).instances[0].last_players).toBe(0);
expect((await read(other.id)).instances.map((instance: { name: string }) => instance.name)).toEqual(['other-host']); expect((await read(other.id)).instances.map((instance: { name: string }) => instance.name)).toEqual(['other-host']);
expect((await read(createHost({ name: 'Empty host' }).id)).instances).toEqual([]); const empty = createHost({ name: 'Empty host' });
expect((await read(empty.id)).instances).toEqual([]);
expect(hostOverview().find(row => row.id === empty.id)?.serverCount).toBe(0);
}); });
}); });
+71
View File
@@ -0,0 +1,71 @@
import { expect, it } from 'vitest';
import { render } from 'svelte/server';
import type { Host } from '../../src/lib/hosts.js';
import { hostListStatus, hostReadingsStale, hostCpuUsage, hostUsedSpace, stableHostOrder } from '../../src/lib/ui/host-overview.js';
import HostList from '../../src/lib/ui/components/HostList.svelte';
const now = Date.parse('2026-10-06T12:00:00Z');
function host(id = 'ec-1', changes: Partial<Host> = {}): Host {
return { id, name: id, hostname: id, mode: 'active', portMin: 28960, portMax: 28980,
bindAddress: '0.0.0.0', publicAddress: '', minimumFreeBytes: 100, createdAt: new Date(now).toISOString(),
lastHeartbeat: new Date(now).toISOString(), connected: true, connectionState: 'online', readiness: 'ready', readinessReasons: [],
inventory: { cpuUsagePercent: 0, memoryTotal: 1024 ** 3, memoryAvailable: 512 * 1024 ** 2, storageTotal: 10 * 1024 ** 3, storageAvailable: 8 * 1024 ** 3 },
enrolled: true, credentialActive: true, decommission: null, preparation: { state: 'ready', items: [] }, ...changes };
}
it('does not show an old Ready state as available when the heartbeat becomes stale', () => {
expect(hostListStatus(host(), now).label).toBe('Ready');
expect(hostReadingsStale(host(), now + 90_001)).toBe(true);
expect(hostListStatus(host(), now + 90_001)).toMatchObject({ key: 'offline', priority: 0 });
expect(hostReadingsStale(host('bad-date', { lastHeartbeat: 'invalid' }), now)).toBe(true);
expect(hostListStatus(host('new', { enrolled: false, connected: false, lastHeartbeat: null }), now).label).toBe('Awaiting enrollment');
});
it('keeps deliberate disablement and deletion distinct from unexpected outages', () => {
expect(hostListStatus(host('disabled', { mode: 'disabled', connected: false }), now).label).toBe('Disabled');
expect(hostListStatus(host('draining', { mode: 'draining' }), now).label).toBe('Draining');
const deleting = host('deleting', { connected: false, decommission: { operationId: 'delete', stage: 'cleaned', requestedAt: '', updatedAt: '', error: null, retryable: false } });
expect(hostListStatus(deleting, now).label).toBe('Deleting');
deleting.decommission!.stage = 'failed'; deleting.decommission!.error = 'Cleanup did not finish';
expect(hostListStatus(deleting, now)).toMatchObject({ key: 'attention', label: 'Cleanup failed', detail: 'Cleanup did not finish' });
});
it('surfaces setup and update failures without treating ordinary preparation as failure', () => {
const preparing = host('preparing', { preparation: { state: 'preparing', items: [{ kind: 'runtime', label: 'Runtime', state: 'preparing', detail: 'Downloading runtime' }] } });
expect(hostListStatus(preparing, now)).toMatchObject({ key: 'preparing', detail: 'Downloading runtime' });
preparing.preparation!.state = 'blocked'; preparing.preparation!.items[0].state = 'blocked';
expect(hostListStatus(preparing, now).key).toBe('attention');
expect(hostListStatus(host('update', { inventory: { agentUpdate: { enabled: true, state: 'failed', currentVersion: '1.0', error: 'Invalid signature' } } }), now)).toMatchObject({ key: 'attention', detail: 'Agent update failed: Invalid signature' });
expect(hostListStatus(host('revoked', { credentialActive: false }), now)).toMatchObject({ key: 'attention', tab: 'settings' });
});
it('sorts issues first initially and preserves existing positions when polls change status', () => {
const healthy = host('a'); const offline = host('z', { connected: false });
const order = stableHostOrder([], [healthy, offline], now);
expect(order).toEqual(['z', 'a']);
expect(stableHostOrder(order, [host('z'), host('a', { connected: false }), host('new')], now)).toEqual(['z', 'a', 'new']);
expect(stableHostOrder(order, [healthy], now)).toEqual(['a']);
});
it('distinguishes real zero usage from missing or invalid metrics', () => {
expect(hostCpuUsage(0)).toBe('0.0%');
for (const value of [undefined, null, NaN, Infinity, -1, 101]) expect(hostCpuUsage(value)).toBe('Not reported');
expect(hostUsedSpace(1024, 1024)).toBe('0 B / 1.0 KiB');
expect(hostUsedSpace(1024, undefined)).toBe('Not reported');
expect(hostUsedSpace(1024, 2048)).toBe('Not reported');
});
it('renders compact host links and workload without duplicate identity, setup metadata or deletion actions', () => {
const body = render(HostList, { props: { hosts: [{ ...host(), serverCount: 3 }], now } }).body;
expect(body).toContain('3 assigned servers on ec-1');
expect(body).toContain('0.0%');
expect(body).toContain('512.0 MiB / 1.0 GiB');
expect(body.match(/>ec-1</g)).toHaveLength(1);
expect(body).not.toContain('Delete host');
expect(body).not.toContain('Game ports');
expect(body).not.toContain('Automatic setup');
expect(body).not.toContain('Resource readings stale');
const stale = render(HostList, { props: { hosts: [{ ...host(), serverCount: 3 }], now: now + 90_001 } }).body;
expect(stale).toContain('Resource readings stale');
expect(stale).toContain('Offline');
});