diff --git a/app/components/StatusBadge.tsx b/app/components/StatusBadge.tsx index 74f0a7a74..0db19d128 100644 --- a/app/components/StatusBadge.tsx +++ b/app/components/StatusBadge.tsx @@ -58,3 +58,20 @@ 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 e6bca7c0d..107e90d42 100644 --- a/app/components/Terminal.tsx +++ b/app/components/Terminal.tsx @@ -1,12 +1,14 @@ 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 = { @@ -14,42 +16,44 @@ const options: ITerminalOptions = { 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(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) @@ -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 () => { @@ -83,7 +84,19 @@ export const Terminal = ({ data, className }: TerminalProps) => { } }, [term, data]) - return
+ return ( + <> +
+
+ term?.scrollToTop()}> + + + term?.scrollToBottom()}> + + +
+ + ) } export default Terminal diff --git a/app/layouts/ProjectLayout.tsx b/app/layouts/ProjectLayout.tsx index 4f2cae2dd..d1699114a 100644 --- a/app/layouts/ProjectLayout.tsx +++ b/app/layouts/ProjectLayout.tsx @@ -1,3 +1,4 @@ +import type { ReactElement } from 'react' import { useMemo } from 'react' import { matchPath, useLocation, useNavigate, useParams } from 'react-router-dom' @@ -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 + * ``, the element passed here should contain an ``. + */ + overrideContentPane?: ReactElement +} + +const ProjectLayout = ({ overrideContentPane }: ProjectLayoutProps) => { const navigate = useNavigate() // org and project will always be there, instance may not const projectSelector = useProjectSelector() @@ -93,7 +101,7 @@ const ProjectLayout = () => { - + {overrideContentPane || } ) } diff --git a/app/layouts/helpers.tsx b/app/layouts/helpers.tsx index 877845542..4cb5c0db3 100644 --- a/app/layouts/helpers.tsx +++ b/app/layouts/helpers.tsx @@ -27,6 +27,23 @@ export const ContentPane = () => (
) +/** + * Special content pane for the serial console that lets us break out of the + * usual layout. Main differences: no `pb-8` and `
` is locked at `h-full` + * to avoid page-level scroll. We also leave off the pagination and page actions + * `
` because we don't need it. + */ +export const SerialConsoleContentPane = () => ( +
+
+ +
+ +
+
+
+) + /** Loader for the `` that wraps all authenticated routes. */ export const userLoader = async () => { await Promise.all([ diff --git a/app/pages/project/instances/instance/InstancePage.tsx b/app/pages/project/instances/instance/InstancePage.tsx index 95ab3c0c0..65d5cdc73 100644 --- a/app/pages/project/instances/instance/InstancePage.tsx +++ b/app/pages/project/instances/instance/InstancePage.tsx @@ -93,7 +93,7 @@ export function InstancePage() { Storage Metrics Network Interfaces - Serial Console + Connect ) diff --git a/app/pages/project/instances/instance/SerialConsolePage.tsx b/app/pages/project/instances/instance/SerialConsolePage.tsx new file mode 100644 index 000000000..8a91903b4 --- /dev/null +++ b/app/pages/project/instances/instance/SerialConsolePage.tsx @@ -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 ( +
+ + +
+ Back to instance +
+ + +
+ {!data && } + + + +
+
+
+
+ + + +
+ + +
+
+
+ ) +} + +function SerialSkeleton() { + const instanceSelector = useInstanceSelector() + + return ( +
+
+ {[...Array(200)].map((_e, i) => ( +
+ ))} +
+ +
+
+ + +
+

+ Connecting to{' '} + + {instanceSelector.instance} + +

+
+
+
+ ) +} diff --git a/app/pages/project/instances/instance/tabs/ConnectTab.tsx b/app/pages/project/instances/instance/tabs/ConnectTab.tsx new file mode 100644 index 000000000..3a9982a58 --- /dev/null +++ b/app/pages/project/instances/instance/tabs/ConnectTab.tsx @@ -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 ( + + Connect to your instance’s serial console + + ) +} diff --git a/app/pages/project/instances/instance/tabs/SerialConsoleTab.tsx b/app/pages/project/instances/instance/tabs/SerialConsoleTab.tsx deleted file mode 100644 index ac1b16aba..000000000 --- a/app/pages/project/instances/instance/tabs/SerialConsoleTab.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Suspense, lazy } from 'react' - -import { useApiQuery } from '@oxide/api' -import { Button } from '@oxide/ui' -import { MiB } from '@oxide/util' - -import { PageActions } from 'app/components/PageActions' -import { useInstanceSelector } from 'app/hooks' - -const Terminal = lazy(() => import('app/components/Terminal')) - -export function SerialConsoleTab() { - const { organization, project, instance } = useInstanceSelector() - - const { 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 ( - <> -
- }> - - -
- -
- -
-
- - ) -} diff --git a/app/routes.tsx b/app/routes.tsx index 759fc58a7..c44d6a433 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -21,7 +21,7 @@ import RootLayout from './layouts/RootLayout' import SettingsLayout from './layouts/SettingsLayout' import SiloLayout from './layouts/SiloLayout' import SystemLayout from './layouts/SystemLayout' -import { userLoader } from './layouts/helpers' +import { SerialConsoleContentPane, userLoader } from './layouts/helpers' import DeviceAuthSuccessPage from './pages/DeviceAuthSuccessPage' import DeviceAuthVerifyPage from './pages/DeviceAuthVerifyPage' import LoginPage from './pages/LoginPage' @@ -41,9 +41,10 @@ import { VpcPage, VpcsPage, } from './pages/project' +import { SerialConsolePage } from './pages/project/instances/instance/SerialConsolePage' +import { ConnectTab } from './pages/project/instances/instance/tabs/ConnectTab' import { MetricsTab } from './pages/project/instances/instance/tabs/MetricsTab' import { NetworkingTab } from './pages/project/instances/instance/tabs/NetworkingTab' -import { SerialConsoleTab } from './pages/project/instances/instance/tabs/SerialConsoleTab' import { StorageTab } from './pages/project/instances/instance/tabs/StorageTab' import { ProfilePage } from './pages/settings/ProfilePage' import { SSHKeysPage } from './pages/settings/SSHKeysPage' @@ -206,6 +207,25 @@ export const routes = createRoutesFromElements( {/* PROJECT */} + + {/* Serial console page gets its own little section here because it + cannot use the normal .*/} + } />} + handle={{ crumb: projectCrumb }} + > + + + } + handle={{ crumb: 'Serial Console' }} + /> + + + + } @@ -217,22 +237,22 @@ export const routes = createRoutesFromElements( loader={CreateInstanceForm.loader} handle={{ crumb: 'New instance' }} /> - } /> } loader={InstancesPage.loader} /> + } /> } loader={InstancePage.loader}> } loader={StorageTab.loader} - handle={{ crumb: 'storage' }} + handle={{ crumb: 'Storage' }} /> } loader={NetworkingTab.loader} - handle={{ crumb: 'network-interfaces' }} + handle={{ crumb: 'Network interfaces' }} /> } - handle={{ crumb: 'serial-console' }} + path="connect" + element={} + handle={{ crumb: 'Connect' }} /> diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 83749d718..c0d02fb34 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -19,6 +19,7 @@ test('path builder', () => { "diskNew": "/orgs/a/projects/b/disks-new", "disks": "/orgs/a/projects/b/disks", "instance": "/orgs/a/projects/b/instances/c", + "instanceConnect": "/orgs/a/projects/b/instances/c/connect", "instanceMetrics": "/orgs/a/projects/b/instances/c/metrics", "instanceNew": "/orgs/a/projects/b/instances-new", "instancePage": "/orgs/a/projects/b/instances/c/storage", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 3df22cc36..0098ce6cf 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -41,6 +41,7 @@ export const pb = { instanceMetrics: (params: Instance) => `${pb.instance(params)}/metrics`, instanceStorage: (params: Instance) => `${pb.instance(params)}/storage`, + instanceConnect: (params: Instance) => `${pb.instance(params)}/connect`, nics: (params: Instance) => `${pb.instance(params)}/network-interfaces`, diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index b7258b714..6a06cc6b5 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -452,7 +452,7 @@ export const handlers = makeHandlers({ }, instanceSerialConsoleV1(_params) { // TODO: Add support for params - return serial + return json(serial, { delay: 3000 }) }, instanceStartV1({ path, query }) { const instance = lookup.instance({ ...path, ...query }) diff --git a/libs/ui/index.ts b/libs/ui/index.ts index aff89a493..f93e483a1 100644 --- a/libs/ui/index.ts +++ b/libs/ui/index.ts @@ -24,6 +24,7 @@ export * from './lib/progress/Progress' export * from './lib/properties-table/PropertiesTable' export * from './lib/radio-group/RadioGroup' export * from './lib/radio/Radio' +export * from './lib/settings-group/SettingsGroup' export * from './lib/side-modal/SideModal' export * from './lib/skip-link/SkipLink' export * from './lib/spinner/Spinner' diff --git a/libs/ui/lib/button/button.css b/libs/ui/lib/button/button.css index a9f7c4a32..7f39deb41 100644 --- a/libs/ui/lib/button/button.css +++ b/libs/ui/lib/button/button.css @@ -30,7 +30,7 @@ } .btn-ghost { - @apply border text-secondary border-default hover:bg-hover disabled:bg-transparent; + @apply border text-secondary border-default hover:bg-hover disabled:bg-transparent disabled:text-disabled; } .btn-ghost:after { @apply hidden; diff --git a/libs/ui/lib/settings-group/SettingsGroup.stories.tsx b/libs/ui/lib/settings-group/SettingsGroup.stories.tsx new file mode 100644 index 000000000..18d62817d --- /dev/null +++ b/libs/ui/lib/settings-group/SettingsGroup.stories.tsx @@ -0,0 +1,24 @@ +import { SettingsGroup } from './SettingsGroup' + +export const Default = () => ( + + Connect to your instance’s serial console + +) + +export const WithoutDocs = () => ( + + Connect to your instance’s serial console + +) + +export const FunctionAction = () => ( + alert('hi')} ctaText="Connect"> + Connect to your instance’s serial console + +) diff --git a/libs/ui/lib/settings-group/SettingsGroup.tsx b/libs/ui/lib/settings-group/SettingsGroup.tsx new file mode 100644 index 000000000..e25763ed3 --- /dev/null +++ b/libs/ui/lib/settings-group/SettingsGroup.tsx @@ -0,0 +1,50 @@ +import { Link } from 'react-router-dom' + +import { Button, OpenLink12Icon, buttonStyle } from '@oxide/ui' + +type Props = { + title: string + docs?: { + text: string + link: string + } + children: React.ReactNode + /** String action is a link */ + cta: string | (() => void) + ctaText: string +} + +export const SettingsGroup = ({ title, docs, children, cta, ctaText }: Props) => { + return ( +
+
+
{title}
+ {children} +
+
+ {/* div always present to keep the button right-aligned */} +
+ {docs && ( + <> + Learn more about{' '} + + {docs.text} + + + + )} +
+ + {typeof cta === 'string' ? ( + + {ctaText} + + ) : ( + + )} +
+
+ ) +}