diff --git a/app/gui/src/dashboard/assets/cross.svg b/app/gui/src/dashboard/assets/cross.svg index 3190ad3d1e04..a9e60f676baa 100644 --- a/app/gui/src/dashboard/assets/cross.svg +++ b/app/gui/src/dashboard/assets/cross.svg @@ -1,4 +1,4 @@ - - + + \ No newline at end of file diff --git a/app/gui/src/dashboard/assets/cross2.svg b/app/gui/src/dashboard/assets/cross2.svg index 920ef74ad3d0..0dc9486b155b 100644 --- a/app/gui/src/dashboard/assets/cross2.svg +++ b/app/gui/src/dashboard/assets/cross2.svg @@ -1,7 +1,7 @@ - - + + \ No newline at end of file diff --git a/app/gui/src/dashboard/assets/error_filled.svg b/app/gui/src/dashboard/assets/error_filled.svg new file mode 100644 index 000000000000..7012571bae24 --- /dev/null +++ b/app/gui/src/dashboard/assets/error_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/gui/src/dashboard/assets/error_outline.svg b/app/gui/src/dashboard/assets/error_outline.svg new file mode 100644 index 000000000000..550619be3a84 --- /dev/null +++ b/app/gui/src/dashboard/assets/error_outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/gui/src/dashboard/assets/warning.svg b/app/gui/src/dashboard/assets/warning.svg new file mode 100644 index 000000000000..ff83234b968b --- /dev/null +++ b/app/gui/src/dashboard/assets/warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/gui/src/dashboard/hooks/projectHooks.ts b/app/gui/src/dashboard/hooks/projectHooks.ts index 711262067b02..c47f420ef6ff 100644 --- a/app/gui/src/dashboard/hooks/projectHooks.ts +++ b/app/gui/src/dashboard/hooks/projectHooks.ts @@ -81,6 +81,51 @@ function useSetProjectAsset() { ) } +export const OPENING_PROJECT_STATES = new Set([ + backendModule.ProjectState.provisioned, + backendModule.ProjectState.scheduled, + backendModule.ProjectState.openInProgress, + backendModule.ProjectState.closing, +]) +export const OPENED_PROJECT_STATES = new Set([backendModule.ProjectState.opened]) +export const CLOSED_PROJECT_STATES = new Set([backendModule.ProjectState.closed]) +export const STATIC_PROJECT_STATES = new Set([ + backendModule.ProjectState.opened, + backendModule.ProjectState.closed, +]) +export const CREATED_PROJECT_STATES = new Set([ + backendModule.ProjectState.created, + backendModule.ProjectState.new, +]) + +/** Stale time for local projects, set to 10 seconds. */ +// eslint-disable-next-line @typescript-eslint/no-magic-numbers +export const LOCAL_PROJECT_OPEN_TIMEOUT_MS = 10 * 1_000 +/** Stale time for cloud projects, set to 5 minutes. */ +// eslint-disable-next-line @typescript-eslint/no-magic-numbers +export const CLOUD_PROJECT_OPEN_TIMEOUT_MS = 5 * 60 * 1_000 + +/** + * Get the timeout based on the backend type. + * @param backendType - The backend type. + * @throws If the backend type is not supported. + * @returns The timeout in milliseconds. + */ +export function getTimeoutBasedOnTheBackendType(backendType: backendModule.BackendType) { + switch (backendType) { + case backendModule.BackendType.local: { + return LOCAL_PROJECT_OPEN_TIMEOUT_MS + } + case backendModule.BackendType.remote: { + return CLOUD_PROJECT_OPEN_TIMEOUT_MS + } + + default: { + throw new Error('Unsupported backend type') + } + } +} + /** Project status query. */ export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOptions) { const { assetId, parentId, backend } = options @@ -90,22 +135,19 @@ export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOp return reactQuery.queryOptions({ queryKey: createGetProjectDetailsQuery.getQueryKey(assetId), queryFn: () => backend.getProjectDetails(assetId, parentId), - meta: { persist: false }, refetchIntervalInBackground: true, refetchOnWindowFocus: true, refetchOnMount: true, networkMode: backend.type === backendModule.BackendType.remote ? 'online' : 'always', - refetchInterval: ({ state }): number | false => { - const staticStates = [backendModule.ProjectState.opened, backendModule.ProjectState.closed] + meta: { persist: false }, + refetchInterval: (query): number | false => { + const { state } = query + + const staticStates = STATIC_PROJECT_STATES - const openingStates = [ - backendModule.ProjectState.provisioned, - backendModule.ProjectState.scheduled, - backendModule.ProjectState.openInProgress, - backendModule.ProjectState.closing, - ] + const openingStates = OPENING_PROJECT_STATES - const createdStates = [backendModule.ProjectState.created, backendModule.ProjectState.new] + const createdStates = CREATED_PROJECT_STATES if (state.status === 'error') { return false @@ -118,28 +160,28 @@ export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOp const currentState = state.data.state.type if (isLocal) { - if (createdStates.includes(currentState)) { + if (createdStates.has(currentState)) { return LOCAL_OPENING_INTERVAL_MS } - if (staticStates.includes(state.data.state.type)) { + if (staticStates.has(state.data.state.type)) { return OPENED_INTERVAL_MS } - if (openingStates.includes(state.data.state.type)) { + if (openingStates.has(state.data.state.type)) { return LOCAL_OPENING_INTERVAL_MS } } - if (createdStates.includes(currentState)) { + if (createdStates.has(currentState)) { return CLOUD_OPENING_INTERVAL_MS } // Cloud project - if (staticStates.includes(state.data.state.type)) { + if (staticStates.has(state.data.state.type)) { return OPENED_INTERVAL_MS } - if (openingStates.includes(state.data.state.type)) { + if (openingStates.has(state.data.state.type)) { return CLOUD_OPENING_INTERVAL_MS } diff --git a/app/gui/src/dashboard/hooks/timeoutHooks.ts b/app/gui/src/dashboard/hooks/timeoutHooks.ts new file mode 100644 index 000000000000..5c48968559cb --- /dev/null +++ b/app/gui/src/dashboard/hooks/timeoutHooks.ts @@ -0,0 +1,124 @@ +/** + * @file Timeout related hooks. + */ +import { useEffect, useRef, useState, type DependencyList } from 'react' +import { useEventCallback } from './eventCallbackHooks' +import { useUnmount } from './unmountHooks' + +/** + * Options for {@link useTimeoutCallback}. + */ +export interface UseTimeoutCallbackOptions { + /** + * Callback to execute after the timeout. + */ + readonly callback: () => void + /** + * Timeout in milliseconds. + */ + readonly ms: number + /** + * Dependencies for {@link useEventCallback}. + * Reset the timeout when the dependencies change. + */ + readonly deps?: DependencyList + /** + * Whether the timeout is disabled. + */ + readonly isDisabled?: boolean +} + +const STABLE_DEPS_ARRAY: DependencyList = [] + +/** + * Hook that executes a callback after a timeout. + */ +export function useTimeoutCallback(options: UseTimeoutCallbackOptions) { + const { callback, ms, deps = STABLE_DEPS_ARRAY, isDisabled = false } = options + + const timeoutRef = useRef | null>(null) + const stableCallback = useEventCallback(callback) + + /** + * Restarts the timer. + */ + const restartTimer = useEventCallback(() => { + stopTimer() + startTimer() + }) + + /** + * Starts the timer. + */ + const startTimer = useEventCallback(() => { + stopTimer() + timeoutRef.current = setTimeout(stableCallback, ms) + }) + + /** + * Stops the timer. + */ + const stopTimer = useEventCallback(() => { + if (timeoutRef.current != null) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + }) + + useEffect(() => { + if (isDisabled) { + return + } + + startTimer() + + return () => { + stopTimer() + } + // There is no way to enable compiler, but it's not needed here + // as everything is stable. + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ms, isDisabled, ...deps]) + + useUnmount(() => { + stopTimer() + }) + + return [restartTimer, stopTimer, startTimer] as const +} + +/** + * Hook that returns a boolean indicating whether the timeout has expired. + */ +export function useTimeout(params: Pick) { + const { ms, deps = STABLE_DEPS_ARRAY } = params + + /** + * Get the default value for the timeout. + */ + const getDefaultValue = useEventCallback(() => { + return ms === 0 + }) + + const [isTimeout, setIsTimeout] = useState(getDefaultValue) + + const [restartTimer] = useTimeoutCallback({ + callback: () => { + setIsTimeout(true) + }, + ms, + deps, + isDisabled: false, + }) + + /** + * Resets the timeout and restarts it. + */ + const restart = useEventCallback(() => { + setIsTimeout(getDefaultValue) + restartTimer() + }) + + return [isTimeout, restart] as const +} diff --git a/app/gui/src/dashboard/layouts/Editor.tsx b/app/gui/src/dashboard/layouts/Editor.tsx index dc41ac689926..073a28951a89 100644 --- a/app/gui/src/dashboard/layouts/Editor.tsx +++ b/app/gui/src/dashboard/layouts/Editor.tsx @@ -20,6 +20,7 @@ import * as backendModule from '#/services/Backend' import { useEventCallback } from '#/hooks/eventCallbackHooks' import * as twMerge from '#/utilities/tailwindMerge' +import { useTimeoutCallback } from '../hooks/timeoutHooks' // ==================== // === StringConfig === @@ -91,9 +92,20 @@ function Editor(props: EditorProps) { backend, }) - const projectQuery = reactQuery.useQuery(projectStatusQuery) + const queryClient = reactQuery.useQueryClient() - const isProjectClosed = projectQuery.data?.state.type === backendModule.ProjectState.closed + const projectQuery = reactQuery.useSuspenseQuery({ + ...projectStatusQuery, + select: (data) => { + const isOpeningProject = projectHooks.OPENING_PROJECT_STATES.has(data.state.type) + const isProjectClosed = projectHooks.CLOSED_PROJECT_STATES.has(data.state.type) + + return { ...data, isOpeningProject, isProjectClosed } + }, + }) + + const isProjectClosed = projectQuery.data.isProjectClosed + const isOpeningProject = projectQuery.data.isOpeningProject React.useEffect(() => { if (isProjectClosed) { @@ -101,12 +113,28 @@ function Editor(props: EditorProps) { } }, [isProjectClosed, startProject, project]) + useTimeoutCallback({ + callback: () => { + const queryState = queryClient.getQueryCache().find({ queryKey: projectStatusQuery.queryKey }) + + queryState?.setState({ + error: new Error('Timeout opening the project'), + status: 'error', + }) + }, + ms: projectHooks.getTimeoutBasedOnTheBackendType(backend.type), + deps: [], + isDisabled: !isOpeningProject || projectQuery.isError, + }) + if (isOpeningFailed) { return ( { - startProject(project) + if (isProjectClosed) { + startProject(project) + } }} /> ) @@ -128,20 +156,17 @@ function Editor(props: EditorProps) { /> ) - case projectQuery.isLoading || - projectQuery.data?.state.type !== backendModule.ProjectState.opened: + case isOpeningProject: return default: return ( - - - + ) } @@ -178,9 +203,7 @@ function EditorInternal(props: EditorInternalProps) { ) React.useEffect(() => { - if (hidden) { - return - } else { + if (!hidden) { return gtagHooks.gtagOpenCloseCallback(gtagEvent, 'open_workflow', 'close_workflow') } }, [hidden, gtagEvent]) diff --git a/app/gui/src/dashboard/layouts/TabBar.tsx b/app/gui/src/dashboard/layouts/TabBar.tsx index 422b3977db2e..00c48ebc5857 100644 --- a/app/gui/src/dashboard/layouts/TabBar.tsx +++ b/app/gui/src/dashboard/layouts/TabBar.tsx @@ -5,8 +5,8 @@ import * as reactQuery from '@tanstack/react-query' import type * as text from 'enso-common/src/text' +import ErrorFilledIcon from '#/assets/warning.svg' import * as projectHooks from '#/hooks/projectHooks' - import type { LaunchedProject } from '#/providers/ProjectsProvider' import * as textProvider from '#/providers/TextProvider' @@ -19,7 +19,6 @@ import { AnimatedBackground } from '#/components/AnimatedBackground' import { useEventCallback } from '#/hooks/eventCallbackHooks' import { useBackendForProjectType } from '#/providers/BackendProvider' import { useInputBindings } from '#/providers/InputBindingsProvider' -import { ProjectState } from '#/services/Backend' import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets' import * as tailwindMerge from '#/utilities/tailwindMerge' import { motion } from 'framer-motion' @@ -163,13 +162,17 @@ export function ProjectTab(props: ProjectTabProps) { onClose?.(project) }) - const { data: isOpened, isSuccess } = reactQuery.useQuery({ + const { + data: isOpened, + isSuccess, + isError, + } = reactQuery.useQuery({ ...projectHooks.createGetProjectDetailsQuery({ assetId: project.id, parentId: project.parentId, backend, }), - select: (data) => data.state.type === ProjectState.opened, + select: (data) => projectHooks.OPENED_PROJECT_STATES.has(data.state.type), }) const isReady = isSuccess && isOpened @@ -187,7 +190,17 @@ export function ProjectTab(props: ProjectTabProps) { } }, [isReady]) - const icon = isReady ? iconRaw : SPINNER + const icon = (() => { + if (isReady) { + return iconRaw + } + + if (isError) { + return + } + + return SPINNER + })() return } diff --git a/app/gui/src/dashboard/pages/dashboard/DashboardTabPanels.tsx b/app/gui/src/dashboard/pages/dashboard/DashboardTabPanels.tsx index 8ddf9130a14c..ecf77ccc5704 100644 --- a/app/gui/src/dashboard/pages/dashboard/DashboardTabPanels.tsx +++ b/app/gui/src/dashboard/pages/dashboard/DashboardTabPanels.tsx @@ -102,11 +102,11 @@ export function DashboardTabPanels(props: DashboardTabPanelsProps) { return ( {(tabPanelProps) => ( - - - - - + + + {tabPanelProps.children} + + )} )