diff --git a/cmd/rollcall/cabling.go b/cmd/rollcall/cabling.go new file mode 100644 index 0000000..eb802bd --- /dev/null +++ b/cmd/rollcall/cabling.go @@ -0,0 +1,298 @@ +package main + +// Cabling: what each machine's ports are plugged into, from the LLDP its +// commissioning saw (lldpcli's neighbours per interface), and where the +// switches at the other end stand. A switch's place comes from NetBox (the +// device's site, location, rack and position, read with the layout) or is +// set here; a switch with neither is drawn where most of its machines are. + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/url" + "sort" + "strings" +) + +type Port struct { + Name string `json:"name"` + MAC string `json:"mac,omitempty"` + Speed string `json:"speed,omitempty"` + Link *bool `json:"link,omitempty"` + Card string `json:"card,omitempty"` // PCI device without the function: the ports of one card + Switch string `json:"switch,omitempty"` + SwitchPort string `json:"switch_port,omitempty"` + PortDescr string `json:"port_descr,omitempty"` + VLAN string `json:"vlan,omitempty"` +} + +type MachinePorts struct { + ID string `json:"id"` + Ports []Port `json:"ports"` +} + +type Switch struct { + Name string `json:"name"` + Descr string `json:"descr,omitempty"` + MgmtIP string `json:"mgmt_ip,omitempty"` + Building string `json:"building,omitempty"` + Room string `json:"room,omitempty"` + Rack string `json:"rack,omitempty"` + Unit int `json:"unit,omitempty"` + Height int `json:"height,omitempty"` + Model string `json:"model,omitempty"` + Source string `json:"source,omitempty"` // netbox | local | "" (not placed) + Links int `json:"links"` +} + +type lldpNeighbour struct{ Switch, Port, PortDescr, VLAN, Descr, MgmtIP string } + +// lldpOf reads inventory "lldp": lldpcli's "interface", an object of +// interfaces or a list of one-interface objects, each one entry or a list. +func lldpOf(inv obj) map[string]lldpNeighbour { + out := map[string]lldpNeighbour{} + add := func(ifn string, v any) { + for _, e := range asList(v) { + eo := asObj(e) + ch := asObj(eo["chassis"]) + var name string + var c obj + for k, v := range ch { + name, c = k, asObj(v) + } + if name == "" { + continue + } + nb := lldpNeighbour{Switch: name, Descr: str(c["descr"]), PortDescr: str(dig(eo, "port", "descr"))} + nb.Port = str(dig(eo, "port", "id", "value")) + if nb.Port == "" { + nb.Port = str(dig(eo, "port", "id")) + } + for _, ip := range asList(c["mgmt-ip"]) { + if s := str(ip); s != "" && nb.MgmtIP == "" { + nb.MgmtIP = s + } + } + for _, v := range asList(eo["vlan"]) { + if vo := asObj(v); vo != nil && nb.VLAN == "" { + nb.VLAN = str(vo["vlan-id"]) + } + } + if _, seen := out[ifn]; !seen { + out[ifn] = nb + } + } + } + switch l := inv["lldp"].(type) { + case obj: + for ifn, v := range l { + add(ifn, v) + } + case []any: + for _, x := range l { + for ifn, v := range asObj(x) { + add(ifn, v) + } + } + } + return out +} + +// portsOf: a machine's ports from its inventory, with the switch port each is cabled to +func portsOf(m *Machine) ([]Port, map[string]lldpNeighbour) { + inv := invOf(m) + nb := lldpOf(inv) + var out []Port + for _, x := range toList(inv["network"]) { + n := asObj(x) + p := Port{Name: str(n["name"]), MAC: str(n["mac"]), Speed: str(n["speed"])} + if p.Name == "" { + continue + } + if b, ok := n["link"].(bool); ok { + p.Link = &b + } + if p.Speed == "Unknown!" { + p.Speed = "" + } + if bus := str(n["bus_info"]); strings.Count(bus, ":") == 2 { // 0000:3b:00.1 → 0000:3b:00 + p.Card, _, _ = strings.Cut(bus, ".") + } + if l, ok := nb[p.Name]; ok { + p.Switch, p.SwitchPort, p.PortDescr, p.VLAN = l.Switch, l.Port, l.PortDescr, l.VLAN + } + out = append(out, p) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Card < out[j].Card }) + return out, nb +} + +// ---------------------------------------------------------------- store + +func (s *Store) Switches(ctx context.Context) ([]Switch, error) { + rows, err := s.pool.Query(ctx, `SELECT name, coalesce(building,''), coalesce(room,''), coalesce(rack,''), coalesce(unit,0), coalesce(height,1), coalesce(model,''), source FROM switches ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Switch{} + for rows.Next() { + var x Switch + if err := rows.Scan(&x.Name, &x.Building, &x.Room, &x.Rack, &x.Unit, &x.Height, &x.Model, &x.Source); err != nil { + return nil, err + } + out = append(out, x) + } + return out, rows.Err() +} + +// PutSwitch places a switch; one placed by hand is not moved by NetBox. +func (s *Store) PutSwitch(ctx context.Context, x Switch) error { + _, err := s.pool.Exec(ctx, `INSERT INTO switches(name, building, room, rack, unit, height, model, source) VALUES ($1,$2,$3,$4,nullif($5,0),nullif($6,0),nullif($7,''),$8) + ON CONFLICT (name) DO UPDATE SET building=$2, room=$3, rack=$4, unit=nullif($5,0), height=nullif($6,0), model=coalesce(nullif($7,''), switches.model), source=$8 + WHERE switches.source <> 'local' OR $8 = 'local'`, x.Name, x.Building, x.Room, x.Rack, x.Unit, x.Height, x.Model, orDefault(x.Source, "local")) + return err +} + +func (s *Store) DeleteSwitch(ctx context.Context, name string) error { + _, err := s.pool.Exec(ctx, `DELETE FROM switches WHERE name=$1`, name) + return err +} + +// ---------------------------------------------------------------- handlers + +// handleCabling: every located machine's ports and the switches they lead to. +func (a *App) handleCabling(w http.ResponseWriter, r *http.Request) { + ms, err := a.store.List(r.Context()) + if err != nil { + fail(w, 500, err) + return + } + placed, _ := a.store.Switches(r.Context()) + sw := map[string]*Switch{} + key := func(name string) string { // an LLDP name may be the FQDN of a NetBox name + k, _, _ := strings.Cut(strings.ToLower(name), ".") + return k + } + for i := range placed { + sw[key(placed[i].Name)] = &placed[i] + } + out := []MachinePorts{} + for _, m := range ms { + if m.Location.Rack == "" { + continue + } + ports, nb := portsOf(m) + for _, l := range nb { + s := sw[key(l.Switch)] + if s == nil { + s = &Switch{Name: l.Switch} + sw[key(l.Switch)] = s + } + s.Links++ + if s.Descr == "" { + s.Descr = l.Descr + } + if s.MgmtIP == "" { + s.MgmtIP = l.MgmtIP + } + } + if len(ports) > 0 { + out = append(out, MachinePorts{ID: m.ID, Ports: ports}) + } + } + for _, mp := range out { // switch names as the machines saw them, the place as stored + for i := range mp.Ports { + if s := sw[key(mp.Ports[i].Switch)]; s != nil && mp.Ports[i].Switch != "" { + mp.Ports[i].Switch = s.Name + } + } + } + list := []Switch{} + for _, s := range sw { + list = append(list, *s) + } + sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name }) + writeJSON(w, 200, obj{"machines": out, "switches": list}) +} + +// handleSwitches places a switch by hand: {"name","building","room","rack","unit","height"}; +// DELETE ?name= forgets the place (NetBox's comes back on the next sync). +func (a *App) handleSwitches(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + if err := a.store.DeleteSwitch(r.Context(), r.URL.Query().Get("name")); err != nil { + fail(w, 500, err) + return + } + writeJSON(w, 200, obj{"deleted": r.URL.Query().Get("name")}) + return + } + var x Switch + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&x); err != nil || strings.TrimSpace(x.Name) == "" || x.Rack == "" { + fail(w, 400, errors.New("name and rack are required")) + return + } + if x.Unit < 0 || x.Unit > 60 || x.Height < 0 || x.Height > 10 { + fail(w, 400, errors.New("unit 0..60, height 0..10")) + return + } + x.Source = "local" + if err := a.store.PutSwitch(r.Context(), x); err != nil { + fail(w, 500, err) + return + } + writeJSON(w, 200, x) +} + +// ---------------------------------------------------------------- NetBox + +// syncSwitches copies the place of every switch the machines' LLDP names +// that NetBox has as a device with a rack. +func (a *App) syncSwitches(ctx context.Context, nb *netBox) error { + ms, err := a.store.List(ctx) + if err != nil { + return err + } + names := map[string]bool{} + for _, m := range ms { + for _, l := range lldpOf(invOf(m)) { + names[l.Switch] = true + if short, _, ok := strings.Cut(l.Switch, "."); ok { + names[short] = true + } + } + } + if len(names) == 0 { + return nil + } + q := url.Values{} + for n := range names { + q.Add("name", n) + } + devs, err := nb.all(ctx, "dcim/devices/", q) + if err != nil { + return err + } + for _, x := range switchPlaces(devs) { + if err := a.store.PutSwitch(ctx, x); err != nil { + return err + } + } + return nil +} + +// switchPlaces: the devices that stand in a rack, as switches +func switchPlaces(devs []obj) []Switch { + var out []Switch + for _, d := range devs { + rack := str(dig(d, "rack", "name")) + if rack == "" { + continue + } + out = append(out, Switch{Name: str(d["name"]), Building: str(dig(d, "site", "name")), Room: str(dig(d, "location", "name")), Rack: rack, + Unit: int(num(d["position"])), Height: 1, Model: str(dig(d, "device_type", "model")), Source: "netbox"}) + } + return out +} diff --git a/cmd/rollcall/cabling_test.go b/cmd/rollcall/cabling_test.go new file mode 100644 index 0000000..c48bc6c --- /dev/null +++ b/cmd/rollcall/cabling_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "testing" +) + +// lldpcli -f json show neighbors details: one interface is an object, several a list +const lldpOne = `{"lldp": {"eno1": {"via": "LLDP", "chassis": {"leaf-1.dc1.example": {"id": {"type": "mac", "value": "0c:29:ef:00:00:01"}, + "descr": "Dell EMC Networking OS10", "mgmt-ip": ["10.0.0.11", "fe80::1"]}}, "port": {"id": {"type": "ifname", "value": "ethernet1/1/12"}, "descr": "node7 eno1"}, + "vlan": {"vlan-id": "100", "pvid": true}}}}` +const lldpList = `{"lldp": [{"eno1": {"chassis": {"leaf-1": {"descr": "x"}}, "port": {"id": {"value": "Ethernet12"}}}}, + {"eno2": [{"chassis": {"leaf-2": {"mgmt-ip": "10.0.0.12"}}, "port": {"id": {"value": "Ethernet12"}}, "vlan": [{"vlan-id": "100"}, {"vlan-id": "200"}]}]}]}` + +func TestLLDPShapes(t *testing.T) { + var inv obj + if err := json.Unmarshal([]byte(lldpOne), &inv); err != nil { + t.Fatal(err) + } + got := lldpOf(inv) + if n := got["eno1"]; n.Switch != "leaf-1.dc1.example" || n.Port != "ethernet1/1/12" || n.VLAN != "100" || n.MgmtIP != "10.0.0.11" || n.PortDescr != "node7 eno1" { + t.Fatalf("one: %+v", n) + } + if err := json.Unmarshal([]byte(lldpList), &inv); err != nil { + t.Fatal(err) + } + got = lldpOf(inv) + if got["eno1"].Switch != "leaf-1" || got["eno2"].Switch != "leaf-2" || got["eno2"].VLAN != "100" || got["eno2"].MgmtIP != "10.0.0.12" { + t.Fatalf("list: %+v", got) + } + if len(lldpOf(obj{"lldp": nil})) != 0 { + t.Fatal("no lldp should be no neighbours") + } +} + +func TestPortsOf(t *testing.T) { + inv := `{"network": [{"name": "eno2", "mac": "aa:00:00:00:00:02", "bus_info": "0000:3b:00.1", "speed": "25000Mb/s", "link": true}, + {"name": "ens18", "bus_info": "virtio1", "speed": "Unknown!"}, {"name": "eno1", "bus_info": "0000:3b:00.0", "link": false}], + "lldp": {"eno2": {"chassis": {"leaf-1": {}}, "port": {"id": {"value": "Ethernet3"}}}}}` + ports, _ := portsOf(&Machine{Inventory: json.RawMessage(inv)}) + if len(ports) != 3 { + t.Fatalf("ports: %+v", ports) + } + byName := map[string]Port{} + for _, p := range ports { + byName[p.Name] = p + } + if p := byName["eno2"]; p.Card != "0000:3b:00" || p.Switch != "leaf-1" || p.SwitchPort != "Ethernet3" || p.Link == nil || !*p.Link { + t.Fatalf("eno2: %+v", p) + } + if p := byName["ens18"]; p.Card != "" || p.Speed != "" { + t.Fatalf("ens18: %+v", p) + } +} + +func TestSwitchPlaces(t *testing.T) { + var devs []obj + _ = json.Unmarshal([]byte(`[{"name": "leaf-1", "site": {"name": "dc1"}, "location": {"name": "Hall 1"}, "rack": {"name": "R01"}, "position": 42.0, "device_type": {"model": "S5248F-ON"}}, + {"name": "spare", "site": {"name": "dc1"}, "rack": null}]`), &devs) + got := switchPlaces(devs) + if len(got) != 1 || got[0] != (Switch{Name: "leaf-1", Building: "dc1", Room: "Hall 1", Rack: "R01", Unit: 42, Height: 1, Model: "S5248F-ON", Source: "netbox"}) { + t.Fatalf("%+v", got) + } +} diff --git a/cmd/rollcall/layout.go b/cmd/rollcall/layout.go index cd0c6a3..a592de7 100644 --- a/cmd/rollcall/layout.go +++ b/cmd/rollcall/layout.go @@ -282,6 +282,9 @@ func (a *App) syncLayout(ctx context.Context) error { } } } + if err := a.syncSwitches(ctx, nb); err != nil { + return fmt.Errorf("switches: %w", err) + } return nil } diff --git a/cmd/rollcall/server.go b/cmd/rollcall/server.go index 836cae5..e3d50c9 100644 --- a/cmd/rollcall/server.go +++ b/cmd/rollcall/server.go @@ -286,6 +286,9 @@ func (a *App) routes() http.Handler { mux.HandleFunc("POST /api/v1/racks", a.adminAuth(a.handleRacks)) mux.HandleFunc("DELETE /api/v1/racks", a.adminAuth(a.handleRacks)) mux.HandleFunc("POST /api/v1/racks/positions", a.adminAuth(a.handleRackPositions)) + mux.HandleFunc("GET /api/v1/cabling", a.adminAuth(a.handleCabling)) + mux.HandleFunc("POST /api/v1/switches", a.adminAuth(a.handleSwitches)) + mux.HandleFunc("DELETE /api/v1/switches", a.adminAuth(a.handleSwitches)) mux.HandleFunc("GET /api/v1/sites", a.adminAuth(a.handleSites)) mux.HandleFunc("POST /api/v1/sites", a.adminAuth(a.handleSites)) mux.HandleFunc("DELETE /api/v1/sites", a.adminAuth(a.handleSites)) diff --git a/cmd/rollcall/store.go b/cmd/rollcall/store.go index 44e54e9..f8b33fc 100644 --- a/cmd/rollcall/store.go +++ b/cmd/rollcall/store.go @@ -104,6 +104,16 @@ CREATE TABLE IF NOT EXISTS racks ( ALTER TABLE racks ADD COLUMN IF NOT EXISTS x int; ALTER TABLE racks ADD COLUMN IF NOT EXISTS y int; ALTER TABLE racks ADD COLUMN IF NOT EXISTS facing text; +CREATE TABLE IF NOT EXISTS switches ( + name text PRIMARY KEY, + building text, + room text, + rack text, + unit int, + height int, + model text, + source text NOT NULL DEFAULT 'local' +); CREATE TABLE IF NOT EXISTS sites ( name text PRIMARY KEY, slug text, diff --git a/cmd/rollcall/web/app.js b/cmd/rollcall/web/app.js index b40df60..ad91376 100644 --- a/cmd/rollcall/web/app.js +++ b/cmd/rollcall/web/app.js @@ -77,7 +77,7 @@ async function refresh(full) { const page = route().page; if (full || page === 'overview' || page === 'jobs') S.jobs = await api('GET', '/api/v1/jobs'); if (full || page === 'overview') S.events = await api('GET', '/api/v1/events'); - if (full || page === 'sites') [S.sites, S.rooms] = await Promise.all([api('GET', '/api/v1/sites'), api('GET', '/api/v1/rooms')]); + if (full || page === 'sites') [S.sites, S.rooms, S.cabling] = await Promise.all([api('GET', '/api/v1/sites'), api('GET', '/api/v1/rooms'), api('GET', '/api/v1/cabling').catch(() => null)]); if (full || page === 'overview' || page === 'network') { [S.ips, S.ipam] = await Promise.all([api('GET', '/api/v1/ips'), api('GET', '/api/v1/ipam').catch(() => null)]); } diff --git a/cmd/rollcall/web/fronts.js b/cmd/rollcall/web/fronts.js index ddf9619..85223a9 100644 --- a/cmd/rollcall/web/fronts.js +++ b/cmd/rollcall/web/fronts.js @@ -123,3 +123,83 @@ function drawFront(p, span, cls, colors, px = 768) { if (inner[1] - right > 12) dots(right + 3, 3, inner[1] - right - 3, H - 6, 3.2); // vents where there are no bays return { canvas: c, ends }; } + +// drawRear paints a server's back: PSUs at the right end, then per server +// (or per sled of a chassis) the BMC port, USB and VGA and its network +// cards with their ports (SFP cages from 10G up, RJ45 below), cover plates +// where there is no card. sleds: [{ports}] from /api/v1/cabling. Returns the +// canvas and each port's centre in mm from the top left: {sled, name, x, y}. +function drawRear(p, span, sleds, colors, px = 768) { + const H = span * MM.U - 1, k = px / MM.W, c = document.createElement('canvas'); + c.width = px; c.height = Math.round(H * k); + const x = c.getContext('2d'); x.scale(k, k); + const [body, ear] = FRONT_STYLE[p.style]; + const rect = (fill, a, b, w, h) => { x.fillStyle = fill; x.fillRect(a, b, w, h); }; + const frame = (a, b, w, h, col = 'rgba(255,255,255,.16)') => { x.lineWidth = 0.5; x.strokeStyle = col; x.strokeRect(a, b, w, h); }; + const grille = (a, b, w, h, step = 2.6) => { x.fillStyle = 'rgba(0,0,0,.6)'; for (let yy = b + 1; yy < b + h - 1; yy += step) x.fillRect(a + 1, yy, w - 2, step * 0.45); }; + const out = []; + rect(body, 0, 0, MM.W, H); + rect(ear, 0, 0, 6, H); rect(ear, MM.W - 6, 0, 6, H); // the rails' flanges + // power supplies at the right end: side by side in 1U, stacked above + const psuW = span === 1 ? 72 : 86, psuH = span === 1 ? H - 4 : Math.min(40, H / 2 - 3); + const psus = span === 1 ? [[MM.W - 8 - 2 * psuW - 2, 2], [MM.W - 8 - psuW, 2]] : [[MM.W - 8 - psuW, H / 2 - psuH - 1], [MM.W - 8 - psuW, H / 2 + 1]]; + for (const [a, b] of psus) { + rect('#2b2e33', a, b, psuW, psuH); frame(a, b, psuW, psuH); + const r = Math.min(psuH, 34) / 2 - 2; x.fillStyle = 'rgba(0,0,0,.65)'; x.beginPath(); x.arc(a + psuW * 0.62, b + psuH / 2, r, 0, 7); x.fill(); + x.strokeStyle = 'rgba(255,255,255,.12)'; x.lineWidth = 0.6; for (let q = 0; q < 4; q++) { x.beginPath(); x.arc(a + psuW * 0.62, b + psuH / 2, r * (0.3 + q * 0.22), 0, 7); x.stroke(); } + rect('#1a1c1f', a + 4, b + psuH / 2 - 4, 12, 8); // the inlet + x.fillStyle = colors.ok; x.beginPath(); x.arc(a + psuW - 6, b + 5, 1, 0, 7); x.fill(); + } + const left = 8, right = psus[0][0] - 3; + const n = Math.max(1, sleds.length), sw = (right - left) / n; + sleds.forEach((s, si) => { + const x0 = left + si * sw; + if (n > 1) frame(x0 + 0.5, 1, sw - 1, H - 2, 'rgba(0,0,0,.8)'); + let xx = x0 + 3; + const yMid = Math.min(H / 2, 20); + // BMC port, two USB, VGA + rect('#0d0e10', xx, yMid - 5, 12, 10); frame(xx, yMid - 5, 12, 10); out.push({ sled: si, name: 'bmc', x: xx + 6, y: yMid }); xx += 15; + rect('#0d0e10', xx, yMid - 7, 6, 6); rect('#0d0e10', xx, yMid + 1, 6, 6); xx += 9; + if (n === 1) { rect('#13243f', xx, yMid - 4, 16, 8); xx += 20; } + // network cards, one plate each, ports along it + const cards = new Map(); + for (const pt of s.ports || []) { const kk = pt.card || pt.name; if (!cards.has(kk)) cards.set(kk, []); cards.get(kk).push(pt); } + for (const ports of cards.values()) { + const fast = ports.some(pt => parseInt(pt.speed) >= 10000), pw = fast ? 14 : 12.5, ph = fast ? 9 : 10.5; + const perRow = Math.max(1, Math.floor((x0 + sw - 4 - xx - 6) / (pw + 2))); + const rows = Math.ceil(ports.length / perRow), cols = Math.min(ports.length, perRow); + const cw = cols * (pw + 2) + 6, ch = Math.min(H - 4, rows * (ph + 3) + 6), cy = Math.max(2, yMid - ch / 2); + if (xx + cw > x0 + sw - 2) break; // no room left on this sled + rect('#2f3338', xx, cy, cw, ch); frame(xx, cy, cw, ch); + ports.forEach((pt, i) => { + const a = xx + 4 + (i % perRow) * (pw + 2), b = cy + 3 + Math.floor(i / perRow) * (ph + 3); + rect('#08090a', a, b, pw, ph); frame(a, b, pw, ph, 'rgba(255,255,255,.25)'); + x.fillStyle = pt.link ? colors.ok : '#3a3d42'; x.fillRect(a + 1, b - 1.6, 2.4, 1.2); + out.push({ sled: si, name: pt.name, x: a + pw / 2, y: b + ph / 2 }); + }); + xx += cw + 3; + } + // cover plates for the empty slots + for (; n === 1 && xx + 18 < right; xx += 20) { rect('#2a2d31', xx, 3, 18, H - 6); grille(xx, 5, 18, H - 10); } + }); + return { canvas: c, ports: out }; +} + +// drawSwitch paints a switch's port side: two rows of ports (odd above, even +// below), uplinks to the right. Returns where port number i is (mm). +function drawSwitch(count, colors, px = 768) { + const H = MM.U - 1, k = px / MM.W, c = document.createElement('canvas'); + c.width = px; c.height = Math.round(H * k); + const x = c.getContext('2d'); x.scale(k, k); + x.fillStyle = '#1f2226'; x.fillRect(0, 0, MM.W, H); + x.fillStyle = '#3a3e43'; x.fillRect(0, 0, 18, H); x.fillRect(MM.W - 18, 0, 18, H); + const cols = Math.ceil(count / 2), pw = Math.min(12, (MM.W - 140) / cols - 1.2), at = {}; + for (let i = 1; i <= count; i++) { + const col = Math.floor((i - 1) / 2), a = 24 + col * (pw + 1.2) + Math.floor(col / 6) * 2, b = (i % 2 ? 9 : 24); + x.fillStyle = '#07080a'; x.fillRect(a, b, pw, 10); x.strokeStyle = 'rgba(255,255,255,.2)'; x.lineWidth = 0.4; x.strokeRect(a, b, pw, 10); + at[i] = { x: a + pw / 2, y: b + 5 }; + } + for (let u = 0; u < 6; u++) { const a = MM.W - 110 + u * 15; x.fillStyle = '#07080a'; x.fillRect(a, 14, 13, 13); x.strokeStyle = 'rgba(255,255,255,.25)'; x.strokeRect(a, 14, 13, 13); at['u' + u] = { x: a + 6.5, y: 20.5 }; } + x.fillStyle = colors.ok; x.beginPath(); x.arc(MM.W - 10, 8, 1.2, 0, 7); x.fill(); + return { canvas: c, at }; +} diff --git a/cmd/rollcall/web/room3d.js b/cmd/rollcall/web/room3d.js index d07133c..aa64961 100644 --- a/cmd/rollcall/web/room3d.js +++ b/cmd/rollcall/web/room3d.js @@ -20,6 +20,13 @@ const frontView = { set(v) { try { localStorage.setItem('rollcall.fronts', v); } catch (e) { /* the choice is not kept */ } }, }; +// cablesView: the cables from the servers' ports to their switches, on or off +const cablesView = { + get() { try { return localStorage.getItem('rollcall.cables') !== 'off'; } catch (e) { return true; } }, + set(v) { try { localStorage.setItem('rollcall.cables', v ? 'on' : 'off'); } catch (e) { /* the choice is not kept */ } }, +}; +const SWITCH_COLORS = ['#4f8cff', '#f2a33a', '#3fbf8f', '#c470e0', '#e05d5d', '#46b8d1', '#b5c43a', '#e07fb0']; + function loadThree() { if (!loadThree.p) loadThree.p = import('./vendor/three/three.module.min.js').catch(e => { loadThree.p = null; throw e; }); return loadThree.p; @@ -56,9 +63,13 @@ async function room3d(site, roomArg, racks) { const css = getComputedStyle(document.body); const sig = JSON.stringify([racks.map((r, i) => [r.name, r.x, r.y, r.facing, r.height, r.auto, Object.values(blocks[i].top).map(b => [b.unit, b.h, b.known, b.ms.map(m => m.id + m.state)])]), racks.map((r, i) => Object.values(blocks[i].top).map(b => b.ms.map(m => [m.manufacturer, m.product, m.disks]))), - SITE.sel, frontView.get(), css.getPropertyValue('--bg'), css.getPropertyValue('--accent')]); + SITE.sel, frontView.get(), cablesView.get(), S.cabling, css.getPropertyValue('--bg'), css.getPropertyValue('--accent')]); if (key !== R3.key) { R3.key = key; R3.orb = null; } - if (sig !== R3.sig) { build(site, room, racks, blocks); R3.sig = sig; } + if (sig !== R3.sig) { + build(site, room, racks, blocks); R3.sig = sig; + const sw = JSON.stringify(R3.switches); // the side panel lists them: draw it again once they are known + if (sw !== R3.swSig) { R3.swSig = sw; if (!SITE.sel) { render(); return; } } + } if (!R3.orb) fit(); size(); draw(); } @@ -103,6 +114,8 @@ function build(site, room, racks, blocks) { Math.max(...racks.map(r => r.x + footprint(r).w)) * TILE, Math.max(...racks.map(r => r.y + footprint(r).h)) * TILE] : null }; sc.add(lines([0, 0.002, 0, w, 0.002, 0, w, 0.002, 0, w, 0.002, d, w, 0.002, d, 0, 0.002, d, 0, 0.002, d, 0, 0.002, 0], lineMat('--muted'))); + const cab = S.cabling || { machines: [], switches: [] }, portsBy = new Map(cab.machines.map(x => [x.id, x.ports])); + const rears = {}, rackAt = {}, portAt = new Map(); racks.forEach((r, i) => { const f = footprint(r), g = new T.Group(), H = r.height || 42, h = PLINTH + H * U_M + TOPCAP, sel = SITE.sel === r.name; g.position.set((r.x + f.w / 2) * TILE, 0, (r.y + f.h / 2) * TILE); @@ -116,7 +129,10 @@ function build(site, room, racks, blocks) { // the rails' face, lighter than the cabinet: where the front is and what is empty const face = new T.Mesh(box(RAIL, H * U_M, 0.004), mat('face', () => new T.MeshStandardMaterial({ color: 0x3c434c, roughness: 0.9 }))); face.position.set(0, PLINTH + H * U_M / 2, -TILE + 0.008); - g.add(body, edges, face); R3.pick.push(body); + const back = face.clone(); back.position.z = TILE - 0.008; + g.add(body, edges, face, back); R3.pick.push(body); + g.updateMatrixWorld(true); + rackAt[lc(r.name)] = { g, H, h, r, used: blocks[i].covered }; for (const b of Object.values(blocks[i].top)) { const span = Math.min(H, b.unit + b.h - 1) - b.unit + 1, hh = span * U_M - 0.004, y = PLINTH + (b.unit - 1) * U_M + 0.002 + hh / 2; const n = b.node ? b.ms.length : 1; @@ -146,6 +162,23 @@ function build(site, room, racks, blocks) { s.userData = { rack: r.name, id: m.id, tip: `${nameOf(m)} · ${m.state} · U${b.unit}${span > 1 ? '–' + (b.unit + span - 1) : ''}${b.known ? '' : ' (height unknown)'} · ${[m.manufacturer, m.product].filter(Boolean).join(' ')}` }; g.add(s); R3.pick.push(s); }); + // the back: PSUs, the BMC port and the network cards with their ports + const p = frontProfile(b, span), sleds = b.ms.slice(0, n).map(m => ({ ports: portsBy.get(m.id) || [] })); + const rkey = p.style + '|' + span + '|' + JSON.stringify(sleds); + const rear = rears[rkey] || (rears[rkey] = (() => { + const f = drawRear(p, span, sleds, colorOf), tex = keep(new T.CanvasTexture(f.canvas)); + tex.colorSpace = T.SRGBColorSpace; tex.anisotropy = R3.gl.capabilities.getMaxAnisotropy(); + const side = mat('rside', () => new T.MeshStandardMaterial({ color: 0x1c1e22, roughness: 0.8 })); + return { look: [side, side, side, side, keep(new T.MeshStandardMaterial({ map: tex, emissiveMap: tex, emissive: 0xffffff, emissiveIntensity: 0.3, roughness: 0.7 })), side], ports: f.ports }; + })()); + const rm = new T.Mesh(box(RAIL - 0.001, hh, 0.02), rear.look); + rm.position.set(0, y, TILE - 0.004); + rm.userData = { rack: r.name, id: n === 1 ? b.ms[0].id : '', tip: `${b.ms.map(nameOf).join(', ')} · the back · ${sleds.reduce((a, x) => a + x.ports.length, 0)} network ports` }; + g.add(rm); R3.pick.push(rm); + for (const pt of rear.ports) { + const m = b.ms[pt.sled]; if (!m) continue; + portAt.set(m.id + '|' + pt.name, { rack: lc(r.name), lx: -RAIL / 2 + RAIL * pt.x / MM.W, ly: y + hh / 2 - hh * pt.y / (span * MM.U - 1) }); + } } if (sel) { // the selected rack's footprint, in the accent colour const p = new T.Mesh(keep(new T.PlaneGeometry(TILE + 0.1, 2 * TILE + 0.1)), mat('selp', () => new T.MeshBasicMaterial({ color: cv('--accent'), transparent: true, opacity: 0.35 }))); @@ -155,6 +188,82 @@ function build(site, room, racks, blocks) { label.position.y = h + 0.22; g.add(label); sc.add(g); }); + R3.switches = placeSwitches(site, room, cab, rackAt, portAt); + for (const sw of R3.switches) if (sw.at) drawSwitchIn(sw, rackAt[sw.at.rack], keep, mat, colorOf); + if (cablesView.get()) cables(cab, rackAt, portAt, keep, mat); +} + +// placeSwitches: where each switch the room's machines lead to stands. Its +// stored place (NetBox, or set by hand) when that is a rack of this room; +// else, when it has none, the rack with most of its links, at the highest +// free unit (on top of the rack when none is free): an estimate. A switch +// placed in another room is reached by cables leaving through the ceiling. +function placeSwitches(site, room, cab, rackAt, portAt) { + const links = {}; // switch -> rack -> links + for (const mp of cab.machines) for (const pt of mp.ports) { + const at = pt.switch && portAt.get(mp.id + '|' + pt.name); if (!at) continue; + (links[pt.switch] = links[pt.switch] || {})[at.rack] = (links[pt.switch][at.rack] || 0) + 1; + } + const taken = {}; // rack -> units switches took + return cab.switches.filter(s => links[s.name]).map((s, i) => { + const out = { ...s, color: SWITCH_COLORS[i % SWITCH_COLORS.length], here: Object.values(links[s.name]).reduce((a, b) => a + b, 0) }; + const inRoom = s.rack && (!s.building || inSite(site, s.building)) && sameRoom(s.room || '', room) && rackAt[lc(s.rack)]; + if (s.rack && !inRoom) { out.away = [s.building, s.room, s.rack].filter(Boolean).join(' / '); return out; } + const rack = inRoom ? lc(s.rack) : Object.entries(links[s.name]).sort((a, b) => b[1] - a[1])[0][0]; + const ra = rackAt[rack]; let unit = inRoom ? s.unit : 0; + if (!unit) { for (let u = ra.H; u >= 1; u--) if (!ra.used.has(u) && !(taken[rack] || []).includes(u)) { unit = u; break; } } + (taken[rack] = taken[rack] || []).push(unit); + out.at = { rack, unit: unit || ra.H + 1 + (taken[rack].filter(u => !u).length - 1), estimated: !inRoom || !s.unit }; + return out; + }); +} + +// switchPort: where a switch's port named like "ethernet1/1/12" or "Gi1/0/24" is on its drawing +function switchPortNo(name) { const m = /(\d+)\D*$/.exec(name || ''); return m ? Number(m[1]) : 0; } + +function drawSwitchIn(sw, ra, keep, mat, colorOf) { + const T = R3.T; + const max = Math.max(48, ...((S.cabling || {}).machines || []).flatMap(mp => mp.ports.filter(p => p.switch === sw.name).map(p => switchPortNo(p.switch_port)))); + const count = Math.min(128, Math.ceil(max / 2) * 2), d = drawSwitch(count, colorOf), tex = keep(new T.CanvasTexture(d.canvas)); + tex.colorSpace = T.SRGBColorSpace; tex.anisotropy = R3.gl.capabilities.getMaxAnisotropy(); + const side = mat('swside', () => new T.MeshStandardMaterial({ color: 0x15171a, roughness: 0.7 })); + const top = sw.at.unit > ra.H, y = top ? ra.h + U_M / 2 + 0.005 : PLINTH + (sw.at.unit - 1) * U_M + U_M / 2; + const look = [side, side, side, side, keep(new T.MeshStandardMaterial({ map: tex, emissiveMap: tex, emissive: 0xffffff, emissiveIntensity: 0.35, roughness: 0.6 })), side]; + const m = new T.Mesh(keep(new T.BoxGeometry(RAIL - 0.001, U_M - 0.004, 0.3)), look); + m.position.set(0, y, TILE + 0.006 - 0.15); + const edge = new T.LineSegments(keep(new T.EdgesGeometry(m.geometry)), mat('swl' + sw.color, () => new T.LineBasicMaterial({ color: new T.Color(sw.color) }))); + edge.position.copy(m.position); + m.userData = { rack: ra.r.name, tip: `${sw.name}${sw.model ? ' · ' + sw.model : ''} · ${sw.here} cables here · ${sw.at.estimated ? 'place estimated: ' + (sw.source ? 'no unit' : 'not in NetBox') + ', set it in the panel' : 'U' + sw.at.unit + ' (' + sw.source + ')'}` }; + ra.g.add(m, edge); R3.pick.push(m); + sw.port = name => { const n = switchPortNo(name), p = d.at[n] || d.at['u' + (n % 6)]; return { lx: -RAIL / 2 + RAIL * p.x / MM.W, ly: y + (U_M - 0.004) / 2 - (U_M - 0.004) * p.y / (MM.U - 1) }; }; +} + +// cables: from each port with an LLDP neighbour out of the back, along the +// rack's side, over the top to the switch's rack when it stands elsewhere, +// into the switch port. One colour per switch; a port without link is grey; +// with a rack selected, the other racks' cables fade. +function cables(cab, rackAt, portAt, keep, mat) { + const T = R3.T, OUT = TILE + 0.07, byName = Object.fromEntries(R3.switches.map(s => [s.name, s])); + const W = (ra, x, y, z) => ra.g.localToWorld(new T.Vector3(x, y, z)); + for (const mp of cab.machines) for (const pt of mp.ports) { + const a = pt.switch && portAt.get(mp.id + '|' + pt.name), sw = byName[pt.switch]; if (!a || !sw) continue; + const ra = rackAt[a.rack], side = a.lx >= 0 ? RAIL / 2 + 0.03 : -RAIL / 2 - 0.03, lift = 0.004 * (switchPortNo(pt.name) % 5); + const pts = [W(ra, a.lx, a.ly, TILE + 0.01), W(ra, a.lx, a.ly - 0.01, OUT), W(ra, side, a.ly - 0.01, OUT)]; + if (sw.at) { + const rb = rackAt[sw.at.rack], b = sw.port(pt.switch_port), sideB = b.lx >= 0 ? RAIL / 2 + 0.03 : -RAIL / 2 - 0.03; + if (rb !== ra) { pts.push(W(ra, side, ra.h + 0.08 + lift, OUT), W(rb, sideB, rb.h + 0.08 + lift, OUT)); } + if (rb === ra) pts.push(W(ra, side, b.ly + 0.012, OUT)); // down its own side to the switch, then across + else pts.push(W(rb, sideB, b.ly + 0.012, OUT)); + pts.push(W(rb, b.lx, b.ly + 0.012, OUT), W(rb, b.lx, b.ly, TILE + 0.012)); + } else pts.push(W(ra, side, ra.h + 0.08 + lift, OUT), W(ra, side * 0.5, 2.9, OUT)); // to a switch in another room: up through the ceiling + const dim = SITE.sel && lc(SITE.sel) !== a.rack && !(sw.at && lc(SITE.sel) === sw.at.rack); + const col = pt.link === false ? '#7a7f86' : sw.color; + const m = new T.Mesh(keep(new T.TubeGeometry(new T.CatmullRomCurve3(pts, false, 'catmullrom', 0.02), pts.length * 12, 0.0035, 6, false)), + mat('cab' + col + dim, () => new T.MeshStandardMaterial({ color: new T.Color(col), roughness: 0.5, transparent: dim, opacity: dim ? 0.15 : 1 }))); + const m0 = S.machines.find(x => x.id === mp.id); + m.userData = { rack: ra.r.name, id: mp.id, tip: `${m0 ? nameOf(m0) : mp.id} ${pt.name}${pt.speed ? ' ' + pt.speed : ''} → ${pt.switch} ${pt.switch_port}${pt.vlan ? ' · VLAN ' + pt.vlan : ''}${pt.link === false ? ' · no link' : ''}${sw.away ? ' (in ' + sw.away + ')' : ''}` }; + R3.scene.add(m); if (!dim) R3.pick.push(m); + } } // labelSprite: a rack's name on a pill, drawn over everything so a rack in @@ -276,3 +385,4 @@ function clicked(ev) { } function room3dReset() { R3.orb = null; fit(); draw(); } +function room3dTurn() { if (R3.orb) { R3.orb.theta += Math.PI; draw(); } } diff --git a/cmd/rollcall/web/sites.js b/cmd/rollcall/web/sites.js index 576ab65..443994b 100644 --- a/cmd/rollcall/web/sites.js +++ b/cmd/rollcall/web/sites.js @@ -215,14 +215,14 @@ function pageRoom(siteName, roomArg) { const selRack = racks.find(r => r.name === SITE.sel); const panel = selRack ? `

${esc(selRack.name)} ${selRack.height}U · front ${({ N: 'north', S: 'south', E: 'east', W: 'west' })[selRack.facing]}

${SITE.edit ? `
` : ''} - ${rackElev(selRack)}
` - : `
Click a rack for its front.${autoN ? `

${autoN} rack(s) have no place yet and are laid out automatically (${v3 ? 'see-through' : 'dashed'}): Edit layout to place them.` : ''}
`; + ${rackElev(selRack)}${cablingTable(site, selRack)}` + : `
Click a rack for its front.${autoN ? `

${autoN} rack(s) have no place yet and are laid out automatically (${v3 ? 'see-through' : 'dashed'}): Edit layout to place them.` : ''}
${v3 ? switchTable() : ''}
`; const legend = v3 ? `drag: turn · right or shift drag: move · wheel: zoom · double click: go to a rack · a click on a selected rack\'s server opens it · see-through: height unknown${frontView.get() === 'real' ? ' · fronts: the drive bays of the model, the strip on the left ear is the state' : ''}` : `emptythick edge: the front · bar: units used${SITE.edit ? ' · drag to move, select and Rotate' : ''}`; return `

Sites / ${esc(site.name)} / ${esc(room || '(no room)')}

${racks.length} racks · floor tiles of 600 mm
${SITE.edit ? '' - : `${v3 ? `` : ''}`}
+ : `${v3 ? `` : ''}`}
${v3 ? '
' : floorSVG(site, room)}
deployed / readybusy / newfailed${legend}
${panel}
`; } @@ -235,6 +235,9 @@ function wireRoom(siteName, roomArg) { $$('.floor-side .elev .slot.m').forEach(s => s.onclick = () => { location.hash = '#/machine/' + s.dataset.id; }); const e = $('#fl-edit'); if (e) e.onclick = () => { SITE.edit = { racks: racksOnFloor(site, room).map(r => ({ ...r })) }; render(); }; const rs = $('#r3-reset'); if (rs) rs.onclick = () => room3dReset(); + const tu = $('#r3-turn'); if (tu) tu.onclick = () => room3dTurn(); + const cb = $('#r3-cables'); if (cb) cb.onclick = () => { cablesView.set(!cablesView.get()); render(); }; + $$('[data-swplace]').forEach(b => b.onclick = () => placeSwitch(site, room, b.dataset.swplace)); if ($('#room3d')) { room3d(site, roomArg, racksOnFloor(site, room)); return; } room3dStop(); const svg = $('svg.floor'); if (!svg) return; @@ -291,3 +294,38 @@ function renderSites() { if (main.dataset.view === 'sites' && refreshSites()) return; main.innerHTML = pageSites(); main.dataset.view = 'sites'; wireSites(); } + +// ---------------------------------------------------------------- cabling +// cablingTable: the selected rack's ports and the switch port each is plugged into (LLDP) +function cablingTable(site, rk) { + const cab = S.cabling; if (!cab) return ''; + const ms = S.machines.filter(m => m.location && inSite(site, m.location.building) && sameRoom(m.location.room || '', rk.room) && lc(m.location.rack) === lc(rk.name)) + .sort((a, b) => (b.location.unit || 0) - (a.location.unit || 0)); + const rows = []; + for (const m of ms) for (const p of ((cab.machines.find(x => x.id === m.id) || {}).ports || [])) { + rows.push([`${esc(nameOf(m))}`, `${esc(p.name)}`, esc(p.speed || ''), + p.switch ? `${esc(p.switch)} ${esc(p.switch_port)}` : `${p.link === false ? 'no link' : 'no LLDP'}`, esc(p.vlan || '')]); + } + return rows.length ? `

Cabling

${table(['Machine', 'Port', 'Speed', 'Switch port', 'VLAN'], rows)}
` : ''; +} + +// switchTable: the switches this room's cables lead to, where they stand +function switchTable() { + const sws = (typeof R3 !== 'undefined' && R3.switches) || []; if (!sws.length) return ''; + const where = s => s.away ? `in ${esc(s.away)}` : s.at ? `${esc(rackLabel(s.at.rack))} U${s.at.unit}${s.at.estimated ? ' estimated' : ` ${esc(s.source)}`}` : ''; + return `

Switches

${sws.map(s => `
+
${esc(s.name)}${s.model ? ` ${esc(s.model)}` : ''}
${where(s)} · ${s.here} cables
+
`).join('')}
+

A switch's place comes from NetBox (its rack and position) or is set here; without either it is drawn where most of its cables are.

`; +} +const rackLabel = k => ((S.racks.find(r => lc(r.name) === k) || {}).name || k); + +async function placeSwitch(site, room, name) { + const sw = (R3.switches || []).find(s => s.name === name) || {}; + const racks = racksOnFloor(site, room); + const v = await dialog(`Place ${name}`, [{ name: 'rack', label: 'Rack', type: 'select', value: sw.at ? rackLabel(sw.at.rack) : '', options: racks.map(r => [r.name, r.name]) }, + { name: 'unit', label: 'Unit', value: sw.at && sw.at.unit <= 60 ? sw.at.unit : '', help: 'lowest U it takes' }], 'Set by hand, the place stands even when NetBox has another.', 'Place'); + if (!v) return; + const rk = racks.find(r => r.name === v.rack); + try { await api('POST', '/api/v1/switches', { name, building: rk.building, room: rk.room, rack: rk.name, unit: Number(v.unit) || 0, height: 1 }); toast(`${name} placed in ${rk.name}`); refresh(true); } catch (e) { toast(e.message, true); } +} diff --git a/cmd/rollcall/web/style.css b/cmd/rollcall/web/style.css index 09be476..88f8673 100644 --- a/cmd/rollcall/web/style.css +++ b/cmd/rollcall/web/style.css @@ -237,3 +237,5 @@ svg.floor .rk text { font-size: 9px; fill: var(--fg); pointer-events: none; font .room3d { position: relative; height: min(72vh, 680px); min-height: 360px; border-radius: 6px; overflow: hidden; } .room3d canvas { display: block; cursor: grab; } .room3d canvas:active { cursor: grabbing; } .r3-tip { position: absolute; pointer-events: none; background: var(--panel); border: 1px solid var(--line2); border-radius: 6px; padding: 4px 8px; font-size: 12px; white-space: nowrap; box-shadow: 0 4px 14px rgba(0,0,0,.18); } +.sw-list { display: grid; gap: 6px; } .sw-row { display: grid; grid-template-columns: 12px minmax(0, 1fr) auto; gap: 8px; align-items: center; } +.sw-row > div { min-width: 0; overflow-wrap: anywhere; } .sw-dot { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }