diff --git a/CLAUDE.md b/CLAUDE.md index 9858955..9ea3cde 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,24 +22,24 @@ A client-only single-page app (React + Vite + TypeScript) to plan a [metal-stack - **One-way derivation.** Everything downstream of the `Plan` — BOM lines (`src/derive/bom.ts`), the topology graph (`src/derive/topology.ts`), rack elevations (`src/derive/rackLayout.ts`) — is computed, never stored. New quantity rules go in `src/derive/bom.ts` with a unit test alongside; new visualization structure goes in `src/derive/topology.ts` (what exists and how it connects), while `src/views/topology/Diagram.tsx` only lays out and draws the graph as SVG (positions, colors) — keep that split. - **Hardware facts are data.** Part numbers, port counts, U heights, nodes-per-chassis, typical power draw (`powerWatts`, drives the per-rack estimate in the Racks view), metal-stack compatibility (`status`, `switchRoles`, `serverUsages`) and vendor lifecycle (`availability`: `eol` / `withdrawn`, independent of metal-stack support) live in `src/model/catalog.ts`. Item identity is split three ways — `vendor`, `model` (what people say, used in BOM reasons and validation messages via `itemLabel()`) and `partNumber` (the orderable ordering code) — and `description` carries the spec only, to a grammar documented in that file's header and enforced by `catalog.test.ts`. Derivation code must not hard-code hardware facts. The compatibility data mirrors the official list (docs repo `metal-stack/docs`, `docs/src/operators/hardware.md`) — update it from there, don't invent entries. - **Validation is derived too.** `src/derive/validate.ts` computes `Issue[]` from the plan: leaf/spine port-capacity checks (with the documented capacity model assumptions in its header comment) and compatibility-list checks. UI dropdowns are populated via `switchesForRole()` / `serversForUsage()` from the catalog, so incompatible hardware is filtered at the source. -- **BOM lines carry reasons.** `BomBuilder.add(id, qty, detail)` records a `BomReason` (`{ quantity, detail, where }`) per contributing rule (`BomLine.reasons`); every rule must pass a short, human-readable detail. `where` is the section the BOM view groups by — `"Partition 2 - Rack 1"`, `"Central rack"`, or empty for plan-wide rules like spares — built from `BomBuilder.inPartition()` and `.at()`, so a rule states its place once instead of repeating it in the detail. Rules that apply per rack are emitted per rack rather than summed, so each rack shows in its own section. `addPartition` emits all central-rack rules before any rack's, and racks in plan order, so first-seen grouping yields the physical order (central rack, then Rack 1, Rack 2 …) for every line without a sort key — keep that emission order when adding rules. `reasonsByWhere()` does the grouping for the view and `formatReasons()` the one-cell text for the CSV/XLSX exports. No long dashes in BOM text: the description column and the reasons both avoid them (the `—` placeholders for an empty cell stay, being a table convention rather than prose). `deriveBom(plan)` is the plan-wide BOM including spares (`Plan.sparesPerLine` per transceiver/cable line, category `spare`, id `#spare`); `deriveBomByPartition(plan)` gives per-partition BOMs without spares. Both take a `BomScope` (`all` | `production` | `management`) for the BOM view's tabs, which mirror the topology view's modes: a line can have contributions from both networks (25G SR optics serve server uplinks _and_ the mgmt fiber), so `BomBuilder.on(network)` tags each rule and the filter drops out-of-scope contributions — the two scopes sum to `all` per line. The partition selector is the orthogonal control, so any combination is reachable. +- **BOM lines carry reasons.** `BomBuilder.add(id, qty, detail)` records a `BomReason` (`{ quantity, detail, where }`) per contributing rule (`BomLine.reasons`); every rule must pass a short, human-readable detail. `where` is the section the BOM view groups by — `"Partition 2 - Rack 1"`, `"Central rack"`, or empty for plan-wide rules like spares — built from `BomBuilder.inPartition()` and `.at()`, so a rule states its place once instead of repeating it in the detail. Rules that apply per rack are emitted per rack rather than summed, so each rack shows in its own section. `addPartition` emits all central-rack rules before any rack's, and racks in plan order, so first-seen grouping yields the physical order (central rack, then Rack 1, Rack 2 …) for every line without a sort key — keep that emission order when adding rules. `reasonsByWhere()` does the grouping for the view and `formatReasons()` the one-cell text for the CSV/XLSX exports. No long dashes in BOM text, as anywhere in the UI (see Styling). `deriveBom(plan)` is the plan-wide BOM including spares (`Plan.sparesPerLine` per transceiver/cable line, category `spare`, id `#spare`); `deriveBomByPartition(plan)` gives per-partition BOMs without spares. Both take a `BomScope` (`all` | `production` | `management`) for the BOM view's tabs, which mirror the topology view's modes: a line can have contributions from both networks (25G SR optics serve server uplinks _and_ the mgmt fiber), so `BomBuilder.on(network)` tags each rule and the filter drops out-of-scope contributions — the two scopes sum to `all` per line. The partition selector is the orthogonal control, so any combination is reachable. - **Price book.** Prices are never part of the plan. `src/store/priceStore.ts` persists `{ currency, prices: Record }` under `metal-stack-planner/prices`; `src/io/priceBook.ts` holds the schema, JSON import/export and the money helpers (spares price like their base item). CSV/XLSX exporters accept an optional price book. -- **Topology view modes.** `filterTopology(graph, mode)` in `src/derive/topology.ts` derives the subgraph for `production` (default: no mgmt nodes/links), `management` (only mgmt nodes and links, no external networks) or `central` (central rack only, both networks). `TopologyView` toggles the mode; `Diagram` must render any of them (e.g. an empty production column). `ZoomPane` wraps the diagram (wheel zoom, drag pan, Fit / 100 %); the side-panel thumbnail uses `fit`. Boxes carry a `target` so clicking a rack, three-rack or central rack jumps to its editor section via `views/plan/navigate.ts`; hovering a node fades unrelated links and nodes. -- **Rack defaults and power budget.** `Partition.rackDefaults` (`heightUnits` 42, `maxPowerWatts` 12000 — five 3U eight-node MicroCloud chassis at ~2 kW plus a leaf pair no longer fit in 10 kW) seed racks added to the partition and apply to the central rack; each rack keeps its own `heightUnits` / `maxPowerWatts` (rack Advanced section). Validation reports an error per physical rack whose estimated draw exceeds its budget. +- **Topology view modes.** `filterTopology(graph, mode)` in `src/derive/topology.ts` derives the subgraph for `production` (default: no mgmt nodes/links), `management` (only mgmt nodes and links, no external networks) or `central` (central rack only, both networks). `TopologyView` toggles the mode; `Diagram` must render any of them (e.g. an empty production column). `ZoomPane` wraps the diagram (wheel zoom, drag pan, Fit / 100 %); the side-panel thumbnail uses `fit`. Boxes carry a `target` so clicking a rack, rack group or central rack jumps to its editor section via `views/plan/navigate.ts`; hovering a node fades unrelated links and nodes. +- **Rack defaults and power budget.** `Partition.rackDefaults` (`heightUnits` 42, `maxPowerWatts` 12000 — five 3U eight-node MicroCloud chassis at ~2 kW plus a leaf pair no longer fit in 10 kW) seed racks added to the partition and apply to the central rack; each rack keeps its own `heightUnits` / `maxPowerWatts` in its Advanced section, which also holds the rarely changed leaf model and leaf count and a rack group's member names. The central rack's Advanced section likewise holds the internet router and storage leaf counts, its hardware models (spine, superspine, storage leaf, mgmt spine/leaf/server) and mgmt leaves per rack, grouped with the network OS, fabric links and rack defaults; the section header shows the chosen models read-only. Issues that can only be fixed in either Advanced section carry `target.field: 'advanced'`, and `navigateTo()` unfolds that section (`details[data-advanced]`) when revealing them. Validation reports an error per physical rack whose estimated draw exceeds its budget. - **Info bubbles.** `views/plan/InfoBubble.tsx` renders the "i" bubbles; `SelectField` / `NumberField` take an `info` prop. Texts must match the metal-stack docs, and links come from `views/plan/docs.ts` — only pages verified to exist on docs.metal-stack.io. The partition's central components are edited in `CentralRackSection.tsx` (the UI says "Central rack", the schema still calls it `fabric`). - **IP plan.** Inputs live in `Plan.ipPlan` (`src/model/ipPlan.ts`: per-family schema, infrastructure inputs, presets). `src/derive/ip/cidr.ts` is the BigInt CIDR library (no dependency); `src/derive/ip/ipPlan.ts` derives limits (each with a formula string), the partition allocation (sequential, skipping slots that overlap Kubernetes, reserve, internet or infrastructure ranges — an internet range containing the whole project CIDR is by design), the example cluster and the infrastructure subnets; `src/derive/ip/validateIp.ts` reports issues with `target: { section: 'ips', field }`, which `navigateTo()` opens in the IPs tab. Infrastructure sizing rules are in the header of `ipPlan.ts`. Presets mirror the layouts of the untracked `ip_calc.ods`; their public ranges must be documentation ranges (RFC 5737 / RFC 3849), never real allocations. `ipPlanToCsv()` in `src/io/ipCsv.ts` exports the address plan. -- **Icons.** Icons come from `src/views/icons/` only: `index.tsx` is the registry (Lucide icons plus the custom `NetworkSwitch`, `Rack`, `ThreeRack` glyphs from `custom.ts`, drawn on Lucide's 24 px grid) with typed maps per tab, node kind, BOM category, rack slot kind and severity, and an `` helper (decorative, `aria-hidden`). An ESLint rule forbids importing `lucide-react` anywhere else. Sizes: 16 px in tabs and buttons, 14 px inline and in topology nodes, 12 px in rack slots; color follows the text. +- **Icons.** Icons come from `src/views/icons/` only: `index.tsx` is the registry (Lucide icons plus the custom `NetworkSwitch`, `Rack`, `RackGroup` glyphs from `custom.ts`, drawn on Lucide's 24 px grid) with typed maps per tab, node kind, BOM category, rack slot kind and severity, and an `` helper (decorative, `aria-hidden`). An ESLint rule forbids importing `lucide-react` anywhere else. Sizes: 16 px in tabs and buttons, 14 px inline and in topology nodes, 12 px in rack slots; color follows the text. - **Templates.** `src/model/templates.ts` holds the starting plans offered by the Templates menu. They must order only hardware that is current at the vendor — `templates.test.ts` fails on any `availability` of `eol` / `withdrawn`, for the templates and for the default plan. Each `build()` returns a fresh Plan with new ids; `templates.test.ts` asserts every template parses and has no validation errors, so keep them valid when capacity rules change. - **Persistence and format version.** Zustand store (`src/store/planStore.ts`) with `persist` middleware → localStorage key `metal-stack-planner/plan`. JSON export/import in `src/io/json.ts`; every import runs through `migrateRawPlan()` (`src/model/migrate.ts`) and is then validated with `PlanSchema.parse`, and the same path runs on rehydrate. `SCHEMA_VERSION` in `migrate.ts` is the plan file format version — currently 1 with no migrations. Purely additive fields only need a Zod `.default()`; anything that breaks older files bumps `SCHEMA_VERSION` and adds a function to `MIGRATIONS`, keyed by the version it upgrades _from_. Files from a newer version are refused with a readable message. - **Client-only invariant.** No network calls, no backend. Keep it that way. -- **Styling.** Tailwind v4. The look follows metal-stack.io: brand tokens (`--color-brand` amber, `--color-ink`, `--color-page`) and the `btn-*` / `card` utilities live in `src/index.css` — use them instead of ad-hoc color classes. The official picture mark (`src/assets/metal-stack-logo.svg`, from the metal-stack docs repo) is bundled locally; never load it or fonts from the network. +- **Styling.** No long dashes (U+2014) in any UI text: labels, info texts, issue messages and placeholders use a colon, comma or parentheses in prose, a middle dot `·` between a heading and its gray subtitle, and an en dash `–` for an empty cell. `src/uiText.test.ts` fails on any outside comments. Tailwind v4. The look follows metal-stack.io: brand tokens (`--color-brand` amber, `--color-ink`, `--color-page`) and the `btn-*` / `card` utilities live in `src/index.css` — use them instead of ad-hoc color classes. The official picture mark (`src/assets/metal-stack-logo.svg`, from the metal-stack docs repo) is bundled locally; never load it or fonts from the network. ## Domain - Topologies: `single-zone` | `metro` | `multisite`. A `Partition` is a metal-stack failure domain (one site / room) — the term is always "partition", never "zone". - 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 `three-rack` (three physical racks sharing the middle rack's leaf pair and mgmt leaf; chassis are distributed evenly by used height units — each goes to the least-used physical rack, 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 three-rack always shows as three physical racks. 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. +- **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. - **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`. diff --git a/README.md b/README.md index a10539b..3c08c3e 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,9 @@ consistent — you edit the plan, everything else follows from it. - **Plan editor** — one or more partitions (metal-stack failure domains), each with a central rack (internet routers, exits, spines, optional superspines, management spines and management - servers), any number of compute racks, and the external networks that attach at the routers. A compute rack is either a single rack or a _three-rack_: three - physical racks sharing the middle rack's leaf pair and management leaf. + servers), any number of compute racks, and the external networks that attach at the routers. A compute rack is either a single rack or a _rack group_: three + 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. - **Live feedback** — a side panel shows node and rack tallies, validation issues that jump to the section they belong to, and a topology thumbnail. All of it updates as you type. - **Validation** — leaf, spine, exit, management leaf and management spine port budgets @@ -58,8 +59,8 @@ consistent — you edit the plan, everything else follows from it. (default), the management network, and the central rack alone. Wheel to zoom, drag to pan, hover a device to highlight its links, click a rack to jump to its editor section. - **Rack view** — height-unit elevations of every physical rack with an estimated power draw - against the rack's budget, including how a three-rack's chassis spread evenly across its - three racks. Click a rack to edit it. + against the rack's budget; a rack group's three racks are drawn in one box, showing how its + chassis spread evenly across them. Click a rack to edit it. - **BOM** — switches with their SONiC licenses, internet routers, servers (chassis derived from node counts), NICs, spares, and the complete cabling: server uplinks with breakout math, 100G fabric and router links, copper OOB and management links, and the fiber uplinks of @@ -83,16 +84,20 @@ consistent — you edit the plan, everything else follows from it. ## Screenshots The screenshots show the **Redundant** template: one partition with a redundant management -network, two three-racks with 112 workers each and three storage servers. +network, two rack groups with 112 workers each and three storage servers. ### Plan editor with live side panel +The central rack shows the counts that size the fabric; hardware models, routers, storage leaves, +fabric links and rack defaults sit in its folded Advanced section. Each rack or rack group shows +its leaf ports, fabric ratio and node count on the right of its header. + ![Plan editor](docs/screenshots/plan.png) ### Topology Production view: routers, exits and spines in the central rack, compute racks below, a -three-rack as three physical racks in one box. The management and central-rack views show the +rack group as three physical racks in one box. The management and central-rack views show the other network and the central rack alone. Server uplinks and BMC links are intentionally not drawn. @@ -100,6 +105,9 @@ drawn. ### Rack elevations +Every physical rack with its own number; a rack group's three racks share one box, with the leaf +pair and management leaf in the middle rack. + ![Rack elevations](docs/screenshots/racks.png) ### IP address plan @@ -111,6 +119,8 @@ left, the derived limits and issues on the right. ### Bill of materials +With derivation shown: every quantity broken down by central rack and rack group. + ![BOM](docs/screenshots/bom.png) ## Getting started @@ -141,7 +151,7 @@ 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 three-racks with 112 workers each and 3 storage servers. | +| 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_. | ## How it works @@ -153,7 +163,7 @@ The whole app operates on a single `Plan` document, described by Zod schemas in | -------------------------- | ------------------------------------------------------- | | `src/derive/bom.ts` | BOM lines and quantities | | `src/derive/topology.ts` | the topology graph (nodes and links) | -| `src/derive/rackLayout.ts` | rack elevations and the three-rack spread | +| `src/derive/rackLayout.ts` | rack elevations and the rack-group spread | | `src/derive/validate.ts` | validation issues | | `src/derive/nodes.ts` | node tallies per rack, partition and plan | | `src/derive/ip/` | CIDR arithmetic, the IP address plan and its validation | diff --git a/docs/screenshots/bom.png b/docs/screenshots/bom.png index b874164..c045aef 100644 Binary files a/docs/screenshots/bom.png and b/docs/screenshots/bom.png differ diff --git a/docs/screenshots/ips.png b/docs/screenshots/ips.png index 6f51924..d3e132a 100644 Binary files a/docs/screenshots/ips.png and b/docs/screenshots/ips.png differ diff --git a/docs/screenshots/plan.png b/docs/screenshots/plan.png index 4184d49..de37244 100644 Binary files a/docs/screenshots/plan.png and b/docs/screenshots/plan.png differ diff --git a/docs/screenshots/racks.png b/docs/screenshots/racks.png index 00ab2d9..c132ede 100644 Binary files a/docs/screenshots/racks.png and b/docs/screenshots/racks.png differ diff --git a/docs/screenshots/topology.png b/docs/screenshots/topology.png index f517bd9..ff721b0 100644 Binary files a/docs/screenshots/topology.png and b/docs/screenshots/topology.png differ diff --git a/src/derive/bandwidth.ts b/src/derive/bandwidth.ts index 9f161d5..847d132 100644 --- a/src/derive/bandwidth.ts +++ b/src/derive/bandwidth.ts @@ -61,7 +61,7 @@ export function requiredSuperspines(partition: Partition): number { /** "1.0 : 1", "14.4 : 1", or "—" when there is nothing to compare. */ export function formatRatio(ratio: number | null): string { - return ratio === null ? '—' : `${ratio.toFixed(1)} : 1` + return ratio === null ? '–' : `${ratio.toFixed(1)} : 1` } /** Large totals as Tbit/s, e.g. "11.5 Tbit/s"; below 1 Tbit/s as Gbit/s. */ diff --git a/src/derive/ip/ipPlan.test.ts b/src/derive/ip/ipPlan.test.ts index 59296c4..1ef3a68 100644 --- a/src/derive/ip/ipPlan.test.ts +++ b/src/derive/ip/ipPlan.test.ts @@ -165,15 +165,15 @@ describe('infrastructure ranges', () => { ]) }) - it('sizes the Redundant template per three-rack and overflows a small block', () => { + it('sizes the Redundant template per rack group and overflows a small block', () => { const plan = templates.find((t) => t.id === 'redundant')!.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 }) const racks = p.subnets.filter((s) => s.purpose === 'Management' && s.scope !== 'Central rack') expect(racks.map((s) => [s.scope, s.needed, s.prefix])).toEqual([ - ['Rack 1', 118, 24], - ['Rack 2', 115, 24], + ['Rack group 1', 118, 24], + ['Rack group 2', 115, 24], ]) plan.ipPlan.infra.partitionPrefix = 23 const small = deriveIpPlan(plan).infra.partitions[0] diff --git a/src/derive/ip/ipPlan.ts b/src/derive/ip/ipPlan.ts index d469d90..a864d0d 100644 --- a/src/derive/ip/ipPlan.ts +++ b/src/derive/ip/ipPlan.ts @@ -35,7 +35,7 @@ import { // interface (production and management switches), one BMC per server // node, management server and internet router, and each management // server's own interface. L2 management: one subnet per partition; L3: -// a central subnet plus one per compute rack (a three-rack once). +// a central subnet plus one per compute rack (a rack group once). // - Transfer networks: one per router ↔ exit link (2 × routers × exits). // Host subnets reserve 3 addresses (network, broadcast, gateway), the // loopback pool none. Every size except the transfer networks gets the @@ -242,7 +242,7 @@ function deriveFamily( ? `Σ 2^(32 − p) over ${validInternet.length} ranges = ${formatCount(total)}` : 'no internet ranges', ) - m.maxTenants = missing('maxTenants', 'n/a — IPv4 tenants share the internet network') + m.maxTenants = missing('maxTenants', 'n/a (IPv4 tenants share the internet network)') } else { const fitting = validInternet.filter((c) => c.prefix <= tenantPrefix) const tenants = fitting.reduce((n, c) => n + subnetCount(c, tenantPrefix), 0n) diff --git a/src/derive/ip/validateIp.ts b/src/derive/ip/validateIp.ts index 0429bbf..28cd781 100644 --- a/src/derive/ip/validateIp.ts +++ b/src/derive/ip/validateIp.ts @@ -239,7 +239,7 @@ function validateFamily(issues: Issue[], plan: Plan, f: FamilyResult): void { field('shootPodCidr'), `A single cluster can have at most ${formatCount(largest.value)} workers (limited by the ${largest.limitedBy}); ` + over.map((p) => `${p.name} plans ${p.workers}`).join(', ') + - ' — fine for several clusters, but no single cluster can use them all.', + '. Fine for several clusters, but no single cluster can use them all.', ) } } diff --git a/src/derive/nodes.ts b/src/derive/nodes.ts index a563fec..314a7f0 100644 --- a/src/derive/nodes.ts +++ b/src/derive/nodes.ts @@ -1,6 +1,6 @@ import type { Partition, Plan, Rack, ServerRole } from '../model/plan' -// Node tallies at every aggregation level: rack (or three-rack entity), +// Node tallies at every aggregation level: rack (or rack group), // partition, and the whole setup. "Nodes" are server nodes in compute // racks — management servers in the central rack are infrastructure and // not counted. diff --git a/src/derive/rackLayout.test.ts b/src/derive/rackLayout.test.ts index fa2685a..b44305e 100644 --- a/src/derive/rackLayout.test.ts +++ b/src/derive/rackLayout.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { createEmptyPlan } from '../model/defaults' +import { createEmptyPlan, withRackKind } from '../model/defaults' import { deriveRackLayout, physicalRackCount } from './rackLayout' import { validatePlan } from './validate' @@ -55,29 +55,31 @@ describe('deriveRackLayout', () => { }) }) -describe('three-rack', () => { +describe('rack group', () => { it('distributes chassis evenly by used height with switches in the middle', () => { const plan = createEmptyPlan() - const rack = plan.partitions[0].racks[0] - rack.kind = 'three-rack' + const partition = plan.partitions[0] + const rack = withRackKind(partition, partition.racks[0], 'rack-group') + partition.racks = [rack] // 14 chassis x 3U over three racks; mid starts with 3U of switches rack.servers[0].count = 14 * 8 const layout = deriveRackLayout(plan)[0] const [left, mid, right] = layout.racks.slice(1) - expect([left.name, mid.name, right.name]).toEqual([ - 'Rack 1 (left)', - 'Rack 1 (mid)', - 'Rack 1 (right)', - ]) + expect([left.name, mid.name, right.name]).toEqual(['Rack 1', 'Rack 2', 'Rack 3']) + expect(layout.racks.slice(1).map((r) => r.group)).toEqual( + Array(3).fill({ id: rack.id, name: 'Rack group 1' }), + ) + expect(layout.racks[0].group).toBeUndefined() expect(mid.slots.slice(0, 3).map((s) => s.label)).toEqual(['Mgmt leaf', 'Leaf 1', 'Leaf 2']) expect([left.usedU, mid.usedU, right.usedU]).toEqual([15, 15, 15]) }) it('spreads a storage group across the racks even when workers fill them unevenly', () => { const plan = createEmptyPlan() - const rack = plan.partitions[0].racks[0] - rack.kind = 'three-rack' + const partition = plan.partitions[0] + const rack = withRackKind(partition, partition.racks[0], 'rack-group') + partition.racks = [rack] // 13 worker chassis x 3U pack to 15/15/12U; without the per-group spread // the emptiest rack would then take two of the three 2U storage systems. rack.servers[0].count = 13 * 8 @@ -96,8 +98,9 @@ describe('three-rack', () => { it('flags overflow when the three racks are full', () => { const plan = createEmptyPlan() - const rack = plan.partitions[0].racks[0] - rack.kind = 'three-rack' + const partition = plan.partitions[0] + const rack = withRackKind(partition, partition.racks[0], 'rack-group') + partition.racks = [rack] // 42 chassis x 3U = 126U > 3x42U minus 3U of switches rack.servers[0].count = 42 * 8 rack.servers[0].uplink = '2x100G' @@ -111,7 +114,7 @@ describe('three-rack', () => { const plan = createEmptyPlan() const partition = plan.partitions[0] expect(physicalRackCount(partition)).toBe(2) - partition.racks[0].kind = 'three-rack' + partition.racks[0] = withRackKind(partition, partition.racks[0], 'rack-group') expect(physicalRackCount(partition)).toBe(4) }) diff --git a/src/derive/rackLayout.ts b/src/derive/rackLayout.ts index fdfb55b..1dd84c3 100644 --- a/src/derive/rackLayout.ts +++ b/src/derive/rackLayout.ts @@ -39,6 +39,8 @@ export interface RackElevation { powerWatts: number /** Power budget of the rack, W (rack setting; partition default for the central rack). */ maxPowerWatts: number + /** Set on the three physical racks of a rack group. */ + group?: { id: string; name: string } } export interface PartitionRackLayout { @@ -61,7 +63,7 @@ interface Item { } /** One physical rack of a plan rack: a single rack has one (no position); - * a three-rack has left/mid/right, with the switches in the middle and + * a rack group has left/mid/right, with the switches in the middle and * chassis distributed evenly by used height (each chassis goes to the * physical rack holding the fewest chassis of its group, the least-used * one among those; ties favor mid, then left, then right). Spreading each @@ -121,10 +123,15 @@ export function physicalRacks(rack: Rack, fabric: FabricConfig): PhysicalRack[] target.items.push(c) target.used += c.units } + const [leftName, midName, rightName] = rack.memberNames ?? [ + `${rack.name} (left)`, + `${rack.name} (middle)`, + `${rack.name} (right)`, + ] return [ - { position: 'left', name: `${rack.name} (left)`, switches: [], chassis: left.items }, - { position: 'mid', name: `${rack.name} (mid)`, switches, chassis: mid.items }, - { position: 'right', name: `${rack.name} (right)`, switches: [], chassis: right.items }, + { position: 'left', name: leftName, switches: [], chassis: left.items }, + { position: 'mid', name: midName, switches, chassis: mid.items }, + { position: 'right', name: rightName, switches: [], chassis: right.items }, ] } @@ -170,7 +177,7 @@ export function formatPower(watts: number): string { } /** Physical racks in a partition: the central rack plus one per single - * rack and three per three-rack. */ + * rack and three per rack group. */ export function physicalRackCount(partition: Partition): number { return ( 1 + partition.racks.reduce((n, rack) => n + physicalRacks(rack, partition.fabric).length, 0) @@ -208,6 +215,7 @@ export function deriveRackLayout(plan: Plan): PartitionRackLayout[] { : `${partition.id}/${rack.id}`, rackId: rack.id, name: phys.name, + ...(phys.position && { group: { id: rack.id, name: rack.name } }), heightUnits: rack.heightUnits, maxPowerWatts: rack.maxPowerWatts, ...place([...phys.switches, ...phys.chassis], rack.heightUnits), diff --git a/src/derive/topology.test.ts b/src/derive/topology.test.ts index 4c93986..e6a6fbb 100644 --- a/src/derive/topology.test.ts +++ b/src/derive/topology.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { createEmptyPlan } from '../model/defaults' +import { createEmptyPlan, withRackKind } from '../model/defaults' import { deriveTopology, filterTopology } from './topology' describe('deriveTopology', () => { @@ -109,16 +109,18 @@ describe('deriveTopology', () => { expect(links).toHaveLength(4) // 2 storage leaves x 2 spines }) - it('draws a three-rack as three physical racks sharing the middle switches', () => { + it('draws a rack group as three physical racks sharing the middle switches', () => { const plan = createEmptyPlan() - const rack = plan.partitions[0].racks[0] - rack.kind = 'three-rack' + const partition = plan.partitions[0] + const rack = withRackKind(partition, partition.racks[0], 'rack-group') + partition.racks = [rack] // 14 chassis x 3U spread evenly: 5 left, 4 mid (3U of switches), 5 right rack.servers[0].count = 14 * 8 const graph = deriveTopology(plan) const racks = graph.partitions[0].racks - expect(racks.map((r) => r.name)).toEqual(['Rack 1 (left)', 'Rack 1 (mid)', 'Rack 1 (right)']) + expect(racks.map((r) => r.name)).toEqual(['Rack 1', 'Rack 2', 'Rack 3']) + expect(racks[0].entity?.name).toBe('Rack group 1') expect(racks.map((r) => r.entity?.position)).toEqual(['left', 'mid', 'right']) const [left, mid, right] = racks expect(mid.leaves).toHaveLength(2) diff --git a/src/derive/topology.ts b/src/derive/topology.ts index 729b433..729cca5 100644 --- a/src/derive/topology.ts +++ b/src/derive/topology.ts @@ -46,13 +46,13 @@ export interface TopoLink { network: LinkNetwork } -/** One physical rack. A three-rack plan rack yields three of these that +/** One physical rack. A rack group yields three of these that * share `entity`; only the middle one holds the leaves and mgmt leaf, and * the left/right racks' server groups uplink to those. */ export interface TopoRack { id: string name: string - /** Set for the physical racks of a three-rack entity. */ + /** Set for the physical racks of a rack group. */ entity?: { id: string; name: string; position: RackPosition } leaves: TopoNode[] mgmtLeaves: TopoNode[] @@ -130,7 +130,7 @@ function deriveRack(partition: Partition, rack: Rack, links: TopoLink[]): TopoRa const physical = physicalRacks(rack, partition.fabric) return physical.map((phys): TopoRack => { // Nodes of each group that landed in this physical rack (chassis-level - // spread for three-racks; everything for a single rack). + // spread for rack groups; everything for a single rack). const nodesByGroup = new Map() for (const chassis of phys.chassis) { if (chassis.groupId === undefined) continue @@ -145,7 +145,7 @@ function deriveRack(partition: Partition, rack: Rack, links: TopoLink[]): TopoRa for (const group of rack.servers) { const count = nodesByGroup.get(group.id) ?? 0 // Keep an empty group visible in a single rack so the user sees it; - // skip it in three-rack positions it didn't spread into. + // skip it in rack-group positions it didn't spread into. if (count === 0 && phys.position) continue const nodeId = `${idPrefix}${group.id}` serverGroups.push({ diff --git a/src/derive/validate.test.ts b/src/derive/validate.test.ts index 7820577..39889d7 100644 --- a/src/derive/validate.test.ts +++ b/src/derive/validate.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { createEmptyPlan, defaultRack } from '../model/defaults' +import { createEmptyPlan, defaultRack, withRackKind } from '../model/defaults' import type { Plan } from '../model/plan' import { countIssues, @@ -114,7 +114,7 @@ describe('issue targets', () => { const issues = validatePlan(plan) const rackIssues = issuesFor(issues, { partitionId: partition.id, rackId: rack.id }) expect(rackIssues.map((i) => i.message)).toContain( - 'Only one leaf switch — no rack-level network redundancy.', + 'Only one leaf switch: no rack-level network redundancy.', ) expect(rackIssues.every((i) => i.where === `${partition.name} / ${rack.name}`)).toBe(true) }) @@ -186,7 +186,11 @@ describe('rack power budget', () => { rack.servers[0].count = 8 * 8 // 8 MicroClouds ≈ 16 kW + switches const issue = validatePlan(plan).find((i) => i.message.includes('Rack power budget')) expect(issue?.severity).toBe('error') - expect(issue?.target).toEqual({ partitionId: plan.partitions[0].id, rackId: rack.id }) + expect(issue?.target).toEqual({ + partitionId: plan.partitions[0].id, + rackId: rack.id, + field: 'advanced', + }) rack.maxPowerWatts = 20000 expect(validatePlan(plan).some((i) => i.message.includes('Rack power budget'))).toBe(false) }) @@ -312,3 +316,65 @@ describe('validatePlan GPUs and vendor availability', () => { ).toEqual(['S-ECSONIC-10-25G-3Y', 'S-ECSONIC-40-100G-3Y']) }) }) + +describe('rack names', () => { + it('warns when two physical racks share a name', () => { + const plan = basePlan() + const partition = plan.partitions[0] + expect(validatePlan(plan).some((i) => i.message.includes('used more than once'))).toBe(false) + partition.racks.push(defaultRack('Rack 1')) + const dupes = validatePlan(plan).filter((i) => i.message.includes('used more than once')) + expect(dupes).toHaveLength(1) + expect(dupes[0].severity).toBe('warning') + }) +}) + +describe('issues fixed in the Advanced section', () => { + const advanced = (plan: Plan) => + validatePlan(plan) + .filter((i) => i.target.field === 'advanced') + .map((i) => i.message) + + it("points height and power issues into the rack's Advanced section", () => { + const plan = basePlan() + const rack = plan.partitions[0].racks[0] + rack.heightUnits = 4 + rack.maxPowerWatts = 1000 + const messages = advanced(plan) + expect(messages.some((m) => m.startsWith('Rack height exceeded'))).toBe(true) + expect(messages.some((m) => m.startsWith('Rack power budget exceeded'))).toBe(true) + }) + + it("points duplicate member names there, but not a single rack's name", () => { + const plan = basePlan() + const partition = plan.partitions[0] + partition.racks.push(defaultRack('Rack 1')) + expect(advanced(plan)).toEqual([]) + + partition.racks[1] = withRackKind(partition, partition.racks[1], 'rack-group') + // The group's left rack is now "Rack 1", clashing with the single rack. + expect(advanced(plan).some((m) => m.includes('used more than once'))).toBe(true) + }) + + it("points the central rack's model issues there, but not its count issues", () => { + const plan = basePlan() + const partition = plan.partitions[0] + partition.fabric.spineModelId = 'switch-as4630' // management-only switch + partition.fabric.spineCount = 1 + partition.fabric.mgmt.serverModelId = 'server-microcloud-h13' + const central = validatePlan(plan).filter((i) => !i.target.rackId) + const inAdvanced = central.filter((i) => i.target.field === 'advanced').map((i) => i.message) + expect(inAdvanced.some((m) => m.startsWith('Spine switch:'))).toBe(true) + expect(inAdvanced.some((m) => m.startsWith('Mgmt server:'))).toBe(true) + const single = central.find((i) => i.message.startsWith('Only one spine')) + expect(single?.target.field).toBeUndefined() + }) + + it('leaves the leaf-port capacity error in the visible section', () => { + const plan = basePlan() + plan.partitions[0].racks[0].servers[0].count = 400 + const capacity = validatePlan(plan).find((i) => i.message.includes('leaf')) + expect(capacity).toBeDefined() + expect(capacity!.target.field).toBeUndefined() + }) +}) diff --git a/src/derive/validate.ts b/src/derive/validate.ts index 0675113..e006524 100644 --- a/src/derive/validate.ts +++ b/src/derive/validate.ts @@ -37,7 +37,9 @@ export interface IssueTarget { rackId?: string /** Issues of another tab: 'ips' for the IP plan. */ section?: 'ips' - /** Field id within the section (e.g. "ipv4.shootPodCidr"). */ + /** Field id within the section (e.g. "ipv4.shootPodCidr"). For a rack or + * central rack section, 'advanced' means the fix is in its folded + * Advanced section, which navigation then opens. */ field?: string } @@ -130,15 +132,17 @@ function validateRack(issues: Issue[], partition: Partition, rack: Rack): void { where: `${partition.name} / ${rack.name}`, target: { partitionId: partition.id, rackId: rack.id }, } - checkSwitchRole(issues, scope, rack.leafModelId, 'leaf', 'Leaf switch') + // Leaf model and count are edited in the rack's Advanced section. + const inAdvanced: Scope = { ...scope, target: { ...scope.target, field: 'advanced' } } + checkSwitchRole(issues, inAdvanced, rack.leafModelId, 'leaf', 'Leaf switch') const nodes = rack.servers.reduce((n, g) => n + g.count, 0) if (nodes > 0 && rack.leafCount === 0) { - report(issues, scope, 'error', 'Rack has servers but no leaf switches.') + report(issues, inAdvanced, 'error', 'Rack has servers but no leaf switches.') return } if (rack.leafCount === 1) { - report(issues, scope, 'warning', 'Only one leaf switch — no rack-level network redundancy.') + report(issues, inAdvanced, 'warning', 'Only one leaf switch: no rack-level network redundancy.') } const needed = leafPortsNeeded(rack) @@ -253,19 +257,21 @@ function validatePartition(issues: Issue[], partition: Partition): void { target: { partitionId: partition.id }, } const { fabric } = partition + // Hardware models are picked in the central rack's Advanced section. + const inAdvanced: Scope = { ...scope, target: { ...scope.target, field: 'advanced' } } - checkSwitchRole(issues, scope, fabric.spineModelId, 'spine', 'Spine switch') + checkSwitchRole(issues, inAdvanced, fabric.spineModelId, 'spine', 'Spine switch') checkSwitchRole(issues, scope, fabric.exitModelId, 'exit', 'Exit switch') - checkSwitchRole(issues, scope, fabric.mgmt.spineModelId, 'mgmt-spine', 'Mgmt spine') - checkSwitchRole(issues, scope, fabric.mgmt.leafModelId, 'mgmt-leaf', 'Mgmt leaf') + checkSwitchRole(issues, inAdvanced, fabric.mgmt.spineModelId, 'mgmt-spine', 'Mgmt spine') + checkSwitchRole(issues, inAdvanced, fabric.mgmt.leafModelId, 'mgmt-leaf', 'Mgmt leaf') if (fabric.storageLeafCount > 0) { - checkSwitchRole(issues, scope, fabric.storageLeafModelId, 'storage-leaf', 'Storage leaf') + checkSwitchRole(issues, inAdvanced, fabric.storageLeafModelId, 'storage-leaf', 'Storage leaf') } const mgmtServer = catalog[fabric.mgmt.serverModelId] if (!mgmtServer?.serverUsages?.includes('management')) { report( issues, - scope, + inAdvanced, 'error', `Mgmt server: ${itemLabel(fabric.mgmt.serverModelId)} is not a management server model.`, ) @@ -275,12 +281,12 @@ function validatePartition(issues: Issue[], partition: Partition): void { issues, scope, 'warning', - 'Management network is not redundant — a single mgmt spine and mgmt server.', + 'Management network is not redundant: a single mgmt spine and mgmt server.', ) } if (fabric.fabricType === 'leaf-spine-superspine') { - checkSwitchRole(issues, scope, fabric.superspineModelId, 'superspine', 'Superspine') + checkSwitchRole(issues, inAdvanced, fabric.superspineModelId, 'superspine', 'Superspine') if (fabric.superspineCount === 0) { report( issues, @@ -294,12 +300,12 @@ function validatePartition(issues: Issue[], partition: Partition): void { issues, scope, 'warning', - 'Superspine count is set but the fabric type is leaf-spine — superspines are ignored.', + 'Superspine count is set but the fabric type is leaf-spine, so superspines are ignored.', ) } if (fabric.spineCount === 1) { - report(issues, scope, 'warning', 'Only one spine — no fabric redundancy.') + report(issues, scope, 'warning', 'Only one spine: no fabric redundancy.') } if (fabric.spineCount === 0 && partition.racks.length > 0) { report(issues, scope, 'error', 'Partition has racks but no spines.') @@ -388,7 +394,7 @@ function validatePartition(issues: Issue[], partition: Partition): void { if (fiberNeeded > fiberAvailable) { report( issues, - scope, + inAdvanced, 'warning', `Mgmt spine fiber ports: ${fiberNeeded} mgmt leaf uplinks need ${fiberNeeded} ${speed} ports ` + `per mgmt spine, but ${itemLabel(fabric.mgmt.spineModelId)} has ${fiberAvailable}. ` + @@ -419,7 +425,7 @@ function checkAvailability(issues: Issue[], plan: Plan): void { issues, { where: 'Plan', target: {} }, 'warning', - `${itemLabel(id)} ${AVAILABILITY_NOTE[availability]} — it may no longer be orderable.`, + `${itemLabel(id)} ${AVAILABILITY_NOTE[availability]}, so it may no longer be orderable.`, ) } } @@ -430,14 +436,29 @@ export function validatePlan(plan: Plan): Issue[] { // Physical height: no rack may hold more units than it has. for (const layout of deriveRackLayout(plan)) { + // Rack names are editable, so uniqueness is checked rather than + // enforced; the physical rack names are what goes on the labels. + const seen = new Set() for (const rack of layout.racks) { + const where = `${layout.partitionName} / ${rack.name}` + const target = { partitionId: layout.partitionId, rackId: rack.rackId } + // 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 (seen.has(rack.name)) { + report( + issues, + rack.group ? inAdvanced : { where, target }, + 'warning', + `Rack name "${rack.name}" is used more than once in ${layout.partitionName}. ` + + `Give every physical rack its own name.`, + ) + } + seen.add(rack.name) if (rack.powerWatts > rack.maxPowerWatts) { report( issues, - { - where: `${layout.partitionName} / ${rack.name}`, - target: { partitionId: layout.partitionId, rackId: rack.rackId }, - }, + inAdvanced, 'error', `Rack power budget exceeded: estimated ${formatPower(rack.powerWatts)} of ` + `${formatPower(rack.maxPowerWatts)} allowed. Raise the budget in the rack's ` + @@ -447,10 +468,7 @@ export function validatePlan(plan: Plan): Issue[] { if (rack.usedU > rack.heightUnits) { report( issues, - { - where: `${layout.partitionName} / ${rack.name}`, - target: { partitionId: layout.partitionId, rackId: rack.rackId }, - }, + inAdvanced, 'error', `Rack height exceeded: ${rack.usedU}U of ${rack.heightUnits}U used.`, ) diff --git a/src/model/defaults.test.ts b/src/model/defaults.test.ts new file mode 100644 index 0000000..0df7bc3 --- /dev/null +++ b/src/model/defaults.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { + createEmptyPlan, + newRack, + nextGroupName, + nextRackNames, + physicalRackNames, + withRackKind, +} from './defaults' + +function partition() { + return createEmptyPlan().partitions[0] +} + +describe('default rack names', () => { + it('numbers every physical rack uniquely, a group taking three numbers', () => { + const p = partition() + p.racks.push(newRack(p, 'rack-group')) + p.racks.push(newRack(p, 'single')) + expect(p.racks.map((r) => r.name)).toEqual(['Rack 1', 'Rack group 1', 'Rack 5']) + expect(physicalRackNames(p)).toEqual(['Rack 1', 'Rack 2', 'Rack 3', 'Rack 4', 'Rack 5']) + expect(nextRackNames(p, 3)).toEqual(['Rack 6', 'Rack 7', 'Rack 8']) + expect(nextGroupName(p)).toBe('Rack group 2') + }) + + it('continues above the highest number instead of reusing a gap', () => { + const p = partition() + p.racks.push(newRack(p, 'single')) + p.racks.push(newRack(p, 'single')) + p.racks.splice(1, 1) // remove Rack 2 + expect(nextRackNames(p, 1)).toEqual(['Rack 4']) + }) + + it('ignores custom names when numbering', () => { + const p = partition() + p.racks[0].name = 'GPU rack' + expect(nextRackNames(p, 1)).toEqual(['Rack 1']) + }) +}) + +describe('withRackKind', () => { + it('turns a single rack into a group and back without duplicating names', () => { + const p = partition() + p.racks.push(newRack(p, 'single')) // Rack 2 + const group = withRackKind(p, p.racks[0], 'rack-group') + expect(group.name).toBe('Rack group 1') + expect(group.memberNames).toEqual(['Rack 1', 'Rack 3', 'Rack 4']) + + p.racks[0] = group + const single = withRackKind(p, group, 'single') + expect(single.name).toBe('Rack 3') // the middle rack, which holds the switches + expect(single.memberNames).toBeUndefined() + expect(single.id).toBe(group.id) + }) + + it('leaves a rack of the requested kind unchanged', () => { + const p = partition() + expect(withRackKind(p, p.racks[0], 'single')).toBe(p.racks[0]) + }) +}) diff --git a/src/model/defaults.ts b/src/model/defaults.ts index cc73848..35bbd85 100644 --- a/src/model/defaults.ts +++ b/src/model/defaults.ts @@ -41,11 +41,15 @@ export function defaultRack( name: string, kind: Rack['kind'] = 'single', defaults: RackDefaults = DEFAULT_RACK_DEFAULTS, + memberNames?: Rack['memberNames'], ): Rack { return { id: id(), name, kind, + ...(kind === 'rack-group' && { + memberNames: memberNames ?? [`${name} (left)`, `${name} (middle)`, `${name} (right)`], + }), heightUnits: defaults.heightUnits, maxPowerWatts: defaults.maxPowerWatts, leafModelId: 'switch-as7726', @@ -62,6 +66,63 @@ export function defaultRack( } } +// Default names. Every physical rack gets its own "Rack " and every +// group its own "Rack group ", numbered above the highest number in use +// rather than into gaps, so numbers keep rising left to right. Names stay +// editable; validate.ts warns about duplicates. + +/** Names of the physical racks of a partition: a single rack's name, a + * rack group's three member names. The central rack is not included. */ +export function physicalRackNames(partition: Partition): string[] { + return partition.racks.flatMap((rack) => + rack.kind === 'rack-group' && rack.memberNames ? rack.memberNames : [rack.name], + ) +} + +function nextNumber(names: string[], prefix: string): number { + const pattern = new RegExp(`^${prefix} (\\d+)$`) + return names.reduce((max, name) => Math.max(max, Number(pattern.exec(name)?.[1] ?? 0)), 0) + 1 +} + +/** The next `count` free physical rack names, "Rack " upwards. */ +export function nextRackNames(partition: Partition, count: number): string[] { + const first = nextNumber(physicalRackNames(partition), 'Rack') + return Array.from({ length: count }, (_, i) => `Rack ${first + i}`) +} + +/** The next free group name, "Rack group ". */ +export function nextGroupName(partition: Partition): string { + const groups = partition.racks.filter((r) => r.kind === 'rack-group').map((r) => r.name) + return `Rack group ${nextNumber(groups, 'Rack group')}` +} + +/** A rack to append to `partition`, with the next free name(s). */ +export function newRack(partition: Partition, kind: Rack['kind']): Rack { + if (kind === 'single') + return defaultRack(nextRackNames(partition, 1)[0], kind, partition.rackDefaults) + const [left, middle, right] = nextRackNames(partition, 3) + return defaultRack(nextGroupName(partition), kind, partition.rackDefaults, [left, middle, right]) +} + +/** `rack` switched to `kind`, renamed so every physical rack keeps a unique + * name: a single rack becomes the left rack of the new group (the two new + * racks take the next free numbers); a group becomes its middle rack, the + * one holding the leaves and the mgmt leaf. */ +export function withRackKind(partition: Partition, rack: Rack, kind: Rack['kind']): Rack { + if (rack.kind === kind) return rack + if (kind === 'rack-group') { + const [second, third] = nextRackNames(partition, 2) + return { + ...rack, + kind, + name: nextGroupName(partition), + memberNames: [rack.name, second, third], + } + } + const { memberNames, ...single } = rack + return { ...single, kind, name: memberNames?.[1] ?? rack.name } +} + export function defaultPartition(name: string): Partition { return { id: id(), diff --git a/src/model/plan.ts b/src/model/plan.ts index 49b8251..dd85050 100644 --- a/src/model/plan.ts +++ b/src/model/plan.ts @@ -49,10 +49,10 @@ export const ServerGroupSchema = z.object({ }) export type ServerGroup = z.infer -/** 'three-rack': one entity of three physical racks sharing the middle +/** 'rack-group': one entity of three physical racks sharing the middle * rack's leaf pair and mgmt leaf — compute spreads middle, then left, * then right. heightUnits applies per physical rack. */ -export const RackKindSchema = z.enum(['single', 'three-rack']) +export const RackKindSchema = z.enum(['single', 'rack-group']) export type RackKind = z.infer /** Defaults a partition applies to racks it creates; each rack keeps its @@ -67,6 +67,9 @@ export const RackSchema = z.object({ id: z.string(), name: z.string(), kind: RackKindSchema.default('single'), + /** Names of a rack group's left, middle and right physical racks; `name` + * then names the group itself. Absent for a single rack. */ + memberNames: z.tuple([z.string(), z.string(), z.string()]).optional(), heightUnits: z.number().int().positive(), /** Power budget per physical rack, W; the estimate is checked against it. */ maxPowerWatts: z.number().int().positive().default(10000), diff --git a/src/model/templates.test.ts b/src/model/templates.test.ts index 2164d1c..cb54fa6 100644 --- a/src/model/templates.test.ts +++ b/src/model/templates.test.ts @@ -3,7 +3,7 @@ import { deriveBom } from '../derive/bom' import { planNodes } from '../derive/nodes' import { validatePlan } from '../derive/validate' import { catalog } from './catalog' -import { createEmptyPlan } from './defaults' +import { createEmptyPlan, physicalRackNames } from './defaults' import { PlanSchema, type Plan } from './plan' import { templates } from './templates' @@ -27,11 +27,19 @@ describe('templates', () => { expect(planNodes(plan)).toEqual({ total: 8, byRole: { worker: 8 } }) }) - it('redundant: two three-racks with workers and three storage servers', () => { + it('redundant: two rack groups with workers and three storage servers', () => { const plan = templates.find((t) => t.id === 'redundant')!.build() const [partition] = plan.partitions expect(partition.fabric.mgmt.redundant).toBe(true) - expect(partition.racks.map((r) => r.kind)).toEqual(['three-rack', 'three-rack']) + expect(partition.racks.map((r) => r.kind)).toEqual(['rack-group', 'rack-group']) + expect(physicalRackNames(partition)).toEqual([ + 'Rack 1', + 'Rack 2', + 'Rack 3', + 'Rack 4', + 'Rack 5', + 'Rack 6', + ]) expect(planNodes(plan).byRole.storage).toBe(3) }) diff --git a/src/model/templates.ts b/src/model/templates.ts index f3cd160..f8bef50 100644 --- a/src/model/templates.ts +++ b/src/model/templates.ts @@ -26,11 +26,11 @@ function group( return { id: id(), role, modelId, count, uplink } } -/** Three-rack entity: 112 MicroCloud workers (14 chassis, spread mid → left), +/** Rack group: 112 MicroCloud workers (14 chassis, spread mid → left), * optionally with storage servers. The 2x AS7726 leaf pair has 60 ports for * servers; 112 workers + 3 storage on 2x25G use 58. */ -function threeRack(name: string, storageServers = 0): Rack { - const rack = defaultRack(name, 'three-rack') +function rackGroup(name: string, memberNames: [string, string, string], storageServers = 0): Rack { + const rack = defaultRack(name, 'rack-group', DEFAULT_RACK_DEFAULTS, memberNames) rack.servers = [group('worker', 'server-microcloud-h13', 112)] if (storageServers > 0) { rack.servers.push(group('storage', 'server-superserver-tn12', storageServers)) @@ -38,7 +38,7 @@ function threeRack(name: string, storageServers = 0): Rack { return rack } -/** One partition with a redundant management network, two three-racks and +/** One partition with a redundant management network, two rack groups and * three storage servers. */ function redundantPartition(name: string): Partition { const fabric = defaultFabric() @@ -47,7 +47,10 @@ function redundantPartition(name: string): Partition { id: id(), name, fabric, - racks: [threeRack('Rack 1', 3), threeRack('Rack 2')], + racks: [ + rackGroup('Rack group 1', ['Rack 1', 'Rack 2', 'Rack 3'], 3), + rackGroup('Rack group 2', ['Rack 4', 'Rack 5', 'Rack 6']), + ], rackDefaults: { ...DEFAULT_RACK_DEFAULTS }, } } @@ -83,7 +86,7 @@ export const templates: PlanTemplate[] = [ id: 'redundant', name: 'Redundant', description: - 'One partition, redundant management network, two three-racks with 224 workers and 3 storage servers.', + 'One partition, redundant management network, two rack groups with 224 workers and 3 storage servers.', build: () => plan('Redundant', 'single-zone', [redundantPartition('Partition 1')]), }, { diff --git a/src/store/planStore.ts b/src/store/planStore.ts index 962498d..41beb26 100644 --- a/src/store/planStore.ts +++ b/src/store/planStore.ts @@ -2,7 +2,7 @@ import { create, useStore } from 'zustand' import { useShallow } from 'zustand/react/shallow' import { persist } from 'zustand/middleware' import { temporal } from 'zundo' -import { createEmptyPlan, defaultPartition, defaultRack } from '../model/defaults' +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' import { normalizePlan } from '../model/normalize' @@ -38,6 +38,7 @@ interface PlannerState { addRack: (partitionId: string, kind?: Rack['kind']) => void removeRack: (partitionId: string, rackId: string) => void patchRack: (partitionId: string, rackId: string, patch: Partial) => void + setRackKind: (partitionId: string, rackId: string, kind: Rack['kind']) => void addServerGroup: (partitionId: string, rackId: string) => void removeServerGroup: (partitionId: string, rackId: string, groupId: string) => void patchServerGroup: ( @@ -166,7 +167,7 @@ export const usePlanStore = create()( set((s) => ({ plan: mapPartition(s.plan, partitionId, (p) => ({ ...p, - racks: [...p.racks, defaultRack(`Rack ${p.racks.length + 1}`, kind, p.rackDefaults)], + racks: [...p.racks, newRack(p, kind)], })), })), removeRack: (partitionId, rackId) => @@ -180,6 +181,13 @@ export const usePlanStore = create()( set((s) => ({ plan: mapRack(s.plan, partitionId, rackId, (r) => ({ ...r, ...patch })), })), + setRackKind: (partitionId, rackId, kind) => + set((s) => ({ + plan: mapPartition(s.plan, partitionId, (p) => ({ + ...p, + racks: p.racks.map((r) => (r.id === rackId ? withRackKind(p, r, kind) : r)), + })), + })), addServerGroup: (partitionId, rackId) => set((s) => ({ plan: mapRack(s.plan, partitionId, rackId, (r) => ({ @@ -189,7 +197,7 @@ export const usePlanStore = create()( { id: crypto.randomUUID(), role: 'worker', - modelId: 'server-microcloud-x11', + modelId: 'server-microcloud-h13', count: 8, uplink: '2x25G', }, diff --git a/src/uiText.test.ts b/src/uiText.test.ts new file mode 100644 index 0000000..40a18e1 --- /dev/null +++ b/src/uiText.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' + +// No long dashes (U+2014) anywhere in the UI: labels, info texts, issue +// messages, placeholders. Prose uses a colon, comma or parentheses, section +// subtitles a middle dot, empty cells an en dash. Comments may keep them. + +const sources = import.meta.glob(['./**/*.{ts,tsx}', '!./**/*.test.ts'], { + query: '?raw', + import: 'default', + eager: true, +}) + +/** Source lines outside comments that contain a long dash. */ +function longDashes(path: string, text: string): string[] { + return text + .split('\n') + .map((line, i) => ({ line, n: i + 1 })) + .filter(({ line }) => !/^\s*(\/\/|\/\*|\*)/.test(line)) + .map(({ line, n }) => ({ code: line.replace(/\s\/\/\s.*$/, ''), n })) + .filter(({ code }) => code.includes('—')) + .map(({ code, n }) => `${path}:${n}: ${code.trim()}`) +} + +describe('UI text', () => { + it('reads the app sources', () => { + expect(Object.keys(sources).length).toBeGreaterThan(20) + }) + + it('uses no long dashes', () => { + const hits = Object.entries(sources).flatMap(([path, text]) => longDashes(path, text)) + expect(hits).toEqual([]) + }) +}) diff --git a/src/views/BomView.tsx b/src/views/BomView.tsx index 598ab71..f0e4bbd 100644 --- a/src/views/BomView.tsx +++ b/src/views/BomView.tsx @@ -295,7 +295,7 @@ export default function BomView() { ? money(sub.total) : sub.total > 0 ? `≥ ${money(sub.total)}` - : '—'} + : '–'} )} @@ -308,7 +308,7 @@ export default function BomView() { key={line.catalogId} className={`border-b border-gray-100 ${isSpare ? 'text-gray-500' : ''}`} > - {line.partNumber ?? '—'} + {line.partNumber ?? '–'} {identity(line) && {identity(line)} } @@ -339,7 +339,7 @@ export default function BomView() { {isSpare ? ( - {price === undefined ? '—' : money(price)} + {price === undefined ? '–' : money(price)} ) : ( setPrice( @@ -363,7 +363,7 @@ export default function BomView() { {showPrices && ( {total === undefined ? ( - + ) : ( money(total) )} diff --git a/src/views/PlanView.tsx b/src/views/PlanView.tsx index 5ead858..7b00f0d 100644 --- a/src/views/PlanView.tsx +++ b/src/views/PlanView.tsx @@ -58,33 +58,9 @@ export default function PlanView() {
-
- -
- Topology -
- {TopologyVariantSchema.options.map((variant) => ( - - ))} -
-
-
+ {/* Plan actions first, then the plan's own settings. */} +
+
+
+ +
+ Topology +
+ {TopologyVariantSchema.options.map((variant) => ( + + ))} +
+
+
{plan.partitions.map((partition) => ( @@ -135,7 +138,7 @@ export default function PlanView() { @@ -177,12 +180,12 @@ export default function PlanView() { Add rack to {partition.name}
diff --git a/src/views/RackLayoutView.tsx b/src/views/RackLayoutView.tsx index 25eeacb..1d04b25 100644 --- a/src/views/RackLayoutView.tsx +++ b/src/views/RackLayoutView.tsx @@ -5,7 +5,7 @@ import { type SlotKind, } from '../derive/rackLayout' import { usePlanStore } from '../store/planStore' -import { Icon, SLOT_ICON } from './icons' +import { Icon, SECTION_ICON, SLOT_ICON } from './icons' import { navigateTo } from './plan/navigate' // Rack elevations with a height-unit scale. U numbers count from the @@ -51,7 +51,7 @@ function Rack({ rack, onClick }: { rack: RackElevation; onClick?: () => void }) fill={overflowU > 0 || rack.powerWatts > rack.maxPowerWatts ? '#b91c1c' : '#6b7280'} > {rack.usedU}U of {rack.heightUnits}U used - {overflowU > 0 ? ` — ${overflowU}U over` : ''} · ~{formatPower(rack.powerWatts)} of{' '} + {overflowU > 0 ? `, ${overflowU}U over` : ''} · ~{formatPower(rack.powerWatts)} of{' '} {formatPower(rack.maxPowerWatts)} @@ -112,7 +112,7 @@ function Rack({ rack, onClick }: { rack: RackElevation; onClick?: () => void }) stroke={overflowing ? '#dc2626' : style.stroke} strokeWidth={1} /> - {`${slot.label} — ${slot.sublabel ?? ''} (${uRange}, ${slot.units}U)`} + {`${slot.label}: ${slot.sublabel ?? ''} (${uRange}, ${slot.units}U)`} {slot.units === 1 ? ( <> void }) ) } +/** Consecutive elevations of one rack group, or a lone rack, in order. */ +function groupRuns( + racks: RackElevation[], +): { group?: RackElevation['group']; racks: RackElevation[] }[] { + const runs: { group?: RackElevation['group']; racks: RackElevation[] }[] = [] + for (const rack of racks) { + const last = runs.at(-1) + if (rack.group && last?.group?.id === rack.group.id) last.racks.push(rack) + else runs.push({ group: rack.group, racks: [rack] }) + } + return runs +} + export default function RackLayoutView() { const plan = usePlanStore((s) => s.plan) const partitions = deriveRackLayout(plan) @@ -191,13 +204,44 @@ export default function RackLayoutView() {

{partition.partitionName}

)}
- {partition.racks.map((rack) => ( - goTo(partition.partitionId, rack.rackId)} - /> - ))} + {groupRuns(partition.racks).map((run) => + run.group ? ( +
+ +
+ {run.racks.map((rack) => ( + goTo(partition.partitionId, rack.rackId)} + /> + ))} +
+
+ ) : ( + run.racks.map((rack) => ( + goTo(partition.partitionId, rack.rackId)} + /> + )) + ), + )}

Estimated power, all racks: ~ {formatPower(partition.racks.reduce((w, r) => w + r.powerWatts, 0))} · per-device diff --git a/src/views/TopologyView.tsx b/src/views/TopologyView.tsx index 7ac758f..7c9e861 100644 --- a/src/views/TopologyView.tsx +++ b/src/views/TopologyView.tsx @@ -52,7 +52,7 @@ export default function TopologyView() { if (!hasContent) { return (

- Nothing to draw yet — add spines and racks in the Plan tab. + Nothing to draw yet. Add spines and racks in the Plan tab.

) } diff --git a/src/views/icons/custom.ts b/src/views/icons/custom.ts index e7f82a0..06c55d8 100644 --- a/src/views/icons/custom.ts +++ b/src/views/icons/custom.ts @@ -22,8 +22,8 @@ export const Rack = createLucideIcon('rack', [ ['path', { d: 'M8 18.75h.01', key: 'u3' }], ]) -/** A three-rack: three physical racks side by side. */ -export const ThreeRack = createLucideIcon('three-rack', [ +/** A rack group: three physical racks side by side. */ +export const RackGroup = createLucideIcon('rack-group', [ ['rect', { x: '1.5', y: '4', width: '6', height: '16', rx: '1', key: 'l' }], ['rect', { x: '9', y: '4', width: '6', height: '16', rx: '1', key: 'm' }], ['rect', { x: '16.5', y: '4', width: '6', height: '16', rx: '1', key: 'r' }], diff --git a/src/views/icons/index.tsx b/src/views/icons/index.tsx index 188ee36..f4ed1f2 100644 --- a/src/views/icons/index.tsx +++ b/src/views/icons/index.tsx @@ -52,7 +52,7 @@ import type { TopoNodeKind } from '../../derive/topology' import type { CatalogCategory } from '../../model/catalog' import type { ExternalNetwork } from '../../model/plan' import type { View } from '../../store/planStore' -import { NetworkSwitch, Rack, ThreeRack } from './custom' +import { NetworkSwitch, Rack, RackGroup } from './custom' // The single place icons come from (Lucide plus the custom switch and rack // glyphs). Views import from here, never from lucide-react directly — an @@ -61,7 +61,7 @@ import { NetworkSwitch, Rack, ThreeRack } from './custom' // icon a type error. export type { LucideIcon } -export { NetworkSwitch, Rack, ThreeRack } +export { NetworkSwitch, Rack, RackGroup } export const TAB_ICON: Record = { plan: PencilRuler, @@ -96,7 +96,7 @@ export const SECTION_ICON = { centralRack: Rack, mgmtNetwork: Cable, rack: Rack, - threeRack: ThreeRack, + rackGroup: RackGroup, externalNetworks: Globe, internet: Globe, projectNetworks: Layers, diff --git a/src/views/ips/ExampleClusterCard.tsx b/src/views/ips/ExampleClusterCard.tsx index d02178d..855614a 100644 --- a/src/views/ips/ExampleClusterCard.tsx +++ b/src/views/ips/ExampleClusterCard.tsx @@ -22,7 +22,7 @@ const ROWS: { label: string; value: (e: ExampleCluster) => string[] }[] = [ { label: 'Seed service CIDR', value: (e) => [formatCidr(e.seedServiceCidr)] }, { label: 'Reserve', - value: (e) => (e.reserveCidrs.length ? e.reserveCidrs.map(formatCidr) : ['—']), + value: (e) => (e.reserveCidrs.length ? e.reserveCidrs.map(formatCidr) : ['–']), }, ] @@ -40,7 +40,7 @@ export default function ExampleClusterCard({ result }: { result: IpPlanResult }) /> Example cluster{' '} - — first project network{name ? ` in ${name}` : ''} + · first project network{name ? ` in ${name}` : ''}
diff --git a/src/views/ips/InfraCard.tsx b/src/views/ips/InfraCard.tsx index 14f4b30..c0fbdcb 100644 --- a/src/views/ips/InfraCard.tsx +++ b/src/views/ips/InfraCard.tsx @@ -23,7 +23,7 @@ export default function InfraCard({ infra, result }: { infra: IpInfra; result: I icon={SECTION_ICON.infrastructure} className="mr-1.5 inline h-4 w-4 align-[-3px] text-gray-500" /> - Infrastructure — IPv4, per partition + Infrastructure · IPv4, per partition
@@ -76,7 +76,7 @@ export default function InfraCard({ infra, result }: { infra: IpInfra; result: I
@@ -104,7 +104,7 @@ export default function InfraCard({ infra, result }: { infra: IpInfra; result: I {p.block - ? `${formatCount(p.usedAddresses)} of ${formatCount(size(p.block))} addresses — needs a /${p.requiredPrefix}` + ? `${formatCount(p.usedAddresses)} of ${formatCount(size(p.block))} addresses, needs a /${p.requiredPrefix}` : `needs a /${p.requiredPrefix}`}
@@ -139,7 +139,7 @@ export default function InfraCard({ infra, result }: { infra: IpInfra; result: I {s.cidr ? ( formatCidr(s.cidr) ) : ( - /{s.prefix} — does not fit + /{s.prefix} does not fit )} {s.detail} diff --git a/src/views/ips/ResultsCard.tsx b/src/views/ips/ResultsCard.tsx index dd82e7d..e47dcaa 100644 --- a/src/views/ips/ResultsCard.tsx +++ b/src/views/ips/ResultsCard.tsx @@ -40,7 +40,7 @@ function Value({ f, id }: { f: FamilyResult; id: MetricId }) { if (m.value === null) { return ( - + ) } @@ -62,7 +62,7 @@ export default function ResultsCard({ result }: { result: IpPlanResult }) { icon={SECTION_ICON.limits} className="mr-1.5 inline h-4 w-4 align-[-3px] text-gray-500" /> - Limits — hover a value for its formula + Limits · hover a value for its formula @@ -111,7 +111,7 @@ export default function ResultsCard({ result }: { result: IpPlanResult }) { ) : ( - + )} diff --git a/src/views/ips/infos.ts b/src/views/ips/infos.ts index 3f44869..406baeb 100644 --- a/src/views/ips/infos.ts +++ b/src/views/ips/infos.ts @@ -22,7 +22,7 @@ export const IP_INFO = { href: DOCS.architecture, }, projectPrefix: { - text: 'Size of one project network — the private network the machines of a cluster get their addresses from. The docs use a /22 tenant network as example.', + text: 'Size of one project network: the private network the machines of a cluster get their addresses from. The docs use a /22 tenant network as example.', href: DOCS.networking, }, frrListenRange: { diff --git a/src/views/plan/CentralRackSection.tsx b/src/views/plan/CentralRackSection.tsx index e11026b..dac21a6 100644 --- a/src/views/plan/CentralRackSection.tsx +++ b/src/views/plan/CentralRackSection.tsx @@ -1,4 +1,10 @@ -import { nosOptions, serversForUsage, switchesForRole, type SwitchRole } from '../../model/catalog' +import { + itemLabel, + nosOptions, + serversForUsage, + switchesForRole, + type SwitchRole, +} from '../../model/catalog' import type { FabricConfig, MgmtNetwork, Partition } from '../../model/plan' import { formatTally, partitionNodes } from '../../derive/nodes' import { formatGbps, formatRatio, spineBandwidth } from '../../derive/bandwidth' @@ -20,6 +26,22 @@ function switchOptions(role: SwitchRole) { })) } +/** The central rack's models while their pickers are folded away: + * production switches once each, then the management gear. */ +function modelSummary(fabric: FabricConfig): string { + const production = [ + fabric.spineModelId, + ...(fabric.fabricType === 'leaf-spine-superspine' ? [fabric.superspineModelId] : []), + ...(fabric.storageLeafCount > 0 ? [fabric.storageLeafModelId] : []), + ] + const mgmtSwitches = [fabric.mgmt.spineModelId, fabric.mgmt.leafModelId] + return [ + [...new Set(production)].map(itemLabel).join(', '), + `mgmt ${[...new Set(mgmtSwitches)].map(itemLabel).join(', ')}`, + `mgmt servers ${itemLabel(fabric.mgmt.serverModelId)}`, + ].join(' · ') +} + export default function CentralRackSection({ partition, issues, @@ -29,6 +51,7 @@ export default function CentralRackSection({ }) { const own = issuesFor(issues, { partitionId: partition.id }) const hasErrors = own.some((i) => i.severity === 'error') + const advancedIssues = own.filter((i) => i.target.field === 'advanced') const patchFabric = usePlanStore((s) => s.patchFabric) const patchRackDefaults = usePlanStore((s) => s.patchRackDefaults) const { fabric } = partition @@ -47,11 +70,11 @@ export default function CentralRackSection({

- Central rack — {partition.name} + Central rack · {partition.name} @@ -80,6 +103,7 @@ export default function CentralRackSection({

+

{modelSummary(fabric)}

- patch({ spineModelId: v })} - /> patch({ exitSwitchCount: n })} /> - patch({ routerCount: n })} - /> {hasSuperspine && ( - <> - patch({ superspineModelId: v })} - /> - patch({ superspineCount: n })} - /> - + patch({ superspineCount: n })} + /> )} - patch({ storageLeafModelId: v })} - /> - patch({ storageLeafCount: n })} - />

@@ -177,7 +163,7 @@ export default function CentralRackSection({ icon={SECTION_ICON.mgmtNetwork} className="mr-1.5 inline h-4 w-4 align-[-3px] text-amber-600" /> - Management network — out-of-band + Management network · out-of-band patchMgmt({ redundant: v === 'redundant' })} /> - patchMgmt({ spineModelId: v })} - /> - ({ - value: i.id, - label: optionLabel(i), - }))} - onChange={(v) => patchMgmt({ serverModelId: v })} - /> - patchMgmt({ leafModelId: v })} - /> - patchMgmt({ leafPerRack: n })} - /> -
+
Advanced{' '} - — network OS, fabric links and rack defaults + · devices and models, network OS, fabric links and rack defaults + {advancedIssues.length > 0 && ( + + + + )} -
+ +
Devices and models
+
+ patch({ routerCount: n })} + /> + patch({ spineModelId: v })} + /> + {hasSuperspine && ( + patch({ superspineModelId: v })} + /> + )} + patch({ storageLeafModelId: v })} + /> + patch({ storageLeafCount: n })} + /> + patchMgmt({ spineModelId: v })} + /> + patchMgmt({ leafModelId: v })} + /> + patchMgmt({ leafPerRack: n })} + /> + ({ + value: i.id, + label: optionLabel(i), + }))} + onChange={(v) => patchMgmt({ serverModelId: v })} + /> +
+ +
Fabric links
+
+
+

+ Every leaf uplinks to every spine with this many 100G links. Each switch's single + management interface connects to the management network on its own. +

+ +
Rack defaults
+

- Every leaf uplinks to every spine with this many 100G links. Each switch's single - management interface connects to the management network on its own. Rack defaults apply to - racks added to this partition; each rack can override them in its own Advanced section. - The central rack uses these values directly. + Rack defaults apply to racks added to this partition; each rack can override them in its + own Advanced section. The central rack uses these values directly.

diff --git a/src/views/plan/ExternalNetworksSection.tsx b/src/views/plan/ExternalNetworksSection.tsx index 09802f5..906a4aa 100644 --- a/src/views/plan/ExternalNetworksSection.tsx +++ b/src/views/plan/ExternalNetworksSection.tsx @@ -34,11 +34,11 @@ 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 exit switches diff --git a/src/views/plan/RackSection.tsx b/src/views/plan/RackSection.tsx index d3c5638..71ee308 100644 --- a/src/views/plan/RackSection.tsx +++ b/src/views/plan/RackSection.tsx @@ -1,4 +1,10 @@ -import { catalog, gpusForServer, serversForUsage, switchesForRole } from '../../model/catalog' +import { + catalog, + gpusForServer, + itemLabel, + serversForUsage, + switchesForRole, +} from '../../model/catalog' import type { Partition, Rack, ServerGroup } from '../../model/plan' import { formatTally, rackNodes } from '../../derive/nodes' import { formatGbps, formatRatio, rackBandwidth } from '../../derive/bandwidth' @@ -142,6 +148,9 @@ function ServerGroupRow({ ) } +/** Physical racks of a rack group, in `memberNames` order. */ +const MEMBER_LABELS = ['Left rack', 'Middle rack (leaves, mgmt leaf)', 'Right rack'] + export default function RackSection({ partition, rack, @@ -153,7 +162,9 @@ export default function RackSection({ }) { const own = issuesFor(issues, { partitionId: partition.id, rackId: rack.id }) const hasErrors = own.some((i) => i.severity === 'error') + const advancedIssues = own.filter((i) => i.target.field === 'advanced') const patchRack = usePlanStore((s) => s.patchRack) + const setRackKind = usePlanStore((s) => s.setRackKind) const removeRack = usePlanStore((s) => s.removeRack) const addServerGroup = usePlanStore((s) => s.addServerGroup) @@ -161,6 +172,7 @@ export default function RackSection({ const needed = leafPortsNeeded(rack) const available = leafPortsAvailable(rack, partition) const overCapacity = needed > available + const nodes = rackNodes(rack) return (
Remove rack -
+
-
+
patchRack(partition.id, rack.id, { kind: v as Rack['kind'] })} - /> -
-
- ({ - value: i.id, - label: optionLabel(i), - }))} - onChange={(v) => patchRack(partition.id, rack.id, { leafModelId: v })} - /> -
-
- patchRack(partition.id, rack.id, { leafCount: n })} + onChange={(v) => setRackKind(partition.id, rack.id, v as Rack['kind'])} />
-
- {/* Tallies on their own line: node count, leaf port usage, fabric ratio. */} -
- - {formatTally(rackNodes(rack))} - - - Leaf ports: {needed} used / {Math.max(available, 0)} available - - {bandwidth.ratio !== null && ( + {rack.memberNames && ( + {rack.memberNames.join(' · ')} + )} + {/* Tallies on the right, as in the central rack: leaf ports, fabric + ratio, issues and node count. */} + - Fabric {formatRatio(bandwidth.ratio)} + Leaf ports {needed} / {Math.max(available, 0)} - )} - + {bandwidth.ratio !== null && ( + + + Fabric {formatRatio(bandwidth.ratio)} + + + )} + + + + {nodes.total} {nodes.total === 1 ? 'node' : 'nodes'} + + +
{rack.servers.map((group) => ( ))} -
- - Advanced — rack height and power budget - -
- patchRack(partition.id, rack.id, { heightUnits: n })} - /> - patchRack(partition.id, rack.id, { maxPowerWatts: n * 1000 })} - /> -
-
+
+ + Advanced{' '} + + · {rack.memberNames ? 'rack names, ' : ''}leaves, height and power budget + + {advancedIssues.length > 0 && ( + + + + )} + +
+ {rack.memberNames && ( +
+ {MEMBER_LABELS.map((label, i) => ( + + ))} +
+ )} +
+
+ ({ + value: i.id, + label: optionLabel(i), + }))} + onChange={(v) => patchRack(partition.id, rack.id, { leafModelId: v })} + /> +
+
+ patchRack(partition.id, rack.id, { leafCount: n })} + /> +
+
+ patchRack(partition.id, rack.id, { heightUnits: n })} + /> +
+
+ patchRack(partition.id, rack.id, { maxPowerWatts: n * 1000 })} + /> +
+
+
+
) } diff --git a/src/views/plan/SidePanel.tsx b/src/views/plan/SidePanel.tsx index 211ccde..c1ba581 100644 --- a/src/views/plan/SidePanel.tsx +++ b/src/views/plan/SidePanel.tsx @@ -85,7 +85,7 @@ export default function SidePanel({ plan, issues }: { plan: Plan; issues: Issue[ ? bom.reduce((sum, l) => sum + (lineTotal({ currency, prices }, l) ?? 0), 0) : 0 const complete = bom.every((l) => lineTotal({ currency, prices }, l) !== undefined) - // Physical racks, central racks included (a three-rack counts as three). + // Physical racks, central racks included (a rack group counts as three). const racks = plan.partitions.reduce((n, p) => n + physicalRackCount(p), 0) const graph = filterTopology(deriveTopology(plan), 'production') const hasTopology = graph.partitions.some( diff --git a/src/views/plan/navigate.ts b/src/views/plan/navigate.ts index 33006bb..29296c0 100644 --- a/src/views/plan/navigate.ts +++ b/src/views/plan/navigate.ts @@ -20,10 +20,13 @@ export function anchorFor(target: IssueTarget): string | undefined { const FLASH_MS = 1600 -export function revealSection(anchor: string | undefined): void { +/** Scrolls to and flashes a section; `openAdvanced` also unfolds its + * Advanced section, for issues whose fix lives there. */ +export function revealSection(anchor: string | undefined, openAdvanced = false): void { if (!anchor) return const el = document.getElementById(anchor) if (!el) return + if (openAdvanced) el.querySelector('details[data-advanced]')?.setAttribute('open', '') el.scrollIntoView({ behavior: 'smooth', block: 'center' }) el.classList.remove('section-flash') // Restart the animation even when the same section is flashed twice. @@ -36,12 +39,13 @@ export function revealSection(anchor: string | undefined): void { * section (switching tabs first lets the section mount). */ export function navigateTo(target: IssueTarget): void { const anchor = anchorFor(target) + const openAdvanced = target.field === 'advanced' const view = target.section === 'ips' ? 'ips' : 'plan' const store = usePlanStore.getState() if (store.activeView === view) { - revealSection(anchor) + revealSection(anchor, openAdvanced) return } store.setActiveView(view) - setTimeout(() => revealSection(anchor), 30) + setTimeout(() => revealSection(anchor, openAdvanced), 30) } diff --git a/src/views/topology/Diagram.tsx b/src/views/topology/Diagram.tsx index 8658876..7bf9318 100644 --- a/src/views/topology/Diagram.tsx +++ b/src/views/topology/Diagram.tsx @@ -80,7 +80,7 @@ export interface DiagramTarget { interface BoxLayout { rect: Rect name: string - /** Enclosing box of a three-rack entity (drawn dashed, behind its racks). */ + /** Enclosing box of a rack group (drawn dashed, behind its racks). */ entity?: boolean target?: DiagramTarget } @@ -147,8 +147,8 @@ function layoutRack( rects.set(group.id, { x: x + RACK_PAD, y: cy, w: innerW, h: SRV_H }) cy += SRV_H + 8 } - // An empty physical rack (a three-rack side nothing spread into) still - // gets a small body so the entity reads as three racks. + // An empty physical rack (a rack-group side nothing spread into) still + // gets a small body so the group reads as three racks. if (cy === y + RACK_HEAD) cy += 22 return { rect: { x, y, w: innerW + 2 * RACK_PAD, h: cy - y + RACK_PAD - 8 }, @@ -158,14 +158,14 @@ function layoutRack( } const RACK_GAP = 24 -/** Gap between the physical racks of one three-rack entity. */ +/** Gap between the physical racks of one rack group. */ const ENTITY_GAP = 10 -/** Padding of the box drawn around a three-rack entity, and the room its +/** Padding of the box drawn around a rack group, and the room its * label needs above the physical racks' own headers. */ const ENTITY_PAD = 8 const ENTITY_HEAD = 26 -/** Whether two adjacent racks belong to the same three-rack entity. */ +/** Whether two adjacent racks belong to the same rack group. */ function sameEntity(a: TopoRack | undefined, b: TopoRack | undefined): boolean { return !!a?.entity && !!b?.entity && a.entity.id === b.entity.id } @@ -246,7 +246,7 @@ function layoutPartition( } // Compute racks and the storage box below. The physical racks of a - // three-rack entity sit close together inside an enclosing box. + // rack group sit close together inside an enclosing box. const rackY = boxRect.y + boxRect.h + 56 let x = Math.max(0, (fabricW - racksW - storageW) / 2) let maxRackH = 0 @@ -276,7 +276,7 @@ function layoutPartition( w: x - entityStartX, h: h + ENTITY_HEAD + ENTITY_PAD, }, - name: `${rack.entity!.name} (three-rack)`, + name: rack.entity!.name, entity: true, target: { partitionId: partition.id, rackId: rack.entity!.id }, }) @@ -418,7 +418,7 @@ export default function Diagram({ }: { graph: TopologyGraph fit?: boolean - /** Click on a rack, three-rack or central rack box → jump to its editor section. */ + /** Click on a rack, rack group or central rack box → jump to its editor section. */ onNavigate?: (target: DiagramTarget) => void }) { const layout = computeLayout(graph)