feat(ws): Implement Start restart and stop workspace actions (#162)

Signed-off-by: Elay Aharoni (EXT-Nokia) <elay.aharoni.ext@nokia.com>
Co-authored-by: Elay Aharoni (EXT-Nokia) <elay.aharoni.ext@nokia.com>
This commit is contained in:
ElayAharoni 2025-02-06 20:32:41 +02:00 committed by GitHub
parent f9da864e1d
commit 4cbc26eaf4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 501 additions and 40 deletions

View File

@ -11,8 +11,8 @@ import {
Pagination,
Button,
Content,
Tooltip,
Brand,
Tooltip,
} from '@patternfly/react-core';
import {
Table,
@ -34,9 +34,21 @@ import DeleteModal from '~/shared/components/DeleteModal';
import { buildKindLogoDictionary } from '~/app/actions/WorkspaceKindsActions';
import useWorkspaceKinds from '~/app/hooks/useWorkspaceKinds';
import { WorkspaceConnectAction } from '~/app/pages/Workspaces/WorkspaceConnectAction';
import { WorkspaceStartActionModal } from '~/app/pages/Workspaces/workspaceActions/WorkspaceStartActionModal';
import { WorkspaceRestartActionModal } from '~/app/pages/Workspaces/workspaceActions/WorkspaceRestartActionModal';
import { WorkspaceStopActionModal } from '~/app/pages/Workspaces/workspaceActions/WorkspaceStopActionModal';
import Filter, { FilteredColumn } from 'shared/components/Filter';
import { formatRam } from 'shared/utilities/WorkspaceResources';
export enum ActionType {
ViewDetails,
Edit,
Delete,
Start,
Restart,
Stop,
}
export const Workspaces: React.FunctionComponent = () => {
/* Mocked workspaces, to be removed after fetching info from backend */
const mockWorkspaces: Workspace[] = [
@ -85,7 +97,7 @@ export const Workspaces: React.FunctionComponent = () => {
lastUpdate: 0,
},
pauseTime: 0,
pendingRestart: false,
pendingRestart: true,
podTemplateOptions: {
imageConfig: {
desired: '',
@ -170,7 +182,7 @@ export const Workspaces: React.FunctionComponent = () => {
lastActivity: 'Last Activity',
};
const filterableColumns: WorkspacesColumnNames = {
const filterableColumns = {
name: 'Name',
kind: 'Kind',
image: 'Image',
@ -182,10 +194,12 @@ export const Workspaces: React.FunctionComponent = () => {
// change when fetch workspaces is implemented
const initialWorkspaces = mockWorkspaces;
const [workspaces, setWorkspaces] = useState(initialWorkspaces);
const [workspaces, setWorkspaces] = useState<Workspace[]>(initialWorkspaces);
const [expandedWorkspacesNames, setExpandedWorkspacesNames] = React.useState<string[]>([]);
const [selectedWorkspace, setSelectedWorkspace] = React.useState<Workspace | null>(null);
const [workspaceToDelete, setWorkspaceToDelete] = React.useState<Workspace | null>(null);
const [isActionAlertModalOpen, setIsActionAlertModalOpen] = React.useState(false);
const [activeActionType, setActiveActionType] = React.useState<ActionType | null>(null);
const selectWorkspace = React.useCallback(
(newSelectedWorkspace) => {
@ -197,6 +211,7 @@ export const Workspaces: React.FunctionComponent = () => {
},
[selectedWorkspace],
);
const setWorkspaceExpanded = (workspace: Workspace, isExpanding = true) =>
setExpandedWorkspacesNames((prevExpanded) => {
const newExpandedWorkspacesNames = prevExpanded.filter((wsName) => wsName !== workspace.name);
@ -300,20 +315,31 @@ export const Workspaces: React.FunctionComponent = () => {
// Actions
const viewDetailsClick = React.useCallback((workspace: Workspace) => {
setSelectedWorkspace(workspace);
setActiveActionType(ActionType.ViewDetails);
}, []);
const editAction = React.useCallback((workspace: Workspace) => {
console.log(`Clicked on edit, on row ${workspace.name}`);
setSelectedWorkspace(workspace);
setActiveActionType(ActionType.Edit);
}, []);
const deleteAction = React.useCallback((workspace: Workspace) => {
console.log(`Clicked on delete, on row ${workspace.name}`);
setSelectedWorkspace(workspace);
setActiveActionType(ActionType.Delete);
}, []);
const startRestartAction = React.useCallback((workspace: Workspace) => {
console.log(`Clicked on start/restart, on row ${workspace.name}`);
const startRestartAction = React.useCallback((workspace: Workspace, action: ActionType) => {
setSelectedWorkspace(workspace);
setActiveActionType(action);
setIsActionAlertModalOpen(true);
}, []);
const stopAction = React.useCallback((workspace: Workspace) => {
console.log(`Clicked on stop, on row ${workspace.name}`);
setSelectedWorkspace(workspace);
setActiveActionType(ActionType.Stop);
setIsActionAlertModalOpen(true);
}, []);
const handleDeleteClick = React.useCallback((workspace: Workspace) => {
@ -322,12 +348,18 @@ export const Workspaces: React.FunctionComponent = () => {
setWorkspaceToDelete(workspace); // Open the modal and set workspace to delete
}, []);
const defaultActions = React.useCallback(
(workspace: Workspace): IActions =>
[
const onCloseActionAlertDialog = () => {
setIsActionAlertModalOpen(false);
setSelectedWorkspace(null);
setActiveActionType(null);
};
const workspaceDefaultActions = (workspace: Workspace): IActions => {
const workspaceState = workspace.status.state;
const workspaceActions = [
{
title: 'View Details',
onClick: () => selectWorkspace(workspace),
onClick: () => viewDetailsClick(workspace),
},
{
title: 'Edit',
@ -340,17 +372,55 @@ export const Workspaces: React.FunctionComponent = () => {
{
isSeparator: true,
},
{
title: 'Start/restart',
onClick: () => startRestartAction(workspace),
workspaceState !== WorkspaceState.Running
? {
title: 'Start',
onClick: () => startRestartAction(workspace, ActionType.Start),
}
: {
title: 'Restart',
onClick: () => startRestartAction(workspace, ActionType.Restart),
},
{
] as IActions;
if (workspaceState === WorkspaceState.Running) {
workspaceActions.push({
title: 'Stop',
onClick: () => stopAction(workspace),
},
] as IActions,
[selectWorkspace, editAction, handleDeleteClick, startRestartAction, stopAction],
});
}
return workspaceActions;
};
const chooseAlertModal = () => {
switch (activeActionType) {
case ActionType.Start:
return (
<WorkspaceStartActionModal
onClose={onCloseActionAlertDialog}
isOpen={isActionAlertModalOpen}
workspace={selectedWorkspace}
/>
);
case ActionType.Restart:
return (
<WorkspaceRestartActionModal
onClose={onCloseActionAlertDialog}
isOpen={isActionAlertModalOpen}
workspace={selectedWorkspace}
/>
);
case ActionType.Stop:
return (
<WorkspaceStopActionModal
onClose={onCloseActionAlertDialog}
isOpen={isActionAlertModalOpen}
workspace={selectedWorkspace}
/>
);
}
return undefined;
};
// States
@ -401,7 +471,10 @@ export const Workspaces: React.FunctionComponent = () => {
);
return (
<Drawer isInline isExpanded={selectedWorkspace != null}>
<Drawer
isInline
isExpanded={selectedWorkspace != null && activeActionType === ActionType.ViewDetails}
>
<DrawerContent panelContent={workspaceDetailsContent}>
<DrawerContentBody>
<PageSection isFilled>
@ -483,7 +556,7 @@ export const Workspaces: React.FunctionComponent = () => {
</Td>
<Td isActionCell data-testid="action-column">
<ActionsColumn
items={defaultActions(workspace).map((action) => ({
items={workspaceDefaultActions(workspace).map((action) => ({
...action,
'data-testid': `action-${typeof action.title === 'string' ? action.title.toLowerCase() : ''}`,
}))}
@ -496,6 +569,7 @@ export const Workspaces: React.FunctionComponent = () => {
</Tbody>
))}
</Table>
{isActionAlertModalOpen && chooseAlertModal()}
<DeleteModal
isOpen={workspaceToDelete != null}
resourceName={workspaceToDelete?.name || ''}

View File

@ -0,0 +1,188 @@
import { ExpandableSection, Icon, Tab, Tabs, TabTitleText, Content } from '@patternfly/react-core';
import {
ExclamationCircleIcon,
ExclamationTriangleIcon,
InfoCircleIcon,
} from '@patternfly/react-icons';
import * as React from 'react';
// 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',
},
},
},
],
},
},
},
};
const getLevelIcon = (level: string) => {
switch (level) {
case 'Info':
return (
<Icon status="info">
<InfoCircleIcon />
</Icon>
);
case 'Warning':
return (
<Icon status="warning">
<ExclamationTriangleIcon />
</Icon>
);
case 'Danger':
return (
<Icon status="danger">
<ExclamationCircleIcon />
</Icon>
);
default:
return (
<Icon status="info">
<InfoCircleIcon />
</Icon>
);
}
};
export const WorkspaceRedirectInformationView: React.FC = () => {
const [activeKey, setActiveKey] = React.useState<string | number>(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,
}));
const getMaxLevel = (
redirects: { dest: string; level: string; message: string; src: string }[],
) => {
let maxLevel = redirects[0].level;
redirects.forEach((redirect) => {
if (
(maxLevel === 'Info' && (redirect.level === 'Warning' || redirect.level === 'Danger')) ||
(maxLevel === 'Warning' && redirect.level === 'Danger')
) {
maxLevel = redirect.level;
}
});
return maxLevel;
};
return (
<Tabs activeKey={activeKey} onSelect={(event, eventKey) => setActiveKey(eventKey)}>
{imageConfigRedirects.length > 0 && (
<Tab
eventKey={0}
title={
<TabTitleText>
Image Config {getLevelIcon(getMaxLevel(imageConfigRedirects))}
</TabTitleText>
}
>
{imageConfigRedirects.map((redirect, index) => (
<Content style={{ display: 'flex', alignItems: 'baseline' }} key={index}>
{getLevelIcon(redirect.level)}
<ExpandableSection toggleText={` ${redirect.src} -> ${redirect.dest}`}>
<Content>{redirect.message}</Content>
</ExpandableSection>
</Content>
))}
</Tab>
)}
{podConfigRedirects.length > 0 && (
<Tab
eventKey={1}
title={
<TabTitleText>Pod Config {getLevelIcon(getMaxLevel(podConfigRedirects))}</TabTitleText>
}
>
{podConfigRedirects.map((redirect, index) => (
<Content style={{ display: 'flex', alignItems: 'baseline' }} key={index}>
{getLevelIcon(redirect.level)}
<ExpandableSection toggleText={` ${redirect.src} -> ${redirect.dest}`}>
<Content>{redirect.message}</Content>
</ExpandableSection>
</Content>
))}
</Tab>
)}
</Tabs>
);
};

View File

@ -0,0 +1,71 @@
import * as React from 'react';
import {
Button,
Content,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
TabTitleText,
} from '@patternfly/react-core';
import { Workspace } from '~/shared/types';
import { WorkspaceRedirectInformationView } from '~/app/pages/Workspaces/workspaceActions/WorkspaceRedirectInformationView';
interface RestartActionAlertProps {
onClose: () => void;
isOpen: boolean;
workspace: Workspace | null;
}
export const WorkspaceRestartActionModal: React.FC<RestartActionAlertProps> = ({
onClose,
isOpen,
workspace,
}) => {
const workspacePendingUpdate = workspace?.status.pendingRestart;
const handleClick = (isUpdate = false) => {
if (isUpdate) {
console.log(`Update ${workspace?.name}`);
}
console.log(`Restart ${workspace?.name}`);
onClose();
};
return (
<Modal
variant="medium"
isOpen={isOpen}
aria-describedby="modal-title-icon-description"
aria-labelledby="title-icon-modal-title"
onClose={onClose}
>
<ModalHeader title="Restart Workspace" />
<ModalBody>
{workspacePendingUpdate ? (
<>
<TabTitleText>
There are pending redirect updates for that workspace. Are you sure you want to
proceed?
</TabTitleText>
<WorkspaceRedirectInformationView />
</>
) : (
<Content>Are you sure you want to restart the workspace?</Content>
)}
</ModalBody>
<ModalFooter>
{workspacePendingUpdate && (
<Button onClick={() => handleClick(true)}>Update and Restart</Button>
)}
<Button
onClick={() => handleClick(false)}
variant={workspacePendingUpdate ? 'secondary' : 'primary'}
>
Restart
</Button>
<Button variant="link" onClick={onClose}>
Cancel
</Button>
</ModalFooter>
</Modal>
);
};

View File

@ -0,0 +1,57 @@
import * as React from 'react';
import {
Button,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
TabTitleText,
} from '@patternfly/react-core';
import { Workspace } from '~/shared/types';
import { WorkspaceRedirectInformationView } from '~/app/pages/Workspaces/workspaceActions/WorkspaceRedirectInformationView';
interface StartActionAlertProps {
onClose: () => void;
isOpen: boolean;
workspace: Workspace | null;
}
export const WorkspaceStartActionModal: React.FC<StartActionAlertProps> = ({
onClose,
isOpen,
workspace,
}) => {
const handleClick = (isUpdate = false) => {
if (isUpdate) {
console.log(`Update ${workspace?.name}`);
}
console.log(`Start ${workspace?.name}`);
onClose();
};
return (
<Modal
variant="medium"
isOpen={isOpen}
aria-describedby="modal-title-icon-description"
aria-labelledby="title-icon-modal-title"
onClose={onClose}
>
<ModalHeader title="Start Workspace" />
<ModalBody>
<TabTitleText>
There are pending redirect updates for that workspace. Are you sure you want to proceed?
</TabTitleText>
<WorkspaceRedirectInformationView />
</ModalBody>
<ModalFooter>
<Button onClick={() => handleClick(true)}>Update and Start</Button>
<Button onClick={() => handleClick(false)} variant="secondary">
Start
</Button>
<Button variant="link" onClick={onClose}>
Cancel
</Button>
</ModalFooter>
</Modal>
);
};

View File

@ -0,0 +1,71 @@
import * as React from 'react';
import {
Button,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
TabTitleText,
Content,
} from '@patternfly/react-core';
import { Workspace } from '~/shared/types';
import { WorkspaceRedirectInformationView } from '~/app/pages/Workspaces/workspaceActions/WorkspaceRedirectInformationView';
interface StopActionAlertProps {
onClose: () => void;
isOpen: boolean;
workspace: Workspace | null;
}
export const WorkspaceStopActionModal: React.FC<StopActionAlertProps> = ({
onClose,
isOpen,
workspace,
}) => {
const workspacePendingUpdate = workspace?.status.pendingRestart;
const handleClick = (isUpdate = false) => {
if (isUpdate) {
console.log(`Update ${workspace?.name}`);
}
console.log(`Stop ${workspace?.name}`);
onClose();
};
return (
<Modal
variant="medium"
isOpen={isOpen}
aria-describedby="modal-title-icon-description"
aria-labelledby="title-icon-modal-title"
onClose={onClose}
>
<ModalHeader title="Stop Workspace" />
<ModalBody>
{workspacePendingUpdate ? (
<>
<TabTitleText>
There are pending redirect updates for that workspace. Are you sure you want to
proceed?
</TabTitleText>
<WorkspaceRedirectInformationView />
</>
) : (
<Content>Are you sure you want to stop the workspace?</Content>
)}
</ModalBody>
<ModalFooter>
{workspacePendingUpdate && (
<Button onClick={() => handleClick(true)}>Update and Stop</Button>
)}
<Button
onClick={() => handleClick(false)}
variant={workspacePendingUpdate ? 'secondary' : 'primary'}
>
{workspacePendingUpdate ? 'Stop and defer updates' : 'Stop'}
</Button>
<Button variant="link" onClick={onClose}>
Cancel
</Button>
</ModalFooter>
</Modal>
);
};