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": "Приложения",
"eventTriggers": "Триггеры",
"logScanRules": "Лог-правила",
"triggers": "Триггеры",
"projects": "Проекты",
"deploy": "Деплой",
"proxies": "Прокси",
@@ -1527,5 +1528,235 @@
"overriding": "Переопределение…",
"overrideTitle": "Создать переопределение глобального правила для этой нагрузки"
}
},
"redeployTriggers": {
"section": "Кузница",
"title": "Триггеры передеплоя",
"titleNew": "Новый триггер",
"titleSingular": "Триггер",
"lede": "Источники сигналов передеплоя — push в registry, события git, ручной запуск, расписания, webhook'и, совпадения в логах. Триггер создаётся один раз и веером раздаёт сигнал всем привязанным к нему нагрузкам.",
"ledeNew": "Выберите вид, дайте имя и решите, могут ли внешние системы дёргать его через webhook. Привязку к нагрузкам делайте со страницы нагрузки после создания.",
"ledeDetail": "Редактируйте конфигурацию триггера, управляйте webhook-приёмом и просматривайте все нагрузки, слушающие этот сигнал.",
"stat": {
"total": "ВСЕГО",
"byKind": "{kind}",
"withWebhook": "С WEBHOOK",
"boundWorkloads": "НАГРУЗОК"
},
"kind": {
"registry": "Registry",
"git": "Git",
"manual": "Ручной",
"schedule": "Расписание",
"webhook": "Webhook",
"logscan": "Лог-скан",
"unknown": "Неизвестный"
},
"kindShort": {
"registry": "REG",
"git": "GIT",
"manual": "MAN",
"schedule": "CRN",
"webhook": "HK",
"logscan": "LOG",
"unknown": "?"
},
"kindHint": {
"registry": "Следит за образом контейнера; срабатывает при push нового тега, подходящего под шаблон.",
"git": "Срабатывает при продвижении указанной ветки или создании тега, подходящего под шаблон.",
"manual": "Срабатывает только через кнопку Deploy на странице нагрузки или POST /workloads/{id}/deploy.",
"schedule": "Срабатывает по фиксированному cron-расписанию.",
"webhook": "Чистый webhook — срабатывает при обращении к URL приёма.",
"logscan": "Срабатывает, когда правило сканирования логов совпадает со строкой.",
"unknown": "Неизвестный вид триггера — используйте сырой JSON-редактор."
},
"toolbar": {
"newButton": "Новый триггер",
"backToList": "К списку триггеров"
},
"filter": {
"all": "ВСЕ",
"ariaLabel": "Фильтр по виду"
},
"empty": {
"heading": "Триггеров пока нет",
"body": "Триггер — источник сигнала передеплоя: registry-watcher, git-hook, ручная кнопка, расписание или webhook. Создайте один и привяжите к скольким угодно нагрузкам.",
"cta": "Создать первый триггер"
},
"list": {
"name": "Имя",
"kind": "Вид",
"bindings": "Нагрузки",
"webhook": "Webhook",
"created": "Создан",
"open": "Открыть",
"webhookOn": "ВКЛ",
"webhookOff": "—",
"noBindings": "—",
"bindingsCount": "{count}"
},
"detail": {
"config": "Конфигурация триггера",
"configSub": "вид {kind} · id {id} · обновлено {updatedAt}",
"webhook": "Webhook-приём",
"webhookSub": "Когда включено, внешние системы могут дёргать триггер по URL ниже. Каждая привязанная нагрузка будет передеплоена по очереди.",
"webhookEnable": "Включить webhook-приём",
"webhookEnableHint": "Когда выключено, триггер срабатывает только из внутренних источников (по конфигу его вида) и кнопки ручного деплоя.",
"webhookRequireSig": "Требовать HMAC-подпись",
"webhookRequireSigHint": "Отклонять запросы без корректного X-Hub-Signature-256. Рекомендуется, если URL доступен из публичной сети.",
"webhookUrlLabel": "URL приёма",
"webhookUrlNote": "Вставьте это в настройки CI / registry / webhook GitHub. Сегмент-секрет — это пароль, обращайтесь как с паролем.",
"webhookCopy": "Копировать",
"webhookCopied": "Скопировано",
"webhookRotate": "Сменить секрет",
"webhookRotating": "Смена…",
"webhookDisabledNote": "Webhook-приём выключен. Включите тумблер, сохраните — и URL появится здесь.",
"bindings": "Привязанные нагрузки",
"bindingsSub": "Все нагрузки, слушающие этот триггер. Чтобы привязать новую нагрузку, откройте её страницу и добавьте этот триггер оттуда.",
"bindingsEmpty": "К этому триггеру пока не привязана ни одна нагрузка. Откройте нагрузку и привяжите этот триггер из её панели «Триггеры».",
"bindingsListItem": {
"openWorkload": "Открыть нагрузку",
"unbind": "Отвязать"
},
"bindingEnabledHint": "Выключите, чтобы оставить привязку, но запретить триггеру передеплоить эту нагрузку.",
"dangerZone": "Опасная зона",
"dangerZoneSub": "Удаление триггера происходит сразу. Все привязки к нему удаляются каскадом.",
"deleteButton": "Удалить триггер",
"deleteTitle": "Удалить триггер?",
"deleteMessage": "Триггер «{name}» будет удалён немедленно вместе с {count} привязкой(-ами). Действие необратимо.",
"rotateTitle": "Сменить секрет webhook?",
"rotateMessage": "Текущий URL приёма перестанет работать сразу. После смены обновите URL во всех внешних интеграциях.",
"rotateConfirm": "Сменить",
"unbindTitle": "Отвязать нагрузку?",
"unbindMessage": "Нагрузка «{name}» перестанет передеплоиваться при срабатывании этого триггера. Сама нагрузка не удаляется.",
"unbindConfirm": "Отвязать"
},
"form": {
"kindLabel": "Вид",
"kindHint": "Выберите источник сигнала передеплоя. Форма ниже подстраивается под вид.",
"name": "Имя",
"namePlaceholder": "например, ghcr.io/me/api · main",
"required": "ОБЯЗАТЕЛЬНО",
"configLabel": "Конфигурация",
"image": "Ссылка на образ",
"imagePlaceholder": "registry.example.com/owner/app",
"imageHint": "Полная ссылка на образ без тега — Tinyforge ловит новые теги, выкладываемые под этой ссылкой.",
"tagPattern": "Шаблон тега",
"tagPatternPlaceholder": "*",
"tagPatternHint": "Glob path.Match (например, v*, release-*). * совпадает с любым тегом.",
"repo": "Репозиторий",
"repoPlaceholder": "owner/name",
"repoHint": "owner/name в формате git-хостинга, не зависит от провайдера.",
"mode": "Режим",
"modePush": "Push в ветку",
"modeTag": "Создание тега",
"branch": "Ветка",
"branchPlaceholder": "main",
"branchHint": "Только push'и, продвигающие эту ветку, дёргают триггер.",
"manualNote": "У ручных триггеров нет конфига. Они срабатывают только через кнопку Deploy на странице нагрузки или POST /workloads/{id}/deploy.",
"unknownNote": "У этого вида ещё нет встроенной формы. Используйте JSON-редактор ниже; сервер валидирует форму.",
"advancedToggle": "Расширенный JSON",
"advancedHint": "Запасной вариант для опытных пользователей — заменяет структурированную форму сырым payload'ом.",
"configJson": "JSON конфигурации",
"configJsonHint": "Должен распарситься как корректный JSON-объект. Структура проверяется сервером по виду.",
"invalidJson": "Некорректный JSON — сервер отклонит.",
"webhookEnabled": "Включить webhook-приём сразу",
"webhookEnabledHint": "Генерирует секретный URL, по которому внешние системы могут дёргать триггер.",
"webhookRequireSig": "Требовать HMAC-подпись",
"webhookRequireSigHint": "Отклонять неподписанные запросы. Секрет — тот же, что вшит в URL — подпишите тело HMAC-SHA256 и пришлите в X-Hub-Signature-256.",
"submit": "Создать триггер",
"submitting": "Создание…",
"cancel": "Отмена"
},
"binding": {
"enabled": "Включена",
"disabled": "Выключена"
}
},
"apps": {
"new": {
"triggers": {
"section": "Триггер",
"sectionSub": "Необязательно. Выберите, откуда придёт сигнал передеплоя — слежение за реестром, git-событие или ручная кнопка.",
"modeInline": "Создать триггер",
"modeInlineHint": "Создаёт новую запись триггера, привязанную к этому приложению — подходит для частого случая 1:1.",
"modePick": "Выбрать существующий",
"modePickHint": "Привязать существующий триггер, чтобы несколько приложений делили один сигнал.",
"modeSkip": "Пропустить — добавить позже",
"modeSkipHint": "Приложение создаётся без привязки триггера. Ручной деплой по-прежнему работает.",
"switchToPick": "Выбрать существующий →",
"switchToInline": "← Создать новый триггер",
"switchToSkip": "Пропустить",
"pickPlaceholder": "Выберите триггер…",
"pickEmpty": "Триггеров ещё нет — создайте один выше или перейдите в /triggers.",
"pickLabel": "Существующий триггер",
"pickHint": "Один триггер можно привязать к нескольким приложениям. Управление автономными триггерами — в /triggers.",
"pickWebhookOn": "ВЕБХУК ВКЛ",
"skippedNote": "Триггер не будет привязан. Добавьте его из панели «Триггеры» в карточке приложения после создания.",
"bindError": "Приложение создано, но привязка триггера не удалась: {error}. Откройте панель «Триггеры» в карточке, чтобы повторить."
}
},
"detail": {
"manualDeploySub": "Обходит настроенные триггеры и отправляет деплой напрямую через source-плагин.",
"chainTriggersZero": "без триггеров",
"chainTriggersOne": "1 триггер",
"chainTriggersMany": "{count} триггер(ов)",
"bindings": {
"title": "Триггеры",
"subEmpty": "Триггеры не привязаны. Ручной деплой работает — добавьте триггер, чтобы подключить передеплой по реестру / git / вебхуку.",
"subCount": "{count} привязанный триггер",
"subCountMany": "{count} привязанных триггеров",
"addButton": "Добавить триггер",
"openTrigger": "Открыть триггер",
"unbindAction": "Отвязать",
"rowEnabled": "Включён",
"rowDisabled": "Выключен",
"rowEnableHint": "Отключите, чтобы сохранить привязку, но остановить передеплой этого приложения.",
"loading": "Загрузка триггеров…",
"loadError": "Не удалось загрузить привязки триггеров",
"unbindTitle": "Отвязать триггер?",
"unbindMessage": "Триггер «{name}» перестанет передеплоить это приложение. Сам триггер не удаляется — он остаётся в /triggers и сохраняет привязки к другим приложениям.",
"unbindConfirm": "Отвязать",
"modal": {
"title": "Добавить триггер",
"subtitle": "Привяжите триггер к этому приложению — создайте новый или выберите существующий, чтобы делить его.",
"tabInline": "Создать новый",
"tabPick": "Выбрать существующий",
"submitInline": "Создать и привязать",
"submitPick": "Привязать",
"submitting": "Привязка…",
"cancel": "Отмена",
"error": "Не удалось привязать",
"pickPlaceholder": "Выберите триггер…",
"pickEmpty": "Триггеров ещё нет — переключитесь на «Создать новый», чтобы добавить.",
"pickLabel": "Существующий триггер",
"pickKind": "Фильтр по виду",
"pickKindAll": "Все виды"
},
"override": {
"toggle": "Переопределить",
"title": "Переопределения привязки",
"subtitle": "Переопределите поля конфига триггера только для этого приложения. Верхнеуровневые ключи отсюда побеждают; остальное наследуется из триггера.",
"badgeOne": "ПЕРЕОПРЕДЕЛЕНО: 1 ПОЛЕ",
"badgeMany": "ПЕРЕОПРЕДЕЛЕНО ПОЛЕЙ: {count}",
"badgeTitle": "Эта привязка переопределяет одно или несколько полей конфига триггера.",
"baseLabel": "Конфиг триггера",
"baseLoading": "Загрузка конфига триггера…",
"baseHint": "Конфиг родительского триггера в режиме чтения. Редактируйте его на странице триггера, если изменения нужны для всех привязок.",
"editLabel": "Переопределение (JSON-объект)",
"editHint": "Слияние по верхнему уровню: переопределяются только указанные здесь ключи. Оставьте {} — будет наследоваться без изменений.",
"previewLabel": "Итоговый конфиг",
"previewHint": "Предпросмотр того, что увидит эта привязка при срабатывании триггера (конфиг триггера + наложенное переопределение).",
"invalidJson": "Переопределение должно быть JSON-объектом.",
"tooLarge": "Размер переопределения — {size} Б, превышает серверный лимит {limit} Б.",
"errInvalidJson": "Нельзя сохранить: переопределение не является валидным JSON-объектом.",
"errTooLarge": "Нельзя сохранить: переопределение превышает серверный лимит 8 КиБ.",
"saveButton": "Сохранить переопределение",
"saving": "Сохранение…",
"resetButton": "Сбросить к наследованию",
"closeButton": "Закрыть"
}
}
}
}
}