a73x

web/src/lib/Console.svelte

Ref:   Size: 6.3 KiB   History

<script lang="ts">
	// Static CSS import: extracted at build time, never executes in SSR —
	// prerender-safe, and more robust than a dynamic pure-CSS import (flaky
	// across Vite majors). Only the xterm JS needs the dynamic import.
	import '@xterm/xterm/css/xterm.css';
	// Type-only import: erased at compile time, so it's just as SSR-safe as
	// the dynamic runtime import below.
	import type { Terminal } from '@xterm/xterm';
	import { mintTicket } from '$lib/fleet.svelte';

	let { vmId }: { vmId: string } = $props();

	let holder: HTMLDivElement | undefined = $state();
	let status = $state<'closed' | 'connecting' | 'open' | 'error'>('closed');
	let error = $state('');
	// Not runes state: xterm + WS are imperative resources, not render inputs.
	let term: Terminal | null = null;
	let ws: WebSocket | null = null;

	// gen invalidates a superseded console session across open()'s async gap
	// (import + ticket mint) and its socket's late close handshake: unmount /
	// vm navigation runs close() while open() is mid-await, and without this
	// the resumed open() would connect a WebSocket nobody can ever close
	// (the server holds its stream until the tab dies). Same pattern as
	// connectGen in fleet.svelte.ts and forId in the detail page's loadEvents.
	let gen = 0;

	async function open() {
		if (status === 'connecting' || status === 'open' || !holder) return;
		// A remote close/error leaves the dead terminal's DOM in holder —
		// release it so reopening doesn't stack a second xterm below it.
		release();
		const myGen = ++gen;
		status = 'connecting';
		error = '';
		// Hoisted out of the try so the catch can close a socket constructed
		// before the `ws = sock` handoff—a throw in that window would
		// otherwise leak a live connection the server holds open.
		let sock: WebSocket | undefined;
		try {
			// Dynamic import: xterm touches `document`, and this SPA prerenders
			// (adapter-static SSR)—never load its JS at module scope.
			const { Terminal } = await import('@xterm/xterm');
			const ticket = await mintTicket();
			if (myGen !== gen || !holder) return; // superseded while importing/minting
			const proto = location.protocol === 'https:' ? 'wss' : 'ws';
			sock = new WebSocket(
				`${proto}://${location.host}/api/v1/vms/${encodeURIComponent(vmId)}/console/ws?ticket=${encodeURIComponent(ticket)}`
			);
			sock.binaryType = 'arraybuffer';
			const t = new Terminal({ scrollback: 5000, fontSize: 13 });
			t.open(holder);
			const enc = new TextEncoder();
			t.onData((d: string) => {
				if (sock?.readyState === WebSocket.OPEN) sock.send(enc.encode(d));
			});
			sock.onmessage = (e) => t.write(new Uint8Array(e.data as ArrayBuffer));
			sock.onopen = () => {
				status = 'open';
				t.focus();
			};
			sock.onclose = (e) => {
				if (myGen !== gen) return; // superseded socket: don't touch live state
				// The server delivers agent-leg failures as a close reason
				// (host offline, VM refused)—surface it verbatim.
				if (e.reason) {
					status = 'error';
					error = e.reason;
				} else if (status === 'open' || status === 'connecting') {
					status = 'closed';
				}
			};
			sock.onerror = () => {
				if (myGen !== gen) return; // superseded socket: don't touch live state
				if (status !== 'error') {
					status = 'error';
					error = 'console connection failed';
				}
			};
			term = t;
			ws = sock;
		} catch (e) {
			sock?.close(); // constructed but never handed to ws: don't leak it
			if (myGen !== gen) return; // superseded: state belongs to the new session
			status = 'error';
			error = e instanceof Error ? e.message : String(e);
		}
	}

	// release frees the imperative resources without touching status—used
	// by close() and by open() to clear a remotely-closed session's leftovers
	// before starting fresh.
	function release() {
		ws?.close();
		term?.dispose();
		ws = null;
		term = null;
	}

	function close() {
		gen++; // invalidate any in-flight open() and its socket's handlers
		release();
		status = 'closed';
	}

	// Reset the session when this component is REUSED for a different VM: the
	// detail page reuses one Console instance across /vms/A → /vms/B, so an
	// open console must tear down when vmId actually changes. Guarded on a real
	// change—NOT returned as an effect cleanup—because the detail page
	// re-renders ~1/s from the SSE fleet stream (fleet.vms is reassigned on
	// every push), and an effect that returned close() as its cleanup re-ran
	// that cleanup on every push, killing a live console ~1s after it opened.
	let sessionVm: string | undefined;
	$effect(() => {
		if (sessionVm !== undefined && sessionVm !== vmId) close();
		sessionVm = vmId;
	});

	// Teardown on unmount only. The body reads nothing reactive, so its cleanup
	// runs solely on destroy—never on a re-render.
	$effect(() => close);
</script>

<section class="console">
	<header>
		<h3>Console</h3>
		{#if status === 'open'}
			<button class="ghost" onclick={close}>Disconnect</button>
		{:else}
			<button onclick={open} disabled={status === 'connecting'}>
				{status === 'connecting' ? 'Connecting…' : 'Open console'}
			</button>
		{/if}
	</header>
	{#if status === 'error'}
		<p class="err">{error}</p>
	{/if}
	<div class="term" bind:this={holder} class:hidden={status === 'closed' || status === 'error'}></div>
	{#if status === 'closed'}
		<p class="hint">
			Serial console—you'll see the boot log and anything printed to ttyS0.
			Logging in requires credentials your cloud-init set up; eitri injects none.
		</p>
	{/if}
</section>

<style>
	.console {
		border: 1px solid var(--hairline);
		padding: 0.6em 0.8em;
		margin-top: 0.8em;
	}
	.console header {
		display: flex;
		align-items: center;
		justify-content: space-between;
	}
	.console h3 {
		margin: 0;
		font-size: inherit;
	}
	/* The terminal itself stays a dark surface—it is a terminal—but its frame
	   is the console's own hairline. */
	.term {
		min-height: 320px;
		background: #000;
		padding: 4px;
		margin-top: 0.5em;
		/* xterm renders a fixed 80-col canvas (~640px): scroll it inside the
		   panel instead of overflowing narrow viewports. */
		overflow-x: auto;
	}
	.term.hidden {
		display: none;
	}
	.err {
		color: var(--bad);
		margin: 0.4em 0 0;
	}
	/* colour comes from the global .hint rule (+layout.svelte); this one just
	   wants a smaller size and top margin. */
	.hint {
		font-size: 0.85em;
		margin: 0.4em 0 0;
	}
</style>