Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/containers/Operations/Operations.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React from 'react';

import {AccessDenied} from '../../components/Errors/403';
import {isAccessError} from '../../components/Errors/PageError/PageError';
import {ResponseError} from '../../components/Errors/ResponseError';
import {ResizeableDataTable} from '../../components/ResizeableDataTable/ResizeableDataTable';
import {TableWithControlsLayout} from '../../components/TableWithControlsLayout/TableWithControlsLayout';
import {operationListApi} from '../../store/reducers/operationList';
import {useAutoRefreshInterval} from '../../utils/hooks';

import {OperationsControls} from './OperationsControls';
import {getColumns} from './columns';
import i18n from './i18n';
import {b} from './shared';
import {useOperationsQueryParams} from './useOperationsQueryParams';

interface OperationsProps {
database: string;
}

export function Operations({database}: OperationsProps) {
const [autoRefreshInterval] = useAutoRefreshInterval();

const {kind, searchValue, pageSize, pageToken, handleKindChange, handleSearchChange} =
useOperationsQueryParams();

const {data, isFetching, error} = operationListApi.useGetOperationListQuery(
{database, kind, page_size: pageSize, page_token: pageToken},
{
pollingInterval: autoRefreshInterval,
},
);

const filteredOperations = React.useMemo(() => {
if (!data?.operations) {
return [];
}
return data.operations.filter((op) =>
op.id?.toLowerCase().includes(searchValue.toLowerCase()),
);
}, [data?.operations, searchValue]);

if (isAccessError(error)) {
return <AccessDenied position="left" />;
}

return (
<TableWithControlsLayout>
<TableWithControlsLayout.Controls>
<OperationsControls
kind={kind}
searchValue={searchValue}
entitiesCountCurrent={filteredOperations.length}
entitiesCountTotal={data?.operations?.length}
entitiesLoading={isFetching}
handleKindChange={handleKindChange}
handleSearchChange={handleSearchChange}
/>
</TableWithControlsLayout.Controls>
{error ? <ResponseError error={error} /> : null}
<TableWithControlsLayout.Table loading={isFetching} className={b('table')}>
{data ? (
<ResizeableDataTable
columns={getColumns()}
data={filteredOperations}
emptyDataMessage={i18n('operations.noData')}
/>
) : null}
</TableWithControlsLayout.Table>
</TableWithControlsLayout>
);
}
53 changes: 53 additions & 0 deletions src/containers/Operations/OperationsControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from 'react';

import {Select} from '@gravity-ui/uikit';

import {EntitiesCount} from '../../components/EntitiesCount';
import {Search} from '../../components/Search';
import type {OperationKind} from '../../types/api/operationList';

import {OPERATION_KINDS} from './constants';
import i18n from './i18n';
import {b} from './shared';

interface OperationsControlsProps {
kind: OperationKind;
searchValue: string;
entitiesCountCurrent: number;
entitiesCountTotal?: number;
entitiesLoading: boolean;
handleKindChange: (kind: OperationKind) => void;
handleSearchChange: (value: string) => void;
}

export function OperationsControls({
kind,
searchValue,
entitiesCountCurrent,
entitiesCountTotal,
entitiesLoading,
handleKindChange,
handleSearchChange,
}: OperationsControlsProps) {
return (
<React.Fragment>
<Search
value={searchValue}
onChange={handleSearchChange}
placeholder={i18n('controls.searchPlaceholder')}
className={b('search')}
/>
<Select
value={[kind]}
options={OPERATION_KINDS}
onUpdate={(value) => handleKindChange(value[0] as OperationKind)}
/>
<EntitiesCount
label={i18n('label.operations')}
loading={entitiesLoading}
total={entitiesCountTotal}
current={entitiesCountCurrent}
/>
</React.Fragment>
);
}
118 changes: 118 additions & 0 deletions src/containers/Operations/columns.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {duration} from '@gravity-ui/date-utils';
import type {Column as DataTableColumn} from '@gravity-ui/react-data-table';
import {Text} from '@gravity-ui/uikit';

import {CellWithPopover} from '../../components/CellWithPopover/CellWithPopover';
import type {TOperation} from '../../types/api/operationList';
import {EStatusCode} from '../../types/api/operationList';
import {EMPTY_DATA_PLACEHOLDER, HOUR_IN_SECONDS, SECOND_IN_MS} from '../../utils/constants';
import {formatDateTime} from '../../utils/dataFormatters/dataFormatters';
import {parseProtobufTimestampToMs} from '../../utils/timeParsers';

import {COLUMNS_NAMES, COLUMNS_TITLES} from './constants';
import i18n from './i18n';

export function getColumns(): DataTableColumn<TOperation>[] {
return [
{
name: COLUMNS_NAMES.ID,
header: COLUMNS_TITLES[COLUMNS_NAMES.ID],
width: 340,
render: ({row}) => {
if (!row.id) {
return EMPTY_DATA_PLACEHOLDER;
}
return (
<CellWithPopover placement={['top', 'bottom']} content={row.id}>
{row.id}
</CellWithPopover>
);
},
},
{
name: COLUMNS_NAMES.STATUS,
header: COLUMNS_TITLES[COLUMNS_NAMES.STATUS],
render: ({row}) => {
if (!row.status) {
return EMPTY_DATA_PLACEHOLDER;
}
return (
<Text color={row.status === EStatusCode.SUCCESS ? 'positive' : 'danger'}>
{row.status}
</Text>
);
},
},
{
name: COLUMNS_NAMES.CREATED_BY,
header: COLUMNS_TITLES[COLUMNS_NAMES.CREATED_BY],
render: ({row}) => {
if (!row.created_by) {
return EMPTY_DATA_PLACEHOLDER;
}
return row.created_by;
},
},
{
name: COLUMNS_NAMES.CREATE_TIME,
header: COLUMNS_TITLES[COLUMNS_NAMES.CREATE_TIME],
render: ({row}) => {
if (!row.create_time) {
return EMPTY_DATA_PLACEHOLDER;
}
return formatDateTime(parseProtobufTimestampToMs(row.create_time));
},
sortAccessor: (row) =>
row.create_time ? parseProtobufTimestampToMs(row.create_time) : 0,
},
{
name: COLUMNS_NAMES.END_TIME,
header: COLUMNS_TITLES[COLUMNS_NAMES.END_TIME],
render: ({row}) => {
if (!row.end_time) {
return EMPTY_DATA_PLACEHOLDER;
}
return formatDateTime(parseProtobufTimestampToMs(row.end_time));
},
sortAccessor: (row) =>
row.end_time ? parseProtobufTimestampToMs(row.end_time) : Number.MAX_SAFE_INTEGER,
},
{
name: COLUMNS_NAMES.DURATION,
header: COLUMNS_TITLES[COLUMNS_NAMES.DURATION],
render: ({row}) => {
let durationValue = 0;
if (!row.create_time) {
return EMPTY_DATA_PLACEHOLDER;
}
const createTime = parseProtobufTimestampToMs(row.create_time);
if (row.end_time) {
const endTime = parseProtobufTimestampToMs(row.end_time);
durationValue = endTime - createTime;
} else {
durationValue = Date.now() - createTime;
}

const durationFormatted =
durationValue > HOUR_IN_SECONDS * SECOND_IN_MS
? duration(durationValue).format('hh:mm:ss')
: duration(durationValue).format('mm:ss');

return row.end_time
? durationFormatted
: i18n('duration.ongoing', {value: durationFormatted});
},
sortAccessor: (row) => {
if (!row.create_time) {
return 0;
}
const createTime = parseProtobufTimestampToMs(row.create_time);
if (row.end_time) {
const endTime = parseProtobufTimestampToMs(row.end_time);
return endTime - createTime;
}
return Date.now() - createTime;
},
},
];
}
38 changes: 38 additions & 0 deletions src/containers/Operations/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type {OperationKind} from '../../types/api/operationList';

import i18n from './i18n';

export const OPERATIONS_SELECTED_COLUMNS_KEY = 'selectedOperationColumns';

export const COLUMNS_NAMES = {
ID: 'id',
STATUS: 'status',
CREATED_BY: 'created_by',
CREATE_TIME: 'create_time',
END_TIME: 'end_time',
DURATION: 'duration',
} as const;

export const COLUMNS_TITLES = {
[COLUMNS_NAMES.ID]: i18n('column.operationId'),
[COLUMNS_NAMES.STATUS]: i18n('column.status'),
[COLUMNS_NAMES.CREATED_BY]: i18n('column.createdBy'),
[COLUMNS_NAMES.CREATE_TIME]: i18n('column.createTime'),
[COLUMNS_NAMES.END_TIME]: i18n('column.endTime'),
[COLUMNS_NAMES.DURATION]: i18n('column.duration'),
} as const;

export const BASE_COLUMNS = [
COLUMNS_NAMES.ID,
COLUMNS_NAMES.STATUS,
COLUMNS_NAMES.CREATED_BY,
COLUMNS_NAMES.CREATE_TIME,
COLUMNS_NAMES.END_TIME,
COLUMNS_NAMES.DURATION,
];

export const OPERATION_KINDS: {value: OperationKind; content: string}[] = [
{value: 'export', content: i18n('kind.export')},
{value: 'ss/backgrounds', content: i18n('kind.ssBackgrounds')},
{value: 'buildindex', content: i18n('kind.buildIndex')},
];
41 changes: 41 additions & 0 deletions src/containers/Operations/i18n/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"label.operations": "Operations",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For new pages lets use dataui rules of keysets naming https://nda.ya.ru/t/jntDK5SX7926D7

"operations.noData": "No operations data",
"dialog.cancel.header": "Cancel operation",
"dialog.cancel.text": "The operation will be cancelled. Do you want to proceed?",
"controls.cancelNotAllowed": "You don't have enough rights to cancel this operation",
"controls.searchPlaceholder": "Search operations",
"controls.kindPlaceholder": "Select operation kind",
"page.title": "Operations",
"kind.ssBackgrounds": "SS/Backgrounds",
"kind.export": "Export",
"kind.buildIndex": "Build Index",
"status.success": "Success",
"status.badRequest": "Bad Request",
"status.unauthorized": "Unauthorized",
"status.internalError": "Internal Error",
"status.aborted": "Aborted",
"status.unavailable": "Unavailable",
"status.overloaded": "Overloaded",
"status.schemeError": "Scheme Error",
"status.genericError": "Generic Error",
"status.timeout": "Timeout",
"status.badSession": "Bad Session",
"status.preconditionFailed": "Precondition Failed",
"status.alreadyExists": "Already Exists",
"status.notFound": "Not Found",
"status.sessionExpired": "Session Expired",
"status.cancelled": "Cancelled",
"status.undetermined": "Undetermined",
"status.unsupported": "Unsupported",
"status.sessionBusy": "Session Busy",
"status.externalError": "External Error",

"column.operationId": "Operation ID",
"column.status": "Status",
"column.createdBy": "Created By",
"column.createTime": "Create Time",
"column.endTime": "End Time",
"column.duration": "Duration",
"duration.ongoing": "{{value}} (ongoing)"
}
7 changes: 7 additions & 0 deletions src/containers/Operations/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import {registerKeysets} from '../../../utils/i18n';

import en from './en.json';

const COMPONENT = 'ydb-operations';

export default registerKeysets(COMPONENT, {en});
1 change: 1 addition & 0 deletions src/containers/Operations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Operations';
3 changes: 3 additions & 0 deletions src/containers/Operations/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {cn} from '../../utils/cn';

export const b = cn('operations');
47 changes: 47 additions & 0 deletions src/containers/Operations/useOperationsQueryParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {NumberParam, StringParam, useQueryParams} from 'use-query-params';
import {z} from 'zod';

import type {OperationKind} from '../../types/api/operationList';

const operationKindSchema = z.enum(['ss/backgrounds', 'export', 'buildindex']).catch('buildindex');

export function useOperationsQueryParams() {
const [queryParams, setQueryParams] = useQueryParams({
kind: StringParam,
search: StringParam,
pageSize: NumberParam,
pageToken: StringParam,
});

const kind = operationKindSchema.parse(queryParams.kind) as OperationKind;
const searchValue = queryParams.search ?? '';
const pageSize = queryParams.pageSize ?? undefined;
const pageToken = queryParams.pageToken ?? undefined;

const handleKindChange = (value: OperationKind) => {
setQueryParams({kind: value}, 'replaceIn');
};

const handleSearchChange = (value: string) => {
setQueryParams({search: value || undefined}, 'replaceIn');
};

const handlePageSizeChange = (value: number) => {
setQueryParams({pageSize: value}, 'replaceIn');
};

const handlePageTokenChange = (value: string | undefined) => {
setQueryParams({pageToken: value}, 'replaceIn');
};

return {
kind,
searchValue,
pageSize,
pageToken,
handleKindChange,
handleSearchChange,
handlePageSizeChange,
handlePageTokenChange,
};
}
Loading
Loading