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
59 changes: 40 additions & 19 deletions app/components/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,33 @@ import { AttachAddon } from './AttachAddon'

const ScrollButton = classed.button`ml-4 flex h-8 w-8 items-center justify-center rounded-md border border-secondary hover:bg-hover`

function getOptions(): ITerminalOptions {
function getTheme(): ITerminalOptions['theme'] {
const style = getComputedStyle(document.body)
return {
background: style.getPropertyValue('--surface-default'),
foreground: style.getPropertyValue('--content-default'),
black: style.getPropertyValue('--surface-default'),
brightBlack: style.getPropertyValue('--content-quinary'),
white: style.getPropertyValue('--content-default'),
brightWhite: style.getPropertyValue('--content-secondary'),
blue: style.getPropertyValue('--content-info-secondary'),
brightBlue: style.getPropertyValue('--content-info'),
green: style.getPropertyValue('--content-success-secondary'),
brightGreen: style.getPropertyValue('--content-success'),
red: style.getPropertyValue('--content-error-secondary'),
brightRed: style.getPropertyValue('--content-error'),
yellow: style.getPropertyValue('--content-notice-secondary'),
brightYellow: style.getPropertyValue('--content-notice'),
cyan: style.getPropertyValue('--content-accent-secondary'),
brightCyan: style.getPropertyValue('--content-accent'),
magenta: style.getPropertyValue('--content-accent-alt-secondary'),
brightMagenta: style.getPropertyValue('--content-accent-alt'),
cursor: style.getPropertyValue('--content-default'),
cursorAccent: style.getPropertyValue('--surface-default'),
}
}

function getOptions(): ITerminalOptions {
return {
// it is not easy to figure out what the exact behavior is when scrollback
// is not defined because it seems to be used in a bunch of places in the
Expand All @@ -36,24 +61,7 @@ function getOptions(): ITerminalOptions {
fullscreenWin: true,
refreshWin: true,
},
theme: {
background: style.getPropertyValue('--surface-default'),
foreground: style.getPropertyValue('--content-default'),
black: style.getPropertyValue('--surface-default'),
brightBlack: style.getPropertyValue('--content-quinary'),
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('--surface-default'),
},
theme: getTheme(),
}
}

Expand Down Expand Up @@ -94,7 +102,20 @@ export function Terminal({ ws }: TerminalProps) {
}

window.addEventListener('resize', resize)

// Update terminal colors when the theme changes. getComputedStyle in
// getTheme() forces a synchronous style recalc, so the CSS custom
// properties already reflect the new theme by the time we read them.
const observer = new MutationObserver(() => {
newTerm.options.theme = getTheme()
})
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
})

return () => {
observer.disconnect()
newTerm.dispose()
window.removeEventListener('resize', resize)
}
Expand Down
18 changes: 13 additions & 5 deletions app/msw-mock-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,17 @@ const randomStatus = () => {

const sleep = async (ms: number) => new Promise((res) => setTimeout(res, ms))

async function streamString(socket: WebSocket, s: string, delayMs = 50) {
for (const c of s) {
socket.send(c)
await sleep(delayMs)
/** Stream boot log line-by-line with realistic timing */
async function streamBootLog(socket: WebSocket, text: string) {
for (const line of text.split('\n')) {
socket.send(line + '\r\n')
if (line === '' || line.startsWith('Welcome to') || line.includes('login:')) {
await sleep(200)
} else if (line.startsWith('[ OK ]') || line.startsWith(' Starting')) {
await sleep(30)
} else {
await sleep(15)
}
}
}

Expand All @@ -66,6 +73,7 @@ export async function startMockAPI() {
const { handlers } = await import('../mock-api/msw/handlers')
const { http, HttpResponse, ws } = await import('msw')
const { setupWorker } = await import('msw/browser')
const serialConsoleText = (await import('../mock-api/serial-console.txt?raw')).default

// defined in here because it depends on the dynamic import
const interceptAll = http.all('/v1/*', async () => {
Expand Down Expand Up @@ -102,7 +110,7 @@ export async function startMockAPI() {
client.send(event.data.toString() === '13' ? '\r\n' : event.data)
})
await sleep(1000) // make sure everything is ready first (especially a problem in CI)
await streamString(client.socket, 'Wake up Neo...')
await streamBootLog(client.socket, serialConsoleText)
})
).start({
quiet: true, // don't log successfully handled requests
Expand Down
10 changes: 6 additions & 4 deletions app/pages/project/instances/SerialConsolePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ type SkeletonProps = {

function SerialSkeleton({ children, animate }: SkeletonProps) {
return (
<div className="relative h-full shrink grow overflow-hidden">
<div className="bg-default relative h-full shrink grow overflow-hidden">
<div className="h-full space-y-2 overflow-hidden">
{[...Array(200)].map((_e, i) => (
<div
Expand All @@ -193,13 +193,15 @@ function SerialSkeleton({ children, animate }: SkeletonProps) {
))}
</div>

{/* gradient uses the surface-default token so it works in both themes */}
<div
className="absolute bottom-0 h-full w-full"
style={{
background: 'linear-gradient(180deg, rgba(8, 15, 17, 0) 0%, #080F11 100%)',
background:
'linear-gradient(180deg, transparent 0%, var(--surface-default) 100%)',
}}
/>
<div className="bg-raise! shadow-modal absolute top-1/2 left-1/2 flex w-96 -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center rounded-lg p-12">
<div className="bg-raise shadow-modal absolute top-1/2 left-1/2 flex w-96 -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center rounded-lg p-12">
{children}
</div>
</div>
Expand All @@ -221,7 +223,7 @@ const CannotConnect = ({ instance }: { instance: Instance }) => (
<span>The instance is </span>
<InstanceStateBadge className="ml-1.5" state={instance.runState} />
</p>
<p className="text-default mt-2 text-center text-balance">
<p className="text-default text-sans-md mt-2 text-center text-balance">
{isStarting(instance)
? 'Waiting for the instance to start before connecting.'
: 'You can only connect to the serial console on a running instance.'}
Expand Down
141 changes: 141 additions & 0 deletions mock-api/serial-console.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
Terminal theme test:
red green yellow blue magenta cyan white
bright-red bright-green bright-yellow bright-blue bright-magenta bright-cyan bright-white bright-black

Booting `Debian GNU/Linux'

Loading Linux 6.12.38+deb13-amd64 ...
Loading initial ramdisk ...
EFI stub: Loaded initrd from LINUX_EFI_INITRD_MEDIA_GUID device path
[ 0.000000] Linux version 6.12.38+deb13-amd64 (debian-kernel@lists.debian.org) (x86_64-linux-gnu-gcc-14 (Debian 14.2.0-19) 14.2.0, GNU ld (GNU Binutils for Debian) 2.44) #1 SMP PREEMPT_DYNAMIC Debian 6.12.38-1 (2025-07-16)
[ 0.000000] Command line: BOOT_IMAGE=/boot/vmlinuz-6.12.38+deb13-amd64 root=PARTUUID=3fa1e012-eadc-4f00-b183-2619d1d2321b ro console=tty0 console=ttyS0,115200n8
[ 0.000000] BIOS-provided physical RAM map:
[ 0.000000] BIOS-e820: [mem 0x0000000000000000-0x000000000009ffff] usable
[ 0.000000] BIOS-e820: [mem 0x0000000000100000-0x00000000bea59fff] usable
[ 0.000000] BIOS-e820: [mem 0x00000000bea5a000-0x00000000bed59fff] reserved
[ 0.000000] BIOS-e820: [mem 0x0000000100000000-0x000000023fffffff] usable
[ 0.000000] NX (Execute Disable) protection: active
[ 0.000000] DMI: Oxide OxVM, BIOS v0.8 The Aftermath 30, 3185 YOLD
[ 0.000000] tsc: Detected 1996.096 MHz processor
[ 0.103404] percpu: Embedded 66 pages/cpu s233472 r8192 d28672 u1048576
[ 0.116419] Built 1 zonelists, mobility grouping on. Total pages: 2064887
[ 0.118081] Policy zone: Normal
[ 0.118583] Kernel command line: BOOT_IMAGE=/boot/vmlinuz-6.12.38+deb13-amd64 root=PARTUUID=3fa1e012-eadc-4f00-b183-2619d1d2321b ro console=tty0 console=ttyS0,115200n8
[ 0.151479] Memory: 8016340K/8259548K available (18432K kernel code, 4484K rwdata, 12340K rodata, 3892K init, 7620K bss, 243208K reserved, 0K cma-reserved)
[ 0.295826] rcu: Hierarchical SRCU implementation.
[ 0.350553] smpboot: CPU0: AMD EPYC-Rome Processor (family: 0x17, model: 0x31, stepping: 0x0)
[ 0.620003] smp: Brought up 1 node, 2 CPUs
[ 0.673460] pci 0000:00:10.0: [01de:0000] type 00 class 0x010802 conventional PCI endpoint
[ 0.789199] MPTCP token hash table entries: 8192 (order: 5, 196608 bytes, linear)
[ 0.841359] Key type asymmetric registered
[ 1.113771] input: AT Translated Set 2 keyboard as /devices/platform/i8042/serio0/input/input0
[ 1.565161] nvme nvme0: pci function 0000:00:10.0
[ 1.849427] tsc: Refined TSC clocksource calibration: 1996.214 MHz
[ 1.935044] EXT4-fs (nvme0n1p1): mounted filesystem dced5a54-4fb7-4dda-abd6-4ea8f50bfe92 ro with ordered data mode. Quota mode: none.
[ 2.403361] nvme0n1: p1 p14 p15
GROWROOT: CHANGED: partition=1 start=262144 old: size=6027264 end=6289407 new: size=20709343 end=20971486
[ 2.850589] systemd[1]: Inserted module 'autofs4'
[ 2.915923] systemd[1]: systemd 257.7-1 running in system mode (+PAM +AUDIT +SELINUX +APPARMOR +IMA +SMACK +SECCOMP +GCRYPT +OPENSSL +ACL +BLKID +CURL +ELFUTILS +FIDO2 +IDN2 +IPTC +KMOD +LIBCRYPTSETUP +LIBFDISK +PCRE2 +PWQUALITY +ZSTD +BPF_FRAMEWORK +XKBCOMMON +SYSVINIT)
[ 2.919095] systemd[1]: Detected virtualization bhyve.
[ 2.920733] systemd[1]: Detected architecture x86-64.

Welcome to Debian GNU/Linux 13 (trixie)!

[ 2.941482] systemd[1]: Hostname set to <oxide-instance>.
[ OK ] Created slice system-getty.slice - Slice /system/getty.
[ OK ] Created slice system-modprobe.slice - Slice /system/modprobe.
[ OK ] Created slice system-serial\x2dget…slice - Slice /system/serial-getty.
[ OK ] Created slice user.slice - User and Session Slice.
[ OK ] Started systemd-ask-password-conso…equests to Console Directory Watch.
[ OK ] Reached target paths.target - Path Units.
[ OK ] Reached target remote-fs.target - Remote File Systems.
[ OK ] Reached target slices.target - Slice Units.
[ OK ] Reached target swap.target - Swaps.
[ OK ] Listening on systemd-journald.socket - Journal Sockets.
[ OK ] Listening on systemd-networkd.socket - Network Service Netlink Socket.
[ OK ] Listening on systemd-udevd-control.socket - udev Control Socket.
[ OK ] Listening on systemd-udevd-kernel.socket - udev Kernel Socket.
Mounting dev-hugepages.mount - Huge Pages File System...
Mounting dev-mqueue.mount - POSIX Message Queue File System...
Starting kmod-static-nodes.service…eate List of Static Device Nodes...
Starting modprobe@configfs.service - Load Kernel Module configfs...
Starting modprobe@drm.service - Load Kernel Module drm...
Starting systemd-journald.service - Journal Service...
Starting systemd-modules-load.service - Load Kernel Modules...
[ OK ] Mounted dev-hugepages.mount - Huge Pages File System.
[ OK ] Mounted dev-mqueue.mount - POSIX Message Queue File System.
[ OK ] Mounted tmp.mount - Temporary Directory /tmp.
[ OK ] Finished kmod-static-nodes.service…Create List of Static Device Nodes.
[ OK ] Finished modprobe@configfs.service - Load Kernel Module configfs.
[ OK ] Finished modprobe@drm.service - Load Kernel Module drm.
[ OK ] Started systemd-journald.service - Journal Service.
[ OK ] Finished modprobe@efi_pstore.service - Load Kernel Module efi_pstore.
[ OK ] Finished modprobe@fuse.service - Load Kernel Module fuse.
[ OK ] Finished systemd-fsck-root.service - File System Check on Root Device.
[ OK ] Finished systemd-modules-load.service - Load Kernel Modules.
[ 4.288341] EXT4-fs (nvme0n1p1): re-mounted dced5a54-4fb7-4dda-abd6-4ea8f50bfe92 r/w.
[ OK ] Finished systemd-remount-fs.servic…mount Root and Kernel File Systems.
[ OK ] Finished systemd-sysctl.service - Apply Kernel Variables.
Starting cloud-init-main.service - Cloud-init: Single Process...
Starting systemd-growfs-root.service - Grow Root File System...
Starting systemd-random-seed.service - Load/Save OS Random Seed...
[ 4.602163] EXT4-fs (nvme0n1p1): resized filesystem to 2588667
[ OK ] Finished systemd-random-seed.service - Load/Save OS Random Seed.
[ OK ] Finished systemd-growfs-root.service - Grow Root File System.
[ OK ] Finished systemd-sysusers.service - Create System Users.
Starting systemd-resolved.service - Network Name Resolution...
Starting systemd-timesyncd.service - Network Time Synchronization...
[ OK ] Started systemd-timesyncd.service - Network Time Synchronization.
[ OK ] Reached target time-set.target - System Time Set.
[ OK ] Started systemd-udevd.service - Ru…anager for Device Events and Files.
[ OK ] Started systemd-resolved.service - Network Name Resolution.
[ OK ] Found device dev-ttyS0.device - /dev/ttyS0.
[ OK ] Reached target local-fs.target - Local File Systems.
Starting apparmor.service - Load AppArmor profiles...
Starting systemd-tmpfiles-setup.se…ate System Files and Directories...
[ OK ] Finished systemd-tmpfiles-setup.se…reate System Files and Directories.
[ OK ] Started cloud-init-main.service - Cloud-init: Single Process.
Starting cloud-init-local.service …-init: Local Stage (pre-network)...
[ OK ] Finished cloud-init-local.service …ud-init: Local Stage (pre-network).
[ OK ] Reached target network-pre.target - Preparation for Network.
Starting systemd-networkd.service - Network Configuration...
[ OK ] Started systemd-networkd.service - Network Configuration.
[ OK ] Reached target network.target - Network.
[ OK ] Finished systemd-networkd-wait-onl… Wait for Network to be Configured.
Starting cloud-init-network.service - Cloud-init: Network Stage...
[ OK ] Finished cloud-init-network.service - Cloud-init: Network Stage.
[ OK ] Reached target network-online.target - Network is Online.
[ OK ] Reached target sysinit.target - System Initialization.
[ OK ] Started apt-daily.timer - Daily apt download activities.
[ OK ] Started dpkg-db-backup.timer - Daily dpkg database backup timer.
[ OK ] Started fstrim.timer - Discard unused filesystem blocks once a week.
[ OK ] Started systemd-tmpfiles-clean.tim…y Cleanup of Temporary Directories.
[ OK ] Reached target timers.target - Timer Units.
[ OK ] Listening on dbus.socket - D-Bus System Message Bus Socket.
[ OK ] Reached target sockets.target - Socket Units.
[ OK ] Reached target basic.target - Basic System.
Starting cloud-config.service - Cloud-init: Config Stage...
Starting dbus.service - D-Bus System Message Bus...
Starting ssh.service - OpenBSD Secure Shell server...
Starting systemd-logind.service - User Login Management...
Starting systemd-user-sessions.service - Permit User Sessions...
[ OK ] Started dbus.service - D-Bus System Message Bus.
[ OK ] Finished systemd-user-sessions.service - Permit User Sessions.
[ OK ] Started getty@tty1.service - Getty on tty1.
[ OK ] Started serial-getty@ttyS0.service - Serial Getty on ttyS0.
[ OK ] Reached target getty.target - Login Prompts.
[ OK ] Started ssh.service - OpenBSD Secure Shell server.
[ OK ] Started systemd-logind.service - User Login Management.
[ OK ] Finished cloud-config.service - Cloud-init: Config Stage.
[ OK ] Finished grub-common.service - Record successful boot for GRUB.
[ OK ] Started unattended-upgrades.service - Unattended Upgrades Shutdown.
[ OK ] Reached target multi-user.target - Multi-User System.
[ OK ] Reached target graphical.target - Graphical Interface.
Starting cloud-final.service - Cloud-init: Final Stage...
[ OK ] Finished cloud-final.service - Cloud-init: Final Stage.
[ OK ] Reached target cloud-init.target - Cloud-init target.
[ OK ] Started polkit.service - Authorization Manager.

Debian GNU/Linux 13 oxide-instance ttyS0

oxide-instance login:
18 changes: 8 additions & 10 deletions test/e2e/instance-serial.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,15 @@ test('serial console for existing instance', async ({ page }) => {

async function testSerialConsole(page: Page) {
const xterm = page.getByRole('application')
const input = page.getByRole('textbox', { name: 'Terminal input' })

// MSW mocks a message. use first() because there are multiple copies on screen
await expect(xterm.getByText('Wake up Neo...').first()).toBeVisible()
// Wait for the boot log to finish so typed input does not interleave with it.
await expect(xterm).toContainText('oxide-instance login:', { timeout: 15_000 })

// we need to do this for our keypresses to land
await page.locator('.xterm-helper-textarea').focus()
await input.focus()
await expect(input).toBeFocused()

await xterm.pressSequentially('abc')
await expect(xterm.getByText('Wake up Neo...abc').first()).toBeVisible()
await xterm.press('Enter')
await xterm.pressSequentially('def')
await expect(xterm.getByText('Wake up Neo...abc').first()).toBeVisible()
await expect(xterm.getByText('def').first()).toBeVisible()
await input.press('Enter')
await input.pressSequentially('def')
await expect(xterm).toContainText('def')
}
26 changes: 26 additions & 0 deletions test/e2e/theme.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,32 @@
*/
import { expect, test } from './utils'

test('Serial console terminal updates colors on theme change', async ({ page }) => {
await page.goto('/projects/mock-project/instances/db1/serial-console')

const xterm = page.getByRole('application')
await expect(xterm).toContainText('oxide-instance login:', { timeout: 15_000 })

// xterm.js sets background-color inline on the .xterm-viewport element
const viewport = page.locator('.xterm-viewport')
const getBg = () => viewport.evaluate((el) => getComputedStyle(el).backgroundColor)

const darkBg = await getBg()

// switch to light via the user menu
await page.getByRole('button', { name: 'User menu' }).click()
await page.getByRole('menuitem', { name: 'Theme' }).click()
await page.getByRole('menuitemradio', { name: 'Light' }).click()

const lightBg = await getBg()
expect(lightBg).not.toEqual(darkBg)

// switch back to dark (menu is still open)
await page.getByRole('menuitemradio', { name: 'Dark' }).click()

expect(await getBg()).toEqual(darkBg)
})

test('Theme picker changes data-theme on <html>', async ({ page }) => {
// default is light in Playwright, but don't rely on that
await page.emulateMedia({ colorScheme: 'light' })
Expand Down
Loading