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