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>