From db40107345460b95623bcff330426398b3eb0b14 Mon Sep 17 00:00:00 2001 From: "Elay Aharoni (EXT-Nokia)" Date: Wed, 18 Dec 2024 15:05:56 +0200 Subject: [PATCH] Implement Start restart and stop workspace actions Signed-off-by: Elay Aharoni (EXT-Nokia) --- .../pages/Workspaces/WorkspaceActionAlert.tsx | 301 ++++++++++++++++++ .../src/app/pages/Workspaces/Workspaces.tsx | 129 +++++--- .../src/shared/components/AlertModal.tsx | 32 ++ workspaces/frontend/src/shared/types.ts | 14 + 4 files changed, 436 insertions(+), 40 deletions(-) create mode 100644 workspaces/frontend/src/app/pages/Workspaces/WorkspaceActionAlert.tsx create mode 100644 workspaces/frontend/src/shared/components/AlertModal.tsx diff --git a/workspaces/frontend/src/app/pages/Workspaces/WorkspaceActionAlert.tsx b/workspaces/frontend/src/app/pages/Workspaces/WorkspaceActionAlert.tsx new file mode 100644 index 00000000..d61d4b66 --- /dev/null +++ b/workspaces/frontend/src/app/pages/Workspaces/WorkspaceActionAlert.tsx @@ -0,0 +1,301 @@ +import * as React from 'react'; +import { + Button, + Content, + ExpandableSection, + Icon, + ModalFooter, + Tab, + Tabs, + TabTitleText, +} from '@patternfly/react-core'; +import { + ExclamationCircleIcon, + ExclamationTriangleIcon, + InfoCircleIcon, +} from '@patternfly/react-icons'; +import { AlertModalContent, WorkspaceActionData } from '~/shared/types'; +import AlertModal from '~/shared/components/AlertModal'; + +// remove when changing to fetch data from BE +const mockedWorkspaceKind = { + 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', + }, + }, + }, + { + id: 'jupyterlab_scipy_190', + displayName: 'jupyter-scipy:v1.9.0', + labels: { pythonVersion: '3.11' }, + hidden: true, + redirect: { + to: 'jupyterlab_scipy_200', + message: { + text: 'This update will change...', + level: 'Warning', + }, + }, + }, + ], + }, + 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' }, + redirect: { + to: 'small_cpu', + message: { + text: 'This update will change...', + level: 'Danger', + }, + }, + }, + ], + }, + }, + }, +}; + +interface WorkspaceActionAlertProps { + onClose: () => void; + isOpen: boolean; + activeActionData: WorkspaceActionData; +} + +export const WorkspaceActionAlert: React.FC = ({ + onClose, + isOpen, + activeActionData, +}) => { + const { action, workspace, isPendingUpdates } = activeActionData; + + console.log(workspace); + const getLevelIcon = (level: string) => { + switch (level) { + case 'Info': + return ( + + + + ); + case 'Warning': + return ( + + + + ); + case 'Danger': + return ( + + + + ); + default: + return ( + + + + ); + } + }; + + const ActionWithUpdatesBody = () => { + const [activeKey, setActiveKey] = React.useState(0); + // change this to get from BE, and use the workspaceKinds API + const workspaceKind = mockedWorkspaceKind; + + const { imageConfig } = workspaceKind.podTemplate.options; + const { podConfig } = workspaceKind.podTemplate.options; + + const imageConfigRedirects = imageConfig.values.map((value) => ({ + src: value.id, + dest: value.redirect.to, + message: value.redirect.message.text, + level: value.redirect.message.level, + })); + const podConfigRedirects = podConfig.values.map((value) => ({ + src: value.id, + dest: value.redirect.to, + message: value.redirect.message.text, + level: value.redirect.message.level, + })); + + return ( + <> + + There are pending redirect updates for that workspace, are you sure you want to proceed? + + setActiveKey(eventKey)}> + {imageConfigRedirects.length > 0 && ( + Image Config}> + {imageConfigRedirects.map((redirect, index) => ( + + {getLevelIcon(redirect.level)} + + {redirect.message} + + + ))} + + )} + {podConfigRedirects.length > 0 && ( + Pod Config}> + {podConfigRedirects.map((redirect, index) => ( + + {getLevelIcon(redirect.level)} + + {redirect.message} + + + ))} + + )} + + + ); + }; + + const handleClick = (isUpdate = false) => { + if (isUpdate) { + console.log('update'); // change to use the API for updating + } + switch (action) { + case 'start': + console.log('start'); // change to use the API for starting the workspace + break; + case 'restart': + console.log('restart'); // change to use the API for restarting the workspace + break; + case 'stop': + console.log('stop'); // change to use the API for stopping the workspace + break; + default: + break; + } + onClose(); + }; + + const getActionModalContent = (): AlertModalContent => { + if (isPendingUpdates) { + switch (action) { + case 'start': + return { + header: 'Start Workspace', + body: , + footer: ( + + + + + ), + }; + case 'restart': + return { + header: 'Restart Workspace', + body: , + footer: ( + + + + + ), + }; + case 'stop': + return { + header: 'Stop Workspace', + body: , + footer: ( + + + + + ), + }; + default: + return { + header: '', + body: undefined, + footer: undefined, + }; + } + } else { + switch (action) { + case 'start': + return { + header: '', + body: undefined, + footer: undefined, + }; + case 'restart': + return { + header: 'Restart Workspace', + body:
are you sure you want to Restart the workspace?
, + footer: ( + + + + ), + }; + case 'stop': + return { + header: 'Stop Workspace', + body:
are you sure you want to Stop the workspace?
, + footer: ( + + + + ), + }; + default: + return { + header: '', + body: undefined, + footer: undefined, + }; + } + } + }; + + return ; +}; diff --git a/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx b/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx index fbf54b8d..6a0775c0 100644 --- a/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx +++ b/workspaces/frontend/src/app/pages/Workspaces/Workspaces.tsx @@ -27,12 +27,18 @@ import { } from '@patternfly/react-table'; import { useState } from 'react'; import { CodeIcon } from '@patternfly/react-icons'; -import { Workspace, WorkspacesColumnNames, WorkspaceState } from '~/shared/types'; +import { + Workspace, + WorkspacesColumnNames, + WorkspaceActionData, + WorkspaceState, +} from '~/shared/types'; import { WorkspaceDetails } from '~/app/pages/Workspaces/Details/WorkspaceDetails'; import { ExpandedWorkspaceRow } from '~/app/pages/Workspaces/ExpandedWorkspaceRow'; import DeleteModal from '~/shared/components/DeleteModal'; import { buildKindLogoDictionary } from '~/app/actions/WorkspaceKindsActions'; import useWorkspaceKinds from '~/app/hooks/useWorkspaceKinds'; +import { WorkspaceActionAlert } from '~/app/pages/Workspaces/WorkspaceActionAlert'; import Filter, { FilteredColumn } from 'shared/components/Filter'; import { formatRam } from 'shared/utilities/WorkspaceResources'; @@ -78,7 +84,7 @@ export const Workspaces: React.FunctionComponent = () => { lastUpdate: 0, }, pauseTime: 0, - pendingRestart: false, + pendingRestart: true, podTemplateOptions: { imageConfig: { desired: '', @@ -123,7 +129,7 @@ export const Workspaces: React.FunctionComponent = () => { lastUpdate: 0, }, pauseTime: 0, - pendingRestart: false, + pendingRestart: true, podTemplateOptions: { imageConfig: { desired: '', @@ -153,7 +159,7 @@ export const Workspaces: React.FunctionComponent = () => { lastActivity: 'Last Activity', }; - const filterableColumns: WorkspacesColumnNames = { + const filterableColumns = { name: 'Name', kind: 'Kind', image: 'Image', @@ -169,7 +175,12 @@ export const Workspaces: React.FunctionComponent = () => { const [expandedWorkspacesNames, setExpandedWorkspacesNames] = React.useState([]); const [selectedWorkspace, setSelectedWorkspace] = React.useState(null); const [workspaceToDelete, setWorkspaceToDelete] = React.useState(null); - + const [isActionAlertModalOpen, setIsActionAlertModalOpen] = React.useState(false); + const [activeActionData, setActiveActionData] = React.useState({ + action: '', + workspace: undefined, + isPendingUpdates: false, + }); const selectWorkspace = React.useCallback( (newSelectedWorkspace) => { if (selectedWorkspace?.name === newSelectedWorkspace?.name) { @@ -284,19 +295,41 @@ export const Workspaces: React.FunctionComponent = () => { // Actions const editAction = React.useCallback((workspace: Workspace) => { - console.log(`Clicked on edit, on row ${workspace.name}`); + const workspacePendingUpdate = workspace.status.pendingRestart; + setActiveActionData({ + action: 'edit', + workspace, + isPendingUpdates: workspacePendingUpdate, + }); }, []); const deleteAction = React.useCallback((workspace: Workspace) => { - console.log(`Clicked on delete, on row ${workspace.name}`); + const workspacePendingUpdate = workspace.status.pendingRestart; + setActiveActionData({ + action: 'delete', + workspace, + isPendingUpdates: workspacePendingUpdate, + }); }, []); - const startRestartAction = React.useCallback((workspace: Workspace) => { - console.log(`Clicked on start/restart, on row ${workspace.name}`); + const startRestartAction = React.useCallback((workspace: Workspace, actionName: string) => { + const workspacePendingUpdate = workspace.status.pendingRestart; + setActiveActionData({ + action: actionName, + workspace, + isPendingUpdates: workspacePendingUpdate, + }); + setIsActionAlertModalOpen(true); }, []); const stopAction = React.useCallback((workspace: Workspace) => { - console.log(`Clicked on stop, on row ${workspace.name}`); + const workspacePendingUpdate = workspace.status.pendingRestart; + setActiveActionData({ + action: 'stop', + workspace, + isPendingUpdates: workspacePendingUpdate, + }); + setIsActionAlertModalOpen(true); }, []); const handleDeleteClick = React.useCallback((workspace: Workspace) => { @@ -305,35 +338,44 @@ export const Workspaces: React.FunctionComponent = () => { setWorkspaceToDelete(workspace); // Open the modal and set workspace to delete }, []); - const defaultActions = React.useCallback( - (workspace: Workspace): IActions => - [ - { - title: 'View Details', - onClick: () => selectWorkspace(workspace), - }, - { - title: 'Edit', - onClick: () => editAction(workspace), - }, - { - title: 'Delete', - onClick: () => handleDeleteClick(workspace), - }, - { - isSeparator: true, - }, - { - title: 'Start/restart', - onClick: () => startRestartAction(workspace), - }, - { - title: 'Stop', - onClick: () => stopAction(workspace), - }, - ] as IActions, - [selectWorkspace, editAction, handleDeleteClick, startRestartAction, stopAction], - ); + const onCloseActionAlertDialog = () => { + setIsActionAlertModalOpen(false); + setActiveActionData({ + action: '', + workspace: undefined, + isPendingUpdates: false, + }); + }; + + const workspaceDefaultActions = (workspace: Workspace): IActions => { + const workspaceState = workspace.status.state; + return [ + { + title: 'Edit', + onClick: () => editAction(workspace), + }, + { + title: 'Delete', + onClick: () => handleDeleteClick(workspace), + }, + { + isSeparator: true, + }, + workspaceState !== WorkspaceState.Running + ? { + title: 'Start', + onClick: () => startRestartAction(workspace, 'start'), + } + : { + title: 'Restart', + onClick: () => startRestartAction(workspace, 'restart'), + }, + workspaceState === WorkspaceState.Running && { + title: 'Stop', + onClick: () => stopAction(workspace), + }, + ] as IActions; + }; // States @@ -463,7 +505,7 @@ export const Workspaces: React.FunctionComponent = () => { ({ + items={workspaceDefaultActions(workspace).map((action) => ({ ...action, 'data-testid': `action-${typeof action.title === 'string' ? action.title.toLowerCase() : ''}`, }))} @@ -476,6 +518,13 @@ export const Workspaces: React.FunctionComponent = () => { ))} + {isActionAlertModalOpen && ( + + )} void; + isOpen: boolean; + content: AlertModalContent; + severity?: 'success' | 'danger' | 'warning' | 'info' | 'custom' | undefined; +} + +const AlertModal: React.FC = ({ onClose, isOpen, content, severity }) => ( + + + {content.body && {content.body}} + {content.footer && ( + + {content.footer} + + + )} + +); +export default AlertModal; diff --git a/workspaces/frontend/src/shared/types.ts b/workspaces/frontend/src/shared/types.ts index d7857ced..9da8e098 100644 --- a/workspaces/frontend/src/shared/types.ts +++ b/workspaces/frontend/src/shared/types.ts @@ -1,3 +1,5 @@ +import React from 'react'; + export interface WorkspaceIcon { url: string; } @@ -135,3 +137,15 @@ export type WorkspacesColumnNames = { ram: string; lastActivity: string; }; + +export interface AlertModalContent { + header: string; + body: React.ReactNode | undefined; + footer: React.ReactNode | undefined; +} + +export interface WorkspaceActionData { + action: string; + workspace: Workspace | undefined; + isPendingUpdates: boolean; +}