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>
251 lines
6.5 KiB
Go
251 lines
6.5 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
|
|
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)
|
|
renderHTML(w, hostDetailPage(host, ops))
|
|
}
|
|
|
|
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)
|
|
|
|
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))
|
|
}
|