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:
@@ -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")
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user