Skip to content

Commit 3dda3fe

Browse files
authored
feat: display a list of operations (#1445)
1 parent 8405466 commit 3dda3fe

File tree

15 files changed

+525
-0
lines changed

15 files changed

+525
-0
lines changed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import React from 'react';
2+
3+
import {AccessDenied} from '../../components/Errors/403';
4+
import {isAccessError} from '../../components/Errors/PageError/PageError';
5+
import {ResponseError} from '../../components/Errors/ResponseError';
6+
import {ResizeableDataTable} from '../../components/ResizeableDataTable/ResizeableDataTable';
7+
import {TableWithControlsLayout} from '../../components/TableWithControlsLayout/TableWithControlsLayout';
8+
import {operationListApi} from '../../store/reducers/operationList';
9+
import {useAutoRefreshInterval} from '../../utils/hooks';
10+
11+
import {OperationsControls} from './OperationsControls';
12+
import {getColumns} from './columns';
13+
import i18n from './i18n';
14+
import {b} from './shared';
15+
import {useOperationsQueryParams} from './useOperationsQueryParams';
16+
17+
interface OperationsProps {
18+
database: string;
19+
}
20+
21+
export function Operations({database}: OperationsProps) {
22+
const [autoRefreshInterval] = useAutoRefreshInterval();
23+
24+
const {kind, searchValue, pageSize, pageToken, handleKindChange, handleSearchChange} =
25+
useOperationsQueryParams();
26+
27+
const {data, isFetching, error} = operationListApi.useGetOperationListQuery(
28+
{database, kind, page_size: pageSize, page_token: pageToken},
29+
{
30+
pollingInterval: autoRefreshInterval,
31+
},
32+
);
33+
34+
const filteredOperations = React.useMemo(() => {
35+
if (!data?.operations) {
36+
return [];
37+
}
38+
return data.operations.filter((op) =>
39+
op.id?.toLowerCase().includes(searchValue.toLowerCase()),
40+
);
41+
}, [data?.operations, searchValue]);
42+
43+
if (isAccessError(error)) {
44+
return <AccessDenied position="left" />;
45+
}
46+
47+
return (
48+
<TableWithControlsLayout>
49+
<TableWithControlsLayout.Controls>
50+
<OperationsControls
51+
kind={kind}
52+
searchValue={searchValue}
53+
entitiesCountCurrent={filteredOperations.length}
54+
entitiesCountTotal={data?.operations?.length}
55+
entitiesLoading={isFetching}
56+
handleKindChange={handleKindChange}
57+
handleSearchChange={handleSearchChange}
58+
/>
59+
</TableWithControlsLayout.Controls>
60+
{error ? <ResponseError error={error} /> : null}
61+
<TableWithControlsLayout.Table loading={isFetching} className={b('table')}>
62+
{data ? (
63+
<ResizeableDataTable
64+
columns={getColumns()}
65+
data={filteredOperations}
66+
emptyDataMessage={i18n('title_empty')}
67+
/>
68+
) : null}
69+
</TableWithControlsLayout.Table>
70+
</TableWithControlsLayout>
71+
);
72+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React from 'react';
2+
3+
import {Select} from '@gravity-ui/uikit';
4+
5+
import {EntitiesCount} from '../../components/EntitiesCount';
6+
import {Search} from '../../components/Search';
7+
import type {OperationKind} from '../../types/api/operationList';
8+
9+
import {OPERATION_KINDS} from './constants';
10+
import i18n from './i18n';
11+
import {b} from './shared';
12+
13+
interface OperationsControlsProps {
14+
kind: OperationKind;
15+
searchValue: string;
16+
entitiesCountCurrent: number;
17+
entitiesCountTotal?: number;
18+
entitiesLoading: boolean;
19+
handleKindChange: (kind: OperationKind) => void;
20+
handleSearchChange: (value: string) => void;
21+
}
22+
23+
export function OperationsControls({
24+
kind,
25+
searchValue,
26+
entitiesCountCurrent,
27+
entitiesCountTotal,
28+
entitiesLoading,
29+
handleKindChange,
30+
handleSearchChange,
31+
}: OperationsControlsProps) {
32+
return (
33+
<React.Fragment>
34+
<Search
35+
value={searchValue}
36+
onChange={handleSearchChange}
37+
placeholder={i18n('pleaceholder_search')}
38+
className={b('search')}
39+
/>
40+
<Select
41+
value={[kind]}
42+
options={OPERATION_KINDS}
43+
onUpdate={(value) => handleKindChange(value[0] as OperationKind)}
44+
/>
45+
<EntitiesCount
46+
label={i18n('label_operations')}
47+
loading={entitiesLoading}
48+
total={entitiesCountTotal}
49+
current={entitiesCountCurrent}
50+
/>
51+
</React.Fragment>
52+
);
53+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import {duration} from '@gravity-ui/date-utils';
2+
import type {Column as DataTableColumn} from '@gravity-ui/react-data-table';
3+
import {Text} from '@gravity-ui/uikit';
4+
5+
import {CellWithPopover} from '../../components/CellWithPopover/CellWithPopover';
6+
import type {TOperation} from '../../types/api/operationList';
7+
import {EStatusCode} from '../../types/api/operationList';
8+
import {EMPTY_DATA_PLACEHOLDER, HOUR_IN_SECONDS, SECOND_IN_MS} from '../../utils/constants';
9+
import {formatDateTime} from '../../utils/dataFormatters/dataFormatters';
10+
import {parseProtobufTimestampToMs} from '../../utils/timeParsers';
11+
12+
import {COLUMNS_NAMES, COLUMNS_TITLES} from './constants';
13+
import i18n from './i18n';
14+
15+
export function getColumns(): DataTableColumn<TOperation>[] {
16+
return [
17+
{
18+
name: COLUMNS_NAMES.ID,
19+
header: COLUMNS_TITLES[COLUMNS_NAMES.ID],
20+
width: 340,
21+
render: ({row}) => {
22+
if (!row.id) {
23+
return EMPTY_DATA_PLACEHOLDER;
24+
}
25+
return (
26+
<CellWithPopover placement={['top', 'bottom']} content={row.id}>
27+
{row.id}
28+
</CellWithPopover>
29+
);
30+
},
31+
},
32+
{
33+
name: COLUMNS_NAMES.STATUS,
34+
header: COLUMNS_TITLES[COLUMNS_NAMES.STATUS],
35+
render: ({row}) => {
36+
if (!row.status) {
37+
return EMPTY_DATA_PLACEHOLDER;
38+
}
39+
return (
40+
<Text color={row.status === EStatusCode.SUCCESS ? 'positive' : 'danger'}>
41+
{row.status}
42+
</Text>
43+
);
44+
},
45+
},
46+
{
47+
name: COLUMNS_NAMES.CREATED_BY,
48+
header: COLUMNS_TITLES[COLUMNS_NAMES.CREATED_BY],
49+
render: ({row}) => {
50+
if (!row.created_by) {
51+
return EMPTY_DATA_PLACEHOLDER;
52+
}
53+
return row.created_by;
54+
},
55+
},
56+
{
57+
name: COLUMNS_NAMES.CREATE_TIME,
58+
header: COLUMNS_TITLES[COLUMNS_NAMES.CREATE_TIME],
59+
render: ({row}) => {
60+
if (!row.create_time) {
61+
return EMPTY_DATA_PLACEHOLDER;
62+
}
63+
return formatDateTime(parseProtobufTimestampToMs(row.create_time));
64+
},
65+
sortAccessor: (row) =>
66+
row.create_time ? parseProtobufTimestampToMs(row.create_time) : 0,
67+
},
68+
{
69+
name: COLUMNS_NAMES.END_TIME,
70+
header: COLUMNS_TITLES[COLUMNS_NAMES.END_TIME],
71+
render: ({row}) => {
72+
if (!row.end_time) {
73+
return EMPTY_DATA_PLACEHOLDER;
74+
}
75+
return formatDateTime(parseProtobufTimestampToMs(row.end_time));
76+
},
77+
sortAccessor: (row) =>
78+
row.end_time ? parseProtobufTimestampToMs(row.end_time) : Number.MAX_SAFE_INTEGER,
79+
},
80+
{
81+
name: COLUMNS_NAMES.DURATION,
82+
header: COLUMNS_TITLES[COLUMNS_NAMES.DURATION],
83+
render: ({row}) => {
84+
let durationValue = 0;
85+
if (!row.create_time) {
86+
return EMPTY_DATA_PLACEHOLDER;
87+
}
88+
const createTime = parseProtobufTimestampToMs(row.create_time);
89+
if (row.end_time) {
90+
const endTime = parseProtobufTimestampToMs(row.end_time);
91+
durationValue = endTime - createTime;
92+
} else {
93+
durationValue = Date.now() - createTime;
94+
}
95+
96+
const durationFormatted =
97+
durationValue > HOUR_IN_SECONDS * SECOND_IN_MS
98+
? duration(durationValue).format('hh:mm:ss')
99+
: duration(durationValue).format('mm:ss');
100+
101+
return row.end_time
102+
? durationFormatted
103+
: i18n('label_duration-ongoing', {value: durationFormatted});
104+
},
105+
sortAccessor: (row) => {
106+
if (!row.create_time) {
107+
return 0;
108+
}
109+
const createTime = parseProtobufTimestampToMs(row.create_time);
110+
if (row.end_time) {
111+
const endTime = parseProtobufTimestampToMs(row.end_time);
112+
return endTime - createTime;
113+
}
114+
return Date.now() - createTime;
115+
},
116+
},
117+
];
118+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type {OperationKind} from '../../types/api/operationList';
2+
3+
import i18n from './i18n';
4+
5+
export const OPERATIONS_SELECTED_COLUMNS_KEY = 'selectedOperationColumns';
6+
7+
export const COLUMNS_NAMES = {
8+
ID: 'id',
9+
STATUS: 'status',
10+
CREATED_BY: 'created_by',
11+
CREATE_TIME: 'create_time',
12+
END_TIME: 'end_time',
13+
DURATION: 'duration',
14+
} as const;
15+
16+
export const COLUMNS_TITLES = {
17+
[COLUMNS_NAMES.ID]: i18n('column_operationId'),
18+
[COLUMNS_NAMES.STATUS]: i18n('column_status'),
19+
[COLUMNS_NAMES.CREATED_BY]: i18n('column_createdBy'),
20+
[COLUMNS_NAMES.CREATE_TIME]: i18n('column_createTime'),
21+
[COLUMNS_NAMES.END_TIME]: i18n('column_endTime'),
22+
[COLUMNS_NAMES.DURATION]: i18n('column_duration'),
23+
} as const;
24+
25+
export const BASE_COLUMNS = [
26+
COLUMNS_NAMES.ID,
27+
COLUMNS_NAMES.STATUS,
28+
COLUMNS_NAMES.CREATED_BY,
29+
COLUMNS_NAMES.CREATE_TIME,
30+
COLUMNS_NAMES.END_TIME,
31+
COLUMNS_NAMES.DURATION,
32+
];
33+
34+
export const OPERATION_KINDS: {value: OperationKind; content: string}[] = [
35+
{value: 'export', content: i18n('kind_export')},
36+
{value: 'ss/backgrounds', content: i18n('kind_ssBackgrounds')},
37+
{value: 'buildindex', content: i18n('kind_buildIndex')},
38+
];
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"label_operations": "Operations",
3+
"title_empty": "No operations data",
4+
"pleaceholder_search": "Search operations",
5+
"placeholder_kind": "Select operation kind",
6+
"kind_ssBackgrounds": "SS/Backgrounds",
7+
"kind_export": "Export",
8+
"kind_buildIndex": "Build Index",
9+
10+
"column_operationId": "Operation ID",
11+
"column_status": "Status",
12+
"column_createdBy": "Created By",
13+
"column_createTime": "Create Time",
14+
"column_endTime": "End Time",
15+
"column_duration": "Duration",
16+
"label_duration-ongoing": "{{value}} (ongoing)"
17+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import {registerKeysets} from '../../../utils/i18n';
2+
3+
import en from './en.json';
4+
5+
const COMPONENT = 'ydb-operations';
6+
7+
export default registerKeysets(COMPONENT, {en});

src/containers/Operations/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './Operations';
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import {cn} from '../../utils/cn';
2+
3+
export const b = cn('operations');
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {NumberParam, StringParam, useQueryParams} from 'use-query-params';
2+
import {z} from 'zod';
3+
4+
import type {OperationKind} from '../../types/api/operationList';
5+
6+
const operationKindSchema = z.enum(['ss/backgrounds', 'export', 'buildindex']).catch('buildindex');
7+
8+
export function useOperationsQueryParams() {
9+
const [queryParams, setQueryParams] = useQueryParams({
10+
kind: StringParam,
11+
search: StringParam,
12+
pageSize: NumberParam,
13+
pageToken: StringParam,
14+
});
15+
16+
const kind = operationKindSchema.parse(queryParams.kind) as OperationKind;
17+
const searchValue = queryParams.search ?? '';
18+
const pageSize = queryParams.pageSize ?? undefined;
19+
const pageToken = queryParams.pageToken ?? undefined;
20+
21+
const handleKindChange = (value: OperationKind) => {
22+
setQueryParams({kind: value}, 'replaceIn');
23+
};
24+
25+
const handleSearchChange = (value: string) => {
26+
setQueryParams({search: value || undefined}, 'replaceIn');
27+
};
28+
29+
const handlePageSizeChange = (value: number) => {
30+
setQueryParams({pageSize: value}, 'replaceIn');
31+
};
32+
33+
const handlePageTokenChange = (value: string | undefined) => {
34+
setQueryParams({pageToken: value}, 'replaceIn');
35+
};
36+
37+
return {
38+
kind,
39+
searchValue,
40+
pageSize,
41+
pageToken,
42+
handleKindChange,
43+
handleSearchChange,
44+
handlePageSizeChange,
45+
handlePageTokenChange,
46+
};
47+
}

src/containers/Tenant/Diagnostics/Diagnostics.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {cn} from '../../../utils/cn';
1616
import {useTypedDispatch, useTypedSelector} from '../../../utils/hooks';
1717
import {Heatmap} from '../../Heatmap';
1818
import {NodesWrapper} from '../../Nodes/NodesWrapper';
19+
import {Operations} from '../../Operations';
1920
import {StorageWrapper} from '../../Storage/StorageWrapper';
2021
import {Tablets} from '../../Tablets';
2122
import {SchemaViewer} from '../Schema/SchemaViewer/SchemaViewer';
@@ -137,6 +138,9 @@ function Diagnostics(props: DiagnosticsProps) {
137138
case TENANT_DIAGNOSTICS_TABS_IDS.configs: {
138139
return <Configs database={tenantName} />;
139140
}
141+
case TENANT_DIAGNOSTICS_TABS_IDS.operations: {
142+
return <Operations database={tenantName} />;
143+
}
140144
default: {
141145
return <div>No data...</div>;
142146
}

0 commit comments

Comments
 (0)