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 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')
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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"
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user