feat(triggers): first-class triggers + bindings with fan-out webhook
Build / build (push) Successful in 10m39s
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user