diff --git a/e2e/playwright/fixtures/sceneFixture.ts b/e2e/playwright/fixtures/sceneFixture.ts index c054e9a08..06be6fdfc 100644 --- a/e2e/playwright/fixtures/sceneFixture.ts +++ b/e2e/playwright/fixtures/sceneFixture.ts @@ -44,6 +44,7 @@ export class SceneFixture { public page: Page public streamWrapper!: Locator public networkToggleConnected!: Locator + public engineConnectionsSpinner!: Locator public startEditSketchBtn!: Locator constructor(page: Page) { @@ -52,6 +53,7 @@ export class SceneFixture { this.networkToggleConnected = page .getByTestId('network-toggle-ok') .or(page.getByTestId('network-toggle-other')) + this.engineConnectionsSpinner = page.getByTestId(`loading-engine`) this.startEditSketchBtn = page .getByRole('button', { name: 'Start Sketch' }) .or(page.getByRole('button', { name: 'Edit Sketch' })) @@ -228,6 +230,7 @@ export class SceneFixture { connectionEstablished = async () => { const timeout = 30000 await expect(this.networkToggleConnected).toBeVisible({ timeout }) + await expect(this.engineConnectionsSpinner).not.toBeVisible() } settled = async (cmdBar: CmdBarFixture) => { @@ -235,6 +238,7 @@ export class SceneFixture { await expect(this.startEditSketchBtn).not.toBeDisabled({ timeout: 15_000 }) await expect(this.startEditSketchBtn).toBeVisible() + await expect(this.engineConnectionsSpinner).not.toBeVisible() await cmdBar.openCmdBar() await cmdBar.chooseCommand('Settings · app · show debug panel') diff --git a/src/components/EngineStream.tsx b/src/components/EngineStream.tsx index 85c7e66b9..b08fb2a6e 100644 --- a/src/components/EngineStream.tsx +++ b/src/components/EngineStream.tsx @@ -5,7 +5,10 @@ import { useModelingContext } from '@src/hooks/useModelingContext' import { useNetworkContext } from '@src/hooks/useNetworkContext' import { NetworkHealthState } from '@src/hooks/useNetworkStatus' import { getArtifactOfTypes } from '@src/lang/std/artifactGraph' -import { EngineCommandManagerEvents } from '@src/lang/std/engineConnection' +import { + EngineCommandManagerEvents, + EngineConnectionStateType, +} from '@src/lang/std/engineConnection' import { btnName } from '@src/lib/cameraControls' import { PATHS } from '@src/lib/paths' import { sendSelectEventToEngine } from '@src/lib/selections' @@ -25,6 +28,7 @@ import { EngineStreamTransition, } from '@src/machines/engineStreamMachine' +import Loading from '@src/components/Loading' import { useSelector } from '@xstate/react' import type { MouseEventHandler } from 'react' import { useEffect, useRef, useState } from 'react' @@ -426,6 +430,12 @@ export const EngineStream = (props: { } menuTargetElement={videoWrapperRef} /> + {engineCommandManager.engineConnection?.state.type !== + EngineConnectionStateType.ConnectionEstablished && ( + + Connecting to engine + + )} ) } diff --git a/src/components/Loading.tsx b/src/components/Loading.tsx index b9950069f..e2de68cf0 100644 --- a/src/components/Loading.tsx +++ b/src/components/Loading.tsx @@ -1,6 +1,10 @@ +import type { MarkedOptions } from '@ts-stack/markdown' +import { Marked, escape, unescape } from '@ts-stack/markdown' import { useEffect, useState } from 'react' +import { CustomIcon } from '@src/components/CustomIcon' import { Spinner } from '@src/components/Spinner' +import type { ErrorType } from '@src/lang/std/engineConnection' import { CONNECTION_ERROR_TEXT, ConnectionError, @@ -9,15 +13,33 @@ import { EngineConnectionEvents, EngineConnectionStateType, } from '@src/lang/std/engineConnection' +import { SafeRenderer } from '@src/lib/markdown' import { engineCommandManager } from '@src/lib/singletons' interface LoadingProps extends React.PropsWithChildren { className?: string + dataTestId?: string } -const Loading = ({ children, className }: LoadingProps) => { - const [error, setError] = useState(ConnectionError.Unset) +const markedOptions: MarkedOptions = { + gfm: true, + breaks: true, + sanitize: true, + unescape, + escape, +} +const Loading = ({ children, className, dataTestId }: LoadingProps) => { + const [error, setError] = useState({ + error: ConnectionError.Unset, + }) + const isUnrecoverableError = error.error > ConnectionError.VeryLongLoadingTime + const colorClass = + error.error === ConnectionError.Unset + ? 'text-primary' + : !isUnrecoverableError + ? 'text-warn-60' + : 'text-chalkboard-60 dark:text-chalkboard-40' useEffect(() => { const onConnectionStateChange = ({ detail: state }: CustomEvent) => { if ( @@ -26,7 +48,7 @@ const Loading = ({ children, className }: LoadingProps) => { state.value?.type !== DisconnectingType.Error ) return - setError(state.value.value.error) + setError(state.value.value) } const onEngineAvailable = ({ detail: engineConnection }: CustomEvent) => { @@ -36,10 +58,27 @@ const Loading = ({ children, className }: LoadingProps) => { ) } - engineCommandManager.addEventListener( - EngineCommandManagerEvents.EngineAvailable, - onEngineAvailable as EventListener - ) + if (engineCommandManager.engineConnection) { + // Do an initial state check in case there is an immediate issue + onConnectionStateChange( + new CustomEvent(EngineConnectionEvents.ConnectionStateChanged, { + detail: engineCommandManager.engineConnection.state, + }) + ) + // Set up a listener on the state for future updates + onEngineAvailable( + new CustomEvent(EngineCommandManagerEvents.EngineAvailable, { + detail: engineCommandManager.engineConnection, + }) + ) + } else { + // If there is no engine connection yet, listen for it to be there *then* + // attach the listener + engineCommandManager.addEventListener( + EngineCommandManagerEvents.EngineAvailable, + onEngineAvailable as EventListener + ) + } return () => { engineCommandManager.removeEventListener( @@ -55,30 +94,59 @@ const Loading = ({ children, className }: LoadingProps) => { useEffect(() => { // Don't set long loading time if there's a more severe error - if (error > ConnectionError.LongLoadingTime) return + if (isUnrecoverableError) return - const timer = setTimeout(() => { - setError(ConnectionError.LongLoadingTime) + const shorterTimer = setTimeout(() => { + setError({ error: ConnectionError.LongLoadingTime }) }, 4000) + const longerTimer = setTimeout(() => { + setError({ error: ConnectionError.VeryLongLoadingTime }) + }, 7000) - return () => clearTimeout(timer) - }, [error, setError]) + return () => { + clearTimeout(shorterTimer) + clearTimeout(longerTimer) + } + }, [error, setError, isUnrecoverableError]) return (
- -

{children || 'Loading'}

-

- {CONNECTION_ERROR_TEXT[error]} + {isUnrecoverableError ? ( + + ) : ( + + )} +

+ {isUnrecoverableError ? 'An error occurred' : children || 'Loading'}

+ {CONNECTION_ERROR_TEXT[error.error] && ( +
+ )}
) } diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx index 539511f0c..fca2cc826 100644 --- a/src/components/Spinner.tsx +++ b/src/components/Spinner.tsx @@ -12,7 +12,7 @@ export const Spinner = (props: SVGProps) => { cx="5" cy="5" r="4" - stroke="var(--primary)" + stroke="currentColor" fill="none" strokeDasharray="4, 4" className="animate-spin origin-center" diff --git a/src/lang/std/engineConnection.ts b/src/lang/std/engineConnection.ts index fbc6e5df7..05c795042 100644 --- a/src/lang/std/engineConnection.ts +++ b/src/lang/std/engineConnection.ts @@ -67,6 +67,7 @@ export enum DisconnectingType { export enum ConnectionError { Unset = 0, LongLoadingTime, + VeryLongLoadingTime, ICENegotiate, DataChannelError, @@ -78,6 +79,7 @@ export enum ConnectionError { MissingAuthToken, BadAuthToken, TooManyConnections, + Outage, // An unknown error is the most severe because it has not been classified // or encountered before. @@ -88,6 +90,8 @@ export const CONNECTION_ERROR_TEXT: Record = { [ConnectionError.Unset]: '', [ConnectionError.LongLoadingTime]: 'Loading is taking longer than expected...', + [ConnectionError.VeryLongLoadingTime]: + 'Loading seems stuck. Do you have a firewall turned on?', [ConnectionError.ICENegotiate]: 'ICE negotiation failed.', [ConnectionError.DataChannelError]: 'The data channel signaled an error.', [ConnectionError.WebSocketError]: 'The websocket signaled an error.', @@ -97,7 +101,10 @@ export const CONNECTION_ERROR_TEXT: Record = { 'Your authorization token is missing; please login again.', [ConnectionError.BadAuthToken]: 'Your authorization token is invalid; please login again.', - [ConnectionError.TooManyConnections]: 'There are too many connections.', + [ConnectionError.TooManyConnections]: + 'There are too many open engine connections associated with your account.', + [ConnectionError.Outage]: + 'We seem to be experiencing an outage. Please visit [status.zoo.dev](https://status.zoo.dev) for updates.', [ConnectionError.Unknown]: 'An unexpected error occurred. Please report this to us.', } @@ -1002,6 +1009,20 @@ class EngineConnection extends EventTarget { } this.disconnectAll() } + + if (firstError.error_code === 'internal_api') { + this.state = { + type: EngineConnectionStateType.Disconnecting, + value: { + type: DisconnectingType.Error, + value: { + error: ConnectionError.Outage, + context: firstError.message, + }, + }, + } + this.disconnectAll() + } return }