From 4707db1c3b9b485ce10ba95cb27a75b3905a2575 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 11 May 2026 22:18:29 +0300 Subject: [PATCH] feat(observability): event-triggers + log-scan-rules UI + i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/src/lib/api.ts | 272 +++++++ web/src/lib/components/RegexTestBox.svelte | 224 ++++++ web/src/lib/i18n/en.json | 226 ++++++ web/src/lib/i18n/ru.json | 226 ++++++ web/src/routes/+layout.svelte | 9 +- web/src/routes/event-triggers/+page.svelte | 428 +++++++++++ .../routes/event-triggers/[id]/+page.svelte | 667 ++++++++++++++++ .../routes/event-triggers/new/+page.svelte | 417 ++++++++++ web/src/routes/log-scan-rules/+page.svelte | 715 ++++++++++++++++++ .../routes/log-scan-rules/[id]/+page.svelte | 683 +++++++++++++++++ .../routes/log-scan-rules/new/+page.svelte | 589 +++++++++++++++ 11 files changed, 4455 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/components/RegexTestBox.svelte create mode 100644 web/src/routes/event-triggers/+page.svelte create mode 100644 web/src/routes/event-triggers/[id]/+page.svelte create mode 100644 web/src/routes/event-triggers/new/+page.svelte create mode 100644 web/src/routes/log-scan-rules/+page.svelte create mode 100644 web/src/routes/log-scan-rules/[id]/+page.svelte create mode 100644 web/src/routes/log-scan-rules/new/+page.svelte diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e661c71..c25e6f7 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1078,6 +1078,141 @@ export function setWorkloadAppID(id: string, appID: string): Promise { return patch(`/api/workloads/${id}/app`, { app_id: appID }); } +export function createPluginWorkload(body: import('./types').PluginWorkloadInput): Promise { + return post('/api/workloads', body); +} + +export function updatePluginWorkload(id: string, body: import('./types').PluginWorkloadInput): Promise { + return put(`/api/workloads/${id}/plugin`, body); +} + +export function deployPluginWorkload( + id: string, + body?: { reference?: string; note?: string } +): Promise<{ workload_id: string; reference: string; triggered_by: string }> { + return post(`/api/workloads/${id}/deploy`, body ?? {}); +} + +export function listHookKinds(signal?: AbortSignal): Promise { + return get('/api/hooks/kinds', signal); +} + +export function deletePluginWorkload(id: string): Promise<{ deleted: string }> { + return del<{ deleted: string }>(`/api/workloads/${id}`); +} + +export interface WorkloadEnv { + id: string; + workload_id: string; + key: string; + value: string; + encrypted: boolean; + created_at: string; + updated_at: string; +} + +export function listWorkloadEnv(id: string, signal?: AbortSignal): Promise { + return get(`/api/workloads/${id}/env`, signal); +} + +export function setWorkloadEnv( + id: string, + body: { key: string; value: string; encrypted: boolean } +): Promise { + return put(`/api/workloads/${id}/env`, body); +} + +export function deleteWorkloadEnv(id: string, envID: string): Promise<{ deleted: string }> { + return del<{ deleted: string }>(`/api/workloads/${id}/env/${envID}`); +} + +export interface WorkloadWebhook { + webhook_url: string; + webhook_secret: string; + has_signing_secret: boolean; + webhook_require_signature: boolean; +} + +export function getWorkloadWebhook(id: string, signal?: AbortSignal): Promise { + return get(`/api/workloads/${id}/webhook`, signal); +} + +export function regenerateWorkloadWebhook(id: string): Promise { + return post(`/api/workloads/${id}/webhook/regenerate`); +} + +export function fetchWorkloadContainerLogs( + workloadId: string, + containerRowId: string, + tail: number +): Promise { + return get( + `/api/workloads/${workloadId}/containers/${containerRowId}/logs?tail=${tail}` + ); +} + +export interface WorkloadVolume { + id: string; + workload_id: string; + source: string; + target: string; + scope: string; + name: string; + created_at: string; + updated_at: string; +} + +export function listWorkloadVolumes(id: string, signal?: AbortSignal): Promise { + return get(`/api/workloads/${id}/volumes`, signal); +} + +export function setWorkloadVolume( + id: string, + body: { source: string; target: string; scope: string; name?: string } +): Promise { + return put(`/api/workloads/${id}/volumes`, body); +} + +export function deleteWorkloadVolume(id: string, volID: string): Promise<{ deleted: string }> { + return del<{ deleted: string }>(`/api/workloads/${id}/volumes/${volID}`); +} + +export interface HookKindSchema { + kind: string; + sample: unknown; +} + +export function getHookKindSchema(kind: string, signal?: AbortSignal): Promise { + return get(`/api/hooks/kinds/${kind}/schema`, signal); +} + +export interface WorkloadChainNode { + id: string; + name: string; + source_kind: string; + trigger_kind: string; + created_at: string; + updated_at: string; +} + +export interface WorkloadChain { + parent: WorkloadChainNode | null; + self: WorkloadChainNode; + children: WorkloadChainNode[]; +} + +export function getWorkloadChain(id: string, signal?: AbortSignal): Promise { + return get(`/api/workloads/${id}/chain`, signal); +} + +export function promoteFromWorkload( + targetID: string, + sourceID: string, + body?: { image_tag?: string; deploy?: boolean } +): Promise<{ workload_id: string; source_id: string; promoted_tag: string; deploy_queued: boolean }> { + return post(`/api/workloads/${targetID}/promote-from/${sourceID}`, body ?? {}); +} + // ── Containers (global index) ─────────────────────────────────────── export interface ListContainersFilter { @@ -1121,4 +1256,141 @@ export function deleteApp(id: string): Promise { return del(`/api/apps/${id}`); } +// ── Event Triggers ────────────────────────────────────────────────── +// Backend: internal/api/event_triggers.go. AND-composed filter shape; +// empty filter fields mean "match any value." The dispatcher fans +// matching event_log entries out to action_target via signed webhook. + +export interface EventTrigger { + id: number; + name: string; + filter_severity: string; // CSV; "" = any + filter_source: string; // CSV; "" = any + filter_message_regex: string; // "" = any + action_type: string; // 'webhook' today + action_target: string; // URL + action_secret: string; // optional HMAC secret + enabled: boolean; + created_at: string; + updated_at: string; +} + +export interface EventTriggerInput { + name: string; + filter_severity?: string; + filter_source?: string; + filter_message_regex?: string; + action_type?: string; + action_target: string; + action_secret?: string; + enabled?: boolean; +} + +export function listEventTriggers(signal?: AbortSignal): Promise { + return get('/api/event-triggers', signal); +} + +export function getEventTrigger(id: number, signal?: AbortSignal): Promise { + return get(`/api/event-triggers/${id}`, signal); +} + +export function createEventTrigger(data: EventTriggerInput): Promise { + return post('/api/event-triggers', data); +} + +export function updateEventTrigger(id: number, data: EventTriggerInput): Promise { + return patch(`/api/event-triggers/${id}`, data); +} + +export function deleteEventTrigger(id: number): Promise { + return del(`/api/event-triggers/${id}`); +} + +export function testEventTrigger(id: number): Promise { + return post(`/api/event-triggers/${id}/test`); +} + +// ── Log scan rules ────────────────────────────────────────────────── +// Backend: internal/api/log_scan_rules.go. Rules are regex patterns +// the scanner manager evaluates against container log lines. Scope +// model: workload_id="" + overrides_id=0 → global; workload_id set → +// workload-only (or per-workload override of a global via +// overrides_id). + +export interface LogScanRule { + id: number; + workload_id: string; // "" = global + overrides_id: number; // 0 = not an override + name: string; + pattern: string; + severity: 'info' | 'warn' | 'error'; + streams: 'all' | 'stdout' | 'stderr'; + cooldown_seconds: number; + enabled: boolean; + created_at: string; + updated_at: string; +} + +export interface LogScanRuleInput { + workload_id?: string; + overrides_id?: number; + name: string; + pattern: string; + severity?: 'info' | 'warn' | 'error'; + streams?: 'all' | 'stdout' | 'stderr'; + cooldown_seconds?: number; + enabled?: boolean; +} + +export interface LogScanTestResult { + matched: boolean; + captures?: Record; + error?: string; +} + +export function listLogScanRules(opts?: { + workloadID?: string; + signal?: AbortSignal; +}): Promise { + const params = opts?.workloadID ? `?workload_id=${encodeURIComponent(opts.workloadID)}` : ''; + return get(`/api/log-scan-rules${params}`, opts?.signal); +} + +export function getLogScanRule(id: number, signal?: AbortSignal): Promise { + return get(`/api/log-scan-rules/${id}`, signal); +} + +export function createLogScanRule(data: LogScanRuleInput): Promise { + return post('/api/log-scan-rules', data); +} + +export function updateLogScanRule(id: number, data: LogScanRuleInput): Promise { + return patch(`/api/log-scan-rules/${id}`, data); +} + +export function deleteLogScanRule(id: number): Promise { + return del(`/api/log-scan-rules/${id}`); +} + +export function testLogScanRule(id: number, sampleLine: string): Promise { + return post(`/api/log-scan-rules/${id}/test`, { sample_line: sampleLine }); +} + +export function getEffectiveLogScanRules(workloadID: string, signal?: AbortSignal): Promise { + return get(`/api/workloads/${workloadID}/effective-rules`, signal); +} + +export interface LogScanStats { + engine: { + dropped_by_bucket: number; + dropped_by_cooldown: number; + }; + active_tails: number; + last_compile_errors: string[]; +} + +export function getLogScanStats(signal?: AbortSignal): Promise { + return get('/api/log-scan-rules/stats', signal); +} + export { ApiError }; diff --git a/web/src/lib/components/RegexTestBox.svelte b/web/src/lib/components/RegexTestBox.svelte new file mode 100644 index 0000000..58c0977 --- /dev/null +++ b/web/src/lib/components/RegexTestBox.svelte @@ -0,0 +1,224 @@ + + + +
+ + + +
+ {#if result.state === 'empty'} +
{$t('observability.regex.promptType')}
+ {:else if result.state === 'invalid-pattern'} +
+ {$t('observability.regex.invalid')} + {result.error} +
+ {:else if result.state === 'no-match'} +
+ {$t('observability.regex.noMatch')} + {$t('observability.regex.noMatchHint')} +
+ {:else} +
+ {$t('observability.regex.match')} + {result.full} +
+ {#if result.captures.length > 0} +
+ {$t('observability.regex.captures')} +
+ {#each result.captures as c} +
+
{c.name}
+
{c.value || '∅'}
+
+ {/each} +
+
+ {/if} + {/if} +
+
+ + diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 9775dde..c124303 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -14,6 +14,9 @@ }, "nav": { "dashboard": "Dashboard", + "apps": "Apps", + "eventTriggers": "Triggers", + "logScanRules": "Log Rules", "projects": "Projects", "deploy": "Deploy", "proxies": "Proxies", @@ -1301,5 +1304,228 @@ "thresholds": "Thresholds", "thresholdsDesc": "Tune when Tinyforge flags stale containers and warns about unused image disk usage.", "dangerZone": "Danger zone" + }, + "observability": { + "section": "Observability", + "manage": "manage", + "loading": "Loading…", + "anyEvent": "any event", + "noUrlSet": "No URL configured", + "configured": "CONFIGURED", + "clear": "Clear", + "advanced": "Advanced", + "cancel": "Cancel", + "save": "Save changes", + "saving": "Saving…", + "delete": "Delete", + "deleting": "Deleting…", + "refresh": "Refresh", + "open": "Open", + "edit": "Edit", + "back": "Back", + "regex": { + "sampleLabel": "Sample line", + "placeholder": "paste a representative log line here", + "promptType": "type a sample to test the pattern", + "noMatch": "NO MATCH", + "noMatchHint": "pattern did not match this line", + "match": "MATCH", + "invalid": "REGEX", + "captures": "Captures" + } + }, + "triggers": { + "title": "Event triggers", + "titleNew": "Forge a new trigger", + "titleSingular": "Trigger", + "lede": "Filter event-log entries (deploy events, log scanner output, future sources) and dispatch a webhook when they match. Filters AND together; empty filters mean \"match anything.\"", + "ledeNew": "Create a filter+action rule. The dispatcher AND-composes all filter fields. Leave a field empty to skip that dimension.", + "stat": { + "total": "TOTAL", + "enabled": "ENABLED", + "disabled": "DISABLED" + }, + "toolbar": { + "newButton": "New trigger", + "backToList": "Back to triggers" + }, + "empty": { + "heading": "No triggers yet", + "body": "Configure a trigger to forward event-log entries to Slack, a notification bridge, or any HTTP receiver. Tinyforge signs requests with X-Hub-Signature-256 when a secret is set.", + "cta": "Create the first trigger" + }, + "list": { + "name": "Name", + "filters": "Filters", + "action": "Action", + "status": "Status", + "open": "Open" + }, + "detail": { + "config": "Configuration", + "configSub": "id #{id} · updated {updatedAt}", + "dangerZone": "Danger zone", + "dangerZoneSub": "Trigger deletion is immediate. No soft-delete.", + "sendTest": "Send test", + "sending": "Sending…", + "testHttp": "HTTP {code}", + "testSigned": "signed", + "testOk": "OK", + "testFail": "FAIL", + "deleteButton": "Delete trigger", + "deleteTitle": "Delete trigger?", + "deleteMessage": "Trigger \"{name}\" will be removed immediately. This cannot be undone." + }, + "form": { + "name": "Name", + "namePlaceholder": "e.g. Slack #alerts on deploy failure", + "required": "REQUIRED", + "andComposed": "AND-COMPOSED", + "filtersLabel": "Filters", + "actionLabel": "Action", + "actionWebhookBadge": "WEBHOOK", + "severityCsv": "Severity (CSV)", + "severityPlaceholder": "warn,error", + "sourceCsv": "Source (CSV)", + "sourcePlaceholder": "deploy,logscan", + "messageRegex": "Message regex (optional)", + "messageRegexPlaceholder": "(?i)\\bpanic\\b", + "invalidRegex": "Invalid regex — server will reject.", + "urlLabel": "URL", + "urlPlaceholder": "https://hooks.slack.com/services/...", + "secretLabel": "HMAC secret (optional)", + "secretPlaceholder": "leave blank for unsigned delivery", + "secretHint": "Receivers verify X-Hub-Signature-256 against the raw body.", + "secretRotateHint": "Stored encrypted at rest. The value is never returned by the API after creation — leave the placeholder untouched to preserve the existing secret, type a new value to rotate, or clear and save to remove signing.", + "enabled": "Enabled", + "enabledHint": "Disabled triggers stay in the table but never dispatch.", + "submit": "Forge trigger", + "submitting": "Forging…", + "webhookUrl": "Webhook URL" + }, + "status": { + "enabled": "enabled", + "disabled": "disabled" + } + }, + "logscan": { + "title": "Log scan rules", + "titleNew": "Forge a new rule", + "titleSingular": "Rule", + "lede": "Regex patterns the scanner runs against every running container's log stream. Matched lines land in event_log with the rule's severity, where event triggers pick them up and fan out to operator-configured webhooks. {enabled} of {total} enabled.", + "ledeNew": "Tail container logs against a regex. Leave the workload field empty to create a global rule. To override an existing global for one workload, use the per-workload override action on the workload detail page.", + "stat": { + "total": "TOTAL", + "global": "GLOBAL", + "workload": "WORKLOAD", + "overrides": "OVERRIDES", + "activeTails": "ACTIVE TAILS", + "droppedBucket": "RATE-LIMITED", + "droppedCooldown": "COOLED DOWN", + "compileErrors": "COMPILE ERRORS" + }, + "stats": { + "heading": "Scanner stats", + "headingSub": "Engine drop counters and last-snapshot compile errors. Counters reset on server restart.", + "noCompileErrors": "All rules compile cleanly.", + "compileErrorsHeading": "Compile errors (rule dropped from snapshot)", + "tailsExplain": "Per-container tail goroutines currently driven by the scanner manager." + }, + "toolbar": { + "newButton": "New rule", + "backToList": "Back to rules" + }, + "filter": { + "all": "ALL", + "global": "GLOBAL", + "workload": "WORKLOAD", + "overrides": "OVERRIDES" + }, + "empty": { + "heading": "No rules yet", + "body": "Start with a global rule like (?i)\\bpanic\\b at severity error, then narrow per-workload via overrides on the workload detail page.", + "cta": "Create the first rule" + }, + "list": { + "name": "Name", + "pattern": "Pattern", + "scope": "Scope", + "severity": "Severity", + "streams": "Streams", + "status": "Status", + "open": "Open" + }, + "detail": { + "config": "Configuration", + "configSub": "id #{id} · scope {scope}", + "regexTest": "Regex test", + "regexTestSub": "Live preview uses the browser's JavaScript regex engine. Click \"Run server test\" to evaluate against Go's RE2 — authoritative and the only reliable signal for RE2-only constructs.", + "runServerTest": "Run server test", + "testing": "Testing…", + "serverTestHint": "Enter a sample line above first", + "serverTestSendHint": "Send sample to backend /test endpoint", + "serverMatch": "SERVER MATCH", + "serverNoMatch": "NO MATCH", + "serverNoMatchHint": "server regex did not match the sample", + "serverError": "ERROR", + "dangerZone": "Danger zone", + "dangerZoneSub": "Deleting a global rule cascade-removes its per-workload overrides.", + "deleteButton": "Delete rule", + "deleteTitle": "Delete rule?", + "deleteMessage": "Rule \"{name}\" will be removed immediately. Per-workload overrides referencing it will be cascade-deleted." + }, + "form": { + "name": "Name", + "namePlaceholder": "e.g. Panic in worker", + "pattern": "Pattern", + "regex": "REGEX", + "patternPlaceholder": "(?i)\\bpanic\\b", + "invalidRegex": "Invalid regex — server will reject.", + "matchShape": "Match shape", + "matchShapeOpts": "SEVERITY · STREAMS · COOLDOWN", + "severity": "Severity", + "streams": "Streams", + "cooldown": "Cooldown (s)", + "cooldownHint": "Cooldown is per-rule per-container — the same rule firing on two containers stays independent. Token bucket caps per-container emissions at 10 events / 60s to prevent flooding event_log.", + "scope": "Scope", + "scopePlaceholder": "empty for global rule, or paste a workload id", + "scopeHint": "Workload-scoped rules apply only to that workload's containers. Per-workload overrides are easier to create from the workload detail page.", + "scopeGlobal": "Global (applies to every workload)", + "scopePick": "Pick workload…", + "scopePickTitle": "Pick a workload", + "scopeClear": "Make global", + "scopeSelected": "Workload", + "scopeUnknown": "Unknown workload", + "enabled": "Enabled", + "enabledHint": "Disabled rules stay in the table but never fire.", + "required": "REQUIRED", + "optional": "OPTIONAL", + "submit": "Forge rule", + "submitting": "Forging…" + }, + "scope": { + "global": "global", + "workload": "workload {id}", + "override": "override of #{id}", + "overrideShort": "override #{id}" + }, + "status": { + "enabled": "enabled", + "disabled": "disabled", + "on": "on", + "off": "off" + }, + "panel": { + "heading": "Log rules", + "subEmpty": "No effective rules for this workload", + "subCount": "{count} effective rules", + "subCountOne": "1 effective rule", + "emptyHint": "This workload has no log scan rules applied. Create one via New rule — globals apply automatically; this workload can also have its own narrower rules or overrides.", + "newRule": "New rule", + "footerHint": "Global rules apply to every workload. Workload rules apply only here. Override rows substitute for a global on this workload — edit them to disable or change severity per-workload without touching the global.", + "override": "Override", + "overriding": "Overriding…", + "overrideTitle": "Create a per-workload override of this global rule" + } } } diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 12ac925..e3e3469 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -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": "Создать переопределение глобального правила для этой нагрузки" + } } } diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 51dd6ba..c8a0f2b 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -32,8 +32,11 @@ countKey?: NavCountKey; /** When true the badge uses a danger style (red). */ alert?: boolean; + /** Static label override when the i18n catalogue does not yet carry the key. */ + labelOverride?: string; }> = [ { href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' }, + { href: '/apps', labelKey: 'nav.apps', icon: 'box' }, { href: '/projects', labelKey: 'nav.projects', icon: 'projects', countKey: 'projects' }, { href: '/sites', labelKey: 'nav.sites', icon: 'globe', countKey: 'sites' }, { href: '/stacks', labelKey: 'nav.stacks', icon: 'stacks', countKey: 'stacks' }, @@ -41,6 +44,8 @@ { href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' }, { href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' }, { href: '/events', labelKey: 'nav.events', icon: 'events', countKey: 'eventsErrors', alert: true }, + { href: '/event-triggers', labelKey: 'nav.eventTriggers', icon: 'events', labelOverride: 'Triggers' }, + { href: '/log-scan-rules', labelKey: 'nav.logScanRules', icon: 'events', labelOverride: 'Log Rules' }, { href: '/settings', labelKey: 'nav.settings', icon: 'settings' } ]; @@ -278,6 +283,8 @@ {:else if item.icon === 'projects'} + {:else if item.icon === 'box'} + {:else if item.icon === 'globe'} {:else if item.icon === 'stacks'} @@ -293,7 +300,7 @@ {:else if item.icon === 'settings'} {/if} - {$t(item.labelKey)} + {item.labelOverride ?? $t(item.labelKey)} {#if item.countKey} {@const count = $navCounts[item.countKey]} diff --git a/web/src/routes/event-triggers/+page.svelte b/web/src/routes/event-triggers/+page.svelte new file mode 100644 index 0000000..6cd9731 --- /dev/null +++ b/web/src/routes/event-triggers/+page.svelte @@ -0,0 +1,428 @@ + + + + {$t('triggers.title')} · Tinyforge + + +
+ {#snippet toolbar()} + + + + {$t('triggers.toolbar.newButton')} + + {/snippet} + + {#snippet stats()} +
+
{$t('triggers.stat.total')}
+
{loading ? '—' : String(triggers.length).padStart(2, '0')}
+
+
+
{$t('triggers.stat.enabled')}
+
{loading ? '—' : String(enabledCount).padStart(2, '0')}
+
+
+
{$t('triggers.stat.disabled')}
+
{loading ? '—' : String(disabledCount).padStart(2, '0')}
+
+ {/snippet} + + {#snippet lede()} + {$t('triggers.lede')} + {/snippet} + + + + {#if error} + + {/if} + + {#if loading} +
+ {#each Array(3) as _, i} +
+ {/each} +
+ {:else if triggers.length === 0} +
+ +

{$t('triggers.empty.heading')}

+

{$t('triggers.empty.body')}

+ + {$t('triggers.empty.cta')} + +
+ {:else} +
+ + + + + + + + + + + + {#each triggers as trig, i (trig.id)} + + + + + + + + {/each} + +
{$t('triggers.list.name')}{$t('triggers.list.filters')}{$t('triggers.list.action')}{$t('triggers.list.status')}{$t('triggers.list.open')}
+ + {String(i + 1).padStart(2, '0')} + {trig.name} + + + {filterSummary(trig)} + +
+ {trig.action_type} + {trig.action_target} +
+
+ + + {trig.enabled ? $t('triggers.status.enabled') : $t('triggers.status.disabled')} + + + + {$t('observability.open')} + +
+
+ {/if} +
+ + diff --git a/web/src/routes/event-triggers/[id]/+page.svelte b/web/src/routes/event-triggers/[id]/+page.svelte new file mode 100644 index 0000000..20d8bb2 --- /dev/null +++ b/web/src/routes/event-triggers/[id]/+page.svelte @@ -0,0 +1,667 @@ + + + + {trigger?.name ?? $t('triggers.titleSingular')} · Tinyforge + + +
+ + + {#if error} + + {/if} + + {#if loading || !trigger} +
+ {#each Array(3) as _, i} +
+ {/each} +
+ {:else} +
+
+

{$t('triggers.detail.config')}.

+ + {$t('triggers.detail.configSub', { id: String(trigger.id), updatedAt: trigger.updated_at })} + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + + {#if !regexValid} + + {$t('triggers.form.invalidRegex')} + + {/if} +
+ +
+ + +
+ +
+
+ + {#if secretConfigured} + + {$t('observability.configured')} + + + {/if} +
+ + + {$t('triggers.form.secretRotateHint')} + +
+ +
+
+ +

{$t('triggers.form.enabledHint')}

+
+ +
+ +
+ + +
+ +
+ {#if testResult} +
+
+ {testOk ? $t('triggers.detail.testOk') : $t('triggers.detail.testFail')} + {$t('triggers.detail.testHttp', { code: String(testResult.status_code) })} + {testResult.latency_ms}ms + {#if testResult.signature_sent} + · {$t('triggers.detail.testSigned')} + {/if} +
+ {#if testResult.error} +
{testResult.error}
+ {/if} + {#if testResult.response_snippet} +
{testResult.response_snippet}
+ {/if} +
+ {/if} +
+
+ +
+
+

+ {$t('triggers.detail.dangerZone')}. +

+ {$t('triggers.detail.dangerZoneSub')} +
+
+ +
+
+ + (confirmDelete = false)} + /> + {/if} +
+ + diff --git a/web/src/routes/event-triggers/new/+page.svelte b/web/src/routes/event-triggers/new/+page.svelte new file mode 100644 index 0000000..68945a9 --- /dev/null +++ b/web/src/routes/event-triggers/new/+page.svelte @@ -0,0 +1,417 @@ + + + + {$t('triggers.titleNew')} · Tinyforge + + +
+ {#snippet lede()} + {$t('triggers.ledeNew')} + {/snippet} + + + +
+ {#if error} + + {/if} + +
+ + +
+ +
+ + + {$t('triggers.form.filtersLabel')} + {$t('triggers.form.andComposed')} + +
+ + +
+ +
+ +
+ + + {$t('triggers.form.actionLabel')} + {$t('triggers.form.actionWebhookBadge')} + + + +
+ +
+
+ +

{$t('triggers.form.enabledHint')}

+
+ +
+ +
+ {$t('observability.cancel')} + +
+
+
+ + diff --git a/web/src/routes/log-scan-rules/+page.svelte b/web/src/routes/log-scan-rules/+page.svelte new file mode 100644 index 0000000..4177589 --- /dev/null +++ b/web/src/routes/log-scan-rules/+page.svelte @@ -0,0 +1,715 @@ + + + + {$t('logscan.title')} · Tinyforge + + +
+ {#snippet toolbar()} + + + + {$t('logscan.toolbar.newButton')} + + {/snippet} + + {#snippet stats()} +
+
{$t('logscan.stat.total')}
+
{loading ? '—' : String(rules.length).padStart(2, '0')}
+
+
+
{$t('logscan.stat.global')}
+
{loading ? '—' : String(globals.length).padStart(2, '0')}
+
+
+
{$t('logscan.stat.workload')}
+
{loading ? '—' : String(workloadOnly.length).padStart(2, '0')}
+
+
+
{$t('logscan.stat.overrides')}
+
{loading ? '—' : String(overrides.length).padStart(2, '0')}
+
+ {/snippet} + + {#snippet lede()} + {$t('logscan.lede', { enabled: String(enabledCount), total: String(rules.length) })} + {/snippet} + + + + {#if error} + + {/if} + + {#if !loading && rules.length > 0} +
+ {#each [['all', $t('logscan.filter.all'), rules.length], ['global', $t('logscan.filter.global'), globals.length], ['workload', $t('logscan.filter.workload'), workloadOnly.length], ['override', $t('logscan.filter.overrides'), overrides.length]] as [key, label, count]} + + {/each} +
+ {/if} + + {#if scanStats} +
+
+

+ {$t('logscan.stats.heading')}. +

+ {$t('logscan.stats.headingSub')} +
+
+
+
{$t('logscan.stat.activeTails')}
+
{String(scanStats.active_tails).padStart(2, '0')}
+
+
0} + > +
{$t('logscan.stat.droppedBucket')}
+
{scanStats.engine.dropped_by_bucket.toLocaleString()}
+
+
+
{$t('logscan.stat.droppedCooldown')}
+
{scanStats.engine.dropped_by_cooldown.toLocaleString()}
+
+
0} + > +
{$t('logscan.stat.compileErrors')}
+
{String(scanStats.last_compile_errors.length).padStart(2, '0')}
+
+
+ {#if scanStats.last_compile_errors.length > 0} + + {:else} +

{$t('logscan.stats.noCompileErrors')}

+ {/if} +
+ {/if} + + {#if loading} +
+ {#each Array(4) as _, i} +
+ {/each} +
+ {:else if rules.length === 0} +
+ +

{$t('logscan.empty.heading')}

+

{$t('logscan.empty.body')}

+ + {$t('logscan.empty.cta')} + +
+ {:else} +
+ + + + + + + + + + + + + + {#each filtered as r, i (r.id)} + + + + + + + + + + {/each} + +
{$t('logscan.list.name')}{$t('logscan.list.pattern')}{$t('logscan.list.scope')}{$t('logscan.list.severity')}{$t('logscan.list.streams')}{$t('logscan.list.status')}{$t('logscan.list.open')}
+ + {String(i + 1).padStart(2, '0')} + {r.name} + + /{r.pattern}/ + {scopeLabel(r)} + + {r.severity} + {r.streams} + + + {r.enabled ? $t('logscan.status.enabled') : $t('logscan.status.disabled')} + + + + {$t('observability.open')} + +
+
+ {/if} +
+ + diff --git a/web/src/routes/log-scan-rules/[id]/+page.svelte b/web/src/routes/log-scan-rules/[id]/+page.svelte new file mode 100644 index 0000000..8eef4e5 --- /dev/null +++ b/web/src/routes/log-scan-rules/[id]/+page.svelte @@ -0,0 +1,683 @@ + + + + {rule?.name ?? $t('logscan.titleSingular')} · Tinyforge + + +
+ {#snippet detailLede()} + {#if rule} + + {$t('logscan.list.scope')} {scopeLabel(rule)} · + {$t('logscan.list.severity')} {rule.severity} · + {$t('logscan.list.streams')} {rule.streams} + + {/if} + {/snippet} + + + + {#if error} + + {/if} + + {#if loading || !rule} +
+ {#each Array(3) as _, i} +
+ {/each} +
+ {:else} +
+
+

{$t('logscan.detail.config')}.

+ + {$t('logscan.detail.configSub', { id: String(rule.id), scope: scopeLabel(rule) })} + +
+ +
+ + +
+ +
+ + + {#if !regexValid} + + {$t('logscan.form.invalidRegex')} + + {/if} +
+ +
+ + + +
+ +
+
+ +

{$t('logscan.form.enabledHint')}

+
+ +
+ +
+ +
+
+ +
+
+

{$t('logscan.detail.regexTest')}.

+ {$t('logscan.detail.regexTestSub')} +
+ +
+ +
+
+ {#if serverTestResult} +
+ {#if serverTestResult.error} + {$t('logscan.detail.serverError')} +
{serverTestResult.error}
+ {:else if serverTestResult.matched} + {$t('logscan.detail.serverMatch')} + {#if serverTestResult.captures} +
+ {#each Object.entries(serverTestResult.captures) as [k, v]} +
+
{k}
+
{v || '∅'}
+
+ {/each} +
+ {/if} + {:else} + {$t('logscan.detail.serverNoMatch')} + {$t('logscan.detail.serverNoMatchHint')} + {/if} +
+ {/if} +
+
+ +
+
+

{$t('logscan.detail.dangerZone')}.

+ {$t('logscan.detail.dangerZoneSub')} +
+
+ +
+
+ + (confirmDelete = false)} + /> + {/if} +
+ + diff --git a/web/src/routes/log-scan-rules/new/+page.svelte b/web/src/routes/log-scan-rules/new/+page.svelte new file mode 100644 index 0000000..bf5d776 --- /dev/null +++ b/web/src/routes/log-scan-rules/new/+page.svelte @@ -0,0 +1,589 @@ + + + + {$t('logscan.titleNew')} · Tinyforge + + +
+ {#snippet lede()} + {$t('logscan.ledeNew')} + {/snippet} + + + +
+ {#if error} + + {/if} + +
+ + +
+ +
+ + + {#if !regexValid} + + {$t('logscan.form.invalidRegex')} + + {/if} + +
+ +
+
+ + {$t('logscan.form.matchShape')} + {$t('logscan.form.matchShapeOpts')} +
+
+ + + +
+

{$t('logscan.form.cooldownHint')}

+
+ +
+
+ + {$t('logscan.form.scope')} + {$t('logscan.form.optional')} +
+
+ {#if workloadID === ''} +
+ + {$t('logscan.form.scopeGlobal')} + +
+ {:else} +
+ {$t('logscan.form.scopeSelected')} + {#if selectedWorkload} + {selectedWorkload.name} + + {selectedWorkload.source_kind || selectedWorkload.kind} + + {:else} + {$t('logscan.form.scopeUnknown')} + {workloadID} + {/if} + + +
+ {/if} +
+

{$t('logscan.form.scopeHint')}

+
+ +
+
+ +

{$t('logscan.form.enabledHint')}

+
+ +
+ +
+ {$t('observability.cancel')} + +
+
+ + (pickerOpen = false)} + /> +
+ +