From b6796e1aad7facb2760b94d368f110a6ffbd07d0 Mon Sep 17 00:00:00 2001 From: Sven-Ric Date: Thu, 15 Oct 2026 23:51:51 +0200 Subject: [PATCH] Filter GPU choices by chassis bay width and validate quantities --- src/derive/validate.test.ts | 18 +++++++++++++----- src/derive/validate.ts | 11 ++++++++++- src/model/catalog.test.ts | 18 ++++++++++++++---- src/model/catalog.ts | 25 ++++++++++++++++++++++--- src/views/plan/RackSection.tsx | 16 ++++++++++++++++ 5 files changed, 75 insertions(+), 13 deletions(-) diff --git a/src/derive/validate.test.ts b/src/derive/validate.test.ts index e02de5a..3c3c1ef 100644 --- a/src/derive/validate.test.ts +++ b/src/derive/validate.test.ts @@ -244,6 +244,7 @@ describe('validatePlan GPUs and vendor availability', () => { modelId: serverModelId, count: 8, uplink: '2x25G', + gpu: { modelId, perNode }, }, ] @@ -251,17 +252,24 @@ describe('validatePlan GPUs and vendor availability', () => { } it('accepts a GPU within the per-node limit', () => { - const issues = validatePlan(planWithGpu('gpu-rtx-6000-ada', 1, 'server-microcloud-x13')) + const issues = validatePlan(planWithGpu('gpu-rtx-6000-ada', 1, 'server-superserver-tn12')) expect(issues.filter((i) => i.message.includes('GPU'))).toEqual([]) }) - it('rejects more GPUs than a node accepts', () => { - const issues = validatePlan(planWithGpu('gpu-rtx-6000-ada', 2, 'server-microcloud-x13')) - expect(issues.some((i) => i.severity === 'error' && i.message.includes('1 GPU per node'))).toBe( - true, + it('rejects a GPU too wide for the node', () => { + const issues = validatePlan(planWithGpu('gpu-h100-pcie', 1, 'server-microcloud-x13')) + expect(issues.map((i) => i.message)).toContain( + 'H100 PCIe is a double-width GPU, but SYS-531MC-H8TNR takes single-width cards.', ) }) + it('rejects more GPUs than a node accepts', () => { + const issues = validatePlan(planWithGpu('gpu-rtx-6000-ada', 3, 'server-superserver-tn12')) + expect( + issues.some((i) => i.severity === 'error' && i.message.includes('2 GPUs per node')), + ).toBe(true) + }) + it('rejects GPUs on a server model that takes none', () => { const issues = validatePlan(planWithGpu('gpu-rtx-6000-ada', 1, 'server-bigtwin-x11')) expect(issues.some((i) => i.severity === 'error' && i.message.includes('takes no GPUs'))).toBe( diff --git a/src/derive/validate.ts b/src/derive/validate.ts index d6ac61c..9610cb7 100644 --- a/src/derive/validate.ts +++ b/src/derive/validate.ts @@ -1,4 +1,4 @@ -import { catalog, itemLabel, portCount, type SwitchRole } from '../model/catalog' +import { catalog, gpuFits, itemLabel, portCount, type SwitchRole } from '../model/catalog' import { mgmtDeviceCount, type Partition, @@ -242,6 +242,15 @@ function checkGpu(issues: Issue[], scope: Scope, group: ServerGroup): void { ) return } + if (server && !gpuFits(item, server)) { + report( + issues, + scope, + 'error', + `${itemLabel(item.id)} is a ${item.gpuWidth}-width GPU, but ` + + `${itemLabel(group.modelId)} takes ${server.gpuWidth}-width cards.`, + ) + } if (gpu.perNode > capacity) { report( issues, diff --git a/src/model/catalog.test.ts b/src/model/catalog.test.ts index efd7e8f..8665f46 100644 --- a/src/model/catalog.test.ts +++ b/src/model/catalog.test.ts @@ -149,12 +149,20 @@ describe('category invariants', () => { } }) - it('gives every GPU a power draw and a part number', () => { + it('gives every GPU a power draw, a part number and a slot width', () => { const gpus = entries.filter(([, i]) => i.category === 'gpu') expect(gpus.length).toBeGreaterThan(0) for (const [id, item] of gpus) { expect(item.powerWatts, `${id}.powerWatts`).toBeGreaterThan(0) expect(item.partNumber, `${id}.partNumber`).toBeDefined() + expect(item.gpuWidth, `${id}.gpuWidth`).toBeDefined() + } + }) + + it('gives every GPU-capable server a bay width', () => { + for (const [id, item] of entries) { + if (item.category !== 'server' || !item.gpuCapable) continue + expect(item.gpuWidth, `${id}.gpuWidth`).toBeDefined() } }) @@ -185,7 +193,7 @@ describe('reachability', () => { ...serversForUsage('worker'), ...serversForUsage('management'), ...serversForUsage('storage'), - ...gpusForServer('server-microcloud-x13'), + ...gpusForServer('server-superserver-tn12'), ].map((i) => i.id), ) @@ -295,8 +303,10 @@ describe('helpers', () => { } }) - it('offers GPUs only for GPU-capable servers', () => { - expect(gpusForServer('server-microcloud-x13').length).toBeGreaterThan(0) + it('offers GPUs only for GPU-capable servers, filtered by bay width', () => { + expect(gpusForServer('server-superserver-tn12').length).toBeGreaterThan(0) + // The MicroCloud bays are single-width; no cataloged GPU fits. + expect(gpusForServer('server-microcloud-x13')).toEqual([]) expect(gpusForServer('server-bigtwin-x11')).toEqual([]) expect(gpusForServer('nope')).toEqual([]) }) diff --git a/src/model/catalog.ts b/src/model/catalog.ts index c3e7a7a..512fdfd 100644 --- a/src/model/catalog.ts +++ b/src/model/catalog.ts @@ -67,6 +67,8 @@ export type ServerUsage = 'worker' | 'management' | 'storage' export type PortSpeed = '1G' | '10G' | '25G' | '100G' +export type GpuWidth = 'single' | 'double' + /** Platform tier a NOS support license is priced by. The vendors name the * tiers differently — Edgecore sells "40/100G" and "10/25G", Broadcom * "100G" and "10G" — so the license table maps this per NOS. */ @@ -105,6 +107,8 @@ export interface CatalogItem { serverUsages?: ServerUsage[] /** Max GPUs a single server node of this model accepts; absent = none. */ gpuCapable?: number + /** Card width on GPUs; maximum bay width on servers. */ + gpuWidth?: GpuWidth /** No plan can select this item, so it never reaches a BOM: it is kept * because the official compatibility list names it. Say why. Every * otherwise-unreachable entry must set this (catalog.test.ts), so that @@ -212,6 +216,7 @@ export const catalog: Record = { status: 'stable', serverUsages: ['worker'], gpuCapable: 1, + gpuWidth: 'single', }, 'server-microcloud-h13': { id: 'server-microcloud-h13', @@ -226,6 +231,7 @@ export const catalog: Record = { status: 'stable', serverUsages: ['worker'], gpuCapable: 1, + gpuWidth: 'single', }, 'server-bigtwin-x11': { id: 'server-bigtwin-x11', @@ -280,6 +286,8 @@ export const catalog: Record = { nodesPerChassis: 1, status: 'stable', serverUsages: ['worker', 'storage'], + gpuCapable: 2, + gpuWidth: 'double', }, 'server-superserver-tr12p': { id: 'server-superserver-tr12p', @@ -409,6 +417,7 @@ export const catalog: Record = { description: '48 GB GDDR6 ECC, PCIe 4.0 ×16, dual slot', powerWatts: 300, status: 'stable', + gpuWidth: 'double', }, 'gpu-h100-pcie': { id: 'gpu-h100-pcie', @@ -419,6 +428,7 @@ export const catalog: Record = { description: '80 GB HBM2e, PCIe 5.0 ×16, dual slot', powerWatts: 350, status: 'stable', + gpuWidth: 'double', }, // --- Transceivers --- @@ -646,11 +656,20 @@ export function serversForUsage(usage: ServerUsage): CatalogItem[] { .sort(byAvailability) } -/** GPU models offerable for a server model — empty unless it accepts any. */ +/** Whether a GPU card physically fits a server node's GPU bay: a + * double-width bay takes any card, a single-width bay only single-width + * cards. */ +export function gpuFits(gpu: CatalogItem, server: CatalogItem): boolean { + return server.gpuWidth === 'double' || gpu.gpuWidth === 'single' +} + +/** GPU models offerable for a server model — empty unless it accepts any, + * and only cards that fit its GPU bay width. */ export function gpusForServer(serverModelId: string): CatalogItem[] { - if (!catalog[serverModelId]?.gpuCapable) return [] + const server = catalog[serverModelId] + if (!server?.gpuCapable) return [] return Object.values(catalog) - .filter((i) => i.category === 'gpu') + .filter((i) => i.category === 'gpu' && gpuFits(i, server)) .sort(byAvailability) } diff --git a/src/views/plan/RackSection.tsx b/src/views/plan/RackSection.tsx index 71ee308..553513c 100644 --- a/src/views/plan/RackSection.tsx +++ b/src/views/plan/RackSection.tsx @@ -55,6 +55,12 @@ function ServerGroupRow({ value: i.id, label: optionLabel(i), })) + if (group.gpu && !gpuOptions.some((option) => option.value === group.gpu?.modelId)) { + gpuOptions.push({ + value: group.gpu.modelId, + label: `${itemLabel(group.gpu.modelId)} (incompatible)`, + }) + } const perNode = Math.min(group.gpu?.perNode ?? 1, catalog[group.modelId]?.gpuCapable ?? 1) return ( @@ -137,6 +143,16 @@ function ServerGroupRow({ onChange={(v) => patch({ gpu: v ? { modelId: v, perNode: perNode } : undefined })} /> )} + {group.gpu && (catalog[group.modelId]?.gpuCapable ?? 0) > 1 && ( + + group.gpu && patch({ gpu: { modelId: group.gpu.modelId, perNode: v || 1 } }) + } + /> + )}