3D rooms: server backs, switches and the cables between them (LLDP)
GET /api/v1/cabling gives each located machine's ports (from its inventory: name, speed, link, the PCI card they sit on) and the switch port each is plugged into, from the LLDP its commissioning saw (lldpcli's neighbours, either JSON shape). Switches get a place: NetBox's (the device's site, location, rack and position, read with the layout on every reconcile) or one set by hand (POST /api/v1/switches, which NetBox does not move); a switch with neither is drawn where most of its cables are, at the highest free unit (on top of the rack when none is free), and marked as estimated. In 3D every server now has a back too: power supplies, the BMC port, USB and VGA, and its network cards with their ports (SFP cages from 10G, RJ45 below), per sled for a multi-node chassis. Switches stand at the back with their ports in two rows. Cables run from each port out of the back, down the rack's side, over the top to another rack's switch, into the switch port; one colour per switch, grey without link, and up through the ceiling to a switch in another room. Hovering a cable names both ends and the VLAN; with a rack selected the other racks' cables fade. "Cables" turns them off, "Turn around" looks from the other side. The side panel lists the room's switches with their place (place… sets it) and, for a selected rack, every port and the switch port it goes to, in 2D as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0d6f227819
commit
c31a89482e
10 files changed
+615
-7
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
+113
-3
@@ -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(); } }
|
||||
@@ -215,14 +215,14 @@ function pageRoom(siteName, roomArg) {
|
||||
const selRack = racks.find(r => r.name === SITE.sel);
|
||||
const panel = selRack ? `<div class="card floor-side"><h3>${esc(selRack.name)} <span class="muted">${selRack.height}U · front ${({ N: 'north', S: 'south', E: 'east', W: 'west' })[selRack.facing]}</span></h3>
|
||||
${SITE.edit ? `<div class="actions" style="margin-bottom:8px"><button class="btn sm" id="rk-rotate">Rotate</button></div>` : ''}
|
||||
${rackElev(selRack)}</div>`
|
||||
: `<div class="card floor-side muted">Click a rack for its front.${autoN ? `<br><br>${autoN} rack(s) have no place yet and are laid out automatically (${v3 ? 'see-through' : 'dashed'}): Edit layout to place them.` : ''}</div>`;
|
||||
${rackElev(selRack)}${cablingTable(site, selRack)}</div>`
|
||||
: `<div class="card floor-side"><span class="muted">Click a rack for its front.${autoN ? `<br><br>${autoN} rack(s) have no place yet and are laid out automatically (${v3 ? 'see-through' : 'dashed'}): Edit layout to place them.` : ''}</span>${v3 ? switchTable() : ''}</div>`;
|
||||
const legend = v3 ? `<span>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' : ''}</span>`
|
||||
: `<span><b class="lg vacant"></b>empty</span><span>thick edge: the front · bar: units used${SITE.edit ? ' · drag to move, select and Rotate' : ''}</span>`;
|
||||
return `<div class="page-head"><h1><a href="#/sites" class="muted" style="font-weight:400">Sites</a> / <a href="#/sites/${encodeURIComponent(site.name)}" class="muted" style="font-weight:400">${esc(site.name)}</a> / ${esc(room || '(no room)')}</h1>
|
||||
<span class="sub">${racks.length} racks · floor tiles of 600 mm</span>
|
||||
<div class="right">${SITE.edit ? '<button class="btn" id="fl-cancel">Cancel</button><button class="btn" id="fl-auto">Automatic again</button><button class="btn primary" id="fl-save">Save layout</button>'
|
||||
: `<span class="seg-btns"><button class="btn${v3 ? '' : ' on'}" data-roomview="2d">2D</button><button class="btn${v3 ? ' on' : ''}" data-roomview="3d">3D</button></span>${v3 ? `<span class="seg-btns" title="servers: their fronts as the model has them, or blocks in their state's colour"><button class="btn${frontView.get() === 'real' ? ' on' : ''}" data-fronts="real">Fronts</button><button class="btn${frontView.get() === 'state' ? ' on' : ''}" data-fronts="state">State</button></span><button class="btn" id="r3-reset">Reset view</button>` : ''}<button class="btn" id="fl-edit">Edit layout</button>`}</div></div>
|
||||
: `<span class="seg-btns"><button class="btn${v3 ? '' : ' on'}" data-roomview="2d">2D</button><button class="btn${v3 ? ' on' : ''}" data-roomview="3d">3D</button></span>${v3 ? `<span class="seg-btns" title="servers: their fronts as the model has them, or blocks in their state's colour"><button class="btn${frontView.get() === 'real' ? ' on' : ''}" data-fronts="real">Fronts</button><button class="btn${frontView.get() === 'state' ? ' on' : ''}" data-fronts="state">State</button></span><button class="btn${cablesView.get() ? ' on' : ''}" id="r3-cables" title="cables from the servers' ports to their switches (LLDP)">Cables</button><button class="btn" id="r3-turn" title="look from the other side: the backs, the switches and the cables">Turn around</button><button class="btn" id="r3-reset">Reset view</button>` : ''}<button class="btn" id="fl-edit">Edit layout</button>`}</div></div>
|
||||
<div class="floor-wrap"><div class="card floor-card">${v3 ? '<div id="room3d" class="room3d"></div>' : floorSVG(site, room)}
|
||||
<div class="legend"><span><b class="lg ok"></b>deployed / ready</span><span><b class="lg warn"></b>busy / new</span><span><b class="lg bad"></b>failed</span>${legend}</div></div>${panel}</div>`;
|
||||
}
|
||||
@@ -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([`<a href="#/machine/${esc(m.id)}">${esc(nameOf(m))}</a>`, `<span class="mono">${esc(p.name)}</span>`, esc(p.speed || ''),
|
||||
p.switch ? `${esc(p.switch)} <span class="mono">${esc(p.switch_port)}</span>` : `<span class="muted">${p.link === false ? 'no link' : 'no LLDP'}</span>`, esc(p.vlan || '')]);
|
||||
}
|
||||
return rows.length ? `<h3 style="margin-top:12px">Cabling</h3><div class="tw">${table(['Machine', 'Port', 'Speed', 'Switch port', 'VLAN'], rows)}</div>` : '';
|
||||
}
|
||||
|
||||
// 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 ? ' <span class="warn">estimated</span>' : ` <span class="muted">${esc(s.source)}</span>`}` : '';
|
||||
return `<h3 style="margin-top:12px">Switches</h3><div class="sw-list">${sws.map(s => `<div class="sw-row"><b class="sw-dot" style="background:${esc(s.color)}"></b>
|
||||
<div><div><b>${esc(s.name)}</b>${s.model ? ` <span class="muted">${esc(s.model)}</span>` : ''}</div><div class="muted">${where(s)} · ${s.here} cables</div></div>
|
||||
<button class="btn sm" data-swplace="${esc(s.name)}">place…</button></div>`).join('')}</div>
|
||||
<p class="muted">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.</p>`;
|
||||
}
|
||||
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); }
|
||||
}
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user