- 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>
1081 lines
42 KiB
Go
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
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,'"') + ')">'
|
|
+ '<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 ? ' · ' + 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) + ')">✏ Edit</button>'
|
|
+ '<button class="btn btn-danger" onclick="confirmDelServer(' + JSON.stringify(s.id) + ')">🗑 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">✏</button>'
|
|
+ '<button class="btn-icon" style="color:var(--danger)" onclick="confirmDelPart(' + JSON.stringify(p.id) + ')" title="Delete">🗑</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) + ')">✏ Edit</button>'
|
|
+ '<button class="btn btn-danger" style="padding:3px 10px;font-size:.75rem" onclick="confirmDelPT(' + JSON.stringify(pt.id) + ')">🗑 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>`
|