diff --git a/services/device-inventory/Dockerfile.cli b/services/device-inventory/Dockerfile.cli index 6a1e347..996db0f 100644 --- a/services/device-inventory/Dockerfile.cli +++ b/services/device-inventory/Dockerfile.cli @@ -1,4 +1,4 @@ -FROM ubuntu:24.04 AS builder +FROM ubuntu:22.04 AS builder RUN apt-get update && apt-get install -y --no-install-recommends \ cmake make g++ && \ rm -rf /var/lib/apt/lists/* @@ -7,7 +7,7 @@ COPY . . RUN rm -rf build && cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && \ cmake --build build --parallel -FROM ubuntu:24.04 +FROM ubuntu:22.04 RUN apt-get update && apt-get install -y --no-install-recommends \ dmidecode iproute2 util-linux pciutils libstdc++6 && \ rm -rf /var/lib/apt/lists/* diff --git a/services/device-inventory/web-ui/main.go b/services/device-inventory/web-ui/main.go index b858c91..8e0bc1d 100644 --- a/services/device-inventory/web-ui/main.go +++ b/services/device-inventory/web-ui/main.go @@ -4,6 +4,7 @@ import ( "bufio" "encoding/json" "fmt" + "io" "log" "net" "net/http" @@ -76,7 +77,7 @@ func sendCommand(tokens []string) ([][]string, error) { return rows, scanner.Err() } -// ── Domain types ───────────────────────────────────────────────────────────── +// ── Domain types ────────────────────────────────────────────────────────────── type Server struct { ID string `json:"id"` @@ -145,8 +146,8 @@ func listParts(serverID string) ([]Part, error) { } fields := make(map[string]string) for i := 5; i+1 < len(f); i += 2 { - val := strings.ReplaceAll(f[i+1], listSep, ", ") - fields[f[i]] = val + v := strings.ReplaceAll(f[i+1], listSep, ", ") + fields[f[i]] = v } var ts int64 fmt.Sscanf(f[4], "%d", &ts) @@ -155,13 +156,52 @@ func listParts(serverID string) ([]Part, error) { return out, nil } -// ── HTTP handlers ───────────────────────────────────────────────────────────── +// 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() @@ -169,48 +209,221 @@ func main() { fmt.Fprint(w, "ok") }) + // /api/servers — list + create mux.HandleFunc("/api/servers", func(w http.ResponseWriter, r *http.Request) { - servers, err := listServers() - if err != nil { - http.Error(w, err.Error(), http.StatusBadGateway) - return + 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) } - if servers == nil { - servers = []Server{} - } - writeJSON(w, servers) }) - mux.HandleFunc("/api/part-types", func(w http.ResponseWriter, r *http.Request) { - pts, err := listPartTypes() - if err != nil { - http.Error(w, err.Error(), http.StatusBadGateway) - return - } - if pts == nil { - pts = []PartType{} - } - writeJSON(w, pts) - }) - - // /api/servers/{id}/parts + // /api/servers/{id} — edit/delete; /api/servers/{id}/parts — list parts mux.HandleFunc("/api/servers/", func(w http.ResponseWriter, r *http.Request) { - parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") - // parts = ["api", "servers", "{id}", "parts"] - if len(parts) != 4 || parts[3] != "parts" { + segs := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(segs) < 3 { http.NotFound(w, r) return } - serverID := parts[2] - plist, err := listParts(serverID) + 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 } - if plist == nil { - plist = []Part{} + 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) } - writeJSON(w, plist) }) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { @@ -237,199 +450,630 @@ const dashboardHTML = ` Device Inventory

Device Inventory

- HOMELAB - +
+ + +
+
-
- -
-
← Select a server to view its hardware inventory
-
+ +
+
+ +
+
Select a server to view its inventory
+
+
+
+
+
+

Part Types

+ +
+

Loading...

+
+
+ +
+ +
+ +
+