a73x

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>