diff --git a/app/pages/project/instances/instance/SerialConsolePage.tsx b/app/pages/project/instances/instance/SerialConsolePage.tsx index f8f083e55f..ad9c8526f8 100644 --- a/app/pages/project/instances/instance/SerialConsolePage.tsx +++ b/app/pages/project/instances/instance/SerialConsolePage.tsx @@ -14,7 +14,7 @@ import { apiQueryClient, instanceCan, usePrefetchedApiQuery, - type InstanceState, + type Instance, } from '@oxide/api' import { PrevArrow12Icon } from '@oxide/design-system/icons/react' @@ -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(null) @@ -140,7 +150,7 @@ export function SerialConsolePage() { {connectionStatus === 'connecting' && } {connectionStatus === 'error' && } {connectionStatus === 'closed' && !canConnect && ( - + )} {/* closed && canConnect shouldn't be possible because there's no way to * close an open connection other than leaving the page */} @@ -161,13 +171,12 @@ export function SerialConsolePage() { ) } -function SerialSkeleton({ - children, - connecting, -}: { +type SkeletonProps = { children: React.ReactNode - connecting?: boolean -}) { + animate?: boolean +} + +function SerialSkeleton({ children, animate }: SkeletonProps) { return (
@@ -175,7 +184,7 @@ function SerialSkeleton({
( - +

Connecting to serial console

@@ -206,14 +215,16 @@ const ConnectingSkeleton = () => ( ) -const CannotConnect = ({ instanceState }: { instanceState: InstanceState }) => ( - +const CannotConnect = ({ instance }: { instance: Instance }) => ( +

- The instance is - + The instance is +

-

- You can only connect to the serial console on a running instance. +

+ {isStarting(instance) + ? 'Waiting for the instance to start before connecting.' + : 'You can only connect to the serial console on a running instance.'}

) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index c84bbdc03e..16daa62477 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -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) @@ -686,7 +686,7 @@ export const handlers = makeHandlers({ setTimeout(() => { instance.run_state = 'running' - }, 1000) + }, 3000) return json(instance, { status: 202 }) }, @@ -696,7 +696,7 @@ export const handlers = makeHandlers({ setTimeout(() => { instance.run_state = 'stopped' - }, 1000) + }, 3000) return json(instance, { status: 202 }) }, diff --git a/test/e2e/instance-serial.e2e.ts b/test/e2e/instance-serial.e2e.ts new file mode 100644 index 0000000000..ed3246eeeb --- /dev/null +++ b/test/e2e/instance-serial.e2e.ts @@ -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 +}) diff --git a/test/e2e/instance.e2e.ts b/test/e2e/instance.e2e.ts index 3fc9a3c68f..973b44d006 100644 --- a/test/e2e/instance.e2e.ts +++ b/test/e2e/instance.e2e.ts @@ -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 @@ -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() diff --git a/test/e2e/utils.ts b/test/e2e/utils.ts index a1800ef735..998a561937 100644 --- a/test/e2e/utils.ts +++ b/test/e2e/utils.ts @@ -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() }