feat: expanded health checks, deploy filtering, per-project notifications, error sanitization, and audit trail

- Expand health endpoint to check DB, Docker, and NPM connectivity (FUNC-M4)
- Add project_id, stage_id, offset query params to deploys endpoint (FUNC-M5, FUNC-M6)
- Add notification_url field to Stage model for per-project overrides (FUNC-M2)
- Add NPM Ping method for health checking
- Sanitize all internal error messages in API handlers (SEC-M4)
- Add audit trail events for admin actions (FUNC-M3)
- Add EventLog event type to event bus
This commit is contained in:
2026-04-04 13:10:10 +03:00
parent 04c1411f5d
commit 91b49cb5ed
14 changed files with 280 additions and 170 deletions
+3 -1
View File
@@ -1,6 +1,7 @@
package api
import (
"log/slog"
"net/http"
"github.com/alexei/docker-watcher/internal/config"
@@ -10,7 +11,8 @@ import (
func (s *Server) exportConfig(w http.ResponseWriter, r *http.Request) {
data, err := config.ExportConfig(s.store)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to export config: "+err.Error())
slog.Error("failed to export config", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
+22 -6
View File
@@ -20,9 +20,21 @@ func (s *Server) listDeploys(w http.ResponseWriter, r *http.Request) {
}
}
deploys, err := s.store.GetRecentDeploys(limit)
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 {
respondError(w, http.StatusInternalServerError, "failed to list deploys: "+err.Error())
slog.Error("failed to list deploys", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, deploys)
@@ -68,7 +80,8 @@ func (s *Server) inspectImage(w http.ResponseWriter, r *http.Request) {
info, err := s.docker.InspectImage(ctx, req.Image)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to inspect image: "+err.Error())
slog.Error("failed to inspect image", "image", req.Image, "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -121,7 +134,8 @@ func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) {
Volumes: "{}",
})
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create project: "+err.Error())
slog.Error("failed to create project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -134,14 +148,16 @@ func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) {
MaxInstances: 1,
})
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create stage: "+err.Error())
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 {
respondError(w, http.StatusInternalServerError, "failed to trigger deploy: "+err.Error())
slog.Error("failed to trigger deploy", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
+30 -35
View File
@@ -4,53 +4,48 @@ import (
"context"
"net/http"
"time"
"github.com/alexei/docker-watcher/internal/docker"
)
// getHealth handles GET /api/health.
// Returns connectivity status for Docker with diagnostic hints on failure.
func (s *Server) getHealth(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
now := time.Now().UTC().Format(time.RFC3339)
result := map[string]any{
"checked_at": now,
}
// Check database connectivity.
if err := s.store.DB().PingContext(ctx); err != nil {
result["database"] = map[string]any{"connected": false, "error": "database unreachable"}
} else {
result["database"] = map[string]any{"connected": true}
}
// Check Docker connectivity.
if s.docker == nil {
diag := docker.Diagnose(nil, "")
respondJSON(w, http.StatusOK, map[string]any{
"docker": map[string]any{
"connected": false,
"error": "docker client not initialized",
"category": diag.Category,
"hints": diag.Hints,
"platform": diag.Platform,
"checked_at": now,
},
})
return
result["docker"] = map[string]any{
"connected": false,
"error": "docker client not initialized",
}
} else if err := s.docker.Ping(ctx); err != nil {
result["docker"] = map[string]any{
"connected": false,
"error": err.Error(),
}
} else {
result["docker"] = map[string]any{"connected": true}
}
err := s.docker.Ping(ctx)
if err == nil {
respondJSON(w, http.StatusOK, map[string]any{
"docker": map[string]any{
"connected": true,
"checked_at": now,
},
})
return
// Check NPM connectivity if configured.
if s.npm != nil {
if err := s.npm.Ping(ctx); err != nil {
result["npm"] = map[string]any{"connected": false, "error": "NPM unreachable"}
} else {
result["npm"] = map[string]any{"connected": true}
}
}
diag := docker.Diagnose(err, "")
respondJSON(w, http.StatusOK, map[string]any{
"docker": map[string]any{
"connected": false,
"error": err.Error(),
"category": diag.Category,
"hints": diag.Hints,
"platform": diag.Platform,
"checked_at": now,
},
})
respondJSON(w, http.StatusOK, result)
}
+16 -8
View File
@@ -23,13 +23,15 @@ func (s *Server) listInstances(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "stage")
return
}
respondError(w, http.StatusInternalServerError, "failed to get stage: "+err.Error())
slog.Error("failed to get stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
instances, err := s.store.GetInstancesByStageID(stageID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list instances: "+err.Error())
slog.Error("failed to list instances", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, instances)
@@ -51,7 +53,8 @@ func (s *Server) deployInstance(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error())
slog.Error("failed to get project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -61,7 +64,8 @@ func (s *Server) deployInstance(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "stage")
return
}
respondError(w, http.StatusInternalServerError, "failed to get stage: "+err.Error())
slog.Error("failed to get stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -77,7 +81,8 @@ func (s *Server) deployInstance(w http.ResponseWriter, r *http.Request) {
deployID, err := s.deployer.AsyncTriggerDeploy(r.Context(), projectID, stageID, req.ImageTag)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to trigger deploy: "+err.Error())
slog.Error("failed to trigger deploy", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusAccepted, map[string]string{
@@ -99,7 +104,8 @@ func (s *Server) removeInstance(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "instance")
return
}
respondError(w, http.StatusInternalServerError, "failed to get instance: "+err.Error())
slog.Error("failed to get instance", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -158,7 +164,8 @@ func (s *Server) controlInstance(w http.ResponseWriter, r *http.Request, action
respondNotFound(w, "instance")
return
}
respondError(w, http.StatusInternalServerError, "failed to get instance: "+err.Error())
slog.Error("failed to get instance", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -187,7 +194,8 @@ func (s *Server) controlInstance(w http.ResponseWriter, r *http.Request, action
}
if controlErr != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to %s instance: %v", action, controlErr))
slog.Error("failed to control instance", "action", action, "instance_id", instanceID, "error", controlErr)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
+45 -7
View File
@@ -2,10 +2,12 @@ package api
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/events"
"github.com/alexei/docker-watcher/internal/store"
)
@@ -24,7 +26,8 @@ type projectRequest struct {
func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) {
projects, err := s.store.GetAllProjects()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list projects: "+err.Error())
slog.Error("failed to list projects", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, projects)
@@ -62,9 +65,20 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
Volumes: req.Volumes,
})
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create project: "+err.Error())
slog.Error("failed to create project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
s.eventBus.Publish(events.Event{
Type: events.EventLog,
Payload: events.EventLogPayload{
Source: "admin",
Severity: "info",
Message: "project created: " + project.Name,
},
})
respondJSON(w, http.StatusCreated, project)
}
@@ -77,14 +91,16 @@ func (s *Server) getProject(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error())
slog.Error("failed to get project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Also fetch stages for this project.
stages, err := s.store.GetStagesByProjectID(id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get stages: "+err.Error())
slog.Error("failed to get stages", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -104,7 +120,8 @@ func (s *Server) updateProject(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error())
slog.Error("failed to get project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -132,9 +149,20 @@ func (s *Server) updateProject(w http.ResponseWriter, r *http.Request) {
}
if err := s.store.UpdateProject(updated); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update project: "+err.Error())
slog.Error("failed to update project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
s.eventBus.Publish(events.Event{
Type: events.EventLog,
Payload: events.EventLogPayload{
Source: "admin",
Severity: "info",
Message: "project updated: " + updated.Name,
},
})
respondJSON(w, http.StatusOK, updated)
}
@@ -146,8 +174,18 @@ func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to delete project: "+err.Error())
slog.Error("failed to delete project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
s.eventBus.Publish(events.Event{
Type: events.EventLog,
Payload: events.EventLogPayload{
Source: "admin",
Severity: "info",
Message: "project deleted: " + id,
},
})
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
+20 -10
View File
@@ -26,7 +26,8 @@ type registryRequest struct {
func (s *Server) listRegistries(w http.ResponseWriter, r *http.Request) {
registries, err := s.store.GetAllRegistries()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list registries: "+err.Error())
slog.Error("failed to list registries", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -80,7 +81,8 @@ func (s *Server) createRegistry(w http.ResponseWriter, r *http.Request) {
// Encrypt the token if provided.
encToken, err := crypto.EncryptIfNotEmpty(s.encKey, req.Token)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt token: "+err.Error())
slog.Error("failed to encrypt token", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -92,7 +94,8 @@ func (s *Server) createRegistry(w http.ResponseWriter, r *http.Request) {
Owner: req.Owner,
})
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create registry: "+err.Error())
slog.Error("failed to create registry", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -112,7 +115,8 @@ func (s *Server) updateRegistry(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "registry")
return
}
respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.Error())
slog.Error("failed to get registry", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -138,14 +142,16 @@ func (s *Server) updateRegistry(w http.ResponseWriter, r *http.Request) {
if req.Token != "" {
encToken, err := crypto.EncryptIfNotEmpty(s.encKey, req.Token)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt token: "+err.Error())
slog.Error("failed to encrypt token", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
updated.Token = encToken
}
if err := s.store.UpdateRegistry(updated); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update registry: "+err.Error())
slog.Error("failed to update registry", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, map[string]string{
@@ -162,7 +168,8 @@ func (s *Server) deleteRegistry(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "registry")
return
}
respondError(w, http.StatusInternalServerError, "failed to delete registry: "+err.Error())
slog.Error("failed to delete registry", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
@@ -184,7 +191,8 @@ func (s *Server) testRegistry(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "registry")
return
}
respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.Error())
slog.Error("failed to get registry", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -244,7 +252,8 @@ func (s *Server) listRegistryTags(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "registry")
return
}
respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.Error())
slog.Error("failed to get registry", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -285,7 +294,8 @@ func (s *Server) listRegistryImages(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "registry")
return
}
respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.Error())
slog.Error("failed to get registry", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
+4 -2
View File
@@ -35,7 +35,8 @@ func (s *Server) streamDeployLogs(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "deploy")
return
}
respondError(w, http.StatusInternalServerError, "failed to get deploy: "+err.Error())
slog.Error("failed to get deploy", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -44,7 +45,8 @@ func (s *Server) streamDeployLogs(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(accept, "text/event-stream") {
logs, err := s.store.GetDeployLogs(deployID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get deploy logs: "+err.Error())
slog.Error("failed to get deploy logs", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, logs)
+19 -9
View File
@@ -2,6 +2,7 @@ package api
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
@@ -27,13 +28,15 @@ func (s *Server) listStageEnv(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "stage")
return
}
respondError(w, http.StatusInternalServerError, "failed to get stage: "+err.Error())
slog.Error("failed to get stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
envs, err := s.store.GetStageEnvByStageID(stageID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list stage env: "+err.Error())
slog.Error("failed to list stage env", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -59,7 +62,8 @@ func (s *Server) createStageEnv(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "stage")
return
}
respondError(w, http.StatusInternalServerError, "failed to get stage: "+err.Error())
slog.Error("failed to get stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -82,7 +86,8 @@ func (s *Server) createStageEnv(w http.ResponseWriter, r *http.Request) {
if encrypted && value != "" {
enc, err := crypto.Encrypt(s.encKey, value)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt value: "+err.Error())
slog.Error("failed to encrypt value", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
value = enc
@@ -95,7 +100,8 @@ func (s *Server) createStageEnv(w http.ResponseWriter, r *http.Request) {
Encrypted: encrypted,
})
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create stage env: "+err.Error())
slog.Error("failed to create stage env", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -117,7 +123,8 @@ func (s *Server) updateStageEnv(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "stage env")
return
}
respondError(w, http.StatusInternalServerError, "failed to get stage env: "+err.Error())
slog.Error("failed to get stage env", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -140,7 +147,8 @@ func (s *Server) updateStageEnv(w http.ResponseWriter, r *http.Request) {
if updated.Encrypted {
enc, err := crypto.Encrypt(s.encKey, value)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt value: "+err.Error())
slog.Error("failed to encrypt value", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
value = enc
@@ -149,7 +157,8 @@ func (s *Server) updateStageEnv(w http.ResponseWriter, r *http.Request) {
}
if err := s.store.UpdateStageEnv(updated); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update stage env: "+err.Error())
slog.Error("failed to update stage env", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -169,7 +178,8 @@ func (s *Server) deleteStageEnv(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "stage env")
return
}
respondError(w, http.StatusInternalServerError, "failed to delete stage env: "+err.Error())
slog.Error("failed to delete stage env", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": envID})
+57 -20
View File
@@ -2,22 +2,25 @@ package api
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/events"
"github.com/alexei/docker-watcher/internal/store"
)
// stageRequest is the expected JSON body for creating/updating a stage.
type stageRequest struct {
Name string `json:"name"`
TagPattern string `json:"tag_pattern"`
AutoDeploy *bool `json:"auto_deploy"`
MaxInstances *int `json:"max_instances"`
Confirm *bool `json:"confirm"`
PromoteFrom string `json:"promote_from"`
Subdomain string `json:"subdomain"`
Name string `json:"name"`
TagPattern string `json:"tag_pattern"`
AutoDeploy *bool `json:"auto_deploy"`
MaxInstances *int `json:"max_instances"`
Confirm *bool `json:"confirm"`
PromoteFrom string `json:"promote_from"`
Subdomain string `json:"subdomain"`
NotificationURL string `json:"notification_url"`
}
// createStage handles POST /api/projects/{id}/stages.
@@ -30,7 +33,8 @@ func (s *Server) createStage(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error())
slog.Error("failed to get project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -61,19 +65,30 @@ func (s *Server) createStage(w http.ResponseWriter, r *http.Request) {
}
stage, err := s.store.CreateStage(store.Stage{
ProjectID: projectID,
Name: req.Name,
TagPattern: req.TagPattern,
AutoDeploy: autoDeploy,
MaxInstances: maxInstances,
Confirm: confirm,
PromoteFrom: req.PromoteFrom,
Subdomain: req.Subdomain,
ProjectID: projectID,
Name: req.Name,
TagPattern: req.TagPattern,
AutoDeploy: autoDeploy,
MaxInstances: maxInstances,
Confirm: confirm,
PromoteFrom: req.PromoteFrom,
Subdomain: req.Subdomain,
NotificationURL: req.NotificationURL,
})
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create stage: "+err.Error())
slog.Error("failed to create stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
s.eventBus.Publish(events.Event{
Type: events.EventLog,
Payload: events.EventLogPayload{
Source: "admin",
Severity: "info",
Message: "stage created: " + stage.Name,
},
})
respondJSON(w, http.StatusCreated, stage)
}
@@ -87,7 +102,8 @@ func (s *Server) updateStage(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "stage")
return
}
respondError(w, http.StatusInternalServerError, "failed to get stage: "+err.Error())
slog.Error("failed to get stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -114,11 +130,22 @@ func (s *Server) updateStage(w http.ResponseWriter, r *http.Request) {
}
updated.PromoteFrom = req.PromoteFrom
updated.Subdomain = req.Subdomain
updated.NotificationURL = req.NotificationURL
if err := s.store.UpdateStage(updated); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update stage: "+err.Error())
slog.Error("failed to update stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
s.eventBus.Publish(events.Event{
Type: events.EventLog,
Payload: events.EventLogPayload{
Source: "admin",
Severity: "info",
Message: "stage updated: " + updated.Name,
},
})
respondJSON(w, http.StatusOK, updated)
}
@@ -130,8 +157,18 @@ func (s *Server) deleteStage(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "stage")
return
}
respondError(w, http.StatusInternalServerError, "failed to delete stage: "+err.Error())
slog.Error("failed to delete stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
s.eventBus.Publish(events.Event{
Type: events.EventLog,
Payload: events.EventLogPayload{
Source: "admin",
Severity: "info",
Message: "stage deleted: " + stageID,
},
})
respondJSON(w, http.StatusOK, map[string]string{"deleted": stageID})
}