feat(triggers): first-class triggers + bindings with fan-out webhook
Build / build (push) Successful in 10m39s

Promote triggers from embedded workload fields to standalone records
joined to workloads via workload_trigger_bindings. One trigger (webhook,
registry watcher, git push, manual) now fans out to many workloads with
per-binding config overrides (top-level JSON merge, binding wins).

Backend
- new triggers + workload_trigger_bindings tables with ON DELETE CASCADE
- boot-time backfill of embedded trigger config inside per-workload tx
- store.ErrUnique sentinel translates SQLite UNIQUE at store boundary
- /api/triggers CRUD + /api/triggers/{id}/{webhook,bindings}
- /api/bindings/{id} update/delete; /api/workloads/{id}/triggers list+bind
- bindTriggerToWorkload accepts trigger_id or inline {kind,name,config}
- inline-create uses CreateTriggerWithBindingTx (no orphan triggers)
- validateBindingConfig enforces 8 KiB cap + plugin Validate on merged
- ListTriggersWithBindingCount + ListBindings*WithNames remove N+1
- POST /api/webhook/triggers/{secret} resolves trigger then fans out
- bounded worker pool (4) per request; per-binding error isolation
- outcome accounting: deployed / skipped / no-match / errored
- legacy /api/webhook/workloads/{secret} route removed (clean break;
  backfill keeps secrets resolvable at the new /triggers/{secret} path)
- reconciler gate dropped from (Source && Trigger) to Source only
- MergeJSONConfig returns freshly allocated slices (no fan-out aliasing)
- WithEffectiveTrigger lets existing Trigger.Match contract stay unchanged

Frontend
- /triggers list, new wizard, [id] detail (bindings, webhook rotate)
- workload create wizard: NEW / PICK / SKIP trigger modes
- workload detail: bindings panel + Add-trigger modal (inline / pick)
- per-binding override editor with merged-preview + 8 KiB guard
- "OVERRIDES n FIELDS" row badge when binding_config is non-empty
- shared TriggerKindForm component (registry / git / manual + JSON)
- 3 raw <input type=checkbox> replaced with <ToggleSwitch>
- full EN + RU i18n: redeployTriggers.*, apps.detail.bindings.*,
  apps.new.triggers.*, nav.triggers; event-triggers nav disambiguated

Doc
- WORKLOAD_REFACTOR_TODO: trigger-split marked DONE; next focus is
  the static-source inline port + hard legacy cutover (Priority 1)
This commit is contained in:
2026-05-16 02:24:31 +03:00
parent 30133bc1eb
commit 2aff22f565
21 changed files with 7445 additions and 460 deletions
+231
View File
@@ -17,6 +17,7 @@
"apps": "Apps",
"eventTriggers": "Triggers",
"logScanRules": "Log Rules",
"triggers": "Triggers",
"projects": "Projects",
"deploy": "Deploy",
"proxies": "Proxies",
@@ -1527,5 +1528,235 @@
"overriding": "Overriding…",
"overrideTitle": "Create a per-workload override of this global rule"
}
},
"redeployTriggers": {
"section": "The Forge",
"title": "Redeploy triggers",
"titleNew": "Forge a new trigger",
"titleSingular": "Trigger",
"lede": "Sources of redeploy signals — registry pushes, git events, manual fires, schedules, webhooks, log matches. Each trigger lives once and fans out to every workload bound to it.",
"ledeNew": "Pick a kind, name it, and decide whether external systems may poke it via webhook. Bind it to one or more workloads from the workload page after creation.",
"ledeDetail": "Edit the trigger config, manage its webhook ingress, and review every workload listening to this signal.",
"stat": {
"total": "TOTAL",
"byKind": "{kind}",
"withWebhook": "WEBHOOK ON",
"boundWorkloads": "WORKLOADS"
},
"kind": {
"registry": "Registry",
"git": "Git",
"manual": "Manual",
"schedule": "Schedule",
"webhook": "Webhook",
"logscan": "Log scan",
"unknown": "Unknown"
},
"kindShort": {
"registry": "REG",
"git": "GIT",
"manual": "MAN",
"schedule": "CRN",
"webhook": "HK",
"logscan": "LOG",
"unknown": "?"
},
"kindHint": {
"registry": "Watch a container image; fire when a new tag matching the pattern is pushed.",
"git": "Fire when a configured branch advances or a tag matching the pattern is created.",
"manual": "Fires only via the workload's Deploy button or POST /workloads/{id}/deploy.",
"schedule": "Fires on a fixed cron-style schedule.",
"webhook": "Pure webhook — fires when the ingress URL is hit.",
"logscan": "Fires when an upstream log-scan rule matches a tailed line.",
"unknown": "Unknown trigger kind — fall back to the raw JSON editor."
},
"toolbar": {
"newButton": "New trigger",
"backToList": "Back to triggers"
},
"filter": {
"all": "ALL",
"ariaLabel": "Filter by kind"
},
"empty": {
"heading": "No triggers yet",
"body": "A trigger is the source of a redeploy signal — a registry watcher, git hook, manual button, scheduled fire, or webhook. Create one and bind it to as many workloads as you like.",
"cta": "Forge the first trigger"
},
"list": {
"name": "Name",
"kind": "Kind",
"bindings": "Workloads",
"webhook": "Webhook",
"created": "Created",
"open": "Open",
"webhookOn": "ON",
"webhookOff": "—",
"noBindings": "—",
"bindingsCount": "{count}"
},
"detail": {
"config": "Trigger configuration",
"configSub": "kind {kind} · id {id} · updated {updatedAt}",
"webhook": "Webhook ingress",
"webhookSub": "When enabled, external systems can fire this trigger by posting to the URL below. Each workload bound to it will be redeployed in turn.",
"webhookEnable": "Enable webhook ingress",
"webhookEnableHint": "When off, the trigger fires only via internal sources (its kind config) and the manual deploy button.",
"webhookRequireSig": "Require HMAC signature",
"webhookRequireSigHint": "Reject requests without a valid X-Hub-Signature-256 header. Recommended whenever the URL is reachable from the public internet.",
"webhookUrlLabel": "Ingress URL",
"webhookUrlNote": "Paste this into your CI / registry / GitHub webhook settings. The secret segment is the bearer — treat it like a password.",
"webhookCopy": "Copy",
"webhookCopied": "Copied",
"webhookRotate": "Rotate secret",
"webhookRotating": "Rotating…",
"webhookDisabledNote": "Webhook ingress is disabled. Toggle it on, save, and the URL will appear here.",
"bindings": "Bound workloads",
"bindingsSub": "Every workload listening to this trigger. To bind a new workload, open the workload page and add this trigger from there.",
"bindingsEmpty": "No workloads are bound to this trigger yet. Open a workload and bind this trigger from its Triggers panel.",
"bindingsListItem": {
"openWorkload": "Open workload",
"unbind": "Unbind"
},
"bindingEnabledHint": "Disable to keep the binding but stop this trigger from redeploying that workload.",
"dangerZone": "Danger zone",
"dangerZoneSub": "Trigger deletion is immediate. All bindings to it are cascade-removed.",
"deleteButton": "Delete trigger",
"deleteTitle": "Delete trigger?",
"deleteMessage": "Trigger \"{name}\" will be removed immediately, along with {count} binding(s). This cannot be undone.",
"rotateTitle": "Rotate webhook secret?",
"rotateMessage": "The current ingress URL stops working immediately. Update every external integration with the new URL after rotation.",
"rotateConfirm": "Rotate now",
"unbindTitle": "Unbind workload?",
"unbindMessage": "Workload \"{name}\" will stop redeploying when this trigger fires. The workload itself is not deleted.",
"unbindConfirm": "Unbind"
},
"form": {
"kindLabel": "Kind",
"kindHint": "Pick the source of the redeploy signal. The form below adapts to the kind.",
"name": "Name",
"namePlaceholder": "e.g. ghcr.io/me/api · main",
"required": "REQUIRED",
"configLabel": "Configuration",
"image": "Image reference",
"imagePlaceholder": "registry.example.com/owner/app",
"imageHint": "Full image reference without the tag — Tinyforge matches new tags pushed under it.",
"tagPattern": "Tag pattern",
"tagPatternPlaceholder": "*",
"tagPatternHint": "path.Match glob (e.g. v*, release-*). Use * to match every tag.",
"repo": "Repository",
"repoPlaceholder": "owner/name",
"repoHint": "Provider-agnostic owner/name slug as advertised by the git host.",
"mode": "Mode",
"modePush": "Push to branch",
"modeTag": "Tag created",
"branch": "Branch",
"branchPlaceholder": "main",
"branchHint": "Only push events advancing this branch fire the trigger.",
"manualNote": "Manual triggers carry no config. They fire only via the workload's Deploy button or POST /workloads/{id}/deploy.",
"unknownNote": "This kind has no built-in form yet. Use the JSON editor below; the server validates the shape.",
"advancedToggle": "Advanced JSON",
"advancedHint": "Power-user fallback — replaces the structured form with the raw config payload.",
"configJson": "Config JSON",
"configJsonHint": "Must parse as a valid JSON object. The shape is validated server-side per kind.",
"invalidJson": "Invalid JSON — server will reject.",
"webhookEnabled": "Enable webhook ingress now",
"webhookEnabledHint": "Generates a secret URL that external systems can hit to fire the trigger.",
"webhookRequireSig": "Require HMAC signature",
"webhookRequireSigHint": "Reject unsigned requests. The secret is the same one embedded in the URL — sign the body with HMAC-SHA256 and send it as X-Hub-Signature-256.",
"submit": "Forge trigger",
"submitting": "Forging…",
"cancel": "Cancel"
},
"binding": {
"enabled": "Enabled",
"disabled": "Disabled"
}
},
"apps": {
"new": {
"triggers": {
"section": "Trigger",
"sectionSub": "Optional. Pick how this app gets a redeploy signal — registry watcher, git event, or manual button.",
"modeInline": "Add a trigger",
"modeInlineHint": "Creates a brand-new trigger record bound to this app — fits the common 1:1 case.",
"modePick": "Pick existing trigger",
"modePickHint": "Bind an existing trigger so multiple apps share one signal.",
"modeSkip": "Skip — add later",
"modeSkipHint": "The app is created without any trigger binding. Manual deploys still work.",
"switchToPick": "Pick existing trigger →",
"switchToInline": "← Create a new trigger instead",
"switchToSkip": "Skip for now",
"pickPlaceholder": "Select a trigger…",
"pickEmpty": "No triggers exist yet — create one inline above, or visit /triggers.",
"pickLabel": "Existing trigger",
"pickHint": "The same trigger can be bound to many apps. Manage standalone triggers under /triggers.",
"pickWebhookOn": "WEBHOOK ON",
"skippedNote": "No trigger will be bound. You can add one from the app's Triggers panel after it's created.",
"bindError": "App created, but the trigger binding failed: {error}. Open the app's Triggers panel to retry."
}
},
"detail": {
"manualDeploySub": "Bypasses configured triggers and dispatches through the source plugin directly.",
"chainTriggersZero": "no triggers",
"chainTriggersOne": "1 trigger",
"chainTriggersMany": "{count} triggers",
"bindings": {
"title": "Triggers",
"subEmpty": "No triggers bound. Manual deploys still work — add a trigger to wire up registry / git / webhook redeploys.",
"subCount": "{count} trigger bound",
"subCountMany": "{count} triggers bound",
"addButton": "Add trigger",
"openTrigger": "View trigger",
"unbindAction": "Unbind",
"rowEnabled": "Enabled",
"rowDisabled": "Disabled",
"rowEnableHint": "Disable to keep the binding but stop this trigger from redeploying the app.",
"loading": "Loading triggers…",
"loadError": "Failed to load trigger bindings",
"unbindTitle": "Unbind trigger?",
"unbindMessage": "Trigger \"{name}\" will stop redeploying this app. The trigger record itself is not deleted — it stays in /triggers and remains bound to any other apps.",
"unbindConfirm": "Unbind",
"modal": {
"title": "Add trigger",
"subtitle": "Bind a trigger to this app — create a new one inline, or pick an existing trigger to share.",
"tabInline": "Create new",
"tabPick": "Bind existing",
"submitInline": "Create & bind",
"submitPick": "Bind",
"submitting": "Binding…",
"cancel": "Cancel",
"error": "Bind failed",
"pickPlaceholder": "Select a trigger…",
"pickEmpty": "No triggers exist yet — switch to \"Create new\" to make one.",
"pickLabel": "Existing trigger",
"pickKind": "Filter by kind",
"pickKindAll": "All kinds"
},
"override": {
"toggle": "Override",
"title": "Per-binding overrides",
"subtitle": "Override fields of the trigger's config for this app only. Top-level keys you set here win; everything else inherits from the trigger.",
"badgeOne": "OVERRIDES 1 FIELD",
"badgeMany": "OVERRIDES {count} FIELDS",
"badgeTitle": "This binding overrides one or more fields of the trigger's config.",
"baseLabel": "Trigger config",
"baseLoading": "Loading trigger config…",
"baseHint": "Read-only view of the parent trigger's config. Edit it from the trigger page if it should change for every binding.",
"editLabel": "Override (JSON object)",
"editHint": "Top-level merge: only keys present here override the trigger. Leave the editor as {} to inherit verbatim.",
"previewLabel": "Effective config",
"previewHint": "Preview of what this binding will see when the trigger fires (trigger config with the override merged on top).",
"invalidJson": "Override must be a JSON object.",
"tooLarge": "Override is {size} B — exceeds the {limit} B server limit.",
"errInvalidJson": "Cannot save: override is not a valid JSON object.",
"errTooLarge": "Cannot save: override exceeds the 8 KiB server limit.",
"saveButton": "Save override",
"saving": "Saving…",
"resetButton": "Reset to inherit",
"closeButton": "Close"
}
}
}
}
}