Simplify host overview and move enrollment into drawer
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user