Racks: one rack whatever the case of its name
CI / agent (push) Successful in 2s
CI / site (push) Successful in 3s
CI / go (push) Successful in 45s

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 <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-11-01 08:59:16 +01:00
1 parent 6da78311f3
commit d9546fe264
3 files changed
+50 -5

No files matched your search

+41
View File
@@ -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
}
+2 -2
View File
@@ -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++
}
}
+7 -3
View File
@@ -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();