1317ff6369
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>
383 lines
13 KiB
Go
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 & 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)
|
|
}
|