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

@ -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>
)
}

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 { 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) => {
)
}
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 (
<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'}
>
<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]}
{isUnrecoverableError ? (
<CustomIcon
name="close"
className="w-8 h-8 !text-chalkboard-10 bg-destroy-60 rounded-full"
/>
) : (
<Spinner />
)}
<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>
)
}

View File

@ -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"