Physical location: building / room / rack / unit / asset tag
- 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:
1 parent
0333c1e486
commit
cb7b6fd19e
11 files changed
+678
-16
No files matched your search
+37
-3
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user