diff --git a/config-overrides.js b/config-overrides.js index 98185f0970..2505a89bc0 100644 --- a/config-overrides.js +++ b/config-overrides.js @@ -58,7 +58,9 @@ module.exports = { // By default jest does not transform anything in node_modules // So this override excludes node_modules except @gravity-ui // see https://github.com/timarney/react-app-rewired/issues/241 - config.transformIgnorePatterns = ['node_modules/(?!(@gravity-ui|@mjackson)/)']; + config.transformIgnorePatterns = [ + 'node_modules/(?!(@gravity-ui|@mjackson|@standard-schema)/)', + ]; // Add .github directory to roots config.roots = ['/src', '/.github']; diff --git a/package-lock.json b/package-lock.json index 1499dc66bf..70ad988696 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "@gravity-ui/websql-autocomplete": "^13.7.0", "@hookform/resolvers": "^3.10.0", "@mjackson/multipart-parser": "^0.8.2", - "@reduxjs/toolkit": "^2.5.0", + "@reduxjs/toolkit": "^2.8.2", "@tanstack/react-table": "^8.20.6", "@ydb-platform/monaco-ghost": "^0.6.1", "axios": "^1.8.4", @@ -5033,10 +5033,13 @@ } }, "node_modules/@reduxjs/toolkit": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.5.0.tgz", - "integrity": "sha512-awNe2oTodsZ6LmRqmkFhtb/KH03hUhxOamEQy411m3Njj3BbFvoBovxo4Q1cBWnV1ErprVj9MlF0UPXkng0eyg==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz", + "integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==", + "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", "immer": "^10.0.3", "redux": "^5.0.1", "redux-thunk": "^3.1.0", @@ -5172,6 +5175,18 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", diff --git a/package.json b/package.json index ea58b37696..64692450fc 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "@gravity-ui/websql-autocomplete": "^13.7.0", "@hookform/resolvers": "^3.10.0", "@mjackson/multipart-parser": "^0.8.2", - "@reduxjs/toolkit": "^2.5.0", + "@reduxjs/toolkit": "^2.8.2", "@tanstack/react-table": "^8.20.6", "@ydb-platform/monaco-ghost": "^0.6.1", "axios": "^1.8.4", diff --git a/src/components/TableSkeleton/TableSkeleton.scss b/src/components/TableSkeleton/TableSkeleton.scss index d976c39f88..1590db2f1b 100644 --- a/src/components/TableSkeleton/TableSkeleton.scss +++ b/src/components/TableSkeleton/TableSkeleton.scss @@ -36,6 +36,19 @@ &__col-5 { width: 20%; + margin-right: 5%; + } + + &__col-6, + &__col-7, + &__col-8, + &__col-9 { + width: 8%; + margin-right: 3%; + } + + &__col-10 { + width: 8%; } &__col-full { diff --git a/src/components/TableSkeleton/TableSkeleton.tsx b/src/components/TableSkeleton/TableSkeleton.tsx index 9b5161695d..0e13a856af 100644 --- a/src/components/TableSkeleton/TableSkeleton.tsx +++ b/src/components/TableSkeleton/TableSkeleton.tsx @@ -11,20 +11,36 @@ interface TableSkeletonProps { className?: string; rows?: number; delay?: number; + columns?: number; + showHeader?: boolean; } -export const TableSkeleton = ({rows = 2, delay = 600, className}: TableSkeletonProps) => { +export const TableSkeleton = ({ + rows = 2, + delay = 600, + className, + columns = 5, + showHeader = true, +}: TableSkeletonProps) => { const [show] = useDelayed(delay); - return ( -
+ const renderHeaderRow = () => { + if (!showHeader) { + return null; + } + + return (
- - - - - + {[...new Array(columns)].map((_, index) => ( + + ))}
+ ); + }; + + return ( +
+ {renderHeaderRow()} {[...new Array(rows)].map((_, index) => (
diff --git a/src/containers/Operations/Operations.tsx b/src/containers/Operations/Operations.tsx index 45f69be6d0..8540e2c0ef 100644 --- a/src/containers/Operations/Operations.tsx +++ b/src/containers/Operations/Operations.tsx @@ -3,9 +3,9 @@ import React from 'react'; import {AccessDenied} from '../../components/Errors/403'; import {ResponseError} from '../../components/Errors/ResponseError'; import {ResizeableDataTable} from '../../components/ResizeableDataTable/ResizeableDataTable'; +import {TableSkeleton} from '../../components/TableSkeleton/TableSkeleton'; import {TableWithControlsLayout} from '../../components/TableWithControlsLayout/TableWithControlsLayout'; -import {operationsApi} from '../../store/reducers/operations'; -import {useAutoRefreshInterval} from '../../utils/hooks'; +import {DEFAULT_TABLE_SETTINGS} from '../../utils/constants'; import {isAccessError} from '../../utils/response'; import {OperationsControls} from './OperationsControls'; @@ -13,46 +13,46 @@ import {getColumns} from './columns'; import {OPERATIONS_SELECTED_COLUMNS_KEY} from './constants'; import i18n from './i18n'; import {b} from './shared'; +import {useOperationsInfiniteQuery} from './useOperationsInfiniteQuery'; import {useOperationsQueryParams} from './useOperationsQueryParams'; interface OperationsProps { database: string; + scrollContainerRef?: React.RefObject; } -export function Operations({database}: OperationsProps) { - const [autoRefreshInterval] = useAutoRefreshInterval(); - - const {kind, searchValue, pageSize, pageToken, handleKindChange, handleSearchChange} = +export function Operations({database, scrollContainerRef}: OperationsProps) { + const {kind, searchValue, pageSize, handleKindChange, handleSearchChange} = useOperationsQueryParams(); - const {data, isLoading, error, refetch} = operationsApi.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]); + const {operations, isLoading, isLoadingMore, error, refreshTable, totalCount} = + useOperationsInfiniteQuery({ + database, + kind, + pageSize, + searchValue, + scrollContainerRef, + }); if (isAccessError(error)) { return ; } + const settings = React.useMemo(() => { + return { + ...DEFAULT_TABLE_SETTINGS, + sortable: false, + }; + }, []); + return ( {error ? : null} - {data ? ( + {operations.length > 0 || isLoading ? ( - ) : null} + ) : ( +
{i18n('title_empty')}
+ )} + {isLoadingMore && ( + + )}
); diff --git a/src/containers/Operations/columns.tsx b/src/containers/Operations/columns.tsx index d6dc6e3d27..11e5de5636 100644 --- a/src/containers/Operations/columns.tsx +++ b/src/containers/Operations/columns.tsx @@ -6,7 +6,7 @@ import {ActionTooltip, Flex, Icon, Text} from '@gravity-ui/uikit'; import {ButtonWithConfirmDialog} from '../../components/ButtonWithConfirmDialog/ButtonWithConfirmDialog'; import {CellWithPopover} from '../../components/CellWithPopover/CellWithPopover'; import {operationsApi} from '../../store/reducers/operations'; -import type {TOperation} from '../../types/api/operations'; +import type {IndexBuildMetadata, OperationKind, TOperation} from '../../types/api/operations'; import {EStatusCode} from '../../types/api/operations'; import {EMPTY_DATA_PLACEHOLDER, HOUR_IN_SECONDS, SECOND_IN_MS} from '../../utils/constants'; import createToast from '../../utils/createToast'; @@ -21,11 +21,23 @@ import './Operations.scss'; export function getColumns({ database, refreshTable, + kind, }: { database: string; refreshTable: VoidFunction; + kind: OperationKind; }): DataTableColumn[] { - return [ + const isBuildIndex = kind === 'buildindex'; + + // Helper function to get description tooltip content + const getDescriptionTooltip = (operation: TOperation): string => { + if (!operation.metadata?.description) { + return ''; + } + return JSON.stringify(operation.metadata.description, null, 2); + }; + + const columns: DataTableColumn[] = [ { name: COLUMNS_NAMES.ID, header: COLUMNS_TITLES[COLUMNS_NAMES.ID], @@ -34,8 +46,11 @@ export function getColumns({ if (!row.id) { return EMPTY_DATA_PLACEHOLDER; } + + const tooltipContent = isBuildIndex ? getDescriptionTooltip(row) || row.id : row.id; + return ( - + {row.id} ); @@ -55,93 +70,114 @@ export function getColumns({ ); }, }, - { - 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; + ]; + + // Add buildindex-specific columns + if (isBuildIndex) { + columns.push( + { + name: COLUMNS_NAMES.STATE, + header: COLUMNS_TITLES[COLUMNS_NAMES.STATE], + render: ({row}) => { + const metadata = row.metadata as IndexBuildMetadata | undefined; + if (!metadata?.state) { + return EMPTY_DATA_PLACEHOLDER; + } + return metadata.state; + }, }, - }, - { - 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)); + { + name: COLUMNS_NAMES.PROGRESS, + header: COLUMNS_TITLES[COLUMNS_NAMES.PROGRESS], + render: ({row}) => { + const metadata = row.metadata as IndexBuildMetadata | undefined; + if (metadata?.progress === undefined) { + return EMPTY_DATA_PLACEHOLDER; + } + return `${Math.round(metadata.progress)}%`; + }, }, - 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)); + ); + } else { + // Add standard columns for non-buildindex operations + columns.push( + { + 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; + }, }, - 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}); + { + 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) => { - 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; + { + 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)); + }, }, - }, - { - name: 'Actions', - sortable: false, - resizeable: false, - header: '', - render: ({row}) => { - return ( - - ); + { + 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}); + }, }, + ); + } + + // Add Actions column + columns.push({ + name: 'Actions', + sortable: false, + resizeable: false, + header: '', + render: ({row}) => { + return ( + + ); }, - ]; + }); + + return columns; } interface OperationsActionsProps { @@ -151,7 +187,7 @@ interface OperationsActionsProps { } function OperationsActions({operation, database, refreshTable}: OperationsActionsProps) { - const [cancelOperation, {isLoading: isLoadingCancel}] = + const [cancelOperation, {isLoading: isCancelLoading}] = operationsApi.useCancelOperationMutation(); const [forgetOperation, {isLoading: isForgetLoading}] = operationsApi.useForgetOperationMutation(); @@ -161,9 +197,16 @@ function OperationsActions({operation, database, refreshTable}: OperationsAction return null; } + const isForgetButtonDisabled = isForgetLoading; + const isCancelButtonDisabled = isCancelLoading || operation.ready === true; + return ( - +
- +
diff --git a/src/containers/Operations/constants.ts b/src/containers/Operations/constants.ts index 10c991ed74..b252b3d33b 100644 --- a/src/containers/Operations/constants.ts +++ b/src/containers/Operations/constants.ts @@ -11,6 +11,8 @@ export const COLUMNS_NAMES = { CREATE_TIME: 'create_time', END_TIME: 'end_time', DURATION: 'duration', + STATE: 'state', + PROGRESS: 'progress', } as const; export const COLUMNS_TITLES = { @@ -20,6 +22,8 @@ export const COLUMNS_TITLES = { [COLUMNS_NAMES.CREATE_TIME]: i18n('column_createTime'), [COLUMNS_NAMES.END_TIME]: i18n('column_endTime'), [COLUMNS_NAMES.DURATION]: i18n('column_duration'), + [COLUMNS_NAMES.STATE]: i18n('column_state'), + [COLUMNS_NAMES.PROGRESS]: i18n('column_progress'), } as const; export const BASE_COLUMNS = [ diff --git a/src/containers/Operations/i18n/en.json b/src/containers/Operations/i18n/en.json index a651c2d7e4..d32cc35873 100644 --- a/src/containers/Operations/i18n/en.json +++ b/src/containers/Operations/i18n/en.json @@ -15,6 +15,8 @@ "column_createTime": "Create Time", "column_endTime": "End Time", "column_duration": "Duration", + "column_state": "State", + "column_progress": "Progress", "label_duration-ongoing": "{{value}} (ongoing)", "header_cancel": "Cancel operation", diff --git a/src/containers/Operations/useOperationsInfiniteQuery.ts b/src/containers/Operations/useOperationsInfiniteQuery.ts new file mode 100644 index 0000000000..d6d0f6aa66 --- /dev/null +++ b/src/containers/Operations/useOperationsInfiniteQuery.ts @@ -0,0 +1,115 @@ +import React from 'react'; + +import {throttle} from 'lodash'; + +import {operationsApi} from '../../store/reducers/operations'; +import type {OperationKind} from '../../types/api/operations'; + +interface UseOperationsInfiniteQueryProps { + database: string; + kind: OperationKind; + pageSize?: number; + searchValue: string; + scrollContainerRef?: React.RefObject; +} + +const DEFAULT_SCROLL_MARGIN = 100; + +export function useOperationsInfiniteQuery({ + database, + kind, + pageSize = 10, + searchValue, + scrollContainerRef, +}: UseOperationsInfiniteQueryProps) { + const {data, error, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage, refetch} = + operationsApi.useGetOperationListInfiniteQuery({ + database, + kind, + page_size: pageSize, + }); + + // Flatten all pages into a single array of operations + const allOperations = React.useMemo(() => { + if (!data?.pages) { + return []; + } + // Each page is a TOperationList, so we need to extract operations from each + return data.pages.flatMap((page) => page.operations || []); + }, [data]); + + const filteredOperations = React.useMemo(() => { + if (!searchValue) { + return allOperations; + } + return allOperations.filter((op) => + op.id?.toLowerCase().includes(searchValue.toLowerCase()), + ); + }, [allOperations, searchValue]); + + // Auto-load more pages to fill viewport + const checkAndLoadMorePages = React.useCallback(async () => { + const scrollContainer = scrollContainerRef?.current; + if (!scrollContainer || !hasNextPage || isFetchingNextPage) { + return; + } + + const {scrollHeight, clientHeight} = scrollContainer; + if (scrollHeight <= clientHeight) { + await fetchNextPage(); + } + }, [scrollContainerRef, hasNextPage, isFetchingNextPage, fetchNextPage]); + + // Check after data updates + React.useLayoutEffect(() => { + if (!isFetchingNextPage) { + checkAndLoadMorePages(); + } + }, [data, isFetchingNextPage, checkAndLoadMorePages]); + + // Scroll handler for infinite scrolling + React.useEffect(() => { + const scrollContainer = scrollContainerRef?.current; + if (!scrollContainer) { + return undefined; + } + + const handleScroll = () => { + const {scrollTop, scrollHeight, clientHeight} = scrollContainer; + + if ( + scrollHeight - scrollTop - clientHeight < DEFAULT_SCROLL_MARGIN && + hasNextPage && + !isFetchingNextPage + ) { + fetchNextPage(); + } + }; + + scrollContainer.addEventListener('scroll', handleScroll); + return () => scrollContainer.removeEventListener('scroll', handleScroll); + }, [scrollContainerRef, hasNextPage, isFetchingNextPage, fetchNextPage]); + + // Resize handler to check if more content is needed when viewport changes + React.useEffect(() => { + const throttledHandleResize = throttle(checkAndLoadMorePages, 200, { + trailing: true, + leading: true, + }); + + window.addEventListener('resize', throttledHandleResize); + return () => { + throttledHandleResize.cancel(); + window.removeEventListener('resize', throttledHandleResize); + }; + }, [checkAndLoadMorePages]); + + return { + operations: filteredOperations, + isLoading, + isLoadingMore: isFetchingNextPage, + error, + refreshTable: refetch, + totalCount: allOperations.length, + }; +} diff --git a/src/containers/Tenant/Diagnostics/Diagnostics.tsx b/src/containers/Tenant/Diagnostics/Diagnostics.tsx index 72884c4ac3..bf9ec04d90 100644 --- a/src/containers/Tenant/Diagnostics/Diagnostics.tsx +++ b/src/containers/Tenant/Diagnostics/Diagnostics.tsx @@ -161,7 +161,7 @@ function Diagnostics(props: DiagnosticsProps) { return ; } case TENANT_DIAGNOSTICS_TABS_IDS.operations: { - return ; + return ; } case TENANT_DIAGNOSTICS_TABS_IDS.backups: { return uiFactory.renderBackups?.({ diff --git a/src/store/reducers/operations.ts b/src/store/reducers/operations.ts index b09abb5b01..4d046d6e47 100644 --- a/src/store/reducers/operations.ts +++ b/src/store/reducers/operations.ts @@ -1,16 +1,37 @@ import type { OperationCancelRequestParams, OperationForgetRequestParams, + OperationKind, OperationListRequestParams, + TOperationList, } from '../../types/api/operations'; import {api} from './api'; +const DEFAULT_PAGE_SIZE = 10; + export const operationsApi = api.injectEndpoints({ endpoints: (build) => ({ - getOperationList: build.query({ - queryFn: async (params: OperationListRequestParams, {signal}) => { + getOperationList: build.infiniteQuery< + TOperationList, // Full response type to access next_page_token + {database: string; kind: OperationKind; page_size?: number}, // Include page_size in query arg + string | undefined // Page param type (page token) + >({ + infiniteQueryOptions: { + initialPageParam: undefined, + getNextPageParam: (lastPage) => { + // Return next page token if available, undefined if no more pages + return lastPage.next_page_token === '0' ? undefined : lastPage.next_page_token; + }, + }, + queryFn: async ({queryArg, pageParam}, {signal}) => { try { + const params: OperationListRequestParams = { + database: queryArg.database, + kind: queryArg.kind, + page_size: queryArg.page_size ?? DEFAULT_PAGE_SIZE, + page_token: pageParam, + }; const data = await window.api.operation.getOperationList(params, {signal}); return {data}; } catch (error) { diff --git a/src/types/api/operations.ts b/src/types/api/operations.ts index 7134805f81..94b7e9fb0c 100644 --- a/src/types/api/operations.ts +++ b/src/types/api/operations.ts @@ -125,7 +125,9 @@ export type OperationKind = export interface OperationListRequestParams { database: string; kind: OperationKind; - page_size?: number; + + // required and important to pass correct value. + page_size: number; page_token?: string; } diff --git a/tests/suites/tenant/diagnostics/Diagnostics.ts b/tests/suites/tenant/diagnostics/Diagnostics.ts index 04ede054a6..d6baaea3a0 100644 --- a/tests/suites/tenant/diagnostics/Diagnostics.ts +++ b/tests/suites/tenant/diagnostics/Diagnostics.ts @@ -6,6 +6,8 @@ import {NodesPage} from '../../nodes/NodesPage'; import {StoragePage} from '../../storage/StoragePage'; import {VISIBILITY_TIMEOUT} from '../TenantPage'; +import {OperationsTable} from './tabs/OperationsModel'; + export enum DiagnosticsTab { Info = 'Info', Schema = 'Schema', @@ -17,6 +19,7 @@ export enum DiagnosticsTab { HotKeys = 'Hot keys', Describe = 'Describe', Storage = 'Storage', + Operations = 'Operations', Access = 'Access', } @@ -231,6 +234,7 @@ export class Diagnostics { storage: StoragePage; nodes: NodesPage; memoryViewer: MemoryViewer; + operations: OperationsTable; private page: Page; private tabs: Locator; @@ -256,6 +260,7 @@ export class Diagnostics { this.storage = new StoragePage(page); this.nodes = new NodesPage(page); this.memoryViewer = new MemoryViewer(page); + this.operations = new OperationsTable(page); this.tabs = page.locator('.kv-tenant-diagnostics__tabs'); this.tableControls = page.locator('.ydb-table-with-controls-layout__controls'); this.schemaViewer = page.locator('.schema-viewer'); diff --git a/tests/suites/tenant/diagnostics/tabs/OperationsModel.ts b/tests/suites/tenant/diagnostics/tabs/OperationsModel.ts new file mode 100644 index 0000000000..9abae7905b --- /dev/null +++ b/tests/suites/tenant/diagnostics/tabs/OperationsModel.ts @@ -0,0 +1,127 @@ +import type {Locator, Page} from '@playwright/test'; + +import {BaseModel} from '../../../../models/BaseModel'; +import {VISIBILITY_TIMEOUT} from '../../TenantPage'; + +export enum OperationTab { + Operations = 'Operations', +} + +export class OperationsTable extends BaseModel { + private tableContainer: Locator; + private tableRows: Locator; + private emptyState: Locator; + private loadingMore: Locator; + private scrollContainer: Locator; + + constructor(page: Page) { + super(page, page.locator('.kv-tenant-diagnostics')); + + this.tableContainer = page.locator('.ydb-table-with-controls-layout'); + this.tableRows = page.locator('.data-table__row:not(.data-table__row_header)'); + this.emptyState = page.locator('.operations__table:has-text("No operations data")'); + this.loadingMore = page.locator('.operations__loading-more'); + this.scrollContainer = page.locator('.kv-tenant-diagnostics__page-wrapper'); + } + + async waitForTableVisible() { + await this.tableContainer.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + } + + async waitForDataLoad() { + // Wait for either data rows or empty state + await this.page.waitForFunction( + () => { + const rows = document.querySelectorAll( + '.data-table__row:not(.data-table__row_header)', + ); + const tableContainer = document.querySelector('.operations__table'); + const hasEmptyText = tableContainer?.textContent?.includes('No operations data'); + return rows.length > 0 || hasEmptyText === true; + }, + {timeout: VISIBILITY_TIMEOUT}, + ); + // Additional wait for stability + await this.page.waitForTimeout(500); + } + + async getRowCount(): Promise { + return await this.tableRows.count(); + } + + async isEmptyStateVisible(): Promise { + return await this.emptyState.isVisible(); + } + + async scrollToBottom() { + await this.scrollContainer.evaluate((element) => { + element.scrollTo({top: element.scrollHeight, behavior: 'instant'}); + }); + } + + async isLoadingMoreVisible(): Promise { + return await this.loadingMore.isVisible(); + } + + async waitForLoadingMoreToDisappear() { + await this.loadingMore.waitFor({state: 'hidden', timeout: VISIBILITY_TIMEOUT}); + } + + async getRowData(rowIndex: number): Promise> { + const row = this.tableRows.nth(rowIndex); + const cells = row.locator('td'); + const allHeaders = await this.page.locator('.data-table__th').allTextContents(); + + // Take first occurrence of headers (they might be duplicated due to virtual scrolling) + const uniqueHeaders: string[] = []; + const seen = new Set(); + let emptyCount = 0; + + for (const header of allHeaders) { + const trimmed = header.trim(); + if (trimmed === '') { + // Handle multiple empty headers (e.g., for action columns) + uniqueHeaders.push(`_empty_${emptyCount++}`); + } else if (!seen.has(trimmed)) { + seen.add(trimmed); + uniqueHeaders.push(trimmed); + } + // Stop when we have enough headers for the cells + if (uniqueHeaders.length >= (await cells.count())) { + break; + } + } + + const rowData: Record = {}; + const cellCount = await cells.count(); + + for (let i = 0; i < cellCount && i < uniqueHeaders.length; i++) { + const headerText = uniqueHeaders[i]; + const cellText = await cells.nth(i).textContent(); + // Don't include empty headers in the result + if (!headerText.startsWith('_empty_')) { + rowData[headerText] = cellText?.trim() || ''; + } + } + + return rowData; + } + + async hasActiveInfiniteScroll(): Promise { + // Check if scrolling triggers loading more + const initialCount = await this.getRowCount(); + await this.scrollToBottom(); + + // Wait a bit to see if loading more appears + await this.page.waitForTimeout(1000); + + const hasLoadingMore = await this.isLoadingMoreVisible(); + if (hasLoadingMore) { + await this.waitForLoadingMoreToDisappear(); + const newCount = await this.getRowCount(); + return newCount > initialCount; + } + + return false; + } +} diff --git a/tests/suites/tenant/diagnostics/tabs/operations.test.ts b/tests/suites/tenant/diagnostics/tabs/operations.test.ts new file mode 100644 index 0000000000..c77abc89dc --- /dev/null +++ b/tests/suites/tenant/diagnostics/tabs/operations.test.ts @@ -0,0 +1,122 @@ +import {expect, test} from '@playwright/test'; + +import {tenantName} from '../../../../utils/constants'; +import {TenantPage} from '../../TenantPage'; +import {Diagnostics, DiagnosticsTab} from '../Diagnostics'; + +import {setupEmptyOperationsMock, setupOperationsMock} from './operationsMocks'; + +test.describe('Operations Tab - Infinite Query', () => { + test('loads initial page of operations on tab click', async ({page}) => { + // Setup mocks with 30 operations (3 pages of 10) + await setupOperationsMock(page, {totalOperations: 30}); + + const pageQueryParams = { + schema: tenantName, + database: tenantName, + tenantPage: 'diagnostics', + }; + + const tenantPageInstance = new TenantPage(page); + await tenantPageInstance.goto(pageQueryParams); + + const diagnostics = new Diagnostics(page); + await diagnostics.clickTab(DiagnosticsTab.Operations); + + // Wait for table to be visible and data to load + await diagnostics.operations.waitForTableVisible(); + await diagnostics.operations.waitForDataLoad(); + + // Verify initial page loaded (should have some rows) + const rowCount = await diagnostics.operations.getRowCount(); + expect(rowCount).toBeGreaterThan(0); + expect(rowCount).toBeLessThanOrEqual(20); // Reasonable page size + + // Verify first row data structure + const firstRowData = await diagnostics.operations.getRowData(0); + expect(firstRowData['Operation ID']).toBeTruthy(); + expect(firstRowData['Operation ID']).toContain('ydb://'); + expect(firstRowData['Status']).toBeTruthy(); + expect(['SUCCESS', 'GENERIC_ERROR', 'CANCELLED', 'ABORTED']).toContain( + firstRowData['Status'], + ); + expect(firstRowData['State']).toBeTruthy(); + expect(firstRowData['Progress']).toBeTruthy(); + + // Verify loading more indicator is not visible initially + const isLoadingVisible = await diagnostics.operations.isLoadingMoreVisible(); + expect(isLoadingVisible).toBe(false); + }); + + test('loads more operations on scroll', async ({page}) => { + // Setup mocks with 30 operations (3 pages of 10) + await setupOperationsMock(page, {totalOperations: 30}); + + const pageQueryParams = { + schema: tenantName, + database: tenantName, + tenantPage: 'diagnostics', + }; + + const tenantPageInstance = new TenantPage(page); + await tenantPageInstance.goto(pageQueryParams); + + const diagnostics = new Diagnostics(page); + await diagnostics.clickTab(DiagnosticsTab.Operations); + + // Wait for initial data + await diagnostics.operations.waitForTableVisible(); + await diagnostics.operations.waitForDataLoad(); + + // Get initial row count + const initialRowCount = await diagnostics.operations.getRowCount(); + expect(initialRowCount).toBeGreaterThan(0); + + // Scroll to bottom + await diagnostics.operations.scrollToBottom(); + + // Wait a bit for potential loading + await page.waitForTimeout(2000); + + // Get final row count + const finalRowCount = await diagnostics.operations.getRowCount(); + + // Check if more rows were loaded + if (finalRowCount > initialRowCount) { + // Infinite scroll worked - more rows were loaded + expect(finalRowCount).toBeGreaterThan(initialRowCount); + } else { + // No more data to load - row count should stay the same + expect(finalRowCount).toBe(initialRowCount); + } + }); + + test('shows empty state when no operations', async ({page}) => { + // Setup empty operations mock + await setupEmptyOperationsMock(page); + + const pageQueryParams = { + schema: tenantName, + database: tenantName, + tenantPage: 'diagnostics', + }; + + const tenantPageInstance = new TenantPage(page); + await tenantPageInstance.goto(pageQueryParams); + + const diagnostics = new Diagnostics(page); + await diagnostics.clickTab(DiagnosticsTab.Operations); + + // Wait for table to be visible + await diagnostics.operations.waitForTableVisible(); + await diagnostics.operations.waitForDataLoad(); + + // Verify empty state is shown + const isEmptyVisible = await diagnostics.operations.isEmptyStateVisible(); + expect(isEmptyVisible).toBe(true); + + // Verify no data rows (or possibly one empty row) + const rowCount = await diagnostics.operations.getRowCount(); + expect(rowCount).toBeLessThanOrEqual(1); + }); +}); diff --git a/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts b/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts new file mode 100644 index 0000000000..8e7b32d5c8 --- /dev/null +++ b/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts @@ -0,0 +1,233 @@ +import type {Page} from '@playwright/test'; + +import {backend} from '../../../../utils/constants'; + +interface Operation { + ready: boolean; + metadata: { + description?: any; + settings?: any; + progress: number; + '@type': string; + state: string; + }; + status: string; + create_time: { + seconds: string; + }; + end_time?: { + seconds: string; + }; + id: string; + issues?: Array<{ + severity: number; + message: string; + }>; + created_by?: string; +} + +const MOCK_DELAY = 200; // 200ms delay to simulate network latency + +interface OperationMockOptions { + totalOperations?: number; + pageSize?: number; +} + +const generateBuildIndexOperations = (start: number, count: number): Operation[] => { + const now = Math.floor(Date.now() / 1000); + return Array.from({length: count}, (_, i) => { + const index = start + i; + const createTime = now - (index + 1) * 60; // Created minutes ago + const endTime = createTime + 30; // Completed after 30 seconds + + return { + ready: true, + metadata: { + description: { + index: { + name: `index_${index}`, + global_index: {}, + index_columns: [`column_${index}`], + }, + path: `/dev02/home/testuser/db1/table_${index}`, + }, + progress: 100, + '@type': 'type.googleapis.com/Ydb.Table.IndexBuildMetadata', + state: 'STATE_DONE', + }, + status: 'SUCCESS', + end_time: { + seconds: endTime.toString(), + }, + create_time: { + seconds: createTime.toString(), + }, + id: `ydb://buildindex/7?id=56300033048${8000 + index}`, + ...(index % 3 === 0 + ? { + issues: [ + { + severity: 1, + message: `TShardStatus { ShardIdx: 72075186224037897:${100 + index} Status: DONE UploadStatus: STATUS_CODE_UNSPECIFIED DebugMessage:
: Error: Shard or requested range is empty\n SeqNoRound: 1 Processed: { upload rows: 0, upload bytes: 0, read rows: 0, read bytes: 0 } }`, + }, + ], + } + : {}), + }; + }); +}; + +const generateExportOperations = (start: number, count: number): Operation[] => { + const now = Math.floor(Date.now() / 1000); + return Array.from({length: count}, (_, i) => { + const index = start + i; + const createTime = now - (index + 1) * 120; // Created 2 minutes ago each + const isCompleted = index % 2 === 0; + + return { + ready: isCompleted, + metadata: { + settings: { + export_s3_settings: { + bucket: `export-bucket-${index}`, + endpoint: 'https://s3.amazonaws.com', + number_of_retries: 3, + scheme: 'HTTPS', + }, + }, + progress: isCompleted ? 100 : 30 + (index % 7) * 10, + '@type': 'type.googleapis.com/Ydb.Export.ExportToS3Metadata', + state: isCompleted ? 'STATE_DONE' : 'STATE_IN_PROGRESS', + }, + status: isCompleted ? 'SUCCESS' : 'GENERIC_ERROR', + create_time: { + seconds: createTime.toString(), + }, + ...(isCompleted + ? { + end_time: { + seconds: (createTime + 60).toString(), + }, + } + : {}), + id: `ydb://export/s3/7?id=56300033048${9000 + index}`, + created_by: `user${index % 3}@example.com`, + }; + }); +}; + +export const setupOperationsMock = async (page: Page, options?: OperationMockOptions) => { + const totalOperations = options?.totalOperations || 100; + + await page.route(`${backend}/operation/list*`, async (route) => { + const url = new URL(route.request().url()); + const params = Object.fromEntries(url.searchParams); + + const requestedPageSize = parseInt(params.page_size || '10', 10); + const pageToken = params.page_token; + const kind = params.kind || 'buildindex'; + + // Calculate page index from token + const pageIndex = pageToken ? parseInt(pageToken, 10) : 0; + const start = pageIndex * requestedPageSize; + const remainingOperations = Math.max(0, totalOperations - start); + const count = Math.min(requestedPageSize, remainingOperations); + + let operations: Operation[] = []; + if (kind === 'buildindex') { + operations = generateBuildIndexOperations(start, count); + } else if (kind === 'export/s3') { + operations = generateExportOperations(start, count); + } else { + operations = []; // Empty for other kinds + } + + // Calculate next page token + const hasMorePages = start + count < totalOperations; + const nextPageToken = hasMorePages ? (pageIndex + 1).toString() : '0'; + + const response = { + next_page_token: nextPageToken, + status: 'SUCCESS', + operations, + }; + + await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response), + }); + }); +}; + +export const setupEmptyOperationsMock = async (page: Page) => { + await page.route(`${backend}/operation/list*`, async (route) => { + const response = { + next_page_token: '0', + status: 'SUCCESS', + operations: [], + }; + + await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response), + }); + }); +}; + +export const setupLargeOperationsMock = async (page: Page, totalOperations = 1000) => { + await setupOperationsMock(page, {totalOperations}); +}; + +export const setupOperationMutationMocks = async (page: Page) => { + // Mock cancel operation + await page.route(`${backend}/operation/cancel*`, async (route) => { + await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + status: 'SUCCESS', + }), + }); + }); + + // Mock forget operation + await page.route(`${backend}/operation/forget*`, async (route) => { + await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + status: 'SUCCESS', + }), + }); + }); +}; + +export const setupOperationErrorMock = async (page: Page) => { + await page.route(`${backend}/operation/list*`, async (route) => { + await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)); + + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ + error: 'Internal server error', + }), + }); + }); +}; + +// Helper to setup all required mocks for operations +export const setupAllOperationMocks = async (page: Page, options?: {totalOperations?: number}) => { + await setupOperationsMock(page, options); + await setupOperationMutationMocks(page); +}; diff --git a/tests/suites/tenant/diagnostics/tabs/queries.test.ts b/tests/suites/tenant/diagnostics/tabs/queries.test.ts index 2facd3f85a..08784d87bd 100644 --- a/tests/suites/tenant/diagnostics/tabs/queries.test.ts +++ b/tests/suites/tenant/diagnostics/tabs/queries.test.ts @@ -97,7 +97,8 @@ test.describe('Diagnostics Queries tab', async () => { ]); }); - test('Query tab first row has values for all columns in Top mode', async ({page}) => { + // TODO: https://github.com/ydb-platform/ydb-embedded-ui/issues/2459 + test.skip('Query tab first row has values for all columns in Top mode', async ({page}) => { const pageQueryParams = { schema: tenantName, database: tenantName, diff --git a/tests/suites/tenant/diagnostics/tabs/topShards.test.ts b/tests/suites/tenant/diagnostics/tabs/topShards.test.ts index ce0581f89d..6e8ebd6d40 100644 --- a/tests/suites/tenant/diagnostics/tabs/topShards.test.ts +++ b/tests/suites/tenant/diagnostics/tabs/topShards.test.ts @@ -89,7 +89,10 @@ test.describe('Diagnostics TopShards tab', async () => { } }); - test('TopShards tab first row has values for all columns in History mode', async ({page}) => { + // TODO: https://github.com/ydb-platform/ydb-embedded-ui/issues/2459 + test.skip('TopShards tab first row has values for all columns in History mode', async ({ + page, + }) => { // Setup mock for TopShards tab in History mode await setupTopShardsHistoryMock(page);