diff --git a/packages/frontend/__mocks__/createWebStorageMock.js b/packages/frontend/__mocks__/createWebStorageMock.js new file mode 100644 index 00000000..e1d985cb --- /dev/null +++ b/packages/frontend/__mocks__/createWebStorageMock.js @@ -0,0 +1,10 @@ +/* global jest */ + +const mockStorage = { + getItem: jest.fn((_key) => Promise.resolve(null)), + setItem: jest.fn((_key, item) => Promise.resolve(item)), + removeItem: jest.fn((_key) => Promise.resolve()), +}; + +const createWebStorage = (_arg) => mockStorage; +module.exports = createWebStorage; diff --git a/packages/frontend/jest.config.ts b/packages/frontend/jest.config.ts index 3582e360..9e044b35 100644 --- a/packages/frontend/jest.config.ts +++ b/packages/frontend/jest.config.ts @@ -4,8 +4,16 @@ const jestConfig: JestConfigWithTsJest = { roots: ['/src'], setupFilesAfterEnv: ['/setupTests.ts'], testEnvironment: 'jsdom', + moduleNameMapper: { + '^@/app(.*)$': '/src/app/$1', + '^@/components(.*)$': '/src/components/$1', + '^@/features/(.*)$': '/src/features/$1', + '^@/utils/(.*)$': '/src/utils/$1', + '^redux-persist/lib/storage/createWebStorage$': + '/__mocks__/createWebStorageMock.js', + }, transform: { - '^.+\\.(ts|tsx)?$':[ + '^.+\\.(ts|tsx)?$': [ 'ts-jest', { tsconfig: 'tsconfig.test.json', @@ -14,7 +22,7 @@ const jestConfig: JestConfigWithTsJest = { ], 'node_modules/(flat|jsonpath-plus)/.+\\.(j|t)sx?$': ['ts-jest', {}], }, - 'transformIgnorePatterns': ['/node_modules/(?!(flat|jsonpath-plus))'] + transformIgnorePatterns: ['/node_modules/(?!(flat|jsonpath-plus))'], }; export default jestConfig; diff --git a/packages/frontend/src/components/Providers/ResourceMonitor.tsx b/packages/frontend/src/components/Providers/ResourceMonitor.tsx index aef53e4b..9edc9e9b 100644 --- a/packages/frontend/src/components/Providers/ResourceMonitor.tsx +++ b/packages/frontend/src/components/Providers/ResourceMonitor.tsx @@ -10,21 +10,48 @@ import { useGetWorkspaceStatusQuery, useTerminateWorkspaceMutation, WorkspaceStatus, + isTimeGreaterThan, + selectRequestedWorkspaceStatusTimestamp, } from '@gen3/core'; import { notifications } from '@mantine/notifications'; import { useDeepCompareEffect } from 'use-deep-compare'; const WORKSPACE_SHUTDOWN_ALERT_LIMIT = 30000; // TODO add to config +enum NotificationStatus { + Info, + Warn, + Error, +} + +const notifyUser = ( + title: string, + message: string, + status = NotificationStatus.Info, +) => { + const NotificationMap: Record = { + [NotificationStatus.Info]: 'utility.1', + [NotificationStatus.Warn]: 'utility.2', + [NotificationStatus.Error]: 'utility.4', + }; + + notifications.show({ + title, + message, + color: NotificationMap[status], + position: 'top-center', + }); +}; + // TODO: convert to seconds/minutes for readability const WorkspacePollingInterval: Record = { - 'Not Found': 0, - Launching: 5000, - Terminating: 5000, - Running: 300000, - Stopped: 5000, - Errored: 10000, - 'Status Error': 0, + [WorkspaceStatus.NotFound]: 0, + [WorkspaceStatus.Launching]: 5000, + [WorkspaceStatus.Terminating]: 5000, + [WorkspaceStatus.Running]: 300000, + [WorkspaceStatus.Stopped]: 5000, + [WorkspaceStatus.Errored]: 10000, + [WorkspaceStatus.StatusError]: 0, }; // TODO: convert to seconds/minutes for readability @@ -69,6 +96,9 @@ export const useWorkspaceResourceMonitor = () => { }); const [terminateWorkspace] = useTerminateWorkspaceMutation(); const requestedStatus = useCoreSelector(selectRequestedWorkspaceStatus); // trigger to start/stop workspaces + const requestedStatusTimestamp = useCoreSelector( + selectRequestedWorkspaceStatusTimestamp, + ); // last time requested status changed const dispatch = useCoreDispatch(); useEffect(() => { @@ -134,19 +164,23 @@ export const useWorkspaceResourceMonitor = () => { if (idleTimeLimit && idleTimeLimit > 0 && lastActivityTime > 0) { const remainingWorkspaceKernelLife = idleTimeLimit - (Date.now() - lastActivityTime); + if (remainingWorkspaceKernelLife <= 0) { // kernel has died due to inactivity + // so terminate try { terminateWorkspace().unwrap(); // Unwrap mutation response } catch (error) { - console.error('Workspace termination failed: ', error); - notifications.show({ - title: 'Error', - message: 'Failed to terminate workspace', - color: 'red', - position: 'top-center', - }); + const errorMessage = + (error as Error).message || 'Unknown error occurred'; + console.error('Workspace termination failed: ', errorMessage); + notifyUser( + 'Workspace Error', + `Failed to terminate workspace: ${errorMessage}`, + NotificationStatus.Error, + ); } + dispatch( setRequestedWorkspaceStatus(RequestedWorkspaceStatus.Terminate), ); @@ -154,27 +188,27 @@ export const useWorkspaceResourceMonitor = () => { WorkspacePollingInterval[WorkspaceStatus.Terminating], ); dispatch(setActiveWorkspaceStatus(WorkspaceStatus.Terminating)); - notifications.show({ - title: 'Workspace Shutdown', - message: - 'Workspace has been idle for too long. Shutting workspace down', - position: 'top-center', - }); + notifyUser( + 'Workspace Shutdown', + 'Workspace has been idle for too long. Shutting workspace down', + NotificationStatus.Error, + ); return; } if (remainingWorkspaceKernelLife <= workspaceShutdownAlertLimit) { - notifications.show({ - title: 'Workspace Warning', - message: 'Workspace has been idle for too long. Will shutdown soon', - position: 'top-center', - }); + notifyUser( + 'Workspace Warning', + 'Workspace has been idle for too long. Will shutdown soon', + NotificationStatus.Warn, + ); } } - if (requestedStatus === 'Launch') { + + if (requestedStatus === RequestedWorkspaceStatus.Launch) { // if the workspace is running then requested status has been met dispatch(setRequestedWorkspaceStatus(RequestedWorkspaceStatus.Unset)); } - if (requestedStatus === 'Terminate') { + if (requestedStatus === RequestedWorkspaceStatus.Terminate) { return; } @@ -184,12 +218,6 @@ export const useWorkspaceResourceMonitor = () => { } if (workspaceStatusData.status === WorkspaceStatus.NotFound) { - console.log( - 'workspaceStatusData.status', - workspaceStatusData.status, - ' requested status', - requestedStatus, - ); // NotFound means pod is not running // either starting up // or finally terminated. @@ -201,6 +229,10 @@ export const useWorkspaceResourceMonitor = () => { // both requested status and workspace pod status are the same so stop all polling setPollingInterval(WorkspacePollingInterval[WorkspaceStatus.NotFound]); dispatch(setActiveWorkspaceStatus(WorkspaceStatus.NotFound)); + if (requestedStatus === RequestedWorkspaceStatus.Terminate) { + // Clean up termination after terminated + dispatch(setRequestedWorkspaceStatus(RequestedWorkspaceStatus.Unset)); + } } return; } @@ -209,4 +241,17 @@ export const useWorkspaceResourceMonitor = () => { dispatch(setActiveWorkspaceStatus(workspaceStatusData.status)); setPollingInterval(WorkspacePollingInterval[workspaceStatusData.status]); }, [dispatch, workspaceStatusData, requestedStatus]); + + if ( + requestedStatus === RequestedWorkspaceStatus.Launch && + isTimeGreaterThan(requestedStatusTimestamp, 1) + ) { + terminateWorkspace(); + dispatch(setRequestedWorkspaceStatus(RequestedWorkspaceStatus.Terminate)); + notifyUser( + 'Workspace Startup', + 'Workspace failed to start. Shutting down', + NotificationStatus.Error, + ); + } }; diff --git a/packages/frontend/src/components/Providers/WorkspaceResourceMonitor.tsx b/packages/frontend/src/components/Providers/WorkspaceResourceMonitor.tsx deleted file mode 100644 index 8896f7a2..00000000 --- a/packages/frontend/src/components/Providers/WorkspaceResourceMonitor.tsx +++ /dev/null @@ -1,210 +0,0 @@ -// import { useEffect, useRef } from 'react'; -// import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -// import { notifications } from '@mantine/notifications'; -// import { -// setActiveWorkspaceStatus, -// useCoreDispatch, -// useCoreSelector, -// useGetWorkspaceStatusQuery, -// useGetWorkspacePayModelsQuery, -// useTerminateWorkspaceMutation, -// selectRequestedWorkspaceStatus, -// WorkspaceStatus, -// setRequestedWorkspaceStatus, -// } from '@gen3/core'; -// -// // Move intervals to constants -// const POLLING_INTERVALS = { -// 'Not Found': 0, -// Launching: 5000, -// Terminating: 5000, -// Running: 300000, -// Stopped: 5000, -// Errored: 10000, -// 'Status Error': 0, -// } as const; -// -// const PAYMENT_POLLING_INTERVALS = { -// 'Not Found': 0, -// Launching: 120000, // 2 minutes -// Terminating: 0, -// Running: 900000, // 15 minutes -// Stopped: 0, -// Errored: 0, -// 'Status Error': 0, -// } as const; -// -// const WORKSPACE_SHUTDOWN_ALERT_LIMIT = 30000; -// -// // Persistent monitoring hook -// export const useWorkspaceMonitor = () => { -// const dispatch = useCoreDispatch(); -// const requestedStatus = useCoreSelector(selectRequestedWorkspaceStatus); -// const statusIntervalRef = useRef(); -// const paymentIntervalRef = useRef(); -// const [terminateWorkspace] = useTerminateWorkspaceMutation(); -// -// const { -// data: workspaceStatusData, -// isError: isWorkspaceStatusError, -// refetch: refetchStatus, -// } = useGetWorkspaceStatusQuery(undefined, { -// pollingInterval: 0, -// refetchOnMountOrArgChange: true, -// refetchOnFocus: true, -// }); -// -// const { -// data: paymentModelData, -// isError: isPaymentModelError, -// error: paymentModelError, -// refetch: refetchPayment, -// } = useGetWorkspacePayModelsQuery(undefined, { -// pollingInterval: 0, -// refetchOnMountOrArgChange: true, -// }); -// -// const handleWorkspaceStatus = async () => { -// if (!workspaceStatusData) return; -// -// if (isWorkspaceStatusError) { -// dispatch(setActiveWorkspaceStatus(WorkspaceStatus.StatusError)); -// return; -// } -// -// const { status, idleTimeLimit, lastActivityTime } = workspaceStatusData; -// -// if (status === WorkspaceStatus.Running) { -// if (idleTimeLimit && idleTimeLimit > 0 && lastActivityTime > 0) { -// const remainingTime = idleTimeLimit - (Date.now() - lastActivityTime); -// -// if (remainingTime <= 0) { -// await terminateWorkspace(); -// dispatch(setActiveWorkspaceStatus(WorkspaceStatus.Terminating)); -// notifications.show({ -// title: 'Workspace Shutdown', -// message: -// 'Workspace has been idle for too long. Shutting workspace down', -// position: 'top-center', -// }); -// return; -// } -// -// if (remainingTime <= WORKSPACE_SHUTDOWN_ALERT_LIMIT) { -// notifications.show({ -// title: 'Workspace Warning', -// message: 'Workspace has been idle for too long. Will shutdown soon', -// position: 'top-center', -// }); -// } -// } -// -// if (requestedStatus === 'Launch') { -// dispatch(setRequestedWorkspaceStatus('Unset')); -// } -// } -// -// if ( -// status === WorkspaceStatus.NotFound && -// requestedStatus !== 'Launching' -// ) { -// dispatch(setActiveWorkspaceStatus(WorkspaceStatus.NotFound)); -// if (requestedStatus === 'Terminating') { -// dispatch(setRequestedWorkspaceStatus('NotSet')); -// } -// } -// -// dispatch(setActiveWorkspaceStatus(status)); -// dispatch(updateLastStatusCheck()); -// }; -// -// const handlePaymentModelCheck = () => { -// if (isPaymentModelError) { -// console.error('Payment model error: ', paymentModelError.toString()); -// } -// if (paymentModelData?.noPayModel) { -// console.warn('No payment model defined'); -// } -// }; -// -// // Setup status monitoring interval -// useEffect(() => { -// const startStatusMonitoring = () => { -// if (workspaceStatusData?.status) { -// const interval = POLLING_INTERVALS[workspaceStatusData.status]; -// if (interval > 0) { -// statusIntervalRef.current = setInterval(() => { -// refetchStatus(); -// }, interval); -// } -// } -// }; -// -// if (statusIntervalRef.current) { -// clearInterval(statusIntervalRef.current); -// } -// -// startStatusMonitoring(); -// dispatch(setMonitoringEnabled(true)); -// -// return () => { -// if (statusIntervalRef.current) { -// clearInterval(statusIntervalRef.current); -// } -// }; -// }, [workspaceStatusData?.status, dispatch, refetchStatus]); -// -// // Setup payment model monitoring interval -// useEffect(() => { -// const startPaymentMonitoring = () => { -// if (workspaceStatusData?.status) { -// const interval = PAYMENT_POLLING_INTERVALS[workspaceStatusData.status]; -// if (interval > 0) { -// paymentIntervalRef.current = setInterval(() => { -// refetchPayment(); -// }, interval); -// } -// } -// }; -// -// if (paymentIntervalRef.current) { -// clearInterval(paymentIntervalRef.current); -// } -// -// startPaymentMonitoring(); -// -// return () => { -// if (paymentIntervalRef.current) { -// clearInterval(paymentIntervalRef.current); -// } -// }; -// }, [workspaceStatusData?.status, refetchPayment]); -// -// // Handle status changes -// useEffect(() => { -// handleWorkspaceStatus(); -// }, [workspaceStatusData, isWorkspaceStatusError, requestedStatus]); -// -// // Handle payment model changes -// useEffect(() => { -// handlePaymentModelCheck(); -// dispatch(updateLastPaymentCheck()); -// }, [paymentModelData, isPaymentModelError]); -// -// return { -// status: workspaceStatusData?.status, -// isStatusError: isWorkspaceStatusError, -// isPaymentError: isPaymentModelError, -// paymentModel: paymentModelData, -// refetchStatus, -// refetchPayment, -// }; -// }; -// -// // Root level component to enable persistent monitoring -// export const WorkspaceMonitorProvider: React.FC<{ -// children: React.ReactNode; -// }> = ({ children }) => { -// useWorkspaceMonitor(); -// return <>{children}; -// }; diff --git a/packages/frontend/src/components/facets/FacetControlsHeader.tsx b/packages/frontend/src/components/facets/FacetControlsHeader.tsx index ff215f76..9f5dc2ae 100644 --- a/packages/frontend/src/components/facets/FacetControlsHeader.tsx +++ b/packages/frontend/src/components/facets/FacetControlsHeader.tsx @@ -80,6 +80,16 @@ const FacetControlsHeader = ({ } }, [field, isFilterExpanded, toggleExpandFilter]); + console.log( + 'FacetControlsHeader: ', + field, + description, + facetName, + showSearch, + showFlip, + showClearSelection, + ); + return (
diff --git a/packages/frontend/src/features/Workspace/TODO.md b/packages/frontend/src/features/Workspace/TODO.md index 0c6323f1..7dffe612 100644 --- a/packages/frontend/src/features/Workspace/TODO.md +++ b/packages/frontend/src/features/Workspace/TODO.md @@ -1,6 +1,7 @@ # Workspace TODO -* Fix error message to prevent 401 from showing in error popup +* ~~__Fix error message to prevent 401 from showing in error popup~~__ * scan and fix for 508/accessibility -* fix inactivity for workspaces -* add pending logout notification +* ~~fix inactivity for workspaces~~ +* ~~_add pending logout notification_~~ +* add more configuration options diff --git a/packages/frontend/src/features/Workspace/WorkspaceStatusProvider.tsx b/packages/frontend/src/features/Workspace/WorkspaceStatusProvider.tsx index 38bb0bd4..89aa397e 100644 --- a/packages/frontend/src/features/Workspace/WorkspaceStatusProvider.tsx +++ b/packages/frontend/src/features/Workspace/WorkspaceStatusProvider.tsx @@ -11,6 +11,7 @@ import { useGetWorkspacePayModelsQuery, useLaunchWorkspaceMutation, useTerminateWorkspaceMutation, + getCurrentTimestamp, WorkspaceStatus, } from '@gen3/core'; import { useDeepCompareEffect } from 'use-deep-compare'; @@ -129,6 +130,7 @@ const WorkspaceStatusProvider = ({ children }: { children: ReactNode }) => { id: id, status: WorkspaceStatus.Launching, requestedStatus: RequestedWorkspaceStatus.Launch, + requestedStatusTimestamp: getCurrentTimestamp(), }), ); }; diff --git a/packages/frontend/src/lib/session/session.tsx b/packages/frontend/src/lib/session/session.tsx index 6abd5018..efa029a5 100644 --- a/packages/frontend/src/lib/session/session.tsx +++ b/packages/frontend/src/lib/session/session.tsx @@ -1,32 +1,31 @@ import React, { useCallback, - useEffect, useContext, + useEffect, useRef, useState, } from 'react'; import { useRouter } from 'next/router'; +import { useDeepCompareMemo } from 'use-deep-compare'; +import { useManageSession } from './hooks'; +import { showNotification } from '@mantine/notifications'; import { Session, SessionProviderProps } from './types'; import { isUserOnPage } from './utils'; import { - useCoreDispatch, - useCoreSelector, type CoreState, - showModal, + GEN3_FENCE_API, + GEN3_REDIRECT_URL, Modals, - useLazyFetchUserDetailsQuery, selectUserAuthStatus, - GEN3_REDIRECT_URL, - GEN3_FENCE_API, + showModal, + useCoreDispatch, + useCoreSelector, + useLazyFetchUserDetailsQuery, } from '@gen3/core'; -import { useDeepCompareMemo } from 'use-deep-compare'; -import { useManageSession } from './hooks'; -import { showNotification } from '@mantine/notifications'; -//import { useResourceMonitor } from '../../components/Providers/ResourceMonitor'; -import { useWorkspaceResourceMonitor } from '../../components/Providers/ResourceMonitor'; -const SecondsToMilliseconds = (seconds: number) => seconds * 1000; -const MinutesToMilliseconds = (minutes: number) => minutes * 60 * 1000; +import { MinutesToMilliseconds } from '../../utils'; +import { useWorkspaceResourceMonitor } from '../../components/Providers/ResourceMonitor'; +///import { useWorkspaceResourceMonitor } from '../../features/Workspace/Monitor/workspaceMonitors'; export const logoutSession = async () => { await fetch(`${GEN3_FENCE_API}/user/logout?next=${GEN3_REDIRECT_URL}/`, { diff --git a/packages/frontend/src/utils/time.ts b/packages/frontend/src/utils/time.ts index 34878dba..eb96c95d 100644 --- a/packages/frontend/src/utils/time.ts +++ b/packages/frontend/src/utils/time.ts @@ -83,3 +83,5 @@ const measureExecutionTime = async (func: () => Promise) => { const getHighResolutionTimestamp = (): number => { return performance.now(); // More precise than Date.now() for performance measurements }; +const SecondsToMilliseconds = (seconds: number) => seconds * 1000; +export const MinutesToMilliseconds = (minutes: number) => minutes * 60 * 1000; diff --git a/packages/sampleCommons/.gitignore b/packages/sampleCommons/.gitignore index 8f322f0d..5c7f3425 100644 --- a/packages/sampleCommons/.gitignore +++ b/packages/sampleCommons/.gitignore @@ -33,3 +33,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +/.swc/