Server heights from firmware and model; rack elevation at real size
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 41s

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 <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-31 19:16:10 +01:00
1 parent 355401465c
commit 9b0b27fce6
12 files changed
+290 -16

No files matched your search

+6
View File
@@ -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,
+11
View File
@@ -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
+7
View File
@@ -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
+4 -1
View File
@@ -34,7 +34,10 @@ type Config struct {
OSDir string `json:"os_dir"` // <os_dir>/<name>/<version>/{disk.raw.zst,meta.json}, served under /os/
// FirmwareDir: <firmware_dir>/<id>/{meta.json,<package>}, served under /firmware/<id>/<package>
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
+104
View File
@@ -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
}
+81
View File
@@ -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")
}
}
+1 -1
View File
@@ -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
+40 -4
View File
@@ -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
}
+5 -5
View File
@@ -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)
}
}
+3
View File
@@ -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
}
+26 -5
View File
@@ -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 += `<div class="u">${u}</div>` + (m ? `<div class="slot m ${esc(m.state)}" data-id="${esc(m.id)}" title="${esc(nameOf(m))} · ${esc(m.state)} · ${esc((m.manufacturer || '') + ' ' + (m.product || ''))}">${esc(nameOf(m))} <span class="muted">${esc(m.os || m.profile || '')}</span></div>` : '<div class="slot"></div>'); }
// 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 += `<div class="u">${u}</div>`;
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 += `<div class="slot m ${esc(worst)}${b.node ? ' node' : ''}" data-id="${esc(m.id)}" style="grid-row:span ${span}" title="${esc(tip)}">${esc(label)} <span class="muted">${b.node ? '' : esc(m.os || m.profile || '')}${b.known ? '' : ' ?U'}</span></div>`;
} else if (!covered.has(u)) rows += '<div class="slot"></div>';
}
return `<div class="rack"><h4><span>${esc(rk.name)} <span class="muted">${esc([rk.building, rk.room].filter(Boolean).join(' / '))}</span></span><span class="muted">${ms.length} · ${H}U <a href="#" data-rackedit="${esc(keyOf(rk.building, rk.room, rk.name))}" title="height / comment">✎</a></span></h4>
<div class="elev">${rows}</div>${noU.length ? `<div class="muted" style="margin-top:6px">no U: ${noU.map(m => `<a href="#/machine/${esc(m.id)}">${esc(nameOf(m))}</a>`).join(', ')}</div>` : ''}${rk.comment ? `<div class="muted" style="margin-top:4px">${esc(rk.comment)}</div>` : ''}</div>`;
<div class="elev">${rows}</div>${clash.length ? `<div class="bad" style="margin-top:6px">${clash.map(esc).join('<br>')}</div>` : ''}${noU.length ? `<div class="muted" style="margin-top:6px">no U: ${noU.map(m => `<a href="#/machine/${esc(m.id)}">${esc(nameOf(m))}</a>`).join(', ')}</div>` : ''}${rk.comment ? `<div class="muted" style="margin-top:4px">${esc(rk.comment)}</div>` : ''}</div>`;
};
return `<div class="page-head"><h1>Racks</h1><span class="sub">${list.length} racks · ${placed.length} machines placed${unplaced ? ` · <a href="#/machines" id="go-unplaced">${unplaced} without location</a>` : ''}</span>
<div class="right"><button class="btn" id="import-loc">Import CSV…</button><button class="btn" id="new-rack">New rack…</button></div></div>
+2
View File
@@ -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); }