Files
electric-cherry/src/lib/ui/components/AddHostDrawer.svelte
T
2026-10-10 00:19:48 -04:00

192 lines
6.4 KiB
Svelte

<script lang="ts">
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { timeFormat } from '$ui/stores/time-zone.js';
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>
<p>
This command downloads the installer from the public Electric Cherry repository. The
host automatically fetches the publisher's public signing key from that repository
during installation and before every update check.
</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 {$timeFormat.dateTime(enrollment.expiresAt)}.
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}>
<Field label="Friendly name">
<Input
required
maxlength={200}
bind:value={name}
placeholder="Game host 1"
/></Field
>
<details>
<summary>Advanced settings</summary>
<div class="ports">
<Field label="First game port">
<Input
type="number"
required
min="1"
max="65535"
bind:value={portMin}
/></Field
>
<Field label="Last game port">
<Input
type="number"
required
min={portMin}
max="65535"
bind:value={portMax}
/></Field
>
</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);
}
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;
}
textarea:focus-visible,
summary:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
</style>