Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions CLAUDE.md

Large diffs are not rendered by default.

26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -83,23 +84,30 @@ 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.

![Topology](docs/screenshots/topology.png)

### 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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 |
Expand Down
Binary file modified docs/screenshots/bom.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/ips.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/plan.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/racks.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/topology.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/derive/bandwidth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
6 changes: 3 additions & 3 deletions src/derive/ip/ipPlan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 2 additions & 2 deletions src/derive/ip/ipPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/derive/ip/validateIp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
)
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/derive/nodes.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
31 changes: 17 additions & 14 deletions src/derive/rackLayout.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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
Expand All @@ -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'
Expand All @@ -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)
})

Expand Down
18 changes: 13 additions & 5 deletions src/derive/rackLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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 },
]
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down
12 changes: 7 additions & 5 deletions src/derive/topology.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions src/derive/topology.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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<string, number>()
for (const chassis of phys.chassis) {
if (chassis.groupId === undefined) continue
Expand All @@ -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({
Expand Down
Loading
Loading