feat(apps): stepped creation wizard, branch previews, and app-creation fixes

This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
  WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
  ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
  + {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
  /apps/[id] edit form onto the same components (removes the duplication). Add
  vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
  environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
  state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
  conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
  label hints; dashboard + /apps "Total workloads" count only source_kind workloads
  (drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
  empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.

Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
This commit is contained in:
2026-05-29 02:09:54 +03:00
parent 956943edbb
commit 410a131cec
112 changed files with 13285 additions and 2765 deletions
+143 -15
View File
@@ -15,7 +15,7 @@
"nav": {
"dashboard": "Панель",
"apps": "Приложения",
"eventTriggers": "Триггеры",
"eventTriggers": "Триггеры событий",
"logScanRules": "Лог-правила",
"triggers": "Триггеры",
"proxies": "Прокси",
@@ -23,7 +23,13 @@
"settings": "Настройки",
"logout": "Выйти",
"dns": "DNS-записи",
"containers": "Контейнеры"
"containers": "Контейнеры",
"sectionObserve": "Наблюдение",
"sectionSystem": "Система",
"closeSidebar": "Закрыть боковую панель",
"openSidebar": "Открыть боковую панель",
"quickNavTitle": "Нажмите «g», затем букву для перехода между разделами",
"quickNavLabel": "быстрая навигация"
},
"dashboard": {
"title": "Панель управления",
@@ -42,7 +48,11 @@
"systemHealth": "Состояние системы",
"daemons": "Демоны",
"systemResources": "Системные ресурсы",
"systemResourcesSubtitle": "CPU, память, диск и топ потребителей"
"systemResourcesSubtitle": "CPU, память, диск и топ потребителей",
"statSubWorkloads": "нагрузки →",
"statSubRunning": "запущено",
"statSubNeedAttention": "требует внимания",
"statSubStale": "устаревшие →"
},
"resources": {
"cpuCores": "Ядра CPU",
@@ -237,6 +247,7 @@
"deleteFailed": "Не удалось удалить реестр",
"testFailed": "Тест подключения не удался",
"loadFailed": "Не удалось загрузить реестры",
"deleteTitle": "Удалить реестр?",
"deleteConfirm": "Удалить реестр «{name}»? Это действие необратимо.",
"healthChecking": "Проверка...",
"healthConnected": "Подключено",
@@ -354,6 +365,7 @@
"createFailed": "Не удалось создать пользователя",
"deleteFailed": "Не удалось удалить пользователя",
"deleteConfirm": "Вы уверены, что хотите удалить этого пользователя?",
"deleteConfirmMessage": "Удалить пользователя «{username}»? Это действие необратимо.",
"usernameRequired": "Имя пользователя и пароль обязательны",
"networkError": "Ошибка сети",
"password": "Пароль"
@@ -400,6 +412,9 @@
"common": {
"cancel": "Отмена",
"confirm": "Подтвердить",
"close": "Закрыть",
"toggle": "Переключить",
"dismissNotification": "Закрыть уведомление",
"delete": "Удалить",
"edit": "Изменить",
"change": "Изменить",
@@ -429,6 +444,7 @@
"missing": "Отсутствует"
},
"containers": {
"eyebrowSuffix": "ГЛОБАЛЬНО",
"errLoad": "Не удалось загрузить контейнеры",
"searchPlaceholder": "Поиск по нагрузке, роли, образу, поддомену…",
"kindFilterLabel": "Тип нагрузки",
@@ -476,6 +492,7 @@
},
"stale": {
"title": "Устаревшие контейнеры",
"eyebrowSuffix": "УСТАРЕВШИЕ",
"noStale": "Нет устаревших контейнеров",
"noStaleDesc": "Все контейнеры исправны и работают.",
"cleanup": "Очистить",
@@ -541,13 +558,13 @@
"unavailable": "Статистика недоступна"
},
"systemHealth": {
"title": "Состояние системы",
"containers": "Контейнеры",
"proxies": "Прокси",
"recentErrors": "Недавние ошибки"
},
"daemons": {
"title": "Демоны",
"notReachable": "{provider} недоступен.",
"refresh": "Обновить",
"refreshing": "Обновление",
"docker": "Docker Engine",
@@ -1110,6 +1127,10 @@
"image": "Ссылка на образ",
"imagePlaceholder": "registry.example.com/owner/app",
"imageHint": "Полная ссылка на образ без тега — Tinyforge ловит новые теги, выкладываемые под этой ссылкой.",
"browseImages": "Выбрать",
"browseImagesHint": "Выберите образ из настроенного реестра вместо ручного ввода ссылки.",
"browseImagesTitle": "Выбор образа",
"browseImagesSearch": "Поиск образов…",
"tagPattern": "Шаблон тега",
"tagPatternPlaceholder": "*",
"tagPatternHint": "Glob path.Match (например, v*, release-*). * совпадает с любым тегом.",
@@ -1122,6 +1143,9 @@
"branch": "Ветка",
"branchPlaceholder": "main",
"branchHint": "Только push'и, продвигающие эту ветку, дёргают триггер.",
"branchPattern": "Шаблон ветки (preview-деплои)",
"branchPatternPlaceholder": "feat/* или * для любой ветки",
"branchPatternHint": "Если задан, любой push в подходящую ветку создаёт отдельный preview-деплой. Оставьте пустым, чтобы выключить.",
"manualNote": "У ручных триггеров нет конфига. Они срабатывают только через кнопку Deploy на странице нагрузки или POST /workloads/{id}/deploy.",
"scheduleNote": "Срабатывает по фиксированному интервалу, который ведёт внутренний планировщик Tinyforge. Внешний webhook не нужен — включите его ниже только если CI тоже должен запускать триггер вручную.",
"intervalPresets": "Быстрые пресеты",
@@ -1186,6 +1210,14 @@
},
"new": {
"pageTitle": "Новое приложение · Tinyforge",
"wizard": {
"stepBasics": "Основное",
"stepConfigure": "Настройка",
"stepTrigger": "Триггер",
"stepReview": "Обзор",
"next": "Далее",
"back": "Назад"
},
"backLabel": "К приложениям",
"eyebrowSuffix": "НОВОЕ ПРИЛОЖЕНИЕ",
"title": "Создать приложение",
@@ -1198,6 +1230,7 @@
"alertTag": "ОШ",
"fieldName": "Имя",
"fieldNameRequired": "ОБЯЗАТЕЛЬНО",
"fieldRequired": "Обязательно",
"fieldNamePlaceholder": "my-app",
"fieldNameHint": "В нижнем регистре, без пробелов. Используется в именах контейнеров и поддоменах.",
"fieldSourcePlugin": "Source-плагин",
@@ -1207,7 +1240,7 @@
"fieldConfigYaml": "YAML",
"fieldConfigForm": "ФОРМА",
"fieldConfigJson": "JSON",
"advancedJson": "Расширенный JSON",
"advancedJson": "Редактировать JSON",
"backToForm": "К форме",
"resetSample": "Сбросить к примеру",
"switchToJsonTitle": "Переключиться на сырой JSON-редактор",
@@ -1230,11 +1263,21 @@
"imageRegistryLabel": "Реестр (для приватных pull-ов)",
"imageRegistryPublic": "(публичный — без авторизации)",
"imageRegistryHint": "Имя должно совпадать с записью на странице «Реестры». Оставьте пустым для публичных образов.",
"imageCpu": "Лимит CPU (ядра, 0 = ∞)",
"imageMemory": "Лимит памяти (МБ, 0 = ∞)",
"imageCpu": "Лимит CPU",
"imageCpuHint": "Ядра, 0 = ∞",
"imageMemory": "Лимит памяти",
"imageMemoryHint": "МБ, 0 = ∞",
"imageMax": "Макс. инстансов",
"imageMaxHint": "1 = строгий blue-green.",
"imageFoot": "Переменные окружения и тома задаются в отдельных панелях на странице нагрузки после создания.",
"dockerfileHeader": "dockerfile-источник · сборка из git-репозитория",
"dockerfileBuildEyebrow": "сборка · dockerfile",
"dockerfileContextPath": "Контекст сборки",
"dockerfileContextPathPlaceholder": "(пусто = корень репо)",
"dockerfilePath": "Путь к Dockerfile",
"dockerfilePort": "Порт контейнера",
"dockerfilePortRequired": "Укажите порт, который слушает приложение (1–65535).",
"dockerfileFoot": "Tinyforge склонирует репо, соберёт образ из Dockerfile и запустит контейнер. Переменные окружения и тома — на странице нагрузки после создания.",
"staticHeader": "static-источник · страницы из репозитория",
"staticProvider": "Провайдер",
"staticBaseUrl": "Base URL",
@@ -1262,7 +1305,7 @@
"staticTestConnection": "Проверить соединение",
"staticConnectionOk": "Соединение установлено",
"staticConnectionFailed": "Ошибка соединения: {error}",
"staticBrowseRepos": "Выбрать репозиторий",
"staticBrowseRepos": "Обзор",
"staticBrowseBranches": "Выбрать ветку",
"staticBrowseFolders": "Выбрать папку",
"staticPickerRepoTitle": "Выбор репозитория",
@@ -1275,6 +1318,7 @@
"staticTreeEmpty": "В этой ветке нет папок.",
"staticDenoAutoDetected": "Обнаружена папка <code>api/</code> — режим автоматически переключён на Deno.",
"imageConflictTag": "ОБРАЗ УЖЕ ИСПОЛЬЗУЕТСЯ",
"imageConflictChecking": "Проверка конфликтов…",
"imageConflictHeading": "Этот образ уже используется в {count} нагрузке(ах):",
"imageConflictOpenBtn": "Открыть",
"imageConflictAcknowledgeNote": "Если это намеренно (например, отдельный этап), нажмите «Создать» ещё раз для продолжения.",
@@ -1300,21 +1344,23 @@
"submit": "Создать приложение",
"submitting": "Создание…",
"submitAnyway": "Всё равно создать",
"unsavedChanges": "В этом приложении есть несохранённые изменения. Покинуть страницу, не создавая его?",
"unsavedChangesTitle": "Несохранённые изменения",
"unsavedChangesConfirm": "Покинуть",
"errors": {
"detectionFailed": "Не удалось определить провайдера.",
"connectionFailed": "Ошибка соединения.",
"reposFailed": "Не удалось загрузить репозитории.",
"branchesFailed": "Не удалось загрузить ветки.",
"treeFailed": "Не удалось загрузить дерево папок.",
"detectionFailed": "Не удалось определить Git-провайдера по этому URL. Проверьте, что базовый URL верен и доступен.",
"connectionFailed": "Не удалось подключиться к репозиторию. Проверьте URL провайдера, владельца/репозиторий и токен доступа (для приватных репозиториев).",
"reposFailed": "Не удалось получить список репозиториев. Проверьте базовый URL и токен доступа.",
"branchesFailed": "Не удалось получить список веток. Проверьте репозиторий и токен доступа.",
"treeFailed": "Не удалось загрузить дерево папок. Проверьте репозиторий, ветку и токен доступа.",
"sourceConfigInvalid": "source_config не является корректным JSON.",
"triggerBindUnknown": "неизвестная ошибка",
"createFailed": "Не удалось создать нагрузку.",
"inspectFailed": "Не удалось проинспектировать образ."
"inspectFailed": "Не удалось проинспектировать образ — убедитесь, что он скачан локально и ссылка указана верно."
},
"imageInspect": "Инспектировать",
"imageInspectHint": "Подставляет порт и healthcheck из образа, чтобы не вводить вручную.",
"imageInspectOk": "Готово — порт и healthcheck подставлены.",
"imageInspectError": "Ошибка инспекции: {error}",
"triggers": {
"section": "Триггер",
"sectionSub": "Необязательно. Выберите, откуда придёт сигнал передеплоя — слежение за реестром, git-событие или ручная кнопка.",
@@ -1334,6 +1380,18 @@
"pickWebhookOn": "ВЕБХУК ВКЛ",
"skippedNote": "Триггер не будет привязан. Добавьте его из панели «Триггеры» в карточке приложения после создания.",
"bindError": "Приложение создано, но привязка триггера не удалась: {error}. Откройте панель «Триггеры» в карточке, чтобы повторить."
},
"manifest": {
"title": "Манифест",
"name": "Имя",
"source": "Источник",
"trigger": "Триггер",
"publicFace": "Публичный фронт",
"unnamed": "(без имени)",
"registryPublic": "публичный реестр",
"folderRoot": "корень",
"triggerManual": "Только вручную",
"internalOnly": "Только внутренний"
}
},
"detail": {
@@ -1365,6 +1423,40 @@
"unavailable": "Не удалось получить размер (контейнер мог быть остановлен).",
"loading": "Вычисление размера…"
},
"buildLog": {
"title": "Журнал сборки",
"sub": "Живой поток вывода сборки Docker.",
"clear": "Очистить"
},
"notifications": {
"title": "Маршруты уведомлений",
"sub": "Множественные точки доставки для событий деплоя/сборки. При пустом списке используется устаревший единственный URL.",
"loading": "Загрузка маршрутов…",
"empty": "Нет настроенных маршрутов уведомлений. Добавьте, чтобы получать события в отдельный канал.",
"addFirst": "Добавить первый маршрут",
"add": "Добавить маршрут",
"edit": "Изменить",
"delete": "Удалить",
"name": "Имя",
"namePlaceholder": "Slack #alerts",
"url": "URL вебхука",
"secret": "Секрет подписи",
"secretPlaceholder": "Опционально — приёмник проверяет HMAC",
"secretEditPlaceholder": "Оставьте пустым, чтобы сохранить текущий секрет",
"secretHint": "HMAC-SHA256 от тела запроса, заголовок X-Hub-Signature-256.",
"eventTypes": "Типы событий",
"eventTypesPlaceholder": "deploy_failure,build_failure (пусто = все)",
"eventTypesHint": "Список через запятую. Пусто — маршрут срабатывает на любое событие.",
"enabled": "Включён",
"save": "Сохранить",
"saving": "Сохранение…",
"cancel": "Отмена",
"allEvents": "все события",
"signed": "подписан",
"disabled": "выключен",
"confirmDeleteTitle": "Удалить маршрут уведомлений?",
"confirmDeleteMessage": "Маршрут перестанет срабатывать. Устаревший URL уведомлений на workload (если задан) снова возьмёт события на себя."
},
"toolbar": {
"stop": "Стоп",
"start": "Старт",
@@ -1455,6 +1547,19 @@
"editStaticModeDenoDesc": "— Deno-рантайм с динамической маршрутизацией.",
"editStaticRenderMarkdown": "Рендерить markdown",
"editStaticRenderMarkdownDesc": "— автоматически отдавать <code>.md</code> как HTML.",
"editDockerfileHeader": "dockerfile-источник · сборка из git-репозитория",
"editDockerfileBuildEyebrow": "сборка · dockerfile",
"editDockerfileContextPath": "Контекст сборки",
"editDockerfileContextPathPlaceholder": "(пусто = корень репо)",
"editDockerfilePath": "Путь к Dockerfile",
"editDockerfilePort": "Порт контейнера",
"editTestConnection": "Проверить соединение",
"editTestConnectionOk": "Соединение установлено",
"editTestConnectionFailed": "Ошибка соединения: {error}",
"editTestConnectionUnknownError": "Неизвестная ошибка",
"overrideKeyUnitSingular": "КЛЮЧ",
"overrideKeyUnitPlural": "КЛЮЧИ",
"editTestConnectionIncomplete": "Заполните провайдера, base URL, owner и name.",
"editSourceJsonHeader": "source_config.json",
"editSourceJsonAria": "Конфигурация source-плагина (JSON)",
"editPublicFaces": "Публичные фронты",
@@ -1494,6 +1599,29 @@
"chainPromoteButton": "Продвинуть от родителя",
"chainPromoting": "Продвижение…",
"chainHint": "Задайте <code>parent_workload_id</code> у нагрузки, чтобы построить цепочку. Дочерние image-источники могут одним кликом продвинуть текущий запущенный тег родителя.",
"previews": {
"title": "Превью-окружения",
"subEmpty": "нет активных превью",
"subCountOne": "1 активное превью",
"subCount": "активных превью: {count}",
"tag": "Превью",
"tagTitle": "Превью-развёртывание этой нагрузки для отдельной ветки",
"armedEmpty": "Нет активных превью — отправьте пуш в ветку, соответствующую шаблону",
"noneEmpty": "Пока нет активных превью.",
"open": "Открыть",
"noUrl": "нет публичного URL",
"teardown": "Удалить",
"teardownTitle": "Удалить превью?",
"teardownMessage": "Это удалит превью для ветки «{name}» вместе с его контейнерами и маршрутами прокси. Новый пуш в эту ветку создаст его заново.",
"teardownConfirm": "Удалить",
"teardownPending": "Удаление…",
"teardownFailed": "Не удалось удалить",
"stateRunning": "Работает",
"statePending": "Запускается",
"stateStopped": "Остановлено",
"stateUnknown": "Неизвестно",
"hint": "Превью создаются автоматически, когда пуш приходит в ветку, соответствующую <code>branch_pattern</code> git-триггера, и удаляются при удалении ветки. Каждое получает собственный поддомен с префиксом-слагом."
},
"volumesTitle": "Тома",
"volumesEmpty": "Нет монтирований",
"volumesCountSingular": "{count} монтирование",