71 lines
7.6 KiB
Svelte
71 lines
7.6 KiB
Svelte
<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 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);
|
||
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');
|
||
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; }
|
||
}
|
||
onMount(() => pollVisible(load, 30000, false));
|
||
</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>
|
||
{#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}
|
||
</section>
|
||
<DeleteHostDialog bind:open={deleteOpen} host={deletingHost} ondeleted={deleted} />
|
||
|
||
<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; }
|
||
</style>
|