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
17 changes: 17 additions & 0 deletions app/components/StatusBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,20 @@ export const SnapshotStatusBadge = (props: {
{props.status}
</Badge>
)

export type SerialConsoleState = 'connecting' | 'connected' | 'disconnected'
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Temporary — not sure how web sockets will work with the API, so I took a stab at some potential states


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>
)
83 changes: 48 additions & 35 deletions app/components/Terminal.tsx
Original file line number Diff line number Diff line change
@@ -1,55 +1,59 @@
import { useEffect, useRef, useState } from 'react'
import type { ITerminalAddon, ITerminalOptions } from 'xterm'
import type { ITerminalOptions } from 'xterm'
import { Terminal as XTerm } from 'xterm'
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[]
className?: string
}

const options: ITerminalOptions = {
allowTransparency: false,
screenReaderMode: true,
rendererType: 'dom',
fontFamily: '"GT America Mono", monospace',
fontSize: 14,
fontSize: 13,
lineHeight: 1.2,
minimumContrastRatio: 21,
windowOptions: {
fullscreenWin: true,
refreshWin: true,
},
}

export const Terminal = ({ data, className }: TerminalProps) => {
const ScrollButton = classed.button`ml-4 flex h-8 w-8 items-center justify-center rounded border border-secondary hover:bg-hover`

function getTheme() {
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'),
}
}

export const Terminal = ({ data }: TerminalProps) => {
const [term, setTerm] = useState<XTerm | null>(null)
const terminalRef = useRef(null)

useEffect(() => {
const style = getComputedStyle(document.body)
const newTerm = new XTerm({
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'),
},
...options,
})
const newTerm = new XTerm({ theme: getTheme(), ...options })

// Persist terminal instance, initialize terminal
setTerm(newTerm)
Expand All @@ -59,14 +63,11 @@ export const Terminal = ({ data, className }: TerminalProps) => {

// Setup terminal addons
const fitAddon = new FitAddon()
const addons: ITerminalAddon[] = [fitAddon]
for (const addon of addons) {
newTerm.loadAddon(addon)
}
newTerm.loadAddon(fitAddon)

// Handle window resizing
const resize = () => {
fitAddon.fit()
}
const resize = () => fitAddon.fit()

resize()
window.addEventListener('resize', resize)
return () => {
Expand All @@ -83,7 +84,19 @@ export const Terminal = ({ data, className }: TerminalProps) => {
}
}, [term, data])

return <div className={className} ref={terminalRef} />
return (
<>
<div className="h-full w-full pr-12" ref={terminalRef} />
<div className="absolute right-0 top-0 space-y-2 text-secondary">
<ScrollButton onClick={() => term?.scrollToTop()}>
<DirectionUpIcon />
</ScrollButton>
<ScrollButton onClick={() => term?.scrollToBottom()}>
<DirectionDownIcon />
</ScrollButton>
</div>
</>
)
}

export default Terminal
12 changes: 10 additions & 2 deletions app/layouts/ProjectLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ReactElement } from 'react'
import { useMemo } from 'react'
import { matchPath, useLocation, useNavigate, useParams } from 'react-router-dom'

Expand Down Expand Up @@ -25,7 +26,14 @@ import { pb } from 'app/util/path-builder'
import { DocsLinkItem, NavLinkItem, Sidebar } from '../components/Sidebar'
import { ContentPane, PageContainer } from './helpers'

const ProjectLayout = () => {
type ProjectLayoutProps = {
/** Sometimes we need a different layout for the content pane. Like
* `<ContentPane />`, the element passed here should contain an `<Outlet />`.
*/
overrideContentPane?: ReactElement
}

const ProjectLayout = ({ overrideContentPane }: ProjectLayoutProps) => {
const navigate = useNavigate()
// org and project will always be there, instance may not
const projectSelector = useProjectSelector()
Expand Down Expand Up @@ -93,7 +101,7 @@ const ProjectLayout = () => {
</NavLinkItem>
</Sidebar.Nav>
</Sidebar>
<ContentPane />
{overrideContentPane || <ContentPane />}
</PageContainer>
)
}
Expand Down
17 changes: 17 additions & 0 deletions app/layouts/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@ export const ContentPane = () => (
</div>
)

/**
* Special content pane for the serial console that lets us break out of the
* usual layout. Main differences: no `pb-8` and `<main>` is locked at `h-full`
* to avoid page-level scroll. We also leave off the pagination and page actions
* `<div>` because we don't need it.
*/
export const SerialConsoleContentPane = () => (
<div id="content-pane" className="flex flex-col overflow-auto">
<div className="flex flex-grow flex-col">
<SkipLinkTarget />
<main className="[&>*]:gutter h-full">
<Outlet />
</main>
</div>
</div>
)

/** Loader for the `<Route>` that wraps all authenticated routes. */
export const userLoader = async () => {
await Promise.all([
Expand Down
2 changes: 1 addition & 1 deletion app/pages/project/instances/instance/InstancePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export function InstancePage() {
<Tab to={pb.instanceStorage(instanceSelector)}>Storage</Tab>
<Tab to={pb.instanceMetrics(instanceSelector)}>Metrics</Tab>
<Tab to={pb.nics(instanceSelector)}>Network Interfaces</Tab>
<Tab to={pb.serialConsole(instanceSelector)}>Serial Console</Tab>
<Tab to={pb.instanceConnect(instanceSelector)}>Connect</Tab>
</RouteTabs>
</>
)
Expand Down
107 changes: 107 additions & 0 deletions app/pages/project/instances/instance/SerialConsolePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { Suspense, lazy } from 'react'
import { Link } from 'react-router-dom'

import { useApiQuery } from '@oxide/api'
import { Button } from '@oxide/ui'
import { DirectionLeftIcon, Spinner } from '@oxide/ui'
import { MiB } from '@oxide/util'

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'))

export function SerialConsolePage() {
const { organization, project, instance } = useInstanceSelector()

const { isRefetching, data, refetch } = useApiQuery(
'instanceSerialConsoleV1',
{
path: { instance },
// holding off on using toPathQuery for now because it doesn't like numbers
query: { organization, project, maxBytes: 10 * MiB, fromStart: 0 },
},
{ refetchOnWindowFocus: false }
)

return (
<div className="!mx-0 flex h-full max-h-[calc(100vh-60px)] !w-full flex-col">
<Link
to={pb.instance({ organization, project, instance })}
className="mx-3 mt-3 mb-6 flex h-10 flex-shrink-0 items-center rounded px-3 bg-accent-secondary"
>
<DirectionLeftIcon className="text-accent-tertiary" />
<div className="ml-2 text-mono-sm text-accent">
<span className="text-accent-tertiary">Back to</span> instance
</div>
</Link>

<div className="gutter relative w-full flex-shrink flex-grow overflow-hidden">
{!data && <SerialSkeleton />}
<Suspense fallback={null}>
<Terminal data={data?.data} />
</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>

<Button variant="ghost" size="sm" disabled={!data} className="ml-2">
Equivalent CLI Command
</Button>
</div>

<SerialConsoleStatusBadge status={data ? 'connected' : 'connecting'} />
</div>
</div>
</div>
)
}

function SerialSkeleton() {
const instanceSelector = useInstanceSelector()

return (
<div className="relative h-full flex-shrink flex-grow overflow-hidden">
<div className="h-full space-y-2 overflow-hidden">
{[...Array(200)].map((_e, i) => (
<div
key={i}
className="h-4 rounded bg-tertiary motion-safe:animate-pulse"
style={{
width: `${Math.sin(Math.sin(i)) * 20 + 40}%`,
}} /* this is silly deterministic way to get random looking lengths */
Comment on lines +81 to +82
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted the text-like line lengths. If we use Math.random() here it'd change every render. Maybe we could use a useCallback but by using the index plus a nested sine function we get natural-ish looking line lengths that are the same every render.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this produces a great result. If you want random that doesn't change on every render, you could do this at top level in the file so it runs only once per pageload.

const rands = new Array(200).fill(0).map(() => Math.random())

then this will produce a similar range to what you had.

width: `${rands[i] * 40 + 20}%`

But I don't think it looks better. It looks messy.

Random

image

Sine formula

image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well all generative code is sine waves, why break the mould.

/>
))}
</div>

<div
className="absolute bottom-0 h-full w-full"
style={{
background: 'linear-gradient(180deg, rgba(8, 15, 17, 0) 0%, #080F11 100%)',
}}
/>
<div className="absolute top-1/2 left-1/2 flex w-96 -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center space-y-4 rounded-lg border p-12 !bg-raise border-secondary elevation-3">
<Spinner size="lg" />

<div className="space-y-2">
<p className="text-center text-sans-xl text-default">
Connecting to{' '}
<Link to={pb.instance(instanceSelector)} className="text-accent-secondary">
{instanceSelector.instance}
</Link>
</p>
</div>
</div>
</div>
)
}
19 changes: 19 additions & 0 deletions app/pages/project/instances/instance/tabs/ConnectTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { SettingsGroup } from '@oxide/ui'

import { useInstanceSelector } from 'app/hooks'
import { pb } from 'app/util/path-builder'

export function ConnectTab() {
const { organization, project, instance } = useInstanceSelector()

return (
<SettingsGroup
title="Serial Console"
docs={{ text: 'Serial Console', link: '/' }}
cta={pb.serialConsole({ organization, project, instance })}
ctaText="Connect"
>
Connect to your instance&rsquo;s serial console
</SettingsGroup>
)
}
41 changes: 0 additions & 41 deletions app/pages/project/instances/instance/tabs/SerialConsoleTab.tsx

This file was deleted.

Loading