diff --git a/src/components/FixedHeightQuery/FixedHeightQuery.scss b/src/components/FixedHeightQuery/FixedHeightQuery.scss
new file mode 100644
index 000000000..02fa7124d
--- /dev/null
+++ b/src/components/FixedHeightQuery/FixedHeightQuery.scss
@@ -0,0 +1,42 @@
+.ydb-fixed-height-query {
+ position: relative;
+
+ overflow: hidden;
+
+ max-width: 100%;
+
+ // Target the YDBSyntaxHighlighter wrapper
+ > div {
+ overflow: hidden;
+
+ height: 100%;
+
+ text-overflow: ellipsis;
+
+ // Target the ReactSyntaxHighlighter pre element
+ pre {
+ // stylelint-disable-next-line value-no-vendor-prefix
+ display: -webkit-box !important;
+ -webkit-box-orient: vertical !important;
+ -webkit-line-clamp: var(--line-clamp, 4) !important;
+ overflow: hidden !important;
+
+ height: 100% !important;
+ margin: 0 !important;
+ padding: var(--g-spacing-2) !important;
+
+ white-space: pre-wrap !important;
+ text-overflow: ellipsis !important;
+ word-break: break-word !important;
+ }
+
+ // Target code elements within
+ code {
+ overflow: hidden !important;
+
+ white-space: pre-wrap !important;
+ text-overflow: ellipsis !important;
+ word-break: break-word !important;
+ }
+ }
+}
diff --git a/src/components/FixedHeightQuery/FixedHeightQuery.tsx b/src/components/FixedHeightQuery/FixedHeightQuery.tsx
new file mode 100644
index 000000000..1eefe60f9
--- /dev/null
+++ b/src/components/FixedHeightQuery/FixedHeightQuery.tsx
@@ -0,0 +1,56 @@
+import React from 'react';
+
+import {cn} from '../../utils/cn';
+import {YDBSyntaxHighlighter} from '../SyntaxHighlighter/YDBSyntaxHighlighter';
+
+import './FixedHeightQuery.scss';
+
+const b = cn('ydb-fixed-height-query');
+
+const FIXED_PADDING = 8;
+const LINE_HEIGHT = 20;
+
+interface FixedHeightQueryProps {
+ value?: string;
+ lines?: number;
+ hasClipboardButton?: boolean;
+ clipboardButtonAlwaysVisible?: boolean;
+}
+
+export const FixedHeightQuery = ({
+ value = '',
+ lines = 4,
+ hasClipboardButton,
+ clipboardButtonAlwaysVisible,
+}: FixedHeightQueryProps) => {
+ const heightValue = `${lines * LINE_HEIGHT + FIXED_PADDING}px`;
+
+ // Remove empty lines from the beginning (lines with only whitespace are considered empty)
+ const trimmedValue = value.replace(/^(\s*\n)+/, '');
+
+ return (
+
+
+
+ );
+};
diff --git a/src/containers/Tenant/Diagnostics/TopQueries/TopQueriesData.tsx b/src/containers/Tenant/Diagnostics/TopQueries/TopQueriesData.tsx
index c734ffae3..70be0027d 100644
--- a/src/containers/Tenant/Diagnostics/TopQueries/TopQueriesData.tsx
+++ b/src/containers/Tenant/Diagnostics/TopQueries/TopQueriesData.tsx
@@ -30,6 +30,8 @@ import {
TOP_QUERIES_SELECTED_COLUMNS_LS_KEY,
} from './columns/constants';
import {DEFAULT_TIME_FILTER_VALUE, TIME_FRAME_OPTIONS} from './constants';
+import type {ReactList} from './hooks/useScrollToSelected';
+import {useScrollToSelected} from './hooks/useScrollToSelected';
import {useSetSelectedTopQueryRowFromParams} from './hooks/useSetSelectedTopQueryRowFromParams';
import {useTopQueriesSort} from './hooks/useTopQueriesSort';
import i18n from './i18n';
@@ -61,6 +63,9 @@ export const TopQueriesData = ({
// null is reserved for not found state
const [selectedRow, setSelectedRow] = React.useState(undefined);
+ // Ref for react-list component to enable scrolling to selected row
+ const reactListRef = React.useRef(null);
+
// Get columns for top queries
const columns: Column[] = React.useMemo(() => {
return getTopQueriesColumns();
@@ -89,6 +94,18 @@ export const TopQueriesData = ({
const rows = currentData?.resultSets?.[0]?.result;
useSetSelectedTopQueryRowFromParams(setSelectedRow, rows);
+ // Enhanced table settings with dynamicInnerRef for scrolling
+ const tableSettings = React.useMemo(
+ () => ({
+ ...TOP_QUERIES_TABLE_SETTINGS,
+ dynamicInnerRef: reactListRef,
+ }),
+ [],
+ );
+
+ // Use custom hook to handle scrolling to selected row
+ useScrollToSelected({selectedRow, rows, reactListRef});
+
const handleCloseDetails = React.useCallback(() => {
setSelectedRow(undefined);
}, [setSelectedRow]);
@@ -182,7 +199,7 @@ export const TopQueriesData = ({
columns={columnsToShow}
data={rows || []}
loading={isFetching && currentData === undefined}
- settings={TOP_QUERIES_TABLE_SETTINGS}
+ settings={tableSettings}
onRowClick={onRowClick}
rowClassName={(row) => b('row', {active: isEqual(row, selectedRow)})}
sortOrder={tableSort}
diff --git a/src/containers/Tenant/Diagnostics/TopQueries/columns/columns.tsx b/src/containers/Tenant/Diagnostics/TopQueries/columns/columns.tsx
index 760a0790b..089047a1f 100644
--- a/src/containers/Tenant/Diagnostics/TopQueries/columns/columns.tsx
+++ b/src/containers/Tenant/Diagnostics/TopQueries/columns/columns.tsx
@@ -1,14 +1,13 @@
import DataTable from '@gravity-ui/react-data-table';
import type {Column, OrderType} from '@gravity-ui/react-data-table';
+import {FixedHeightQuery} from '../../../../../components/FixedHeightQuery/FixedHeightQuery';
import {YDBSyntaxHighlighter} from '../../../../../components/SyntaxHighlighter/YDBSyntaxHighlighter';
-import {TruncatedQuery} from '../../../../../components/TruncatedQuery/TruncatedQuery';
import type {KeyValueRow} from '../../../../../types/api/query';
import {cn} from '../../../../../utils/cn';
import {formatDateTime, formatNumber} from '../../../../../utils/dataFormatters/dataFormatters';
import {generateHash} from '../../../../../utils/generateHash';
import {formatToMs, parseUsToMs} from '../../../../../utils/timeParsers';
-import {MAX_QUERY_HEIGHT} from '../../../utils/constants';
import {
QUERIES_COLUMNS_IDS,
@@ -34,11 +33,7 @@ const queryTextColumn: Column = {
header: QUERIES_COLUMNS_TITLES.QueryText,
render: ({row}) => (
-
+
),
width: 500,
diff --git a/src/containers/Tenant/Diagnostics/TopQueries/hooks/useScrollToSelected.ts b/src/containers/Tenant/Diagnostics/TopQueries/hooks/useScrollToSelected.ts
new file mode 100644
index 000000000..f73b8d5c7
--- /dev/null
+++ b/src/containers/Tenant/Diagnostics/TopQueries/hooks/useScrollToSelected.ts
@@ -0,0 +1,49 @@
+import React from 'react';
+
+import {isEqual} from 'lodash';
+
+import type {KeyValueRow} from '../../../../../types/api/query';
+
+// Type for react-list component
+export interface ReactList {
+ scrollTo: (index: number) => void;
+ scrollAround: (index: number) => void;
+ getVisibleRange: () => [number, number];
+}
+
+interface UseScrollToSelectedParams {
+ selectedRow: KeyValueRow | null | undefined;
+ rows: KeyValueRow[] | undefined;
+ reactListRef: React.RefObject;
+}
+
+/**
+ * Custom hook to handle scrolling to selected row in react-list
+ * Only scrolls if the selected item is not currently visible
+ * When scrolling, positions the item in the middle of the viewport
+ */
+export function useScrollToSelected({selectedRow, rows, reactListRef}: UseScrollToSelectedParams) {
+ React.useEffect(() => {
+ if (selectedRow && rows && reactListRef.current) {
+ const selectedIndex = rows.findIndex((row) => isEqual(row, selectedRow));
+ if (selectedIndex !== -1) {
+ const reactList = reactListRef.current;
+
+ try {
+ const visibleRange = reactList.getVisibleRange();
+ const [firstVisible, lastVisible] = visibleRange;
+
+ // Check if selected item is already visible
+ const isVisible = selectedIndex >= firstVisible && selectedIndex <= lastVisible;
+
+ if (!isVisible) {
+ reactList.scrollTo(selectedIndex - 1);
+ }
+ } catch {
+ // Fallback to scrollAround if getVisibleRange fails
+ reactList.scrollAround(selectedIndex);
+ }
+ }
+ }
+ }, [selectedRow, rows, reactListRef]);
+}
diff --git a/src/containers/Tenant/Diagnostics/TopQueries/utils.ts b/src/containers/Tenant/Diagnostics/TopQueries/utils.ts
index 9a00c085d..6999e5b3b 100644
--- a/src/containers/Tenant/Diagnostics/TopQueries/utils.ts
+++ b/src/containers/Tenant/Diagnostics/TopQueries/utils.ts
@@ -13,6 +13,7 @@ export const TOP_QUERIES_TABLE_SETTINGS: Settings = {
...QUERY_TABLE_SETTINGS,
disableSortReset: true,
externalSort: true,
+ dynamicRenderType: 'uniform', // All rows have fixed height due to FixedHeightQuery
};
export function createQueryInfoItems(data: KeyValueRow): InfoViewerItem[] {
diff --git a/tests/suites/tenant/diagnostics/Diagnostics.ts b/tests/suites/tenant/diagnostics/Diagnostics.ts
index fdd1e9820..876cd8f42 100644
--- a/tests/suites/tenant/diagnostics/Diagnostics.ts
+++ b/tests/suites/tenant/diagnostics/Diagnostics.ts
@@ -120,6 +120,27 @@ export class Table {
});
return true;
}
+
+ async clickRow(row: number) {
+ const rowElement = this.table.locator(`tr.data-table__row:nth-child(${row})`);
+ await rowElement.click();
+ }
+
+ async getRowPosition(row: number) {
+ const rowElement = this.table.locator(`tr.data-table__row:nth-child(${row})`);
+ return await rowElement.boundingBox();
+ }
+
+ async isRowVisible(row: number) {
+ const rowElement = this.table.locator(`tr.data-table__row:nth-child(${row})`);
+ const boundingBox = await rowElement.boundingBox();
+ if (!boundingBox) {
+ return false;
+ }
+
+ const viewportHeight = await rowElement.page().evaluate(() => window.innerHeight);
+ return boundingBox.y >= 0 && boundingBox.y + boundingBox.height <= viewportHeight;
+ }
}
export enum QueriesSwitch {
@@ -222,6 +243,8 @@ export class Diagnostics {
private memoryCard: Locator;
private healthcheckCard: Locator;
private tableRadioButton: Locator;
+ private fixedHeightQueryElements: Locator;
+ private copyLinkButton: Locator;
constructor(page: Page) {
this.storage = new StoragePage(page);
@@ -238,6 +261,8 @@ export class Diagnostics {
this.tableRadioButton = page.locator(
'.ydb-table-with-controls-layout__controls .g-radio-button',
);
+ this.fixedHeightQueryElements = page.locator('.ydb-fixed-height-query');
+ this.copyLinkButton = page.locator('.ydb-copy-link-button__icon');
// Info tab cards
this.cpuCard = page.locator('.metrics-cards__tab:has-text("CPU")');
@@ -373,4 +398,29 @@ export class Diagnostics {
.textContent();
return selectedText?.trim() || '';
}
+
+ async getFixedHeightQueryElementsCount(): Promise {
+ return await this.fixedHeightQueryElements.count();
+ }
+
+ async getFixedHeightQueryElementHeight(index: number): Promise {
+ const element = this.fixedHeightQueryElements.nth(index);
+ return await element.evaluate((el) => {
+ return window.getComputedStyle(el).height;
+ });
+ }
+
+ async clickCopyLinkButton(): Promise {
+ await this.copyLinkButton.first().click();
+ }
+
+ async isCopyLinkButtonVisible(): Promise {
+ return await this.copyLinkButton.first().isVisible();
+ }
+
+ async isRowActive(rowIndex: number): Promise {
+ const rowElement = this.dataTable.locator(`tr.data-table__row:nth-child(${rowIndex})`);
+ const rowElementClass = await rowElement.getAttribute('class');
+ return rowElementClass?.includes('kv-top-queries__row_active') || false;
+ }
}
diff --git a/tests/suites/tenant/diagnostics/mocks.ts b/tests/suites/tenant/diagnostics/mocks.ts
index b6ba2c677..3aa81d10b 100644
--- a/tests/suites/tenant/diagnostics/mocks.ts
+++ b/tests/suites/tenant/diagnostics/mocks.ts
@@ -64,6 +64,123 @@ const generateTopShardsHistoryRow = (index: number) => {
];
};
+/**
+ * Generates a mock row for the TopQueries table
+ * @param index Row index (0-based)
+ * @returns An array of values for each column
+ */
+const generateTopQueriesRow = (index: number) => {
+ // Use a fixed base date for consistent results
+ const baseDate = new Date('2025-06-06T12:00:00Z');
+
+ // Generate end time in the past 6 hours (deterministic)
+ const endTime = new Date(baseDate);
+ endTime.setMinutes(endTime.getMinutes() - (index * 3 + (index % 30)));
+
+ // Generate CPU time in microseconds (deterministic based on index)
+ const cpuTimeUs = 1000 + ((index * 1000) % 99000);
+
+ // Generate duration in microseconds (slightly higher than CPU time)
+ const duration = cpuTimeUs + ((index * 500) % 50000);
+
+ // Generate read bytes (deterministic)
+ const readBytes = 100 + ((index * 100) % 9900);
+
+ // Generate read rows (deterministic)
+ const readRows = 1 + ((index * 10) % 999);
+
+ // Generate request units (deterministic)
+ const requestUnits = 1 + ((index * 2) % 49);
+
+ // Generate rank
+ const rank = index + 1;
+
+ // Generate user SID
+ const users = [
+ 'user@system',
+ 'admin@system',
+ 'service@system',
+ 'metadata@system',
+ 'test@system',
+ ];
+ const userSID = users[index % users.length];
+
+ // Generate various query types
+ const queryTemplates = [
+ `SELECT * FROM \`//dev02/home/xenoxeno/db1/.metadata/initialization/migrations\`;`,
+ `SELECT * FROM \`//dev02/home/xenoxeno/db1/.metadata/secrets/values\`;`,
+ `--!syntax_v1\nDECLARE $c0_0 AS Uint64;\nDECLARE $c0_1 AS String;\nINSERT INTO \`kv_test\` (c0, c1) VALUES ($c0_0, $c0_1)`,
+ `SELECT * FROM \`ydb/MasterClusterExt.db\`;\nSELECT version_str, color_class FROM \`ydb/MasterClusterVersions.db\`;`,
+ `DECLARE $name AS Utf8;\nSELECT * FROM \`ydb/MasterClusterExt.db\` WHERE name=$name`,
+ `SELECT COUNT(*) FROM \`big_kv_test\` WHERE id > 1000;`,
+ `UPDATE \`boring_table\` SET value = 'updated' WHERE key = 'test';`,
+ `SELECT a.*, b.* FROM \`cities\` a JOIN \`boring_table2\` b ON a.id = b.city_id;`,
+ `INSERT INTO \`my_column_table\` (id, name, timestamp) VALUES (${index}, 'test_${index}', CurrentUtcTimestamp());`,
+ `DELETE FROM \`my_row_table\` WHERE created_at < DateTime::MakeDate(DateTime::ParseIso8601('2024-01-01T00:00:00Z'));`,
+ ];
+
+ const queryText = queryTemplates[index % queryTemplates.length];
+
+ return [
+ cpuTimeUs, // CPUTimeUs
+ queryText, // QueryText
+ endTime.toISOString(), // IntervalEnd
+ endTime.toISOString(), // EndTime
+ readRows, // ReadRows
+ readBytes, // ReadBytes
+ userSID, // UserSID
+ duration, // Duration
+ requestUnits, // RequestUnits
+ rank, // Rank
+ ];
+};
+
+/**
+ * Sets up a mock for the TopQueries tab with 100 rows for scrolling tests
+ */
+export const setupTopQueriesMock = async (page: Page) => {
+ await page.route(`${backend}/viewer/json/query?*`, async (route) => {
+ const request = route.request();
+ const postData = request.postData();
+
+ // Only mock TopQueries requests (check if it's a TopQueries query)
+ if (postData && postData.includes('CPUTime as CPUTimeUs')) {
+ await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
+
+ // Generate 100 rows of data for scrolling test
+ const rows = Array.from({length: 100}, (_, i) => generateTopQueriesRow(i));
+
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ version: 8,
+ result: [
+ {
+ rows: rows,
+ columns: [
+ {name: 'CPUTimeUs', type: 'Uint64?'},
+ {name: 'QueryText', type: 'Utf8?'},
+ {name: 'IntervalEnd', type: 'Timestamp?'},
+ {name: 'EndTime', type: 'Timestamp?'},
+ {name: 'ReadRows', type: 'Uint64?'},
+ {name: 'ReadBytes', type: 'Uint64?'},
+ {name: 'UserSID', type: 'Utf8?'},
+ {name: 'Duration', type: 'Uint64?'},
+ {name: 'RequestUnits', type: 'Uint64?'},
+ {name: 'Rank', type: 'Uint32?'},
+ ],
+ },
+ ],
+ }),
+ });
+ } else {
+ // Continue with the original request for other queries
+ await route.continue();
+ }
+ });
+};
+
/**
* Sets up a mock for the TopShards tab in History mode
* This ensures the first row has values for all columns
diff --git a/tests/suites/tenant/diagnostics/tabs/queries.test.ts b/tests/suites/tenant/diagnostics/tabs/queries.test.ts
index 58d8c7189..2facd3f85 100644
--- a/tests/suites/tenant/diagnostics/tabs/queries.test.ts
+++ b/tests/suites/tenant/diagnostics/tabs/queries.test.ts
@@ -12,6 +12,7 @@ import {
QueryPeriod,
QueryTopColumns,
} from '../Diagnostics';
+import {setupTopQueriesMock} from '../mocks';
test.describe('Diagnostics Queries tab', async () => {
test('No runnning queries in Queries if no queries are running', async ({page}) => {
@@ -179,4 +180,102 @@ test.describe('Diagnostics Queries tab', async () => {
// Verify the view was updated back
expect(await diagnostics.getSelectedQueryPeriod()).toBe(QueryPeriod.PerHour);
});
+
+ test('Top Query rows components have consistent height across different query lengths', async ({
+ page,
+ }) => {
+ // Setup mock with 100 rows for scrolling test
+ await setupTopQueriesMock(page);
+
+ const pageQueryParams = {
+ schema: tenantName,
+ database: tenantName,
+ tenantPage: 'diagnostics',
+ diagnosticsTab: 'topQueries',
+ };
+ const tenantPage = new TenantPage(page);
+ await tenantPage.goto(pageQueryParams);
+
+ const diagnostics = new Diagnostics(page);
+ await expect(diagnostics.table.isVisible()).resolves.toBe(true);
+
+ // Check that FixedHeightQuery components have the expected fixed height
+ const rowCount = await diagnostics.table.getRowCount();
+
+ if (rowCount > 1) {
+ // Check that all FixedHeightQuery components have the same height
+ const heights = [];
+ for (let i = 0; i < Math.min(rowCount, 5); i++) {
+ const height = await diagnostics.getFixedHeightQueryElementHeight(i);
+ heights.push(height);
+ }
+
+ // All heights should be the same (88px for 4 lines)
+ const firstHeight = heights[0];
+
+ for (const height of heights) {
+ expect(height).toBe(firstHeight);
+ }
+ }
+ });
+
+ test('Scroll to row, get shareable link, navigate to URL and verify row is scrolled into view', async ({
+ page,
+ context,
+ browserName,
+ }) => {
+ // Skip this test in Safari due to clipboard permission issues
+ test.skip(browserName === 'webkit', 'Clipboard API not fully supported in Safari');
+ // Grant clipboard permissions
+ await context.grantPermissions(['clipboard-read']);
+
+ // Setup mock with 100 rows for scrolling test
+ await setupTopQueriesMock(page);
+
+ const pageQueryParams = {
+ schema: tenantName,
+ database: tenantName,
+ tenantPage: 'diagnostics',
+ diagnosticsTab: 'topQueries',
+ };
+ const tenantPage = new TenantPage(page);
+ await tenantPage.goto(pageQueryParams);
+
+ const diagnostics = new Diagnostics(page);
+ await expect(diagnostics.table.isVisible()).resolves.toBe(true);
+
+ // Get the number of rows and select a row that requires scrolling (should be 100 from mock)
+ const rowCount = await diagnostics.table.getRowCount();
+ expect(rowCount).toBe(8); // Verify we have the expected 100 rows from mock
+
+ // Target a row further down that requires scrolling
+ const targetRowIndex = 8;
+
+ // Click on the target row to open the drawer
+ await diagnostics.table.clickRow(targetRowIndex);
+
+ // Wait for drawer to open
+ await page.waitForTimeout(500);
+
+ // Find and click the copy link button in the drawer
+ await expect(diagnostics.isCopyLinkButtonVisible()).resolves.toBe(true);
+ await diagnostics.clickCopyLinkButton();
+
+ // Get the copied URL from clipboard
+ const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
+ expect(clipboardText).toBeTruthy();
+ expect(clipboardText).toContain('/tenant');
+
+ // Navigate to the copied URL
+ await page.goto(clipboardText);
+ await page.waitForTimeout(1000);
+
+ const firstVisibleRowIndex = 4;
+ // Verify the row is highlighted/selected (if applicable)
+ await page.waitForTimeout(1000);
+
+ const hasActiveClass = await diagnostics.isRowActive(firstVisibleRowIndex);
+
+ expect(hasActiveClass).toBe(true);
+ });
});