diff --git a/src/containers/Operations/Operations.tsx b/src/containers/Operations/Operations.tsx
new file mode 100644
index 0000000000..bb9d66f54e
--- /dev/null
+++ b/src/containers/Operations/Operations.tsx
@@ -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 ;
+ }
+
+ return (
+
+
+
+
+ {error ? : null}
+
+ {data ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/containers/Operations/OperationsControls.tsx b/src/containers/Operations/OperationsControls.tsx
new file mode 100644
index 0000000000..f0d203e342
--- /dev/null
+++ b/src/containers/Operations/OperationsControls.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/src/containers/Operations/columns.tsx b/src/containers/Operations/columns.tsx
new file mode 100644
index 0000000000..bf8302d094
--- /dev/null
+++ b/src/containers/Operations/columns.tsx
@@ -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[] {
+ return [
+ {
+ name: COLUMNS_NAMES.ID,
+ header: COLUMNS_TITLES[COLUMNS_NAMES.ID],
+ width: 340,
+ render: ({row}) => {
+ if (!row.id) {
+ return EMPTY_DATA_PLACEHOLDER;
+ }
+ return (
+
+ {row.id}
+
+ );
+ },
+ },
+ {
+ name: COLUMNS_NAMES.STATUS,
+ header: COLUMNS_TITLES[COLUMNS_NAMES.STATUS],
+ render: ({row}) => {
+ if (!row.status) {
+ return EMPTY_DATA_PLACEHOLDER;
+ }
+ return (
+
+ {row.status}
+
+ );
+ },
+ },
+ {
+ 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('label_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;
+ },
+ },
+ ];
+}
diff --git a/src/containers/Operations/constants.ts b/src/containers/Operations/constants.ts
new file mode 100644
index 0000000000..869e54e6dd
--- /dev/null
+++ b/src/containers/Operations/constants.ts
@@ -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')},
+];
diff --git a/src/containers/Operations/i18n/en.json b/src/containers/Operations/i18n/en.json
new file mode 100644
index 0000000000..d38a15908e
--- /dev/null
+++ b/src/containers/Operations/i18n/en.json
@@ -0,0 +1,17 @@
+{
+ "label_operations": "Operations",
+ "title_empty": "No operations data",
+ "pleaceholder_search": "Search operations",
+ "placeholder_kind": "Select operation kind",
+ "kind_ssBackgrounds": "SS/Backgrounds",
+ "kind_export": "Export",
+ "kind_buildIndex": "Build Index",
+
+ "column_operationId": "Operation ID",
+ "column_status": "Status",
+ "column_createdBy": "Created By",
+ "column_createTime": "Create Time",
+ "column_endTime": "End Time",
+ "column_duration": "Duration",
+ "label_duration-ongoing": "{{value}} (ongoing)"
+}
diff --git a/src/containers/Operations/i18n/index.ts b/src/containers/Operations/i18n/index.ts
new file mode 100644
index 0000000000..ce3c610081
--- /dev/null
+++ b/src/containers/Operations/i18n/index.ts
@@ -0,0 +1,7 @@
+import {registerKeysets} from '../../../utils/i18n';
+
+import en from './en.json';
+
+const COMPONENT = 'ydb-operations';
+
+export default registerKeysets(COMPONENT, {en});
diff --git a/src/containers/Operations/index.ts b/src/containers/Operations/index.ts
new file mode 100644
index 0000000000..6e9bb57cba
--- /dev/null
+++ b/src/containers/Operations/index.ts
@@ -0,0 +1 @@
+export * from './Operations';
diff --git a/src/containers/Operations/shared.ts b/src/containers/Operations/shared.ts
new file mode 100644
index 0000000000..19569df58d
--- /dev/null
+++ b/src/containers/Operations/shared.ts
@@ -0,0 +1,3 @@
+import {cn} from '../../utils/cn';
+
+export const b = cn('operations');
diff --git a/src/containers/Operations/useOperationsQueryParams.ts b/src/containers/Operations/useOperationsQueryParams.ts
new file mode 100644
index 0000000000..570cdd044b
--- /dev/null
+++ b/src/containers/Operations/useOperationsQueryParams.ts
@@ -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,
+ };
+}
diff --git a/src/containers/Tenant/Diagnostics/Diagnostics.tsx b/src/containers/Tenant/Diagnostics/Diagnostics.tsx
index 9aefbb38e7..e351682153 100644
--- a/src/containers/Tenant/Diagnostics/Diagnostics.tsx
+++ b/src/containers/Tenant/Diagnostics/Diagnostics.tsx
@@ -16,6 +16,7 @@ import {cn} from '../../../utils/cn';
import {useTypedDispatch, useTypedSelector} from '../../../utils/hooks';
import {Heatmap} from '../../Heatmap';
import {NodesWrapper} from '../../Nodes/NodesWrapper';
+import {Operations} from '../../Operations';
import {StorageWrapper} from '../../Storage/StorageWrapper';
import {Tablets} from '../../Tablets';
import {SchemaViewer} from '../Schema/SchemaViewer/SchemaViewer';
@@ -137,6 +138,9 @@ function Diagnostics(props: DiagnosticsProps) {
case TENANT_DIAGNOSTICS_TABS_IDS.configs: {
return ;
}
+ case TENANT_DIAGNOSTICS_TABS_IDS.operations: {
+ return ;
+ }
default: {
return No data...
;
}
diff --git a/src/containers/Tenant/Diagnostics/DiagnosticsPages.ts b/src/containers/Tenant/Diagnostics/DiagnosticsPages.ts
index 480a0e494a..b30741fd4b 100644
--- a/src/containers/Tenant/Diagnostics/DiagnosticsPages.ts
+++ b/src/containers/Tenant/Diagnostics/DiagnosticsPages.ts
@@ -75,6 +75,11 @@ const configs = {
title: 'Configs',
};
+const operations = {
+ id: TENANT_DIAGNOSTICS_TABS_IDS.operations,
+ title: 'Operations',
+};
+
export const ASYNC_REPLICATION_PAGES = [overview, tablets, describe];
export const DATABASE_PAGES = [
@@ -87,6 +92,7 @@ export const DATABASE_PAGES = [
network,
describe,
configs,
+ operations,
];
export const TABLE_PAGES = [overview, schema, topShards, nodes, graph, tablets, hotKeys, describe];
diff --git a/src/services/api.ts b/src/services/api.ts
index 659b9f40b6..e6313b2dfa 100644
--- a/src/services/api.ts
+++ b/src/services/api.ts
@@ -23,6 +23,7 @@ import type {ModifyDiskResponse} from '../types/api/modifyDisk';
import type {TNetInfo} from '../types/api/netInfo';
import type {NodesRequestParams, TNodesInfo} from '../types/api/nodes';
import type {TEvNodesInfo} from '../types/api/nodesList';
+import type {OperationListRequestParams, TOperationList} from '../types/api/operationList';
import type {EDecommitStatus, TEvPDiskStateResponse, TPDiskInfoResponse} from '../types/api/pdisk';
import type {
Actions,
@@ -870,6 +871,20 @@ export class YdbEmbeddedAPI extends AxiosWrapper {
throw new Error('Method is not implemented.');
}
+ getOperationList(
+ params: OperationListRequestParams,
+ {concurrentId, signal}: AxiosOptions = {},
+ ) {
+ return this.get(
+ this.getPath('/operation/list'),
+ {...params},
+ {
+ concurrentId,
+ requestConfig: {signal},
+ },
+ );
+ }
+
getClusterBaseInfo(
_clusterName: string,
_opts: AxiosOptions = {},
diff --git a/src/store/reducers/operationList.ts b/src/store/reducers/operationList.ts
new file mode 100644
index 0000000000..88daff9b7d
--- /dev/null
+++ b/src/store/reducers/operationList.ts
@@ -0,0 +1,20 @@
+import type {OperationListRequestParams} from '../../types/api/operationList';
+
+import {api} from './api';
+
+export const operationListApi = api.injectEndpoints({
+ endpoints: (build) => ({
+ getOperationList: build.query({
+ queryFn: async (params: OperationListRequestParams, {signal}) => {
+ try {
+ const data = await window.api.getOperationList(params, {signal});
+ return {data};
+ } catch (error) {
+ return {error};
+ }
+ },
+ providesTags: ['All'],
+ }),
+ }),
+ overrideExisting: 'throw',
+});
diff --git a/src/store/reducers/tenant/constants.ts b/src/store/reducers/tenant/constants.ts
index 7faf1d0e0d..32b31606be 100644
--- a/src/store/reducers/tenant/constants.ts
+++ b/src/store/reducers/tenant/constants.ts
@@ -26,6 +26,7 @@ export const TENANT_DIAGNOSTICS_TABS_IDS = {
consumers: 'consumers',
partitions: 'partitions',
configs: 'configs',
+ operations: 'operations',
} as const;
export const TENANT_SUMMARY_TABS_IDS = {
diff --git a/src/types/api/operationList.ts b/src/types/api/operationList.ts
new file mode 100644
index 0000000000..5f5c3bd7b0
--- /dev/null
+++ b/src/types/api/operationList.ts
@@ -0,0 +1,123 @@
+import type {IProtobufTimeObject} from './common';
+
+/**
+ * endpoint: /operation/list
+ *
+ * source: https://github.com/ydb-platform/ydb/blob/main/ydb/public/api/protos/ydb_operation.proto
+ */
+export enum EStatusCode {
+ STATUS_CODE_UNSPECIFIED = 'STATUS_CODE_UNSPECIFIED',
+ SUCCESS = 'SUCCESS',
+ BAD_REQUEST = 'BAD_REQUEST',
+ UNAUTHORIZED = 'UNAUTHORIZED',
+ INTERNAL_ERROR = 'INTERNAL_ERROR',
+ ABORTED = 'ABORTED',
+ UNAVAILABLE = 'UNAVAILABLE',
+ OVERLOADED = 'OVERLOADED',
+ SCHEME_ERROR = 'SCHEME_ERROR',
+ GENERIC_ERROR = 'GENERIC_ERROR',
+ TIMEOUT = 'TIMEOUT',
+ BAD_SESSION = 'BAD_SESSION',
+ PRECONDITION_FAILED = 'PRECONDITION_FAILED',
+ ALREADY_EXISTS = 'ALREADY_EXISTS',
+ NOT_FOUND = 'NOT_FOUND',
+ SESSION_EXPIRED = 'SESSION_EXPIRED',
+ CANCELLED = 'CANCELLED',
+ UNDETERMINED = 'UNDETERMINED',
+ UNSUPPORTED = 'UNSUPPORTED',
+ SESSION_BUSY = 'SESSION_BUSY',
+ EXTERNAL_ERROR = 'EXTERNAL_ERROR',
+}
+
+export interface TPosition {
+ row?: number;
+ column?: number;
+ file?: string;
+}
+
+export interface TIssueMessage {
+ position?: TPosition;
+ message?: string;
+ end_position?: TPosition;
+ issue_code?: number;
+ severity?: number;
+ issues?: TIssueMessage[];
+}
+
+export interface IndexBuildMetadata {
+ '@type'?: 'type.googleapis.com/Ydb.Table.IndexBuildMetadata';
+ description?: IndexBuildDescription;
+ state?: IndexBuildState;
+ progress?: number;
+}
+
+interface IndexBuildDescription {
+ path?: string;
+ index?: TableIndex;
+}
+
+export type TGlobalIndex = {_type?: 'global_index'; global_index: unknown};
+export type TGlobalAsyncIndex = {_type?: 'global_async_index'; global_async_index: unknown};
+export type TGlobalUniqueIndex = {_type?: 'global_unique_index'; global_unique_index: unknown};
+export type TGlobalVectorKmeansTreeIndexIndex = {
+ _type?: 'global_vector_kmeans_tree_index';
+ global_vector_kmeans_tree_index: unknown;
+};
+
+export type TIndexType =
+ | TGlobalIndex
+ | TGlobalAsyncIndex
+ | TGlobalUniqueIndex
+ | TGlobalVectorKmeansTreeIndexIndex;
+
+type TableIndex = {
+ name?: string;
+ index_columns?: string[];
+ data_columns?: string[];
+} & TIndexType;
+
+export enum IndexBuildState {
+ STATE_UNSPECIFIED = 'STATE_UNSPECIFIED',
+ STATE_PREPARING = 'STATE_PREPARING',
+ STATE_TRANSFERING_DATA = 'STATE_TRANSFERING_DATA',
+ STATE_APPLYING = 'STATE_APPLYING',
+ STATE_DONE = 'STATE_DONE',
+ STATE_CANCELLATION = 'STATE_CANCELLATION',
+ STATE_CANCELLED = 'STATE_CANCELLED',
+ STATE_REJECTION = 'STATE_REJECTION',
+ STATE_REJECTED = 'STATE_REJECTED',
+}
+
+export type TOperationMetadata = IndexBuildMetadata;
+
+export interface TCostInfo {
+ consumed_units?: number;
+}
+
+export interface TOperation {
+ id?: string;
+ ready?: boolean;
+ status?: EStatusCode;
+ issues?: TIssueMessage[];
+ metadata?: TOperationMetadata;
+ cost_info?: TCostInfo;
+ create_time?: IProtobufTimeObject;
+ end_time?: IProtobufTimeObject;
+ created_by?: string;
+}
+
+export interface TOperationList {
+ status?: EStatusCode;
+ issues?: TIssueMessage[];
+ operations?: TOperation[];
+ next_page_token?: string;
+}
+
+export type OperationKind = 'ss/backgrounds' | 'import' | 'export' | 'buildindex' | 'scriptexec';
+
+export interface OperationListRequestParams {
+ database: string;
+ kind: OperationKind;
+ page_size?: number;
+ page_token?: string;
+}