feat(observability): event-triggers + log-scan-rules UI + i18n

Operator-facing surfaces for the two backend features:

- /event-triggers — list (filter summary, status pill),
  /event-triggers/new (form with regex validation), and
  /event-triggers/[id] (edit + Send-test + delete) with
  CONFIGURED secret badge + clear-to-rotate flow, ConfirmDialog
  for delete, aria-live regions on async result slots.
- /log-scan-rules — list with scope filter chips and stats panel
  (active tails, RATE-LIMITED, COOLED DOWN, COMPILE ERRORS),
  /log-scan-rules/new (with EntityPicker for workload scope and
  inline RegexTestBox), /log-scan-rules/[id] (edit + server-side
  /test + delete + live RegexTestBox panel).
- web/src/lib/components/RegexTestBox.svelte — reusable
  client-side regex test with sample input + captures display.
- web/src/lib/api.ts — typed wrappers for EventTrigger and
  LogScanRule CRUD + /test + getLogScanStats +
  getEffectiveLogScanRules.
- web/src/routes/+layout.svelte — nav entries for both surfaces.
- web/src/lib/i18n/{en,ru}.json — ~90 keys under observability.*,
  triggers.*, logscan.* namespaces; Russian translations cover
  every key.

Design + a11y polish per a frontend-design review pass: all
boolean inputs use ToggleSwitch, all destructive actions use
ConfirmDialog with confirmVariant="danger" / onconfirm /
oncancel, hand-rolled .btn-primary replaced with global
forge-btn classes, hex colors replaced with var(--*) tokens,
role="alert" on error banners, aria-invalid + aria-describedby
on invalid-regex inputs, aria-busy on async forms, mobile
breakpoints (hide-md columns, .row.three collapsing 3→2→1,
.table-wrap overflow-x).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 22:18:29 +03:00
parent 7a9ff7ad54
commit 4707db1c3b
11 changed files with 4455 additions and 1 deletions
+226
View File
@@ -14,6 +14,9 @@
},
"nav": {
"dashboard": "Панель",
"apps": "Приложения",
"eventTriggers": "Триггеры",
"logScanRules": "Лог-правила",
"projects": "Проекты",
"deploy": "Деплой",
"proxies": "Прокси",
@@ -1301,5 +1304,228 @@
"thresholds": "Пороги",
"thresholdsDesc": "Настройте, когда Tinyforge помечает контейнеры как устаревшие и предупреждает о неиспользуемых образах.",
"dangerZone": "Опасная зона"
},
"observability": {
"section": "Наблюдаемость",
"manage": "управление",
"loading": "Загрузка…",
"anyEvent": "любое событие",
"noUrlSet": "URL не настроен",
"configured": "НАСТРОЕН",
"clear": "Очистить",
"advanced": "Расширенно",
"cancel": "Отмена",
"save": "Сохранить",
"saving": "Сохранение…",
"delete": "Удалить",
"deleting": "Удаление…",
"refresh": "Обновить",
"open": "Открыть",
"edit": "Изменить",
"back": "Назад",
"regex": {
"sampleLabel": "Пример строки",
"placeholder": "вставьте сюда характерную строку лога",
"promptType": "введите образец для проверки шаблона",
"noMatch": "НЕТ СОВПАДЕНИЯ",
"noMatchHint": "шаблон не совпал с этой строкой",
"match": "СОВПАЛО",
"invalid": "REGEX",
"captures": "Группы"
}
},
"triggers": {
"title": "Триггеры событий",
"titleNew": "Новый триггер",
"titleSingular": "Триггер",
"lede": "Фильтруйте записи журнала событий (события деплоев, вывод сканера логов, будущие источники) и отправляйте webhook при совпадении. Фильтры объединяются по И; пустой фильтр означает «совпадает всё».",
"ledeNew": "Создайте правило «фильтр + действие». Диспетчер объединяет фильтры по И. Оставьте поле пустым, чтобы пропустить это измерение.",
"stat": {
"total": "ВСЕГО",
"enabled": "ВКЛЮЧЕНО",
"disabled": "ВЫКЛЮЧЕНО"
},
"toolbar": {
"newButton": "Новый триггер",
"backToList": "К списку триггеров"
},
"empty": {
"heading": "Триггеров пока нет",
"body": "Настройте триггер, чтобы пересылать записи журнала событий в Slack, мост уведомлений или любой HTTP-приёмник. Tinyforge подписывает запросы заголовком X-Hub-Signature-256, если задан секрет.",
"cta": "Создать первый триггер"
},
"list": {
"name": "Имя",
"filters": "Фильтры",
"action": "Действие",
"status": "Статус",
"open": "Открыть"
},
"detail": {
"config": "Конфигурация",
"configSub": "id #{id} · обновлено {updatedAt}",
"dangerZone": "Опасная зона",
"dangerZoneSub": "Удаление триггера происходит сразу. Восстановления нет.",
"sendTest": "Отправить тест",
"sending": "Отправка…",
"testHttp": "HTTP {code}",
"testSigned": "подписано",
"testOk": "OK",
"testFail": "ОШИБКА",
"deleteButton": "Удалить триггер",
"deleteTitle": "Удалить триггер?",
"deleteMessage": "Триггер «{name}» будет удалён немедленно. Действие необратимо."
},
"form": {
"name": "Имя",
"namePlaceholder": "например, Slack #alerts при сбое деплоя",
"required": "ОБЯЗАТЕЛЬНО",
"andComposed": "ОБЪЕДИНЕНИЕ ПО И",
"filtersLabel": "Фильтры",
"actionLabel": "Действие",
"actionWebhookBadge": "WEBHOOK",
"severityCsv": "Уровень (CSV)",
"severityPlaceholder": "warn,error",
"sourceCsv": "Источник (CSV)",
"sourcePlaceholder": "deploy,logscan",
"messageRegex": "Регулярное выражение сообщения (необязательно)",
"messageRegexPlaceholder": "(?i)\\bpanic\\b",
"invalidRegex": "Некорректный regex — сервер отклонит.",
"urlLabel": "URL",
"urlPlaceholder": "https://hooks.slack.com/services/...",
"secretLabel": "HMAC-секрет (необязательно)",
"secretPlaceholder": "оставьте пустым для неподписанной доставки",
"secretHint": "Приёмники проверяют X-Hub-Signature-256 по сырому телу запроса.",
"secretRotateHint": "Хранится в зашифрованном виде. После создания API не возвращает значение — оставьте плейсхолдер без изменений, чтобы сохранить существующий секрет, введите новое значение для смены или очистите и сохраните, чтобы отключить подпись.",
"enabled": "Включён",
"enabledHint": "Выключенные триггеры остаются в таблице, но не срабатывают.",
"submit": "Создать триггер",
"submitting": "Создание…",
"webhookUrl": "URL webhook"
},
"status": {
"enabled": "включён",
"disabled": "выключен"
}
},
"logscan": {
"title": "Правила сканирования логов",
"titleNew": "Новое правило",
"titleSingular": "Правило",
"lede": "Регулярные выражения, которые сканер применяет к потоку логов каждого работающего контейнера. Совпавшие строки попадают в event_log с уровнем правила, откуда триггеры событий передают их на настроенные webhook-приёмники. Включено {enabled} из {total}.",
"ledeNew": "Сканируйте логи контейнеров по регулярному выражению. Оставьте поле «нагрузка» пустым, чтобы создать глобальное правило. Чтобы переопределить глобальное для одной нагрузки, используйте действие «Переопределить» на странице нагрузки.",
"stat": {
"total": "ВСЕГО",
"global": "ГЛОБАЛЬНЫЕ",
"workload": "НАГРУЗКА",
"overrides": "ПЕРЕОПРЕДЕЛЕНИЯ",
"activeTails": "АКТИВНЫХ TAIL",
"droppedBucket": "ЛИМИТ",
"droppedCooldown": "COOLDOWN",
"compileErrors": "ОШИБКИ КОМПИЛЯЦИИ"
},
"stats": {
"heading": "Статистика сканера",
"headingSub": "Счётчики отбрасываний движка и ошибки компиляции из последнего снимка. Счётчики сбрасываются при перезапуске сервера.",
"noCompileErrors": "Все правила компилируются без ошибок.",
"compileErrorsHeading": "Ошибки компиляции (правило отброшено из снимка)",
"tailsExplain": "Сейчас открыто goroutine-tail'ов по контейнерам у менеджера сканера."
},
"toolbar": {
"newButton": "Новое правило",
"backToList": "К списку правил"
},
"filter": {
"all": "ВСЕ",
"global": "ГЛОБАЛЬНЫЕ",
"workload": "НАГРУЗКА",
"overrides": "ПЕРЕОПРЕДЕЛЕНИЯ"
},
"empty": {
"heading": "Правил пока нет",
"body": "Начните с глобального правила вроде (?i)\\bpanic\\b с уровнем error, затем сужайте по нагрузкам через переопределения на странице нагрузки.",
"cta": "Создать первое правило"
},
"list": {
"name": "Имя",
"pattern": "Шаблон",
"scope": "Область",
"severity": "Уровень",
"streams": "Потоки",
"status": "Статус",
"open": "Открыть"
},
"detail": {
"config": "Конфигурация",
"configSub": "id #{id} · область {scope}",
"regexTest": "Проверка regex",
"regexTestSub": "Предпросмотр использует JavaScript-движок regex в браузере. Нажмите «Проверить на сервере», чтобы получить авторитетную проверку Go RE2 — это единственный надёжный сигнал для конструкций, специфичных для RE2.",
"runServerTest": "Проверить на сервере",
"testing": "Проверка…",
"serverTestHint": "Сначала введите пример строки выше",
"serverTestSendHint": "Отправить пример на backend /test",
"serverMatch": "СОВПАЛО (СЕРВЕР)",
"serverNoMatch": "НЕТ СОВПАДЕНИЯ",
"serverNoMatchHint": "серверный regex не совпал с примером",
"serverError": "ОШИБКА",
"dangerZone": "Опасная зона",
"dangerZoneSub": "Удаление глобального правила каскадно удаляет его переопределения для нагрузок.",
"deleteButton": "Удалить правило",
"deleteTitle": "Удалить правило?",
"deleteMessage": "Правило «{name}» будет удалено немедленно. Переопределения по нагрузкам, ссылающиеся на него, также удалятся."
},
"form": {
"name": "Имя",
"namePlaceholder": "например, Panic в воркере",
"pattern": "Шаблон",
"regex": "REGEX",
"patternPlaceholder": "(?i)\\bpanic\\b",
"invalidRegex": "Некорректный regex — сервер отклонит.",
"matchShape": "Параметры совпадения",
"matchShapeOpts": "УРОВЕНЬ · ПОТОКИ · COOLDOWN",
"severity": "Уровень",
"streams": "Потоки",
"cooldown": "Cooldown (с)",
"cooldownHint": "Cooldown — на правило × на контейнер: одно правило, срабатывающее в двух контейнерах, считается независимо. Token bucket ограничивает выдачу на контейнер до 10 событий / 60с, чтобы не переполнить event_log.",
"scope": "Область",
"scopePlaceholder": "пусто для глобального правила или вставьте id нагрузки",
"scopeHint": "Правила области нагрузки применяются только к её контейнерам. Переопределения для отдельных нагрузок проще создавать со страницы нагрузки.",
"scopeGlobal": "Глобально (применяется ко всем нагрузкам)",
"scopePick": "Выбрать нагрузку…",
"scopePickTitle": "Выберите нагрузку",
"scopeClear": "Сделать глобальным",
"scopeSelected": "Нагрузка",
"scopeUnknown": "Неизвестная нагрузка",
"enabled": "Включено",
"enabledHint": "Выключенные правила остаются в таблице, но не срабатывают.",
"required": "ОБЯЗАТЕЛЬНО",
"optional": "НЕОБЯЗАТЕЛЬНО",
"submit": "Создать правило",
"submitting": "Создание…"
},
"scope": {
"global": "глобальное",
"workload": "нагрузка {id}",
"override": "переопределение #{id}",
"overrideShort": "переопр. #{id}"
},
"status": {
"enabled": "включено",
"disabled": "выключено",
"on": "вкл",
"off": "выкл"
},
"panel": {
"heading": "Лог-правила",
"subEmpty": "Для этой нагрузки правил нет",
"subCount": "Действует правил: {count}",
"subCountOne": "Действует 1 правило",
"emptyHint": "Для этой нагрузки нет правил сканирования логов. Создайте через «Новое правило» — глобальные правила применяются автоматически; для этой нагрузки также можно завести свои или переопределения.",
"newRule": "Новое правило",
"footerHint": "Глобальные правила применяются ко всем нагрузкам. Правила нагрузки — только здесь. Переопределения замещают глобальное для этой нагрузки — изменяйте уровень или отключайте их, не трогая исходное глобальное.",
"override": "Переопределить",
"overriding": "Переопределение…",
"overrideTitle": "Создать переопределение глобального правила для этой нагрузки"
}
}
}