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": "Приложения",
|
||||
"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": "Закрыть"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user