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: add tasks table component and task list page[DET-3221] #652

Merged
merged 19 commits into from
Jun 16, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion webui/react/src/components/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ const getColor = (name: string): string => {

const Avatar: React.FC<Props> = ({ name }: Props) => {
const style = { backgroundColor: getColor(name) };
return <div className={css.base} id="avatar" style={style}>{getInitials(name)}</div>;
return <div className={css.base} id="avatar" style={style} title={name}>
{getInitials(name)}
</div>;
};

export default Avatar;
8 changes: 5 additions & 3 deletions webui/react/src/components/Icon.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import React from 'react';

import { CommonProps } from 'types';

import css from './Icon.module.scss';

interface Props {
interface Props extends CommonProps {
name?: string;
size?: 'tiny' | 'small' | 'medium' | 'large';
}
Expand All @@ -12,13 +14,13 @@ const defaultProps: Props = {
size: 'medium',
};

const Icon: React.FC<Props> = ({ name, size }: Props) => {
const Icon: React.FC<Props> = ({ name, size, ...rest }: Props) => {
const classes = [ css.base ];

if (name) classes.push(`icon-${name}`);
if (size) classes.push(css[size]);

return <i className={classes.join(' ')} />;
return <i className={classes.join(' ')} {...rest} />;
};

Icon.defaultProps = defaultProps;
Expand Down
35 changes: 21 additions & 14 deletions webui/react/src/components/Link.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { PropsWithChildren, useCallback } from 'react';
import React, { MouseEventHandler, PropsWithChildren, useCallback } from 'react';

import { routeAll, setupUrlForDev } from 'routes';

Expand All @@ -9,21 +9,14 @@ interface Props {
inherit?: boolean;
path: string;
popout?: boolean;
onClick?: (event: React.MouseEvent) => void;
onClick?: MouseEventHandler;
}

const windowFeatures = [ 'noopener', 'noreferrer' ];

const Link: React.FC<Props> = ({
disabled, inherit, path, popout, onClick, children,
}: PropsWithChildren<Props>) => {
const classes = [ css.base ];
const rel = windowFeatures.join(' ');

if (!disabled) classes.push(css.link);
if (inherit) classes.push(css.inherit);

const handleClick = useCallback((event: React.MouseEvent): void => {
export const makeClickHandler = (path: string, onClick?: MouseEventHandler,
popout?: boolean): MouseEventHandler => {
const handler: MouseEventHandler = (event) => {
const url = setupUrlForDev(path);

event.persist();
Expand All @@ -36,10 +29,24 @@ const Link: React.FC<Props> = ({
} else {
routeAll(url);
}
}, [ onClick, path, popout ]);
};
return handler;
};

const Link: React.FC<Props> = ({
disabled, inherit, path, popout, onClick, children,
}: PropsWithChildren<Props>) => {
const classes = [ css.base ];
const rel = windowFeatures.join(' ');
const handleClick =
useCallback(makeClickHandler(path, onClick, popout), [ path, onClick, popout ]);

if (!disabled) classes.push(css.link);
if (inherit) classes.push(css.inherit);

hamidzr marked this conversation as resolved.
Show resolved Hide resolved
return (
<a className={classes.join(' ')} href={path} rel={rel} onClick={handleClick}>
<a className={classes.join(' ')} href={path} rel={rel}
onClick={handleClick}>
{children}
</a>
);
Expand Down
4 changes: 2 additions & 2 deletions webui/react/src/components/TaskActionDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ import Icon from 'components/Icon';
import Experiments from 'contexts/Experiments';
import handleError, { ErrorLevel, ErrorType } from 'ErrorHandler';
import { archiveExperiment, killTask, setExperimentState } from 'services/api';
import { Experiment, RecentTask, RunState, TaskType } from 'types';
import { Experiment, RunState, Task, TaskType } from 'types';
import { capitalize } from 'utils/string';
import { cancellableRunStates, isTaskKillable, terminalRunStates } from 'utils/types';

import css from './TaskActionDropdown.module.scss';

interface Props {
task: RecentTask;
task: Task;
}

const stopPropagation = (e: React.MouseEvent): void => e.stopPropagation();
Expand Down
3 changes: 2 additions & 1 deletion webui/react/src/components/TaskCard.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ export default {
const baseProps: RecentTask = {
id: '1a2f',
lastEvent: {
date: (new Date()).toString(),
date: (Date.now()).toString(),
name: 'opened',
},
ownerId: 5,
progress: 0.34,
startTime: (Date.now()).toString(),
state: RunState.Active,
title: 'I\'m a task',
type: TaskType.Experiment,
Expand Down
3 changes: 2 additions & 1 deletion webui/react/src/components/TaskCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import ProgressBar from 'components/ProgressBar';
import TaskActionDropdown from 'components/TaskActionDropdown';
import { RecentTask } from 'types';
import { percent } from 'utils/number';
import { canBeOpened } from 'utils/task';

import css from './TaskCard.module.scss';

Expand All @@ -20,7 +21,7 @@ const TaskCard: React.FC<RecentTask> = (props: RecentTask) => {

return (
<div className={classes.join(' ')}>
<Link disabled={!props.url} inherit path={props.url || '#'}>
<Link disabled={canBeOpened(props)} inherit path={props.url || '#'}>
{hasProgress && <div className={css.progressBar}>
<ProgressBar percent={(props.progress || 0) * 100} state={props.state} />
</div>}
Expand Down
6 changes: 6 additions & 0 deletions webui/react/src/components/TaskTable.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

.base {
tr:hover {
text-decoration: none;
}
}
28 changes: 28 additions & 0 deletions webui/react/src/components/TaskTable.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from 'react';

import { ExperimentsDecorator } from 'storybook/ConetextDecorators';
import RouterDecorator from 'storybook/RouterDecorator';
import { Task, TaskType } from 'types';
import { generateTasks } from 'utils/task';

import TaskTable from './TaskTable';

export default {
component: TaskTable,
decorators: [ RouterDecorator, ExperimentsDecorator ],
title: 'TaskTable',
};

const tasks: Task[] = generateTasks(20).filter(task => task.type !== TaskType.Experiment);

export const Default = (): React.ReactNode => {
return <TaskTable tasks={tasks} />;
};

export const Loading = (): React.ReactNode => {
return <TaskTable />;
};

export const LoadedNoRows = (): React.ReactNode => {
return <TaskTable tasks={[]} />;
};
100 changes: 100 additions & 0 deletions webui/react/src/components/TaskTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Table } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import React from 'react';
import TimeAgo from 'timeago-react';

import { CommandState, CommandType, CommonProps, Task } from 'types';
import { alphanumericSorter, commandStateSorter, stringTimeSorter } from 'utils/data';
import { canBeOpened } from 'utils/task';
import { commandTypeToLabel } from 'utils/types';

import Avatar from './Avatar';
import Badge from './Badge';
import { BadgeType } from './Badge';
import Icon from './Icon';
import { makeClickHandler } from './Link';
import linkCss from './Link.module.scss';
import TaskActionDropdown from './TaskActionDropdown';
import css from './TaskTable.module.scss';

interface Props extends CommonProps {
tasks?: Task[];
}

type Renderer<T> = (text: string, record: T, index: number) => React.ReactNode

const typeRenderer: Renderer<Task> = (_, record) =>
(<Icon name={record.type.toLowerCase()}
title={commandTypeToLabel[record.type as unknown as CommandType]} />);
const startTimeRenderer: Renderer<Task> = (_, record) => (
<span title={new Date(parseInt(record.startTime) * 1000).toTimeString()}>
<TimeAgo datetime={record.startTime} />
</span>
);
const stateRenderer: Renderer<Task> = (_, record) => (
<Badge state={record.state} type={BadgeType.State} />
);
const actionsRenderer: Renderer<Task> = (_, record) => (<TaskActionDropdown task={record} />);
const userRenderer: Renderer<Task> = (_, record) =>
(<Avatar name={record.username || record.id} />);

const columns: ColumnsType<Task> = [
{
dataIndex: 'id',
sorter: (a, b): number => alphanumericSorter(a.id, b.id),
title: 'ID',
},
{
render: typeRenderer,
sorter: (a, b): number => alphanumericSorter(a.type, b.type),
title: 'Type',
},
{
dataIndex: 'title',
sorter: (a, b): number => alphanumericSorter(a.title, b.title),
title: 'Description',
},
{
defaultSortOrder: 'descend',
render: startTimeRenderer,
sorter: (a, b): number => stringTimeSorter(a.startTime, b.startTime),
title: 'Start Time',
},
{
render: stateRenderer,
sorter: (a, b): number => commandStateSorter(a.state as CommandState, b.state as CommandState),
title: 'State',
},
{
render: userRenderer,
sorter: (a, b): number =>
alphanumericSorter(a.username || a.ownerId.toString(), b.username || b.ownerId.toString()),
title: 'User',
},
{
render: actionsRenderer,
title: '',
},
];

const TaskTable: React.FC<Props> = ({ tasks }: Props) => {
return (
<Table
className={css.base}
columns={columns}
dataSource={tasks}
loading={tasks === undefined}
rowClassName={(record) => canBeOpened(record) ? linkCss.base : ''}
rowKey="id"
onRow={(record) => {
return {
// can't use an actual link element on the whole row since anchor tag is not a valid
// direct tr child https://developer.mozilla.org/en-US/docs/Web/HTML/Element/tr
onClick: canBeOpened(record) ? makeClickHandler(record.url as string) : undefined,
};
}} />

);
};

export default TaskTable;
29 changes: 28 additions & 1 deletion webui/react/src/pages/TaskList.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,37 @@
import React from 'react';

import Page from 'components/Page';
import TaskTable from 'components/TaskTable';
import { Commands, Notebooks, Shells, Tensorboards } from 'contexts/Commands';
import { commandToTask } from 'utils/types';

const TaskList: React.FC = () => {
const commands = Commands.useStateContext();
const notebooks = Notebooks.useStateContext();
const shells = Shells.useStateContext();
const tensorboards = Tensorboards.useStateContext();

const sources = [
commands,
notebooks,
shells,
tensorboards,
];

const loadedTasks = sources
.filter(src => src.data !== undefined)
.map(src => src.data || [])
.reduce((acc, cur) => [ ...acc, ...cur ], [])
.map(commandToTask);

const hasLoaded = sources.find(src => src.hasLoaded);

// TODO select and batch operation:
// https://ant.design/components/table/#components-table-demo-row-selection-and-operation
return (
<Page title="Tasks" />
<Page title="Tasks">
<TaskTable tasks={hasLoaded ? loadedTasks : undefined} />
</Page>
);
};

Expand Down
10 changes: 4 additions & 6 deletions webui/react/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@ import { CancelToken } from 'axios';

import { generateApi } from 'services/apiBuilder';
import * as Config from 'services/apiConfig';
import { ExperimentsParams, KillCommandParams, KillExpParams, LaunchTensorboardParams,
PatchExperimentParams, PatchExperimentState } from 'services/types';
import {
CommandType, Credentials, DeterminedInfo, Experiment, RecentTask, TaskType, User,
} from 'types';
import { ExperimentsParams, KillCommandParams, KillExpParams,
LaunchTensorboardParams, PatchExperimentParams, PatchExperimentState } from 'services/types';
import { CommandType, Credentials, DeterminedInfo, Experiment, Task, TaskType, User } from 'types';

/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export const isAuthFailure = (e: any): boolean => {
Expand Down Expand Up @@ -36,7 +34,7 @@ export const launchTensorboard =
generateApi<LaunchTensorboardParams, void>(Config.launchTensorboard);

export const killTask =
async (task: RecentTask, cancelToken?: CancelToken): Promise<void> => {
async (task: Task, cancelToken?: CancelToken): Promise<void> => {
if (task.type === TaskType.Experiment) {
return killExperiment({ cancelToken, experimentId: parseInt(task.id) });
}
Expand Down
Loading