Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/upload-assets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Upload assets to dl.oxide.computer

on:
push:
branches: [main]
branches: [main, serial-console-ws]

jobs:
build-and-upload:
Expand Down
17 changes: 0 additions & 17 deletions app/components/StatusBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,20 +62,3 @@ export const SnapshotStatusBadge = (props: {
{props.status}
</Badge>
)

export type SerialConsoleState = 'connecting' | 'connected' | 'disconnected'

const SERIAL_CONSOLE_COLORS: Record<SerialConsoleState, BadgeColor> = {
connecting: 'notice',
connected: 'default',
disconnected: 'destructive',
}

export const SerialConsoleStatusBadge = (props: {
status: SerialConsoleState
className?: string
}) => (
<Badge color={SERIAL_CONSOLE_COLORS[props.status]} className={props.className}>
{props.status}
</Badge>
)
105 changes: 54 additions & 51 deletions app/components/Terminal.tsx
Original file line number Diff line number Diff line change
@@ -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<XTerm | null>(null)
const terminalRef = useRef(null)
const terminalRef = useRef<HTMLDivElement>(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 (
<>
Expand Down
120 changes: 86 additions & 34 deletions app/pages/project/instances/instance/SerialConsolePage.tsx
Original file line number Diff line number Diff line change
@@ -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<WsState, BadgeColor> = {
connecting: 'notice',
open: 'default',
closed: 'notice',
error: 'destructive',
}

const statusMessage: Record<WsState, string> = {
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<WebSocket | null>(null)

const [connectionStatus, setConnectionStatus] = useState<WsState>('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 (
<div className="!mx-0 flex h-full max-h-[calc(100vh-60px)] !w-full flex-col">
<Link
to={pb.instance({ project, instance })}
to={pb.instance(instanceSelector)}
className="mx-3 mt-3 mb-6 flex h-10 flex-shrink-0 items-center rounded px-3 bg-accent-secondary"
>
<PrevArrow12Icon className="text-accent-tertiary" />
Expand All @@ -46,27 +107,18 @@ export function SerialConsolePage() {
</Link>

<div className="gutter relative w-full flex-shrink flex-grow overflow-hidden">
{!data && <SerialSkeleton />}
<Suspense fallback={null}>
<Terminal data={data?.data} />
</Suspense>
{connectionStatus !== 'open' && <SerialSkeleton />}
<Suspense fallback={null}>{ws.current && <Terminal ws={ws.current} />}</Suspense>
</div>
<div className="flex-shrink-0 justify-between overflow-hidden border-t bg-default border-secondary empty:border-t-0">
<div className="gutter flex h-20 items-center justify-between">
<div>
<Button
loading={isRefetching}
size="sm"
onClick={() => refetch()}
disabled={!data}
>
Refresh
</Button>

<EquivalentCliCommand command={command} />
</div>

<SerialConsoleStatusBadge status={data ? 'connected' : 'connecting'} />
<Badge color={statusColor[connectionStatus]}>
{statusMessage[connectionStatus]}
</Badge>
</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion libs/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
wrapQueryClient,
} from './hooks'

const api = new Api({
export const api = new Api({
baseUrl: process.env.API_URL,
})

Expand Down
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading