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>
295 lines
7.9 KiB
Go
295 lines
7.9 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) OperationDetail(w http.ResponseWriter, r *http.Request) {
|
|
idStr := chi.URLParam(r, "id")
|
|
var id int64
|
|
fmt.Sscanf(idStr, "%d", &id)
|
|
|
|
op, err := u.Ops.Get(r.Context(), id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
http.Error(w, "Operation not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
host, _ := u.Hosts.Get(r.Context(), op.HostID)
|
|
activity, _ := u.Activity.ListByOperation(r.Context(), op.ID, 100)
|
|
renderHTML(w, operationDetailPage(op, host, activity))
|
|
}
|
|
|
|
func (u *UI) CancelOperation(w http.ResponseWriter, r *http.Request) {
|
|
idStr := chi.URLParam(r, "id")
|
|
var id int64
|
|
fmt.Sscanf(idStr, "%d", &id)
|
|
|
|
op, err := u.Ops.Get(r.Context(), id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
http.Error(w, "Operation not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if op.State != model.OpActive {
|
|
http.Redirect(w, r, fmt.Sprintf("/ops/%d", id), http.StatusSeeOther)
|
|
return
|
|
}
|
|
u.Runner.FailHost(r.Context(), op.HostID, "cancelled by user")
|
|
http.Redirect(w, r, fmt.Sprintf("/ops/%d", id), http.StatusSeeOther)
|
|
}
|
|
|
|
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))
|
|
}
|