Files
Provisioning/internal/api/render.go
T
josh a6603b463f
build-and-push / test (push) Failing after 32s
build-and-push / build-and-push (push) Has been skipped
Add activity log system for provisioning lifecycle visibility
Hosts stuck in states like pxe_ready had zero visibility into why.
This adds a persistent activity log that records every meaningful
step (state transitions, PXE events, cluster join stages, failures)
and surfaces it on the host detail page with live SSE updates.
Includes a stuck-detection warning banner when a host sits in
pxe_ready for >10 minutes with no iPXE request.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-13 23:30:21 -04:00

291 lines
9.9 KiB
Go

package api
import (
"fmt"
"html"
"net/http"
"strings"
"time"
"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)
led := ledClass(h.State)
return fmt.Sprintf(`
<a href="/hosts/%d" class="tile %s" id="tile-%d">
<div class="tile-header">
<span class="led led-lg %s"></span>
<span class="tile-name">%s</span>
</div>
<div class="tile-meta">
<span class="tile-type">%s</span>
</div>
<div class="tile-footer">
<span class="tile-mac">%s</span>
<span class="tile-state-label">%s</span>
</div>
</a>
`, h.ID, stateClass, h.ID, led, html.EscapeString(h.Hostname), html.EscapeString(h.ServerType), h.MAC, h.State)
}
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, activity []model.ActivityEntry) string {
stateClass := stateColor(h.State)
led := ledClass(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</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 = "—"
}
var stuckWarning string
if h.State == model.StatePXEReady && time.Since(h.UpdatedAt) > 10*time.Minute {
mins := int(time.Since(h.UpdatedAt).Minutes())
stuckWarning = fmt.Sprintf(`<div class="stuck-warning">Host has been in PXE_READY for %d minutes with no iPXE request. This usually means the host failed to PXE boot — check secure boot settings, network connectivity, and BIOS boot order.</div>`, mins)
}
var activityHTML strings.Builder
for _, e := range activity {
activityHTML.WriteString(fmt.Sprintf(
`<div class="log-entry log-%s"><span class="log-time">%s</span><span class="log-source">%s</span><span class="log-msg">%s</span></div>`,
e.Level, e.CreatedAt.Format("15:04"), html.EscapeString(e.Source), html.EscapeString(e.Message)))
}
if len(activity) == 0 {
activityHTML.WriteString(`<p class="empty">No activity recorded yet.</p>`)
}
return layout(h.Hostname, fmt.Sprintf(`
<div class="host-header">
<span class="led led-lg %s"></span>
<h2 style="margin-bottom:0">%s</h2>
<span class="badge %s">%s</span>
</div>
%s
<div class="panel">
<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>
</div>
<h3>Operations</h3>
<div class="panel">
<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>
</div>
<h3>Activity Log</h3>
<div class="panel">
<div class="activity-log" id="activity-log" data-host-id="%d">%s</div>
</div>
`, led, html.EscapeString(h.Hostname), stateClass, h.State,
stuckWarning,
h.MAC, h.ServerType, ip, html.EscapeString(h.Notes), actions.String(),
opsHTML.String(),
h.ID, activityHTML.String()))
}
func imagesPage(images []model.Image) string {
var rows strings.Builder
for _, img := range images {
def := ""
if img.IsDefault {
def = `<span class="badge state-green">default</span>`
} else {
def = fmt.Sprintf(`<form method="POST" action="/images/%d/default" class="inline"><button class="btn" style="font-size:0.7rem;padding:0.25rem 0.5rem">Set Default</button></form>`, img.ID)
}
deleteBtn := fmt.Sprintf(`<form method="POST" action="/images/%d/delete" class="inline" onsubmit="return confirm('Delete image %s?')"><button class="btn btn-danger" style="font-size:0.7rem;padding:0.25rem 0.5rem">Delete</button></form>`, img.ID, html.EscapeString(img.Name))
rows.WriteString(fmt.Sprintf(`<tr><td>%s</td><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"), deleteBtn))
}
if len(images) == 0 {
rows.WriteString(`<tr><td colspan="6" class="empty">No images uploaded yet.</td></tr>`)
}
return layout("Images", fmt.Sprintf(`
<div class="actions">
<a href="/images/new" class="btn">Upload Image</a>
<span class="count">%d images</span>
</div>
<div class="panel">
<table class="ops-table">
<thead><tr><th>Name</th><th>Kind</th><th>Version</th><th>Default</th><th>Added</th><th></th></tr></thead>
<tbody>%s</tbody>
</table>
</div>
`, len(images), rows.String()))
}
func imageUploadForm(errMsg string) string {
errHTML := ""
if errMsg != "" {
errHTML = fmt.Sprintf(`<div class="error">%s</div>`, html.EscapeString(errMsg))
}
return layout("Upload Image", fmt.Sprintf(`
<h2>Upload Boot Image</h2>
%s
<form id="upload-form" method="POST" action="/images/upload" enctype="multipart/form-data" class="form">
<input type="hidden" name="upload_id" id="upload-id" value="">
<label>Name<input type="text" name="name" placeholder="proxmox-8.2" required pattern="[a-z0-9][a-z0-9.\-]*"></label>
<label>Version<input type="text" name="version" placeholder="8.2-1" required></label>
<label>Kind
<select name="kind">
<option value="proxmox" selected>Proxmox VE</option>
</select>
</label>
<label>ISO File<input type="file" name="iso" accept=".iso" required></label>
<button type="submit" class="btn">Upload &amp; Extract</button>
</form>
<div id="upload-progress" style="display:none" class="upload-progress">
<h3 id="progress-title">Uploading ISO...</h3>
<div class="progress-bar-track">
<div class="progress-bar-fill" id="progress-fill"></div>
</div>
<div class="progress-text" id="progress-text">Preparing upload...</div>
<div class="progress-detail" id="progress-detail"></div>
</div>
`, errHTML))
}
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 ledClass(s model.HostState) string {
switch s {
case model.StateRegistered:
return "led-grey"
case model.StatePXEReady, model.StatePXEBooted, model.StateInstalling:
return "led-blue"
case model.StateInstalled, model.StateFirstBoot, model.StateJoining:
return "led-amber"
case model.StateReady:
return "led-green"
case model.StateFailed:
return "led-red"
default:
return "led-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="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
<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>
<div class="sse-status">
<span class="sse-label">Link</span>
<span class="led led-grey" id="sse-dot"></span>
</div>
</nav>
<main>%s</main>
<script src="/static/app.js"></script>
</body>
</html>`, html.EscapeString(title), body)
}