From 9b0b27fce63c72a6a68f1feca46b95e0f079d0ad Mon Sep 17 00:00:00 2001 From: Ilker Manap Date: Sat, 31 Oct 2026 19:16:10 +0100 Subject: [PATCH] Server heights from firmware and model; rack elevation at real size Nobody types in how many units a server takes. Commissioning reads the SMBIOS chassis height; without one the model decides, from a table of heights checked against vendor documentation (Dell, HPE, Lenovo, xFusion, Fujitsu, Inspur/KAYTUS, GIGABYTE; Supermicro model numbers do not carry the height), and rack_units in config.json covers the rest and overrides both. Sleds of a multi-node chassis share its units. NetBox device types get the height (0 for sleds, which go into the rack without a position; Rollcall's own types are corrected). The rack elevation spans each server over its height, draws a chassis's sleds as one block, marks unknown heights and lists overlaps. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++ README.md | 11 ++++ agent/rollcall-agent.py | 7 +++ cmd/rollcall/config.go | 5 +- cmd/rollcall/hwsize.go | 104 +++++++++++++++++++++++++++++++++ cmd/rollcall/hwsize_test.go | 81 +++++++++++++++++++++++++ cmd/rollcall/ipam.go | 2 +- cmd/rollcall/nbdevices.go | 44 ++++++++++++-- cmd/rollcall/nbdevices_test.go | 10 ++-- cmd/rollcall/server.go | 3 + cmd/rollcall/web/app.js | 31 ++++++++-- cmd/rollcall/web/style.css | 2 + 12 files changed, 290 insertions(+), 16 deletions(-) create mode 100644 cmd/rollcall/hwsize.go create mode 100644 cmd/rollcall/hwsize_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index cb49373..53ce6cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ publish a tag that has no section here. ## [Unreleased] ### Added +- Server heights without typing them in: SMBIOS chassis height from commissioning, else a model + table checked against vendor documentation (Dell, HPE, Lenovo, xFusion, Fujitsu, Inspur/KAYTUS, + GIGABYTE; not Supermicro, whose model numbers do not carry it), else `rack_units` per model in + `config.json`. Multi-node sleds share their chassis. The rack elevation spans servers over their + height, groups sleds, marks unknown heights and warns about overlaps; NetBox device types get the + height (0 for sleds, placed without a position). The machine API has `size`. - Network page for large sites: a problems strip (offline rack controllers and the VLANs left without DHCP/PXE, provisioning VLANs without it, subnets over 80%, IPAM errors), filters by fabric, purpose and rack controller, a search that shows a machine's path (interface, address, VLAN, diff --git a/README.md b/README.md index ef9c02d..a55fcb6 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,17 @@ tag rules see `.Node.Building`, `.Node.Room`, `.Node.Rack`, `.Node.Unit`, so a rack can drive a tag (`{{ eq .Node.Rack "R12" }}`) or a Slurm `topology.conf` overlay. +**Server heights are not typed in.** `unit` is the lowest U a machine +occupies; how many it takes comes from the firmware (SMBIOS type 3 "Height", +read at commissioning), else from the model (a table of checked heights for +Dell, HPE, Lenovo, xFusion, Fujitsu, Inspur/KAYTUS and GIGABYTE models, see +`hwsize.go`), else from `rack_units` in `config.json` for models neither +knows (`"rack_units": {"SYS-6029P-TRT": 2}`; it also overrides the others). +Sleds of a multi-node chassis (Dell C-series, blades) share the chassis's +units: the elevation draws them as one block, and NetBox gets a device type +of height 0 placed in the rack without a position. The elevation spans each +server over its height, marks unknown heights `?U` and warns about overlaps. + ## Network and rack controllers ### Model diff --git a/agent/rollcall-agent.py b/agent/rollcall-agent.py index 5d50961..d2e5317 100644 --- a/agent/rollcall-agent.py +++ b/agent/rollcall-agent.py @@ -90,6 +90,13 @@ def dmi_system(): # fallbacks via sysfs d["system-uuid"] = d["system-uuid"] or readf("/sys/class/dmi/id/product_uuid") d["system-serial-number"] = d["system-serial-number"] or readf("/sys/class/dmi/id/product_serial") + # SMBIOS type 3: the enclosure's height in rack units ("2 U"; often "Unspecified") + # and power cords; the region falls back to the model when the height is missing + for b in dmi_blocks(3)[:1]: + m = re.match(r"(\d+)\s*U\b", b.get("Height", "")) + d["chassis-height-u"] = int(m.group(1)) if m else None + cords = b.get("Number Of Power Cords", "") + d["chassis-power-cords"] = int(cords) if cords.isdigit() else None return d diff --git a/cmd/rollcall/config.go b/cmd/rollcall/config.go index 0924785..9be34c7 100644 --- a/cmd/rollcall/config.go +++ b/cmd/rollcall/config.go @@ -34,7 +34,10 @@ type Config struct { OSDir string `json:"os_dir"` // ///{disk.raw.zst,meta.json}, served under /os/ // FirmwareDir: //{meta.json,}, served under /firmware// FirmwareDir string `json:"firmware_dir"` - OSSources string `json:"os_sources"` // JSON list of upstream cloud images for `rollcall os sync` + // RackUnits: height in rack units per model ("PowerEdge R750" or "Dell Inc. PowerEdge R750"), + // for models the firmware and the built-in table do not know (hwsize.go) + RackUnits map[string]int `json:"rack_units,omitempty"` + OSSources string `json:"os_sources"` // JSON list of upstream cloud images for `rollcall os sync` // region/rack: this server runs an embedded rack controller named RackName; // remote rack controllers (rollcall rack) authenticate with RackSecret diff --git a/cmd/rollcall/hwsize.go b/cmd/rollcall/hwsize.go new file mode 100644 index 0000000..4548c8b --- /dev/null +++ b/cmd/rollcall/hwsize.go @@ -0,0 +1,104 @@ +package main + +// How many rack units a machine takes, so nobody has to type it in: the +// operator's rack_units for the model, else what the firmware says (SMBIOS +// type 3 "Height", read at commissioning), else the model. A sled in a +// multi-node chassis (Dell C-series, blades) takes no unit of its own: its +// height is the chassis's, and it is marked as a node. +// +// The model table holds only heights checked against the vendor's or a +// reliable listing (October 2026): +// Dell: PowerEdge Rack Quick Reference Guide (R6xx 1U, R7xx 2U, R860 2U, +// R940 3U, R960 4U); C6400/C6600: four sleds in 2U. +// HPE: QuickSpecs (DL325 Gen11 1U, DL345 Gen11 2U, DL560 Gen11 2U; DL360 1U, +// DL380 2U). +// Lenovo: Lenovo Press (SR850 V3 2U, SR860 V3 4U, SR950 V3 8U, SR950 4U; +// SR630 1U, SR650 2U). +// xFusion: product pages (1288H 1U, 2288H 2U, 2488H 2U). +// Fujitsu: datasheets (RX2530 1U, RX2540 2U), Red Hat catalog (RX4770 M7 3U). +// Inspur/KAYTUS: heights in mm from a vendor-sourced table (NF5180M6 43 mm, +// NF5280M6 87 mm, NF5468M6 175.5 mm). +// GIGABYTE: R1xx 1U, R2xx 2U, G2xx 2U, G4xx 4U, from the vendor's listings. +// Supermicro's first digit is not the height (SYS-6029 comes in 1U and 2U), +// so Supermicro is left to SMBIOS or rack_units. + +import ( + "encoding/json" + "regexp" + "strings" +) + +type rackSize struct { + U int `json:"u"` // rack units, 0 = unknown + Node bool `json:"node"` // a sled sharing a chassis of U units + Source string `json:"source"` // config | smbios | model | "" +} + +type sizeRule struct { + vendor *regexp.Regexp + model *regexp.Regexp + u int + node bool +} + +func rx(s string) *regexp.Regexp { return regexp.MustCompile(`(?i)` + s) } + +var sizeRules = []sizeRule{ + {rx(`dell`), rx(`\bC6[456][0-9]{2}\b`), 2, true}, // C6420, C6520, C6525, C6620, C6615 sleds + {rx(`dell`), rx(`\bR6[0-9]{2,3}\b`), 1, false}, // R640, R650, R660, R6515, R6625 + {rx(`dell`), rx(`\bR7[0-9]{2,3}\b`), 2, false}, // R740, R750, R760, R7525, R7625 + {rx(`dell`), rx(`\bR860\b`), 2, false}, // + {rx(`dell`), rx(`\bR940\b`), 3, false}, // + {rx(`dell`), rx(`\bR960\b`), 4, false}, // + {rx(`hpe?\b|hewlett`), rx(`\bDL3(25|60)\b`), 1, false}, // + {rx(`hpe?\b|hewlett`), rx(`\bDL3(45|80)\b|\bDL560\b`), 2, false}, + {rx(`lenovo`), rx(`\bSR950 V3\b`), 8, false}, // before the plain SR950 + {rx(`lenovo`), rx(`\bSR950\b`), 4, false}, // + {rx(`lenovo`), rx(`\bSR860 V3\b`), 4, false}, // + {rx(`lenovo`), rx(`\bSR850 V3\b`), 2, false}, // + {rx(`lenovo`), rx(`\bSR630\b`), 1, false}, // SR630, SR630 V2, V3 + {rx(`lenovo`), rx(`\bSR650\b`), 2, false}, // + {rx(`xfusion|huawei`), rx(`\b1288H\b`), 1, false}, // + {rx(`xfusion|huawei`), rx(`\b2[24]88H\b`), 2, false}, // + {rx(`fujitsu`), rx(`\bRX2530\b`), 1, false}, // + {rx(`fujitsu`), rx(`\bRX2540\b`), 2, false}, // + {rx(`fujitsu`), rx(`\bRX4770 M7\b`), 3, false}, // + {rx(`inspur|kaytus`), rx(`\bNF5180M6\b`), 1, false}, // + {rx(`inspur|kaytus`), rx(`\bNF5280M6\b`), 2, false}, // + {rx(`inspur|kaytus`), rx(`\bNF5468M6\b`), 4, false}, // + {rx(`giga-?byte`), rx(`^R1[0-9]{2}-`), 1, false}, // R183-Z94-… + {rx(`giga-?byte`), rx(`^[RG]2[0-9]{2}-`), 2, false}, // R283-…, G293-… + {rx(`giga-?byte`), rx(`^G4[0-9]{2}-`), 4, false}, // G493-… +} + +// machineSize: the machine's height in rack units and where it came from. +func machineSize(m *Machine, override map[string]int) rackSize { + mfr, model := dmiValue(deref(m.Manufacturer)), dmiValue(deref(m.Product)) + sys := asObj(asObj(invOf(m))["system"]) + node := false + if ct := strings.ToLower(str(sys["chassis-type"])); strings.Contains(ct, "multi-system") || strings.Contains(ct, "blade") { + node = true + } + if model != "" { + for k, u := range override { // exact model, or "vendor model" + if strings.EqualFold(k, model) || strings.EqualFold(k, mfr+" "+model) { + return rackSize{U: u, Node: node, Source: "config"} + } + } + } + if u, ok := sys["chassis-height-u"].(float64); ok && u > 0 && u <= 60 { + return rackSize{U: int(u), Node: node, Source: "smbios"} + } + for _, r := range sizeRules { + if r.vendor.MatchString(mfr) && r.model.MatchString(model) { + return rackSize{U: r.u, Node: node || r.node, Source: "model"} + } + } + return rackSize{Node: node} +} + +func invOf(m *Machine) obj { + var inv obj + _ = json.Unmarshal(m.Inventory, &inv) + return inv +} diff --git a/cmd/rollcall/hwsize_test.go b/cmd/rollcall/hwsize_test.go new file mode 100644 index 0000000..bb13b39 --- /dev/null +++ b/cmd/rollcall/hwsize_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "encoding/json" + "testing" +) + +func sizedMachine(mfr, product string, sys map[string]any) *Machine { + inv, _ := json.Marshal(map[string]any{"system": sys}) + return &Machine{ID: "m-1", Manufacturer: &mfr, Product: &product, Inventory: inv} +} + +func TestMachineSize(t *testing.T) { + for _, c := range []struct { + mfr, product string + sys map[string]any + want rackSize + }{ + {"Dell Inc.", "PowerEdge R750", nil, rackSize{2, false, "model"}}, + {"Dell Inc.", "PowerEdge R6515", nil, rackSize{1, false, "model"}}, + {"Dell Inc.", "PowerEdge R940", nil, rackSize{3, false, "model"}}, + {"Dell Inc.", "PowerEdge C6620", nil, rackSize{2, true, "model"}}, + {"HPE", "ProLiant DL380 Gen10", nil, rackSize{2, false, "model"}}, + {"HPE", "ProLiant DL325 Gen11", nil, rackSize{1, false, "model"}}, + {"Lenovo", "ThinkSystem SR950 V3 -[7DC5CTO1WW]-", nil, rackSize{8, false, "model"}}, + {"Lenovo", "ThinkSystem SR950 -[7X12CTO1WW]-", nil, rackSize{4, false, "model"}}, + {"Lenovo", "ThinkSystem SR650 V2 -[7Z73CTO1WW]-", nil, rackSize{2, false, "model"}}, + {"XFUSION", "2288H V7", nil, rackSize{2, false, "model"}}, + {"Inspur", "NF5468M6", nil, rackSize{4, false, "model"}}, + {"GIGABYTE", "R283-Z92-AAD1-000", nil, rackSize{2, false, "model"}}, + {"GIGABYTE", "G493-SB3-AAP1-000", nil, rackSize{4, false, "model"}}, + {"Supermicro", "SYS-2029P-TRT", nil, rackSize{}}, // first digit is not the height + {"QEMU", "Standard PC (i440FX + PIIX, 1996)", nil, rackSize{}}, // unknown + {"Supermicro", "Super Server", map[string]any{"chassis-height-u": 2}, rackSize{2, false, "smbios"}}, + {"Dell Inc.", "PowerEdge R650", map[string]any{"chassis-height-u": 0}, rackSize{1, false, "model"}}, // 0 = unspecified + {"Acme", "X1", map[string]any{"chassis-type": "Multi-system chassis"}, rackSize{0, true, ""}}, + } { + if got := machineSize(sizedMachine(c.mfr, c.product, c.sys), nil); got != c.want { + t.Errorf("%s %s: %+v, want %+v", c.mfr, c.product, got, c.want) + } + } + // the operator's word wins over the firmware's, the firmware's over the table's + m := sizedMachine("Dell Inc.", "PowerEdge R750", map[string]any{"chassis-height-u": 1}) + if got := machineSize(m, nil); got.U != 1 || got.Source != "smbios" { + t.Errorf("smbios before the table: %+v", got) + } + if got := machineSize(m, map[string]int{"Dell Inc. PowerEdge R750": 3}); got.U != 3 || got.Source != "config" { + t.Errorf("config first: %+v", got) + } + if typeHeight(rackSize{2, true, "model"}) != 0 || typeHeight(rackSize{}) != 1 || typeHeight(rackSize{U: 4}) != 4 { + t.Error("typeHeight") + } +} + +func TestNetBoxDeviceHeights(t *testing.T) { + nb, f := testDCIM(t) + tagged := []any{map[string]any{"name": "rollcall"}} + mid := f.put("dcim/manufacturers/", map[string]any{"name": "Dell Inc.", "slug": "dell-inc"}) + r750 := f.put("dcim/device-types/", map[string]any{"manufacturer": float64(mid), "model": "PowerEdge R750", "slug": "poweredge-r750", "u_height": float64(1), "tags": tagged}) + s := func(v string) *string { return &v } + mk := func(id, product string) *Machine { + inv, _ := json.Marshal(obj{"network": []obj{}}) + return &Machine{ID: id, State: "deployed", NodeName: s(id), Manufacturer: s("Dell Inc."), Product: s(product), Inventory: inv, + Location: Location{Building: "DC1", Rack: "R12", Unit: 10}} + } + rep := nb.syncDevices(t.Context(), DeviceSyncConfig{Site: "ev"}, []*Machine{mk("big", "PowerEdge R750"), mk("sled", "PowerEdge C6620")}, nil, nil) + if rep.Error != "" { + t.Fatal(rep.Error) + } + if f.objs["dcim/device-types/"][r750]["u_height"] != float64(2) { + t.Fatalf("Rollcall's R750 type kept u_height %v", f.objs["dcim/device-types/"][r750]["u_height"]) + } + sledType := f.find("dcim/device-types/", "model", "PowerEdge C6620") + sled := f.find("dcim/devices/", "name", "sled") + if sledType["u_height"] != float64(0) || sled["rack"] == nil || sled["position"] != nil { + t.Fatalf("sled: type %v device %v (in the rack, no position)", sledType["u_height"], sled) + } + if f.find("dcim/devices/", "name", "big")["position"] != float64(10) { + t.Fatal("a whole server keeps its position") + } +} diff --git a/cmd/rollcall/ipam.go b/cmd/rollcall/ipam.go index 81eb427..b4028e8 100644 --- a/cmd/rollcall/ipam.go +++ b/cmd/rollcall/ipam.go @@ -332,7 +332,7 @@ func (a *App) reconcileIPAM(ctx context.Context, dry bool) ipamReport { if ms, err := a.store.List(ctx); err != nil { rep.Devices = &devReport{Error: err.Error()} } else { - rep.Devices = nb.syncDevices(ctx, *a.cfg.IPAM.DeviceSync, ms, local) + rep.Devices = nb.syncDevices(ctx, *a.cfg.IPAM.DeviceSync, ms, local, a.cfg.RackUnits) } } return rep diff --git a/cmd/rollcall/nbdevices.go b/cmd/rollcall/nbdevices.go index 58887c9..32bf26c 100644 --- a/cmd/rollcall/nbdevices.go +++ b/cmd/rollcall/nbdevices.go @@ -135,7 +135,7 @@ func nbIfaceType(n invNIC) string { } // syncDevices brings NetBox's devices in line with the machines. -func (n *netBox) syncDevices(ctx context.Context, cfg DeviceSyncConfig, machines []*Machine, ips []IPAddr) *devReport { +func (n *netBox) syncDevices(ctx context.Context, cfg DeviceSyncConfig, machines []*Machine, ips []IPAddr, units map[string]int) *devReport { rep := &devReport{Created: []string{}, Updated: []string{}, Deleted: []string{}, Conflicts: []string{}} if cfg.Site == "" { rep.Error = "ipam.device_sync.site (a site slug) is required" @@ -213,14 +213,14 @@ func (n *netBox) syncDevices(ctx context.Context, cfg DeviceSyncConfig, machines name := orDefault(deref(m.NodeName), m.ID) mfr, model := orDefault(deref(m.Manufacturer), "Unknown"), orDefault(deref(m.Product), "Unknown") tk := mfr + "|" + model + size := machineSize(m, units) if _, ok := types[tk]; !ok { mid, err := n.ensure(ctx, "dcim/manufacturers/", url.Values{"slug": {nbSlug(mfr)}}, obj{"name": mfr, "slug": nbSlug(mfr), "tags": tags}) if err != nil { rep.fail(fmt.Sprintf("%s: manufacturer: %v", name, err)) continue } - tid, err := n.ensure(ctx, "dcim/device-types/", url.Values{"manufacturer_id": {strconv.FormatInt(mid, 10)}, "slug": {nbSlug(model)}}, - obj{"manufacturer": mid, "model": model, "slug": nbSlug(model), "u_height": 1, "tags": tags}) + tid, err := n.ensureType(ctx, mid, model, size, tags) if err != nil { rep.fail(fmt.Sprintf("%s: device type: %v", name, err)) continue @@ -239,7 +239,7 @@ func (n *netBox) syncDevices(ctx context.Context, cfg DeviceSyncConfig, machines } if id, ok := rackBy[siteName{site, strings.ToLower(m.Location.Rack)}]; ok && m.Location.Rack != "" { body["rack"] = id - if m.Location.Unit > 0 { + if m.Location.Unit > 0 && !size.Node { // a sled takes no unit of its own: in the rack, without a position body["position"], body["face"] = m.Location.Unit, "front" } } @@ -463,3 +463,39 @@ func dmiValue(s string) string { } return strings.TrimSpace(s) } + +// typeHeight is a device type's u_height for a machine size: 0 for a sled in +// a shared chassis, 1 when the height is unknown. +func typeHeight(sz rackSize) int { + switch { + case sz.Node: + return 0 + case sz.U > 0: + return sz.U + } + return 1 +} + +// ensureType finds or creates the device type; a type Rollcall created gets +// its height corrected once the machine's is known. +func (n *netBox) ensureType(ctx context.Context, mfr int64, model string, sz rackSize, tags []obj) (int64, error) { + got, err := n.all(ctx, "dcim/device-types/", url.Values{"manufacturer_id": {strconv.FormatInt(mfr, 10)}, "slug": {nbSlug(model)}}) + if err != nil { + return 0, err + } + uh := typeHeight(sz) + if len(got) > 0 { + t := got[0] + if n.tagged(t) && sz.Source != "" && int(num(t["u_height"])) != uh { + if err := n.do(ctx, "PATCH", fmt.Sprintf("dcim/device-types/%d/", nbID(t)), obj{"u_height": uh}, nil); err != nil { + return 0, fmt.Errorf("height of %s: %w", model, err) + } + } + return nbID(t), nil + } + var made obj + if err := n.do(ctx, "POST", "dcim/device-types/", obj{"manufacturer": mfr, "model": model, "slug": nbSlug(model), "u_height": uh, "tags": tags}, &made); err != nil { + return 0, err + } + return nbID(made), nil +} diff --git a/cmd/rollcall/nbdevices_test.go b/cmd/rollcall/nbdevices_test.go index 2dc87cf..ec3a517 100644 --- a/cmd/rollcall/nbdevices_test.go +++ b/cmd/rollcall/nbdevices_test.go @@ -250,7 +250,7 @@ func TestNetBoxDeviceSync(t *testing.T) { ns := "Not Specified" b.Serial = &ns - rep := nb.syncDevices(ctx, cfg, []*Machine{a, b, c, n}, ips) + rep := nb.syncDevices(ctx, cfg, []*Machine{a, b, c, n}, ips, nil) if rep.Error != "" { t.Fatal(rep.Error) } @@ -302,7 +302,7 @@ func TestNetBoxDeviceSync(t *testing.T) { // a second pass changes nothing f.reqs = nil - rep = nb.syncDevices(ctx, cfg, []*Machine{a, b, c, n}, ips) + rep = nb.syncDevices(ctx, cfg, []*Machine{a, b, c, n}, ips, nil) for _, r := range f.reqs { if !strings.HasPrefix(r, "GET ") { t.Fatalf("second pass wrote: %v (%+v)", f.reqs, rep) @@ -313,7 +313,7 @@ func TestNetBoxDeviceSync(t *testing.T) { a.State = "releasing" a.Location.Unit = 20 a.Inventory, _ = json.Marshal(obj{"network": []obj{{"name": "eno1", "mac": "aa:bb:cc:00:00:01", "speed": "25000Mb/s"}}}) - rep = nb.syncDevices(ctx, cfg, []*Machine{a, c}, ips) + rep = nb.syncDevices(ctx, cfg, []*Machine{a, c}, ips, nil) if fmt.Sprint(rep.Updated) != "[node001]" || fmt.Sprint(rep.Deleted) != "[m-b]" || rep.Error != "" { t.Fatalf("report %+v", rep) } @@ -324,10 +324,10 @@ func TestNetBoxDeviceSync(t *testing.T) { func TestNetBoxDeviceSyncSite(t *testing.T) { nb, _ := testDCIM(t) - if rep := nb.syncDevices(context.Background(), DeviceSyncConfig{}, nil, nil); !strings.Contains(rep.Error, "required") { + if rep := nb.syncDevices(context.Background(), DeviceSyncConfig{}, nil, nil, nil); !strings.Contains(rep.Error, "required") { t.Fatal(rep.Error) } - if rep := nb.syncDevices(context.Background(), DeviceSyncConfig{Site: "mars"}, nil, nil); !strings.Contains(rep.Error, "no site") { + if rep := nb.syncDevices(context.Background(), DeviceSyncConfig{Site: "mars"}, nil, nil, nil); !strings.Contains(rep.Error, "no site") { t.Fatal(rep.Error) } } diff --git a/cmd/rollcall/server.go b/cmd/rollcall/server.go index 537e83d..63e9d83 100644 --- a/cmd/rollcall/server.go +++ b/cmd/rollcall/server.go @@ -431,6 +431,7 @@ type Summary struct { ManualTags []string `json:"manual_tags"` AutoTags []string `json:"auto_tags"` Location Location `json:"location"` + Size rackSize `json:"size"` // rack units (hwsize.go) Rack string `json:"rack_controller"` NetLayout string `json:"net_layout"` } @@ -706,6 +707,7 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) { continue } s := summarize(m) + s.Size = machineSize(m, a.cfg.RackUnits) s.Job = active[m.ID] out = append(out, s) } @@ -721,6 +723,7 @@ func (a *App) handleGet(w http.ResponseWriter, r *http.Request) { evs, _ := a.store.Events(r.Context(), m.ID) jobs, _ := a.store.Jobs(r.Context(), m.ID, 10) s := summarize(m) + s.Size = machineSize(m, a.cfg.RackUnits) if j, err := a.store.ActiveJob(r.Context(), m.ID); err == nil { s.Job = j } diff --git a/cmd/rollcall/web/app.js b/cmd/rollcall/web/app.js index f8ce794..f9e622e 100644 --- a/cmd/rollcall/web/app.js +++ b/cmd/rollcall/web/app.js @@ -611,12 +611,33 @@ function pageRacks() { const shown = list.filter(x => !sel || x.building === sel || x.building + '/' + x.room === sel); const elevation = rk => { const ms = placed.filter(m => (m.location.building || '') === rk.building && (m.location.room || '') === rk.room && m.location.rack === rk.name); - const byU = {}; const noU = []; - for (const m of ms) { if (m.location.unit > 0) byU[m.location.unit] = m; else noU.push(m); } - const H = rk.height || 42; let rows = ''; - for (let u = H; u >= 1; u--) { const m = byU[u]; rows += `
${u}
` + (m ? `
${esc(nameOf(m))} ${esc(m.os || m.profile || '')}
` : '
'); } + // blocks of units: a server spans its height (size from the firmware or the + // model, ?U when unknown); sleds of one multi-node chassis share one block + const H = rk.height || 42, noU = [], blocks = []; + for (const m of ms) { + if (!(m.location.unit > 0)) { noU.push(m); continue; } + const sz = m.size || {}, h = Math.max(1, sz.u || 1), unit = m.location.unit; + const b = sz.node && blocks.find(x => x.node && x.unit === unit); + if (b) b.ms.push(m); else blocks.push({ unit, h, node: !!sz.node, known: !!sz.u, ms: [m] }); + } + const clash = []; + blocks.forEach((a, i) => blocks.slice(i + 1).forEach(b => { if (a.unit <= b.unit + b.h - 1 && b.unit <= a.unit + a.h - 1) clash.push(`${a.ms.map(nameOf).join('+')} and ${b.ms.map(nameOf).join('+')} overlap at U${Math.max(a.unit, b.unit)}`); })); + const top = {}, covered = new Set(); + for (const b of blocks) { const t = Math.min(H, b.unit + b.h - 1); if (top[t] || [...Array(t - b.unit + 1).keys()].some(k => covered.has(b.unit + k))) continue; top[t] = b; for (let u = b.unit; u <= t; u++) covered.add(u); } + let rows = ''; + for (let u = H; u >= 1; u--) { + rows += `
${u}
`; + const b = top[u]; + if (b) { + const m = b.ms[0], worst = b.ms.map(x => x.state).sort((x, y) => ['bad', 'warn', 'ok', 'idle'].indexOf(stateClass(x)) - ['bad', 'warn', 'ok', 'idle'].indexOf(stateClass(y)))[0]; + const span = Math.min(H, b.unit + b.h - 1) - b.unit + 1; + const label = b.node ? `${b.ms.length} node${b.ms.length > 1 ? 's' : ''}: ${b.ms.map(nameOf).join(', ')}` : nameOf(m); + const tip = b.ms.map(x => `${nameOf(x)} · ${x.state} · ${(x.manufacturer || '') + ' ' + (x.product || '')}`).join('\n') + (b.known ? `\n${b.h}U${b.node ? ' chassis' : ''} (${(m.size || {}).source})` : '\nheight unknown: set rack_units for this model'); + rows += `
${esc(label)} ${b.node ? '' : esc(m.os || m.profile || '')}${b.known ? '' : ' ?U'}
`; + } else if (!covered.has(u)) rows += '
'; + } return `

${esc(rk.name)} ${esc([rk.building, rk.room].filter(Boolean).join(' / '))}${ms.length} · ${H}U ✎

-
${rows}
${noU.length ? `
no U: ${noU.map(m => `${esc(nameOf(m))}`).join(', ')}
` : ''}${rk.comment ? `
${esc(rk.comment)}
` : ''}
`; +
${rows}
${clash.length ? `
${clash.map(esc).join('
')}
` : ''}${noU.length ? `
no U: ${noU.map(m => `${esc(nameOf(m))}`).join(', ')}
` : ''}${rk.comment ? `
${esc(rk.comment)}
` : ''}`; }; return `

Racks

${list.length} racks · ${placed.length} machines placed${unplaced ? ` · ${unplaced} without location` : ''}
diff --git a/cmd/rollcall/web/style.css b/cmd/rollcall/web/style.css index bd6cced..d73f189 100644 --- a/cmd/rollcall/web/style.css +++ b/cmd/rollcall/web/style.css @@ -105,6 +105,8 @@ td.check, th.check { width: 28px; padding-right: 0; } .elev .slot.m.deploying, .elev .slot.m.releasing, .elev .slot.m.stateless { border-left-color: var(--warn); } .elev .slot.m.deploy-failed, .elev .slot.m.release-failed { border-left-color: var(--danger); } .elev .slot.m:hover { filter: brightness(1.08); } +.elev .u { grid-column: 1; } .elev .slot { grid-column: 2; display: flex; align-items: center; gap: 4px; } +.elev .slot.node { background-image: repeating-linear-gradient(90deg, transparent 0 24%, var(--line) 24% 25%); } .tree { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; } .tree .chip.on { background: var(--accent); color: var(--accent-fg); }