Skip to content

Commit

Permalink
Connect to serial console of starting instance (#2374)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
david-crespo authored Aug 16, 2024
1 parent e30f2eb commit 9e83117
Show file tree
Hide file tree
Showing 5 changed files with 71 additions and 27 deletions.
51 changes: 31 additions & 20 deletions app/pages/project/instances/instance/SerialConsolePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
apiQueryClient,
instanceCan,
usePrefetchedApiQuery,
type InstanceState,
type Instance,
} from '@oxide/api'
import { PrevArrow12Icon } from '@oxide/design-system/icons/react'

Expand Down Expand Up @@ -53,14 +53,24 @@ SerialConsolePage.loader = async ({ params }: LoaderFunctionArgs) => {
return null
}

function isStarting(i: Instance | undefined) {
return i?.runState === 'creating' || i?.runState === 'starting'
}

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

const { data: instanceData } = usePrefetchedApiQuery('instanceView', {
query: { project },
path: { instance },
})
const { data: instanceData } = usePrefetchedApiQuery(
'instanceView',
{
query: { project },
path: { instance },
},
// if we land here and the instance is starting, we will not be able to
// connect, so we poll and connect as soon as it's running.
{ refetchInterval: (q) => (isStarting(q.state.data) ? 1000 : false) }
)

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

Expand Down Expand Up @@ -140,7 +150,7 @@ export function SerialConsolePage() {
{connectionStatus === 'connecting' && <ConnectingSkeleton />}
{connectionStatus === 'error' && <ErrorSkeleton />}
{connectionStatus === 'closed' && !canConnect && (
<CannotConnect instanceState={instanceData.runState} />
<CannotConnect instance={instanceData} />
)}
{/* closed && canConnect shouldn't be possible because there's no way to
* close an open connection other than leaving the page */}
Expand All @@ -161,21 +171,20 @@ export function SerialConsolePage() {
)
}

function SerialSkeleton({
children,
connecting,
}: {
type SkeletonProps = {
children: React.ReactNode
connecting?: boolean
}) {
animate?: boolean
}

function SerialSkeleton({ children, animate }: SkeletonProps) {
return (
<div className="relative h-full shrink grow overflow-hidden">
<div className="h-full space-y-2 overflow-hidden">
{[...Array(200)].map((_e, i) => (
<div
key={i}
className={cn('h-4 rounded bg-tertiary', {
'motion-safe:animate-pulse': connecting,
'motion-safe:animate-pulse': animate,
})}
style={{
width: `${Math.sin(Math.sin(i)) * 20 + 40}%`,
Expand All @@ -198,22 +207,24 @@ function SerialSkeleton({
}

const ConnectingSkeleton = () => (
<SerialSkeleton connecting>
<SerialSkeleton animate>
<Spinner size="lg" />
<div className="mt-4 text-center">
<p className="text-sans-xl">Connecting to serial console</p>
</div>
</SerialSkeleton>
)

const CannotConnect = ({ instanceState }: { instanceState: InstanceState }) => (
<SerialSkeleton>
const CannotConnect = ({ instance }: { instance: Instance }) => (
<SerialSkeleton animate={isStarting(instance)}>
<p className="flex items-center justify-center text-sans-xl">
<span>The instance is</span>
<InstanceStatusBadge className="ml-1" status={instanceState} />
<span>The instance is </span>
<InstanceStatusBadge className="ml-1.5" status={instance.runState} />
</p>
<p className="mt-2 text-center text-secondary">
You can only connect to the serial console on a running instance.
<p className="mt-2 text-balance text-center text-secondary">
{isStarting(instance)
? 'Waiting for the instance to start before connecting.'
: 'You can only connect to the serial console on a running instance.'}
</p>
</SerialSkeleton>
)
Expand Down
8 changes: 4 additions & 4 deletions mock-api/msw/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,11 +518,11 @@ export const handlers = makeHandlers({

setTimeout(() => {
newInstance.run_state = 'starting'
}, 1000)
}, 500)

setTimeout(() => {
newInstance.run_state = 'running'
}, 5000)
}, 4000)

db.instances.push(newInstance)

Expand Down Expand Up @@ -686,7 +686,7 @@ export const handlers = makeHandlers({

setTimeout(() => {
instance.run_state = 'running'
}, 1000)
}, 3000)

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

setTimeout(() => {
instance.run_state = 'stopped'
}, 1000)
}, 3000)

return json(instance, { status: 202 })
},
Expand Down
33 changes: 33 additions & 0 deletions test/e2e/instance-serial.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { expect, test } from './utils'

test('serial console can connect while starting', async ({ page }) => {
// create an instance
await page.goto('/projects/mock-project/instances-new')
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('abc')
await page.getByLabel('Image', { exact: true }).click()
await page.getByRole('option', { name: 'ubuntu-22-04' }).click()

await page.getByRole('button', { name: 'Create instance' }).click()

// now go starting to its serial console page while it's starting up
await expect(page).toHaveURL('/projects/mock-project/instances/abc/storage')
await page.getByRole('tab', { name: 'Connect' }).click()
await page.getByRole('link', { name: 'Connect' }).click()

// The message goes from creating to starting and then disappears once
// the instance is running
await expect(page.getByText('The instance is creating')).toBeVisible()
await expect(page.getByText('Waiting for the instance to start')).toBeVisible()
await expect(page.getByText('The instance is starting')).toBeVisible()
await expect(page.getByText('The instance is')).toBeHidden()

// Here it would be nice to test that the serial console connects, but we
// can't mock websockets with MSW yet: https://github.com/mswjs/msw/pull/2011
})
4 changes: 2 additions & 2 deletions test/e2e/instance.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ test('can stop and delete a running instance', async ({ page }) => {
await page.getByRole('menuitem', { name: 'Stop' }).click()
await page.getByRole('button', { name: 'Confirm' }).click()

await sleep(3000)
await sleep(4000)
await refreshInstance(page)

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

await sleep(3000)
await sleep(4000)
await refreshInstance(page)

await expect(row.getByRole('cell', { name: /stopped/ })).toBeVisible()
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export async function stopInstance(page: Page) {
await page.getByRole('menuitem', { name: 'Stop' }).click()
await page.getByRole('button', { name: 'Confirm' }).click()
await closeToast(page)
await sleep(1200)
await sleep(2000)
await refreshInstance(page)
await expect(page.getByText('statusstopped')).toBeVisible()
}
Expand Down

0 comments on commit 9e83117

Please sign in to comment.