feat(apps): stepped creation wizard, branch previews, and app-creation fixes

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.
This commit is contained in:
2026-05-29 02:09:54 +03:00
parent 956943edbb
commit 410a131cec
112 changed files with 13285 additions and 2765 deletions
+239
View File
@@ -0,0 +1,239 @@
// Package preview implements branch-pattern preview deploys. A "template"
// workload is one whose git trigger has a BranchPattern configured; when
// an inbound push event names a branch other than the template's primary
// Branch, the dispatcher materializes (or reuses) a child workload via
// MaterializeForBranch and dispatches the deploy against the child. The
// child is then torn down on a matching branch-delete event.
//
// The package is intentionally narrow:
// - it does not know about Docker, the proxy, or any plugin internals
// - it operates over a Store interface so the webhook handler can mock
// it in tests
// - it owns the per-branch naming + subdomain mangling so the wiring
// code (trigger fan-out) stays a pure dispatch path
package preview
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"github.com/alexei/tinyforge/internal/store"
)
// Store is the slice of the persistence layer the preview package needs.
// Defined locally so tests can fake it without dragging the full Store.
type Store interface {
GetWorkloadByID(id string) (store.Workload, error)
ListChildrenByParent(parentID string) ([]store.Workload, error)
CreateWorkload(w store.Workload) (store.Workload, error)
DeleteWorkload(id string) error
}
// branchSlugPattern strips characters that are unsafe inside a Docker
// container name, hostname label, or filesystem path. Compiled once.
var branchSlugPattern = regexp.MustCompile(`[^a-z0-9-]+`)
// slugifyBranch converts a git ref-component into a safe slug. Lowercase,
// hyphen-only, length-capped to 32 so name + slug fit inside the Docker
// 63-char container-name and 63-char DNS-label limits with room for the
// `tf-build-` prefix.
func slugifyBranch(branch string) string {
b := strings.ToLower(branch)
b = strings.ReplaceAll(b, "/", "-")
b = branchSlugPattern.ReplaceAllString(b, "-")
b = strings.Trim(b, "-")
if b == "" {
return "branch"
}
if len(b) > 32 {
b = strings.Trim(b[:32], "-")
if b == "" {
b = "branch"
}
}
return b
}
// findExistingPreview returns the child workload whose source_config
// already names `branch`, if any. Linear scan over the children list —
// fine because the bound is "branches a single team keeps open at once"
// which is in the dozens, not thousands.
func findExistingPreview(children []store.Workload, branch string) (store.Workload, bool) {
for _, c := range children {
var cfg struct {
Branch string `json:"branch"`
}
if c.SourceConfig != "" {
_ = json.Unmarshal([]byte(c.SourceConfig), &cfg)
}
if cfg.Branch == branch {
return c, true
}
}
return store.Workload{}, false
}
// patchSourceConfigBranch returns a copy of the template's source_config
// with the `branch` field replaced. Unknown keys round-trip so plugin-
// specific config (port, dockerfile path, storage settings, ...) survive.
// A malformed source_config is replaced rather than propagated so the
// preview workload has a clean baseline.
func patchSourceConfigBranch(sourceConfig, branch string) (string, error) {
if branch == "" {
return "", fmt.Errorf("preview: branch is empty")
}
m := map[string]json.RawMessage{}
if sourceConfig != "" && sourceConfig != "{}" {
if err := json.Unmarshal([]byte(sourceConfig), &m); err != nil {
m = map[string]json.RawMessage{}
}
}
enc, err := json.Marshal(branch)
if err != nil {
return "", fmt.Errorf("preview: encode branch: %w", err)
}
m["branch"] = enc
out, err := json.Marshal(m)
if err != nil {
return "", fmt.Errorf("preview: encode source_config: %w", err)
}
return string(out), nil
}
// patchPublicFacesSubdomain prefixes every public face's Subdomain with
// the branch slug so two preview deploys never collide on the same FQDN.
// Faces with no subdomain are left untouched — the operator clearly
// didn't want a per-branch host carved out for that face.
func patchPublicFacesSubdomain(publicFaces, slug string) (string, error) {
if publicFaces == "" || publicFaces == "[]" {
return publicFaces, nil
}
var faces []map[string]any
if err := json.Unmarshal([]byte(publicFaces), &faces); err != nil {
// Malformed faces MUST fail loudly: returning the template's faces
// verbatim would give the preview the SAME subdomains as the
// template, so the preview's proxy route would clobber the template's
// (the exact collision the slug prefix exists to prevent).
return "", fmt.Errorf("preview: parse public_faces: %w", err)
}
for _, f := range faces {
sub, ok := f["subdomain"].(string)
if !ok || sub == "" {
continue
}
f["subdomain"] = slug + "-" + sub
}
out, err := json.Marshal(faces)
if err != nil {
return "", fmt.Errorf("preview: re-encode public_faces: %w", err)
}
return string(out), nil
}
// IsPreviewChild reports whether child was materialized as a branch preview
// of template (vs. an operator-created stage-chain member that merely shares
// the parent link — both use parent_workload_id). It reverses the exact
// MaterializeForBranch naming formula — name == template.Name + "/" +
// slugifyBranch(child's branch) — so a hand-named stage workload under the
// same parent is never mistaken for a preview and cascade-deleted.
func IsPreviewChild(template, child store.Workload) bool {
if child.ParentWorkloadID != template.ID {
return false
}
var cfg struct {
Branch string `json:"branch"`
}
if child.SourceConfig != "" {
_ = json.Unmarshal([]byte(child.SourceConfig), &cfg)
}
if cfg.Branch == "" {
return false
}
return child.Name == template.Name+"/"+slugifyBranch(cfg.Branch)
}
// ListPreviewChildren returns every preview workload materialized from
// template. Used by the delete path to cascade-teardown previews so deleting
// a template does not orphan their containers, proxy routes, and rows.
func ListPreviewChildren(s Store, template store.Workload) ([]store.Workload, error) {
children, err := s.ListChildrenByParent(template.ID)
if err != nil {
return nil, fmt.Errorf("preview: list children: %w", err)
}
out := make([]store.Workload, 0, len(children))
for _, c := range children {
if IsPreviewChild(template, c) {
out = append(out, c)
}
}
return out, nil
}
// MaterializeForBranch returns the existing preview workload for
// (template, branch) or creates one if none exists. The new workload
// inherits the template's source kind, trigger kind, notification
// settings, and public faces (with the branch slug prefixed onto each
// subdomain). Idempotent: a second call with the same arguments returns
// the same workload row.
func MaterializeForBranch(s Store, template store.Workload, branch string) (store.Workload, error) {
if branch == "" {
return store.Workload{}, fmt.Errorf("preview: branch is required")
}
children, err := s.ListChildrenByParent(template.ID)
if err != nil {
return store.Workload{}, fmt.Errorf("preview: list children: %w", err)
}
if existing, ok := findExistingPreview(children, branch); ok {
return existing, nil
}
slug := slugifyBranch(branch)
newCfg, err := patchSourceConfigBranch(template.SourceConfig, branch)
if err != nil {
return store.Workload{}, err
}
newFaces, err := patchPublicFacesSubdomain(template.PublicFaces, slug)
if err != nil {
return store.Workload{}, err
}
// Webhook + notification secrets are NOT copied to the preview. The
// trigger dispatch reaches previews via the parent's trigger binding,
// not via a per-preview inbound webhook, so the preview never needs
// its own signing secret. Keeping these empty also stops the preview
// from masquerading as a first-class workload in webhook routes.
child := store.Workload{
Kind: template.Kind,
Name: template.Name + "/" + slug,
AppID: template.AppID,
SourceKind: template.SourceKind,
SourceConfig: newCfg,
TriggerKind: template.TriggerKind,
TriggerConfig: template.TriggerConfig,
PublicFaces: newFaces,
ParentWorkloadID: template.ID,
}
created, err := s.CreateWorkload(child)
if err != nil {
return store.Workload{}, fmt.Errorf("preview: create child: %w", err)
}
return created, nil
}
// FindPreviewForBranch looks up an existing preview without creating
// one. Returns (Workload{}, false, nil) when no preview exists. Errors
// only on a store failure.
func FindPreviewForBranch(s Store, templateID, branch string) (store.Workload, bool, error) {
if templateID == "" || branch == "" {
return store.Workload{}, false, nil
}
children, err := s.ListChildrenByParent(templateID)
if err != nil {
return store.Workload{}, false, fmt.Errorf("preview: list children: %w", err)
}
w, ok := findExistingPreview(children, branch)
return w, ok, nil
}
+200
View File
@@ -0,0 +1,200 @@
package preview
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/alexei/tinyforge/internal/store"
)
// fakeStore is a minimal in-memory store satisfying the preview.Store
// interface. Tests verify business logic without the SQLite layer.
type fakeStore struct {
workloads map[string]store.Workload
createErr error
}
func newFakeStore() *fakeStore {
return &fakeStore{workloads: map[string]store.Workload{}}
}
func (f *fakeStore) GetWorkloadByID(id string) (store.Workload, error) {
w, ok := f.workloads[id]
if !ok {
return store.Workload{}, errors.New("not found")
}
return w, nil
}
func (f *fakeStore) ListChildrenByParent(parentID string) ([]store.Workload, error) {
out := []store.Workload{}
for _, w := range f.workloads {
if w.ParentWorkloadID == parentID {
out = append(out, w)
}
}
return out, nil
}
func (f *fakeStore) CreateWorkload(w store.Workload) (store.Workload, error) {
if f.createErr != nil {
return store.Workload{}, f.createErr
}
if w.ID == "" {
w.ID = "preview-" + w.Name
}
f.workloads[w.ID] = w
return w, nil
}
func (f *fakeStore) DeleteWorkload(id string) error {
delete(f.workloads, id)
return nil
}
func TestSlugifyBranch_StripsUnsafeChars(t *testing.T) {
cases := []struct {
in string
want string
}{
{"main", "main"},
{"Feature/User-Auth", "feature-user-auth"},
{"PR#42", "pr-42"},
{"release/v1.2.3", "release-v1-2-3"},
{"___", "branch"},
{strings.Repeat("a", 50), strings.Repeat("a", 32)},
}
for _, c := range cases {
got := slugifyBranch(c.in)
if got != c.want {
t.Errorf("slugifyBranch(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestPatchSourceConfigBranch_PreservesUnknownKeys(t *testing.T) {
src := `{"port":3000,"dockerfile_path":"Dockerfile","branch":"main","provider":"github"}`
out, err := patchSourceConfigBranch(src, "feat/x")
if err != nil {
t.Fatalf("patch: %v", err)
}
var got map[string]any
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got["branch"] != "feat/x" {
t.Errorf("branch = %v, want feat/x", got["branch"])
}
if got["port"] == nil || got["dockerfile_path"] == nil || got["provider"] == nil {
t.Errorf("unknown keys dropped: %+v", got)
}
}
func TestPatchPublicFacesSubdomain_PrefixesSubdomains(t *testing.T) {
faces := `[{"subdomain":"app","domain":"example.com"},{"subdomain":"","domain":"raw.example.com"}]`
out, err := patchPublicFacesSubdomain(faces, "feat-x")
if err != nil {
t.Fatalf("patch: %v", err)
}
var got []map[string]any
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got[0]["subdomain"] != "feat-x-app" {
t.Errorf("first subdomain = %v, want feat-x-app", got[0]["subdomain"])
}
if got[1]["subdomain"] != "" {
t.Errorf("empty subdomain must stay empty, got %v", got[1]["subdomain"])
}
}
func TestMaterializeForBranch_CreatesNewWhenMissing(t *testing.T) {
fs := newFakeStore()
template := store.Workload{
ID: "tmpl-1",
Kind: "project",
Name: "myapp",
AppID: "app-1",
SourceKind: "dockerfile",
SourceConfig: `{"branch":"main","port":3000}`,
TriggerKind: "git",
PublicFaces: `[{"subdomain":"www","domain":"x.test"}]`,
}
fs.workloads[template.ID] = template
child, err := MaterializeForBranch(fs, template, "feat/login")
if err != nil {
t.Fatalf("materialize: %v", err)
}
if child.ParentWorkloadID != template.ID {
t.Errorf("parent = %q, want %q", child.ParentWorkloadID, template.ID)
}
if !strings.Contains(child.Name, "feat-login") {
t.Errorf("name = %q, want it to include slug", child.Name)
}
var cfg map[string]any
if err := json.Unmarshal([]byte(child.SourceConfig), &cfg); err != nil {
t.Fatalf("decode child source_config: %v", err)
}
if cfg["branch"] != "feat/login" {
t.Errorf("child branch = %v, want feat/login", cfg["branch"])
}
if cfg["port"] == nil {
t.Errorf("child should inherit template port; got %+v", cfg)
}
var faces []map[string]any
if err := json.Unmarshal([]byte(child.PublicFaces), &faces); err != nil {
t.Fatalf("decode child faces: %v", err)
}
if !strings.HasPrefix(faces[0]["subdomain"].(string), "feat-login-") {
t.Errorf("face subdomain = %v, want feat-login- prefix", faces[0]["subdomain"])
}
}
func TestMaterializeForBranch_ReusesExisting(t *testing.T) {
fs := newFakeStore()
template := store.Workload{
ID: "tmpl-1",
Kind: "project",
Name: "myapp",
SourceKind: "dockerfile",
SourceConfig: `{"branch":"main"}`,
}
fs.workloads[template.ID] = template
first, err := MaterializeForBranch(fs, template, "feat/x")
if err != nil {
t.Fatalf("first materialize: %v", err)
}
second, err := MaterializeForBranch(fs, template, "feat/x")
if err != nil {
t.Fatalf("second materialize: %v", err)
}
if first.ID != second.ID {
t.Errorf("expected idempotence: got %q then %q", first.ID, second.ID)
}
if len(fs.workloads) != 2 {
t.Errorf("expected exactly one preview created, store has %d", len(fs.workloads))
}
}
func TestMaterializeForBranch_RejectsEmptyBranch(t *testing.T) {
fs := newFakeStore()
_, err := MaterializeForBranch(fs, store.Workload{ID: "tmpl"}, "")
if err == nil {
t.Fatal("expected error for empty branch")
}
}
func TestFindPreviewForBranch_MissingReturnsFalse(t *testing.T) {
fs := newFakeStore()
_, ok, err := FindPreviewForBranch(fs, "tmpl", "feat/x")
if err != nil {
t.Fatalf("find: %v", err)
}
if ok {
t.Error("expected ok=false for missing preview")
}
}