From cb7b6fd19ec8147d9ba175fa8a0fcbf3a9e5576b Mon Sep 17 00:00:00 2001 From: Ilker Manap Date: Tue, 27 Oct 2026 15:32:26 +0100 Subject: [PATCH] Physical location: building / room / rack / unit / asset tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - machines.building/room/rack/rack_unit/asset_tag, racks table (height, comment) - API: POST /machines/{id}/location, POST /locations/import (CSV or JSON, matched by id/serial/mac/node_name, ?dry=1), GET/POST/DELETE /racks, ?building/?room/?rack filters - CLI: rollcall location ..., location import, location racks/rack, list -rack/-room/-building - UI: location column and building > room > rack chips, bulk Set location…, Racks page with elevation views and CSV import dialog, location in machine detail - .Node.Building/Room/Rack/Unit/AssetTag in overlay templates and tag match rules Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 + README.md | 21 ++ cmd/rollcall/client.go | 40 +++- cmd/rollcall/location.go | 456 ++++++++++++++++++++++++++++++++++++ cmd/rollcall/main.go | 8 +- cmd/rollcall/server.go | 12 +- cmd/rollcall/stateless.go | 4 + cmd/rollcall/store.go | 20 +- cmd/rollcall/web/app.js | 113 ++++++++- cmd/rollcall/web/index.html | 1 + cmd/rollcall/web/style.css | 15 ++ 11 files changed, 678 insertions(+), 16 deletions(-) create mode 100644 cmd/rollcall/location.go diff --git a/CHANGELOG.md b/CHANGELOG.md index db5fe62..2d8a864 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ publish a tag that has no section here. OS images), state/tag filter chips, search, sorting and paging, multi-select with bulk power/deploy/assign/tag/re-commission/release, machine detail with tabs, dialog-driven actions, fleet-wide `/api/v1/events` and `/api/v1/jobs`, `?token=` sign-in for bookmarks. +- Physical location per machine (building, room, rack, unit, asset tag): `rollcall location`, + CSV import matched by id/serial/MAC/node name with dry run, rack definitions with height, + `list -rack/-room/-building`, location column and chips in the UI, Racks page with + elevation views, bulk "Set location…", `.Node.Rack` etc. in templates and tag rules. - `rollcall version`; CD-built binary carries `git describe`. - Release notes from `CHANGELOG.md`. diff --git a/README.md b/README.md index f9f1b14..00d51ca 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,27 @@ rollcall power tag:rack-3 off * **For stateless nodes** a tag's overlays are applied after the profile's, so a `gpu` tag can add `gres.conf` lines or driver modules without a second profile. +## Physical location + +Every machine can carry building / room / rack / rack unit and an asset tag. + +```sh +rollcall location m-2ef1dab828c8 -building DC1 -room "Hall A" -rack R12 -unit 24 -asset A-0042 +rollcall location tag:gpu -building DC1 -room "Hall B" # bulk by tag +rollcall location import racks.csv -dry # from a DCIM / asset export +rollcall location racks | rack R12 -building DC1 -room "Hall A" -height 48 +rollcall list -rack R12 +``` + +The CSV needs a header; rows are matched by `id`, `serial`, `mac` or `node_name` +and may carry `building, room, rack, unit, asset_tag`. Racks are created on +first use (42U) and can be resized. The web UI shows a location column with +building › room › rack chips, a **Racks** page with elevation views (machines +drawn at their U, coloured by state) and bulk "Set location…". Templates and +tag rules see `.Node.Building`, `.Node.Room`, `.Node.Rack`, `.Node.Unit`, so a +rack can drive a tag (`{{ eq .Node.Rack "R12" }}`) or a Slurm `topology.conf` +overlay. + ## Lab `lab/` documents a Proxmox SDN test setup: an isolated vnet, a PXE server VM diff --git a/cmd/rollcall/client.go b/cmd/rollcall/client.go index ab6a8dc..59c5b31 100644 --- a/cmd/rollcall/client.go +++ b/cmd/rollcall/client.go @@ -97,14 +97,20 @@ func runClient(cmd string, args []string) error { case "list": fs := newFlags("list") tag := fs.String("tag", "", "only machines with this tag") + rack := fs.String("rack", "", "only machines in this rack") + room := fs.String("room", "", "only machines in this room") + bld := fs.String("building", "", "only machines in this building") _ = fs.Parse(args) ms, err := c.machines(*tag) if err != nil { return err } - f := "%-16s %-14s %-10s %-16s %-14s %-24s %4s %7s %3s %7s %3s %2s %3s %-18s %s\n" - fmt.Printf(f, "ID", "STATE", "NODE", "PROFILE/OS", "MGMT-IP", "MODEL", "CPU", "RAM", "DSK", "STORAGE", "NIC", "IB", "GPU", "BMC", "TAGS") + f := "%-16s %-14s %-10s %-16s %-14s %-22s %-18s %4s %7s %3s %7s %3s %2s %3s %-18s %s\n" + fmt.Printf(f, "ID", "STATE", "NODE", "PROFILE/OS", "MGMT-IP", "LOCATION", "MODEL", "CPU", "RAM", "DSK", "STORAGE", "NIC", "IB", "GPU", "BMC", "TAGS") for _, m := range ms { + if (*rack != "" && !strings.EqualFold(m.Location.Rack, *rack)) || (*room != "" && !strings.EqualFold(m.Location.Room, *room)) || (*bld != "" && !strings.EqualFold(m.Location.Building, *bld)) { + continue + } state := m.State if m.Job != nil { var p obj @@ -125,7 +131,7 @@ func runClient(cmd string, args []string) error { bmc = fmt.Sprintf("%s (%s,%s)", str(b["address"]), str(b["source"]), ok) } model := strings.TrimSpace(deref(m.Manufacturer) + " " + deref(m.Product)) - fmt.Printf(f, m.ID, state, deref(m.NodeName), trunc(what, 16), deref(m.MgmtIP), trunc(model, 24), m.CPUs, human(m.MemoryBytes), + fmt.Printf(f, m.ID, state, deref(m.NodeName), trunc(what, 16), deref(m.MgmtIP), trunc(m.Location.String(), 22), trunc(model, 18), m.CPUs, human(m.MemoryBytes), fmt.Sprint(int(m.Disks)), human(m.StorageBytes), fmt.Sprint(m.NICs), fmt.Sprint(m.IBHCAs), fmt.Sprint(m.GPUs), trunc(bmc, 18), strings.Join(m.Tags, ",")) } case "show": @@ -207,6 +213,8 @@ func runClient(cmd string, args []string) error { return c.deployCmd(cmd, args) case "tag", "tags": return c.tagCmd(cmd, args) + case "location": + return c.locationCmd(args) default: return c.statelessCmd(cmd, args) } @@ -629,6 +637,32 @@ func (c *client) statelessCmd(cmd string, args []string) error { return nil } +func readFileArg(p string) ([]byte, error) { + if p == "-" { + return io.ReadAll(os.Stdin) + } + return os.ReadFile(p) +} + +func (c *client) rawBody(method, path, ctype string, body []byte) ([]byte, error) { + req, err := http.NewRequest(method, c.base+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Content-Type", ctype) + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(data))) + } + return data, nil +} + func (c *client) raw(method, path string) ([]byte, error) { req, err := http.NewRequest(method, c.base+path, nil) if err != nil { diff --git a/cmd/rollcall/location.go b/cmd/rollcall/location.go new file mode 100644 index 0000000..a48af54 --- /dev/null +++ b/cmd/rollcall/location.go @@ -0,0 +1,456 @@ +package main + +// Physical location: building / room / rack / unit (and an asset tag) per machine, +// plus optional rack definitions (height) for the elevation view. Locations are +// set by hand, in bulk, or imported from a CSV exported by a DCIM / asset list. + +import ( + "context" + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "strings" +) + +type Location struct { + Building string `json:"building"` + Room string `json:"room"` + Rack string `json:"rack"` + Unit int `json:"unit"` // lowest U occupied, 0 = unknown + AssetTag string `json:"asset_tag"` +} + +func (l Location) String() string { + var p []string + for _, s := range []string{l.Building, l.Room, l.Rack} { + if s != "" { + p = append(p, s) + } + } + s := strings.Join(p, " / ") + if l.Unit > 0 { + s += fmt.Sprintf(" U%d", l.Unit) + } + return s +} + +func (l Location) empty() bool { + return l.Building == "" && l.Room == "" && l.Rack == "" && l.Unit == 0 && l.AssetTag == "" +} + +type Rack struct { + Building string `json:"building"` + Room string `json:"room"` + Name string `json:"name"` + Height int `json:"height"` + Comment string `json:"comment"` + Machines int `json:"machines,omitempty"` +} + +// ---------------------------------------------------------------- store + +func (s *Store) SetLocation(ctx context.Context, id string, l Location) error { + tag, err := s.pool.Exec(ctx, `UPDATE machines SET building=nullif($2,''), room=nullif($3,''), rack=nullif($4,''), rack_unit=nullif($5,0), asset_tag=nullif($6,'') WHERE id=$1`, + id, strings.TrimSpace(l.Building), strings.TrimSpace(l.Room), strings.TrimSpace(l.Rack), l.Unit, strings.TrimSpace(l.AssetTag)) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return errNotFound + } + return nil +} + +func (s *Store) Racks(ctx context.Context) ([]Rack, error) { + rows, err := s.pool.Query(ctx, `SELECT building, room, name, height, coalesce(comment,'') FROM racks ORDER BY building, room, name`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Rack{} + for rows.Next() { + var r Rack + if err := rows.Scan(&r.Building, &r.Room, &r.Name, &r.Height, &r.Comment); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +func (s *Store) PutRack(ctx context.Context, r Rack) error { + if r.Height <= 0 { + r.Height = 42 + } + _, err := s.pool.Exec(ctx, `INSERT INTO racks(building, room, name, height, comment) VALUES ($1,$2,$3,$4,$5) + ON CONFLICT (building, room, name) DO UPDATE SET height=$4, comment=$5`, r.Building, r.Room, r.Name, r.Height, r.Comment) + return err +} + +func (s *Store) DeleteRack(ctx context.Context, building, room, name string) error { + _, err := s.pool.Exec(ctx, `DELETE FROM racks WHERE building=$1 AND room=$2 AND name=$3`, building, room, name) + return err +} + +// ---------------------------------------------------------------- handlers + +// handleLocation sets one machine's location: {"building","room","rack","unit","asset_tag"}. +// Fields that are absent keep their value; "" or 0 clears one. +func (a *App) handleLocation(w http.ResponseWriter, r *http.Request) { + m, err := a.store.Get(r.Context(), r.PathValue("id")) + if err != nil { + fail(w, 500, err) + return + } + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + fail(w, 400, err) + return + } + l := m.Location + if v, ok := req["building"]; ok { + l.Building = str(v) + } + if v, ok := req["room"]; ok { + l.Room = str(v) + } + if v, ok := req["rack"]; ok { + l.Rack = str(v) + } + if v, ok := req["unit"]; ok { + l.Unit = atoi(v) + } + if v, ok := req["asset_tag"]; ok { + l.AssetTag = str(v) + } + if err := a.store.SetLocation(r.Context(), m.ID, l); err != nil { + fail(w, 500, err) + return + } + a.ensureRack(r.Context(), l) + _ = a.store.AddEvent(r.Context(), m.ID, "location", l.String()) + m, _ = a.store.Get(r.Context(), m.ID) + a.retag(r.Context(), []*Machine{m}) // rules may look at the rack + writeJSON(w, 200, obj{"id": m.ID, "location": m.Location}) +} + +// ensureRack creates a rack definition (default height) the first time a machine lands in it. +func (a *App) ensureRack(ctx context.Context, l Location) { + if l.Rack == "" { + return + } + racks, _ := a.store.Racks(ctx) + for _, r := range racks { + if r.Building == l.Building && r.Room == l.Room && r.Name == l.Rack { + return + } + } + _ = a.store.PutRack(ctx, Rack{Building: l.Building, Room: l.Room, Name: l.Rack}) +} + +func (a *App) handleRacks(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + racks, err := a.store.Racks(r.Context()) + if err != nil { + fail(w, 500, err) + return + } + 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 { + racks[i].Machines++ + } + } + } + writeJSON(w, 200, racks) + case http.MethodPost: + var rk Rack + if err := json.NewDecoder(r.Body).Decode(&rk); err != nil || rk.Name == "" { + fail(w, 400, errors.New("name is required")) + return + } + if err := a.store.PutRack(r.Context(), rk); err != nil { + fail(w, 500, err) + return + } + writeJSON(w, 200, rk) + case http.MethodDelete: + q := r.URL.Query() + if err := a.store.DeleteRack(r.Context(), q.Get("building"), q.Get("room"), q.Get("name")); err != nil { + fail(w, 500, err) + return + } + writeJSON(w, 200, obj{"deleted": q.Get("name")}) + } +} + +// handleLocationImport takes CSV (text/csv) or JSON rows. Columns (header names, any order): +// +// id | serial | mac | node_name one of these identifies the machine +// building, room, rack, unit, asset_tag +// +// Response lists matched / unmatched rows. Dry run with ?dry=1. +func (a *App) handleLocationImport(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 8<<20)) + if err != nil { + fail(w, 400, err) + return + } + var rows []map[string]string + if strings.HasPrefix(strings.TrimSpace(string(body)), "[") { + var raw []map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + fail(w, 400, err) + return + } + for _, x := range raw { + row := map[string]string{} + for k, v := range x { + row[strings.ToLower(k)] = strings.TrimSpace(str(v)) + } + rows = append(rows, row) + } + } else { + rd := csv.NewReader(strings.NewReader(string(body))) + rd.FieldsPerRecord = -1 + rd.TrimLeadingSpace = true + recs, err := rd.ReadAll() + if err != nil { + fail(w, 400, fmt.Errorf("csv: %w", err)) + return + } + if len(recs) < 2 { + fail(w, 400, errors.New("csv needs a header and at least one row")) + return + } + hdr := recs[0] + for i := range hdr { + hdr[i] = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(hdr[i], "\xef\xbb\xbf"))) // strip UTF-8 BOM + } + for _, rec := range recs[1:] { + row := map[string]string{} + for i, v := range rec { + if i < len(hdr) { + row[hdr[i]] = strings.TrimSpace(v) + } + } + rows = append(rows, row) + } + } + ms, err := a.store.List(r.Context()) + if err != nil { + fail(w, 500, err) + return + } + find := func(row map[string]string) *Machine { + for _, m := range ms { + if id := row["id"]; id != "" && m.ID == id { + return m + } + if sn := row["serial"]; sn != "" && strings.EqualFold(deref(m.Serial), sn) { + return m + } + if nn := row["node_name"]; nn != "" && deref(m.NodeName) == nn { + return m + } + if mac := normMAC(row["mac"]); mac != "" && hasTag(m.MACs, mac) { + return m + } + } + return nil + } + dry := r.URL.Query().Get("dry") == "1" + var matched, unmatched []obj + for i, row := range rows { + m := find(row) + if m == nil { + unmatched = append(unmatched, obj{"row": i + 1, "keys": obj{"id": row["id"], "serial": row["serial"], "mac": row["mac"], "node_name": row["node_name"]}}) + continue + } + l := m.Location + if v, ok := row["building"]; ok { + l.Building = v + } + if v, ok := row["room"]; ok { + l.Room = v + } + if v, ok := row["rack"]; ok { + l.Rack = v + } + if v, ok := row["unit"]; ok { + l.Unit, _ = strconv.Atoi(strings.TrimPrefix(strings.ToUpper(v), "U")) + } + if v, ok := row["asset_tag"]; ok { + l.AssetTag = v + } + matched = append(matched, obj{"row": i + 1, "id": m.ID, "name": deref(m.NodeName), "location": l}) + if !dry { + if err := a.store.SetLocation(r.Context(), m.ID, l); err != nil { + fail(w, 500, err) + return + } + a.ensureRack(r.Context(), l) + _ = a.store.AddEvent(r.Context(), m.ID, "location", l.String()+" (import)") + } + } + if !dry { + a.retag(r.Context(), nil) + } + if matched == nil { + matched = []obj{} + } + if unmatched == nil { + unmatched = []obj{} + } + writeJSON(w, 200, obj{"dry_run": dry, "matched": matched, "unmatched": unmatched}) +} + +// locationFilter applies ?building= ?room= ?rack= to the list. +func locationMatch(m *Machine, q map[string][]string) bool { + for _, k := range []string{"building", "room", "rack"} { + if v := q[k]; len(v) > 0 && v[0] != "" { + var have string + switch k { + case "building": + have = m.Location.Building + case "room": + have = m.Location.Room + case "rack": + have = m.Location.Rack + } + if !strings.EqualFold(have, v[0]) { + return false + } + } + } + return true +} + +// ---------------------------------------------------------------- CLI + +func (c *client) locationCmd(args []string) error { + if len(args) < 1 { + usage() + } + switch args[0] { + case "import": + if len(args) < 2 { + usage() + } + fs := newFlags("location import") + dry := fs.Bool("dry", false, "show what would change") + _ = fs.Parse(args[2:]) + b, err := readFileArg(args[1]) + if err != nil { + return err + } + q := "" + if *dry { + q = "?dry=1" + } + data, err := c.rawBody("POST", "/api/v1/locations/import"+q, "text/csv", b) + if err != nil { + return err + } + var res struct { + DryRun bool `json:"dry_run"` + Matched []obj `json:"matched"` + Unmatched []obj `json:"unmatched"` + } + _ = json.Unmarshal(data, &res) + for _, m := range res.Matched { + lb, _ := json.Marshal(m["location"]) + fmt.Printf("row %v: %s %s -> %s\n", m["row"], str(m["id"]), str(m["name"]), lb) + } + for _, u := range res.Unmatched { + kb, _ := json.Marshal(u["keys"]) + fmt.Printf("row %v: NO MATCH %s\n", u["row"], kb) + } + fmt.Printf("%d matched, %d unmatched%s\n", len(res.Matched), len(res.Unmatched), map[bool]string{true: " (dry run)", false: ""}[res.DryRun]) + case "racks": + var racks []Rack + if err := c.do("GET", "/api/v1/racks", nil, &racks); err != nil { + return err + } + fmt.Printf("%-14s %-14s %-12s %6s %8s %s\n", "BUILDING", "ROOM", "RACK", "HEIGHT", "MACHINES", "COMMENT") + for _, r := range racks { + fmt.Printf("%-14s %-14s %-12s %6d %8d %s\n", r.Building, r.Room, r.Name, r.Height, r.Machines, r.Comment) + } + case "rack": + if len(args) < 2 { + usage() + } + fs := newFlags("location rack") + b := fs.String("building", "", "building") + rm := fs.String("room", "", "room") + h := fs.Int("height", 42, "rack units") + cm := fs.String("comment", "", "comment") + _ = fs.Parse(args[2:]) + return c.do("POST", "/api/v1/racks", Rack{Building: *b, Room: *rm, Name: args[1], Height: *h, Comment: *cm}, nil) + default: // location ID -building B -room R -rack K -unit N -asset T + fs := newFlags("location") + b := fs.String("building", "\x00", "building") + rm := fs.String("room", "\x00", "room") + rk := fs.String("rack", "\x00", "rack") + u := fs.Int("unit", -1, "lowest rack unit") + at := fs.String("asset", "\x00", "asset tag") + _ = fs.Parse(args[1:]) + ids, err := c.targets(args[0]) + if err != nil { + return err + } + req := obj{} + if *b != "\x00" { + req["building"] = *b + } + if *rm != "\x00" { + req["room"] = *rm + } + if *rk != "\x00" { + req["rack"] = *rk + } + if *u >= 0 { + req["unit"] = *u + } + if *at != "\x00" { + req["asset_tag"] = *at + } + if len(req) == 0 { + var ms []Summary + if err := c.do("GET", "/api/v1/machines", nil, &ms); err != nil { + return err + } + for _, m := range ms { + if hasTag(ids, m.ID) { + fmt.Printf("%s %s: %s\n", m.ID, deref(m.NodeName), m.Location.String()) + } + } + return nil + } + for _, id := range ids { + var d obj + if err := c.do("POST", "/api/v1/machines/"+id+"/location", req, &d); err != nil { + return err + } + lb, _ := json.Marshal(d["location"]) + fmt.Printf("%s -> %s\n", id, lb) + } + } + return nil +} + +func sortedKeys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/cmd/rollcall/main.go b/cmd/rollcall/main.go index 824767e..26d7f11 100644 --- a/cmd/rollcall/main.go +++ b/cmd/rollcall/main.go @@ -46,7 +46,11 @@ func usage() { rollcall jobs ID [-watch] | job cancel ID rollcall tag ID +NAME... -NAME... manual tags on a machine rollcall tags list | add NAME [-match TMPL] [-overlays a,b] [-cloud-config FILE] [-comment C] | show NAME | rm NAME - (deploy and power also take -tag NAME instead of ID to act on every machine with that tag) + rollcall location ID [-building B] [-room R] [-rack K] [-unit N] [-asset T] (no flags: show) + rollcall location import FILE.csv [-dry] columns: id|serial|mac|node_name, building, room, rack, unit, asset_tag + rollcall location racks | rack NAME -building B -room R [-height 42] + rollcall list [-tag T] [-building B] [-room R] [-rack K] + (deploy, power and location take tag:NAME instead of ID to act on every machine with that tag) env: ROLLCALL_URL (default from config listen), ROLLCALL_TOKEN, ROLLCALL_CONFIG `) os.Exit(2) @@ -74,7 +78,7 @@ func main() { fmt.Println(randomToken(24)) case "list", "show", "events", "power", "bootdev", "recommission", "delete", "images", "profile", "assign", "unassign", "overlay", "render", - "deploy", "release", "jobs", "job", "tag", "tags": + "deploy", "release", "jobs", "job", "tag", "tags", "location": if err := runClient(cmd, args); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) diff --git a/cmd/rollcall/server.go b/cmd/rollcall/server.go index 9fa5aa5..28ebc19 100644 --- a/cmd/rollcall/server.go +++ b/cmd/rollcall/server.go @@ -146,6 +146,12 @@ func (a *App) routes() http.Handler { } writeJSON(w, 200, jobs) })) + // physical location + mux.HandleFunc("POST /api/v1/machines/{id}/location", a.adminAuth(a.handleLocation)) + mux.HandleFunc("POST /api/v1/locations/import", a.adminAuth(a.handleLocationImport)) + mux.HandleFunc("GET /api/v1/racks", a.adminAuth(a.handleRacks)) + mux.HandleFunc("POST /api/v1/racks", a.adminAuth(a.handleRacks)) + mux.HandleFunc("DELETE /api/v1/racks", a.adminAuth(a.handleRacks)) // tags mux.HandleFunc("GET /api/v1/tags", a.adminAuth(a.handleTags)) mux.HandleFunc("POST /api/v1/tags", a.adminAuth(a.handleTags)) @@ -264,6 +270,7 @@ type Summary struct { Tags []string `json:"tags"` // effective (manual + auto) ManualTags []string `json:"manual_tags"` AutoTags []string `json:"auto_tags"` + Location Location `json:"location"` } func num(v any) float64 { @@ -282,7 +289,7 @@ func summarize(m *Machine) Summary { IBHCAs: listLen(dig(inv, "infiniband", "hcas")), GPUs: nodeCtx(m).GPUs, // real accelerators only Profile: m.Profile, NodeName: m.NodeName, MgmtIP: m.MgmtIP, IBIP: m.IBIP, BootedAt: m.BootedAt, OS: m.OS, OSVersion: m.OSVersion, DeployedAt: m.DeployedAt, - Tags: effectiveTags(m), ManualTags: m.Tags, AutoTags: m.AutoTags} + Tags: effectiveTags(m), ManualTags: m.Tags, AutoTags: m.AutoTags, Location: m.Location} if m.BMC != nil { var b obj if json.Unmarshal(m.BMC, &b) == nil { @@ -464,6 +471,9 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) { if wantTag != "" && !hasTag(effectiveTags(m), wantTag) { continue } + if !locationMatch(m, r.URL.Query()) { + continue + } s := summarize(m) s.Job = active[m.ID] out = append(out, s) diff --git a/cmd/rollcall/stateless.go b/cmd/rollcall/stateless.go index 8d098b5..f757161 100644 --- a/cmd/rollcall/stateless.go +++ b/cmd/rollcall/stateless.go @@ -262,6 +262,9 @@ type NodeCtx struct { NVMe bool PCIVendors []string // vendor ids seen on the PCI bus (e.g. 10de = NVIDIA, 15b3 = Mellanox) OS string + Building, Room, Rack string + Unit int + AssetTag string } type overlayCtx struct { @@ -318,6 +321,7 @@ func nodeCtx(m *Machine) NodeCtx { } n.Tags = effectiveTags(m) n.Manufacturer, n.Product, n.OS = deref(m.Manufacturer), deref(m.Product), deref(m.OS) + n.Building, n.Room, n.Rack, n.Unit, n.AssetTag = m.Location.Building, m.Location.Room, m.Location.Rack, m.Location.Unit, m.Location.AssetTag n.Disks = int(num(dig(inv, "storage", "disk_count"))) for _, d := range toList(dig(inv, "storage", "disks")) { if strings.HasPrefix(str(dig(d, "name")), "nvme") || str(dig(d, "tran")) == "nvme" { diff --git a/cmd/rollcall/store.go b/cmd/rollcall/store.go index 03d2a52..0818737 100644 --- a/cmd/rollcall/store.go +++ b/cmd/rollcall/store.go @@ -87,6 +87,19 @@ ALTER TABLE machines ADD COLUMN IF NOT EXISTS tags jsonb NOT NULL DEFAULT '[]'; ALTER TABLE machines ADD COLUMN IF NOT EXISTS auto_tags jsonb NOT NULL DEFAULT '[]'; CREATE INDEX IF NOT EXISTS machines_tags ON machines USING gin(tags); CREATE INDEX IF NOT EXISTS machines_auto_tags ON machines USING gin(auto_tags); +ALTER TABLE machines ADD COLUMN IF NOT EXISTS building text; +ALTER TABLE machines ADD COLUMN IF NOT EXISTS room text; +ALTER TABLE machines ADD COLUMN IF NOT EXISTS rack text; +ALTER TABLE machines ADD COLUMN IF NOT EXISTS rack_unit int; +ALTER TABLE machines ADD COLUMN IF NOT EXISTS asset_tag text; +CREATE TABLE IF NOT EXISTS racks ( + building text NOT NULL, + room text NOT NULL, + name text NOT NULL, + height int NOT NULL DEFAULT 42, + comment text, + PRIMARY KEY (building, room, name) +); ` var errNotFound = errors.New("not found") @@ -117,6 +130,7 @@ type Machine struct { DeployedAt *time.Time `json:"deployed_at"` Tags []string `json:"tags"` // manual AutoTags []string `json:"auto_tags"` // from tag match rules, recomputed at commissioning + Location Location `json:"location"` bmcSecret []byte nodeToken []byte hostKey []byte @@ -141,7 +155,8 @@ func openStore(ctx context.Context, url string) (*Store, error) { const machineCols = `id, uuid, serial, hostname, manufacturer, product, state, first_seen, last_seen, commissioned_at, macs, bmc, bmc_secret, inventory, profile, node_name, mgmt_ip, ib_ip, node_token, host_key, booted_at, - os, os_version, deployed_at, tags, auto_tags` + os, os_version, deployed_at, tags, auto_tags, + coalesce(building,''), coalesce(room,''), coalesce(rack,''), coalesce(rack_unit,0), coalesce(asset_tag,'')` func scanMachine(row pgx.Row, full bool) (*Machine, error) { m := &Machine{} @@ -149,7 +164,8 @@ func scanMachine(row pgx.Row, full bool) (*Machine, error) { err := row.Scan(&m.ID, &m.UUID, &m.Serial, &m.Hostname, &m.Manufacturer, &m.Product, &m.State, &m.FirstSeen, &m.LastSeen, &m.CommissionedAt, &macs, &m.BMC, &m.bmcSecret, &m.Inventory, &m.Profile, &m.NodeName, &m.MgmtIP, &m.IBIP, &m.nodeToken, &m.hostKey, &m.BootedAt, - &m.OS, &m.OSVersion, &m.DeployedAt, &tags, &auto) + &m.OS, &m.OSVersion, &m.DeployedAt, &tags, &auto, + &m.Location.Building, &m.Location.Room, &m.Location.Rack, &m.Location.Unit, &m.Location.AssetTag) if errors.Is(err, pgx.ErrNoRows) { return nil, errNotFound } diff --git a/cmd/rollcall/web/app.js b/cmd/rollcall/web/app.js index 36b8f5e..6847970 100644 --- a/cmd/rollcall/web/app.js +++ b/cmd/rollcall/web/app.js @@ -14,7 +14,8 @@ let token = ''; try { token = localStorage.getItem('rollcall.token') || ''; } catch (e) { /* storage unavailable */ } // ?token=… on the URL signs in once and is removed from the address bar (for bookmarks/kiosks) { const u = new URL(location.href); const t = u.searchParams.get('token'); if (t) { token = t; try { localStorage.setItem('rollcall.token', t); } catch (e) { /* ignore */ } u.searchParams.delete('token'); history.replaceState(null, '', u.pathname + u.search + u.hash); } } -const S = { machines: [], tags: [], profiles: [], os: [], jobs: [], events: [], sel: new Set(), q: '', states: new Set(), tagf: new Set(), page: 0, per: 50, sort: 'name', dir: 1, loaded: false }; +const S = { machines: [], tags: [], profiles: [], os: [], jobs: [], events: [], racks: [], sel: new Set(), q: '', states: new Set(), tagf: new Set(), loc: { building: '', room: '', rack: '' }, page: 0, per: 50, sort: 'name', dir: 1, loaded: false }; +const locStr = l => { if (!l) return ''; const p = [l.building, l.room, l.rack].filter(Boolean).join(' / '); return p + (l.unit ? ` U${l.unit}` : ''); }; let timer = null; async function api(method, path, body) { @@ -58,8 +59,9 @@ function dialog(title, fields, note, okLabel = 'OK') { async function refresh(full) { if (!token) return askToken(); try { - const [machines, tags, profiles, os] = await Promise.all([api('GET', '/api/v1/machines'), api('GET', '/api/v1/tags'), api('GET', '/api/v1/profiles'), api('GET', '/api/v1/os')]); - Object.assign(S, { machines, tags, profiles, os, loaded: true }); + const [machines, tags, profiles, os, racks] = await Promise.all([api('GET', '/api/v1/machines'), api('GET', '/api/v1/tags'), api('GET', '/api/v1/profiles'), api('GET', '/api/v1/os'), api('GET', '/api/v1/racks')]); + Object.assign(S, { machines, tags, profiles, os, racks, loaded: true }); + $('#n-racks').textContent = racks.length || ''; 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'); @@ -104,6 +106,7 @@ function render() { case 'machines': main.innerHTML = pageMachines(); wireMachines(); break; case 'machine': if (!detailCache[r.id]) { main.innerHTML = '

loading…

'; loadDetail(r.id); } else { main.innerHTML = pageMachine(detailCache[r.id], r.tab); wireMachine(detailCache[r.id]); } break; case 'jobs': main.innerHTML = pageJobs(); wireJobs(); break; + case 'racks': main.innerHTML = pageRacks(); wireRacks(); break; case 'tags': main.innerHTML = pageTags(); wireTags(); break; case 'profiles': main.innerHTML = pageProfiles(); wireProfiles(); break; case 'images': main.innerHTML = pageImages(); break; @@ -144,9 +147,12 @@ function wireOverview() { $$('[data-go]').forEach(el => el.onclick = () => { con // ---------------------------------------------------------------- machines list function filtered() { const q = S.q.toLowerCase(); + const L = S.loc; let ms = S.machines.filter(m => (!S.states.size || S.states.has(m.state)) && (!S.tagf.size || [...S.tagf].every(t => (m.tags || []).includes(t))) - && (!q || JSON.stringify([m.id, m.state, m.manufacturer, m.product, m.serial, m.macs, m.hostname, m.node_name, m.os, m.profile, m.tags, m.mgmt_ip, m.bmc && m.bmc.address]).toLowerCase().includes(q))); - const key = { name: m => nameOf(m), state: m => m.state, role: m => m.profile || m.os || '', model: m => (m.manufacturer || '') + (m.product || ''), cpu: m => Number(m.cpus) || 0, ram: m => m.memory_bytes, seen: m => m.last_seen || '' }[S.sort] || (m => nameOf(m)); + && (!L.building || (m.location || {}).building === L.building) && (!L.room || (m.location || {}).room === L.room) && (!L.rack || (m.location || {}).rack === L.rack) + && (!q || JSON.stringify([m.id, m.state, m.manufacturer, m.product, m.serial, m.macs, m.hostname, m.node_name, m.os, m.profile, m.tags, m.mgmt_ip, m.bmc && m.bmc.address, locStr(m.location), (m.location || {}).asset_tag]).toLowerCase().includes(q))); + const locKey = m => { const l = m.location || {}; return `${l.building}|${l.room}|${l.rack}|${String(l.unit || 0).padStart(3, '0')}`; }; + const key = { name: m => nameOf(m), state: m => m.state, role: m => m.profile || m.os || '', model: m => (m.manufacturer || '') + (m.product || ''), cpu: m => Number(m.cpus) || 0, ram: m => m.memory_bytes, seen: m => m.last_seen || '', loc: locKey }[S.sort] || (m => nameOf(m)); ms.sort((a, b) => { const x = key(a), y = key(b); return (x > y ? 1 : x < y ? -1 : 0) * S.dir; }); return ms; } @@ -169,6 +175,7 @@ function pageMachines() { const ms = filtered(); const by = {}; for (const m of S.machines) by[m.state] = (by[m.state] || 0) + 1; const tagCount = {}; for (const m of S.machines) for (const t of m.tags || []) tagCount[t] = (tagCount[t] || 0) + 1; + const locChips = locationChips(); const pages = Math.max(1, Math.ceil(ms.length / S.per)); if (S.page >= pages) S.page = pages - 1; const slice = ms.slice(S.page * S.per, (S.page + 1) * S.per); const allSel = slice.length && slice.every(m => S.sel.has(m.id)); @@ -178,14 +185,16 @@ function pageMachines() {
State:${Object.keys(by).sort().map(s => `${esc(s)}${by[s]}`).join('')} ${Object.keys(tagCount).length ? `Tag:${Object.keys(tagCount).sort().map(t => `${esc(t)}${tagCount[t]}`).join('')}` : ''} - ${(S.states.size || S.tagf.size || S.q) ? '' : ''}
+ ${(S.states.size || S.tagf.size || S.q || L.building || L.room || L.rack) ? '' : ''} + ${locChips} ${S.sel.size ? bulkBar() : ''} ${S.machines.length ? '' : '
No machines yet. PXE-boot an unknown server: it commissions itself and appears here.
'} - ${th('name', 'Machine')}${th('state', 'State')}${th('role', 'Profile / OS')}${th('model', 'Model')}${th('cpu', 'Hardware')}${th('seen', 'Seen')} +
TagsBMC
${th('name', 'Machine')}${th('state', 'State')}${th('loc', 'Location')}${th('role', 'Profile / OS')}${th('model', 'Model')}${th('cpu', 'Hardware')}${th('seen', 'Seen')}${slice.map(m => ` + @@ -195,6 +204,16 @@ function pageMachines() {
${pages > 1 ? ` page ${S.page + 1} / ${pages} ` : ''} per page
`; } +// chips for building > room > rack, narrowing as you pick +function locationChips() { + const L = S.loc; + const has = S.machines.some(m => m.location && (m.location.building || m.location.room || m.location.rack)); + if (!has) return ''; + const cnt = (f, pred) => { const c = {}; for (const m of S.machines) { const l = m.location || {}; if (pred(l) && l[f]) c[l[f]] = (c[l[f]] || 0) + 1; } return c; }; + const b = cnt('building', () => true), r = cnt('room', l => !L.building || l.building === L.building), k = cnt('rack', l => (!L.building || l.building === L.building) && (!L.room || l.room === L.room)); + const grp = (label, f, c) => Object.keys(c).length ? `${label}:${Object.keys(c).sort().map(v => `${esc(v)}${c[v]}`).join('')}` : ''; + return `
${grp('Building', 'building', b)} ${grp('Room', 'room', r)} ${grp('Rack', 'rack', k)}
`; +} function bulkBar() { const n = S.sel.size; return `
${n} selected @@ -202,6 +221,7 @@ function bulkBar() { + ${menuBtn('More', [['recommission', 'Re-commission (next boot)'], ['recommission-now', 'Re-commission now (BMC)'], ['release', 'Release (wipe disk)…'], ['unassign', 'Unassign profile'], ['hr'], ['bootdev-pxe', 'Boot device: PXE'], ['bootdev-disk', 'Boot device: disk']], 'bmore')}
`; } @@ -215,7 +235,8 @@ function wireMachines() { $('#refresh').onclick = () => refresh(true); $$('[data-state]').forEach(c => c.onclick = () => { const s = c.dataset.state; S.states.has(s) ? S.states.delete(s) : S.states.add(s); S.page = 0; location.hash = '#/machines'; render(); }); $$('[data-tag]').forEach(c => c.onclick = () => { const t = c.dataset.tag; S.tagf.has(t) ? S.tagf.delete(t) : S.tagf.add(t); S.page = 0; render(); }); - const clr = $('#clear'); if (clr) clr.onclick = () => { S.states.clear(); S.tagf.clear(); S.q = ''; location.hash = '#/machines'; render(); }; + const clr = $('#clear'); if (clr) clr.onclick = () => { S.states.clear(); S.tagf.clear(); S.q = ''; S.loc = { building: '', room: '', rack: '' }; location.hash = '#/machines'; render(); }; + $$('[data-loc]').forEach(c => c.onclick = () => { const f = c.dataset.loc, v = c.dataset.v; if (S.loc[f] === v) { S.loc[f] = ''; if (f === 'building') { S.loc.room = ''; S.loc.rack = ''; } if (f === 'room') S.loc.rack = ''; } else { S.loc[f] = v; if (f === 'building') { S.loc.room = ''; S.loc.rack = ''; } if (f === 'room') S.loc.rack = ''; } S.page = 0; render(); }); $$('th.sort').forEach(h => h.onclick = () => { if (S.sort === h.dataset.sort) S.dir = -S.dir; else { S.sort = h.dataset.sort; S.dir = 1; } render(); }); $$('tr.row').forEach(tr => tr.onclick = ev => { if (ev.target.closest('input,a')) return; location.hash = '#/machine/' + tr.dataset.id; }); $$('[data-sel]').forEach(cb => cb.onchange = () => { cb.checked ? S.sel.add(cb.dataset.sel) : S.sel.delete(cb.dataset.sel); render(); }); @@ -229,6 +250,7 @@ function wireMachines() { $('#bdeploy').onclick = () => act.deploy(ids()); $('#bassign').onclick = () => act.assign(ids()); $('#btag').onclick = () => act.tag(ids()); + $('#bloc').onclick = () => act.location(ids()); $$('#bmore [data-item]').forEach(b => b.onclick = () => act.more(ids(), b.dataset.item)); $('#bclear').onclick = () => { S.sel.clear(); render(); }; } @@ -278,6 +300,37 @@ const act = { const add = v.add.trim().split(/\s+/).filter(Boolean), remove = v.remove.trim().split(/\s+/).filter(Boolean); await each(ids, 'tags', async id => { const r = await api('POST', `/api/v1/machines/${id}/tags`, { add, remove }); return r.effective.join(' ') || 'none'; }); }, + async location(ids) { + const one = ids.length === 1 ? S.machines.find(m => m.id === ids[0]) : null; + const cur = (one && one.location) || {}; + const vals = f => [...new Set(S.machines.map(m => (m.location || {})[f]).filter(Boolean))].sort(); + const dl = f => { let d = $('#dl-' + f); if (!d) { d = document.createElement('datalist'); d.id = 'dl-' + f; document.body.appendChild(d); } d.innerHTML = vals(f).map(v => `
TagsBMC
${esc(nameOf(m))}
${esc(m.mgmt_ip || m.id)}
${stateCell(m)}${m.location && (m.location.rack || m.location.room || m.location.building) ? `${esc([m.location.building, m.location.room].filter(Boolean).join(' / '))}
${esc(m.location.rack || '')}${m.location.unit ? ` U${m.location.unit}` : ''}` : '—'}
${m.profile ? esc(m.profile) : m.os ? `${esc(m.os)}
${esc(m.os_version || '')}` : '—'}
${tagPills(m)} ${esc(((m.manufacturer || '') + ' ' + (m.product || '')).trim().slice(0, 34))}
${esc(m.serial || '')}