From a9c48bd559b59ba34e861e26a62b06dfdb6ef82e Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Date: Fri, 13 Sep 2024 07:42:36 -0400 Subject: [PATCH] Projects list view: expand workbenches column to a table --- .../cypress/cypress/pages/projects.ts | 26 ++- .../cypress/cypress/pages/workbench.ts | 4 - .../tests/mocked/projects/projectList.cy.ts | 110 +++++++--- .../tests/mocked/projects/workbench.cy.ts | 10 +- frontend/src/api/k8s/notebooks.ts | 2 +- frontend/src/images/icons/NotebookIcon.ts | 13 ++ ...usToggle.tsx => NotebookActionsColumn.tsx} | 167 +++++++------- .../projects/notebook/NotebookStateStatus.tsx | 127 +++++++++++ .../projects/notebook/NotebookStatusText.scss | 13 -- .../projects/notebook/NotebookStatusText.tsx | 89 -------- .../detail/notebooks/NotebookTableRow.tsx | 44 +--- .../screens/projects/NotebookStateStatus.tsx | 30 --- .../screens/projects/ProjectListView.tsx | 15 +- .../screens/projects/ProjectTableRow.tsx | 206 ++++++++---------- .../projects/ProjectTableRowNotebookTable.tsx | 55 +++++ .../ProjectTableRowNotebookTableRow.tsx | 45 ++++ .../screens/projects/notebookTableData.tsx | 43 ++++ .../projects/screens/projects/tableData.tsx | 39 +--- .../utilities/useWatchProjectNotebooks.tsx | 27 +++ 19 files changed, 620 insertions(+), 445 deletions(-) create mode 100644 frontend/src/images/icons/NotebookIcon.ts rename frontend/src/pages/projects/notebook/{NotebookStatusToggle.tsx => NotebookActionsColumn.tsx} (50%) create mode 100644 frontend/src/pages/projects/notebook/NotebookStateStatus.tsx delete mode 100644 frontend/src/pages/projects/notebook/NotebookStatusText.scss delete mode 100644 frontend/src/pages/projects/notebook/NotebookStatusText.tsx delete mode 100644 frontend/src/pages/projects/screens/projects/NotebookStateStatus.tsx create mode 100644 frontend/src/pages/projects/screens/projects/ProjectTableRowNotebookTable.tsx create mode 100644 frontend/src/pages/projects/screens/projects/ProjectTableRowNotebookTableRow.tsx create mode 100644 frontend/src/pages/projects/screens/projects/notebookTableData.tsx create mode 100644 frontend/src/utilities/useWatchProjectNotebooks.tsx diff --git a/frontend/src/__tests__/cypress/cypress/pages/projects.ts b/frontend/src/__tests__/cypress/cypress/pages/projects.ts index 9a22cd5d35..ff168853cc 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/projects.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/projects.ts @@ -37,21 +37,35 @@ class NotebookRow extends TableRow { } } +class ProjectNotebookRow extends TableRow { + findNotebookRouteLink() { + return this.find().findByTestId('notebook-route-link'); + } + + findNotebookStatusText() { + return this.find().findByTestId('notebook-status-text'); + } +} + class ProjectRow extends TableRow { findDescription() { return this.find().findByTestId('table-row-title-description'); } - findEnableSwitch() { - return this.find().pfSwitch('notebook-status-switch'); + findNotebookColumn() { + return this.find().findByTestId('notebook-column-expand'); } - findNotebookRouteLink() { - return this.find().findByTestId('notebook-route-link'); + findNotebookTable() { + return this.find().parents('tbody').findByTestId('project-notebooks-table'); } - findNotebookStatusText() { - return this.find().findByTestId('notebook-status-text'); + getNotebookRow(notebookName: string) { + return new ProjectNotebookRow(() => this.findNotebookLink(notebookName).parents('tr')); + } + + findNotebookLink(notebookName: string) { + return this.findNotebookTable().findByRole('link', { name: notebookName }); } } diff --git a/frontend/src/__tests__/cypress/cypress/pages/workbench.ts b/frontend/src/__tests__/cypress/cypress/pages/workbench.ts index 2373064ee2..76db2e8990 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/workbench.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/workbench.ts @@ -140,10 +140,6 @@ class NotebookRow extends TableRow { .findByTestId('add-storage-button'); } - findEnableSwitch() { - return this.find().pfSwitch('notebook-status-switch'); - } - shouldHaveContainerSize(name: string) { this.find().find(`[data-label="Container size"]`).contains(name).should('exist'); return this; diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/projectList.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/projectList.cy.ts index ba6752e199..7c327ae1cc 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/projectList.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/projectList.cy.ts @@ -88,14 +88,6 @@ describe('Data science projects details', () => { cy.url().should('include', '/projects/test-project'); }); - it('should test url for workbench creation', () => { - initIntercepts(); - projectListPage.visit(); - projectListPage.findCreateWorkbenchButton().click(); - - cy.url().should('include', '/projects/test-project/spawner'); - }); - it('should list the new project', () => { initIntercepts(); projectListPage.visit(); @@ -218,33 +210,73 @@ describe('Data science projects details', () => { deleteProject.should('have.attr', 'aria-disabled', 'true'); }); - describe('Table filter', () => { - it('filter by name', () => { - initIntercepts(); - projectListPage.visit(); - - // Select the "Name" filter - const projectListToolbar = projectListPage.getTableToolbar(); - projectListToolbar.findFilterMenuOption('filter-toolbar-dropdown', 'Name').click(); - projectListToolbar.findFilterInput('name').type('Test Project'); - // Verify only rows with the typed run name exist - projectListPage.getProjectRow('Test Project').find().should('exist'); - }); + it('should filter by name', () => { + initIntercepts(); + projectListPage.visit(); - it('filter by user', () => { - initIntercepts(); - projectListPage.visit(); + // Select the "Name" filter + const projectListToolbar = projectListPage.getTableToolbar(); + projectListToolbar.findFilterMenuOption('filter-toolbar-dropdown', 'Name').click(); + projectListToolbar.findFilterInput('name').type('Test Project'); + // Verify only rows with the typed run name exist + projectListPage.getProjectRow('Test Project').find().should('exist'); + }); - // Select the "User" filter - const projectListToolbar = projectListPage.getTableToolbar(); - projectListToolbar.findFilterMenuOption('filter-toolbar-dropdown', 'User').click(); - projectListToolbar.findFilterInput('user').type('test-user'); - // Verify only rows with the typed run user exist - projectListPage.getProjectRow('Test Project').find().should('exist'); - }); + it('should filter by user', () => { + initIntercepts(); + projectListPage.visit(); + + // Select the "User" filter + const projectListToolbar = projectListPage.getTableToolbar(); + projectListToolbar.findFilterMenuOption('filter-toolbar-dropdown', 'User').click(); + projectListToolbar.findFilterInput('user').type('test-user'); + // Verify only rows with the typed run user exist + projectListPage.getProjectRow('Test Project').find().should('exist'); }); - it('Validate that clicking on switch toggle will open modal to stop workbench', () => { + it('should show list of workbenches when the column is expanded', () => { + cy.interceptK8sList(ProjectModel, mockK8sResourceList([mockProjectK8sResource({})])); + cy.interceptK8s(RouteModel, mockRouteK8sResource({ notebookName: 'test-notebook' })).as( + 'getWorkbench', + ); + cy.interceptK8sList( + { model: NotebookModel }, + mockK8sResourceList([ + mockNotebookK8sResource({ + opts: { + spec: { + template: { + spec: { + containers: [ + { + name: 'test-notebook', + image: 'test-image:latest', + }, + ], + }, + }, + }, + metadata: { + name: 'test-notebook', + namespace: 'test-project', + labels: { + 'opendatahub.io/notebook-image': 'true', + }, + annotations: { + 'opendatahub.io/image-display-name': 'Test image', + }, + }, + }, + }), + ]), + ); + projectListPage.visit(); + cy.wait('@getWorkbench'); + const projectTableRow = projectListPage.getProjectRow('Test Project'); + projectTableRow.findNotebookColumn().click(); + }); + + it('should open the modal to stop workbench when user stops the workbench', () => { cy.interceptK8sList(ProjectModel, mockK8sResourceList([mockProjectK8sResource({})])); cy.interceptK8s('PATCH', NotebookModel, mockNotebookK8sResource({})).as('stopWorkbench'); cy.interceptK8sList(PodModel, mockK8sResourceList([mockPodK8sResource({})])); @@ -252,7 +284,7 @@ describe('Data science projects details', () => { 'getWorkbench', ); cy.interceptK8sList( - { model: NotebookModel, ns: 'test-project' }, + NotebookModel, mockK8sResourceList([ mockNotebookK8sResource({ opts: { @@ -270,6 +302,7 @@ describe('Data science projects details', () => { }, metadata: { name: 'test-notebook', + namespace: 'test-project', labels: { 'opendatahub.io/notebook-image': 'true', }, @@ -282,9 +315,14 @@ describe('Data science projects details', () => { ]), ); projectListPage.visit(); - cy.wait('@getWorkbench'); const projectTableRow = projectListPage.getProjectRow('Test Project'); - projectTableRow.findEnableSwitch().click(); + projectTableRow.findNotebookColumn().click(); + + const notebookRow = projectTableRow.getNotebookRow('Test Notebook'); + notebookRow.findNotebookRouteLink().should('have.attr', 'aria-disabled', 'false'); + + notebookRow.findKebabAction('Start').should('be.disabled'); + notebookRow.findKebabAction('Stop').click(); //stop workbench notebookConfirmModal.findStopWorkbenchButton().should('be.enabled'); @@ -315,8 +353,8 @@ describe('Data science projects details', () => { }, ]); }); - projectTableRow.findNotebookStatusText().should('have.text', 'Stopped '); - projectTableRow.findNotebookRouteLink().should('have.attr', 'aria-disabled', 'true'); + notebookRow.findNotebookStatusText().should('have.text', 'Stopped'); + notebookRow.findNotebookRouteLink().should('have.attr', 'aria-disabled', 'true'); }); }); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/workbench.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/workbench.cy.ts index 499e484000..ef02fb255f 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/workbench.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/projects/workbench.cy.ts @@ -422,7 +422,7 @@ describe('Workbench page', () => { const notebookRow = workbenchPage.getNotebookRow('Test Notebook'); notebookRow.shouldHaveNotebookImageName('Test Image'); notebookRow.shouldHaveContainerSize('Small'); - notebookRow.findHaveNotebookStatusText().should('have.text', 'Running '); + notebookRow.findHaveNotebookStatusText().should('have.text', 'Running'); notebookRow.findNotebookRouteLink().should('have.attr', 'aria-disabled', 'false'); //Name sorting @@ -451,7 +451,7 @@ describe('Workbench page', () => { const notebookRow = workbenchPage.getNotebookRow('Test Notebook'); //stop Workbench - notebookRow.findEnableSwitch().click(); + notebookRow.findKebabAction('Stop').click(); notebookConfirmModal.findStopWorkbenchButton().should('be.enabled'); cy.interceptK8s( NotebookModel, @@ -480,7 +480,7 @@ describe('Workbench page', () => { }, ]); }); - notebookRow.findHaveNotebookStatusText().should('have.text', 'Stopped '); + notebookRow.findHaveNotebookStatusText().should('have.text', 'Stopped'); notebookRow.findNotebookRouteLink().should('have.attr', 'aria-disabled', 'true'); cy.interceptK8s('PATCH', NotebookModel, mockNotebookK8sResource({})).as('startWorkbench'); @@ -501,8 +501,8 @@ describe('Workbench page', () => { }), ); - notebookRow.findEnableSwitch().click(); - notebookRow.findHaveNotebookStatusText().should('have.text', 'Starting... '); + notebookRow.findKebabAction('Start').click(); + notebookRow.findHaveNotebookStatusText().should('have.text', 'Starting'); notebookRow.findHaveNotebookStatusText().click(); cy.wait('@startWorkbench').then((interception) => { diff --git a/frontend/src/api/k8s/notebooks.ts b/frontend/src/api/k8s/notebooks.ts index c5e1e48bf7..14c122ac40 100644 --- a/frontend/src/api/k8s/notebooks.ts +++ b/frontend/src/api/k8s/notebooks.ts @@ -206,7 +206,7 @@ export const getStopPatch = (): Patch => ({ value: getStopPatchDataString(), }); -export const getNotebooks = (namespace: string): Promise => +export const getNotebooks = (namespace?: string): Promise => k8sListResource({ model: NotebookModel, queryOptions: { ns: namespace }, diff --git a/frontend/src/images/icons/NotebookIcon.ts b/frontend/src/images/icons/NotebookIcon.ts new file mode 100644 index 0000000000..cdabcf8399 --- /dev/null +++ b/frontend/src/images/icons/NotebookIcon.ts @@ -0,0 +1,13 @@ +import { createIcon } from '@patternfly/react-icons/dist/esm/createIcon'; + +const NotebookIcon = createIcon({ + name: 'NotebookIcon', + width: 32, + height: 32, + svgPath: + 'M30.0823 5.41458C30.5653 5.50638 30.9696 5.82808 31.1634 6.27538C31.7494 7.62408 32.0335 9.06458 32.0071 10.5558C31.9094 16.0861 27.1594 20.7467 21.6282 20.7467H21.5994C20.6482 20.7438 19.7102 20.6105 18.8025 20.3502L8.79906 30.3536C7.73606 31.4166 6.35086 31.9566 4.98706 31.9566C3.79176 31.9571 2.61256 31.5425 1.67946 30.7022C0.635455 29.7617 0.0402555 28.4727 0.00365549 27.0718C-0.0324445 25.6909 0.507056 24.3389 1.48366 23.3628L11.6492 13.1968C11.3894 12.2906 11.2561 11.3536 11.2527 10.4024C11.2351 4.85888 15.9007 0.0917815 21.4407 -0.00781855C22.9397 -0.0322186 24.3728 0.249481 25.7243 0.836482C26.1711 1.02988 26.4929 1.43418 26.5847 1.91708C26.6784 2.40928 26.5236 2.91418 26.1701 3.26718L21.007 8.43028C20.3601 9.07728 20.2263 10.0885 20.6955 10.7819C21.0124 11.2506 21.489 11.5378 22.0373 11.59C22.5813 11.6422 23.1101 11.4518 23.4934 11.0685L28.7322 5.82918C29.0847 5.47558 29.5911 5.32078 30.0823 5.41458ZM21.6058 18.7466H21.6287C26.094 18.7466 29.9284 14.9849 30.007 10.5205C30.0241 9.57818 29.8873 8.66208 29.6012 7.78858L24.9074 12.4824C24.1003 13.29 22.9856 13.6933 21.8469 13.5805C20.7102 13.4721 19.6868 12.8603 19.0393 11.9023C18.0261 10.4053 18.259 8.35008 19.593 7.01608L24.2107 2.39788C23.3841 2.12738 22.5203 1.99068 21.6336 1.99068C21.5814 1.99068 21.5291 1.99118 21.4769 1.99218C17.0048 2.07228 13.2386 5.92038 13.2528 10.3955C13.2563 11.335 13.4169 12.2578 13.7299 13.1377C13.8588 13.501 13.7674 13.9068 13.4945 14.1797L2.89776 24.7769C2.29866 25.376 1.98076 26.1724 2.00276 27.0196C2.02516 27.8663 2.38556 28.6466 3.01786 29.2159C4.20436 30.2847 6.16386 30.1612 7.38506 28.9395L17.8197 18.5049C18.0922 18.2314 18.4979 18.1401 18.8617 18.2695C19.7435 18.583 20.6663 18.7437 21.6058 18.7466ZM7.5 26.0098C7.5 26.8382 6.82843 27.5098 6 27.5098C5.17157 27.5098 4.5 26.8382 4.5 26.0098C4.5 25.1813 5.17157 24.5098 6 24.5098C6.82843 24.5098 7.5 25.1813 7.5 26.0098Z', + xOffset: 0, + yOffset: 0, +}); + +export default NotebookIcon; diff --git a/frontend/src/pages/projects/notebook/NotebookStatusToggle.tsx b/frontend/src/pages/projects/notebook/NotebookActionsColumn.tsx similarity index 50% rename from frontend/src/pages/projects/notebook/NotebookStatusToggle.tsx rename to frontend/src/pages/projects/notebook/NotebookActionsColumn.tsx index 8c33b4a226..c16ac0d886 100644 --- a/frontend/src/pages/projects/notebook/NotebookStatusToggle.tsx +++ b/frontend/src/pages/projects/notebook/NotebookActionsColumn.tsx @@ -1,51 +1,43 @@ import * as React from 'react'; -import { Flex, Switch } from '@patternfly/react-core'; +import { ActionsColumn } from '@patternfly/react-table'; +import { useNavigate } from 'react-router-dom'; +import { NotebookKind, ProjectKind } from '~/k8sTypes'; +import { NotebookState } from '~/pages/projects/notebook/types'; +import { fireFormTrackingEvent } from '~/concepts/analyticsTracking/segmentIOUtils'; +import { TrackingOutcome } from '~/concepts/analyticsTracking/trackingProperties'; import { startNotebook, stopNotebook } from '~/api'; import useNotebookAcceleratorProfile from '~/pages/projects/screens/detail/notebooks/useNotebookAcceleratorProfile'; import useNotebookDeploymentSize from '~/pages/projects/screens/detail/notebooks/useNotebookDeploymentSize'; -import { computeNotebooksTolerations } from '~/utilities/tolerations'; +import useStopNotebookModalAvailability from '~/pages/projects/notebook/useStopNotebookModalAvailability'; import { useAppContext } from '~/app/AppContext'; +import { computeNotebooksTolerations } from '~/utilities/tolerations'; import { currentlyHasPipelines } from '~/concepts/pipelines/elyra/utils'; -import { fireFormTrackingEvent } from '~/concepts/analyticsTracking/segmentIOUtils'; -import { TrackingOutcome } from '~/concepts/analyticsTracking/trackingProperties'; -import { FAST_POLL_INTERVAL } from '~/utilities/const'; -import useRefreshInterval from '~/utilities/useRefreshInterval'; -import { NotebookState } from './types'; -import StopNotebookConfirmModal from './StopNotebookConfirmModal'; -import useStopNotebookModalAvailability from './useStopNotebookModalAvailability'; -import NotebookStatusText from './NotebookStatusText'; - -type NotebookStatusToggleProps = { - notebookState: NotebookState; - enablePipelines?: boolean; - isDisabled?: boolean; -}; +import StopNotebookConfirmModal from '~/pages/projects/notebook/StopNotebookConfirmModal'; +import useNotebookImage from '~/pages/projects/screens/detail/notebooks/useNotebookImage'; +import { NotebookImageAvailability } from '~/pages/projects/screens/detail/notebooks/const'; -const NotebookStatusToggle: React.FC = ({ - notebookState, - enablePipelines, - isDisabled, -}) => { +export const useNotebookActionsColumn = ( + project: ProjectKind, + notebookState: NotebookState, + enablePipelines: boolean, + onNotebookDelete: (notebook: NotebookKind) => void, +): [React.ReactNode, () => void] => { + const navigate = useNavigate(); const { notebook, isStarting, isRunning, isStopping, refresh } = notebookState; const acceleratorProfile = useNotebookAcceleratorProfile(notebook); const { size } = useNotebookDeploymentSize(notebook); const [isOpenConfirm, setOpenConfirm] = React.useState(false); const [inProgress, setInProgress] = React.useState(false); + const [notebookImage] = useNotebookImage(notebookState.notebook); const [dontShowModalValue] = useStopNotebookModalAvailability(); const { dashboardConfig } = useAppContext(); const notebookName = notebook.metadata.name; const notebookNamespace = notebook.metadata.namespace; + const isDisabled = + isStopping || + inProgress || + (notebookImage?.imageAvailability === NotebookImageAvailability.DELETED && !isRunning); const isRunningOrStarting = isStarting || isRunning; - useRefreshInterval(FAST_POLL_INTERVAL, refresh); - - let label = ''; - if (isStarting) { - label = 'Starting...'; - } else if (isStopping) { - label = 'Stopping...'; - } else { - label = isRunning ? 'Running' : 'Stopped'; - } const fireNotebookTrackingEvent = React.useCallback( (action: 'started' | 'stopped') => { @@ -82,55 +74,70 @@ const NotebookStatusToggle: React.FC = ({ }); }, [fireNotebookTrackingEvent, notebookName, notebookNamespace, refresh]); - return ( - - { - if (isRunningOrStarting) { - if (dontShowModalValue) { + return [ + <> + { + setInProgress(true); + const tolerationSettings = computeNotebooksTolerations( + dashboardConfig, + notebookState.notebook, + ); + startNotebook( + notebook, + tolerationSettings, + enablePipelines && !currentlyHasPipelines(notebook), + ).then(() => { + fireNotebookTrackingEvent('started'); + refresh().then(() => setInProgress(false)); + }); + }, + }, + { + isDisabled: isDisabled || !isRunningOrStarting, + title: 'Stop', + onClick: () => { + if (dontShowModalValue) { + handleStop(); + } else { + setOpenConfirm(true); + } + }, + }, + { + isDisabled: isStarting || isStopping, + title: 'Edit workbench', + onClick: () => { + navigate( + `/projects/${project.metadata.name}/spawner/${notebookState.notebook.metadata.name}`, + ); + }, + }, + { + title: 'Delete workbench', + onClick: () => { + onNotebookDelete(notebookState.notebook); + }, + }, + ]} + /> + {isOpenConfirm ? ( + { + if (confirmStatus) { handleStop(); - } else { - setOpenConfirm(true); } - } else { - setInProgress(true); - const tolerationSettings = computeNotebooksTolerations( - dashboardConfig, - notebookState.notebook, - ); - startNotebook( - notebook, - tolerationSettings, - enablePipelines && !currentlyHasPipelines(notebook), - ).then(() => { - fireNotebookTrackingEvent('started'); - refresh().then(() => setInProgress(false)); - }); - } - }} - /> - - { - if (confirmStatus) { - handleStop(); - } - setOpenConfirm(false); - }} - /> - - ); + setOpenConfirm(false); + }} + /> + ) : null} + , + handleStop, + ]; }; - -export default NotebookStatusToggle; diff --git a/frontend/src/pages/projects/notebook/NotebookStateStatus.tsx b/frontend/src/pages/projects/notebook/NotebookStateStatus.tsx new file mode 100644 index 0000000000..08e46b78ed --- /dev/null +++ b/frontend/src/pages/projects/notebook/NotebookStateStatus.tsx @@ -0,0 +1,127 @@ +import * as React from 'react'; +import { Button, Label, LabelProps, Popover, Tooltip } from '@patternfly/react-core'; +import { + ExclamationCircleIcon, + InProgressIcon, + PauseCircleIcon, + RunningIcon, + SyncAltIcon, +} from '@patternfly/react-icons'; +import { EventStatus } from '~/types'; +import { useDeepCompareMemoize } from '~/utilities/useDeepCompareMemoize'; +import { NotebookState } from './types'; +import { getEventFullMessage, useNotebookStatus } from './utils'; +import StartNotebookModal from './StartNotebookModal'; + +type NotebookStateStatusProps = { + notebookState: NotebookState; + stopNotebook: () => void; +}; + +const rotate = 'pf-v5-c-spinner-animation-rotate 3s linear infinite'; + +const NotebookStateStatus: React.FC = ({ + notebookState, + stopNotebook, +}) => { + const { notebook, runningPodUid, isStarting, isStopping, isRunning } = notebookState; + const [unstableNotebookStatus, events] = useNotebookStatus(notebook, runningPodUid, isStarting); + const notebookStatus = useDeepCompareMemoize(unstableNotebookStatus); + const isError = notebookStatus?.currentStatus === EventStatus.ERROR; + const [isPopoverVisible, setPopoverVisible] = React.useState(false); + const [isStartModalOpen, setStartModalOpen] = React.useState(false); + + const statusLabelSettings = React.useMemo((): { + label: string; + color: LabelProps['color']; + icon: React.ReactNode; + } => { + if (isError) { + return { label: 'Failed', color: 'red', icon: }; + } + if (isStarting) { + return { + label: 'Starting', + color: 'blue', + icon: , + }; + } + if (isStopping) { + return { + label: 'Stopping', + color: 'grey', + icon: , + }; + } + if (isRunning) { + return { label: 'Running', color: 'green', icon: }; + } + return { label: 'Stopped', color: 'grey', icon: }; + }, [isError, isRunning, isStarting, isStopping]); + + const StatusLabel = ( + + ); + + if (isStarting) { + return ( + <> + setPopoverVisible(false)} + isVisible={isPopoverVisible} + headerContent="Notebook status" + bodyContent={ + events[events.length - 1] + ? getEventFullMessage(events[events.length - 1]) + : 'Waiting for notebook to start...' + } + footerContent={ + + } + > + {StatusLabel} + + {isStartModalOpen ? ( + { + if (stopped) { + stopNotebook(); + } + setStartModalOpen(false); + }} + /> + ) : null} + + ); + } + + return notebookStatus?.currentStatus === EventStatus.ERROR ? ( + {StatusLabel} + ) : ( + StatusLabel + ); +}; + +export default NotebookStateStatus; diff --git a/frontend/src/pages/projects/notebook/NotebookStatusText.scss b/frontend/src/pages/projects/notebook/NotebookStatusText.scss deleted file mode 100644 index 5091fd0994..0000000000 --- a/frontend/src/pages/projects/notebook/NotebookStatusText.scss +++ /dev/null @@ -1,13 +0,0 @@ -// This is an override provided by UX to set underline styles of starting text -// Because this is not implemented in PF Card component -.odh-notebook-status-popover { - &__starting-text { - cursor: pointer; - text-decoration: underline var(--pf-v5-global--BorderWidth--sm) dashed - var(--pf-v5-global--BorderColor--200); - text-underline-offset: 0.25rem; - &:hover { - text-decoration-color: var(--pf-v5-global--Color--100); - } - } -} diff --git a/frontend/src/pages/projects/notebook/NotebookStatusText.tsx b/frontend/src/pages/projects/notebook/NotebookStatusText.tsx deleted file mode 100644 index b435fcb5f5..0000000000 --- a/frontend/src/pages/projects/notebook/NotebookStatusText.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import * as React from 'react'; -import { Button, Icon, Popover, Text, Tooltip } from '@patternfly/react-core'; -import { ExclamationCircleIcon } from '@patternfly/react-icons'; -import { useDeepCompareMemoize } from '~/utilities/useDeepCompareMemoize'; -import { EventStatus } from '~/types'; -import StartNotebookModal from './StartNotebookModal'; -import { NotebookState } from './types'; -import { getEventFullMessage, useNotebookStatus } from './utils'; - -import './NotebookStatusText.scss'; - -type NotebookStatusTextProps = { - notebookState: NotebookState; - stopNotebook: () => void; - labelText: string; -}; - -const NotebookStatusText: React.FC = ({ - notebookState, - stopNotebook, - labelText, -}) => { - const { notebook, runningPodUid, isStarting } = notebookState; - const [isStartModalOpen, setStartModalOpen] = React.useState(false); - const [unstableNotebookStatus, events] = useNotebookStatus(notebook, runningPodUid, isStarting); - const notebookStatus = useDeepCompareMemoize(unstableNotebookStatus); - const [isPopoverVisible, setPopoverVisible] = React.useState(false); - - return ( - <> - setPopoverVisible(false)} - isVisible={isPopoverVisible} - headerContent="Notebook status" - bodyContent={ - events[events.length - 1] - ? getEventFullMessage(events[events.length - 1]) - : 'Waiting for notebook to start...' - } - footerContent={ - - } - > - { - if (isStarting) { - setPopoverVisible((visible) => !visible); - } - }} - className={isStarting ? 'odh-notebook-status-popover__starting-text' : undefined} - > - {labelText}{' '} - {notebookStatus?.currentStatus === EventStatus.ERROR && ( - - - - - - )} - - - { - if (stopped) { - stopNotebook(); - } - setStartModalOpen(false); - }} - /> - - ); -}; - -export default NotebookStatusText; diff --git a/frontend/src/pages/projects/screens/detail/notebooks/NotebookTableRow.tsx b/frontend/src/pages/projects/screens/detail/notebooks/NotebookTableRow.tsx index 8bc85e882e..49c4e19417 100644 --- a/frontend/src/pages/projects/screens/detail/notebooks/NotebookTableRow.tsx +++ b/frontend/src/pages/projects/screens/detail/notebooks/NotebookTableRow.tsx @@ -1,11 +1,10 @@ import * as React from 'react'; -import { ActionsColumn, ExpandableRowContent, Tbody, Td, Tr } from '@patternfly/react-table'; +import { ExpandableRowContent, Tbody, Td, Tr } from '@patternfly/react-table'; import { Button, Flex, FlexItem, Icon, Popover, Split, SplitItem } from '@patternfly/react-core'; import { useNavigate } from 'react-router-dom'; import { InfoCircleIcon } from '@patternfly/react-icons'; import { NotebookState } from '~/pages/projects/notebook/types'; import NotebookRouteLink from '~/pages/projects/notebook/NotebookRouteLink'; -import NotebookStatusToggle from '~/pages/projects/notebook/NotebookStatusToggle'; import { NotebookKind } from '~/k8sTypes'; import NotebookImagePackageDetails from '~/pages/projects/notebook/NotebookImagePackageDetails'; import { ProjectDetailsContext } from '~/pages/projects/ProjectDetailsContext'; @@ -14,6 +13,8 @@ import { ProjectObjectType, typedObjectImage } from '~/concepts/design/utils'; import DashboardPopupIconButton from '~/concepts/dashboard/DashboardPopupIconButton'; import { getDescriptionFromK8sResource, getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; import { NotebookSize } from '~/types'; +import NotebookStateStatus from '~/pages/projects/notebook/NotebookStateStatus'; +import { useNotebookActionsColumn } from '~/pages/projects/notebook/NotebookActionsColumn'; import useNotebookDeploymentSize from './useNotebookDeploymentSize'; import useNotebookImage from './useNotebookImage'; import NotebookSizeDetails from './NotebookSizeDetails'; @@ -52,6 +53,12 @@ const NotebookTableRow: React.FC = ({ }, }; const [notebookImage, loaded, loadError] = useNotebookImage(obj.notebook); + const [ActionColumn, stopNotebook] = useNotebookActionsColumn( + currentProject, + obj, + canEnablePipelines, + onNotebookDelete, + ); return ( @@ -147,41 +154,12 @@ const NotebookTableRow: React.FC = ({ ) : null} - + - {!compact ? ( - - { - navigate( - `/projects/${currentProject.metadata.name}/spawner/${obj.notebook.metadata.name}`, - ); - }, - }, - { - title: 'Delete workbench', - onClick: () => { - onNotebookDelete(obj.notebook); - }, - }, - ]} - /> - - ) : null} + {!compact ? {ActionColumn} : null} {!compact ? ( diff --git a/frontend/src/pages/projects/screens/projects/NotebookStateStatus.tsx b/frontend/src/pages/projects/screens/projects/NotebookStateStatus.tsx deleted file mode 100644 index b91e501357..0000000000 --- a/frontend/src/pages/projects/screens/projects/NotebookStateStatus.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import * as React from 'react'; -import { NotebookState } from '~/pages/projects/notebook/types'; -import NotebookStatusToggle from '~/pages/projects/notebook/NotebookStatusToggle'; -import { NotebookImageAvailability } from '~/pages/projects/screens/detail/notebooks/const'; -import useNotebookImage from '~/pages/projects/screens/detail/notebooks/useNotebookImage'; - -type NotebookStateStatusProps = { - enablePipelines: boolean; - notebookState: NotebookState; -}; - -const NotebookStateStatus: React.FC = ({ - notebookState, - enablePipelines, -}) => { - const [notebookImage] = useNotebookImage(notebookState.notebook); - - return ( - - ); -}; - -export default NotebookStateStatus; diff --git a/frontend/src/pages/projects/screens/projects/ProjectListView.tsx b/frontend/src/pages/projects/screens/projects/ProjectListView.tsx index 4d610d9293..04a857dafb 100644 --- a/frontend/src/pages/projects/screens/projects/ProjectListView.tsx +++ b/frontend/src/pages/projects/screens/projects/ProjectListView.tsx @@ -12,7 +12,8 @@ import { initialProjectsFilterData, ProjectsFilterDataType, } from '~/pages/projects/screens/projects/const'; -import { columns, subColumns } from './tableData'; +import { useWatchProjectNotebooks } from '~/utilities/useWatchProjectNotebooks'; +import { columns } from './tableData'; import DeleteProjectModal from './DeleteProjectModal'; import ManageProjectModal from './ManageProjectModal'; @@ -29,6 +30,11 @@ const ProjectListView: React.FC = ({ allowCreate }) => { () => setFilterData(initialProjectsFilterData), [setFilterData], ); + const namespaces = React.useMemo( + () => projects.map((project) => project.metadata.name), + [projects], + ); + const [projectNotebooks, loaded] = useWatchProjectNotebooks(namespaces); const filteredProjects = React.useMemo( () => @@ -66,18 +72,19 @@ const ProjectListView: React.FC = ({ allowCreate }) => { <> } data-testid="project-view-table" + disableRowRenderSupport rowRenderer={(project) => ( setEditData(data)} setDeleteData={(data) => setDeleteData(data)} diff --git a/frontend/src/pages/projects/screens/projects/ProjectTableRow.tsx b/frontend/src/pages/projects/screens/projects/ProjectTableRow.tsx index e888bfbba8..481657578a 100644 --- a/frontend/src/pages/projects/screens/projects/ProjectTableRow.tsx +++ b/frontend/src/pages/projects/screens/projects/ProjectTableRow.tsx @@ -1,141 +1,125 @@ import * as React from 'react'; -import { Button, Spinner, Text, TextVariants, Timestamp } from '@patternfly/react-core'; -import { ActionsColumn, Td, Tr } from '@patternfly/react-table'; -import { useNavigate } from 'react-router-dom'; -import { ProjectKind } from '~/k8sTypes'; +import { Text, TextVariants, Timestamp } from '@patternfly/react-core'; +import { ActionsColumn, Tbody, Td, Tr } from '@patternfly/react-table'; +import { NotebookKind, ProjectKind } from '~/k8sTypes'; +import NotebookIcon from '~/images/icons/NotebookIcon'; import useProjectTableRowItems from '~/pages/projects/screens/projects/useProjectTableRowItems'; import { getProjectOwner } from '~/concepts/projects/utils'; -import useProjectNotebookStates from '~/pages/projects/notebook/useProjectNotebookStates'; -import NotebookRouteLink from '~/pages/projects/notebook/NotebookRouteLink'; -import CanEnableElyraPipelinesCheck from '~/concepts/pipelines/elyra/CanEnableElyraPipelinesCheck'; -import NotebookStateStatus from '~/pages/projects/screens/projects/NotebookStateStatus'; -import { getDescriptionFromK8sResource, getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; +import ProjectTableRowNotebookTable from '~/pages/projects/screens/projects/ProjectTableRowNotebookTable'; import { TableRowTitleDescription } from '~/components/table'; import ResourceNameTooltip from '~/components/ResourceNameTooltip'; -import ProjectLink from '~/pages/projects/screens/projects/ProjectLink'; +import { getDescriptionFromK8sResource } from '~/concepts/k8s/utils'; +import ProjectLink from './ProjectLink'; + +// Plans to add other expandable columns in the future +export enum ExpandableColumns { + WORKBENCHES = 1, +} type ProjectTableRowProps = { obj: ProjectKind; + notebooks: NotebookKind[]; isRefreshing: boolean; setEditData: (data: ProjectKind) => void; setDeleteData: (data: ProjectKind) => void; }; const ProjectTableRow: React.FC = ({ obj: project, + notebooks, isRefreshing, setEditData, setDeleteData, }) => { - const navigate = useNavigate(); const owner = getProjectOwner(project); - + const [expandColumn, setExpandColumn] = React.useState(); const [item, runAccessCheck] = useProjectTableRowItems( project, isRefreshing, setEditData, setDeleteData, ); - const [notebookStates, loaded] = useProjectNotebookStates(project.metadata.name); + + const toggleExpandColumn = (colIndex: ExpandableColumns) => { + setExpandColumn(expandColumn === colIndex ? undefined : colIndex); + }; return ( - - {(enablePipelines) => ( - <> - {(notebookStates.length ? notebookStates : [null]).map((notebookState, index) => ( - - {index === 0 ? ( - - ) : null} - {index === 0 ? ( - - ) : null} - {loaded ? ( - <> - {notebookState ? ( - - ) : ( - - )} - - ) : ( - - )} - {loaded && notebookState ? ( - - ) : null} - {index === 0 ? ( - - ) : null} - - ))} - - )} - + + + + + + + + + + + ); }; diff --git a/frontend/src/pages/projects/screens/projects/ProjectTableRowNotebookTable.tsx b/frontend/src/pages/projects/screens/projects/ProjectTableRowNotebookTable.tsx new file mode 100644 index 0000000000..d028db77c2 --- /dev/null +++ b/frontend/src/pages/projects/screens/projects/ProjectTableRowNotebookTable.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { Table } from '~/components/table'; +import { NotebookKind, ProjectKind } from '~/k8sTypes'; +import useProjectNotebookStates from '~/pages/projects/notebook/useProjectNotebookStates'; +import CanEnableElyraPipelinesCheck from '~/concepts/pipelines/elyra/CanEnableElyraPipelinesCheck'; +import ProjectTableRowNotebookTableRow from '~/pages/projects/screens/projects/ProjectTableRowNotebookTableRow'; +import DeleteNotebookModal from '~/pages/projects/notebook/DeleteNotebookModal'; +import { columns } from './notebookTableData'; + +type ProjectTableRowNotebookTableProps = { + obj: ProjectKind; +}; +const ProjectTableRowNotebookTable: React.FC = ({ + obj: project, +}) => { + const [notebookStates, loaded, , refresh] = useProjectNotebookStates(project.metadata.name); + const [notebookToDelete, setNotebookToDelete] = React.useState(); + + return ( + + {(enablePipelines) => ( + <> +
- - - - } - description={getDescriptionFromK8sResource(project)} - truncateDescriptionLines={2} - subtitle={ - owner ? ( -
- {owner} -
- ) : undefined - } - /> -
- {project.metadata.creationTimestamp ? ( - - ) : ( - 'Unknown' - )} - - - - {' '} - to add a custom notebook. - - - - - - -
+ + + + } + description={getDescriptionFromK8sResource(project)} + truncateDescriptionLines={2} + subtitle={ + owner ? ( +
+ {owner} +
+ ) : undefined + } + /> +
+ {project.metadata.creationTimestamp ? ( + + ) : ( + 'Unknown' + )} + toggleExpandColumn(column), + } + : undefined + } + data-testid="notebook-column-expand" + > + + {notebooks.length} + + +
+ +
( + + )} + /> + { + if (deleted) { + refresh(); + } + setNotebookToDelete(undefined); + }} + /> + + )} + + ); +}; + +export default ProjectTableRowNotebookTable; diff --git a/frontend/src/pages/projects/screens/projects/ProjectTableRowNotebookTableRow.tsx b/frontend/src/pages/projects/screens/projects/ProjectTableRowNotebookTableRow.tsx new file mode 100644 index 0000000000..511f1c08dd --- /dev/null +++ b/frontend/src/pages/projects/screens/projects/ProjectTableRowNotebookTableRow.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { Td, Tr } from '@patternfly/react-table'; +import { NotebookKind, ProjectKind } from '~/k8sTypes'; +import NotebookRouteLink from '~/pages/projects/notebook/NotebookRouteLink'; +import NotebookStateStatus from '~/pages/projects/notebook/NotebookStateStatus'; +import { getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; +import { NotebookState } from '~/pages/projects/notebook/types'; +import { useNotebookActionsColumn } from '~/pages/projects/notebook/NotebookActionsColumn'; + +type ProjectTableRowNotebookTableRowProps = { + project: ProjectKind; + obj: NotebookState; + onNotebookDelete: (notebook: NotebookKind) => void; + enablePipelines: boolean; +}; +const ProjectTableRowNotebookTableRow: React.FC = ({ + project, + obj: notebookState, + onNotebookDelete, + enablePipelines, +}) => { + const [ActionColumn, stopNotebook] = useNotebookActionsColumn( + project, + notebookState, + enablePipelines, + onNotebookDelete, + ); + return ( + + + + + + ); +}; + +export default ProjectTableRowNotebookTableRow; diff --git a/frontend/src/pages/projects/screens/projects/notebookTableData.tsx b/frontend/src/pages/projects/screens/projects/notebookTableData.tsx new file mode 100644 index 0000000000..8fe71a3510 --- /dev/null +++ b/frontend/src/pages/projects/screens/projects/notebookTableData.tsx @@ -0,0 +1,43 @@ +import { SortableData } from '~/components/table'; +import { getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; +import { NotebookState } from '~/pages/projects/notebook/types'; + +const getNotebookStatusValue = (notebookState: NotebookState): number => { + if (notebookState.isRunning) { + return 0; + } + if (notebookState.isStarting) { + return 1; + } + if (notebookState.isStopping) { + return 2; + } + if (notebookState.isStopped) { + return 3; + } + return 4; +}; + +export const columns: SortableData[] = [ + { + field: 'name', + label: 'Name', + sortable: (a, b) => + getDisplayNameFromK8sResource(a.notebook).localeCompare( + getDisplayNameFromK8sResource(b.notebook), + ), + width: 40, + }, + { + field: 'status', + label: 'Status', + sortable: (a, b) => getNotebookStatusValue(a) - getNotebookStatusValue(b), + width: 40, + }, + { + field: 'kebab', + label: '', + sortable: false, + width: 10, + }, +]; diff --git a/frontend/src/pages/projects/screens/projects/tableData.tsx b/frontend/src/pages/projects/screens/projects/tableData.tsx index 79ee148193..469343243f 100644 --- a/frontend/src/pages/projects/screens/projects/tableData.tsx +++ b/frontend/src/pages/projects/screens/projects/tableData.tsx @@ -4,31 +4,6 @@ import { getProjectCreationTime } from '~/concepts/projects/utils'; import { getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; export const columns: SortableData[] = [ - { - field: 'project', - label: 'Project', - sortable: false, - colSpan: 2, - hasRightBorder: true, - width: 50, - }, - { - field: 'Workbenches', - label: 'Workbenches', - sortable: false, - colSpan: 2, - hasRightBorder: true, - width: 40, - }, - { - field: 'kebab', - label: '', - sortable: false, - rowSpan: 2, - width: 10, - }, -]; -export const subColumns: SortableData[] = [ { field: 'name', label: 'Name', @@ -40,20 +15,18 @@ export const subColumns: SortableData[] = [ field: 'created', label: 'Created', sortable: (a, b) => getProjectCreationTime(a) - getProjectCreationTime(b), - hasRightBorder: true, - width: 20, + width: 30, }, { - field: 'workbench-name', - label: 'Name', + field: 'Workbenches', + label: 'Workbenches', sortable: false, - width: 10, + width: 30, }, { - field: 'workbench-status', - label: 'Status', + field: 'kebab', + label: '', sortable: false, - hasRightBorder: true, width: 10, }, ]; diff --git a/frontend/src/utilities/useWatchProjectNotebooks.tsx b/frontend/src/utilities/useWatchProjectNotebooks.tsx new file mode 100644 index 0000000000..a13a02dc72 --- /dev/null +++ b/frontend/src/utilities/useWatchProjectNotebooks.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import useFetchState, { FetchState } from '~/utilities/useFetchState'; +import { NotebookKind } from '~/k8sTypes'; +import { getNotebooks } from '~/api'; +import { POLL_INTERVAL } from '~/utilities/const'; + +type ProjectNotebooks = { [key: string]: NotebookKind[] | undefined }; + +const fetchNotebooks = (namespaces: string[]): Promise => + new Promise((resolve) => { + const fetchers = namespaces.map((namespace) => getNotebooks(namespace)); + Promise.all(fetchers).then((results) => { + const projectNotebooks: ProjectNotebooks = {}; + namespaces.forEach((ns, i) => (projectNotebooks[ns] = results[i])); + resolve(projectNotebooks); + }); + }); + +export const useWatchProjectNotebooks = ( + namespaces: string[], + refreshRate = POLL_INTERVAL, +): FetchState => + useFetchState( + React.useCallback(() => fetchNotebooks(namespaces), [namespaces]), + {}, + { refreshRate }, + );
+ + + + {ActionColumn}