Skip to content

Commit 69ba87b

Browse files
authored
Handle serial console close event properly (#2246)
* the event is 'close', not 'closed' * plumb instance state into serial console skeleton * make a dent in a more robust can't connect message * handle error case in mediocre fashion, aria-label on scroll buttons
1 parent 13d46e8 commit 69ba87b

File tree

5 files changed

+111
-29
lines changed

5 files changed

+111
-29
lines changed

app/api/util.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ const instanceActions: Record<string, InstanceState[]> = {
100100
attachDisk: ['creating', 'stopped'],
101101
// https://github.com/oxidecomputer/omicron/blob/8f0cbf0/nexus/db-queries/src/db/datastore/network_interface.rs#L482
102102
updateNic: ['stopped'],
103+
// https://github.com/oxidecomputer/omicron/blob/ebcc2acd/nexus/src/app/instance.rs#L1648-L1676
104+
serialConsole: ['running', 'rebooting', 'migrating', 'repairing'],
103105
}
104106

105107
// setting .states is a cute way to make it ergonomic to call the test function

app/components/Terminal.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,11 @@ export default function Terminal({ ws }: TerminalProps) {
106106
<>
107107
<div className="h-full w-[calc(100%-3rem)] text-mono-code" ref={terminalRef} />
108108
<div className="absolute right-0 top-0 space-y-2 text-secondary">
109-
<ScrollButton onClick={() => term?.scrollToTop()}>
110-
<DirectionUpIcon />
109+
<ScrollButton onClick={() => term?.scrollToTop()} aria-label="Scroll to top">
110+
<DirectionUpIcon aria-hidden />
111111
</ScrollButton>
112-
<ScrollButton onClick={() => term?.scrollToBottom()}>
113-
<DirectionDownIcon />
112+
<ScrollButton onClick={() => term?.scrollToBottom()} aria-label="Scroll to bottom">
113+
<DirectionDownIcon aria-hidden />
114114
</ScrollButton>
115115
</div>
116116
</>

app/pages/project/instances/instance/SerialConsolePage.tsx

Lines changed: 103 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,22 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8+
import cn from 'classnames'
89
import { lazy, Suspense, useEffect, useRef, useState } from 'react'
9-
import { Link } from 'react-router-dom'
10-
11-
import { api } from '@oxide/api'
10+
import { Link, type LoaderFunctionArgs } from 'react-router-dom'
11+
12+
import {
13+
api,
14+
apiQueryClient,
15+
instanceCan,
16+
usePrefetchedApiQuery,
17+
type InstanceState,
18+
} from '@oxide/api'
1219
import { PrevArrow12Icon } from '@oxide/design-system/icons/react'
1320

1421
import { EquivalentCliCommand } from '~/components/EquivalentCliCommand'
15-
import { useInstanceSelector } from '~/hooks'
22+
import { InstanceStatusBadge } from '~/components/StatusBadge'
23+
import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params'
1624
import { Badge, type BadgeColor } from '~/ui/lib/Badge'
1725
import { Spinner } from '~/ui/lib/Spinner'
1826
import { cliCmd } from '~/util/cli-cmd'
@@ -36,13 +44,29 @@ const statusMessage: Record<WsState, string> = {
3644
error: 'error',
3745
}
3846

47+
SerialConsolePage.loader = async ({ params }: LoaderFunctionArgs) => {
48+
const { project, instance } = getInstanceSelector(params)
49+
await apiQueryClient.prefetchQuery('instanceView', {
50+
path: { instance },
51+
query: { project },
52+
})
53+
return null
54+
}
55+
3956
export function SerialConsolePage() {
4057
const instanceSelector = useInstanceSelector()
4158
const { project, instance } = instanceSelector
4259

60+
const { data: instanceData } = usePrefetchedApiQuery('instanceView', {
61+
query: { project },
62+
path: { instance },
63+
})
64+
4365
const ws = useRef<WebSocket | null>(null)
4466

45-
const [connectionStatus, setConnectionStatus] = useState<WsState>('connecting')
67+
const canConnect = instanceCan.serialConsole(instanceData)
68+
const initialState = canConnect ? 'connecting' : 'closed'
69+
const [connectionStatus, setConnectionStatus] = useState<WsState>(initialState)
4670

4771
// In dev, React 18 strict mode fires all effects twice for lulz, even ones
4872
// with no dependencies. In order to prevent the websocket from being killed
@@ -54,6 +78,8 @@ export function SerialConsolePage() {
5478
// 1a. cleanup runs, nothing happens because socket was not open yet
5579
// 2. effect runs, but `ws.current` is truthy, so nothing happens
5680
useEffect(() => {
81+
if (!canConnect) return
82+
5783
// TODO: error handling if this connection fails
5884
if (!ws.current) {
5985
const { project, instance } = instanceSelector
@@ -70,7 +96,7 @@ export function SerialConsolePage() {
7096
ws.current.close()
7197
}
7298
}
73-
}, [instanceSelector])
99+
}, [instanceSelector, canConnect])
74100

75101
// Because this one does not look at ready state, just whether the thing is
76102
// defined, it will remove the event listeners before the spurious second
@@ -81,20 +107,22 @@ export function SerialConsolePage() {
81107
// 1a. cleanup runs, event listeners removed
82108
// 2. effect runs again, event listeners attached again
83109
useEffect(() => {
110+
if (!canConnect) return // don't bother if instance is not running
111+
84112
const setOpen = () => setConnectionStatus('open')
85113
const setClosed = () => setConnectionStatus('closed')
86114
const setError = () => setConnectionStatus('error')
87115

88116
ws.current?.addEventListener('open', setOpen)
89-
ws.current?.addEventListener('closed', setClosed)
117+
ws.current?.addEventListener('close', setClosed)
90118
ws.current?.addEventListener('error', setError)
91119

92120
return () => {
93121
ws.current?.removeEventListener('open', setOpen)
94-
ws.current?.removeEventListener('closed', setClosed)
122+
ws.current?.removeEventListener('close', setClosed)
95123
ws.current?.removeEventListener('error', setError)
96124
}
97-
}, [])
125+
}, [canConnect])
98126

99127
return (
100128
<div className="!mx-0 flex h-full max-h-[calc(100vh-60px)] !w-full flex-col">
@@ -109,7 +137,13 @@ export function SerialConsolePage() {
109137
</Link>
110138

111139
<div className="gutter relative w-full shrink grow overflow-hidden">
112-
{connectionStatus !== 'open' && <SerialSkeleton />}
140+
{connectionStatus === 'connecting' && <ConnectingSkeleton />}
141+
{connectionStatus === 'error' && <ErrorSkeleton />}
142+
{connectionStatus === 'closed' && !canConnect && (
143+
<CannotConnect instanceState={instanceData.runState} />
144+
)}
145+
{/* closed && canConnect shouldn't be possible because there's no way to
146+
* close an open connection other than leaving the page */}
113147
<Suspense fallback={null}>{ws.current && <Terminal ws={ws.current} />}</Suspense>
114148
</div>
115149
<div className="shrink-0 justify-between overflow-hidden border-t bg-default border-secondary empty:border-t-0">
@@ -127,16 +161,22 @@ export function SerialConsolePage() {
127161
)
128162
}
129163

130-
function SerialSkeleton() {
131-
const instanceSelector = useInstanceSelector()
132-
164+
function SerialSkeleton({
165+
children,
166+
connecting,
167+
}: {
168+
children: React.ReactNode
169+
connecting?: boolean
170+
}) {
133171
return (
134172
<div className="relative h-full shrink grow overflow-hidden">
135173
<div className="h-full space-y-2 overflow-hidden">
136174
{[...Array(200)].map((_e, i) => (
137175
<div
138176
key={i}
139-
className="h-4 rounded bg-tertiary motion-safe:animate-pulse"
177+
className={cn('h-4 rounded bg-tertiary', {
178+
'motion-safe:animate-pulse': connecting,
179+
})}
140180
style={{
141181
width: `${Math.sin(Math.sin(i)) * 20 + 40}%`,
142182
}} /* this is silly deterministic way to get random looking lengths */
@@ -150,18 +190,56 @@ function SerialSkeleton() {
150190
background: 'linear-gradient(180deg, rgba(8, 15, 17, 0) 0%, #080F11 100%)',
151191
}}
152192
/>
153-
<div className="absolute left-1/2 top-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">
154-
<Spinner size="lg" />
155-
156-
<div className="space-y-2">
157-
<p className="text-center text-sans-xl text-default">
158-
Connecting to{' '}
159-
<Link to={pb.instance(instanceSelector)} className="text-accent-secondary">
160-
{instanceSelector.instance}
161-
</Link>
162-
</p>
163-
</div>
193+
<div className="absolute left-1/2 top-1/2 flex w-96 -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center rounded-lg border p-12 !bg-raise border-secondary elevation-3">
194+
{children}
164195
</div>
165196
</div>
166197
)
167198
}
199+
200+
function InstanceLink() {
201+
const { instance, project } = useInstanceSelector()
202+
return (
203+
<Link
204+
className="text-sans-xl text-accent-secondary hover:text-accent"
205+
to={pb.instance({ project, instance })}
206+
>
207+
{instance}
208+
</Link>
209+
)
210+
}
211+
212+
const ConnectingSkeleton = () => (
213+
<SerialSkeleton connecting>
214+
<Spinner size="lg" />
215+
<div className="mt-4 text-center">
216+
<p className="text-sans-xl">Connecting to</p>
217+
<InstanceLink />
218+
</div>
219+
</SerialSkeleton>
220+
)
221+
222+
const CannotConnect = ({ instanceState }: { instanceState: InstanceState }) => (
223+
<SerialSkeleton>
224+
<p className="flex items-center justify-center text-sans-xl">
225+
<span>
226+
Instance <InstanceLink /> is
227+
</span>
228+
<InstanceStatusBadge className="ml-1" status={instanceState} />
229+
</p>
230+
<p className="mt-2 text-center text-secondary">
231+
You can only connect to the serial console on a running instance.
232+
</p>
233+
</SerialSkeleton>
234+
)
235+
236+
// TODO: sure would be nice to say something useful about the error, but
237+
// we don't know what kind of thing we might pull off the error event
238+
const ErrorSkeleton = () => (
239+
<SerialSkeleton>
240+
<p className="flex items-center justify-center text-center text-sans-xl">
241+
Serial console connection failed
242+
</p>
243+
<p className="mt-2 text-center text-secondary">Please try again.</p>
244+
</SerialSkeleton>
245+
)

app/routes.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ export const routes = createRoutesFromElements(
276276
<Route path=":instance" handle={{ crumb: instanceCrumb }}>
277277
<Route
278278
path="serial-console"
279+
loader={SerialConsolePage.loader}
279280
element={<SerialConsolePage />}
280281
handle={{ crumb: 'Serial Console' }}
281282
/>

app/util/path-builder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export const pb = {
4141

4242
instances: (params: Project) => `${projectBase(params)}/instances`,
4343
instancesNew: (params: Project) => `${projectBase(params)}/instances-new`,
44+
/** Don't link directly to this. Use instancePage instead. */
4445
instance: (params: Instance) => `${pb.instances(params)}/${params.instance}`,
4546

4647
/**

0 commit comments

Comments
 (0)