diff --git a/src/components/PaginatedTable/PaginatedTable.tsx b/src/components/PaginatedTable/PaginatedTable.tsx index 24451754fd..ab31333034 100644 --- a/src/components/PaginatedTable/PaginatedTable.tsx +++ b/src/components/PaginatedTable/PaginatedTable.tsx @@ -25,6 +25,7 @@ import './PaginatedTable.scss'; export interface PaginatedTableProps { limit: number; + initialEntitiesCount?: number; fetchData: FetchData; filters?: F; tableName: string; @@ -42,6 +43,7 @@ export interface PaginatedTableProps { export const PaginatedTable = ({ limit, + initialEntitiesCount, fetchData, filters, tableName, @@ -56,9 +58,12 @@ export const PaginatedTable = ({ renderEmptyDataMessage, containerClassName, }: PaginatedTableProps) => { + const initialTotal = initialEntitiesCount || limit; + const initialFound = initialEntitiesCount || 0; + const [sortParams, setSortParams] = React.useState(initialSortParams); - const [totalEntities, setTotalEntities] = React.useState(limit); - const [foundEntities, setFoundEntities] = React.useState(0); + const [totalEntities, setTotalEntities] = React.useState(initialTotal); + const [foundEntities, setFoundEntities] = React.useState(initialFound); const [activeChunks, setActiveChunks] = React.useState([]); const [isInitialLoad, setIsInitialLoad] = React.useState(true); @@ -82,8 +87,8 @@ export const PaginatedTable = ({ // reset table on filters change React.useLayoutEffect(() => { - setTotalEntities(limit); - setFoundEntities(0); + setTotalEntities(initialTotal); + setFoundEntities(initialFound); setIsInitialLoad(true); if (parentContainer) { parentContainer.scrollTo(0, 0); @@ -92,7 +97,7 @@ export const PaginatedTable = ({ } setActiveChunks([0]); - }, [filters, limit, parentContainer]); + }, [filters, initialFound, initialTotal, limit, parentContainer]); const renderChunks = () => { if (!observer) { @@ -117,6 +122,7 @@ export const PaginatedTable = ({ key={value} id={value} limit={limit} + totalLength={totalLength} rowHeight={rowHeight} columns={columns} fetchData={fetchData} diff --git a/src/components/PaginatedTable/TableChunk.tsx b/src/components/PaginatedTable/TableChunk.tsx index 7356c215d3..b77f87353b 100644 --- a/src/components/PaginatedTable/TableChunk.tsx +++ b/src/components/PaginatedTable/TableChunk.tsx @@ -17,6 +17,7 @@ const typedMemo: (Component: T) => T = React.memo; interface TableChunkProps { id: number; limit: number; + totalLength: number; rowHeight: number; columns: Column[]; filters?: F; @@ -35,6 +36,7 @@ interface TableChunkProps { export const TableChunk = typedMemo(function TableChunk({ id, limit, + totalLength, rowHeight, columns, fetchData, @@ -101,7 +103,11 @@ export const TableChunk = typedMemo(function TableChunk({ } }, [currentData, isActive, onDataFetched]); - const dataLength = currentData?.data?.length || limit; + const chunkOffset = id * limit; + const remainingLenght = totalLength - chunkOffset; + const calculatedChunkLength = remainingLenght < limit ? remainingLenght : limit; + + const dataLength = currentData?.data?.length || calculatedChunkLength; const renderContent = () => { if (!isActive) { diff --git a/src/components/TableSkeleton/TableSkeleton.scss b/src/components/TableSkeleton/TableSkeleton.scss index 5e9fb52b4c..d976c39f88 100644 --- a/src/components/TableSkeleton/TableSkeleton.scss +++ b/src/components/TableSkeleton/TableSkeleton.scss @@ -1,5 +1,11 @@ .table-skeleton { - width: 100%; + &__wrapper { + width: 100%; + + &_hidden { + visibility: hidden; + } + } &__row { display: flex; diff --git a/src/components/TableSkeleton/TableSkeleton.tsx b/src/components/TableSkeleton/TableSkeleton.tsx index 004eb75847..9b5161695d 100644 --- a/src/components/TableSkeleton/TableSkeleton.tsx +++ b/src/components/TableSkeleton/TableSkeleton.tsx @@ -1,6 +1,7 @@ import {Skeleton} from '@gravity-ui/uikit'; import {cn} from '../../utils/cn'; +import {useDelayed} from '../../utils/hooks/useDelayed'; import './TableSkeleton.scss'; @@ -9,21 +10,26 @@ const b = cn('table-skeleton'); interface TableSkeletonProps { className?: string; rows?: number; + delay?: number; } -export const TableSkeleton = ({rows = 2, className}: TableSkeletonProps) => ( -
-
- - - - - -
- {[...new Array(rows)].map((_, index) => ( -
- +export const TableSkeleton = ({rows = 2, delay = 600, className}: TableSkeletonProps) => { + const [show] = useDelayed(delay); + + return ( +
+
+ + + + +
- ))} -
-); + {[...new Array(rows)].map((_, index) => ( +
+ +
+ ))} +
+ ); +}; diff --git a/src/containers/Cluster/Cluster.scss b/src/containers/Cluster/Cluster.scss index 69e0b03f7a..a12bbe5c50 100644 --- a/src/containers/Cluster/Cluster.scss +++ b/src/containers/Cluster/Cluster.scss @@ -1,14 +1,13 @@ @import '../../styles/mixins.scss'; .cluster { + position: relative; + overflow: auto; - flex-grow: 1; height: 100%; padding: 0 20px; - @include flex-container(); - &__header { position: sticky; left: 0; diff --git a/src/containers/Storage/PaginatedStorage.tsx b/src/containers/Storage/PaginatedStorage.tsx index 663fcdea10..d47d49f56e 100644 --- a/src/containers/Storage/PaginatedStorage.tsx +++ b/src/containers/Storage/PaginatedStorage.tsx @@ -1,162 +1,22 @@ -import {StringParam, useQueryParams} from 'use-query-params'; +import {PaginatedStorageGroups} from './PaginatedStorageGroups'; +import {PaginatedStorageNodes} from './PaginatedStorageNodes'; +import {useStorageQueryParams} from './useStorageQueryParams'; -import {AccessDenied} from '../../components/Errors/403/AccessDenied'; -import {ResponseError} from '../../components/Errors/ResponseError/ResponseError'; -import type {RenderControls, RenderErrorMessage} from '../../components/PaginatedTable'; -import {useClusterBaseInfo} from '../../store/reducers/cluster/cluster'; -import {VISIBLE_ENTITIES} from '../../store/reducers/storage/constants'; -import {storageTypeSchema, visibleEntitiesSchema} from '../../store/reducers/storage/types'; -import type {StorageType, VisibleEntities} from '../../store/reducers/storage/types'; -import {NodesUptimeFilterValues, nodesUptimeFilterValuesSchema} from '../../utils/nodes'; -import {useAdditionalNodeProps} from '../AppWithClusters/useClusterData'; - -import {StorageControls} from './StorageControls/StorageControls'; -import {PaginatedStorageGroups} from './StorageGroups/PaginatedStorageGroups'; -import {useStorageGroupsSelectedColumns} from './StorageGroups/columns/hooks'; -import {PaginatedStorageNodes} from './StorageNodes/PaginatedStorageNodes'; -import {useStorageNodesSelectedColumns} from './StorageNodes/columns/hooks'; - -interface PaginatedStorageProps { +export interface PaginatedStorageProps { database?: string; nodeId?: string; groupId?: string; parentContainer?: Element | null; } -export const PaginatedStorage = ({ - database, - nodeId, - groupId, - parentContainer, -}: PaginatedStorageProps) => { - const {balancer} = useClusterBaseInfo(); - const {additionalNodesProps} = useAdditionalNodeProps({balancer}); +export const PaginatedStorage = (props: PaginatedStorageProps) => { + const {storageType} = useStorageQueryParams(); - const [queryParams, setQueryParams] = useQueryParams({ - type: StringParam, - visible: StringParam, - search: StringParam, - uptimeFilter: StringParam, - }); - const storageType = storageTypeSchema.parse(queryParams.type); - const isGroups = storageType === 'groups'; const isNodes = storageType === 'nodes'; - const visibleEntities = visibleEntitiesSchema.parse(queryParams.visible); - const searchValue = queryParams.search ?? ''; - const nodesUptimeFilter = nodesUptimeFilterValuesSchema.parse(queryParams.uptimeFilter); - - const { - columnsToShow: storageNodesColumnsToShow, - columnsToSelect: storageNodesColumnsToSelect, - setColumns: setStorageNodesSelectedColumns, - } = useStorageNodesSelectedColumns({ - additionalNodesProps, - visibleEntities, - database, - groupId, - }); - - const { - columnsToShow: storageGroupsColumnsToShow, - columnsToSelect: storageGroupsColumnsToSelect, - setColumns: setStorageGroupsSelectedColumns, - } = useStorageGroupsSelectedColumns(visibleEntities); - - const handleTextFilterChange = (value: string) => { - setQueryParams({search: value || undefined}, 'replaceIn'); - }; - - const handleGroupVisibilityChange = (value: VisibleEntities) => { - setQueryParams({visible: value}, 'replaceIn'); - }; - - const handleStorageTypeChange = (value: StorageType) => { - setQueryParams({type: value}, 'replaceIn'); - }; - - const handleUptimeFilterChange = (value: NodesUptimeFilterValues) => { - setQueryParams({uptimeFilter: value}, 'replaceIn'); - }; - - const handleShowAllGroups = () => { - handleGroupVisibilityChange(VISIBLE_ENTITIES.all); - }; - - const handleShowAllNodes = () => { - setQueryParams( - { - visible: VISIBLE_ENTITIES.all, - uptimeFilter: NodesUptimeFilterValues.All, - }, - 'replaceIn', - ); - }; - - const renderControls: RenderControls = ({totalEntities, foundEntities, inited}) => { - const columnsToSelect = isGroups - ? storageGroupsColumnsToSelect - : storageNodesColumnsToSelect; - - const handleSelectedColumnsUpdate = isGroups - ? setStorageGroupsSelectedColumns - : setStorageNodesSelectedColumns; - - return ( - - ); - }; - - const renderErrorMessage: RenderErrorMessage = (error) => { - if (error.status === 403) { - return ; - } - - return ; - }; - if (isNodes) { - return ( - - ); + return ; } - return ( - - ); + return ; }; diff --git a/src/containers/Storage/PaginatedStorageGroups.tsx b/src/containers/Storage/PaginatedStorageGroups.tsx new file mode 100644 index 0000000000..44f550faaf --- /dev/null +++ b/src/containers/Storage/PaginatedStorageGroups.tsx @@ -0,0 +1,172 @@ +import React from 'react'; + +import {ResponseError} from '../../components/Errors/ResponseError/ResponseError'; +import {LoaderWrapper} from '../../components/LoaderWrapper/LoaderWrapper'; +import type {RenderControls} from '../../components/PaginatedTable'; +import {TableWithControlsLayout} from '../../components/TableWithControlsLayout/TableWithControlsLayout'; +import { + useCapabilitiesLoaded, + useStorageGroupsHandlerHasGrouping, +} from '../../store/reducers/capabilities/hooks'; +import {storageApi} from '../../store/reducers/storage/storage'; +import {useAutoRefreshInterval} from '../../utils/hooks'; + +import type {PaginatedStorageProps} from './PaginatedStorage'; +import {StorageGroupsControls} from './StorageControls/StorageControls'; +import {PaginatedStorageGroupsTable} from './StorageGroups/PaginatedStorageGroupsTable'; +import {useStorageGroupsSelectedColumns} from './StorageGroups/columns/hooks'; +import {TableGroup} from './TableGroup/TableGroup'; +import {useExpandedGroups} from './TableGroup/useExpandedTableGroups'; +import i18n from './i18n'; +import {b, renderPaginatedTableErrorMessage} from './shared'; +import {useStorageQueryParams} from './useStorageQueryParams'; + +import './Storage.scss'; + +export function PaginatedStorageGroups(props: PaginatedStorageProps) { + const {storageGroupsGroupByParam, visibleEntities, handleShowAllGroups} = + useStorageQueryParams(); + + const capabilitiesLoaded = useCapabilitiesLoaded(); + const storageGroupsHandlerHasGroupping = useStorageGroupsHandlerHasGrouping(); + + // Other filters do not fit with grouping + // Reset them if grouping available + React.useEffect(() => { + if (storageGroupsHandlerHasGroupping && visibleEntities !== 'all') { + handleShowAllGroups(); + } + }, [handleShowAllGroups, storageGroupsHandlerHasGroupping, visibleEntities]); + + const renderContent = () => { + if (storageGroupsHandlerHasGroupping && storageGroupsGroupByParam) { + return ; + } + + return ; + }; + + return {renderContent()}; +} + +function StorageGroupsComponent({database, nodeId, parentContainer}: PaginatedStorageProps) { + const {searchValue, visibleEntities, handleShowAllGroups} = useStorageQueryParams(); + + const {columnsToShow, columnsToSelect, setColumns} = + useStorageGroupsSelectedColumns(visibleEntities); + + const renderControls: RenderControls = ({totalEntities, foundEntities, inited}) => { + return ( + + ); + }; + + return ( + + ); +} + +function GroupedStorageGroupsComponent({database, groupId, nodeId}: PaginatedStorageProps) { + const [autoRefreshInterval] = useAutoRefreshInterval(); + const {searchValue, storageGroupsGroupByParam, visibleEntities, handleShowAllGroups} = + useStorageQueryParams(); + + const {columnsToShow, columnsToSelect, setColumns} = + useStorageGroupsSelectedColumns(visibleEntities); + + const {currentData, isFetching, error} = storageApi.useGetStorageGroupsInfoQuery( + { + database, + with: 'all', + nodeId, + groupId, + filter: searchValue, + shouldUseGroupsHandler: true, + group: storageGroupsGroupByParam, + }, + { + pollingInterval: autoRefreshInterval, + }, + ); + + const isLoading = currentData === undefined && isFetching; + const {tableGroups, found = 0, total = 0} = currentData || {}; + + const {expandedGroups, setIsGroupExpanded} = useExpandedGroups(tableGroups); + + const renderControls = () => { + return ( + + ); + }; + + const renderGroups = () => { + if (tableGroups?.length) { + return tableGroups.map(({name, count}) => { + const isExpanded = expandedGroups[name]; + + return ( + + + + ); + }); + } + + return i18n('no-groups'); + }; + + return ( + + {renderControls()} + {error ? : null} + + {renderGroups()} + + + ); +} diff --git a/src/containers/Storage/PaginatedStorageNodes.tsx b/src/containers/Storage/PaginatedStorageNodes.tsx new file mode 100644 index 0000000000..63a545b984 --- /dev/null +++ b/src/containers/Storage/PaginatedStorageNodes.tsx @@ -0,0 +1,204 @@ +import React from 'react'; + +import {ResponseError} from '../../components/Errors/ResponseError'; +import {LoaderWrapper} from '../../components/LoaderWrapper/LoaderWrapper'; +import type {RenderControls} from '../../components/PaginatedTable'; +import {TableWithControlsLayout} from '../../components/TableWithControlsLayout/TableWithControlsLayout'; +import { + useCapabilitiesLoaded, + useViewerNodesHandlerHasGrouping, +} from '../../store/reducers/capabilities/hooks'; +import {useClusterBaseInfo} from '../../store/reducers/cluster/cluster'; +import {storageApi} from '../../store/reducers/storage/storage'; +import {valueIsDefined} from '../../utils'; +import {useAutoRefreshInterval} from '../../utils/hooks'; +import {NodesUptimeFilterValues} from '../../utils/nodes'; +import {useAdditionalNodeProps} from '../AppWithClusters/useClusterData'; + +import type {PaginatedStorageProps} from './PaginatedStorage'; +import {StorageNodesControls} from './StorageControls/StorageControls'; +import {PaginatedStorageNodesTable} from './StorageNodes/PaginatedStorageNodesTable'; +import {useStorageNodesSelectedColumns} from './StorageNodes/columns/hooks'; +import {TableGroup} from './TableGroup/TableGroup'; +import {useExpandedGroups} from './TableGroup/useExpandedTableGroups'; +import i18n from './i18n'; +import {b, renderPaginatedTableErrorMessage} from './shared'; +import {useStorageQueryParams} from './useStorageQueryParams'; + +import './Storage.scss'; + +export const PaginatedStorageNodes = (props: PaginatedStorageProps) => { + const {storageNodesGroupByParam, visibleEntities, nodesUptimeFilter, handleShowAllNodes} = + useStorageQueryParams(); + + const capabilitiesLoaded = useCapabilitiesLoaded(); + const viewerNodesHandlerHasGrouping = useViewerNodesHandlerHasGrouping(); + + // Other filters do not fit with grouping + // Reset them if grouping available + React.useEffect(() => { + if ( + viewerNodesHandlerHasGrouping && + visibleEntities !== 'all' && + nodesUptimeFilter !== NodesUptimeFilterValues.All + ) { + handleShowAllNodes(); + } + }, [handleShowAllNodes, nodesUptimeFilter, viewerNodesHandlerHasGrouping, visibleEntities]); + + const renderContent = () => { + if (viewerNodesHandlerHasGrouping && storageNodesGroupByParam) { + return ; + } + + return ; + }; + + return {renderContent()}; +}; + +function StorageNodesComponent({database, groupId, parentContainer}: PaginatedStorageProps) { + const {searchValue, visibleEntities, nodesUptimeFilter, handleShowAllNodes} = + useStorageQueryParams(); + + const {columnsToShow, columnsToSelect, setColumns} = useStorageNodesColumnsToSelect({ + database, + groupId, + }); + + const renderControls: RenderControls = ({totalEntities, foundEntities, inited}) => { + return ( + + ); + }; + + return ( + + ); +} + +function GroupedStorageNodesComponent({database, groupId, nodeId}: PaginatedStorageProps) { + const [autoRefreshInterval] = useAutoRefreshInterval(); + + const {searchValue, storageNodesGroupByParam, handleShowAllNodes} = useStorageQueryParams(); + + const {columnsToShow, columnsToSelect, setColumns} = useStorageNodesColumnsToSelect({ + database, + groupId, + }); + + const {currentData, isFetching, error} = storageApi.useGetStorageNodesInfoQuery( + { + database, + with: 'all', + filter: searchValue, + node_id: nodeId, + // node_id and group_id params don't work together + group_id: valueIsDefined(nodeId) ? undefined : groupId, + group: storageNodesGroupByParam, + }, + { + pollingInterval: autoRefreshInterval, + }, + ); + + const isLoading = currentData === undefined && isFetching; + const {tableGroups, found = 0, total = 0} = currentData || {}; + + const {expandedGroups, setIsGroupExpanded} = useExpandedGroups(tableGroups); + + const renderControls = () => { + return ( + + ); + }; + + const renderGroups = () => { + if (tableGroups?.length) { + return tableGroups.map(({name, count}) => { + const isExpanded = expandedGroups[name]; + + return ( + + + + ); + }); + } + + return i18n('no-nodes'); + }; + + return ( + + {renderControls()} + {error ? : null} + + {renderGroups()} + + + ); +} + +function useStorageNodesColumnsToSelect({ + database, + groupId, +}: { + database?: string; + groupId?: string; +}) { + const {balancer} = useClusterBaseInfo(); + const {additionalNodesProps} = useAdditionalNodeProps({balancer}); + const {visibleEntities} = useStorageQueryParams(); + + return useStorageNodesSelectedColumns({ + additionalNodesProps, + visibleEntities, + database, + groupId, + }); +} diff --git a/src/containers/Storage/Storage.scss b/src/containers/Storage/Storage.scss index f7c24d9edb..7dc3df8bc1 100644 --- a/src/containers/Storage/Storage.scss +++ b/src/containers/Storage/Storage.scss @@ -15,4 +15,8 @@ .entity-status { justify-content: center; } + + &__groups-wrapper { + padding-right: 20px; + } } diff --git a/src/containers/Storage/Storage.tsx b/src/containers/Storage/Storage.tsx index 501d7e6187..ea0fd16f67 100644 --- a/src/containers/Storage/Storage.tsx +++ b/src/containers/Storage/Storage.tsx @@ -1,7 +1,5 @@ import React from 'react'; -import {StringParam, useQueryParams} from 'use-query-params'; - import {AccessDenied} from '../../components/Errors/403'; import {isAccessError} from '../../components/Errors/PageError/PageError'; import {ResponseError} from '../../components/Errors/ResponseError'; @@ -12,26 +10,20 @@ import { } from '../../store/reducers/capabilities/hooks'; import {useClusterBaseInfo} from '../../store/reducers/cluster/cluster'; import type {NodesSortParams} from '../../store/reducers/nodes/types'; -import {VISIBLE_ENTITIES} from '../../store/reducers/storage/constants'; import {filterGroups, filterNodes} from '../../store/reducers/storage/selectors'; import {storageApi} from '../../store/reducers/storage/storage'; -import {storageTypeSchema, visibleEntitiesSchema} from '../../store/reducers/storage/types'; -import type { - StorageSortParams, - StorageType, - VisibleEntities, -} from '../../store/reducers/storage/types'; +import type {StorageSortParams} from '../../store/reducers/storage/types'; import {valueIsDefined} from '../../utils'; import {useAutoRefreshInterval, useTableSort} from '../../utils/hooks'; -import {NodesUptimeFilterValues, nodesUptimeFilterValuesSchema} from '../../utils/nodes'; import {useAdditionalNodeProps} from '../AppWithClusters/useClusterData'; -import {StorageControls} from './StorageControls/StorageControls'; +import {StorageGroupsControls, StorageNodesControls} from './StorageControls/StorageControls'; import {StorageGroupsTable} from './StorageGroups/StorageGroupsTable'; import {useStorageGroupsSelectedColumns} from './StorageGroups/columns/hooks'; import {StorageNodesTable} from './StorageNodes/StorageNodesTable'; import {useStorageNodesSelectedColumns} from './StorageNodes/columns/hooks'; import {b} from './shared'; +import {useStorageQueryParams} from './useStorageQueryParams'; import {defaultSortNode, getDefaultSortGroup} from './utils'; import './Storage.scss'; @@ -47,24 +39,23 @@ export const Storage = ({database, nodeId, groupId, pDiskId}: StorageProps) => { const {balancer} = useClusterBaseInfo(); const {additionalNodesProps} = useAdditionalNodeProps({balancer}); + const { + storageType, + searchValue, + visibleEntities, + nodesUptimeFilter, + + handleShowAllGroups, + handleShowAllNodes, + } = useStorageQueryParams(); + const capabilitiesLoaded = useCapabilitiesLoaded(); const groupsHandlerAvailable = useStorageGroupsHandlerAvailable(); const [autoRefreshInterval] = useAutoRefreshInterval(); - const [queryParams, setQueryParams] = useQueryParams({ - type: StringParam, - visible: StringParam, - search: StringParam, - uptimeFilter: StringParam, - }); - const storageType = storageTypeSchema.parse(queryParams.type); const isGroups = storageType === 'groups'; const isNodes = storageType === 'nodes'; - const visibleEntities = visibleEntitiesSchema.parse(queryParams.visible); - const filter = queryParams.search ?? ''; - const uptimeFilter = nodesUptimeFilterValuesSchema.parse(queryParams.uptimeFilter); - const [nodeSort, setNodeSort] = React.useState({ sortOrder: undefined, sortValue: undefined, @@ -126,15 +117,20 @@ export const Storage = ({database, nodeId, groupId, pDiskId}: StorageProps) => { const {currentData: {nodes = []} = {}} = nodesQuery; const {currentData: {groups = []} = {}} = groupsQuery; - const {nodes: _, groups: __, ...entitiesCount} = currentData ?? {found: 0, total: 0}; + + const nodesTotalCount = nodesQuery.currentData?.total || 0; + const groupsTotalCount = groupsQuery.currentData?.total || 0; const isLoading = currentData === undefined && isFetching; const storageNodes = React.useMemo( - () => filterNodes(nodes, filter, uptimeFilter), - [filter, nodes, uptimeFilter], + () => filterNodes(nodes, searchValue, nodesUptimeFilter), + [nodes, nodesUptimeFilter, searchValue], + ); + const storageGroups = React.useMemo( + () => filterGroups(groups, searchValue), + [searchValue, groups], ); - const storageGroups = React.useMemo(() => filterGroups(groups, filter), [filter, groups]); const [nodesSort, handleNodesSort] = useTableSort(nodesSortParams, (params) => setNodeSort(params as NodesSortParams), @@ -143,27 +139,6 @@ export const Storage = ({database, nodeId, groupId, pDiskId}: StorageProps) => { setGroupSort(params as StorageSortParams), ); - const handleTextFilterChange = (value: string) => { - setQueryParams({search: value || undefined}, 'replaceIn'); - }; - - const handleGroupVisibilityChange = (value: VisibleEntities) => { - setQueryParams({visible: value}, 'replaceIn'); - }; - - const handleStorageTypeChange = (value: StorageType) => { - setQueryParams({type: value}, 'replaceIn'); - }; - - const handleUptimeFilterChange = (value: NodesUptimeFilterValues) => { - setQueryParams({uptimeFilter: value}, 'replaceIn'); - }; - - const handleShowAllNodes = () => { - handleGroupVisibilityChange(VISIBLE_ENTITIES.all); - handleUptimeFilterChange(NodesUptimeFilterValues.All); - }; - const renderDataTable = () => { return ( @@ -172,7 +147,7 @@ export const Storage = ({database, nodeId, groupId, pDiskId}: StorageProps) => { key="groups" visibleEntities={visibleEntities} data={storageGroups} - onShowAll={() => handleGroupVisibilityChange(VISIBLE_ENTITIES.all)} + onShowAll={handleShowAllGroups} sort={groupsSort} handleSort={handleGroupsSort} columns={storageGroupsColumnsToShow} @@ -182,7 +157,7 @@ export const Storage = ({database, nodeId, groupId, pDiskId}: StorageProps) => { { }; const renderControls = () => { - const entitiesCountCurrent = isGroups ? storageGroups.length : storageNodes.length; - - const columnsToSelect = isGroups - ? storageGroupsColumnsToSelect - : storageNodesColumnsToSelect; - - const handleSelectedColumnsUpdate = isGroups - ? setStorageGroupsSelectedColumns - : setStorageNodesSelectedColumns; - return ( - + + {isGroups ? ( + + ) : null} + {isNodes ? ( + + ) : null} + ); }; diff --git a/src/containers/Storage/StorageControls/StorageControls.tsx b/src/containers/Storage/StorageControls/StorageControls.tsx index bf50162859..1f7980bfee 100644 --- a/src/containers/Storage/StorageControls/StorageControls.tsx +++ b/src/containers/Storage/StorageControls/StorageControls.tsx @@ -1,32 +1,22 @@ import React from 'react'; import type {TableColumnSetupItem} from '@gravity-ui/uikit'; -import {TableColumnSetup} from '@gravity-ui/uikit'; +import {Select, TableColumnSetup, Text} from '@gravity-ui/uikit'; import {EntitiesCount} from '../../../components/EntitiesCount/EntitiesCount'; import {Search} from '../../../components/Search/Search'; import {UptimeFilter} from '../../../components/UptimeFIlter'; -import {STORAGE_TYPES} from '../../../store/reducers/storage/constants'; -import type {StorageType, VisibleEntities} from '../../../store/reducers/storage/types'; -import type {NodesUptimeFilterValues} from '../../../utils/nodes'; +import {STORAGE_GROUPS_GROUP_BY_OPTIONS} from '../StorageGroups/columns/constants'; +import {STORAGE_NODES_GROUP_BY_OPTIONS} from '../StorageNodes/columns/constants'; import {StorageTypeFilter} from '../StorageTypeFilter/StorageTypeFilter'; import {StorageVisibleEntitiesFilter} from '../StorageVisibleEntitiesFilter/StorageVisibleEntitiesFilter'; import i18n from '../i18n'; import {b} from '../shared'; +import {useStorageQueryParams} from '../useStorageQueryParams'; interface StorageControlsProps { - searchValue?: string; - handleSearchValueChange: (value: string) => void; - withTypeSelector?: boolean; - storageType: StorageType; - handleStorageTypeChange: (value: StorageType) => void; - - visibleEntities: VisibleEntities; - handleVisibleEntitiesChange: (value: VisibleEntities) => void; - - nodesUptimeFilter: NodesUptimeFilterValues; - handleNodesUptimeFilterChange: (value: NodesUptimeFilterValues) => void; + withGroupBySelect?: boolean; entitiesCountCurrent: number; entitiesCountTotal?: number; @@ -36,19 +26,9 @@ interface StorageControlsProps { handleSelectedColumnsUpdate: (updated: TableColumnSetupItem[]) => void; } -export const StorageControls = ({ - searchValue, - handleSearchValueChange, - +export function StorageGroupsControls({ withTypeSelector, - storageType, - handleStorageTypeChange, - - visibleEntities, - handleVisibleEntitiesChange, - - nodesUptimeFilter, - handleNodesUptimeFilterChange, + withGroupBySelect, entitiesCountCurrent, entitiesCountTotal, @@ -56,35 +36,121 @@ export const StorageControls = ({ columnsToSelect, handleSelectedColumnsUpdate, -}: StorageControlsProps) => { - const isNodes = storageType === STORAGE_TYPES.nodes; - const entityName = isNodes ? i18n('nodes') : i18n('groups'); +}: StorageControlsProps) { + const { + searchValue, + storageType, + visibleEntities, + storageGroupsGroupByParam, + handleTextFilterChange, + handleStorageTypeChange, + handleVisibleEntitiesChange, + handleStorageGroupsGroupByParamChange, + } = useStorageQueryParams(); + + const handleGroupBySelectUpdate = (value: string[]) => { + handleStorageGroupsGroupByParamChange(value[0]); + }; return ( {withTypeSelector && ( )} - + )} + + + {withGroupBySelect ? ( + + {i18n('controls_group-by-placeholder')} + + + ) : null} ); -}; +} diff --git a/src/containers/Storage/StorageGroups/PaginatedStorageGroups.tsx b/src/containers/Storage/StorageGroups/PaginatedStorageGroupsTable.tsx similarity index 79% rename from src/containers/Storage/StorageGroups/PaginatedStorageGroups.tsx rename to src/containers/Storage/StorageGroups/PaginatedStorageGroupsTable.tsx index f850411dd5..413bd5b086 100644 --- a/src/containers/Storage/StorageGroups/PaginatedStorageGroups.tsx +++ b/src/containers/Storage/StorageGroups/PaginatedStorageGroupsTable.tsx @@ -9,6 +9,7 @@ import { } from '../../../store/reducers/capabilities/hooks'; import {VISIBLE_ENTITIES} from '../../../store/reducers/storage/constants'; import type {VisibleEntities} from '../../../store/reducers/storage/types'; +import type {GroupsGroupByField} from '../../../types/api/storage'; import {StorageGroupsEmptyDataMessage} from './StorageGroupsEmptyDataMessage'; import {STORAGE_GROUPS_COLUMNS_WIDTH_LS_KEY} from './columns/constants'; @@ -16,40 +17,47 @@ import type {StorageGroupsColumn} from './columns/types'; import {useGroupsGetter} from './getGroups'; import i18n from './i18n'; -interface PaginatedStorageGroupsProps { +interface PaginatedStorageGroupsTableProps { columns: StorageGroupsColumn[]; - searchValue: string; - visibleEntities: VisibleEntities; database?: string; nodeId?: string; + filterGroup?: string; + filterGroupBy?: GroupsGroupByField; + + searchValue: string; + visibleEntities: VisibleEntities; onShowAll: VoidFunction; parentContainer?: Element | null; - renderControls: RenderControls; + renderControls?: RenderControls; renderErrorMessage: RenderErrorMessage; + initialEntitiesCount?: number; } -export const PaginatedStorageGroups = ({ +export const PaginatedStorageGroupsTable = ({ columns, - searchValue, - visibleEntities, database, nodeId, + filterGroup, + filterGroupBy, + searchValue, + visibleEntities, onShowAll, parentContainer, renderControls, renderErrorMessage, -}: PaginatedStorageGroupsProps) => { + initialEntitiesCount, +}: PaginatedStorageGroupsTableProps) => { const capabilitiesLoaded = useCapabilitiesLoaded(); const groupsHandlerAvailable = useStorageGroupsHandlerAvailable(); const fetchData = useGroupsGetter(groupsHandlerAvailable); const tableFilters = React.useMemo(() => { - return {searchValue, visibleEntities, database, nodeId}; - }, [searchValue, visibleEntities, database, nodeId]); + return {searchValue, visibleEntities, database, nodeId, filterGroup, filterGroupBy}; + }, [searchValue, visibleEntities, database, nodeId, filterGroup, filterGroupBy]); const renderEmptyDataMessage = () => { if (visibleEntities !== VISIBLE_ENTITIES.all) { @@ -72,6 +80,7 @@ export const PaginatedStorageGroups = ({ columns={columns} fetchData={fetchData} limit={50} + initialEntitiesCount={initialEntitiesCount} renderControls={renderControls} renderErrorMessage={renderErrorMessage} renderEmptyDataMessage={renderEmptyDataMessage} diff --git a/src/containers/Storage/StorageGroups/columns/constants.ts b/src/containers/Storage/StorageGroups/columns/constants.ts index 652322fe0e..ce29bc6ad7 100644 --- a/src/containers/Storage/StorageGroups/columns/constants.ts +++ b/src/containers/Storage/StorageGroups/columns/constants.ts @@ -1,3 +1,7 @@ +import type {SelectOption} from '@gravity-ui/uikit'; +import {z} from 'zod'; + +import type {GroupsGroupByField} from '../../../../types/api/storage'; import type {ValueOf} from '../../../../types/common'; import i18n from './i18n'; @@ -9,16 +13,21 @@ export const STORAGE_GROUPS_COLUMNS_IDS = { GroupId: 'GroupId', PoolName: 'PoolName', MediaType: 'MediaType', + Encryption: 'Encryption', Erasure: 'Erasure', Used: 'Used', Limit: 'Limit', Usage: 'Usage', + DiskSpaceUsage: 'DiskSpaceUsage', DiskSpace: 'DiskSpace', Read: 'Read', Write: 'Write', + Latency: 'Latency', VDisks: 'VDisks', VDisksPDisks: 'VDisksPDisks', + MissingDisks: 'MissingDisks', Degraded: 'Degraded', + State: 'State', } as const; type StorageGroupsColumnId = ValueOf; @@ -47,6 +56,9 @@ export const STORAGE_GROUPS_COLUMNS_TITLES = { get MediaType() { return i18n('type'); }, + get Encryption() { + return i18n('encryption'); + }, get Erasure() { return i18n('erasure'); }, @@ -62,6 +74,9 @@ export const STORAGE_GROUPS_COLUMNS_TITLES = { get Usage() { return i18n('usage'); }, + get DiskSpaceUsage() { + return i18n('disk-space-usage'); + }, get DiskSpace() { return i18n('space'); }, @@ -71,6 +86,9 @@ export const STORAGE_GROUPS_COLUMNS_TITLES = { get Write() { return i18n('write'); }, + get Latency() { + return i18n('latency'); + }, get VDisks() { return i18n('vdisks'); }, @@ -78,6 +96,39 @@ export const STORAGE_GROUPS_COLUMNS_TITLES = { return i18n('vdisks-pdisks'); }, get Degraded() { - return i18n('degraded'); + return i18n('missing-disks'); + }, + get MissingDisks() { + return i18n('missing-disks'); + }, + get State() { + return i18n('state'); }, } as const satisfies Record; + +const STORAGE_GROUPS_GROUP_BY_PARAMS = [ + 'PoolName', + 'MediaType', + 'Encryption', + 'Erasure', + 'Usage', + 'DiskSpaceUsage', + 'State', + 'MissingDisks', + 'Latency', +] as const satisfies GroupsGroupByField[]; + +export const STORAGE_GROUPS_GROUP_BY_OPTIONS: SelectOption[] = STORAGE_GROUPS_GROUP_BY_PARAMS.map( + (param) => { + return { + value: param, + content: STORAGE_GROUPS_COLUMNS_TITLES[param], + }; + }, +); + +export const storageGroupsGroupByParamSchema = z + .custom< + GroupsGroupByField | undefined + >((value) => STORAGE_GROUPS_GROUP_BY_PARAMS.includes(value)) + .catch(undefined); diff --git a/src/containers/Storage/StorageGroups/columns/i18n/en.json b/src/containers/Storage/StorageGroups/columns/i18n/en.json index c33a4b8a22..d1c3a8931c 100644 --- a/src/containers/Storage/StorageGroups/columns/i18n/en.json +++ b/src/containers/Storage/StorageGroups/columns/i18n/en.json @@ -1,15 +1,20 @@ { "pool-name": "Pool Name", "type": "Type", + "encryption": "Encryption", "erasure": "Erasure", "degraded": "Degraded", + "missing-disks": "Missing Disks", + "state": "State", "usage": "Usage", + "disk-space-usage": "Disk space usage", "group-id": "Group ID", "used": "Used", "limit": "Limit", "space": "Space", "read": "Read", "write": "Write", + "latency": "Latency", "vdisks": "VDisks", "vdisks-pdisks": "VDisks with PDisks" } diff --git a/src/containers/Storage/StorageGroups/getGroups.ts b/src/containers/Storage/StorageGroups/getGroups.ts index 443ae43ab3..50878e7866 100644 --- a/src/containers/Storage/StorageGroups/getGroups.ts +++ b/src/containers/Storage/StorageGroups/getGroups.ts @@ -9,10 +9,6 @@ import type { import {prepareSortValue} from '../../../utils/filters'; import {isSortableStorageProperty} from '../../../utils/storage'; -const getConcurrentId = (limit?: number, offset?: number) => { - return `getStorageGroups|offset${offset}|limit${limit}`; -}; - type GetStorageGroups = FetchData; export function useGroupsGetter(shouldUseGroupsHandler: boolean) { @@ -20,25 +16,25 @@ export function useGroupsGetter(shouldUseGroupsHandler: boolean) { async (params) => { const {limit, offset, sortParams, filters} = params; const {sortOrder, columnId} = sortParams ?? {}; - const {searchValue, visibleEntities, database, nodeId} = filters ?? {}; + const {searchValue, visibleEntities, database, nodeId, filterGroup, filterGroupBy} = + filters ?? {}; const sort = isSortableStorageProperty(columnId) ? prepareSortValue(columnId, sortOrder) : undefined; - const {groups, found, total} = await requestStorageData( - { - limit, - offset, - sort, - filter: searchValue, - with: visibleEntities, - database, - nodeId, - shouldUseGroupsHandler, - }, - {concurrentId: getConcurrentId(limit, offset)}, - ); + const {groups, found, total} = await requestStorageData({ + limit, + offset, + sort, + filter: searchValue, + with: visibleEntities, + database, + nodeId, + filter_group: filterGroup, + filter_group_by: filterGroupBy, + shouldUseGroupsHandler, + }); return { data: groups || [], diff --git a/src/containers/Storage/StorageNodes/PaginatedStorageNodes.tsx b/src/containers/Storage/StorageNodes/PaginatedStorageNodesTable.tsx similarity index 78% rename from src/containers/Storage/StorageNodes/PaginatedStorageNodes.tsx rename to src/containers/Storage/StorageNodes/PaginatedStorageNodesTable.tsx index f29827d417..585a441d68 100644 --- a/src/containers/Storage/StorageNodes/PaginatedStorageNodes.tsx +++ b/src/containers/Storage/StorageNodes/PaginatedStorageNodesTable.tsx @@ -4,6 +4,7 @@ import type {RenderControls, RenderErrorMessage} from '../../../components/Pagin import {ResizeablePaginatedTable} from '../../../components/PaginatedTable'; import {VISIBLE_ENTITIES} from '../../../store/reducers/storage/constants'; import type {VisibleEntities} from '../../../store/reducers/storage/types'; +import type {NodesGroupByField} from '../../../types/api/nodes'; import {NodesUptimeFilterValues} from '../../../utils/nodes'; import {StorageNodesEmptyDataMessage} from './StorageNodesEmptyDataMessage'; @@ -13,35 +14,49 @@ import {getStorageNodes} from './getNodes'; import i18n from './i18n'; import {getRowUnavailableClassName} from './shared'; -interface PaginatedStorageNodesProps { +interface PaginatedStorageNodesTableProps { columns: StorageNodesColumn[]; + database?: string; + + filterGroup?: string; + filterGroupBy?: NodesGroupByField; + searchValue: string; visibleEntities: VisibleEntities; nodesUptimeFilter: NodesUptimeFilterValues; - database?: string; - onShowAll: VoidFunction; parentContainer?: Element | null; - renderControls: RenderControls; + renderControls?: RenderControls; renderErrorMessage: RenderErrorMessage; + initialEntitiesCount?: number; } -export const PaginatedStorageNodes = ({ +export const PaginatedStorageNodesTable = ({ columns, + database, + filterGroup, + filterGroupBy, searchValue, visibleEntities, nodesUptimeFilter, - database, onShowAll, parentContainer, renderControls, renderErrorMessage, -}: PaginatedStorageNodesProps) => { + initialEntitiesCount, +}: PaginatedStorageNodesTableProps) => { const tableFilters = React.useMemo(() => { - return {searchValue, visibleEntities, nodesUptimeFilter, database}; - }, [searchValue, visibleEntities, nodesUptimeFilter, database]); + return { + searchValue, + visibleEntities, + nodesUptimeFilter, + database, + filterGroup, + filterGroupBy, + }; + }, [searchValue, visibleEntities, nodesUptimeFilter, database, filterGroup, filterGroupBy]); const renderEmptyDataMessage = () => { if ( @@ -68,6 +83,7 @@ export const PaginatedStorageNodes = ({ fetchData={getStorageNodes} rowHeight={51} limit={50} + initialEntitiesCount={initialEntitiesCount} renderControls={renderControls} renderErrorMessage={renderErrorMessage} renderEmptyDataMessage={renderEmptyDataMessage} diff --git a/src/containers/Storage/StorageNodes/columns/constants.ts b/src/containers/Storage/StorageNodes/columns/constants.ts index 787a62e10c..39763085b6 100644 --- a/src/containers/Storage/StorageNodes/columns/constants.ts +++ b/src/containers/Storage/StorageNodes/columns/constants.ts @@ -1,3 +1,7 @@ +import type {SelectOption} from '@gravity-ui/uikit'; +import {z} from 'zod'; + +import type {NodesGroupByField} from '../../../../types/api/nodes'; import type {ValueOf} from '../../../../types/common'; import i18n from './i18n'; @@ -10,9 +14,11 @@ export const STORAGE_NODES_COLUMNS_IDS = { Host: 'Host', DC: 'DC', Rack: 'Rack', + Version: 'Version', Uptime: 'Uptime', PDisks: 'PDisks', Missing: 'Missing', + DiskSpaceUsage: 'DiskSpaceUsage', } as const; type StorageNodesColumnId = ValueOf; @@ -42,6 +48,9 @@ export const STORAGE_NODES_COLUMNS_TITLES = { get Rack() { return i18n('rack'); }, + get Version() { + return i18n('version'); + }, get Uptime() { return i18n('uptime'); }, @@ -51,4 +60,30 @@ export const STORAGE_NODES_COLUMNS_TITLES = { get Missing() { return i18n('missing'); }, + get DiskSpaceUsage() { + return i18n('disk-space-usage'); + }, } as const satisfies Record; + +const STORAGE_NODES_GROUP_BY_PARAMS = [ + 'Host', + 'DC', + 'Rack', + 'Version', + 'Uptime', + 'Missing', + 'DiskSpaceUsage', +] as const satisfies NodesGroupByField[]; + +export const STORAGE_NODES_GROUP_BY_OPTIONS: SelectOption[] = STORAGE_NODES_GROUP_BY_PARAMS.map( + (param) => { + return { + value: param, + content: STORAGE_NODES_COLUMNS_TITLES[param], + }; + }, +); + +export const storageNodesGroupByParamSchema = z + .custom((value) => STORAGE_NODES_GROUP_BY_PARAMS.includes(value)) + .catch(undefined); diff --git a/src/containers/Storage/StorageNodes/columns/i18n/en.json b/src/containers/Storage/StorageNodes/columns/i18n/en.json index f73498831b..9817b28a29 100644 --- a/src/containers/Storage/StorageNodes/columns/i18n/en.json +++ b/src/containers/Storage/StorageNodes/columns/i18n/en.json @@ -3,7 +3,9 @@ "host": "Host", "dc": "DC", "rack": "Rack", + "version": "Version", "uptime": "Uptime", "missing": "Missing", + "disk-space-usage": "Disk space usage", "pdisks": "PDisks" } diff --git a/src/containers/Storage/StorageNodes/getNodes.ts b/src/containers/Storage/StorageNodes/getNodes.ts index 341e647ee3..45de5d66f7 100644 --- a/src/containers/Storage/StorageNodes/getNodes.ts +++ b/src/containers/Storage/StorageNodes/getNodes.ts @@ -8,37 +8,33 @@ import type {NodesRequestParams} from '../../../types/api/nodes'; import {prepareSortValue} from '../../../utils/filters'; import {getUptimeParamValue, isSortableNodesProperty} from '../../../utils/nodes'; -const getConcurrentId = (limit?: number, offset?: number) => { - return `getStorageNodes|offset${offset}|limit${limit}`; -}; - export const getStorageNodes: FetchData< PreparedStorageNode, PreparedStorageNodeFilters, Pick > = async (params) => { const {type = 'static', storage = true, limit, offset, sortParams, filters} = params; - const {searchValue, nodesUptimeFilter, visibleEntities, database} = filters ?? {}; + const {searchValue, nodesUptimeFilter, visibleEntities, database, filterGroup, filterGroupBy} = + filters ?? {}; const {sortOrder, columnId} = sortParams ?? {}; const sort = isSortableNodesProperty(columnId) ? prepareSortValue(columnId, sortOrder) : undefined; - const response = await window.api.getNodes( - { - type, - storage, - limit, - offset, - sort, - filter: searchValue, - uptime: getUptimeParamValue(nodesUptimeFilter), - with: visibleEntities, - database, - }, - {concurrentId: getConcurrentId(limit, offset)}, - ); + const response = await window.api.getNodes({ + type, + storage, + limit, + offset, + sort, + filter: searchValue, + uptime: getUptimeParamValue(nodesUptimeFilter), + with: visibleEntities, + database, + filter_group: filterGroup, + filter_group_by: filterGroupBy, + }); const preparedResponse = prepareStorageNodesResponse(response); return { data: preparedResponse.nodes || [], diff --git a/src/containers/Storage/TableGroup/TableGroup.scss b/src/containers/Storage/TableGroup/TableGroup.scss new file mode 100644 index 0000000000..94e6500dfa --- /dev/null +++ b/src/containers/Storage/TableGroup/TableGroup.scss @@ -0,0 +1,49 @@ +.ydb-table-group { + display: flex; + flex-direction: column; + + width: 100%; + margin-bottom: 20px; + + border: 1px solid var(--g-color-line-generic); + border-radius: var(--g-spacing-2); + + &__button { + padding: 8px 0; + + cursor: pointer; + + border: unset; + background: unset; + } + + &__title-wrapper { + position: sticky; + left: 0; + + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: var(--g-spacing-2); + + width: max-content; + padding-left: 20px; + } + + &__title { + display: flex; + flex-direction: row; + gap: var(--g-spacing-4); + } + + &__count { + display: flex; + flex-direction: row; + gap: var(--g-spacing-3); + } + + &__content { + padding: 12px 0 20px 20px; + } +} diff --git a/src/containers/Storage/TableGroup/TableGroup.tsx b/src/containers/Storage/TableGroup/TableGroup.tsx new file mode 100644 index 0000000000..56e248df8d --- /dev/null +++ b/src/containers/Storage/TableGroup/TableGroup.tsx @@ -0,0 +1,63 @@ +import React from 'react'; + +import {ArrowToggle, Label, Text} from '@gravity-ui/uikit'; + +import {cn} from '../../../utils/cn'; + +import './TableGroup.scss'; + +const b = cn('ydb-table-group'); + +interface TableGroupProps { + children?: React.ReactNode; + + title: string; + entityName: string; + count: string | number; + expanded: boolean; + onIsExpandedChange: (name: string, isExpanded: boolean) => void; +} + +export function TableGroup({ + children, + title, + entityName, + count, + expanded = false, + onIsExpandedChange, +}: TableGroupProps) { + const toggleCollapsed = () => { + onIsExpandedChange(title, !expanded); + }; + + const renderTitle = () => { + return ( + + ); + }; + + const renderContent = () => { + if (expanded) { + return
{children}
; + } + + return null; + }; + + return ( +
+ {renderTitle()} + {renderContent()} +
+ ); +} diff --git a/src/containers/Storage/TableGroup/useExpandedTableGroups.ts b/src/containers/Storage/TableGroup/useExpandedTableGroups.ts new file mode 100644 index 0000000000..13b20a8775 --- /dev/null +++ b/src/containers/Storage/TableGroup/useExpandedTableGroups.ts @@ -0,0 +1,34 @@ +import React from 'react'; + +import type {TableGroup} from '../../../store/reducers/storage/types'; + +export function useExpandedGroups(groups?: TableGroup[]) { + const [expandedGroups, setExpandedGroups] = React.useState>({}); + + React.useEffect(() => { + if (groups?.length) { + setExpandedGroups((previousExpandedGroups) => { + return groups.reduce((result, {name}) => { + const previousValue = previousExpandedGroups[name]; + + // Preserve previously expanded groups on groups list change + return { + ...result, + [name]: previousValue ?? false, + }; + }, {}); + }); + } + }, [groups]); + + const setIsGroupExpanded = React.useCallback((name: string, isExpanded: boolean) => { + setExpandedGroups((previousExpandedGroups) => { + return { + ...previousExpandedGroups, + [name]: isExpanded, + }; + }); + }, []); + + return {expandedGroups, setIsGroupExpanded}; +} diff --git a/src/containers/Storage/i18n/en.json b/src/containers/Storage/i18n/en.json index 747f2427e8..3ebb61d745 100644 --- a/src/containers/Storage/i18n/en.json +++ b/src/containers/Storage/i18n/en.json @@ -3,5 +3,9 @@ "nodes": "Nodes", "controls_groups-search-placeholder": "Group ID, Pool name", - "controls_nodes-search-placeholder": "Node ID, FQDN" + "controls_nodes-search-placeholder": "Node ID, FQDN", + "controls_group-by-placeholder": "Group by:", + + "no-nodes": "No such nodes", + "no-groups": "No such groups" } diff --git a/src/containers/Storage/i18n/index.ts b/src/containers/Storage/i18n/index.ts index c5605734c2..aba814350d 100644 --- a/src/containers/Storage/i18n/index.ts +++ b/src/containers/Storage/i18n/index.ts @@ -1,8 +1,7 @@ import {registerKeysets} from '../../../utils/i18n'; import en from './en.json'; -import ru from './ru.json'; const COMPONENT = 'ydb-storage'; -export default registerKeysets(COMPONENT, {ru, en}); +export default registerKeysets(COMPONENT, {en}); diff --git a/src/containers/Storage/i18n/ru.json b/src/containers/Storage/i18n/ru.json deleted file mode 100644 index 2fecf76a4c..0000000000 --- a/src/containers/Storage/i18n/ru.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "groups": "Группы", - "nodes": "Ноды", - - "controls_groups-search-placeholder": "ID группы, имя пула", - "controls_nodes-search-placeholder": "ID узла, FQDN" -} diff --git a/src/containers/Storage/shared.ts b/src/containers/Storage/shared.ts deleted file mode 100644 index be94080698..0000000000 --- a/src/containers/Storage/shared.ts +++ /dev/null @@ -1,3 +0,0 @@ -import {cn} from '../../utils/cn'; - -export const b = cn('global-storage'); diff --git a/src/containers/Storage/shared.tsx b/src/containers/Storage/shared.tsx new file mode 100644 index 0000000000..6c59dcfbe2 --- /dev/null +++ b/src/containers/Storage/shared.tsx @@ -0,0 +1,14 @@ +import {AccessDenied} from '../../components/Errors/403'; +import {ResponseError} from '../../components/Errors/ResponseError'; +import type {RenderErrorMessage} from '../../components/PaginatedTable'; +import {cn} from '../../utils/cn'; + +export const b = cn('global-storage'); + +export const renderPaginatedTableErrorMessage: RenderErrorMessage = (error) => { + if (error.status === 403) { + return ; + } + + return ; +}; diff --git a/src/containers/Storage/useStorageQueryParams.ts b/src/containers/Storage/useStorageQueryParams.ts new file mode 100644 index 0000000000..92e4e6e2c3 --- /dev/null +++ b/src/containers/Storage/useStorageQueryParams.ts @@ -0,0 +1,84 @@ +import {StringParam, useQueryParams} from 'use-query-params'; + +import type {StorageType, VisibleEntities} from '../../store/reducers/storage/types'; +import {storageTypeSchema, visibleEntitiesSchema} from '../../store/reducers/storage/types'; +import {NodesUptimeFilterValues, nodesUptimeFilterValuesSchema} from '../../utils/nodes'; + +import {storageGroupsGroupByParamSchema} from './StorageGroups/columns/constants'; +import {storageNodesGroupByParamSchema} from './StorageNodes/columns/constants'; + +export function useStorageQueryParams() { + const [queryParams, setQueryParams] = useQueryParams({ + type: StringParam, + visible: StringParam, + search: StringParam, + uptimeFilter: StringParam, + storageNodesGroupBy: StringParam, + storageGroupsGroupBy: StringParam, + }); + + const storageType = storageTypeSchema.parse(queryParams.type); + + const visibleEntities = visibleEntitiesSchema.parse(queryParams.visible); + const searchValue = queryParams.search ?? ''; + const nodesUptimeFilter = nodesUptimeFilterValuesSchema.parse(queryParams.uptimeFilter); + + const storageGroupsGroupByParam = storageGroupsGroupByParamSchema.parse( + queryParams.storageGroupsGroupBy, + ); + const storageNodesGroupByParam = storageNodesGroupByParamSchema.parse( + queryParams.storageNodesGroupBy, + ); + + const handleTextFilterChange = (value: string) => { + setQueryParams({search: value || undefined}, 'replaceIn'); + }; + + const handleVisibleEntitiesChange = (value: VisibleEntities) => { + setQueryParams({visible: value}, 'replaceIn'); + }; + + const handleStorageTypeChange = (value: StorageType) => { + setQueryParams({type: value}, 'replaceIn'); + }; + + const handleUptimeFilterChange = (value: NodesUptimeFilterValues) => { + setQueryParams({uptimeFilter: value}, 'replaceIn'); + }; + + const handleStorageGroupsGroupByParamChange = (value: string) => { + setQueryParams({storageGroupsGroupBy: value}, 'replaceIn'); + }; + const handleStorageNodesGroupByParamChange = (value: string) => { + setQueryParams({storageNodesGroupBy: value}, 'replaceIn'); + }; + + const handleShowAllGroups = () => { + handleVisibleEntitiesChange('all'); + }; + + const handleShowAllNodes = () => { + handleVisibleEntitiesChange('all'); + handleUptimeFilterChange(NodesUptimeFilterValues.All); + }; + + return { + storageType, + visibleEntities, + searchValue, + nodesUptimeFilter, + storageGroupsGroupByParam, + storageNodesGroupByParam, + + handleTextFilterChange, + handleVisibleEntitiesChange, + handleStorageTypeChange, + handleUptimeFilterChange, + + handleStorageGroupsGroupByParamChange, + handleStorageNodesGroupByParamChange, + + handleShowAllGroups, + handleShowAllNodes, + }; +} diff --git a/src/store/reducers/capabilities/hooks.ts b/src/store/reducers/capabilities/hooks.ts index 1b8245f34b..321d5a4137 100644 --- a/src/store/reducers/capabilities/hooks.ts +++ b/src/store/reducers/capabilities/hooks.ts @@ -31,6 +31,14 @@ export const useStorageGroupsHandlerAvailable = () => { return useGetFeatureVersion('/storage/groups') > 2; }; +export const useStorageGroupsHandlerHasGrouping = () => { + return useGetFeatureVersion('/storage/groups') > 4; +}; + +export const useViewerNodesHandlerHasGrouping = () => { + return useGetFeatureVersion('/viewer/nodes') > 6; +}; + export const useFeatureFlagsAvailable = () => { return useGetFeatureVersion('/viewer/feature_flags') > 1; }; diff --git a/src/store/reducers/storage/requestStorageData.ts b/src/store/reducers/storage/requestStorageData.ts index 3a98663a2a..5797733764 100644 --- a/src/store/reducers/storage/requestStorageData.ts +++ b/src/store/reducers/storage/requestStorageData.ts @@ -1,5 +1,5 @@ import type {AxiosOptions} from '../../../services/api'; -import type {StorageRequestParams} from '../../../types/api/storage'; +import type {GroupsRequestParams, StorageRequestParams} from '../../../types/api/storage'; import {prepareGroupsResponse, prepareStorageResponse} from './utils'; @@ -8,8 +8,8 @@ export async function requestStorageData( version = 'v2', shouldUseGroupsHandler, ...params - }: StorageRequestParams & {shouldUseGroupsHandler?: boolean}, - options: AxiosOptions, + }: StorageRequestParams & GroupsRequestParams & {shouldUseGroupsHandler?: boolean}, + options?: AxiosOptions, ) { if (shouldUseGroupsHandler && version !== 'v1') { const result = await window.api.getStorageGroups({...params}, options); diff --git a/src/store/reducers/storage/storage.ts b/src/store/reducers/storage/storage.ts index 40403db249..d3054474a3 100644 --- a/src/store/reducers/storage/storage.ts +++ b/src/store/reducers/storage/storage.ts @@ -1,5 +1,5 @@ import type {NodesRequestParams} from '../../../types/api/nodes'; -import type {StorageRequestParams} from '../../../types/api/storage'; +import type {GroupsRequestParams, StorageRequestParams} from '../../../types/api/storage'; import {api} from '../api'; import {requestStorageData} from './requestStorageData'; @@ -23,7 +23,8 @@ export const storageApi = api.injectEndpoints({ }), getStorageGroupsInfo: builder.query({ queryFn: async ( - params: StorageRequestParams & {shouldUseGroupsHandler?: boolean}, + params: StorageRequestParams & + GroupsRequestParams & {shouldUseGroupsHandler?: boolean}, {signal}, ) => { try { diff --git a/src/store/reducers/storage/types.ts b/src/store/reducers/storage/types.ts index 7574362036..41c72e4e6f 100644 --- a/src/store/reducers/storage/types.ts +++ b/src/store/reducers/storage/types.ts @@ -2,8 +2,8 @@ import type {OrderType} from '@gravity-ui/react-data-table'; import {z} from 'zod'; import type {EFlag} from '../../../types/api/enums'; -import type {TSystemStateInfo} from '../../../types/api/nodes'; -import type {StorageV2SortValue} from '../../../types/api/storage'; +import type {NodesGroupByField, TSystemStateInfo} from '../../../types/api/nodes'; +import type {GroupsGroupByField, StorageV2SortValue} from '../../../types/api/storage'; import type {PreparedPDisk, PreparedVDisk} from '../../../utils/disks/types'; import type {NodesUptimeFilterValues} from '../../../utils/nodes'; @@ -19,6 +19,8 @@ export interface PreparedStorageNodeFilters { nodesUptimeFilter: NodesUptimeFilterValues; visibleEntities: VisibleEntities; database?: string; + filterGroup?: string; + filterGroupBy?: NodesGroupByField; } export interface PreparedStorageNode extends TSystemStateInfo { @@ -38,6 +40,8 @@ export interface PreparedStorageGroupFilters { visibleEntities: VisibleEntities; database?: string; nodeId?: string; + filterGroup?: string; + filterGroupBy?: GroupsGroupByField; } export interface PreparedStorageGroup { @@ -86,9 +90,15 @@ export interface StorageSortParams { sortValue: StorageV2SortValue | undefined; } +export type TableGroup = { + name: string; + count: number; +}; + export interface PreparedStorageResponse { nodes?: PreparedStorageNode[]; groups?: PreparedStorageGroup[]; found: number | undefined; total: number | undefined; + tableGroups?: TableGroup[]; } diff --git a/src/store/reducers/storage/utils.ts b/src/store/reducers/storage/utils.ts index bc2348f2af..1bd9f000fe 100644 --- a/src/store/reducers/storage/utils.ts +++ b/src/store/reducers/storage/utils.ts @@ -16,7 +16,12 @@ import {preparePDiskData, prepareVDiskData} from '../../../utils/disks/prepareDi import {prepareNodeSystemState} from '../../../utils/nodes'; import {getUsage} from '../../../utils/storage'; -import type {PreparedStorageGroup, PreparedStorageNode, PreparedStorageResponse} from './types'; +import type { + PreparedStorageGroup, + PreparedStorageNode, + PreparedStorageResponse, + TableGroup, +} from './types'; // ==== Prepare groups ==== @@ -205,7 +210,17 @@ const prepareStorageNodeData = (node: TNodeInfo): PreparedStorageNode => { // ==== Prepare responses ==== export const prepareStorageNodesResponse = (data: TNodesInfo): PreparedStorageResponse => { - const {Nodes, TotalNodes, FoundNodes} = data; + const {Nodes, TotalNodes, FoundNodes, NodeGroups} = data; + + const tableGroups = NodeGroups?.map(({GroupName, NodeCount}) => { + if (GroupName && NodeCount) { + return { + name: GroupName, + count: Number(NodeCount), + }; + } + return undefined; + }).filter((group): group is TableGroup => Boolean(group)); const preparedNodes = Nodes?.map(prepareStorageNodeData); @@ -213,6 +228,7 @@ export const prepareStorageNodesResponse = (data: TNodesInfo): PreparedStorageRe nodes: preparedNodes, total: Number(TotalNodes) || preparedNodes?.length, found: Number(FoundNodes), + tableGroups, }; }; @@ -229,7 +245,7 @@ export const prepareStorageResponse = (data: TStorageInfo): PreparedStorageRespo }; export function prepareGroupsResponse(data: StorageGroupsResponse): PreparedStorageResponse { - const {FoundGroups, TotalGroups, StorageGroups = []} = data; + const {FoundGroups, TotalGroups, StorageGroups = [], StorageGroupGroups} = data; const preparedGroups: PreparedStorageGroup[] = StorageGroups.map((group) => { const {Usage, Read, Write, Used, Limit, MissingDisks, VDisks = [], Overall} = group; @@ -268,9 +284,20 @@ export function prepareGroupsResponse(data: StorageGroupsResponse): PreparedStor }; }); + const tableGroups = StorageGroupGroups?.map(({GroupName, GroupCount}) => { + if (GroupName && GroupCount) { + return { + name: GroupName, + count: Number(GroupCount), + }; + } + return undefined; + }).filter((group): group is TableGroup => Boolean(group)); + return { groups: preparedGroups, total: Number(TotalGroups) || preparedGroups.length, found: Number(FoundGroups), + tableGroups, }; } diff --git a/src/types/api/capabilities.ts b/src/types/api/capabilities.ts index a635a29018..36b829acba 100644 --- a/src/types/api/capabilities.ts +++ b/src/types/api/capabilities.ts @@ -11,4 +11,5 @@ export type Capability = | '/scheme/directory' | '/storage/groups' | '/viewer/query' - | '/viewer/feature_flags'; + | '/viewer/feature_flags' + | '/viewer/nodes'; diff --git a/src/types/api/nodes.ts b/src/types/api/nodes.ts index 54072c9860..664afb97b1 100644 --- a/src/types/api/nodes.ts +++ b/src/types/api/nodes.ts @@ -141,7 +141,7 @@ type NodesType = 'static' | 'dynamic' | 'any'; type NodesWithFilter = 'space' | 'missing' | 'all'; -type NodesGroupByField = +export type NodesGroupByField = | 'NodeId' | 'Host' | 'NodeName' @@ -153,7 +153,7 @@ type NodesGroupByField = | 'Uptime' | 'Version'; -type NodesRequiredField = +export type NodesRequiredField = | 'NodeId' | 'SystemState' | 'PDisks' diff --git a/src/types/api/storage.ts b/src/types/api/storage.ts index 2275534ffd..847c0e7c9c 100644 --- a/src/types/api/storage.ts +++ b/src/types/api/storage.ts @@ -119,10 +119,10 @@ export interface StorageGroupsResponse { FieldsAvailable?: number; FieldsRequired?: number; StorageGroups?: TGroupsStorageGroupInfo[]; - StorageGroupGroups?: StorageGroupGroups; + StorageGroupGroups?: StorageGroupGroups[]; } -interface StorageGroupGroups { +export interface StorageGroupGroups { GroupName?: string; GroupCount?: string; }