diff --git a/src/components/PaginatedTable/PaginatedTable.tsx b/src/components/PaginatedTable/PaginatedTable.tsx index 04395ab6ec..36a20c3ad0 100644 --- a/src/components/PaginatedTable/PaginatedTable.tsx +++ b/src/components/PaginatedTable/PaginatedTable.tsx @@ -38,7 +38,7 @@ export interface PaginatedTableProps { containerClassName?: string; } -const DEFAULT_PAGINATION_LIMIT = 20; +const DEFAULT_PAGINATION_LIMIT = 200; export const PaginatedTable = ({ limit: chunkSize = DEFAULT_PAGINATION_LIMIT, @@ -67,7 +67,7 @@ export const PaginatedTable = ({ const tableRef = React.useRef(null); - const activeChunks = useScrollBasedChunks({ + const [activeChunks, visibleRange] = useScrollBasedChunks({ parentRef, tableRef, totalItems: foundEntities, @@ -118,6 +118,7 @@ export const PaginatedTable = ({ renderEmptyDataMessage={renderEmptyDataMessage} onDataFetched={handleDataFetched} isActive={isActive} + visibleRange={visibleRange} /> )); }; diff --git a/src/components/PaginatedTable/TableChunk.tsx b/src/components/PaginatedTable/TableChunk.tsx index 56620bc20b..894e02eb2b 100644 --- a/src/components/PaginatedTable/TableChunk.tsx +++ b/src/components/PaginatedTable/TableChunk.tsx @@ -6,7 +6,7 @@ import {getArray} from '../../utils'; import {useAutoRefreshInterval} from '../../utils/hooks'; import {ResponseError} from '../Errors/ResponseError'; -import {EmptyTableRow, LoadingTableRow, TableRow} from './TableRow'; +import {EmptyTableRow, TableRow} from './TableRow'; import i18n from './i18n'; import type { Column, @@ -16,6 +16,7 @@ import type { RenderErrorMessage, SortParams, } from './types'; +import type {VisibleRange} from './useScrollBasedChunks'; import {typedMemo} from './utils'; const DEBOUNCE_TIMEOUT = 200; @@ -30,6 +31,7 @@ interface TableChunkProps { sortParams?: SortParams; isActive: boolean; tableName: string; + visibleRange: VisibleRange; fetchData: FetchData; getRowClassName?: GetRowClassName; @@ -54,6 +56,7 @@ export const TableChunk = typedMemo(function TableChunk({ renderEmptyDataMessage, onDataFetched, isActive, + visibleRange, }: TableChunkProps) { const [isTimeoutActive, setIsTimeoutActive] = React.useState(true); const [autoRefreshInterval] = useAutoRefreshInterval(); @@ -100,32 +103,35 @@ export const TableChunk = typedMemo(function TableChunk({ const dataLength = currentData?.data?.length || calculatedCount; + // Check if a row is within the visible range + const isRowVisible = (rowIndex: number): boolean => { + // Calculate the absolute row index within the entire table + const absoluteRowIndex = id * chunkSize + rowIndex; + + // Check if the row is within the visible range (including overscan) + return absoluteRowIndex >= visibleRange.startRow && absoluteRowIndex <= visibleRange.endRow; + }; + const renderContent = () => { if (!isActive) { return null; } - if (!currentData) { - if (error) { - const errorData = error as IResponseError; - return ( - - {renderErrorMessage ? ( - renderErrorMessage(errorData) - ) : ( - - )} - - ); - } else { - return getArray(dataLength).map((value) => ( - - )); - } + if (!currentData && error) { + const errorData = error as IResponseError; + return ( + + {renderErrorMessage ? ( + renderErrorMessage(errorData) + ) : ( + + )} + + ); } // Data is loaded, but there are no entities in the chunk - if (!currentData.data?.length) { + if (currentData?.data && !currentData.data?.length) { return ( {renderEmptyDataMessage ? renderEmptyDataMessage() : i18n('empty')} @@ -133,13 +139,16 @@ export const TableChunk = typedMemo(function TableChunk({ ); } - return currentData.data.map((rowData, index) => ( + const data = currentData?.data || getArray(dataLength); + + return data.map((rowData, index) => ( )); }; diff --git a/src/components/PaginatedTable/TableRow.tsx b/src/components/PaginatedTable/TableRow.tsx index 7ce9987b26..4a08310731 100644 --- a/src/components/PaginatedTable/TableRow.tsx +++ b/src/components/PaginatedTable/TableRow.tsx @@ -1,3 +1,5 @@ +import React from 'react'; + import {Skeleton} from '@gravity-ui/uikit'; import {DEFAULT_ALIGN, DEFAULT_RESIZEABLE} from './constants'; @@ -37,65 +39,73 @@ const TableRowCell = ({ ); }; -interface LoadingTableRowProps { - columns: Column[]; +interface VisibilityProps { + isVisible?: boolean; +} + +interface TableRowColumnProps { + column: Column; + row?: T; height: number; } -export const LoadingTableRow = typedMemo(function ({columns, height}: LoadingTableRowProps) { - return ( - - {columns.map((column) => { - const resizeable = column.resizeable ?? DEFAULT_RESIZEABLE; +export const TableRowColumn = typedMemo( + ({row, column, height, isVisible = true}: TableRowColumnProps & VisibilityProps) => { + const resizeable = column.resizeable ?? DEFAULT_RESIZEABLE; - return ( - - - - ); - })} - - ); -}); + const renderedCell = React.useMemo(() => { + if (row) { + return column.render({row}); + } + return null; + }, [column, row]); + + return ( + + {isVisible && row ? ( + renderedCell + ) : ( + + )} + + ); + }, +); interface TableRowProps { columns: Column[]; - row: T; + row?: T; height: number; getRowClassName?: GetRowClassName; } -export const TableRow = ({row, columns, getRowClassName, height}: TableRowProps) => { - const additionalClassName = getRowClassName?.(row); +export const TableRow = ({ + row, + columns, + getRowClassName, + height, + isVisible = true, +}: TableRowProps & VisibilityProps) => { + const additionalClassName = row ? getRowClassName?.(row) : undefined; return ( - {columns.map((column) => { - const resizeable = column.resizeable ?? DEFAULT_RESIZEABLE; - - return ( - - {column.render({row})} - - ); - })} + {columns.map((column) => ( + + ))} ); }; diff --git a/src/components/PaginatedTable/useScrollBasedChunks.ts b/src/components/PaginatedTable/useScrollBasedChunks.ts index e44dc1768d..5ed85a97af 100644 --- a/src/components/PaginatedTable/useScrollBasedChunks.ts +++ b/src/components/PaginatedTable/useScrollBasedChunks.ts @@ -13,6 +13,13 @@ interface UseScrollBasedChunksProps { overscanCount?: number; } +export interface VisibleRange { + startChunk: number; + endChunk: number; + startRow: number; + endRow: number; +} + const DEFAULT_OVERSCAN_COUNT = 1; const THROTTLE_DELAY = 100; @@ -23,7 +30,7 @@ export const useScrollBasedChunks = ({ rowHeight, chunkSize, overscanCount = DEFAULT_OVERSCAN_COUNT, -}: UseScrollBasedChunksProps): boolean[] => { +}: UseScrollBasedChunksProps): [boolean[], VisibleRange] => { const chunksCount = React.useMemo( () => Math.ceil(totalItems / chunkSize), [chunkSize, totalItems], @@ -34,6 +41,12 @@ export const useScrollBasedChunks = ({ Math.min(overscanCount, Math.max(chunksCount - 1, 0)), ); + // Track exact visible rows (not just chunks) + const [startRow, setStartRow] = React.useState(0); + const [endRow, setEndRow] = React.useState( + Math.min(overscanCount * chunkSize, Math.max(totalItems - 1, 0)), + ); + const calculateVisibleRange = React.useCallback(() => { const container = parentRef?.current; const table = tableRef.current; @@ -46,13 +59,29 @@ export const useScrollBasedChunks = ({ const visibleStart = Math.max(containerScroll - tableOffset, 0); const visibleEnd = visibleStart + container.clientHeight; - const start = Math.max(Math.floor(visibleStart / rowHeight / chunkSize) - overscanCount, 0); - const end = Math.min( + // Calculate visible chunks (with overscan) + const startChunk = Math.max( + Math.floor(visibleStart / rowHeight / chunkSize) - overscanCount, + 0, + ); + const endChunk = Math.min( Math.floor(visibleEnd / rowHeight / chunkSize) + overscanCount, Math.max(chunksCount - 1, 0), ); - return {start, end}; + // Calculate visible rows (more precise) + const startRowIndex = Math.max(Math.floor(visibleStart / rowHeight), 0); + const endRowIndex = Math.min( + Math.floor(visibleEnd / rowHeight), + Math.max(totalItems - 1, 0), + ); + + return { + start: startChunk, + end: endChunk, + startRow: startRowIndex, + endRow: endRowIndex, + }; }, [parentRef, tableRef, rowHeight, chunkSize, overscanCount, chunksCount]); const handleScroll = React.useCallback(() => { @@ -60,6 +89,8 @@ export const useScrollBasedChunks = ({ if (newRange) { setStartChunk(newRange.start); setEndChunk(newRange.end); + setStartRow(newRange.startRow); + setEndRow(newRange.endRow); } }, [calculateVisibleRange]); @@ -81,12 +112,24 @@ export const useScrollBasedChunks = ({ }; }, [handleScroll, parentRef]); - return React.useMemo(() => { + // Create the visibility information + const activeChunks = React.useMemo(() => { // boolean array that represents active chunks - const activeChunks = Array(chunksCount).fill(false); + const chunks = Array(chunksCount).fill(false); for (let i = startChunk; i <= endChunk; i++) { - activeChunks[i] = true; + chunks[i] = true; } - return activeChunks; + return chunks; }, [chunksCount, startChunk, endChunk]); + + const visibleRange = React.useMemo(() => { + return { + startChunk, + endChunk, + startRow, + endRow, + }; + }, [startChunk, endChunk, startRow, endRow]); + + return [activeChunks, visibleRange]; }; diff --git a/src/containers/Storage/StorageNodes/getNodes.ts b/src/containers/Storage/StorageNodes/getNodes.ts index be9a0714e0..9080fddecb 100644 --- a/src/containers/Storage/StorageNodes/getNodes.ts +++ b/src/containers/Storage/StorageNodes/getNodes.ts @@ -13,6 +13,8 @@ import {prepareSortValue} from '../../../utils/filters'; import {getUptimeParamValue} from '../../../utils/nodes'; import {getRequiredDataFields} from '../../../utils/tableUtils/getRequiredDataFields'; +import {generateNodes} from './nodes'; + export const getStorageNodes: FetchData< PreparedStorageNode, PreparedStorageNodeFilters, @@ -44,22 +46,37 @@ export const getStorageNodes: FetchData< const dataFieldsRequired = getRequiredDataFields(columnsIds, NODES_COLUMNS_TO_DATA_FIELDS); - const response = await window.api.viewer.getNodes({ - type, - storage, - limit, - offset, - sort, - filter: searchValue, - uptime: getUptimeParamValue(nodesUptimeFilter), - with: visibleEntities, - database, - node_id: nodeId, - group_id: groupId, - filter_group: filterGroup, - filter_group_by: filterGroupBy, - fieldsRequired: dataFieldsRequired, - }); + let response; + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('mocks')) { + // Get mock configuration from URL parameters or use defaults + const pdisks = parseInt(urlParams.get('pdisks') || '10', 10); + const vdisksPerPDisk = parseInt(urlParams.get('vdisksPerPDisk') || '2', 10); + const totalNodes = parseInt(urlParams.get('totalNodes') || '50', 10); + response = generateNodes(totalNodes, { + maxVdisksPerPDisk: vdisksPerPDisk, + maxPdisks: pdisks, + offset, + limit, + }); + } else { + response = await window.api.viewer.getNodes({ + type, + storage, + limit, + offset, + sort, + filter: searchValue, + uptime: getUptimeParamValue(nodesUptimeFilter), + with: visibleEntities, + database, + node_id: nodeId, + group_id: groupId, + filter_group: filterGroup, + filter_group_by: filterGroupBy, + fieldsRequired: dataFieldsRequired, + }); + } const preparedResponse = prepareStorageNodesResponse(response); return { data: preparedResponse.nodes || [], diff --git a/src/containers/Storage/StorageNodes/nodes.ts b/src/containers/Storage/StorageNodes/nodes.ts new file mode 100644 index 0000000000..1c4cfe1879 --- /dev/null +++ b/src/containers/Storage/StorageNodes/nodes.ts @@ -0,0 +1,224 @@ +import {EFlag} from '../../../types/api/enums'; +import type { + TEndpoint, + TNodeInfo, + TNodesInfo, + TPoolStats, + TSystemStateInfo, +} from '../../../types/api/nodes'; +import {TPDiskState} from '../../../types/api/pdisk'; +import {EVDiskState} from '../../../types/api/vdisk'; + +// Different disk sizes to simulate variety (in bytes) +const DISK_SIZES = [ + '68719476736', // 64 GB + '137438953472', // 128 GB + '274877906944', // 256 GB + '549755813888', // 512 GB + '1099511627776', // 1 TB +]; + +const getRandomDiskSize = () => DISK_SIZES[Math.floor(Math.random() * DISK_SIZES.length)]; + +const generatePoolStats = (count = 5): TPoolStats[] => { + const poolNames = ['System', 'User', 'Batch', 'IO', 'IC'] as const; + return poolNames.slice(0, count).map((Name) => ({ + Name, + Usage: Math.random() * 0.02, + Threads: Math.floor(Math.random() * 3) + 1, + })); +}; + +const generateEndpoints = (): TEndpoint[] => [ + {Name: 'ic', Address: ':19001'}, + {Name: 'http-mon', Address: ':8765'}, + {Name: 'grpcs', Address: ':2135'}, + {Name: 'grpc', Address: ':2136'}, +]; + +const generateSystemState = (nodeId: number): TSystemStateInfo => ({ + StartTime: '1734358137851', + ChangeTime: '1734358421375', + LoadAverage: [3.381347656, 2.489257813, 1.279296875], + NumberOfCpus: 8, + SystemState: EFlag.Green, + NodeId: nodeId, + Host: `localhost-${nodeId}`, + Version: 'main.95ce0df', + PoolStats: generatePoolStats(), + Endpoints: generateEndpoints(), + Roles: ['Bootstrapper', 'StateStorage', 'StateStorageBoard', 'SchemeBoard', 'Storage'], + MemoryLimit: '2147483648', + MaxDiskUsage: 0.002349853516, + Location: { + DataCenter: '1', + Rack: '1', + Unit: '1', + }, + TotalSessions: 0, + CoresUsed: 0.07583969556, + CoresTotal: 8, +}); + +const generatePDisk = (nodeId: number, pdiskId: number, totalSize = '68719476736') => ({ + PDiskId: pdiskId, + ChangeTime: '1734358142074', + Path: `/ydb_data/pdisk${pdiskId}l3ki78no.data`, + Guid: pdiskId.toString(), + Category: '0', + TotalSize: totalSize, + AvailableSize: (Number(totalSize) * 0.9).toString(), // 90% available by default + State: TPDiskState.Normal, + NodeId: nodeId, + Device: EFlag.Green, + Realtime: EFlag.Green, + SerialNumber: '', + SystemSize: '213909504', + LogUsedSize: '35651584', + LogTotalSize: '68486692864', + EnforcedDynamicSlotSize: '22817013760', +}); + +const generateVDisk = (nodeId: number, vdiskId: number, pdiskId: number) => ({ + VDiskId: { + GroupID: vdiskId, + GroupGeneration: 1, + Ring: 0, + Domain: 0, + VDisk: 0, + }, + ChangeTime: '1734358420919', + PDiskId: pdiskId, + VDiskSlotId: vdiskId, + Guid: '1', + Kind: '0', + NodeId: nodeId, + VDiskState: EVDiskState.OK, + DiskSpace: EFlag.Green, + SatisfactionRank: { + FreshRank: { + Flag: EFlag.Green, + }, + LevelRank: { + Flag: EFlag.Green, + }, + }, + Replicated: true, + ReplicationProgress: 1, + ReplicationSecondsRemaining: 0, + AllocatedSize: '0', + AvailableSize: '22817013760', + HasUnreadableBlobs: false, + IncarnationGuid: '11528832187803248876', + InstanceGuid: '14836434871903384493', + FrontQueues: EFlag.Green, + StoragePoolName: 'static', + ReadThroughput: '0', + WriteThroughput: '420', +}); + +interface NodeGeneratorOptions { + maxVdisksPerPDisk?: number; + maxPdisks?: number; +} + +const DEFAULT_OPTIONS: NodeGeneratorOptions = { + maxVdisksPerPDisk: 3, + maxPdisks: 4, +}; + +const generateNode = (nodeId: number, options: NodeGeneratorOptions = {}): TNodeInfo => { + const maxPdisks = options.maxPdisks ?? DEFAULT_OPTIONS.maxPdisks!; + const maxVdisksPerPDisk = options.maxVdisksPerPDisk ?? DEFAULT_OPTIONS.maxVdisksPerPDisk!; + + // Generate a random number of pdisks up to maxPdisks + const pdisksCount = Math.floor(Math.random() * maxPdisks) + 1; + + // For each pdisk, generate a random number of vdisks up to maxVdisksPerPDisk + const pdiskVdisksCounts = Array.from({length: pdisksCount}, () => + Math.floor(Math.random() * maxVdisksPerPDisk), + ); + const totalVdisks = pdiskVdisksCounts.reduce((sum: number, count: number) => sum + count, 0); + + return { + NodeId: nodeId, + UptimeSeconds: 284, + CpuUsage: 0.00947996, + DiskSpaceUsage: 0.234985, + SystemState: generateSystemState(nodeId), + PDisks: Array.from({length: pdisksCount}, (_, i) => + generatePDisk(nodeId, i + 1, getRandomDiskSize()), + ), + VDisks: Array.from({length: totalVdisks}, (_, i) => { + // Find which pdisk this vdisk belongs to based on the distribution + let pdiskIndex = 0; + let vdiskCount = pdiskVdisksCounts[0]; + while (i >= vdiskCount && pdiskIndex < pdisksCount - 1) { + pdiskIndex++; + vdiskCount += pdiskVdisksCounts[pdiskIndex]; + } + return generateVDisk(nodeId, i, pdiskIndex + 1); + }), + }; +}; + +interface GenerateNodesOptions extends NodeGeneratorOptions { + offset?: number; + limit?: number; +} + +// Keep a cache of generated nodes to maintain consistency between paginated requests +let cachedNodes: TNodeInfo[] | null = null; +let currentTotalNodes = 50; // Default number of nodes + +export const generateNodes = (count?: number, options: GenerateNodesOptions = {}): TNodesInfo => { + const totalNodes = count ?? currentTotalNodes; + const {offset = 0, limit = totalNodes, maxVdisksPerPDisk, maxPdisks} = options; + + // Reset cache if total nodes count changes + if (totalNodes !== currentTotalNodes) { + cachedNodes = null; + currentTotalNodes = totalNodes; + } + + // Generate or use cached nodes + if (!cachedNodes) { + cachedNodes = Array.from({length: totalNodes}, (_, i) => + generateNode(i + 1, {maxVdisksPerPDisk, maxPdisks}), + ); + } + + // Calculate MaximumSlotsPerDisk and MaximumDisksPerNode across all nodes + let maxSlotsPerDisk = 0; + let maxDisksPerNode = 0; + + cachedNodes.forEach((node) => { + // Count pdisks per node + if (node.PDisks) { + maxDisksPerNode = Math.max(maxDisksPerNode, node.PDisks.length); + } + + // Count vdisks per pdisk + if (node.VDisks) { + const pdiskVdiskCounts = new Map(); + node.VDisks.forEach((vdisk) => { + if (typeof vdisk.PDiskId === 'number') { + const count = (pdiskVdiskCounts.get(vdisk.PDiskId) || 0) + 1; + pdiskVdiskCounts.set(vdisk.PDiskId, count); + maxSlotsPerDisk = Math.max(maxSlotsPerDisk, count); + } + }); + } + }); + + // Get the requested slice of nodes + const paginatedNodes = cachedNodes.slice(offset, offset + limit); + + return { + TotalNodes: totalNodes.toString(), + FoundNodes: totalNodes.toString(), + Nodes: paginatedNodes, + MaximumSlotsPerDisk: maxSlotsPerDisk.toString(), + MaximumDisksPerNode: maxDisksPerNode.toString(), + }; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 8de8c148cc..130a6353cf 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,5 +1,5 @@ -export const getArray = (arrayLength: number) => { - return [...Array(arrayLength).keys()]; +export const getArray = (arrayLength: number): undefined[] => { + return Array.from({length: arrayLength}); }; export function valueIsDefined(value: T | null | undefined): value is T {