Physical location: building / room / rack / unit / asset tag
CI / go (push) Successful in 35s
CI / agent (push) Successful in 4s
CI / site (push) Successful in 3s

- 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 <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Fable 5.1 committed 2026-10-27 15:32:26 +01:00
1 parent 0333c1e486
commit cb7b6fd19e
11 files changed
+678 -16

No files matched your search

+4
View File
@@ -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`.
+21
View File
@@ -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
+37 -3
View File
@@ -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 {
+456
View File
@@ -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
}
+6 -2
View File
@@ -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)
+11 -1
View File
@@ -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)
+4
View File
@@ -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" {
+18 -2
View File
@@ -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
}
+105 -8
View File
@@ -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 = '<p class="muted">loading…</p>'; 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() {
<div class="right"><input id="q" type="search" placeholder="search name, serial, MAC, model, IP, tag…" value="${esc(S.q)}"><button class="btn" id="refresh">Refresh</button></div></div>
<div class="chips" style="margin-bottom:8px"><span class="muted">State:</span>${Object.keys(by).sort().map(s => `<span class="chip${S.states.has(s) ? ' on' : ''}" data-state="${esc(s)}">${esc(s)}<span class="n">${by[s]}</span></span>`).join('')}
${Object.keys(tagCount).length ? `<span class="muted" style="margin-left:10px">Tag:</span>${Object.keys(tagCount).sort().map(t => `<span class="chip${S.tagf.has(t) ? ' on' : ''}" data-tag="${esc(t)}">${esc(t)}<span class="n">${tagCount[t]}</span></span>`).join('')}` : ''}
${(S.states.size || S.tagf.size || S.q) ? '<button class="btn ghost sm" id="clear">clear</button>' : ''}</div>
${(S.states.size || S.tagf.size || S.q || L.building || L.room || L.rack) ? '<button class="btn ghost sm" id="clear">clear</button>' : ''}</div>
${locChips}
${S.sel.size ? bulkBar() : ''}
${S.machines.length ? '' : '<div class="empty">No machines yet. PXE-boot an unknown server: it commissions itself and appears here.</div>'}
<table id="mt"><thead><tr><th class="check"><input type="checkbox" id="selall"${allSel ? ' checked' : ''}></th>${th('name', 'Machine')}${th('state', 'State')}${th('role', 'Profile / OS')}<th>Tags</th>${th('model', 'Model')}${th('cpu', 'Hardware')}<th>BMC</th>${th('seen', 'Seen')}</tr></thead>
<table id="mt"><thead><tr><th class="check"><input type="checkbox" id="selall"${allSel ? ' checked' : ''}></th>${th('name', 'Machine')}${th('state', 'State')}${th('loc', 'Location')}${th('role', 'Profile / OS')}<th>Tags</th>${th('model', 'Model')}${th('cpu', 'Hardware')}<th>BMC</th>${th('seen', 'Seen')}</tr></thead>
<tbody>${slice.map(m => `<tr class="row${S.sel.has(m.id) ? ' selected' : ''}" data-id="${esc(m.id)}">
<td class="check"><input type="checkbox" data-sel="${esc(m.id)}"${S.sel.has(m.id) ? ' checked' : ''}></td>
<td><b>${esc(nameOf(m))}</b><br><span class="muted mono">${esc(m.mgmt_ip || m.id)}</span></td>
<td>${stateCell(m)}</td>
<td>${m.location && (m.location.rack || m.location.room || m.location.building) ? `${esc([m.location.building, m.location.room].filter(Boolean).join(' / '))}<br><b>${esc(m.location.rack || '')}</b>${m.location.unit ? ` <span class="muted">U${m.location.unit}</span>` : ''}` : '<span class="muted">—</span>'}</td>
<td>${m.profile ? esc(m.profile) : m.os ? `${esc(m.os)}<br><span class="muted mono">${esc(m.os_version || '')}</span>` : '<span class="muted">—</span>'}</td>
<td>${tagPills(m)}</td>
<td>${esc(((m.manufacturer || '') + ' ' + (m.product || '')).trim().slice(0, 34))}<br><span class="muted mono">${esc(m.serial || '')}</span></td>
@@ -195,6 +204,16 @@ function pageMachines() {
<div class="pager">${pages > 1 ? `<button class="btn sm" id="prev"${S.page === 0 ? ' disabled' : ''}>‹</button> page ${S.page + 1} / ${pages} <button class="btn sm" id="next"${S.page >= pages - 1 ? ' disabled' : ''}>›</button>` : ''}
<select id="per">${[25, 50, 100, 250].map(n => `<option${n === S.per ? ' selected' : ''}>${n}</option>`).join('')}</select> per page</div>`;
}
// 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 ? `<span class="muted">${label}:</span>${Object.keys(c).sort().map(v => `<span class="chip${L[f] === v ? ' on' : ''}" data-loc="${f}" data-v="${esc(v)}">${esc(v)}<span class="n">${c[v]}</span></span>`).join('')}` : '';
return `<div class="chips" style="margin-bottom:8px">${grp('Building', 'building', b)} ${grp('Room', 'room', r)} ${grp('Rack', 'rack', k)}</div>`;
}
function bulkBar() {
const n = S.sel.size;
return `<div class="bulk"><b>${n} selected</b>
@@ -202,6 +221,7 @@ function bulkBar() {
<button class="btn primary" id="bdeploy">Deploy OS…</button>
<button class="btn" id="bassign">Assign profile…</button>
<button class="btn" id="btag">Tag…</button>
<button class="btn" id="bloc">Set location…</button>
${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')}
<button class="btn ghost" id="bclear">Clear selection</button></div>`;
}
@@ -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 => `<option value="${esc(v)}">`).join(''); return d.id; };
const fields = [
{ name: 'building', label: 'Building', value: cur.building || S.loc.building || '', list: dl('building') },
{ name: 'room', label: 'Room', value: cur.room || S.loc.room || '', list: dl('room') },
{ name: 'rack', label: 'Rack', value: cur.rack || S.loc.rack || '', list: dl('rack') },
];
if (one) fields.push({ name: 'unit', label: 'Rack unit (lowest U)', value: cur.unit || '', placeholder: 'e.g. 24' }, { name: 'asset_tag', label: 'Asset tag', value: cur.asset_tag || '' });
else fields.push({ name: 'unit_start', label: 'First rack unit', placeholder: 'optional: U of the first selected machine', help: 'machines are stacked upwards from here in selection order; leave empty to keep units' });
const v = await dialog(`Location of ${one ? nameOf(one) : ids.length + ' machines'}`, fields, 'Empty fields clear the value. Racks are created on first use (42U); adjust height on the Racks page.', 'Save');
if (!v) return;
let u = one ? null : (v.unit_start ? Number(v.unit_start) : null);
await each(ids, 'location', async id => {
const body = { building: v.building.trim(), room: v.room.trim(), rack: v.rack.trim() };
if (one) { body.unit = Number(v.unit) || 0; body.asset_tag = v.asset_tag.trim(); } else if (u) { body.unit = u; u += 1; }
const r = await api('POST', `/api/v1/machines/${id}/location`, body); return locStr(r.location) || 'cleared';
});
},
async importLocations() {
const v = await dialog('Import locations (CSV)', [{ name: 'csv', label: 'CSV', type: 'textarea', rows: 10, placeholder: 'serial,building,room,rack,unit,asset_tag\nABC123,DC1,Hall A,R12,24,A-0042\n…' , help: 'identify rows by id, serial, mac or node_name; other columns: building, room, rack, unit, asset_tag' }, { name: 'dry', label: 'Dry run (show matches only)', type: 'checkbox', value: true }], '', 'Import');
if (!v) return;
try {
const r = await fetch('/api/v1/locations/import' + (v.dry ? '?dry=1' : ''), { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'text/csv' }, body: v.csv });
const d = await r.json(); if (!r.ok) throw new Error(d.error || r.statusText);
toast(`${v.dry ? 'DRY RUN: ' : ''}${d.matched.length} matched, ${d.unmatched.length} unmatched\n` + d.matched.slice(0, 12).map(m => `${m.name || m.id}: ${locStr(m.location)}`).join('\n') + (d.unmatched.length ? '\nunmatched rows: ' + d.unmatched.map(u => u.row).join(', ') : ''), d.unmatched.length > 0);
if (!v.dry) refresh(true);
} catch (e) { toast(e.message, true); }
},
async more(ids, item) {
const n = ids.length;
switch (item) {
@@ -319,6 +372,7 @@ function pageMachine(d, tab) {
['Stateless profile', s.profile ? `${esc(s.profile)} · booted ${esc(s.booted_at ? when(s.booted_at) : 'not yet')}` : ''],
['OS on disk', s.os ? `${esc(s.os)} <span class="muted mono">${esc(s.os_version || '')}</span> · deployed ${esc(when(s.deployed_at))}` : ''],
['Phoned home', s.deployed_at ? (s.booted_at ? `<span class="ok">${esc(when(s.booted_at))}</span>` : '<span class="muted">not yet</span>') : ''],
['Location', `${esc(locStr(s.location)) || '<span class="muted">unknown</span>'}${s.location && s.location.asset_tag ? ` · asset <span class="mono">${esc(s.location.asset_tag)}</span>` : ''} <button class="btn sm" id="d-loc">edit</button>`],
['Tags', `<span id="d-tags">${tagPills(s, true) || '<span class="muted">none</span>'}</span> <button class="btn sm" id="d-tag">edit</button>`],
['BMC', b.address ? `<span class="dot ${b.configured ? 'ok' : 'bad'}"></span><span class="mono">${esc(b.address)}</span> ${esc(b.user || '')} · ${d.has_bmc_secret ? '<span class="ok">credentials stored</span>' : '<span class="bad">no credentials</span>'}` : '<span class="bad">none</span>'],
['Current job', job ? `<span class="mono">${esc(job.id)}</span> ${esc(job.kind)} ${pill(job.state)}${active ? `<div class="bar" style="margin-top:4px"><span style="width:${Math.round(p.pct || 0)}%"></span></div><span class="muted">${esc(p.phase || '')} ${Math.round(p.pct || 0)}% ${esc(p.msg || '')}</span>` : ''}${job.error ? `<br><span class="bad">${esc(job.error)}</span>` : ''}` : ''],
@@ -376,6 +430,7 @@ function wireMachine(d) {
$('#dassign').onclick = () => act.assign(ids);
$$('#dmore [data-item]').forEach(b => b.onclick = () => act.more(ids, b.dataset.item));
const t = $('#d-tag'); if (t) t.onclick = () => act.tag(ids);
const lo = $('#d-loc'); if (lo) lo.onclick = () => act.location(ids);
$$('[data-untag]').forEach(a => a.onclick = ev => { ev.preventDefault(); each(ids, 'untag', async () => (await api('POST', `/api/v1/machines/${id}/tags`, { remove: [a.dataset.untag] })).effective.join(' ')); });
}
@@ -388,6 +443,48 @@ function pageJobs() {
}
function wireJobs() { $('#refresh').onclick = () => refresh(true); }
// ---------------------------------------------------------------- racks
function pageRacks() {
const r = route(); const sel = r.arg ? decodeURIComponent(r.arg) : '';
const placed = S.machines.filter(m => m.location && m.location.rack);
const unplaced = S.machines.length - placed.length;
// racks from definitions plus any rack a machine mentions
const keyOf = (b, rm, n) => `${b}|${rm}|${n}`;
const racks = new Map(S.racks.map(x => [keyOf(x.building, x.room, x.name), { ...x }]));
for (const m of placed) { const l = m.location; const k = keyOf(l.building || '', l.room || '', l.rack); if (!racks.has(k)) racks.set(k, { building: l.building || '', room: l.room || '', name: l.rack, height: 42, machines: 0 }); }
const list = [...racks.values()].sort((a, b) => keyOf(a.building, a.room, a.name).localeCompare(keyOf(b.building, b.room, b.name)));
const buildings = [...new Set(list.map(x => x.building))].sort();
const tree = buildings.map(b => { const rooms = [...new Set(list.filter(x => x.building === b).map(x => x.room))].sort(); return `<span class="chip${sel === b ? ' on' : ''}" data-rsel="${esc(b)}"><b>${esc(b || '(no building)')}</b><span class="n">${list.filter(x => x.building === b).length} racks</span></span>` + rooms.map(rm => `<span class="chip${sel === b + '/' + rm ? ' on' : ''}" data-rsel="${esc(b + '/' + rm)}">${esc(rm || '(no room)')}<span class="n">${list.filter(x => x.building === b && x.room === rm).length}</span></span>`).join(''); }).join('<span class="sep" style="width:12px"></span>');
const shown = list.filter(x => !sel || x.building === sel || x.building + '/' + x.room === sel);
const elevation = rk => {
const ms = placed.filter(m => (m.location.building || '') === rk.building && (m.location.room || '') === rk.room && m.location.rack === rk.name);
const byU = {}; const noU = [];
for (const m of ms) { if (m.location.unit > 0) byU[m.location.unit] = m; else noU.push(m); }
const H = rk.height || 42; let rows = '';
for (let u = H; u >= 1; u--) { const m = byU[u]; rows += `<div class="u">${u}</div>` + (m ? `<div class="slot m ${esc(m.state)}" data-id="${esc(m.id)}" title="${esc(nameOf(m))} · ${esc(m.state)} · ${esc((m.manufacturer || '') + ' ' + (m.product || ''))}">${esc(nameOf(m))} <span class="muted">${esc(m.os || m.profile || '')}</span></div>` : '<div class="slot"></div>'); }
return `<div class="rack"><h4><span>${esc(rk.name)} <span class="muted">${esc([rk.building, rk.room].filter(Boolean).join(' / '))}</span></span><span class="muted">${ms.length} · ${H}U <a href="#" data-rackedit="${esc(keyOf(rk.building, rk.room, rk.name))}" title="height / comment">✎</a></span></h4>
<div class="elev">${rows}</div>${noU.length ? `<div class="muted" style="margin-top:6px">no U: ${noU.map(m => `<a href="#/machine/${esc(m.id)}">${esc(nameOf(m))}</a>`).join(', ')}</div>` : ''}${rk.comment ? `<div class="muted" style="margin-top:4px">${esc(rk.comment)}</div>` : ''}</div>`;
};
return `<div class="page-head"><h1>Racks</h1><span class="sub">${list.length} racks · ${placed.length} machines placed${unplaced ? ` · <a href="#/machines" id="go-unplaced">${unplaced} without location</a>` : ''}</span>
<div class="right"><button class="btn" id="import-loc">Import CSV…</button><button class="btn" id="new-rack">New rack…</button></div></div>
${list.length ? `<div class="tree">${tree}${sel ? '<button class="btn ghost sm" id="rclear">all</button>' : ''}</div><div class="racks">${shown.map(elevation).join('')}</div>`
: '<div class="empty">No locations yet. Set building / room / rack / U on machines (detail → Location → edit, or select several and “Set location…”), or import a CSV from your asset list.</div>'}`;
}
async function editRack(rk) {
const v = await dialog(rk ? `Rack ${rk.name}` : 'New rack', [{ name: 'building', label: 'Building', value: rk ? rk.building : S.loc.building }, { name: 'room', label: 'Room', value: rk ? rk.room : S.loc.room }, { name: 'name', label: 'Rack', value: rk ? rk.name : '' }, { name: 'height', label: 'Height (U)', value: rk ? rk.height : 42 }, { name: 'comment', label: 'Comment', value: rk ? rk.comment || '' : '' }], '', 'Save');
if (!v) return;
try { await api('POST', '/api/v1/racks', { building: v.building.trim(), room: v.room.trim(), name: v.name.trim(), height: Number(v.height) || 42, comment: v.comment }); toast(`rack ${v.name} saved`); refresh(true); } catch (e) { toast(e.message, true); }
}
function wireRacks() {
$$('[data-rsel]').forEach(c => c.onclick = () => { location.hash = '#/racks/' + encodeURIComponent(c.dataset.rsel); });
const rc = $('#rclear'); if (rc) rc.onclick = () => nav('racks');
$$('.elev .slot.m').forEach(s => s.onclick = () => { location.hash = '#/machine/' + s.dataset.id; });
$$('[data-rackedit]').forEach(a => a.onclick = ev => { ev.preventDefault(); const [b, rm, n] = a.dataset.rackedit.split('|'); editRack(S.racks.find(x => x.building === b && x.room === rm && x.name === n) || { building: b, room: rm, name: n, height: 42 }); });
$('#new-rack').onclick = () => editRack(null);
$('#import-loc').onclick = () => act.importLocations();
const gu = $('#go-unplaced'); if (gu) gu.onclick = () => { S.q = ''; S.loc = { building: '', room: '', rack: '' }; S.states.clear(); S.tagf.clear(); };
}
// ---------------------------------------------------------------- tags
function pageTags() {
return `<div class="page-head"><h1>Tags</h1><span class="sub">single out machines for special treatment: auto rules from the inventory, cloud-init fragments at deployment, overlays for stateless nodes</span><div class="right"><button class="btn primary" id="newtag">New tag…</button></div></div>
+1
View File
@@ -12,6 +12,7 @@
<div class="brand">Rollcall<span class="muted">bare-metal fleet</span></div>
<a href="#/overview" data-nav="overview">Overview</a>
<a href="#/machines" data-nav="machines">Machines <span class="count" id="n-machines"></span></a>
<a href="#/racks" data-nav="racks">Racks <span class="count" id="n-racks"></span></a>
<a href="#/jobs" data-nav="jobs">Jobs <span class="count" id="n-jobs"></span></a>
<a href="#/tags" data-nav="tags">Tags</a>
<a href="#/profiles" data-nav="profiles">Profiles</a>
+15
View File
@@ -91,6 +91,21 @@ td.check, th.check { width: 28px; padding-right: 0; }
.bar { position: relative; height: 8px; background: var(--pill); border-radius: 999px; overflow: hidden; min-width: 120px; }
.bar > span { position: absolute; inset: 0 auto 0 0; background: var(--accent); transition: width .5s; }
/* racks */
.racks { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
.rack { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; }
.rack h4 { margin: 0 0 6px; font-size: 13px; display: flex; justify-content: space-between; } .rack h4 .muted { font-weight: 400; }
.elev { display: grid; grid-template-columns: 26px 1fr; gap: 0; border: 1px solid var(--line2); border-radius: 4px; overflow: hidden; font-size: 11px; }
.elev .u { color: var(--muted); text-align: right; padding: 0 4px; border-bottom: 1px solid var(--line); border-right: 1px solid var(--line); line-height: 16px; font-variant-numeric: tabular-nums; }
.elev .slot { border-bottom: 1px solid var(--line); line-height: 16px; padding: 0 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: default; }
.elev .slot.m { cursor: pointer; background: var(--sel); border-left: 3px solid var(--accent); }
.elev .slot.m.deployed, .elev .slot.m.booted, .elev .slot.m.ready { border-left-color: var(--ok); }
.elev .slot.m.deploying, .elev .slot.m.releasing, .elev .slot.m.stateless { border-left-color: var(--warn); }
.elev .slot.m.deploy-failed, .elev .slot.m.release-failed { border-left-color: var(--danger); }
.elev .slot.m:hover { filter: brightness(1.08); }
.tree { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.tree .chip.on { background: var(--accent); color: var(--accent-fg); }
/* detail */
.d-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
.d-head h1 { margin: 0; font-size: 20px; } .d-head .id { color: var(--muted); font-family: var(--mono); font-size: 12px; }