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 } if existing, err := u.Hosts.GetByHostname(r.Context(), hostname); err == nil && existing != nil { errs = append(errs, "A host with this hostname already exists") } if existing, err := u.Hosts.GetByMAC(r.Context(), mac); err == nil && existing != nil { errs = append(errs, "A host with this MAC address already exists") } 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)) }