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 триггер",
+24 -27
View File
@@ -4,6 +4,7 @@
import * as api from '$lib/api';
import { IconPlus, IconRefresh } from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { t } from '$lib/i18n';
let workloads = $state<Workload[]>([]);
let loading = $state(true);
@@ -32,7 +33,7 @@
try {
workloads = await api.listWorkloads();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load apps';
error = e instanceof Error ? e.message : $t('apps.list.loadError');
} finally {
loading = false;
}
@@ -60,43 +61,42 @@
<div class="forge">
{#snippet appsToolbar()}
<button class="forge-btn-icon" onclick={load} aria-label="Refresh">
<button class="forge-btn-icon" onclick={load} aria-label={$t('apps.list.refresh')}>
<IconRefresh size={16} />
</button>
<a href="/apps/new" class="forge-btn">
<IconPlus size={14} />
<span>New App</span>
<span>{$t('apps.list.newApp')}</span>
</a>
{/snippet}
{#snippet appsStats()}
<div>
<dt>TOTAL</dt>
<dt>{$t('apps.list.statTotal')}</dt>
<dd>{loading ? '—' : String(pluginRows.length).padStart(2, '0')}</dd>
</div>
<div>
<dt>IMAGE</dt>
<dt>{$t('apps.list.statImage')}</dt>
<dd>{loading ? '—' : String(countBy('image')).padStart(2, '0')}</dd>
</div>
<div>
<dt>COMPOSE</dt>
<dt>{$t('apps.list.statCompose')}</dt>
<dd>{loading ? '—' : String(countBy('compose')).padStart(2, '0')}</dd>
</div>
<div>
<dt>STATIC</dt>
<dt>{$t('apps.list.statStatic')}</dt>
<dd class="accent">{loading ? '—' : String(countBy('static')).padStart(2, '0')}</dd>
</div>
{/snippet}
{#snippet appsLede()}
Plugin-native deployables &mdash; <em>image</em>, <em>compose</em>, or <em>static</em>, with
pluggable redeploy triggers. Legacy projects, stacks, and sites continue to live under their
own sections during the cutover.
{$t('apps.list.ledePrefix')} <em>{$t('apps.list.ledeKindImage')}</em>, <em>{$t('apps.list.ledeKindCompose')}</em>{$t('apps.list.ledeMiddle')}
<em>{$t('apps.list.ledeKindStatic')}</em>{$t('apps.list.ledeSuffix')}
{/snippet}
<ForgeHero
eyebrowSuffix="APPS"
title="Apps"
eyebrowSuffix={$t('apps.list.eyebrowSuffix')}
title={$t('apps.list.title')}
size="lg"
toolbar={appsToolbar}
lede_html={appsLede}
@@ -104,11 +104,11 @@
/>
{#if error}
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
<div class="alert"><span class="alert-tag">{$t('apps.list.alertTag')}</span><span>{error}</span></div>
{/if}
{#if !loading && pluginRows.length > 0}
<div class="filter-row" role="tablist" aria-label="Filter by source plugin">
<div class="filter-row" role="tablist" aria-label={$t('apps.list.filterAriaLabel')}>
<button
class="chip"
class:active={filter === 'all'}
@@ -116,7 +116,7 @@
aria-selected={filter === 'all'}
onclick={() => (filter = 'all')}
>
<span class="chip-label">ALL</span>
<span class="chip-label">{$t('apps.list.filterAll')}</span>
<span class="chip-count">{String(pluginRows.length).padStart(2, '0')}</span>
</button>
{#each sourceKinds as kind}
@@ -145,13 +145,10 @@
{:else if filtered.length === 0}
<div class="empty">
<div class="empty-mark"><span></span><span></span><span></span></div>
<h2>No apps yet</h2>
<p>
Apps unify image, compose, and static deployables behind a single plugin-driven
surface. Forge your first one to see it light up here.
</p>
<h2>{$t('apps.list.emptyTitle')}</h2>
<p>{$t('apps.list.emptyBody')}</p>
<a href="/apps/new" class="btn-primary">
<IconPlus size={14} /><span>Forge the first app</span>
<IconPlus size={14} /><span>{$t('apps.list.emptyCta')}</span>
</a>
</div>
{:else}
@@ -159,11 +156,11 @@
<table class="forge-table">
<thead>
<tr>
<th>Name</th>
<th>Source</th>
<th>Trigger</th>
<th>Created</th>
<th class="t-right">Actions</th>
<th>{$t('apps.list.colName')}</th>
<th>{$t('apps.list.colSource')}</th>
<th>{$t('apps.list.colTrigger')}</th>
<th>{$t('apps.list.colCreated')}</th>
<th class="t-right">{$t('apps.list.colActions')}</th>
</tr>
</thead>
<tbody>
@@ -186,7 +183,7 @@
<td class="muted mono">{w.created_at}</td>
<td class="actions-cell">
<a class="row-action" href={`/apps/${w.id}`}>
Open <span class="arrow" aria-hidden="true"></span>
{$t('apps.list.rowOpen')} <span class="arrow" aria-hidden="true"></span>
</a>
</td>
</tr>
+166 -171
View File
@@ -401,7 +401,7 @@
// session storage may be disabled — ignore.
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load app';
error = e instanceof Error ? e.message : $t('apps.detail.loadError');
} finally {
loading = false;
}
@@ -469,7 +469,7 @@
async function addVolume() {
volumeError = '';
if (!newVolTarget.trim().startsWith('/')) {
volumeError = 'Target must be an absolute container path (e.g. /data)';
volumeError = $t('apps.detail.volumeTargetError');
return;
}
volumeSaving = true;
@@ -483,7 +483,7 @@
newVolTarget = '';
volumeRows = await api.listWorkloadVolumes(id);
} catch (e) {
volumeError = e instanceof Error ? e.message : 'Failed to set volume';
volumeError = e instanceof Error ? e.message : $t('apps.detail.volumeSetFailed');
} finally {
volumeSaving = false;
}
@@ -495,7 +495,7 @@
await api.deleteWorkloadVolume(id, volID);
volumeRows = volumeRows.filter((v) => v.id !== volID);
} catch (e) {
volumeError = e instanceof Error ? e.message : 'Failed to delete volume';
volumeError = e instanceof Error ? e.message : $t('apps.detail.volumeDeleteFailed');
}
}
@@ -503,7 +503,7 @@
envError = '';
const key = newEnvKey.trim();
if (!key) {
envError = 'Key is required';
envError = $t('apps.detail.envKeyRequired');
return;
}
envSaving = true;
@@ -517,7 +517,7 @@
newEnvValue = '';
envRows = await api.listWorkloadEnv(id);
} catch (e) {
envError = e instanceof Error ? e.message : 'Failed to set env';
envError = e instanceof Error ? e.message : $t('apps.detail.envSetFailed');
} finally {
envSaving = false;
}
@@ -529,7 +529,7 @@
await api.deleteWorkloadEnv(id, envID);
envRows = envRows.filter((e) => e.id !== envID);
} catch (e) {
envError = e instanceof Error ? e.message : 'Failed to delete env';
envError = e instanceof Error ? e.message : $t('apps.detail.envDeleteFailed');
}
}
@@ -540,10 +540,13 @@
try {
const body = deployRef ? { reference: deployRef } : undefined;
const res = await api.deployPluginWorkload(id, body);
lastDeployMsg = `Dispatched ${res.reference || '(default)'} as ${res.triggered_by}`;
lastDeployMsg = $t('apps.detail.manualDeployDispatched', {
reference: res.reference || '(default)',
by: res.triggered_by
});
setTimeout(load, 1500);
} catch (e) {
error = e instanceof Error ? e.message : 'Deploy failed';
error = e instanceof Error ? e.message : $t('apps.detail.deployError');
} finally {
deploying = false;
}
@@ -789,7 +792,7 @@
editing = false;
await load();
} catch (e) {
error = e instanceof Error ? e.message : 'Save failed';
error = e instanceof Error ? e.message : $t('apps.detail.saveError');
} finally {
saving = false;
}
@@ -913,7 +916,7 @@
await api.deletePluginWorkload(id);
goto('/apps');
} catch (e) {
error = e instanceof Error ? e.message : 'Delete failed';
error = e instanceof Error ? e.message : $t('apps.detail.deleteError');
deleting = false;
confirmDelete = false;
}
@@ -960,30 +963,30 @@
</script>
<svelte:head>
<title>{workload?.name ?? 'App'} · Tinyforge</title>
<title>{workload?.name ?? $t('apps.detail.pageTitleFallback')} · Tinyforge</title>
</svelte:head>
<div class="forge">
{#if loading && !workload}
<div class="loading-line">
<span class="spinner" aria-hidden="true"></span>
<span>Loading workload…</span>
<span>{$t('apps.detail.loading')}</span>
</div>
{:else if error && !workload}
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
<div class="alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{error}</span></div>
{:else if workload}
{#snippet detailToolbar()}
<button class="forge-btn-icon" onclick={load} aria-label="Refresh">
<button class="forge-btn-icon" onclick={load} aria-label={$t('apps.detail.refreshLabel')}>
<IconRefresh size={16} />
</button>
{#if !editing}
<button class="forge-btn-ghost" onclick={startEdit}>
<IconEdit size={13} />
<span>Edit</span>
<span>{$t('apps.detail.editButton')}</span>
</button>
<button class="forge-btn-ghost danger" onclick={() => (confirmDelete = true)}>
<IconTrash size={13} />
<span>Delete</span>
<span>{$t('apps.detail.deleteButton')}</span>
</button>
{/if}
{/snippet}
@@ -1003,24 +1006,24 @@
</span>
<span class="lede-sep">·</span>
<span class="lede-meta">
created <code>{workload!.created_at}</code>
{$t('apps.detail.createdAt')} <code>{workload!.created_at}</code>
</span>
</span>
{/snippet}
<ForgeHero
backHref="/apps"
backLabel="Back to apps"
eyebrowSuffix="APP"
backLabel={$t('apps.detail.backLabel')}
eyebrowSuffix={$t('apps.detail.eyebrowSuffix')}
title={workload.name}
kicker={`id: ${workload.id}`}
kicker={$t('apps.detail.kickerId', { id: workload.id })}
size="lg"
toolbar={detailToolbar}
lede_html={detailLede}
/>
{#if error}
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
<div class="alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{error}</span></div>
{/if}
{#if editing}
@@ -1032,17 +1035,16 @@
<span class="reg reg-br" aria-hidden="true"></span>
<header class="panel-head">
<h2 class="panel-title">Edit configuration<span class="title-accent">.</span></h2>
<h2 class="panel-title">{$t('apps.detail.editTitle')}<span class="title-accent">.</span></h2>
<span class="panel-sub">
Source <code>{workload.source_kind}</code> · triggers managed in the
Triggers panel below
{$t('apps.detail.editSubPrefix')} <code>{workload.source_kind}</code> {$t('apps.detail.editSubSuffix')}
</span>
</header>
<div class="field">
<label for="edit-name" class="field-label">
<span class="num">01</span>
<span class="lbl">Name</span>
<span class="lbl">{$t('apps.detail.editFieldName')}</span>
</label>
<input
id="edit-name"
@@ -1057,15 +1059,15 @@
<div class="field">
<label for="edit-parent" class="field-label">
<span class="num">02</span>
<span class="lbl">Parent workload</span>
<span class="opt">OPTIONAL</span>
<span class="lbl">{$t('apps.detail.editFieldParent')}</span>
<span class="opt">{$t('apps.detail.editFieldOptional')}</span>
</label>
<input
id="edit-parent"
type="text"
class="input"
bind:value={editParentID}
placeholder="workload UUID (blank for root)"
placeholder={$t('apps.detail.editFieldParentPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
@@ -1074,28 +1076,28 @@
<div class="field">
<div class="field-label">
<span class="num">03</span>
<span class="lbl">Source config</span>
<span class="lbl">{$t('apps.detail.editSourceConfig')}</span>
<span class="req">
{useEditComposeForm
? 'YAML'
? $t('apps.detail.editConfigYaml')
: useEditImageForm || useEditStaticForm
? 'FORM'
: 'JSON'}
? $t('apps.detail.editConfigForm')
: $t('apps.detail.editConfigJson')}
</span>
</div>
{#if useEditComposeForm}
<div class="editor">
<div class="editor-head">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="editor-title">compose.yaml</span>
<span class="editor-title">{$t('apps.detail.editComposeHeader')}</span>
<span class="spacer"></span>
<button
type="button"
class="editor-chip"
onclick={toggleEditAdvancedJSON}
title="Switch to the raw JSON editor"
title={$t('apps.detail.switchToJsonTitle')}
>
Advanced JSON
{$t('apps.detail.advancedJson')}
</button>
</div>
<textarea
@@ -1104,24 +1106,24 @@
rows="12"
spellcheck="false"
class="code-area"
placeholder={'services:\n web:\n image: nginx:alpine'}
aria-label="Compose YAML"
placeholder={$t('apps.detail.editComposePlaceholder')}
aria-label={$t('apps.detail.editComposeAria')}
></textarea>
<div class="editor-foot">
<span class="foot-status">
<span class="foot-dot" aria-hidden="true"></span>
YAML
{$t('apps.detail.editConfigYaml')}
</span>
</div>
</div>
<label class="sub" for="edit-compose-project">
<span class="sub-label">Compose project name (optional)</span>
<span class="sub-label">{$t('apps.detail.editComposeProject')}</span>
<input
id="edit-compose-project"
type="text"
class="input"
bind:value={editComposeProjectName}
placeholder="(defaults to sanitized workload name)"
placeholder={$t('apps.detail.editComposeProjectPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
@@ -1129,24 +1131,24 @@
{:else if useEditImageForm}
<div class="image-form">
<div class="image-form-head">
<span class="editor-title">image source · runtime knobs</span>
<span class="editor-title">{$t('apps.detail.editImageHeader')}</span>
<button
type="button"
class="editor-chip"
onclick={toggleEditAdvancedJSON}
title="Switch to the raw JSON editor"
title={$t('apps.detail.switchToJsonTitle')}
>
Advanced JSON
{$t('apps.detail.advancedJson')}
</button>
</div>
<label class="sub" for="edit-image-ref">
<span class="sub-label">Image (registry path)</span>
<span class="sub-label">{$t('apps.detail.editImageRef')}</span>
<input
id="edit-image-ref"
type="text"
class="input mono"
bind:value={editImageRef}
placeholder="registry.example.com/owner/app"
placeholder={$t('apps.detail.editImageRefPlaceholder')}
autocomplete="off"
spellcheck="false"
required
@@ -1154,7 +1156,7 @@
</label>
<div class="row three">
<label class="sub" for="edit-image-port">
<span class="sub-label">Port</span>
<span class="sub-label">{$t('apps.detail.editImagePort')}</span>
<input
id="edit-image-port"
type="number"
@@ -1165,7 +1167,7 @@
/>
</label>
<label class="sub" for="edit-image-healthcheck">
<span class="sub-label">Healthcheck path</span>
<span class="sub-label">{$t('apps.detail.editImageHealthcheck')}</span>
<input
id="edit-image-healthcheck"
type="text"
@@ -1177,7 +1179,7 @@
/>
</label>
<label class="sub" for="edit-image-default-tag">
<span class="sub-label">Default tag</span>
<span class="sub-label">{$t('apps.detail.editImageDefaultTag')}</span>
<input
id="edit-image-default-tag"
type="text"
@@ -1190,14 +1192,14 @@
</label>
</div>
<label class="sub" for="edit-image-registry">
<span class="sub-label">Registry (for private pulls)</span>
<span class="sub-label">{$t('apps.detail.editImageRegistry')}</span>
{#if editRegistries.length > 0}
<select
id="edit-image-registry"
class="input"
bind:value={editImageRegistryName}
>
<option value="">(public — no auth)</option>
<option value="">{$t('apps.detail.editImageRegistryPublic')}</option>
{#each editRegistries as r}
<option value={r.name}>{r.name} {r.url}</option>
{/each}
@@ -1208,14 +1210,14 @@
type="text"
class="input"
bind:value={editImageRegistryName}
placeholder="(public no auth)"
placeholder={$t('apps.detail.editImageRegistryPublic')}
autocomplete="off"
/>
{/if}
</label>
<div class="row three">
<label class="sub" for="edit-image-cpu">
<span class="sub-label">CPU limit (cores, 0 = ∞)</span>
<span class="sub-label">{$t('apps.detail.editImageCpu')}</span>
<input
id="edit-image-cpu"
type="number"
@@ -1226,7 +1228,7 @@
/>
</label>
<label class="sub" for="edit-image-memory">
<span class="sub-label">Memory limit (MB, 0 = ∞)</span>
<span class="sub-label">{$t('apps.detail.editImageMemory')}</span>
<input
id="edit-image-memory"
type="number"
@@ -1236,7 +1238,7 @@
/>
</label>
<label class="sub" for="edit-image-max">
<span class="sub-label">Max instances</span>
<span class="sub-label">{$t('apps.detail.editImageMax')}</span>
<input
id="edit-image-max"
type="number"
@@ -1246,27 +1248,24 @@
/>
</label>
</div>
<p class="hint image-form-foot">
Env vars and volume mounts use their own panels below — saving here
preserves them.
</p>
<p class="hint image-form-foot">{$t('apps.detail.editImageFoot')}</p>
</div>
{:else if useEditStaticForm}
<div class="image-form">
<div class="image-form-head">
<span class="editor-title">static source · pages from a repo</span>
<span class="editor-title">{$t('apps.detail.editStaticHeader')}</span>
<button
type="button"
class="editor-chip"
onclick={toggleEditAdvancedJSON}
title="Switch to the raw JSON editor"
title={$t('apps.detail.switchToJsonTitle')}
>
Advanced JSON
{$t('apps.detail.advancedJson')}
</button>
</div>
<div class="row">
<label class="sub" for="edit-static-provider">
<span class="sub-label">Provider</span>
<span class="sub-label">{$t('apps.detail.editStaticProvider')}</span>
<select
id="edit-static-provider"
class="input"
@@ -1278,13 +1277,13 @@
</select>
</label>
<label class="sub" for="edit-static-base-url">
<span class="sub-label">Base URL</span>
<span class="sub-label">{$t('apps.detail.editStaticBaseUrl')}</span>
<input
id="edit-static-base-url"
type="url"
class="input mono"
bind:value={editStaticBaseURL}
placeholder="https://git.example.com"
placeholder={$t('apps.detail.editStaticBaseUrlPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
@@ -1292,7 +1291,7 @@
</div>
<div class="row">
<label class="sub" for="edit-static-owner">
<span class="sub-label">Repo owner</span>
<span class="sub-label">{$t('apps.detail.editStaticRepoOwner')}</span>
<input
id="edit-static-owner"
type="text"
@@ -1304,7 +1303,7 @@
/>
</label>
<label class="sub" for="edit-static-name">
<span class="sub-label">Repo name</span>
<span class="sub-label">{$t('apps.detail.editStaticRepoName')}</span>
<input
id="edit-static-name"
type="text"
@@ -1318,7 +1317,7 @@
</div>
<div class="row">
<label class="sub" for="edit-static-branch">
<span class="sub-label">Branch</span>
<span class="sub-label">{$t('apps.detail.editStaticBranch')}</span>
<input
id="edit-static-branch"
type="text"
@@ -1329,31 +1328,31 @@
/>
</label>
<label class="sub" for="edit-static-folder">
<span class="sub-label">Folder path (optional)</span>
<span class="sub-label">{$t('apps.detail.editStaticFolder')}</span>
<input
id="edit-static-folder"
type="text"
class="input mono"
bind:value={editStaticFolderPath}
placeholder="(repo root)"
placeholder={$t('apps.detail.editStaticFolderPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
</label>
</div>
<label class="sub" for="edit-static-token">
<span class="sub-label">Access token (private repos)</span>
<span class="sub-label">{$t('apps.detail.editStaticToken')}</span>
<input
id="edit-static-token"
type="password"
class="input"
bind:value={editStaticAccessToken}
placeholder="(leave blank for public repos)"
placeholder={$t('apps.detail.editStaticTokenPlaceholder')}
autocomplete="new-password"
/>
</label>
<fieldset class="static-mode">
<legend class="sub-label">Mode</legend>
<legend class="sub-label">{$t('apps.detail.editStaticMode')}</legend>
<label class="radio">
<input
type="radio"
@@ -1361,7 +1360,7 @@
value="static"
bind:group={editStaticMode}
/>
<span><strong>static</strong> — serve files via nginx.</span>
<span><strong>static</strong> {$t('apps.detail.editStaticModeStaticDesc')}</span>
</label>
<label class="radio">
<input
@@ -1370,16 +1369,16 @@
value="deno"
bind:group={editStaticMode}
/>
<span><strong>deno</strong> — Deno runtime with dynamic routing.</span>
<span><strong>deno</strong> {$t('apps.detail.editStaticModeDenoDesc')}</span>
</label>
</fieldset>
<label class="checkbox-row">
<ToggleSwitch
bind:checked={editStaticRenderMarkdown}
label="Render markdown"
label={$t('apps.detail.editStaticRenderMarkdown')}
/>
<span>
<strong>Render markdown</strong> — auto-render <code>.md</code> as HTML.
<strong>{$t('apps.detail.editStaticRenderMarkdown')}</strong> {@html $t('apps.detail.editStaticRenderMarkdownDesc')}
</span>
</label>
</div>
@@ -1387,16 +1386,16 @@
<div class="editor">
<div class="editor-head">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="editor-title">source_config.json</span>
<span class="editor-title">{$t('apps.detail.editSourceJsonHeader')}</span>
<span class="spacer"></span>
{#if (workload?.source_kind ?? '') === 'compose' || (workload?.source_kind ?? '') === 'image' || (workload?.source_kind ?? '') === 'static'}
<button
type="button"
class="editor-chip"
onclick={toggleEditAdvancedJSON}
title="Switch back to the form"
title={$t('apps.detail.switchToFormTitle')}
>
Back to form
{$t('apps.detail.backToForm')}
</button>
{/if}
</div>
@@ -1406,12 +1405,12 @@
rows="12"
spellcheck="false"
class="code-area"
aria-label="Source plugin configuration (JSON)"
aria-label={$t('apps.detail.editSourceJsonAria')}
></textarea>
<div class="editor-foot">
<span class="foot-status" class:bad={!sourceValid}>
<span class="foot-dot" aria-hidden="true"></span>
{sourceValid ? 'JSON OK' : 'JSON INVALID'}
{sourceValid ? $t('apps.detail.jsonOk') : $t('apps.detail.jsonInvalid')}
</span>
</div>
</div>
@@ -1421,13 +1420,13 @@
<div class="field">
<div class="field-label">
<span class="num">04</span>
<span class="lbl">Public faces</span>
<span class="opt">JSON ARRAY</span>
<span class="lbl">{$t('apps.detail.editPublicFaces')}</span>
<span class="opt">{$t('apps.detail.editPublicFacesTag')}</span>
</div>
<div class="editor">
<div class="editor-head">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="editor-title">public_faces.json</span>
<span class="editor-title">{$t('apps.detail.editPublicFacesHeader')}</span>
</div>
<textarea
id="edit-faces"
@@ -1435,19 +1434,19 @@
rows="7"
spellcheck="false"
class="code-area"
aria-label="Public faces configuration (JSON array)"
aria-label={$t('apps.detail.editPublicFacesAria')}
></textarea>
<div class="editor-foot">
<span class="foot-status" class:bad={!facesValid}>
<span class="foot-dot" aria-hidden="true"></span>
{facesValid ? 'JSON OK' : 'JSON INVALID'}
{facesValid ? $t('apps.detail.jsonOk') : $t('apps.detail.jsonInvalid')}
</span>
</div>
</div>
</div>
<div class="actions">
<button class="forge-btn-ghost" onclick={cancelEdit} disabled={saving}>Cancel</button>
<button class="forge-btn-ghost" onclick={cancelEdit} disabled={saving}>{$t('apps.detail.editCancel')}</button>
<button
class="forge-btn"
onclick={saveEdit}
@@ -1458,7 +1457,7 @@
(useEditStaticForm && (!editStaticRepoOwner.trim() || !editStaticRepoName.trim())) ||
!facesValid}
>
{saving ? 'Saving' : 'Save changes'}
{saving ? $t('apps.detail.editSaving') : $t('apps.detail.editSave')}
</button>
</div>
</section>
@@ -1467,38 +1466,35 @@
<!-- ── Manual deploy ────────────────────────────── -->
<section class="panel">
<header class="panel-head">
<h2 class="panel-title">Manual deploy<span class="title-accent">.</span></h2>
<h2 class="panel-title">{$t('apps.detail.manualDeployTitle')}<span class="title-accent">.</span></h2>
<span class="panel-sub">{$t('apps.detail.manualDeploySub')}</span>
</header>
{#if lastDeployMsg}
<div class="success">
<span class="success-tag">OK</span>
<span class="success-tag">{$t('apps.detail.manualDeployOk')}</span>
<span>{lastDeployMsg}</span>
</div>
{/if}
<div class="deploy-row">
<label class="deploy-input" for="deploy-ref">
<span class="sr-only">Deploy reference</span>
<span class="sr-only">{$t('apps.detail.manualDeployRefAria')}</span>
<input
id="deploy-ref"
type="text"
bind:value={deployRef}
placeholder="reference (image tag, git sha, blank for default)"
placeholder={$t('apps.detail.manualDeployRefPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
</label>
<button class="forge-btn" onclick={deploy} disabled={deploying}>
<IconDeploy size={14} />
<span>{deploying ? 'Dispatching' : 'Deploy'}</span>
<span>{deploying ? $t('apps.detail.manualDeployDispatching') : $t('apps.detail.manualDeployButton')}</span>
</button>
</div>
<p class="hint">
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.
</p>
<p class="hint">{$t('apps.detail.manualDeployHint')}</p>
</section>
<!-- ── Triggers (bindings) ─────────────────────────
@@ -1787,15 +1783,17 @@
<!-- ── Containers ───────────────────────────────── -->
<section class="panel">
<header class="panel-head">
<h2 class="panel-title">Containers<span class="title-accent">.</span></h2>
<h2 class="panel-title">{$t('apps.detail.containersTitle')}<span class="title-accent">.</span></h2>
<span class="panel-sub">
{containers.length === 0 ? 'No containers yet' : `${containers.length} reconciled`}
{containers.length === 0
? $t('apps.detail.containersEmpty')
: $t('apps.detail.containersCount', { count: String(containers.length) })}
</span>
</header>
{#if containers.length === 0}
<div class="empty-inline">
<span class="empty-mark" aria-hidden="true"></span>
<span>No containers yet — deploy to spin one up.</span>
<span>{$t('apps.detail.containersEmptyInline')}</span>
</div>
{:else}
{#if logContainerRowID}
@@ -1814,12 +1812,12 @@
<table class="forge-table">
<thead>
<tr>
<th>Role</th>
<th>State</th>
<th>Image</th>
<th>Subdomain</th>
<th>Last seen</th>
<th class="t-right">Actions</th>
<th>{$t('apps.detail.containersColRole')}</th>
<th>{$t('apps.detail.containersColState')}</th>
<th>{$t('apps.detail.containersColImage')}</th>
<th>{$t('apps.detail.containersColSubdomain')}</th>
<th>{$t('apps.detail.containersColLastSeen')}</th>
<th class="t-right">{$t('apps.detail.containersColActions')}</th>
</tr>
</thead>
<tbody>
@@ -1840,10 +1838,10 @@
<button
class="forge-btn-ghost"
onclick={() => (logContainerRowID = c.id)}
aria-label={`View logs for ${c.role || c.id}`}
aria-label={`${$t('apps.detail.containersLogsAction')}: ${c.role || c.id}`}
>
<IconServer size={13} />
<span>Logs</span>
<span>{$t('apps.detail.containersLogsAction')}</span>
</button>
{:else}
<span class="muted mono">—</span>
@@ -1861,20 +1859,20 @@
{#if !editing && chain && (chain.parent || chain.children.length > 0)}
<section class="panel">
<header class="panel-head">
<h2 class="panel-title">Chain<span class="title-accent">.</span></h2>
<h2 class="panel-title">{$t('apps.detail.chainTitle')}<span class="title-accent">.</span></h2>
<span class="panel-sub">
{chain.parent ? 'promotes from a parent' : 'parent of'}
{chain.parent ? $t('apps.detail.chainSubFromParent') : $t('apps.detail.chainSubParentOf')}
{chain.children.length}
{chain.children.length === 1 ? 'child' : 'children'}
{chain.children.length === 1 ? $t('apps.detail.chainChildSingular') : $t('apps.detail.chainChildPlural')}
</span>
</header>
{#if chainError}
<div class="alert inline-alert"><span class="alert-tag">ERR</span><span>{chainError}</span></div>
<div class="alert inline-alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{chainError}</span></div>
{/if}
{#if chain.parent}
<div class="chain-row">
<span class="chain-label">Parent</span>
<span class="chain-label">{$t('apps.detail.chainParentLabel')}</span>
<a class="chain-card" href={`/apps/${chain.parent.id}`}>
<span class="chain-name">{chain.parent.name}</span>
<span class="mono muted">{chain.parent.source_kind}</span>
@@ -1885,14 +1883,14 @@
disabled={promoting !== null}
onclick={() => promoteFrom(chain!.parent!.id)}
>
{promoting === chain.parent.id ? 'Promoting…' : 'Promote from parent'}
{promoting === chain.parent.id ? $t('apps.detail.chainPromoting') : $t('apps.detail.chainPromoteButton')}
</button>
{/if}
</div>
{/if}
<div class="chain-row">
<span class="chain-label">This</span>
<span class="chain-label">{$t('apps.detail.chainSelfLabel')}</span>
<div class="chain-card chain-self">
<span class="chain-name">{workload?.name ?? '—'}</span>
<span class="mono muted">
@@ -1909,7 +1907,7 @@
{#if chain.children.length > 0}
<div class="chain-row chain-children">
<span class="chain-label">Children</span>
<span class="chain-label">{$t('apps.detail.chainChildrenLabel')}</span>
<div class="chain-children-list">
{#each chain.children as child (child.id)}
<a class="chain-card" href={`/apps/${child.id}`}>
@@ -1920,10 +1918,7 @@
</div>
</div>
{/if}
<p class="hint">
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.
</p>
<p class="hint">{@html $t('apps.detail.chainHint')}</p>
</section>
{/if}
@@ -2024,26 +2019,28 @@
{#if !editing}
<section class="panel">
<header class="panel-head">
<h2 class="panel-title">Volumes<span class="title-accent">.</span></h2>
<h2 class="panel-title">{$t('apps.detail.volumesTitle')}<span class="title-accent">.</span></h2>
<span class="panel-sub">
{volumeRows.length === 0
? 'No mounts'
: `${volumeRows.length} mount${volumeRows.length === 1 ? '' : 's'}`}
? $t('apps.detail.volumesEmpty')
: volumeRows.length === 1
? $t('apps.detail.volumesCountSingular', { count: '1' })
: $t('apps.detail.volumesCountPlural', { count: String(volumeRows.length) })}
</span>
</header>
{#if volumeError}
<div class="alert inline-alert"><span class="alert-tag">ERR</span><span>{volumeError}</span></div>
<div class="alert inline-alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{volumeError}</span></div>
{/if}
{#if volumeRows.length > 0}
<div class="table-wrap">
<table class="forge-table">
<thead>
<tr>
<th>Target</th>
<th>Source</th>
<th>Scope</th>
<th class="t-right">Updated</th>
<th class="t-right">Actions</th>
<th>{$t('apps.detail.volumesColTarget')}</th>
<th>{$t('apps.detail.volumesColSource')}</th>
<th>{$t('apps.detail.volumesColScope')}</th>
<th class="t-right">{$t('apps.detail.volumesColUpdated')}</th>
<th class="t-right">{$t('apps.detail.volumesColActions')}</th>
</tr>
</thead>
<tbody>
@@ -2059,7 +2056,7 @@
<button
class="forge-btn-ghost danger"
onclick={() => removeVolume(v.id)}
aria-label={`Delete mount ${v.target}`}
aria-label={`${$t('apps.detail.volumesColActions')}: ${v.target}`}
>
<IconTrash size={13} />
</button>
@@ -2078,24 +2075,24 @@
}}
>
<label class="env-field">
<span>Source (host)</span>
<span>{$t('apps.detail.volumeSource')}</span>
<input
type="text"
bind:value={newVolSource}
placeholder="/srv/data/myapp"
placeholder={$t('apps.detail.volumeSourcePlaceholder')}
/>
</label>
<label class="env-field">
<span>Target (container)</span>
<span>{$t('apps.detail.volumeTarget')}</span>
<input
type="text"
bind:value={newVolTarget}
placeholder="/data"
placeholder={$t('apps.detail.volumeTargetPlaceholder')}
required
/>
</label>
<label class="env-field">
<span>Scope</span>
<span>{$t('apps.detail.volumeScope')}</span>
<select bind:value={newVolScope}>
<option value="absolute">absolute</option>
<option value="named">named</option>
@@ -2104,13 +2101,10 @@
</select>
</label>
<button class="forge-btn" type="submit" disabled={volumeSaving || !newVolTarget.trim()}>
{volumeSaving ? 'Saving' : 'Add / Replace'}
{volumeSaving ? $t('apps.detail.volumeSaving') : $t('apps.detail.volumeAddButton')}
</button>
</form>
<p class="hint">
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.
</p>
<p class="hint">{$t('apps.detail.volumeHint')}</p>
</section>
{/if}
@@ -2119,25 +2113,27 @@
{#if !editing}
<section class="panel">
<header class="panel-head">
<h2 class="panel-title">Env<span class="title-accent">.</span></h2>
<h2 class="panel-title">{$t('apps.detail.envTitle')}<span class="title-accent">.</span></h2>
<span class="panel-sub">
{envRows.length === 0
? 'No overrides'
: `${envRows.length} override${envRows.length === 1 ? '' : 's'}`}
? $t('apps.detail.envEmpty')
: envRows.length === 1
? $t('apps.detail.envCountSingular', { count: '1' })
: $t('apps.detail.envCountPlural', { count: String(envRows.length) })}
</span>
</header>
{#if envError}
<div class="alert inline-alert"><span class="alert-tag">ERR</span><span>{envError}</span></div>
<div class="alert inline-alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{envError}</span></div>
{/if}
{#if envRows.length > 0}
<div class="table-wrap">
<table class="forge-table">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th class="t-right">Updated</th>
<th class="t-right">Actions</th>
<th>{$t('apps.detail.envColKey')}</th>
<th>{$t('apps.detail.envColValue')}</th>
<th class="t-right">{$t('apps.detail.envColUpdated')}</th>
<th class="t-right">{$t('apps.detail.envColActions')}</th>
</tr>
</thead>
<tbody>
@@ -2148,7 +2144,7 @@
{#if e.encrypted}
<span class="state-pill st-encrypted">
<span class="pulse" aria-hidden="true"></span>
ENCRYPTED
{$t('apps.detail.envEncrypted')}
</span>
{:else}
<span class="mono">{e.value || '—'}</span>
@@ -2159,7 +2155,7 @@
<button
class="forge-btn-ghost danger"
onclick={() => removeEnv(e.id)}
aria-label={`Delete ${e.key}`}
aria-label={`${$t('apps.detail.envColActions')}: ${e.key}`}
>
<IconTrash size={13} />
</button>
@@ -2178,31 +2174,28 @@
}}
>
<label class="env-field">
<span>Key</span>
<span>{$t('apps.detail.envKey')}</span>
<input
type="text"
bind:value={newEnvKey}
placeholder="DATABASE_URL"
placeholder={$t('apps.detail.envKeyPlaceholder')}
pattern="[A-Za-z_][A-Za-z0-9_]*"
required
/>
</label>
<label class="env-field">
<span>Value</span>
<input type="text" bind:value={newEnvValue} placeholder="(empty to unset)" />
<span>{$t('apps.detail.envValue')}</span>
<input type="text" bind:value={newEnvValue} placeholder={$t('apps.detail.envValuePlaceholder')} />
</label>
<label class="env-toggle">
<ToggleSwitch bind:checked={newEnvEncrypted} label="Encrypt at rest" />
<span>Encrypt at rest</span>
<ToggleSwitch bind:checked={newEnvEncrypted} label={$t('apps.detail.envEncryptLabel')} />
<span>{$t('apps.detail.envEncryptLabel')}</span>
</label>
<button class="forge-btn" type="submit" disabled={envSaving || !newEnvKey.trim()}>
{envSaving ? 'Saving' : 'Add / Replace'}
{envSaving ? $t('apps.detail.envSaving') : $t('apps.detail.envAddButton')}
</button>
</form>
<p class="hint">
Encrypted values are write-only after store — the API redacts them on read. Rotate by
setting a new value.
</p>
<p class="hint">{$t('apps.detail.envHint')}</p>
</section>
<!-- Webhook URL panel removed — inbound webhooks live on
@@ -2224,21 +2217,21 @@
<span class="chev" class:rot={!openSource} aria-hidden="true">
<IconChevronDown size={16} />
</span>
<h2 class="panel-title">Source config<span class="title-accent">.</span></h2>
<h2 class="panel-title">{$t('apps.detail.sourceConfigTitle')}<span class="title-accent">.</span></h2>
<span class="panel-sub mono">{workload.source_kind}</span>
</button>
<button
type="button"
class="copy-btn"
onclick={() => copyToClipboard('source', prettyJson(workload!.source_config))}
aria-label="Copy source config"
aria-label={$t('apps.detail.sourceConfigCopyAria')}
>
{#if copied.source}
<IconCheck size={13} />
{:else}
<IconCopy size={13} />
{/if}
<span>{copied.source ? 'Copied' : 'Copy'}</span>
<span>{copied.source ? $t('apps.detail.sourceConfigCopied') : $t('apps.detail.sourceConfigCopy')}</span>
</button>
</header>
{#if openSource}
@@ -2261,20 +2254,20 @@
<span class="chev" class:rot={!openFaces} aria-hidden="true">
<IconChevronDown size={16} />
</span>
<h2 class="panel-title">Public faces<span class="title-accent">.</span></h2>
<h2 class="panel-title">{$t('apps.detail.publicFacesTitle')}<span class="title-accent">.</span></h2>
</button>
<button
type="button"
class="copy-btn"
onclick={() => copyToClipboard('faces', prettyJson(workload!.public_faces))}
aria-label="Copy public faces"
aria-label={$t('apps.detail.publicFacesCopyAria')}
>
{#if copied.faces}
<IconCheck size={13} />
{:else}
<IconCopy size={13} />
{/if}
<span>{copied.faces ? 'Copied' : 'Copy'}</span>
<span>{copied.faces ? $t('apps.detail.sourceConfigCopied') : $t('apps.detail.sourceConfigCopy')}</span>
</button>
</header>
{#if openFaces}
@@ -2290,9 +2283,11 @@
<ConfirmDialog
open={confirmDelete}
title="Delete this app?"
message={`Tears down all containers and proxy routes owned by "${workload?.name ?? 'this workload'}", then removes the row. This cannot be undone.`}
confirmLabel={deleting ? 'Deleting…' : 'Yes, delete'}
title={$t('apps.detail.deleteConfirmTitle')}
message={$t('apps.detail.deleteConfirmMessage', {
name: workload?.name ?? $t('apps.detail.deleteConfirmFallbackName')
})}
confirmLabel={deleting ? $t('apps.detail.deleteConfirmDeleting') : $t('apps.detail.deleteConfirmYes')}
confirmVariant="danger"
onconfirm={doDelete}
oncancel={() => {
+94 -117
View File
@@ -325,7 +325,7 @@
existingTriggers = [];
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load plugin kinds';
error = e instanceof Error ? e.message : $t('apps.new.loadingKinds');
} finally {
loading = false;
}
@@ -533,20 +533,20 @@
</script>
<svelte:head>
<title>New App · Tinyforge</title>
<title>{$t('apps.new.pageTitle')}</title>
</svelte:head>
<div class="forge">
{#snippet newLede()}
Create a plugin-native workload. <em>Source</em> = how it deploys (image, compose, static).
Pick or create a <em>trigger</em> below — when one fires, the source plugin redeploys.
{$t('apps.new.ledePrefix')} <em>{$t('apps.new.ledeSourceLabel')}</em>
{$t('apps.new.ledeSourceMid')} <em>{$t('apps.new.ledeTriggerLabel')}</em> {$t('apps.new.ledeSuffix')}
{/snippet}
<ForgeHero
backHref="/apps"
backLabel="Back to apps"
eyebrowSuffix="NEW APP"
title="Forge a new app"
backLabel={$t('apps.new.backLabel')}
eyebrowSuffix={$t('apps.new.eyebrowSuffix')}
title={$t('apps.new.title')}
size="lg"
lede_html={newLede}
/>
@@ -554,7 +554,7 @@
{#if loading}
<div class="loading-line">
<span class="spinner" aria-hidden="true"></span>
<span>Loading available plugin kinds…</span>
<span>{$t('apps.new.loadingKinds')}</span>
</div>
{:else}
<form onsubmit={submit} class="form" novalidate>
@@ -564,36 +564,36 @@
<span class="reg reg-br" aria-hidden="true"></span>
{#if error}
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
<div class="alert"><span class="alert-tag">{$t('apps.new.alertTag')}</span><span>{error}</span></div>
{/if}
<div class="field">
<label for="app-name" class="field-label">
<span class="num">01</span>
<span class="lbl">Name</span>
<span class="req">REQUIRED</span>
<span class="lbl">{$t('apps.new.fieldName')}</span>
<span class="req">{$t('apps.new.fieldNameRequired')}</span>
</label>
<input
id="app-name"
type="text"
bind:value={name}
required
placeholder="my-app"
placeholder={$t('apps.new.fieldNamePlaceholder')}
class="input"
autocomplete="off"
spellcheck="false"
/>
<p class="hint">Lowercase, no spaces. Becomes part of container names and subdomains.</p>
<p class="hint">{$t('apps.new.fieldNameHint')}</p>
</div>
<div class="field">
<div class="field-label">
<span class="num">02</span>
<span class="lbl">Source plugin</span>
<span class="opt">REQUIRED</span>
<span class="lbl">{$t('apps.new.fieldSourcePlugin')}</span>
<span class="opt">{$t('apps.new.fieldNameRequired')}</span>
</div>
<label class="sub" for="app-source">
<span class="sub-label">Source</span>
<span class="sub-label">{$t('apps.new.fieldSourceLabel')}</span>
<select
id="app-source"
class="input"
@@ -605,37 +605,34 @@
{/each}
</select>
</label>
<p class="hint">
Populated from the running daemon — only plugins compiled in show up. Triggers
(registry / git / manual) are configured below as standalone records.
</p>
<p class="hint">{$t('apps.new.fieldSourceHint')}</p>
</div>
<div class="field">
<div class="field-label">
<span class="num">03</span>
<span class="lbl">Source config</span>
<span class="lbl">{$t('apps.new.fieldSourceConfig')}</span>
<span class="req">
{useComposeForm
? 'YAML'
? $t('apps.new.fieldConfigYaml')
: useImageForm || useStaticForm
? 'FORM'
: 'JSON'}
? $t('apps.new.fieldConfigForm')
: $t('apps.new.fieldConfigJson')}
</span>
</div>
{#if useComposeForm}
<div class="editor">
<div class="editor-head">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="editor-title">compose.yaml · compose</span>
<span class="editor-title">{$t('apps.new.composeHeader')}</span>
<span class="spacer"></span>
<button
type="button"
class="editor-chip"
onclick={toggleAdvancedJSON}
title="Switch to the raw JSON editor"
title={$t('apps.new.switchToJsonTitle')}
>
Advanced JSON
{$t('apps.new.advancedJson')}
</button>
</div>
<textarea
@@ -644,26 +641,26 @@
rows="12"
spellcheck="false"
class="code-area"
placeholder={'services:\n web:\n image: nginx:alpine\n ports:\n - "80"'}
aria-label="Compose YAML"
placeholder={$t('apps.new.composePlaceholder')}
aria-label={$t('apps.new.composeAriaLabel')}
></textarea>
<div class="editor-foot">
<span class="foot-status">
<span class="foot-dot" aria-hidden="true"></span>
YAML
{$t('apps.new.fieldConfigYaml')}
</span>
<span class="sep">·</span>
<span>{composeYaml.split('\n').length} lines</span>
<span>{composeYaml.split('\n').length} {$t('apps.new.linesUnit')}</span>
</div>
</div>
<label class="sub" for="app-compose-project">
<span class="sub-label">Compose project name (optional)</span>
<span class="sub-label">{$t('apps.new.composeProjectLabel')}</span>
<input
id="app-compose-project"
type="text"
class="input"
bind:value={composeProjectName}
placeholder="(defaults to sanitized workload name)"
placeholder={$t('apps.new.composeProjectPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
@@ -675,36 +672,33 @@
to be set at create time. -->
<div class="image-form">
<div class="image-form-head">
<span class="editor-title">image source · runtime knobs</span>
<span class="editor-title">{$t('apps.new.imageHeader')}</span>
<button
type="button"
class="editor-chip"
onclick={toggleAdvancedJSON}
title="Switch to the raw JSON editor"
title={$t('apps.new.switchToJsonTitle')}
>
Advanced JSON
{$t('apps.new.advancedJson')}
</button>
</div>
<label class="sub" for="app-image-ref">
<span class="sub-label">Image (registry path)</span>
<span class="sub-label">{$t('apps.new.imageRefLabel')}</span>
<input
id="app-image-ref"
type="text"
class="input mono"
bind:value={imageRef}
placeholder="registry.example.com/owner/app"
placeholder={$t('apps.new.imageRefPlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
<p class="hint">
Fully-qualified reference; the tag is set per-deploy via the trigger or
the Default tag field below.
</p>
<p class="hint">{$t('apps.new.imageRefHint')}</p>
</label>
<div class="row three">
<label class="sub" for="app-image-port">
<span class="sub-label">Port</span>
<span class="sub-label">{$t('apps.new.imagePort')}</span>
<input
id="app-image-port"
type="number"
@@ -715,7 +709,7 @@
/>
</label>
<label class="sub" for="app-image-healthcheck">
<span class="sub-label">Healthcheck path</span>
<span class="sub-label">{$t('apps.new.imageHealthcheck')}</span>
<input
id="app-image-healthcheck"
type="text"
@@ -727,7 +721,7 @@
/>
</label>
<label class="sub" for="app-image-default-tag">
<span class="sub-label">Default tag</span>
<span class="sub-label">{$t('apps.new.imageDefaultTag')}</span>
<input
id="app-image-default-tag"
type="text"
@@ -740,14 +734,14 @@
</label>
</div>
<label class="sub" for="app-image-registry">
<span class="sub-label">Registry (for private pulls)</span>
<span class="sub-label">{$t('apps.new.imageRegistryLabel')}</span>
{#if registries.length > 0}
<select
id="app-image-registry"
class="input"
bind:value={imageRegistryName}
>
<option value="">(public — no auth)</option>
<option value="">{$t('apps.new.imageRegistryPublic')}</option>
{#each registries as r}
<option value={r.name}>{r.name} {r.url}</option>
{/each}
@@ -758,18 +752,15 @@
type="text"
class="input"
bind:value={imageRegistryName}
placeholder="(public — no auth)"
placeholder={$t('apps.new.imageRegistryPublic')}
autocomplete="off"
/>
{/if}
<p class="hint">
Match the name from the Registries settings page. Leave empty for
public images.
</p>
<p class="hint">{$t('apps.new.imageRegistryHint')}</p>
</label>
<div class="row three">
<label class="sub" for="app-image-cpu">
<span class="sub-label">CPU limit (cores, 0 = ∞)</span>
<span class="sub-label">{$t('apps.new.imageCpu')}</span>
<input
id="app-image-cpu"
type="number"
@@ -780,7 +771,7 @@
/>
</label>
<label class="sub" for="app-image-memory">
<span class="sub-label">Memory limit (MB, 0 = ∞)</span>
<span class="sub-label">{$t('apps.new.imageMemory')}</span>
<input
id="app-image-memory"
type="number"
@@ -790,7 +781,7 @@
/>
</label>
<label class="sub" for="app-image-max">
<span class="sub-label">Max instances</span>
<span class="sub-label">{$t('apps.new.imageMax')}</span>
<input
id="app-image-max"
type="number"
@@ -798,13 +789,10 @@
class="input"
bind:value={imageMaxInstances}
/>
<p class="hint">1 = strict blue-green.</p>
<p class="hint">{$t('apps.new.imageMaxHint')}</p>
</label>
</div>
<p class="hint image-form-foot">
Env vars and volume mounts live in their own panels on the workload
detail page after creation.
</p>
<p class="hint image-form-foot">{$t('apps.new.imageFoot')}</p>
</div>
{:else if useStaticForm}
<!-- Static source form. Provider + repo + mode in
@@ -812,19 +800,19 @@
password input. -->
<div class="image-form">
<div class="image-form-head">
<span class="editor-title">static source · pages from a repo</span>
<span class="editor-title">{$t('apps.new.staticHeader')}</span>
<button
type="button"
class="editor-chip"
onclick={toggleAdvancedJSON}
title="Switch to the raw JSON editor"
title={$t('apps.new.switchToJsonTitle')}
>
Advanced JSON
{$t('apps.new.advancedJson')}
</button>
</div>
<div class="row">
<label class="sub" for="app-static-provider">
<span class="sub-label">Provider</span>
<span class="sub-label">{$t('apps.new.staticProvider')}</span>
<select
id="app-static-provider"
class="input"
@@ -836,13 +824,13 @@
</select>
</label>
<label class="sub" for="app-static-base-url">
<span class="sub-label">Base URL</span>
<span class="sub-label">{$t('apps.new.staticBaseUrl')}</span>
<input
id="app-static-base-url"
type="url"
class="input mono"
bind:value={staticBaseURL}
placeholder="https://git.example.com"
placeholder={$t('apps.new.staticBaseUrlPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
@@ -850,26 +838,26 @@
</div>
<div class="row">
<label class="sub" for="app-static-owner">
<span class="sub-label">Repo owner</span>
<span class="sub-label">{$t('apps.new.staticRepoOwner')}</span>
<input
id="app-static-owner"
type="text"
class="input mono"
bind:value={staticRepoOwner}
placeholder="owner"
placeholder={$t('apps.new.staticRepoOwnerPlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
</label>
<label class="sub" for="app-static-name">
<span class="sub-label">Repo name</span>
<span class="sub-label">{$t('apps.new.staticRepoName')}</span>
<input
id="app-static-name"
type="text"
class="input mono"
bind:value={staticRepoName}
placeholder="pages"
placeholder={$t('apps.new.staticRepoNamePlaceholder')}
autocomplete="off"
spellcheck="false"
required
@@ -878,46 +866,44 @@
</div>
<div class="row">
<label class="sub" for="app-static-branch">
<span class="sub-label">Branch</span>
<span class="sub-label">{$t('apps.new.staticBranch')}</span>
<input
id="app-static-branch"
type="text"
class="input mono"
bind:value={staticBranch}
placeholder="main"
placeholder={$t('apps.new.staticBranchPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
</label>
<label class="sub" for="app-static-folder">
<span class="sub-label">Folder path (optional)</span>
<span class="sub-label">{$t('apps.new.staticFolder')}</span>
<input
id="app-static-folder"
type="text"
class="input mono"
bind:value={staticFolderPath}
placeholder="(repo root)"
placeholder={$t('apps.new.staticFolderPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
</label>
</div>
<label class="sub" for="app-static-token">
<span class="sub-label">Access token (private repos)</span>
<span class="sub-label">{$t('apps.new.staticToken')}</span>
<input
id="app-static-token"
type="password"
class="input"
bind:value={staticAccessToken}
placeholder="(leave blank for public repos)"
placeholder={$t('apps.new.staticTokenPlaceholder')}
autocomplete="new-password"
/>
<p class="hint">
Encrypted at rest. Required only when the repo is private.
</p>
<p class="hint">{$t('apps.new.staticTokenHint')}</p>
</label>
<fieldset class="static-mode">
<legend class="sub-label">Mode</legend>
<legend class="sub-label">{$t('apps.new.staticMode')}</legend>
<label class="radio">
<input
type="radio"
@@ -926,8 +912,7 @@
bind:group={staticMode}
/>
<span>
<strong>static</strong> — serve files via nginx; zero runtime
overhead.
<strong>static</strong> {$t('apps.new.staticModeStaticDesc')}
</span>
</label>
<label class="radio">
@@ -938,40 +923,35 @@
bind:group={staticMode}
/>
<span>
<strong>deno</strong> — Deno runtime container with optional
dynamic routing.
<strong>deno</strong> {$t('apps.new.staticModeDenoDesc')}
</span>
</label>
</fieldset>
<label class="toggle-row">
<ToggleSwitch
bind:checked={staticRenderMarkdown}
label="Render markdown"
label={$t('apps.new.staticRenderMarkdown')}
/>
<span>
<strong>Render markdown</strong> — auto-render <code>.md</code>
files as HTML pages.
<strong>{$t('apps.new.staticRenderMarkdown')}</strong> {@html $t('apps.new.staticRenderMarkdownDesc')}
</span>
</label>
<p class="hint image-form-foot">
The webhook secret for git push triggers lives on the workload's
Webhook panel after creation.
</p>
<p class="hint image-form-foot">{$t('apps.new.staticFoot')}</p>
</div>
{:else}
<div class="editor">
<div class="editor-head">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="editor-title">source_config.json · {sourceKind}</span>
<span class="editor-title">{$t('apps.new.sourceConfigJsonTitle', { kind: sourceKind })}</span>
<span class="spacer"></span>
{#if sourceKind === 'compose' || sourceKind === 'image' || sourceKind === 'static'}
<button
type="button"
class="editor-chip"
onclick={toggleAdvancedJSON}
title="Switch back to the form"
title={$t('apps.new.switchToFormTitle')}
>
Back to form
{$t('apps.new.backToForm')}
</button>
{/if}
<button
@@ -979,7 +959,7 @@
class="editor-chip"
onclick={() => (sourceConfig = sourceConfigSample(sourceKind))}
>
Reset sample
{$t('apps.new.resetSample')}
</button>
</div>
<textarea
@@ -988,15 +968,15 @@
rows="12"
spellcheck="false"
class="code-area"
aria-label="Source plugin configuration (JSON)"
aria-label={$t('apps.new.sourceConfigJsonAria')}
></textarea>
<div class="editor-foot">
<span class="foot-status" class:bad={!sourceValid}>
<span class="foot-dot" aria-hidden="true"></span>
{sourceValid ? 'JSON OK' : 'JSON INVALID'}
{sourceValid ? $t('apps.new.jsonOk') : $t('apps.new.jsonInvalid')}
</span>
<span class="sep">·</span>
<span>{sourceLines} lines</span>
<span>{sourceLines} {$t('apps.new.linesUnit')}</span>
<span class="sep">·</span>
<span>{sourceBytes} B</span>
</div>
@@ -1008,7 +988,7 @@
<legend class="field-label as-legend">
<span class="num">04</span>
<span class="lbl">{$t('apps.new.triggers.section')}</span>
<span class="opt">OPTIONAL</span>
<span class="opt">{$t('apps.new.triggerNumOptional')}</span>
</legend>
<p class="hint">{$t('apps.new.triggers.sectionSub')}</p>
@@ -1029,7 +1009,7 @@
class:active={triggerMode === 'inline'}
onclick={() => (triggerMode = 'inline')}
>
<span class="trig-mode-tag mono">NEW</span>
<span class="trig-mode-tag mono">{$t('apps.new.triggerNewTag')}</span>
<span class="trig-mode-name">{$t('apps.new.triggers.modeInline')}</span>
<span class="trig-mode-hint">{$t('apps.new.triggers.modeInlineHint')}</span>
</button>
@@ -1041,7 +1021,7 @@
class:active={triggerMode === 'pick'}
onclick={() => (triggerMode = 'pick')}
>
<span class="trig-mode-tag mono">PICK</span>
<span class="trig-mode-tag mono">{$t('apps.new.triggerPickTag')}</span>
<span class="trig-mode-name">{$t('apps.new.triggers.modePick')}</span>
<span class="trig-mode-hint">{$t('apps.new.triggers.modePickHint')}</span>
</button>
@@ -1053,7 +1033,7 @@
class:active={triggerMode === 'skip'}
onclick={() => (triggerMode = 'skip')}
>
<span class="trig-mode-tag mono">SKIP</span>
<span class="trig-mode-tag mono">{$t('apps.new.triggerSkipTag')}</span>
<span class="trig-mode-name">{$t('apps.new.triggers.modeSkip')}</span>
<span class="trig-mode-hint">{$t('apps.new.triggers.modeSkipHint')}</span>
</button>
@@ -1073,7 +1053,7 @@
<div class="trig-sub">
{#if existingTriggers.length === 0}
<div class="note muted-note">
<span class="note-tag"></span>
<span class="note-tag">{$t('apps.new.noteEmptyTag')}</span>
<p>{$t('apps.new.triggers.pickEmpty')}</p>
</div>
{:else}
@@ -1100,7 +1080,7 @@
{:else}
<div class="trig-sub">
<div class="note muted-note">
<span class="note-tag">SKIP</span>
<span class="note-tag">{$t('apps.new.noteSkipTag')}</span>
<p>{$t('apps.new.triggers.skippedNote')}</p>
</div>
</div>
@@ -1110,34 +1090,34 @@
<fieldset class="field group">
<legend class="field-label as-legend">
<span class="num">05</span>
<span class="lbl">Public face</span>
<span class="opt">OPTIONAL</span>
<span class="lbl">{$t('apps.new.faceLabel')}</span>
<span class="opt">{$t('apps.new.faceOptional')}</span>
</legend>
<div class="row three">
<label class="sub" for="app-public-subdomain">
<span class="sub-label">Subdomain</span>
<span class="sub-label">{$t('apps.new.faceSubdomain')}</span>
<input
id="app-public-subdomain"
type="text"
class="input"
bind:value={publicSubdomain}
placeholder="myapp"
placeholder={$t('apps.new.faceSubdomainPlaceholder')}
autocomplete="off"
/>
</label>
<label class="sub" for="app-public-domain">
<span class="sub-label">Domain</span>
<span class="sub-label">{$t('apps.new.faceDomain')}</span>
<input
id="app-public-domain"
type="text"
class="input"
bind:value={publicDomain}
placeholder="(inherit from settings)"
placeholder={$t('apps.new.faceDomainPlaceholder')}
autocomplete="off"
/>
</label>
<label class="sub" for="app-public-port">
<span class="sub-label">Target port</span>
<span class="sub-label">{$t('apps.new.facePort')}</span>
<input
id="app-public-port"
type="number"
@@ -1148,20 +1128,17 @@
/>
</label>
</div>
<p class="hint">
Leave blank to skip provisioning a proxy route. Filling any field creates a single
face row attached to this workload.
</p>
<p class="hint">{$t('apps.new.faceHint')}</p>
</fieldset>
<div class="actions">
<a href="/apps" class="forge-btn-ghost">Cancel</a>
<a href="/apps" class="forge-btn-ghost">{$t('apps.new.cancel')}</a>
<button
class="btn-primary"
type="submit"
disabled={submitting || !name.trim() || (!useComposeForm && !useImageForm && !useStaticForm && !sourceValid) || (useImageForm && !imageRef.trim()) || (useStaticForm && (!staticRepoOwner.trim() || !staticRepoName.trim())) || !triggerStepValid}
>
<span>{submitting ? 'Forging' : 'Forge app'}</span>
<span>{submitting ? $t('apps.new.submitting') : $t('apps.new.submit')}</span>
<span class="arrow" aria-hidden="true"></span>
</button>
</div>