Add loading spinner back to stream just for engine connection, give it an error state (#6220)

* Add an engine error type for an "outage"

* Add a loading spinner back to the stream just for engine connection

* Refactor Loading spinner to account for early errors

* Add styling and state logic for unrecoverable errors in Loading

* Let engine error messages contain markdown

* Clarify 'too many connections' error message

* Add a "VeryLongLoadTime" error that suggests checking firewall

* Give the engine connection spinner a test ID and use it
This commit is contained in:
Frank Noirot
2025-04-14 13:00:30 -04:00
committed by GitHub
parent 6c7e42b541
commit bf7ec424a7
5 changed files with 129 additions and 26 deletions

View File

@ -44,6 +44,7 @@ export class SceneFixture {
public page: Page public page: Page
public streamWrapper!: Locator public streamWrapper!: Locator
public networkToggleConnected!: Locator public networkToggleConnected!: Locator
public engineConnectionsSpinner!: Locator
public startEditSketchBtn!: Locator public startEditSketchBtn!: Locator
constructor(page: Page) { constructor(page: Page) {
@ -52,6 +53,7 @@ export class SceneFixture {
this.networkToggleConnected = page this.networkToggleConnected = page
.getByTestId('network-toggle-ok') .getByTestId('network-toggle-ok')
.or(page.getByTestId('network-toggle-other')) .or(page.getByTestId('network-toggle-other'))
this.engineConnectionsSpinner = page.getByTestId(`loading-engine`)
this.startEditSketchBtn = page this.startEditSketchBtn = page
.getByRole('button', { name: 'Start Sketch' }) .getByRole('button', { name: 'Start Sketch' })
.or(page.getByRole('button', { name: 'Edit Sketch' })) .or(page.getByRole('button', { name: 'Edit Sketch' }))
@ -228,6 +230,7 @@ export class SceneFixture {
connectionEstablished = async () => { connectionEstablished = async () => {
const timeout = 30000 const timeout = 30000
await expect(this.networkToggleConnected).toBeVisible({ timeout }) await expect(this.networkToggleConnected).toBeVisible({ timeout })
await expect(this.engineConnectionsSpinner).not.toBeVisible()
} }
settled = async (cmdBar: CmdBarFixture) => { settled = async (cmdBar: CmdBarFixture) => {
@ -235,6 +238,7 @@ export class SceneFixture {
await expect(this.startEditSketchBtn).not.toBeDisabled({ timeout: 15_000 }) await expect(this.startEditSketchBtn).not.toBeDisabled({ timeout: 15_000 })
await expect(this.startEditSketchBtn).toBeVisible() await expect(this.startEditSketchBtn).toBeVisible()
await expect(this.engineConnectionsSpinner).not.toBeVisible()
await cmdBar.openCmdBar() await cmdBar.openCmdBar()
await cmdBar.chooseCommand('Settings · app · show debug panel') await cmdBar.chooseCommand('Settings · app · show debug panel')

View File

@ -5,7 +5,10 @@ import { useModelingContext } from '@src/hooks/useModelingContext'
import { useNetworkContext } from '@src/hooks/useNetworkContext' import { useNetworkContext } from '@src/hooks/useNetworkContext'
import { NetworkHealthState } from '@src/hooks/useNetworkStatus' import { NetworkHealthState } from '@src/hooks/useNetworkStatus'
import { getArtifactOfTypes } from '@src/lang/std/artifactGraph' 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 { btnName } from '@src/lib/cameraControls'
import { PATHS } from '@src/lib/paths' import { PATHS } from '@src/lib/paths'
import { sendSelectEventToEngine } from '@src/lib/selections' import { sendSelectEventToEngine } from '@src/lib/selections'
@ -25,6 +28,7 @@ import {
EngineStreamTransition, EngineStreamTransition,
} from '@src/machines/engineStreamMachine' } from '@src/machines/engineStreamMachine'
import Loading from '@src/components/Loading'
import { useSelector } from '@xstate/react' import { useSelector } from '@xstate/react'
import type { MouseEventHandler } from 'react' import type { MouseEventHandler } from 'react'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
@ -426,6 +430,12 @@ export const EngineStream = (props: {
} }
menuTargetElement={videoWrapperRef} menuTargetElement={videoWrapperRef}
/> />
{engineCommandManager.engineConnection?.state.type !==
EngineConnectionStateType.ConnectionEstablished && (
<Loading dataTestId="loading-engine" className="fixed inset-0">
Connecting to engine
</Loading>
)}
</div> </div>
) )
} }

View File

@ -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 { useEffect, useState } from 'react'
import { CustomIcon } from '@src/components/CustomIcon'
import { Spinner } from '@src/components/Spinner' import { Spinner } from '@src/components/Spinner'
import type { ErrorType } from '@src/lang/std/engineConnection'
import { import {
CONNECTION_ERROR_TEXT, CONNECTION_ERROR_TEXT,
ConnectionError, ConnectionError,
@ -9,15 +13,33 @@ import {
EngineConnectionEvents, EngineConnectionEvents,
EngineConnectionStateType, EngineConnectionStateType,
} from '@src/lang/std/engineConnection' } from '@src/lang/std/engineConnection'
import { SafeRenderer } from '@src/lib/markdown'
import { engineCommandManager } from '@src/lib/singletons' import { engineCommandManager } from '@src/lib/singletons'
interface LoadingProps extends React.PropsWithChildren { interface LoadingProps extends React.PropsWithChildren {
className?: string className?: string
dataTestId?: string
} }
const Loading = ({ children, className }: LoadingProps) => { const markedOptions: MarkedOptions = {
const [error, setError] = useState<ConnectionError>(ConnectionError.Unset) gfm: true,
breaks: true,
sanitize: true,
unescape,
escape,
}
const Loading = ({ children, className, dataTestId }: LoadingProps) => {
const [error, setError] = useState<ErrorType>({
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(() => { useEffect(() => {
const onConnectionStateChange = ({ detail: state }: CustomEvent) => { const onConnectionStateChange = ({ detail: state }: CustomEvent) => {
if ( if (
@ -26,7 +48,7 @@ const Loading = ({ children, className }: LoadingProps) => {
state.value?.type !== DisconnectingType.Error state.value?.type !== DisconnectingType.Error
) )
return return
setError(state.value.value.error) setError(state.value.value)
} }
const onEngineAvailable = ({ detail: engineConnection }: CustomEvent) => { const onEngineAvailable = ({ detail: engineConnection }: CustomEvent) => {
@ -36,10 +58,27 @@ const Loading = ({ children, className }: LoadingProps) => {
) )
} }
engineCommandManager.addEventListener( if (engineCommandManager.engineConnection) {
EngineCommandManagerEvents.EngineAvailable, // Do an initial state check in case there is an immediate issue
onEngineAvailable as EventListener 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 () => { return () => {
engineCommandManager.removeEventListener( engineCommandManager.removeEventListener(
@ -55,30 +94,59 @@ const Loading = ({ children, className }: LoadingProps) => {
useEffect(() => { useEffect(() => {
// Don't set long loading time if there's a more severe error // Don't set long loading time if there's a more severe error
if (error > ConnectionError.LongLoadingTime) return if (isUnrecoverableError) return
const timer = setTimeout(() => { const shorterTimer = setTimeout(() => {
setError(ConnectionError.LongLoadingTime) setError({ error: ConnectionError.LongLoadingTime })
}, 4000) }, 4000)
const longerTimer = setTimeout(() => {
setError({ error: ConnectionError.VeryLongLoadingTime })
}, 7000)
return () => clearTimeout(timer) return () => {
}, [error, setError]) clearTimeout(shorterTimer)
clearTimeout(longerTimer)
}
}, [error, setError, isUnrecoverableError])
return ( return (
<div <div
className={`body-bg flex flex-col items-center justify-center h-screen ${className}`} className={`body-bg flex flex-col items-center justify-center h-screen ${colorClass} ${className}`}
data-testid="loading" data-testid={dataTestId ? dataTestId : 'loading'}
> >
<Spinner /> {isUnrecoverableError ? (
<p className="text-base mt-4 text-primary">{children || 'Loading'}</p> <CustomIcon
<p name="close"
className={ className="w-8 h-8 !text-chalkboard-10 bg-destroy-60 rounded-full"
'text-sm mt-4 text-primary/60 transition-opacity duration-500' + />
(error !== ConnectionError.Unset ? ' opacity-100' : ' opacity-0') ) : (
} <Spinner />
> )}
{CONNECTION_ERROR_TEXT[error]} <p className={`text-base mt-4`}>
{isUnrecoverableError ? 'An error occurred' : children || 'Loading'}
</p> </p>
{CONNECTION_ERROR_TEXT[error.error] && (
<div
className={
'text-center text-sm mt-4 text-opacity-70 transition-opacity duration-500' +
(error.error !== ConnectionError.Unset
? ' opacity-100'
: ' opacity-0')
}
dangerouslySetInnerHTML={{
__html: Marked.parse(
CONNECTION_ERROR_TEXT[error.error] +
(error.context
? '\n\nThe error details are: ' + error.context
: ''),
{
renderer: new SafeRenderer(markedOptions),
...markedOptions,
}
),
}}
></div>
)}
</div> </div>
) )
} }

View File

@ -12,7 +12,7 @@ export const Spinner = (props: SVGProps<SVGSVGElement>) => {
cx="5" cx="5"
cy="5" cy="5"
r="4" r="4"
stroke="var(--primary)" stroke="currentColor"
fill="none" fill="none"
strokeDasharray="4, 4" strokeDasharray="4, 4"
className="animate-spin origin-center" className="animate-spin origin-center"

View File

@ -67,6 +67,7 @@ export enum DisconnectingType {
export enum ConnectionError { export enum ConnectionError {
Unset = 0, Unset = 0,
LongLoadingTime, LongLoadingTime,
VeryLongLoadingTime,
ICENegotiate, ICENegotiate,
DataChannelError, DataChannelError,
@ -78,6 +79,7 @@ export enum ConnectionError {
MissingAuthToken, MissingAuthToken,
BadAuthToken, BadAuthToken,
TooManyConnections, TooManyConnections,
Outage,
// An unknown error is the most severe because it has not been classified // An unknown error is the most severe because it has not been classified
// or encountered before. // or encountered before.
@ -88,6 +90,8 @@ export const CONNECTION_ERROR_TEXT: Record<ConnectionError, string> = {
[ConnectionError.Unset]: '', [ConnectionError.Unset]: '',
[ConnectionError.LongLoadingTime]: [ConnectionError.LongLoadingTime]:
'Loading is taking longer than expected...', 'Loading is taking longer than expected...',
[ConnectionError.VeryLongLoadingTime]:
'Loading seems stuck. Do you have a firewall turned on?',
[ConnectionError.ICENegotiate]: 'ICE negotiation failed.', [ConnectionError.ICENegotiate]: 'ICE negotiation failed.',
[ConnectionError.DataChannelError]: 'The data channel signaled an error.', [ConnectionError.DataChannelError]: 'The data channel signaled an error.',
[ConnectionError.WebSocketError]: 'The websocket signaled an error.', [ConnectionError.WebSocketError]: 'The websocket signaled an error.',
@ -97,7 +101,10 @@ export const CONNECTION_ERROR_TEXT: Record<ConnectionError, string> = {
'Your authorization token is missing; please login again.', 'Your authorization token is missing; please login again.',
[ConnectionError.BadAuthToken]: [ConnectionError.BadAuthToken]:
'Your authorization token is invalid; please login again.', '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]: [ConnectionError.Unknown]:
'An unexpected error occurred. Please report this to us.', 'An unexpected error occurred. Please report this to us.',
} }
@ -1002,6 +1009,20 @@ class EngineConnection extends EventTarget {
} }
this.disconnectAll() 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 return
} }