Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: experiment list batch [DET-3001] #866

Merged
merged 10 commits into from
Jul 20, 2020
4 changes: 2 additions & 2 deletions webui/react/src/components/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ interface Props {
name: string;
}

const getInitials = (name: string): string => {
const getInitials = (name = ''): string => {
// Reduce the name to initials.
const initials = name
.split(/\s+/)
Expand All @@ -20,7 +20,7 @@ const getInitials = (name: string): string => {
return initials.length > 2 ? `${initials.charAt(0)}${initials.substr(-1)}` : initials;
};

const getColor = (name: string): string => {
const getColor = (name = ''): string => {
const hexColor = md5(name).substr(0, 6);
const hslColor = hex2hsl(hexColor);
return hsl2str({ ...hslColor, l: 50 });
Expand Down
1 change: 1 addition & 0 deletions webui/react/src/components/Spinner.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
position: absolute;
top: 0;
width: 100%;
z-index: 1000;
}
.opaque {
background-color: var(--theme-colors-monochrome-17);
Expand Down
7 changes: 3 additions & 4 deletions webui/react/src/components/TableBatch.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
padding-right: var(--theme-sizes-layout-big);
position: absolute;
width: 100%;
z-index: 2;
z-index: 1;
}
.container::before {
background-color: var(--theme-colors-monochrome-17);
Expand All @@ -38,12 +38,11 @@
background-color: var(--theme-colors-monochrome-17);
bottom: 0;
content: '';
height: calc(var(--theme-sizes-layout-medium) - 1);
height: calc(var(--theme-sizes-layout-medium) - 0.1rem);
left: 3rem;
position: absolute;
transform: translateX(-50%);
width: 3rem;
z-index: 1;
}
.actions {
align-items: center;
Expand All @@ -56,7 +55,7 @@
font-size: var(--theme-sizes-font-medium);
}
&.show {
height: 4rem;
height: 4.8rem;
opacity: 1;
}
}
175 changes: 171 additions & 4 deletions webui/react/src/pages/ExperimentList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Input, Table } from 'antd';
import { ExclamationCircleOutlined } from '@ant-design/icons';
import { Button, Input, Modal, Table } from 'antd';
import { SelectValue } from 'antd/lib/select';
import React, { useCallback, useEffect, useMemo, useState } from 'react';

Expand All @@ -7,21 +8,40 @@ import { makeClickHandler } from 'components/Link';
import linkCss from 'components/Link.module.scss';
import Page from 'components/Page';
import StateSelectFilter from 'components/StateSelectFilter';
import TableBatch from 'components/TableBatch';
import Toggle from 'components/Toggle';
import UserSelectFilter from 'components/UserSelectFilter';
import Auth from 'contexts/Auth';
import Users from 'contexts/Users';
import handleError, { ErrorLevel, ErrorType } from 'ErrorHandler';
import usePolling from 'hooks/usePolling';
import { useRestApiSimple } from 'hooks/useRestApi';
import useStorage from 'hooks/useStorage';
import { getExperimentSummaries } from 'services/api';
import { setupUrlForDev } from 'routes';
import {
archiveExperiment, getExperimentSummaries, killExperiment, launchTensorboard, setExperimentState,
} from 'services/api';
import { ExperimentsParams } from 'services/types';
import { ALL_VALUE, Experiment, ExperimentFilters, ExperimentItem } from 'types';
import {
ALL_VALUE, Command, Experiment, ExperimentFilters, ExperimentItem, RunState, TBSourceType,
} from 'types';
import { openBlank } from 'utils/routes';
import { filterExperiments, processExperiments } from 'utils/task';
import { cancellableRunStates, isTaskKillable, terminalRunStates, waitPageUrl } from 'utils/types';

import css from './ExperimentList.module.scss';
import { columns } from './ExperimentList.table';

enum Action {
Activate = 'Activate',
Archive = 'Archive',
hkang1 marked this conversation as resolved.
Show resolved Hide resolved
Cancel = 'Cancel',
Kill = 'Kill',
Pause = 'Pause',
OpenTensorBoard = 'OpenTensorboard',
Unarchive = 'Unarchive',
}

const defaultFilters: ExperimentFilters = {
limit: 25,
showArchived: false,
Expand All @@ -40,11 +60,58 @@ const ExperimentList: React.FC = () => {
{ ...defaultFilters, username: (auth.user || {}).username });
const [ filters, setFilters ] = useState<ExperimentFilters>(initFilters);
const [ search, setSearch ] = useState('');
const [ selectedRowKeys, setSelectedRowKeys ] = useState<string[]>([]);

const filteredExperiments = useMemo(() => {
return filterExperiments(experiments, filters, users.data || [], search);
}, [ experiments, filters, search, users.data ]);

const showBatch = selectedRowKeys.length !== 0;

const experimentMap = useMemo(() => {
return experiments.reduce((acc, task) => {
acc[task.id] = task;
return acc;
}, {} as Record<string, ExperimentItem>);
}, [ experiments ]);

const selectedExperiments = useMemo(() => {
return selectedRowKeys.map(key => experimentMap[key]);
}, [ experimentMap, selectedRowKeys ]);

const {
hasActivatable,
hasArchivable,
hasCancelable,
hasKillable,
hasPausable,
hasUnarchivable,
} = useMemo(() => {
const tracker = {
hasActivatable: false,
hasArchivable: false,
hasCancelable: false,
hasKillable: false,
hasPausable: false,
hasUnarchivable: false,
};
for (let i = 0; i < selectedExperiments.length; i++) {
hkang1 marked this conversation as resolved.
Show resolved Hide resolved
const experiment = selectedExperiments[i];
const isArchivable = !experiment.archived && terminalRunStates.includes(experiment.state);
const isCancelable = cancellableRunStates.includes(experiment.state);
const isKillable = isTaskKillable(experiment);
const isActivatable = experiment.state === RunState.Paused;
const isPausable = experiment.state === RunState.Active;
if (!tracker.hasArchivable && isArchivable) tracker.hasArchivable = true;
if (!tracker.hasUnarchivable && experiment.archived) tracker.hasUnarchivable = true;
if (!tracker.hasCancelable && isCancelable) tracker.hasCancelable = true;
if (!tracker.hasKillable && isKillable) tracker.hasKillable = true;
if (!tracker.hasActivatable && isActivatable) tracker.hasActivatable = true;
if (!tracker.hasPausable && isPausable) tracker.hasPausable = true;
}
return tracker;
}, [ selectedExperiments ]);

const fetchExperiments = useCallback((): void => {
requestExperiments({});
}, [ requestExperiments ]);
Expand Down Expand Up @@ -79,6 +146,75 @@ const ExperimentList: React.FC = () => {
handleFilterChange({ ...filters, username });
}, [ filters, handleFilterChange ]);

const sendBatchActions = useCallback((action: Action): Promise<void[] | Command> => {
if (action === Action.OpenTensorBoard) {
return launchTensorboard({
ids: selectedExperiments.map(experiment => experiment.id),
type: TBSourceType.Experiment,
});
}
return Promise.all(selectedExperiments
.map(experiment => {
switch (action) {
case Action.Activate:
return setExperimentState({ experimentId: experiment.id, state: RunState.Active });
case Action.Archive:
return archiveExperiment(experiment.id, true);
case Action.Cancel:
return setExperimentState({ experimentId: experiment.id, state: RunState.Canceled });
case Action.Kill:
return killExperiment({ experimentId: experiment.id });
case Action.Pause:
return setExperimentState({ experimentId: experiment.id, state: RunState.Paused });
case Action.Unarchive:
return archiveExperiment(experiment.id, false);
default:
return Promise.resolve();
}
}));
}, [ selectedExperiments ]);

const handleBatchAction = useCallback(async (action: Action) => {
try {
const result = await sendBatchActions(action);
if (action === Action.OpenTensorBoard) {
const url = waitPageUrl(result as Command);
if (url) openBlank(setupUrlForDev(url));
}

// Refetch experiment list to get updates based on batch action.
await fetchExperiments();
} catch (e) {
const publicSubject = action === Action.OpenTensorBoard ?
'Unable to Open TensorBoard for Selected Experiments' :
`Unable to ${action} Selected Experiments`;
handleError({
error: e,
level: ErrorLevel.Error,
message: e.message,
publicMessage: 'Please try again later.',
publicSubject,
silent: false,
type: ErrorType.Server,
});
}
}, [ fetchExperiments, sendBatchActions ]);

const handleConfirmation = useCallback((action: Action) => {
Modal.confirm({
hkang1 marked this conversation as resolved.
Show resolved Hide resolved
content: `
Are you sure you want to ${action.toLocaleLowerCase()}
all the eligible selected experiments?
`,
icon: <ExclamationCircleOutlined />,
okText: action,
onOk: () => handleBatchAction(action),
title: 'Confirm Batch Action',
});
}, [ handleBatchAction ]);

const handleTableRowSelect = useCallback(rowKeys => setSelectedRowKeys(rowKeys), []);

const handleTableRow = useCallback((record: ExperimentItem) => ({
onClick: makeClickHandler(record.url as string),
}), []);
Expand All @@ -94,20 +230,51 @@ const ExperimentList: React.FC = () => {
prefix={<Icon name="search" size="small" />}
onChange={handleSearchChange} />
<div className={css.filters}>
<Toggle prefixLabel="Show Archived" onChange={handleArchiveChange} />
<Toggle
checked={filters.showArchived}
prefixLabel="Show Archived"
onChange={handleArchiveChange} />
<StateSelectFilter
showCommandStates={false}
value={filters.states}
onChange={handleStateChange} />
<UserSelectFilter value={filters.username} onChange={handleUserChange} />
</div>
</div>
<TableBatch message="Apply batch operations to multiple experiments." show={showBatch}>
<Button
type="primary"
onClick={(): Promise<void> => handleBatchAction(Action.OpenTensorBoard)}>
Open TensorBoard
</Button>
<Button
disabled={!hasActivatable}
onClick={(): void => handleConfirmation(Action.Activate)}>Activate</Button>
<Button
disabled={!hasPausable}
onClick={(): void => handleConfirmation(Action.Pause)}>Pause</Button>
<Button
disabled={!hasArchivable}
onClick={(): void => handleConfirmation(Action.Archive)}>Archive</Button>
<Button
disabled={!hasUnarchivable}
onClick={(): void => handleConfirmation(Action.Unarchive)}>Unarchive</Button>
<Button
disabled={!hasCancelable}
onClick={(): void => handleConfirmation(Action.Cancel)}>Cancel</Button>
<Button
danger
disabled={!hasKillable}
type="primary"
onClick={(): void => handleConfirmation(Action.Kill)}>Kill</Button>
</TableBatch>
<Table
columns={columns}
dataSource={filteredExperiments}
loading={!experimentsResponse.hasLoaded}
rowClassName={(): string => linkCss.base}
rowKey="id"
rowSelection={{ onChange: handleTableRowSelect, selectedRowKeys }}
size="small"
onRow={handleTableRow} />
</div>
Expand Down
35 changes: 25 additions & 10 deletions webui/react/src/pages/TaskList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Button, Input, notification, Table } from 'antd';
import axios from 'axios';
import { ExclamationCircleOutlined } from '@ant-design/icons';
import { Button, Input, Modal, Table } from 'antd';
import React, { useCallback, useMemo, useState } from 'react';

import Icon from 'components/Icon';
Expand All @@ -11,6 +11,7 @@ import TaskFilter from 'components/TaskFilter';
import Auth from 'contexts/Auth';
import { Commands, Notebooks, Shells, Tensorboards } from 'contexts/Commands';
import Users from 'contexts/Users';
import handleError, { ErrorLevel, ErrorType } from 'ErrorHandler';
import useStorage from 'hooks/useStorage';
import { killCommand } from 'services/api';
import { ALL_VALUE, CommandTask, CommandType, TaskFilters } from 'types';
Expand Down Expand Up @@ -98,29 +99,43 @@ const TaskList: React.FC = () => {

const handleBatchKill = useCallback(async () => {
try {
const source = axios.CancelToken.source();
const promises = selectedTasks.map(task => killCommand({
cancelToken: source.token,
commandId: task.id,
commandType: task.type,
}));
await Promise.all(promises);
} catch (e) {
notification.warn({
description: 'Please try again later.',
message: 'Unable to Kill Selected Tasks',
handleError({
error: e,
level: ErrorLevel.Error,
message: e.message,
publicMessage: 'Please try again later.',
publicSubject: 'Unable to Kill Selected Tasks',
silent: false,
type: ErrorType.Server,
});
}
}, [ selectedTasks ]);

const handleConfirmation = useCallback(() => {
Modal.confirm({
hkang1 marked this conversation as resolved.
Show resolved Hide resolved
content: `
Are you sure you want to kill
all the eligible selected experiments?
`,
icon: <ExclamationCircleOutlined />,
okText: 'Kill',
onOk: handleBatchKill,
title: 'Confirm Batch Kill',
});
}, [ handleBatchKill ]);

const handleTableRowSelect = useCallback(rowKeys => setSelectedRowKeys(rowKeys), []);

const handleTableRow = useCallback((record: CommandTask) => ({
onClick: canBeOpened(record) ? makeClickHandler(record.url as string) : undefined,
}), []);

// TODO select and batch operation:
// https://ant.design/components/table/#components-table-demo-row-selection-and-operation
return (
<Page title="Tasks">
<div className={css.base}>
Expand All @@ -142,7 +157,7 @@ const TaskList: React.FC = () => {
danger
disabled={!hasKillable}
type="primary"
onClick={handleBatchKill}>Kill</Button>
onClick={handleConfirmation}>Kill</Button>
</TableBatch>
<Table
columns={columns}
Expand Down
8 changes: 8 additions & 0 deletions webui/react/src/styles/antd.scss
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ html {

.ant-btn[disabled],
.ant-btn[disabled]:hover {
background-color: var(--theme-colors-monochrome-15);
border-color: var(--theme-colors-monochrome-12);
color: var(--theme-colors-monochrome-9);
}
.ant-btn-primary[disabled],
.ant-btn-primary[disabled]:hover,
.ant-btn-primary.ant-btn-dangerous[disabled],
.ant-btn-primary.ant-btn-dangerous[disabled]:hover {
background-color: var(--theme-colors-monochrome-11);
border-color: var(--theme-colors-monochrome-11);
color: var(--theme-colors-monochrome-17);
Expand Down
Loading