diff --git a/.github/workflows/upload-assets.yaml b/.github/workflows/upload-assets.yaml index db40e8495c..5bc1a73d75 100644 --- a/.github/workflows/upload-assets.yaml +++ b/.github/workflows/upload-assets.yaml @@ -3,7 +3,7 @@ name: Upload assets to dl.oxide.computer on: push: - branches: [main] + branches: [main, serial-console-ws] jobs: build-and-upload: diff --git a/app/components/StatusBadge.tsx b/app/components/StatusBadge.tsx index a817218746..865ec329f6 100644 --- a/app/components/StatusBadge.tsx +++ b/app/components/StatusBadge.tsx @@ -62,20 +62,3 @@ export const SnapshotStatusBadge = (props: { {props.status} ) - -export type SerialConsoleState = 'connecting' | 'connected' | 'disconnected' - -const SERIAL_CONSOLE_COLORS: Record = { - connecting: 'notice', - connected: 'default', - disconnected: 'destructive', -} - -export const SerialConsoleStatusBadge = (props: { - status: SerialConsoleState - className?: string -}) => ( - - {props.status} - -) diff --git a/app/components/Terminal.tsx b/app/components/Terminal.tsx index 132607c1ca..181d5490b8 100644 --- a/app/components/Terminal.tsx +++ b/app/components/Terminal.tsx @@ -1,87 +1,90 @@ import { useEffect, useRef, useState } from 'react' import type { ITerminalOptions } from 'xterm' import { Terminal as XTerm } from 'xterm' +import { AttachAddon } from 'xterm-addon-attach' import { FitAddon } from 'xterm-addon-fit' import 'xterm/css/xterm.css' import { DirectionDownIcon, DirectionUpIcon } from '@oxide/ui' import { classed } from '@oxide/util' -interface TerminalProps { - data?: number[] -} - -const options: ITerminalOptions = { - allowTransparency: false, - screenReaderMode: true, - fontFamily: '"GT America Mono", monospace', - fontSize: 13, - lineHeight: 1.2, - windowOptions: { - fullscreenWin: true, - refreshWin: true, - }, -} - const ScrollButton = classed.button`ml-4 flex h-8 w-8 items-center justify-center rounded border border-secondary hover:bg-hover` -function getTheme() { +function getOptions(): ITerminalOptions { const style = getComputedStyle(document.body) return { - background: style.getPropertyValue('--surface-default'), - foreground: style.getPropertyValue('--content-default'), - black: style.getPropertyValue('--surface-default'), - brightBlack: style.getPropertyValue('--surface-secondary'), - white: style.getPropertyValue('--content-default'), - brightWhite: style.getPropertyValue('--content-secondary'), - blue: style.getPropertyValue('--base-blue-500'), - brightBlue: style.getPropertyValue('--base-blue-900'), - green: style.getPropertyValue('--content-success'), - brightGreen: style.getPropertyValue('--content-success-secondary'), - red: style.getPropertyValue('--content-error'), - brightRed: style.getPropertyValue('--content-error-secondary'), - yellow: style.getPropertyValue('--content-notice'), - brightYellow: style.getPropertyValue('--content-notice-secondary'), - cursor: style.getPropertyValue('--content-default'), - cursorAccent: style.getPropertyValue('--content-accent'), + allowTransparency: false, + screenReaderMode: true, + fontFamily: '"GT America Mono", monospace', + fontSize: 13, + lineHeight: 1.2, + windowOptions: { + fullscreenWin: true, + refreshWin: true, + }, + theme: { + background: style.getPropertyValue('--surface-default'), + foreground: style.getPropertyValue('--content-default'), + black: style.getPropertyValue('--surface-default'), + brightBlack: style.getPropertyValue('--surface-secondary'), + white: style.getPropertyValue('--content-default'), + brightWhite: style.getPropertyValue('--content-secondary'), + blue: style.getPropertyValue('--base-blue-500'), + brightBlue: style.getPropertyValue('--base-blue-900'), + green: style.getPropertyValue('--content-success'), + brightGreen: style.getPropertyValue('--content-success-secondary'), + red: style.getPropertyValue('--content-error'), + brightRed: style.getPropertyValue('--content-error-secondary'), + yellow: style.getPropertyValue('--content-notice'), + brightYellow: style.getPropertyValue('--content-notice-secondary'), + cursor: style.getPropertyValue('--content-default'), + cursorAccent: style.getPropertyValue('--content-accent'), + }, } } -export const Terminal = ({ data }: TerminalProps) => { +interface TerminalProps { + ws: WebSocket +} + +export const Terminal = ({ ws }: TerminalProps) => { const [term, setTerm] = useState(null) - const terminalRef = useRef(null) + const terminalRef = useRef(null) useEffect(() => { - const newTerm = new XTerm({ theme: getTheme(), ...options }) + const newTerm = new XTerm(getOptions()) - // Persist terminal instance, initialize terminal + // TODO: the render triggered by this call is load-bearing and should not + // be. Moving initialization out to a useMemo like it should be + // + // const term = useMemo(() => new XTerm(getOptions()), []) + // + // introduces a bug where the serial console text does not show up until you + // click the terminal area or resize the window. It cannot be about making + // this effect run again, because the deps don't include newTerm. It must be + // something internal to XTerm. Overall I do not feel particularly good + // about this whole section. setTerm(newTerm) - if (terminalRef.current) { - newTerm.open(terminalRef.current) - } - // Setup terminal addons const fitAddon = new FitAddon() newTerm.loadAddon(fitAddon) + newTerm.loadAddon(new AttachAddon(ws)) // Handle window resizing const resize = () => fitAddon.fit() - resize() + // ref will always be defined by the time the effect runs, but make TS happy + if (terminalRef.current) { + newTerm.open(terminalRef.current) + resize() + } + window.addEventListener('resize', resize) return () => { newTerm.dispose() window.removeEventListener('resize', resize) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - useEffect(() => { - if (data) { - term?.clear() - term?.write(new Uint8Array(data)) - } - }, [term, data]) + }, [ws]) return ( <> diff --git a/app/pages/project/instances/instance/SerialConsolePage.tsx b/app/pages/project/instances/instance/SerialConsolePage.tsx index c40d8b9ad1..3df33e03e2 100644 --- a/app/pages/project/instances/instance/SerialConsolePage.tsx +++ b/app/pages/project/instances/instance/SerialConsolePage.tsx @@ -1,42 +1,103 @@ -import { Suspense, lazy } from 'react' +import { Suspense, lazy, useEffect, useRef, useState } from 'react' import { Link } from 'react-router-dom' -import { useApiQuery } from '@oxide/api' -import { Button, PrevArrow12Icon, Spinner } from '@oxide/ui' -import { MiB } from '@oxide/util' +import { api } from '@oxide/api' +import type { BadgeColor } from '@oxide/ui' +import { Badge } from '@oxide/ui' +import { PrevArrow12Icon, Spinner } from '@oxide/ui' import EquivalentCliCommand from 'app/components/EquivalentCliCommand' -import { SerialConsoleStatusBadge } from 'app/components/StatusBadge' import { useInstanceSelector } from 'app/hooks' import { pb } from 'app/util/path-builder' const Terminal = lazy(() => import('app/components/Terminal')) +// need prefix so Vite dev server can handle it specially +const pathPrefix = process.env.NODE_ENV === 'development' ? '/ws-serial-console' : '' + +type WsState = 'connecting' | 'open' | 'closed' | 'error' + +const statusColor: Record = { + connecting: 'notice', + open: 'default', + closed: 'notice', + error: 'destructive', +} + +const statusMessage: Record = { + connecting: 'connecting', + open: 'connected', + closed: 'disconnected', + error: 'error', +} + export function SerialConsolePage() { - const { project, instance } = useInstanceSelector() - - const maxBytes = 10 * MiB - - const { isRefetching, data, refetch } = useApiQuery( - 'instanceSerialConsole', - { - path: { instance }, - // holding off on using toPathQuery for now because it doesn't like numbers - query: { project, maxBytes, fromStart: 0 }, - }, - { refetchOnWindowFocus: false } - ) + const instanceSelector = useInstanceSelector() + const { project, instance } = instanceSelector + + const ws = useRef(null) + + const [connectionStatus, setConnectionStatus] = useState('connecting') + + // In dev, React 18 strict mode fires all effects twice for lulz, even ones + // with no dependencies. In order to prevent the websocket from being killed + // before it's even connected, in the cleanup callback we check not only that + // it is non-null, but also that it is OPEN before trying to kill it. This + // allows the effect to run twice with no ill effect. + // + // 1. effect runs, WS connection initialized and starts connecting + // 1a. cleanup runs, nothing happens because socket was not open yet + // 2. effect runs, but `ws.current` is truthy, so nothing happens + useEffect(() => { + // TODO: error handling if this connection fails + if (!ws.current) { + const { project, instance } = instanceSelector + ws.current = api.ws.instanceSerialConsoleStream(window.location.host + pathPrefix, { + path: { instance }, + query: { project, fromStart: 0 }, + }) + ws.current.binaryType = 'arraybuffer' // important! + } + return () => { + if (ws.current?.readyState === WebSocket.OPEN) { + ws.current.close() + } + } + }, [instanceSelector]) + + // Because this one does not look at ready state, just whether the thing is + // defined, it will remove the event listeners before the spurious second + // render. But that's fine, we can add and remove listeners all day. + // + // 1. effect runs, ws connection is there because the other effect has run, + // so listeners are attached + // 1a. cleanup runs, event listeners removed + // 2. effect runs again, event listeners attached again + useEffect(() => { + const setOpen = () => setConnectionStatus('open') + const setClosed = () => setConnectionStatus('closed') + const setError = () => setConnectionStatus('error') + + ws.current?.addEventListener('open', setOpen) + ws.current?.addEventListener('closed', setClosed) + ws.current?.addEventListener('error', setError) + + return () => { + ws.current?.removeEventListener('open', setOpen) + ws.current?.removeEventListener('closed', setClosed) + ws.current?.removeEventListener('error', setError) + } + }, []) const command = `oxide instance serial --project ${project} - --max-bytes ${maxBytes} ${instance} --continuous` return (
@@ -46,27 +107,18 @@ export function SerialConsolePage() {
- {!data && } - - - + {connectionStatus !== 'open' && } + {ws.current && }
- -
- + + {statusMessage[connectionStatus]} +
diff --git a/libs/api/index.ts b/libs/api/index.ts index 5329506014..04e260d028 100644 --- a/libs/api/index.ts +++ b/libs/api/index.ts @@ -10,7 +10,7 @@ import { wrapQueryClient, } from './hooks' -const api = new Api({ +export const api = new Api({ baseUrl: process.env.API_URL, }) diff --git a/package-lock.json b/package-lock.json index bb1a2dc421..2f588c9210 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "tunnel-rat": "^0.0.4", "uuid": "^8.3.2", "xterm": "^5.1.0", + "xterm-addon-attach": "^0.8.0", "xterm-addon-fit": "^0.7.0", "zod": "^3.19.1", "zustand": "^4.0.0" @@ -20507,6 +20508,14 @@ "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.1.0.tgz", "integrity": "sha512-LovENH4WDzpwynj+OTkLyZgJPeDom9Gra4DMlGAgz6pZhIDCQ+YuO7yfwanY+gVbn/mmZIStNOnVRU/ikQuAEQ==" }, + "node_modules/xterm-addon-attach": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/xterm-addon-attach/-/xterm-addon-attach-0.8.0.tgz", + "integrity": "sha512-k8N5boSYn6rMJTTNCgFpiSTZ26qnYJf3v/nJJYexNO2sdAHDN3m1ivVQWVZ8CHJKKnZQw1rc44YP2NtgalWHfQ==", + "peerDependencies": { + "xterm": "^5.0.0" + } + }, "node_modules/xterm-addon-fit": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.7.0.tgz", @@ -35529,6 +35538,12 @@ "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.1.0.tgz", "integrity": "sha512-LovENH4WDzpwynj+OTkLyZgJPeDom9Gra4DMlGAgz6pZhIDCQ+YuO7yfwanY+gVbn/mmZIStNOnVRU/ikQuAEQ==" }, + "xterm-addon-attach": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/xterm-addon-attach/-/xterm-addon-attach-0.8.0.tgz", + "integrity": "sha512-k8N5boSYn6rMJTTNCgFpiSTZ26qnYJf3v/nJJYexNO2sdAHDN3m1ivVQWVZ8CHJKKnZQw1rc44YP2NtgalWHfQ==", + "requires": {} + }, "xterm-addon-fit": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.7.0.tgz", diff --git a/package.json b/package.json index 198651f924..cdb021abd2 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "tunnel-rat": "^0.0.4", "uuid": "^8.3.2", "xterm": "^5.1.0", + "xterm-addon-attach": "^0.8.0", "xterm-addon-fit": "^0.7.0", "zod": "^3.19.1", "zustand": "^4.0.0" diff --git a/tools/deno/mock-serial-console.ts b/tools/deno/mock-serial-console.ts new file mode 100755 index 0000000000..aadba97201 --- /dev/null +++ b/tools/deno/mock-serial-console.ts @@ -0,0 +1,37 @@ +#! /usr/bin/env -S deno run --allow-run --allow-net +import { delay } from 'https://deno.land/std@0.181.0/async/delay.ts' +import { serve } from 'https://deno.land/std@0.181.0/http/server.ts' + +/* + * This exists because MSW does not support websockets. So in MSW mode, we also + * run this little server and configure Vite to proxy WS requests to it. + */ + +async function streamString(socket: WebSocket, s: string, delayMs = 50) { + for (const c of s) { + socket.send(new TextEncoder().encode(c)) + await delay(delayMs) + } +} + +async function serialConsole(req: Request) { + await delay(500) + const { socket, response } = Deno.upgradeWebSocket(req) + console.log(`New client connected`) + + // send hello as a binary frame for xterm to display + socket.onopen = () => { + setTimeout(() => { + streamString(socket, 'Wake up Neo...') + }, 200) + } + + // echo back binary data + socket.onmessage = (m) => socket.send(m.data) + + socket.onclose = () => console.log('Connection closed') + + return response +} + +serve(serialConsole, { port: 6036 }) diff --git a/vite.config.ts b/vite.config.ts index a206efc123..c694381023 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -74,6 +74,17 @@ export default defineConfig(({ mode }) => ({ }, rewrite: (path) => path.replace(/^\/api/, ''), }, + '/ws-serial-console': { + // local mock server vs Nexus + target: 'ws://localhost:' + (process.env.MSW ? 6036 : 12220), + ws: true, + configure(proxy) { + proxy.on('error', (_, req) => { + console.error(' to', '/ws-serial-console' + req.url) + }) + }, + rewrite: (path) => path.replace(/^\/ws-serial-console/, ''), + }, // We want to actually hit Nexus for this because it gives us a login redirect '/login': { target: 'http://localhost:12220',