From 1869d2d900f74094df7d175aebde29dd75ef71a7 Mon Sep 17 00:00:00 2001 From: "Liav Weiss (EXT-Nokia)" Date: Thu, 9 Jan 2025 14:16:15 +0200 Subject: [PATCH] Merge notebooks-v2 into kind_logo_modification/#148 branch Signed-off-by: Liav Weiss (EXT-Nokia) --- .../cypress/tests/e2e/workspacekind.cy.ts | 61 +++++++++++++++ .../tests/mocked/workspacekinds.mock.ts | 76 +++++++++++++++++++ .../src/app/actions/WorkspacekindsActions.tsx | 20 +++++ .../src/app/context/useNotebookAPIState.tsx | 4 +- .../frontend/src/app/hooks/useNotebookAPI.ts | 2 +- .../src/app/hooks/useWorkspacekinds.ts | 24 ++++++ .../src/app/pages/Workspaces/Workspaces.tsx | 23 +++++- workspaces/frontend/src/app/types.ts | 4 + .../src/shared/api/notebookService.ts | 13 +++- .../frontend/src/shared/api/useAPIState.ts | 4 +- workspaces/frontend/src/shared/types.ts | 53 ++++++++++++- 11 files changed, 276 insertions(+), 8 deletions(-) create mode 100644 workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/workspacekind.cy.ts create mode 100644 workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspacekinds.mock.ts create mode 100644 workspaces/frontend/src/app/actions/WorkspacekindsActions.tsx create mode 100644 workspaces/frontend/src/app/hooks/useWorkspacekinds.ts diff --git a/workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/workspacekind.cy.ts b/workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/workspacekind.cy.ts new file mode 100644 index 00000000..f05d43f9 --- /dev/null +++ b/workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/workspacekind.cy.ts @@ -0,0 +1,61 @@ +import { mockWorkspacekindsValid, mockWorkspacekindsInValid } from '../mocked/workspacekinds.mock'; + +describe('Test buildKindLogoDictionary Functionality', () => { + // Mock valid workspace kinds + context('With Valid Data', () => { + before(() => { + // Mock the API response + cy.intercept('GET', '/api/v1/workspacekinds', { + statusCode: 200, + body: mockWorkspacekindsValid, + }); + + // Visit the page + cy.visit('/'); + }); + + it('should fetch and populate kind logos', () => { + // Check that the logos are rendered in the table + cy.get('tbody tr').each(($row) => { + cy.wrap($row).find('td[data-label="Kind"]').within(() => { + cy.get('img') + .should('exist') + .then(($img) => { + // Ensure the image is fully loaded + cy.wrap($img[0]).should('have.prop', 'complete', true); + }); + }); + }); + }); + }); + + // Mock invalid workspace kinds + context('With Invalid Data', () => { + before(() => { + // Mock the API response for invalid workspace kinds + cy.intercept('GET', '/api/v1/workspacekinds', { + statusCode: 200, + body: mockWorkspacekindsInValid, + }); + + // Visit the page + cy.visit('/'); + }); + + it('should fallback when logo URL is invalid', () => { + const workspaceKinds = mockWorkspacekindsInValid.data; // Access mock data + + cy.get('tbody tr').each(($row, index) => { + cy.wrap($row).find('td[data-label="Kind"]').within(() => { + cy.get('img') + .should('exist') + .then(($img) => { + // If the image src is invalid, it should not load + expect($img[0].naturalWidth).to.equal(0); // If the image is invalid, naturalWidth should be 0 + }); + }); + }); + }); + }); +}); + 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 new file mode 100644 index 00000000..e31f100b --- /dev/null +++ b/workspaces/frontend/src/__tests__/cypress/cypress/tests/mocked/workspacekinds.mock.ts @@ -0,0 +1,76 @@ +import { WorkspaceKind } from '~/shared/types'; + +// Factory function to create a valid WorkspaceKind +function createMockWorkspaceKind(overrides: Partial = {}): WorkspaceKind { + return { + name: 'jupyter-lab', + displayName: 'JupyterLab Notebook', + description: 'A Workspace which runs JupyterLab in a Pod', + deprecated: false, + deprecationMessage: '', + 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' }, + }, + ], + }, + }, + }, + ...overrides, // Allows customization + }; +} + +// Generate valid mock data with "data" property +export const mockWorkspacekindsValid = { + data: [ + createMockWorkspaceKind(), + ], +}; + +// Generate invalid mock data with "data" property +export const mockWorkspacekindsInValid = { + data: [ + createMockWorkspaceKind({ + logo: { + url: 'https://invalid-url.example.com/invalid-logo.svg', // Broken URL + }, + }), + ], +}; diff --git a/workspaces/frontend/src/app/actions/WorkspacekindsActions.tsx b/workspaces/frontend/src/app/actions/WorkspacekindsActions.tsx new file mode 100644 index 00000000..a1fcb4f5 --- /dev/null +++ b/workspaces/frontend/src/app/actions/WorkspacekindsActions.tsx @@ -0,0 +1,20 @@ +import { WorkspaceKind } from '~/shared/types'; + +type KindLogoDict = Record; + +/** + * Builds a dictionary of kind names to logos, and returns it. + * @param {WorkspaceKind[]} workspaceKinds - The list of workspace kinds. + * @returns {KindLogoDict} A dictionary with kind names as keys and logo URLs as values. + */ +export function buildKindLogoDictionary(workspaceKinds: WorkspaceKind[] | []): KindLogoDict { + const kindLogoDict: KindLogoDict = {}; + + for (const workspaceKind of workspaceKinds) { + kindLogoDict[workspaceKind.name] = workspaceKind.logo.url; + } + + return kindLogoDict; +} + + diff --git a/workspaces/frontend/src/app/context/useNotebookAPIState.tsx b/workspaces/frontend/src/app/context/useNotebookAPIState.tsx index cb078d08..d79f910c 100644 --- a/workspaces/frontend/src/app/context/useNotebookAPIState.tsx +++ b/workspaces/frontend/src/app/context/useNotebookAPIState.tsx @@ -1,9 +1,10 @@ import React from 'react'; import { APIState } from '~/shared/api/types'; import { NotebookAPIs } from '~/app/types'; -import { getNamespaces } from '~/shared/api/notebookService'; +import { getNamespaces, getWorkspacekinds} from '~/shared/api/notebookService'; import useAPIState from '~/shared/api/useAPIState'; + export type NotebookAPIState = APIState; const useNotebookAPIState = ( @@ -12,6 +13,7 @@ const useNotebookAPIState = ( const createAPI = React.useCallback( (path: string) => ({ getNamespaces: getNamespaces(path), + getWorkspacekinds: getWorkspacekinds(path), }), [], ); diff --git a/workspaces/frontend/src/app/hooks/useNotebookAPI.ts b/workspaces/frontend/src/app/hooks/useNotebookAPI.ts index 468ed669..a32597e9 100644 --- a/workspaces/frontend/src/app/hooks/useNotebookAPI.ts +++ b/workspaces/frontend/src/app/hooks/useNotebookAPI.ts @@ -8,7 +8,7 @@ type UseNotebookAPI = NotebookAPIState & { export const useNotebookAPI = (): UseNotebookAPI => { const { apiState, refreshAPIState: refreshAllAPI } = React.useContext(NotebookContext); - + return { refreshAllAPI, ...apiState, diff --git a/workspaces/frontend/src/app/hooks/useWorkspacekinds.ts b/workspaces/frontend/src/app/hooks/useWorkspacekinds.ts new file mode 100644 index 00000000..730aaf6e --- /dev/null +++ b/workspaces/frontend/src/app/hooks/useWorkspacekinds.ts @@ -0,0 +1,24 @@ +import * as React from 'react'; +import useFetchState, { + FetchState, + FetchStateCallbackPromise, +} from '~/shared/utilities/useFetchState'; +import { WorkspaceKind } from '~/shared/types'; +import { useNotebookAPI } from '~/app/hooks/useNotebookAPI'; + +const useWorkspacekinds = (): FetchState => { + const { api, apiAvailable } = useNotebookAPI(); + const call = React.useCallback>( + (opts) => { + if (!apiAvailable) { + return Promise.reject(new Error('API not yet available')); + } + return api.getWorkspacekinds(opts); + }, + [api, apiAvailable], + ); + + return useFetchState(call, []); +}; + +export default useWorkspacekinds; diff --git a/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx b/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx index 031e5da1..ffad1d5e 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx @@ -25,6 +25,8 @@ import { Button, PaginationVariant, Pagination, + Tooltip, + Brand, } from '@patternfly/react-core'; import { Table, @@ -42,6 +44,8 @@ import { Workspace, WorkspacesColumnNames, WorkspaceState } from '~/shared/types import { WorkspaceDetails } from '~/app/pages/Workspaces/Details/WorkspaceDetails'; import { ExpandedWorkspaceRow } from '~/app/pages/Workspaces/ExpandedWorkspaceRow'; import { formatRam } from 'shared/utilities/WorkspaceResources'; +import { buildKindLogoDictionary } from '~/app/actions/WorkspacekindsActions'; +import useWorkspacekinds from '~/app/hooks/useWorspacekinds'; export const Workspaces: React.FunctionComponent = () => { /* Mocked workspaces, to be removed after fetching info from backend */ @@ -143,6 +147,15 @@ export const Workspaces: React.FunctionComponent = () => { }, ]; + const [workspaceKinds, loaded, loadError] = useWorkspacekinds(); + let kindLogoDict: Record = {}; + + if (loaded && workspaceKinds) { + kindLogoDict = buildKindLogoDictionary(workspaceKinds); + } else { + console.error(loadError || 'Failed to load workspace kinds.'); + } + // Table columns const columnNames: WorkspacesColumnNames = { name: 'Name', @@ -548,7 +561,15 @@ export const Workspaces: React.FunctionComponent = () => { }} /> {workspace.name} - {workspace.kind} + + + + + {workspace.options.imageConfig} {workspace.options.podConfig} diff --git a/workspaces/frontend/src/app/types.ts b/workspaces/frontend/src/app/types.ts index ca80e9cd..2a7b5b50 100644 --- a/workspaces/frontend/src/app/types.ts +++ b/workspaces/frontend/src/app/types.ts @@ -1,4 +1,5 @@ import { APIOptions } from '~/shared/api/types'; +import { WorkspaceKind } from '~/shared/types'; export type ResponseBody = { data: T; @@ -64,6 +65,9 @@ export type NamespacesList = Namespace[]; export type GetNamespaces = (opts: APIOptions) => Promise; +export type GetWorkspacekinds = (opts: APIOptions) => Promise; + export type NotebookAPIs = { getNamespaces: GetNamespaces; + getWorkspacekinds: GetWorkspacekinds; }; diff --git a/workspaces/frontend/src/shared/api/notebookService.ts b/workspaces/frontend/src/shared/api/notebookService.ts index 5f38a5cc..c7cf9979 100644 --- a/workspaces/frontend/src/shared/api/notebookService.ts +++ b/workspaces/frontend/src/shared/api/notebookService.ts @@ -1,7 +1,8 @@ -import { NamespacesList } from '~/app/types'; +import { NamespacesList} from '~/app/types'; import { isNotebookResponse, restGET } from '~/shared/api/apiUtils'; import { APIOptions } from '~/shared/api/types'; import { handleRestFailures } from '~/shared/api/errorUtils'; +import { WorkspaceKind } from '../types'; export const getNamespaces = (hostPath: string) => @@ -12,3 +13,13 @@ export const getNamespaces = } 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'); + }); diff --git a/workspaces/frontend/src/shared/api/useAPIState.ts b/workspaces/frontend/src/shared/api/useAPIState.ts index e6c7ec87..57e2a0a3 100644 --- a/workspaces/frontend/src/shared/api/useAPIState.ts +++ b/workspaces/frontend/src/shared/api/useAPIState.ts @@ -6,7 +6,7 @@ const useAPIState = ( createAPI: (path: string) => T, ): [apiState: APIState, refreshAPIState: () => void] => { const [internalAPIToggleState, setInternalAPIToggleState] = React.useState(false); - + const refreshAPIState = React.useCallback(() => { setInternalAPIToggleState((v) => !v); }, []); @@ -18,7 +18,7 @@ const useAPIState = ( path = ''; } const api = createAPI(path); - + return { apiAvailable: !!path, api, diff --git a/workspaces/frontend/src/shared/types.ts b/workspaces/frontend/src/shared/types.ts index 577c371b..0f6f3b7e 100644 --- a/workspaces/frontend/src/shared/types.ts +++ b/workspaces/frontend/src/shared/types.ts @@ -10,11 +10,60 @@ export interface WorkspaceKind { name: string; displayName: string; description: string; - hidden: boolean; deprecated: boolean; - deprecationWarning: string; + deprecationMessage: string, + hidden: boolean; icon: WorkspaceIcon; logo: WorkspaceLogo; + podTemplate: { + podMetadata: { + labels: { + myWorkspaceKindLabel: string + }, + annotations: { + myWorkspaceKindAnnotation: string + } + }, + volumeMounts: { + home: string + }, + options: { + imageConfig: { + default: string, + values: [ + { + id: string, + displayName: string, + labels: { + pythonVersion: string + }, + hidden: true, + redirect: { + to: string, + message: { + text: string, + level: string + } + } + }, + ] + }, + podConfig: { + default: string, + values: [ + { + id: string, + displayName: string, + description: string, + labels: { + cpu: string, + memory: string + } + }, + ] + } + } + } } export enum WorkspaceState {