Simplify host overview and move enrollment into drawer
This commit is contained in:
@@ -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>
|
||||
@@ -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); }
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { presentHostPreparation } from '$lib/server/hosts/preparation.js';
|
||||
import { hostOverview } from '$lib/server/hosts/overview.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 }) {
|
||||
requireManagementOrigin(request);
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { listHosts } from '$lib/server/hosts/index.js';
|
||||
import { presentHostPreparation } from '$lib/server/hosts/preparation.js';
|
||||
import { hostOverview } from '$lib/server/hosts/overview.js';
|
||||
|
||||
export function load() { return { hosts: listHosts().map(presentHostPreparation) }; }
|
||||
export function load() { return { hosts: hostOverview(), serverNow: Date.now() }; }
|
||||
|
||||
@@ -1,70 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
let { data }: { data: import('./$types').PageData } = $props();
|
||||
import { page } from '$app/stores';
|
||||
import type { Host } from '$lib/hosts.js';
|
||||
import { hostAgentUpdateLabel, hostDeletionMessage, hostReadinessLabel } from '$lib/hosts.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { hostListStatus, stableHostOrder } from '$ui/host-overview.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte';
|
||||
let hosts = $state<Host[]>(untrack(() => data.hosts));
|
||||
let loading = $state(false); let busy = $state(false); let error = $state('');
|
||||
let notice = $state(''); let deleteOpen = $state(false); let deletingHost = $state<Host | null>(null);
|
||||
import AddHostDrawer from '$ui/components/AddHostDrawer.svelte';
|
||||
import HostList from '$ui/components/HostList.svelte';
|
||||
let { data }: { data: import('./$types').PageData } = $props();
|
||||
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 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 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) {
|
||||
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 {
|
||||
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; name = ''; await load();
|
||||
} catch (cause) { error = (cause as Error).message; } finally { busy = false; }
|
||||
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; clockOffset = body.serverNow - Date.now(); now = body.serverNow; order = stableHostOrder(order, hosts, now); error = ''; }
|
||||
} 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>
|
||||
|
||||
<svelte:head><title>Hosts · Electric Cherry</title></svelte:head>
|
||||
<section class="page">
|
||||
<SectionHeader title="Hosts" subtitle="Connect game-server hosts. Runtime and game files are prepared automatically.">
|
||||
{#snippet actions()}<Button href="/capacity">View capacity</Button>{/snippet}
|
||||
<SectionHeader title="Hosts">
|
||||
{#snippet actions()}{#if canManage}<Button variant="primary" onclick={() => addOpen = true}>Add host</Button>{/if}{/snippet}
|
||||
</SectionHeader>
|
||||
{#if error}<p role="alert" class="ui-error">{error}</p>{/if}
|
||||
{#if notice}<p role="status">{notice}</p>{/if}
|
||||
{#if canManage}<section class="panel">
|
||||
<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>
|
||||
<form onsubmit={add}><fieldset disabled={busy}>
|
||||
<label>Friendly name<input required maxlength="200" bind:value={name} placeholder="Game host 1" /></label>
|
||||
<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>
|
||||
<Button type="submit" disabled={busy}>{busy ? 'Creating…' : 'Add host'}</Button>
|
||||
</fieldset></form>
|
||||
{#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}
|
||||
{#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 hosts.length}
|
||||
<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>
|
||||
{#if filtersVisible}<div class="filters">
|
||||
<input type="search" aria-label="Search hosts" placeholder="Search hosts…" bind:value={search} />
|
||||
<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>
|
||||
{#if search || status}<Button variant="ghost" size="sm" onclick={() => { search = ''; status = ''; }}>Clear filters</Button>{/if}
|
||||
</div>{/if}
|
||||
{#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}
|
||||
{:else}<p class="empty">No hosts connected yet.{canManage ? ' Add a host to begin.' : ''}</p>{/if}
|
||||
</section>
|
||||
<DeleteHostDialog bind:open={deleteOpen} host={deletingHost} ondeleted={deleted} />
|
||||
{#if canManage}<AddHostDrawer bind:open={addOpen} onadded={load} />{/if}
|
||||
|
||||
<style>
|
||||
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; }
|
||||
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); }
|
||||
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); }
|
||||
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; }
|
||||
.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; }
|
||||
.page { display:grid; gap:20px; }
|
||||
.summary,.filters { display:flex; align-items:center; flex-wrap:wrap; gap:12px; }
|
||||
.summary,.empty { color:var(--text-secondary); font-size:var(--text-sm); }
|
||||
.summary button { background:none; border:0; color:var(--state-danger); cursor:pointer; font:inherit; padding:4px; }
|
||||
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>
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<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.">
|
||||
{#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>
|
||||
<BackLink fallback="/hosts" />
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if}
|
||||
@@ -155,6 +155,11 @@
|
||||
</Dialog>
|
||||
|
||||
<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; }
|
||||
.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); }
|
||||
|
||||
@@ -31,6 +31,14 @@ describe('host detail instances', () => {
|
||||
.run(host.id, now, now);
|
||||
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 read = async (id: string) => (await GET({ request: new Request(`https://ec.test/api/hosts/${id}`), params: { id } }).json());
|
||||
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();
|
||||
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(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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user