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(`
No hosts registered. Register one.
`)
}
return layout("Dashboard", fmt.Sprintf(`
%s
`, len(hosts), tiles.String()))
}
func hostTile(h model.Host) string {
stateClass := stateColor(h.State)
led := ledClass(h.State)
return fmt.Sprintf(`
%s
`, 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(`%s `, t, selected, t))
}
errHTML := ""
if errMsg != "" {
errHTML = fmt.Sprintf(`%s
`, 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(`
Register Host
%s
`, 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(`Rebuild `, h.ID))
}
actions.WriteString(fmt.Sprintf(`Delete `, 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(`%s %s %s %s %s `,
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(`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.
`, mins)
}
var activityHTML strings.Builder
for _, e := range activity {
activityHTML.WriteString(fmt.Sprintf(
`%s %s %s
`,
e.Level, e.CreatedAt.Format("15:04"), html.EscapeString(e.Source), html.EscapeString(e.Message)))
}
if len(activity) == 0 {
activityHTML.WriteString(`No activity recorded yet.
`)
}
return layout(h.Hostname, fmt.Sprintf(`
%s
MAC %s
Server Type %s
IP Address %s
Notes %s
%s
Operations
Kind State Started Duration Error
%s
Activity Log
`, 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 = `default `
} else {
def = fmt.Sprintf(`Set Default `, img.ID)
}
deleteBtn := fmt.Sprintf(`Delete `, img.ID, html.EscapeString(img.Name))
rows.WriteString(fmt.Sprintf(`%s %s %s %s %s %s `,
html.EscapeString(img.Name), img.Kind, img.Version, def, img.CreatedAt.Format("2006-01-02"), deleteBtn))
}
if len(images) == 0 {
rows.WriteString(`No images uploaded yet. `)
}
return layout("Images", fmt.Sprintf(`
Name Kind Version Default Added
%s
`, len(images), rows.String()))
}
func imageUploadForm(errMsg string) string {
errHTML := ""
if errMsg != "" {
errHTML = fmt.Sprintf(`%s
`, html.EscapeString(errMsg))
}
return layout("Upload Image", fmt.Sprintf(`
Upload Boot Image
%s
Name
Version
Kind
Proxmox VE
ISO File
Upload & Extract
Uploading ISO...
Preparing upload...
`, 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(`Error %s `, html.EscapeString(op.ErrorMessage))
}
hostname := "unknown"
hostLink := ""
if host != nil {
hostname = html.EscapeString(host.Hostname)
hostLink = fmt.Sprintf(`%s `, host.ID, hostname)
}
var cancelBtn string
if op.State == model.OpActive {
cancelBtn = fmt.Sprintf(`
Cancel Operation `, op.ID)
}
var activityHTML strings.Builder
for _, e := range activity {
activityHTML.WriteString(fmt.Sprintf(
`%s %s %s
`,
e.Level, e.CreatedAt.Format("15:04"), html.EscapeString(e.Source), html.EscapeString(e.Message)))
}
if len(activity) == 0 {
activityHTML.WriteString(`No activity recorded yet.
`)
}
return layout(string(op.Kind), fmt.Sprintf(`
Kind %s
State %s
Host %s
Started %s
Duration %s
%s
%s
Activity Log
`, 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(`
%s — Provisioning
Provisioning
Link
%s
`, html.EscapeString(title), body)
}