Files
Provisioning/internal/api/ui.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

254 lines
6.7 KiB
Go

package api
import (
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"provisioning/internal/config"
"provisioning/internal/events"
"provisioning/internal/image"
"provisioning/internal/model"
"provisioning/internal/orchestrator"
"provisioning/internal/pxe"
"provisioning/internal/statemachine"
"provisioning/internal/store"
"github.com/go-chi/chi/v5"
)
type UI struct {
Hosts *store.Hosts
Ops *store.Operations
Locks *store.Locks
Images *store.Images
Activity *store.Activity
ImageSvc *image.Service
Runner *orchestrator.Runner
Orchestrator *orchestrator.HostOrchestrator
Hub *events.Hub
PXE *pxe.Supervisor
Config *config.Config
ServerTypes *config.ServerTypeRegistry
}
func (u *UI) Dashboard(w http.ResponseWriter, r *http.Request) {
hosts, _ := u.Hosts.List(r.Context())
renderHTML(w, dashboardPage(hosts))
}
func (u *UI) NewHostForm(w http.ResponseWriter, r *http.Request) {
types := u.ServerTypes.Keys()
renderHTML(w, hostFormPage(types, "", nil))
}
func (u *UI) CreateHost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
hostname := strings.TrimSpace(r.FormValue("hostname"))
mac := strings.TrimSpace(r.FormValue("mac"))
serverType := r.FormValue("server_type")
notes := r.FormValue("notes")
var errs []string
if hostname == "" {
errs = append(errs, "Hostname is required")
}
if !isValidMAC(mac) {
errs = append(errs, "Invalid MAC address format (expected xx:xx:xx:xx:xx:xx)")
}
if _, ok := u.ServerTypes.Get(serverType); !ok {
errs = append(errs, "Invalid server type")
}
if len(errs) > 0 {
types := u.ServerTypes.Keys()
renderHTML(w, hostFormPage(types, strings.Join(errs, "; "), &model.Host{
Hostname: hostname,
MAC: mac,
ServerType: serverType,
Notes: notes,
}))
return
}
_, err := u.Hosts.Create(r.Context(), model.Host{
Hostname: hostname,
MAC: mac,
ServerType: serverType,
Notes: notes,
})
if err != nil {
types := u.ServerTypes.Keys()
renderHTML(w, hostFormPage(types, "Host already exists: "+err.Error(), nil))
return
}
hosts, _ := u.Hosts.List(r.Context())
_ = u.PXE.Reload(hosts)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
var id int64
fmt.Sscanf(idStr, "%d", &id)
host, err := u.Hosts.Get(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.Error(w, "Host not found", http.StatusNotFound)
return
}
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
ops, _ := u.Ops.ListByHost(r.Context(), host.ID)
activity, _ := u.Activity.ListByHost(r.Context(), host.ID, 50)
renderHTML(w, hostDetailPage(host, ops, activity))
}
func (u *UI) TriggerRebuild(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
var id int64
fmt.Sscanf(idStr, "%d", &id)
host, err := u.Hosts.Get(r.Context(), id)
if err != nil {
http.Error(w, "Host not found", http.StatusNotFound)
return
}
locked, _ := u.Locks.IsLocked(r.Context(), host.ID)
if locked {
http.Redirect(w, r, fmt.Sprintf("/hosts/%d", id), http.StatusSeeOther)
return
}
opID, _ := u.Ops.Create(r.Context(), model.Operation{
HostID: host.ID,
Kind: model.OpRebuildProxmox,
})
_ = u.Locks.Acquire(r.Context(), host.ID, opID)
u.Runner.LogActivity(r.Context(), host.ID, model.LogInfo, "ui", "Rebuild triggered by user")
if err := u.Orchestrator.PrepareRebuild(r.Context(), host.ID); err != nil {
_ = u.Locks.Release(r.Context(), host.ID)
http.Error(w, "Failed to prepare rebuild: "+err.Error(), http.StatusInternalServerError)
return
}
u.Runner.Transition(r.Context(), host.ID, statemachine.TriggerRebuildRequested)
hosts, _ := u.Hosts.List(r.Context())
_ = u.PXE.Reload(hosts)
http.Redirect(w, r, fmt.Sprintf("/hosts/%d", id), http.StatusSeeOther)
}
func (u *UI) DeleteHost(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
var id int64
fmt.Sscanf(idStr, "%d", &id)
_ = u.Hosts.Delete(r.Context(), id)
hosts, _ := u.Hosts.List(r.Context())
_ = u.PXE.Reload(hosts)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (u *UI) ImagesPage(w http.ResponseWriter, r *http.Request) {
images, _ := u.Images.List(r.Context())
renderHTML(w, imagesPage(images))
}
func (u *UI) NewImageForm(w http.ResponseWriter, r *http.Request) {
renderHTML(w, imageUploadForm(""))
}
func (u *UI) UploadImage(w http.ResponseWriter, r *http.Request) {
isXHR := r.Header.Get("X-Requested-With") == "XMLHttpRequest"
if err := r.ParseMultipartForm(0); err != nil {
if isXHR {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "Invalid form submission"})
} else {
renderHTML(w, imageUploadForm("Invalid form submission"))
}
return
}
name := strings.TrimSpace(r.FormValue("name"))
version := strings.TrimSpace(r.FormValue("version"))
kind := strings.TrimSpace(r.FormValue("kind"))
uploadID := strings.TrimSpace(r.FormValue("upload_id"))
file, _, err := r.FormFile("iso")
if err != nil {
if isXHR {
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "ISO file is required"})
} else {
renderHTML(w, imageUploadForm("ISO file is required"))
}
return
}
defer file.Close()
var progressFn image.ProgressFunc
if uploadID != "" {
progressFn = func(stage, detail string) {
u.Hub.Publish(events.Event{
Name: "image.upload_progress",
Payload: fmt.Sprintf(`{"upload_id":%q,"stage":%q,"detail":%q}`, uploadID, stage, detail),
})
}
}
_, err = u.ImageSvc.Upload(r.Context(), image.UploadParams{
Name: name,
Kind: kind,
Version: version,
ISO: file,
OnProgress: progressFn,
})
if err != nil {
if isXHR {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
} else {
renderHTML(w, imageUploadForm(err.Error()))
}
return
}
if isXHR {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
} else {
http.Redirect(w, r, "/images", http.StatusSeeOther)
}
}
func (u *UI) SetDefaultImage(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
var id int64
fmt.Sscanf(idStr, "%d", &id)
_ = u.Images.SetDefault(r.Context(), id)
http.Redirect(w, r, "/images", http.StatusSeeOther)
}
func (u *UI) DeleteImage(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
var id int64
fmt.Sscanf(idStr, "%d", &id)
_ = u.ImageSvc.Delete(r.Context(), id)
http.Redirect(w, r, "/images", http.StatusSeeOther)
}
var macRegex = regexp.MustCompile(`^([0-9a-fA-F]{2}[:\-]){5}[0-9a-fA-F]{2}$`)
func isValidMAC(mac string) bool {
return macRegex.MatchString(strings.TrimSpace(mac))
}