Files
Provisioning/internal/api/render.go
T
josh 1317ff6369
build-and-push / test (push) Successful in 34s
build-and-push / build-and-push (push) Successful in 1m8s
Add job detail page with activity log and cancel support
Operations are now clickable from the host detail page, linking to
/ops/{id} which shows the operation info, host link, duration, and
activity log filtered to that operation. Active operations can be
cancelled, which transitions the host to failed and releases the lock.
SSE activity events now include operation_id for real-time filtering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-14 10:37:18 -04:00

383 lines
13 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><a href="/ops/%d">%s</a></td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>`,
op.ID, 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 operationDetailPage(op *model.Operation, host *model.Host, activity []model.ActivityEntry) string {
osClass := opStateColor(op.State)
oLed := opLedClass(op.State)
duration := ""
if op.CompletedAt != nil {
duration = op.CompletedAt.Sub(op.StartedAt).Truncate(1e9).String()
} else {
duration = time.Since(op.StartedAt).Truncate(1e9).String() + " (running)"
}
errRow := ""
if op.ErrorMessage != "" {
errRow = fmt.Sprintf(`<tr><th>Error</th><td style="color:var(--red)">%s</td></tr>`, html.EscapeString(op.ErrorMessage))
}
hostname := "unknown"
hostLink := ""
if host != nil {
hostname = html.EscapeString(host.Hostname)
hostLink = fmt.Sprintf(`<a href="/hosts/%d" style="color:var(--accent);text-decoration:none">%s</a>`, host.ID, hostname)
}
var cancelBtn string
if op.State == model.OpActive {
cancelBtn = fmt.Sprintf(`<div class="actions"><form method="POST" action="/ops/%d/cancel" class="inline" onsubmit="return confirm('Cancel this operation? The host will be marked as failed.')"><button class="btn btn-danger">Cancel Operation</button></form></div>`, op.ID)
}
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(string(op.Kind), 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>
<div class="panel">
<table class="detail-table">
<tr><th>Kind</th><td>%s</td></tr>
<tr><th>State</th><td>%s</td></tr>
<tr><th>Host</th><td>%s</td></tr>
<tr><th>Started</th><td>%s</td></tr>
<tr><th>Duration</th><td>%s</td></tr>
%s
</table>
%s
</div>
<h3>Activity Log</h3>
<div class="panel">
<div class="activity-log" id="activity-log" data-operation-id="%d">%s</div>
</div>
`, oLed, html.EscapeString(string(op.Kind)), osClass, op.State,
op.Kind, op.State, hostLink,
op.StartedAt.Format("2006-01-02 15:04:05"),
duration, errRow, cancelBtn,
op.ID, activityHTML.String()))
}
func opStateColor(s model.OperationState) string {
switch s {
case model.OpActive:
return "state-blue"
case model.OpCompleted:
return "state-green"
case model.OpFailed:
return "state-red"
default:
return "state-grey"
}
}
func opLedClass(s model.OperationState) string {
switch s {
case model.OpActive:
return "led-blue"
case model.OpCompleted:
return "led-green"
case model.OpFailed:
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)
}