From d9546fe26408129117819ae4e3ac7be32cecc444 Mon Sep 17 00:00:00 2001 From: Ilker Manap Date: Sun, 1 Nov 2026 08:59:16 +0100 Subject: [PATCH] Racks: one rack whatever the case of its name A rack created from a machine's location ("lab / pxe hall / R1") and the same rack from NetBox ("Lab / PXE Hall / R1") showed twice on the Racks page. The NetBox layout sync now folds such a rack into NetBox's spelling: its floor place and comment carry over where the NetBox one has none, and the machines located in it move to the NetBox spelling. A machine's location no longer creates a rack that differs only in case, rack machine counts ignore case, and the Racks page keys racks without case, NetBox's spelling first. Co-Authored-By: Claude Opus 5.5 --- cmd/rollcall/layout.go | 41 ++++++++++++++++++++++++++++++++++++++++ cmd/rollcall/location.go | 4 ++-- cmd/rollcall/web/app.js | 10 +++++++--- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/cmd/rollcall/layout.go b/cmd/rollcall/layout.go index b802e93..cd0c6a3 100644 --- a/cmd/rollcall/layout.go +++ b/cmd/rollcall/layout.go @@ -15,6 +15,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "net/http" "strings" ) @@ -105,6 +106,38 @@ func (s *Store) SetRackPosition(ctx context.Context, building, room, name string return nil } +// RespellRack folds rack `from` into `to`, the same rack spelled otherwise +// (NetBox's spelling of a rack typed in by hand): its place and comment go to +// `to` where that has none, and machines located in `from` move to `to`. +func (s *Store) RespellRack(ctx context.Context, from, to Rack) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + if _, err := tx.Exec(ctx, `INSERT INTO racks(building, room, name, height, comment, x, y, facing) + SELECT $4, $5, $6, height, comment, x, y, facing FROM racks WHERE building=$1 AND room=$2 AND name=$3 + ON CONFLICT (building, room, name) DO UPDATE SET comment=coalesce(nullif(racks.comment,''), excluded.comment), + x=coalesce(racks.x, excluded.x), y=CASE WHEN racks.x IS NULL THEN excluded.y ELSE racks.y END, + facing=CASE WHEN racks.x IS NULL THEN excluded.facing ELSE racks.facing END`, + from.Building, from.Room, from.Name, to.Building, to.Room, to.Name); err != nil { + return err + } + if _, err := tx.Exec(ctx, `DELETE FROM racks WHERE building=$1 AND room=$2 AND name=$3`, from.Building, from.Room, from.Name); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE machines SET building=nullif($4,''), room=nullif($5,''), rack=$6 + WHERE coalesce(building,'')=$1 AND coalesce(room,'')=$2 AND rack=$3`, from.Building, from.Room, from.Name, to.Building, to.Room, to.Name); err != nil { + return err + } + return tx.Commit(ctx) +} + +// sameRack: one rack whatever the case of its building, room and name +func sameRack(a, b Rack) bool { + return strings.EqualFold(a.Building, b.Building) && strings.EqualFold(a.Room, b.Room) && strings.EqualFold(a.Name, b.Name) +} + // ---------------------------------------------------------------- handlers func (a *App) handleSites(w http.ResponseWriter, r *http.Request) { @@ -240,6 +273,14 @@ func (a *App) syncLayout(ctx context.Context) error { if err := a.store.PutRack(ctx, x); err != nil { return err } + for _, h := range have { // the same rack typed in otherwise: NetBox's spelling wins + if sameRack(h, x) && (h.Building != x.Building || h.Room != x.Room || h.Name != x.Name) { + if err := a.store.RespellRack(ctx, h, x); err != nil { + return err + } + log.Printf("layout: rack %s / %s / %s is %s / %s / %s in NetBox: merged", h.Building, h.Room, h.Name, x.Building, x.Room, x.Name) + } + } } return nil } diff --git a/cmd/rollcall/location.go b/cmd/rollcall/location.go index 9a7c584..b0df8d4 100644 --- a/cmd/rollcall/location.go +++ b/cmd/rollcall/location.go @@ -151,7 +151,7 @@ func (a *App) ensureRack(ctx context.Context, l Location) { } racks, _ := a.store.Racks(ctx) for _, r := range racks { - if r.Building == l.Building && r.Room == l.Room && r.Name == l.Rack { + if sameRack(r, Rack{Building: l.Building, Room: l.Room, Name: l.Rack}) { // "lab" is the rack NetBox calls "Lab" return } } @@ -169,7 +169,7 @@ func (a *App) handleRacks(w http.ResponseWriter, r *http.Request) { ms, _ := a.store.List(r.Context()) for i := range racks { for _, m := range ms { - if m.Location.Building == racks[i].Building && m.Location.Room == racks[i].Room && m.Location.Rack == racks[i].Name { + if sameRack(racks[i], Rack{Building: m.Location.Building, Room: m.Location.Room, Name: m.Location.Rack}) { racks[i].Machines++ } } diff --git a/cmd/rollcall/web/app.js b/cmd/rollcall/web/app.js index 02dab8a..b40df60 100644 --- a/cmd/rollcall/web/app.js +++ b/cmd/rollcall/web/app.js @@ -648,9 +648,13 @@ function pageRacks() { const r = route(); const sel = r.arg ? decodeURIComponent(r.arg) : ''; const placed = S.machines.filter(m => m.location && m.location.rack); const unplaced = S.machines.length - placed.length; - // racks from definitions plus any rack a machine mentions - const keyOf = (b, rm, n) => `${b}|${rm}|${n}`; - const racks = new Map(S.racks.map(x => [keyOf(x.building, x.room, x.name), { ...x }])); + // racks from definitions plus any rack a machine mentions, one per name whatever its case + const keyOf = (b, rm, n) => `${b}|${rm}|${n}`.toLowerCase(); + const racks = new Map(); + for (const x of S.racks) { // a rack typed in by hand and the same from NetBox: NetBox's spelling (its rooms are in S.rooms) + const k = keyOf(x.building, x.room, x.name); + if (!racks.has(k) || (S.rooms || []).some(r => r.building === x.building && r.name === x.room)) racks.set(k, { ...x }); + } for (const m of placed) { const l = m.location; const k = keyOf(l.building || '', l.room || '', l.rack); if (!racks.has(k)) racks.set(k, { building: l.building || '', room: l.room || '', name: l.rack, height: 42, machines: 0 }); } const list = [...racks.values()].sort((a, b) => keyOf(a.building, a.room, a.name).localeCompare(keyOf(b.building, b.room, b.name))); const buildings = [...new Set(list.map(x => x.building))].sort();