diff --git a/cvat-ui/src/actions/share-actions.ts b/cvat-ui/src/actions/share-actions.ts index 25a4aed5..1ceed81e 100644 --- a/cvat-ui/src/actions/share-actions.ts +++ b/cvat-ui/src/actions/share-actions.ts @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -28,16 +29,16 @@ const shareActions = { export type ShareActions = ActionUnion; -export function loadShareDataAsync(directory: string, success: () => void, failure: () => void): ThunkAction { - return async (dispatch): Promise => { +export function loadShareDataAsync(directory: string): ThunkAction { + return async (dispatch): Promise => { try { dispatch(shareActions.loadShareData()); const values = await core.server.share(directory); - success(); dispatch(shareActions.loadShareDataSuccess(values as ShareFileInfo[], directory)); + return (values as ShareFileInfo[]); } catch (error) { - failure(); dispatch(shareActions.loadShareDataFailed(error)); + throw error; } }; } diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index ace911b3..11c755c2 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -20,9 +20,6 @@ export enum TasksActionTypes { DELETE_TASK = 'DELETE_TASK', DELETE_TASK_SUCCESS = 'DELETE_TASK_SUCCESS', DELETE_TASK_FAILED = 'DELETE_TASK_FAILED', - CREATE_TASK = 'CREATE_TASK', - CREATE_TASK_STATUS_UPDATED = 'CREATE_TASK_STATUS_UPDATED', - CREATE_TASK_SUCCESS = 'CREATE_TASK_SUCCESS', CREATE_TASK_FAILED = 'CREATE_TASK_FAILED', UPDATE_TASK = 'UPDATE_TASK', UPDATE_TASK_SUCCESS = 'UPDATE_TASK_SUCCESS', @@ -144,26 +141,6 @@ export function deleteTaskAsync(taskInstance: any): ThunkAction, { }; } -function createTask(): AnyAction { - const action = { - type: TasksActionTypes.CREATE_TASK, - payload: {}, - }; - - return action; -} - -function createTaskSuccess(taskId: number): AnyAction { - const action = { - type: TasksActionTypes.CREATE_TASK_SUCCESS, - payload: { - taskId, - }, - }; - - return action; -} - function createTaskFailed(error: any): AnyAction { const action = { type: TasksActionTypes.CREATE_TASK_FAILED, @@ -175,19 +152,9 @@ function createTaskFailed(error: any): AnyAction { return action; } -function createTaskUpdateStatus(status: string): AnyAction { - const action = { - type: TasksActionTypes.CREATE_TASK_STATUS_UPDATED, - payload: { - status, - }, - }; - - return action; -} - -export function createTaskAsync(data: any): ThunkAction, {}, {}, AnyAction> { - return async (dispatch: ActionCreator): Promise => { +export function createTaskAsync(data: any, onProgress?: (status: string) => void): +ThunkAction, {}, {}, AnyAction> { + return async (dispatch): Promise => { const description: any = { name: data.basic.name, labels: data.labels, @@ -246,7 +213,7 @@ export function createTaskAsync(data: any): ThunkAction, {}, {}, A if (gitPlugin) { gitPlugin.callbacks.onStatusChange = (status: string): void => { - dispatch(createTaskUpdateStatus(status)); + onProgress?.(status); }; gitPlugin.data.task = taskInstance; gitPlugin.data.repos = data.advanced.repository; @@ -255,12 +222,10 @@ export function createTaskAsync(data: any): ThunkAction, {}, {}, A } } - dispatch(createTask()); try { const savedTask = await taskInstance.save((status: string, progress: number): void => { - dispatch(createTaskUpdateStatus(status + (progress !== null ? ` ${Math.floor(progress * 100)}%` : ''))); + onProgress?.(status + (progress !== null ? ` ${Math.floor(progress * 100)}%` : '')); }); - dispatch(createTaskSuccess(savedTask.id)); return savedTask; } catch (error) { dispatch(createTaskFailed(error)); diff --git a/cvat-ui/src/assets/multi-plus-icon.svg b/cvat-ui/src/assets/multi-plus-icon.svg new file mode 100644 index 00000000..ec93171e --- /dev/null +++ b/cvat-ui/src/assets/multi-plus-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cvat-ui/src/components/create-task-page/basic-configuration-form.tsx b/cvat-ui/src/components/create-task-page/basic-configuration-form.tsx index a30f8b6a..01fdae32 100644 --- a/cvat-ui/src/components/create-task-page/basic-configuration-form.tsx +++ b/cvat-ui/src/components/create-task-page/basic-configuration-form.tsx @@ -1,37 +1,56 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT import React, { RefObject } from 'react'; import Input from 'antd/lib/input'; +import Text from 'antd/lib/typography/Text'; +import Tooltip from 'antd/lib/tooltip'; import Form, { FormInstance } from 'antd/lib/form'; -import { Store } from 'antd/lib/form/interface'; +import { QuestionCircleOutlined } from '@ant-design/icons'; export interface BaseConfiguration { name: string; } interface Props { - onSubmit(values: BaseConfiguration): void; + onChange(values: BaseConfiguration): void; + many: boolean; + exampleMultiTaskName?: string; } export default class BasicConfigurationForm extends React.PureComponent { private formRef: RefObject; private inputRef: RefObject; + private initialName: string; public constructor(props: Props) { super(props); this.formRef = React.createRef(); this.inputRef = React.createRef(); + + const { many } = this.props; + this.initialName = many ? '{{file_name}}' : ''; + } + + componentDidMount(): void { + const { onChange } = this.props; + onChange({ + name: this.initialName, + }); + } + + private handleChangeName(e: React.ChangeEvent): void { + const { onChange } = this.props; + onChange({ + name: e.target.value, + }); } public submit(): Promise { - const { onSubmit } = this.props; if (this.formRef.current) { - return this.formRef.current.validateFields().then((values: Store): Promise => { - onSubmit({ name: values.name }); - return Promise.resolve(); - }); + return this.formRef.current.validateFields(); } return Promise.reject(new Error('Form ref is empty')); @@ -50,6 +69,8 @@ export default class BasicConfigurationForm extends React.PureComponent { } public render(): JSX.Element { + const { many, exampleMultiTaskName } = this.props; + return (
{ message: 'Task name cannot be empty', }, ]} + initialValue={this.initialName} > - + this.handleChangeName(e)} + /> + {many ? ( + + ( + <> + You can substitute in the template: +
    +
  • + some_text - any text +
  • +
  • + {'{{'} + index + {'}}'} +  - index file in set +
  • +
  • + {'{{'} + file_name + {'}}'} +  - name of file +
  • +
+ Example:  + + {exampleMultiTaskName || 'Task name 1 - video_1.mp4'} + + + )} + > + When forming the name, a template is used. + {' '} + +
+
+ ) : null}
); } diff --git a/cvat-ui/src/components/create-task-page/create-task-content.tsx b/cvat-ui/src/components/create-task-page/create-task-content.tsx index eefcc589..db2e1077 100644 --- a/cvat-ui/src/components/create-task-page/create-task-content.tsx +++ b/cvat-ui/src/components/create-task-page/create-task-content.tsx @@ -7,11 +7,11 @@ import React, { RefObject } from 'react'; import { RouteComponentProps } from 'react-router'; import { withRouter } from 'react-router-dom'; import { Row, Col } from 'antd/lib/grid'; -import Alert from 'antd/lib/alert'; import Button from 'antd/lib/button'; import Collapse from 'antd/lib/collapse'; import notification from 'antd/lib/notification'; import Text from 'antd/lib/typography/Text'; +import Alert from 'antd/lib/alert'; // eslint-disable-next-line import/no-extraneous-dependencies import { ValidateErrorEntity } from 'rc-field-form/lib/interface'; import { StorageLocation } from 'reducers'; @@ -19,11 +19,20 @@ import { getCore, Storage } from 'cvat-core-wrapper'; import ConnectedFileManager from 'containers/file-manager/file-manager'; import LabelsEditor from 'components/labels-editor/labels-editor'; import { Files } from 'components/file-manager/file-manager'; + +import { + getFileContentType, + getContentTypeRemoteFile, + getFileNameFromPath, +} from 'utils/files'; + import BasicConfigurationForm, { BaseConfiguration } from './basic-configuration-form'; import ProjectSearchField from './project-search-field'; import ProjectSubsetField from './project-subset-field'; +import MultiTasksProgress from './multi-task-progress'; import AdvancedConfigurationForm, { AdvancedConfiguration, SortingMethod } from './advanced-configuration-form'; +type TabName = 'local' | 'share' | 'remote' | 'cloudStorage'; const core = getCore(); export interface CreateTaskData { @@ -33,22 +42,28 @@ export interface CreateTaskData { advanced: AdvancedConfiguration; labels: any[]; files: Files; - activeFileManagerTab: string; + activeFileManagerTab: TabName; cloudStorageId: number | null; } interface Props { - onCreate: (data: CreateTaskData) => void; - status: string; - taskId: number | null; + onCreate: (data: CreateTaskData, onProgress?: (status: string, progress?: number) => void) => Promise; projectId: number | null; installedGit: boolean; - dumpers:[] + dumpers:[]; + many: boolean; } -type State = CreateTaskData; +type State = CreateTaskData & { + multiTasks: (CreateTaskData & { + status: 'pending' | 'progress' | 'failed' | 'completed' | 'cancelled'; + })[]; + uploadFileErrorMessage: string; + loading: boolean; + statusInProgressTask: string; +}; -const defaultState = { +const defaultState: State = { projectId: null, basic: { name: '', @@ -79,6 +94,15 @@ const defaultState = { }, activeFileManagerTab: 'local', cloudStorageId: null, + multiTasks: [], + uploadFileErrorMessage: '', + loading: false, + statusInProgressTask: '', +}; + +const UploadFileErrorMessages = { + one: 'It can not be processed. You can upload an archive with images, a video or multiple images', + multi: 'It can not be processed. You can upload one or more videos', }; class CreateTaskContent extends React.PureComponent { @@ -132,23 +156,36 @@ class CreateTaskContent extends React.PureComponent { - const { activeFileManagerTab } = this.state; - const files = this.fileManagerContainer.getFiles(); - - this.setState({ - files, - }); + const { activeFileManagerTab, files } = this.state; if (activeFileManagerTab === 'cloudStorage') { this.setState({ cloudStorageId: this.fileManagerContainer.getCloudStorageId(), }); } - const totalLen = Object.keys(files).reduce((acc, key) => acc + files[key].length, 0); + const totalLen = Object.keys(files).reduce((acc, key: string) => acc + files[(key as TabName)].length, 0); return !!totalLen; }; + private startLoading = (): void => { + this.setState({ + loading: true, + }); + }; + + private stopLoading = (): void => { + this.setState({ + loading: false, + }); + }; + + private changeStatusInProgressTask = (status: string): void => { + this.setState({ + statusInProgressTask: status, + }); + }; + private handleProjectIdChange = (value: null | number): void => { const { projectId, subset } = this.state; @@ -159,7 +196,7 @@ class CreateTaskContent extends React.PureComponent { + private handleChangeBasicConfiguration = (values: BaseConfiguration): void => { this.setState({ basic: { ...values }, }); @@ -177,11 +214,9 @@ class CreateTaskContent extends React.PureComponent { - const values = this.state; + private changeFileManagerTab = (value: TabName): void => { this.setState({ - ...values, - activeFileManagerTab: key, + activeFileManagerTab: value, }); }; @@ -207,33 +242,125 @@ class CreateTaskContent extends React.PureComponent { - const { history } = this.props; + private handleUploadLocalFiles = (uploadedFiles: File[]): void => { + const { many } = this.props; + const { files } = this.state; - this.handleSubmit() - .then((createdTask) => { - const { id } = createdTask; - history.push(`/tasks/${id}`); - }) - .catch(() => {}); + let uploadFileErrorMessage = ''; + + if (!many && uploadedFiles.length > 1) { + uploadFileErrorMessage = uploadedFiles.every((it) => (getFileContentType(it) === 'image' || it.name === 'manifest.jsonl')) ? '' : UploadFileErrorMessages.one; + } else if (many) { + uploadFileErrorMessage = uploadedFiles.every((it) => getFileContentType(it) === 'video') ? '' : UploadFileErrorMessages.multi; + } + + this.setState({ + uploadFileErrorMessage, + }); + + if (!uploadFileErrorMessage) { + this.setState({ + files: { + ...files, + local: uploadedFiles, + }, + }); + } }; - private handleSubmitAndContinue = (): void => { - this.handleSubmit() - .then(() => { - notification.info({ - message: 'The task has been created', - className: 'cvat-notification-create-task-success', - }); - }) - .then(this.resetState) - .then(this.focusToForm) - .catch(() => {}); + private handleUploadRemoteFiles = async (urls: string[]): Promise => { + const { many } = this.props; + + const { files } = this.state; + const { length } = urls; + + let uploadFileErrorMessage = ''; + + try { + if (!many && length > 1) { + let index = 0; + while (index < length) { + const isImageFile = await getContentTypeRemoteFile(urls[index]) === 'image'; + if (!isImageFile) { + uploadFileErrorMessage = UploadFileErrorMessages.one; + break; + } + index++; + } + } else if (many) { + let index = 0; + while (index < length) { + const isVideoFile = await getContentTypeRemoteFile(urls[index]) === 'video'; + if (!isVideoFile) { + uploadFileErrorMessage = UploadFileErrorMessages.multi; + break; + } + index++; + } + } + } catch (err) { + uploadFileErrorMessage = `We can't process it. ${err}`; + } + + this.setState({ + uploadFileErrorMessage, + }); + + if (!uploadFileErrorMessage) { + this.setState({ + files: { + ...files, + remote: urls, + }, + }); + } }; - private handleSubmit = (): Promise => new Promise((resolve, reject) => { - const { projectId } = this.state; + private handleUploadShareFiles = (shareFiles: { + key: string; + type: string; + mime_type: string; + }[]): void => { + const { many } = this.props; + const { files } = this.state; + + let uploadFileErrorMessage = ''; + + if (!many && shareFiles.length > 1) { + uploadFileErrorMessage = shareFiles.every((it) => it.mime_type === 'image') ? + '' : UploadFileErrorMessages.one; + } else if (many) { + uploadFileErrorMessage = shareFiles.every((it) => it.mime_type === 'video') ? + '' : UploadFileErrorMessages.multi; + } + + this.setState({ + uploadFileErrorMessage, + }); + if (!uploadFileErrorMessage) { + this.setState({ + files: { + ...files, + share: shareFiles.map((it) => it.key), + }, + }); + } + }; + + private handleUploadCloudStorageFiles = (cloudStorageFiles: string[]): void => { + const { files } = this.state; + + this.setState({ + files: { + ...files, + cloudStorage: cloudStorageFiles, + }, + }); + }; + + private validateBlocks = (): Promise => new Promise((resolve, reject) => { + const { projectId } = this.state; if (!this.validateLabelsOrProject()) { notification.error({ message: 'Could not create a task', @@ -287,13 +414,7 @@ class CreateTaskContent extends React.PureComponent { - const { onCreate } = this.props; - return onCreate(this.state); - }) - .then((cratedTask) => { - resolve(cratedTask); - }) + .then(resolve) .catch((error: Error | ValidateErrorEntity): void => { notification.error({ message: 'Could not create a task', @@ -308,12 +429,252 @@ class CreateTaskContent extends React.PureComponent { + const { history } = this.props; + + this.validateBlocks() + .then(this.createOneTask) + .then((createdTask) => { + const { id } = createdTask; + history.push(`/tasks/${id}`); + }) + .catch(() => {}); + }; + + private handleSubmitAndContinue = (): void => { + this.validateBlocks() + .then(this.createOneTask) + .then(() => { + notification.info({ + message: 'The task has been created', + className: 'cvat-notification-create-task-success', + }); + }) + .then(this.resetState) + .then(this.focusToForm) + .catch(() => {}); + }; + + private createOneTask = (): Promise => { + const { onCreate } = this.props; + this.startLoading(); + return onCreate(this.state, this.changeStatusInProgressTask) + .finally(this.stopLoading); + }; + + private setStatusOneOfMultiTasks = async (index: number, status: string): Promise => { + const { multiTasks } = this.state; + const resultTask = { + ...multiTasks[index], + status, + }; + + return new Promise((resolve) => { + const newMultiTasks: any = [ + ...multiTasks.slice(0, index), + resultTask, + ...multiTasks.slice(index + 1), + ]; + this.setState({ + multiTasks: newMultiTasks, + }, resolve); + }); + }; + + private createOneOfMultiTasks = async (index: any): Promise => { + const { onCreate } = this.props; + const { multiTasks } = this.state; + const task = multiTasks[index]; + + if (task.status !== 'pending') return; + + await this.setStatusOneOfMultiTasks(index, 'progress'); + try { + await onCreate(task); + await this.setStatusOneOfMultiTasks(index, 'completed'); + } catch (err) { + console.warn(err); + await this.setStatusOneOfMultiTasks(index, 'failed'); + } + }; + + private createMultiTasks = async (): Promise => { + const { multiTasks } = this.state; + this.startLoading(); + const { length } = multiTasks; + let index = 0; + const queueSize = 1; + const promises = Array(queueSize) + .fill(undefined) + .map(async (): Promise => { + // eslint-disable-next-line no-constant-condition + while (true) { + index++; // preliminary increase is needed to avoid using the same index when queueSize > 1 + if (index > length) break; + await this.createOneOfMultiTasks(index - 1); + } + }); + await Promise.allSettled(promises); + this.stopLoading(); + }; + + private addMultiTasks = async (): Promise => new Promise((resolve) => { + const { + projectId, + subset, + advanced, + labels, + files: allFiles, + activeFileManagerTab, + cloudStorageId, + } = this.state; + + const files: (File | string)[] = allFiles[activeFileManagerTab]; + + this.setState({ + multiTasks: files.map((file, index) => ({ + projectId, + basic: { + name: this.getTaskName(index, activeFileManagerTab), + }, + subset, + advanced, + labels, + files: { + ...defaultState.files, + [activeFileManagerTab]: [file], + }, + activeFileManagerTab, + cloudStorageId, + status: 'pending', + } + )), + }, resolve); + }); + + private handleSubmitMutliTasks = (): void => { + this.validateBlocks() + .then(() => { + this.addMultiTasks(); + }) + .then(this.createMultiTasks) + .then(() => { + const { multiTasks } = this.state; + const countCompleted = multiTasks.filter((item) => item.status === 'completed').length; + const countFailed = multiTasks.filter((item) => item.status === 'failed').length; + const countCancelled = multiTasks.filter((item) => item.status === 'cancelled').length; + const countAll = multiTasks.length; + + notification.info({ + message: 'The tasks have been created', + description: + `Completed: ${countCompleted}, failed: ${countFailed},${countCancelled ? + ` cancelled: ${countCancelled},` : + ''} total: ${countAll}, `, + className: 'cvat-notification-create-task-success', + }); + }); + }; + + private handleCancelMultiTasks = (): void => { + const { multiTasks } = this.state; + let count = 0; + const newMultiTasks: any = multiTasks.map((it) => { + if (it.status === 'pending') { + count++; + return { + ...it, + status: 'cancelled', + }; + } + return it; + }); + this.setState({ + multiTasks: newMultiTasks, + }, () => { + notification.info({ + message: `Creation of ${count} tasks have been canceled`, + className: 'cvat-notification-create-task-success', + }); + }); + }; + + private handleOkMultiTasks = (): void => { + const { history } = this.props; + history.push('/tasks/'); + }; + + private handleRetryCancelledMultiTasks = (): void => { + const { multiTasks } = this.state; + const newMultiTasks: any = multiTasks.map((it) => { + if (it.status === 'cancelled') { + return { + ...it, + status: 'pending', + }; + } + return it; + }); + this.setState({ + multiTasks: newMultiTasks, + }, () => { + this.createMultiTasks(); + }); + }; + + private handleRetryFailedMultiTasks = (): void => { + const { multiTasks } = this.state; + const newMultiTasks: any = multiTasks.map((it) => { + if (it.status === 'failed') { + return { + ...it, + status: 'pending', + }; + } + return it; + }); + this.setState({ + multiTasks: newMultiTasks, + }, () => { + this.createMultiTasks(); + }); + }; + + private getTaskName = (indexFile: number, fileManagerTabName: TabName, defaultFileName = ''): string => { + const { many } = this.props; + const { basic } = this.state; + const { files } = this.state; + const file = files[fileManagerTabName][indexFile]; + let fileName = defaultFileName; + switch (fileManagerTabName) { + case 'remote': + fileName = getFileNameFromPath(file as string) || defaultFileName; + break; + case 'share': + fileName = getFileNameFromPath(file as string) || defaultFileName; + break; + default: + fileName = (file as File)?.name || (file as string) || defaultFileName; + break; + } + return many ? + basic.name + .replaceAll('{{file_name}}', fileName) + .replaceAll('{{index}}', indexFile.toString()) : + basic.name; + }; + private renderBasicBlock(): JSX.Element { + const { many } = this.props; + const exampleMultiTaskName = many ? this.getTaskName(0, 'local', 'fileName.mp4') : ''; + return ( ); @@ -390,17 +751,37 @@ class CreateTaskContent extends React.PureComponent - * - Select files - { - this.fileManagerContainer = container; - }} - /> - + <> + + * + Select files + { + this.fileManagerContainer = container; + }} + /> + + { uploadFileErrorMessage ? ( + + + + ) : null } + ); } @@ -450,16 +831,21 @@ class CreateTaskContent extends React.PureComponent); + } return ( - - @@ -467,9 +853,45 @@ class CreateTaskContent extends React.PureComponent item.status === 'pending').length; + const countAll = items.length; + + if ((loading || countPending !== countAll) && currentFiles.length) { + return ( + + ); + } + + return ( + + + + + + ); + } + public render(): JSX.Element { - const { status } = this.props; - const loading = !!status && status !== 'CREATED' && status !== 'FAILED'; + const { many } = this.props; return ( @@ -485,7 +907,7 @@ class CreateTaskContent extends React.PureComponent - {loading ? : this.renderActions()} + {many ? this.renderFooterMutliTasks() : this.renderFooterSingleTask() } ); diff --git a/cvat-ui/src/components/create-task-page/create-task-page.tsx b/cvat-ui/src/components/create-task-page/create-task-page.tsx index 31d242d4..f6ad2fb0 100644 --- a/cvat-ui/src/components/create-task-page/create-task-page.tsx +++ b/cvat-ui/src/components/create-task-page/create-task-page.tsx @@ -1,9 +1,10 @@ // Copyright (C) 2019-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT import './styles.scss'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useLocation } from 'react-router'; import { Row, Col } from 'antd/lib/grid'; import Modal from 'antd/lib/modal'; @@ -14,26 +15,30 @@ import TextArea from 'antd/lib/input/TextArea'; import CreateTaskContent, { CreateTaskData } from './create-task-content'; interface Props { - onCreate: (data: CreateTaskData) => void; - status: string; - error: string; - taskId: number | null; + onCreate: (data: CreateTaskData, onProgress?: (status: string) => void) => Promise; installedGit: boolean; dumpers: [] } export default function CreateTaskPage(props: Props): JSX.Element { const { - error, status, taskId, onCreate, installedGit, dumpers, + onCreate, installedGit, dumpers, } = props; const location = useLocation(); + const [error, setError] = useState(''); let projectId = null; const params = new URLSearchParams(location.search); if (params.get('projectId')?.match(/^[1-9]+[0-9]*$/)) { projectId = +(params.get('projectId') as string); } + const many = params.get('many') === 'true'; + const handleCreate: typeof onCreate = (...onCreateParams) => onCreate(...onCreateParams) + .catch((err) => { + setError(err.toString()); + throw err; + }); useEffect(() => { if (error) { @@ -75,12 +80,11 @@ export default function CreateTaskPage(props: Props): JSX.Element { Create a new task diff --git a/cvat-ui/src/components/create-task-page/multi-task-progress.tsx b/cvat-ui/src/components/create-task-page/multi-task-progress.tsx new file mode 100644 index 00000000..26ca8b6d --- /dev/null +++ b/cvat-ui/src/components/create-task-page/multi-task-progress.tsx @@ -0,0 +1,159 @@ +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import React from 'react'; +import Alert from 'antd/lib/alert'; +import Progress from 'antd/lib/progress'; +import Row from 'antd/lib/row'; +import Col from 'antd/lib/col'; +import Button from 'antd/lib/button'; +import Collapse from 'antd/lib/collapse'; +import Text from 'antd/lib/typography/Text'; +import List from 'antd/lib/list'; + +interface Props { + tasks: any[]; + onCancel: () => void; + onOk: () => void; + onRetryFailedTasks: () => void; + onRetryCancelledTasks: () => void; +} + +export default function MultiTasksProgress(props: Props): JSX.Element { + const { + tasks: items, + onOk, + onCancel, + onRetryFailedTasks, + onRetryCancelledTasks, + } = props; + let alertType: any = 'info'; + + const countPending = items.filter((item) => item.status === 'pending').length; + const countProgress = items.filter((item) => item.status === 'progress').length; + const countCompleted = items.filter((item) => item.status === 'completed').length; + const countFailed = items.filter((item) => item.status === 'failed').length; + const countCancelled = items.filter((item) => item.status === 'cancelled').length; + const countAll = items.length; + const percent = countAll ? + Math.ceil(((countAll - (countPending + countProgress)) / countAll) * 100) : + 0; + + const failedFiles: string[] = percent === 100 && countFailed ? + items.filter((item) => item.status === 'failed') + .map((item): string => { + const tabs = Object.keys(item.files); + const itemType = tabs.find((key) => (item.files[key][0])) || 'local'; + return item.files[itemType][0]?.name || item.files[itemType][0] || ''; + }) + .filter(Boolean) : + []; + + if (percent === 100) { + if (countFailed === countAll) { + alertType = 'error'; + } else if (countFailed) { + alertType = 'warning'; + } + } + + return ( + + {percent === 100 ? ( + + + Finished + + + ) : null} + + + {`Pending: ${countPending} `} + + + {`Progress: ${countProgress} `} + + + {`Completed: ${countCompleted} `} + + + {`Failed: ${countFailed} `} + + {countCancelled ? ({`Cancelled: ${countCancelled} `}) : null} + + {`Total: ${countAll}.`} + + + +
+ {percent === 100 && countFailed ? ( + + + + Failed files + + )} + key='appearance' + > + { item }} + /> + + + + ) : null } + + {percent === 100 ? + ( + <> + + + + { + countCancelled ? ( + + + + ) : null + } + + + + + ) : ( + + + + )} + + + )} + /> + ); +} diff --git a/cvat-ui/src/components/file-manager/file-manager.tsx b/cvat-ui/src/components/file-manager/file-manager.tsx index ebabc7a8..d0d71bc1 100644 --- a/cvat-ui/src/components/file-manager/file-manager.tsx +++ b/cvat-ui/src/components/file-manager/file-manager.tsx @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -9,17 +10,17 @@ import Tabs from 'antd/lib/tabs'; import Input from 'antd/lib/input'; import Text from 'antd/lib/typography/Text'; import Paragraph from 'antd/lib/typography/Paragraph'; -import Upload, { RcFile } from 'antd/lib/upload'; +import { RcFile } from 'antd/lib/upload'; import Empty from 'antd/lib/empty'; import Tree, { TreeNodeNormal } from 'antd/lib/tree/Tree'; import { FormInstance } from 'antd/lib/form'; // eslint-disable-next-line import/no-extraneous-dependencies import { EventDataNode } from 'rc-tree/lib/interface'; -import { InboxOutlined } from '@ant-design/icons'; import consts from 'consts'; import { CloudStorage } from 'reducers'; import CloudStorageTab from './cloud-storages-tab'; +import LocalFiles from './local-files'; export interface Files { local: File[]; @@ -37,9 +38,15 @@ interface State { } interface Props { - treeData: TreeNodeNormal[]; - onLoadData: (key: string, success: () => void, failure: () => void) => void; + treeData: (TreeNodeNormal & { mime_type: string })[]; + share: any; + many: boolean; + onLoadData: (key: string) => Promise; onChangeActiveKey(key: string): void; + onUploadLocalFiles(files: File[]): void; + onUploadRemoteFiles(urls: string[]): void; + onUploadShareFiles(keys: string[]): Promise; + onUploadCloudStorageFiles(cloudStorageFiles: string[]): void; } export class FileManager extends React.PureComponent { @@ -48,6 +55,7 @@ export class FileManager extends React.PureComponent { public constructor(props: Props) { super(props); this.cloudStorageTabFormRef = React.createRef(); + const { onLoadData } = this.props; this.state = { files: { @@ -62,17 +70,19 @@ export class FileManager extends React.PureComponent { active: 'local', }; - this.loadData('/'); + onLoadData('/'); } - private onSelectCloudStorageFiles = (cloudStorageFiles: string[]): void => { + private handleUploadCloudStorageFiles = (cloudStorageFiles: string[]): void => { const { files } = this.state; + const { onUploadCloudStorageFiles } = this.props; this.setState({ files: { ...files, cloudStorage: cloudStorageFiles, }, }); + onUploadCloudStorageFiles(cloudStorageFiles); }; public getCloudStorageId(): number | null { @@ -90,14 +100,6 @@ export class FileManager extends React.PureComponent { }; } - private loadData = (key: string): Promise => new Promise((resolve, reject): void => { - const { onLoadData } = this.props; - - const success = (): void => resolve(); - const failure = (): void => reject(); - onLoadData(key, success, failure); - }); - public reset(): void { const { active } = this.state; if (active === 'cloudStorage') { @@ -118,65 +120,43 @@ export class FileManager extends React.PureComponent { } private renderLocalSelector(): JSX.Element { + const { many, onUploadLocalFiles } = this.props; const { files } = this.state; return ( - { + { this.setState({ files: { ...files, local: newLocalFiles, }, }); + onUploadLocalFiles(newLocalFiles); return false; }} - > -

- -

-

Click or drag files to this area

-

Support for a bulk images or a single video

-
- {files.local.length >= 5 && ( - <> -
- {`${files.local.length} files selected`} - - )} + />
); } private renderShareSelector(): JSX.Element { - function renderTreeNodes(data: TreeNodeNormal[]): JSX.Element[] { + function getTreeNodes(data: TreeNodeNormal[]): TreeNodeNormal[] { // sort alphabetically - data.sort((a: TreeNodeNormal, b: TreeNodeNormal): number => ( - a.key.toLocaleString().localeCompare(b.key.toLocaleString()))); - return data.map((item: TreeNodeNormal) => { - if (item.children) { - return ( - - {renderTreeNodes(item.children)} - - ); - } - - return ; - }); + return data + .sort((a: TreeNodeNormal, b: TreeNodeNormal): number => ( + a.key.toLocaleString().localeCompare(b.key.toLocaleString()))) + .map((it) => ({ + ...it, + children: it.children ? getTreeNodes(it.children) : undefined, + })); } const { SHARE_MOUNT_GUIDE_URL } = consts; - const { treeData } = this.props; + const { treeData, onUploadShareFiles, onLoadData } = this.props; const { expandedKeys, files } = this.state; return ( @@ -190,7 +170,7 @@ export class FileManager extends React.PureComponent { checkStrictly={false} expandedKeys={expandedKeys} checkedKeys={files.share} - loadData={(event: EventDataNode): Promise => this.loadData(event.key.toLocaleString())} + loadData={(event: EventDataNode): Promise => onLoadData(event.key.toLocaleString())} onExpand={(newExpandedKeys: ReactText[]): void => { this.setState({ expandedKeys: newExpandedKeys.map((text: ReactText): string => text.toLocaleString()), @@ -212,10 +192,10 @@ export class FileManager extends React.PureComponent { share: keys, }, }); + onUploadShareFiles(keys).then().catch(); }} - > - {renderTreeNodes(treeData)} - + treeData={getTreeNodes(treeData)} + /> ) : (
@@ -233,6 +213,7 @@ export class FileManager extends React.PureComponent { } private renderRemoteSelector(): JSX.Element { + const { onUploadRemoteFiles } = this.props; const { files } = this.state; return ( @@ -243,12 +224,14 @@ export class FileManager extends React.PureComponent { rows={6} value={[...files.remote].join('\n')} onChange={(event: React.ChangeEvent): void => { + const urls = event.target.value.split('\n'); this.setState({ files: { ...files, - remote: event.target.value.split('\n'), + remote: urls, }, }); + onUploadRemoteFiles(urls.filter(Boolean)); }} /> @@ -274,14 +257,14 @@ export class FileManager extends React.PureComponent { setSearchPhrase={(_potentialCloudStorage: string) => { this.setState({ potentialCloudStorage: _potentialCloudStorage }); }} - onSelectFiles={this.onSelectCloudStorageFiles} + onSelectFiles={this.handleUploadCloudStorageFiles} /> ); } public render(): JSX.Element { - const { onChangeActiveKey } = this.props; + const { onChangeActiveKey, many } = this.props; const { active } = this.state; return ( @@ -300,7 +283,7 @@ export class FileManager extends React.PureComponent { {this.renderLocalSelector()} {this.renderShareSelector()} {this.renderRemoteSelector()} - {this.renderCloudStorageSelector()} + {!many && this.renderCloudStorageSelector()} ); diff --git a/cvat-ui/src/components/file-manager/local-files.tsx b/cvat-ui/src/components/file-manager/local-files.tsx new file mode 100644 index 00000000..546ca7c3 --- /dev/null +++ b/cvat-ui/src/components/file-manager/local-files.tsx @@ -0,0 +1,50 @@ +// Copyright (C) 2022 Intel Corporation +// Copyright (C) 2022 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import React from 'react'; + +import Text from 'antd/lib/typography/Text'; +import Upload, { RcFile } from 'antd/lib/upload'; +import { InboxOutlined } from '@ant-design/icons'; + +interface Props { + files: File[]; + many: boolean; + onUpload: (_: RcFile, uploadedFiles: RcFile[]) => boolean; +} + +export default function LocalFiles(props: Props): JSX.Element { + const { files, onUpload, many } = props; + const hintText = many ? 'You can upload one or more videos' : + 'You can upload an archive with images, a video, or multiple images'; + + return ( + <> + +

+ +

+

Click or drag files to this area

+

{ hintText }

+
+ {files.length >= 5 && ( + <> +
+ {`${files.length} files selected`} + + )} + + ); +} diff --git a/cvat-ui/src/components/tasks-page/top-bar.tsx b/cvat-ui/src/components/tasks-page/top-bar.tsx index 213a29ef..d7aa1bf3 100644 --- a/cvat-ui/src/components/tasks-page/top-bar.tsx +++ b/cvat-ui/src/components/tasks-page/top-bar.tsx @@ -19,6 +19,7 @@ import { usePrevious } from 'utils/hooks'; import { localStorageRecentKeyword, localStorageRecentCapacity, predefinedFilterValues, config, } from './tasks-filter-configuration'; +import { MutliPlusIcon } from '../../icons'; const FilteringComponent = ResourceFilterHOC( config, localStorageRecentKeyword, localStorageRecentCapacity, predefinedFilterValues, @@ -101,6 +102,14 @@ export default function TopBarComponent(props: VisibleTopBarProps): JSX.Element > Create a new task +