Skip to content

Commit 9e83117

Browse files
authored
Connect to serial console of starting instance (#2374)
* connect to serial console of starting instance by polling until ready * on second thought just show the connecting thing when starting * On third thought, don't do that revert 1f027a7 * tweak copy and animate skeleton when waiting to start
1 parent e30f2eb commit 9e83117

File tree

5 files changed

+71
-27
lines changed

5 files changed

+71
-27
lines changed

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

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
apiQueryClient,
1515
instanceCan,
1616
usePrefetchedApiQuery,
17-
type InstanceState,
17+
type Instance,
1818
} from '@oxide/api'
1919
import { PrevArrow12Icon } from '@oxide/design-system/icons/react'
2020

@@ -53,14 +53,24 @@ SerialConsolePage.loader = async ({ params }: LoaderFunctionArgs) => {
5353
return null
5454
}
5555

56+
function isStarting(i: Instance | undefined) {
57+
return i?.runState === 'creating' || i?.runState === 'starting'
58+
}
59+
5660
export function SerialConsolePage() {
5761
const instanceSelector = useInstanceSelector()
5862
const { project, instance } = instanceSelector
5963

60-
const { data: instanceData } = usePrefetchedApiQuery('instanceView', {
61-
query: { project },
62-
path: { instance },
63-
})
64+
const { data: instanceData } = usePrefetchedApiQuery(
65+
'instanceView',
66+
{
67+
query: { project },
68+
path: { instance },
69+
},
70+
// if we land here and the instance is starting, we will not be able to
71+
// connect, so we poll and connect as soon as it's running.
72+
{ refetchInterval: (q) => (isStarting(q.state.data) ? 1000 : false) }
73+
)
6474

6575
const ws = useRef<WebSocket | null>(null)
6676

@@ -140,7 +150,7 @@ export function SerialConsolePage() {
140150
{connectionStatus === 'connecting' && <ConnectingSkeleton />}
141151
{connectionStatus === 'error' && <ErrorSkeleton />}
142152
{connectionStatus === 'closed' && !canConnect && (
143-
<CannotConnect instanceState={instanceData.runState} />
153+
<CannotConnect instance={instanceData} />
144154
)}
145155
{/* closed && canConnect shouldn't be possible because there's no way to
146156
* close an open connection other than leaving the page */}
@@ -161,21 +171,20 @@ export function SerialConsolePage() {
161171
)
162172
}
163173

164-
function SerialSkeleton({
165-
children,
166-
connecting,
167-
}: {
174+
type SkeletonProps = {
168175
children: React.ReactNode
169-
connecting?: boolean
170-
}) {
176+
animate?: boolean
177+
}
178+
179+
function SerialSkeleton({ children, animate }: SkeletonProps) {
171180
return (
172181
<div className="relative h-full shrink grow overflow-hidden">
173182
<div className="h-full space-y-2 overflow-hidden">
174183
{[...Array(200)].map((_e, i) => (
175184
<div
176185
key={i}
177186
className={cn('h-4 rounded bg-tertiary', {
178-
'motion-safe:animate-pulse': connecting,
187+
'motion-safe:animate-pulse': animate,
179188
})}
180189
style={{
181190
width: `${Math.sin(Math.sin(i)) * 20 + 40}%`,
@@ -198,22 +207,24 @@ function SerialSkeleton({
198207
}
199208

200209
const ConnectingSkeleton = () => (
201-
<SerialSkeleton connecting>
210+
<SerialSkeleton animate>
202211
<Spinner size="lg" />
203212
<div className="mt-4 text-center">
204213
<p className="text-sans-xl">Connecting to serial console</p>
205214
</div>
206215
</SerialSkeleton>
207216
)
208217

209-
const CannotConnect = ({ instanceState }: { instanceState: InstanceState }) => (
210-
<SerialSkeleton>
218+
const CannotConnect = ({ instance }: { instance: Instance }) => (
219+
<SerialSkeleton animate={isStarting(instance)}>
211220
<p className="flex items-center justify-center text-sans-xl">
212-
<span>The instance is</span>
213-
<InstanceStatusBadge className="ml-1" status={instanceState} />
221+
<span>The instance is </span>
222+
<InstanceStatusBadge className="ml-1.5" status={instance.runState} />
214223
</p>
215-
<p className="mt-2 text-center text-secondary">
216-
You can only connect to the serial console on a running instance.
224+
<p className="mt-2 text-balance text-center text-secondary">
225+
{isStarting(instance)
226+
? 'Waiting for the instance to start before connecting.'
227+
: 'You can only connect to the serial console on a running instance.'}
217228
</p>
218229
</SerialSkeleton>
219230
)

mock-api/msw/handlers.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -518,11 +518,11 @@ export const handlers = makeHandlers({
518518

519519
setTimeout(() => {
520520
newInstance.run_state = 'starting'
521-
}, 1000)
521+
}, 500)
522522

523523
setTimeout(() => {
524524
newInstance.run_state = 'running'
525-
}, 5000)
525+
}, 4000)
526526

527527
db.instances.push(newInstance)
528528

@@ -686,7 +686,7 @@ export const handlers = makeHandlers({
686686

687687
setTimeout(() => {
688688
instance.run_state = 'running'
689-
}, 1000)
689+
}, 3000)
690690

691691
return json(instance, { status: 202 })
692692
},
@@ -696,7 +696,7 @@ export const handlers = makeHandlers({
696696

697697
setTimeout(() => {
698698
instance.run_state = 'stopped'
699-
}, 1000)
699+
}, 3000)
700700

701701
return json(instance, { status: 202 })
702702
},

test/e2e/instance-serial.e2e.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
import { expect, test } from './utils'
9+
10+
test('serial console can connect while starting', async ({ page }) => {
11+
// create an instance
12+
await page.goto('/projects/mock-project/instances-new')
13+
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('abc')
14+
await page.getByLabel('Image', { exact: true }).click()
15+
await page.getByRole('option', { name: 'ubuntu-22-04' }).click()
16+
17+
await page.getByRole('button', { name: 'Create instance' }).click()
18+
19+
// now go starting to its serial console page while it's starting up
20+
await expect(page).toHaveURL('/projects/mock-project/instances/abc/storage')
21+
await page.getByRole('tab', { name: 'Connect' }).click()
22+
await page.getByRole('link', { name: 'Connect' }).click()
23+
24+
// The message goes from creating to starting and then disappears once
25+
// the instance is running
26+
await expect(page.getByText('The instance is creating')).toBeVisible()
27+
await expect(page.getByText('Waiting for the instance to start')).toBeVisible()
28+
await expect(page.getByText('The instance is starting')).toBeVisible()
29+
await expect(page.getByText('The instance is')).toBeHidden()
30+
31+
// Here it would be nice to test that the serial console connects, but we
32+
// can't mock websockets with MSW yet: https://github.com/mswjs/msw/pull/2011
33+
})

test/e2e/instance.e2e.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ test('can stop and delete a running instance', async ({ page }) => {
3636
await page.getByRole('menuitem', { name: 'Stop' }).click()
3737
await page.getByRole('button', { name: 'Confirm' }).click()
3838

39-
await sleep(3000)
39+
await sleep(4000)
4040
await refreshInstance(page)
4141

4242
// now it's stopped
@@ -61,7 +61,7 @@ test('can stop a starting instance', async ({ page }) => {
6161
await page.getByRole('menuitem', { name: 'Stop' }).click()
6262
await page.getByRole('button', { name: 'Confirm' }).click()
6363

64-
await sleep(3000)
64+
await sleep(4000)
6565
await refreshInstance(page)
6666

6767
await expect(row.getByRole('cell', { name: /stopped/ })).toBeVisible()

test/e2e/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export async function stopInstance(page: Page) {
111111
await page.getByRole('menuitem', { name: 'Stop' }).click()
112112
await page.getByRole('button', { name: 'Confirm' }).click()
113113
await closeToast(page)
114-
await sleep(1200)
114+
await sleep(2000)
115115
await refreshInstance(page)
116116
await expect(page.getByText('statusstopped')).toBeVisible()
117117
}

0 commit comments

Comments
 (0)