Files
Provisioning/internal/api/render.go
T
josh bda568b25c 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>
2026-05-03 20:55:14 -04:00

186 lines
5.7 KiB
Go

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)
}