chore(workload): close the workload-first arc — apps i18n + codemap + tests
Build / build (push) Successful in 10m36s

Closes the workload-first refactor by landing the Priority 3 polish
items and the Priority 4 test gap. Net: ~2,400 lines added,
~350 lines modified across 13 files.

Priority 3 — polish
- apps.* i18n namespace: 276 new keys across apps.list.* (27),
  apps.new.* (91, sibling of existing apps.new.triggers.*), and
  apps.detail.* (158, sibling of existing apps.detail.bindings.*).
  EN+RU at 1314 keys each, perfectly in sync. /apps, /apps/new,
  /apps/[id] now render entirely from i18n.
- New codemap docs/CODEMAPS/workload-plugin.md (238 lines):
  Source × Trigger contract, dispatch seam, webhook fan-out path,
  recipes for adding a new Source or Trigger kind. Plus
  docs/CODEMAPS/INDEX.md gateway.

Priority 4 — tests
- internal/api/workloads_test.go (new, ~30 subtests): /api/workloads
  CRUD + deploy + delete + env + volumes + chain + promote-from +
  triggers list/inline-bind + auth gating + standalone /api/triggers
  CRUD (create / dup-409 / kind filter / delete). Uses real
  POST handlers via httptest.NewServer + a fake plugin source
  registered under "testfakesource".
- internal/deployer/dispatch_test.go (new, 11 tests):
  DispatchPlugin / DispatchTeardown / DispatchReconcile happy +
  unknown-kind + propagated-error each; PluginDeps wiring; a real
  2s-bounded RWMutex deadlock probe on PluginDeps vs SetDNSProvider.
- internal/workload/plugin/source/compose/compose_test.go (new,
  ~26 subtests): composeProjectName sanitization,
  writeYAML / writeYAMLIfChanged hash short-circuit, Validate happy
  + bad inputs, Kind / SchemaSample.

Coverage delta on the workload-plugin path:
- internal/api: 1.1% → 16.0%
- internal/deployer: 0% → 54.1%
- internal/workload/plugin/source/compose: 0% → 38.5%
- Trigger plugins already at 87-95% from the trigger-split work.

Production fix surfaced by the tests
- store.CreateWorkload now self-references RefID = ID when caller
  leaves RefID empty (the typical plugin-native path). The api
  layer's broken backfill loop (called UpdateWorkload, which
  deliberately omits ref_id) is gone. Multiple sibling plugin
  workloads can now coexist under the UNIQUE(kind, ref_id) constraint.

Review fixes addressed before commit
- CRITICAL: deadlock-detect test gained a real 2s time.After (was
  selecting on context.Background().Done() which never fires).
- HIGH: happy-path test now hard-asserts RefID = ID (was a t.Logf
  that would silently pass after a production fix).
- HIGH: standalone /api/triggers CRUD coverage added (was bypassed
  by the workload-side bind flow).
- HIGH: seedWorkload bypass deleted; tests now go through the
  real POST /api/workloads handler.
- MEDIUM: withTempDir restore is a no-op (t.Setenv auto-restores);
  dead `old := os.Getenv(...)` capture removed.
- MEDIUM: list-workloads test now asserts ID membership, not just
  count.

Doc
- WORKLOAD_REFACTOR_TODO: all three Priority 1 items, Priority 3
  polish, and Priority 4 tests marked DONE. The workload-first arc
  is closed.
This commit is contained in:
2026-05-16 06:42:43 +03:00
parent 739b67856a
commit e3c7b13d58
13 changed files with 2736 additions and 352 deletions
+274
View File
@@ -1128,7 +1128,127 @@
}
},
"apps": {
"list": {
"eyebrowSuffix": "APPS",
"title": "Apps",
"ledePrefix": "Plugin-native deployables —",
"ledeKindImage": "image",
"ledeKindCompose": "compose",
"ledeKindStatic": "static",
"ledeMiddle": ", or",
"ledeSuffix": ", with pluggable redeploy triggers. Legacy projects, stacks, and sites continue to live under their own sections during the cutover.",
"statTotal": "TOTAL",
"statImage": "IMAGE",
"statCompose": "COMPOSE",
"statStatic": "STATIC",
"refresh": "Refresh",
"newApp": "New App",
"filterAriaLabel": "Filter by source plugin",
"filterAll": "ALL",
"loadError": "Failed to load apps",
"alertTag": "ERR",
"emptyTitle": "No apps yet",
"emptyBody": "Apps unify image, compose, and static deployables behind a single plugin-driven surface. Forge your first one to see it light up here.",
"emptyCta": "Forge the first app",
"colName": "Name",
"colSource": "Source",
"colTrigger": "Trigger",
"colCreated": "Created",
"colActions": "Actions",
"rowOpen": "Open"
},
"new": {
"pageTitle": "New App · Tinyforge",
"backLabel": "Back to apps",
"eyebrowSuffix": "NEW APP",
"title": "Forge a new app",
"ledePrefix": "Create a plugin-native workload.",
"ledeSourceLabel": "Source",
"ledeSourceMid": "= how it deploys (image, compose, static). Pick or create a",
"ledeTriggerLabel": "trigger",
"ledeSuffix": "below — when one fires, the source plugin redeploys.",
"loadingKinds": "Loading available plugin kinds…",
"alertTag": "ERR",
"fieldName": "Name",
"fieldNameRequired": "REQUIRED",
"fieldNamePlaceholder": "my-app",
"fieldNameHint": "Lowercase, no spaces. Becomes part of container names and subdomains.",
"fieldSourcePlugin": "Source plugin",
"fieldSourceLabel": "Source",
"fieldSourceHint": "Populated from the running daemon — only plugins compiled in show up. Triggers (registry / git / manual) are configured below as standalone records.",
"fieldSourceConfig": "Source config",
"fieldConfigYaml": "YAML",
"fieldConfigForm": "FORM",
"fieldConfigJson": "JSON",
"advancedJson": "Advanced JSON",
"backToForm": "Back to form",
"resetSample": "Reset sample",
"switchToJsonTitle": "Switch to the raw JSON editor",
"switchToFormTitle": "Switch back to the form",
"jsonOk": "JSON OK",
"jsonInvalid": "JSON INVALID",
"linesUnit": "lines",
"composeHeader": "compose.yaml · compose",
"composeAriaLabel": "Compose YAML",
"composeProjectLabel": "Compose project name (optional)",
"composeProjectPlaceholder": "(defaults to sanitized workload name)",
"composePlaceholder": "services:\n web:\n image: nginx:alpine\n ports:\n - \"80\"",
"imageHeader": "image source · runtime knobs",
"imageRefLabel": "Image (registry path)",
"imageRefPlaceholder": "registry.example.com/owner/app",
"imageRefHint": "Fully-qualified reference; the tag is set per-deploy via the trigger or the Default tag field below.",
"imagePort": "Port",
"imageHealthcheck": "Healthcheck path",
"imageDefaultTag": "Default tag",
"imageRegistryLabel": "Registry (for private pulls)",
"imageRegistryPublic": "(public — no auth)",
"imageRegistryHint": "Match the name from the Registries settings page. Leave empty for public images.",
"imageCpu": "CPU limit (cores, 0 = ∞)",
"imageMemory": "Memory limit (MB, 0 = ∞)",
"imageMax": "Max instances",
"imageMaxHint": "1 = strict blue-green.",
"imageFoot": "Env vars and volume mounts live in their own panels on the workload detail page after creation.",
"staticHeader": "static source · pages from a repo",
"staticProvider": "Provider",
"staticBaseUrl": "Base URL",
"staticBaseUrlPlaceholder": "https://git.example.com",
"staticRepoOwner": "Repo owner",
"staticRepoOwnerPlaceholder": "owner",
"staticRepoName": "Repo name",
"staticRepoNamePlaceholder": "pages",
"staticBranch": "Branch",
"staticBranchPlaceholder": "main",
"staticFolder": "Folder path (optional)",
"staticFolderPlaceholder": "(repo root)",
"staticToken": "Access token (private repos)",
"staticTokenPlaceholder": "(leave blank for public repos)",
"staticTokenHint": "Encrypted at rest. Required only when the repo is private.",
"staticMode": "Mode",
"staticModeStaticDesc": "— serve files via nginx; zero runtime overhead.",
"staticModeDenoDesc": "— Deno runtime container with optional dynamic routing.",
"staticRenderMarkdown": "Render markdown",
"staticRenderMarkdownDesc": "— auto-render <code>.md</code> files as HTML pages.",
"staticFoot": "The webhook secret for git push triggers lives on the workload's Webhook panel after creation.",
"sourceConfigJsonTitle": "source_config.json · {kind}",
"sourceConfigJsonAria": "Source plugin configuration (JSON)",
"triggerNumLabel": "Trigger",
"triggerNumOptional": "OPTIONAL",
"triggerNewTag": "NEW",
"triggerPickTag": "PICK",
"triggerSkipTag": "SKIP",
"noteSkipTag": "SKIP",
"noteEmptyTag": "∅",
"faceLabel": "Public face",
"faceOptional": "OPTIONAL",
"faceSubdomain": "Subdomain",
"faceSubdomainPlaceholder": "myapp",
"faceDomain": "Domain",
"faceDomainPlaceholder": "(inherit from settings)",
"facePort": "Target port",
"faceHint": "Leave blank to skip provisioning a proxy route. Filling any field creates a single face row attached to this workload.",
"cancel": "Cancel",
"submit": "Forge app",
"submitting": "Forging…",
"triggers": {
"section": "Trigger",
"sectionSub": "Optional. Pick how this app gets a redeploy signal — registry watcher, git event, or manual button.",
@@ -1151,6 +1271,160 @@
}
},
"detail": {
"pageTitleFallback": "App",
"backLabel": "Back to apps",
"eyebrowSuffix": "APP",
"kickerId": "id: {id}",
"loading": "Loading workload…",
"loadError": "Failed to load app",
"deployError": "Deploy failed",
"saveError": "Save failed",
"deleteError": "Delete failed",
"alertTag": "ERR",
"createdAt": "created",
"refreshLabel": "Refresh",
"editButton": "Edit",
"deleteButton": "Delete",
"editTitle": "Edit configuration",
"editSubPrefix": "Source",
"editSubSuffix": "· triggers managed in the Triggers panel below",
"editFieldName": "Name",
"editFieldParent": "Parent workload",
"editFieldOptional": "OPTIONAL",
"editFieldParentPlaceholder": "workload UUID (blank for root)",
"editSourceConfig": "Source config",
"editConfigYaml": "YAML",
"editConfigForm": "FORM",
"editConfigJson": "JSON",
"advancedJson": "Advanced JSON",
"backToForm": "Back to form",
"switchToJsonTitle": "Switch to the raw JSON editor",
"switchToFormTitle": "Switch back to the form",
"jsonOk": "JSON OK",
"jsonInvalid": "JSON INVALID",
"editComposeProject": "Compose project name (optional)",
"editComposeProjectPlaceholder": "(defaults to sanitized workload name)",
"editComposePlaceholder": "services:\n web:\n image: nginx:alpine",
"editComposeAria": "Compose YAML",
"editComposeHeader": "compose.yaml",
"editImageHeader": "image source · runtime knobs",
"editImageRef": "Image (registry path)",
"editImageRefPlaceholder": "registry.example.com/owner/app",
"editImagePort": "Port",
"editImageHealthcheck": "Healthcheck path",
"editImageDefaultTag": "Default tag",
"editImageRegistry": "Registry (for private pulls)",
"editImageRegistryPublic": "(public — no auth)",
"editImageCpu": "CPU limit (cores, 0 = ∞)",
"editImageMemory": "Memory limit (MB, 0 = ∞)",
"editImageMax": "Max instances",
"editImageFoot": "Env vars and volume mounts use their own panels below — saving here preserves them.",
"editStaticHeader": "static source · pages from a repo",
"editStaticProvider": "Provider",
"editStaticBaseUrl": "Base URL",
"editStaticBaseUrlPlaceholder": "https://git.example.com",
"editStaticRepoOwner": "Repo owner",
"editStaticRepoName": "Repo name",
"editStaticBranch": "Branch",
"editStaticFolder": "Folder path (optional)",
"editStaticFolderPlaceholder": "(repo root)",
"editStaticToken": "Access token (private repos)",
"editStaticTokenPlaceholder": "(leave blank for public repos)",
"editStaticMode": "Mode",
"editStaticModeStaticDesc": "— serve files via nginx.",
"editStaticModeDenoDesc": "— Deno runtime with dynamic routing.",
"editStaticRenderMarkdown": "Render markdown",
"editStaticRenderMarkdownDesc": "— auto-render <code>.md</code> as HTML.",
"editSourceJsonHeader": "source_config.json",
"editSourceJsonAria": "Source plugin configuration (JSON)",
"editPublicFaces": "Public faces",
"editPublicFacesTag": "JSON ARRAY",
"editPublicFacesHeader": "public_faces.json",
"editPublicFacesAria": "Public faces configuration (JSON array)",
"editCancel": "Cancel",
"editSave": "Save changes",
"editSaving": "Saving…",
"manualDeployTitle": "Manual deploy",
"manualDeployOk": "OK",
"manualDeployDispatched": "Dispatched {reference} as {by}",
"manualDeployRefAria": "Deploy reference",
"manualDeployRefPlaceholder": "reference (image tag, git sha, blank for default)",
"manualDeployButton": "Deploy",
"manualDeployDispatching": "Dispatching…",
"manualDeployHint": "Use a specific image tag, git sha, or branch to force a deploy. Leave blank to use the default reference resolved by the source plugin.",
"containersTitle": "Containers",
"containersEmpty": "No containers yet",
"containersCount": "{count} reconciled",
"containersEmptyInline": "No containers yet — deploy to spin one up.",
"containersColRole": "Role",
"containersColState": "State",
"containersColImage": "Image",
"containersColSubdomain": "Subdomain",
"containersColLastSeen": "Last seen",
"containersColActions": "Actions",
"containersLogsAction": "Logs",
"chainTitle": "Chain",
"chainSubFromParent": "promotes from a parent",
"chainSubParentOf": "parent of",
"chainChildSingular": "child",
"chainChildPlural": "children",
"chainParentLabel": "Parent",
"chainSelfLabel": "This",
"chainChildrenLabel": "Children",
"chainPromoteButton": "Promote from parent",
"chainPromoting": "Promoting…",
"chainHint": "Set <code>parent_workload_id</code> on a workload to build a chain. Image-source children can promote the parent's currently-running tag with one click.",
"volumesTitle": "Volumes",
"volumesEmpty": "No mounts",
"volumesCountSingular": "{count} mount",
"volumesCountPlural": "{count} mounts",
"volumesColTarget": "Target",
"volumesColSource": "Source",
"volumesColScope": "Scope",
"volumesColUpdated": "Updated",
"volumesColActions": "Actions",
"volumeSource": "Source (host)",
"volumeSourcePlaceholder": "/srv/data/myapp",
"volumeTarget": "Target (container)",
"volumeTargetPlaceholder": "/data",
"volumeScope": "Scope",
"volumeAddButton": "Add / Replace",
"volumeSaving": "Saving…",
"volumeHint": "Absolute mounts bind a host path into the container. Non-absolute scopes are accepted for future use; only absolute is honoured at deploy time today.",
"volumeTargetError": "Target must be an absolute container path (e.g. /data)",
"volumeSetFailed": "Failed to set volume",
"volumeDeleteFailed": "Failed to delete volume",
"envTitle": "Env",
"envEmpty": "No overrides",
"envCountSingular": "{count} override",
"envCountPlural": "{count} overrides",
"envColKey": "Key",
"envColValue": "Value",
"envColUpdated": "Updated",
"envColActions": "Actions",
"envEncrypted": "ENCRYPTED",
"envKey": "Key",
"envKeyPlaceholder": "DATABASE_URL",
"envValue": "Value",
"envValuePlaceholder": "(empty to unset)",
"envEncryptLabel": "Encrypt at rest",
"envAddButton": "Add / Replace",
"envSaving": "Saving…",
"envHint": "Encrypted values are write-only after store — the API redacts them on read. Rotate by setting a new value.",
"envKeyRequired": "Key is required",
"envSetFailed": "Failed to set env",
"envDeleteFailed": "Failed to delete env",
"sourceConfigTitle": "Source config",
"sourceConfigCopy": "Copy",
"sourceConfigCopied": "Copied",
"sourceConfigCopyAria": "Copy source config",
"publicFacesTitle": "Public faces",
"publicFacesCopyAria": "Copy public faces",
"deleteConfirmTitle": "Delete this app?",
"deleteConfirmMessage": "Tears down all containers and proxy routes owned by \"{name}\", then removes the row. This cannot be undone.",
"deleteConfirmFallbackName": "this workload",
"deleteConfirmYes": "Yes, delete",
"deleteConfirmDeleting": "Deleting…",
"manualDeploySub": "Bypasses configured triggers and dispatches through the source plugin directly.",
"chainTriggersZero": "no triggers",
"chainTriggersOne": "1 trigger",
+274
View File
@@ -1128,7 +1128,127 @@
}
},
"apps": {
"list": {
"eyebrowSuffix": "ПРИЛОЖЕНИЯ",
"title": "Приложения",
"ledePrefix": "Plugin-native деплои —",
"ledeKindImage": "image",
"ledeKindCompose": "compose",
"ledeKindStatic": "static",
"ledeMiddle": ", или",
"ledeSuffix": ", с подключаемыми триггерами передеплоя. Старые проекты, стеки и сайты на время переезда остаются в собственных разделах.",
"statTotal": "ВСЕГО",
"statImage": "IMAGE",
"statCompose": "COMPOSE",
"statStatic": "STATIC",
"refresh": "Обновить",
"newApp": "Новое приложение",
"filterAriaLabel": "Фильтр по source-плагину",
"filterAll": "ВСЕ",
"loadError": "Не удалось загрузить приложения",
"alertTag": "ОШ",
"emptyTitle": "Приложений пока нет",
"emptyBody": "Приложения объединяют image, compose и static-деплои за единым plugin-driven интерфейсом. Создайте первое, чтобы увидеть его здесь.",
"emptyCta": "Создать первое приложение",
"colName": "Имя",
"colSource": "Источник",
"colTrigger": "Триггер",
"colCreated": "Создано",
"colActions": "Действия",
"rowOpen": "Открыть"
},
"new": {
"pageTitle": "Новое приложение · Tinyforge",
"backLabel": "К приложениям",
"eyebrowSuffix": "НОВОЕ ПРИЛОЖЕНИЕ",
"title": "Создать приложение",
"ledePrefix": "Создайте plugin-native нагрузку.",
"ledeSourceLabel": "Источник",
"ledeSourceMid": "= как она деплоится (image, compose, static). Выберите или создайте",
"ledeTriggerLabel": "триггер",
"ledeSuffix": "ниже — при срабатывании source-плагин передеплоит приложение.",
"loadingKinds": "Загрузка доступных видов плагинов…",
"alertTag": "ОШ",
"fieldName": "Имя",
"fieldNameRequired": "ОБЯЗАТЕЛЬНО",
"fieldNamePlaceholder": "my-app",
"fieldNameHint": "В нижнем регистре, без пробелов. Используется в именах контейнеров и поддоменах.",
"fieldSourcePlugin": "Source-плагин",
"fieldSourceLabel": "Источник",
"fieldSourceHint": "Список берётся из запущенного демона — видны только вкомпилированные плагины. Триггеры (registry / git / manual) настраиваются ниже как отдельные записи.",
"fieldSourceConfig": "Конфигурация источника",
"fieldConfigYaml": "YAML",
"fieldConfigForm": "ФОРМА",
"fieldConfigJson": "JSON",
"advancedJson": "Расширенный JSON",
"backToForm": "К форме",
"resetSample": "Сбросить к примеру",
"switchToJsonTitle": "Переключиться на сырой JSON-редактор",
"switchToFormTitle": "Вернуться к форме",
"jsonOk": "JSON ОК",
"jsonInvalid": "JSON НЕВЕРНЫЙ",
"linesUnit": "строк",
"composeHeader": "compose.yaml · compose",
"composeAriaLabel": "Compose YAML",
"composeProjectLabel": "Имя compose-проекта (опционально)",
"composeProjectPlaceholder": "(по умолчанию — нормализованное имя нагрузки)",
"composePlaceholder": "services:\n web:\n image: nginx:alpine\n ports:\n - \"80\"",
"imageHeader": "image-источник · параметры рантайма",
"imageRefLabel": "Образ (путь в реестре)",
"imageRefPlaceholder": "registry.example.com/owner/app",
"imageRefHint": "Полная ссылка без тега; тег задаётся при деплое — триггером или полем «Тег по умолчанию» ниже.",
"imagePort": "Порт",
"imageHealthcheck": "Путь healthcheck",
"imageDefaultTag": "Тег по умолчанию",
"imageRegistryLabel": "Реестр (для приватных pull-ов)",
"imageRegistryPublic": "(публичный — без авторизации)",
"imageRegistryHint": "Имя должно совпадать с записью на странице «Реестры». Оставьте пустым для публичных образов.",
"imageCpu": "Лимит CPU (ядра, 0 = ∞)",
"imageMemory": "Лимит памяти (МБ, 0 = ∞)",
"imageMax": "Макс. инстансов",
"imageMaxHint": "1 = строгий blue-green.",
"imageFoot": "Переменные окружения и тома задаются в отдельных панелях на странице нагрузки после создания.",
"staticHeader": "static-источник · страницы из репозитория",
"staticProvider": "Провайдер",
"staticBaseUrl": "Base URL",
"staticBaseUrlPlaceholder": "https://git.example.com",
"staticRepoOwner": "Владелец репозитория",
"staticRepoOwnerPlaceholder": "owner",
"staticRepoName": "Имя репозитория",
"staticRepoNamePlaceholder": "pages",
"staticBranch": "Ветка",
"staticBranchPlaceholder": "main",
"staticFolder": "Папка (опционально)",
"staticFolderPlaceholder": "(корень репозитория)",
"staticToken": "Токен доступа (приватные репозитории)",
"staticTokenPlaceholder": "(оставьте пустым для публичных репозиториев)",
"staticTokenHint": "Шифруется при хранении. Нужен только для приватных репозиториев.",
"staticMode": "Режим",
"staticModeStaticDesc": "— раздача файлов через nginx; ноль рантайм-оверхеда.",
"staticModeDenoDesc": "— Deno-рантайм с опциональной динамической маршрутизацией.",
"staticRenderMarkdown": "Рендерить markdown",
"staticRenderMarkdownDesc": "— автоматически отдавать <code>.md</code> файлы как HTML-страницы.",
"staticFoot": "Секрет вебхука для git push-триггеров появляется в панели вебхука нагрузки после создания.",
"sourceConfigJsonTitle": "source_config.json · {kind}",
"sourceConfigJsonAria": "Конфигурация source-плагина (JSON)",
"triggerNumLabel": "Триггер",
"triggerNumOptional": "ОПЦИОНАЛЬНО",
"triggerNewTag": "НОВЫЙ",
"triggerPickTag": "ВЫБРАТЬ",
"triggerSkipTag": "ПРОПУСК",
"noteSkipTag": "ПРОПУСК",
"noteEmptyTag": "∅",
"faceLabel": "Публичный фронт",
"faceOptional": "ОПЦИОНАЛЬНО",
"faceSubdomain": "Поддомен",
"faceSubdomainPlaceholder": "myapp",
"faceDomain": "Домен",
"faceDomainPlaceholder": "(наследуется из настроек)",
"facePort": "Целевой порт",
"faceHint": "Оставьте пустым, чтобы не создавать прокси-маршрут. Заполнение любого поля создаст одну запись фронта, привязанную к этой нагрузке.",
"cancel": "Отмена",
"submit": "Создать приложение",
"submitting": "Создание…",
"triggers": {
"section": "Триггер",
"sectionSub": "Необязательно. Выберите, откуда придёт сигнал передеплоя — слежение за реестром, git-событие или ручная кнопка.",
@@ -1151,6 +1271,160 @@
}
},
"detail": {
"pageTitleFallback": "Приложение",
"backLabel": "К приложениям",
"eyebrowSuffix": "ПРИЛОЖЕНИЕ",
"kickerId": "id: {id}",
"loading": "Загрузка нагрузки…",
"loadError": "Не удалось загрузить приложение",
"deployError": "Деплой не удался",
"saveError": "Сохранение не удалось",
"deleteError": "Удаление не удалось",
"alertTag": "ОШ",
"createdAt": "создано",
"refreshLabel": "Обновить",
"editButton": "Изменить",
"deleteButton": "Удалить",
"editTitle": "Редактирование конфигурации",
"editSubPrefix": "Источник",
"editSubSuffix": "· триггеры настраиваются в панели «Триггеры» ниже",
"editFieldName": "Имя",
"editFieldParent": "Родительская нагрузка",
"editFieldOptional": "ОПЦИОНАЛЬНО",
"editFieldParentPlaceholder": "UUID нагрузки (пусто для корневой)",
"editSourceConfig": "Конфигурация источника",
"editConfigYaml": "YAML",
"editConfigForm": "ФОРМА",
"editConfigJson": "JSON",
"advancedJson": "Расширенный JSON",
"backToForm": "К форме",
"switchToJsonTitle": "Переключиться на сырой JSON-редактор",
"switchToFormTitle": "Вернуться к форме",
"jsonOk": "JSON ОК",
"jsonInvalid": "JSON НЕВЕРНЫЙ",
"editComposeProject": "Имя compose-проекта (опционально)",
"editComposeProjectPlaceholder": "(по умолчанию — нормализованное имя нагрузки)",
"editComposePlaceholder": "services:\n web:\n image: nginx:alpine",
"editComposeAria": "Compose YAML",
"editComposeHeader": "compose.yaml",
"editImageHeader": "image-источник · параметры рантайма",
"editImageRef": "Образ (путь в реестре)",
"editImageRefPlaceholder": "registry.example.com/owner/app",
"editImagePort": "Порт",
"editImageHealthcheck": "Путь healthcheck",
"editImageDefaultTag": "Тег по умолчанию",
"editImageRegistry": "Реестр (для приватных pull-ов)",
"editImageRegistryPublic": "(публичный — без авторизации)",
"editImageCpu": "Лимит CPU (ядра, 0 = ∞)",
"editImageMemory": "Лимит памяти (МБ, 0 = ∞)",
"editImageMax": "Макс. инстансов",
"editImageFoot": "Переменные окружения и тома живут в своих панелях ниже — сохранение здесь их не затронет.",
"editStaticHeader": "static-источник · страницы из репозитория",
"editStaticProvider": "Провайдер",
"editStaticBaseUrl": "Base URL",
"editStaticBaseUrlPlaceholder": "https://git.example.com",
"editStaticRepoOwner": "Владелец репозитория",
"editStaticRepoName": "Имя репозитория",
"editStaticBranch": "Ветка",
"editStaticFolder": "Папка (опционально)",
"editStaticFolderPlaceholder": "(корень репозитория)",
"editStaticToken": "Токен доступа (приватные репозитории)",
"editStaticTokenPlaceholder": "(оставьте пустым для публичных репозиториев)",
"editStaticMode": "Режим",
"editStaticModeStaticDesc": "— раздача файлов через nginx.",
"editStaticModeDenoDesc": "— Deno-рантайм с динамической маршрутизацией.",
"editStaticRenderMarkdown": "Рендерить markdown",
"editStaticRenderMarkdownDesc": "— автоматически отдавать <code>.md</code> как HTML.",
"editSourceJsonHeader": "source_config.json",
"editSourceJsonAria": "Конфигурация source-плагина (JSON)",
"editPublicFaces": "Публичные фронты",
"editPublicFacesTag": "JSON МАССИВ",
"editPublicFacesHeader": "public_faces.json",
"editPublicFacesAria": "Конфигурация публичных фронтов (JSON-массив)",
"editCancel": "Отмена",
"editSave": "Сохранить",
"editSaving": "Сохранение…",
"manualDeployTitle": "Ручной деплой",
"manualDeployOk": "ОК",
"manualDeployDispatched": "Отправлено {reference} как {by}",
"manualDeployRefAria": "Референс для деплоя",
"manualDeployRefPlaceholder": "референс (тег образа, git sha; пусто — по умолчанию)",
"manualDeployButton": "Деплой",
"manualDeployDispatching": "Отправка…",
"manualDeployHint": "Задайте конкретный тег образа, git sha или ветку, чтобы принудительно деплоить. Оставьте пустым, чтобы source-плагин выбрал референс по умолчанию.",
"containersTitle": "Контейнеры",
"containersEmpty": "Контейнеров пока нет",
"containersCount": "{count} согласовано",
"containersEmptyInline": "Контейнеров пока нет — задеплойте, чтобы поднять первый.",
"containersColRole": "Роль",
"containersColState": "Состояние",
"containersColImage": "Образ",
"containersColSubdomain": "Поддомен",
"containersColLastSeen": "Последний раз виден",
"containersColActions": "Действия",
"containersLogsAction": "Логи",
"chainTitle": "Цепочка",
"chainSubFromParent": "продвигается от родителя",
"chainSubParentOf": "родитель для",
"chainChildSingular": "дочерней нагрузки",
"chainChildPlural": "дочерних нагрузок",
"chainParentLabel": "Родитель",
"chainSelfLabel": "Эта",
"chainChildrenLabel": "Дочерние",
"chainPromoteButton": "Продвинуть от родителя",
"chainPromoting": "Продвижение…",
"chainHint": "Задайте <code>parent_workload_id</code> у нагрузки, чтобы построить цепочку. Дочерние image-источники могут одним кликом продвинуть текущий запущенный тег родителя.",
"volumesTitle": "Тома",
"volumesEmpty": "Нет монтирований",
"volumesCountSingular": "{count} монтирование",
"volumesCountPlural": "{count} монтирований",
"volumesColTarget": "Цель",
"volumesColSource": "Источник",
"volumesColScope": "Скоуп",
"volumesColUpdated": "Обновлено",
"volumesColActions": "Действия",
"volumeSource": "Источник (хост)",
"volumeSourcePlaceholder": "/srv/data/myapp",
"volumeTarget": "Цель (контейнер)",
"volumeTargetPlaceholder": "/data",
"volumeScope": "Скоуп",
"volumeAddButton": "Добавить / Заменить",
"volumeSaving": "Сохранение…",
"volumeHint": "Абсолютные монтирования прокидывают путь хоста в контейнер. Другие скоупы приняты для будущих сценариев; сегодня при деплое применяется только absolute.",
"volumeTargetError": "Цель должна быть абсолютным путём в контейнере (например, /data)",
"volumeSetFailed": "Не удалось задать том",
"volumeDeleteFailed": "Не удалось удалить том",
"envTitle": "Окружение",
"envEmpty": "Нет переопределений",
"envCountSingular": "{count} переопределение",
"envCountPlural": "{count} переопределений",
"envColKey": "Ключ",
"envColValue": "Значение",
"envColUpdated": "Обновлено",
"envColActions": "Действия",
"envEncrypted": "ЗАШИФРОВАНО",
"envKey": "Ключ",
"envKeyPlaceholder": "DATABASE_URL",
"envValue": "Значение",
"envValuePlaceholder": "(пусто — снять)",
"envEncryptLabel": "Шифровать при хранении",
"envAddButton": "Добавить / Заменить",
"envSaving": "Сохранение…",
"envHint": "Зашифрованные значения после записи доступны только на запись — API скрывает их при чтении. Ротация — установка нового значения.",
"envKeyRequired": "Ключ обязателен",
"envSetFailed": "Не удалось задать переменную",
"envDeleteFailed": "Не удалось удалить переменную",
"sourceConfigTitle": "Конфигурация источника",
"sourceConfigCopy": "Копировать",
"sourceConfigCopied": "Скопировано",
"sourceConfigCopyAria": "Копировать конфиг источника",
"publicFacesTitle": "Публичные фронты",
"publicFacesCopyAria": "Копировать публичные фронты",
"deleteConfirmTitle": "Удалить это приложение?",
"deleteConfirmMessage": "Сносит все контейнеры и прокси-маршруты, принадлежащие «{name}», затем удаляет запись. Это нельзя отменить.",
"deleteConfirmFallbackName": "эта нагрузка",
"deleteConfirmYes": "Да, удалить",
"deleteConfirmDeleting": "Удаление…",
"manualDeploySub": "Обходит настроенные триггеры и отправляет деплой напрямую через source-плагин.",
"chainTriggersZero": "без триггеров",
"chainTriggersOne": "1 триггер",