package api import ( "log/slog" "net/http" "strconv" "strings" "github.com/alexei/docker-watcher/internal/docker" "github.com/alexei/docker-watcher/internal/store" ) // listDeploys handles GET /api/deploys. func (s *Server) listDeploys(w http.ResponseWriter, r *http.Request) { limitStr := r.URL.Query().Get("limit") limit := 50 if limitStr != "" { if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 { limit = parsed } } offsetStr := r.URL.Query().Get("offset") offset := 0 if offsetStr != "" { if parsed, err := strconv.Atoi(offsetStr); err == nil && parsed >= 0 { offset = parsed } } projectID := r.URL.Query().Get("project_id") stageID := r.URL.Query().Get("stage_id") deploys, err := s.store.GetDeploys(projectID, stageID, limit, offset) if err != nil { slog.Error("failed to list deploys", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } respondJSON(w, http.StatusOK, deploys) } // NOTE: getDeployLogs has been replaced by streamDeployLogs in sse.go. // The new handler supports both SSE streaming and JSON fallback via Accept header. // inspectRequest is the expected JSON body for POST /api/deploy/inspect. type inspectRequest struct { Image string `json:"image"` } // inspectResponse is the response body for POST /api/deploy/inspect. type inspectResponse struct { Image string `json:"image"` Port int `json:"port"` Healthcheck string `json:"healthcheck"` } // inspectImage handles POST /api/deploy/inspect. // Pulls the image and inspects it for EXPOSE ports and healthcheck config. func (s *Server) inspectImage(w http.ResponseWriter, r *http.Request) { var req inspectRequest if !decodeJSON(w, r, &req) { return } if req.Image == "" { respondError(w, http.StatusBadRequest, "image is required") return } ctx := r.Context() // Pull the image first so it's available locally for inspection. // Split image:tag for the pull call. imageRef, tag := splitImageTag(req.Image) if err := s.docker.PullImage(ctx, imageRef, tag, ""); err != nil { slog.Warn("pull image for inspect", "image", req.Image, "error", err) // Try to inspect anyway in case the image is already local. } info, err := s.docker.InspectImage(ctx, req.Image) if err != nil { slog.Error("failed to inspect image", "image", req.Image, "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } port := docker.ExtractPort(info.ExposedPorts) respondJSON(w, http.StatusOK, inspectResponse{ Image: req.Image, Port: port, Healthcheck: info.Healthcheck, }) } // quickDeployRequest is the expected JSON body for POST /api/deploy/quick. type quickDeployRequest struct { Name string `json:"name"` Image string `json:"image"` Tag string `json:"tag"` Registry string `json:"registry"` Port int `json:"port"` } // quickDeploy handles POST /api/deploy/quick. // Creates a project, a default stage, and triggers a deploy in one call. func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) { var req quickDeployRequest if !decodeJSON(w, r, &req) { return } if req.Image == "" { respondError(w, http.StatusBadRequest, "image is required") return } if req.Tag == "" { req.Tag = "latest" } if req.Name == "" { // Derive name from image. parts := strings.Split(req.Image, "/") req.Name = parts[len(parts)-1] } // Create project. project, err := s.store.CreateProject(store.Project{ Name: req.Name, Image: req.Image, Registry: req.Registry, Port: req.Port, Env: "{}", Volumes: "{}", }) if err != nil { slog.Error("failed to create project", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } // Create default stage. stage, err := s.store.CreateStage(store.Stage{ ProjectID: project.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1, }) if err != nil { slog.Error("failed to create stage", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } // Trigger deploy asynchronously. deployID, err := s.deployer.AsyncTriggerDeploy(r.Context(), project.ID, stage.ID, req.Tag) if err != nil { slog.Error("failed to trigger deploy", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } respondJSON(w, http.StatusAccepted, map[string]any{ "project": project, "stage": stage, "tag": req.Tag, "deploy_id": deployID, "status": "deploying", }) } // splitImageTag splits "image:tag" into image and tag parts. // Returns the full string and empty tag if no colon separator is found. func splitImageTag(ref string) (string, string) { if idx := strings.LastIndex(ref, ":"); idx != -1 { afterColon := ref[idx+1:] if !strings.Contains(afterColon, "/") { return ref[:idx], afterColon } } return ref, "" }