diff --git a/src/containers/Storage/Disks/Disks.scss b/src/containers/Storage/Disks/Disks.scss index 128c13d7f7..75c8f9d99b 100644 --- a/src/containers/Storage/Disks/Disks.scss +++ b/src/containers/Storage/Disks/Disks.scss @@ -26,7 +26,7 @@ } &__pdisk-item { - width: 80px; + min-width: 80px; } &__pdisk-progress-bar { --progress-bar-full-height: 20px; diff --git a/src/containers/Storage/PDisk/PDisk.scss b/src/containers/Storage/PDisk/PDisk.scss index b5654b1d70..d6aa887fe6 100644 --- a/src/containers/Storage/PDisk/PDisk.scss +++ b/src/containers/Storage/PDisk/PDisk.scss @@ -1,28 +1,42 @@ .pdisk-storage { + --pdisk-vdisk-width: 3px; + --pdisk-gap-width: 2px; + position: relative; - width: 120px; + display: flex; + flex-direction: column; + justify-content: flex-end; + + width: calc( + var(--pdisk-max-slots, 1) * var(--pdisk-vdisk-width) + (var(--pdisk-max-slots, 1) - 1) * + var(--pdisk-gap-width) + ); + min-width: 120px; &__content { position: relative; display: block; + flex: 1; - border-radius: 4px; // to match interactive area with disk shape + border-radius: 4px; } &__vdisks { display: flex; - // this breaks disks relative sizes, but disks rarely exceed one line - flex-wrap: wrap; - gap: 2px; + flex: 0 0 auto; + gap: var(--pdisk-gap-width); margin-bottom: 4px; + + white-space: nowrap; } &__vdisks-item { - flex-basis: 3px; - flex-shrink: 0; + flex: 0 0 var(--pdisk-vdisk-width); + + min-width: var(--pdisk-vdisk-width); .stack__layer { .data-table__row:hover & { diff --git a/src/containers/Storage/PDisk/PDisk.tsx b/src/containers/Storage/PDisk/PDisk.tsx index b37b5bbf40..e6cc14073b 100644 --- a/src/containers/Storage/PDisk/PDisk.tsx +++ b/src/containers/Storage/PDisk/PDisk.tsx @@ -16,6 +16,8 @@ import './PDisk.scss'; const b = cn('pdisk-storage'); +const PDISK_MAX_SLOTS_CSS_VAR = '--pdisk-max-slots'; + interface PDiskProps { data?: PreparedPDisk; vDisks?: PreparedVDisk[]; @@ -25,6 +27,7 @@ interface PDiskProps { className?: string; progressBarClassName?: string; viewContext?: StorageViewContext; + maximumSlotsPerDisk?: string; } export const PDisk = ({ @@ -36,6 +39,7 @@ export const PDisk = ({ className, progressBarClassName, viewContext, + maximumSlotsPerDisk, }: PDiskProps) => { const {NodeId, PDiskId} = data; const pDiskIdsDefined = valueIsDefined(NodeId) && valueIsDefined(PDiskId); @@ -73,7 +77,17 @@ export const PDisk = ({ } return ( -
+
{renderVDisks()} - +
); })} diff --git a/src/store/reducers/storage/__tests__/calculateMaximumSlotsPerDisk.test.ts b/src/store/reducers/storage/__tests__/calculateMaximumSlotsPerDisk.test.ts new file mode 100644 index 0000000000..0815f84fbf --- /dev/null +++ b/src/store/reducers/storage/__tests__/calculateMaximumSlotsPerDisk.test.ts @@ -0,0 +1,190 @@ +import type {TNodeInfo} from '../../../../types/api/nodes'; +import {TPDiskState} from '../../../../types/api/pdisk'; +import {EVDiskState} from '../../../../types/api/vdisk'; +import type {TVDiskID} from '../../../../types/api/vdisk'; +import {calculateMaximumSlotsPerDisk} from '../utils'; + +const createVDiskId = (id: number): TVDiskID => ({ + GroupID: id, + GroupGeneration: 1, + Ring: 1, + Domain: 1, + VDisk: id, +}); + +describe('calculateMaximumSlotsPerDisk', () => { + it('should return providedMaximumSlotsPerDisk when it is provided', () => { + const nodes: TNodeInfo[] = []; + const providedMaximumSlotsPerDisk = '5'; + + expect(calculateMaximumSlotsPerDisk(nodes, providedMaximumSlotsPerDisk)).toBe('5'); + }); + + it('should return "1" for empty nodes array', () => { + const nodes: TNodeInfo[] = []; + + expect(calculateMaximumSlotsPerDisk(nodes)).toBe('1'); + }); + + it('should return "1" for undefined nodes', () => { + expect(calculateMaximumSlotsPerDisk(undefined)).toBe('1'); + }); + + it('should return "1" for nodes without PDisks or VDisks', () => { + const nodes: TNodeInfo[] = [ + { + NodeId: 1, + SystemState: {}, + }, + ]; + + expect(calculateMaximumSlotsPerDisk(nodes)).toBe('1'); + }); + + it('should calculate maximum slots correctly for single node with one PDisk and multiple VDisks', () => { + const nodes: TNodeInfo[] = [ + { + NodeId: 1, + SystemState: {}, + PDisks: [ + { + PDiskId: 1, + State: TPDiskState.Normal, + }, + ], + VDisks: [ + { + VDiskId: createVDiskId(1), + PDiskId: 1, + VDiskState: EVDiskState.OK, + }, + { + VDiskId: createVDiskId(2), + PDiskId: 1, + VDiskState: EVDiskState.OK, + }, + ], + }, + ]; + + expect(calculateMaximumSlotsPerDisk(nodes)).toBe('2'); + }); + + it('should calculate maximum slots across multiple nodes', () => { + const nodes: TNodeInfo[] = [ + { + NodeId: 1, + SystemState: {}, + PDisks: [ + { + PDiskId: 1, + State: TPDiskState.Normal, + }, + ], + VDisks: [ + { + VDiskId: createVDiskId(1), + PDiskId: 1, + VDiskState: EVDiskState.OK, + }, + ], + }, + { + NodeId: 2, + SystemState: {}, + PDisks: [ + { + PDiskId: 2, + State: TPDiskState.Normal, + }, + ], + VDisks: [ + { + VDiskId: createVDiskId(2), + PDiskId: 2, + VDiskState: EVDiskState.OK, + }, + { + VDiskId: createVDiskId(3), + PDiskId: 2, + VDiskState: EVDiskState.OK, + }, + { + VDiskId: createVDiskId(4), + PDiskId: 2, + VDiskState: EVDiskState.OK, + }, + ], + }, + ]; + + expect(calculateMaximumSlotsPerDisk(nodes)).toBe('3'); + }); + + it('should handle nodes with multiple PDisks', () => { + const nodes: TNodeInfo[] = [ + { + NodeId: 1, + SystemState: {}, + PDisks: [ + { + PDiskId: 1, + State: TPDiskState.Normal, + }, + { + PDiskId: 2, + State: TPDiskState.Normal, + }, + ], + VDisks: [ + { + VDiskId: createVDiskId(1), + PDiskId: 1, + VDiskState: EVDiskState.OK, + }, + { + VDiskId: createVDiskId(2), + PDiskId: 1, + VDiskState: EVDiskState.OK, + }, + { + VDiskId: createVDiskId(3), + PDiskId: 2, + VDiskState: EVDiskState.OK, + }, + ], + }, + ]; + + expect(calculateMaximumSlotsPerDisk(nodes)).toBe('2'); + }); + + it('should ignore VDisks with non-matching PDiskId', () => { + const nodes: TNodeInfo[] = [ + { + NodeId: 1, + SystemState: {}, + PDisks: [ + { + PDiskId: 1, + State: TPDiskState.Normal, + }, + ], + VDisks: [ + { + VDiskId: createVDiskId(1), + PDiskId: 1, + VDiskState: EVDiskState.OK, + }, + { + VDiskId: createVDiskId(2), + PDiskId: 2, // Non-matching PDiskId + VDiskState: EVDiskState.OK, + }, + ], + }, + ]; + + expect(calculateMaximumSlotsPerDisk(nodes)).toBe('1'); + }); +}); diff --git a/src/store/reducers/storage/__test__/prepareGroupsDisks.test.ts b/src/store/reducers/storage/__tests__/prepareGroupsDisks.test.ts similarity index 100% rename from src/store/reducers/storage/__test__/prepareGroupsDisks.test.ts rename to src/store/reducers/storage/__tests__/prepareGroupsDisks.test.ts diff --git a/src/store/reducers/storage/types.ts b/src/store/reducers/storage/types.ts index e9378e6485..829405f28e 100644 --- a/src/store/reducers/storage/types.ts +++ b/src/store/reducers/storage/types.ts @@ -35,6 +35,7 @@ export interface PreparedStorageNode extends PreparedNodeSystemState { VDisks?: PreparedVDisk[]; Missing: number; + MaximumSlotsPerDisk: string; } export interface PreparedStorageGroupFilters { diff --git a/src/store/reducers/storage/utils.ts b/src/store/reducers/storage/utils.ts index 8605d2eae5..e759800b3d 100644 --- a/src/store/reducers/storage/utils.ts +++ b/src/store/reducers/storage/utils.ts @@ -188,7 +188,10 @@ const prepareStorageGroups = ( // ==== Prepare nodes ==== -const prepareStorageNodeData = (node: TNodeInfo): PreparedStorageNode => { +const prepareStorageNodeData = ( + node: TNodeInfo, + maximumSlotsPerDisk: string, +): PreparedStorageNode => { const missing = node.PDisks?.filter((pDisk) => { return pDisk.State !== TPDiskState.Normal; @@ -214,13 +217,36 @@ const prepareStorageNodeData = (node: TNodeInfo): PreparedStorageNode => { PDisks: pDisks, VDisks: vDisks, Missing: missing, + MaximumSlotsPerDisk: maximumSlotsPerDisk, }; }; +export const calculateMaximumSlotsPerDisk = ( + nodes: TNodeInfo[] | undefined, + providedMaximumSlotsPerDisk?: string, +) => { + if (providedMaximumSlotsPerDisk) { + return providedMaximumSlotsPerDisk; + } + + return String( + Math.max( + 1, + ...(nodes || []).flatMap((node) => + (node.PDisks || []).map( + (pDisk) => + (node.VDisks || []).filter((vDisk) => vDisk.PDiskId === pDisk.PDiskId) + .length || 0, + ), + ), + ), + ); +}; + // ==== Prepare responses ==== export const prepareStorageNodesResponse = (data: TNodesInfo): PreparedStorageResponse => { - const {Nodes, TotalNodes, FoundNodes, NodeGroups} = data; + const {Nodes, TotalNodes, FoundNodes, NodeGroups, MaximumSlotsPerDisk} = data; const tableGroups = NodeGroups?.map(({GroupName, NodeCount}) => { if (GroupName && NodeCount) { @@ -232,7 +258,8 @@ export const prepareStorageNodesResponse = (data: TNodesInfo): PreparedStorageRe return undefined; }).filter((group): group is TableGroup => Boolean(group)); - const preparedNodes = Nodes?.map(prepareStorageNodeData); + const maximumSlots = calculateMaximumSlotsPerDisk(Nodes, MaximumSlotsPerDisk); + const preparedNodes = Nodes?.map((node) => prepareStorageNodeData(node, maximumSlots)); return { nodes: preparedNodes, diff --git a/src/types/api/nodes.ts b/src/types/api/nodes.ts index af800716d2..716d974ab9 100644 --- a/src/types/api/nodes.ts +++ b/src/types/api/nodes.ts @@ -18,6 +18,8 @@ export interface TNodesInfo { TotalNodes: string; /** uint64 */ FoundNodes: string; + /** uint64 */ + MaximumSlotsPerDisk?: string; } export interface TNodeInfo {