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:
@ -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')
|
||||
|
@ -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 && (
|
||||
<Loading dataTestId="loading-engine" className="fixed inset-0">
|
||||
Connecting to engine
|
||||
</Loading>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -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>(ConnectionError.Unset)
|
||||
const markedOptions: MarkedOptions = {
|
||||
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(() => {
|
||||
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) => {
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={`body-bg flex flex-col items-center justify-center h-screen ${className}`}
|
||||
data-testid="loading"
|
||||
className={`body-bg flex flex-col items-center justify-center h-screen ${colorClass} ${className}`}
|
||||
data-testid={dataTestId ? dataTestId : 'loading'}
|
||||
>
|
||||
{isUnrecoverableError ? (
|
||||
<CustomIcon
|
||||
name="close"
|
||||
className="w-8 h-8 !text-chalkboard-10 bg-destroy-60 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<Spinner />
|
||||
<p className="text-base mt-4 text-primary">{children || 'Loading'}</p>
|
||||
<p
|
||||
className={
|
||||
'text-sm mt-4 text-primary/60 transition-opacity duration-500' +
|
||||
(error !== ConnectionError.Unset ? ' opacity-100' : ' opacity-0')
|
||||
}
|
||||
>
|
||||
{CONNECTION_ERROR_TEXT[error]}
|
||||
)}
|
||||
<p className={`text-base mt-4`}>
|
||||
{isUnrecoverableError ? 'An error occurred' : children || 'Loading'}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ export const Spinner = (props: SVGProps<SVGSVGElement>) => {
|
||||
cx="5"
|
||||
cy="5"
|
||||
r="4"
|
||||
stroke="var(--primary)"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeDasharray="4, 4"
|
||||
className="animate-spin origin-center"
|
||||
|
@ -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, string> = {
|
||||
[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<ConnectionError, string> = {
|
||||
'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
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user