443a3db9e1
ISO uploads now show a progress bar during file transfer (via XHR upload.onprogress) and real-time extraction status (via SSE events through the existing Hub). Falls back to plain form POST if JS is disabled. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
226 lines
7.8 KiB
Go
226 lines
7.8 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 = `<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.75rem;padding:0.2rem 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.75rem;padding:0.2rem 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" style="text-align:center;color:var(--text-muted)">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>
|
|
<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>
|
|
`, 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 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)
|
|
}
|