From 7a137675a3379b0ac47e52654e75a2eb2a19ff13 Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Tue, 10 Jun 2025 18:35:37 -0400 Subject: [PATCH 1/3] fix readiness indicator in workspaces active tab to additionally account for readiness probe --- src/contexts/instance-context.js | 2 +- src/contexts/workspaces-context/api.ts | 15 ++++--- src/contexts/workspaces-context/api.types.ts | 3 +- src/views/splash-screen.js | 2 +- src/views/workspaces/active.js | 43 +++++++++++++++++++- 5 files changed, 55 insertions(+), 10 deletions(-) diff --git a/src/contexts/instance-context.js b/src/contexts/instance-context.js index df501942..94e4ceaa 100644 --- a/src/contexts/instance-context.js +++ b/src/contexts/instance-context.js @@ -32,7 +32,7 @@ export const InstanceProvider = ({ children }) => { let isAppReady = false; try { - isAppReady = await api.getAppReady(decoded_url); + isAppReady = await api.getAppReady(sid); } catch(e) {} // just absorb the exception, the default false is correct here if (isAppReady) { let newActivity = { diff --git a/src/contexts/workspaces-context/api.ts b/src/contexts/workspaces-context/api.ts index 35e93278..135297c6 100644 --- a/src/contexts/workspaces-context/api.ts +++ b/src/contexts/workspaces-context/api.ts @@ -357,17 +357,22 @@ export class WorkspacesAPI implements IWorkspacesAPI { } @APIRequest() - async getAppReady(decodedURL: string, fetchOptions: AxiosRequestConfig={}): Promise { - const parts = decodedURL.split('/'); - const sid = (parts.length >= 2)?parts[parts.length - 2]:""; - - if(sid.length == 0) return false; + async getAppReady(sid: string, fetchOptions: AxiosRequestConfig={}): Promise { const res = await this.axios.get(`/instances/${ sid }/is_ready/`, { ...fetchOptions }); if(res.data) return res.data.is_ready; return false; } + + @APIRequest() + async getAppReadyByURL(decodedURL: string, fetchOptions: AxiosRequestConfig={}): Promise { + const parts = decodedURL.split('/'); + const sid = (parts.length >= 2)?parts[parts.length - 2]:""; + + if(sid.length == 0) return false; + return await this.getAppReady(sid, fetchOptions) + } } /** diff --git a/src/contexts/workspaces-context/api.types.ts b/src/contexts/workspaces-context/api.types.ts index caf75fd1..1803c91b 100644 --- a/src/contexts/workspaces-context/api.types.ts +++ b/src/contexts/workspaces-context/api.types.ts @@ -233,5 +233,6 @@ export interface IWorkspacesAPI { stopAppInstance(sid: string, fetchOptions?: AxiosRequestConfig): Promise updateAppInstance(sid: string, workspace: string, cpu: string, gpu: string, memory: string, fetchOptions?: AxiosRequestConfig): Promise launchApp(appId: string, cpus: number, gpus: number, memory: string, fetchOptions?: AxiosRequestConfig): Promise - getAppReady(appUrl: string, fetchOptions?: AxiosRequestConfig): Promise + getAppReady(sid: string, fetchOptions?: AxiosRequestConfig): Promise + getAppReadyByURL(decodedURL: string, fetchOptions?: AxiosRequestConfig): Promise } diff --git a/src/views/splash-screen.js b/src/views/splash-screen.js index f30dceaa..94279066 100644 --- a/src/views/splash-screen.js +++ b/src/views/splash-screen.js @@ -39,7 +39,7 @@ export const SplashScreenView = withWorkspaceAuthentication((props) => { (async () => { try { await callWithRetry(async () => { - const isReady = await api.getAppReady(decoded_url); + const isReady = await api.getAppReadyByURL(decoded_url); if (isReady && shouldCancel === false) { setLoading(false) } else { diff --git a/src/views/workspaces/active.js b/src/views/workspaces/active.js index 0078a292..c5cb3c9b 100644 --- a/src/views/workspaces/active.js +++ b/src/views/workspaces/active.js @@ -1,4 +1,4 @@ -import React, { Fragment, useEffect, useState } from 'react'; +import React, { Fragment, useEffect, useRef, useState } from 'react'; import { Button, Col, Form, Input, Layout, Modal, Table, Typography, Slider, Spin, Row, Progress, Space, Tooltip } from 'antd'; import { DeleteOutlined, RightCircleOutlined, LoadingOutlined, CloseOutlined, ExclamationOutlined, QuestionOutlined } from '@ant-design/icons'; import { NavigationTabGroup } from '../../components/workspaces/navigation-tab-group'; @@ -18,6 +18,7 @@ const memoryFormatter = (value) => { export const ActiveView = withWorkspaceAuthentication(() => { const [instances, setInstances] = useState(); + const [readinessStates, setReadinessStates] = useState({}) const [apps, setApps] = useState(); const [refresh, setRefresh] = useState(false); const [isLoading, setLoading] = useState(false); @@ -42,6 +43,8 @@ export const ActiveView = withWorkspaceAuthentication(() => { { text: 'Active', path: '/helx/workspaces/active' }, ] + const readinessStateAbortControllers = useRef([]) + useTitle("Active Workspaces") useEffect(() => { @@ -75,6 +78,35 @@ export const ActiveView = withWorkspaceAuthentication(() => { }, [instances, api]) + useEffect(() => { + if (!instances) return + + const pollAppReadiness = (instance) => { + let cancelled = false + const poll = async () => { + try { + const ready = await api.getAppReady(instance.sid) + if (!cancelled) setReadinessStates((oldStates) => ({ ...oldStates, [instance.sid]: ready })) + } catch (e) { + console.log(`Failed to get app readiness for instance ${ instance }`, e) + if (!cancelled) setTimeout(poll, 5000) + } + } + setTimeout(poll, 5000) + return () => { + cancelled = true + } + } + const launchedApps = instances.filter((instance) => { + const activity = getLatestActivity(instance.sid) + return activity?.data.status === "LAUNCHED" + }) + const pollCancels = launchedApps.map((instance) => pollAppReadiness(instance)) + return () => { + pollCancels.forEach((cancel) => cancel()) + } + }, [instances, getLatestActivity, api]) + const stopInstanceHandler = async () => { // besides making requests to delete the instance, close its browser tab and stop polling service setIsStopping(true); @@ -202,7 +234,14 @@ export const ActiveView = withWorkspaceAuthentication(() => { let activity = getLatestActivity(record.sid) let indicator = null let statusText = null - switch (activity?.data.status) { + + let status = activity?.data.status + if (status === "LAUNCHED") { + // Containers may be launched but also need to ensure the pod readiness probe has passed, + // otherwise display as still launching. + if (!readinessStates[record.sid]) status = "LAUNCHING" + } + switch (status) { case "LAUNCHING": indicator = } /> statusText = "Launching" From f64a7584ce8104cad144b41243e0da2bd1b4fc7e Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Tue, 10 Jun 2025 18:39:40 -0400 Subject: [PATCH 2/3] small fix --- src/views/workspaces/active.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/views/workspaces/active.js b/src/views/workspaces/active.js index c5cb3c9b..c328fe9c 100644 --- a/src/views/workspaces/active.js +++ b/src/views/workspaces/active.js @@ -80,13 +80,17 @@ export const ActiveView = withWorkspaceAuthentication(() => { useEffect(() => { if (!instances) return - + const pollAppReadiness = (instance) => { let cancelled = false const poll = async () => { try { const ready = await api.getAppReady(instance.sid) - if (!cancelled) setReadinessStates((oldStates) => ({ ...oldStates, [instance.sid]: ready })) + if (!cancelled) { + setReadinessStates((oldStates) => ({ ...oldStates, [instance.sid]: ready })) + // Once the app's readiness probe is passing, we don't need to continue polling it. + if (!ready) setTimeout(poll, 5000) + } } catch (e) { console.log(`Failed to get app readiness for instance ${ instance }`, e) if (!cancelled) setTimeout(poll, 5000) From 8c54ab0f7de5f42e34479db2aa2043a85d7da4de Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Wed, 11 Jun 2025 16:29:41 -0400 Subject: [PATCH 3/3] rework readiness polling in active view --- src/views/workspaces/active.js | 65 +++++++++++----------------------- 1 file changed, 20 insertions(+), 45 deletions(-) diff --git a/src/views/workspaces/active.js b/src/views/workspaces/active.js index c328fe9c..cd125e00 100644 --- a/src/views/workspaces/active.js +++ b/src/views/workspaces/active.js @@ -1,4 +1,4 @@ -import React, { Fragment, useEffect, useRef, useState } from 'react'; +import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react'; import { Button, Col, Form, Input, Layout, Modal, Table, Typography, Slider, Spin, Row, Progress, Space, Tooltip } from 'antd'; import { DeleteOutlined, RightCircleOutlined, LoadingOutlined, CloseOutlined, ExclamationOutlined, QuestionOutlined } from '@ant-design/icons'; import { NavigationTabGroup } from '../../components/workspaces/navigation-tab-group'; @@ -18,10 +18,8 @@ const memoryFormatter = (value) => { export const ActiveView = withWorkspaceAuthentication(() => { const [instances, setInstances] = useState(); - const [readinessStates, setReadinessStates] = useState({}) const [apps, setApps] = useState(); const [refresh, setRefresh] = useState(false); - const [isLoading, setLoading] = useState(false); const { api } = useWorkspacesAPI() const { appSpecs, appActivityCache, getLatestActivity } = useActivity(); const { analyticsEvents } = useAnalytics(); @@ -43,23 +41,31 @@ export const ActiveView = withWorkspaceAuthentication(() => { { text: 'Active', path: '/helx/workspaces/active' }, ] - const readinessStateAbortControllers = useRef([]) - useTitle("Active Workspaces") + const isLoading = useMemo(() => instances === undefined, [instances]) + useEffect(() => { - const renderInstance = async () => { - setLoading(true); + let stale = false + // We poll the instances endpoint to periodically update readiness probe values. + // This approach is simpler than individually polling each app's readiness. + const pollInstances = async () => { try { const instances = await api.getAppInstances() + if (stale) return setInstances(instances) + setTimeout(pollInstances, 5000) } catch (e) { + if (stale) return setInstances([]) openNotificationWithIcon('error', 'Error', 'An error has occurred while loading instances.') + setTimeout(pollInstances, 2500) } - setLoading(false); } - renderInstance(); + pollInstances() + return () => { + stale = true + } }, [refresh, api]) useEffect(() => { @@ -73,43 +79,12 @@ export const ActiveView = withWorkspaceAuthentication(() => { openNotificationWithIcon('error', 'Error', 'An error has occurred while loading app configuration.') } } - if (instances && instances.length === 0) setTimeout(() => navigate('/helx/workspaces/available'), 1000) - else loadAppsConfig(); - - }, [instances, api]) + loadAppsConfig(); + }, [api]) useEffect(() => { - if (!instances) return - - const pollAppReadiness = (instance) => { - let cancelled = false - const poll = async () => { - try { - const ready = await api.getAppReady(instance.sid) - if (!cancelled) { - setReadinessStates((oldStates) => ({ ...oldStates, [instance.sid]: ready })) - // Once the app's readiness probe is passing, we don't need to continue polling it. - if (!ready) setTimeout(poll, 5000) - } - } catch (e) { - console.log(`Failed to get app readiness for instance ${ instance }`, e) - if (!cancelled) setTimeout(poll, 5000) - } - } - setTimeout(poll, 5000) - return () => { - cancelled = true - } - } - const launchedApps = instances.filter((instance) => { - const activity = getLatestActivity(instance.sid) - return activity?.data.status === "LAUNCHED" - }) - const pollCancels = launchedApps.map((instance) => pollAppReadiness(instance)) - return () => { - pollCancels.forEach((cancel) => cancel()) - } - }, [instances, getLatestActivity, api]) + if (instances && instances.length === 0) setTimeout(() => navigate('/helx/workspaces/available'), 1000) + }, [instances]) const stopInstanceHandler = async () => { // besides making requests to delete the instance, close its browser tab and stop polling service @@ -243,7 +218,7 @@ export const ActiveView = withWorkspaceAuthentication(() => { if (status === "LAUNCHED") { // Containers may be launched but also need to ensure the pod readiness probe has passed, // otherwise display as still launching. - if (!readinessStates[record.sid]) status = "LAUNCHING" + if (record.status !== "ready") status = "LAUNCHING" } switch (status) { case "LAUNCHING":