410a131cec
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
+ {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
/apps/[id] edit form onto the same components (removes the duplication). Add
vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
label hints; dashboard + /apps "Total workloads" count only source_kind workloads
(drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.
Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
232 lines
7.0 KiB
Go
232 lines
7.0 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/alexei/tinyforge/internal/crypto"
|
|
"github.com/alexei/tinyforge/internal/store"
|
|
)
|
|
|
|
// workloadNotificationRow is the JSON shape returned to clients. The
|
|
// `secret_set` boolean replaces the actual ciphertext: once stored a
|
|
// secret is write-only, mirroring how workload_env hides encrypted
|
|
// values. Rotating means submitting a new value.
|
|
type workloadNotificationRow struct {
|
|
ID string `json:"id"`
|
|
WorkloadID string `json:"workload_id"`
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
SecretSet bool `json:"secret_set"`
|
|
EventTypes string `json:"event_types"`
|
|
Enabled bool `json:"enabled"`
|
|
SortOrder int `json:"sort_order"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
func toWorkloadNotificationRow(n store.WorkloadNotification) workloadNotificationRow {
|
|
return workloadNotificationRow{
|
|
ID: n.ID,
|
|
WorkloadID: n.WorkloadID,
|
|
Name: n.Name,
|
|
URL: n.URL,
|
|
SecretSet: n.Secret != "",
|
|
EventTypes: n.EventTypes,
|
|
Enabled: n.Enabled,
|
|
SortOrder: n.SortOrder,
|
|
CreatedAt: n.CreatedAt,
|
|
UpdatedAt: n.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func (s *Server) listWorkloadNotifications(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if _, err := s.store.GetWorkloadByID(id); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "workload")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "get workload")
|
|
return
|
|
}
|
|
rows, err := s.store.ListWorkloadNotifications(id)
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "list workload notifications")
|
|
return
|
|
}
|
|
out := make([]workloadNotificationRow, 0, len(rows))
|
|
for _, n := range rows {
|
|
out = append(out, toWorkloadNotificationRow(n))
|
|
}
|
|
respondJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// workloadNotificationRequest is the POST/PUT body. Secret is the raw
|
|
// plaintext webhook signing key; the server encrypts it at rest with
|
|
// the global encryption key before INSERT. An empty Secret on UPDATE
|
|
// leaves the stored secret untouched so the operator can edit the URL
|
|
// or event filter without re-entering the secret each time.
|
|
type workloadNotificationRequest struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
Secret string `json:"secret"`
|
|
EventTypes string `json:"event_types"`
|
|
Enabled *bool `json:"enabled"`
|
|
SortOrder int `json:"sort_order"`
|
|
}
|
|
|
|
func (s *Server) createWorkloadNotification(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if _, err := s.store.GetWorkloadByID(id); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "workload")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "get workload")
|
|
return
|
|
}
|
|
var req workloadNotificationRequest
|
|
if !decodeJSONStrict(w, r, &req) {
|
|
return
|
|
}
|
|
req.URL = strings.TrimSpace(req.URL)
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
if req.URL == "" {
|
|
respondError(w, http.StatusBadRequest, "url is required")
|
|
return
|
|
}
|
|
encSecret := ""
|
|
if req.Secret != "" {
|
|
v, err := crypto.Encrypt(s.encKey, req.Secret)
|
|
if err != nil {
|
|
slog.Error("workload notifications: encrypt secret", "workload", id, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "encrypt secret")
|
|
return
|
|
}
|
|
encSecret = v
|
|
}
|
|
enabled := true
|
|
if req.Enabled != nil {
|
|
enabled = *req.Enabled
|
|
}
|
|
created, err := s.store.CreateWorkloadNotification(store.WorkloadNotification{
|
|
WorkloadID: id,
|
|
Name: req.Name,
|
|
URL: req.URL,
|
|
Secret: encSecret,
|
|
EventTypes: req.EventTypes,
|
|
Enabled: enabled,
|
|
SortOrder: req.SortOrder,
|
|
})
|
|
if err != nil {
|
|
slog.Error("workload notifications: create", "workload", id, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "create workload notification")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusCreated, toWorkloadNotificationRow(created))
|
|
}
|
|
|
|
func (s *Server) updateWorkloadNotification(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
nid := chi.URLParam(r, "nid")
|
|
if _, err := s.store.GetWorkloadByID(id); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "workload")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "get workload")
|
|
return
|
|
}
|
|
existing, err := s.store.GetWorkloadNotification(nid)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "workload_notification")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "get workload_notification")
|
|
return
|
|
}
|
|
if existing.WorkloadID != id {
|
|
// Route mismatch — the row exists but under a different workload.
|
|
// Return 404 rather than 403 so we don't leak the existence of
|
|
// foreign rows to an unauthorised caller.
|
|
respondNotFound(w, "workload_notification")
|
|
return
|
|
}
|
|
|
|
var req workloadNotificationRequest
|
|
if !decodeJSONStrict(w, r, &req) {
|
|
return
|
|
}
|
|
req.URL = strings.TrimSpace(req.URL)
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
if req.URL == "" {
|
|
respondError(w, http.StatusBadRequest, "url is required")
|
|
return
|
|
}
|
|
|
|
existing.Name = req.Name
|
|
existing.URL = req.URL
|
|
existing.EventTypes = req.EventTypes
|
|
existing.SortOrder = req.SortOrder
|
|
if req.Enabled != nil {
|
|
existing.Enabled = *req.Enabled
|
|
}
|
|
// Empty Secret on UPDATE preserves the stored ciphertext — explicit
|
|
// rotation requires sending the new plaintext. This avoids forcing
|
|
// the operator to re-enter their secret on every URL edit.
|
|
if req.Secret != "" {
|
|
v, err := crypto.Encrypt(s.encKey, req.Secret)
|
|
if err != nil {
|
|
slog.Error("workload notifications: encrypt secret", "workload", id, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "encrypt secret")
|
|
return
|
|
}
|
|
existing.Secret = v
|
|
}
|
|
|
|
if err := s.store.UpdateWorkloadNotification(existing); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "workload_notification")
|
|
return
|
|
}
|
|
slog.Error("workload notifications: update", "workload", id, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "update workload notification")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, toWorkloadNotificationRow(existing))
|
|
}
|
|
|
|
func (s *Server) deleteWorkloadNotification(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
nid := chi.URLParam(r, "nid")
|
|
existing, err := s.store.GetWorkloadNotification(nid)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "workload_notification")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "get workload_notification")
|
|
return
|
|
}
|
|
if existing.WorkloadID != id {
|
|
respondNotFound(w, "workload_notification")
|
|
return
|
|
}
|
|
if err := s.store.DeleteWorkloadNotification(nid); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "workload_notification")
|
|
return
|
|
}
|
|
slog.Error("workload notifications: delete", "workload", id, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "delete workload notification")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, map[string]any{"success": true})
|
|
}
|