package api import ( "errors" "fmt" "net/http" "regexp" "strings" "provisioning/internal/config" "provisioning/internal/events" "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 Runner *orchestrator.Runner 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) 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)) } 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)) }