From 3dbf8e91b40e58fc1b0ba916c49db781dda0fd30 Mon Sep 17 00:00:00 2001 From: Sven-Ric Date: Thu, 15 Oct 2026 23:57:37 +0200 Subject: [PATCH] Size redundant ORv3 power infrastructure from physical rack loads --- README.md | 31 +- src/derive/bom.ts | 85 +++++- src/derive/orv3.test.ts | 267 +++++++++++++++++- src/derive/orv3.ts | 45 +++ src/derive/rackLayout.ts | 32 ++- src/derive/servers.ts | 9 + src/derive/validate.ts | 14 +- src/model/catalog.ts | 79 ++++++ src/store/planStore.ts | 8 + src/views/BomView.tsx | 4 +- src/views/RackLayoutView.test.tsx | 36 +++ src/views/RackLayoutView.tsx | 12 +- src/views/icons/index.tsx | 3 + src/views/plan/RackSection.selection.test.tsx | 147 ++++++++++ src/views/plan/RackSection.test.tsx | 12 +- src/views/plan/RackSection.tsx | 25 +- src/views/plan/RackWidthField.tsx | 2 +- 17 files changed, 778 insertions(+), 33 deletions(-) create mode 100644 src/derive/orv3.ts create mode 100644 src/derive/servers.ts create mode 100644 src/views/RackLayoutView.test.tsx create mode 100644 src/views/plan/RackSection.selection.test.tsx diff --git a/README.md b/README.md index 27eadf5..5691ff9 100644 --- a/README.md +++ b/README.md @@ -47,15 +47,40 @@ consistent — you edit the plan, everything else follows from it. physical racks sharing the middle rack's leaf pair and management leaf. Every physical rack gets its own number by default (a group takes three), and names stay editable. - **Rack formats** — switch each rack or rack group between 19-inch EIA (U) and 21-inch - ORV3 (OU) under Advanced, consistently across all rack types. Partition defaults seed new racks and set the central rack format. Gigabyte + ORV3 (OU) under Advanced, consistently across all rack types. Selecting an ORV3 node automatically + changes its compute rack or rack group to ORV3. If that rack already has 19-inch servers selected, + a confirmation warns about the format change and additional mounting adapters in the BOM; + cancelling keeps the plan unchanged. Partition defaults seed new racks and set the central rack format. Gigabyte TO25-S12 and TO25-Z12 sleds use separately counted TO25-BT0 two-node trays, with one BMC link per sled. ORV3 racks also accept 19-inch equipment: the BOM estimates one mounting adapter kit for 1U chassis/devices and two for 2U and taller. Counts use physical chassis, including partially filled ones, rather than nodes. Choose shelves or rails to match each device's height, depth and load. Switching keeps existing selections; 21-inch nodes in 19-inch racks remain incompatible. Existing saved plans default to 19-inch racks. - ORV3 power shelves and bus bars are planned separately; enter usable rack height after - reserving space for them and any extra space required by the selected mounting hardware. TO25 power values are + The BOM includes one GIGABYTE DOA3-ST0 frame with its integrated 48V bus bar per physical + ORV3 rack, including all three members of a rack group, central racks and separate on-prem + control-plane racks. Delta shelves have six slots for 5.5 kW PSU modules and include a power + management controller. Each feed gets `ceil(estimated equipment watts / 5500)` PSU modules + for N+N redundancy, with `ceil(modules per feed / 6)` shelves per feed. The BOM lists shelves, + fitted PSU modules, blanking panels for empty slots and one complete AC input cable set per shelf + separately. For example, a 3 kW load gets two shelves with one 5.5 kW PSU each and ten blanks. + Diagrams show fitted capacity per shelf; the rack power budget remains a separate limit. The load uses + the rack elevation's server, GPU and switch estimates, including partial trays and each rack + group's actual equipment distribution. Empty racks get a frame but no power shelves. Changing + the rack power budget only changes validation, not shelf quantities. Shared rack + infrastructure belongs to the Production BOM scope. Prices for the previous fully populated + assembly are retained but do not apply to the new shelf and PSU lines. Supplier-specific component and cable + ordering codes remain blank pending confirmation. Battery backup is optional and excluded. + Hardware references: [GIGABYTE rack specifications](https://www.gigabyte.com/Enterprise/Data-Center/DOA3-ST0) + and [Delta power solutions](https://www.deltaww.com/en-US/products/power-solutions). + Rack diagrams show each power shelf as a 1OU slot labeled by feed A or B. The bus bar + remains included in the BOM but is hidden in the diagrams. Power shelves sit above switches + and servers as a schematic default. Used height + includes power shelves, so enter mounting height without subtracting shelf space manually. + The reference frame provides 43OU including shelves; validation flags greater mounting heights. + Allow separately for any extra space required by the selected mounting hardware. + Confirm bus bar capacity, shelf configuration, site feeds and AC distribution for 19-inch + devices with the supplier. TO25 power values are planning estimates. SP5 CPU/RAM parts for the Z12 and TO25 E1.S/boot-drive options are not yet modeled; the planner flags those omissions instead of adding incompatible default parts. - **CPU sockets and RAM** — modeled dual-socket servers offer a count selector for one or two identical CPUs, diff --git a/src/derive/bom.ts b/src/derive/bom.ts index 1beb642..73592e3 100644 --- a/src/derive/bom.ts +++ b/src/derive/bom.ts @@ -22,6 +22,11 @@ import { import { configBuckets, positionListLabel } from '../model/nodeConfig' import { resolveNodeCompute } from '../model/sizes' import { controlPlaneLeafCount, hasOwnRack, inCentralRack } from './controlPlane' +import { orv3Infrastructure } from './orv3' +import { deriveRackLayout, type RackElevation } from './rackLayout' +import { chassisCount } from './servers' + +export { chassisCount } from './servers' // The BOM is always derived from the Plan, never stored. Every quantity rule // lives here and gets a unit test in bom.test.ts. Every rule also records a @@ -55,6 +60,13 @@ import { controlPlaneLeafCount, hasOwnRack, inCentralRack } from './controlPlane // `nos` and the switch's `licenseClass` (catalog `nosLicenseId`). // - Mounting: height-based adapter estimates per 19-inch chassis/device in // an ORv3 rack. Adapters inherit the mounted device's network and location. +// - ORv3 infrastructure: one frame with bus bar per physical rack, populated +// power shelves, fitted PSU modules, slot blanks and input cable sets. N+N +// PSU modules on independent feeds cover the rack's +// estimated equipment load on either feed, using the same physical rack +// distribution and power estimates as the elevations. Empty racks need no +// shelves. Shared infrastructure is assigned +// to production so the two network scopes remain additive. // - Spares: a fixed number (Plan.sparesPerLine) of spares per transceiver // and cable line, added to the plan-wide BOM only (not per partition). @@ -206,12 +218,6 @@ class BomBuilder { } } -/** Server chassis needed for a group: nodes divided by nodes-per-chassis. */ -export function chassisCount(group: ServerGroup): number { - const nodesPerChassis = catalog[group.modelId]?.nodesPerChassis ?? 1 - return Math.ceil(group.count / nodesPerChassis) -} - /** BMC ports: per chassis by default, per sled on ORv3 platforms. */ export function rackBmcPorts(rack: Rack): number { return rack.servers.reduce( @@ -428,6 +434,50 @@ function addMgmtLinks(bom: BomBuilder, partition: Partition, central: BomBuilder /** Section the central-rack rules are reported under. */ const CENTRAL_RACK = 'Central rack' +function addOrv3Infrastructure(bom: BomBuilder, rack: RackElevation): void { + const infrastructure = orv3Infrastructure(rack) + if (!infrastructure) return + const { + frameId, + shelfId, + inputCableSetId, + shelvesPerFeed, + shelfCount, + psuId, + blankId, + psuSlots, + psuCapacityWatts, + psusPerFeed, + psuCount, + blankCount, + } = infrastructure + // Keep rack-group contributions together while identifying the physical + // member whose equipment determines this quantity. + const perRack = bom.on('production').at(rack.group?.name ?? rack.name, rack.widthInches) + perRack.add(frameId, 1, `${rack.name}: 1 physical ORV3 rack × 1 frame with bus bar`) + const shelves = shelfCount + perRack.add( + shelfId, + shelves, + `${rack.name}: ${shelvesPerFeed} shelves per feed × 2 feeds (N+N, ${psusPerFeed} PSUs per feed / ${psuSlots} slots per shelf, rounded up; controller included, PSUs ordered separately)`, + ) + perRack.add( + psuId, + psuCount, + `${rack.name}: ${psusPerFeed} PSUs per feed × 2 feeds (N+N, ceil(${rack.powerWatts} W estimated equipment load / ${psuCapacityWatts} W per PSU))`, + ) + perRack.add( + blankId, + blankCount, + `${rack.name}: ${shelves} shelves × ${psuSlots} PSU slots minus ${psuCount} fitted PSUs`, + ) + perRack.add( + inputCableSetId, + shelves, + `${rack.name}: ${shelves} power shelves × 1 complete AC input cable set`, + ) +} + /** The on-prem control-plane cluster: its nodes, their uplinks and, for a * control-plane rack of its own, the leaf pair and mgmt leaf that serve * them. A KaaS control plane orders nothing. Emitted inside the host @@ -498,8 +548,14 @@ function addControlPlane( } } -function addPartition(bom: BomBuilder, plan: Plan, partition: Partition): void { +function addPartition( + bom: BomBuilder, + plan: Plan, + partition: Partition, + racks: RackElevation[], +): void { const { fabric } = partition + for (const rack of racks) addOrv3Infrastructure(bom, rack) // Network and location are tagged once here, so every rule below inherits // the network it belongs to and the section it is reported under. const prodCentral = bom.on('production').at(CENTRAL_RACK, partition.rackDefaults.widthInches) @@ -604,8 +660,14 @@ function spareLines(lines: BomLine[], perLine: number): BomLine[] { export function deriveBom(plan: Plan, scope: BomScope = 'all'): BomLine[] { const multi = plan.partitions.length > 1 const bom = new BomBuilder(scope) - for (const partition of plan.partitions) { - addPartition(multi ? bom.inPartition(partition.name) : bom, plan, partition) + const layouts = deriveRackLayout(plan) + for (const [index, partition] of plan.partitions.entries()) { + addPartition( + multi ? bom.inPartition(partition.name) : bom, + plan, + partition, + layouts[index].racks, + ) } const lines = bom.build() return [...lines, ...spareLines(lines, plan.sparesPerLine)] @@ -616,9 +678,10 @@ export function deriveBomByPartition( plan: Plan, scope: BomScope = 'all', ): { partition: Partition; lines: BomLine[] }[] { - return plan.partitions.map((partition) => { + const layouts = deriveRackLayout(plan) + return plan.partitions.map((partition, index) => { const bom = new BomBuilder(scope) - addPartition(bom, plan, partition) + addPartition(bom, plan, partition, layouts[index].racks) return { partition, lines: bom.build() } }) } diff --git a/src/derive/orv3.test.ts b/src/derive/orv3.test.ts index 718a2d4..f86fe71 100644 --- a/src/derive/orv3.test.ts +++ b/src/derive/orv3.test.ts @@ -6,6 +6,7 @@ import { usePlanStore } from '../store/planStore' import { deriveBom, deriveBomByPartition, rackBmcPorts, type BomScope } from './bom' import { deriveRackLayout } from './rackLayout' import { validatePlan } from './validate' +import { orv3Infrastructure } from './orv3' function orv3Plan(count = 3) { const plan = createEmptyPlan() @@ -16,7 +17,267 @@ function orv3Plan(count = 3) { return plan } +describe('ORV3 rack and power BOM', () => { + const frameId = 'rack-gigabyte-doa3-st0' + const shelfId = 'power-orv3-shelf' + const psuId = 'power-orv3-psu-5500w' + const blankId = 'power-orv3-psu-blank' + const cableSetId = 'power-orv3-input-cable-set' + const ids = [frameId, shelfId, cableSetId] + const quantity = (plan: ReturnType, id: string) => + deriveBom(plan).find((line) => line.catalogId === id)?.quantity ?? 0 + + it('adds a frame with bus bar, redundant populated shelves and input sets per ORV3 rack', () => { + const plan = orv3Plan() + expect(ids.map((id) => quantity(plan, id))).toEqual([1, 2, 2]) + const shelves = deriveBom(plan).find((line) => line.catalogId === shelfId)! + expect(shelves.reasons).toHaveLength(1) + expect(shelves.reasons[0].where).toBe(plan.partitions[0].racks[0].name) + expect(shelves.reasons[0].detail).toContain('N+N') + expect(shelves.reasons[0].detail).toContain('controller included, PSUs ordered separately') + expect(deriveBom(plan).filter((line) => line.category === 'power')).toHaveLength(4) + expect(quantity(plan, psuId)).toBe(2) + expect(quantity(plan, blankId)).toBe(10) + expect(quantity(plan, 'power-orv3-shelf-33kw')).toBe(0) + expect(deriveBom(plan).some((line) => ids.some((id) => line.catalogId === `${id}#spare`))).toBe( + false, + ) + expect(ids.map((id) => quantity(createEmptyPlan(), id))).toEqual([0, 0, 0]) + }) + + it('scales with server load, including partial trays, independently of the rack budget', () => { + const plan = orv3Plan() + const rack = plan.partitions[0].racks[0] + rack.leafCount = 0 + plan.partitions[0].fabric.mgmt.leafPerRack = 0 + for (const [nodes, shelves] of [ + [0, 0], + [82, 2], // 32.8 kW + [83, 4], // 33.2 kW, including a half-populated tray + [165, 4], // Exactly 66 kW + [166, 6], + ]) { + rack.servers[0].count = nodes + expect(quantity(plan, shelfId)).toBe(shelves) + expect(quantity(plan, cableSetId)).toBe(shelves) + expect(quantity(plan, frameId)).toBe(1) + } + rack.maxPowerWatts = 100000 + expect(quantity(plan, shelfId)).toBe(6) + rack.servers[0].count = 3 + expect(quantity(plan, shelfId)).toBe(2) + }) + + it.each([ + [0, 0, 0, 0, []], + [5500, 2, 2, 10, [1]], + [5501, 2, 4, 8, [2]], + [33000, 2, 12, 0, [6]], + [33001, 4, 14, 10, [6, 1]], + ])( + 'fits PSU modules and blanks for %s W with enough capacity on either feed', + (powerWatts, shelves, psus, blanks, population) => { + const infrastructure = orv3Infrastructure({ widthInches: 21, powerWatts })! + expect(infrastructure.shelfCount).toBe(shelves) + expect(infrastructure.psuCount).toBe(psus) + expect(infrastructure.blankCount).toBe(blanks) + expect(infrastructure.psusPerShelf).toEqual(population) + expect(infrastructure.psusPerFeed * infrastructure.psuCapacityWatts).toBeGreaterThanOrEqual( + powerWatts, + ) + }, + ) + + it('changes fitted capacity and BOM PSU counts when server load changes', () => { + const plan = orv3Plan(12) // 4.8 kW of servers + 690 W of switches. + const rack = plan.partitions[0].racks[0] + const fitted = () => + deriveRackLayout(plan)[0].racks[1].slots.filter((slot) => slot.kind === 'power') + expect(quantity(plan, psuId)).toBe(2) + expect(quantity(plan, blankId)).toBe(10) + expect(fitted().map((slot) => slot.sublabel)).toEqual(['5.5 kW fitted', '5.5 kW fitted']) + rack.servers[0].count = 13 + expect(quantity(plan, psuId)).toBe(4) + expect(quantity(plan, blankId)).toBe(8) + expect(fitted().map((slot) => slot.sublabel)).toEqual(['11.0 kW fitted', '11.0 kW fitted']) + expect(fitted().every((slot) => slot.powerWatts === 0)).toBe(true) + expect(quantity(plan, shelfId)).toBe(2) + rack.maxPowerWatts = 100000 + expect(quantity(plan, psuId)).toBe(4) + rack.servers = [] + rack.leafCount = 0 + plan.partitions[0].fabric.mgmt.leafPerRack = 0 + for (const id of [shelfId, psuId, blankId, cableSetId]) expect(quantity(plan, id)).toBe(0) + }) + + it.each([ + [33000, 2], + [33001, 4], + ])('rounds up at the shelf capacity boundary: %s W', (powerWatts, shelfCount) => { + expect(orv3Infrastructure({ widthInches: 21, powerWatts })?.shelfCount).toBe(shelfCount) + }) + + it('sizes rack-group members individually and leaves empty frames without shelves', () => { + const plan = orv3Plan(0) + const partition = plan.partitions[0] + partition.racks[0] = withRackKind(partition, partition.racks[0], 'rack-group') + // Only the middle rack has equipment (shared switches). + expect(ids.map((id) => quantity(plan, id))).toEqual([3, 2, 2]) + partition.racks[0].servers[0].count = 246 + // 32.8 kW of servers in each member, plus 690 W of switches in the middle. + expect(ids.map((id) => quantity(plan, id))).toEqual([3, 8, 8]) + const shelves = deriveBom(plan).find((line) => line.catalogId === shelfId)! + expect(shelves.reasons.map((reason) => reason.quantity)).toEqual([2, 4, 2]) + expect(deriveBom(plan).find((line) => line.catalogId === psuId)!.reasons[1].detail).toContain( + '33490 W estimated equipment load', + ) + expect(deriveBom(plan).find((line) => line.catalogId === shelfId)?.reasons[0].where).toBe( + partition.racks[0].name, + ) + }) + + it('includes GPUs and per-position overrides in the equipment load', () => { + const plan = orv3Plan(44) + const group = plan.partitions[0].racks[0].servers[0] + expect(quantity(plan, shelfId)).toBe(2) + group.gpu = { modelId: 'gpu-h100-pcie', perNode: 1 } + // 17.6 kW servers + 15.4 kW GPUs + 690 W switches. + expect(quantity(plan, shelfId)).toBe(4) + group.nodeConfigs = { 0: { sizeId: group.sizeId } } + // Only half of the nodes now have a GPU. + expect(quantity(plan, shelfId)).toBe(2) + }) + + it('sizes central and separate control-plane shelves from their hosted equipment', () => { + const plan = createEmptyPlan() + plan.partitions[0].rackDefaults.widthInches = 21 + const cp = plan.controlPlane + cp.hosting = 'on-prem' + cp.nodeCount = 100 + cp.rack.widthInches = 21 + // The management server model draws 400 W per node. + expect(quantity(plan, shelfId)).toBe(4) + cp.placement = 'own-rack' + expect(quantity(plan, shelfId)).toBe(6) // Central pair plus four for the control plane. + cp.nodeCount = 3 + expect(quantity(plan, shelfId)).toBe(4) + cp.hosting = 'kaas' + expect(quantity(plan, shelfId)).toBe(2) + }) + + it('includes central and hosted control-plane racks once in their host partition', () => { + const plan = createEmptyPlan() + plan.partitions.push(defaultPartition('Partition 2')) + const host = plan.partitions[1] + host.rackDefaults.widthInches = 21 + plan.controlPlane.hosting = 'on-prem' + plan.controlPlane.partitionId = host.id + plan.controlPlane.rack.widthInches = 21 + // Hosted nodes share the central frame and power infrastructure. + expect(ids.map((id) => quantity(plan, id))).toEqual([1, 2, 2]) + plan.controlPlane.placement = 'own-rack' + expect(ids.map((id) => quantity(plan, id))).toEqual([2, 4, 4]) + const partitionBoms = deriveBomByPartition(plan) + expect(partitionBoms[0].lines.some((line) => ids.includes(line.catalogId))).toBe(false) + for (const id of ids) { + expect(partitionBoms[1].lines.find((line) => line.catalogId === id)?.quantity).toBe( + quantity(plan, id), + ) + } + expect( + deriveBom(plan) + .find((line) => line.catalogId === frameId) + ?.reasons.map((reason) => reason.where), + ).toEqual(['Partition 2 - Central rack', `Partition 2 - ${plan.controlPlane.rack.name}`]) + plan.controlPlane.hosting = 'kaas' + expect(ids.map((id) => quantity(plan, id))).toEqual([1, 2, 2]) + }) + + it('assigns shared infrastructure to production without duplicating it in management', () => { + const plan = orv3Plan() + plan.partitions[0].rackDefaults.widthInches = 21 + for (const id of ids) { + expect(deriveBom(plan, 'production').find((line) => line.catalogId === id)?.quantity).toBe( + quantity(plan, id), + ) + expect(deriveBom(plan, 'management').some((line) => line.catalogId === id)).toBe(false) + } + const all = deriveBom(plan).filter((line) => line.category !== 'spare') + for (const line of all) { + const split = (['production', 'management'] as const).reduce( + (sum, scope) => + sum + + (deriveBom(plan, scope).find((item) => item.catalogId === line.catalogId)?.quantity ?? 0), + 0, + ) + expect(split, line.catalogId).toBe(line.quantity) + } + }) + + it('includes shelf space in elevation and height validation', () => { + const plan = orv3Plan() + const rack = plan.partitions[0].racks[0] + rack.heightUnits = 9 + expect(deriveRackLayout(plan)[0].racks[1].usedU).toBe(9) + expect(validatePlan(plan).some((issue) => issue.message.includes('Rack height exceeded'))).toBe( + false, + ) + rack.heightUnits = 8 + expect(validatePlan(plan).some((issue) => issue.message.includes('9OU of 8OU'))).toBe(true) + rack.heightUnits = 43 + expect( + validatePlan(plan).some((issue) => issue.message.includes('exceeds the reference ORV3')), + ).toBe(false) + rack.heightUnits = 44 + expect( + validatePlan(plan).some((issue) => issue.message.includes('43OU mounting capacity')), + ).toBe(true) + rack.servers[0].count = 83 + expect( + validatePlan(plan).some((issue) => issue.message.includes('includes 4OU of power shelves')), + ).toBe(true) + expect(deriveRackLayout(plan)[0].racks[1].usedU).toBe(91) + }) +}) + describe('ORV3 racks', () => { + it('shows bus bars and exactly the BOM shelf count across all physical rack types', () => { + const plan = orv3Plan(246) + const partition = plan.partitions[0] + partition.rackDefaults.widthInches = 21 + partition.racks[0] = withRackKind(partition, partition.racks[0], 'rack-group') + plan.controlPlane.hosting = 'on-prem' + plan.controlPlane.placement = 'own-rack' + plan.controlPlane.rack.widthInches = 21 + const racks = deriveRackLayout(plan)[0].racks + expect(racks.map((rack) => rack.slots.filter((slot) => slot.kind === 'power').length)).toEqual([ + 2, 2, 4, 2, 2, + ]) + expect(racks.every((rack) => rack.busBar?.label.includes('bus bar'))).toBe(true) + const shelves = racks.flatMap((rack) => rack.slots.filter((slot) => slot.kind === 'power')) + expect(shelves).toHaveLength( + deriveBom(plan).find((line) => line.catalogId === 'power-orv3-shelf')!.quantity, + ) + expect(shelves.every((slot) => slot.units === 1 && slot.powerWatts === 0)).toBe(true) + expect(racks[2].powerWatts).toBe(33490) + expect( + racks[2].slots.filter((slot) => slot.kind === 'power').map((slot) => slot.label), + ).toEqual(['Power shelf A1', 'Power shelf A2', 'Power shelf B1', 'Power shelf B2']) + }) + + it('keeps bus bars visible in empty ORV3 racks and omits infrastructure from EIA racks', () => { + const plan = orv3Plan(0) + const partition = plan.partitions[0] + partition.racks[0].leafCount = 0 + partition.fabric.mgmt.leafPerRack = 0 + const [central, empty] = deriveRackLayout(plan)[0].racks + expect(empty.busBar).toBeDefined() + expect(empty.slots).toEqual([]) + expect(empty.usedU).toBe(0) + expect(central.busBar).toBeUndefined() + expect(central.slots.some((slot) => slot.kind === 'power')).toBe(false) + }) + it('orders sleds, partial trays, CPUs and BMC links independently', () => { const plan = orv3Plan() const rack = plan.partitions[0].racks[0] @@ -47,7 +308,7 @@ describe('ORV3 racks', () => { expect(rack.slots.filter((slot) => slot.kind === 'server').map((slot) => slot.units)).toEqual([ 2, 2, ]) - expect(rack.usedU).toBe(7) // 3 switch slots plus two 2OU trays + expect(rack.usedU).toBe(9) // 3 switch slots, two 2OU trays and two power shelves expect( rack.slots.filter((slot) => slot.kind === 'server').map((slot) => slot.powerWatts), ).toEqual([800, 400]) @@ -79,7 +340,7 @@ describe('ORV3 racks', () => { expect(rack.servers[0].modelId).toBe('server-gigabyte-to25-s12') rack.widthInches = 21 rack.heightUnits = 6 - expect(validatePlan(plan).some((issue) => issue.message.includes('7OU of 6OU'))).toBe(true) + expect(validatePlan(plan).some((issue) => issue.message.includes('9OU of 6OU'))).toBe(true) }) it('counts each sled against management port capacity', () => { @@ -172,7 +433,7 @@ describe('19-inch equipment in ORV3 racks', () => { false, ) const used = deriveRackLayout(plan)[0].racks[1].usedU - expect(used).toBe(13) // 3 switch slots, 4OU of ORV3 trays, 6OU of EIA chassis + expect(used).toBe(15) // 3 switch slots, 4OU of ORV3 trays, 6OU of EIA chassis, 2OU power rack.servers = rack.servers.filter((group) => group.id === 'eia') rack.widthInches = 19 expect(adapters(plan)).toBeUndefined() diff --git a/src/derive/orv3.ts b/src/derive/orv3.ts new file mode 100644 index 0000000..5a66746 --- /dev/null +++ b/src/derive/orv3.ts @@ -0,0 +1,45 @@ +import { catalog } from '../model/catalog' +import type { RackWidth } from '../model/plan' + +/** Shared sizing for the BOM, rack diagram and height validation. */ +export function orv3Infrastructure(rack: { widthInches: RackWidth; powerWatts: number }) { + if (rack.widthInches !== 21) return undefined + const frameId = 'rack-gigabyte-doa3-st0' + const shelfId = 'power-orv3-shelf' + const inputCableSetId = 'power-orv3-input-cable-set' + const shelf = catalog[shelfId] + const psuId = shelf.psuModelId! + const blankId = shelf.psuBlankModelId! + const psuSlots = shelf.psuSlots! + const psuCapacityWatts = catalog[psuId].powerCapacityWatts! + const capacityWatts = psuSlots * psuCapacityWatts + // Each feed can independently carry the estimated load. Populate only the + // modules needed, filling shelves in order; cover the remaining PSU slots. + const psusPerFeed = Math.ceil(rack.powerWatts / psuCapacityWatts) + const shelvesPerFeed = Math.ceil(psusPerFeed / psuSlots) + const psusPerShelf = Array.from({ length: shelvesPerFeed }, (_, i) => + Math.min(psuSlots, psusPerFeed - i * psuSlots), + ) + const shelfCount = 2 * shelvesPerFeed + const psuCount = 2 * psusPerFeed + const blankCount = shelfCount * psuSlots - psuCount + const reservedHeightUnits = shelfCount * catalog[shelfId].heightUnits! + return { + frameId, + shelfId, + inputCableSetId, + psuId, + blankId, + psuSlots, + psuCapacityWatts, + psusPerFeed, + psusPerShelf, + psuCount, + blankCount, + capacityWatts, + shelvesPerFeed, + shelfCount, + reservedHeightUnits, + usableHeightUnits: catalog[frameId].usableHeightUnits!, + } +} diff --git a/src/derive/rackLayout.ts b/src/derive/rackLayout.ts index 262b91b..6422c1d 100644 --- a/src/derive/rackLayout.ts +++ b/src/derive/rackLayout.ts @@ -8,15 +8,16 @@ import { type Rack, type RackWidth, } from '../model/plan' -import { chassisCount } from './bom' +import { chassisCount } from './servers' import { hasOwnRack, inCentralRack } from './controlPlane' +import { orv3Infrastructure } from './orv3' // Derives physical rack elevations (which device sits in which height // units) from the Plan. Like the BOM, the layout is always computed, never // stored. Devices fill each rack from the top: network gear first, then // server chassis — the classic ToR arrangement. -export type SlotKind = 'network' | 'mgmt' | 'server' | 'storage' | 'control-plane' +export type SlotKind = 'network' | 'mgmt' | 'server' | 'storage' | 'control-plane' | 'power' export interface RackSlot { label: string @@ -45,6 +46,8 @@ export interface RackElevation { maxPowerWatts: number /** Set on the three physical racks of a rack group. */ group?: { id: string; name: string } + /** Rear-mounted infrastructure, not shown in the diagram and consumes no OU. */ + busBar?: { label: string } } export interface PartitionRackLayout { @@ -190,6 +193,29 @@ function place( return { slots, usedU: offset, powerWatts: items.reduce((w, i) => w + i.powerWatts, 0) } } +/** Power shelves use mounting space but supply the existing equipment load; + * their output capacity must not be added to the rack's power consumption. + * Positions are schematic, with power shelves above switches and servers. */ +function withPowerInfrastructure(rack: RackElevation): RackElevation { + const infrastructure = orv3Infrastructure(rack) + if (!infrastructure) return rack + const { shelfId, psusPerShelf, psuCapacityWatts } = infrastructure + const shelves: Item[] = ['A', 'B'].flatMap((feed) => + psusPerShelf.map((psus, i) => ({ + label: `Power shelf ${feed}${i + 1}`, + sublabel: `${formatPower(psus * psuCapacityWatts)} fitted`, + units: catalog[shelfId].heightUnits!, + kind: 'power', + powerWatts: 0, + })), + ) + return { + ...rack, + busBar: { label: 'DC bus bar · rear' }, + ...place([...shelves, ...rack.slots], rack.heightUnits), + } +} + /** "1.6 kW" / "450 W" for rack headers. */ export function formatPower(watts: number): string { return watts >= 1000 ? `${(watts / 1000).toFixed(1)} kW` : `${watts} W` @@ -303,7 +329,7 @@ export function deriveRackLayout(plan: Plan): PartitionRackLayout[] { return { partitionId: partition.id, partitionName: partition.name, - racks: [central, ...racks, ...controlPlaneRacks], + racks: [central, ...racks, ...controlPlaneRacks].map(withPowerInfrastructure), } }) } diff --git a/src/derive/servers.ts b/src/derive/servers.ts new file mode 100644 index 0000000..a14802c --- /dev/null +++ b/src/derive/servers.ts @@ -0,0 +1,9 @@ +import { catalog } from '../model/catalog' +import type { ServerGroup } from '../model/plan' + +/** Server chassis needed for a group: nodes divided by nodes-per-chassis. + * Shared by the equipment layout and BOM without coupling their derivations. */ +export function chassisCount(group: ServerGroup): number { + const nodesPerChassis = catalog[group.modelId]?.nodesPerChassis ?? 1 + return Math.ceil(group.count / nodesPerChassis) +} diff --git a/src/derive/validate.ts b/src/derive/validate.ts index 42d8ae8..ad89375 100644 --- a/src/derive/validate.ts +++ b/src/derive/validate.ts @@ -35,6 +35,7 @@ import { deriveBom, mgmtUplinkSpeed, rackBmcPorts, spinePortsPerSpine } from './ import { controlPlaneLeafCount, controlPlaneSwitchPorts, inCentralRack } from './controlPlane' import { validateIpPlan } from './ip/validateIp' import { deriveRackLayout, formatPower } from './rackLayout' +import { orv3Infrastructure } from './orv3' // Validates a Plan against physical switch limits and the official // metal-stack hardware compatibility list (encoded in catalog.ts). @@ -708,13 +709,22 @@ export function validatePlan(plan: Plan): Issue[] { // Height, power and a group's member names are edited in the rack's // Advanced section; the central rack's budget is partition-level. const inAdvanced = { where, target: { ...target, ...(rack.rackId && { field: 'advanced' }) } } - if (rack.widthInches === 21) { + const infrastructure = orv3Infrastructure(rack) + if (infrastructure) { report( issues, inAdvanced, 'warning', - 'ORV3 power infrastructure is not included in the BOM or elevation: reserve space and budget for 48V DC bus bars and power shelves. The BOM includes mounting adapter kits for 19-inch equipment; reserve any extra space needed by the selected shelves or rails. Enter usable rack height after these reservations.', + `The BOM includes an ORV3 frame with bus bar, ${infrastructure.shelfCount} power shelves with ${infrastructure.psuCount} fitted PSU modules (N+N, ${infrastructure.psusPerFeed} per feed, sized to ${rack.powerWatts} W estimated equipment load), ${infrastructure.blankCount} slot blanks and their AC input cable sets for this physical rack. The diagram includes ${infrastructure.reservedHeightUnits}OU of power shelves in used height. Enter mounting height including power shelves; do not subtract shelf space manually. Confirm bus bar capacity, shelf configuration, site feeds and AC distribution for 19-inch devices with the supplier. Battery backup is optional and excluded. Shared rack infrastructure appears under Production in the BOM.`, ) + if (rack.heightUnits > infrastructure.usableHeightUnits) { + report( + issues, + inAdvanced, + 'warning', + `Configured usable height ${rack.heightUnits}OU exceeds the reference ORV3 frame's ${infrastructure.usableHeightUnits}OU mounting capacity. Reduce usable height or arrange a different rack configuration with the supplier.`, + ) + } } if (seen.has(rack.name)) { report( diff --git a/src/model/catalog.ts b/src/model/catalog.ts index 69afbba..c13719d 100644 --- a/src/model/catalog.ts +++ b/src/model/catalog.ts @@ -58,6 +58,8 @@ export type CatalogCategory = | 'switch' | 'server' | 'chassis' + | 'rack' + | 'power' | 'router' | 'nic' | 'gpu' @@ -139,6 +141,14 @@ export interface CatalogItem { * (1600 W / 2200 W per full chassis); the rest are datasheet-based * estimates. Partial chassis are scaled by their node count. */ powerWatts?: number + /** Rated output capacity of a power shelf or PSU, not its consumption. */ + powerCapacityWatts?: number + /** Modular power shelf components; its controller is included in the shelf. */ + psuSlots?: number + psuModelId?: string + psuBlankModelId?: string + /** Rack space available before power shelves and mounting reservations. */ + usableHeightUnits?: number ports?: { speed: PortSpeed; count: number }[] /** Board socket on servers, compatible socket on CPUs. */ socket?: CpuSocket @@ -517,6 +527,75 @@ export const catalog: Record = { 'EIA mounting kit for ORV3 (select shelf or rails for device height, depth and load)', rackWidthInches: 21, }, + // Rack and included bus bar: https://www.gigabyte.com/Enterprise/Data-Center/DOA3-ST0 + // 43 usable OU; the reinforced bar occupies the 23rd OU of the 44OU frame. + // The published ordering number has a configuration wildcard, so use the + // model until the supplier confirms an orderable configuration. + 'rack-gigabyte-doa3-st0': { + id: 'rack-gigabyte-doa3-st0', + category: 'rack', + vendor: 'GIGABYTE', + model: 'DOA3-ST0', + description: 'ORV3 rack frame, integrated 48V DC bus bar', + rackWidthInches: 21, + heightUnits: 44, + usableHeightUnits: 43, + }, + // Gigabyte lists Delta's 21-inch 33 kW shelf as an option for DOA3-ST0. + // Fully populated assembly: six 5.5 kW PSUs and a power management controller. + // https://www.deltaww.com/en-US/products/power-solutions + // https://www.gigabyte.com/FileUpload/Global/WebPage/1052/OCP-ORV3-Solution.pdf + // Quote as an assembly, not a bare shelf; no separate PSU/controller lines. + 'power-orv3-shelf-33kw': { + id: 'power-orv3-shelf-33kw', + category: 'power', + vendor: 'Delta', + model: '33 kW ORV3 power shelf assembly', + referenceOnly: 'Legacy fully populated assembly retained for existing price books', + description: '48V DC power shelf, 6× 5.5 kW PSUs, power management controller', + rackWidthInches: 21, + heightUnits: 1, + powerCapacityWatts: 33000, + }, + // Keep the fully populated assembly above for existing price books. The + // derived BOM now quotes the shelf/controller and fitted PSUs separately. + // Delta's 33 kW shelf has six 5.5 kW PSU slots: + // https://www.deltaww.com/en-US/products/power-solutions + 'power-orv3-shelf': { + id: 'power-orv3-shelf', + category: 'power', + vendor: 'Delta', + model: 'ORV3 power shelf with controller', + description: '48V DC distribution, six PSU slots (PSUs and slot blanks ordered separately)', + rackWidthInches: 21, + heightUnits: 1, + psuSlots: 6, + psuModelId: 'power-orv3-psu-5500w', + psuBlankModelId: 'power-orv3-psu-blank', + }, + 'power-orv3-psu-5500w': { + id: 'power-orv3-psu-5500w', + category: 'power', + vendor: 'Delta', + model: 'ORV3 5.5 kW PSU module', + description: 'Hot-pluggable AC to 48V DC rectifier (confirm shelf-compatible ordering code)', + powerCapacityWatts: 5500, + }, + // Empty PSU slots need airflow blanks: OCP power shelf specification §5.8. + // https://www.opencompute.org/documents/ocp-open-rack-v3-power-shelf-rev-1-0-1-pdf + 'power-orv3-psu-blank': { + id: 'power-orv3-psu-blank', + category: 'power', + model: 'ORV3 PSU slot blank', + description: 'Airflow blanking panel for an unpopulated rectifier bay', + }, + 'power-orv3-input-cable-set': { + id: 'power-orv3-input-cable-set', + category: 'power', + model: 'ORV3 power shelf input cable set', + description: 'Complete AC feed cable set per shelf (confirm site connectors and feed rating)', + rackWidthInches: 21, + }, // Management servers are not provisioned by metal-stack, so the // compatibility list does not apply and any current 1U dual-socket server // will do. The X11-generation 6019U is kept for plans that already name diff --git a/src/store/planStore.ts b/src/store/planStore.ts index 44e2aad..0b0bb96 100644 --- a/src/store/planStore.ts +++ b/src/store/planStore.ts @@ -2,6 +2,7 @@ import { create, useStore } from 'zustand' import { useShallow } from 'zustand/react/shallow' import { persist } from 'zustand/middleware' import { temporal } from 'zundo' +import { catalog } from '../model/catalog' import { createEmptyPlan, defaultPartition, newRack, withRackKind } from '../model/defaults' import { ipPresets, type IpFamily, type IpFamilyKey, type IpInfra } from '../model/ipPlan' import { migrateRawPlan, SCHEMA_VERSION } from '../model/migrate' @@ -233,6 +234,13 @@ export const usePlanStore = create()( set((s) => ({ plan: mapRack(s.plan, partitionId, rackId, (r) => ({ ...r, + // Keep the model and its required rack format in one undoable edit. + widthInches: + patch.modelId && + catalog[patch.modelId]?.rackWidthInches === 21 && + r.servers.some((g) => g.id === groupId) + ? 21 + : r.widthInches, servers: r.servers.map((g) => (g.id === groupId ? { ...g, ...patch } : g)), })), })), diff --git a/src/views/BomView.tsx b/src/views/BomView.tsx index c4ce1bd..3925019 100644 --- a/src/views/BomView.tsx +++ b/src/views/BomView.tsx @@ -33,6 +33,8 @@ const CATEGORY_LABEL = { switch: 'Switches', server: 'Servers', chassis: 'Chassis, trays and adapters', + rack: 'Racks and bus bars', + power: 'Power infrastructure', router: 'Routers', nic: 'NICs', gpu: 'GPUs', @@ -50,7 +52,7 @@ const scopes: { scope: BomScope; label: string; hint: string }[] = [ { scope: 'production', label: 'Production', - hint: 'Fabric switches, routers, servers and their optics and cables', + hint: 'Fabric switches, routers, servers, rack infrastructure and their optics and cables', }, { scope: 'management', diff --git a/src/views/RackLayoutView.test.tsx b/src/views/RackLayoutView.test.tsx new file mode 100644 index 0000000..b30f05d --- /dev/null +++ b/src/views/RackLayoutView.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' +import { createEmptyPlan } from '../model/defaults' +import { deriveRackLayout } from '../derive/rackLayout' +import { Rack } from './RackLayoutView' + +describe('rack infrastructure diagram', () => { + it('renders both shelf feeds and combined height usage while hiding the bus bar', () => { + const plan = createEmptyPlan() + plan.partitions[0].racks[0].widthInches = 21 + const rack = deriveRackLayout(plan)[0].racks[1] + const html = renderToStaticMarkup() + expect(html).toContain('Power shelf A1') + expect(html).toContain('Power shelf B1') + expect(html).toContain('5.5 kW fitted') + expect(html).not.toContain('33.0 kW') + expect(html).not.toContain('bus bar') + expect(html).toContain(`${rack.usedU} of 42 OU`) + }) + + it('hides the bus bar in empty racks and leaves EIA diagrams unchanged', () => { + const plan = createEmptyPlan() + const partition = plan.partitions[0] + partition.racks[0].widthInches = 21 + partition.racks[0].servers = [] + partition.racks[0].leafCount = 0 + partition.fabric.mgmt.leafPerRack = 0 + const [central, empty] = deriveRackLayout(plan)[0].racks + const emptyHtml = renderToStaticMarkup() + expect(emptyHtml).not.toContain('bus bar') + expect(emptyHtml).not.toContain('Power shelf') + const eiaHtml = renderToStaticMarkup() + expect(eiaHtml).not.toContain('bus bar') + expect(eiaHtml).not.toContain('Power shelf') + }) +}) diff --git a/src/views/RackLayoutView.tsx b/src/views/RackLayoutView.tsx index 7d36158..b20c39e 100644 --- a/src/views/RackLayoutView.tsx +++ b/src/views/RackLayoutView.tsx @@ -26,6 +26,7 @@ const SLOT_STYLE: Record void }) { +export function Rack({ rack, onClick }: { rack: RackElevation; onClick?: () => void }) { const slotW = (SLOT_W * rack.widthInches) / 19 const unit = rackUnit(rack.widthInches) const overflowU = Math.max(0, rack.usedU - rack.heightUnits) @@ -331,6 +332,15 @@ export default function RackLayoutView() { Storage + {partitions.some((partition) => + partition.racks.some((rack) => rack.slots.some((slot) => slot.kind === 'power')), + ) && ( + + + + Power shelves (A/B feeds), schematic positions + + )} ) diff --git a/src/views/icons/index.tsx b/src/views/icons/index.tsx index 660d2e3..7762eda 100644 --- a/src/views/icons/index.tsx +++ b/src/views/icons/index.tsx @@ -138,6 +138,8 @@ export const CATEGORY_ICON: Record = { switch: NetworkSwitch, server: Server, chassis: Server, + rack: Rack, + power: Cable, router: Router, nic: CircuitBoard, gpu: Gpu, @@ -151,6 +153,7 @@ export const CATEGORY_ICON: Record = { } export const SLOT_ICON: Record = { + power: Cable, network: NetworkSwitch, mgmt: ServerCog, server: Server, diff --git a/src/views/plan/RackSection.selection.test.tsx b/src/views/plan/RackSection.selection.test.tsx new file mode 100644 index 0000000..ebca6a7 --- /dev/null +++ b/src/views/plan/RackSection.selection.test.tsx @@ -0,0 +1,147 @@ +import type { ComponentProps } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { deriveBom } from '../../derive/bom' +import { deriveRackLayout } from '../../derive/rackLayout' +import { createEmptyPlan, newRack, withRackKind } from '../../model/defaults' +import type { Plan } from '../../model/plan' +import { usePlanStore } from '../../store/planStore' +import type { SelectField } from './fields' +import RackSection from './RackSection' + +const { fields } = vi.hoisted(() => ({ fields: new Map void>() })) +vi.mock('./fields', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + SelectField: (props: ComponentProps) => { + fields.set(props.label, props.onChange) + return + }, + } +}) + +const orv3Model = 'server-gigabyte-to25-s12' +const confirm = vi.fn<(message: string) => boolean>() + +function renderRack() { + fields.clear() + const partition = usePlanStore.getState().plan.partitions[0] + renderToStaticMarkup() +} + +function loadPlan(plan: Plan) { + usePlanStore.getState().replacePlan(plan) + usePlanStore.temporal.getState().clear() + vi.advanceTimersByTime(1000) + renderRack() +} + +function selectModel(modelId = orv3Model) { + fields.get('Server model')!(modelId) +} + +describe('automatic ORV3 selection', () => { + beforeEach(() => { + vi.useFakeTimers() + confirm.mockReset().mockReturnValue(true) + vi.stubGlobal('window', { confirm }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it.each(['single', 'rack-group'] as const)( + 'confirms a %s conversion, retains other servers, and derives adapters and trays', + (kind) => { + const plan = createEmptyPlan() + const partition = plan.partitions[0] + const rack = withRackKind(partition, partition.racks[0], kind) + partition.racks[0] = rack + // Edit the second group, leaving the first 19-inch group in place. + rack.servers.push({ ...rack.servers[0], id: 'new-nodes', count: 3 }) + partition.racks.push(newRack(partition, 'single')) + loadPlan(plan) + selectModel() + + expect(confirm).toHaveBeenCalledOnce() + expect(confirm.mock.calls[0][0]).toContain('21″ ORV3') + expect(confirm.mock.calls[0][0]).toContain('mounting adapter kits') + expect(confirm.mock.calls[0][0]).toContain('BOM') + if (kind === 'rack-group') expect(confirm.mock.calls[0][0]).toContain('all three racks') + const updated = usePlanStore.getState().plan + const changedRack = updated.partitions[0].racks[0] + expect(changedRack.widthInches).toBe(21) + expect(changedRack.servers[0]).toEqual(rack.servers[0]) + expect(changedRack.servers[1].modelId).toBe(orv3Model) + expect(updated.partitions[0].racks[1]).toEqual(partition.racks[1]) + expect(updated.partitions[0].rackDefaults).toEqual(partition.rackDefaults) + const bom = deriveBom(updated) + expect(bom.find((line) => line.catalogId === 'rack-adapter-19-to-21')?.quantity).toBe(5) + expect(bom.find((line) => line.catalogId === 'chassis-gigabyte-to25-bt0')?.quantity).toBe(2) + expect( + deriveRackLayout(updated)[0] + .racks.slice(1, kind === 'single' ? 2 : 4) + .map((physical) => physical.widthInches), + ).toEqual(kind === 'single' ? [21] : [21, 21, 21]) + + usePlanStore.temporal.getState().undo() + expect(usePlanStore.getState().plan).toEqual(plan) + usePlanStore.temporal.getState().redo() + expect(usePlanStore.getState().plan).toEqual(updated) + }, + ) + + it('leaves the entire plan and undo history unchanged when cancelled', () => { + const plan = createEmptyPlan() + const group = plan.partitions[0].racks[0].servers[0] + group.compute = { cpuModelId: 'cpu-epyc-4124p' } + group.nodeConfigs = { 0: { sizeId: group.sizeId, compute: group.compute } } + loadPlan(plan) + confirm.mockReturnValue(false) + selectModel() + expect(confirm).toHaveBeenCalledOnce() + expect(usePlanStore.getState().plan).toBe(plan) + expect(usePlanStore.temporal.getState().pastStates).toHaveLength(0) + }) + + it('switches automatically without prompting when no 19-inch nodes are placed', () => { + const plan = createEmptyPlan() + plan.partitions[0].racks[0].servers[0].count = 0 + loadPlan(plan) + selectModel() + expect(confirm).not.toHaveBeenCalled() + expect(usePlanStore.getState().plan.partitions[0].racks[0].widthInches).toBe(21) + }) + + it('does not prompt again in an ORV3 rack or switch back when selecting EIA', () => { + const plan = createEmptyPlan() + plan.partitions[0].racks[0].widthInches = 21 + loadPlan(plan) + selectModel() + renderRack() + selectModel('server-microcloud-h13') + expect(confirm).not.toHaveBeenCalled() + expect(usePlanStore.getState().plan.partitions[0].racks[0].widthInches).toBe(21) + }) + + it('keeps EIA selections in a 19-inch rack without prompting', () => { + loadPlan(createEmptyPlan()) + selectModel('server-bigtwin-x12') + expect(confirm).not.toHaveBeenCalled() + expect(usePlanStore.getState().plan.partitions[0].racks[0].widthInches).toBe(19) + }) + + it('keeps an ORV3 model when changing between worker and storage roles', () => { + const plan = createEmptyPlan() + plan.partitions[0].racks[0].servers[0].modelId = orv3Model + loadPlan(plan) + fields.get('Role')!('storage') + expect(usePlanStore.getState().plan.partitions[0].racks[0].servers[0]).toMatchObject({ + modelId: orv3Model, + role: 'storage', + }) + }) +}) diff --git a/src/views/plan/RackSection.test.tsx b/src/views/plan/RackSection.test.tsx index 9519545..24ea6a8 100644 --- a/src/views/plan/RackSection.test.tsx +++ b/src/views/plan/RackSection.test.tsx @@ -123,7 +123,7 @@ describe('rack format controls', () => { .sort(), ) plan.partitions[0].racks[0].widthInches = 19 - expect(renderImportedRack(plan).html).not.toContain('') + expect(renderImportedRack(plan).html).toContain('') }) it('does not offer an adapter-count override for ORV3 racks', () => { @@ -135,9 +135,9 @@ describe('rack format controls', () => { expect(html).not.toContain('Use automatic') }) - it('shows ORV3 choices, OU height, and preserves incompatible selections', () => { + it('offers ORV3 choices in either format and shows OU height for ORV3 racks', () => { const plan = createEmptyPlan() - expect(renderImportedRack(plan).html).not.toContain('TO25-S12-AA01') + expect(renderImportedRack(plan).html).toContain('TO25-S12-AA01') const rack = plan.partitions[0].racks[0] rack.widthInches = 21 const { html } = renderImportedRack(plan) @@ -148,6 +148,10 @@ describe('rack format controls', () => { expect(html).not.toContain('AS-3015MR-H8TNR (incompatible)') rack.servers[0].modelId = 'server-gigabyte-to25-s12' rack.widthInches = 19 - expect(renderImportedRack(plan).html).toContain('TO25-S12-AA01 (incompatible)') + const imported = renderImportedRack(plan) + expect(imported.html).toContain( + '', + ) + expect(imported.messages.some((message) => message.includes('requires a 21″ rack'))).toBe(true) }) }) diff --git a/src/views/plan/RackSection.tsx b/src/views/plan/RackSection.tsx index 9afe11b..f82c4f0 100644 --- a/src/views/plan/RackSection.tsx +++ b/src/views/plan/RackSection.tsx @@ -83,7 +83,24 @@ function ServerGroupRow({ }) { const patchServerGroup = usePlanStore((s) => s.patchServerGroup) const removeServerGroup = usePlanStore((s) => s.removeServerGroup) - const patch = (p: Partial) => patchServerGroup(partition.id, rack.id, group.id, p) + const patch = (p: Partial) => { + const switchesToOrv3 = + rack.widthInches !== 21 && + p.modelId !== undefined && + catalog[p.modelId]?.rackWidthInches === 21 + if ( + switchesToOrv3 && + rack.servers.some( + (server) => server.count > 0 && (catalog[server.modelId]?.rackWidthInches ?? 19) === 19, + ) && + !window.confirm( + `Selecting this ORV3 node will change ${rack.name}${rack.kind === 'rack-group' ? ' (all three racks)' : ''} to 21″ ORV3. Additional mounting adapter kits for 19″ equipment will be added to the BOM. Continue?`, + ) + ) { + return + } + patchServerGroup(partition.id, rack.id, group.id, p) + } const nodesPer = catalog[group.modelId]?.nodesPerChassis ?? 1 const multiNode = nodesPer > 1 @@ -237,7 +254,7 @@ function ServerGroupRow({ ]} onChange={(v) => { const role = v as ServerGroup['role'] - const compatible = serversForUsage(role, rack.widthInches) + const compatible = serversForUsage(role) patch( compatible.some((i) => i.id === group.modelId) ? { role } @@ -253,11 +270,11 @@ function ServerGroupRow({ patch({ modelId: v, diff --git a/src/views/plan/RackWidthField.tsx b/src/views/plan/RackWidthField.tsx index 9bc40e8..3d0b93d 100644 --- a/src/views/plan/RackWidthField.tsx +++ b/src/views/plan/RackWidthField.tsx @@ -17,7 +17,7 @@ export default function RackWidthField({ { value: '21', label: '21″ ORV3' }, ]} info={{ - text: 'Applies to every physical rack in a rack group. Height is measured in U for 19-inch racks and OU for 21-inch ORV3 racks. Switching format keeps your server selections and height count; review compatibility and usable height. 19-inch equipment is supported in ORV3 racks with height-based adapter estimates added to the BOM. The estimate is one kit for 1U devices and two for 2U and taller. Power shelves, bus bars and any additional space for the selected mounting hardware must be planned separately.', + text: 'Applies to every physical rack in a rack group. Height is measured in U for 19-inch racks and OU for 21-inch ORV3 racks. Switching format keeps your server selections and height count; review compatibility and usable height. The BOM includes a rack frame with bus bar and power shelves with PSU modules sized N+N to each physical rack’s estimated server, GPU and switch load, plus slot blanks and AC input cable sets. The diagram shows fitted PSU capacity per shelf; the rack power budget remains a separate limit. Empty racks need no power shelves. These shared items appear under Production. The rack diagram counts power shelves in used height. Enter mounting height including power shelves; do not subtract their space manually. 19-inch equipment needs mounting adapters: one kit for 1U devices and two for 2U and taller. Allow any extra mounting space and confirm site feeds and AC distribution with the supplier. Battery backup is optional and excluded.', }} onChange={(v) => onChange(v === '21' ? 21 : 19)} />