From 88fde480251deb588e5519ff633b8630a25e9e51 Mon Sep 17 00:00:00 2001 From: Markus Fensterer Date: Fri, 25 Sep 2026 11:39:00 +0200 Subject: [PATCH] Rename templates to PoC/Production, drop the topology variant, attach storage at the exits Three cleanups to the plan model and its editor: - The Starter template is really a proof of concept and Redundant is what a production install looks like, so they are now PoC and Production. - Plan.topology (single-zone | metro | multisite) was a label nothing derived from; its only effect was a warning restating the partition count. The field, its store action, the radio group and the warning are gone. Old plan files still import: PlanSchema strips the unknown key, so the format version stays at 1. - External networks of kind storage no longer enter through the internet routers. They attach at the partition's storage leaves, or at its exit switches when it has none (attachesAtStorageLeaves() is the single source of that rule, used by the derivation and by the diagram, which draws the capsule over the storage box). filterTopology() drops an external network whose attachment points all fell away, so central mode no longer floats a storage capsule. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 6 +-- README.md | 8 ++-- src/derive/bandwidth.test.ts | 4 +- src/derive/ip/ipPlan.test.ts | 4 +- src/derive/ip/validateIp.test.ts | 2 +- src/derive/topology.test.ts | 40 ++++++++++++++++- src/derive/topology.ts | 46 ++++++++++++++++---- src/derive/validate.ts | 8 ---- src/model/defaults.ts | 1 - src/model/plan.ts | 4 -- src/model/templates.test.ts | 11 +++-- src/model/templates.ts | 28 ++++++------ src/store/planStore.ts | 3 -- src/views/PlanView.tsx | 24 ----------- src/views/plan/ExternalNetworksSection.tsx | 11 +++-- src/views/topology/Diagram.tsx | 50 +++++++++++++++------- 16 files changed, 149 insertions(+), 101 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0c05ef2..ca3879b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,15 +37,15 @@ A client-only single-page app (React + Vite + TypeScript) to plan a [metal-stack ## Domain -- Topologies: `single-zone` | `metro` | `multisite`. A `Partition` is a metal-stack failure domain (one site / room) — the term is always "partition", never "zone". +- A `Partition` is a metal-stack failure domain (one site / room) — the term is always "partition", never "zone". A plan has no topology variant: the partitions it holds say what it is. - Fabric types per partition: `leaf-spine` | `leaf-spine-superspine` (`FabricConfig.fabricType`); superspines and storage leaves only enter the BOM/validation when configured. - **Central rack**: spines, exits, superspines, mgmt spines and mgmt servers live together in a central rack per partition; compute racks hold ToR leaves, worker/storage server groups, and a mgmt leaf. There is no separate OOB switch — the mgmt leaf carries BMC/out-of-band access. - **Rack kinds** (`Rack.kind`): `single` (one physical rack) or `rack-group` (three physical racks sharing the middle rack's leaf pair and mgmt leaf; chassis are distributed evenly per server group — each goes to the physical rack holding the fewest chassis of its group, the least-used by height units among those, ties mid → left → right). `physicalRacks()` in `derive/rackLayout.ts` is the single source of that spread; the topology graph and the elevations both consume it, so a rack group always shows as three physical racks — in the racks view inside one dashed group box (`RackElevation.group`). A group's `name` names the group ("Rack group 1"); its physical racks are named by `memberNames` (left, middle, right). Names are editable, but defaults are unique per partition: `newRack()` / `withRackKind()` in `model/defaults.ts` number every physical rack above the highest `Rack ` in use (a group takes three numbers, switching kinds keeps names unique), and validation warns on duplicate physical rack names. BOM sections, IP-plan subnets and rack-level validation use the group name; elevations and the topology use the physical names. In both views the mgmt leaf sits at the top of a compute rack, above the leaves. Leaf port capacity counts only spine uplinks against front-panel ports — leaf↔mgmt connectivity uses the switches' dedicated OOB mgmt port. -- **Management network** (`FabricConfig.mgmt`): has its own topology — `layer: 'l2' | 'l3'` and `redundant: boolean`; redundancy drives the count of mgmt spines and mgmt servers (2 vs 1, `mgmtDeviceCount()`). Production tiers use Edgecore AS7726 (the AS7712 is vendor end-of-life), management tiers AS4630/AS4625. Every switch has exactly one management interface: leaves connect it to the rack's mgmt leaf, central-rack switches and routers to the mgmt spines — derived, never configured. `FabricConfig.leafSpineLinks` (default 1) is the number of 100G links from each leaf to each spine. External networks (internet, company networks, storage) attach at exit switches. +- **Management network** (`FabricConfig.mgmt`): has its own topology — `layer: 'l2' | 'l3'` and `redundant: boolean`; redundancy drives the count of mgmt spines and mgmt servers (2 vs 1, `mgmtDeviceCount()`). Production tiers use Edgecore AS7726 (the AS7712 is vendor end-of-life), management tiers AS4630/AS4625. Every switch has exactly one management interface: leaves connect it to the rack's mgmt leaf, central-rack switches and routers to the mgmt spines — derived, never configured. `FabricConfig.leafSpineLinks` (default 1) is the number of 100G links from each leaf to each spine. External networks (internet, company networks, storage) attach in the central rack: internet and company networks at the internet routers (at the exits when a partition has no routers), a storage network at the partition's storage leaves, or at the exits when it has none (`attachesAtStorageLeaves()` in `derive/topology.ts` is the single source of that rule, used by the derivation and by `Diagram` to draw storage capsules over the storage box). `filterTopology()` drops an external network whose attachment points all fell away. - **GPUs.** `ServerGroup.gpu` (optional `{ modelId, perNode }`) fits every node of a group alike: a BOM line at `nodes × perNode`, watts folded into the chassis slot so the rack power estimate picks them up, and a validation error when `perNode` exceeds the server model's catalog `gpuCapable` (absent = takes no GPUs). `gpusForServer()` populates the dropdown, so the field only appears for GPU-capable models. - `ServerGroup.role` is `worker` | `storage` only. Management servers are never a compute-rack group — they are configured on the fabric's management network and live in the central rack. Legacy `management` groups are dropped on import in `model/normalize.ts`. - **Fabric bandwidth.** `src/derive/bandwidth.ts` is the single source of the oversubscription ratio: per rack `Σ nodes × (50 Gbit/s for 2x25G, 200 for 2x100G)` against `leafCount × spineCount × leafSpineLinks × 100 Gbit/s`, and per spine `leaves × leafSpineLinks × 100` against `superspineCount × 100` when a superspine tier exists. `FabricConfig.nonBlocking` (default `false`, so templates stay valid) turns a ratio above 1:1 into a validation error naming the links per leaf ↔ spine pair (or superspines) that would fix it; the ratio chips in the plan editor show it either way. North-south bandwidth (spines → exits → routers) is deliberately not part of this. -- **Cabling model** (header comment in `src/derive/bom.ts`): `leafSpineLinks` 100G links per leaf ↔ spine pair, one per exit/superspine/storage leaf ↔ spine (MTP trunk + 2x SR4; `spinePortsPerSpine()`). Management copper (RJ45 1G, no transceivers): one BMC/OOB port per server chassis (`rackBmcPorts()`, matches the sample BOMs) and one mgmt interface per leaf to the mgmt leaf (validated against the mgmt leaf's 1G ports); one mgmt interface per central-rack switch and router, and every mgmt server × every mgmt spine, to the mgmt spines. Management fiber: every mgmt leaf × every mgmt spine over LC duplex with SR transceivers — 25G when both switch models have 25G ports, else 10G (`mgmtUplinkSpeed()`). One NOS support license per switch, resolved by `nosLicenseId()` from the partition's `FabricConfig.nos` (`broadcom-sonic` by default, `edgecore-sonic` the alternative, chosen in the partition's Advanced section) and the switch's catalog `licenseClass` (`100g` / `25g`) — the vendors tier their licenses differently, so `nosOptions` in `catalog.ts` maps tier to SKU per NOS. Internet routers (`FabricConfig.routerCount`, default 2 for new plans) sit at the top of the central rack, link twice to every exit (validated against the exit's 100G ports) and take the external networks in the topology. +- **Cabling model** (header comment in `src/derive/bom.ts`): `leafSpineLinks` 100G links per leaf ↔ spine pair, one per exit/superspine/storage leaf ↔ spine (MTP trunk + 2x SR4; `spinePortsPerSpine()`). Management copper (RJ45 1G, no transceivers): one BMC/OOB port per server chassis (`rackBmcPorts()`, matches the sample BOMs) and one mgmt interface per leaf to the mgmt leaf (validated against the mgmt leaf's 1G ports); one mgmt interface per central-rack switch and router, and every mgmt server × every mgmt spine, to the mgmt spines. Management fiber: every mgmt leaf × every mgmt spine over LC duplex with SR transceivers — 25G when both switch models have 25G ports, else 10G (`mgmtUplinkSpeed()`). One NOS support license per switch, resolved by `nosLicenseId()` from the partition's `FabricConfig.nos` (`broadcom-sonic` by default, `edgecore-sonic` the alternative, chosen in the partition's Advanced section) and the switch's catalog `licenseClass` (`100g` / `25g`) — the vendors tier their licenses differently, so `nosOptions` in `catalog.ts` maps tier to SKU per NOS. Internet routers (`FabricConfig.routerCount`, default 2 for new plans) sit at the top of the central rack, link twice to every exit (validated against the exit's 100G ports) and take the internet and company networks in the topology. - Server uplinks are configured per `ServerGroup`: `2x25G` (Intel E810-XXVDA2) or `2x100G` (Intel E810-CQDA2). 25G server ports terminate on 100G leaf ports via 4x25G breakout — this breakout math drives transceiver and cable quantities in the BOM. - `ServerGroup.count` is server _nodes_; chassis counts are derived via `nodesPerChassis` (MicroCloud = 8, BigTwin = 4). - The topology view shows devices and links only — no metal-stack component placements (the metal-core, metal-api, PXE and BMC chips were removed on purpose) and no link-count badges. Server groups have no edges at all — neither uplinks to the leaves nor BMC links to the mgmt leaf are drawn (too many edges); the BOM still counts them. The central rack is drawn in two columns (production left, management right) with spines and mgmt spines in the bottom row so rack uplinks don't cross other device rows. diff --git a/README.md b/README.md index 3c08c3e..8e40d1f 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ consistent — you edit the plan, everything else follows from it. ## Screenshots -The screenshots show the **Redundant** template: one partition with a redundant management +The screenshots show the **Production** template: one partition with a redundant management network, two rack groups with 112 workers each and three storage servers. ### Plan editor with live side panel @@ -150,9 +150,9 @@ default plan. Use **Export JSON** to save a plan file and **Import JSON** to loa | Template | Contents | | ---------------- | --------------------------------------------------------------------------------------------------------- | -| Starter | One partition, non-redundant management network, one rack with 8 workers on a leaf pair. | -| Redundant | One partition, redundant management network, two rack groups with 112 workers each and 3 storage servers. | -| Three partitions | Multisite topology with three partitions, each like _Redundant_. | +| PoC | One partition, non-redundant management network, one rack with 8 workers on a leaf pair. | +| Production | One partition, redundant management network, two rack groups with 112 workers each and 3 storage servers. | +| Three partitions | Three partitions, each like _Production_. | ## How it works diff --git a/src/derive/bandwidth.test.ts b/src/derive/bandwidth.test.ts index 89937dd..fadcf96 100644 --- a/src/derive/bandwidth.test.ts +++ b/src/derive/bandwidth.test.ts @@ -48,8 +48,8 @@ describe('rackBandwidth', () => { expect(rackBandwidth(partition.racks[0], partition).ratio).toBeNull() }) - it('reports the oversubscription of the Redundant template', () => { - const plan = templates.find((t) => t.id === 'redundant')!.build() + it('reports the oversubscription of the Production template', () => { + const plan = templates.find((t) => t.id === 'production')!.build() const partition = plan.partitions[0] // 112 workers + 3 storage nodes x 50 Gbit/s = 5750 against 400 const b = rackBandwidth(partition.racks[0], partition) diff --git a/src/derive/ip/ipPlan.test.ts b/src/derive/ip/ipPlan.test.ts index 1ef3a68..7f0b9be 100644 --- a/src/derive/ip/ipPlan.test.ts +++ b/src/derive/ip/ipPlan.test.ts @@ -165,8 +165,8 @@ describe('infrastructure ranges', () => { ]) }) - it('sizes the Redundant template per rack group and overflows a small block', () => { - const plan = templates.find((t) => t.id === 'redundant')!.build() + it('sizes the Production template per rack group and overflows a small block', () => { + const plan = templates.find((t) => t.id === 'production')!.build() const p = deriveIpPlan(plan).infra.partitions[0] const pxe = p.subnets.find((s) => s.purpose === 'PXE (vlan4000)')! expect(pxe).toMatchObject({ needed: 229, prefix: 23 }) diff --git a/src/derive/ip/validateIp.test.ts b/src/derive/ip/validateIp.test.ts index ec12293..a8367e9 100644 --- a/src/derive/ip/validateIp.test.ts +++ b/src/derive/ip/validateIp.test.ts @@ -62,7 +62,7 @@ describe('validateIpPlan', () => { }) it('warns when a partition plans more workers than one cluster can hold', () => { - const plan = templates.find((t) => t.id === 'redundant')!.build() + const plan = templates.find((t) => t.id === 'production')!.build() // compact preset: 16 workers per shoot cluster, the template plans 224 const w = messages(plan, 'warning').find((m) => m.includes('at most 16 workers')) expect(w).toContain('Partition 1 plans 224') diff --git a/src/derive/topology.test.ts b/src/derive/topology.test.ts index e6a6fbb..dd31f96 100644 --- a/src/derive/topology.test.ts +++ b/src/derive/topology.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { createEmptyPlan, withRackKind } from '../model/defaults' +import type { Plan } from '../model/plan' import { deriveTopology, filterTopology } from './topology' describe('deriveTopology', () => { @@ -69,7 +70,7 @@ describe('deriveTopology', () => { expect(spineSuper).toHaveLength(4) // 2 spines x 2 superspines }) - it('attaches external networks at the routers, or the exits without routers', () => { + it('attaches the internet network at the routers, or the exits without routers', () => { const graph = deriveTopology(createEmptyPlan()) const partition = graph.partitions[0] expect(partition.externalNetworks).toHaveLength(1) @@ -109,6 +110,43 @@ describe('deriveTopology', () => { expect(links).toHaveLength(4) // 2 storage leaves x 2 spines }) + it('attaches a storage network at the storage leaves, or the exits without them', () => { + const plan = createEmptyPlan() + plan.externalNetworks = [ + { id: 'storage-net', name: 'Ceph', kind: 'storage', attachedPartitionId: '' }, + ] + const storageTargets = (p: Plan) => { + const graph = deriveTopology(p) + const partition = graph.partitions[0] + const net = partition.externalNetworks[0] + return { + graph, + partition, + net, + targets: graph.links.filter((l) => l.from === net.id).map((l) => l.to), + } + } + + // Without storage leaves the storage network lands on the exits, not on + // the internet routers. + const noLeaves = storageTargets(plan) + expect(noLeaves.partition.central.routers).toHaveLength(2) + expect(noLeaves.targets.sort()).toEqual( + noLeaves.partition.central.exits.map((n) => n.id).sort(), + ) + + plan.partitions[0].fabric.storageLeafCount = 2 + const withLeaves = storageTargets(plan) + expect(withLeaves.targets.sort()).toEqual( + withLeaves.partition.storageLeaves.map((n) => n.id).sort(), + ) + + // Central mode drops the storage leaves, so the capsule goes with them. + const central = filterTopology(withLeaves.graph, 'central') + expect(central.partitions[0].externalNetworks).toHaveLength(0) + expect(central.links.some((l) => l.from === withLeaves.net.id)).toBe(false) + }) + it('draws a rack group as three physical racks sharing the middle switches', () => { const plan = createEmptyPlan() const partition = plan.partitions[0] diff --git a/src/derive/topology.ts b/src/derive/topology.ts index 729cca5..0430511 100644 --- a/src/derive/topology.ts +++ b/src/derive/topology.ts @@ -62,7 +62,8 @@ export interface TopoRack { /** Spines, exits, superspines, mgmt spines and mgmt servers all live in the * partition's central rack. */ export interface TopoCentralRack { - /** Internet routers; external networks attach here when present. */ + /** Internet routers; internet and company networks attach here when + * present. */ routers: TopoNode[] superspines: TopoNode[] spines: TopoNode[] @@ -77,9 +78,10 @@ export interface TopoPartition { central: TopoCentralRack storageLeaves: TopoNode[] racks: TopoRack[] - /** External networks attached at this partition's exits. A network that + /** External networks attached to this partition, at the routers, the + * exits or the storage leaves (see deriveTopology). A network that * attaches to every partition appears once per partition, so each is - * drawn next to the exits it connects to. */ + * drawn next to the devices it connects to. */ externalNetworks: TopoNode[] } @@ -301,12 +303,19 @@ function derivePartition(partition: Partition, links: TopoLink[]): TopoPartition } } +/** Whether an external network node hangs off the partition's storage + * leaves rather than off its routers or exits. The single source of that + * rule: the derivation links it there, the diagram draws it there. */ +export function attachesAtStorageLeaves(partition: TopoPartition, node: TopoNode): boolean { + return node.networkKind === 'storage' && partition.storageLeaves.length > 0 +} + export function deriveTopology(plan: Plan): TopologyGraph { const links: TopoLink[] = [] const partitions = plan.partitions.map((partition) => derivePartition(partition, links)) - // External networks attach at the exit switches of their partition (or - // every partition when unset), as one node per partition they attach to. + // External networks attach in their partition (or in every partition when + // unset), as one node per partition they attach to. for (const net of plan.externalNetworks) { const targets = partitions.filter( (p) => !net.attachedPartitionId || p.id === net.attachedPartitionId, @@ -320,9 +329,18 @@ export function deriveTopology(plan: Plan): TopologyGraph { networkKind: net.kind, } partition.externalNetworks.push(node) - // Attach at the routers, or directly at the exits when there are none. + // Storage backends land on the dedicated storage leaves, or on the + // exit switches when the partition has none: they never enter through + // the internet routers. Every other network attaches at the routers, + // or directly at the exits when there are none. const attach = - partition.central.routers.length > 0 ? partition.central.routers : partition.central.exits + net.kind === 'storage' + ? attachesAtStorageLeaves(partition, node) + ? partition.storageLeaves + : partition.central.exits + : partition.central.routers.length > 0 + ? partition.central.routers + : partition.central.exits for (const device of attach) { links.push({ from: node.id, to: device.id, count: 1, network: 'external' }) } @@ -348,7 +366,9 @@ function nodeVisible(node: TopoNode, mode: TopologyMode): boolean { /** The subgraph for a view mode: nodes of the other network are dropped, * compute racks and storage are dropped in central mode, external * networks in management mode, and links are kept only when both ends - * remain and belong to the shown network. */ + * remain and belong to the shown network. An external network whose + * attachment points all fell away (a storage network on storage leaves, + * in central mode) goes with them instead of floating unconnected. */ export function filterTopology(graph: TopologyGraph, mode: TopologyMode): TopologyGraph { const keep = (nodes: TopoNode[]) => nodes.filter((n) => nodeVisible(n, mode)) const partitions = graph.partitions.map((p): TopoPartition => ({ @@ -383,12 +403,20 @@ export function filterTopology(graph: TopologyGraph, mode: TopologyMode): Topolo ...p.central.mgmtSpines, ...p.central.mgmtServers, ...p.storageLeaves, - ...p.externalNetworks, ...p.racks.flatMap((r) => [...r.leaves, ...r.mgmtLeaves, ...r.serverGroups]), ]) { ids.add(n.id) } } + // External networks only survive with a device left to attach to. + for (const p of partitions) { + p.externalNetworks = p.externalNetworks.filter((n) => + graph.links.some( + (l) => (l.from === n.id && ids.has(l.to)) || (l.to === n.id && ids.has(l.from)), + ), + ) + for (const n of p.externalNetworks) ids.add(n.id) + } const links = graph.links.filter((l) => { if (!ids.has(l.from) || !ids.has(l.to)) return false if (mode === 'production') return l.network !== 'management' diff --git a/src/derive/validate.ts b/src/derive/validate.ts index e006524..fc9a288 100644 --- a/src/derive/validate.ts +++ b/src/derive/validate.ts @@ -476,14 +476,6 @@ export function validatePlan(plan: Plan): Issue[] { } } - if (plan.topology !== 'single-zone' && plan.partitions.length < 2) { - report( - issues, - { where: 'Plan', target: {} }, - 'warning', - `Topology is ${plan.topology} but the plan has only ${plan.partitions.length} partition(s).`, - ) - } checkAvailability(issues, plan) issues.push(...validateIpPlan(plan)) return issues diff --git a/src/model/defaults.ts b/src/model/defaults.ts index 35bbd85..24ddbcc 100644 --- a/src/model/defaults.ts +++ b/src/model/defaults.ts @@ -141,7 +141,6 @@ export function createEmptyPlan(): Plan { name: 'New metal-stack plan', createdAt: now, updatedAt: now, - topology: 'single-zone', partitions: [defaultPartition('Partition 1')], sparesPerLine: 2, ipPlan: defaultIpPlan(), diff --git a/src/model/plan.ts b/src/model/plan.ts index dd85050..97c1217 100644 --- a/src/model/plan.ts +++ b/src/model/plan.ts @@ -6,9 +6,6 @@ import { SCHEMA_VERSION } from './migrate' // schemas are the source of truth — all TypeScript types are inferred from // them, and every JSON import must pass PlanSchema before entering the store. -export const TopologyVariantSchema = z.enum(['single-zone', 'metro', 'multisite']) -export type TopologyVariant = z.infer - export const UplinkSpeedSchema = z.enum(['2x25G', '2x100G']) export type UplinkSpeed = z.infer @@ -158,7 +155,6 @@ export const PlanSchema = z.object({ name: z.string(), createdAt: z.string(), updatedAt: z.string(), - topology: TopologyVariantSchema, partitions: z.array(PartitionSchema), externalNetworks: z.array(ExternalNetworkSchema), /** Fixed number of spares added per transceiver and cable BOM line. */ diff --git a/src/model/templates.test.ts b/src/model/templates.test.ts index cb54fa6..9fbaebb 100644 --- a/src/model/templates.test.ts +++ b/src/model/templates.test.ts @@ -19,16 +19,16 @@ describe('templates', () => { expect(t.build().partitions[0].id).not.toBe(t.build().partitions[0].id) }) - it('starter: one partition, non-redundant mgmt, 8 workers on two leaves', () => { - const plan = templates.find((t) => t.id === 'starter')!.build() + it('poc: one partition, non-redundant mgmt, 8 workers on two leaves', () => { + const plan = templates.find((t) => t.id === 'poc')!.build() expect(plan.partitions).toHaveLength(1) expect(plan.partitions[0].fabric.mgmt.redundant).toBe(false) expect(plan.partitions[0].racks[0].leafCount).toBe(2) expect(planNodes(plan)).toEqual({ total: 8, byRole: { worker: 8 } }) }) - it('redundant: two rack groups with workers and three storage servers', () => { - const plan = templates.find((t) => t.id === 'redundant')!.build() + it('production: two rack groups with workers and three storage servers', () => { + const plan = templates.find((t) => t.id === 'production')!.build() const [partition] = plan.partitions expect(partition.fabric.mgmt.redundant).toBe(true) expect(partition.racks.map((r) => r.kind)).toEqual(['rack-group', 'rack-group']) @@ -43,9 +43,8 @@ describe('templates', () => { expect(planNodes(plan).byRole.storage).toBe(3) }) - it('three partitions: multisite with three redundant partitions', () => { + it('three partitions: three partitions like the Production template', () => { const plan = templates.find((t) => t.id === 'three-partitions')!.build() - expect(plan.topology).toBe('multisite') expect(plan.partitions).toHaveLength(3) expect(plan.partitions.every((p) => p.racks.length === 2)).toBe(true) expect(planNodes(plan).byRole.storage).toBe(9) diff --git a/src/model/templates.ts b/src/model/templates.ts index f8bef50..2863f60 100644 --- a/src/model/templates.ts +++ b/src/model/templates.ts @@ -40,7 +40,7 @@ function rackGroup(name: string, memberNames: [string, string, string], storageS /** One partition with a redundant management network, two rack groups and * three storage servers. */ -function redundantPartition(name: string): Partition { +function productionPartition(name: string): Partition { const fabric = defaultFabric() fabric.mgmt.redundant = true return { @@ -55,14 +55,14 @@ function redundantPartition(name: string): Partition { } } -function plan(name: string, topology: Plan['topology'], partitions: Partition[]): Plan { - return { ...createEmptyPlan(), name, topology, partitions } +function plan(name: string, partitions: Partition[]): Plan { + return { ...createEmptyPlan(), name, partitions } } export const templates: PlanTemplate[] = [ { - id: 'starter', - name: 'Starter', + id: 'poc', + name: 'PoC', description: 'One partition, non-redundant management network, one rack with 8 workers on a leaf pair.', build: () => { @@ -71,7 +71,7 @@ export const templates: PlanTemplate[] = [ const rack = defaultRack('Rack 1') rack.leafCount = 2 rack.servers = [group('worker', 'server-microcloud-h13', 8)] - return plan('Starter', 'single-zone', [ + return plan('PoC', [ { id: id(), name: 'Partition 1', @@ -83,21 +83,21 @@ export const templates: PlanTemplate[] = [ }, }, { - id: 'redundant', - name: 'Redundant', + id: 'production', + name: 'Production', description: 'One partition, redundant management network, two rack groups with 224 workers and 3 storage servers.', - build: () => plan('Redundant', 'single-zone', [redundantPartition('Partition 1')]), + build: () => plan('Production', [productionPartition('Partition 1')]), }, { id: 'three-partitions', name: 'Three partitions', - description: 'Multisite: three separate partitions, each like the Redundant template.', + description: 'Three separate partitions, each like the Production template.', build: () => - plan('Three partitions', 'multisite', [ - redundantPartition('Partition 1'), - redundantPartition('Partition 2'), - redundantPartition('Partition 3'), + plan('Three partitions', [ + productionPartition('Partition 1'), + productionPartition('Partition 2'), + productionPartition('Partition 3'), ]), }, ] diff --git a/src/store/planStore.ts b/src/store/planStore.ts index 41beb26..2fc955e 100644 --- a/src/store/planStore.ts +++ b/src/store/planStore.ts @@ -14,7 +14,6 @@ import { type Rack, type RackDefaults, type ServerGroup, - type TopologyVariant, } from '../model/plan' export type View = 'plan' | 'topology' | 'racks' | 'ips' | 'bom' @@ -24,7 +23,6 @@ interface PlannerState { activeView: View setActiveView: (view: View) => void setPlanName: (name: string) => void - setTopology: (topology: TopologyVariant) => void setSparesPerLine: (sparesPerLine: number) => void patchIpFamily: (family: IpFamilyKey, patch: Partial) => void patchIpInfra: (patch: Partial) => void @@ -87,7 +85,6 @@ export const usePlanStore = create()( activeView: 'plan', setActiveView: (activeView) => set({ activeView }), setPlanName: (name) => set((s) => ({ plan: touched(s.plan, { name }) })), - setTopology: (topology) => set((s) => ({ plan: touched(s.plan, { topology }) })), setSparesPerLine: (sparesPerLine) => set((s) => ({ plan: touched(s.plan, { sparesPerLine }) })), patchIpFamily: (family, patch) => diff --git a/src/views/PlanView.tsx b/src/views/PlanView.tsx index 099779a..e0f0f7a 100644 --- a/src/views/PlanView.tsx +++ b/src/views/PlanView.tsx @@ -3,7 +3,6 @@ import { physicalRackCount } from '../derive/rackLayout' import { validatePlan } from '../derive/validate' import { exportPlanJson, importPlanJson } from '../io/json' import { downloadText } from '../io/download' -import { TopologyVariantSchema, type TopologyVariant } from '../model/plan' import { usePlanStore } from '../store/planStore' import { useToastStore } from '../store/toastStore' import ExternalNetworksSection from './plan/ExternalNetworksSection' @@ -16,12 +15,6 @@ import SidePanel from './plan/SidePanel' import TemplateMenu from './plan/TemplateMenu' import { ACTION_ICON, Icon, SECTION_ICON } from './icons' -const topologyLabels: Record = { - 'single-zone': 'Single zone', - metro: 'Metro', - multisite: 'Multisite', -} - const undoAction = { label: 'Undo', onClick: () => usePlanStore.temporal.getState().undo(), @@ -30,7 +23,6 @@ const undoAction = { export default function PlanView() { const plan = usePlanStore((s) => s.plan) const setPlanName = usePlanStore((s) => s.setPlanName) - const setTopology = usePlanStore((s) => s.setTopology) const addRack = usePlanStore((s) => s.addRack) const addPartition = usePlanStore((s) => s.addPartition) const removePartition = usePlanStore((s) => s.removePartition) @@ -106,22 +98,6 @@ export default function PlanView() { className="w-72 rounded-md border border-gray-300 bg-white px-3 py-2" /> -
- Topology -
- {TopologyVariantSchema.options.map((variant) => ( - - ))} -
-
diff --git a/src/views/plan/ExternalNetworksSection.tsx b/src/views/plan/ExternalNetworksSection.tsx index 906a4aa..331695a 100644 --- a/src/views/plan/ExternalNetworksSection.tsx +++ b/src/views/plan/ExternalNetworksSection.tsx @@ -13,8 +13,9 @@ const kindLabels: Record = { } /** External attachment points (internet, company networks, storage - * backends). They attach at the exit switches of one partition, or of - * every partition when none is chosen. */ + * backends). They attach in one partition, or in every partition when + * none is chosen: storage networks at its storage leaves (at its exit + * switches when it has none), everything else at its internet routers. */ export default function ExternalNetworksSection({ plan }: { plan: Plan }) { const add = usePlanStore((s) => s.addExternalNetwork) const patch = usePlanStore((s) => s.patchExternalNetwork) @@ -34,11 +35,13 @@ export default function ExternalNetworksSection({ plan }: { plan: Plan }) { className="mr-1.5 inline h-4 w-4 align-[-3px] text-gray-500" /> External networks{' '} - · attach at the exit switches + + · attach at the routers, exits or storage leaves + diff --git a/src/views/topology/Diagram.tsx b/src/views/topology/Diagram.tsx index f7cda40..7306683 100644 --- a/src/views/topology/Diagram.tsx +++ b/src/views/topology/Diagram.tsx @@ -1,12 +1,13 @@ import { useState } from 'react' import { EXTERNAL_NETWORK_ICON, NODE_ICON } from '../icons' import { COLOR } from '../colors' -import type { - TopoLink, - TopoNode, - TopologyGraph, - TopoPartition, - TopoRack, +import { + attachesAtStorageLeaves, + type TopoLink, + type TopoNode, + type TopologyGraph, + type TopoPartition, + type TopoRack, } from '../../derive/topology' // Renders the derived TopologyGraph as a fabric elevation. Each partition @@ -103,6 +104,15 @@ function placeRow(rects: Map, nodes: TopoNode[], cx: number, y: nu } } +/** A centered row of external network capsules. */ +function placeCapsules(rects: Map, nodes: TopoNode[], cx: number, y: number) { + let x = cx - (nodes.length * (EXT_W + GAP) - GAP) / 2 + for (const node of nodes) { + rects.set(node.id, { x, y, w: EXT_W, h: EXT_H }) + x += EXT_W + GAP + } +} + /** Two node groups sharing one centered row with a gap between them. */ function placeGroupedRow( rects: Map, @@ -192,7 +202,19 @@ function layoutPartition( const { central } = partition const racksW = racksRowWidth(partition.racks) - const storageW = partition.storageLeaves.length > 0 ? NODE_W + 2 * RACK_PAD + 24 : 0 + // Storage networks hang off the storage leaves when the partition has + // them (attachesAtStorageLeaves), so they are drawn over the storage box + // instead of over the central rack, keeping their links short. + const storageNets = partition.externalNetworks.filter((n) => + attachesAtStorageLeaves(partition, n), + ) + const centralNets = partition.externalNetworks.filter( + (n) => !attachesAtStorageLeaves(partition, n), + ) + const storageW = + partition.storageLeaves.length > 0 + ? Math.max(NODE_W + 2 * RACK_PAD + 24, storageNets.length * (EXT_W + GAP) - GAP) + : 0 // Central rack, two columns: production (optional routers, then // superspines/exits, then spines) and management (mgmt servers over mgmt @@ -211,7 +233,7 @@ function layoutPartition( const cx = fabricW / 2 // External networks sit above the central rack, centered over the exits. - const extH = partition.externalNetworks.length > 0 ? EXT_H + 28 : 0 + const extH = centralNets.length > 0 ? EXT_H + 28 : 0 const boxTop = y0 + 24 + extH const row0Y = boxTop + RACK_HEAD const row1Y = hasRouters ? row0Y + NODE_H + ROW_GAP : row0Y @@ -231,19 +253,14 @@ function layoutPartition( h: row2Y + NODE_H + RACK_PAD - boxTop, } layout.boxes.push({ rect: boxRect, name: 'Central rack', target: { partitionId: partition.id } }) - if (partition.externalNetworks.length > 0) { + if (centralNets.length > 0) { const anchors = hasRouters ? central.routers : central.exits const exitRects = anchors.map((e) => rects.get(e.id)).filter((r): r is Rect => !!r) const ecx = exitRects.length > 0 ? exitRects.reduce((sum, r) => sum + r.x + r.w / 2, 0) / exitRects.length : prodCx - const total = partition.externalNetworks.length * (EXT_W + GAP) - GAP - let ex = ecx - total / 2 - for (const node of partition.externalNetworks) { - rects.set(node.id, { x: ex, y: y0 + 24, w: EXT_W, h: EXT_H }) - ex += EXT_W + GAP - } + placeCapsules(rects, centralNets, ecx, y0 + 24) } // Compute racks and the storage box below. The physical racks of a @@ -303,6 +320,9 @@ function layoutPartition( h: NODE_H, }) }) + // The storage networks go in the gap above the box, over the leaves + // they attach to. + placeCapsules(rects, storageNets, box.x + box.w / 2, rackY - EXT_H - 12) maxRackH = Math.max(maxRackH, box.h) }