web/src/routes/+page.svelte
Ref: Size: 20.1 KiB History
<script lang="ts">
import {
fleet,
action,
createVM,
setPower,
deleteVM,
decommissionHost,
createJoinBlob,
vmStatus,
vmDetail,
restoreVM,
vmPower,
vmIP,
vmNetworkValue,
vmPowerAction,
vmTrustStale,
deleteConfirm,
upgradeAgent,
upgradeStuck,
upgradeStuckNote,
refreshUserCAs,
capacityReading,
hostStatusLabel,
hostNetworkOptions,
type CreateVMRequest,
type VM
} from '$lib/fleet.svelte';
import JoinHost from '$lib/JoinHost.svelte';
let showCreate = $state(false);
let advanced = $state(false);
let joinBlob = $state('');
let busy = $state('');
let filter = $state('');
// Case-insensitive substring match over the row's visible fields, so a
// large fleet can be narrowed live from one box.
const needle = $derived(filter.trim().toLowerCase());
const shownHosts = $derived(
fleet.hosts.filter(
(h) =>
!needle ||
`${h.name} ${osLabel(h)} ${h.bridge_cidr} ${h.status} ${h.online ? 'online' : 'offline'}`
.toLowerCase()
.includes(needle)
)
);
// hostNameById / vmCountByHost: computed once per hosts/vms snapshot instead
// of re-scanning on every row (host lookup was O(hosts) per VM row, VM count
// was O(vms) per host row—both now O(1) lookups into a map built once).
const hostNameById = $derived.by(() => new Map(fleet.hosts.map((h) => [h.id, h.name])));
const vmCountByHost = $derived.by(() => {
const m = new Map<string, number>();
for (const v of fleet.vms) m.set(v.host_id, (m.get(v.host_id) ?? 0) + 1);
return m;
});
// hostName is the display name for a VM's host: falls back to the id's
// first 8 chars if the host isn't in the current snapshot (e.g. mid-delete).
function hostName(hostId: string): string {
return hostNameById.get(hostId) ?? hostId.slice(0, 8);
}
// osLabel: compact distro for the list ("Ubuntu 24.04"), falling back to the
// full os-release pretty name, then the coarse GOOS, then a dash for a
// host that has not reported facts yet.
function osLabel(h: (typeof fleet.hosts)[number]): string {
if (h.os_id && h.os_version) {
return h.os_id.charAt(0).toUpperCase() + h.os_id.slice(1) + ' ' + h.os_version;
}
return h.os_pretty || h.os || '—';
}
// osTitle: the full pretty name + kernel, shown on hover so the detail is a
// mouseover away without widening the column.
function osTitle(h: (typeof fleet.hosts)[number]): string {
return [h.os_pretty, h.kernel].filter(Boolean).join(' · ');
}
// loadRatio: normalized pressure = 1-min load ÷ vCPUs (a 0–1+ gauge, unlike
// the raw whole-machine load). null when offline, metric-less, or vCPUs are
// unknown—the cell then shows a dash rather than a bogus 0.
function loadRatio(h: (typeof fleet.hosts)[number]): number | null {
if (!h.online || !h.metrics) return null;
const vcpus = h.capacity.vcpus;
if (!vcpus) return null;
return h.metrics.load1 / vcpus;
}
const shownVMs = $derived(
fleet.vms.filter((v) => {
if (!needle) return true;
return `${v.name} ${hostName(v.host_id)} ${vmStatus(v)} ${vmPower(v)} ${vmIP(v)} ${vmNetworkValue(v)}`
.toLowerCase()
.includes(needle);
})
);
let form = $state<CreateVMRequest>({ host_id: '' });
// A network belongs to a host, so the choice on offer is the selected host's
// advertised set—the last set it advertised, online or not (hostNetworkOptions
// carries why online is no part of it).
const hostNetworks = $derived(hostNetworkOptions(fleet.hosts, form.host_id));
// A picked name belongs to the host it was picked on, so only a change of
// host clears it—not the name's absence from the list above. Clearing on
// absence would let a host flapping dark for one tick silently turn a
// named-network create into a NAT one, which is the outcome this design
// forbids: a VM that asked for a named network either gets it or does not
// exist. A name the newly
// chosen host does not serve therefore reaches the server, which refuses it
// and says which networks that host has. pickedOn only remembers which host
// answered for the current choice, and is deliberately not $state: this
// effect both reads and writes it, so a reactive latch would make the effect
// depend on its own write. Guarded on a real change, the shape Console
// resets a reused session with.
let pickedOn: string | undefined;
$effect(() => {
if (pickedOn !== undefined && pickedOn !== form.host_id) form.network = '';
pickedOn = form.host_id;
});
// noCA is the tenant with nothing registered, which is worth saying before
// the form is filled in rather than after it is submitted. Held off until
// the CAs have actually been fetched, so an empty list that only means
// "still loading" never reads as a warning.
const noCA = $derived(fleet.userCAsLoaded && fleet.userCAs.length === 0);
// The VMs table marks guests whose trust predates the tenant's CURRENT CA
// set, so the CAs are needed for the table itself and not only for the
// create dialog. They are not in the SSE snapshot, so fetch them on mount;
// they change only when someone registers or removes one.
$effect(() => {
refreshUserCAs();
});
function openCreate() {
form = { host_id: fleet.hosts[0]?.id ?? '' };
showCreate = true;
// Ask again as the dialog opens: the mount fetch may be minutes old, and
// this keeps the answer current for a tenant that registered one in
// another tab.
refreshUserCAs();
}
async function submitCreate(e: Event) {
e.preventDefault();
busy = 'create';
if (await action(() => createVM(stripEmpty(form)))) {
showCreate = false;
}
busy = '';
}
// stripEmpty drops keys whose value is empty/undefined/null before sending
// the create-VM form—the caller guarantees the required keys (host_id)
// are non-empty, so this only ever strips optional-but-blank fields.
function stripEmpty<T extends object>(f: T): T {
return Object.fromEntries(
Object.entries(f).filter(([, v]) => v !== '' && v !== undefined && v !== null)
) as T;
}
async function power(id: string, p: 'running' | 'stopped') {
await action(() => setPower(id, p));
}
async function remove(v: VM) {
if (!confirm(deleteConfirm(v))) return;
await action(() => deleteVM(v.id));
}
async function restore(id: string) {
await action(() => restoreVM(id));
}
async function decommission(id: string, name: string) {
if (!confirm(`Decommission host ${name}? Its VMs will be drained and removed.`)) return;
await action(() => decommissionHost(id));
}
async function upgrade(h: (typeof fleet.hosts)[number]) {
if (!confirm(`Upgrade ${h.name} to ${fleet.latest_version}?`)) return;
await action(() => upgradeAgent(h.id));
}
async function addHost() {
await action(async () => {
joinBlob = await createJoinBlob();
});
}
</script>
{#if fleet.latest_version && fleet.server_version && fleet.latest_version !== fleet.server_version}
<div class="update-banner">
eitri {fleet.latest_version} is available (server running {fleet.server_version}) –
<a href="https://eitri.sh/docs/upgrade" target="_blank" rel="noreferrer">upgrade guide</a>
</div>
{/if}
<div class="row">
<input
id="fleet-filter"
class="filter"
type="search"
placeholder="filter hosts & VMs…"
bind:value={filter}
aria-label="filter hosts and VMs"
/>
</div>
<section>
<div class="row">
<h2>Hosts ({needle ? `${shownHosts.length}/${fleet.hosts.length}` : fleet.hosts.length})</h2>
{#if fleet.hosts.length > 0}
<button class="ghost" onclick={addHost}>+ Add host</button>
{/if}
</div>
{#if fleet.hosts.length === 0}
<div class="onboard">
<h3>Add your first host</h3>
<p class="hint">
A host is any Linux box or Mac that runs your VMs—the server's own box counts. Mint a
one-time join token, then run the printed command on the box; it comes online here the
moment it enrolls.
</p>
{#if joinBlob}
<JoinHost blob={joinBlob} version={fleet.latest_version} />
{:else}
<button onclick={addHost}>Add your first host</button>
{/if}
</div>
{:else if joinBlob || shownHosts.length === 0}
{#if joinBlob}
<JoinHost blob={joinBlob} version={fleet.latest_version} />
{/if}
{#if shownHosts.length === 0}
<p class="hint">No hosts match “{filter}”.</p>
{/if}
{/if}
{#if fleet.hosts.length > 0 && shownHosts.length > 0}
<div class="scroll">
<table>
<thead>
<tr>
<th rowspan="2">Name</th>
<th rowspan="2">Status</th>
<th rowspan="2">OS</th>
<th rowspan="2">Version</th>
<th rowspan="2" class="num">Load</th>
<th rowspan="2" class="num">VMs</th>
<th rowspan="2">Guest network</th>
<th colspan="3" class="group">Used / total</th>
<th rowspan="2"></th>
</tr>
<tr><th class="num">vCPU</th><th class="num">mem MB</th><th class="num">disk GB</th></tr>
</thead>
<tbody>
{#each shownHosts as h (h.id)}
{@const load = loadRatio(h)}
<tr>
<td><a href="/hosts/{h.id}">{h.name}</a></td>
<td>
<span class="dot {h.online ? 'on' : 'off'}"></span>
{hostStatusLabel(h)}
</td>
<td title={osTitle(h)}>{osLabel(h)}</td>
<td>
{h.agent_version || '—'}
<!-- The host page's three readings, at table scale: a version
cell has no room for the sentence, so a stuck offer shows
the word and carries the whole of it in the tooltip. -->
{#if h.pending_upgrade}
{#if upgradeStuck(h)}
<span class="stuck" title={upgradeStuckNote(h)}>stuck → {h.pending_upgrade.version}</span>
{:else}
<span class="pending">…→ {h.pending_upgrade.version}</span>
{/if}
{:else if h.agent_update_available}
<button class="ghost upgrade" onclick={() => upgrade(h)}>↑ {fleet.latest_version}</button>
{/if}
</td>
<td class="num">
{#if load !== null}
<span
class="load"
class:over={load > 1}
title="load1 {h.metrics?.load1.toFixed(2)} · {h.capacity.vcpus} vCPU"
>
{load.toFixed(2)}<span class="meter"
><i style="width:{Math.min(load, 1) * 100}%"></i></span
>
</span>
{:else}
<span class="hint">—</span>
{/if}
</td>
<td class="num">{vmCountByHost.get(h.id) ?? 0}</td>
<td>
{h.bridge_cidr || '—'}
<!-- The named networks beside the NAT bridge every host has, so
that adding --host-network to a unit can be confirmed here
rather than inferred from what the create dialog offers. A
host serving none says nothing. -->
{#if h.host_networks.length > 0}
<span class="networks" title="advertised with --host-network"
>· {h.host_networks.join(', ')}</span
>
{/if}
</td>
<td class="num" class:over={capacityReading(h.allocated.vcpus, h.capacity.vcpus).over > 0}
>{h.allocated.vcpus}/{h.capacity.vcpus || '?'}</td
>
<td class="num" class:over={capacityReading(h.allocated.mem_mb, h.capacity.mem_mb).over > 0}
>{h.allocated.mem_mb}/{h.capacity.mem_mb || '?'}</td
>
<td class="num" class:over={capacityReading(h.allocated.disk_gb, h.capacity.disk_gb).over > 0}
>{h.allocated.disk_gb}/{h.capacity.disk_gb || '?'}</td
>
<td>
{#if h.status !== 'decommissioning'}
<button class="danger" onclick={() => decommission(h.id, h.name)}>Decommission</button>
{:else}
<span class="hint">draining…</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<section>
<div class="row">
<h2>VMs ({needle ? `${shownVMs.length}/${fleet.vms.length}` : fleet.vms.length})</h2>
<button onclick={openCreate} disabled={fleet.hosts.length === 0}>+ Create VM</button>
</div>
{#if fleet.vms.length === 0}
<p class="hint">No VMs.</p>
{:else if shownVMs.length === 0}
<p class="hint">No VMs match “{filter}”.</p>
{:else}
<div class="scroll">
<table>
<thead>
<tr><th>Name</th><th>Host</th><th class="num">Allocation</th><th>Status</th><th>Power</th><th></th></tr>
</thead>
<tbody>
{#each shownVMs as v (v.id)}
<tr>
<td>
<a href="/vms/{v.id}">{v.name}</a>
{#if vmTrustStale(v)}<span
class="stale"
title="stale trust—this guest was created before the tenant's current CA set. A certificate from a CA registered since will not open it; recreate the VM to pick one up."
>△ stale trust</span
>{/if}
</td>
<td>{hostName(v.host_id)}</td>
<td class="num">{v.vcpus}c · {v.mem_mb}MB · {v.disk_gb}GB</td>
<td>
{#if v.deleted}<span class="teardown">deleting—restorable</span>{:else}{vmStatus(v)}{/if}{vmDetail(
v
)
? ` · ${vmDetail(v)}`
: ''}{v.last_error ? ` · ${v.last_error}` : ''}
</td>
<td>{vmPower(v)}</td>
<td class="actions">
{#if v.deleted}
<button class="restore" onclick={() => restore(v.id)}>Restore</button>
{:else}
{@const act = vmPowerAction(v)}
{#if act === 'stop'}
<button class="danger" onclick={() => power(v.id, 'stopped')}>Stop</button>
{:else if act === 'start'}
<button class="ghost" onclick={() => power(v.id, 'running')}>Start</button>
{/if}
<button class="danger" onclick={() => remove(v)}>Delete</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
{#if showCreate}
<div class="modal" role="dialog">
<form class="card" onsubmit={submitCreate}>
<h3>Create VM</h3>
{#if noCA}
<!-- Courtesy, not enforcement: the API refuses this create with a
409 and the form renders that message. Saying it here saves a
round trip, and says it while the reader can still act. -->
<div class="no-ca">
<p>
This tenant has no registered SSH user CA. A guest trusts the CA set it is created
with, so a VM made now would trust no certificate and nothing could ever reach it —
registering a CA afterwards does not reach a guest that already exists.
</p>
<p><a href="/settings">Register a CA in settings</a>, then create the VM.</p>
</div>
{/if}
<label>
Host
<select bind:value={form.host_id} required>
{#each fleet.hosts as h (h.id)}
<option value={h.id}>{h.name}</option>
{/each}
</select>
</label>
{#if hostNetworks.length > 0}
<label>
Network
<select bind:value={form.network}>
<option value="">NAT (default)</option>
{#each hostNetworks as n}
<option value={n}>{n}</option>
{/each}
</select>
</label>
{/if}
<label>
Name (optional)
<input bind:value={form.name} placeholder="auto: sandbox-xxxx" />
</label>
<label class="checkbox">
<input type="checkbox" bind:checked={advanced} /> Advanced
</label>
{#if advanced}
<!-- Placeholders mirror the control plane's create defaults
(types.DefaultVCPUs / DefaultMemMB / DefaultDiskGB); a Go guard
test (types_defaults_test.go) fails if they drift apart. -->
<div class="grid">
<label>vCPUs<input type="number" bind:value={form.vcpus} placeholder="2" /></label>
<label>Mem MB<input type="number" bind:value={form.mem_mb} placeholder="2048" /></label>
<label>Disk GB<input type="number" bind:value={form.disk_gb} placeholder="10" /></label>
</div>
<label>Image URL<input bind:value={form.image_url} placeholder="server default" /></label>
<label>Image sha256<input bind:value={form.image_sha256} placeholder="paired with URL" /></label>
<label
>cloud-init<textarea bind:value={form.cloud_init} rows="3" placeholder="#cloud-config …"
></textarea></label
>
{/if}
<div class="row end">
<button type="button" class="ghost" onclick={() => (showCreate = false)}>Cancel</button>
<button type="submit" disabled={busy === 'create'}
>{busy === 'create' ? 'Creating…' : 'Create'}</button
>
</div>
</form>
</div>
{/if}
<style>
.row {
display: flex;
align-items: center;
gap: 0.75em;
}
.row.end {
justify-content: flex-end;
margin-top: 0.5em;
}
h2 {
font-size: inherit;
margin: 1.5em 0 0.5em;
}
h3 {
font-size: inherit;
}
/* A tenant with no CA is about to make something it cannot reach, so the
warning gets a box of its own inside the dialog. Ink border like the
teardown callout: this one is meant to stop the reader. */
.no-ca {
border: 1px solid var(--ink);
padding: 0.6em 0.8em;
}
.no-ca p {
margin: 0 0 0.5em;
}
.no-ca p:last-child {
margin-bottom: 0;
}
/* Every table keeps its natural width and scrolls sideways inside its own
box. Nothing is squeezed and nothing is truncated: a CIDR or a host name
that does not fit is scrolled to, never elided into a half-truth. */
.scroll {
overflow-x: auto;
}
.scroll table {
min-width: 100%;
}
/* The heading that spans the three capacity columns. Numbers align on their
digits (td.num/th.num, global) so capacity reads down the column instead
of drifting with the width of each value. */
.group {
text-align: center;
}
/* Normalized-load pressure: the number is the reading, and the meter beside
it is the same value at a glance. A host past 1.0 sets in bold. */
.load {
display: inline-block;
font-variant-numeric: tabular-nums;
}
/* One mark for "past the line", wherever a line exists: a host past 1.0 load,
and an allocation column past the capacity its host reports. The latter
can happen without a create being at fault — a host that re-reports a
smaller machine puts VMs placed honestly over it. */
.load.over,
td.num.over {
font-weight: bold;
}
.meter {
display: inline-block;
width: 6ch;
height: 3px;
background: var(--wash);
vertical-align: middle;
margin-left: 0.6ch;
}
.meter > i {
display: block;
height: 100%;
background: var(--ink);
}
.upgrade {
margin-left: 0.5em;
}
/* Both sit where the button was. In flight is faint, like every other row
fact; stuck borrows the colour errors already speak in and hangs the
explanation off the title, since the fleet table is scanned, not read. */
.pending {
margin-left: 0.5em;
color: var(--faint);
}
.stuck {
margin-left: 0.5em;
color: var(--bad);
cursor: help;
}
/* Faint, like every other secondary row fact: the bridge is the reading,
the names are what else this host can put a guest on. */
.networks {
color: var(--faint);
cursor: help;
}
.actions {
display: flex;
gap: 0.5em;
}
.update-banner {
display: inline-block;
border: 1px solid var(--hairline);
padding: 0.25em 0.75em;
margin: 1em 0 0;
}
.modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.card {
background: var(--paper);
color: var(--ink);
border: 1px solid var(--ink);
padding: 1em;
width: 52ch;
max-width: 92vw;
display: flex;
flex-direction: column;
gap: 0.5em;
}
.card h3 {
margin: 0 0 0.25em;
}
label {
display: flex;
flex-direction: column;
gap: 0.1em;
}
label.checkbox {
flex-direction: row;
align-items: center;
gap: 0.5em;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.5em;
}
/* Let grid columns shrink to their 1fr share; number inputs have an
intrinsic min-width that otherwise pushes the third column past the card. */
.grid > label {
min-width: 0;
}
.card input,
.card select,
.card textarea {
width: 100%;
}
/* A tick box keeps its own size; only the fields fill the card. */
.card input[type='checkbox'] {
width: auto;
}
.filter {
margin-top: 1em;
width: 28ch;
}
.teardown {
font-weight: bold;
}
/* Stale trust is a footnote, not an alarm: the guest works, it just cannot
be opened by every CA the tenant now holds. Faint and glyph-led, like the
status lights—the triangle carries the state, so it survives without
colour, and the words carry it for anyone who cannot see the glyph. */
.stale {
color: var(--faint);
white-space: nowrap;
cursor: help;
}
.onboard {
border: 1px solid var(--hairline);
padding: 1em;
margin: 0.5em 0;
max-width: 72ch;
}
.onboard h3 {
margin: 0 0 0.5em;
}
.onboard p {
margin-bottom: 0.5em;
}
</style>