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
+76 -5
View File
@@ -16,6 +16,8 @@ import (
"time"
"github.com/google/uuid"
"github.com/alexei/tinyforge/internal/metrics"
)
// Event represents a deployment / site-sync notification payload.
@@ -83,17 +85,68 @@ type TestResult struct {
// Notifications are fire-and-forget by default — failures are logged but do
// not propagate. SendSyncForTest is the exception, used only by the manual
// test endpoint.
//
// outboundSem caps the number of in-flight outbound notifications. Without
// it a single burst (e.g. 1000 event triggers firing on a noisy log scan)
// would spawn 1000 simultaneous TCP connections, which both DoSes the
// receiver and exhausts local FDs.
type Notifier struct {
httpClient *http.Client
wg sync.WaitGroup
httpClient *http.Client
wg sync.WaitGroup
outboundSem chan struct{}
}
// maxOutboundNotifications bounds the in-flight outbound webhook fan-out.
// Sized to keep small bursts non-blocking while preventing a runaway storm
// from starving the rest of the process. Tunable later via settings if any
// operator legitimately needs more concurrency.
const maxOutboundNotifications = 32
// New creates a Notifier with sensible defaults.
func New() *Notifier {
// Transport with bounded host pooling so a slow receiver cannot pin
// arbitrarily many sockets open. MaxConnsPerHost mirrors the worker
// pool size; idle pruning keeps long-lived processes from holding
// stale TCP entries indefinitely.
//
// NOTE: we deliberately do NOT apply the staticsite SSRF dialer here.
// Notification URLs are admin-configured, and an admin already has
// Docker-socket (host-root-equivalent) access, so the SSRF surface adds
// nothing they couldn't already reach. Blocking loopback/private targets
// would instead break the common self-hosted pattern of notifying a
// same-host sidecar/bridge (e.g. service-to-notification-bridge on
// 127.0.0.1). See the security review (rated LOW / out of trust boundary).
tr := &http.Transport{
MaxIdleConns: 64,
MaxIdleConnsPerHost: 8,
MaxConnsPerHost: maxOutboundNotifications,
IdleConnTimeout: 90 * time.Second,
}
return &Notifier{
httpClient: &http.Client{
Timeout: 10 * time.Second,
Timeout: 10 * time.Second,
Transport: tr,
},
outboundSem: make(chan struct{}, maxOutboundNotifications),
}
}
// acquireSlot reserves an outbound slot, respecting ctx so a backed-up
// queue cannot starve a request that already has its own deadline.
func (n *Notifier) acquireSlot(ctx context.Context) bool {
select {
case n.outboundSem <- struct{}{}:
return true
case <-ctx.Done():
return false
}
}
func (n *Notifier) releaseSlot() {
select {
case <-n.outboundSem:
default:
// Drained during shutdown — never block.
}
}
@@ -128,8 +181,15 @@ func (n *Notifier) SendSigned(webhookURL, secret string, tier Tier, event Event)
n.wg.Add(1)
go func() {
defer n.wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if !n.acquireSlot(ctx) {
slog.Warn("notify: dropped — outbound queue saturated",
"tier", tier, "host", safeHost(webhookURL), "delivery", delivery, "event", event.Type)
metrics.OutboundNotifyTotal.Inc("dropped")
return
}
defer n.releaseSlot()
_, err := n.doSend(ctx, webhookURL, secret, tier, delivery, event)
// URL host only — never log the secret or full URL with user-info.
@@ -138,11 +198,13 @@ func (n *Notifier) SendSigned(webhookURL, secret string, tier Tier, event Event)
slog.Warn("notify: webhook send failed",
"tier", tier, "host", host, "delivery", delivery,
"event", event.Type, "signed", secret != "", "error", err)
metrics.OutboundNotifyTotal.Inc("failure")
return
}
slog.Info("notify: webhook dispatched",
"tier", tier, "host", host, "delivery", delivery,
"event", event.Type, "signed", secret != "")
metrics.OutboundNotifyTotal.Inc("success")
}()
}
@@ -166,8 +228,15 @@ func (n *Notifier) SendPayload(webhookURL, secret, eventType string, payload any
n.wg.Add(1)
go func() {
defer n.wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if !n.acquireSlot(ctx) {
slog.Warn("notify: dropped trigger payload — outbound queue saturated",
"tier", TierEventTrigger, "host", safeHost(webhookURL), "delivery", delivery, "event", eventType)
metrics.OutboundNotifyTotal.Inc("dropped")
return
}
defer n.releaseSlot()
_, err := n.doSendRaw(ctx, webhookURL, secret, TierEventTrigger, delivery, eventType, timestamp, payload)
host := safeHost(webhookURL)
@@ -175,11 +244,13 @@ func (n *Notifier) SendPayload(webhookURL, secret, eventType string, payload any
slog.Warn("notify: trigger webhook send failed",
"tier", TierEventTrigger, "host", host, "delivery", delivery,
"event", eventType, "signed", secret != "", "error", err)
metrics.OutboundNotifyTotal.Inc("failure")
return
}
slog.Info("notify: trigger webhook dispatched",
"tier", TierEventTrigger, "host", host, "delivery", delivery,
"event", eventType, "signed", secret != "")
metrics.OutboundNotifyTotal.Inc("success")
}()
}