Initial implementation: host lifecycle + PXE + admin dashboard
Go service for Proxmox homelab cluster provisioning. Handles PXE boot, Proxmox autoinstall (answer file generation), cluster join via SSH, and Infrastructure API registration. - Host state machine (registered → pxe_ready → installing → ready) - dnsmasq supervisor with MAC-based allowlist - iPXE script and Proxmox answer file generation - First-boot phone-home → cluster join → infra registration - Operation locking with expiry (409 on conflict) - SSE event hub for real-time dashboard updates - Admin dashboard (host grid, detail, registration form) - Config-driven server types with hot-reload - Docker deployment (multi-stage fat image) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"provisioning/internal/model"
|
||||
)
|
||||
|
||||
func renderHTML(w http.ResponseWriter, body string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(body))
|
||||
}
|
||||
|
||||
func dashboardPage(hosts []model.Host) string {
|
||||
var tiles strings.Builder
|
||||
for _, h := range hosts {
|
||||
tiles.WriteString(hostTile(h))
|
||||
}
|
||||
if len(hosts) == 0 {
|
||||
tiles.WriteString(`<p class="empty">No hosts registered. <a href="/hosts/new">Register one.</a></p>`)
|
||||
}
|
||||
return layout("Dashboard", fmt.Sprintf(`
|
||||
<div class="actions">
|
||||
<a href="/hosts/new" class="btn">Register Host</a>
|
||||
<span class="count">%d hosts</span>
|
||||
</div>
|
||||
<div class="host-grid">%s</div>
|
||||
`, len(hosts), tiles.String()))
|
||||
}
|
||||
|
||||
func hostTile(h model.Host) string {
|
||||
stateClass := stateColor(h.State)
|
||||
return fmt.Sprintf(`
|
||||
<a href="/hosts/%d" class="tile %s" id="tile-%d">
|
||||
<div class="tile-name">%s</div>
|
||||
<div class="tile-type">%s</div>
|
||||
<div class="tile-state">%s</div>
|
||||
<div class="tile-mac">%s</div>
|
||||
</a>
|
||||
`, h.ID, stateClass, h.ID, html.EscapeString(h.Hostname), html.EscapeString(h.ServerType), h.State, h.MAC)
|
||||
}
|
||||
|
||||
func hostFormPage(types []string, errMsg string, prefill *model.Host) string {
|
||||
var opts strings.Builder
|
||||
for _, t := range types {
|
||||
selected := ""
|
||||
if prefill != nil && prefill.ServerType == t {
|
||||
selected = " selected"
|
||||
}
|
||||
opts.WriteString(fmt.Sprintf(`<option value="%s"%s>%s</option>`, t, selected, t))
|
||||
}
|
||||
errHTML := ""
|
||||
if errMsg != "" {
|
||||
errHTML = fmt.Sprintf(`<div class="error">%s</div>`, html.EscapeString(errMsg))
|
||||
}
|
||||
hostname, mac, notes := "", "", ""
|
||||
if prefill != nil {
|
||||
hostname = html.EscapeString(prefill.Hostname)
|
||||
mac = html.EscapeString(prefill.MAC)
|
||||
notes = html.EscapeString(prefill.Notes)
|
||||
}
|
||||
return layout("Register Host", fmt.Sprintf(`
|
||||
<h2>Register Host</h2>
|
||||
%s
|
||||
<form method="POST" action="/hosts" class="form">
|
||||
<label>Hostname<input type="text" name="hostname" value="%s" required></label>
|
||||
<label>MAC Address<input type="text" name="mac" value="%s" placeholder="aa:bb:cc:dd:ee:ff" required></label>
|
||||
<label>Server Type<select name="server_type" required>%s</select></label>
|
||||
<label>Notes<textarea name="notes">%s</textarea></label>
|
||||
<button type="submit" class="btn">Register</button>
|
||||
</form>
|
||||
`, errHTML, hostname, mac, opts.String(), notes))
|
||||
}
|
||||
|
||||
func hostDetailPage(h *model.Host, ops []model.Operation) string {
|
||||
stateClass := stateColor(h.State)
|
||||
canRebuild := h.State == model.StateRegistered || h.State == model.StateReady || h.State == model.StateFailed
|
||||
|
||||
var actions strings.Builder
|
||||
if canRebuild {
|
||||
actions.WriteString(fmt.Sprintf(`<form method="POST" action="/hosts/%d/rebuild" class="inline"><button class="btn">Rebuild with Proxmox</button></form>`, h.ID))
|
||||
}
|
||||
actions.WriteString(fmt.Sprintf(`<form method="POST" action="/hosts/%d/delete" class="inline" onsubmit="return confirm('Delete this host?')"><button class="btn btn-danger">Delete</button></form>`, h.ID))
|
||||
|
||||
var opsHTML strings.Builder
|
||||
for _, op := range ops {
|
||||
duration := ""
|
||||
if op.CompletedAt != nil {
|
||||
duration = op.CompletedAt.Sub(op.StartedAt).Truncate(1e9).String()
|
||||
}
|
||||
errCell := ""
|
||||
if op.ErrorMessage != "" {
|
||||
errCell = html.EscapeString(op.ErrorMessage)
|
||||
}
|
||||
opsHTML.WriteString(fmt.Sprintf(`<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>`,
|
||||
op.Kind, op.State, op.StartedAt.Format("2006-01-02 15:04"), duration, errCell))
|
||||
}
|
||||
|
||||
ip := h.IPAddress
|
||||
if ip == "" {
|
||||
ip = "—"
|
||||
}
|
||||
|
||||
return layout(h.Hostname, fmt.Sprintf(`
|
||||
<div class="host-header">
|
||||
<h2>%s</h2>
|
||||
<span class="badge %s">%s</span>
|
||||
</div>
|
||||
<table class="detail-table">
|
||||
<tr><th>MAC</th><td>%s</td></tr>
|
||||
<tr><th>Server Type</th><td>%s</td></tr>
|
||||
<tr><th>IP Address</th><td>%s</td></tr>
|
||||
<tr><th>Notes</th><td>%s</td></tr>
|
||||
</table>
|
||||
<div class="actions">%s</div>
|
||||
<h3>Operations</h3>
|
||||
<table class="ops-table">
|
||||
<thead><tr><th>Kind</th><th>State</th><th>Started</th><th>Duration</th><th>Error</th></tr></thead>
|
||||
<tbody>%s</tbody>
|
||||
</table>
|
||||
`, html.EscapeString(h.Hostname), stateClass, h.State, h.MAC, h.ServerType, ip, html.EscapeString(h.Notes), actions.String(), opsHTML.String()))
|
||||
}
|
||||
|
||||
func imagesPage(images []model.Image) string {
|
||||
var rows strings.Builder
|
||||
for _, img := range images {
|
||||
def := ""
|
||||
if img.IsDefault {
|
||||
def = "✓"
|
||||
}
|
||||
rows.WriteString(fmt.Sprintf(`<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>`,
|
||||
html.EscapeString(img.Name), img.Kind, img.Version, def, img.CreatedAt.Format("2006-01-02")))
|
||||
}
|
||||
return layout("Images", fmt.Sprintf(`
|
||||
<h2>Boot Images</h2>
|
||||
<table class="ops-table">
|
||||
<thead><tr><th>Name</th><th>Kind</th><th>Version</th><th>Default</th><th>Added</th></tr></thead>
|
||||
<tbody>%s</tbody>
|
||||
</table>
|
||||
`, rows.String()))
|
||||
}
|
||||
|
||||
func stateColor(s model.HostState) string {
|
||||
switch s {
|
||||
case model.StateRegistered:
|
||||
return "state-grey"
|
||||
case model.StatePXEReady, model.StatePXEBooted, model.StateInstalling:
|
||||
return "state-blue"
|
||||
case model.StateInstalled, model.StateFirstBoot, model.StateJoining:
|
||||
return "state-amber"
|
||||
case model.StateReady:
|
||||
return "state-green"
|
||||
case model.StateFailed:
|
||||
return "state-red"
|
||||
default:
|
||||
return "state-grey"
|
||||
}
|
||||
}
|
||||
|
||||
func layout(title, body string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>%s — Provisioning</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="topbar">
|
||||
<a href="/" class="brand">Provisioning</a>
|
||||
<div class="nav-links">
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/images">Images</a>
|
||||
</div>
|
||||
<span class="sse-indicator" id="sse-dot">●</span>
|
||||
</nav>
|
||||
<main>%s</main>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>`, html.EscapeString(title), body)
|
||||
}
|
||||
Reference in New Issue
Block a user