From 0acbcda08456b96e7832368e52605577ad31af8d Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 9 May 2026 13:52:31 +0300 Subject: [PATCH] feat(workload): /api/workloads /api/containers /api/apps endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the read API surface that the global Containers view (and the per-workload container panel on project/stack/site detail pages) consume. - GET /api/workloads (?kind=) → workload list - GET /api/workloads/{id} → single workload - GET /api/workloads/{id}/containers → workload's containers - PATCH /api/workloads/{id}/app → assign/clear app_id (admin) - GET /api/containers (?workload_id=&kind=&state=&app_id=) → global index, decorated with workload + app name so the table renders without N+1 fetches - GET /api/containers/{id} → single container row - GET /api/apps → list - GET /api/apps/{id} → single - POST /api/apps → create (admin) - PUT /api/apps/{id} → update (admin) - DELETE /api/apps/{id} → delete (admin) — clears app_id on owning workloads but leaves them assigned-to-none Mutations on projects/stacks/sites still go through the existing kind-specific endpoints; the new surface is read-only at the workload layer. --- internal/api/apps.go | 105 +++++++++++++++++++++++++++++++++++++ internal/api/containers.go | 91 ++++++++++++++++++++++++++++++++ internal/api/router.go | 24 +++++++++ internal/api/workloads.go | 80 ++++++++++++++++++++++++++++ 4 files changed, 300 insertions(+) create mode 100644 internal/api/apps.go create mode 100644 internal/api/containers.go create mode 100644 internal/api/workloads.go diff --git a/internal/api/apps.go b/internal/api/apps.go new file mode 100644 index 0000000..d01f723 --- /dev/null +++ b/internal/api/apps.go @@ -0,0 +1,105 @@ +package api + +import ( + "errors" + "net/http" + "strings" + + "github.com/alexei/tinyforge/internal/store" + "github.com/go-chi/chi/v5" +) + +// listApps handles GET /api/apps. +func (s *Server) listApps(w http.ResponseWriter, r *http.Request) { + out, err := s.store.ListApps() + if err != nil { + respondError(w, http.StatusInternalServerError, "list apps") + return + } + respondJSON(w, http.StatusOK, out) +} + +// getApp handles GET /api/apps/{id}. +func (s *Server) getApp(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + a, err := s.store.GetAppByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "app") + return + } + respondError(w, http.StatusInternalServerError, "get app") + return + } + respondJSON(w, http.StatusOK, a) +} + +// createApp handles POST /api/apps. Body: {"name": "...", "description": "..."}. +func (s *Server) createApp(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Description string `json:"description"` + } + if !decodeJSON(w, r, &req) { + return + } + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + respondError(w, http.StatusBadRequest, "name is required") + return + } + + a, err := s.store.CreateApp(store.App{ + Name: req.Name, Description: req.Description, + }) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusCreated, a) +} + +// updateApp handles PUT /api/apps/{id}. +func (s *Server) updateApp(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + var req struct { + Name string `json:"name"` + Description string `json:"description"` + } + if !decodeJSON(w, r, &req) { + return + } + a, err := s.store.GetAppByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "app") + return + } + respondError(w, http.StatusInternalServerError, "get app") + return + } + if name := strings.TrimSpace(req.Name); name != "" { + a.Name = name + } + a.Description = req.Description + if err := s.store.UpdateApp(a); err != nil { + respondError(w, http.StatusInternalServerError, "update app") + return + } + respondJSON(w, http.StatusOK, a) +} + +// deleteApp handles DELETE /api/apps/{id}. Workloads previously assigned to +// this app become unassigned (app_id cleared), they are NOT deleted. +func (s *Server) deleteApp(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if err := s.store.DeleteApp(id); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "app") + return + } + respondError(w, http.StatusInternalServerError, "delete app") + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/containers.go b/internal/api/containers.go new file mode 100644 index 0000000..cb82432 --- /dev/null +++ b/internal/api/containers.go @@ -0,0 +1,91 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/alexei/tinyforge/internal/store" + "github.com/go-chi/chi/v5" +) + +// containerView decorates a stored Container row with the human-readable +// names the global Containers table needs (workload name, app name). +// Decorating server-side avoids N+1 fetches on the frontend. +type containerView struct { + store.Container + WorkloadName string `json:"workload_name"` + AppID string `json:"app_id,omitempty"` + AppName string `json:"app_name,omitempty"` +} + +// listAllContainers handles GET /api/containers. +// Query params: workload_id, kind, state, app_id (all optional, AND-combined). +// Returns the global container index, newest first, decorated with workload +// and app names. +func (s *Server) listAllContainers(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + filter := store.ContainerFilter{ + WorkloadID: q.Get("workload_id"), + WorkloadKind: q.Get("kind"), + State: q.Get("state"), + AppID: q.Get("app_id"), + } + + rows, err := s.store.ListContainers(filter) + if err != nil { + respondError(w, http.StatusInternalServerError, "list containers") + return + } + + // Pre-load workloads + apps so the join is in-memory rather than per-row. + workloads, err := s.store.ListWorkloads("") + if err != nil { + respondError(w, http.StatusInternalServerError, "list workloads") + return + } + wlByID := make(map[string]store.Workload, len(workloads)) + for _, wl := range workloads { + wlByID[wl.ID] = wl + } + + apps, err := s.store.ListApps() + if err != nil { + respondError(w, http.StatusInternalServerError, "list apps") + return + } + appByID := make(map[string]store.App, len(apps)) + for _, a := range apps { + appByID[a.ID] = a + } + + out := make([]containerView, 0, len(rows)) + for _, c := range rows { + v := containerView{Container: c} + if wl, ok := wlByID[c.WorkloadID]; ok { + v.WorkloadName = wl.Name + if wl.AppID != "" { + v.AppID = wl.AppID + if app, ok := appByID[wl.AppID]; ok { + v.AppName = app.Name + } + } + } + out = append(out, v) + } + respondJSON(w, http.StatusOK, out) +} + +// getContainer handles GET /api/containers/{id}. +func (s *Server) getContainer(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + c, err := s.store.GetContainerByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "container") + return + } + respondError(w, http.StatusInternalServerError, "get container") + return + } + respondJSON(w, http.StatusOK, c) +} diff --git a/internal/api/router.go b/internal/api/router.go index b580164..263ebdf 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -374,6 +374,30 @@ func (s *Server) Router() chi.Router { // Stale container endpoints (read). r.Get("/containers/stale", s.listStaleContainers) + // Workload-shaped endpoints (the unifying layer over project / + // stack / site). Read-only; mutations still go through the + // kind-specific endpoints (POST /projects, PUT /stacks/{id}, …). + r.Get("/workloads", s.listWorkloads) + r.Route("/workloads/{id}", func(r chi.Router) { + r.Get("/", s.getWorkload) + r.Get("/containers", s.listWorkloadContainers) + r.With(auth.AdminOnly).Patch("/app", s.updateWorkloadAppID) + }) + + // Global container index, joined to workload + app names. + r.Get("/containers", s.listAllContainers) + r.Get("/containers/{id}", s.getContainer) + + // App grouping (optional UI; admin-gated mutations). + r.Get("/apps", s.listApps) + r.Get("/apps/{id}", s.getApp) + r.Group(func(r chi.Router) { + r.Use(auth.AdminOnly) + r.Post("/apps", s.createApp) + r.Put("/apps/{id}", s.updateApp) + r.Delete("/apps/{id}", s.deleteApp) + }) + // System resources (read-only). r.Get("/system/stats", s.getSystemStats) r.Get("/system/stats/history", s.getSystemStatsHistory) diff --git a/internal/api/workloads.go b/internal/api/workloads.go new file mode 100644 index 0000000..eb41a0b --- /dev/null +++ b/internal/api/workloads.go @@ -0,0 +1,80 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/alexei/tinyforge/internal/store" + "github.com/go-chi/chi/v5" +) + +// listWorkloads handles GET /api/workloads. Optional ?kind=project|stack|site +// filter narrows the result. The shape mirrors the projects/stacks/sites +// listing endpoints — clients use this to render the global Workloads view. +func (s *Server) listWorkloads(w http.ResponseWriter, r *http.Request) { + kind := store.WorkloadKind(r.URL.Query().Get("kind")) + out, err := s.store.ListWorkloads(kind) + if err != nil { + respondError(w, http.StatusInternalServerError, "list workloads") + return + } + respondJSON(w, http.StatusOK, out) +} + +// getWorkload handles GET /api/workloads/{id}. +func (s *Server) getWorkload(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + wl, err := s.store.GetWorkloadByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "workload") + return + } + respondError(w, http.StatusInternalServerError, "get workload") + return + } + respondJSON(w, http.StatusOK, wl) +} + +// listWorkloadContainers handles GET /api/workloads/{id}/containers. +// Returns every Container row owned by this workload, newest first. The +// frontend's component uses this on every kind-specific +// detail page (project, stack, site) so the table shape is uniform. +func (s *Server) listWorkloadContainers(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + out, err := s.store.ListContainersByWorkload(id) + if err != nil { + respondError(w, http.StatusInternalServerError, "list workload containers") + return + } + respondJSON(w, http.StatusOK, out) +} + +// updateWorkloadAppID handles PATCH /api/workloads/{id}/app. Body: {"app_id": "..."}. +// Empty string clears the app assignment. Used by the (optional) Apps UI. +func (s *Server) updateWorkloadAppID(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + var req struct { + AppID string `json:"app_id"` + } + if !decodeJSON(w, r, &req) { + return + } + + wl, err := s.store.GetWorkloadByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "workload") + return + } + respondError(w, http.StatusInternalServerError, "get workload") + return + } + wl.AppID = req.AppID + if err := s.store.UpdateWorkload(wl); err != nil { + respondError(w, http.StatusInternalServerError, "update workload") + return + } + respondJSON(w, http.StatusOK, wl) +}