Merge branch '20000-process-cancel-button' into main. Closes #20000
authorStephen Smith <stephen@curii.com>
Fri, 17 Feb 2023 17:40:07 +0000 (12:40 -0500)
committerStephen Smith <stephen@curii.com>
Fri, 17 Feb 2023 17:40:07 +0000 (12:40 -0500)
Arvados-DCO-1.1-Signed-off-by: Stephen Smith <stephen@curii.com>

cypress/integration/process.spec.js
src/components/icon/icon.tsx
src/store/processes/process.ts
src/store/processes/processes-actions.ts
src/views/process-panel/process-details-card.tsx
src/views/process-panel/process-panel-root.tsx
src/views/process-panel/process-panel.tsx

index 02657b3959971583f4d5aa3ad2580903ca860fc9..19544c9ca543bcbc257b823df48f2d2eaf97fa6f 100644 (file)
@@ -1170,7 +1170,191 @@ describe('Process tests', function() {
         });
 
         cy.get('[data-cy=process-details]').should('contain', copiedCrName);
-        cy.get('[data-cy=process-details]').find('button').contains('Run Process');
+        cy.get('[data-cy=process-details]').find('button').contains('Run');
+    });
+
+    const getFakeContainer = (fakeContainerUuid) => ({
+        href: `/containers/${fakeContainerUuid}`,
+        kind: "arvados#container",
+        etag: "ecfosljpnxfari9a8m7e4yv06",
+        uuid: fakeContainerUuid,
+        owner_uuid: "zzzzz-tpzed-000000000000000",
+        created_at: "2023-02-13T15:55:47.308915000Z",
+        modified_by_client_uuid: "zzzzz-ozdt8-q6dzdi1lcc03155",
+        modified_by_user_uuid: "zzzzz-tpzed-000000000000000",
+        modified_at: "2023-02-15T19:12:45.987086000Z",
+        command: [
+          "arvados-cwl-runner",
+          "--api=containers",
+          "--local",
+          "--project-uuid=zzzzz-j7d0g-yr18k784zplfeza",
+          "/var/lib/cwl/workflow.json#main",
+          "/var/lib/cwl/cwl.input.json",
+        ],
+        container_image: "4ad7d11381df349e464694762db14e04+303",
+        cwd: "/var/spool/cwl",
+        environment: {},
+        exit_code: null,
+        finished_at: null,
+        locked_by_uuid: null,
+        log: null,
+        output: null,
+        output_path: "/var/spool/cwl",
+        progress: null,
+        runtime_constraints: {
+          API: true,
+          cuda: {
+            device_count: 0,
+            driver_version: "",
+            hardware_capability: "",
+          },
+          keep_cache_disk: 2147483648,
+          keep_cache_ram: 0,
+          ram: 1342177280,
+          vcpus: 1,
+        },
+        runtime_status: {},
+        started_at: null,
+        auth_uuid: null,
+        scheduling_parameters: {
+          max_run_time: 0,
+          partitions: [],
+          preemptible: false,
+        },
+        runtime_user_uuid: "zzzzz-tpzed-vllbpebicy84rd5",
+        runtime_auth_scopes: ["all"],
+        lock_count: 2,
+        gateway_address: null,
+        interactive_session_started: false,
+        output_storage_classes: ["default"],
+        output_properties: {},
+        cost: 0.0,
+        subrequests_cost: 0.0,
+      });
+
+    it('shows cancel button when appropriate', function() {
+        // Ignore collection requests
+        cy.intercept({method: 'GET', url: `**/arvados/v1/collections/*`}, {
+            statusCode: 200,
+            body: {}
+        });
+
+        // Uncommitted container
+        const crUncommitted = `Test process ${Math.floor(Math.random() * 999999)}`;
+        createContainerRequest(
+            activeUser,
+            crUncommitted,
+            'arvados/jobs',
+            ['echo', 'hello world'],
+            false, 'Uncommitted')
+        .then(function(containerRequest) {
+            // Navigate to process and verify run / cancel button
+            cy.goToPath(`/processes/${containerRequest.uuid}`);
+            cy.waitForDom();
+            cy.get('[data-cy=process-details]').should('contain', crUncommitted);
+            cy.get('[data-cy=process-run-button]').should('exist');
+            cy.get('[data-cy=process-cancel-button]').should('not.exist');
+        });
+
+        // Queued container
+        const crQueued = `Test process ${Math.floor(Math.random() * 999999)}`;
+        const fakeCrUuid = 'zzzzz-dz642-000000000000001';
+        createContainerRequest(
+            activeUser,
+            crQueued,
+            'arvados/jobs',
+            ['echo', 'hello world'],
+            false, 'Committed')
+        .then(function(containerRequest) {
+            // Fake container uuid
+            cy.intercept({method: 'GET', url: `**/arvados/v1/container_requests/${containerRequest.uuid}`}, (req) => {
+                req.reply((res) => {
+                    res.body.output_uuid = fakeCrUuid;
+                    res.body.priority = 500;
+                    res.body.state = "Committed";
+                });
+            });
+
+            // Fake container
+            const container = getFakeContainer(fakeCrUuid);
+            cy.intercept({method: 'GET', url: `**/arvados/v1/container/${fakeCrUuid}`}, {
+                statusCode: 200,
+                body: {...container, state: "Queued", priority: 500}
+            });
+
+            // Navigate to process and verify cancel button
+            cy.goToPath(`/processes/${containerRequest.uuid}`);
+            cy.waitForDom();
+            cy.get('[data-cy=process-details]').should('contain', crQueued);
+            cy.get('[data-cy=process-cancel-button]').contains('Cancel');
+        });
+
+        // Locked container
+        const crLocked = `Test process ${Math.floor(Math.random() * 999999)}`;
+        const fakeCrLockedUuid = 'zzzzz-dz642-000000000000002';
+        createContainerRequest(
+            activeUser,
+            crLocked,
+            'arvados/jobs',
+            ['echo', 'hello world'],
+            false, 'Committed')
+        .then(function(containerRequest) {
+            // Fake container uuid
+            cy.intercept({method: 'GET', url: `**/arvados/v1/container_requests/${containerRequest.uuid}`}, (req) => {
+                req.reply((res) => {
+                    res.body.output_uuid = fakeCrLockedUuid;
+                    res.body.priority = 500;
+                    res.body.state = "Committed";
+                });
+            });
+
+            // Fake container
+            const container = getFakeContainer(fakeCrLockedUuid);
+            cy.intercept({method: 'GET', url: `**/arvados/v1/container/${fakeCrLockedUuid}`}, {
+                statusCode: 200,
+                body: {...container, state: "Locked", priority: 500}
+            });
+
+            // Navigate to process and verify cancel button
+            cy.goToPath(`/processes/${containerRequest.uuid}`);
+            cy.waitForDom();
+            cy.get('[data-cy=process-details]').should('contain', crLocked);
+            cy.get('[data-cy=process-cancel-button]').contains('Cancel');
+        });
+
+        // On Hold container
+        const crOnHold = `Test process ${Math.floor(Math.random() * 999999)}`;
+        const fakeCrOnHoldUuid = 'zzzzz-dz642-000000000000003';
+        createContainerRequest(
+            activeUser,
+            crOnHold,
+            'arvados/jobs',
+            ['echo', 'hello world'],
+            false, 'Committed')
+        .then(function(containerRequest) {
+            // Fake container uuid
+            cy.intercept({method: 'GET', url: `**/arvados/v1/container_requests/${containerRequest.uuid}`}, (req) => {
+                req.reply((res) => {
+                    res.body.output_uuid = fakeCrOnHoldUuid;
+                    res.body.priority = 0;
+                    res.body.state = "Committed";
+                });
+            });
+
+            // Fake container
+            const container = getFakeContainer(fakeCrOnHoldUuid);
+            cy.intercept({method: 'GET', url: `**/arvados/v1/container/${fakeCrOnHoldUuid}`}, {
+                statusCode: 200,
+                body: {...container, state: "Queued", priority: 0}
+            });
+
+            // Navigate to process and verify cancel button
+            cy.goToPath(`/processes/${containerRequest.uuid}`);
+            cy.waitForDom();
+            cy.get('[data-cy=process-details]').should('contain', crOnHold);
+            cy.get('[data-cy=process-run-button]').should('exist');
+            cy.get('[data-cy=process-cancel-button]').should('not.exist');
+        });
     });
 
 });
index 48cd8bed4f4c48ddb802e3c3b41eaab4616a51af..20b87b20515e90de0fdaf281a5d9b75b25a789a1 100644 (file)
@@ -74,6 +74,7 @@ import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
 import RemoveCircleOutline from '@material-ui/icons/RemoveCircleOutline';
 import NotInterested from '@material-ui/icons/NotInterested';
 import Image from '@material-ui/icons/Image';
+import Stop from '@material-ui/icons/Stop';
 
 // Import FontAwesome icons
 import { library } from '@fortawesome/fontawesome-svg-core';
@@ -215,3 +216,4 @@ export const SetupIcon: IconType = (props) => <RemoveCircleOutline {...props} />
 export const InactiveIcon: IconType = (props) => <NotInterested {...props} />;
 export const ImageIcon: IconType = (props) => <Image {...props} />;
 export const StartIcon: IconType = (props) => <PlayArrow {...props} />;
+export const StopIcon: IconType = (props) => <Stop {...props} />;
index d7fd3aa2320c3d3a3367de2c1a9dacdea337af18..ad0a14c72ec172b887cc6be0bf0df3b14443b114 100644 (file)
@@ -161,6 +161,28 @@ export const getProcessStatus = ({ containerRequest, container }: Process): Proc
     }
 };
 
+export const isProcessRunnable = ({ containerRequest }: Process): boolean => (
+    containerRequest.state === ContainerRequestState.UNCOMMITTED
+);
+
+export const isProcessResumable = ({ containerRequest, container }: Process): boolean => (
+    containerRequest.state === ContainerRequestState.COMMITTED &&
+    containerRequest.priority === 0 &&
+    // Don't show run button when container is present & running or cancelled
+    !(container && (container.state === ContainerState.RUNNING ||
+                            container.state === ContainerState.CANCELLED ||
+                            container.state === ContainerState.COMPLETE))
+);
+
+export const isProcessCancelable = ({ containerRequest, container }: Process): boolean => (
+    containerRequest.priority !== null &&
+    containerRequest.priority > 0 &&
+    container !== undefined &&
+        (container.state === ContainerState.QUEUED ||
+        container.state === ContainerState.LOCKED ||
+        container.state === ContainerState.RUNNING)
+);
+
 const isSubprocess = (containerUuid: string) => (resource: Resource) =>
     resource.kind === ResourceKind.CONTAINER_REQUEST
     && (resource as ContainerRequestResource).requestingContainerUuid === containerUuid;
index 815d6aec5e37eb443f3f636a58c63d990481684d..0b2de8373ca83769f9ee0d548e95530cba053ea2 100644 (file)
@@ -114,12 +114,32 @@ export const cancelRunningWorkflow = (uuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         try {
             const process = await services.containerRequestService.update(uuid, { priority: 0 });
+            dispatch<any>(updateResources([process]));
+            if (process.containerUuid) {
+                const container = await services.containerService.get(process.containerUuid, false);
+                dispatch<any>(updateResources([container]));
+            }
             return process;
         } catch (e) {
             throw new Error('Could not cancel the process.');
         }
     };
 
+export const resumeOnHoldWorkflow = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            const process = await services.containerRequestService.update(uuid, { priority: 500 });
+            dispatch<any>(updateResources([process]));
+            if (process.containerUuid) {
+                const container = await services.containerService.get(process.containerUuid, false);
+                dispatch<any>(updateResources([container]));
+            }
+            return process;
+        } catch (e) {
+            throw new Error('Could not resume the process.');
+        }
+    };
+
 export const startWorkflow = (uuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         try {
index 4fa4701a48cc5672fb0a1d895c5749fe775347d4..15728eb61f971bc48d484064f121934bec20517e 100644 (file)
@@ -16,15 +16,14 @@ import {
     Button,
 } from '@material-ui/core';
 import { ArvadosTheme } from 'common/custom-theme';
-import { CloseIcon, MoreOptionsIcon, ProcessIcon, StartIcon } from 'components/icon/icon';
-import { Process } from 'store/processes/process';
+import { CloseIcon, MoreOptionsIcon, ProcessIcon, StartIcon, StopIcon } from 'components/icon/icon';
+import { Process, isProcessRunnable, isProcessResumable, isProcessCancelable } from 'store/processes/process';
 import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
 import { ProcessDetailsAttributes } from './process-details-attributes';
 import { ProcessStatus } from 'views-components/data-explorer/renderers';
-import { ContainerState } from 'models/container';
-import { ContainerRequestState } from 'models/container-request';
+import classNames from 'classnames';
 
-type CssRules = 'card' | 'content' | 'title' | 'header' | 'cancelButton' | 'avatar' | 'iconHeader' | 'runButton';
+type CssRules = 'card' | 'content' | 'title' | 'header' | 'cancelButton' | 'avatar' | 'iconHeader' | 'actionButton';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     card: {
@@ -54,21 +53,20 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         paddingTop: theme.spacing.unit * 0.5,
         color: theme.customs.colors.green700,
     },
+    actionButton: {
+        padding: "0px 5px 0 0",
+        marginRight: "5px",
+        fontSize: '0.78rem',
+    },
     cancelButton: {
-        paddingRight: theme.spacing.unit * 2,
-        fontSize: '14px',
         color: theme.customs.colors.red900,
-        "&:hover": {
-            cursor: 'pointer'
-        }
-    },
-    runButton: {
-        backgroundColor: theme.customs.colors.green700,
+        borderColor: theme.customs.colors.red900,
         '&:hover': {
-            backgroundColor: theme.customs.colors.green800,
+            borderColor: theme.customs.colors.red900,
+        },
+        '& svg': {
+            fontSize: '22px',
         },
-        padding: "0px 5px 0 0",
-        marginRight: "5px",
     },
 });
 
@@ -76,13 +74,21 @@ export interface ProcessDetailsCardDataProps {
     process: Process;
     cancelProcess: (uuid: string) => void;
     startProcess: (uuid: string) => void;
+    resumeOnHoldWorkflow: (uuid: string) => void;
     onContextMenu: (event: React.MouseEvent<HTMLElement>) => void;
 }
 
 type ProcessDetailsCardProps = ProcessDetailsCardDataProps & WithStyles<CssRules> & MPVPanelProps;
 
 export const ProcessDetailsCard = withStyles(styles)(
-    ({ cancelProcess, startProcess, onContextMenu, classes, process, doHidePanel, panelName }: ProcessDetailsCardProps) => {
+    ({ cancelProcess, startProcess, resumeOnHoldWorkflow, onContextMenu, classes, process, doHidePanel, panelName }: ProcessDetailsCardProps) => {
+        let runAction: ((uuid: string) => void) | undefined = undefined;
+        if (isProcessRunnable(process)) {
+            runAction = startProcess;
+        } else if (isProcessResumable(process)) {
+            runAction = resumeOnHoldWorkflow;
+        }
+
         return <Card className={classes.card}>
             <CardHeader
                 className={classes.header}
@@ -106,18 +112,28 @@ export const ProcessDetailsCard = withStyles(styles)(
                     </Tooltip>}
                 action={
                     <div>
-                        {process.containerRequest.state === ContainerRequestState.UNCOMMITTED &&
+                        {runAction !== undefined &&
                             <Button
+                                data-cy="process-run-button"
                                 variant="contained"
                                 size="small"
                                 color="primary"
-                                className={classes.runButton}
-                                onClick={() => startProcess(process.containerRequest.uuid)}>
+                                className={classes.actionButton}
+                                onClick={() => runAction && runAction(process.containerRequest.uuid)}>
                                 <StartIcon />
-                                Run Process
+                                Run
+                            </Button>}
+                        {isProcessCancelable(process) &&
+                            <Button
+                                data-cy="process-cancel-button"
+                                variant="outlined"
+                                size="small"
+                                color="primary"
+                                className={classNames(classes.actionButton, classes.cancelButton)}
+                                onClick={() => cancelProcess(process.containerRequest.uuid)}>
+                                <StopIcon />
+                                Cancel
                             </Button>}
-                        {process.container && process.container.state === ContainerState.RUNNING &&
-                            <span className={classes.cancelButton} onClick={() => cancelProcess(process.containerRequest.uuid)}>Cancel</span>}
                         <ProcessStatus uuid={process.containerRequest.uuid} />
                         <Tooltip title="More options" disableFocusListener>
                             <IconButton
index 11b31ae0c97e5f4bb0d83e84718d60d32fd476f8..d99c62ec7493c986e1ea7bda21bf0d30ca9fffc7 100644 (file)
@@ -52,6 +52,7 @@ export interface ProcessPanelRootActionProps {
     onToggle: (status: string) => void;
     cancelProcess: (uuid: string) => void;
     startProcess: (uuid: string) => void;
+    resumeOnHoldWorkflow: (uuid: string) => void;
     onLogFilterChange: (filter: FilterOption) => void;
     navigateToLog: (uuid: string) => void;
     onCopyToClipboard: (uuid: string) => void;
@@ -124,6 +125,7 @@ export const ProcessPanelRoot = withStyles(styles)(
                         onContextMenu={event => props.onContextMenu(event, process)}
                         cancelProcess={props.cancelProcess}
                         startProcess={props.startProcess}
+                        resumeOnHoldWorkflow={props.resumeOnHoldWorkflow}
                     />
                 </MPVPanelContent>
                 <MPVPanelContent forwardProps xs="auto" data-cy="process-cmd">
index 2ad7e2a37b6cd8bb24a78c6fdd2581a105d65297..9dcb72cf8810ee59fd841610f157c46f4d69569a 100644 (file)
@@ -25,7 +25,7 @@ import {
     updateOutputParams,
     loadNodeJson
 } from 'store/process-panel/process-panel-actions';
-import { cancelRunningWorkflow, startWorkflow } from 'store/processes/processes-actions';
+import { cancelRunningWorkflow, resumeOnHoldWorkflow, startWorkflow } from 'store/processes/processes-actions';
 import { navigateToLogCollection, setProcessLogsPanelFilter } from 'store/process-logs-panel/process-logs-panel-actions';
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 
@@ -63,6 +63,7 @@ const mapDispatchToProps = (dispatch: Dispatch): ProcessPanelRootActionProps =>
     },
     cancelProcess: (uuid) => dispatch<any>(cancelRunningWorkflow(uuid)),
     startProcess: (uuid) => dispatch<any>(startWorkflow(uuid)),
+    resumeOnHoldWorkflow: (uuid) => dispatch<any>(resumeOnHoldWorkflow(uuid)),
     onLogFilterChange: (filter) => dispatch(setProcessLogsPanelFilter(filter.value)),
     navigateToLog: (uuid) => dispatch<any>(navigateToLogCollection(uuid)),
     loadInputs: (containerRequest) => dispatch<any>(loadInputs(containerRequest)),