// Package handlers provides HTTP handlers for the rdev API. package handlers import ( "errors" "net/http" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/auth" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/internal/service" "github.com/orchard9/rdev/pkg/api" ) // WorkersHandler handles worker pool management endpoints. type WorkersHandler struct { workerService *service.WorkerService } // NewWorkersHandler creates a new workers handler. func NewWorkersHandler(workerService *service.WorkerService) *WorkersHandler { return &WorkersHandler{ workerService: workerService, } } // Mount registers the worker pool routes. func (h *WorkersHandler) Mount(r api.Router) { r.Route("/workers", func(r chi.Router) { r.With(auth.RequireScope(auth.ScopeWorkersRead, auth.ScopeAdmin)).Get("/", h.List) r.With(auth.RequireScope(auth.ScopeWorkersRead, auth.ScopeAdmin)).Get("/{workerId}", h.Get) r.With(auth.RequireScope(auth.ScopeWorkersWrite, auth.ScopeAdmin)).Post("/{workerId}/drain", h.Drain) }) } // WorkerDTO is the data transfer object for workers. type WorkerDTO struct { ID string `json:"id"` Hostname string `json:"hostname"` Status string `json:"status"` CurrentTask string `json:"current_task,omitempty"` Capabilities []string `json:"capabilities,omitempty"` RegisteredAt string `json:"registered_at"` LastHeartbeat string `json:"last_heartbeat"` Version string `json:"version,omitempty"` } func toWorkerDTO(w *domain.Worker) *WorkerDTO { if w == nil { return nil } return &WorkerDTO{ ID: w.ID, Hostname: w.Hostname, Status: string(w.Status), CurrentTask: w.CurrentTask, Capabilities: w.Capabilities, RegisteredAt: w.RegisteredAt.Format("2006-01-02T15:04:05Z07:00"), LastHeartbeat: w.LastHeartbeat.Format("2006-01-02T15:04:05Z07:00"), Version: w.Version, } } // List returns all workers with optional status filter. // GET /workers?status=idle func (h *WorkersHandler) List(w http.ResponseWriter, r *http.Request) { filter := port.WorkerFilter{} if s := r.URL.Query().Get("status"); s != "" { st := domain.WorkerStatus(s) if !st.IsValid() { api.WriteBadRequest(w, r, "invalid status: must be idle, busy, draining, or offline") return } filter.Status = &st } workers, err := h.workerService.ListWorkers(r.Context(), filter) if err != nil { api.WriteInternalError(w, r, "failed to list workers") return } dtos := make([]*WorkerDTO, len(workers)) for i, wkr := range workers { dtos[i] = toWorkerDTO(wkr) } // Compute summary counts idle, busy, draining, offline := 0, 0, 0, 0 for _, wkr := range workers { switch wkr.Status { case domain.WorkerStatusIdle: idle++ case domain.WorkerStatusBusy: busy++ case domain.WorkerStatusDraining: draining++ case domain.WorkerStatusOffline: offline++ } } api.WriteSuccess(w, r, map[string]any{ "workers": dtos, "total": len(dtos), "summary": map[string]int{ "idle": idle, "busy": busy, "draining": draining, "offline": offline, }, }) } // Get returns a specific worker by ID. // GET /workers/{workerId} func (h *WorkersHandler) Get(w http.ResponseWriter, r *http.Request) { workerID := chi.URLParam(r, "workerId") if workerID == "" { api.WriteBadRequest(w, r, "worker ID is required") return } worker, err := h.workerService.GetWorker(r.Context(), workerID) if err != nil { if errors.Is(err, domain.ErrWorkerNotFound) { api.WriteNotFound(w, r, "worker not found: "+workerID) return } api.WriteInternalError(w, r, "failed to get worker") return } api.WriteSuccess(w, r, toWorkerDTO(worker)) } // Drain sets a worker to draining status. // POST /workers/{workerId}/drain func (h *WorkersHandler) Drain(w http.ResponseWriter, r *http.Request) { workerID := chi.URLParam(r, "workerId") if workerID == "" { api.WriteBadRequest(w, r, "worker ID is required") return } if err := h.workerService.DrainWorker(r.Context(), workerID); err != nil { if errors.Is(err, domain.ErrWorkerNotFound) { api.WriteNotFound(w, r, "worker not found: "+workerID) return } api.WriteInternalError(w, r, "failed to drain worker") return } api.WriteSuccess(w, r, map[string]any{ "worker_id": workerID, "status": "draining", "message": "worker will finish current task then stop accepting new work", }) }