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 = ` Device Inventory

Device Inventory

Select a server to view its inventory

Part Types

Loading...

`