homelab/services/device-inventory/web-ui/main.go
Dan V a110afa40b feat(device-inventory): add management web UI and pciutils for NIC discovery
- web-ui/main.go: full CRUD REST API + dark-theme SPA (servers, parts, part-types)
- Dockerfile.cli: add pciutils runtime dep for lspci NIC enrichment

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 22:42:20 +02:00

1081 lines
42 KiB
Go

package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"strings"
"time"
)
const (
fieldSep = "\x01"
listSep = "\x02"
)
var serverAddr string
func init() {
host := os.Getenv("INVENTORY_HOST")
if host == "" {
host = "inventory-server"
}
port := os.Getenv("INVENTORY_PORT")
if port == "" {
port = "9876"
}
serverAddr = host + ":" + port
}
// sendCommand sends a protocol command and returns the response rows.
func sendCommand(tokens []string) ([][]string, error) {
conn, err := net.DialTimeout("tcp", serverAddr, 5*time.Second)
if err != nil {
return nil, fmt.Errorf("connect to %s: %w", serverAddr, err)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(15 * time.Second))
line := strings.Join(tokens, fieldSep) + "\n"
if _, err := conn.Write([]byte(line)); err != nil {
return nil, fmt.Errorf("write: %w", err)
}
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 256*1024), 256*1024)
if !scanner.Scan() {
return nil, fmt.Errorf("no response from server")
}
status := scanner.Text()
if strings.HasPrefix(status, "ERR") {
msg := ""
if len(status) > 4 {
msg = status[4:]
}
return nil, fmt.Errorf("server: %s", msg)
}
if status != "OK" {
return nil, fmt.Errorf("unexpected status: %s", status)
}
var rows [][]string
for scanner.Scan() {
row := scanner.Text()
if row == "END" {
break
}
rows = append(rows, strings.Split(row, fieldSep))
}
return rows, scanner.Err()
}
// ── Domain types ──────────────────────────────────────────────────────────────
type Server struct {
ID string `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
Location string `json:"location"`
Description string `json:"description"`
}
type PartType struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
type Part struct {
ID string `json:"id"`
Type string `json:"type"`
ServerID string `json:"server_id"`
Serial string `json:"serial"`
LastUpdated int64 `json:"last_updated"`
Fields map[string]string `json:"fields"`
}
// ── Query helpers ─────────────────────────────────────────────────────────────
func listServers() ([]Server, error) {
rows, err := sendCommand([]string{"LIST_SERVERS"})
if err != nil {
return nil, err
}
var out []Server
for _, f := range rows {
if len(f) < 5 {
continue
}
out = append(out, Server{ID: f[0], Name: f[1], Hostname: f[2], Location: f[3], Description: f[4]})
}
return out, nil
}
func listPartTypes() ([]PartType, error) {
rows, err := sendCommand([]string{"LIST_PART_TYPES"})
if err != nil {
return nil, err
}
var out []PartType
for _, f := range rows {
if len(f) < 3 {
continue
}
out = append(out, PartType{ID: f[0], Name: f[1], Description: f[2]})
}
return out, nil
}
func listParts(serverID string) ([]Part, error) {
rows, err := sendCommand([]string{"LIST_PARTS", serverID})
if err != nil {
return nil, err
}
var out []Part
for _, f := range rows {
if len(f) < 5 {
continue
}
fields := make(map[string]string)
for i := 5; i+1 < len(f); i += 2 {
v := strings.ReplaceAll(f[i+1], listSep, ", ")
fields[f[i]] = v
}
var ts int64
fmt.Sscanf(f[4], "%d", &ts)
out = append(out, Part{Type: f[0], ID: f[1], ServerID: f[2], Serial: f[3], LastUpdated: ts, Fields: fields})
}
return out, nil
}
// toProtoVal converts comma-separated list fields to the protocol's listSep-delimited format.
func toProtoVal(key, value string) string {
listFields := map[string]bool{
"ip_addresses": true, "partition_ids": true, "partition_sizes_gb": true,
"vm_hostnames": true, "vm_server_ids": true,
}
if listFields[key] {
parts := strings.Split(value, ",")
for i, p := range parts {
parts[i] = strings.TrimSpace(p)
}
return strings.Join(parts, listSep)
}
return value
}
func readBody(r *http.Request) (map[string]interface{}, error) {
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}
var m map[string]interface{}
if err := json.Unmarshal(body, &m); err != nil {
return nil, err
}
return m, nil
}
func str(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// ── HTTP helpers ──────────────────────────────────────────────────────────────
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
// ── main ──────────────────────────────────────────────────────────────────────
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
})
// /api/servers — list + create
mux.HandleFunc("/api/servers", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
servers, err := listServers()
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
if servers == nil {
servers = []Server{}
}
writeJSON(w, servers)
case http.MethodPost:
m, err := readBody(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if _, err = sendCommand([]string{"ADD_SERVER", str(m, "name"), str(m, "hostname"), str(m, "location"), str(m, "description")}); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
servers, err := listServers()
if err != nil || len(servers) == 0 {
writeJSON(w, map[string]string{"status": "ok"})
return
}
writeJSON(w, servers[len(servers)-1])
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
// /api/servers/{id} — edit/delete; /api/servers/{id}/parts — list parts
mux.HandleFunc("/api/servers/", func(w http.ResponseWriter, r *http.Request) {
segs := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(segs) < 3 {
http.NotFound(w, r)
return
}
id := segs[2]
if len(segs) == 4 && segs[3] == "parts" {
plist, err := listParts(id)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
if plist == nil {
plist = []Part{}
}
writeJSON(w, plist)
return
}
switch r.Method {
case http.MethodPatch:
m, err := readBody(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
for _, f := range []string{"name", "hostname", "location", "description"} {
if v, ok := m[f]; ok {
if s, ok2 := v.(string); ok2 {
if _, err = sendCommand([]string{"EDIT_SERVER", id, f, s}); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
}
}
}
writeJSON(w, map[string]string{"status": "ok"})
case http.MethodDelete:
if _, err := sendCommand([]string{"REMOVE_SERVER", id}); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, map[string]string{"status": "ok"})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
// /api/part-types — list + create
mux.HandleFunc("/api/part-types", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
pts, err := listPartTypes()
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
if pts == nil {
pts = []PartType{}
}
writeJSON(w, pts)
case http.MethodPost:
m, err := readBody(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if _, err = sendCommand([]string{"ADD_PART_TYPE", str(m, "name"), str(m, "description")}); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
pts, err := listPartTypes()
if err != nil || len(pts) == 0 {
writeJSON(w, map[string]string{"status": "ok"})
return
}
writeJSON(w, pts[len(pts)-1])
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
// /api/part-types/{id} — edit/delete
mux.HandleFunc("/api/part-types/", func(w http.ResponseWriter, r *http.Request) {
segs := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(segs) < 3 {
http.NotFound(w, r)
return
}
id := segs[2]
switch r.Method {
case http.MethodPatch:
m, err := readBody(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
for _, f := range []string{"name", "description"} {
if v, ok := m[f]; ok {
if s, ok2 := v.(string); ok2 {
if _, err = sendCommand([]string{"EDIT_PART_TYPE", id, f, s}); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
}
}
}
writeJSON(w, map[string]string{"status": "ok"})
case http.MethodDelete:
if _, err := sendCommand([]string{"REMOVE_PART_TYPE", id}); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, map[string]string{"status": "ok"})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
// /api/parts — create
mux.HandleFunc("/api/parts", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
m, err := readBody(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
cmd := []string{"ADD_PART", str(m, "type"), str(m, "server_id")}
if fields, ok := m["fields"].(map[string]interface{}); ok {
for k, v := range fields {
if s, ok := v.(string); ok {
cmd = append(cmd, k, toProtoVal(k, s))
}
}
}
if _, err = sendCommand(cmd); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, map[string]string{"status": "ok"})
})
// /api/parts/{id} — edit/delete
mux.HandleFunc("/api/parts/", func(w http.ResponseWriter, r *http.Request) {
segs := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(segs) < 3 {
http.NotFound(w, r)
return
}
id := segs[2]
switch r.Method {
case http.MethodPatch:
m, err := readBody(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if fields, ok := m["fields"].(map[string]interface{}); ok {
for k, v := range fields {
if s, ok := v.(string); ok {
if _, err = sendCommand([]string{"EDIT_PART", id, k, toProtoVal(k, s)}); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
}
}
}
writeJSON(w, map[string]string{"status": "ok"})
case http.MethodDelete:
if _, err := sendCommand([]string{"REMOVE_PART", id}); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, map[string]string{"status": "ok"})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, dashboardHTML)
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("inventory-web-ui listening on :%s, backend: %s", port, serverAddr)
if err := http.ListenAndServe(":"+port, mux); err != nil {
log.Fatal(err)
}
}
// ── Embedded dashboard ────────────────────────────────────────────────────────
const dashboardHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Device Inventory</title>
<style>
:root {
--bg:#0f1117; --s1:#1a1d27; --s2:#23263a; --border:#2e3250;
--accent:#6c8bef; --ah:#5a78e0; --text:#e2e8f0; --muted:#8892aa;
--danger:#f87171; --dh:#ef4444; --ok:#4ade80;
--cpu:#f97316; --mem:#22c55e; --disk:#3b82f6; --nic:#a855f7; --slot:#64748b;
}
* { box-sizing:border-box; margin:0; padding:0; }
html,body { height:100%; overflow:hidden; }
body { background:var(--bg); color:var(--text); font-family:'Segoe UI',system-ui,sans-serif; display:flex; flex-direction:column; }
header { background:var(--s1); border-bottom:1px solid var(--border); padding:0 24px; display:flex; align-items:center; gap:16px; height:52px; flex-shrink:0; }
header h1 { font-size:1.15rem; font-weight:700; }
.tabs { display:flex; gap:4px; margin-left:8px; }
.tab-btn { background:none; border:none; color:var(--muted); cursor:pointer; padding:6px 14px; border-radius:6px; font-size:.875rem; font-weight:500; transition:background .15s,color .15s; }
.tab-btn:hover { background:var(--s2); color:var(--text); }
.tab-btn.active { background:var(--s2); color:var(--accent); }
#conn-status { margin-left:auto; font-size:.72rem; color:var(--muted); }
.tab-panel { display:none; flex:1; overflow:hidden; }
.tab-panel.active { display:flex; flex-direction:column; }
.srv-layout { display:flex; flex:1; overflow:hidden; }
.sidebar { width:255px; background:var(--s1); border-right:1px solid var(--border); display:flex; flex-direction:column; flex-shrink:0; }
.sidebar-hdr { display:flex; align-items:center; justify-content:space-between; padding:12px 14px; border-bottom:1px solid var(--border); }
.sidebar-hdr span { font-size:.7rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:var(--muted); }
#server-list { overflow-y:auto; flex:1; }
.srv-item { padding:10px 14px; cursor:pointer; border-left:3px solid transparent; transition:background .12s,border-color .12s; }
.srv-item:hover { background:var(--s2); }
.srv-item.active { background:var(--s2); border-left-color:var(--accent); }
.srv-item .sn { font-weight:600; font-size:.88rem; }
.srv-item .sh { font-size:.73rem; color:var(--muted); margin-top:2px; }
#srv-detail { flex:1; overflow-y:auto; padding:24px; }
.empty-state { display:flex; align-items:center; justify-content:center; height:100%; color:var(--muted); }
.srv-hdr { display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:20px; }
.srv-hdr-info h2 { font-size:1.3rem; font-weight:700; }
.srv-hdr-info p { color:var(--muted); font-size:.82rem; margin-top:3px; }
.srv-hdr-info .desc { font-size:.78rem; color:var(--muted); margin-top:4px; }
.btn-row { display:flex; gap:8px; flex-shrink:0; }
.btn { border:none; border-radius:6px; padding:6px 14px; font-size:.82rem; font-weight:500; cursor:pointer; transition:background .15s; }
.btn-primary { background:var(--accent); color:#fff; }
.btn-primary:hover { background:var(--ah); }
.btn-danger { background:var(--danger); color:#fff; }
.btn-danger:hover { background:var(--dh); }
.btn-ghost { background:var(--s2); color:var(--text); border:1px solid var(--border); }
.btn-ghost:hover { background:var(--border); }
.btn-icon { background:none; border:none; cursor:pointer; padding:3px 6px; font-size:.9rem; color:var(--muted); border-radius:4px; }
.btn-icon:hover { background:var(--s2); color:var(--text); }
.parts-toolbar { display:flex; align-items:center; justify-content:space-between; margin-bottom:16px; }
.parts-toolbar span { font-size:.8rem; font-weight:600; }
.part-group { margin-bottom:28px; }
.pg-title { font-size:.7rem; font-weight:700; text-transform:uppercase; letter-spacing:.1em; margin-bottom:10px; display:flex; align-items:center; gap:8px; }
.dot { width:8px; height:8px; border-radius:50%; display:inline-block; }
.dot-cpu { background:var(--cpu); }
.dot-mem { background:var(--mem); }
.dot-disk { background:var(--disk); }
.dot-nic { background:var(--nic); }
.dot-slot { background:var(--slot); }
.badge { font-size:.68rem; color:var(--muted); background:var(--s2); border-radius:12px; padding:1px 7px; }
.parts-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:12px; }
.part-card { background:var(--s1); border:1px solid var(--border); border-radius:8px; padding:14px; }
.pc-hdr { display:flex; align-items:center; justify-content:space-between; margin-bottom:8px; }
.pc-id { font-size:.68rem; color:var(--muted); }
.pc-actions { display:flex; gap:2px; }
.pc-serial { font-size:.73rem; color:var(--muted); margin-bottom:8px; }
.field { display:flex; justify-content:space-between; font-size:.76rem; padding:3px 0; border-bottom:1px solid var(--border); gap:8px; }
.field:last-of-type { border-bottom:none; }
.fk { color:var(--muted); flex-shrink:0; }
.fv { color:var(--text); text-align:right; word-break:break-word; }
.pc-ts { font-size:.67rem; color:var(--muted); margin-top:8px; text-align:right; }
.pt-page { padding:24px; overflow-y:auto; flex:1; }
.pt-hdr { display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; }
.pt-hdr h2 { font-size:1.1rem; font-weight:700; }
table { width:100%; border-collapse:collapse; background:var(--s1); border-radius:8px; overflow:hidden; }
th { text-align:left; font-size:.7rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); padding:10px 14px; border-bottom:1px solid var(--border); }
td { padding:10px 14px; border-bottom:1px solid var(--border); font-size:.85rem; }
tr:last-child td { border-bottom:none; }
tr:hover td { background:var(--s2); }
#overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:100; align-items:center; justify-content:center; }
#overlay.show { display:flex; }
.modal { background:var(--s1); border:1px solid var(--border); border-radius:10px; width:min(520px,95vw); max-height:90vh; display:flex; flex-direction:column; }
.modal-hdr { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border); flex-shrink:0; }
.modal-hdr h3 { font-size:1rem; font-weight:600; }
.modal-body { padding:20px; overflow-y:auto; flex:1; }
.modal-ftr { display:flex; justify-content:flex-end; gap:10px; padding:14px 20px; border-top:1px solid var(--border); flex-shrink:0; }
.fg { margin-bottom:14px; }
.fg label { display:block; font-size:.75rem; font-weight:600; color:var(--muted); margin-bottom:5px; }
.fg input,.fg select,.fg textarea { width:100%; background:var(--s2); border:1px solid var(--border); border-radius:6px; padding:8px 10px; color:var(--text); font-size:.85rem; outline:none; }
.fg input:focus,.fg select:focus,.fg textarea:focus { border-color:var(--accent); }
.fg select option { background:var(--s2); }
#kve { display:flex; flex-direction:column; gap:6px; margin-bottom:8px; }
.kv-row { display:flex; gap:6px; align-items:center; }
.kv-row input { flex:1; background:var(--s2); border:1px solid var(--border); border-radius:6px; padding:6px 8px; color:var(--text); font-size:.82rem; outline:none; }
.kv-row input:focus { border-color:var(--accent); }
.kv-row input.ro { color:var(--muted); }
.kv-add { font-size:.78rem; color:var(--accent); background:none; border:none; cursor:pointer; padding:2px 0; text-align:left; }
.kv-add:hover { color:var(--ah); }
#toast-wrap { position:fixed; bottom:20px; right:20px; z-index:200; display:flex; flex-direction:column; gap:8px; pointer-events:none; }
.toast { padding:10px 16px; border-radius:8px; font-size:.83rem; font-weight:500; color:#fff; animation:slideIn .25s ease; }
.toast.ok { background:#166534; border:1px solid var(--ok); }
.toast.err { background:#7f1d1d; border:1px solid var(--danger); }
@keyframes slideIn { from{transform:translateX(60px);opacity:0} to{transform:translateX(0);opacity:1} }
.err-msg { color:var(--danger); font-size:.82rem; background:rgba(248,113,113,.08); border:1px solid rgba(248,113,113,.25); border-radius:6px; padding:8px 12px; }
</style>
</head>
<body>
<header>
<h1>Device Inventory</h1>
<div class="tabs">
<button class="tab-btn active" id="tab-srv-btn" onclick="showTab('srv')">Servers</button>
<button class="tab-btn" id="tab-pt-btn" onclick="showTab('pt')">Part Types</button>
</div>
<span id="conn-status"></span>
</header>
<div class="tab-panel active" id="tab-srv">
<div class="srv-layout">
<nav class="sidebar">
<div class="sidebar-hdr">
<span>Servers</span>
<button class="btn btn-primary" onclick="openAddServer()" style="padding:4px 10px;font-size:.75rem">+ Add</button>
</div>
<div id="server-list"><p style="padding:12px 14px;color:var(--muted);font-size:.82rem">Loading...</p></div>
</nav>
<div id="srv-detail">
<div class="empty-state">Select a server to view its inventory</div>
</div>
</div>
</div>
<div class="tab-panel" id="tab-pt">
<div class="pt-page">
<div class="pt-hdr">
<h2>Part Types</h2>
<button class="btn btn-primary" onclick="openAddPartType()">+ Add Part Type</button>
</div>
<div id="pt-table-wrap"><p style="color:var(--muted);font-size:.85rem">Loading...</p></div>
</div>
</div>
<div id="overlay">
<div class="modal">
<div class="modal-hdr">
<h3 id="modal-title"></h3>
<button class="btn-icon" onclick="closeModal()" style="font-size:1.1rem">x</button>
</div>
<div class="modal-body" id="modal-body"></div>
<div class="modal-ftr">
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" id="modal-ok" onclick="modalSubmit()">Save</button>
</div>
</div>
</div>
<div id="toast-wrap"></div>
<script>
var S = {sel: null, servers: [], parts: [], partTypes: []};
var modalCb = null;
var SCHEMAS = {
cpu: ['name','manufacturer','speed_ghz','cores','threads','serial'],
cpu_slot: ['form_factor','installed_cpu_id'],
memory_stick: ['size_mb','speed_mhz','manufacturer','channel_config','other_info','serial'],
memory_slot: ['allowed_size_mb','allowed_speed_mhz','installed_stick_id'],
disk: ['model','manufacturer','disk_type','disk_size_gb','connection_type','connection_speed_mbps','disk_speed_mbps','age_years','partition_ids','partition_sizes_gb','vm_hostnames','serial'],
nic: ['model','manufacturer','mac_address','ip_addresses','connection_speed_mbps','dhcp','age_years','serial']
};
var TYPE_COLORS = {cpu:'cpu',cpu_slot:'slot',memory_stick:'mem',memory_slot:'slot',disk:'disk',nic:'nic'};
var TYPE_LABELS = {cpu:'CPUs',cpu_slot:'CPU Slots',memory_stick:'Memory Sticks',memory_slot:'Memory Slots',disk:'Disks',nic:'Network Cards'};
var TYPE_ORDER = ['cpu','cpu_slot','memory_stick','memory_slot','disk','nic'];
var KEY_LABELS = {
speed_mhz:'Speed (MHz)',size_mb:'Size (MB)',manufacturer:'Manufacturer',
channel_config:'Channel',other_info:'Other',
allowed_speed_mhz:'Max Speed (MHz)',allowed_size_mb:'Max Size (MB)',installed_stick_id:'Installed Stick',
name:'Name',speed_ghz:'Speed (GHz)',cores:'Cores',threads:'Threads',
form_factor:'Form Factor',installed_cpu_id:'Installed CPU',
model:'Model',generation:'Generation',connection_type:'Connection',
connection_speed_mbps:'Speed (Mbps)',disk_speed_mbps:'Disk Speed (Mbps)',
disk_size_gb:'Size (GB)',disk_type:'Type',age_years:'Age (yrs)',
partition_ids:'Partitions',partition_sizes_gb:'Partition Sizes',
vm_hostnames:'VM Hostnames',vm_server_ids:'VM Server IDs',
mac_address:'MAC',ip_addresses:'IPs',dhcp:'DHCP',serial:'Serial'
};
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function fmtTime(ts) {
if (!ts) return 'unknown';
return new Date(ts * 1000).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'});
}
function labelFor(k) { return KEY_LABELS[k] || k.replace(/_/g,' '); }
// ── tabs ──────────────────────────────────────────────────────────────────────
function showTab(name) {
document.querySelectorAll('.tab-panel').forEach(function(p){ p.classList.remove('active'); });
document.querySelectorAll('.tab-btn').forEach(function(b){ b.classList.remove('active'); });
document.getElementById('tab-'+name).classList.add('active');
document.getElementById('tab-'+name+'-btn').classList.add('active');
if (name === 'pt') loadPartTypes();
}
// ── toast ─────────────────────────────────────────────────────────────────────
function toast(msg, isErr) {
var wrap = document.getElementById('toast-wrap');
var t = document.createElement('div');
t.className = 'toast ' + (isErr ? 'err' : 'ok');
t.textContent = msg;
wrap.appendChild(t);
setTimeout(function(){ if (t.parentNode) wrap.removeChild(t); }, 3500);
}
// ── modal ─────────────────────────────────────────────────────────────────────
function openModal(title, bodyHTML, onSubmit, okLabel, danger) {
okLabel = okLabel || 'Save';
document.getElementById('modal-title').textContent = title;
document.getElementById('modal-body').innerHTML = bodyHTML;
var okBtn = document.getElementById('modal-ok');
okBtn.textContent = okLabel;
okBtn.className = 'btn ' + (danger ? 'btn-danger' : 'btn-primary');
document.getElementById('overlay').classList.add('show');
modalCb = onSubmit;
}
function closeModal() {
document.getElementById('overlay').classList.remove('show');
modalCb = null;
}
function modalSubmit() { if (modalCb) modalCb(); }
document.addEventListener('keydown', function(e){ if (e.key === 'Escape') closeModal(); });
document.getElementById('overlay').addEventListener('click', function(e){ if (e.target === this) closeModal(); });
// ── form helpers ──────────────────────────────────────────────────────────────
function fg(label, name, v, type) {
v = v || ''; type = type || 'text';
return '<div class="fg"><label>' + esc(label) + '</label>'
+ '<input type="' + type + '" name="' + name + '" value="' + esc(v) + '"></div>';
}
function fgSelect(label, name, opts, sel) {
sel = sel || '';
var html = '<div class="fg"><label>' + esc(label) + '</label><select name="' + name + '">';
opts.forEach(function(o){
html += '<option value="' + esc(o.v) + '"' + (o.v === sel ? ' selected' : '') + '>' + esc(o.l) + '</option>';
});
html += '</select></div>';
return html;
}
function val(name) {
var el = document.querySelector('#modal-body [name="' + name + '"]');
return el ? el.value : '';
}
// ── KV editor ─────────────────────────────────────────────────────────────────
function kvRow(key, value, keyReadonly) {
return '<div class="kv-row">'
+ '<input type="text" placeholder="key" value="' + esc(key) + '"' + (keyReadonly ? ' readonly class="ro"' : '') + '>'
+ '<input type="text" placeholder="value" value="' + esc(value) + '">'
+ '<button class="btn-icon" type="button" onclick="this.parentNode.remove()" title="Remove">x</button>'
+ '</div>';
}
function buildKV(fields, schema) {
fields = fields || {}; schema = schema || [];
var used = {};
var html = '<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:8px">Fields</div>'
+ '<div id="kve">';
schema.forEach(function(k){
used[k] = true;
html += kvRow(k, fields[k] || '', true);
});
Object.keys(fields).forEach(function(k){
if (!used[k] && k !== 'serial') html += kvRow(k, fields[k], false);
});
html += '</div>';
html += '<button class="kv-add" type="button" onclick="addKV()">+ field</button>';
return html;
}
function addKV() {
var kve = document.getElementById('kve');
if (!kve) return;
var row = document.createElement('div');
row.className = 'kv-row';
row.innerHTML = '<input type="text" placeholder="key">'
+ '<input type="text" placeholder="value">'
+ '<button class="btn-icon" type="button" onclick="this.parentNode.remove()" title="Remove">x</button>';
kve.appendChild(row);
}
function collectKV() {
var result = {};
document.querySelectorAll('#kve .kv-row').forEach(function(row){
var inputs = row.querySelectorAll('input');
var k = inputs[0].value.trim();
var v = inputs[1].value.trim();
if (k) result[k] = v;
});
return result;
}
// ── servers ───────────────────────────────────────────────────────────────────
async function loadServers() {
try {
var res = await fetch('/api/servers');
if (!res.ok) throw new Error(await res.text());
S.servers = await res.json();
var el = document.getElementById('conn-status');
if (el) el.textContent = S.servers.length + ' server' + (S.servers.length !== 1 ? 's' : '');
renderServerList();
if (S.sel) {
var found = S.servers.find(function(s){ return s.id === S.sel.id; });
if (found) { S.sel = found; renderServerDetail(); }
}
} catch(e) {
document.getElementById('server-list').innerHTML =
'<p style="padding:12px 14px;color:var(--danger);font-size:.78rem">' + esc(String(e)) + '</p>';
}
}
function renderServerList() {
var list = document.getElementById('server-list');
if (!S.servers.length) {
list.innerHTML = '<p style="padding:12px 14px;color:var(--muted);font-size:.82rem">No servers yet.</p>';
return;
}
list.innerHTML = S.servers.map(function(s){
return '<div class="srv-item' + (S.sel && S.sel.id === s.id ? ' active' : '') + '" onclick="selectServer(' + JSON.stringify(s).replace(/"/g,'&quot;') + ')">'
+ '<div class="sn">' + esc(s.name) + '</div>'
+ '<div class="sh">' + esc(s.hostname) + '</div>'
+ '</div>';
}).join('');
}
async function selectServer(server) {
S.sel = server;
renderServerList();
var detail = document.getElementById('srv-detail');
detail.innerHTML = '<p style="color:var(--muted);font-size:.85rem">Loading parts...</p>';
try {
var res = await fetch('/api/servers/' + server.id + '/parts');
if (!res.ok) throw new Error(await res.text());
S.parts = await res.json();
renderServerDetail();
} catch(e) {
detail.innerHTML = '<div class="err-msg">' + esc(String(e)) + '</div>';
}
}
async function reloadParts() {
if (!S.sel) return;
try {
var res = await fetch('/api/servers/' + S.sel.id + '/parts');
if (!res.ok) throw new Error(await res.text());
S.parts = await res.json();
renderServerDetail();
} catch(e) {
toast('Failed to reload parts: ' + e.message, true);
}
}
function renderServerDetail() {
var detail = document.getElementById('srv-detail');
var s = S.sel;
var html = '<div class="srv-hdr">'
+ '<div class="srv-hdr-info">'
+ '<h2>' + esc(s.name) + '</h2>'
+ '<p>' + esc(s.hostname) + (s.location ? ' &nbsp;&middot;&nbsp; ' + esc(s.location) : '') + '</p>'
+ (s.description ? '<p class="desc">' + esc(s.description) + '</p>' : '')
+ '</div>'
+ '<div class="btn-row">'
+ '<button class="btn btn-ghost" onclick="openEditServer(' + JSON.stringify(s.id) + ')">&#9999; Edit</button>'
+ '<button class="btn btn-danger" onclick="confirmDelServer(' + JSON.stringify(s.id) + ')">&#128465; Delete</button>'
+ '</div></div>';
html += '<div class="parts-toolbar">'
+ '<span>Hardware <span class="badge">' + S.parts.length + '</span></span>'
+ '<button class="btn btn-primary" onclick="openAddPart()">+ Add Part</button>'
+ '</div>';
var byType = {};
S.parts.forEach(function(p){ (byType[p.type] = byType[p.type] || []).push(p); });
var types = TYPE_ORDER.filter(function(t){ return byType[t]; });
Object.keys(byType).forEach(function(t){ if (TYPE_ORDER.indexOf(t) === -1) types.push(t); });
if (!types.length) {
html += '<p style="color:var(--muted);font-size:.88rem">No parts yet. Use "+ Add Part" to add hardware.</p>';
} else {
types.forEach(function(type){
var color = TYPE_COLORS[type] || 'slot';
var label = TYPE_LABELS[type] || type.replace(/_/g,' ');
html += '<div class="part-group"><div class="pg-title"><span class="dot dot-' + color + '"></span>'
+ esc(label) + ' <span class="badge">' + byType[type].length + '</span></div>'
+ '<div class="parts-grid">';
byType[type].forEach(function(p){
html += '<div class="part-card">'
+ '<div class="pc-hdr"><span class="pc-id">#' + esc(p.id) + '</span>'
+ '<div class="pc-actions">'
+ '<button class="btn-icon" onclick="openEditPart(' + JSON.stringify(p.id) + ')" title="Edit">&#9999;</button>'
+ '<button class="btn-icon" style="color:var(--danger)" onclick="confirmDelPart(' + JSON.stringify(p.id) + ')" title="Delete">&#128465;</button>'
+ '</div></div>';
if (p.serial && p.serial !== 'NULL') {
html += '<div class="pc-serial">S/N: ' + esc(p.serial) + '</div>';
}
var skip = {serial:true, last_updated:true};
Object.keys(p.fields || {}).forEach(function(k){
var v = p.fields[k];
if (skip[k] || !v || v === 'NULL' || v === '0') return;
html += '<div class="field"><span class="fk">' + esc(labelFor(k)) + '</span><span class="fv">' + esc(v) + '</span></div>';
});
if (p.last_updated) html += '<div class="pc-ts">Updated ' + fmtTime(p.last_updated) + '</div>';
html += '</div>';
});
html += '</div></div>';
});
}
detail.innerHTML = html;
}
// ── server CRUD ───────────────────────────────────────────────────────────────
function openAddServer() {
openModal('Add Server',
fg('Name','name') + fg('Hostname','hostname') + fg('Location','location') + fg('Description','description'),
async function() {
try {
var res = await fetch('/api/servers', {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:val('name'),hostname:val('hostname'),location:val('location'),description:val('description')})
});
if (!res.ok) throw new Error(await res.text());
closeModal(); toast('Server added');
await loadServers();
} catch(e) { toast(e.message, true); }
}
);
}
function openEditServer(id) {
var s = S.servers.find(function(x){ return x.id === id; });
if (!s) return;
openModal('Edit Server',
fg('Name','name',s.name) + fg('Hostname','hostname',s.hostname) + fg('Location','location',s.location) + fg('Description','description',s.description),
async function() {
try {
var res = await fetch('/api/servers/' + id, {
method:'PATCH', headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:val('name'),hostname:val('hostname'),location:val('location'),description:val('description')})
});
if (!res.ok) throw new Error(await res.text());
closeModal(); toast('Server updated');
await loadServers();
} catch(e) { toast(e.message, true); }
}
);
}
function confirmDelServer(id) {
var s = S.servers.find(function(x){ return x.id === id; });
var name = s ? s.name : id;
openModal('Delete Server',
'<p style="color:var(--text)">Delete <strong>' + esc(name) + '</strong>? This cannot be undone.</p>',
async function() {
try {
var res = await fetch('/api/servers/' + id, {method:'DELETE'});
if (!res.ok) throw new Error(await res.text());
closeModal(); toast('Server deleted');
S.sel = null;
document.getElementById('srv-detail').innerHTML = '<div class="empty-state">Select a server to view its inventory</div>';
await loadServers();
} catch(e) { toast(e.message, true); }
},
'Delete', true
);
}
// ── part CRUD ─────────────────────────────────────────────────────────────────
function openAddPart() {
if (!S.sel) return;
var typeOpts = [
{v:'cpu',l:'CPU'},{v:'cpu_slot',l:'CPU Slot'},
{v:'memory_stick',l:'Memory Stick'},{v:'memory_slot',l:'Memory Slot'},
{v:'disk',l:'Disk'},{v:'nic',l:'NIC'}
];
var bodyHTML = fgSelect('Part Type','part_type',typeOpts,'cpu')
+ fg('Serial','serial')
+ '<div id="kv-section">' + buildKV({}, SCHEMAS['cpu']) + '</div>';
openModal('Add Part', bodyHTML, async function() {
try {
var ptype = val('part_type');
var fields = collectKV();
var serial = val('serial');
if (serial) fields['serial'] = serial;
var res = await fetch('/api/parts', {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({type:ptype, server_id:S.sel.id, fields:fields})
});
if (!res.ok) throw new Error(await res.text());
closeModal(); toast('Part added');
await reloadParts();
} catch(e) { toast(e.message, true); }
});
var typeEl = document.querySelector('#modal-body [name="part_type"]');
if (typeEl) {
typeEl.addEventListener('change', function() {
var sec = document.getElementById('kv-section');
if (sec) sec.innerHTML = buildKV({}, SCHEMAS[this.value] || []);
});
}
}
function openEditPart(id) {
var p = S.parts.find(function(x){ return x.id === id; });
if (!p) return;
var fields = Object.assign({}, p.fields || {});
if (p.serial && p.serial !== 'NULL') fields['serial'] = p.serial;
var bodyHTML = fg('Serial','serial', p.serial === 'NULL' ? '' : p.serial)
+ buildKV(fields, SCHEMAS[p.type] || []);
openModal('Edit Part', bodyHTML, async function() {
try {
var kv = collectKV();
var serial = val('serial');
if (serial) kv['serial'] = serial;
var res = await fetch('/api/parts/' + id, {
method:'PATCH', headers:{'Content-Type':'application/json'},
body:JSON.stringify({fields:kv})
});
if (!res.ok) throw new Error(await res.text());
closeModal(); toast('Part updated');
await reloadParts();
} catch(e) { toast(e.message, true); }
});
}
function confirmDelPart(id) {
openModal('Delete Part',
'<p style="color:var(--text)">Delete part <strong>' + esc(id) + '</strong>? This cannot be undone.</p>',
async function() {
try {
var res = await fetch('/api/parts/' + id, {method:'DELETE'});
if (!res.ok) throw new Error(await res.text());
closeModal(); toast('Part deleted');
await reloadParts();
} catch(e) { toast(e.message, true); }
},
'Delete', true
);
}
// ── part types ────────────────────────────────────────────────────────────────
async function loadPartTypes() {
try {
var res = await fetch('/api/part-types');
if (!res.ok) throw new Error(await res.text());
S.partTypes = await res.json();
renderPartTypes();
} catch(e) {
document.getElementById('pt-table-wrap').innerHTML = '<p class="err-msg">' + esc(String(e)) + '</p>';
}
}
function renderPartTypes() {
var wrap = document.getElementById('pt-table-wrap');
if (!S.partTypes.length) {
wrap.innerHTML = '<p style="color:var(--muted);font-size:.85rem">No part types defined.</p>';
return;
}
var html = '<table><thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead><tbody>';
S.partTypes.forEach(function(pt){
html += '<tr>'
+ '<td>' + esc(pt.name) + '</td>'
+ '<td style="color:var(--muted)">' + esc(pt.description) + '</td>'
+ '<td><div style="display:flex;gap:6px">'
+ '<button class="btn btn-ghost" style="padding:3px 10px;font-size:.75rem" onclick="openEditPartType(' + JSON.stringify(pt.id) + ')">&#9999; Edit</button>'
+ '<button class="btn btn-danger" style="padding:3px 10px;font-size:.75rem" onclick="confirmDelPT(' + JSON.stringify(pt.id) + ')">&#128465; Delete</button>'
+ '</div></td></tr>';
});
html += '</tbody></table>';
wrap.innerHTML = html;
}
function openAddPartType() {
openModal('Add Part Type',
fg('Name','name') + fg('Description','description'),
async function() {
try {
var res = await fetch('/api/part-types', {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:val('name'),description:val('description')})
});
if (!res.ok) throw new Error(await res.text());
closeModal(); toast('Part type added');
await loadPartTypes();
} catch(e) { toast(e.message, true); }
}
);
}
function openEditPartType(id) {
var pt = S.partTypes.find(function(x){ return x.id === id; });
if (!pt) return;
openModal('Edit Part Type',
fg('Name','name',pt.name) + fg('Description','description',pt.description),
async function() {
try {
var res = await fetch('/api/part-types/' + id, {
method:'PATCH', headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:val('name'),description:val('description')})
});
if (!res.ok) throw new Error(await res.text());
closeModal(); toast('Part type updated');
await loadPartTypes();
} catch(e) { toast(e.message, true); }
}
);
}
function confirmDelPT(id) {
var pt = S.partTypes.find(function(x){ return x.id === id; });
var name = pt ? pt.name : id;
openModal('Delete Part Type',
'<p style="color:var(--text)">Delete <strong>' + esc(name) + '</strong>? This cannot be undone.</p>',
async function() {
try {
var res = await fetch('/api/part-types/' + id, {method:'DELETE'});
if (!res.ok) throw new Error(await res.text());
closeModal(); toast('Part type deleted');
await loadPartTypes();
} catch(e) { toast(e.message, true); }
},
'Delete', true
);
}
// ── init ──────────────────────────────────────────────────────────────────────
loadServers();
setInterval(loadServers, 60000);
</script>
</body>
</html>`