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() {| ${th('name', 'Machine')}${th('state', 'State')}${th('role', 'Profile / OS')} | Tags | ${th('model', 'Model')}${th('cpu', 'Hardware')}BMC | ${th('seen', 'Seen')}
|---|
| ${th('name', 'Machine')}${th('state', 'State')}${th('loc', 'Location')}${th('role', 'Profile / OS')} | Tags | ${th('model', 'Model')}${th('cpu', 'Hardware')}BMC | ${th('seen', 'Seen')}||||
|---|---|---|---|---|---|---|
| ${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 || '')} |
@@ -195,6 +204,16 @@ function pageMachines() {