Skip to content

Commit

Permalink
feat: pause unpause UX (#9615)
Browse files Browse the repository at this point in the history
  • Loading branch information
keita-determined authored Jul 9, 2024
1 parent 58fbf68 commit 3a8c042
Show file tree
Hide file tree
Showing 8 changed files with 268 additions and 26 deletions.
63 changes: 60 additions & 3 deletions webui/react/src/components/RunActionDropdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@ import UIProvider, { DefaultTheme } from 'hew/Theme';
import { ConfirmationProvider } from 'hew/useConfirm';

import { handlePath } from 'routes/utils';
import { archiveRuns, deleteRuns, killRuns, unarchiveRuns } from 'services/api';
import { RunState } from 'types';
import {
archiveRuns,
deleteRuns,
killRuns,
pauseRuns,
resumeRuns,
unarchiveRuns,
} from 'services/api';
import { FlatRunExperiment, RunState } from 'types';

import RunActionDropdown, { Action } from './RunActionDropdown';
import { cell, run } from './RunActionDropdown.test.mock';
Expand All @@ -30,6 +37,8 @@ vi.mock('services/api', () => ({
archiveRuns: vi.fn(),
deleteRuns: vi.fn(),
killRuns: vi.fn(),
pauseRuns: vi.fn(),
resumeRuns: vi.fn(),
unarchiveRuns: vi.fn(),
}));

Expand All @@ -54,7 +63,12 @@ vi.mock('hooks/usePermissions', () => {
};
});

const setup = (link?: string, state?: RunState, archived?: boolean) => {
const setup = (
link?: string,
state?: RunState,
archived?: boolean,
experiment?: FlatRunExperiment,
) => {
const onComplete = vi.fn();
const onVisibleChange = vi.fn();
render(
Expand All @@ -68,6 +82,7 @@ const setup = (link?: string, state?: RunState, archived?: boolean) => {
run={{
...run,
archived: archived === undefined ? run.archived : archived,
experiment: experiment === undefined ? run.experiment : experiment,
state: state === undefined ? run.state : state,
}}
onComplete={onComplete}
Expand Down Expand Up @@ -176,4 +191,46 @@ describe('RunActionDropdown', () => {
setup();
expect(screen.queryByText(Action.Move)).not.toBeInTheDocument();
});

it('should provide Pause option', async () => {
mocks.canModifyFlatRun.mockImplementation(() => true);
const experiment: FlatRunExperiment = {
description: '',
forkedFrom: 6634,
id: 6833,
isMultitrial: false,
name: 'iris_tf_keras_adaptive_search',
progress: 0.9444444,
resourcePool: 'compute-pool',
searcherMetric: 'val_categorical_accuracy',
searcherType: 'single',
unmanaged: false,
};
setup(undefined, RunState.Active, false, experiment);
expect(screen.getByText(Action.Pause)).toBeInTheDocument();
await user.click(screen.getByText(Action.Pause));
await user.click(screen.getByRole('button', { name: Action.Pause }));
expect(vi.mocked(pauseRuns)).toBeCalled();
});

it('should hide Pause option without permissions', () => {
mocks.canModifyFlatRun.mockImplementation(() => false);
setup();
expect(screen.queryByText(Action.Pause)).not.toBeInTheDocument();
});

it('should provide Resume option', async () => {
mocks.canModifyFlatRun.mockImplementation(() => true);
setup(undefined, RunState.Paused, false);
expect(screen.getByText(Action.Resume)).toBeInTheDocument();
await user.click(screen.getByText(Action.Resume));
await user.click(screen.getByRole('button', { name: Action.Resume }));
expect(vi.mocked(resumeRuns)).toBeCalled();
});

it('should hide Resume option without permissions', () => {
mocks.canModifyFlatRun.mockImplementation(() => false);
setup();
expect(screen.queryByText(Action.Resume)).not.toBeInTheDocument();
});
});
45 changes: 42 additions & 3 deletions webui/react/src/components/RunActionDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@ import React, { useCallback, useMemo } from 'react';
import usePermissions from 'hooks/usePermissions';
import FlatRunMoveModalComponent from 'pages/FlatRuns/FlatRunMoveModal';
import { handlePath } from 'routes/utils';
import { archiveRuns, deleteRuns, killRuns, unarchiveRuns } from 'services/api';
import {
archiveRuns,
deleteRuns,
killRuns,
pauseRuns,
resumeRuns,
unarchiveRuns,
} from 'services/api';
import { FlatRun, FlatRunAction, ValueOf } from 'types';
import handleError, { ErrorLevel, ErrorType } from 'utils/error';
import { getActionsForFlatRun } from 'utils/flatRun';
Expand All @@ -38,7 +45,15 @@ export const Action = {

type Action = ValueOf<typeof Action>;

const dropdownActions = [Action.Archive, Action.Unarchive, Action.Kill, Action.Move, Action.Delete];
const dropdownActions = [
Action.Archive,
Action.Unarchive,
Action.Kill,
Action.Move,
Action.Pause,
Action.Resume,
Action.Delete,
];

const RunActionDropdown: React.FC<Props> = ({
run,
Expand Down Expand Up @@ -132,6 +147,30 @@ const RunActionDropdown: React.FC<Props> = ({
case Action.Move:
flatRunMoveModalOpen();
break;
case Action.Pause:
confirm({
content: `Are you sure you want to pause run ${run.id}?`,
okText: 'Pause',
onConfirm: async () => {
await pauseRuns({ projectId, runIds: [run.id] });
await onComplete?.(action, run.id);
},
onError: handleError,
title: 'Confirm Run Pause',
});
break;
case Action.Resume:
confirm({
content: `Are you sure you want to resume run ${run.id}?`,
okText: 'Resume',
onConfirm: async () => {
await resumeRuns({ projectId, runIds: [run.id] });
await onComplete?.(action, run.id);
},
onError: handleError,
title: 'Confirm Run Resume',
});
break;
case Action.Copy:
await copyToClipboard(cellCopyData ?? '');
openToast({
Expand All @@ -143,7 +182,7 @@ const RunActionDropdown: React.FC<Props> = ({
} catch (e) {
handleError(e, {
level: ErrorLevel.Error,
publicMessage: `Unable to ${action} experiment ${run.id}.`,
publicMessage: `Unable to ${action} run ${run.id}.`,
publicSubject: `${capitalize(action)} failed.`,
silent: false,
type: ErrorType.Server,
Expand Down
41 changes: 25 additions & 16 deletions webui/react/src/pages/FlatRuns/FlatRunActionButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,28 @@ import Link from 'components/Link';
import usePermissions from 'hooks/usePermissions';
import FlatRunMoveModalComponent from 'pages/FlatRuns/FlatRunMoveModal';
import { paths } from 'routes/utils';
import { archiveRuns, deleteRuns, killRuns, unarchiveRuns } from 'services/api';
import {
archiveRuns,
deleteRuns,
killRuns,
pauseRuns,
resumeRuns,
unarchiveRuns,
} from 'services/api';
import projectStore from 'stores/projects';
import { BulkActionResult, ExperimentAction, FlatRun, Project } from 'types';
import handleError from 'utils/error';
import { canActionFlatRun, getActionsForFlatRunsUnion } from 'utils/flatRun';
import { capitalizeWord } from 'utils/string';
import { capitalizeWord, pluralizer } from 'utils/string';

const BATCH_ACTIONS = [
ExperimentAction.Move,
ExperimentAction.Archive,
ExperimentAction.Unarchive,
ExperimentAction.Delete,
ExperimentAction.Pause,
ExperimentAction.Activate,
ExperimentAction.Kill,
ExperimentAction.Delete,
] as const;

type BatchAction = (typeof BATCH_ACTIONS)[number];
Expand All @@ -36,6 +45,8 @@ const ACTION_ICONS: Record<BatchAction, IconName> = {
[ExperimentAction.Move]: 'workspaces',
[ExperimentAction.Kill]: 'cancelled',
[ExperimentAction.Delete]: 'error',
[ExperimentAction.Activate]: 'play',
[ExperimentAction.Pause]: 'pause',
} as const;

const LABEL_PLURAL = 'runs';
Expand Down Expand Up @@ -88,8 +99,10 @@ const FlatRunActionButton = ({
return await unarchiveRuns(params);
case ExperimentAction.Delete:
return await deleteRuns(params);
default:
break;
case ExperimentAction.Pause:
return await pauseRuns(params);
case ExperimentAction.Activate:
return await resumeRuns(params);
}
},
[flatRunMoveModalOpen, projectId, selectedRuns],
Expand All @@ -114,24 +127,20 @@ const FlatRunActionButton = ({
} else if (numFailures === 0) {
openToast({
closeable: true,
description: `${action} succeeded for ${
results.successful.length
} ${LABEL_PLURAL.toLowerCase()}`,
description: `${action} succeeded for ${results.successful.length} ${pluralizer(results.successful.length, 'run')}`,
title: `${action} Success`,
});
} else if (numSuccesses === 0) {
openToast({
description: `Unable to ${action.toLowerCase()} ${numFailures} ${LABEL_PLURAL.toLowerCase()}`,
description: `Unable to ${action.toLowerCase()} ${numFailures} ${pluralizer(numFailures, 'run')}`,
severity: 'Warning',
title: `${action} Failure`,
});
} else {
openToast({
closeable: true,
description: `${action} succeeded for ${numSuccesses} out of ${
numFailures + numSuccesses
} eligible
${LABEL_PLURAL.toLowerCase()}`,
description: `${action} succeeded for ${numSuccesses} out of ${numFailures + numSuccesses} eligible
${pluralizer(numFailures + numSuccesses, 'run')}`,
severity: 'Warning',
title: `Partial ${action} Failure`,
});
Expand Down Expand Up @@ -206,20 +215,20 @@ const FlatRunActionButton = ({
} else if (numFailures === 0) {
openToast({
closeable: true,
description: `${results.successful.length} runs moved to project ${destinationProjectName}`,
description: `${results.successful.length} ${pluralizer(results.successful.length, 'run')} moved to project ${destinationProjectName}`,
link: <Link path={paths.projectDetails(destinationProjectId)}>View Project</Link>,
title: 'Move Success',
});
} else if (numSuccesses === 0) {
openToast({
description: `Unable to move ${numFailures} runs`,
description: `Unable to move ${numFailures} ${pluralizer(numFailures, 'run')}`,
severity: 'Warning',
title: 'Move Failure',
});
} else {
openToast({
closeable: true,
description: `${numFailures} out of ${numFailures + numSuccesses} eligible runs failed to move to project ${destinationProjectName}`,
description: `${numFailures} out of ${numFailures + numSuccesses} eligible ${pluralizer(numFailures + numSuccesses, 'run')} failed to move to project ${destinationProjectName}`,
link: <Link path={paths.projectDetails(destinationProjectId)}>View Project</Link>,
severity: 'Warning',
title: 'Partial Move Failure',
Expand Down
12 changes: 12 additions & 0 deletions webui/react/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,18 @@ export const unarchiveRuns = generateDetApi<
void
>(Config.unarchiveRuns);

export const pauseRuns = generateDetApi<
Api.V1ResumeRunsRequest,
Api.V1ResumeRunsResponse,
Type.BulkActionResult
>(Config.pauseRuns);

export const resumeRuns = generateDetApi<
Api.V1ResumeRunsRequest,
Api.V1ResumeRunsResponse,
Type.BulkActionResult
>(Config.resumeRuns);

/* Tasks */

export const getCommands = generateDetApi<
Expand Down
20 changes: 20 additions & 0 deletions webui/react/src/services/apiConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1190,6 +1190,26 @@ export const unarchiveRuns: DetApi<
request: (params, options) => detApi.Internal.unarchiveRuns(params, options),
};

export const pauseRuns: DetApi<
Api.V1PauseRunsRequest,
Api.V1PauseRunsResponse,
Type.BulkActionResult
> = {
name: 'pauseRuns',
postProcess: (response) => decoder.mapV1ActionResults(response.results),
request: (params, options) => detApi.Internal.pauseRuns(params, options),
};

export const resumeRuns: DetApi<
Api.V1ResumeRunsRequest,
Api.V1ResumeRunsResponse,
Type.BulkActionResult
> = {
name: 'resumeRuns',
postProcess: (response) => decoder.mapV1ActionResults(response.results),
request: (params, options) => detApi.Internal.resumeRuns(params, options),
};

/* Tasks */

export const getTask: DetApi<
Expand Down
3 changes: 2 additions & 1 deletion webui/react/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1280,7 +1280,8 @@ export const FlatRunAction = {
Delete: 'Delete',
Kill: 'Kill',
Move: 'Move',
// Pause: 'Pause',
Pause: 'Pause',
Resume: 'Resume',
Unarchive: 'Unarchive',
} as const;

Expand Down
Loading

0 comments on commit 3a8c042

Please sign in to comment.