UI support cloud storage (#3372)

Co-authored-by: Boris Sekachev <boris.sekachev@intel.com>
main
Maria Khrustaleva 4 years ago committed by GitHub
parent 6df808dfce
commit 9a53879a8d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Support cloud storage status (<https://github.com/openvinotoolkit/cvat/pull/3386>) - Support cloud storage status (<https://github.com/openvinotoolkit/cvat/pull/3386>)
- Support cloud storage preview (<https://github.com/openvinotoolkit/cvat/pull/3386>) - Support cloud storage preview (<https://github.com/openvinotoolkit/cvat/pull/3386>)
- cvat-core: support cloud storages (<https://github.com/openvinotoolkit/cvat/pull/3313>) - cvat-core: support cloud storages (<https://github.com/openvinotoolkit/cvat/pull/3313>)
- cvat-ui: support cloud storages (<https://github.com/openvinotoolkit/cvat/pull/3372>)
### Changed ### Changed

@ -24,7 +24,6 @@ function build() {
const { FrameData } = require('./frames'); const { FrameData } = require('./frames');
const { CloudStorage } = require('./cloud-storage'); const { CloudStorage } = require('./cloud-storage');
const enums = require('./enums'); const enums = require('./enums');
const { const {

@ -1,6 +1,6 @@
{ {
"name": "cvat-ui", "name": "cvat-ui",
"version": "1.23.1", "version": "1.24.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {

@ -1,6 +1,6 @@
{ {
"name": "cvat-ui", "name": "cvat-ui",
"version": "1.23.1", "version": "1.24.0",
"description": "CVAT single-page application", "description": "CVAT single-page application",
"main": "src/index.tsx", "main": "src/index.tsx",
"scripts": { "scripts": {

@ -0,0 +1,194 @@
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import { Dispatch, ActionCreator } from 'redux';
import { ActionUnion, createAction, ThunkAction } from 'utils/redux';
import getCore from 'cvat-core-wrapper';
import { CloudStoragesQuery, CloudStorage } from 'reducers/interfaces';
const cvat = getCore();
export enum CloudStorageActionTypes {
UPDATE_CLOUD_STORAGES_GETTING_QUERY = 'UPDATE_CLOUD_STORAGES_GETTING_QUERY',
GET_CLOUD_STORAGES = 'GET_CLOUD_STORAGES',
GET_CLOUD_STORAGE_SUCCESS = 'GET_CLOUD_STORAGES_SUCCESS',
GET_CLOUD_STORAGE_FAILED = 'GET_CLOUD_STORAGES_FAILED',
GET_CLOUD_STORAGE_STATUS = 'GET_CLOUD_STORAGE_STATUS',
GET_CLOUD_STORAGE_STATUS_SUCCESS = 'GET_CLOUD_STORAGE_STATUS_SUCCESS',
GET_CLOUD_STORAGE_STATUS_FAILED = 'GET_CLOUD_STORAGE_STATUS_FAILED',
GET_CLOUD_STORAGE_PREVIEW_FAILED = 'GET_CLOUD_STORAGE_PREVIEW_FAILED',
CREATE_CLOUD_STORAGE = 'CREATE_CLOUD_STORAGE',
CREATE_CLOUD_STORAGE_SUCCESS = 'CREATE_CLOUD_STORAGE_SUCCESS',
CREATE_CLOUD_STORAGE_FAILED = 'CREATE_CLOUD_STORAGE_FAILED',
DELETE_CLOUD_STORAGE = 'DELETE_CLOUD_STORAGE',
DELETE_CLOUD_STORAGE_SUCCESS = 'DELETE_CLOUD_STORAGE_SUCCESS',
DELETE_CLOUD_STORAGE_FAILED = 'DELETE_CLOUD_STORAGE_FAILED',
UPDATE_CLOUD_STORAGE = 'UPDATE_CLOUD_STORAGE',
UPDATE_CLOUD_STORAGE_SUCCESS = 'UPDATE_CLOUD_STORAGE_SUCCESS',
UPDATE_CLOUD_STORAGE_FAILED = 'UPDATE_CLOUD_STORAGE_FAILED',
LOAD_CLOUD_STORAGE_CONTENT = 'LOAD_CLOUD_STORAGE_CONTENT',
LOAD_CLOUD_STORAGE_CONTENT_FAILED = 'LOAD_CLOUD_STORAGE_CONTENT_FAILED',
LOAD_CLOUD_STORAGE_CONTENT_SUCCESS = 'LOAD_CLOUD_STORAGE_CONTENT_SUCCESS',
}
const cloudStoragesActions = {
updateCloudStoragesGettingQuery: (query: Partial<CloudStoragesQuery>) =>
createAction(CloudStorageActionTypes.UPDATE_CLOUD_STORAGES_GETTING_QUERY, { query }),
getCloudStorages: () => createAction(CloudStorageActionTypes.GET_CLOUD_STORAGES),
getCloudStoragesSuccess: (
array: any[],
previews: string[],
statuses: string[],
count: number,
query: Partial<CloudStoragesQuery>,
) =>
createAction(CloudStorageActionTypes.GET_CLOUD_STORAGE_SUCCESS, {
array,
previews,
statuses,
count,
query,
}),
getCloudStoragesFailed: (error: any, query: Partial<CloudStoragesQuery>) =>
createAction(CloudStorageActionTypes.GET_CLOUD_STORAGE_FAILED, { error, query }),
deleteCloudStorage: (cloudStorageID: number) =>
createAction(CloudStorageActionTypes.DELETE_CLOUD_STORAGE, { cloudStorageID }),
deleteCloudStorageSuccess: (cloudStorageID: number) =>
createAction(CloudStorageActionTypes.DELETE_CLOUD_STORAGE_SUCCESS, { cloudStorageID }),
deleteCloudStorageFailed: (error: any, cloudStorageID: number) =>
createAction(CloudStorageActionTypes.DELETE_CLOUD_STORAGE_FAILED, { error, cloudStorageID }),
createCloudStorage: () => createAction(CloudStorageActionTypes.CREATE_CLOUD_STORAGE),
createCloudStorageSuccess: (cloudStorageID: number) =>
createAction(CloudStorageActionTypes.CREATE_CLOUD_STORAGE_SUCCESS, { cloudStorageID }),
createCloudStorageFailed: (error: any) =>
createAction(CloudStorageActionTypes.CREATE_CLOUD_STORAGE_FAILED, { error }),
updateCloudStorage: () => createAction(CloudStorageActionTypes.UPDATE_CLOUD_STORAGE, {}),
updateCloudStorageSuccess: (cloudStorage: CloudStorage) =>
createAction(CloudStorageActionTypes.UPDATE_CLOUD_STORAGE_SUCCESS, { cloudStorage }),
updateCloudStorageFailed: (cloudStorage: CloudStorage, error: any) =>
createAction(CloudStorageActionTypes.UPDATE_CLOUD_STORAGE_FAILED, { cloudStorage, error }),
loadCloudStorageContent: () => createAction(CloudStorageActionTypes.LOAD_CLOUD_STORAGE_CONTENT),
loadCloudStorageContentSuccess: (cloudStorageID: number, content: any) =>
createAction(CloudStorageActionTypes.LOAD_CLOUD_STORAGE_CONTENT_SUCCESS, { cloudStorageID, content }),
loadCloudStorageContentFailed: (cloudStorageID: number, error: any) =>
createAction(CloudStorageActionTypes.LOAD_CLOUD_STORAGE_CONTENT_FAILED, { cloudStorageID, error }),
getCloudStorageStatus: () => createAction(CloudStorageActionTypes.GET_CLOUD_STORAGE_STATUS),
getCloudStorageStatusSuccess: (cloudStorageID: number, status: string) =>
createAction(CloudStorageActionTypes.GET_CLOUD_STORAGE_STATUS_SUCCESS, { cloudStorageID, status }),
getCloudStorageStatusFailed: (cloudStorageID: number, error: any) =>
createAction(CloudStorageActionTypes.GET_CLOUD_STORAGE_STATUS_FAILED, { cloudStorageID, error }),
getCloudStoragePreiewFailed: (cloudStorageID: number, error: any) =>
createAction(CloudStorageActionTypes.GET_CLOUD_STORAGE_PREVIEW_FAILED, { cloudStorageID, error }),
};
export type CloudStorageActions = ActionUnion<typeof cloudStoragesActions>;
export function getCloudStoragesAsync(query: Partial<CloudStoragesQuery>): ThunkAction {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
dispatch(cloudStoragesActions.getCloudStorages());
dispatch(cloudStoragesActions.updateCloudStoragesGettingQuery(query));
const filteredQuery = { ...query };
for (const key in filteredQuery) {
if (filteredQuery[key] === null) {
delete filteredQuery[key];
}
}
let result = null;
try {
result = await cvat.cloudStorages.get(filteredQuery);
} catch (error) {
dispatch(cloudStoragesActions.getCloudStoragesFailed(error, query));
return;
}
const array = Array.from(result);
const promises = array.map((cloudStorage: CloudStorage): string =>
(cloudStorage as any).getPreview().catch((error: any) => {
dispatch(cloudStoragesActions.getCloudStoragePreiewFailed(cloudStorage.id, error));
return '';
}));
const statusPromises = array.map((cloudStorage: CloudStorage): string =>
(cloudStorage as any).getStatus().catch((error: any) => {
dispatch(cloudStoragesActions.getCloudStorageStatusFailed(cloudStorage.id, error));
return '';
}));
dispatch(cloudStoragesActions.getCloudStoragesSuccess(
array,
await Promise.all(promises),
await Promise.all(statusPromises),
result.count,
query,
));
};
}
export function deleteCloudStorageAsync(cloudStorageInstance: any): ThunkAction {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
try {
dispatch(cloudStoragesActions.deleteCloudStorage(cloudStorageInstance.id));
await cloudStorageInstance.delete();
} catch (error) {
dispatch(cloudStoragesActions.deleteCloudStorageFailed(error, cloudStorageInstance.id));
return;
}
dispatch(cloudStoragesActions.deleteCloudStorageSuccess(cloudStorageInstance.id));
};
}
export function createCloudStorageAsync(data: any): ThunkAction {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
const cloudStorageInstance = new cvat.classes.CloudStorage(data);
dispatch(cloudStoragesActions.createCloudStorage());
try {
const savedCloudStorage = await cloudStorageInstance.save();
dispatch(cloudStoragesActions.createCloudStorageSuccess(savedCloudStorage.id));
} catch (error) {
dispatch(cloudStoragesActions.createCloudStorageFailed(error));
}
};
}
export function updateCloudStorageAsync(data: any): ThunkAction {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
const cloudStorageInstance = new cvat.classes.CloudStorage(data);
dispatch(cloudStoragesActions.updateCloudStorage());
try {
const savedCloudStorage = await cloudStorageInstance.save();
dispatch(cloudStoragesActions.updateCloudStorageSuccess(savedCloudStorage));
} catch (error) {
dispatch(cloudStoragesActions.updateCloudStorageFailed(data, error));
}
};
}
export function loadCloudStorageContentAsync(cloudStorage: CloudStorage): ThunkAction {
return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
dispatch(cloudStoragesActions.loadCloudStorageContent());
try {
const result = await cloudStorage.getContent();
dispatch(cloudStoragesActions.loadCloudStorageContentSuccess(cloudStorage.id, result));
} catch (error) {
dispatch(cloudStoragesActions.loadCloudStorageContentFailed(cloudStorage.id, error));
}
};
}
// export function getCloudStorageStatusAsync(cloudStorage: CloudStorage): ThunkAction {
// return async (dispatch: ActionCreator<Dispatch>): Promise<void> => {
// dispatch(cloudStoragesActions.getCloudStorageStatus());
// try {
// const result = await cloudStorage.getStatus();
// dispatch(cloudStoragesActions.getCloudStorageStatusSuccess(cloudStorage.id, result));
// } catch (error) {
// dispatch(cloudStoragesActions.getCloudStorageStatusFailed(cloudStorage.id, error));
// }
// };
// }

@ -386,10 +386,13 @@ export function createTaskAsync(data: any): ThunkAction<Promise<void>, {}, {}, A
if (data.subset) { if (data.subset) {
description.subset = data.subset; description.subset = data.subset;
} }
if (data.cloudStorageId) {
description.cloud_storage_id = data.cloudStorageId;
}
const taskInstance = new cvat.classes.Task(description); const taskInstance = new cvat.classes.Task(description);
taskInstance.clientFiles = data.files.local; taskInstance.clientFiles = data.files.local;
taskInstance.serverFiles = data.files.share; taskInstance.serverFiles = data.files.share.concat(data.files.cloudStorage);
taskInstance.remoteFiles = data.files.remote; taskInstance.remoteFiles = data.files.remote;
if (data.advanced.repository) { if (data.advanced.repository) {

@ -0,0 +1,21 @@
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<path d="M1.289 2.771L0 3.30333V12.6463L1.289 13.1755L1.29675 13.1678V2.77833L1.289 2.771Z" fill="#8C3123"/>
<path d="M8.18756 11.8195L1.28906 13.1755V2.771L8.18756 4.0975V11.8195Z" fill="#E05243"/>
<path d="M5.07349 9.69626L7.99961 10.0039L8.01798 9.96888L8.03442 6.00655L7.99961 5.97559L5.07349 6.27876V9.69626Z" fill="#8C3123"/>
<path d="M7.99976 11.8347L14.7104 13.1784L14.721 13.1645L14.7208 2.78029L14.7102 2.771L7.99976 4.11273V11.8347Z" fill="#8C3123"/>
<path d="M10.9267 9.69626L7.99976 10.0039V5.97559L10.9267 6.27876V9.69626Z" fill="#E05243"/>
<path d="M10.9265 4.62622L7.99961 5.06674L5.07349 4.62622L7.99592 3.99365L10.9265 4.62622Z" fill="#5E1F18"/>
<path d="M10.9265 11.3448L7.99961 10.9014L5.07349 11.3448L7.99605 12.0185L10.9265 11.3448Z" fill="#F2B0A9"/>
<path d="M5.07349 4.62612L7.99961 4.02813L8.0233 4.02209V0.0161548L7.99961 0L5.07349 1.20841V4.62612Z" fill="#8C3123"/>
<path d="M10.9267 4.62612L7.99976 4.02813V0L10.9267 1.20841V4.62612Z" fill="#E05243"/>
<path d="M7.99968 15.9704L5.07324 14.7624V11.3447L7.99968 11.9425L8.04274 11.9829L8.03106 15.9006L7.99968 15.9704Z" fill="#8C3123"/>
<path d="M7.99976 15.9704L10.9264 14.7624V11.3447L7.99976 11.9425V15.9704Z" fill="#E05243"/>
<path d="M14.7104 2.771L16 3.30333V12.6463L14.7104 13.1784V2.771Z" fill="#E05243"/>
</g>
<defs>
<clipPath id="clip0">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -0,0 +1,4 @@
<svg width="1em" height="1em" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.98 25.9939C18.55 25.2679 21.498 24.6669 21.532 24.6589L21.594 24.6439L18.224 20.0289C16.37 17.4909 14.854 15.4039 14.854 15.3919C14.854 15.3799 18.334 4.3359 18.354 4.2969C18.361 4.2839 20.729 8.9909 24.095 15.7079L29.869 27.2289L29.913 27.3169H8.49097L14.98 25.9939Z" fill="#0089D6"/>
<path d="M2.125 24.586C2.125 24.58 3.713 21.406 5.654 17.533L9.183 10.492L13.3 6.52002C15.562 4.33502 17.419 2.54402 17.426 2.54102C17.4112 2.60727 17.3891 2.67167 17.36 2.73302L12.89 13.759L8.5 24.589H5.311C4.24902 24.5999 3.18696 24.5989 2.125 24.586V24.586Z" fill="#0089D6"/>
</svg>

After

Width:  |  Height:  |  Size: 683 B

@ -24,6 +24,8 @@ $border-color-3: #242424;
$border-color-hover: #40a9ff; $border-color-hover: #40a9ff;
$background-color-1: white; $background-color-1: white;
$background-color-2: #f1f1f1; $background-color-2: #f1f1f1;
$notification-background-color-1: #d9ecff;
$notification-border-color-1: #1890ff;
$transparent-color: rgba(0, 0, 0, 0); $transparent-color: rgba(0, 0, 0, 0);
$player-slider-color: #979797; $player-slider-color: #979797;
$player-buttons-color: #242424; $player-buttons-color: #242424;

@ -0,0 +1,145 @@
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router';
import { CloudSyncOutlined, MoreOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import Card from 'antd/lib/card';
import Meta from 'antd/lib/card/Meta';
import Paragraph from 'antd/lib/typography/Paragraph';
import Text from 'antd/lib/typography/Text';
import Button from 'antd/lib/button';
import Dropdown from 'antd/lib/dropdown';
import Menu from 'antd/lib/menu';
import Modal from 'antd/lib/modal';
import moment from 'moment';
import { CloudStorage, CombinedState } from 'reducers/interfaces';
import { deleteCloudStorageAsync } from 'actions/cloud-storage-actions';
import CVATTooltip from 'components/common/cvat-tooltip';
import Status from './cloud-storage-status';
interface Props {
cloudStorageInstance: CloudStorage;
}
export default function CloudStorageItemComponent(props: Props): JSX.Element {
const history = useHistory();
const dispatch = useDispatch();
// cloudStorageInstance: {storage, preview, status}
const { cloudStorageInstance } = props;
const {
id,
displayName,
providerType,
owner,
createdDate,
updatedDate,
description,
} = cloudStorageInstance.storage;
const { preview, status } = cloudStorageInstance;
const deletes = useSelector((state: CombinedState) => state.cloudStorages.activities.deletes);
const deleted = cloudStorageInstance.storage.id in deletes ? deletes[cloudStorageInstance.storage.id] : false;
const style: React.CSSProperties = {};
if (deleted) {
style.pointerEvents = 'none';
style.opacity = 0.5;
}
const onUpdate = useCallback(() => {
history.push(`/cloudstorages/update/${id}`);
}, []);
const onDelete = useCallback(() => {
Modal.confirm({
title: 'Please, confirm your action',
content: `You are going to remove the cloudstorage "${displayName}". Continue?`,
className: 'cvat-delete-cloud-storage-modal',
onOk: () => {
dispatch(deleteCloudStorageAsync(cloudStorageInstance.storage));
},
okButtonProps: {
type: 'primary',
danger: true,
},
okText: 'Delete',
});
}, [cloudStorageInstance.storage.id]);
return (
<Card
cover={(
<>
{preview ? (
<img
className='cvat-cloud-storage-item-preview'
src={preview}
alt='Preview image'
aria-hidden
/>
) : (
<div className='cvat-cloud-storage-item-empty-preview' aria-hidden>
<CloudSyncOutlined />
</div>
)}
{description ? (
<CVATTooltip overlay={description}>
<QuestionCircleOutlined className='cvat-cloud-storage-description-icon' />
</CVATTooltip>
) : null}
</>
)}
size='small'
style={style}
className='cvat-cloud-storage-item'
>
<Meta
title={(
<Paragraph>
<Text strong>{`#${id}: `}</Text>
<Text>{displayName}</Text>
</Paragraph>
)}
description={(
<>
<Paragraph>
<Text type='secondary'>Provider: </Text>
<Text>{providerType}</Text>
</Paragraph>
<Paragraph>
<Text type='secondary'>Created </Text>
{owner ? <Text type='secondary'>{`by ${owner.username}`}</Text> : null}
<Text type='secondary'> on </Text>
<Text type='secondary'>{moment(createdDate).format('MMMM Do YYYY')}</Text>
</Paragraph>
<Paragraph>
<Text type='secondary'>Last updated </Text>
<Text type='secondary'>{moment(updatedDate).fromNow()}</Text>
</Paragraph>
<Status status={status} />
<Dropdown
overlay={(
<Menu className='cvat-project-actions-menu'>
<Menu.Item onClick={onUpdate}>Update</Menu.Item>
<Menu.Item onClick={onDelete}>Delete</Menu.Item>
</Menu>
)}
>
<Button
className='cvat-cloud-storage-item-menu-button'
type='link'
size='large'
icon={<MoreOutlined />}
/>
</Dropdown>
</>
)}
/>
</Card>
);
}

@ -0,0 +1,28 @@
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import Paragraph from 'antd/lib/typography/Paragraph';
import Text from 'antd/lib/typography/Text';
import { StorageStatuses } from '../../utils/enums';
interface Props {
status: string;
}
export default function Status(props: Props): JSX.Element {
const { status } = props;
// TODO: make dynamic loading of statuses separately in the future
return (
<Paragraph>
<Text type='secondary'>Status: </Text>
{status ? (
<Text type={status === StorageStatuses.AVAILABLE ? 'success' : 'danger'}>{status}</Text>
) : (
<Text type='warning'>Loading ...</Text>
)}
</Paragraph>
);
}

@ -0,0 +1,79 @@
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import Pagination from 'antd/lib/pagination';
import { Row, Col } from 'antd/lib/grid';
import { CloudStorage } from 'reducers/interfaces';
import CloudStorageItemComponent from './cloud-storage-item';
interface Props {
storages: CloudStorage[];
previews: string[];
statuses: string[];
totalCount: number;
page: number;
onChangePage(page: number): void;
}
export default function StoragesList(props: Props): JSX.Element {
const {
storages, previews, statuses, totalCount, page, onChangePage,
} = props;
const groupedStorages = storages.reduce(
(acc: CloudStorage[][], storage: CloudStorage, index: number): CloudStorage[][] => {
if (index && index % 4) {
acc[acc.length - 1].push({
storage,
preview: previews[index],
status: statuses[index],
});
} else {
acc.push([{
storage,
preview: previews[index],
status: statuses[index],
}]);
}
return acc;
},
[],
);
return (
<>
<Row justify='center' align='middle'>
<Col span={24} className='cvat-cloud-storages-list'>
{groupedStorages.map(
(instances: CloudStorage[]): JSX.Element => (
<Row key={instances[0].storage.id} gutter={[8, 8]}>
{instances.map((instance: CloudStorage) => (
<Col span={6} key={instance.storage.id}>
<CloudStorageItemComponent cloudStorageInstance={instance} />
</Col>
))}
</Row>
),
)}
</Col>
</Row>
<Row justify='center' align='middle'>
<Col>
<Pagination
className='cvat-cloud-storages-pagination'
onChange={onChangePage}
showSizeChanger={false}
total={totalCount}
pageSize={12}
current={page}
showQuickJumper
/>
</Col>
</Row>
</>
);
}

@ -0,0 +1,111 @@
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import './styles.scss';
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router';
import { Row, Col } from 'antd/lib/grid';
import Spin from 'antd/lib/spin';
import { CloudStorage, CloudStoragesQuery, CombinedState } from 'reducers/interfaces';
import { getCloudStoragesAsync } from 'actions/cloud-storage-actions';
import CloudStoragesListComponent from './cloud-storages-list';
import EmptyCloudStorageListComponent from './empty-cloud-storages-list';
import TopBarComponent from './top-bar';
export default function StoragesPageComponent(): JSX.Element {
const dispatch = useDispatch();
const history = useHistory();
const { search } = history.location;
const totalCount = useSelector((state: CombinedState) => state.cloudStorages.count);
const isFetching = useSelector((state: CombinedState) => state.cloudStorages.fetching);
const current = useSelector((state: CombinedState) => state.cloudStorages.current)
.map((cloudStrage: CloudStorage) => cloudStrage.instance);
const previews = useSelector((state: CombinedState) => state.cloudStorages.current)
.map((cloudStrage: CloudStorage) => cloudStrage.preview as string);
const statuses = useSelector((state: CombinedState) => state.cloudStorages.current)
.map((cloudStrage: CloudStorage) => cloudStrage.status as string);
const query = useSelector((state: CombinedState) => state.cloudStorages.gettingQuery);
const onSearch = useCallback(
(_query: CloudStoragesQuery) => {
if (!isFetching) dispatch(getCloudStoragesAsync(_query));
},
[isFetching],
);
const onChangePage = useCallback(
(page: number) => {
if (!isFetching && page !== query.page) dispatch(getCloudStoragesAsync({ ...query, page }));
},
[query],
);
const dimensions = {
md: 22,
lg: 18,
xl: 16,
xxl: 16,
};
useEffect(() => {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== null && typeof value !== 'undefined') {
searchParams.append(key, value.toString());
}
}
history.push({
pathname: '/cloudstorages',
search: `?${searchParams.toString()}`,
});
}, [query]);
useEffect(() => {
const searchParams = { ...query };
for (const [key, value] of new URLSearchParams(search)) {
if (key in searchParams) {
searchParams[key] = ['page', 'id'].includes(key) ? +value : value;
}
}
onSearch(searchParams);
}, []);
const searchWasUsed = Object.entries(query).some(([key, value]) => {
if (key === 'page') {
return value && Number.isInteger(value) && value > 1;
}
return !!value;
});
if (isFetching) {
return (
<Row className='cvat-cloud-storages-page' justify='center' align='middle'>
<Spin size='large' />
</Row>
);
}
return (
<Row className='cvat-cloud-storages-page' justify='center' align='top'>
<Col {...dimensions}>
<TopBarComponent query={query} onSearch={onSearch} />
{current.length ? (
<CloudStoragesListComponent
totalCount={totalCount}
page={query.page}
storages={current}
previews={previews}
statuses={statuses}
onChangePage={onChangePage}
/>
) : (
<EmptyCloudStorageListComponent notFound={searchWasUsed} />
)}
</Col>
</Row>
);
}

@ -0,0 +1,51 @@
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import Empty from 'antd/lib/empty';
import { Row, Col } from 'antd/lib/grid';
import Text from 'antd/lib/typography/Text';
import { CloudTwoTone } from '@ant-design/icons';
import { Link } from 'react-router-dom';
interface Props {
notFound: boolean;
}
export default function EmptyStoragesListComponent(props: Props): JSX.Element {
const { notFound } = props;
const description = notFound ? (
<Row justify='center' align='middle'>
<Col>
<Text strong>No results matched your search found...</Text>
</Col>
</Row>
) : (
<>
<Row justify='center' align='middle'>
<Col>
<Text strong>No cloud storages attached yet ...</Text>
</Col>
</Row>
<Row justify='center' align='middle'>
<Col>
<Text type='secondary'>To get started with your cloud storage</Text>
</Col>
</Row>
<Row justify='center' align='middle'>
<Col>
<Link to='/cloudstorages/create'>attach a new one</Link>
</Col>
</Row>
</>
);
return (
<div className='cvat-empty-cloud-storages-list'>
<Empty description={description} image={<CloudTwoTone className='cvat-empty-cloud-storages-list-icon' />} />
</div>
);
}

@ -0,0 +1,75 @@
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
@import '../../base.scss';
.cvat-cloud-storages-page {
padding: $grid-unit-size * 2;
height: 100%;
overflow: auto;
.ant-spin {
position: absolute;
top: 50%;
left: 50%;
}
}
.cvat-empty-cloud-storages-list-icon {
font-size: $grid-unit-size * 14;
}
.cvat-cloud-storages-pagination {
margin-top: $grid-unit-size * 2;
}
.cvat-cloud-storages-list-top-bar {
> div:first-child {
.cvat-title {
margin-right: $grid-unit-size;
}
display: flex;
}
> div:last-child {
text-align: right;
}
}
.cvat-cloud-storages-list,
.cvat-empty-cloud-storages-list {
margin-top: $grid-unit-size * 2;
}
.cvat-cloud-storage-item {
div.ant-typography {
margin-bottom: 0;
}
.cvat-cloud-storage-item-empty-preview {
font-size: $grid-unit-size * 15;
text-align: center;
height: $grid-unit-size * 24;
}
img {
height: $grid-unit-size * 24;
width: auto;
margin: auto;
}
.cvat-cloud-storage-item-menu-button {
position: absolute;
bottom: 0;
right: 0;
}
.cvat-cloud-storage-description-icon {
position: absolute;
top: $grid-unit-size;
width: auto;
right: $grid-unit-size;
}
}

@ -0,0 +1,43 @@
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React from 'react';
import { useHistory } from 'react-router';
import { Row, Col } from 'antd/lib/grid';
import Button from 'antd/lib/button';
import Text from 'antd/lib/typography/Text';
import { PlusOutlined } from '@ant-design/icons';
import SearchField from 'components/search-field/search-field';
import { CloudStoragesQuery } from 'reducers/interfaces';
interface Props {
onSearch(query: CloudStoragesQuery): void;
query: CloudStoragesQuery;
}
export default function StoragesTopBar(props: Props): JSX.Element {
const { onSearch, query } = props;
const history = useHistory();
return (
<Row justify='space-between' align='middle' className='cvat-cloud-storages-list-top-bar'>
<Col md={11} lg={9} xl={9} xxl={9}>
<Text className='cvat-title'>Cloud Storages</Text>
<SearchField instance='cloudstorage' onSearch={onSearch} query={query} />
</Col>
<Col md={{ span: 11 }} lg={{ span: 9 }} xl={{ span: 9 }} xxl={{ span: 9 }}>
<Button
size='large'
className='cvat-attach-cloud-storage-button'
type='primary'
onClick={(): void => history.push('/cloudstorages/create')}
icon={<PlusOutlined />}
>
Attach a new storage
</Button>
</Col>
</Row>
);
}

@ -0,0 +1,515 @@
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React, {
useState, useEffect, useRef,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router';
import { Row, Col } from 'antd/lib/grid';
import Button from 'antd/lib/button';
import Form from 'antd/lib/form';
import Select from 'antd/lib/select';
import Input from 'antd/lib/input';
import TextArea from 'antd/lib/input/TextArea';
import notification from 'antd/lib/notification';
import { CombinedState, CloudStorage } from 'reducers/interfaces';
import { createCloudStorageAsync, updateCloudStorageAsync } from 'actions/cloud-storage-actions';
import { ProviderType, CredentialsType } from 'utils/enums';
import { AzureProvider, S3Provider } from '../../icons';
import S3Region from './s3-region';
import ManifestsManager from './manifests-manager';
export interface Props {
cloudStorage?: CloudStorage;
}
type CredentialsFormNames = 'key' | 'secret_key' | 'account_name' | 'session_token';
type CredentialsCamelCaseNames = 'key' | 'secretKey' | 'accountName' | 'sessionToken';
interface CloudStorageForm {
credentials_type: CredentialsType;
display_name: string;
provider_type: ProviderType;
resource: string;
account_name?: string;
session_token?: string;
key?: string;
secret_key?: string;
SAS_token?: string;
description?: string;
region?: string;
manifests: string[];
}
export default function CreateCloudStorageForm(props: Props): JSX.Element {
const { cloudStorage } = props;
const dispatch = useDispatch();
const history = useHistory();
const [form] = Form.useForm();
const shouldShowCreationNotification = useRef(false);
const shouldShowUpdationNotification = useRef(false);
const [providerType, setProviderType] = useState<ProviderType | null>(null);
const [credentialsType, setCredentialsType] = useState<CredentialsType | null>(null);
const [selectedRegion, setSelectedRegion] = useState<string | undefined>(undefined);
const newCloudStorageId = useSelector((state: CombinedState) => state.cloudStorages.activities.creates.id);
const attaching = useSelector((state: CombinedState) => state.cloudStorages.activities.creates.attaching);
const updating = useSelector((state: CombinedState) => state.cloudStorages.activities.updates.updating);
const updatedCloudStorageId = useSelector(
(state: CombinedState) => state.cloudStorages.activities.updates.cloudStorageID,
);
const loading = cloudStorage ? updating : attaching;
const fakeCredentialsData = {
accountName: 'X'.repeat(24),
sessionToken: 'X'.repeat(300),
key: 'X'.repeat(20),
secretKey: 'X'.repeat(40),
};
const [keyVisibility, setKeyVisibility] = useState(false);
const [secretKeyVisibility, setSecretKeyVisibility] = useState(false);
const [sessionTokenVisibility, setSessionTokenVisibility] = useState(false);
const [accountNameVisibility, setAccountNameVisibility] = useState(false);
const [manifestNames, setManifestNames] = useState<string[]>([]);
function initializeFields(): void {
setManifestNames(cloudStorage.manifests);
const fieldsValue: CloudStorageForm = {
credentials_type: cloudStorage.credentialsType,
display_name: cloudStorage.displayName,
description: cloudStorage.description,
provider_type: cloudStorage.providerType,
resource: cloudStorage.resourceName,
manifests: manifestNames,
};
setProviderType(cloudStorage.providerType);
setCredentialsType(cloudStorage.credentialsType);
if (cloudStorage.credentialsType === CredentialsType.ACCOUNT_NAME_TOKEN_PAIR) {
fieldsValue.account_name = fakeCredentialsData.accountName;
fieldsValue.SAS_token = fakeCredentialsData.sessionToken;
} else if (cloudStorage.credentialsType === CredentialsType.KEY_SECRET_KEY_PAIR) {
fieldsValue.key = fakeCredentialsData.key;
fieldsValue.secret_key = fakeCredentialsData.secretKey;
}
if (cloudStorage.providerType === ProviderType.AWS_S3_BUCKET && cloudStorage.specificAttributes) {
const region = new URLSearchParams(cloudStorage.specificAttributes).get('region');
if (region) {
setSelectedRegion(region);
}
}
form.setFieldsValue(fieldsValue);
}
function onReset(): void {
if (cloudStorage) {
initializeFields();
} else {
setManifestNames([]);
setSelectedRegion(undefined);
form.resetFields();
}
}
const onCancel = (): void => {
if (history.length) {
history.goBack();
} else {
history.push('/cloudstorages');
}
};
useEffect(() => {
onReset();
}, []);
useEffect(() => {
if (
Number.isInteger(newCloudStorageId) &&
shouldShowCreationNotification &&
shouldShowCreationNotification.current
) {
// Clear form
onReset();
notification.info({
message: 'The cloud storage has been attached',
className: 'cvat-notification-create-cloud-storage-success',
});
}
if (shouldShowCreationNotification !== undefined) {
shouldShowCreationNotification.current = true;
}
}, [newCloudStorageId]);
useEffect(() => {
if (updatedCloudStorageId && shouldShowUpdationNotification && shouldShowUpdationNotification.current) {
notification.info({
message: 'The cloud storage has been updated',
className: 'cvat-notification-update-cloud-storage-success',
});
}
if (shouldShowUpdationNotification !== undefined) {
shouldShowUpdationNotification.current = true;
}
}, [updatedCloudStorageId]);
useEffect(() => {
if (cloudStorage && cloudStorage.credentialsType !== CredentialsType.ANONYMOUS_ACCESS) {
notification.info({
message: `For security reasons, your credentials are hidden and represented by fake values
that will not be taken into account when updating the cloud storage.
If you want to replace the original credentials, simply enter new ones.`,
className: 'cvat-notification-update-info-cloud-storage',
duration: 15,
});
}
}, [cloudStorage]);
const onSubmit = async (): Promise<void> => {
let cloudStorageData: Record<string, any> = {};
const formValues = await form.validateFields();
cloudStorageData = { ...formValues };
if (formValues.region !== undefined) {
delete cloudStorageData.region;
cloudStorageData.specific_attributes = `region=${selectedRegion}`;
}
if (cloudStorageData.credentials_type === CredentialsType.ACCOUNT_NAME_TOKEN_PAIR) {
delete cloudStorageData.SAS_token;
cloudStorageData.session_token = formValues.SAS_token;
}
if (cloudStorageData.manifests && cloudStorageData.manifests.length) {
delete cloudStorageData.manifests;
cloudStorageData.manifests = form
.getFieldValue('manifests')
.map((manifest: any): string => manifest.name);
}
if (cloudStorage) {
cloudStorageData.id = cloudStorage.id;
if (cloudStorageData.account_name === fakeCredentialsData.accountName) {
delete cloudStorageData.account_name;
}
if (cloudStorageData.key === fakeCredentialsData.key) {
delete cloudStorageData.key;
}
if (cloudStorageData.secret_key === fakeCredentialsData.secretKey) {
delete cloudStorageData.secret_key;
}
if (cloudStorageData.session_token === fakeCredentialsData.sessionToken) {
delete cloudStorageData.session_token;
}
dispatch(updateCloudStorageAsync(cloudStorageData));
} else {
dispatch(createCloudStorageAsync(cloudStorageData));
}
};
const resetCredentialsValues = (): void => {
form.setFieldsValue({
key: undefined,
secret_key: undefined,
session_token: undefined,
account_name: undefined,
});
};
const onFocusCredentialsItem = (credential: CredentialsCamelCaseNames, key: CredentialsFormNames): void => {
// reset fake credential when updating a cloud storage and cursor is in this field
if (cloudStorage && form.getFieldValue(key) === fakeCredentialsData[credential]) {
form.setFieldsValue({
[key]: undefined,
});
}
};
const onBlurCredentialsItem = (
credential: CredentialsCamelCaseNames,
key: CredentialsFormNames,
setVisibility: any,
): void => {
// set fake credential when updating a cloud storage and cursor disappears from the field and value not changed
if (cloudStorage && !form.getFieldValue(key)) {
form.setFieldsValue({
[key]: fakeCredentialsData[credential],
});
setVisibility(false);
}
};
const onChangeCredentialsType = (value: CredentialsType): void => {
setCredentialsType(value);
resetCredentialsValues();
};
const onSelectRegion = (key: string): void => {
setSelectedRegion(key);
};
const commonProps = {
className: 'cvat-cloud-storage-form-item',
labelCol: { span: 5 },
wrapperCol: { offset: 1 },
};
const credentialsBlok = (): JSX.Element => {
const internalCommonProps = {
...commonProps,
labelCol: { span: 8, offset: 2 },
wrapperCol: { offset: 1 },
};
if (providerType === ProviderType.AWS_S3_BUCKET && credentialsType === CredentialsType.KEY_SECRET_KEY_PAIR) {
return (
<>
<Form.Item
label='ACCESS KEY ID'
name='key'
rules={[{ required: true, message: 'Please, specify your access_key_id' }]}
{...internalCommonProps}
>
<Input.Password
maxLength={20}
visibilityToggle={keyVisibility}
onChange={() => setKeyVisibility(true)}
onFocus={() => onFocusCredentialsItem('key', 'key')}
onBlur={() => onBlurCredentialsItem('key', 'key', setKeyVisibility)}
/>
</Form.Item>
<Form.Item
label='SECRET ACCESS KEY ID'
name='secret_key'
rules={[{ required: true, message: 'Please, specify your secret_access_key_id' }]}
{...internalCommonProps}
>
<Input.Password
maxLength={40}
visibilityToggle={secretKeyVisibility}
onChange={() => setSecretKeyVisibility(true)}
onFocus={() => onFocusCredentialsItem('secretKey', 'secret_key')}
onBlur={() => onBlurCredentialsItem('secretKey', 'secret_key', setSecretKeyVisibility)}
/>
</Form.Item>
</>
);
}
if (
providerType === ProviderType.AZURE_CONTAINER &&
credentialsType === CredentialsType.ACCOUNT_NAME_TOKEN_PAIR
) {
return (
<>
<Form.Item
label='Account name'
name='account_name'
rules={[{ required: true, message: 'Please, specify your account name' }]}
{...internalCommonProps}
>
<Input.Password
minLength={3}
maxLength={24}
visibilityToggle={accountNameVisibility}
onChange={() => setAccountNameVisibility(true)}
onFocus={() => onFocusCredentialsItem('accountName', 'account_name')}
onBlur={() =>
onBlurCredentialsItem('accountName', 'account_name', setAccountNameVisibility)}
/>
</Form.Item>
<Form.Item
label='SAS token'
name='SAS_token'
rules={[{ required: true, message: 'Please, specify your SAS token' }]}
{...internalCommonProps}
>
<Input.Password
visibilityToggle={sessionTokenVisibility}
maxLength={437}
onChange={() => setSessionTokenVisibility(true)}
onFocus={() => onFocusCredentialsItem('sessionToken', 'session_token')}
onBlur={() =>
onBlurCredentialsItem('sessionToken', 'session_token', setSessionTokenVisibility)}
/>
</Form.Item>
</>
);
}
if (providerType === ProviderType.AZURE_CONTAINER && credentialsType === CredentialsType.ANONYMOUS_ACCESS) {
return (
<>
<Form.Item
label='Account name'
name='account_name'
rules={[{ required: true, message: 'Please, specify your account name' }]}
{...internalCommonProps}
>
<Input.Password
minLength={3}
maxLength={24}
visibilityToggle={accountNameVisibility}
onChange={() => setAccountNameVisibility(true)}
/>
</Form.Item>
</>
);
}
return <></>;
};
const AWSS3Configuration = (): JSX.Element => {
const internalCommonProps = {
...commonProps,
labelCol: { span: 6, offset: 1 },
wrapperCol: { offset: 1 },
};
return (
<>
<Form.Item
label='Bucket name'
name='resource'
rules={[{ required: true, message: 'Please, specify a bucket name' }]}
{...internalCommonProps}
>
<Input disabled={!!cloudStorage} maxLength={63} />
</Form.Item>
<Form.Item
label='Authorization type'
name='credentials_type'
rules={[{ required: true, message: 'Please, specify credentials type' }]}
{...internalCommonProps}
>
<Select onSelect={(value: CredentialsType) => onChangeCredentialsType(value)}>
<Select.Option value={CredentialsType.KEY_SECRET_KEY_PAIR}>
Key id and secret access key pair
</Select.Option>
<Select.Option value={CredentialsType.ANONYMOUS_ACCESS}>Anonymous access</Select.Option>
</Select>
</Form.Item>
{credentialsBlok()}
<S3Region
selectedRegion={selectedRegion}
onSelectRegion={onSelectRegion}
internalCommonProps={internalCommonProps}
/>
</>
);
};
const AzureBlobStorageConfiguration = (): JSX.Element => {
const internalCommonProps = {
...commonProps,
labelCol: { span: 6, offset: 1 },
wrapperCol: { offset: 1 },
};
return (
<>
<Form.Item
label='Container name'
name='resource'
rules={[{ required: true, message: 'Please, specify a container name' }]}
{...internalCommonProps}
>
<Input disabled={!!cloudStorage} maxLength={63} />
</Form.Item>
<Form.Item
label='Authorization type'
name='credentials_type'
rules={[{ required: true, message: 'Please, specify credentials type' }]}
{...internalCommonProps}
>
<Select onSelect={(value: CredentialsType) => onChangeCredentialsType(value)}>
<Select.Option value={CredentialsType.ACCOUNT_NAME_TOKEN_PAIR}>
Account name and SAS token
</Select.Option>
<Select.Option value={CredentialsType.ANONYMOUS_ACCESS}>Anonymous access</Select.Option>
</Select>
</Form.Item>
{credentialsBlok()}
</>
);
};
return (
<Form className='cvat-cloud-storage-form' layout='horizontal' form={form}>
<Form.Item
{...commonProps}
label='Display name'
name='display_name'
rules={[{ required: true, message: 'Please, specify a display name' }]}
>
<Input maxLength={63} />
</Form.Item>
<Form.Item {...commonProps} label='Description' name='description'>
<TextArea autoSize={{ minRows: 1, maxRows: 5 }} placeholder='Any useful description' />
</Form.Item>
<Form.Item
{...commonProps}
label='Provider'
name='provider_type'
rules={[{ required: true, message: 'Please, specify a cloud storage provider' }]}
>
<Select
disabled={!!cloudStorage}
onSelect={(value: ProviderType) => {
setProviderType(value);
setCredentialsType(null);
form.resetFields(['credentials_type']);
}}
>
<Select.Option value={ProviderType.AWS_S3_BUCKET}>
<span className='cvat-cloud-storage-select-provider'>
<S3Provider />
AWS S3
</span>
</Select.Option>
<Select.Option value={ProviderType.AZURE_CONTAINER}>
<span className='cvat-cloud-storage-select-provider'>
<AzureProvider />
Azure Blob Container
</span>
</Select.Option>
</Select>
</Form.Item>
{providerType === ProviderType.AWS_S3_BUCKET && AWSS3Configuration()}
{providerType === ProviderType.AZURE_CONTAINER && AzureBlobStorageConfiguration()}
<ManifestsManager form={form} manifestNames={manifestNames} setManifestNames={setManifestNames} />
<Row justify='end'>
<Col>
<Button
htmlType='button'
onClick={() => onCancel()}
className='cvat-cloud-storage-reset-button'
disabled={loading}
>
Cancel
</Button>
</Col>
<Col offset={1}>
<Button
type='primary'
htmlType='submit'
onClick={onSubmit}
className='cvat-cloud-storage-submit-button'
loading={loading}
disabled={loading}
>
{cloudStorage ? 'Update' : 'Submit'}
</Button>
</Col>
</Row>
</Form>
);
}

@ -0,0 +1,21 @@
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import './styles.scss';
import React from 'react';
import { Row, Col } from 'antd/lib/grid';
import Text from 'antd/lib/typography/Text';
import CreateCloudStorageForm from './cloud-storage-form';
export default function CreateCloudStoragePageComponent(): JSX.Element {
return (
<Row justify='center' align='top' className='cvat-attach-cloud-storage-form-wrapper'>
<Col md={20} lg={16} xl={14} xxl={9}>
<Text className='cvat-title'>Create a cloud storage</Text>
<CreateCloudStorageForm />
</Col>
</Row>
);
}

@ -0,0 +1,139 @@
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React, { useEffect, useRef, useState } from 'react';
import { MinusCircleOutlined, PlusCircleOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import Button from 'antd/lib/button';
import Col from 'antd/lib/col';
import Form from 'antd/lib/form';
import Input from 'antd/lib/input';
import Row from 'antd/lib/row';
import notification from 'antd/lib/notification';
import Tooltip from 'antd/lib/tooltip';
interface Props {
form: any;
manifestNames: string[];
setManifestNames: (manifestNames: string[]) => void;
}
export default function ManifestsManager(props: Props): JSX.Element {
const { form, manifestNames, setManifestNames } = props;
const maxManifestsCount = useRef(5);
const [limitingAddingManifestNotification, setLimitingAddingManifestNotification] = useState(false);
const updateManifestFields = (): void => {
const newManifestFormItems = manifestNames.map((name, idx) => ({
id: idx,
name,
}));
form.setFieldsValue({
manifests: [...newManifestFormItems],
});
};
useEffect(() => {
updateManifestFields();
}, [manifestNames]);
useEffect(() => {
if (limitingAddingManifestNotification) {
notification.warning({
message: `Unable to add manifest. The maximum number of files is ${maxManifestsCount.current}`,
className: 'cvat-notification-limiting-adding-manifest',
});
}
}, [limitingAddingManifestNotification]);
const onChangeManifestPath = (manifestName: string | undefined, manifestId: number): void => {
if (manifestName !== undefined) {
setManifestNames(manifestNames.map((name, idx) => (idx !== manifestId ? name : manifestName)));
}
};
const onDeleteManifestItem = (key: number): void => {
if (maxManifestsCount.current === manifestNames.length && limitingAddingManifestNotification) {
setLimitingAddingManifestNotification(false);
}
setManifestNames(manifestNames.filter((name, idx) => idx !== key));
};
const onAddManifestItem = (): void => {
if (maxManifestsCount.current <= manifestNames.length) {
setLimitingAddingManifestNotification(true);
} else {
setManifestNames(manifestNames.concat(['']));
}
};
return (
<>
<Form.Item
name='manifests'
label={(
<>
Manifests
<Tooltip title='More information'>
<Button
type='link'
target='_blank'
className='cvat-cloud-storage-help-button'
href='https://openvinotoolkit.github.io/cvat/docs/manual/advanced/dataset_manifest/'
>
<QuestionCircleOutlined />
</Button>
</Tooltip>
</>
)}
rules={[{ required: true, message: 'Please, specify at least one manifest file' }]}
/>
<Form.List name='manifests'>
{
(fields) => (
<>
{fields.map((field, idx): JSX.Element => (
<Form.Item key={idx} shouldUpdate>
<Row justify='space-between' align='top'>
<Col>
<Form.Item
name={[idx, 'name']}
rules={[
{
required: true,
message: 'Please specify a manifest name',
},
]}
initialValue={field.name}
>
<Input
placeholder='manifest.jsonl'
onChange={(event) => onChangeManifestPath(event.target.value, idx)}
/>
</Form.Item>
</Col>
<Col>
<Form.Item>
<Button type='link' onClick={() => onDeleteManifestItem(idx)}>
<MinusCircleOutlined />
</Button>
</Form.Item>
</Col>
</Row>
</Form.Item>
))}
</>
)
}
</Form.List>
<Row justify='start'>
<Col>
<Button type='ghost' onClick={onAddManifestItem} className='cvat-add-manifest-button'>
Add manifest
<PlusCircleOutlined />
</Button>
</Col>
</Row>
</>
);
}

@ -0,0 +1,119 @@
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import React, { useState } from 'react';
import Divider from 'antd/lib/divider';
import Select from 'antd/lib/select';
import { PlusCircleOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import Input from 'antd/lib/input';
import Button from 'antd/lib/button';
import Form from 'antd/lib/form';
import notification from 'antd/lib/notification';
import Tooltip from 'antd/lib/tooltip';
import consts from '../../consts';
const { Option } = Select;
interface Props {
selectedRegion: undefined | string;
onSelectRegion: any;
internalCommonProps: any;
}
function prepareDefaultRegions(): Map<string, string> {
const temp = new Map<string, string>();
for (const [key, value] of consts.DEFAULT_AWS_S3_REGIONS) {
temp.set(key, value);
}
return temp;
}
export default function S3Region(props: Props): JSX.Element {
const { selectedRegion, onSelectRegion, internalCommonProps } = props;
const [regions, setRegions] = useState<Map<string, string>>(() => prepareDefaultRegions());
const [newRegionKey, setNewRegionKey] = useState<string>('');
const [newRegionName, setNewRegionName] = useState<string>('');
const handleAddingRegion = (): void => {
if (!newRegionKey || !newRegionName) {
notification.warning({
message: 'Incorrect region',
className: 'cvat-incorrect-add-region-notification',
});
} else if (regions.has(newRegionKey)) {
notification.warning({
message: 'This region already exists',
className: 'cvat-incorrect-add-region-notification',
});
} else {
const regionsCopy = regions;
setRegions(regionsCopy.set(newRegionKey, newRegionName));
setNewRegionKey('');
setNewRegionName('');
}
};
return (
<Form.Item
label={(
<>
Region
<Tooltip title='More information'>
<Button
className='cvat-cloud-storage-help-button'
type='link'
target='_blank'
href='https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html#concepts-available-regions'
>
<QuestionCircleOutlined />
</Button>
</Tooltip>
</>
)}
name='region'
{...internalCommonProps}
>
<Select
placeholder='Select region'
defaultValue={selectedRegion ? regions.get(selectedRegion) : undefined}
dropdownRender={(menu) => (
<div>
{menu}
<Divider className='cvat-divider' />
<div className='cvat-cloud-storage-region-creator'>
<Input
value={newRegionKey}
onChange={(event: any) => setNewRegionKey(event.target.value)}
maxLength={14}
placeholder='key'
/>
<Input
value={newRegionName}
onChange={(event: any) => setNewRegionName(event.target.value)}
placeholder='name'
/>
<Button
type='link'
onClick={handleAddingRegion}
>
Add region
<PlusCircleOutlined />
</Button>
</div>
</div>
)}
onSelect={(_, instance) => onSelectRegion(instance.key)}
>
{
Array.from(regions.entries()).map(
([key, value]): JSX.Element => (
<Option key={key} value={value}>
{value}
</Option>
),
)
}
</Select>
</Form.Item>
);
}

@ -0,0 +1,65 @@
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
@import '../../base.scss';
.cvat-update-cloud-storage-form-wrapper,
.cvat-attach-cloud-storage-form-wrapper {
text-align: center;
padding-top: $grid-unit-size * 5;
overflow-y: auto;
height: 90%;
position: fixed;
width: 100%;
> div > span {
font-size: $grid-unit-size * 4;
}
.cvat-cloud-storage-form {
margin-top: $grid-unit-size * 2;
width: 100%;
height: auto;
border: 1px solid $border-color-1;
border-radius: $grid-unit-size / 2;
padding: $grid-unit-size * 2;
background: $background-color-1;
text-align: initial;
.cvat-cloud-storage-form-item {
justify-content: space-between;
> div:first-child {
text-align: left;
}
}
> div:not(first-child) {
margin-top: $grid-unit-size;
}
.cvat-attach-cloud-storage-reset-button,
.cvat-attach-cloud-storage-submit-button {
width: $grid-unit-size * 12;
}
}
}
.cvat-cloud-storage-region-creator {
display: flex;
padding: $grid-unit-size;
> * {
margin: 0 $grid-unit-size;
}
> button {
cursor: pointer;
}
}
.cvat-cloud-storage-help-button {
padding-left: $grid-unit-size * 0.5;
padding-right: 0;
}

@ -30,6 +30,7 @@ export interface CreateTaskData {
labels: any[]; labels: any[];
files: Files; files: Files;
activeFileManagerTab: string; activeFileManagerTab: string;
cloudStorageId: number | null;
} }
interface Props { interface Props {
@ -58,8 +59,10 @@ const defaultState = {
local: [], local: [],
share: [], share: [],
remote: [], remote: [],
cloudStorage: [],
}, },
activeFileManagerTab: 'local', activeFileManagerTab: 'local',
cloudStorageId: null,
}; };
class CreateTaskContent extends React.PureComponent<Props & RouteComponentProps, State> { class CreateTaskContent extends React.PureComponent<Props & RouteComponentProps, State> {
@ -94,12 +97,8 @@ class CreateTaskContent extends React.PureComponent<Props & RouteComponentProps,
className: 'cvat-notification-create-task-success', className: 'cvat-notification-create-task-success',
}); });
if (this.basicConfigurationComponent.current) { this.basicConfigurationComponent.current?.resetFields();
this.basicConfigurationComponent.current.resetFields(); this.advancedConfigurationComponent.current?.resetFields();
}
if (this.advancedConfigurationComponent.current) {
this.advancedConfigurationComponent.current.resetFields();
}
this.fileManagerContainer.reset(); this.fileManagerContainer.reset();
@ -116,10 +115,18 @@ class CreateTaskContent extends React.PureComponent<Props & RouteComponentProps,
}; };
private validateFiles = (): boolean => { private validateFiles = (): boolean => {
const { activeFileManagerTab } = this.state;
const files = this.fileManagerContainer.getFiles(); const files = this.fileManagerContainer.getFiles();
this.setState({ this.setState({
files, files,
}); });
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) => acc + files[key].length, 0);
return !!totalLen; return !!totalLen;
@ -298,7 +305,6 @@ class CreateTaskContent extends React.PureComponent<Props & RouteComponentProps,
ref={(container: any): void => { ref={(container: any): void => {
this.fileManagerContainer = container; this.fileManagerContainer = container;
}} }}
withRemote
/> />
</Col> </Col>
); );

@ -29,6 +29,9 @@ import ModelsPageContainer from 'containers/models-page/models-page';
import AnnotationPageContainer from 'containers/annotation-page/annotation-page'; import AnnotationPageContainer from 'containers/annotation-page/annotation-page';
import LoginPageContainer from 'containers/login-page/login-page'; import LoginPageContainer from 'containers/login-page/login-page';
import RegisterPageContainer from 'containers/register-page/register-page'; import RegisterPageContainer from 'containers/register-page/register-page';
import CloudStoragesPageComponent from 'components/cloud-storages-page/cloud-storages-page';
import CreateCloudStoragePageComponent from 'components/create-cloud-storage-page/create-cloud-storage-page';
import UpdateCloudStoragePageComponent from 'components/update-cloud-storage-page/update-cloud-storage-page';
import getCore from 'cvat-core-wrapper'; import getCore from 'cvat-core-wrapper';
import GlobalHotKeys, { KeyMap } from 'utils/mousetrap-react'; import GlobalHotKeys, { KeyMap } from 'utils/mousetrap-react';
import { NotificationsState } from 'reducers/interfaces'; import { NotificationsState } from 'reducers/interfaces';
@ -334,6 +337,17 @@ class CVATApplication extends React.PureComponent<CVATAppProps & RouteComponentP
<Route exact path='/tasks/create' component={CreateTaskPageContainer} /> <Route exact path='/tasks/create' component={CreateTaskPageContainer} />
<Route exact path='/tasks/:id' component={TaskPageContainer} /> <Route exact path='/tasks/:id' component={TaskPageContainer} />
<Route exact path='/tasks/:tid/jobs/:jid' component={AnnotationPageContainer} /> <Route exact path='/tasks/:tid/jobs/:jid' component={AnnotationPageContainer} />
<Route exact path='/cloudstorages' component={CloudStoragesPageComponent} />
<Route
exact
path='/cloudstorages/create'
component={CreateCloudStoragePageComponent}
/>
<Route
exact
path='/cloudstorages/update/:id'
component={UpdateCloudStoragePageComponent}
/>
{isModelPluginActive && ( {isModelPluginActive && (
<Route exact path='/models' component={ModelsPageContainer} /> <Route exact path='/models' component={ModelsPageContainer} />
)} )}

@ -0,0 +1,239 @@
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import './styles.scss';
import React, { ReactText, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Row } from 'antd/lib/grid';
import Tree from 'antd/lib/tree/Tree';
import Spin from 'antd/lib/spin';
import Alert from 'antd/lib/alert';
import Empty from 'antd/lib/empty';
import { EventDataNode } from 'antd/lib/tree';
import Checkbox, { CheckboxChangeEvent } from 'antd/lib/checkbox';
import Divider from 'antd/lib/divider';
import { CloudStorage, CombinedState } from 'reducers/interfaces';
import { loadCloudStorageContentAsync } from 'actions/cloud-storage-actions';
interface Props {
cloudStorage: CloudStorage;
selectedManifest: string;
onSelectFiles: (checkedKeysValue: string[]) => void;
selectedFiles: string[];
}
interface DataNode {
title: string;
key: string;
isLeaf: boolean;
disabled: boolean;
children: DataNode[];
}
interface DataStructure {
name: string;
children: Map<string, DataStructure> | null;
unparsedChildren: string[] | null;
}
type Files =
| ReactText[]
| {
checked: ReactText[];
halfChecked: ReactText[];
};
export default function CloudStorageFiles(props: Props): JSX.Element {
const {
cloudStorage, selectedManifest, selectedFiles, onSelectFiles,
} = props;
const dispatch = useDispatch();
const isFetching = useSelector((state: CombinedState) => state.cloudStorages.activities.contentLoads.fetching);
const content = useSelector((state: CombinedState) => state.cloudStorages.activities.contentLoads.content);
const error = useSelector((state: CombinedState) => state.cloudStorages.activities.contentLoads.error);
const [treeData, setTreeData] = useState<DataNode[]>([]);
const [initialData, setInitialData] = useState<DataStructure>({
name: 'root',
children: null,
unparsedChildren: null,
});
const [checkedAll, setCheckedAll] = useState(false);
useEffect(() => {
dispatch(loadCloudStorageContentAsync(cloudStorage));
}, [cloudStorage.id, selectedManifest]);
const parseContent = (mass: string[], root = ''): Map<string, DataStructure> => {
const data: Map<string, DataStructure> = new Map();
// define directories
const upperDirs: Set<string> = new Set(
mass.filter((path: string) => path.includes('/')).map((path: string) => path.split('/', 1)[0]),
);
for (const dir of upperDirs) {
const child: DataStructure = {
name: dir,
children: null,
unparsedChildren: mass
.filter((path: string) => path.startsWith(`${dir}/`))
.map((path: string) => path.replace(`${dir}/`, '')),
};
data.set(`${root}${dir}/`, child);
}
// define files
const rootFiles = mass.filter((path: string) => !path.includes('/'));
for (const rootFile of rootFiles) {
const child: DataStructure = {
name: rootFile,
children: null,
unparsedChildren: null,
};
data.set(`${root}${rootFile}`, child);
}
return data;
};
const updateData = (key: string, data: Map<string, DataStructure> | null): Map<string, DataStructure> | null => {
if (data === null) {
return data;
}
for (const [dataItemKey, dataItemValue] of data) {
if (key.startsWith(dataItemKey) && key.replace(dataItemKey, '')) {
// eslint-disable-next-line no-param-reassign
data = updateData(key, dataItemValue.children);
} else if (dataItemKey === key) {
const unparsedDataItemChildren = dataItemValue.unparsedChildren;
if (dataItemValue && unparsedDataItemChildren) {
dataItemValue.children = parseContent(unparsedDataItemChildren, dataItemKey);
dataItemValue.unparsedChildren = null;
}
}
}
return data;
};
const onLoadData = (key: string): Promise<void> =>
new Promise((resolve) => {
if (initialData.children === null) {
resolve();
return;
}
setInitialData({
...initialData,
children: updateData(key, initialData.children),
});
resolve();
});
useEffect(() => {
if (content) {
const children = parseContent(content);
setInitialData({
...initialData,
children,
});
} else {
setInitialData({
name: 'root',
children: null,
unparsedChildren: null,
});
}
}, [content]);
const prepareNodes = (data: Map<string, DataStructure>, nodes: DataNode[]): DataNode[] => {
for (const [key, value] of data) {
const node: DataNode = {
title: value.name,
key,
isLeaf: !value.children && !value.unparsedChildren,
disabled: !!value.unparsedChildren,
children: [],
};
if (value.children) {
node.children = prepareNodes(value.children, []);
}
nodes.push(node);
}
return nodes;
};
useEffect(() => {
if (initialData.children && content) {
const nodes = prepareNodes(initialData.children, []);
setTreeData(nodes);
} else {
setTreeData([]);
}
}, [initialData]);
const onChangeCheckedAll = (checked: boolean): void => {
setCheckedAll(checked);
if (checked) {
onSelectFiles((content as string[]).concat([selectedManifest]));
} else {
onSelectFiles([]);
}
};
if (isFetching) {
return (
<Row className='cvat-create-task-page-empty-cloud-storage' justify='center' align='middle'>
<Spin size='large' />
</Row>
);
}
if (error) {
return (
<Alert
className='cvat-cloud-storage-alert-fetching-failed'
message='Could not fetch cloud storage data'
type='error'
/>
);
}
return (
<>
{treeData.length ? (
<>
<Checkbox
className='cvat-cloud-storage-files-checkbox'
onChange={(event: CheckboxChangeEvent) => onChangeCheckedAll(event.target.checked)}
checked={checkedAll}
>
Select all
</Checkbox>
<Divider className='cvat-divider' />
<Tree.DirectoryTree
selectable={false}
multiple
checkable
height={256}
onCheck={(checkedKeys: Files) => {
const checkedFiles = (checkedKeys as string[]).filter((f) => !f.endsWith('/'));
if (checkedFiles.length === (content as string[]).length) {
setCheckedAll(true);
} else if (checkedAll) {
setCheckedAll(false);
}
onSelectFiles(checkedFiles.concat([selectedManifest]));
}}
loadData={(event: EventDataNode): Promise<void> => onLoadData(event.key.toLocaleString())}
treeData={treeData}
checkedKeys={selectedFiles}
/>
</>
) : (
<Empty className='cvat-empty-cloud-storages-tree' description='The storage is empty' />
)}
</>
);
}

@ -0,0 +1,188 @@
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import './styles.scss';
import React, { useEffect, useState } from 'react';
import Form from 'antd/lib/form';
import notification from 'antd/lib/notification';
import AutoComplete from 'antd/lib/auto-complete';
import Input from 'antd/lib/input';
import { debounce } from 'lodash';
import Select from 'antd/lib/select';
import getCore from 'cvat-core-wrapper';
import { CloudStorage } from 'reducers/interfaces';
import { AzureProvider, S3Provider } from 'icons';
import { ProviderType } from 'utils/enums';
import CloudStorageFiles from './cloud-storages-files';
interface Props {
formRef: any;
cloudStorage: CloudStorage | null;
searchPhrase: string;
setSearchPhrase: (searchPhrase: string) => void;
selectedFiles: string[];
onSelectFiles: (files: string[]) => void;
onSelectCloudStorage: (cloudStorageId: number | null) => void;
}
async function searchCloudStorages(filter: Record<string, string>): Promise<CloudStorage[]> {
try {
const data = await getCore().cloudStorages.get(filter);
return data;
} catch (error) {
notification.error({
message: 'Could not fetch a list of cloud storages',
description: error.toString(),
});
}
return [];
}
const { Option } = Select;
const searchCloudStoragesWrapper = debounce((phrase, setList) => {
const filter = { displayName: phrase };
searchCloudStorages(filter).then((list) => {
setList(list);
});
}, 500);
export default function CloudStorageTab(props: Props): JSX.Element {
const { searchPhrase, setSearchPhrase } = props;
const [initialList, setInitialList] = useState<CloudStorage[]>([]);
const [list, setList] = useState<CloudStorage[]>([]);
const {
formRef, cloudStorage, selectedFiles, onSelectFiles, onSelectCloudStorage,
} = props;
const [selectedManifest, setSelectedManifest] = useState<string | null>(null);
useEffect(() => {
searchCloudStorages({}).then((data) => {
setInitialList(data);
if (!list.length) {
setList(data);
}
});
}, []);
useEffect(() => {
if (!searchPhrase) {
setList(initialList);
} else {
searchCloudStoragesWrapper(searchPhrase, setList);
}
}, [searchPhrase, initialList]);
useEffect(() => {
if (cloudStorage) {
setSelectedManifest(cloudStorage.manifests[0]);
}
}, [cloudStorage]);
useEffect(() => {
if (selectedManifest) {
cloudStorage.manifestPath = selectedManifest;
}
}, [selectedManifest]);
const onBlur = (): void => {
if (!searchPhrase && cloudStorage) {
onSelectCloudStorage(null);
} else if (searchPhrase) {
const potentialStorages = list.filter((_cloudStorage) => _cloudStorage.displayName.includes(searchPhrase));
if (potentialStorages.length === 1) {
const potentialStorage = potentialStorages[0];
setSearchPhrase(potentialStorage.displayName);
// eslint-disable-next-line prefer-destructuring
potentialStorage.manifestPath = potentialStorage.manifests[0];
onSelectCloudStorage(potentialStorage);
}
}
};
return (
<Form ref={formRef} className='cvat-create-task-page-cloud-storages-tab-form' layout='vertical'>
<Form.Item
label='Select cloud storage'
name='cloudStorageSelect'
rules={[{ required: true, message: 'Please, specify a cloud storage' }]}
valuePropName='label'
>
<AutoComplete
onBlur={onBlur}
value={searchPhrase}
placeholder='Search...'
showSearch
onSearch={(phrase: string) => {
setSearchPhrase(phrase);
}}
options={list.map((_cloudStorage) => ({
value: _cloudStorage.id.toString(),
label: (
<span
className='cvat-cloud-storage-select-provider'
>
{_cloudStorage.providerType === ProviderType.AWS_S3_BUCKET ? (
<S3Provider />
) : (
<AzureProvider />
)}
{_cloudStorage.displayName}
</span>
),
}))}
onSelect={(value: string) => {
const selectedCloudStorage =
list.filter((_cloudStorage: CloudStorage) => _cloudStorage.id === +value)[0] || null;
// eslint-disable-next-line prefer-destructuring
selectedCloudStorage.manifestPath = selectedCloudStorage.manifests[0];
onSelectCloudStorage(selectedCloudStorage);
setSearchPhrase(selectedCloudStorage?.displayName || '');
}}
allowClear
>
<Input />
</AutoComplete>
</Form.Item>
{cloudStorage ? (
<Form.Item
label='Select manifest file'
name='manifestSelect'
rules={[{ required: true, message: 'Please, specify a manifest file' }]}
initialValue={cloudStorage.manifests[0]}
>
<Select
onSelect={(value: string) => setSelectedManifest(value)}
>
{cloudStorage.manifests.map(
(manifest: string): JSX.Element => (
<Option key={manifest} value={manifest}>
{manifest}
</Option>
),
)}
</Select>
</Form.Item>
) : null}
{cloudStorage && selectedManifest ? (
<Form.Item
label='Files'
name='cloudStorageFiles'
rules={[{ required: true, message: 'Please, select a files' }]}
>
<CloudStorageFiles
cloudStorage={cloudStorage}
selectedManifest={selectedManifest}
selectedFiles={selectedFiles}
onSelectFiles={onSelectFiles}
/>
</Form.Item>
) : null}
</Form>
);
}

@ -3,7 +3,8 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import './styles.scss'; import './styles.scss';
import React, { ReactText } from 'react'; import React, { ReactText, RefObject } from 'react';
import Tabs from 'antd/lib/tabs'; import Tabs from 'antd/lib/tabs';
import Input from 'antd/lib/input'; import Input from 'antd/lib/input';
import Text from 'antd/lib/typography/Text'; import Text from 'antd/lib/typography/Text';
@ -11,41 +12,52 @@ import Paragraph from 'antd/lib/typography/Paragraph';
import Upload, { RcFile } from 'antd/lib/upload'; import Upload, { RcFile } from 'antd/lib/upload';
import Empty from 'antd/lib/empty'; import Empty from 'antd/lib/empty';
import Tree, { TreeNodeNormal } from 'antd/lib/tree/Tree'; import Tree, { TreeNodeNormal } from 'antd/lib/tree/Tree';
import { FormInstance } from 'antd/lib/form';
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies
import { EventDataNode } from 'rc-tree/lib/interface'; import { EventDataNode } from 'rc-tree/lib/interface';
import { InboxOutlined } from '@ant-design/icons'; import { InboxOutlined } from '@ant-design/icons';
import consts from 'consts'; import consts from 'consts';
import { CloudStorage } from 'reducers/interfaces';
import CloudStorageTab from './cloud-storages-tab';
export interface Files { export interface Files {
local: File[]; local: File[];
share: string[]; share: string[];
remote: string[]; remote: string[];
cloudStorage: string[];
} }
interface State { interface State {
files: Files; files: Files;
expandedKeys: string[]; expandedKeys: string[];
active: 'local' | 'share' | 'remote'; active: 'local' | 'share' | 'remote' | 'cloudStorage';
cloudStorage: CloudStorage | null;
potentialCloudStorage: string;
} }
interface Props { interface Props {
withRemote: boolean;
treeData: TreeNodeNormal[]; treeData: TreeNodeNormal[];
onLoadData: (key: string, success: () => void, failure: () => void) => void; onLoadData: (key: string, success: () => void, failure: () => void) => void;
onChangeActiveKey(key: string): void; onChangeActiveKey(key: string): void;
} }
export default class FileManager extends React.PureComponent<Props, State> { export class FileManager extends React.PureComponent<Props, State> {
private cloudStorageTabFormRef: RefObject<FormInstance>;
public constructor(props: Props) { public constructor(props: Props) {
super(props); super(props);
this.cloudStorageTabFormRef = React.createRef<FormInstance>();
this.state = { this.state = {
files: { files: {
local: [], local: [],
share: [], share: [],
remote: [], remote: [],
cloudStorage: [],
}, },
cloudStorage: null,
potentialCloudStorage: '',
expandedKeys: [], expandedKeys: [],
active: 'local', active: 'local',
}; };
@ -53,12 +65,28 @@ export default class FileManager extends React.PureComponent<Props, State> {
this.loadData('/'); this.loadData('/');
} }
private onSelectCloudStorageFiles = (cloudStorageFiles: string[]): void => {
const { files } = this.state;
this.setState({
files: {
...files,
cloudStorage: cloudStorageFiles,
},
});
};
public getCloudStorageId(): number | null {
const { cloudStorage } = this.state;
return cloudStorage?.id || null;
}
public getFiles(): Files { public getFiles(): Files {
const { active, files } = this.state; const { active, files } = this.state;
return { return {
local: active === 'local' ? files.local : [], local: active === 'local' ? files.local : [],
share: active === 'share' ? files.share : [], share: active === 'share' ? files.share : [],
remote: active === 'remote' ? files.remote : [], remote: active === 'remote' ? files.remote : [],
cloudStorage: active === 'cloudStorage' ? files.cloudStorage : [],
}; };
} }
@ -72,6 +100,10 @@ export default class FileManager extends React.PureComponent<Props, State> {
}); });
public reset(): void { public reset(): void {
const { active } = this.state;
if (active === 'cloudStorage') {
this.cloudStorageTabFormRef.current?.resetFields();
}
this.setState({ this.setState({
expandedKeys: [], expandedKeys: [],
active: 'local', active: 'local',
@ -79,7 +111,10 @@ export default class FileManager extends React.PureComponent<Props, State> {
local: [], local: [],
share: [], share: [],
remote: [], remote: [],
cloudStorage: [],
}, },
cloudStorage: null,
potentialCloudStorage: '',
}); });
} }
@ -221,8 +256,33 @@ export default class FileManager extends React.PureComponent<Props, State> {
); );
} }
private renderCloudStorageSelector(): JSX.Element {
const { cloudStorage, potentialCloudStorage, files } = this.state;
return (
<Tabs.TabPane
key='cloudStorage'
className='cvat-create-task-page-cloud-storage-tab'
tab={<span> Cloud Storage </span>}
>
<CloudStorageTab
formRef={this.cloudStorageTabFormRef}
cloudStorage={cloudStorage}
selectedFiles={files.cloudStorage.filter((item) => !item.endsWith('manifest.jsonl'))}
onSelectCloudStorage={(_cloudStorage: CloudStorage | null) => {
this.setState({ cloudStorage: _cloudStorage });
}}
searchPhrase={potentialCloudStorage}
setSearchPhrase={(_potentialCloudStorage: string) => {
this.setState({ potentialCloudStorage: _potentialCloudStorage });
}}
onSelectFiles={this.onSelectCloudStorageFiles}
/>
</Tabs.TabPane>
);
}
public render(): JSX.Element { public render(): JSX.Element {
const { withRemote, onChangeActiveKey } = this.props; const { onChangeActiveKey } = this.props;
const { active } = this.state; const { active } = this.state;
return ( return (
@ -240,9 +300,12 @@ export default class FileManager extends React.PureComponent<Props, State> {
> >
{this.renderLocalSelector()} {this.renderLocalSelector()}
{this.renderShareSelector()} {this.renderShareSelector()}
{withRemote && this.renderRemoteSelector()} {this.renderRemoteSelector()}
{this.renderCloudStorageSelector()}
</Tabs> </Tabs>
</> </>
); );
} }
} }
export default FileManager;

@ -19,3 +19,26 @@
display: block; display: block;
} }
} }
.cvat-create-task-page-cloud-storages-tab {
display: flex;
justify-content: space-between;
}
.cvat-create-task-page-cloud-storages-tab-form {
.cvat-empty-cloud-storages-tree {
background-color: $background-color-2;
border: 1px;
box-sizing: border-box;
border-radius: $grid-unit-size * 0.5;
text-align: center;
> .ant-typography {
margin-top: $grid-unit-size * 1.25;
}
> .cvat-cloud-storage-icon {
font-size: $grid-unit-size * 20;
}
}
}

@ -277,7 +277,18 @@ function HeaderContainer(props: Props): JSX.Element {
> >
Tasks Tasks
</Button> </Button>
<Button
className='cvat-header-button'
type='link'
value='cloudstorages'
href='/cloudstorages?page=1'
onClick={(event: React.MouseEvent): void => {
event.preventDefault();
history.push('/cloudstorages?page=1');
}}
>
Cloud Storages
</Button>
{isModelsPluginActive && ( {isModelsPluginActive && (
<Button <Button
className='cvat-header-button' className='cvat-header-button'

@ -27,10 +27,17 @@ export default function ProjectListComponent(): JSX.Element {
); );
} }
const dimensions = {
md: 22,
lg: 18,
xl: 16,
xxl: 16,
};
return ( return (
<> <>
<Row justify='center' align='middle' className='cvat-project-list-content'> <Row justify='center' align='middle' className='cvat-project-list-content'>
<Col className='cvat-projects-list' md={22} lg={18} xl={16} xxl={14}> <Col className='cvat-projects-list' {...dimensions}>
{projects.map( {projects.map(
(project: Project): JSX.Element => ( (project: Project): JSX.Element => (
<ProjectItem key={project.instance.id} projectInstance={project} /> <ProjectItem key={project.instance.id} projectInstance={project} />
@ -39,7 +46,7 @@ export default function ProjectListComponent(): JSX.Element {
</Col> </Col>
</Row> </Row>
<Row justify='center' align='middle'> <Row justify='center' align='middle'>
<Col md={22} lg={18} xl={16} xxl={14}> <Col {...dimensions}>
<Pagination <Pagination
className='cvat-projects-pagination' className='cvat-projects-pagination'
onChange={changePage} onChange={changePage}

@ -1,87 +0,0 @@
// Copyright (C) 2020-2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import './styles.scss';
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Search from 'antd/lib/input/Search';
import SearchTooltip from 'components/search-tooltip/search-tooltip';
import { CombinedState, ProjectsQuery } from 'reducers/interfaces';
import { getProjectsAsync } from 'actions/projects-actions';
function getSearchField(gettingQuery: ProjectsQuery): string {
let searchString = '';
for (const field of Object.keys(gettingQuery)) {
if (gettingQuery[field] !== null && field !== 'page') {
if (field === 'search') {
return (gettingQuery[field] as any) as string;
}
// not constant condition
// eslint-disable-next-line
if (typeof (gettingQuery[field] === 'number')) {
searchString += `${field}:${gettingQuery[field]} AND `;
} else {
searchString += `${field}:"${gettingQuery[field]}" AND `;
}
}
}
return searchString.slice(0, -5);
}
export default function ProjectSearchField(): JSX.Element {
const dispatch = useDispatch();
const gettingQuery = useSelector((state: CombinedState) => state.projects.gettingQuery);
const handleSearch = (value: string): void => {
const query = { ...gettingQuery };
const search = value
.replace(/\s+/g, ' ')
.replace(/\s*:+\s*/g, ':')
.trim();
const fields = Object.keys(query).filter((key) => key !== 'page');
for (const field of fields) {
query[field] = null;
}
query.search = null;
let specificRequest = false;
for (const param of search.split(/[\s]+and[\s]+|[\s]+AND[\s]+/)) {
if (param.includes(':')) {
const [field, fieldValue] = param.split(':');
if (fields.includes(field) && !!fieldValue) {
specificRequest = true;
if (field === 'id') {
if (Number.isInteger(+fieldValue)) {
query[field] = +fieldValue;
}
} else {
query[field] = fieldValue;
}
}
}
}
query.page = 1;
if (!specificRequest && value) {
query.search = value;
}
dispatch(getProjectsAsync(query));
};
return (
<SearchTooltip instance='project'>
<Search
defaultValue={getSearchField(gettingQuery)}
onSearch={handleSearch}
size='large'
placeholder='Search'
/>
</SearchTooltip>
);
}

@ -46,13 +46,12 @@
} }
.cvat-projects-top-bar { .cvat-projects-top-bar {
> div:nth-child(1) { > div:first-child {
display: flex; .cvat-title {
margin-right: $grid-unit-size;
> span:nth-child(2) {
width: $grid-unit-size * 25;
margin-left: $grid-unit-size;
} }
display: flex;
} }
> div:nth-child(2) { > div:nth-child(2) {

@ -1,26 +1,41 @@
// Copyright (C) 2020 Intel Corporation // Copyright (C) 2020-2021 Intel Corporation
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
import React from 'react'; import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { Row, Col } from 'antd/lib/grid'; import { Row, Col } from 'antd/lib/grid';
import Button from 'antd/lib/button'; import Button from 'antd/lib/button';
import Text from 'antd/lib/typography/Text'; import Text from 'antd/lib/typography/Text';
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import SearchField from './search-field'; import SearchField from 'components/search-field/search-field';
import { CombinedState, ProjectsQuery } from 'reducers/interfaces';
import { getProjectsAsync } from 'actions/projects-actions';
export default function TopBarComponent(): JSX.Element { export default function TopBarComponent(): JSX.Element {
const history = useHistory(); const history = useHistory();
const dispatch = useDispatch();
const query = useSelector((state: CombinedState) => state.projects.gettingQuery);
const dimensions = {
md: 11,
lg: 9,
xl: 8,
xxl: 8,
};
return ( return (
<Row justify='center' align='middle' className='cvat-projects-top-bar'> <Row justify='center' align='middle' className='cvat-projects-top-bar'>
<Col md={11} lg={9} xl={8} xxl={7}> <Col {...dimensions}>
<Text className='cvat-title'>Projects</Text> <Text className='cvat-title'>Projects</Text>
<SearchField /> <SearchField
query={query}
instance='project'
onSearch={(_query: ProjectsQuery) => dispatch(getProjectsAsync(_query))}
/>
</Col> </Col>
<Col md={{ span: 11 }} lg={{ span: 9 }} xl={{ span: 8 }} xxl={{ span: 7 }}> <Col {...dimensions}>
<Button <Button
size='large' size='large'
id='cvat-create-project-button' id='cvat-create-project-button'

@ -0,0 +1,92 @@
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import './styles.scss';
import React from 'react';
import Search from 'antd/lib/input/Search';
import SearchTooltip from 'components/search-tooltip/search-tooltip';
interface Query {
[key: string]: string | number | boolean | null | undefined;
}
interface Props {
query: Query;
instance: 'task' | 'project' | 'cloudstorage';
onSearch(query: object): void;
}
export default function SearchField(props: Props): JSX.Element {
const { onSearch, query, instance } = props;
function parse(_query: Query): string {
let searchString = '';
for (const field of Object.keys(_query)) {
const value = _query[field];
if (value !== null && typeof value !== 'undefined' && field !== 'page') {
if (field === 'search') {
return _query[field] as string;
}
// eslint-disable-next-line
if (typeof (_query[field] === 'number')) {
searchString += `${field}: ${_query[field]} AND `;
} else {
searchString += `${field}: "${_query[field]}" AND `;
}
}
}
return searchString.slice(0, -5);
}
const handleSearch = (value: string): void => {
const currentQuery = { ...query };
const search = value
.replace(/\s+/g, ' ')
.replace(/\s*:+\s*/g, ':')
.trim();
const fields = Object.keys(query).filter((key) => key !== 'page');
for (const field of fields) {
currentQuery[field] = null;
}
currentQuery.search = null;
let specificRequest = false;
for (const param of search.split(/[\s]+and[\s]+|[\s]+AND[\s]+/)) {
if (param.includes(':')) {
const [field, fieldValue] = param.split(':');
if (fields.includes(field) && !!fieldValue) {
specificRequest = true;
if (field === 'id') {
if (Number.isInteger(+fieldValue)) {
currentQuery[field] = +fieldValue;
}
} else {
currentQuery[field] = fieldValue;
}
}
}
}
currentQuery.page = 1;
if (!specificRequest && value) {
currentQuery.search = value;
}
onSearch(currentQuery);
};
return (
<SearchTooltip instance={instance}>
<Search
className='cvat-search-field'
defaultValue={parse(query)}
onSearch={handleSearch}
size='large'
placeholder='Search'
/>
</SearchTooltip>
);
}

@ -0,0 +1,9 @@
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
@import '../../base.scss';
.cvat-search-field {
width: $grid-unit-size * 30;
}

@ -11,49 +11,111 @@ import './styles.scss';
import CVATTooltip from 'components/common/cvat-tooltip'; import CVATTooltip from 'components/common/cvat-tooltip';
interface Props { interface Props {
instance: 'task' | 'project'; instance: 'task' | 'project' | 'cloudstorage';
children: JSX.Element; children: JSX.Element;
} }
// provider: isEnum.bind(CloudStorageProviderType),
// credentialsType: isEnum.bind(CloudStorageCredentialsType),
export default function SearchTooltip(props: Props): JSX.Element { export default function SearchTooltip(props: Props): JSX.Element {
const { instance, children } = props; const { instance, children } = props;
const instances = ` ${instance}s `; const instances = ` ${instance}s `;
return ( return (
<CVATTooltip <CVATTooltip
overlayClassName={`cvat-${instance}s-search-tooltip`} overlayClassName={`cvat-${instance}s-search-tooltip cvat-search-tooltip`}
title={( title={(
<> <>
{instance === 'cloudstorage' ? (
<Paragraph>
<Text strong>displayName: Azure</Text>
<Text>
all
{instances}
where name includes the substring
<q>Azure</q>
</Text>
</Paragraph>
) : null}
{instance === 'cloudstorage' ? (
<Paragraph>
<Text strong>description: Personal bucket</Text>
<Text>
all
{instances}
where description includes the substring
<q>Personal bucket</q>
</Text>
</Paragraph>
) : null}
{instance === 'cloudstorage' ? (
<Paragraph>
<Text strong>resourceName: mycvatbucket</Text>
<Text>
all
{instances}
where a name of the resource includes the substring
<q>mycvatbucket</q>
</Text>
</Paragraph>
) : null}
{instance === 'cloudstorage' ? (
<Paragraph>
<Text strong>providerType: AWS_S3_BUCKET</Text>
<Text>
<q>AWS_S3_BUCKET</q>
or
<q>AZURE_CONTAINER</q>
</Text>
</Paragraph>
) : null}
{instance === 'cloudstorage' ? (
<Paragraph>
<Text strong>credentialsType: KEY_SECRET_KEY_PAIR</Text>
<Text>
<q>KEY_SECRET_KEY_PAIR</q>
or
<q>ACCOUNT_NAME_TOKEN_PAIR</q>
or
<q>ANONYMOUS_ACCESS</q>
</Text>
</Paragraph>
) : null}
<Paragraph> <Paragraph>
<Text strong>owner: admin</Text> <Text strong>owner: admin</Text>
<Text> <Text>
all all
{instances} {instances}
created by the user who has the substring created by users who have the substring
<q>admin</q> <q>admin</q>
in their username in their username
</Text> </Text>
</Paragraph> </Paragraph>
<Paragraph> {instance !== 'cloudstorage' ? (
<Text strong>assignee: employee</Text> <Paragraph>
<Text> <Text strong>assignee: employee</Text>
all <Text>
{instances} all
which are assigned to a user who has the substring {instances}
<q>admin</q> which are assigned to a user who has the substring
in their username <q>admin</q>
</Text> in their username
</Paragraph> </Text>
<Paragraph> </Paragraph>
<Text strong>name: training</Text> ) : null}
<Text> {instance !== 'cloudstorage' ? (
all <Paragraph>
{instances} <Text strong>name: training</Text>
with the substring <Text>
<q>training</q> all
in its name {instances}
</Text> with the substring
</Paragraph> <q>training</q>
in its name
</Text>
</Paragraph>
) : null}
{instance === 'task' ? ( {instance === 'task' ? (
<Paragraph> <Paragraph>
<Text strong>mode: annotation</Text> <Text strong>mode: annotation</Text>
@ -62,10 +124,12 @@ export default function SearchTooltip(props: Props): JSX.Element {
</Text> </Text>
</Paragraph> </Paragraph>
) : null} ) : null}
<Paragraph> {instance !== 'cloudstorage' ? (
<Text strong>status: annotation</Text> <Paragraph>
<Text>annotation, validation, or completed</Text> <Text strong>status: annotation</Text>
</Paragraph> <Text>annotation, validation, or completed</Text>
</Paragraph>
) : null}
<Paragraph> <Paragraph>
<Text strong>id: 5</Text> <Text strong>id: 5</Text>
<Text> <Text>

@ -4,12 +4,15 @@
@import '../../base.scss'; @import '../../base.scss';
.cvat-projects-search-tooltip, .cvat-search-tooltip {
.cvat-tasks-search-tooltip {
span { span {
color: white; color: white;
} }
q {
margin: 0 1em 0 1em;
}
strong::after { strong::after {
content: ' - '; content: ' - ';
} }

@ -19,9 +19,8 @@
> div:nth-child(1) { > div:nth-child(1) {
display: flex; display: flex;
> span:nth-child(2) { span {
width: 200px; margin-right: $grid-unit-size;
margin-left: 10px;
} }
} }
} }

@ -30,27 +30,6 @@ interface TasksPageProps {
taskImporting: boolean; taskImporting: boolean;
} }
function getSearchField(gettingQuery: TasksQuery): string {
let searchString = '';
for (const field of Object.keys(gettingQuery)) {
if (gettingQuery[field] !== null && field !== 'page') {
if (field === 'search') {
return (gettingQuery[field] as any) as string;
}
// not constant condition
// eslint-disable-next-line
if (typeof (gettingQuery[field] === 'number')) {
searchString += `${field}:${gettingQuery[field]} AND `;
} else {
searchString += `${field}:"${gettingQuery[field]}" AND `;
}
}
}
return searchString.slice(0, -5);
}
function updateQuery(previousQuery: TasksQuery, searchString: string): TasksQuery { function updateQuery(previousQuery: TasksQuery, searchString: string): TasksQuery {
const params = new URLSearchParams(searchString); const params = new URLSearchParams(searchString);
const query = { ...previousQuery }; const query = { ...previousQuery };
@ -127,47 +106,6 @@ class TasksPageComponent extends React.PureComponent<TasksPageProps & RouteCompo
} }
} }
private handleSearch = (value: string): void => {
const { gettingQuery } = this.props;
const query = { ...gettingQuery };
const search = value
.replace(/\s+/g, ' ')
.replace(/\s*:+\s*/g, ':')
.trim();
const fields = ['name', 'mode', 'owner', 'assignee', 'status', 'id'];
for (const field of fields) {
query[field] = null;
}
query.search = null;
let specificRequest = false;
for (const param of search.split(/[\s]+and[\s]+|[\s]+AND[\s]+/)) {
if (param.includes(':')) {
const [field, fieldValue] = param.split(':');
if (fields.includes(field) && !!fieldValue) {
specificRequest = true;
if (field === 'id') {
if (Number.isInteger(+fieldValue)) {
query[field] = +fieldValue;
}
} else {
query[field] = fieldValue;
}
}
}
}
query.page = 1;
if (!specificRequest && value) {
// only id
query.search = value;
}
this.updateURL(query);
};
private handlePagination = (page: number): void => { private handlePagination = (page: number): void => {
const { gettingQuery } = this.props; const { gettingQuery } = this.props;
@ -179,7 +117,7 @@ class TasksPageComponent extends React.PureComponent<TasksPageProps & RouteCompo
this.updateURL(query); this.updateURL(query);
}; };
private updateURL(gettingQuery: TasksQuery): void { private updateURL = (gettingQuery: TasksQuery): void => {
const { history } = this.props; const { history } = this.props;
let queryString = '?'; let queryString = '?';
for (const field of Object.keys(gettingQuery)) { for (const field of Object.keys(gettingQuery)) {
@ -197,7 +135,7 @@ class TasksPageComponent extends React.PureComponent<TasksPageProps & RouteCompo
// force update if any changes // force update if any changes
this.forceUpdate(); this.forceUpdate();
} }
} };
public render(): JSX.Element { public render(): JSX.Element {
const { const {
@ -211,8 +149,8 @@ class TasksPageComponent extends React.PureComponent<TasksPageProps & RouteCompo
return ( return (
<div className='cvat-tasks-page'> <div className='cvat-tasks-page'>
<TopBar <TopBar
onSearch={this.handleSearch} onSearch={this.updateURL}
searchValue={getSearchField(gettingQuery)} query={gettingQuery}
onFileUpload={onImportTask} onFileUpload={onImportTask}
taskImporting={taskImporting} taskImporting={taskImporting}
/> />

@ -7,22 +7,22 @@ import { useHistory } from 'react-router';
import { Row, Col } from 'antd/lib/grid'; import { Row, Col } from 'antd/lib/grid';
import { PlusOutlined, UploadOutlined, LoadingOutlined } from '@ant-design/icons'; import { PlusOutlined, UploadOutlined, LoadingOutlined } from '@ant-design/icons';
import Button from 'antd/lib/button'; import Button from 'antd/lib/button';
import Input from 'antd/lib/input';
import Text from 'antd/lib/typography/Text'; import Text from 'antd/lib/typography/Text';
import Upload from 'antd/lib/upload'; import Upload from 'antd/lib/upload';
import SearchTooltip from 'components/search-tooltip/search-tooltip'; import SearchField from 'components/search-field/search-field';
import { TasksQuery } from 'reducers/interfaces';
interface VisibleTopBarProps { interface VisibleTopBarProps {
onSearch: (value: string) => void; onSearch: (query: TasksQuery) => void;
onFileUpload(file: File): void; onFileUpload(file: File): void;
searchValue: string; query: TasksQuery;
taskImporting: boolean; taskImporting: boolean;
} }
export default function TopBarComponent(props: VisibleTopBarProps): JSX.Element { export default function TopBarComponent(props: VisibleTopBarProps): JSX.Element {
const { const {
searchValue, onSearch, onFileUpload, taskImporting, query, onSearch, onFileUpload, taskImporting,
} = props; } = props;
const history = useHistory(); const history = useHistory();
@ -33,15 +33,7 @@ export default function TopBarComponent(props: VisibleTopBarProps): JSX.Element
<Row justify='space-between' align='bottom'> <Row justify='space-between' align='bottom'>
<Col> <Col>
<Text className='cvat-title'>Tasks</Text> <Text className='cvat-title'>Tasks</Text>
<SearchTooltip instance='task'> <SearchField instance='task' onSearch={onSearch} query={query} />
<Input.Search
className='cvat-task-page-search-task'
defaultValue={searchValue}
onSearch={onSearch}
size='large'
placeholder='Search'
/>
</SearchTooltip>
</Col> </Col>
<Col> <Col>
<Row gutter={8}> <Row gutter={8}>

@ -0,0 +1,18 @@
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
@import '../../base.scss';
.cvat-update-cloud-storage-form-wrapper {
text-align: center;
padding-top: $grid-unit-size * 5;
overflow-y: auto;
height: 90%;
position: fixed;
width: 100%;
> div > span {
font-size: $grid-unit-size * 4;
}
}

@ -0,0 +1,60 @@
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import './styles.scss';
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import { Row, Col } from 'antd/lib/grid';
import Spin from 'antd/lib/spin';
import Result from 'antd/lib/result';
import Text from 'antd/lib/typography/Text';
import { CombinedState } from 'reducers/interfaces';
import { getCloudStoragesAsync } from 'actions/cloud-storage-actions';
import CreateCloudStorageForm from 'components/create-cloud-storage-page/cloud-storage-form';
interface ParamType {
id: string;
}
export default function UpdateCloudStoragePageComponent(): JSX.Element {
const dispatch = useDispatch();
const cloudStorageId = +useParams<ParamType>().id;
const isFetching = useSelector((state: CombinedState) => state.cloudStorages.fetching);
const isInitialized = useSelector((state: CombinedState) => state.cloudStorages.initialized);
const cloudStorages = useSelector((state: CombinedState) => state.cloudStorages.current)
.map((cloudStrage) => cloudStrage.instance);
const [cloudStorage] = cloudStorages.filter((_cloudStorage) => _cloudStorage.id === cloudStorageId);
useEffect(() => {
if (!cloudStorage && !isFetching) {
dispatch(getCloudStoragesAsync({ id: cloudStorageId }));
}
}, [isFetching]);
if (!cloudStorage && !isInitialized) {
return <Spin size='large' className='cvat-spinner' />;
}
if (!cloudStorage) {
return (
<Result
className='cvat-not-found'
status='404'
title={`Sorry, but the cloud storage #${cloudStorageId} was not found`}
subTitle='Please, be sure id you requested exists and you have appropriate permissions'
/>
);
}
return (
<Row justify='center' align='top' className='cvat-update-cloud-storage-form-wrapper'>
<Col md={20} lg={16} xl={14} xxl={9}>
<Text className='cvat-title'>{`Update cloud storage #${cloudStorageId}`}</Text>
<CreateCloudStorageForm cloudStorage={cloudStorage} />
</Col>
</Row>
);
}

@ -25,6 +25,25 @@ const DEFAULT_PROJECT_SUBSETS = ['Train', 'Test', 'Validation'];
const INTEL_TERMS_OF_USE_URL = 'https://www.intel.com/content/www/us/en/legal/terms-of-use.html'; const INTEL_TERMS_OF_USE_URL = 'https://www.intel.com/content/www/us/en/legal/terms-of-use.html';
const INTEL_COOKIES_URL = 'https://www.intel.com/content/www/us/en/privacy/intel-cookie-notice.html'; const INTEL_COOKIES_URL = 'https://www.intel.com/content/www/us/en/privacy/intel-cookie-notice.html';
const INTEL_PRIVACY_URL = 'https://www.intel.com/content/www/us/en/privacy/intel-privacy-notice.html'; const INTEL_PRIVACY_URL = 'https://www.intel.com/content/www/us/en/privacy/intel-privacy-notice.html';
const DEFAULT_AWS_S3_REGIONS: string[][] = [
['us-east-1', 'US East (N. Virginia)'],
['us-east-2', 'US East (Ohio)'],
['us-west-1', 'US West (N. California)'],
['us-west-2', 'US West (Oregon)'],
['ap-south-1', 'Asia Pacific (Mumbai)'],
['ap-northeast-1', 'Asia Pacific (Tokyo)'],
['ap-northeast-2', 'Asia Pacific (Seoul)'],
['ap-northeast-3', 'Asia Pacific (Osaka)'],
['ap-southeast-1', 'Asia Pacific (Singapore)'],
['ap-southeast-2', 'Asia Pacific (Sydney)'],
['ca-central-1', 'Canada (Central)'],
['eu-central-1', 'EU (Frankfurt)'],
['eu-west-1', 'Europe (Ireland)'],
['eu-west-2', 'Europe (London)'],
['eu-west-3', 'Europe (Paris)'],
['eu-north-1', 'Europe (Stockholm)'],
['sa-east-1', 'South America (São Paulo)'],
];
export default { export default {
UNDEFINED_ATTRIBUTE_VALUE, UNDEFINED_ATTRIBUTE_VALUE,
@ -47,4 +66,5 @@ export default {
INTEL_TERMS_OF_USE_URL, INTEL_TERMS_OF_USE_URL,
INTEL_COOKIES_URL, INTEL_COOKIES_URL,
INTEL_PRIVACY_URL, INTEL_PRIVACY_URL,
DEFAULT_AWS_S3_REGIONS,
}; };

@ -13,7 +13,6 @@ import { ShareItem, CombinedState } from 'reducers/interfaces';
interface OwnProps { interface OwnProps {
ref: any; ref: any;
withRemote: boolean;
onChangeActiveKey(key: string): void; onChangeActiveKey(key: string): void;
} }
@ -60,25 +59,32 @@ type Props = StateToProps & DispatchToProps & OwnProps;
export class FileManagerContainer extends React.PureComponent<Props> { export class FileManagerContainer extends React.PureComponent<Props> {
private managerComponentRef: any; private managerComponentRef: any;
public constructor(props: Props) {
super(props);
this.managerComponentRef = React.createRef();
}
public getFiles(): Files { public getFiles(): Files {
return this.managerComponentRef.getFiles(); return this.managerComponentRef.getFiles();
} }
public getCloudStorageId(): number | null {
return this.managerComponentRef.getCloudStorageId();
}
public reset(): Files { public reset(): Files {
return this.managerComponentRef.reset(); return this.managerComponentRef.reset();
} }
public render(): JSX.Element { public render(): JSX.Element {
const { const { treeData, getTreeData, onChangeActiveKey } = this.props;
treeData, getTreeData, withRemote, onChangeActiveKey,
} = this.props;
return ( return (
<FileManagerComponent <FileManagerComponent
treeData={treeData} treeData={treeData}
onLoadData={getTreeData} onLoadData={getTreeData}
onChangeActiveKey={onChangeActiveKey} onChangeActiveKey={onChangeActiveKey}
withRemote={withRemote}
ref={(component): void => { ref={(component): void => {
this.managerComponentRef = component; this.managerComponentRef = component;
}} }}

@ -50,6 +50,8 @@ import SVGAITools from './assets/ai-tools-icon.svg';
import SVGBrain from './assets/brain.svg'; import SVGBrain from './assets/brain.svg';
import SVGOpenCV from './assets/opencv.svg'; import SVGOpenCV from './assets/opencv.svg';
import SVGFilterIcon from './assets/object-filter-icon.svg'; import SVGFilterIcon from './assets/object-filter-icon.svg';
import SVGCVATAzureProvider from './assets/vscode-icons_file-type-azure.svg';
import SVGCVATS3Provider from './assets/S3.svg';
export const CVATLogo = React.memo((): JSX.Element => <SVGCVATLogo />); export const CVATLogo = React.memo((): JSX.Element => <SVGCVATLogo />);
export const AccountIcon = React.memo((): JSX.Element => <SVGAccountIcon />); export const AccountIcon = React.memo((): JSX.Element => <SVGAccountIcon />);
@ -97,3 +99,5 @@ export const ColorizeIcon = React.memo((): JSX.Element => <SVGColorizeIcon />);
export const BrainIcon = React.memo((): JSX.Element => <SVGBrain />); export const BrainIcon = React.memo((): JSX.Element => <SVGBrain />);
export const OpenCVIcon = React.memo((): JSX.Element => <SVGOpenCV />); export const OpenCVIcon = React.memo((): JSX.Element => <SVGOpenCV />);
export const FilterIcon = React.memo((): JSX.Element => <SVGFilterIcon />); export const FilterIcon = React.memo((): JSX.Element => <SVGFilterIcon />);
export const AzureProvider = React.memo((): JSX.Element => <SVGCVATAzureProvider />);
export const S3Provider = React.memo((): JSX.Element => <SVGCVATS3Provider />);

@ -0,0 +1,343 @@
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
import { CloudStorageActions, CloudStorageActionTypes } from 'actions/cloud-storage-actions';
import { AuthActions, AuthActionTypes } from 'actions/auth-actions';
import { CloudStoragesState, CloudStorage } from './interfaces';
const defaultState: CloudStoragesState = {
initialized: false,
fetching: false,
count: 0,
current: [],
// currentStatuses: [],
gettingQuery: {
page: 1,
id: null,
search: null,
owner: null,
displayName: null,
description: null,
resourceName: null,
providerType: null,
credentialsType: null,
status: null,
},
activities: {
creates: {
attaching: false,
id: null,
error: '',
},
updates: {
updating: false,
cloudStorageID: null,
error: '',
},
deletes: {},
contentLoads: {
cloudStorageID: null,
content: null,
fetching: false,
error: '',
},
// getsStatus: {
// cloudStorageID: null,
// status: null,
// fetching: false,
// error: '',
// },
},
};
export default (
state: CloudStoragesState = defaultState,
action: CloudStorageActions | AuthActions,
): CloudStoragesState => {
switch (action.type) {
case CloudStorageActionTypes.UPDATE_CLOUD_STORAGES_GETTING_QUERY:
return {
...state,
gettingQuery: {
...defaultState.gettingQuery,
...action.payload.query,
},
};
case CloudStorageActionTypes.GET_CLOUD_STORAGES:
return {
...state,
initialized: false,
fetching: true,
count: 0,
current: [],
// currentStatuses: [],
};
case CloudStorageActionTypes.GET_CLOUD_STORAGE_SUCCESS: {
const { count, query } = action.payload;
const combined = action.payload.array.map(
(cloudStorage: any, index: number): CloudStorage => ({
instance: cloudStorage,
preview: action.payload.previews[index],
status: action.payload.statuses[index],
}),
);
return {
...state,
initialized: true,
fetching: false,
count,
gettingQuery: {
...defaultState.gettingQuery,
...query,
},
current: combined,
};
}
case CloudStorageActionTypes.GET_CLOUD_STORAGE_FAILED: {
return {
...state,
initialized: true,
fetching: false,
};
}
case CloudStorageActionTypes.CREATE_CLOUD_STORAGE: {
return {
...state,
activities: {
...state.activities,
creates: {
attaching: true,
id: null,
error: '',
},
},
};
}
case CloudStorageActionTypes.CREATE_CLOUD_STORAGE_SUCCESS: {
return {
...state,
activities: {
...state.activities,
creates: {
attaching: false,
id: action.payload.cloudStorageID,
error: '',
},
},
};
}
case CloudStorageActionTypes.CREATE_CLOUD_STORAGE_FAILED: {
return {
...state,
activities: {
...state.activities,
creates: {
...state.activities.creates,
attaching: false,
error: action.payload.error.toString(),
},
},
};
}
case CloudStorageActionTypes.UPDATE_CLOUD_STORAGE: {
return {
...state,
activities: {
...state.activities,
updates: {
updating: true,
cloudStorageID: null,
error: '',
},
},
};
}
case CloudStorageActionTypes.UPDATE_CLOUD_STORAGE_SUCCESS: {
const { cloudStorage } = action.payload;
return {
...state,
activities: {
...state.activities,
updates: {
updating: false,
cloudStorageID: cloudStorage.id,
error: '',
},
},
current: state.current.map(
(_cloudStorage: CloudStorage): CloudStorage => {
if (_cloudStorage.id === cloudStorage.id) {
return cloudStorage;
}
return _cloudStorage;
},
),
};
}
case CloudStorageActionTypes.UPDATE_CLOUD_STORAGE_FAILED: {
return {
...state,
activities: {
...state.activities,
updates: {
...state.activities.updates,
updating: false,
error: action.payload.error.toString(),
},
},
};
}
case CloudStorageActionTypes.DELETE_CLOUD_STORAGE: {
const { cloudStorageID } = action.payload;
const { deletes } = state.activities;
deletes[cloudStorageID] = false;
return {
...state,
activities: {
...state.activities,
deletes: {
...deletes,
},
},
};
}
case CloudStorageActionTypes.DELETE_CLOUD_STORAGE_SUCCESS: {
const { cloudStorageID } = action.payload;
const { deletes } = state.activities;
deletes[cloudStorageID] = true;
return {
...state,
activities: {
...state.activities,
deletes: {
...deletes,
},
},
};
}
case CloudStorageActionTypes.DELETE_CLOUD_STORAGE_FAILED: {
const { cloudStorageID } = action.payload;
const { deletes } = state.activities;
delete deletes[cloudStorageID];
return {
...state,
activities: {
...state.activities,
deletes: {
...deletes,
},
},
};
}
case CloudStorageActionTypes.LOAD_CLOUD_STORAGE_CONTENT:
return {
...state,
activities: {
...state.activities,
contentLoads: {
cloudStorageID: null,
content: null,
error: '',
fetching: true,
},
},
};
case CloudStorageActionTypes.LOAD_CLOUD_STORAGE_CONTENT_SUCCESS: {
const { cloudStorageID, content } = action.payload;
return {
...state,
activities: {
...state.activities,
contentLoads: {
cloudStorageID,
content,
error: '',
fetching: false,
},
},
};
}
case CloudStorageActionTypes.LOAD_CLOUD_STORAGE_CONTENT_FAILED: {
return {
...state,
activities: {
...state.activities,
contentLoads: {
...state.activities.contentLoads,
error: action.payload.error.toString(),
fetching: false,
},
},
};
}
// case CloudStorageActionTypes.GET_CLOUD_STORAGE_STATUS:
// return {
// ...state,
// activities: {
// ...state.activities,
// getsStatus: {
// cloudStorageID: null,
// status: null,
// error: '',
// fetching: true,
// },
// },
// };
// case CloudStorageActionTypes.GET_CLOUD_STORAGE_STATUS_SUCCESS: {
// const { cloudStorageID, status } = action.payload;
// const statuses = state.currentStatuses;
// const index = statuses.findIndex((item) => item.id === cloudStorageID);
// if (index !== -1) {
// statuses[index] = {
// ...statuses[index],
// status,
// };
// } else {
// statuses.push({
// id: cloudStorageID,
// status,
// });
// }
// return {
// ...state,
// currentStatuses: statuses,
// activities: {
// ...state.activities,
// getsStatus: {
// cloudStorageID,
// status,
// error: '',
// fetching: false,
// },
// },
// };
// }
// case CloudStorageActionTypes.GET_CLOUD_STORAGE_STATUS_FAILED: {
// return {
// ...state,
// activities: {
// ...state.activities,
// getsStatus: {
// ...state.activities.getsStatus,
// error: action.payload.error.toString(),
// fetching: false,
// },
// },
// };
// }
case AuthActionTypes.LOGOUT_SUCCESS: {
return { ...defaultState };
}
default:
return state;
}
};

@ -30,6 +30,7 @@ export interface ProjectsQuery {
owner: string | null; owner: string | null;
name: string | null; name: string | null;
status: string | null; status: string | null;
assignee: string | null;
[key: string]: string | boolean | number | null | undefined; [key: string]: string | boolean | number | null | undefined;
} }
@ -121,6 +122,57 @@ export interface FormatsState {
initialized: boolean; initialized: boolean;
} }
export interface CloudStoragesQuery {
page: number;
id: number | null;
search: string | null;
owner: string | null;
displayName: string | null;
description: string | null;
resourceName: string | null;
providerType: string | null;
credentialsType: string | null;
[key: string]: string | number | null | undefined;
}
export type CloudStorage = any;
export interface CloudStoragesState {
initialized: boolean;
fetching: boolean;
count: number;
current: CloudStorage[];
// currentStatuses: any[];
gettingQuery: CloudStoragesQuery;
activities: {
creates: {
attaching: boolean;
id: null | number;
error: string;
};
updates: {
updating: boolean;
cloudStorageID: null | number;
error: string;
};
deletes: {
[cloudStorageID: number]: boolean;
};
contentLoads: {
cloudStorageID: number | null;
content: any | null;
fetching: boolean;
error: string;
};
// getsStatus: {
// cloudStorageID: number | null;
// status: string | null;
// fetching: boolean;
// error: string;
// };
};
}
export enum SupportedPlugins { export enum SupportedPlugins {
GIT_INTEGRATION = 'GIT_INTEGRATION', GIT_INTEGRATION = 'GIT_INTEGRATION',
ANALYTICS = 'ANALYTICS', ANALYTICS = 'ANALYTICS',
@ -334,6 +386,12 @@ export interface NotificationsState {
predictor: { predictor: {
prediction: null | ErrorState; prediction: null | ErrorState;
}; };
cloudStorages: {
creating: null | ErrorState;
fetching: null | ErrorState;
updating: null | ErrorState;
deleting: null | ErrorState;
};
}; };
messages: { messages: {
tasks: { tasks: {
@ -633,6 +691,7 @@ export interface CombinedState {
shortcuts: ShortcutsState; shortcuts: ShortcutsState;
review: ReviewState; review: ReviewState;
export: ExportState; export: ExportState;
cloudStorages: CloudStoragesState;
} }
export enum DimensionType { export enum DimensionType {

@ -17,6 +17,7 @@ import { BoundariesActionTypes } from 'actions/boundaries-actions';
import { UserAgreementsActionTypes } from 'actions/useragreements-actions'; import { UserAgreementsActionTypes } from 'actions/useragreements-actions';
import { ReviewActionTypes } from 'actions/review-actions'; import { ReviewActionTypes } from 'actions/review-actions';
import { ExportActionTypes } from 'actions/export-actions'; import { ExportActionTypes } from 'actions/export-actions';
import { CloudStorageActionTypes } from 'actions/cloud-storage-actions';
import getCore from 'cvat-core-wrapper'; import getCore from 'cvat-core-wrapper';
import { NotificationsState } from './interfaces'; import { NotificationsState } from './interfaces';
@ -113,6 +114,12 @@ const defaultState: NotificationsState = {
predictor: { predictor: {
prediction: null, prediction: null,
}, },
cloudStorages: {
creating: null,
fetching: null,
updating: null,
deleting: null,
},
}, },
messages: { messages: {
tasks: { tasks: {
@ -1175,6 +1182,126 @@ export default function (state = defaultState, action: AnyAction): Notifications
}, },
}; };
} }
case CloudStorageActionTypes.GET_CLOUD_STORAGE_FAILED: {
return {
...state,
errors: {
...state.errors,
cloudStorages: {
...state.errors.cloudStorages,
fetching: {
message: 'Could not fetch cloud storage',
reason: action.payload.error.toString(),
},
},
},
};
}
case CloudStorageActionTypes.CREATE_CLOUD_STORAGE_FAILED: {
return {
...state,
errors: {
...state.errors,
cloudStorages: {
...state.errors.cloudStorages,
creating: {
message: 'Could not create the cloud storage',
reason: action.payload.error.toString(),
className: 'cvat-notification-notice-create-cloud-storage-failed',
},
},
},
};
}
case CloudStorageActionTypes.UPDATE_CLOUD_STORAGE_FAILED: {
const { cloudStorage, error } = action.payload;
return {
...state,
errors: {
...state.errors,
cloudStorages: {
...state.errors.cloudStorages,
updating: {
message: `Could not update cloud storage #${cloudStorage.id}`,
reason: error.toString(),
className: 'cvat-notification-notice-update-cloud-storage-failed',
},
},
},
};
}
case CloudStorageActionTypes.DELETE_CLOUD_STORAGE_FAILED: {
const { cloudStorageID } = action.payload;
return {
...state,
errors: {
...state.errors,
cloudStorages: {
...state.errors.cloudStorages,
deleting: {
message:
'Could not delete ' +
`<a href="/cloudstorages/${cloudStorageID}" target="_blank">
cloud storage ${cloudStorageID}</a>`,
reason: action.payload.error.toString(),
className: 'cvat-notification-notice-delete-cloud-storage-failed',
},
},
},
};
}
case CloudStorageActionTypes.LOAD_CLOUD_STORAGE_CONTENT_FAILED: {
const { cloudStorageID } = action.payload;
return {
...state,
errors: {
...state.errors,
cloudStorages: {
...state.errors.cloudStorages,
fetching: {
message: `Could not fetch content for cloud storage #${cloudStorageID}`,
reason: action.payload.error.toString(),
className: 'cvat-notification-notice-fetch-cloud-storage-content-failed',
},
},
},
};
}
case CloudStorageActionTypes.GET_CLOUD_STORAGE_STATUS_FAILED: {
const { cloudStorageID } = action.payload;
return {
...state,
errors: {
...state.errors,
cloudStorages: {
...state.errors.cloudStorages,
fetching: {
message: `Could not fetch cloud storage #${cloudStorageID} status`,
reason: action.payload.error.toString(),
className: 'cvat-notification-notice-fetch-cloud-storage-status-failed',
},
},
},
};
}
case CloudStorageActionTypes.GET_CLOUD_STORAGE_PREVIEW_FAILED: {
const { cloudStorageID } = action.payload;
return {
...state,
errors: {
...state.errors,
cloudStorages: {
...state.errors.cloudStorages,
fetching: {
message: `Could not fetch preview for cloud storage #${cloudStorageID}`,
reason: action.payload.error.toString(),
className: 'cvat-notification-notice-fetch-cloud-storage-preview-failed',
},
},
},
};
}
case BoundariesActionTypes.RESET_AFTER_ERROR: case BoundariesActionTypes.RESET_AFTER_ERROR:
case AuthActionTypes.LOGOUT_SUCCESS: { case AuthActionTypes.LOGOUT_SUCCESS: {
return { ...defaultState }; return { ...defaultState };

@ -19,6 +19,7 @@ const defaultState: ProjectsState = {
id: null, id: null,
search: null, search: null,
owner: null, owner: null,
assignee: null,
name: null, name: null,
status: null, status: null,
}, },
@ -119,8 +120,10 @@ export default (state: ProjectsState = defaultState, action: AnyAction): Project
current: state.current.map( current: state.current.map(
(project): Project => ({ (project): Project => ({
...project, ...project,
instance: project.instance.id === action.payload.project.id ? instance:
action.payload.project : project.instance, project.instance.id === action.payload.project.id ?
action.payload.project :
project.instance,
}), }),
), ),
}; };
@ -131,8 +134,10 @@ export default (state: ProjectsState = defaultState, action: AnyAction): Project
current: state.current.map( current: state.current.map(
(project): Project => ({ (project): Project => ({
...project, ...project,
instance: project.instance.id === action.payload.project.id ? instance:
action.payload.project : project.instance, project.instance.id === action.payload.project.id ?
action.payload.project :
project.instance,
}), }),
), ),
}; };

@ -18,6 +18,7 @@ import shortcutsReducer from './shortcuts-reducer';
import userAgreementsReducer from './useragreements-reducer'; import userAgreementsReducer from './useragreements-reducer';
import reviewReducer from './review-reducer'; import reviewReducer from './review-reducer';
import exportReducer from './export-reducer'; import exportReducer from './export-reducer';
import cloudStoragesReducer from './cloud-storages-reducer';
export default function createRootReducer(): Reducer { export default function createRootReducer(): Reducer {
return combineReducers({ return combineReducers({
@ -35,6 +36,7 @@ export default function createRootReducer(): Reducer {
shortcuts: shortcutsReducer, shortcuts: shortcutsReducer,
userAgreements: userAgreementsReducer, userAgreements: userAgreementsReducer,
review: reviewReducer, review: reviewReducer,
export: exportReducer, export: exportReducer,
cloudStorages: cloudStoragesReducer,
}); });
} }

@ -68,3 +68,17 @@ hr {
z-index: 9999; z-index: 9999;
pointer-events: none; pointer-events: none;
} }
.cvat-cloud-storage-select-provider {
display: flex;
justify-content: flex-start;
align-items: center;
> svg {
margin-right: $grid-unit-size;
}
}
.cvat-divider {
margin: $grid-unit-size * 0.5 0;
}

@ -0,0 +1,20 @@
// Copyright (C) 2021 Intel Corporation
//
// SPDX-License-Identifier: MIT
export enum ProviderType {
AWS_S3_BUCKET = 'AWS_S3_BUCKET',
AZURE_CONTAINER = 'AZURE_CONTAINER',
}
export enum CredentialsType {
KEY_SECRET_KEY_PAIR = 'KEY_SECRET_KEY_PAIR',
ACCOUNT_NAME_TOKEN_PAIR = 'ACCOUNT_NAME_TOKEN_PAIR',
ANONYMOUS_ACCESS = 'ANONYMOUS_ACCESS',
}
export enum StorageStatuses {
AVAILABLE = 'AVAILABLE',
FORBIDDEN = 'FORBIDDEN',
NOT_FOUND = 'NOT_FOUND',
}

@ -11,7 +11,7 @@ context('Search task feature.', () => {
function searchTask(option, result) { function searchTask(option, result) {
cy.intercept('GET', '/api/v1/tasks**').as('searchTask'); cy.intercept('GET', '/api/v1/tasks**').as('searchTask');
cy.get('.cvat-task-page-search-task').find('[placeholder="Search"]').clear().type(`${option}{Enter}`); cy.get('.cvat-search-field').find('[placeholder="Search"]').clear().type(`${option}{Enter}`);
cy.wait('@searchTask').its('response.statusCode').should('equal', 200); cy.wait('@searchTask').its('response.statusCode').should('equal', 200);
cy.contains('.cvat-item-task-name', taskName).should(result); cy.contains('.cvat-item-task-name', taskName).should(result);
} }
@ -30,7 +30,7 @@ context('Search task feature.', () => {
describe(`Testing case "${caseId}"`, () => { describe(`Testing case "${caseId}"`, () => {
it('Tooltip task filter contain all the possible options.', () => { it('Tooltip task filter contain all the possible options.', () => {
cy.get('.cvat-task-page-search-task').trigger('mouseover'); cy.get('.cvat-search-field').trigger('mouseover');
cy.get('.cvat-tasks-search-tooltip').should('be.visible'); cy.get('.cvat-tasks-search-tooltip').should('be.visible');
}); });

Loading…
Cancel
Save