Files
tiny-forge/internal/api/containers.go
T
alexei.dolgolyov 0acbcda084 feat(workload): /api/workloads /api/containers /api/apps endpoints
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.
2026-05-09 13:52:31 +03:00

92 lines
2.4 KiB
Go

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)
}