diff --git a/src/containers/Operations/Operations.tsx b/src/containers/Operations/Operations.tsx
index 8540e2c0e..3285ca681 100644
--- a/src/containers/Operations/Operations.tsx
+++ b/src/containers/Operations/Operations.tsx
@@ -34,10 +34,6 @@ export function Operations({database, scrollContainerRef}: OperationsProps) {
scrollContainerRef,
});
- if (isAccessError(error)) {
- return ;
- }
-
const settings = React.useMemo(() => {
return {
...DEFAULT_TABLE_SETTINGS,
@@ -45,6 +41,10 @@ export function Operations({database, scrollContainerRef}: OperationsProps) {
};
}, []);
+ if (isAccessError(error)) {
+ return ;
+ }
+
return (
diff --git a/src/store/reducers/operations.ts b/src/store/reducers/operations.ts
index 4d046d6e4..2b630f541 100644
--- a/src/store/reducers/operations.ts
+++ b/src/store/reducers/operations.ts
@@ -8,7 +8,7 @@ import type {
import {api} from './api';
-const DEFAULT_PAGE_SIZE = 10;
+const DEFAULT_PAGE_SIZE = 20;
export const operationsApi = api.injectEndpoints({
endpoints: (build) => ({
diff --git a/tests/suites/tenant/diagnostics/tabs/OperationsModel.ts b/tests/suites/tenant/diagnostics/tabs/OperationsModel.ts
index 9abae7905..e1908fe7d 100644
--- a/tests/suites/tenant/diagnostics/tabs/OperationsModel.ts
+++ b/tests/suites/tenant/diagnostics/tabs/OperationsModel.ts
@@ -13,6 +13,8 @@ export class OperationsTable extends BaseModel {
private emptyState: Locator;
private loadingMore: Locator;
private scrollContainer: Locator;
+ private accessDeniedState: Locator;
+ private accessDeniedTitle: Locator;
constructor(page: Page) {
super(page, page.locator('.kv-tenant-diagnostics'));
@@ -22,6 +24,9 @@ export class OperationsTable extends BaseModel {
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');
+ // AccessDenied component is rendered at the root level of Operations component
+ this.accessDeniedState = page.locator('.kv-tenant-diagnostics .empty-state');
+ this.accessDeniedTitle = this.accessDeniedState.locator('.empty-state__title');
}
async waitForTableVisible() {
@@ -124,4 +129,69 @@ export class OperationsTable extends BaseModel {
return false;
}
+
+ async isAccessDeniedVisible(): Promise {
+ try {
+ await this.accessDeniedState.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT});
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ async getAccessDeniedTitle(): Promise {
+ return await this.accessDeniedTitle.innerText();
+ }
+
+ async getOperationsCount(): Promise {
+ // The EntitiesCount component renders a Label with the count
+ const countLabel = await this.page
+ .locator('.ydb-entities-count .g-label__content')
+ .textContent();
+ if (!countLabel) {
+ return 0;
+ }
+ const match = countLabel.match(/(\d+)/);
+ return match ? parseInt(match[1], 10) : 0;
+ }
+
+ async waitForOperationsCount(expectedCount: number, timeout = 5000): Promise {
+ await this.page.waitForFunction(
+ (expected) => {
+ const countElement = document.querySelector(
+ '.ydb-entities-count .g-label__content',
+ );
+ if (!countElement) {
+ return false;
+ }
+ const text = countElement.textContent || '';
+ const match = text.match(/(\d+)/);
+ const currentCount = match ? parseInt(match[1], 10) : 0;
+ return currentCount === expected;
+ },
+ expectedCount,
+ {timeout},
+ );
+ }
+
+ async waitForOperationsCountToChange(previousCount: number, timeout = 5000): Promise {
+ await this.page.waitForFunction(
+ (prev) => {
+ const countElement = document.querySelector(
+ '.ydb-entities-count .g-label__content',
+ );
+ if (!countElement) {
+ return false;
+ }
+ const text = countElement.textContent || '';
+ const match = text.match(/(\d+)/);
+ const currentCount = match ? parseInt(match[1], 10) : 0;
+ return currentCount !== prev;
+ },
+ previousCount,
+ {timeout},
+ );
+ // Now get the actual new count
+ return await this.getOperationsCount();
+ }
}
diff --git a/tests/suites/tenant/diagnostics/tabs/operations.test.ts b/tests/suites/tenant/diagnostics/tabs/operations.test.ts
index c77abc89d..e719454ec 100644
--- a/tests/suites/tenant/diagnostics/tabs/operations.test.ts
+++ b/tests/suites/tenant/diagnostics/tabs/operations.test.ts
@@ -4,12 +4,16 @@ import {tenantName} from '../../../../utils/constants';
import {TenantPage} from '../../TenantPage';
import {Diagnostics, DiagnosticsTab} from '../Diagnostics';
-import {setupEmptyOperationsMock, setupOperationsMock} from './operationsMocks';
+import {
+ setupEmptyOperationsMock,
+ setupOperation403Mock,
+ 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});
+ // Setup mocks with 80 operations (4 pages of 20)
+ await setupOperationsMock(page, {totalOperations: 80});
const pageQueryParams = {
schema: tenantName,
@@ -27,10 +31,13 @@ test.describe('Operations Tab - Infinite Query', () => {
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
+ // Wait a bit for the counter to stabilize after initial load
+ await page.waitForTimeout(1000);
+
+ // Verify initial page loaded (should show count in badge)
+ const operationsCount = await diagnostics.operations.getOperationsCount();
+ expect(operationsCount).toBeGreaterThan(0);
+ expect(operationsCount).toBeLessThanOrEqual(20); // Should have up to DEFAULT_PAGE_SIZE operations loaded initially
// Verify first row data structure
const firstRowData = await diagnostics.operations.getRowData(0);
@@ -49,8 +56,8 @@ test.describe('Operations Tab - Infinite Query', () => {
});
test('loads more operations on scroll', async ({page}) => {
- // Setup mocks with 30 operations (3 pages of 10)
- await setupOperationsMock(page, {totalOperations: 30});
+ // Setup mocks with 80 operations (4 pages of 20)
+ await setupOperationsMock(page, {totalOperations: 80});
const pageQueryParams = {
schema: tenantName,
@@ -68,26 +75,32 @@ test.describe('Operations Tab - Infinite Query', () => {
await diagnostics.operations.waitForTableVisible();
await diagnostics.operations.waitForDataLoad();
- // Get initial row count
- const initialRowCount = await diagnostics.operations.getRowCount();
- expect(initialRowCount).toBeGreaterThan(0);
+ // Get initial operations count
+ const initialOperationsCount = await diagnostics.operations.getOperationsCount();
+ expect(initialOperationsCount).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();
+ // Wait for operations count to potentially change
+ let finalOperationsCount: number;
+ try {
+ finalOperationsCount = await diagnostics.operations.waitForOperationsCountToChange(
+ initialOperationsCount,
+ 3000,
+ );
+ } catch (_e) {
+ // If timeout, the count didn't change
+ finalOperationsCount = await diagnostics.operations.getOperationsCount();
+ }
- // Check if more rows were loaded
- if (finalRowCount > initialRowCount) {
- // Infinite scroll worked - more rows were loaded
- expect(finalRowCount).toBeGreaterThan(initialRowCount);
+ // Check if more operations were loaded
+ if (finalOperationsCount > initialOperationsCount) {
+ // Infinite scroll worked - more operations were loaded
+ expect(finalOperationsCount).toBeGreaterThan(initialOperationsCount);
} else {
- // No more data to load - row count should stay the same
- expect(finalRowCount).toBe(initialRowCount);
+ // No more data to load - operations count should stay the same
+ expect(finalOperationsCount).toBe(initialOperationsCount);
}
});
@@ -119,4 +132,103 @@ test.describe('Operations Tab - Infinite Query', () => {
const rowCount = await diagnostics.operations.getRowCount();
expect(rowCount).toBeLessThanOrEqual(1);
});
+
+ test('shows access denied when operations request returns 403', async ({page}) => {
+ // Setup 403 error mock
+ await setupOperation403Mock(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 a bit for potential loading
+ await page.waitForTimeout(2000);
+
+ // Wait for access denied state to be visible
+ const isAccessDeniedVisible = await diagnostics.operations.isAccessDeniedVisible();
+ expect(isAccessDeniedVisible).toBe(true);
+
+ // Verify the access denied message
+ const accessDeniedTitle = await diagnostics.operations.getAccessDeniedTitle();
+ expect(accessDeniedTitle).toBe('Access denied');
+ });
+
+ test('loads all operations when scrolling to the bottom multiple times', async ({page}) => {
+ // Setup mocks with 80 operations (4 pages of 20)
+ await setupOperationsMock(page, {totalOperations: 80});
+
+ 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();
+
+ // Wait a bit for the counter to stabilize after initial load
+ await page.waitForTimeout(2000);
+
+ // Get initial operations count (should be around 20)
+ const initialOperationsCount = await diagnostics.operations.getOperationsCount();
+ expect(initialOperationsCount).toBeGreaterThan(0);
+ expect(initialOperationsCount).toBeLessThanOrEqual(20);
+
+ // Keep scrolling until all operations are loaded
+ let previousOperationsCount = initialOperationsCount;
+ let currentOperationsCount = initialOperationsCount;
+ const maxScrollAttempts = 10; // Safety limit to prevent infinite loop
+ let scrollAttempts = 0;
+
+ while (currentOperationsCount < 80 && scrollAttempts < maxScrollAttempts) {
+ // Scroll to bottom
+ await diagnostics.operations.scrollToBottom();
+
+ // Wait for potential loading
+ await page.waitForTimeout(1000);
+
+ // Check if loading more is visible and wait for it to complete
+ const isLoadingVisible = await diagnostics.operations.isLoadingMoreVisible();
+ if (isLoadingVisible) {
+ await diagnostics.operations.waitForLoadingMoreToDisappear();
+ }
+
+ // Wait for operations count to change or timeout
+ try {
+ currentOperationsCount =
+ await diagnostics.operations.waitForOperationsCountToChange(
+ previousOperationsCount,
+ 3000,
+ );
+ } catch (_e) {
+ // If timeout, the count didn't change - we might have reached the end
+ currentOperationsCount = await diagnostics.operations.getOperationsCount();
+ }
+
+ previousOperationsCount = currentOperationsCount;
+ scrollAttempts++;
+ }
+
+ // Verify all 80 operations were loaded
+ expect(currentOperationsCount).toBe(80);
+
+ const rowCount = await diagnostics.operations.getRowCount();
+ // Verify the last operation has the expected ID pattern
+ const lastRowData = await diagnostics.operations.getRowData(rowCount - 1);
+ expect(lastRowData['Operation ID']).toContain('ydb://');
+ });
});
diff --git a/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts b/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts
index 8e7b32d5c..ee333dc07 100644
--- a/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts
+++ b/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts
@@ -123,7 +123,7 @@ export const setupOperationsMock = async (page: Page, options?: OperationMockOpt
const url = new URL(route.request().url());
const params = Object.fromEntries(url.searchParams);
- const requestedPageSize = parseInt(params.page_size || '10', 10);
+ const requestedPageSize = parseInt(params.page_size || '20', 10);
const pageToken = params.page_token;
const kind = params.kind || 'buildindex';
@@ -226,6 +226,20 @@ export const setupOperationErrorMock = async (page: Page) => {
});
};
+export const setupOperation403Mock = async (page: Page) => {
+ await page.route(`${backend}/operation/list*`, async (route) => {
+ await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
+
+ await route.fulfill({
+ status: 403,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ error: 'Forbidden',
+ }),
+ });
+ });
+};
+
// Helper to setup all required mocks for operations
export const setupAllOperationMocks = async (page: Page, options?: {totalOperations?: number}) => {
await setupOperationsMock(page, options);