homelab/services/device-inventory/web-ui/main.go
Dan V e1a482c500 feat(device-inventory): add hooks system and DNS updater hook
- Add Hook interface (filter + execute contract) in server/hooks/hook.h
- Add HookRunner in server/hooks/hook_runner.h: spawns a detached thread
  per matching hook, with try/catch protection against crashes
- Add DnsUpdaterHook in server/hooks/dns_updater_hook.{h,cpp}:
  triggers on server name changes, logs in to Technitium, deletes the
  old A record (ignores 404), and adds the new A record
  Config via env vars: TECHNITIUM_HOST/PORT/USER/PASS/ZONE, DNS_TTL
- Add Database::get_nics_for_server() to resolve a server's IPv4 address
- Wire HookRunner into InventoryServer; cmd_edit_server now fires hooks
  with before/after Server snapshots
- Update CMakeLists.txt to include dns_updater_hook.cpp
- Document env vars in Dockerfile

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-24 14:58:36 +01:00

437 lines
16 KiB
Go

package main
import (
"bufio"
"encoding/json"
"fmt"
"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 {
val := strings.ReplaceAll(f[i+1], listSep, ", ")
fields[f[i]] = val
}
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
}
// ── HTTP handlers ─────────────────────────────────────────────────────────────
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
})
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
}
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
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" {
http.NotFound(w, r)
return
}
serverID := parts[2]
plist, err := listParts(serverID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
if plist == nil {
plist = []Part{}
}
writeJSON(w, plist)
})
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; --surface: #1a1d27; --surface2: #23263a;
--border: #2e3250; --accent: #6c8bef; --accent2: #a78bfa;
--text: #e2e8f0; --muted: #8892aa; --danger: #f87171;
--cpu: #f97316; --mem: #22c55e; --disk: #3b82f6; --nic: #a855f7;
--slot: #64748b;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; min-height: 100vh; }
header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 14px 24px; display: flex; align-items: center; gap: 12px; }
header h1 { font-size: 1.25rem; font-weight: 600; color: var(--text); }
header span.badge { background: var(--accent); color: #fff; font-size: 0.7rem; padding: 2px 8px; border-radius: 12px; font-weight: 700; letter-spacing: .05em; }
.layout { display: grid; grid-template-columns: 280px 1fr; height: calc(100vh - 53px); }
.sidebar { background: var(--surface); border-right: 1px solid var(--border); overflow-y: auto; padding: 16px 0; }
.sidebar h2 { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); padding: 0 16px 10px; }
.server-item { padding: 10px 16px; cursor: pointer; border-left: 3px solid transparent; transition: background .15s, border-color .15s; }
.server-item:hover { background: var(--surface2); }
.server-item.active { background: var(--surface2); border-left-color: var(--accent); }
.server-item .sname { font-weight: 600; font-size: 0.9rem; }
.server-item .shost { font-size: 0.75rem; color: var(--muted); margin-top: 2px; }
.server-item .sloc { font-size: 0.7rem; color: var(--muted); margin-top: 1px; }
.main { overflow-y: auto; padding: 24px; }
.main-empty { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--muted); font-size: 0.95rem; }
.server-header { margin-bottom: 20px; }
.server-header h2 { font-size: 1.4rem; font-weight: 700; }
.server-header p { color: var(--muted); font-size: 0.85rem; margin-top: 4px; }
.part-group { margin-bottom: 28px; }
.part-group-title { font-size: 0.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%; }
.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); }
.parts-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
.part-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px; }
.part-card .part-header { display: flex; align-items: baseline; gap: 8px; margin-bottom: 10px; }
.part-card .part-id { font-size: 0.7rem; color: var(--muted); }
.part-card .part-serial { font-size: 0.75rem; color: var(--muted); margin-top: 2px; margin-bottom: 10px; }
.part-card .field { display: flex; justify-content: space-between; font-size: 0.78rem; padding: 4px 0; border-bottom: 1px solid var(--border); gap: 8px; }
.part-card .field:last-child { border-bottom: none; }
.part-card .fk { color: var(--muted); flex-shrink: 0; }
.part-card .fv { color: var(--text); text-align: right; word-break: break-word; }
.part-card .updated { font-size: 0.7rem; color: var(--muted); margin-top: 8px; text-align: right; }
.loading { color: var(--muted); font-size: 0.9rem; }
.error-msg { color: var(--danger); font-size: 0.85rem; background: rgba(248,113,113,.1); border: 1px solid rgba(248,113,113,.3); border-radius: 6px; padding: 10px 14px; }
.count { font-size: 0.7rem; color: var(--muted); background: var(--surface2); border-radius: 12px; padding: 1px 7px; }
@media(max-width:700px){ .layout{grid-template-columns:1fr} .sidebar{height:auto;max-height:200px} }
</style>
</head>
<body>
<header>
<h1>Device Inventory</h1>
<span class="badge">HOMELAB</span>
<span id="status" style="margin-left:auto;font-size:.75rem;color:var(--muted)"></span>
</header>
<div class="layout">
<nav class="sidebar">
<h2>Servers</h2>
<div id="server-list"><p style="padding:12px 16px;color:var(--muted);font-size:.85rem">Loading…</p></div>
</nav>
<main class="main" id="main">
<div class="main-empty">← Select a server to view its hardware inventory</div>
</main>
</div>
<script>
const TYPE_COLORS = {
cpu:'cpu', cpu_slot:'slot',
memory_stick:'mem', memory_slot:'slot',
disk:'disk', nic:'nic',
};
const TYPE_LABELS = {
cpu:'CPUs', cpu_slot:'CPU Slots',
memory_stick:'Memory Sticks', memory_slot:'Memory Slots',
disk:'Disks', nic:'Network Cards',
};
const TYPE_ORDER = ['cpu','cpu_slot','memory_stick','memory_slot','disk','nic'];
let currentServer = null;
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) {
const map = {
speed_mhz:'Speed (MHz)', size_mb:'Size (MB)', manufacturer:'Manufacturer',
channel_config:'Channel Config', other_info:'Other Info',
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:'Conn Speed (Mbps)',
disk_speed_mbps:'Disk Speed (Mbps)', disk_size_gb:'Size (GB)', disk_type:'Type',
age_years:'Age (years)', partition_ids:'Partitions', partition_sizes_gb:'Partition Sizes (GB)',
vm_hostnames:'Used by VMs', vm_server_ids:'VM Server IDs',
mac_address:'MAC', ip_addresses:'IP Addresses', dhcp:'DHCP',
};
return map[k] || k.replace(/_/g,' ');
}
async function loadServers() {
try {
const res = await fetch('/api/servers');
if (!res.ok) throw new Error(await res.text());
const servers = await res.json();
document.getElementById('status').textContent = servers.length + ' server' + (servers.length!==1?'s':'');
const list = document.getElementById('server-list');
if (!servers.length) {
list.innerHTML = '<p style="padding:12px 16px;color:var(--muted);font-size:.85rem">No servers registered yet.</p>';
return;
}
list.innerHTML = servers.map(s =>
'<div class="server-item" data-id="'+s.id+'" onclick="selectServer('+JSON.stringify(s).replace(/"/g,'&quot;')+')">'
+ '<div class="sname">'+esc(s.name)+'</div>'
+ '<div class="shost">'+esc(s.hostname)+'</div>'
+ '<div class="sloc">'+esc(s.location)+'</div>'
+ '</div>'
).join('');
} catch(e) {
document.getElementById('server-list').innerHTML =
'<p style="padding:12px 16px;color:var(--danger);font-size:.8rem">'+esc(String(e))+'</p>';
}
}
async function selectServer(server) {
currentServer = server.id;
document.querySelectorAll('.server-item').forEach(el => {
el.classList.toggle('active', el.dataset.id === server.id);
});
const main = document.getElementById('main');
main.innerHTML = '<div class="loading">Loading hardware…</div>';
try {
const res = await fetch('/api/servers/'+server.id+'/parts');
if (!res.ok) throw new Error(await res.text());
const parts = await res.json();
renderParts(main, server, parts);
} catch(e) {
main.innerHTML = '<div class="error-msg">'+esc(String(e))+'</div>';
}
}
function renderParts(main, server, parts) {
const byType = {};
for (const p of parts) { (byType[p.type] = byType[p.type]||[]).push(p); }
const types = TYPE_ORDER.filter(t => byType[t]);
const extra = Object.keys(byType).filter(t => !TYPE_ORDER.includes(t));
let html = '<div class="server-header">'
+ '<h2>'+esc(server.name)+'</h2>'
+ '<p>'+esc(server.hostname)+' &nbsp;·&nbsp; '+esc(server.location)+'</p>'
+ (server.description ? '<p style="margin-top:4px;font-size:.8rem;color:var(--muted)">'+esc(server.description)+'</p>' : '')
+ '</div>';
if (!parts.length) {
html += '<p style="color:var(--muted);font-size:.9rem">No parts discovered yet. Run the daily CronJob or use the CLI to add parts.</p>';
main.innerHTML = html;
return;
}
for (const type of [...types, ...extra]) {
const color = TYPE_COLORS[type] || 'slot';
const label = TYPE_LABELS[type] || type;
html += '<div class="part-group"><div class="part-group-title"><span class="dot dot-'+color+'"></span>'
+ label + ' <span class="count">'+byType[type].length+'</span></div>'
+ '<div class="parts-grid">';
for (const p of byType[type]) {
html += '<div class="part-card">'
+ '<div class="part-header"><span class="part-id">#'+esc(p.id)+'</span></div>';
if (p.serial && p.serial !== 'NULL') {
html += '<div class="part-serial">S/N: '+esc(p.serial)+'</div>';
}
const skip = new Set(['serial','last_updated']);
for (const [k,v] of Object.entries(p.fields||{})) {
if (skip.has(k) || !v || v==='NULL' || v==='0') continue;
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="updated">Updated '+fmtTime(p.last_updated)+'</div>';
}
html += '</div>';
}
html += '</div></div>';
}
main.innerHTML = html;
}
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
loadServers();
setInterval(loadServers, 60000);
</script>
</body>
</html>`