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:
@@ -131,8 +131,14 @@ const maxWebhookBodyBytes = 256 * 1024 // 256 KiB
|
||||
// PluginDispatcher is what the plugin-workload webhook handler needs from
|
||||
// the deployer: the canonical Source-dispatch entry point plus access to
|
||||
// the same Deps bundle so Trigger.Match can read store / crypto.
|
||||
//
|
||||
// DispatchTeardown is required so the preview-deploy flow can tear down
|
||||
// an ephemeral per-branch child workload when its upstream branch is
|
||||
// deleted. Same teardown path the API /workloads/{id} DELETE route uses;
|
||||
// nil error on a clean teardown lets the caller delete the workload row.
|
||||
type PluginDispatcher interface {
|
||||
DispatchPlugin(ctx context.Context, w pluginWorkload, intent pluginIntent) error
|
||||
DispatchTeardown(ctx context.Context, w pluginWorkload) error
|
||||
PluginDeps() pluginDeps
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/metrics"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
"github.com/alexei/tinyforge/internal/workload/preview"
|
||||
)
|
||||
|
||||
// maxTriggerFanOutConcurrency caps how many bindings dispatch in
|
||||
@@ -44,6 +46,17 @@ const (
|
||||
ReasonConfigError = "config merge error"
|
||||
ReasonMatchError = "match error"
|
||||
ReasonDispatchFailed = "dispatch failed"
|
||||
ReasonPreviewError = "preview materialize error"
|
||||
ReasonPreviewTorndown = "preview torn down"
|
||||
// ReasonPreviewNoop: a branch-delete webhook arrived but no preview was
|
||||
// ever materialized for that branch — a legitimate clean skip, distinct
|
||||
// from "no binding matched" so it isn't misreported as a wiring problem.
|
||||
ReasonPreviewNoop = "preview noop"
|
||||
// ReasonPreviewOrphaned: the preview container was torn down but its
|
||||
// workload row could not be deleted, leaving an orphan row. Surfaced
|
||||
// distinctly so the partial failure is visible rather than masquerading
|
||||
// as a clean teardown.
|
||||
ReasonPreviewOrphaned = "preview torn down (row orphaned)"
|
||||
)
|
||||
|
||||
// handleTriggerWebhook processes an inbound webhook for a first-class
|
||||
@@ -172,7 +185,7 @@ func (h *Handler) handleTriggerWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Deployed:
|
||||
deployed++
|
||||
case r.Reason == ReasonBindingDisabled:
|
||||
case r.Reason == ReasonBindingDisabled, r.Reason == ReasonPreviewNoop:
|
||||
skipped++
|
||||
case r.Reason == ReasonNoMatch:
|
||||
noMatch++
|
||||
@@ -194,8 +207,10 @@ func (h *Handler) handleTriggerWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
case noMatch == len(results)-skipped:
|
||||
delivery.Detail = "no binding matched"
|
||||
default:
|
||||
delivery.Detail = fmt.Sprintf("matched=0 skipped=%d errored=%d", skipped, errored)
|
||||
delivery.Detail = fmt.Sprintf("matched=0 skipped=%d errored=%d nomatch=%d",
|
||||
skipped, errored, noMatch)
|
||||
}
|
||||
metrics.WebhookDeliveriesTotal.Inc(delivery.Outcome)
|
||||
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"trigger": trg.Name,
|
||||
@@ -326,6 +341,18 @@ func (h *Handler) fireBinding(
|
||||
if intent.TriggeredBy == "" {
|
||||
intent.TriggeredBy = "trigger-webhook"
|
||||
}
|
||||
|
||||
// Preview-deploy fork: the git trigger plugin attaches preview_branch
|
||||
// metadata when BranchPattern matches a non-baseline branch. Route
|
||||
// the dispatch through a per-branch child workload rather than
|
||||
// redeploying the parent template. The fork is intentionally before
|
||||
// the dispatch so the template's container never gets clobbered by
|
||||
// a feature-branch push.
|
||||
if previewBranch := intent.Metadata["preview_branch"]; previewBranch != "" {
|
||||
fired, reason := h.handlePreviewIntent(ctx, row, intent, previewBranch)
|
||||
return fired, reason
|
||||
}
|
||||
|
||||
if err := h.plugins.DispatchPlugin(ctx, pwl, *intent); err != nil {
|
||||
slog.Warn("webhook: dispatch failed",
|
||||
"trigger", trg.Name, "workload", row.Name, "error", err)
|
||||
@@ -336,3 +363,72 @@ func (h *Handler) fireBinding(
|
||||
return true, intent.Reason
|
||||
}
|
||||
|
||||
// handlePreviewIntent dispatches an intent that targeted a non-baseline
|
||||
// branch on a preview-template workload. Two paths:
|
||||
//
|
||||
// 1. Branch deleted: find the matching preview workload, dispatch
|
||||
// Teardown, then delete the workload row so the dashboard reflects
|
||||
// the upstream state.
|
||||
// 2. Branch pushed: materialize (or reuse) the preview workload, then
|
||||
// dispatch the deploy against it. The template workload itself is
|
||||
// never deployed against a feature branch.
|
||||
//
|
||||
// On any error the helper logs and returns a generic reason — the
|
||||
// fan-out caller treats these the same as a normal dispatch failure.
|
||||
func (h *Handler) handlePreviewIntent(
|
||||
ctx context.Context,
|
||||
template store.Workload,
|
||||
intent *plugin.DeploymentIntent,
|
||||
branch string,
|
||||
) (bool, string) {
|
||||
deleted := intent.Metadata["preview_deleted"] == "1"
|
||||
if deleted {
|
||||
child, ok, err := preview.FindPreviewForBranch(h.store, template.ID, branch)
|
||||
if err != nil {
|
||||
slog.Warn("webhook: preview lookup failed",
|
||||
"template", template.Name, "branch", branch, "error", err)
|
||||
return false, ReasonPreviewError
|
||||
}
|
||||
if !ok {
|
||||
// Branch was deleted upstream but we never materialized a
|
||||
// preview for it — nothing to do. Report as a distinct noop so
|
||||
// it isn't bucketed as "no binding matched".
|
||||
return false, ReasonPreviewNoop
|
||||
}
|
||||
childPwl := toPluginWorkload(child)
|
||||
if err := h.plugins.DispatchTeardown(ctx, childPwl); err != nil {
|
||||
slog.Warn("webhook: preview teardown dispatch failed",
|
||||
"template", template.Name, "preview", child.Name, "error", err)
|
||||
return false, ReasonDispatchFailed
|
||||
}
|
||||
if err := h.store.DeleteWorkload(child.ID); err != nil {
|
||||
// Container is gone but the row is orphaned. Surface this as a
|
||||
// distinct reason so the partial failure is visible rather than
|
||||
// reported as a clean teardown; the operator can delete the row
|
||||
// from the dashboard if it sticks around.
|
||||
slog.Warn("webhook: preview row delete failed (orphaned row)",
|
||||
"template", template.Name, "preview", child.Name, "error", err)
|
||||
return true, ReasonPreviewOrphaned
|
||||
}
|
||||
slog.Info("webhook: preview torn down",
|
||||
"template", template.Name, "branch", branch, "preview", child.Name)
|
||||
return true, ReasonPreviewTorndown
|
||||
}
|
||||
|
||||
child, err := preview.MaterializeForBranch(h.store, template, branch)
|
||||
if err != nil {
|
||||
slog.Warn("webhook: preview materialize failed",
|
||||
"template", template.Name, "branch", branch, "error", err)
|
||||
return false, ReasonPreviewError
|
||||
}
|
||||
childPwl := toPluginWorkload(child)
|
||||
if err := h.plugins.DispatchPlugin(ctx, childPwl, *intent); err != nil {
|
||||
slog.Warn("webhook: preview dispatch failed",
|
||||
"template", template.Name, "preview", child.Name, "error", err)
|
||||
return false, ReasonDispatchFailed
|
||||
}
|
||||
slog.Info("webhook: triggered preview deploy",
|
||||
"template", template.Name, "branch", branch, "preview", child.Name, "reason", intent.Reason)
|
||||
return true, intent.Reason
|
||||
}
|
||||
|
||||
|
||||
@@ -327,6 +327,10 @@ func parseGitLabPushEvent(body []byte, headers http.Header) vendorParseResult {
|
||||
Ref: probe.Ref,
|
||||
CommitSHA: probe.After,
|
||||
Pusher: pusher,
|
||||
// GitLab does not emit `deleted: true`; the canonical signal
|
||||
// is an all-zero `after` SHA. Same parser helper used for the
|
||||
// GitHub / Gitea fallback so the two branches agree.
|
||||
Deleted: isZeroSHA(probe.After),
|
||||
},
|
||||
}
|
||||
if strings.HasPrefix(probe.Ref, "refs/heads/") {
|
||||
@@ -346,6 +350,7 @@ func parseGenericGitPush(body []byte) (plugin.InboundEvent, error) {
|
||||
var probe struct {
|
||||
Ref string `json:"ref"`
|
||||
After string `json:"after"`
|
||||
Deleted bool `json:"deleted"`
|
||||
Repository struct {
|
||||
FullName string `json:"full_name"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
@@ -370,6 +375,12 @@ func parseGenericGitPush(body []byte) (plugin.InboundEvent, error) {
|
||||
if pusher == "" {
|
||||
pusher = probe.Pusher.Username
|
||||
}
|
||||
// Branch / tag deletion is signalled either by the explicit
|
||||
// `deleted: true` flag (GitHub / Gitea) or by an all-zero `after`
|
||||
// SHA (older shapes). Both are honoured so the preview-deploy flow
|
||||
// can tear down ephemeral workloads even when a vendor omits the
|
||||
// boolean flag.
|
||||
deleted := probe.Deleted || isZeroSHA(probe.After)
|
||||
evt := plugin.InboundEvent{
|
||||
Kind: "git-push",
|
||||
Git: &plugin.GitEvent{
|
||||
@@ -377,6 +388,7 @@ func parseGenericGitPush(body []byte) (plugin.InboundEvent, error) {
|
||||
Ref: probe.Ref,
|
||||
CommitSHA: probe.After,
|
||||
Pusher: pusher,
|
||||
Deleted: deleted,
|
||||
},
|
||||
}
|
||||
if strings.HasPrefix(probe.Ref, "refs/heads/") {
|
||||
@@ -388,3 +400,19 @@ func parseGenericGitPush(body []byte) (plugin.InboundEvent, error) {
|
||||
}
|
||||
return evt, nil
|
||||
}
|
||||
|
||||
// isZeroSHA returns true when sha is the canonical "no commit" sentinel
|
||||
// (40 zeros) that vendors emit on the `after` field of a branch- or
|
||||
// tag-delete push event. Length-tolerant because some test fixtures
|
||||
// truncate the SHA.
|
||||
func isZeroSHA(sha string) bool {
|
||||
if sha == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range sha {
|
||||
if r != '0' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(sha) >= 7
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user