diff --git a/src/components/PaginatedTable/PaginatedTable.tsx b/src/components/PaginatedTable/PaginatedTable.tsx index 01dc6a998..e88a1110b 100644 --- a/src/components/PaginatedTable/PaginatedTable.tsx +++ b/src/components/PaginatedTable/PaginatedTable.tsx @@ -1,7 +1,6 @@ import React from 'react'; -import {TableWithControlsLayout} from '../TableWithControlsLayout/TableWithControlsLayout'; - +import {usePaginatedTableState} from './PaginatedTableContext'; import {TableChunk} from './TableChunk'; import {TableHead} from './TableHead'; import {DEFAULT_TABLE_ROW_HEIGHT} from './constants'; @@ -12,12 +11,12 @@ import type { GetRowClassName, HandleTableColumnsResize, PaginatedTableData, - RenderControls, RenderEmptyDataMessage, RenderErrorMessage, SortParams, } from './types'; import {useScrollBasedChunks} from './useScrollBasedChunks'; +import {useTableScroll} from './useTableScroll'; import './PaginatedTable.scss'; @@ -30,10 +29,10 @@ export interface PaginatedTableProps { columns: Column[]; getRowClassName?: GetRowClassName; rowHeight?: number; - parentRef: React.RefObject; + scrollContainerRef: React.RefObject; + tableContainerRef: React.RefObject; initialSortParams?: SortParams; onColumnsResize?: HandleTableColumnsResize; - renderControls?: RenderControls; renderEmptyDataMessage?: RenderEmptyDataMessage; renderErrorMessage?: RenderErrorMessage; containerClassName?: string; @@ -52,28 +51,44 @@ export const PaginatedTable = ({ columns, getRowClassName, rowHeight = DEFAULT_TABLE_ROW_HEIGHT, - parentRef, + scrollContainerRef, + tableContainerRef, initialSortParams, onColumnsResize, - renderControls, renderErrorMessage, renderEmptyDataMessage, containerClassName, onDataFetched, keepCache = true, }: PaginatedTableProps) => { - const initialTotal = initialEntitiesCount || 0; - const initialFound = initialEntitiesCount || 1; + // Get state and setters from context + const {tableState, setSortParams, setTotalEntities, setFoundEntities, setIsInitialLoad} = + usePaginatedTableState(); + + const {sortParams, foundEntities} = tableState; - const [sortParams, setSortParams] = React.useState(initialSortParams); - const [totalEntities, setTotalEntities] = React.useState(initialTotal); - const [foundEntities, setFoundEntities] = React.useState(initialFound); - const [isInitialLoad, setIsInitialLoad] = React.useState(true); + // Initialize state with props if available + React.useEffect(() => { + if (initialSortParams) { + setSortParams(initialSortParams); + } + + if (initialEntitiesCount) { + setTotalEntities(initialEntitiesCount); + setFoundEntities(initialEntitiesCount); + } + }, [ + setSortParams, + setTotalEntities, + setFoundEntities, + initialSortParams, + initialEntitiesCount, + ]); const tableRef = React.useRef(null); const activeChunks = useScrollBasedChunks({ - parentRef, + scrollContainerRef, tableRef, totalItems: foundEntities, rowHeight, @@ -105,18 +120,26 @@ export const PaginatedTable = ({ onDataFetched?.(data); } }, - [onDataFetched], + [onDataFetched, setFoundEntities, setIsInitialLoad, setTotalEntities], ); - // reset table on filters change + // Use the extracted table scroll hook + // The hook handles scrolling internally based on dependencies + useTableScroll({ + tableContainerRef, + scrollContainerRef, + dependencies: [rawFilters], // Add filters as a dependency to trigger scroll when they change + }); + + // Reset table on filters change React.useLayoutEffect(() => { - setTotalEntities(initialTotal); - setFoundEntities(initialFound); + const defaultTotal = initialEntitiesCount || 0; + const defaultFound = initialEntitiesCount || 1; + + setTotalEntities(defaultTotal); + setFoundEntities(defaultFound); setIsInitialLoad(true); - if (parentRef?.current) { - parentRef.current.scrollTo(0, 0); - } - }, [rawFilters, initialFound, initialTotal, parentRef]); + }, [initialEntitiesCount, setTotalEntities, setFoundEntities, setIsInitialLoad]); const renderChunks = () => { return activeChunks.map((isActive, index) => ( @@ -148,24 +171,9 @@ export const PaginatedTable = ({ ); - const renderContent = () => { - if (renderControls) { - return ( - - - {renderControls({inited: !isInitialLoad, totalEntities, foundEntities})} - - {renderTable()} - - ); - } - - return renderTable(); - }; - return (
- {renderContent()} + {renderTable()}
); }; diff --git a/src/components/PaginatedTable/PaginatedTableContext.tsx b/src/components/PaginatedTable/PaginatedTableContext.tsx new file mode 100644 index 000000000..b7339f9cc --- /dev/null +++ b/src/components/PaginatedTable/PaginatedTableContext.tsx @@ -0,0 +1,98 @@ +import React from 'react'; + +import type {PaginatedTableState} from './types'; + +// Default state for the table +const defaultTableState: PaginatedTableState = { + sortParams: undefined, + totalEntities: 0, + foundEntities: 0, + isInitialLoad: true, +}; + +// Context type definition +interface PaginatedTableStateContextType { + // State + tableState: PaginatedTableState; + + // Granular setters + setSortParams: (params: PaginatedTableState['sortParams']) => void; + setTotalEntities: (total: number) => void; + setFoundEntities: (found: number) => void; + setIsInitialLoad: (isInitial: boolean) => void; +} + +// Creating the context with default values +export const PaginatedTableStateContext = React.createContext({ + tableState: defaultTableState, + setSortParams: () => undefined, + setTotalEntities: () => undefined, + setFoundEntities: () => undefined, + setIsInitialLoad: () => undefined, +}); + +// Provider component props +interface PaginatedTableStateProviderProps { + children: React.ReactNode; + initialState?: Partial; +} + +// Provider component +export const PaginatedTableProvider = ({ + children, + initialState = {}, +}: PaginatedTableStateProviderProps) => { + // Use individual state variables for each field + const [sortParams, setSortParams] = React.useState( + initialState.sortParams ?? defaultTableState.sortParams, + ); + const [totalEntities, setTotalEntities] = React.useState( + initialState.totalEntities ?? defaultTableState.totalEntities, + ); + const [foundEntities, setFoundEntities] = React.useState( + initialState.foundEntities ?? defaultTableState.foundEntities, + ); + const [isInitialLoad, setIsInitialLoad] = React.useState( + initialState.isInitialLoad ?? defaultTableState.isInitialLoad, + ); + + // Construct tableState from individual state variables + const tableState = React.useMemo( + () => ({ + sortParams, + totalEntities, + foundEntities, + isInitialLoad, + }), + [sortParams, totalEntities, foundEntities, isInitialLoad], + ); + + // Create the context value with the constructed tableState and direct setters + const contextValue = React.useMemo( + () => ({ + tableState, + setSortParams, + setTotalEntities, + setFoundEntities, + setIsInitialLoad, + }), + [tableState, setSortParams, setTotalEntities, setFoundEntities, setIsInitialLoad], + ); + + return ( + + {children} + + ); +}; + +// Custom hook for consuming the context +export const usePaginatedTableState = () => { + const context = React.useContext(PaginatedTableStateContext); + + if (context === undefined) { + throw new Error('usePaginatedTableState must be used within a PaginatedTableStateProvider'); + } + + return context; +}; diff --git a/src/components/PaginatedTable/ResizeablePaginatedTable.tsx b/src/components/PaginatedTable/ResizeablePaginatedTable.tsx index b0e082ea0..5224943a1 100644 --- a/src/components/PaginatedTable/ResizeablePaginatedTable.tsx +++ b/src/components/PaginatedTable/ResizeablePaginatedTable.tsx @@ -5,7 +5,7 @@ import {useTableResize} from '../../utils/hooks/useTableResize'; import type {PaginatedTableProps} from './PaginatedTable'; import {PaginatedTable} from './PaginatedTable'; import {b} from './shared'; -import type {Column} from './types'; +import type {Column, PaginatedTableState} from './types'; function updateColumnsWidth(columns: Column[], columnsWidthSetup: ColumnWidthByName) { return columns.map((column) => { @@ -16,11 +16,13 @@ function updateColumnsWidth(columns: Column[], columnsWidthSetup: ColumnWi interface ResizeablePaginatedTableProps extends Omit, 'onColumnsResize'> { columnsWidthLSKey: string; + onStateChange?: (state: PaginatedTableState) => void; } export function ResizeablePaginatedTable({ columnsWidthLSKey, columns, + tableContainerRef, ...props }: ResizeablePaginatedTableProps) { const [tableColumnsWidth, setTableColumnsWidth] = useTableResize(columnsWidthLSKey); @@ -29,6 +31,7 @@ export function ResizeablePaginatedTable({ return ( = ( export type OnError = (error?: IResponseError) => void; +export interface PaginatedTableState { + sortParams?: SortParams; + totalEntities: number; + foundEntities: number; + isInitialLoad: boolean; +} + interface ControlsParams { totalEntities: number; foundEntities: number; diff --git a/src/components/PaginatedTable/useScrollBasedChunks.ts b/src/components/PaginatedTable/useScrollBasedChunks.ts index 1abf3de00..b05b5d458 100644 --- a/src/components/PaginatedTable/useScrollBasedChunks.ts +++ b/src/components/PaginatedTable/useScrollBasedChunks.ts @@ -3,7 +3,7 @@ import React from 'react'; import {calculateElementOffsetTop, rafThrottle} from './utils'; interface UseScrollBasedChunksProps { - parentRef: React.RefObject; + scrollContainerRef: React.RefObject; tableRef: React.RefObject; totalItems: number; rowHeight: number; @@ -14,7 +14,7 @@ interface UseScrollBasedChunksProps { const DEFAULT_OVERSCAN_COUNT = 1; export const useScrollBasedChunks = ({ - parentRef, + scrollContainerRef, tableRef, totalItems, rowHeight, @@ -32,7 +32,7 @@ export const useScrollBasedChunks = ({ ); const calculateVisibleRange = React.useCallback(() => { - const container = parentRef?.current; + const container = scrollContainerRef?.current; const table = tableRef.current; if (!container || !table) { return null; @@ -49,7 +49,7 @@ export const useScrollBasedChunks = ({ Math.max(chunksCount - 1, 0), ); return {start, end}; - }, [parentRef, tableRef, rowHeight, chunkSize, overscanCount, chunksCount]); + }, [scrollContainerRef, tableRef, rowHeight, chunkSize, overscanCount, chunksCount]); const updateVisibleChunks = React.useCallback(() => { const newRange = calculateVisibleRange(); @@ -80,7 +80,7 @@ export const useScrollBasedChunks = ({ }, [updateVisibleChunks]); React.useEffect(() => { - const container = parentRef?.current; + const container = scrollContainerRef?.current; if (!container) { return undefined; } @@ -91,7 +91,7 @@ export const useScrollBasedChunks = ({ return () => { container.removeEventListener('scroll', throttledHandleScroll); }; - }, [handleScroll, parentRef]); + }, [handleScroll, scrollContainerRef]); return React.useMemo(() => { // boolean array that represents active chunks diff --git a/src/components/PaginatedTable/useTableScroll.ts b/src/components/PaginatedTable/useTableScroll.ts new file mode 100644 index 000000000..f750b085c --- /dev/null +++ b/src/components/PaginatedTable/useTableScroll.ts @@ -0,0 +1,55 @@ +import React from 'react'; + +interface UseTableScrollProps { + tableContainerRef: React.RefObject; + scrollContainerRef: React.RefObject; + dependencies?: any[]; // Optional additional dependencies for the effect +} + +export const useTableScroll = ({ + tableContainerRef, + scrollContainerRef, + dependencies = [], +}: UseTableScrollProps) => { + // Get the CSS variable value for sticky top offset + const getStickyTopOffset = React.useCallback(() => { + // Try to get the variable from parent elements + if (tableContainerRef.current) { + const computedStyle = window.getComputedStyle(tableContainerRef.current); + const stickyTopOffset = computedStyle.getPropertyValue( + '--data-table-sticky-top-offset', + ); + + return stickyTopOffset ? parseInt(stickyTopOffset, 10) : 0; + } + return 0; + }, [tableContainerRef]); + + // Handle table scrolling function + const handleTableScroll = React.useCallback(() => { + if (tableContainerRef.current && scrollContainerRef.current) { + // Get the sticky top offset value + const stickyTopOffset = getStickyTopOffset(); + + // Scroll the parent container to the position of the table container + const tableRect = tableContainerRef.current.getBoundingClientRect(); + const scrollContainerRect = scrollContainerRef.current.getBoundingClientRect(); + const scrollTop = + tableRect.top - scrollContainerRect.top + scrollContainerRef.current.scrollTop; + if (tableRect.top < scrollContainerRect.top) { + // Adjust scroll position to account for sticky offset + scrollContainerRef.current.scrollTo(0, scrollTop - stickyTopOffset); + } + } + }, [scrollContainerRef, tableContainerRef, getStickyTopOffset]); + + // Trigger scroll adjustment with dependencies + React.useLayoutEffect(() => { + handleTableScroll(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [handleTableScroll, ...dependencies]); + + return { + handleTableScroll, + }; +}; diff --git a/src/components/TableWithControlsLayout/TableWithControlsLayout.tsx b/src/components/TableWithControlsLayout/TableWithControlsLayout.tsx index 16a74fc4e..56dac5e84 100644 --- a/src/components/TableWithControlsLayout/TableWithControlsLayout.tsx +++ b/src/components/TableWithControlsLayout/TableWithControlsLayout.tsx @@ -1,3 +1,5 @@ +import React from 'react'; + import {cn} from '../../utils/cn'; import {TableSkeleton} from '../TableSkeleton/TableSkeleton'; @@ -32,10 +34,17 @@ TableWithControlsLayout.Controls = function TableControls({ ); }; -TableWithControlsLayout.Table = function Table({children, loading, className}: TableProps) { +TableWithControlsLayout.Table = React.forwardRef(function Table( + {children, loading, className}, + ref, +) { if (loading) { return ; } - return
{children}
; -}; + return ( +
+ {children} +
+ ); +}); diff --git a/src/containers/Cluster/Cluster.scss b/src/containers/Cluster/Cluster.scss index e2df00d8b..c9928a011 100644 --- a/src/containers/Cluster/Cluster.scss +++ b/src/containers/Cluster/Cluster.scss @@ -61,8 +61,4 @@ .ydb-table-with-controls-layout__controls-wrapper { top: 40px; } - - .ydb-table-with-controls-layout { - --data-table-sticky-top-offset: 102px; - } } diff --git a/src/containers/Cluster/Cluster.tsx b/src/containers/Cluster/Cluster.tsx index 2bfc51b49..4caa55467 100644 --- a/src/containers/Cluster/Cluster.tsx +++ b/src/containers/Cluster/Cluster.tsx @@ -171,14 +171,17 @@ export function Cluster({ - + - + ; + scrollContainerRef: React.RefObject; additionalNodesProps?: AdditionalNodesProps; withPeerRoleFilter?: boolean; @@ -58,7 +64,7 @@ export interface NodesProps { export function Nodes({ path, database, - parentRef, + scrollContainerRef, additionalNodesProps, withPeerRoleFilter, columns = getNodesColumns({database, getNodeRef: additionalNodesProps?.getNodeRef}), @@ -106,7 +112,7 @@ export function Nodes({ ; + scrollContainerRef: React.RefObject; withPeerRoleFilter?: boolean; @@ -152,7 +158,7 @@ interface NodesComponentProps { function NodesComponent({ path, database, - parentRef, + scrollContainerRef, withPeerRoleFilter, columns, defaultColumnsIds, @@ -163,6 +169,7 @@ function NodesComponent({ const {searchValue, uptimeFilter, peerRoleFilter} = useNodesPageQueryParams(groupByParams); const {problemFilter} = useProblemFilter(); const viewerNodesHandlerHasGrouping = useViewerNodesHandlerHasGrouping(); + const tableContainerRef = React.useRef(null); const {columnsToShow, columnsToSelect, setColumns} = useSelectedColumns( columns, @@ -172,40 +179,126 @@ function NodesComponent({ requiredColumnsIds, ); - const renderControls: RenderControls = ({totalEntities, foundEntities, inited}) => { - return ( - - ); - }; + return ( + + + + + + + + + + + ); +} + +// Wrapper component to connect NodesControls with the PaginatedTable state +function NodesControlsWithTableState({ + withGroupBySelect, + groupByParams, + withPeerRoleFilter, + columnsToSelect, + handleSelectedColumnsUpdate, +}: { + withGroupBySelect: boolean; + groupByParams: NodesGroupByField[]; + withPeerRoleFilter?: boolean; + columnsToSelect: TableColumnSetupItem[]; + handleSelectedColumnsUpdate: (updated: TableColumnSetupItem[]) => void; +}) { + const {tableState} = usePaginatedTableState(); return ( - ); } +interface NodeGroupProps { + name: string; + count: number; + isExpanded: boolean; + path?: string; + database?: string; + searchValue: string; + peerRoleFilter?: NodesPeerRole; + groupByParam?: NodesGroupByField; + columns: Column[]; + scrollContainerRef: React.RefObject; + tableContainerRef: React.RefObject; + onIsExpandedChange: (name: string, isExpanded: boolean) => void; +} + +const NodeGroup = React.memo(function NodeGroup({ + name, + count, + isExpanded, + path, + database, + searchValue, + peerRoleFilter, + groupByParam, + columns, + scrollContainerRef, + tableContainerRef, + onIsExpandedChange, +}: NodeGroupProps) { + return ( + + + + ); +}); + function GroupedNodesComponent({ path, database, - parentRef, + scrollContainerRef, withPeerRoleFilter, columns, defaultColumnsIds, @@ -215,6 +308,7 @@ function GroupedNodesComponent({ }: NodesComponentProps) { const {searchValue, peerRoleFilter, groupByParam} = useNodesPageQueryParams(groupByParams); const [autoRefreshInterval] = useAutoRefreshInterval(); + const tableContainerRef = React.useRef(null); const {columnsToShow, columnsToSelect, setColumns} = useSelectedColumns( columns, @@ -247,20 +341,16 @@ function GroupedNodesComponent({ const {expandedGroups, setIsGroupExpanded} = useExpandedGroups(tableGroups); - const renderControls = () => { - return ( - - ); - }; + // Initialize the table state with the API data + const initialState = React.useMemo( + () => ({ + foundEntities: found, + totalEntities: total, + isInitialLoad: isLoading, + sortParams: undefined, + }), + [found, total, isLoading], + ); const renderGroups = () => { if (tableGroups?.length) { @@ -268,28 +358,21 @@ function GroupedNodesComponent({ const isExpanded = expandedGroups[name]; return ( - - - + /> ); }); } @@ -298,12 +381,26 @@ function GroupedNodesComponent({ }; return ( - - {renderControls()} - {error ? : null} - - {renderGroups()} - - + + + + + + {error ? : null} + + {renderGroups()} + + + ); } diff --git a/src/containers/Nodes/NodesTable.tsx b/src/containers/Nodes/NodesTable.tsx index c64f0f99c..c1fc06346 100644 --- a/src/containers/Nodes/NodesTable.tsx +++ b/src/containers/Nodes/NodesTable.tsx @@ -1,8 +1,8 @@ import React from 'react'; import {Illustration} from '../../components/Illustration'; -import type {RenderControls} from '../../components/PaginatedTable'; import {ResizeablePaginatedTable} from '../../components/PaginatedTable'; +import type {PaginatedTableState} from '../../components/PaginatedTable/types'; import {NODES_COLUMNS_WIDTH_LS_KEY} from '../../components/nodesColumns/constants'; import type {NodesFilters, NodesPreparedEntity} from '../../store/reducers/nodes/types'; import type {ProblemFilterValue} from '../../store/reducers/settings/types'; @@ -28,10 +28,11 @@ interface NodesTableProps { filterGroupBy?: NodesGroupByField; columns: Column[]; - parentRef: React.RefObject; + scrollContainerRef: React.RefObject; + tableContainerRef: React.RefObject; - renderControls?: RenderControls; initialEntitiesCount?: number; + onStateChange?: (state: PaginatedTableState) => void; } export function NodesTable({ @@ -44,9 +45,10 @@ export function NodesTable({ filterGroup, filterGroupBy, columns, - parentRef, - renderControls, + scrollContainerRef, + tableContainerRef, initialEntitiesCount, + onStateChange, }: NodesTableProps) { const tableFilters: NodesFilters = React.useMemo(() => { return { @@ -81,11 +83,12 @@ export function NodesTable({ return ( ; + scrollContainerRef: React.RefObject; initialEntitiesCount?: number; } diff --git a/src/containers/Storage/PaginatedStorageGroups.tsx b/src/containers/Storage/PaginatedStorageGroups.tsx index 6c4a91bf9..0298bc337 100644 --- a/src/containers/Storage/PaginatedStorageGroups.tsx +++ b/src/containers/Storage/PaginatedStorageGroups.tsx @@ -2,13 +2,17 @@ import React from 'react'; import {ResponseError} from '../../components/Errors/ResponseError/ResponseError'; import {LoaderWrapper} from '../../components/LoaderWrapper/LoaderWrapper'; -import type {RenderControls} from '../../components/PaginatedTable'; +import { + PaginatedTableProvider, + usePaginatedTableState, +} from '../../components/PaginatedTable/PaginatedTableContext'; import {TableWithControlsLayout} from '../../components/TableWithControlsLayout/TableWithControlsLayout'; import { useCapabilitiesLoaded, useStorageGroupsHandlerHasGrouping, } from '../../store/reducers/capabilities/hooks'; import {storageApi} from '../../store/reducers/storage/storage'; +import type {GroupsGroupByField} from '../../types/api/storage'; import {useAutoRefreshInterval} from '../../utils/hooks'; import {renderPaginatedTableErrorMessage} from '../../utils/renderPaginatedTableErrorMessage'; @@ -16,6 +20,7 @@ import type {PaginatedStorageProps} from './PaginatedStorage'; import {StorageGroupsControls} from './StorageControls/StorageControls'; import {PaginatedStorageGroupsTable} from './StorageGroups/PaginatedStorageGroupsTable'; import {useStorageGroupsSelectedColumns} from './StorageGroups/columns/hooks'; +import type {StorageGroupsColumn} from './StorageGroups/columns/types'; import {TableGroup} from './TableGroup/TableGroup'; import {useExpandedGroups} from './TableGroup/useExpandedTableGroups'; import i18n from './i18n'; @@ -24,6 +29,33 @@ import {useStorageQueryParams} from './useStorageQueryParams'; import './Storage.scss'; +// Wrapper component to connect StorageGroupsControls with the PaginatedTable state +function StorageGroupsControlsWithTableState({ + withTypeSelector, + withGroupBySelect, + columnsToSelect, + handleSelectedColumnsUpdate, +}: { + withTypeSelector?: boolean; + withGroupBySelect?: boolean; + columnsToSelect: any[]; + handleSelectedColumnsUpdate: (updated: any[]) => void; +}) { + const {tableState} = usePaginatedTableState(); + + return ( + + ); +} + export function PaginatedStorageGroups(props: PaginatedStorageProps) { const {storageGroupsGroupByParam, visibleEntities, handleShowAllGroups} = useStorageQueryParams(); @@ -50,53 +82,117 @@ export function PaginatedStorageGroups(props: PaginatedStorageProps) { return {renderContent()}; } +interface StorageGroupGroupProps { + name: string; + count: number; + isExpanded: boolean; + database?: string; + nodeId?: string | number; + groupId?: string | number; + pDiskId?: string | number; + searchValue: string; + visibleEntities: 'all'; + filterGroupBy?: GroupsGroupByField; + columns: StorageGroupsColumn[]; + tableContainerRef: React.RefObject; + scrollContainerRef: React.RefObject; + onIsExpandedChange: (name: string, isExpanded: boolean) => void; + handleShowAllGroups: VoidFunction; +} + +const StorageGroupGroup = React.memo(function StorageGroupGroup({ + name, + count, + isExpanded, + database, + nodeId, + groupId, + pDiskId, + searchValue, + scrollContainerRef, + filterGroupBy, + columns, + tableContainerRef, + onIsExpandedChange, + handleShowAllGroups, +}: StorageGroupGroupProps) { + return ( + + + + ); +}); + function StorageGroupsComponent({ database, nodeId, groupId, pDiskId, viewContext, - parentRef, + scrollContainerRef, initialEntitiesCount, }: PaginatedStorageProps) { const {searchValue, visibleEntities, handleShowAllGroups} = useStorageQueryParams(); const storageGroupsHandlerHasGroupping = useStorageGroupsHandlerHasGrouping(); + const tableContainerRef = React.useRef(null); const {columnsToShow, columnsToSelect, setColumns} = useStorageGroupsSelectedColumns({ visibleEntities, viewContext, }); - const renderControls: RenderControls = ({totalEntities, foundEntities, inited}) => { - return ( - - ); - }; - return ( - + + + + + + + + + + ); } @@ -105,10 +201,11 @@ function GroupedStorageGroupsComponent({ nodeId, groupId, pDiskId, - parentRef, + scrollContainerRef, viewContext, }: PaginatedStorageProps) { const [autoRefreshInterval] = useAutoRefreshInterval(); + const tableContainerRef = React.useRef(null); const {searchValue, storageGroupsGroupByParam, visibleEntities, handleShowAllGroups} = useStorageQueryParams(); @@ -138,19 +235,16 @@ function GroupedStorageGroupsComponent({ const {expandedGroups, setIsGroupExpanded} = useExpandedGroups(tableGroups); - const renderControls = () => { - return ( - - ); - }; + // Initialize the table state with the API data + const initialState = React.useMemo( + () => ({ + foundEntities: found, + totalEntities: total, + isInitialLoad: isLoading, + sortParams: undefined, + }), + [found, total, isLoading], + ); const renderGroups = () => { if (tableGroups?.length) { @@ -158,30 +252,24 @@ function GroupedStorageGroupsComponent({ const isExpanded = expandedGroups[name]; return ( - - - + handleShowAllGroups={handleShowAllGroups} + columns={columnsToShow} + tableContainerRef={tableContainerRef} + /> ); }); } @@ -190,12 +278,25 @@ function GroupedStorageGroupsComponent({ }; return ( - - {renderControls()} - {error ? : null} - - {renderGroups()} - - + + + + + + {error ? : null} + + {renderGroups()} + + + ); } diff --git a/src/containers/Storage/PaginatedStorageNodes.tsx b/src/containers/Storage/PaginatedStorageNodes.tsx index aa6d7b506..cdce98db9 100644 --- a/src/containers/Storage/PaginatedStorageNodes.tsx +++ b/src/containers/Storage/PaginatedStorageNodes.tsx @@ -2,7 +2,10 @@ import React from 'react'; import {ResponseError} from '../../components/Errors/ResponseError'; import {LoaderWrapper} from '../../components/LoaderWrapper/LoaderWrapper'; -import type {RenderControls} from '../../components/PaginatedTable'; +import { + PaginatedTableProvider, + usePaginatedTableState, +} from '../../components/PaginatedTable/PaginatedTableContext'; import {TableWithControlsLayout} from '../../components/TableWithControlsLayout/TableWithControlsLayout'; import { useCapabilitiesLoaded, @@ -30,7 +33,34 @@ import {useStorageColumnsSettings} from './utils'; import './Storage.scss'; -export const PaginatedStorageNodes = (props: PaginatedStorageProps) => { +// Wrapper component to connect StorageNodesControls with the PaginatedTable state +function StorageNodesControlsWithTableState({ + withTypeSelector, + withGroupBySelect, + columnsToSelect, + handleSelectedColumnsUpdate, +}: { + withTypeSelector?: boolean; + withGroupBySelect?: boolean; + columnsToSelect: any[]; + handleSelectedColumnsUpdate: (updated: any[]) => void; +}) { + const {tableState} = usePaginatedTableState(); + + return ( + + ); +} + +export function PaginatedStorageNodes(props: PaginatedStorageProps) { const {storageNodesGroupByParam, visibleEntities, nodesUptimeFilter, handleShowAllNodes} = useStorageQueryParams(); @@ -57,18 +87,83 @@ export const PaginatedStorageNodes = (props: PaginatedStorageProps) => { }; return {renderContent()}; -}; +} + +interface StorageNodeGroupProps { + name: string; + count: number; + isExpanded: boolean; + database?: string; + nodeId?: string | number; + groupId?: string | number; + searchValue: string; + visibleEntities: 'all'; + filterGroupBy?: NodesGroupByField; + columns: any[]; + tableContainerRef: React.RefObject; + scrollContainerRef: React.RefObject; + onIsExpandedChange: (name: string, isExpanded: boolean) => void; + handleShowAllNodes: VoidFunction; + onDataFetched: (data: any) => void; +} + +const StorageNodeGroup = React.memo(function StorageNodeGroup({ + name, + count, + isExpanded, + database, + nodeId, + groupId, + searchValue, + scrollContainerRef, + filterGroupBy, + columns, + tableContainerRef, + onIsExpandedChange, + handleShowAllNodes, + onDataFetched, +}: StorageNodeGroupProps) { + return ( + + + + ); +}); function StorageNodesComponent({ database, nodeId, groupId, viewContext, - parentRef, + scrollContainerRef, initialEntitiesCount, }: PaginatedStorageProps) { const {searchValue, visibleEntities, nodesUptimeFilter, handleShowAllNodes} = useStorageQueryParams(); + const tableContainerRef = React.useRef(null); const viewerNodesHandlerHasGrouping = useViewerNodesHandlerHasGrouping(); @@ -80,36 +175,36 @@ function StorageNodesComponent({ columnsSettings, }); - const renderControls: RenderControls = ({totalEntities, foundEntities, inited}) => { - return ( - - ); - }; - return ( - + + + + + + + + + + ); } @@ -118,15 +213,18 @@ function GroupedStorageNodesComponent({ groupId, nodeId, viewContext, - parentRef, + scrollContainerRef, }: PaginatedStorageProps) { const [autoRefreshInterval] = useAutoRefreshInterval(); + const tableContainerRef = React.useRef(null); const {searchValue, storageNodesGroupByParam, handleShowAllNodes} = useStorageQueryParams(); - const {columnsToSelect, setColumns} = useStorageNodesColumnsToSelect({ + const {handleDataFetched, columnsSettings} = useStorageColumnsSettings(); + const {columnsToShow, columnsToSelect, setColumns} = useStorageNodesColumnsToSelect({ database, viewContext, + columnsSettings, }); const {currentData, isFetching, error} = storageApi.useGetStorageNodesInfoQuery( @@ -148,19 +246,16 @@ function GroupedStorageNodesComponent({ const {expandedGroups, setIsGroupExpanded} = useExpandedGroups(tableGroups); - const renderControls = () => { - return ( - - ); - }; + // Initialize the table state with the API data + const initialState = React.useMemo( + () => ({ + foundEntities: found, + totalEntities: total, + isInitialLoad: isLoading, + sortParams: undefined, + }), + [found, total, isLoading], + ); const renderGroups = () => { if (tableGroups?.length) { @@ -168,26 +263,24 @@ function GroupedStorageNodesComponent({ const isExpanded = expandedGroups[name]; return ( - - - + handleShowAllNodes={handleShowAllNodes} + columns={columnsToShow} + tableContainerRef={tableContainerRef} + onDataFetched={handleDataFetched} + /> ); }); } @@ -196,65 +289,26 @@ function GroupedStorageNodesComponent({ }; return ( - - {renderControls()} - {error ? : null} - - {renderGroups()} - - - ); -} - -interface StorageNodesTableGroupContentProps { - database?: string; - parentRef: React.RefObject; - nodeId?: string | number; - groupId?: string | number; - searchValue: string; - handleShowAllNodes: VoidFunction; - filterGroup: string; - filterGroupBy?: NodesGroupByField; - viewContext?: StorageViewContext; - initialEntitiesCount: number; -} - -function StorageNodesTableGroupContent({ - database, - parentRef, - nodeId, - groupId, - searchValue, - handleShowAllNodes, - filterGroup, - filterGroupBy, - viewContext, - initialEntitiesCount, -}: StorageNodesTableGroupContentProps) { - const {handleDataFetched, columnsSettings} = useStorageColumnsSettings(); - const {columnsToShow} = useStorageNodesColumnsToSelect({ - database, - viewContext, - columnsSettings, - }); - - return ( - + + + + + + {error ? : null} + + {renderGroups()} + + + ); } diff --git a/src/containers/Storage/StorageGroups/PaginatedStorageGroupsTable.tsx b/src/containers/Storage/StorageGroups/PaginatedStorageGroupsTable.tsx index 2f7ff63d2..ea92b3e9f 100644 --- a/src/containers/Storage/StorageGroups/PaginatedStorageGroupsTable.tsx +++ b/src/containers/Storage/StorageGroups/PaginatedStorageGroupsTable.tsx @@ -1,7 +1,7 @@ import React from 'react'; import {LoaderWrapper} from '../../../components/LoaderWrapper/LoaderWrapper'; -import type {RenderControls, RenderErrorMessage} from '../../../components/PaginatedTable'; +import type {RenderErrorMessage} from '../../../components/PaginatedTable'; import {ResizeablePaginatedTable} from '../../../components/PaginatedTable'; import { useCapabilitiesLoaded, @@ -32,8 +32,8 @@ interface PaginatedStorageGroupsTableProps { visibleEntities: VisibleEntities; onShowAll: VoidFunction; - parentRef: React.RefObject; - renderControls?: RenderControls; + scrollContainerRef: React.RefObject; + tableContainerRef: React.RefObject; renderErrorMessage: RenderErrorMessage; initialEntitiesCount?: number; } @@ -49,8 +49,8 @@ export const PaginatedStorageGroupsTable = ({ searchValue, visibleEntities, onShowAll, - parentRef, - renderControls, + scrollContainerRef, + tableContainerRef, renderErrorMessage, initialEntitiesCount, }: PaginatedStorageGroupsTableProps) => { @@ -98,11 +98,11 @@ export const PaginatedStorageGroupsTable = ({ ; - renderControls?: RenderControls; + scrollContainerRef: React.RefObject; + tableContainerRef: React.RefObject; renderErrorMessage: RenderErrorMessage; initialEntitiesCount?: number; onDataFetched?: (data: PaginatedTableData) => void; @@ -58,8 +54,8 @@ export const PaginatedStorageNodesTable = ({ visibleEntities, nodesUptimeFilter, onShowAll, - parentRef, - renderControls, + scrollContainerRef, + tableContainerRef, renderErrorMessage, initialEntitiesCount, onDataFetched, @@ -106,12 +102,12 @@ export const PaginatedStorageNodesTable = ({ return ( 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/containers/Storage/TableGroup/TableGroup.tsx b/src/containers/Storage/TableGroup/TableGroup.tsx index 56e248df8..62ec17acc 100644 --- a/src/containers/Storage/TableGroup/TableGroup.tsx +++ b/src/containers/Storage/TableGroup/TableGroup.tsx @@ -18,14 +18,14 @@ interface TableGroupProps { onIsExpandedChange: (name: string, isExpanded: boolean) => void; } -export function TableGroup({ +export const TableGroup = ({ children, title, entityName, count, expanded = false, onIsExpandedChange, -}: TableGroupProps) { +}: TableGroupProps) => { const toggleCollapsed = () => { onIsExpandedChange(title, !expanded); }; @@ -60,4 +60,6 @@ export function TableGroup({ {renderContent()} ); -} +}; + +TableGroup.displayName = 'TableGroup'; diff --git a/src/containers/StorageGroupPage/StorageGroupPage.tsx b/src/containers/StorageGroupPage/StorageGroupPage.tsx index edfc9cb66..46426f271 100644 --- a/src/containers/StorageGroupPage/StorageGroupPage.tsx +++ b/src/containers/StorageGroupPage/StorageGroupPage.tsx @@ -112,7 +112,7 @@ export function StorageGroupPage() { ); } @@ -116,7 +116,7 @@ function Diagnostics(props: DiagnosticsProps) { return ; } case TENANT_DIAGNOSTICS_TABS_IDS.storage: { - return ; + return ; } case TENANT_DIAGNOSTICS_TABS_IDS.network: { return ( @@ -124,7 +124,7 @@ function Diagnostics(props: DiagnosticsProps) { path={path} database={tenantName} additionalNodesProps={props.additionalNodesProps} - parentRef={containerRef} + scrollContainerRef={containerRef} /> ); } @@ -149,7 +149,7 @@ function Diagnostics(props: DiagnosticsProps) { key={path} path={path} database={tenantName} - parentRef={containerRef} + scrollContainerRef={containerRef} /> ); } diff --git a/src/containers/Tenant/Diagnostics/Network/NetworkWrapper.tsx b/src/containers/Tenant/Diagnostics/Network/NetworkWrapper.tsx index b31ffc12e..4f3adae4e 100644 --- a/src/containers/Tenant/Diagnostics/Network/NetworkWrapper.tsx +++ b/src/containers/Tenant/Diagnostics/Network/NetworkWrapper.tsx @@ -18,14 +18,14 @@ import { } from './NetworkTable/constants'; interface NetworkWrapperProps - extends Pick { + extends Pick { database: string; } export function NetworkWrapper({ database, path, - parentRef, + scrollContainerRef, additionalNodesProps, }: NetworkWrapperProps) { const capabilitiesLoaded = useCapabilitiesLoaded(); @@ -40,7 +40,7 @@ export function NetworkWrapper({ ; + scrollContainerRef: React.RefObject; } -export function TopicData({parentRef, path, database}: TopicDataProps) { +export function TopicData({scrollContainerRef, path, database}: TopicDataProps) { const [autoRefreshInterval] = useAutoRefreshInterval(); const [startOffset, setStartOffset] = React.useState(); const [endOffset, setEndOffset] = React.useState(); @@ -53,6 +54,8 @@ export function TopicData({parentRef, path, database}: TopicDataProps) { const [baseOffset, setBaseOffset] = React.useState(); const [baseEndOffset, setBaseEndOffset] = React.useState(); + const tableContainerRef = React.useRef(null); + const startRef = React.useRef(); startRef.current = startOffset; @@ -164,12 +167,12 @@ export function TopicData({parentRef, path, database}: TopicDataProps) { (newOffset: number) => { const scrollTop = (newOffset - (baseOffset ?? 0)) * DEFAULT_TABLE_ROW_HEIGHT; const normalizedScrollTop = Math.max(0, scrollTop); - parentRef.current?.scrollTo({ + scrollContainerRef.current?.scrollTo({ top: normalizedScrollTop, behavior: 'instant', }); }, - [baseOffset, parentRef], + [baseOffset, scrollContainerRef], ); React.useEffect(() => { @@ -186,22 +189,20 @@ export function TopicData({parentRef, path, database}: TopicDataProps) { } }, [currentData, isFetching, scrollToOffset]); - const renderControls: RenderControls = () => { - return ( - - ); - }; + const renderControls = ( + + ); const renderEmptyDataMessage = () => { const hasFilters = selectedOffset || startTimestamp; @@ -225,26 +226,37 @@ export function TopicData({parentRef, path, database}: TopicDataProps) { return ( !isNil(baseOffset) && !isNil(baseEndOffset) && ( - { - return b('row', { - active: Boolean(selectedOffset && String(row.Offset) === selectedOffset), - }); - }} - /> + + + + {renderControls} + + + { + return b('row', { + active: Boolean( + selectedOffset && String(row.Offset) === selectedOffset, + ), + }); + }} + /> + + + ) ); } diff --git a/src/containers/VDiskPage/VDiskPage.tsx b/src/containers/VDiskPage/VDiskPage.tsx index 6a4a6485a..9bf1e7f40 100644 --- a/src/containers/VDiskPage/VDiskPage.tsx +++ b/src/containers/VDiskPage/VDiskPage.tsx @@ -193,7 +193,7 @@ export function VDiskPage() { groupId={GroupID} nodeId={nodeId} pDiskId={pDiskId ?? undefined} - parentRef={containerRef} + scrollContainerRef={containerRef} viewContext={{ groupId: GroupID?.toString(), nodeId: nodeId?.toString(),