diff --git a/workspaces/frontend/README.md b/workspaces/frontend/README.md index 7e5ed38d3..b43fbf1d2 100644 --- a/workspaces/frontend/README.md +++ b/workspaces/frontend/README.md @@ -50,6 +50,12 @@ This is the default setup for running the UI locally. Make sure you build the pr npm run start:dev ``` +The command above requires the backend to be active in order to serve data. To run the UI independently, without establishing a connection to the backend, use the following command to start the application with a mocked API: + + ```bash + npm run start:dev:mock + ``` + ### Testing Run all tests: diff --git a/workspaces/frontend/config/webpack.common.js b/workspaces/frontend/config/webpack.common.js index a78bd03cc..cfa03d366 100644 --- a/workspaces/frontend/config/webpack.common.js +++ b/workspaces/frontend/config/webpack.common.js @@ -140,13 +140,13 @@ module.exports = (env) => { }, { test: /\.css$/i, - use: [ - 'style-loader', - 'css-loader', - ], + use: ['style-loader', 'css-loader'], include: [ - path.resolve(relativeDir, 'node_modules/@patternfly/react-catalog-view-extension/dist/css/react-catalog-view-extension.css'), - ] + path.resolve( + relativeDir, + 'node_modules/@patternfly/react-catalog-view-extension/dist/css/react-catalog-view-extension.css', + ), + ], }, ], }, diff --git a/workspaces/frontend/config/webpack.dev.js b/workspaces/frontend/config/webpack.dev.js index d419ccd58..9283bfc15 100644 --- a/workspaces/frontend/config/webpack.dev.js +++ b/workspaces/frontend/config/webpack.dev.js @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-var-requires */ const path = require('path'); +const { EnvironmentPlugin } = require('webpack'); const { merge } = require('webpack-merge'); const common = require('./webpack.common.js'); const { stylePaths } = require('./stylePaths'); @@ -9,6 +10,7 @@ const PORT = process.env.PORT || '9000'; const PROXY_HOST = process.env.PROXY_HOST || 'localhost'; const PROXY_PORT = process.env.PROXY_PORT || '4000'; const PROXY_PROTOCOL = process.env.PROXY_PROTOCOL || 'http:'; +const MOCK_API_ENABLED = process.env.MOCK_API_ENABLED || 'false'; const relativeDir = path.resolve(__dirname, '..'); module.exports = merge(common('development'), { @@ -45,4 +47,9 @@ module.exports = merge(common('development'), { }, ], }, + plugins: [ + new EnvironmentPlugin({ + WEBPACK_REPLACE__mockApiEnabled: MOCK_API_ENABLED, + }), + ], }); diff --git a/workspaces/frontend/jest.config.js b/workspaces/frontend/jest.config.js index e8e27cc64..01c94bcd0 100644 --- a/workspaces/frontend/jest.config.js +++ b/workspaces/frontend/jest.config.js @@ -26,9 +26,7 @@ module.exports = { testEnvironment: 'jest-environment-jsdom', // include projects from node_modules as required - transformIgnorePatterns: [ - 'node_modules/(?!yaml|lodash-es|uuid|@patternfly|delaunator)', - ], + transformIgnorePatterns: ['node_modules/(?!yaml|lodash-es|uuid|@patternfly|delaunator)'], // A list of paths to snapshot serializer modules Jest should use for snapshot testing snapshotSerializers: [], diff --git a/workspaces/frontend/package-lock.json b/workspaces/frontend/package-lock.json index ccf1348a5..97a1a675d 100644 --- a/workspaces/frontend/package-lock.json +++ b/workspaces/frontend/package-lock.json @@ -41,6 +41,7 @@ "concurrently": "^9.1.0", "copy-webpack-plugin": "^11.0.0", "core-js": "^3.39.0", + "cross-env": "^7.0.3", "css-loader": "^6.11.0", "css-minimizer-webpack-plugin": "^5.0.1", "cypress": "^13.16.1", @@ -7750,6 +7751,24 @@ "node": ">=8" } }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/workspaces/frontend/package.json b/workspaces/frontend/package.json index ed2c1326c..6af360145 100644 --- a/workspaces/frontend/package.json +++ b/workspaces/frontend/package.json @@ -17,6 +17,7 @@ "build:clean": "rimraf ./dist", "build:prod": "webpack --config ./config/webpack.prod.js", "start:dev": "webpack serve --hot --color --config ./config/webpack.dev.js", + "start:dev:mock": "cross-env MOCK_API_ENABLED=true npm run start:dev", "test": "run-s test:lint test:unit test:cypress-ci", "test:cypress-ci": "npx concurrently -P -k -s first \"npm run cypress:server:build && npm run cypress:server\" \"npx wait-on tcp:127.0.0.1:9001 && npm run cypress:run:mock -- {@}\" -- ", "test:jest": "jest --passWithNoTests", @@ -52,6 +53,7 @@ "concurrently": "^9.1.0", "copy-webpack-plugin": "^11.0.0", "core-js": "^3.39.0", + "cross-env": "^7.0.3", "css-loader": "^6.11.0", "css-minimizer-webpack-plugin": "^5.0.1", "cypress": "^13.16.1", @@ -95,8 +97,8 @@ "webpack-merge": "^5.10.0" }, "dependencies": { - "@patternfly/react-code-editor": "^6.2.0", "@patternfly/react-catalog-view-extension": "^6.1.0", + "@patternfly/react-code-editor": "^6.2.0", "@patternfly/react-core": "^6.2.0", "@patternfly/react-icons": "^6.2.0", "@patternfly/react-styles": "^6.2.0", diff --git a/workspaces/frontend/src/__mocks__/mockNamespaces.ts b/workspaces/frontend/src/__mocks__/mockNamespaces.ts index 6b6761fb6..6265e681b 100644 --- a/workspaces/frontend/src/__mocks__/mockNamespaces.ts +++ b/workspaces/frontend/src/__mocks__/mockNamespaces.ts @@ -1,7 +1,8 @@ -import { NamespacesList } from '~/app/types'; +import { buildMockNamespace } from '~/shared/mock/mockBuilder'; +import { Namespace } from '~/shared/api/backendApiTypes'; -export const mockNamespaces: NamespacesList = [ - { name: 'default' }, - { name: 'kubeflow' }, - { name: 'custom-namespace' }, +export const mockNamespaces: Namespace[] = [ + buildMockNamespace({ name: 'default' }), + buildMockNamespace({ name: 'kubeflow' }), + buildMockNamespace({ name: 'custom-namespace' }), ]; diff --git a/workspaces/frontend/src/__mocks__/mockWorkspaces.ts b/workspaces/frontend/src/__mocks__/mockWorkspaces.ts new file mode 100644 index 000000000..83093df48 --- /dev/null +++ b/workspaces/frontend/src/__mocks__/mockWorkspaces.ts @@ -0,0 +1,55 @@ +import { buildMockWorkspace } from '~/shared/mock/mockBuilder'; + +export const mockWorkspaces = [ + buildMockWorkspace(), + buildMockWorkspace({ + name: 'My Second Jupyter Notebook', + podTemplate: { + podMetadata: { + labels: {}, + annotations: {}, + }, + volumes: { + home: { + pvcName: 'workspace-home-pvc', + mountPath: '/home', + readOnly: false, + }, + data: [ + { + pvcName: 'workspace-data-pvc', + mountPath: '/data', + readOnly: false, + }, + ], + }, + options: { + imageConfig: { + current: { + id: 'jupyterlab_scipy_180', + displayName: 'jupyter-scipy:v1.9.0', + description: 'JupyterLab, with SciPy Packages', + labels: [ + { + key: 'pythonVersion', + value: '3.11', + }, + ], + }, + }, + podConfig: { + current: { + id: 'large_cpu', + displayName: 'Large CPU', + description: 'Pod with 1 CPU, 16 Gb RAM', + labels: [ + { key: 'cpu', value: '4000m' }, + { key: 'memory', value: '16Gi' }, + { key: 'gpu', value: '1' }, + ], + }, + }, + }, + }, + }), +]; diff --git a/workspaces/frontend/src/__mocks__/utils.ts b/workspaces/frontend/src/__mocks__/utils.ts index f808fe7cb..d4af2e359 100644 --- a/workspaces/frontend/src/__mocks__/utils.ts +++ b/workspaces/frontend/src/__mocks__/utils.ts @@ -1,4 +1,4 @@ -import { ResponseBody } from '~/app/types'; +import { ResponseBody } from '~/shared/api/types'; export const mockBFFResponse = (data: T): ResponseBody => ({ data, diff --git a/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaceKinds.mock.ts b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaceKinds.mock.ts index 03c0b05fa..4d0a48615 100644 --- a/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaceKinds.mock.ts +++ b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaceKinds.mock.ts @@ -1,4 +1,4 @@ -import type { WorkspaceKind } from '~/shared/types'; +import type { WorkspaceKind } from '~/shared/api/backendApiTypes'; // Factory function to create a valid WorkspaceKind function createMockWorkspaceKind(overrides: Partial = {}): WorkspaceKind { diff --git a/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/filterWorkspacesTest.cy.ts b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/filterWorkspacesTest.cy.ts index ebff315c8..075c8c3ca 100644 --- a/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/filterWorkspacesTest.cy.ts +++ b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspaces/filterWorkspacesTest.cy.ts @@ -1,3 +1,6 @@ +import { mockNamespaces } from '~/__mocks__/mockNamespaces'; +import { mockWorkspaces } from '~/__mocks__/mockWorkspaces'; +import { mockBFFResponse } from '~/__mocks__/utils'; import { home } from '~/__tests__/cypress/cypress/pages/home'; const useFilter = (filterName: string, searchValue: string) => { @@ -9,41 +12,49 @@ const useFilter = (filterName: string, searchValue: string) => { }; describe('Application', () => { + beforeEach(() => { + cy.intercept('GET', '/api/v1/namespaces', { + body: mockBFFResponse(mockNamespaces), + }); + cy.intercept('GET', '/api/v1/workspaces/default', { + body: mockBFFResponse(mockWorkspaces), + }); + }); it('filter rows with single filter', () => { home.visit(); useFilter('Name', 'My'); cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); - cy.get("[id$='workspaces-table-row-1']").contains('My Jupyter Notebook'); - cy.get("[id$='workspaces-table-row-2']").contains('My Other Jupyter Notebook'); + cy.get("[id$='workspaces-table-row-1']").contains('My First Jupyter Notebook'); + cy.get("[id$='workspaces-table-row-2']").contains('My Second Jupyter Notebook'); }); it('filter rows with multiple filters', () => { home.visit(); useFilter('Name', 'My'); - useFilter('Pod Config', 'Small'); + useFilter('Pod Config', 'Tiny'); cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 1); - cy.get("[id$='workspaces-table-row-1']").contains('My Jupyter Notebook'); + cy.get("[id$='workspaces-table-row-1']").contains('My First Jupyter Notebook'); }); it('filter rows with multiple filters and remove one', () => { home.visit(); useFilter('Name', 'My'); - useFilter('Pod Config', 'Small'); + useFilter('Pod Config', 'Tiny'); cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 1); - cy.get("[id$='workspaces-table-row-1']").contains('My Jupyter Notebook'); + cy.get("[id$='workspaces-table-row-1']").contains('My First Jupyter Notebook'); cy.get("[class$='pf-v6-c-label-group__close']").eq(1).click(); cy.get("[class$='pf-v6-c-toolbar__group']").should('not.contain', 'Pod Config'); cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 2); - cy.get("[id$='workspaces-table-row-1']").contains('My Jupyter Notebook'); - cy.get("[id$='workspaces-table-row-2']").contains('My Other Jupyter Notebook'); + cy.get("[id$='workspaces-table-row-1']").contains('My First Jupyter Notebook'); + cy.get("[id$='workspaces-table-row-2']").contains('My Second Jupyter Notebook'); }); it('filter rows with multiple filters and remove all', () => { home.visit(); useFilter('Name', 'My'); - useFilter('Pod Config', 'Small'); + useFilter('Pod Config', 'Tiny'); cy.get("[id$='workspaces-table-content']").find('tr').should('have.length', 1); - cy.get("[id$='workspaces-table-row-1']").contains('My Jupyter Notebook'); + cy.get("[id$='workspaces-table-row-1']").contains('My First Jupyter Notebook'); cy.get('*').contains('Clear all filters').click(); cy.get("[class$='pf-v6-c-toolbar__group']").should('not.contain', 'Pod Config'); cy.get("[class$='pf-v6-c-toolbar__group']").should('not.contain', 'Name'); diff --git a/workspaces/frontend/src/app/App.tsx b/workspaces/frontend/src/app/App.tsx index 098b393c2..81b487236 100644 --- a/workspaces/frontend/src/app/App.tsx +++ b/workspaces/frontend/src/app/App.tsx @@ -15,6 +15,7 @@ import { Title, } from '@patternfly/react-core'; import { BarsIcon } from '@patternfly/react-icons'; +import ErrorBoundary from '~/app/error/ErrorBoundary'; import NamespaceSelector from '~/shared/components/NamespaceSelector'; import logoDarkTheme from '~/images/logo-dark-theme.svg'; import { NamespaceContextProvider } from './context/NamespaceContextProvider'; @@ -63,19 +64,21 @@ const App: React.FC = () => { ); return ( - - - } - > - - - - + + + + } + > + + + + + ); }; diff --git a/workspaces/frontend/src/app/EnsureAPIAvailability.tsx b/workspaces/frontend/src/app/EnsureAPIAvailability.tsx new file mode 100644 index 000000000..53dbeeaf2 --- /dev/null +++ b/workspaces/frontend/src/app/EnsureAPIAvailability.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { Bullseye, Spinner } from '@patternfly/react-core'; +import { useNotebookAPI } from './hooks/useNotebookAPI'; + +interface EnsureAPIAvailabilityProps { + children: React.ReactNode; +} + +const EnsureAPIAvailability: React.FC = ({ children }) => { + const { apiAvailable } = useNotebookAPI(); + if (!apiAvailable) { + return ( + + + + ); + } + + return <>{children}; +}; + +export default EnsureAPIAvailability; diff --git a/workspaces/frontend/src/app/actions/WorkspaceKindsActions.tsx b/workspaces/frontend/src/app/actions/WorkspaceKindsActions.tsx index 49bb9ec97..b9db0aae3 100644 --- a/workspaces/frontend/src/app/actions/WorkspaceKindsActions.tsx +++ b/workspaces/frontend/src/app/actions/WorkspaceKindsActions.tsx @@ -1,4 +1,4 @@ -import { WorkspaceKind } from '~/shared/types'; +import { WorkspaceKind, WorkspaceOptionRedirect } from '~/shared/api/backendApiTypes'; type KindLogoDict = Record; @@ -20,10 +20,7 @@ export function buildKindLogoDictionary(workspaceKinds: WorkspaceKind[] | []): K return kindLogoDict; } -type WorkspaceRedirectStatus = Record< - string, - { to: string; message: string; level: string } | null ->; +type WorkspaceRedirectStatus = Record; /** * Builds a dictionary of workspace kinds to redirect statuses. @@ -36,17 +33,10 @@ export function buildWorkspaceRedirectStatus( const workspaceRedirectStatus: WorkspaceRedirectStatus = {}; for (const workspaceKind of workspaceKinds) { // Loop through the `values` array inside `imageConfig` - const redirect = workspaceKind.podTemplate.options.imageConfig.values.find( - (value) => value.redirect, - )?.redirect; - // If redirect exists, extract the necessary properties - workspaceRedirectStatus[workspaceKind.name] = redirect - ? { - to: redirect.to, - message: redirect.message.text, - level: redirect.message.level, - } - : null; + workspaceRedirectStatus[workspaceKind.name] = + workspaceKind.podTemplate.options.imageConfig.values.find( + (value) => value.redirect, + )?.redirect; } return workspaceRedirectStatus; } diff --git a/workspaces/frontend/src/app/context/NotebookContext.tsx b/workspaces/frontend/src/app/context/NotebookContext.tsx index 34787e47b..df17db283 100644 --- a/workspaces/frontend/src/app/context/NotebookContext.tsx +++ b/workspaces/frontend/src/app/context/NotebookContext.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { ReactNode } from 'react'; import { BFF_API_VERSION } from '~/app/const'; +import EnsureAPIAvailability from '~/app/EnsureAPIAvailability'; import useNotebookAPIState, { NotebookAPIState } from './useNotebookAPIState'; export type NotebookContextType = { @@ -33,7 +34,7 @@ export const NotebookContextProvider: React.FC = ( [apiState, refreshAPIState], )} > - {children} + {children} ); }; diff --git a/workspaces/frontend/src/app/context/useNotebookAPIState.tsx b/workspaces/frontend/src/app/context/useNotebookAPIState.tsx index 6347618ce..2af754c65 100644 --- a/workspaces/frontend/src/app/context/useNotebookAPIState.tsx +++ b/workspaces/frontend/src/app/context/useNotebookAPIState.tsx @@ -1,24 +1,100 @@ import React from 'react'; +import { NotebookAPIs } from '~/shared/api/notebookApi'; +import { + createWorkspace, + createWorkspaceKind, + deleteWorkspace, + deleteWorkspaceKind, + getHealthCheck, + getWorkspace, + getWorkspaceKind, + listAllWorkspaces, + listNamespaces, + listWorkspaceKinds, + listWorkspaces, + patchWorkspace, + patchWorkspaceKind, + updateWorkspace, + updateWorkspaceKind, +} from '~/shared/api/notebookService'; import { APIState } from '~/shared/api/types'; -import { NotebookAPIs } from '~/app/types'; -import { getNamespaces, getWorkspaceKinds, createWorkspace } from '~/shared/api/notebookService'; import useAPIState from '~/shared/api/useAPIState'; +import { + mockCreateWorkspace, + mockCreateWorkspaceKind, + mockDeleteWorkspace, + mockDeleteWorkspaceKind, + mockGetHealthCheck, + mockGetWorkspace, + mockGetWorkspaceKind, + mockListAllWorkspaces, + mockListNamespaces, + mockListWorkspaceKinds, + mockListWorkspaces, + mockPatchWorkspace, + mockPatchWorkspaceKind, + mockUpdateWorkspace, + mockUpdateWorkspaceKind, +} from '~/shared/mock/mockNotebookService'; export type NotebookAPIState = APIState; +const MOCK_API_ENABLED = process.env.WEBPACK_REPLACE__mockApiEnabled === 'true'; + const useNotebookAPIState = ( hostPath: string | null, ): [apiState: NotebookAPIState, refreshAPIState: () => void] => { - const createAPI = React.useCallback( + const createApi = React.useCallback( (path: string) => ({ - getNamespaces: getNamespaces(path), - getWorkspaceKinds: getWorkspaceKinds(path), + // Health + getHealthCheck: getHealthCheck(path), + // Namespace + listNamespaces: listNamespaces(path), + // Workspace + listAllWorkspaces: listAllWorkspaces(path), + listWorkspaces: listWorkspaces(path), createWorkspace: createWorkspace(path), + getWorkspace: getWorkspace(path), + updateWorkspace: updateWorkspace(path), + patchWorkspace: patchWorkspace(path), + deleteWorkspace: deleteWorkspace(path), + // WorkspaceKind + listWorkspaceKinds: listWorkspaceKinds(path), + createWorkspaceKind: createWorkspaceKind(path), + getWorkspaceKind: getWorkspaceKind(path), + patchWorkspaceKind: patchWorkspaceKind(path), + deleteWorkspaceKind: deleteWorkspaceKind(path), + updateWorkspaceKind: updateWorkspaceKind(path), + }), + [], + ); + + const createMockApi = React.useCallback( + (path: string) => ({ + // Health + getHealthCheck: mockGetHealthCheck(path), + // Namespace + listNamespaces: mockListNamespaces(path), + // Workspace + listAllWorkspaces: mockListAllWorkspaces(path), + listWorkspaces: mockListWorkspaces(path), + createWorkspace: mockCreateWorkspace(path), + getWorkspace: mockGetWorkspace(path), + updateWorkspace: mockUpdateWorkspace(path), + patchWorkspace: mockPatchWorkspace(path), + deleteWorkspace: mockDeleteWorkspace(path), + // WorkspaceKind + listWorkspaceKinds: mockListWorkspaceKinds(path), + createWorkspaceKind: mockCreateWorkspaceKind(path), + getWorkspaceKind: mockGetWorkspaceKind(path), + patchWorkspaceKind: mockPatchWorkspaceKind(path), + deleteWorkspaceKind: mockDeleteWorkspaceKind(path), + updateWorkspaceKind: mockUpdateWorkspaceKind(path), }), [], ); - return useAPIState(hostPath, createAPI); + return useAPIState(hostPath, MOCK_API_ENABLED ? createMockApi : createApi); }; export default useNotebookAPIState; diff --git a/workspaces/frontend/src/app/error/ErrorBoundary.tsx b/workspaces/frontend/src/app/error/ErrorBoundary.tsx new file mode 100644 index 000000000..d59b85e2e --- /dev/null +++ b/workspaces/frontend/src/app/error/ErrorBoundary.tsx @@ -0,0 +1,98 @@ +import * as React from 'react'; +import { Button, Split, SplitItem, Title } from '@patternfly/react-core'; +import { TimesIcon } from '@patternfly/react-icons'; +import ErrorDetails from '~/app/error/ErrorDetails'; +import UpdateState from '~/app/error/UpdateState'; + +type ErrorBoundaryProps = { + children?: React.ReactNode; +}; + +type ErrorBoundaryState = + | { hasError: false } + | { + hasError: true; + error: Error; + errorInfo: React.ErrorInfo; + isUpdateState: boolean; + }; + +class ErrorBoundary extends React.Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { + hasError: false, + }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + this.setState({ + hasError: true, + error, + errorInfo, + isUpdateState: error.name === 'ChunkLoadError', + }); + // eslint-disable-next-line no-console + console.error('Caught error:', error, errorInfo); + } + + render(): React.ReactNode { + const { children } = this.props; + const { hasError } = this.state; + + if (hasError) { + const { error, errorInfo, isUpdateState } = this.state; + if (isUpdateState) { + return ( + this.setState((prevState) => ({ ...prevState, isUpdateState: false }))} + /> + ); + } + return ( +
+ + + + An error occurred + +

+ Try{' '} + {' '} + the page if there was a recent update. +

+
+ +
+ ); + } + + return children; + } +} + +export default ErrorBoundary; diff --git a/workspaces/frontend/src/app/error/ErrorDetails.tsx b/workspaces/frontend/src/app/error/ErrorDetails.tsx new file mode 100644 index 000000000..66d84a20b --- /dev/null +++ b/workspaces/frontend/src/app/error/ErrorDetails.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { + ClipboardCopy, + ClipboardCopyVariant, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Title, +} from '@patternfly/react-core'; + +type ErrorDetailsProps = { + title: string; + errorMessage?: string; + componentStack: string; + stack?: string; +}; + +const ErrorDetails: React.FC = ({ + title, + errorMessage, + componentStack, + stack, +}) => ( + <> + + {title} + + + {errorMessage ? ( + + Description: + {errorMessage} + + ) : null} + + + Component trace: + + + {componentStack.trim()} + + + + + {stack ? ( + + Stack trace: + + + {stack.trim()} + + + + ) : null} + + +); + +export default ErrorDetails; diff --git a/workspaces/frontend/src/app/error/UpdateState.tsx b/workspaces/frontend/src/app/error/UpdateState.tsx new file mode 100644 index 000000000..dce1252b9 --- /dev/null +++ b/workspaces/frontend/src/app/error/UpdateState.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { + Button, + EmptyState, + EmptyStateActions, + EmptyStateBody, + EmptyStateFooter, + EmptyStateVariant, + PageSection, +} from '@patternfly/react-core'; +import { PathMissingIcon } from '@patternfly/react-icons'; + +type Props = { + onClose: () => void; +}; + +const UpdateState: React.FC = ({ onClose }) => ( + + + This is likely the result of a recent update. + + + + + + + + + + +); + +export default UpdateState; diff --git a/workspaces/frontend/src/app/hooks/useCreateWorkspace.ts b/workspaces/frontend/src/app/hooks/useCreateWorkspace.ts deleted file mode 100644 index 0e2cfc1b3..000000000 --- a/workspaces/frontend/src/app/hooks/useCreateWorkspace.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as React from 'react'; -import useFetchState, { - FetchState, - FetchStateCallbackPromise, -} from '~/shared/utilities/useFetchState'; -import { CreateWorkspaceData } from '~/app/types'; -import { useNotebookAPI } from '~/app/hooks/useNotebookAPI'; -import { Workspace } from '~/shared/types'; -import { createWorkspaceCall } from '~/app/pages/Workspaces/utils'; -import { APIOptions } from '~/shared/api/types'; - -const useCreateWorkspace = ( - namespace: string, - formData: CreateWorkspaceData, -): FetchState => { - const { api, apiAvailable } = useNotebookAPI(); - - const call = React.useCallback>( - (opts: APIOptions) => { - if (!apiAvailable) { - return Promise.reject(new Error('API not yet available')); - } - if (!namespace) { - return Promise.reject(new Error('namespace is not available yet')); - } - return createWorkspaceCall(opts, api, formData, namespace).then((result) => result.workspace); - }, - [api, apiAvailable, namespace, formData], - ); - - return useFetchState(call, null); -}; - -export default useCreateWorkspace; diff --git a/workspaces/frontend/src/app/hooks/useGenericObjectState.ts b/workspaces/frontend/src/app/hooks/useGenericObjectState.ts new file mode 100644 index 000000000..7c6098aaa --- /dev/null +++ b/workspaces/frontend/src/app/hooks/useGenericObjectState.ts @@ -0,0 +1,34 @@ +import * as React from 'react'; + +export type UpdateObjectAtPropAndValue = ( + propKey: K, + propValue: T[K], +) => void; + +export type GenericObjectState = [ + data: T, + setData: UpdateObjectAtPropAndValue, + resetDefault: () => void, +]; + +const useGenericObjectState = (defaultData: T | (() => T)): GenericObjectState => { + const [value, setValue] = React.useState(defaultData); + + const setPropValue = React.useCallback>((propKey, propValue) => { + setValue((oldValue) => { + if (oldValue[propKey] !== propValue) { + return { ...oldValue, [propKey]: propValue }; + } + return oldValue; + }); + }, []); + + const defaultDataRef = React.useRef(value); + const resetToDefault = React.useCallback(() => { + setValue(defaultDataRef.current); + }, []); + + return [value, setPropValue, resetToDefault]; +}; + +export default useGenericObjectState; diff --git a/workspaces/frontend/src/app/hooks/useNamespaces.ts b/workspaces/frontend/src/app/hooks/useNamespaces.ts index 8b84fd33e..d07c0ba1b 100644 --- a/workspaces/frontend/src/app/hooks/useNamespaces.ts +++ b/workspaces/frontend/src/app/hooks/useNamespaces.ts @@ -3,19 +3,19 @@ import useFetchState, { FetchState, FetchStateCallbackPromise, } from '~/shared/utilities/useFetchState'; -import { NamespacesList } from '~/app/types'; import { useNotebookAPI } from '~/app/hooks/useNotebookAPI'; +import { Namespace } from '~/shared/api/backendApiTypes'; -const useNamespaces = (): FetchState => { +const useNamespaces = (): FetchState => { const { api, apiAvailable } = useNotebookAPI(); - const call = React.useCallback>( + const call = React.useCallback>( (opts) => { if (!apiAvailable) { return Promise.reject(new Error('API not yet available')); } - return api.getNamespaces(opts); + return api.listNamespaces(opts); }, [api, apiAvailable], ); diff --git a/workspaces/frontend/src/app/hooks/useWorkspaceCountPerKind.ts b/workspaces/frontend/src/app/hooks/useWorkspaceCountPerKind.ts new file mode 100644 index 000000000..c627de362 --- /dev/null +++ b/workspaces/frontend/src/app/hooks/useWorkspaceCountPerKind.ts @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { useNotebookAPI } from '~/app/hooks/useNotebookAPI'; +import { Workspace, WorkspaceKind } from '~/shared/api/backendApiTypes'; + +type WorkspaceCountPerKind = Record; + +export const useWorkspaceCountPerKind = (): WorkspaceCountPerKind => { + const { api } = useNotebookAPI(); + + const [workspaceCountPerKind, setWorkspaceCountPerKind] = React.useState( + {}, + ); + + React.useEffect(() => { + api.listAllWorkspaces({}).then((workspaces) => { + const countPerKind = workspaces.reduce((acc: WorkspaceCountPerKind, workspace: Workspace) => { + acc[workspace.workspaceKind.name] = (acc[workspace.workspaceKind.name] || 0) + 1; + return acc; + }, {}); + setWorkspaceCountPerKind(countPerKind); + }); + }, [api]); + + return workspaceCountPerKind; +}; diff --git a/workspaces/frontend/src/app/hooks/useWorkspaceKindByName.ts b/workspaces/frontend/src/app/hooks/useWorkspaceKindByName.ts new file mode 100644 index 000000000..942f24b6e --- /dev/null +++ b/workspaces/frontend/src/app/hooks/useWorkspaceKindByName.ts @@ -0,0 +1,26 @@ +import * as React from 'react'; +import useFetchState, { + FetchState, + FetchStateCallbackPromise, +} from '~/shared/utilities/useFetchState'; +import { useNotebookAPI } from '~/app/hooks/useNotebookAPI'; +import { WorkspaceKind } from '~/shared/api/backendApiTypes'; + +const useWorkspaceKindByName = (kind: string): FetchState => { + const { api, apiAvailable } = useNotebookAPI(); + + const call = React.useCallback>( + (opts) => { + if (!apiAvailable) { + return Promise.reject(new Error('API not yet available')); + } + + return api.getWorkspaceKind(opts, kind); + }, + [api, apiAvailable, kind], + ); + + return useFetchState(call, null); +}; + +export default useWorkspaceKindByName; diff --git a/workspaces/frontend/src/app/hooks/useWorkspaceKinds.ts b/workspaces/frontend/src/app/hooks/useWorkspaceKinds.ts index f0ad18462..4db6cb2a6 100644 --- a/workspaces/frontend/src/app/hooks/useWorkspaceKinds.ts +++ b/workspaces/frontend/src/app/hooks/useWorkspaceKinds.ts @@ -3,7 +3,7 @@ import useFetchState, { FetchState, FetchStateCallbackPromise, } from '~/shared/utilities/useFetchState'; -import { WorkspaceKind } from '~/shared/types'; +import { WorkspaceKind } from '~/shared/api/backendApiTypes'; import { useNotebookAPI } from '~/app/hooks/useNotebookAPI'; const useWorkspaceKinds = (): FetchState => { @@ -13,7 +13,7 @@ const useWorkspaceKinds = (): FetchState => { if (!apiAvailable) { return Promise.reject(new Error('API not yet available')); } - return api.getWorkspaceKinds(opts); + return api.listWorkspaceKinds(opts); }, [api, apiAvailable], ); diff --git a/workspaces/frontend/src/app/hooks/useWorkspaces.ts b/workspaces/frontend/src/app/hooks/useWorkspaces.ts new file mode 100644 index 000000000..b6347f9d3 --- /dev/null +++ b/workspaces/frontend/src/app/hooks/useWorkspaces.ts @@ -0,0 +1,26 @@ +import * as React from 'react'; +import useFetchState, { + FetchState, + FetchStateCallbackPromise, +} from '~/shared/utilities/useFetchState'; +import { useNotebookAPI } from '~/app/hooks/useNotebookAPI'; +import { Workspace } from '~/shared/api/backendApiTypes'; + +const useWorkspaces = (namespace: string): FetchState => { + const { api, apiAvailable } = useNotebookAPI(); + + const call = React.useCallback>( + (opts) => { + if (!apiAvailable) { + return Promise.reject(new Error('API not yet available')); + } + + return api.listWorkspaces(opts, namespace); + }, + [api, apiAvailable, namespace], + ); + + return useFetchState(call, null); +}; + +export default useWorkspaces; diff --git a/workspaces/frontend/src/app/pages/WorkspaceKinds/WorkspaceKinds.tsx b/workspaces/frontend/src/app/pages/WorkspaceKinds/WorkspaceKinds.tsx index 9becc15e6..805a79214 100644 --- a/workspaces/frontend/src/app/pages/WorkspaceKinds/WorkspaceKinds.tsx +++ b/workspaces/frontend/src/app/pages/WorkspaceKinds/WorkspaceKinds.tsx @@ -40,84 +40,16 @@ import { IActions, } from '@patternfly/react-table'; import { CodeIcon, FilterIcon, SearchIcon } from '@patternfly/react-icons'; -import { WorkspaceKind, WorkspaceKindsColumnNames } from '~/shared/types'; +import { WorkspaceKind } from '~/shared/api/backendApiTypes'; +import useWorkspaceKinds from '~/app/hooks/useWorkspaceKinds'; +import { useWorkspaceCountPerKind } from '~/app/hooks/useWorkspaceCountPerKind'; +import { WorkspaceKindsColumnNames } from '~/app/types'; export enum ActionType { ViewDetails, } export const WorkspaceKinds: React.FunctionComponent = () => { - // Todo: Remove mock and use useWorkspaceKinds API instead. - const mockWorkspaceKinds: WorkspaceKind[] = [ - { - name: 'jupyterlab', - displayName: 'JupyterLab Notebook', - description: - 'Example of a description for JupyterLab a Workspace which runs JupyterLab in a Pod.', - deprecated: true, - deprecationMessage: - 'This WorkspaceKind was removed on 20XX-XX-XX, please use another WorkspaceKind.', - hidden: false, - icon: { - url: 'https://jupyter.org/assets/favicons/apple-touch-icon-152x152.png', - }, - logo: { - url: 'https://upload.wikimedia.org/wikipedia/commons/3/38/Jupyter_logo.svg', - }, - podTemplate: { - podMetadata: { - labels: { - myWorkspaceKindLabel: 'my-value', - }, - annotations: { - myWorkspaceKindAnnotation: 'my-value', - }, - }, - volumeMounts: { - home: '/home/jovyan', - }, - options: { - imageConfig: { - default: 'jupyterlab_scipy_190', - values: [ - { - id: 'jupyterlab_scipy_180', - displayName: 'jupyter-scipy:v1.8.0', - labels: { - pythonVersion: '3.11', - }, - hidden: true, - redirect: { - to: 'jupyterlab_scipy_190', - message: { - text: 'This update will change...', - level: 'Info', - }, - }, - }, - ], - }, - podConfig: { - default: 'tiny_cpu', - values: [ - { - id: 'tiny_cpu', - displayName: 'Tiny CPU', - description: 'Pod with 0.1 CPU, 128 Mb RAM', - labels: { - cpu: '100m', - memory: '128Mi', - }, - }, - ], - }, - }, - }, - }, - ]; - - const mockNumberOfWorkspaces = 1; // Todo: Create a function to calculate number of workspaces for each workspace kind. - // Table columns const columnNames: WorkspaceKindsColumnNames = { icon: '', @@ -127,7 +59,8 @@ export const WorkspaceKinds: React.FunctionComponent = () => { numberOfWorkspaces: 'Number of workspaces', }; - const initialWorkspaceKinds = mockWorkspaceKinds; + const [workspaceKinds, workspaceKindsLoaded, workspaceKindsError] = useWorkspaceKinds(); + const workspaceCountPerKind = useWorkspaceCountPerKind(); const [selectedWorkspaceKind, setSelectedWorkspaceKind] = React.useState( null, ); @@ -150,19 +83,19 @@ export const WorkspaceKinds: React.FunctionComponent = () => { name: workspaceKind.name, description: workspaceKind.description, deprecated: workspaceKind.deprecated, - numOfWorkspaces: mockNumberOfWorkspaces, + numOfWorkspaces: workspaceCountPerKind[workspaceKind.name] ?? 0, }; return [icon, name, description, deprecated, numberOfWorkspaces]; }, - [], + [workspaceCountPerKind], ); const sortedWorkspaceKinds = React.useMemo(() => { if (activeSortIndex === null) { - return initialWorkspaceKinds; + return workspaceKinds; } - return [...initialWorkspaceKinds].sort((a, b) => { + return [...workspaceKinds].sort((a, b) => { const aValue = getSortableRowValues(a)[activeSortIndex]; const bValue = getSortableRowValues(b)[activeSortIndex]; if (typeof aValue === 'boolean' && typeof bValue === 'boolean') { @@ -174,7 +107,7 @@ export const WorkspaceKinds: React.FunctionComponent = () => { ? (aValue as string).localeCompare(bValue as string) : (bValue as string).localeCompare(aValue as string); }); - }, [initialWorkspaceKinds, activeSortIndex, activeSortDirection, getSortableRowValues]); + }, [workspaceKinds, activeSortIndex, activeSortDirection, getSortableRowValues]); const getSortParams = React.useCallback( (columnIndex: number): ThProps['sort'] => ({ @@ -535,6 +468,14 @@ export const WorkspaceKinds: React.FunctionComponent = () => { const DESCRIPTION_CHAR_LIMIT = 50; + if (workspaceKindsError) { + return

Error loading workspace kinds: {workspaceKindsError.message}

; // TODO: UX for error state + } + + if (!workspaceKindsLoaded) { + return

Loading...

; // TODO: UX for loading state + } + return ( { )} - {mockNumberOfWorkspaces} + + {workspaceCountPerKind[workspaceKind.name] ?? 0} + { const navigate = useNavigate(); + const { api } = useNotebookAPI(); + const { selectedNamespace } = useNamespaceContext(); + const [isSubmitting, setIsSubmitting] = React.useState(false); const [currentStep, setCurrentStep] = useState(WorkspaceCreationSteps.KindSelection); - const [selectedKind, setSelectedKind] = useState(); - const [selectedImage, setSelectedImage] = useState(); - const [selectedPodConfig, setSelectedPodConfig] = useState(); - const [, setSelectedProperties] = useState(); + + const [data, setData, resetData] = useGenericObjectState({ + kind: undefined, + image: undefined, + podConfig: undefined, + properties: { + deferUpdates: false, + homeDirectory: '', + volumes: [], + secrets: [], + workspaceName: '', + }, + }); const getStepVariant = useCallback( (step: WorkspaceCreationSteps) => { @@ -62,17 +73,68 @@ const WorkspaceCreation: React.FunctionComponent = () => { setCurrentStep(currentStep + 1); }, [currentStep]); + const canGoToPreviousStep = useMemo(() => currentStep > 0, [currentStep]); + + const canGoToNextStep = useMemo( + () => currentStep < Object.keys(WorkspaceCreationSteps).length / 2 - 1, + [currentStep], + ); + + const canSubmit = useMemo( + () => !isSubmitting && !canGoToNextStep, + [canGoToNextStep, isSubmitting], + ); + + const handleCreate = useCallback(() => { + // TODO: properly validate data before submitting + if (!data.kind || !data.image || !data.podConfig) { + return; + } + + const workspaceCreate: WorkspaceCreate = { + name: data.properties.workspaceName, + kind: data.kind.name, + deferUpdates: data.properties.deferUpdates, + paused: false, + podTemplate: { + podMetadata: { + labels: {}, + annotations: {}, + }, + options: { + imageConfig: data.image.id, + podConfig: data.podConfig.id, + }, + volumes: { + home: data.properties.homeDirectory, + data: data.properties.volumes, + secrets: data.properties.secrets, + }, + }, + }; + + setIsSubmitting(true); + + api + .createWorkspace({}, selectedNamespace, { data: workspaceCreate }) + .then((newWorkspace) => { + // TODO: alert user about success + console.info('New workspace created:', JSON.stringify(newWorkspace)); + navigate('/workspaces'); + }) + .catch((err) => { + // TODO: alert user about error + console.error('Error creating workspace:', err); + }) + .finally(() => { + setIsSubmitting(false); + }); + }, [api, data, navigate, selectedNamespace]); + const cancel = useCallback(() => { navigate('/workspaces'); }, [navigate]); - const onSelectWorkspaceKind = useCallback((newWorkspaceKind: WorkspaceKind | undefined) => { - setSelectedKind(newWorkspaceKind); - setSelectedImage(undefined); - setSelectedPodConfig(undefined); - setSelectedProperties(undefined); - }, []); - return ( <> @@ -157,26 +219,33 @@ const WorkspaceCreation: React.FunctionComponent = () => { {currentStep === WorkspaceCreationSteps.KindSelection && ( { + resetData(); + setData('kind', kind); + }} /> )} {currentStep === WorkspaceCreationSteps.ImageSelection && ( setData('image', image)} + images={data.kind?.podTemplate.options.imageConfig.values ?? []} /> )} {currentStep === WorkspaceCreationSteps.PodConfigSelection && ( setData('podConfig', podConfig)} + podConfigs={data.kind?.podTemplate.options.podConfig.values ?? []} /> )} {currentStep === WorkspaceCreationSteps.Properties && ( - + setData('properties', properties)} + selectedImage={data.image} + /> )} @@ -186,7 +255,7 @@ const WorkspaceCreation: React.FunctionComponent = () => { variant="primary" ouiaId="Primary" onClick={previousStep} - isDisabled={currentStep === 0} + isDisabled={!canGoToPreviousStep} > Previous @@ -196,11 +265,21 @@ const WorkspaceCreation: React.FunctionComponent = () => { variant="primary" ouiaId="Primary" onClick={nextStep} - isDisabled={currentStep === Object.keys(WorkspaceCreationSteps).length / 2 - 1} + isDisabled={!canGoToNextStep} > Next + + + diff --git a/workspaces/frontend/src/app/pages/Workspaces/workspaceActions/WorkspaceStopActionModal.tsx b/workspaces/frontend/src/app/pages/Workspaces/workspaceActions/WorkspaceStopActionModal.tsx index 992044f61..981b3fbbf 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/workspaceActions/WorkspaceStopActionModal.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/workspaceActions/WorkspaceStopActionModal.tsx @@ -8,7 +8,7 @@ import { TabTitleText, Content, } from '@patternfly/react-core'; -import { Workspace } from '~/shared/types'; +import { Workspace } from '~/shared/api/backendApiTypes'; import { WorkspaceRedirectInformationView } from '~/app/pages/Workspaces/workspaceActions/WorkspaceRedirectInformationView'; interface StopActionAlertProps { @@ -22,7 +22,7 @@ export const WorkspaceStopActionModal: React.FC = ({ isOpen, workspace, }) => { - const workspacePendingUpdate = workspace?.status.pendingRestart; + const workspacePendingUpdate = workspace?.pendingRestart; const handleClick = (isUpdate = false) => { if (isUpdate) { console.log(`Update ${workspace?.name}`); @@ -46,7 +46,7 @@ export const WorkspaceStopActionModal: React.FC = ({ There are pending redirect updates for that workspace. Are you sure you want to proceed? - + ) : ( Are you sure you want to stop the workspace? diff --git a/workspaces/frontend/src/app/types.ts b/workspaces/frontend/src/app/types.ts index d3c081720..83267440f 100644 --- a/workspaces/frontend/src/app/types.ts +++ b/workspaces/frontend/src/app/types.ts @@ -1,90 +1,43 @@ -import { APIOptions } from '~/shared/api/types'; -import { Workspace, WorkspaceKind, WorkspacePodTemplateMutate } from '~/shared/types'; - -export type ResponseBody = { - data: T; - metadata?: Record; -}; - -export enum ResponseMetadataType { - INT = 'MetadataIntValue', - DOUBLE = 'MetadataDoubleValue', - STRING = 'MetadataStringValue', - STRUCT = 'MetadataStructValue', - PROTO = 'MetadataProtoValue', - BOOL = 'MetadataBoolValue', +import { + WorkspaceImageConfigValue, + WorkspaceKind, + WorkspacePodConfigValue, + WorkspacePodVolumeMount, + WorkspacePodSecretMount, +} from '~/shared/api/backendApiTypes'; + +export interface WorkspacesColumnNames { + name: string; + kind: string; + image: string; + podConfig: string; + state: string; + homeVol: string; + cpu: string; + ram: string; + lastActivity: string; + redirectStatus: string; } -export type ResponseCustomPropertyInt = { - metadataType: ResponseMetadataType.INT; - int_value: string; // int64-formatted string -}; - -export type ResponseCustomPropertyDouble = { - metadataType: ResponseMetadataType.DOUBLE; - double_value: number; -}; - -export type ResponseCustomPropertyString = { - metadataType: ResponseMetadataType.STRING; - string_value: string; -}; - -export type ResponseCustomPropertyStruct = { - metadataType: ResponseMetadataType.STRUCT; - struct_value: string; // Base64 encoded bytes for struct value -}; - -export type ResponseCustomPropertyProto = { - metadataType: ResponseMetadataType.PROTO; - type: string; // url describing proto value - proto_value: string; // Base64 encoded bytes for proto value -}; - -export type ResponseCustomPropertyBool = { - metadataType: ResponseMetadataType.BOOL; - bool_value: boolean; -}; - -export type ResponseCustomProperty = - | ResponseCustomPropertyInt - | ResponseCustomPropertyDouble - | ResponseCustomPropertyString - | ResponseCustomPropertyStruct - | ResponseCustomPropertyProto - | ResponseCustomPropertyBool; - -export type ResponseCustomProperties = Record; -export type ResponseStringCustomProperties = Record; - -export type Namespace = { +export interface WorkspaceKindsColumnNames { + icon: string; name: string; -}; - -export type NamespacesList = Namespace[]; - -export type GetNamespaces = (opts: APIOptions) => Promise; - -export type GetWorkspaceKinds = (opts: APIOptions) => Promise; - -export type CreateWorkspace = ( - opts: APIOptions, - data: CreateWorkspaceData, - namespace: string, -) => Promise; + description: string; + deprecated: string; + numberOfWorkspaces: string; +} -export type NotebookAPIs = { - getNamespaces: GetNamespaces; - getWorkspaceKinds: GetWorkspaceKinds; - createWorkspace: CreateWorkspace; -}; +export interface WorkspaceCreateProperties { + workspaceName: string; + deferUpdates: boolean; + homeDirectory: string; + volumes: WorkspacePodVolumeMount[]; + secrets: WorkspacePodSecretMount[]; +} -export type CreateWorkspaceData = { - data: { - name: string; - kind: string; - paused: boolean; - deferUpdates: boolean; - podTemplate: WorkspacePodTemplateMutate; - }; -}; +export interface WorkspaceCreateFormData { + kind: WorkspaceKind | undefined; + image: WorkspaceImageConfigValue | undefined; + podConfig: WorkspacePodConfigValue | undefined; + properties: WorkspaceCreateProperties; +} diff --git a/workspaces/frontend/src/shared/api/__tests__/notebookService.spec.ts b/workspaces/frontend/src/shared/api/__tests__/notebookService.spec.ts index 50f12a9e9..d84aa9a98 100644 --- a/workspaces/frontend/src/shared/api/__tests__/notebookService.spec.ts +++ b/workspaces/frontend/src/shared/api/__tests__/notebookService.spec.ts @@ -1,7 +1,7 @@ +import { BFF_API_VERSION } from '~/app/const'; import { restGET } from '~/shared/api/apiUtils'; import { handleRestFailures } from '~/shared/api/errorUtils'; -import { getNamespaces } from '~/shared/api/notebookService'; -import { BFF_API_VERSION } from '~/app/const'; +import { listNamespaces } from '~/shared/api/notebookService'; const mockRestPromise = Promise.resolve({ data: {} }); const mockRestResponse = {}; @@ -11,6 +11,7 @@ jest.mock('~/shared/api/apiUtils', () => ({ restGET: jest.fn(() => mockRestPromise), restPATCH: jest.fn(() => mockRestPromise), isNotebookResponse: jest.fn(() => true), + extractNotebookResponse: jest.fn(() => mockRestResponse), })); jest.mock('~/shared/api/errorUtils', () => ({ @@ -23,7 +24,7 @@ const APIOptionsMock = {}; describe('getNamespaces', () => { it('should call restGET and handleRestFailures to fetch namespaces', async () => { - const response = await getNamespaces(`/api/${BFF_API_VERSION}/namespaces`)(APIOptionsMock); + const response = await listNamespaces(`/api/${BFF_API_VERSION}/namespaces`)(APIOptionsMock); expect(response).toEqual(mockRestResponse); expect(restGETMock).toHaveBeenCalledTimes(1); expect(restGETMock).toHaveBeenCalledWith( diff --git a/workspaces/frontend/src/shared/api/apiUtils.ts b/workspaces/frontend/src/shared/api/apiUtils.ts index c51058269..3d90488b5 100644 --- a/workspaces/frontend/src/shared/api/apiUtils.ts +++ b/workspaces/frontend/src/shared/api/apiUtils.ts @@ -1,7 +1,6 @@ -import { APIOptions } from '~/shared/api/types'; +import { APIOptions, ResponseBody } from '~/shared/api/types'; import { EitherOrNone } from '~/shared/typeHelpers'; -import { ResponseBody } from '~/app/types'; -import { DEV_MODE, AUTH_HEADER } from '~/shared/utilities/const'; +import { AUTH_HEADER, DEV_MODE } from '~/shared/utilities/const'; export const mergeRequestInit = ( opts: APIOptions = {}, @@ -180,3 +179,10 @@ export const isNotebookResponse = (response: unknown): response is ResponseBo } return false; }; + +export function extractNotebookResponse(response: unknown): T { + if (isNotebookResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); +} diff --git a/workspaces/frontend/src/shared/api/backendApiTypes.ts b/workspaces/frontend/src/shared/api/backendApiTypes.ts new file mode 100644 index 000000000..3f660d91e --- /dev/null +++ b/workspaces/frontend/src/shared/api/backendApiTypes.ts @@ -0,0 +1,270 @@ +export enum WorkspaceServiceStatus { + ServiceStatusHealthy = 'Healthy', + ServiceStatusUnhealthy = 'Unhealthy', +} + +export interface WorkspaceSystemInfo { + version: string; +} + +export interface HealthCheckResponse { + status: WorkspaceServiceStatus; + systemInfo: WorkspaceSystemInfo; +} + +export interface Namespace { + name: string; +} + +export interface WorkspaceImageRef { + url: string; +} + +export interface WorkspacePodConfigValue { + id: string; + displayName: string; + description: string; + labels: WorkspaceOptionLabel[]; + hidden: boolean; + redirect?: WorkspaceOptionRedirect; +} + +export interface WorkspaceKindPodConfig { + default: string; + values: WorkspacePodConfigValue[]; +} + +export interface WorkspaceKindPodMetadata { + labels: Record; + annotations: Record; +} + +export interface WorkspacePodVolumeMounts { + home: string; +} + +export interface WorkspaceOptionLabel { + key: string; + value: string; +} + +export enum WorkspaceRedirectMessageLevel { + RedirectMessageLevelInfo = 'Info', + RedirectMessageLevelWarning = 'Warning', + RedirectMessageLevelDanger = 'Danger', +} + +export interface WorkspaceRedirectMessage { + text: string; + level: WorkspaceRedirectMessageLevel; +} + +export interface WorkspaceOptionRedirect { + to: string; + message?: WorkspaceRedirectMessage; +} + +export interface WorkspaceImageConfigValue { + id: string; + displayName: string; + description: string; + labels: WorkspaceOptionLabel[]; + hidden: boolean; + redirect?: WorkspaceOptionRedirect; +} + +export interface WorkspaceKindImageConfig { + default: string; + values: WorkspaceImageConfigValue[]; +} + +export interface WorkspaceKindPodTemplateOptions { + imageConfig: WorkspaceKindImageConfig; + podConfig: WorkspaceKindPodConfig; +} + +export interface WorkspaceKindPodTemplate { + podMetadata: WorkspaceKindPodMetadata; + volumeMounts: WorkspacePodVolumeMounts; + options: WorkspaceKindPodTemplateOptions; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface WorkspaceKindCreate {} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface WorkspaceKindUpdate {} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface WorkspaceKindPatch {} + +export interface WorkspaceKind { + name: string; + displayName: string; + description: string; + deprecated: boolean; + deprecationMessage: string; + hidden: boolean; + icon: WorkspaceImageRef; + logo: WorkspaceImageRef; + podTemplate: WorkspaceKindPodTemplate; +} + +export enum WorkspaceState { + WorkspaceStateRunning = 'Running', + WorkspaceStateTerminating = 'Terminating', + WorkspaceStatePaused = 'Paused', + WorkspaceStatePending = 'Pending', + WorkspaceStateError = 'Error', + WorkspaceStateUnknown = 'Unknown', +} + +export interface WorkspaceKindInfo { + name: string; + missing: boolean; + icon: WorkspaceImageRef; + logo: WorkspaceImageRef; +} + +export interface WorkspacePodMetadata { + labels: Record; + annotations: Record; +} + +export interface WorkspacePodVolumeInfo { + pvcName: string; + mountPath: string; + readOnly: boolean; +} + +export interface WorkspaceOptionInfo { + id: string; + displayName: string; + description: string; + labels: WorkspaceOptionLabel[]; +} + +export interface WorkspaceRedirectStep { + sourceId: string; + targetId: string; + message?: WorkspaceRedirectMessage; +} + +export interface WorkspaceImageConfig { + current: WorkspaceOptionInfo; + desired?: WorkspaceOptionInfo; + redirectChain?: WorkspaceRedirectStep[]; +} + +export interface WorkspacePodConfig { + current: WorkspaceOptionInfo; + desired?: WorkspaceOptionInfo; + redirectChain?: WorkspaceRedirectStep[]; +} + +export interface WorkspacePodTemplateOptions { + imageConfig: WorkspaceImageConfig; + podConfig: WorkspacePodConfig; +} + +export interface WorkspacePodVolumes { + home?: WorkspacePodVolumeInfo; + data: WorkspacePodVolumeInfo[]; +} + +export interface WorkspacePodTemplate { + podMetadata: WorkspacePodMetadata; + volumes: WorkspacePodVolumes; + options: WorkspacePodTemplateOptions; +} + +export enum WorkspaceProbeResult { + ProbeResultSuccess = 'Success', + ProbeResultFailure = 'Failure', + ProbeResultTimeout = 'Timeout', +} + +export interface WorkspaceLastProbeInfo { + startTimeMs: number; + endTimeMs: number; + result: WorkspaceProbeResult; + message: string; +} + +export interface WorkspaceActivity { + lastActivity: number; + lastUpdate: number; + lastProbe?: WorkspaceLastProbeInfo; +} + +export interface WorkspaceHttpService { + displayName: string; + httpPath: string; +} + +export interface WorkspaceService { + httpService?: WorkspaceHttpService; +} + +export interface WorkspacePodMetadataMutate { + labels: Record; + annotations: Record; +} + +export interface WorkspacePodVolumeMount { + pvcName: string; + mountPath: string; + readOnly?: boolean; +} + +export interface WorkspacePodSecretMount { + secretName: string; + mountPath: string; + defaultMode?: number; +} + +export interface WorkspacePodVolumesMutate { + home?: string; + data?: WorkspacePodVolumeMount[]; + secrets?: WorkspacePodSecretMount[]; +} + +export interface WorkspacePodTemplateOptionsMutate { + imageConfig: string; + podConfig: string; +} + +export interface WorkspacePodTemplateMutate { + podMetadata: WorkspacePodMetadataMutate; + volumes: WorkspacePodVolumesMutate; + options: WorkspacePodTemplateOptionsMutate; +} + +export interface Workspace { + name: string; + namespace: string; + workspaceKind: WorkspaceKindInfo; + deferUpdates: boolean; + paused: boolean; + pausedTime: number; + pendingRestart: boolean; + state: WorkspaceState; + stateMessage: string; + podTemplate: WorkspacePodTemplate; + activity: WorkspaceActivity; + services: WorkspaceService[]; +} + +export interface WorkspaceCreate { + name: string; + kind: string; + paused: boolean; + deferUpdates: boolean; + podTemplate: WorkspacePodTemplateMutate; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface WorkspaceUpdate {} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface WorkspacePatch {} diff --git a/workspaces/frontend/src/shared/api/callTypes.ts b/workspaces/frontend/src/shared/api/callTypes.ts new file mode 100644 index 000000000..924a6c425 --- /dev/null +++ b/workspaces/frontend/src/shared/api/callTypes.ts @@ -0,0 +1,45 @@ +import { + CreateWorkspace, + CreateWorkspaceKind, + DeleteWorkspace, + DeleteWorkspaceKind, + GetHealthCheck, + GetWorkspace, + GetWorkspaceKind, + ListAllWorkspaces, + ListNamespaces, + ListWorkspaceKinds, + ListWorkspaces, + PatchWorkspace, + PatchWorkspaceKind, + UpdateWorkspace, + UpdateWorkspaceKind, +} from '~/shared/api/notebookApi'; +import { APIOptions } from '~/shared/api/types'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type KubeflowSpecificAPICall = (opts: APIOptions, ...args: any[]) => Promise; +type KubeflowAPICall = (hostPath: string) => ActualCall; + +// Health +export type GetHealthCheckAPI = KubeflowAPICall; + +// Namespace +export type ListNamespacesAPI = KubeflowAPICall; + +// Workspace +export type ListAllWorkspacesAPI = KubeflowAPICall; +export type ListWorkspacesAPI = KubeflowAPICall; +export type CreateWorkspaceAPI = KubeflowAPICall; +export type GetWorkspaceAPI = KubeflowAPICall; +export type UpdateWorkspaceAPI = KubeflowAPICall; +export type PatchWorkspaceAPI = KubeflowAPICall; +export type DeleteWorkspaceAPI = KubeflowAPICall; + +// WorkspaceKind +export type ListWorkspaceKindsAPI = KubeflowAPICall; +export type CreateWorkspaceKindAPI = KubeflowAPICall; +export type GetWorkspaceKindAPI = KubeflowAPICall; +export type UpdateWorkspaceKindAPI = KubeflowAPICall; +export type PatchWorkspaceKindAPI = KubeflowAPICall; +export type DeleteWorkspaceKindAPI = KubeflowAPICall; diff --git a/workspaces/frontend/src/shared/api/notebookApi.ts b/workspaces/frontend/src/shared/api/notebookApi.ts new file mode 100644 index 000000000..8b0341c07 --- /dev/null +++ b/workspaces/frontend/src/shared/api/notebookApi.ts @@ -0,0 +1,91 @@ +import { + HealthCheckResponse, + Namespace, + Workspace, + WorkspaceCreate, + WorkspaceKind, + WorkspaceKindCreate, + WorkspaceKindPatch, + WorkspaceKindUpdate, + WorkspacePatch, + WorkspaceUpdate, +} from '~/shared/api/backendApiTypes'; +import { APIOptions, RequestData } from '~/shared/api/types'; + +// Health +export type GetHealthCheck = (opts: APIOptions) => Promise; + +// Namespace +export type ListNamespaces = (opts: APIOptions) => Promise; + +// Workspace +export type ListAllWorkspaces = (opts: APIOptions) => Promise; +export type ListWorkspaces = (opts: APIOptions, namespace: string) => Promise; +export type GetWorkspace = ( + opts: APIOptions, + namespace: string, + workspace: string, +) => Promise; +export type CreateWorkspace = ( + opts: APIOptions, + namespace: string, + data: RequestData, +) => Promise; +export type UpdateWorkspace = ( + opts: APIOptions, + namespace: string, + workspace: string, + data: RequestData, +) => Promise; +export type PatchWorkspace = ( + opts: APIOptions, + namespace: string, + workspace: string, + data: RequestData, +) => Promise; +export type DeleteWorkspace = ( + opts: APIOptions, + namespace: string, + workspace: string, +) => Promise; + +// WorkspaceKind +export type ListWorkspaceKinds = (opts: APIOptions) => Promise; +export type GetWorkspaceKind = (opts: APIOptions, kind: string) => Promise; +export type CreateWorkspaceKind = ( + opts: APIOptions, + data: RequestData, +) => Promise; +export type UpdateWorkspaceKind = ( + opts: APIOptions, + kind: string, + data: RequestData, +) => Promise; +export type PatchWorkspaceKind = ( + opts: APIOptions, + kind: string, + data: RequestData, +) => Promise; +export type DeleteWorkspaceKind = (opts: APIOptions, kind: string) => Promise; + +export type NotebookAPIs = { + // Health + getHealthCheck: GetHealthCheck; + // Namespace + listNamespaces: ListNamespaces; + // Workspace + listAllWorkspaces: ListAllWorkspaces; + listWorkspaces: ListWorkspaces; + getWorkspace: GetWorkspace; + createWorkspace: CreateWorkspace; + updateWorkspace: UpdateWorkspace; + patchWorkspace: PatchWorkspace; + deleteWorkspace: DeleteWorkspace; + // WorkspaceKind + listWorkspaceKinds: ListWorkspaceKinds; + getWorkspaceKind: GetWorkspaceKind; + createWorkspaceKind: CreateWorkspaceKind; + updateWorkspaceKind: UpdateWorkspaceKind; + patchWorkspaceKind: PatchWorkspaceKind; + deleteWorkspaceKind: DeleteWorkspaceKind; +}; diff --git a/workspaces/frontend/src/shared/api/notebookService.ts b/workspaces/frontend/src/shared/api/notebookService.ts index 81017369e..a5cdf859e 100644 --- a/workspaces/frontend/src/shared/api/notebookService.ts +++ b/workspaces/frontend/src/shared/api/notebookService.ts @@ -1,37 +1,108 @@ -import { NamespacesList, CreateWorkspaceData } from '~/app/types'; -import { isNotebookResponse, restGET, restCREATE } from '~/shared/api/apiUtils'; -import { APIOptions } from '~/shared/api/types'; +import { + extractNotebookResponse, + restCREATE, + restDELETE, + restGET, + restPATCH, + restUPDATE, +} from '~/shared/api/apiUtils'; import { handleRestFailures } from '~/shared/api/errorUtils'; -import { Workspace, WorkspaceKind } from '~/shared/types'; - -export const getNamespaces = - (hostPath: string) => - (opts: APIOptions): Promise => - handleRestFailures(restGET(hostPath, `/namespaces`, {}, opts)).then((response) => { - if (isNotebookResponse(response)) { - return response.data; - } - throw new Error('Invalid response format'); - }); - -export const getWorkspaceKinds = - (hostPath: string) => - (opts: APIOptions): Promise => - handleRestFailures(restGET(hostPath, `/workspacekinds`, {}, opts)).then((response) => { - if (isNotebookResponse(response)) { - return response.data; - } - throw new Error('Invalid response format'); - }); - -export const createWorkspace = - (hostPath: string) => - (opts: APIOptions, data: CreateWorkspaceData, namespace = ''): Promise => - handleRestFailures(restCREATE(hostPath, `/workspaces/${namespace}`, data, opts)).then( - (response) => { - if (isNotebookResponse(response)) { - return response.data; - } - throw new Error('Invalid response format'); - }, - ); +import { + HealthCheckResponse, + Namespace, + Workspace, + WorkspaceKind, +} from '~/shared/api/backendApiTypes'; +import { + CreateWorkspaceAPI, + CreateWorkspaceKindAPI, + DeleteWorkspaceAPI, + DeleteWorkspaceKindAPI, + GetHealthCheckAPI, + GetWorkspaceAPI, + GetWorkspaceKindAPI, + ListAllWorkspacesAPI, + ListNamespacesAPI, + ListWorkspaceKindsAPI, + ListWorkspacesAPI, + PatchWorkspaceAPI, + PatchWorkspaceKindAPI, + UpdateWorkspaceAPI, + UpdateWorkspaceKindAPI, +} from './callTypes'; + +export const getHealthCheck: GetHealthCheckAPI = (hostPath) => (opts) => + handleRestFailures(restGET(hostPath, `/healthcheck`, {}, opts)).then((response) => + extractNotebookResponse(response), + ); + +export const listNamespaces: ListNamespacesAPI = (hostPath) => (opts) => + handleRestFailures(restGET(hostPath, `/namespaces`, {}, opts)).then((response) => + extractNotebookResponse(response), + ); + +export const listAllWorkspaces: ListAllWorkspacesAPI = (hostPath) => (opts) => + handleRestFailures(restGET(hostPath, `/workspaces`, {}, opts)).then((response) => + extractNotebookResponse(response), + ); + +export const listWorkspaces: ListWorkspacesAPI = (hostPath) => (opts, namespace) => + handleRestFailures(restGET(hostPath, `/workspaces/${namespace}`, {}, opts)).then((response) => + extractNotebookResponse(response), + ); + +export const getWorkspace: GetWorkspaceAPI = (hostPath) => (opts, namespace, workspace) => + handleRestFailures(restGET(hostPath, `/workspaces/${namespace}/${workspace}`, {}, opts)).then( + (response) => extractNotebookResponse(response), + ); + +export const createWorkspace: CreateWorkspaceAPI = (hostPath) => (opts, namespace, data) => + handleRestFailures(restCREATE(hostPath, `/workspaces/${namespace}`, data, {}, opts)).then( + (response) => extractNotebookResponse(response), + ); + +export const updateWorkspace: UpdateWorkspaceAPI = + (hostPath) => (opts, namespace, workspace, data) => + handleRestFailures( + restUPDATE(hostPath, `/workspaces/${namespace}/${workspace}`, data, {}, opts), + ).then((response) => extractNotebookResponse(response)); + +export const patchWorkspace: PatchWorkspaceAPI = (hostPath) => (opts, namespace, workspace, data) => + handleRestFailures(restPATCH(hostPath, `/workspaces/${namespace}/${workspace}`, data, opts)).then( + (response) => extractNotebookResponse(response), + ); + +export const deleteWorkspace: DeleteWorkspaceAPI = (hostPath) => (opts, namespace, workspace) => + handleRestFailures( + restDELETE(hostPath, `/workspaces/${namespace}/${workspace}`, {}, {}, opts), + ).then((response) => extractNotebookResponse(response)); + +export const listWorkspaceKinds: ListWorkspaceKindsAPI = (hostPath) => (opts) => + handleRestFailures(restGET(hostPath, `/workspacekinds`, {}, opts)).then((response) => + extractNotebookResponse(response), + ); + +export const getWorkspaceKind: GetWorkspaceKindAPI = (hostPath) => (opts, kind) => + handleRestFailures(restGET(hostPath, `/workspacekinds/${kind}`, {}, opts)).then((response) => + extractNotebookResponse(response), + ); + +export const createWorkspaceKind: CreateWorkspaceKindAPI = (hostPath) => (opts, data) => + handleRestFailures(restCREATE(hostPath, `/workspacekinds`, data, {}, opts)).then((response) => + extractNotebookResponse(response), + ); + +export const updateWorkspaceKind: UpdateWorkspaceKindAPI = (hostPath) => (opts, kind, data) => + handleRestFailures(restUPDATE(hostPath, `/workspacekinds/${kind}`, data, {}, opts)).then( + (response) => extractNotebookResponse(response), + ); + +export const patchWorkspaceKind: PatchWorkspaceKindAPI = (hostPath) => (opts, kind, data) => + handleRestFailures(restPATCH(hostPath, `/workspacekinds/${kind}`, data, opts)).then((response) => + extractNotebookResponse(response), + ); + +export const deleteWorkspaceKind: DeleteWorkspaceKindAPI = (hostPath) => (opts, kind) => + handleRestFailures(restDELETE(hostPath, `/workspacekinds/${kind}`, {}, {}, opts)).then( + (response) => extractNotebookResponse(response), + ); diff --git a/workspaces/frontend/src/shared/api/types.ts b/workspaces/frontend/src/shared/api/types.ts index bb0055bc8..6f1ac507d 100644 --- a/workspaces/frontend/src/shared/api/types.ts +++ b/workspaces/frontend/src/shared/api/types.ts @@ -18,3 +18,12 @@ export type APIState = { /** The available API functions */ api: T; }; + +export type ResponseBody = { + data: T; + metadata?: Record; +}; + +export type RequestData = { + data: T; +}; diff --git a/workspaces/frontend/src/shared/mock/mockBuilder.ts b/workspaces/frontend/src/shared/mock/mockBuilder.ts new file mode 100644 index 000000000..9187df278 --- /dev/null +++ b/workspaces/frontend/src/shared/mock/mockBuilder.ts @@ -0,0 +1,270 @@ +import { + HealthCheckResponse, + Namespace, + Workspace, + WorkspaceKind, + WorkspaceKindInfo, + WorkspaceRedirectMessageLevel, + WorkspaceServiceStatus, + WorkspaceState, +} from '~/shared/api/backendApiTypes'; + +export const buildMockHealthCheckResponse = ( + healthCheckResponse?: Partial, +): HealthCheckResponse => ({ + status: WorkspaceServiceStatus.ServiceStatusHealthy, + systemInfo: { version: '1.0.0' }, + ...healthCheckResponse, +}); + +export const buildMockNamespace = (namespace?: Partial): Namespace => ({ + name: 'default', + ...namespace, +}); + +export const buildMockWorkspaceKindInfo = ( + workspaceKindInfo?: Partial, +): WorkspaceKindInfo => ({ + name: 'jupyterlab', + missing: false, + icon: { + url: 'https://jupyter.org/assets/favicons/apple-touch-icon-152x152.png', + }, + logo: { + url: 'https://upload.wikimedia.org/wikipedia/commons/3/38/Jupyter_logo.svg', + }, + ...workspaceKindInfo, +}); + +export const buildMockWorkspace = (workspace?: Partial): Workspace => ({ + name: 'My First Jupyter Notebook', + namespace: 'default', + workspaceKind: buildMockWorkspaceKindInfo(), + paused: true, + deferUpdates: true, + pausedTime: 1739673500, + state: WorkspaceState.WorkspaceStateRunning, + stateMessage: 'Workspace is running', + podTemplate: { + podMetadata: { + labels: { labelKey1: 'labelValue1', labelKey2: 'labelValue2' }, + annotations: { annotationKey1: 'annotationValue1', annotationKey2: 'annotationValue2' }, + }, + volumes: { + home: { + pvcName: 'Volume-Home', + mountPath: '/home', + readOnly: false, + }, + data: [ + { + pvcName: 'Volume-Data1', + mountPath: '/data', + readOnly: true, + }, + { + pvcName: 'Volume-Data2', + mountPath: '/data', + readOnly: false, + }, + ], + }, + options: { + imageConfig: { + current: { + id: 'jupyterlab_scipy_190', + displayName: 'jupyter-scipy:v1.9.0', + description: 'JupyterLab, with SciPy Packages', + labels: [ + { + key: 'pythonVersion', + value: '3.11', + }, + ], + }, + }, + podConfig: { + current: { + id: 'tiny_cpu', + displayName: 'Tiny CPU', + description: 'Pod with 0.1 CPU, 128 Mb RAM', + labels: [ + { + key: 'cpu', + value: '100m', + }, + { + key: 'memory', + value: '128Mi', + }, + ], + }, + }, + }, + }, + activity: { + lastActivity: 1746551485113, + lastUpdate: 1746551485113, + }, + pendingRestart: false, + services: [ + { + httpService: { + displayName: 'JupyterLab', + httpPath: 'https://jupyterlab.example.com', + }, + }, + { + httpService: { + displayName: 'Spark Master', + httpPath: 'https://spark-master.example.com', + }, + }, + ], + ...workspace, +}); + +export const buildMockWorkspaceKind = (workspaceKind?: Partial): WorkspaceKind => ({ + name: 'jupyterlab', + displayName: 'JupyterLab Notebook', + description: 'A Workspace which runs JupyterLab in a Pod', + deprecated: false, + deprecationMessage: + 'This WorkspaceKind will be removed on 20XX-XX-XX, please use another WorkspaceKind.', + hidden: false, + icon: { + url: 'https://jupyter.org/assets/favicons/apple-touch-icon-152x152.png', + }, + logo: { + url: 'https://upload.wikimedia.org/wikipedia/commons/3/38/Jupyter_logo.svg', + }, + podTemplate: { + podMetadata: { + labels: { + myWorkspaceKindLabel: 'my-value', + }, + annotations: { + myWorkspaceKindAnnotation: 'my-value', + }, + }, + volumeMounts: { + home: '/home/jovyan', + }, + options: { + imageConfig: { + default: 'jupyterlab_scipy_190', + values: [ + { + id: 'jupyterlab_scipy_180', + displayName: 'jupyter-scipy:v1.8.0', + description: 'JupyterLab, with SciPy Packages', + labels: [ + { key: 'pythonVersion', value: '3.11' }, + { key: 'jupyterlabVersion', value: '1.8.0' }, + ], + hidden: true, + redirect: { + to: 'jupyterlab_scipy_190', + message: { + text: 'This update will change...', + level: WorkspaceRedirectMessageLevel.RedirectMessageLevelInfo, + }, + }, + }, + { + id: 'jupyterlab_scipy_190', + displayName: 'jupyter-scipy:v1.9.0', + description: 'JupyterLab, with SciPy Packages', + labels: [ + { key: 'pythonVersion', value: '3.12' }, + { key: 'jupyterlabVersion', value: '1.9.0' }, + ], + hidden: true, + redirect: { + to: 'jupyterlab_scipy_200', + message: { + text: 'This update will change...', + level: WorkspaceRedirectMessageLevel.RedirectMessageLevelWarning, + }, + }, + }, + { + id: 'jupyterlab_scipy_200', + displayName: 'jupyter-scipy:v2.0.0', + description: 'JupyterLab, with SciPy Packages', + labels: [ + { key: 'pythonVersion', value: '3.12' }, + { key: 'jupyterlabVersion', value: '2.0.0' }, + ], + hidden: true, + redirect: { + to: 'jupyterlab_scipy_210', + message: { + text: 'This update will change...', + level: WorkspaceRedirectMessageLevel.RedirectMessageLevelWarning, + }, + }, + }, + { + id: 'jupyterlab_scipy_210', + displayName: 'jupyter-scipy:v2.1.0', + description: 'JupyterLab, with SciPy Packages', + labels: [ + { key: 'pythonVersion', value: '3.13' }, + { key: 'jupyterlabVersion', value: '2.1.0' }, + ], + hidden: true, + redirect: { + to: 'jupyterlab_scipy_220', + message: { + text: 'This update will change...', + level: WorkspaceRedirectMessageLevel.RedirectMessageLevelWarning, + }, + }, + }, + ], + }, + podConfig: { + default: 'tiny_cpu', + values: [ + { + id: 'tiny_cpu', + displayName: 'Tiny CPU', + description: 'Pod with 0.1 CPU, 128 Mb RAM', + hidden: false, + labels: [ + { key: 'cpu', value: '100m' }, + { key: 'memory', value: '128Mi' }, + ], + redirect: { + to: 'small_cpu', + message: { + text: 'This update will change...', + level: WorkspaceRedirectMessageLevel.RedirectMessageLevelDanger, + }, + }, + }, + { + id: 'large_cpu', + displayName: 'Large CPU', + description: 'Pod with 1 CPU, 1 Gb RAM', + hidden: false, + labels: [ + { key: 'cpu', value: '1000m' }, + { key: 'memory', value: '1Gi' }, + { key: 'gpu', value: '1' }, + ], + redirect: { + to: 'large_cpu', + message: { + text: 'This update will change...', + level: WorkspaceRedirectMessageLevel.RedirectMessageLevelDanger, + }, + }, + }, + ], + }, + }, + }, + ...workspaceKind, +}); diff --git a/workspaces/frontend/src/shared/mock/mockNotebookService.ts b/workspaces/frontend/src/shared/mock/mockNotebookService.ts new file mode 100644 index 000000000..6148bbd8f --- /dev/null +++ b/workspaces/frontend/src/shared/mock/mockNotebookService.ts @@ -0,0 +1,62 @@ +import { + CreateWorkspaceAPI, + CreateWorkspaceKindAPI, + DeleteWorkspaceAPI, + DeleteWorkspaceKindAPI, + GetHealthCheckAPI, + GetWorkspaceAPI, + GetWorkspaceKindAPI, + ListAllWorkspacesAPI, + ListNamespacesAPI, + ListWorkspaceKindsAPI, + ListWorkspacesAPI, + PatchWorkspaceAPI, + PatchWorkspaceKindAPI, + UpdateWorkspaceAPI, + UpdateWorkspaceKindAPI, +} from '~/shared/api/callTypes'; +import { + mockAllWorkspaces, + mockedHealthCheckResponse, + mockNamespaces, + mockWorkspace1, + mockWorkspaceKind1, + mockWorkspaceKinds, +} from '~/shared/mock/mockNotebookServiceData'; + +export const mockGetHealthCheck: GetHealthCheckAPI = () => async () => mockedHealthCheckResponse; + +export const mockListNamespaces: ListNamespacesAPI = () => async () => mockNamespaces; + +export const mockListAllWorkspaces: ListAllWorkspacesAPI = () => async () => mockAllWorkspaces; + +export const mockListWorkspaces: ListWorkspacesAPI = () => async (_opts, namespace) => + mockAllWorkspaces.filter((workspace) => workspace.namespace === namespace); + +export const mockGetWorkspace: GetWorkspaceAPI = () => async (_opts, namespace, workspace) => + mockAllWorkspaces.find((w) => w.name === workspace && w.namespace === namespace)!; + +export const mockCreateWorkspace: CreateWorkspaceAPI = () => async () => mockWorkspace1; + +export const mockUpdateWorkspace: UpdateWorkspaceAPI = () => async () => mockWorkspace1; + +export const mockPatchWorkspace: PatchWorkspaceAPI = () => async () => mockWorkspace1; + +export const mockDeleteWorkspace: DeleteWorkspaceAPI = () => async () => { + /* no-op */ +}; + +export const mockListWorkspaceKinds: ListWorkspaceKindsAPI = () => async () => mockWorkspaceKinds; + +export const mockGetWorkspaceKind: GetWorkspaceKindAPI = () => async (_opts, kind) => + mockWorkspaceKinds.find((w) => w.name === kind)!; + +export const mockCreateWorkspaceKind: CreateWorkspaceKindAPI = () => async () => mockWorkspaceKind1; + +export const mockUpdateWorkspaceKind: UpdateWorkspaceKindAPI = () => async () => mockWorkspaceKind1; + +export const mockPatchWorkspaceKind: PatchWorkspaceKindAPI = () => async () => mockWorkspaceKind1; + +export const mockDeleteWorkspaceKind: DeleteWorkspaceKindAPI = () => async () => { + /* no-op */ +}; diff --git a/workspaces/frontend/src/shared/mock/mockNotebookServiceData.ts b/workspaces/frontend/src/shared/mock/mockNotebookServiceData.ts new file mode 100644 index 000000000..093835c71 --- /dev/null +++ b/workspaces/frontend/src/shared/mock/mockNotebookServiceData.ts @@ -0,0 +1,146 @@ +import { + Workspace, + WorkspaceKind, + WorkspaceKindInfo, + WorkspaceState, +} from '~/shared/api/backendApiTypes'; +import { + buildMockHealthCheckResponse, + buildMockNamespace, + buildMockWorkspace, + buildMockWorkspaceKind, + buildMockWorkspaceKindInfo, +} from '~/shared/mock/mockBuilder'; + +// Health +export const mockedHealthCheckResponse = buildMockHealthCheckResponse(); + +// Namespace +export const mockNamespace1 = buildMockNamespace({ name: 'workspace-test-1' }); +export const mockNamespace2 = buildMockNamespace({ name: 'workspace-test-2' }); +export const mockNamespace3 = buildMockNamespace({ name: 'workspace-test-3' }); + +export const mockNamespaces = [mockNamespace1, mockNamespace2, mockNamespace3]; + +// WorkspaceKind +export const mockWorkspaceKind1: WorkspaceKind = buildMockWorkspaceKind({ + name: 'jupyterlab1', + displayName: 'JupyterLab Notebook 1', +}); + +export const mockWorkspaceKind2: WorkspaceKind = buildMockWorkspaceKind({ + name: 'jupyterlab2', + displayName: 'JupyterLab Notebook 2', +}); + +export const mockWorkspaceKind3: WorkspaceKind = buildMockWorkspaceKind({ + name: 'jupyterlab3', + displayName: 'JupyterLab Notebook 3', +}); + +export const mockWorkspaceKinds = [mockWorkspaceKind1, mockWorkspaceKind2, mockWorkspaceKind3]; + +export const mockWorkspaceKindInfo1: WorkspaceKindInfo = buildMockWorkspaceKindInfo({ + name: mockWorkspaceKind1.name, +}); + +export const mockWorkspaceKindInfo2: WorkspaceKindInfo = buildMockWorkspaceKindInfo({ + name: mockWorkspaceKind2.name, +}); + +// Workspace +export const mockWorkspace1: Workspace = buildMockWorkspace({ + workspaceKind: mockWorkspaceKindInfo1, + namespace: mockNamespace1.name, +}); + +export const mockWorkspace2: Workspace = buildMockWorkspace({ + name: 'My Second Jupyter Notebook', + workspaceKind: mockWorkspaceKindInfo1, + namespace: mockNamespace1.name, + state: WorkspaceState.WorkspaceStatePaused, + paused: false, + deferUpdates: false, + podTemplate: { + podMetadata: { + labels: { labelKey1: 'labelValue1', labelKey2: 'labelValue2' }, + annotations: { annotationKey1: 'annotationValue1', annotationKey2: 'annotationValue2' }, + }, + volumes: { + home: { + pvcName: 'Volume-Home', + mountPath: '/home', + readOnly: false, + }, + data: [ + { + pvcName: 'PVC-1', + mountPath: '/data', + readOnly: false, + }, + ], + }, + options: { + imageConfig: { + current: { + id: 'jupyterlab_scipy_190', + displayName: 'jupyter-scipy:v1.9.0', + description: 'JupyterLab, with SciPy Packages', + labels: [ + { + key: 'pythonVersion', + value: '3.11', + }, + ], + }, + }, + podConfig: { + current: { + id: 'large_cpu', + displayName: 'Large CPU', + description: 'Pod with 4 CPU, 16 Gb RAM', + labels: [ + { + key: 'cpu', + value: '4000m', + }, + { + key: 'memory', + value: '16Gi', + }, + ], + }, + }, + }, + }, +}); + +export const mockWorkspace3: Workspace = buildMockWorkspace({ + name: 'My Third Jupyter Notebook', + namespace: mockNamespace1.name, + workspaceKind: mockWorkspaceKindInfo1, + state: WorkspaceState.WorkspaceStateRunning, + pendingRestart: true, +}); + +export const mockWorkspace4 = buildMockWorkspace({ + name: 'My Fourth Jupyter Notebook', + namespace: mockNamespace2.name, + state: WorkspaceState.WorkspaceStateError, + workspaceKind: mockWorkspaceKindInfo2, +}); + +export const mockWorkspace5 = buildMockWorkspace({ + name: 'My Fifth Jupyter Notebook', + namespace: mockNamespace2.name, + state: WorkspaceState.WorkspaceStateTerminating, + workspaceKind: mockWorkspaceKindInfo2, +}); + +export const mockAllWorkspaces = [ + mockWorkspace1, + mockWorkspace2, + mockWorkspace3, + mockWorkspace4, + mockWorkspace5, +]; diff --git a/workspaces/frontend/src/shared/types.ts b/workspaces/frontend/src/shared/types.ts deleted file mode 100644 index f775a2ea8..000000000 --- a/workspaces/frontend/src/shared/types.ts +++ /dev/null @@ -1,205 +0,0 @@ -export interface WorkspaceIcon { - url: string; -} - -export interface WorkspaceLogo { - url: string; -} - -export interface WorkspaceImage { - id: string; - displayName: string; - labels: any; // eslint-disable-line @typescript-eslint/no-explicit-any - hidden: boolean; - redirect?: { - to: string; - message: { - text: string; - level: string; - }; - }; -} - -export interface WorkspaceVolume { - pvcName: string; - mountPath: string; - readOnly: boolean; -} - -export interface WorkspaceVolumes { - home: string; - data: WorkspaceVolume[]; - secrets: WorkspaceSecret[]; -} - -export interface WorkspaceSecret { - defaultMode: number; - secretName: string; - mountPath: string; -} - -export interface WorkspaceProperties { - workspaceName: string; - deferUpdates: boolean; - homeDirectory: string; - volumes: boolean; - isVolumesExpanded: boolean; - redirect?: { - to: string; - message: { - text: string; - level: string; - }; - }; -} - -export interface WorkspacePodConfig { - id: string; - displayName: string; - description: string; - labels: any; // eslint-disable-line @typescript-eslint/no-explicit-any - redirect?: { - to: string; - message: { - text: string; - level: string; - }; - }; -} - -export interface WorkspaceKind { - name: string; - displayName: string; - description: string; - deprecated: boolean; - deprecationMessage: string; - hidden: boolean; - icon: WorkspaceIcon; - logo: WorkspaceLogo; - podTemplate: { - podMetadata: { - labels: any; // eslint-disable-line @typescript-eslint/no-explicit-any - annotations: any; // eslint-disable-line @typescript-eslint/no-explicit-any - }; - volumeMounts: { - home: string; - }; - options: { - imageConfig: { - default: string; - values: WorkspaceImage[]; - }; - podConfig: { - default: string; - values: WorkspacePodConfig[]; - }; - }; - }; -} - -export enum WorkspaceState { - Running, - Terminating, - Paused, - Pending, - Error, - Unknown, -} - -export interface WorkspaceStatus { - activity: { - lastActivity: number; - lastUpdate: number; - }; - pauseTime: number; - pendingRestart: boolean; - podTemplateOptions: { - imageConfig: { - desired: string; - redirectChain: { - source: string; - target: string; - }[]; - }; - }; - state: WorkspaceState; - stateMessage: string; -} - -export interface WorkspacePodMetadataMutate { - labels: Record; - annotations: Record; -} - -export interface WorkspacePodVolumeMount { - pvcName: string; - mountPath: string; - readOnly?: boolean; -} - -export interface WorkspacePodVolumesMutate { - home?: string; - data: WorkspacePodVolumeMount[]; -} - -export interface WorkspacePodTemplateOptionsMutate { - imageConfig: string; - podConfig: string; -} - -export interface WorkspacePodTemplateMutate { - podMetadata: WorkspacePodMetadataMutate; - volumes: WorkspacePodVolumesMutate; - options: WorkspacePodTemplateOptionsMutate; -} - -export interface Workspace { - name: string; - namespace: string; - paused: boolean; - deferUpdates: boolean; - kind: string; - cpu: number; - ram: number; - podTemplate: { - podMetadata: { - labels: string[]; - annotations: string[]; - }; - volumes: WorkspaceVolumes; - endpoints: { - displayName: string; - port: string; - }[]; - }; - options: { - imageConfig: string; - podConfig: string; - }; - status: WorkspaceStatus; - redirectStatus: { - level: 'Info' | 'Warning' | 'Danger'; - text: string; - }; -} - -export type WorkspacesColumnNames = { - name: string; - kind: string; - image: string; - podConfig: string; - state: string; - homeVol: string; - cpu: string; - ram: string; - lastActivity: string; - redirectStatus: string; -}; - -export type WorkspaceKindsColumnNames = { - icon: string; - name: string; - description: string; - deprecated: string; - numberOfWorkspaces: string; -}; diff --git a/workspaces/frontend/src/shared/utilities/WorkspaceUtils.ts b/workspaces/frontend/src/shared/utilities/WorkspaceUtils.ts index f89fa221a..52da1fcc8 100644 --- a/workspaces/frontend/src/shared/utilities/WorkspaceUtils.ts +++ b/workspaces/frontend/src/shared/utilities/WorkspaceUtils.ts @@ -1,3 +1,5 @@ +import { Workspace } from '~/shared/api/backendApiTypes'; + export const formatRam = (valueInKb: number): string => { const units = ['KB', 'MB', 'GB', 'TB']; let index = 0; @@ -14,3 +16,11 @@ export const formatRam = (valueInKb: number): string => { // Helper function to format UNIX timestamps export const formatTimestamp = (timestamp: number): string => timestamp && timestamp > 0 ? new Date(timestamp * 1000).toLocaleString() : '-'; + +export const extractCpuValue = (workspace: Workspace): string => + workspace.podTemplate.options.podConfig.current.labels.find((label) => label.key === 'cpu') + ?.value || 'N/A'; + +export const extractMemoryValue = (workspace: Workspace): string => + workspace.podTemplate.options.podConfig.current.labels.find((label) => label.key === 'memory') + ?.value || 'N/A'; diff --git a/workspaces/frontend/tsconfig.json b/workspaces/frontend/tsconfig.json index b7e5ca628..1a9726b8f 100644 --- a/workspaces/frontend/tsconfig.json +++ b/workspaces/frontend/tsconfig.json @@ -5,10 +5,7 @@ "outDir": "dist", "module": "esnext", "target": "ES6", - "lib": [ - "es6", - "dom" - ], + "lib": ["es6", "dom"], "sourceMap": true, "jsx": "react", "moduleResolution": "node", @@ -22,22 +19,11 @@ "allowSyntheticDefaultImports": true, "strict": true, "paths": { - "~/*": [ - "./*" - ] + "~/*": ["./*"] }, "importHelpers": true, "skipLibCheck": true }, - "include": [ - "**/*.ts", - "**/*.tsx", - "**/*.jsx", - "**/*.js" - ], - "exclude": [ - "node_modules", - "src/__tests__/cypress" - ] - -} \ No newline at end of file + "include": ["**/*.ts", "**/*.tsx", "**/*.jsx", "**/*.js"], + "exclude": ["node_modules", "src/__tests__/cypress"] +}