confirmDeleteId && doDelete(confirmDeleteId)}
+ oncancel={() => (confirmDeleteId = null)}
+ />
+{/if}
+
+
diff --git a/web/src/lib/components/wizard/WizardRail.svelte b/web/src/lib/components/wizard/WizardRail.svelte
new file mode 100644
index 0000000..265fa6d
--- /dev/null
+++ b/web/src/lib/components/wizard/WizardRail.svelte
@@ -0,0 +1,210 @@
+
+
+
+
+
diff --git a/web/src/lib/components/workload/AppManifest.svelte b/web/src/lib/components/workload/AppManifest.svelte
new file mode 100644
index 0000000..b1e69a0
--- /dev/null
+++ b/web/src/lib/components/workload/AppManifest.svelte
@@ -0,0 +1,181 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {$t('apps.new.manifest.title')}
+
+ {#if sourceKind}
+ {sourceKind}
+ {/if}
+
+
+
+ {#each rows as row (row.label)}
+
+
- {row.label}
+ - {row.value}
+
+ {/each}
+
+
+
+
diff --git a/web/src/lib/components/workload/ComposeSourceForm.svelte b/web/src/lib/components/workload/ComposeSourceForm.svelte
new file mode 100644
index 0000000..3689b90
--- /dev/null
+++ b/web/src/lib/components/workload/ComposeSourceForm.svelte
@@ -0,0 +1,222 @@
+
+
+
+
+
+
+ {$t('apps.new.composeHeader')}
+
+
+
+
+
+
+
+
+
diff --git a/web/src/lib/components/workload/DockerfileSourceForm.svelte b/web/src/lib/components/workload/DockerfileSourceForm.svelte
new file mode 100644
index 0000000..3872a62
--- /dev/null
+++ b/web/src/lib/components/workload/DockerfileSourceForm.svelte
@@ -0,0 +1,286 @@
+
+
+
+
+
+
diff --git a/web/src/lib/components/workload/ImageSourceForm.svelte b/web/src/lib/components/workload/ImageSourceForm.svelte
new file mode 100644
index 0000000..343f135
--- /dev/null
+++ b/web/src/lib/components/workload/ImageSourceForm.svelte
@@ -0,0 +1,741 @@
+
+
+
+
+
+
diff --git a/web/src/lib/components/workload/SourceKindPicker.svelte b/web/src/lib/components/workload/SourceKindPicker.svelte
new file mode 100644
index 0000000..514dc52
--- /dev/null
+++ b/web/src/lib/components/workload/SourceKindPicker.svelte
@@ -0,0 +1,122 @@
+
+
+
+ {#each kinds as kind, i (kind)}
+
+ {/each}
+
+
+
diff --git a/web/src/lib/components/workload/StaticDiscoveryWizard.svelte b/web/src/lib/components/workload/StaticDiscoveryWizard.svelte
new file mode 100644
index 0000000..c058b99
--- /dev/null
+++ b/web/src/lib/components/workload/StaticDiscoveryWizard.svelte
@@ -0,0 +1,1136 @@
+
+
+
+{#if variant === 'static'}
+
+
+
+
+ {#if detectStatus === 'ok'}
+
+
+ {$t('apps.new.staticDetectedOk', { provider: detectedProvider || git.provider })}
+
+ {:else if detectStatus === 'error'}
+
+
+ {$t('apps.new.staticDetectedFailed', { error: detectError })}
+
+ {/if}
+
+
+ {#if showFolderTree}
+
+
+ {#if folderPath}
+
+ {$t('apps.new.staticFolderSelectedPrefix')} {folderPath}
+
+ {/if}
+
+ {#if treeOpen}
+
+ {#if treeLoading}
+
+
+ {$t('apps.new.staticTreeLoading')}
+
+ {:else if treeError}
+
+
+ {treeError}
+
+ {:else if folders.length === 0}
+
{$t('apps.new.staticTreeEmpty')}
+ {:else}
+
+ {#each getTopLevelFolders() as folder (folder.path)}
+ {@const isSelected = folderPath === folder.path}
+ {@const isExpanded = expandedDirs.has(folder.path)}
+ {@const children = getChildFolders(folder.path)}
+
+
+ {#if children.length > 0}
+
+ {:else}
+
+ {/if}
+
+
+ {#if isExpanded}
+
+ {#each children as child (child.path)}
+ {@const childSelected = folderPath === child.path}
+
+ {/each}
+
+ {/if}
+
+ {/each}
+ {/if}
+
+ {/if}
+
+ {/if}
+{:else}
+
+
+
+
+
+ {#if detectStatus === 'ok'}
+
+
+ {$t('apps.new.staticDetectedOk', { provider: detectedProvider || git.provider })}
+
+ {:else if detectStatus === 'error'}
+
+
+ {$t('apps.new.staticDetectedFailed', { error: detectError })}
+
+ {/if}
+
+
+
+
+ {#if testStatus === 'ok'}
+
+
+ {$t('apps.new.staticConnectionOk')}
+
+ {:else if testStatus === 'error'}
+
+
+ {$t('apps.new.staticConnectionFailed', { error: testError })}
+
+ {/if}
+
+{/if}
+
+
+ (showRepoPicker = false)}
+/>
+ (showBranchPicker = false)}
+/>
+
+
diff --git a/web/src/lib/components/workload/StaticSourceForm.svelte b/web/src/lib/components/workload/StaticSourceForm.svelte
new file mode 100644
index 0000000..8a5b114
--- /dev/null
+++ b/web/src/lib/components/workload/StaticSourceForm.svelte
@@ -0,0 +1,240 @@
+
+
+
+
+
+
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json
index 696a4a9..3729947 100644
--- a/web/src/lib/i18n/en.json
+++ b/web/src/lib/i18n/en.json
@@ -15,7 +15,7 @@
"nav": {
"dashboard": "Dashboard",
"apps": "Apps",
- "eventTriggers": "Triggers",
+ "eventTriggers": "Event Triggers",
"logScanRules": "Log Rules",
"triggers": "Triggers",
"proxies": "Proxies",
@@ -23,7 +23,13 @@
"settings": "Settings",
"logout": "Log out",
"dns": "DNS Records",
- "containers": "Containers"
+ "containers": "Containers",
+ "sectionObserve": "Observe",
+ "sectionSystem": "System",
+ "closeSidebar": "Close sidebar",
+ "openSidebar": "Open sidebar",
+ "quickNavTitle": "Press 'g' then a letter to jump between sections",
+ "quickNavLabel": "quick-nav"
},
"dashboard": {
"title": "Dashboard",
@@ -42,7 +48,11 @@
"systemHealth": "System health",
"daemons": "Daemons",
"systemResources": "System resources",
- "systemResourcesSubtitle": "CPU, memory, disk, and top consumers"
+ "systemResourcesSubtitle": "CPU, memory, disk, and top consumers",
+ "statSubWorkloads": "workloads →",
+ "statSubRunning": "running",
+ "statSubNeedAttention": "need attention",
+ "statSubStale": "stale →"
},
"resources": {
"cpuCores": "CPU Cores",
@@ -237,6 +247,7 @@
"deleteFailed": "Failed to delete registry",
"testFailed": "Connection test failed",
"loadFailed": "Failed to load registries",
+ "deleteTitle": "Delete registry?",
"deleteConfirm": "Delete registry \"{name}\"? This cannot be undone.",
"healthChecking": "Checking...",
"healthConnected": "Connected",
@@ -354,6 +365,7 @@
"createFailed": "Failed to create user",
"deleteFailed": "Failed to delete user",
"deleteConfirm": "Are you sure you want to delete this user?",
+ "deleteConfirmMessage": "Delete user \"{username}\"? This cannot be undone.",
"usernameRequired": "Username and password are required",
"networkError": "Network error",
"password": "Password"
@@ -400,6 +412,9 @@
"common": {
"cancel": "Cancel",
"confirm": "Confirm",
+ "close": "Close",
+ "toggle": "Toggle",
+ "dismissNotification": "Dismiss notification",
"delete": "Delete",
"edit": "Edit",
"change": "Change",
@@ -429,6 +444,7 @@
"missing": "Missing"
},
"containers": {
+ "eyebrowSuffix": "GLOBAL",
"errLoad": "Failed to load containers",
"searchPlaceholder": "Search workload, role, image, subdomain…",
"kindFilterLabel": "Workload kind",
@@ -476,6 +492,7 @@
},
"stale": {
"title": "Stale Containers",
+ "eyebrowSuffix": "STALE",
"noStale": "No stale containers",
"noStaleDesc": "All containers are healthy and running.",
"cleanup": "Clean up",
@@ -541,13 +558,13 @@
"unavailable": "Stats unavailable"
},
"systemHealth": {
- "title": "System Health",
"containers": "Containers",
"proxies": "Proxies",
"recentErrors": "Recent Errors"
},
"daemons": {
"title": "Daemons",
+ "notReachable": "{provider} is not reachable.",
"refresh": "Refresh",
"refreshing": "Refreshing",
"docker": "Docker Engine",
@@ -1110,6 +1127,10 @@
"image": "Image reference",
"imagePlaceholder": "registry.example.com/owner/app",
"imageHint": "Full image reference without the tag — Tinyforge matches new tags pushed under it.",
+ "browseImages": "Browse",
+ "browseImagesHint": "Pick an image from a configured registry instead of typing the reference.",
+ "browseImagesTitle": "Select an image",
+ "browseImagesSearch": "Search images…",
"tagPattern": "Tag pattern",
"tagPatternPlaceholder": "*",
"tagPatternHint": "path.Match glob (e.g. v*, release-*). Use * to match every tag.",
@@ -1122,6 +1143,9 @@
"branch": "Branch",
"branchPlaceholder": "main",
"branchHint": "Only push events advancing this branch fire the trigger.",
+ "branchPattern": "Branch pattern (preview deploys)",
+ "branchPatternPlaceholder": "feat/* or * for any branch",
+ "branchPatternHint": "When set, any push to a matching branch spawns a per-branch preview deploy. Leave empty to disable previews.",
"manualNote": "Manual triggers carry no config. They fire only via the workload's Deploy button or POST /workloads/{id}/deploy.",
"scheduleNote": "Fires on a fixed interval driven by Tinyforge's internal scheduler. No external webhook is required — enable the webhook ingress below only if a CI also needs to fire it on demand.",
"intervalPresets": "Quick presets",
@@ -1186,6 +1210,14 @@
},
"new": {
"pageTitle": "New App · Tinyforge",
+ "wizard": {
+ "stepBasics": "Basics",
+ "stepConfigure": "Configure",
+ "stepTrigger": "Trigger",
+ "stepReview": "Review",
+ "next": "Next",
+ "back": "Back"
+ },
"backLabel": "Back to apps",
"eyebrowSuffix": "NEW APP",
"title": "Forge a new app",
@@ -1198,6 +1230,7 @@
"alertTag": "ERR",
"fieldName": "Name",
"fieldNameRequired": "REQUIRED",
+ "fieldRequired": "Required",
"fieldNamePlaceholder": "my-app",
"fieldNameHint": "Lowercase, no spaces. Becomes part of container names and subdomains.",
"fieldSourcePlugin": "Source plugin",
@@ -1207,7 +1240,7 @@
"fieldConfigYaml": "YAML",
"fieldConfigForm": "FORM",
"fieldConfigJson": "JSON",
- "advancedJson": "Advanced JSON",
+ "advancedJson": "Edit as JSON",
"backToForm": "Back to form",
"resetSample": "Reset sample",
"switchToJsonTitle": "Switch to the raw JSON editor",
@@ -1230,11 +1263,21 @@
"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 = ∞)",
+ "imageCpu": "CPU limit",
+ "imageCpuHint": "Cores, 0 = ∞",
+ "imageMemory": "Memory limit",
+ "imageMemoryHint": "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.",
+ "dockerfileHeader": "dockerfile source · build from a git repo",
+ "dockerfileBuildEyebrow": "build · dockerfile",
+ "dockerfileContextPath": "Build context",
+ "dockerfileContextPathPlaceholder": "(empty = repo root)",
+ "dockerfilePath": "Dockerfile path",
+ "dockerfilePort": "Container port",
+ "dockerfilePortRequired": "Container port is required — pick the port your app listens on (1–65535).",
+ "dockerfileFoot": "Tinyforge clones the repo, builds the image from the Dockerfile, and runs one container. Env vars and volumes live in the detail page after creation.",
"staticHeader": "static source · pages from a repo",
"staticProvider": "Provider",
"staticBaseUrl": "Base URL",
@@ -1262,7 +1305,7 @@
"staticTestConnection": "Test connection",
"staticConnectionOk": "Connected",
"staticConnectionFailed": "Connection failed: {error}",
- "staticBrowseRepos": "Browse repositories",
+ "staticBrowseRepos": "Browse",
"staticBrowseBranches": "Browse branches",
"staticBrowseFolders": "Browse folders",
"staticPickerRepoTitle": "Select repository",
@@ -1275,6 +1318,7 @@
"staticTreeEmpty": "No folders found in this branch.",
"staticDenoAutoDetected": "Auto-detected an api/ folder — switched to Deno mode.",
"imageConflictTag": "IMAGE IN USE",
+ "imageConflictChecking": "Checking for conflicts…",
"imageConflictHeading": "{count} workload(s) already use this image:",
"imageConflictOpenBtn": "Open",
"imageConflictAcknowledgeNote": "If this is intentional (for example a separate stage), continue to create a new workload.",
@@ -1300,21 +1344,23 @@
"submit": "Forge app",
"submitting": "Forging…",
"submitAnyway": "Forge anyway",
+ "unsavedChanges": "You have unsaved changes to this app. Leave without creating it?",
+ "unsavedChangesTitle": "Unsaved changes",
+ "unsavedChangesConfirm": "Leave",
"errors": {
- "detectionFailed": "Provider detection failed.",
- "connectionFailed": "Connection failed.",
- "reposFailed": "Failed to load repositories.",
- "branchesFailed": "Failed to load branches.",
- "treeFailed": "Failed to load folder tree.",
+ "detectionFailed": "Couldn't detect a Git provider at that URL. Check the base URL is correct and reachable.",
+ "connectionFailed": "Couldn't reach the repository. Check the provider URL, owner/repo, and access token (for private repos).",
+ "reposFailed": "Couldn't list repositories. Check the base URL and access token.",
+ "branchesFailed": "Couldn't list branches. Check the repository and access token.",
+ "treeFailed": "Couldn't load the folder tree. Check the repository, branch, and access token.",
"sourceConfigInvalid": "Source config is not valid JSON.",
"triggerBindUnknown": "unknown error",
"createFailed": "Workload create failed.",
- "inspectFailed": "Image inspect failed."
+ "inspectFailed": "Couldn't inspect that image — make sure it's pulled locally and the reference is correct."
},
"imageInspect": "Inspect",
"imageInspectHint": "Pulls port + healthcheck from the image so you don't have to type them.",
"imageInspectOk": "Inspected — port + healthcheck filled.",
- "imageInspectError": "Inspect failed: {error}",
"triggers": {
"section": "Trigger",
"sectionSub": "Optional. Pick how this app gets a redeploy signal — registry watcher, git event, or manual button.",
@@ -1334,6 +1380,18 @@
"pickWebhookOn": "WEBHOOK ON",
"skippedNote": "No trigger will be bound. You can add one from the app's Triggers panel after it's created.",
"bindError": "App created, but the trigger binding failed: {error}. Open the app's Triggers panel to retry."
+ },
+ "manifest": {
+ "title": "Manifest",
+ "name": "Name",
+ "source": "Source",
+ "trigger": "Trigger",
+ "publicFace": "Public face",
+ "unnamed": "(unnamed)",
+ "registryPublic": "public registry",
+ "folderRoot": "root",
+ "triggerManual": "Manual only",
+ "internalOnly": "Internal only"
}
},
"detail": {
@@ -1365,6 +1423,40 @@
"unavailable": "Usage probe unavailable (container may be stopped).",
"loading": "Computing usage…"
},
+ "buildLog": {
+ "title": "Build log",
+ "sub": "Live tail of the Docker daemon's build output.",
+ "clear": "Clear"
+ },
+ "notifications": {
+ "title": "Notification routes",
+ "sub": "Multi-destination fan-out for deploy/build events. Falls back to the workload's legacy URL when empty.",
+ "loading": "Loading routes…",
+ "empty": "No per-workload notification routes configured. Add one to get a per-channel destination.",
+ "addFirst": "Add first route",
+ "add": "Add route",
+ "edit": "Edit route",
+ "delete": "Delete route",
+ "name": "Name",
+ "namePlaceholder": "Slack #alerts",
+ "url": "Webhook URL",
+ "secret": "Signing secret",
+ "secretPlaceholder": "Optional — receiver verifies HMAC if set",
+ "secretEditPlaceholder": "Leave empty to keep the existing secret",
+ "secretHint": "HMAC-SHA256 over the request body, sent as X-Hub-Signature-256.",
+ "eventTypes": "Event types",
+ "eventTypesPlaceholder": "deploy_failure,build_failure (empty = all)",
+ "eventTypesHint": "Comma-separated allow-list. Empty means every event fires this route.",
+ "enabled": "Enabled",
+ "save": "Save route",
+ "saving": "Saving…",
+ "cancel": "Cancel",
+ "allEvents": "all events",
+ "signed": "signed",
+ "disabled": "disabled",
+ "confirmDeleteTitle": "Delete notification route?",
+ "confirmDeleteMessage": "This route will stop firing immediately. The workload's legacy notification URL (if set) will resume catching events when no routes match."
+ },
"toolbar": {
"stop": "Stop",
"start": "Start",
@@ -1455,6 +1547,19 @@
"editStaticModeDenoDesc": "— Deno runtime with dynamic routing.",
"editStaticRenderMarkdown": "Render markdown",
"editStaticRenderMarkdownDesc": "— auto-render .md as HTML.",
+ "editDockerfileHeader": "dockerfile source · build from a git repo",
+ "editDockerfileBuildEyebrow": "build · dockerfile",
+ "editDockerfileContextPath": "Build context",
+ "editDockerfileContextPathPlaceholder": "(empty = repo root)",
+ "editDockerfilePath": "Dockerfile path",
+ "editDockerfilePort": "Container port",
+ "editTestConnection": "Test connection",
+ "editTestConnectionOk": "Connection OK",
+ "editTestConnectionFailed": "Connection failed: {error}",
+ "editTestConnectionUnknownError": "Unknown error",
+ "overrideKeyUnitSingular": "KEY",
+ "overrideKeyUnitPlural": "KEYS",
+ "editTestConnectionIncomplete": "Fill provider, base URL, owner, and name first.",
"editSourceJsonHeader": "source_config.json",
"editSourceJsonAria": "Source plugin configuration (JSON)",
"editPublicFaces": "Public faces",
@@ -1494,6 +1599,29 @@
"chainPromoteButton": "Promote from parent",
"chainPromoting": "Promoting…",
"chainHint": "Set parent_workload_id on a workload to build a chain. Image-source children can promote the parent's currently-running tag with one click.",
+ "previews": {
+ "title": "Preview environments",
+ "subEmpty": "no active previews",
+ "subCountOne": "1 active preview",
+ "subCount": "{count} active previews",
+ "tag": "Preview",
+ "tagTitle": "Per-branch preview deploy of this workload",
+ "armedEmpty": "No active previews — push to a branch matching",
+ "noneEmpty": "No active previews yet.",
+ "open": "Open",
+ "noUrl": "no public URL",
+ "teardown": "Tear down",
+ "teardownTitle": "Tear down preview?",
+ "teardownMessage": "This deletes the preview for branch \"{name}\" and removes its containers and proxy routes. Pushing to the branch again will recreate it.",
+ "teardownConfirm": "Tear down",
+ "teardownPending": "Tearing down…",
+ "teardownFailed": "Teardown failed",
+ "stateRunning": "Running",
+ "statePending": "Pending",
+ "stateStopped": "Stopped",
+ "stateUnknown": "Unknown",
+ "hint": "Previews are created automatically when a push lands on a branch matching a git trigger's branch_pattern, and torn down when the branch is deleted. Each gets its own slug-prefixed subdomain."
+ },
"volumesTitle": "Volumes",
"volumesEmpty": "No mounts",
"volumesCountSingular": "{count} mount",
diff --git a/web/src/lib/i18n/index.ts b/web/src/lib/i18n/index.ts
index 50f0896..780ee33 100644
--- a/web/src/lib/i18n/index.ts
+++ b/web/src/lib/i18n/index.ts
@@ -53,7 +53,7 @@ function getNestedValue(obj: Record, path: string): string {
/**
* Derived store that returns a translation function.
- * Usage: $t('dashboard.title') or $t('projectDetail.deleteConfirmMessage', { name: 'my-app' })
+ * Usage: $t('dashboard.title') or $t('settingsAuth.deleteConfirmMessage', { username: 'alice' })
*/
export const t = derived(locale, ($locale) => {
const dict = translations[$locale] ?? translations.en;
diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json
index 93772d6..a5aa727 100644
--- a/web/src/lib/i18n/ru.json
+++ b/web/src/lib/i18n/ru.json
@@ -15,7 +15,7 @@
"nav": {
"dashboard": "Панель",
"apps": "Приложения",
- "eventTriggers": "Триггеры",
+ "eventTriggers": "Триггеры событий",
"logScanRules": "Лог-правила",
"triggers": "Триггеры",
"proxies": "Прокси",
@@ -23,7 +23,13 @@
"settings": "Настройки",
"logout": "Выйти",
"dns": "DNS-записи",
- "containers": "Контейнеры"
+ "containers": "Контейнеры",
+ "sectionObserve": "Наблюдение",
+ "sectionSystem": "Система",
+ "closeSidebar": "Закрыть боковую панель",
+ "openSidebar": "Открыть боковую панель",
+ "quickNavTitle": "Нажмите «g», затем букву для перехода между разделами",
+ "quickNavLabel": "быстрая навигация"
},
"dashboard": {
"title": "Панель управления",
@@ -42,7 +48,11 @@
"systemHealth": "Состояние системы",
"daemons": "Демоны",
"systemResources": "Системные ресурсы",
- "systemResourcesSubtitle": "CPU, память, диск и топ потребителей"
+ "systemResourcesSubtitle": "CPU, память, диск и топ потребителей",
+ "statSubWorkloads": "нагрузки →",
+ "statSubRunning": "запущено",
+ "statSubNeedAttention": "требует внимания",
+ "statSubStale": "устаревшие →"
},
"resources": {
"cpuCores": "Ядра CPU",
@@ -237,6 +247,7 @@
"deleteFailed": "Не удалось удалить реестр",
"testFailed": "Тест подключения не удался",
"loadFailed": "Не удалось загрузить реестры",
+ "deleteTitle": "Удалить реестр?",
"deleteConfirm": "Удалить реестр «{name}»? Это действие необратимо.",
"healthChecking": "Проверка...",
"healthConnected": "Подключено",
@@ -354,6 +365,7 @@
"createFailed": "Не удалось создать пользователя",
"deleteFailed": "Не удалось удалить пользователя",
"deleteConfirm": "Вы уверены, что хотите удалить этого пользователя?",
+ "deleteConfirmMessage": "Удалить пользователя «{username}»? Это действие необратимо.",
"usernameRequired": "Имя пользователя и пароль обязательны",
"networkError": "Ошибка сети",
"password": "Пароль"
@@ -400,6 +412,9 @@
"common": {
"cancel": "Отмена",
"confirm": "Подтвердить",
+ "close": "Закрыть",
+ "toggle": "Переключить",
+ "dismissNotification": "Закрыть уведомление",
"delete": "Удалить",
"edit": "Изменить",
"change": "Изменить",
@@ -429,6 +444,7 @@
"missing": "Отсутствует"
},
"containers": {
+ "eyebrowSuffix": "ГЛОБАЛЬНО",
"errLoad": "Не удалось загрузить контейнеры",
"searchPlaceholder": "Поиск по нагрузке, роли, образу, поддомену…",
"kindFilterLabel": "Тип нагрузки",
@@ -476,6 +492,7 @@
},
"stale": {
"title": "Устаревшие контейнеры",
+ "eyebrowSuffix": "УСТАРЕВШИЕ",
"noStale": "Нет устаревших контейнеров",
"noStaleDesc": "Все контейнеры исправны и работают.",
"cleanup": "Очистить",
@@ -541,13 +558,13 @@
"unavailable": "Статистика недоступна"
},
"systemHealth": {
- "title": "Состояние системы",
"containers": "Контейнеры",
"proxies": "Прокси",
"recentErrors": "Недавние ошибки"
},
"daemons": {
"title": "Демоны",
+ "notReachable": "{provider} недоступен.",
"refresh": "Обновить",
"refreshing": "Обновление",
"docker": "Docker Engine",
@@ -1110,6 +1127,10 @@
"image": "Ссылка на образ",
"imagePlaceholder": "registry.example.com/owner/app",
"imageHint": "Полная ссылка на образ без тега — Tinyforge ловит новые теги, выкладываемые под этой ссылкой.",
+ "browseImages": "Выбрать",
+ "browseImagesHint": "Выберите образ из настроенного реестра вместо ручного ввода ссылки.",
+ "browseImagesTitle": "Выбор образа",
+ "browseImagesSearch": "Поиск образов…",
"tagPattern": "Шаблон тега",
"tagPatternPlaceholder": "*",
"tagPatternHint": "Glob path.Match (например, v*, release-*). * совпадает с любым тегом.",
@@ -1122,6 +1143,9 @@
"branch": "Ветка",
"branchPlaceholder": "main",
"branchHint": "Только push'и, продвигающие эту ветку, дёргают триггер.",
+ "branchPattern": "Шаблон ветки (preview-деплои)",
+ "branchPatternPlaceholder": "feat/* или * для любой ветки",
+ "branchPatternHint": "Если задан, любой push в подходящую ветку создаёт отдельный preview-деплой. Оставьте пустым, чтобы выключить.",
"manualNote": "У ручных триггеров нет конфига. Они срабатывают только через кнопку Deploy на странице нагрузки или POST /workloads/{id}/deploy.",
"scheduleNote": "Срабатывает по фиксированному интервалу, который ведёт внутренний планировщик Tinyforge. Внешний webhook не нужен — включите его ниже только если CI тоже должен запускать триггер вручную.",
"intervalPresets": "Быстрые пресеты",
@@ -1186,6 +1210,14 @@
},
"new": {
"pageTitle": "Новое приложение · Tinyforge",
+ "wizard": {
+ "stepBasics": "Основное",
+ "stepConfigure": "Настройка",
+ "stepTrigger": "Триггер",
+ "stepReview": "Обзор",
+ "next": "Далее",
+ "back": "Назад"
+ },
"backLabel": "К приложениям",
"eyebrowSuffix": "НОВОЕ ПРИЛОЖЕНИЕ",
"title": "Создать приложение",
@@ -1198,6 +1230,7 @@
"alertTag": "ОШ",
"fieldName": "Имя",
"fieldNameRequired": "ОБЯЗАТЕЛЬНО",
+ "fieldRequired": "Обязательно",
"fieldNamePlaceholder": "my-app",
"fieldNameHint": "В нижнем регистре, без пробелов. Используется в именах контейнеров и поддоменах.",
"fieldSourcePlugin": "Source-плагин",
@@ -1207,7 +1240,7 @@
"fieldConfigYaml": "YAML",
"fieldConfigForm": "ФОРМА",
"fieldConfigJson": "JSON",
- "advancedJson": "Расширенный JSON",
+ "advancedJson": "Редактировать JSON",
"backToForm": "К форме",
"resetSample": "Сбросить к примеру",
"switchToJsonTitle": "Переключиться на сырой JSON-редактор",
@@ -1230,11 +1263,21 @@
"imageRegistryLabel": "Реестр (для приватных pull-ов)",
"imageRegistryPublic": "(публичный — без авторизации)",
"imageRegistryHint": "Имя должно совпадать с записью на странице «Реестры». Оставьте пустым для публичных образов.",
- "imageCpu": "Лимит CPU (ядра, 0 = ∞)",
- "imageMemory": "Лимит памяти (МБ, 0 = ∞)",
+ "imageCpu": "Лимит CPU",
+ "imageCpuHint": "Ядра, 0 = ∞",
+ "imageMemory": "Лимит памяти",
+ "imageMemoryHint": "МБ, 0 = ∞",
"imageMax": "Макс. инстансов",
"imageMaxHint": "1 = строгий blue-green.",
"imageFoot": "Переменные окружения и тома задаются в отдельных панелях на странице нагрузки после создания.",
+ "dockerfileHeader": "dockerfile-источник · сборка из git-репозитория",
+ "dockerfileBuildEyebrow": "сборка · dockerfile",
+ "dockerfileContextPath": "Контекст сборки",
+ "dockerfileContextPathPlaceholder": "(пусто = корень репо)",
+ "dockerfilePath": "Путь к Dockerfile",
+ "dockerfilePort": "Порт контейнера",
+ "dockerfilePortRequired": "Укажите порт, который слушает приложение (1–65535).",
+ "dockerfileFoot": "Tinyforge склонирует репо, соберёт образ из Dockerfile и запустит контейнер. Переменные окружения и тома — на странице нагрузки после создания.",
"staticHeader": "static-источник · страницы из репозитория",
"staticProvider": "Провайдер",
"staticBaseUrl": "Base URL",
@@ -1262,7 +1305,7 @@
"staticTestConnection": "Проверить соединение",
"staticConnectionOk": "Соединение установлено",
"staticConnectionFailed": "Ошибка соединения: {error}",
- "staticBrowseRepos": "Выбрать репозиторий",
+ "staticBrowseRepos": "Обзор",
"staticBrowseBranches": "Выбрать ветку",
"staticBrowseFolders": "Выбрать папку",
"staticPickerRepoTitle": "Выбор репозитория",
@@ -1275,6 +1318,7 @@
"staticTreeEmpty": "В этой ветке нет папок.",
"staticDenoAutoDetected": "Обнаружена папка api/ — режим автоматически переключён на Deno.",
"imageConflictTag": "ОБРАЗ УЖЕ ИСПОЛЬЗУЕТСЯ",
+ "imageConflictChecking": "Проверка конфликтов…",
"imageConflictHeading": "Этот образ уже используется в {count} нагрузке(ах):",
"imageConflictOpenBtn": "Открыть",
"imageConflictAcknowledgeNote": "Если это намеренно (например, отдельный этап), нажмите «Создать» ещё раз для продолжения.",
@@ -1300,21 +1344,23 @@
"submit": "Создать приложение",
"submitting": "Создание…",
"submitAnyway": "Всё равно создать",
+ "unsavedChanges": "В этом приложении есть несохранённые изменения. Покинуть страницу, не создавая его?",
+ "unsavedChangesTitle": "Несохранённые изменения",
+ "unsavedChangesConfirm": "Покинуть",
"errors": {
- "detectionFailed": "Не удалось определить провайдера.",
- "connectionFailed": "Ошибка соединения.",
- "reposFailed": "Не удалось загрузить репозитории.",
- "branchesFailed": "Не удалось загрузить ветки.",
- "treeFailed": "Не удалось загрузить дерево папок.",
+ "detectionFailed": "Не удалось определить Git-провайдера по этому URL. Проверьте, что базовый URL верен и доступен.",
+ "connectionFailed": "Не удалось подключиться к репозиторию. Проверьте URL провайдера, владельца/репозиторий и токен доступа (для приватных репозиториев).",
+ "reposFailed": "Не удалось получить список репозиториев. Проверьте базовый URL и токен доступа.",
+ "branchesFailed": "Не удалось получить список веток. Проверьте репозиторий и токен доступа.",
+ "treeFailed": "Не удалось загрузить дерево папок. Проверьте репозиторий, ветку и токен доступа.",
"sourceConfigInvalid": "source_config не является корректным JSON.",
"triggerBindUnknown": "неизвестная ошибка",
"createFailed": "Не удалось создать нагрузку.",
- "inspectFailed": "Не удалось проинспектировать образ."
+ "inspectFailed": "Не удалось проинспектировать образ — убедитесь, что он скачан локально и ссылка указана верно."
},
"imageInspect": "Инспектировать",
"imageInspectHint": "Подставляет порт и healthcheck из образа, чтобы не вводить вручную.",
"imageInspectOk": "Готово — порт и healthcheck подставлены.",
- "imageInspectError": "Ошибка инспекции: {error}",
"triggers": {
"section": "Триггер",
"sectionSub": "Необязательно. Выберите, откуда придёт сигнал передеплоя — слежение за реестром, git-событие или ручная кнопка.",
@@ -1334,6 +1380,18 @@
"pickWebhookOn": "ВЕБХУК ВКЛ",
"skippedNote": "Триггер не будет привязан. Добавьте его из панели «Триггеры» в карточке приложения после создания.",
"bindError": "Приложение создано, но привязка триггера не удалась: {error}. Откройте панель «Триггеры» в карточке, чтобы повторить."
+ },
+ "manifest": {
+ "title": "Манифест",
+ "name": "Имя",
+ "source": "Источник",
+ "trigger": "Триггер",
+ "publicFace": "Публичный фронт",
+ "unnamed": "(без имени)",
+ "registryPublic": "публичный реестр",
+ "folderRoot": "корень",
+ "triggerManual": "Только вручную",
+ "internalOnly": "Только внутренний"
}
},
"detail": {
@@ -1365,6 +1423,40 @@
"unavailable": "Не удалось получить размер (контейнер мог быть остановлен).",
"loading": "Вычисление размера…"
},
+ "buildLog": {
+ "title": "Журнал сборки",
+ "sub": "Живой поток вывода сборки Docker.",
+ "clear": "Очистить"
+ },
+ "notifications": {
+ "title": "Маршруты уведомлений",
+ "sub": "Множественные точки доставки для событий деплоя/сборки. При пустом списке используется устаревший единственный URL.",
+ "loading": "Загрузка маршрутов…",
+ "empty": "Нет настроенных маршрутов уведомлений. Добавьте, чтобы получать события в отдельный канал.",
+ "addFirst": "Добавить первый маршрут",
+ "add": "Добавить маршрут",
+ "edit": "Изменить",
+ "delete": "Удалить",
+ "name": "Имя",
+ "namePlaceholder": "Slack #alerts",
+ "url": "URL вебхука",
+ "secret": "Секрет подписи",
+ "secretPlaceholder": "Опционально — приёмник проверяет HMAC",
+ "secretEditPlaceholder": "Оставьте пустым, чтобы сохранить текущий секрет",
+ "secretHint": "HMAC-SHA256 от тела запроса, заголовок X-Hub-Signature-256.",
+ "eventTypes": "Типы событий",
+ "eventTypesPlaceholder": "deploy_failure,build_failure (пусто = все)",
+ "eventTypesHint": "Список через запятую. Пусто — маршрут срабатывает на любое событие.",
+ "enabled": "Включён",
+ "save": "Сохранить",
+ "saving": "Сохранение…",
+ "cancel": "Отмена",
+ "allEvents": "все события",
+ "signed": "подписан",
+ "disabled": "выключен",
+ "confirmDeleteTitle": "Удалить маршрут уведомлений?",
+ "confirmDeleteMessage": "Маршрут перестанет срабатывать. Устаревший URL уведомлений на workload (если задан) снова возьмёт события на себя."
+ },
"toolbar": {
"stop": "Стоп",
"start": "Старт",
@@ -1455,6 +1547,19 @@
"editStaticModeDenoDesc": "— Deno-рантайм с динамической маршрутизацией.",
"editStaticRenderMarkdown": "Рендерить markdown",
"editStaticRenderMarkdownDesc": "— автоматически отдавать .md как HTML.",
+ "editDockerfileHeader": "dockerfile-источник · сборка из git-репозитория",
+ "editDockerfileBuildEyebrow": "сборка · dockerfile",
+ "editDockerfileContextPath": "Контекст сборки",
+ "editDockerfileContextPathPlaceholder": "(пусто = корень репо)",
+ "editDockerfilePath": "Путь к Dockerfile",
+ "editDockerfilePort": "Порт контейнера",
+ "editTestConnection": "Проверить соединение",
+ "editTestConnectionOk": "Соединение установлено",
+ "editTestConnectionFailed": "Ошибка соединения: {error}",
+ "editTestConnectionUnknownError": "Неизвестная ошибка",
+ "overrideKeyUnitSingular": "КЛЮЧ",
+ "overrideKeyUnitPlural": "КЛЮЧИ",
+ "editTestConnectionIncomplete": "Заполните провайдера, base URL, owner и name.",
"editSourceJsonHeader": "source_config.json",
"editSourceJsonAria": "Конфигурация source-плагина (JSON)",
"editPublicFaces": "Публичные фронты",
@@ -1494,6 +1599,29 @@
"chainPromoteButton": "Продвинуть от родителя",
"chainPromoting": "Продвижение…",
"chainHint": "Задайте parent_workload_id у нагрузки, чтобы построить цепочку. Дочерние image-источники могут одним кликом продвинуть текущий запущенный тег родителя.",
+ "previews": {
+ "title": "Превью-окружения",
+ "subEmpty": "нет активных превью",
+ "subCountOne": "1 активное превью",
+ "subCount": "активных превью: {count}",
+ "tag": "Превью",
+ "tagTitle": "Превью-развёртывание этой нагрузки для отдельной ветки",
+ "armedEmpty": "Нет активных превью — отправьте пуш в ветку, соответствующую шаблону",
+ "noneEmpty": "Пока нет активных превью.",
+ "open": "Открыть",
+ "noUrl": "нет публичного URL",
+ "teardown": "Удалить",
+ "teardownTitle": "Удалить превью?",
+ "teardownMessage": "Это удалит превью для ветки «{name}» вместе с его контейнерами и маршрутами прокси. Новый пуш в эту ветку создаст его заново.",
+ "teardownConfirm": "Удалить",
+ "teardownPending": "Удаление…",
+ "teardownFailed": "Не удалось удалить",
+ "stateRunning": "Работает",
+ "statePending": "Запускается",
+ "stateStopped": "Остановлено",
+ "stateUnknown": "Неизвестно",
+ "hint": "Превью создаются автоматически, когда пуш приходит в ветку, соответствующую branch_pattern git-триггера, и удаляются при удалении ветки. Каждое получает собственный поддомен с префиксом-слагом."
+ },
"volumesTitle": "Тома",
"volumesEmpty": "Нет монтирований",
"volumesCountSingular": "{count} монтирование",
diff --git a/web/src/lib/sse.ts b/web/src/lib/sse.ts
index 43807b1..90630b0 100644
--- a/web/src/lib/sse.ts
+++ b/web/src/lib/sse.ts
@@ -9,7 +9,7 @@ import { getAuthToken } from './auth';
// ── Types ──────────────────────────────────────────────────────────
-export type SSEEventType = 'deploy_log' | 'instance_status' | 'deploy_status' | 'event_log';
+export type SSEEventType = 'deploy_log' | 'instance_status' | 'deploy_status' | 'event_log' | 'build_log';
export interface SSEEvent {
type: SSEEventType;
@@ -47,7 +47,18 @@ export interface EventLogSSEPayload {
created_at: string;
}
-type SSEPayload = DeployLogPayload | InstanceStatusPayload | DeployStatusPayload | EventLogSSEPayload;
+export interface BuildLogPayload {
+ workload_id: string;
+ line: string;
+ stream?: string;
+}
+
+type SSEPayload =
+ | DeployLogPayload
+ | InstanceStatusPayload
+ | DeployStatusPayload
+ | EventLogSSEPayload
+ | BuildLogPayload;
export interface SSEOptions {
/** Called for each SSE event received. */
@@ -123,6 +134,16 @@ export function connectSSE(url: string, options: SSEOptions): SSEConnection {
if (closed) return;
+ // Defensive clear: onerror can fire multiple times in quick
+ // succession during a network flap, each call would otherwise
+ // queue an additional reconnect and abandon the prior
+ // EventSource without closing it. Cancel any pending retry
+ // before scheduling a fresh one.
+ if (retryTimeout !== null) {
+ clearTimeout(retryTimeout);
+ retryTimeout = null;
+ }
+
retryCount++;
onError?.(retryCount);
@@ -168,10 +189,21 @@ export function connectGlobalEvents(callbacks: {
onInstanceStatus?: (payload: InstanceStatusPayload) => void;
onDeployStatus?: (payload: DeployStatusPayload) => void;
onEventLog?: (payload: EventLogSSEPayload) => void;
+ onBuildLog?: (payload: BuildLogPayload) => void;
onOpen?: () => void;
onError?: (attempt: number) => void;
+ /**
+ * Opt in to build-log frames for a single workload. Build logs are
+ * high-volume; the server only streams them to connections that pass
+ * this, so a verbose build can't flood every dashboard connection.
+ * Omit it on connections that don't render build output.
+ */
+ buildLogWorkloadId?: string;
}): SSEConnection {
- return connectSSE('/api/events', {
+ const url = callbacks.buildLogWorkloadId
+ ? `/api/events?workload_id=${encodeURIComponent(callbacks.buildLogWorkloadId)}`
+ : '/api/events';
+ return connectSSE(url, {
onEvent(event) {
if (event.type === 'instance_status') {
callbacks.onInstanceStatus?.(event.payload as InstanceStatusPayload);
@@ -179,6 +211,8 @@ export function connectGlobalEvents(callbacks: {
callbacks.onDeployStatus?.(event.payload as DeployStatusPayload);
} else if (event.type === 'event_log') {
callbacks.onEventLog?.(event.payload as EventLogSSEPayload);
+ } else if (event.type === 'build_log') {
+ callbacks.onBuildLog?.(event.payload as BuildLogPayload);
}
},
onOpen: callbacks.onOpen,
diff --git a/web/src/lib/styles/tokens.css b/web/src/lib/styles/tokens.css
index 3fb3081..cb8cdeb 100644
--- a/web/src/lib/styles/tokens.css
+++ b/web/src/lib/styles/tokens.css
@@ -5,17 +5,32 @@
───────────────────────────────────────────────────────────────────── */
:root {
- /* ── Brand Colors ───────────────────────────────────── */
- --color-brand-50: #eef2ff;
- --color-brand-100: #e0e7ff;
- --color-brand-200: #c7d2fe;
- --color-brand-300: #a5b4fc;
- --color-brand-400: #818cf8;
- --color-brand-500: #6366f1;
- --color-brand-600: #4f46e5;
- --color-brand-700: #4338ca;
- --color-brand-800: #3730a3;
- --color-brand-900: #312e81;
+ /* ── Brand Colors ─────────────────────────────────────
+ "Forge" identity: amber / ember hues matching the
+ industrial control-room aesthetic. Indigo was a poor
+ fit for a product called Tinyforge — these tokens are
+ amber 50-900 with a slight orange shift on the 500-700
+ range so the active accent reads as molten metal rather
+ than playful pastel. */
+ --color-brand-50: #fff8eb;
+ --color-brand-100: #fdebcb;
+ --color-brand-200: #fbd591;
+ --color-brand-300: #f8b955;
+ --color-brand-400: #f59e0b;
+ --color-brand-500: #d97706;
+ --color-brand-600: #b45309;
+ --color-brand-700: #92400e;
+ --color-brand-800: #78350f;
+ --color-brand-900: #451a03;
+
+ /* Forge ember accent — used directly by forge-ember CSS
+ class and the highlight ring on hover states. Distinct
+ token so a future rebrand can shift the accent without
+ re-touching every consumer of --color-brand-*. */
+ --forge-ember: #ea580c;
+ --forge-ember-deep: #c2410c;
+ --forge-anvil: #1c1917;
+ --forge-spark: #fed7aa;
/* ── Semantic Colors ────────────────────────────────── */
--color-success: #16a34a;
@@ -45,10 +60,15 @@
--border-focus: var(--color-brand-500);
--border-input: #cbd5e1;
- /* ── Text Colors ────────────────────────────────────── */
+ /* ── Text Colors ──────────────────────────────────────
+ Tertiary darkened from #94a3b8 (3.4:1 on #f8fafc — fails
+ WCAG AA) to #64748b (4.6:1 — AA-compliant). The old hue
+ is kept as --text-tertiary-soft for non-text decorations
+ (rule dots, separators) where contrast is not a concern. */
--text-primary: #0f172a;
--text-secondary: #475569;
- --text-tertiary: #94a3b8;
+ --text-tertiary: #64748b;
+ --text-tertiary-soft: #94a3b8;
--text-inverse: #ffffff;
--text-link: var(--color-brand-600);
--text-link-hover: var(--color-brand-700);
@@ -66,9 +86,15 @@
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
- /* ── Typography Scale ───────────────────────────────── */
- --font-family-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
- --font-family-mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', monospace;
+ /* ── Typography Scale ─────────────────────────────────
+ System UI stack as the default: matches the OS, costs
+ zero bytes, and reads as a tool rather than a marketing
+ site. Inter remains as a fallback hint for installs that
+ have it (downloaded for an earlier theme) but is no
+ longer first-class. Monospace stays JetBrains Mono for
+ the code feel — operators read a lot of SHAs. */
+ --font-family-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Inter', 'Helvetica Neue', Arial, sans-serif;
+ --font-family-mono: ui-monospace, 'JetBrains Mono', SFMono-Regular, 'Cascadia Code', Menlo, Consolas, monospace;
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
@@ -128,8 +154,12 @@
--border-input: #475569;
--text-primary: #f1f5f9;
- --text-secondary: #94a3b8;
- --text-tertiary: #64748b;
+ /* Dark mode: secondary darkened to #94a3b8 (4.7:1 on #1e293b),
+ tertiary held at #94a3b8 too — same hue but used on darker
+ surfaces. The legacy #64748b on #1e293b was 3.2:1, failing AA. */
+ --text-secondary: #cbd5e1;
+ --text-tertiary: #94a3b8;
+ --text-tertiary-soft: #64748b;
--text-inverse: #0f172a;
--text-link: var(--color-brand-400);
--text-link-hover: var(--color-brand-300);
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index d02006d..1c1d840 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -111,19 +111,29 @@ export interface ApiEnvelope {
error?: string;
}
-/** Response shape for POST /api/deploy/inspect */
+/** Response shape for POST /api/discovery/image/inspect */
export interface InspectResult {
- image: string;
port: number;
healthcheck: string;
}
-/** Item for the EntityPicker command-palette component. */
+/**
+ * Item for the EntityPicker command-palette component.
+ *
+ * `icon` was historically typed as `string` and rendered via `{@html}` —
+ * which made it a potential stored-XSS sink the moment a caller built the
+ * value from any non-literal data. Narrowed to a controlled token union
+ * so every supported glyph is rendered through a known SVG path. Adding a
+ * new glyph requires a code change here AND a render-branch in
+ * EntityPicker.svelte — keep them in sync.
+ */
+export type EntityPickerIcon = 'lock' | 'box' | 'folder' | 'branch';
+
export interface EntityPickerItem {
value: string;
label: string;
description?: string;
- icon?: string;
+ icon?: EntityPickerIcon;
group?: string;
disabled?: boolean;
disabledHint?: string;
diff --git a/web/src/lib/workload/sourceForms.test.ts b/web/src/lib/workload/sourceForms.test.ts
new file mode 100644
index 0000000..42b3b9d
--- /dev/null
+++ b/web/src/lib/workload/sourceForms.test.ts
@@ -0,0 +1,276 @@
+import { describe, it, expect } from 'vitest';
+import {
+ emptyImageState,
+ emptyComposeState,
+ emptyStaticState,
+ emptyDockerfileState,
+ seedImageState,
+ seedComposeState,
+ seedStaticState,
+ seedDockerfileState,
+ imageToConfig,
+ composeToConfig,
+ staticToConfig,
+ dockerfileToConfig,
+ stringifyConfig,
+ isImageValid,
+ isComposeValid,
+ isStaticValid,
+ isDockerfileValid
+} from './sourceForms';
+
+describe('image source', () => {
+ it('seeds defaults from empty/malformed JSON', () => {
+ expect(seedImageState('{}')).toEqual(emptyImageState());
+ expect(seedImageState('not json')).toEqual(emptyImageState());
+ expect(seedImageState('[]')).toEqual(emptyImageState());
+ expect(seedImageState('42')).toEqual(emptyImageState());
+ });
+
+ it('seeds populated fields', () => {
+ const json = JSON.stringify({
+ image: 'nginx',
+ port: 8080,
+ healthcheck: '/healthz',
+ default_tag: 'stable',
+ registry_name: 'docker.io',
+ cpu_limit: 2,
+ memory_limit: 512,
+ max_instances: 3
+ });
+ expect(seedImageState(json)).toEqual({
+ ref: 'nginx',
+ port: 8080,
+ healthcheck: '/healthz',
+ defaultTag: 'stable',
+ registryName: 'docker.io',
+ cpuLimit: 2,
+ memoryLimit: 512,
+ maxInstances: 3
+ });
+ });
+
+ it('serializes to the exact source_config shape and key order', () => {
+ const config = imageToConfig(emptyImageState(), '{}');
+ expect(Object.keys(config)).toEqual([
+ 'image',
+ 'registry_name',
+ 'port',
+ 'healthcheck',
+ 'env',
+ 'volumes',
+ 'cpu_limit',
+ 'memory_limit',
+ 'default_tag',
+ 'max_instances'
+ ]);
+ expect(config).toEqual({
+ image: '',
+ registry_name: '',
+ port: 0,
+ healthcheck: '',
+ env: {},
+ volumes: [],
+ cpu_limit: 0,
+ memory_limit: 0,
+ default_tag: 'latest',
+ max_instances: 1
+ });
+ });
+
+ it('preserves env and volumes from the existing config', () => {
+ const existing = JSON.stringify({
+ image: 'old',
+ env: { FOO: 'bar' },
+ volumes: [{ source: 'data', scope: 'named' }]
+ });
+ const config = imageToConfig({ ...emptyImageState(), ref: 'new' }, existing);
+ expect(config.env).toEqual({ FOO: 'bar' });
+ expect(config.volumes).toEqual([{ source: 'data', scope: 'named' }]);
+ expect(config.image).toBe('new');
+ });
+
+ it('round-trips state -> config -> state', () => {
+ const state = seedImageState(
+ JSON.stringify({ image: 'app', port: 3000, default_tag: 'v1', max_instances: 2 })
+ );
+ expect(seedImageState(stringifyConfig(imageToConfig(state, '{}')))).toEqual(state);
+ });
+
+ it('validity requires a non-empty image ref', () => {
+ expect(isImageValid(emptyImageState())).toBe(false);
+ expect(isImageValid({ ...emptyImageState(), ref: ' ' })).toBe(false);
+ expect(isImageValid({ ...emptyImageState(), ref: 'nginx' })).toBe(true);
+ });
+});
+
+describe('compose source', () => {
+ it('seeds defaults and populated fields', () => {
+ expect(seedComposeState('{}')).toEqual(emptyComposeState());
+ expect(
+ seedComposeState(JSON.stringify({ compose_yaml: 'services: {}', compose_project_name: 'app' }))
+ ).toEqual({ yaml: 'services: {}', projectName: 'app' });
+ });
+
+ it('serializes to the exact shape', () => {
+ const config = composeToConfig({ yaml: 'x', projectName: 'p' });
+ expect(Object.keys(config)).toEqual(['compose_yaml', 'compose_project_name']);
+ expect(config).toEqual({ compose_yaml: 'x', compose_project_name: 'p' });
+ });
+
+ it('validity requires non-empty yaml', () => {
+ expect(isComposeValid(emptyComposeState())).toBe(false);
+ expect(isComposeValid({ yaml: 'services: {}', projectName: '' })).toBe(true);
+ });
+});
+
+describe('static source', () => {
+ it('seeds defaults, normalizing provider and branch', () => {
+ expect(seedStaticState('{}')).toEqual(emptyStaticState());
+ // unknown provider -> gitea; empty branch -> main
+ expect(seedStaticState(JSON.stringify({ provider: 'bogus', branch: '' }))).toEqual(
+ emptyStaticState()
+ );
+ expect(seedStaticState(JSON.stringify({ provider: 'github' })).provider).toBe('github');
+ expect(seedStaticState(JSON.stringify({ mode: 'deno' })).mode).toBe('deno');
+ });
+
+ it('serializes to the exact shape and key order', () => {
+ const config = staticToConfig(emptyStaticState(), '{}');
+ expect(Object.keys(config)).toEqual([
+ 'provider',
+ 'base_url',
+ 'repo_owner',
+ 'repo_name',
+ 'branch',
+ 'folder_path',
+ 'access_token',
+ 'mode',
+ 'render_markdown'
+ ]);
+ expect(config.branch).toBe('main');
+ });
+
+ it('preserves storage_* keys only when present', () => {
+ const withStorage = staticToConfig(
+ emptyStaticState(),
+ JSON.stringify({ storage_enabled: true, storage_limit_mb: 100 })
+ );
+ expect(withStorage.storage_enabled).toBe(true);
+ expect(withStorage.storage_limit_mb).toBe(100);
+
+ const without = staticToConfig(emptyStaticState(), '{}');
+ expect('storage_enabled' in without).toBe(false);
+ expect('storage_limit_mb' in without).toBe(false);
+ });
+
+ it('round-trips a populated state', () => {
+ const state = seedStaticState(
+ JSON.stringify({
+ provider: 'gitlab',
+ base_url: 'https://gl.example',
+ repo_owner: 'me',
+ repo_name: 'site',
+ branch: 'dev',
+ folder_path: 'public',
+ access_token: 'secret',
+ mode: 'deno',
+ render_markdown: true
+ })
+ );
+ expect(seedStaticState(stringifyConfig(staticToConfig(state, '{}')))).toEqual(state);
+ });
+
+ it('validity requires base_url + owner + repo', () => {
+ expect(isStaticValid(emptyStaticState())).toBe(false);
+ expect(
+ isStaticValid({
+ ...emptyStaticState(),
+ baseURL: 'https://x',
+ repoOwner: 'o',
+ repoName: 'r'
+ })
+ ).toBe(true);
+ });
+});
+
+describe('dockerfile source', () => {
+ it('seeds defaults, defaulting dockerfile_path to Dockerfile', () => {
+ expect(seedDockerfileState('{}')).toEqual(emptyDockerfileState());
+ expect(seedDockerfileState(JSON.stringify({ dockerfile_path: '' })).dockerfilePath).toBe(
+ 'Dockerfile'
+ );
+ expect(seedDockerfileState(JSON.stringify({ dockerfile_path: 'docker/Dockerfile' })).dockerfilePath).toBe(
+ 'docker/Dockerfile'
+ );
+ });
+
+ it('serializes to the exact shape and key order', () => {
+ const config = dockerfileToConfig(emptyDockerfileState(), '{}');
+ expect(Object.keys(config)).toEqual([
+ 'provider',
+ 'base_url',
+ 'repo_owner',
+ 'repo_name',
+ 'branch',
+ 'access_token',
+ 'context_path',
+ 'dockerfile_path',
+ 'port'
+ ]);
+ expect(config.dockerfile_path).toBe('Dockerfile');
+ expect(config.branch).toBe('main');
+ expect(config.port).toBe(0);
+ });
+
+ it('preserves unknown keys but scrubs static-only keys', () => {
+ const existing = JSON.stringify({
+ // unknown key the operator added via raw JSON -> preserved
+ healthcheck: '/up',
+ cpu_limit: 1,
+ // static-only leftovers from a static->dockerfile switch -> scrubbed
+ folder_path: 'public',
+ mode: 'deno',
+ render_markdown: true,
+ storage_enabled: true,
+ storage_limit_mb: 50
+ });
+ const config = dockerfileToConfig(emptyDockerfileState(), existing);
+ expect(config.healthcheck).toBe('/up');
+ expect(config.cpu_limit).toBe(1);
+ expect('folder_path' in config).toBe(false);
+ expect('mode' in config).toBe(false);
+ expect('render_markdown' in config).toBe(false);
+ expect('storage_enabled' in config).toBe(false);
+ expect('storage_limit_mb' in config).toBe(false);
+ });
+
+ it('round-trips a populated state', () => {
+ const state = seedDockerfileState(
+ JSON.stringify({
+ provider: 'github',
+ base_url: 'https://gh.example',
+ repo_owner: 'me',
+ repo_name: 'svc',
+ branch: 'main',
+ access_token: 't',
+ context_path: 'backend',
+ dockerfile_path: 'backend/Dockerfile',
+ port: 8000
+ })
+ );
+ expect(seedDockerfileState(stringifyConfig(dockerfileToConfig(state, '{}')))).toEqual(state);
+ });
+
+ it('validity requires git fields + a positive port', () => {
+ const base = {
+ ...emptyDockerfileState(),
+ baseURL: 'https://x',
+ repoOwner: 'o',
+ repoName: 'r'
+ };
+ expect(isDockerfileValid(base)).toBe(false); // port 0
+ expect(isDockerfileValid({ ...base, port: 8080 })).toBe(true);
+ expect(isDockerfileValid({ ...base, port: -1 })).toBe(false);
+ });
+});
diff --git a/web/src/lib/workload/sourceForms.ts b/web/src/lib/workload/sourceForms.ts
new file mode 100644
index 0000000..2a7dc20
--- /dev/null
+++ b/web/src/lib/workload/sourceForms.ts
@@ -0,0 +1,353 @@
+/**
+ * Shared source-config form model for the four workload Source kinds
+ * (image / compose / static / dockerfile).
+ *
+ * Before this module the seed (JSON -> form fields) and serialize
+ * (form fields -> source_config JSON) logic lived inline and DUPLICATED
+ * verbatim in both `routes/apps/new/+page.svelte` and
+ * `routes/apps/[id]/+page.svelte`. A drift between the two silently
+ * changes the `source_config` shape the backend stores, which breaks
+ * deploys. This module is the single source of truth so the create
+ * wizard and the detail-page edit form serialize identically.
+ *
+ * The functions are pure (no Svelte runes, no DOM) so they unit-test in
+ * a plain node environment. Components hold the state objects as `$state`
+ * and call these to seed / serialize.
+ *
+ * Fidelity contract: output key order, defaults, and the preserve/scrub
+ * behaviour below MUST match the legacy inline helpers exactly. Tests in
+ * `sourceForms.test.ts` lock the shapes.
+ */
+
+export type GitProvider = 'gitea' | 'github' | 'gitlab';
+
+/** Image source: deploy a pre-built image from a registry. */
+export interface ImageFormState {
+ ref: string;
+ port: number;
+ healthcheck: string;
+ defaultTag: string;
+ registryName: string;
+ cpuLimit: number;
+ memoryLimit: number;
+ maxInstances: number;
+}
+
+/** Compose source: a docker-compose stack. */
+export interface ComposeFormState {
+ yaml: string;
+ projectName: string;
+}
+
+/**
+ * Git-discovery fields shared by the static and dockerfile sources —
+ * both clone a repo via the same provider/owner/repo/branch/token path.
+ * Extracted so a single discovery component can bind this slice of
+ * either form.
+ */
+export interface GitSourceState {
+ provider: GitProvider;
+ baseURL: string;
+ repoOwner: string;
+ repoName: string;
+ branch: string;
+ accessToken: string;
+}
+
+/** Static source: serve files (optionally Deno) from a repo folder. */
+export interface StaticFormState extends GitSourceState {
+ folderPath: string;
+ mode: 'static' | 'deno';
+ renderMarkdown: boolean;
+}
+
+/** Dockerfile source: build an image from a Dockerfile in a repo. */
+export interface DockerfileFormState extends GitSourceState {
+ contextPath: string;
+ dockerfilePath: string;
+ port: number;
+}
+
+// ── Defaults ────────────────────────────────────────────────────────
+
+export function emptyImageState(): ImageFormState {
+ return {
+ ref: '',
+ port: 0,
+ healthcheck: '',
+ defaultTag: 'latest',
+ registryName: '',
+ cpuLimit: 0,
+ memoryLimit: 0,
+ maxInstances: 1
+ };
+}
+
+export function emptyComposeState(): ComposeFormState {
+ return { yaml: '', projectName: '' };
+}
+
+function emptyGitSourceState(): GitSourceState {
+ return {
+ provider: 'gitea',
+ baseURL: '',
+ repoOwner: '',
+ repoName: '',
+ branch: 'main',
+ accessToken: ''
+ };
+}
+
+export function emptyStaticState(): StaticFormState {
+ return { ...emptyGitSourceState(), folderPath: '', mode: 'static', renderMarkdown: false };
+}
+
+export function emptyDockerfileState(): DockerfileFormState {
+ return { ...emptyGitSourceState(), contextPath: '', dockerfilePath: 'Dockerfile', port: 0 };
+}
+
+// ── Parse helpers ───────────────────────────────────────────────────
+
+/** Parse to an object for seeding; malformed / non-object JSON -> {}. */
+function parseObject(jsonText: string): Record {
+ try {
+ const parsed: unknown = JSON.parse(jsonText);
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+ return parsed as Record;
+ }
+ } catch {
+ // fall through
+ }
+ return {};
+}
+
+/** Parse for preserve helpers; malformed JSON -> null (caller guards). */
+function tryParse(jsonText: string): Record | null {
+ try {
+ const parsed: unknown = JSON.parse(jsonText);
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+ return parsed as Record;
+ }
+ } catch {
+ // fall through
+ }
+ return null;
+}
+
+function strOr(value: unknown, fallback: string): string {
+ return typeof value === 'string' ? value : fallback;
+}
+
+/** Non-empty string or fallback (matches legacy `typeof x === 'string' && x ? x : d`). */
+function strOrTruthy(value: unknown, fallback: string): string {
+ return typeof value === 'string' && value ? value : fallback;
+}
+
+function numOr(value: unknown, fallback: number): number {
+ return typeof value === 'number' ? value : fallback;
+}
+
+function normProvider(value: unknown): GitProvider {
+ return value === 'github' || value === 'gitlab' ? value : 'gitea';
+}
+
+// ── Seed: source_config JSON -> form state ──────────────────────────
+
+export function seedImageState(jsonText: string): ImageFormState {
+ const o = parseObject(jsonText);
+ return {
+ ref: strOr(o.image, ''),
+ port: numOr(o.port, 0),
+ healthcheck: strOr(o.healthcheck, ''),
+ defaultTag: strOr(o.default_tag, 'latest'),
+ registryName: strOr(o.registry_name, ''),
+ cpuLimit: numOr(o.cpu_limit, 0),
+ memoryLimit: numOr(o.memory_limit, 0),
+ maxInstances: numOr(o.max_instances, 1)
+ };
+}
+
+export function seedComposeState(jsonText: string): ComposeFormState {
+ const o = parseObject(jsonText);
+ return {
+ yaml: strOr(o.compose_yaml, ''),
+ projectName: strOr(o.compose_project_name, '')
+ };
+}
+
+export function seedStaticState(jsonText: string): StaticFormState {
+ const o = parseObject(jsonText);
+ return {
+ provider: normProvider(o.provider),
+ baseURL: strOr(o.base_url, ''),
+ repoOwner: strOr(o.repo_owner, ''),
+ repoName: strOr(o.repo_name, ''),
+ branch: strOrTruthy(o.branch, 'main'),
+ accessToken: strOr(o.access_token, ''),
+ folderPath: strOr(o.folder_path, ''),
+ mode: o.mode === 'deno' ? 'deno' : 'static',
+ renderMarkdown: typeof o.render_markdown === 'boolean' ? o.render_markdown : false
+ };
+}
+
+export function seedDockerfileState(jsonText: string): DockerfileFormState {
+ const o = parseObject(jsonText);
+ return {
+ provider: normProvider(o.provider),
+ baseURL: strOr(o.base_url, ''),
+ repoOwner: strOr(o.repo_owner, ''),
+ repoName: strOr(o.repo_name, ''),
+ branch: strOrTruthy(o.branch, 'main'),
+ accessToken: strOr(o.access_token, ''),
+ contextPath: strOr(o.context_path, ''),
+ dockerfilePath: strOrTruthy(o.dockerfile_path, 'Dockerfile'),
+ port: numOr(o.port, 0)
+ };
+}
+
+// ── Serialize: form state -> source_config object ───────────────────
+
+/**
+ * Preserve `env` (object) and `volumes` (array) from an existing config
+ * — they're edited in dedicated detail-page panels, not the source form,
+ * and must survive a form round-trip.
+ */
+function preserveEnvVolumes(existingJson: string): {
+ env: Record;
+ volumes: unknown[];
+} {
+ const existing = tryParse(existingJson);
+ let env: Record = {};
+ let volumes: unknown[] = [];
+ if (existing) {
+ if (existing.env && typeof existing.env === 'object') {
+ env = existing.env as Record;
+ }
+ if (Array.isArray(existing.volumes)) {
+ volumes = existing.volumes;
+ }
+ }
+ return { env, volumes };
+}
+
+export function imageToConfig(s: ImageFormState, existingJson: string): Record {
+ const { env, volumes } = preserveEnvVolumes(existingJson);
+ return {
+ image: s.ref,
+ registry_name: s.registryName,
+ port: s.port,
+ healthcheck: s.healthcheck,
+ env,
+ volumes,
+ cpu_limit: s.cpuLimit,
+ memory_limit: s.memoryLimit,
+ default_tag: s.defaultTag,
+ max_instances: s.maxInstances
+ };
+}
+
+export function composeToConfig(s: ComposeFormState): Record {
+ return { compose_yaml: s.yaml, compose_project_name: s.projectName };
+}
+
+export function staticToConfig(s: StaticFormState, existingJson: string): Record {
+ const out: Record = {
+ provider: s.provider,
+ base_url: s.baseURL,
+ repo_owner: s.repoOwner,
+ repo_name: s.repoName,
+ branch: s.branch || 'main',
+ folder_path: s.folderPath,
+ access_token: s.accessToken,
+ mode: s.mode,
+ render_markdown: s.renderMarkdown
+ };
+ // Preserve storage_* keys set via the raw JSON editor (not yet surfaced
+ // as form controls) so a form round-trip doesn't silently drop them.
+ const existing = tryParse(existingJson);
+ if (existing) {
+ if (typeof existing.storage_enabled === 'boolean') out.storage_enabled = existing.storage_enabled;
+ if (typeof existing.storage_limit_mb === 'number') out.storage_limit_mb = existing.storage_limit_mb;
+ }
+ return out;
+}
+
+/**
+ * Keys the dockerfile form owns. Everything else in an existing config is
+ * preserved on round-trip EXCEPT the static-only keys (folder_path / mode
+ * / render_markdown / storage_*) which are deliberately scrubbed: after a
+ * static -> dockerfile switch they'd otherwise linger as dead keys and
+ * make the backend log "unknown field" noise on every save.
+ */
+const DOCKERFILE_OWNED_KEYS: ReadonlySet = new Set([
+ 'provider',
+ 'base_url',
+ 'repo_owner',
+ 'repo_name',
+ 'branch',
+ 'access_token',
+ 'context_path',
+ 'dockerfile_path',
+ 'port',
+ 'folder_path',
+ 'mode',
+ 'render_markdown',
+ 'storage_enabled',
+ 'storage_limit_mb'
+]);
+
+export function dockerfileToConfig(
+ s: DockerfileFormState,
+ existingJson: string
+): Record {
+ const preserved: Record = {};
+ const existing = tryParse(existingJson);
+ if (existing) {
+ for (const [k, v] of Object.entries(existing)) {
+ if (!DOCKERFILE_OWNED_KEYS.has(k)) preserved[k] = v;
+ }
+ }
+ return {
+ provider: s.provider,
+ base_url: s.baseURL,
+ repo_owner: s.repoOwner,
+ repo_name: s.repoName,
+ branch: s.branch || 'main',
+ access_token: s.accessToken,
+ context_path: s.contextPath,
+ dockerfile_path: s.dockerfilePath || 'Dockerfile',
+ port: s.port || 0,
+ ...preserved
+ };
+}
+
+/** Pretty-print a config object for the Advanced-JSON editor view. */
+export function stringifyConfig(config: Record): string {
+ return JSON.stringify(config, null, 2);
+}
+
+// ── Per-kind validity ───────────────────────────────────────────────
+// Encodes the required fields per source kind. These back the wizard's
+// step gating (replacing the prior opaque ~250-char boolean). Optional
+// fields (folder_path, context_path, healthcheck, resource limits, ...)
+// are intentionally not required here.
+
+export function isImageValid(s: ImageFormState): boolean {
+ return s.ref.trim() !== '';
+}
+
+export function isComposeValid(s: ComposeFormState): boolean {
+ return s.yaml.trim() !== '';
+}
+
+function isGitSourceValid(s: GitSourceState): boolean {
+ return s.baseURL.trim() !== '' && s.repoOwner.trim() !== '' && s.repoName.trim() !== '';
+}
+
+export function isStaticValid(s: StaticFormState): boolean {
+ return isGitSourceValid(s);
+}
+
+export function isDockerfileValid(s: DockerfileFormState): boolean {
+ return isGitSourceValid(s) && typeof s.port === 'number' && Number.isFinite(s.port) && s.port > 0;
+}
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte
index 3d67a19..1d12eca 100644
--- a/web/src/routes/+layout.svelte
+++ b/web/src/routes/+layout.svelte
@@ -25,27 +25,51 @@
type NavCountKey = 'apps' | 'workloads' | 'proxies' | 'containers' | 'eventsErrors';
+ // Navigation entries are now grouped into named sections. The
+ // renderer treats a `section:` marker entry as a visual divider with
+ // an uppercase eyebrow label, but otherwise renders items as before.
+ // Grouping the flat list (Events / Event Triggers / Log Rules sat
+ // next to Apps / Containers without any visual separation) was the
+ // biggest readability complaint from the earlier UI review.
+ type NavSection = 'build' | 'observe' | 'system';
const navItems: ReadonlyArray<{
href: string;
labelKey: string;
icon: string;
+ section: NavSection;
countKey?: NavCountKey;
/** When true the badge uses a danger style (red). */
alert?: boolean;
- /** Static label override when the i18n catalogue does not yet carry the key. */
- labelOverride?: string;
}> = [
- { href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
- { href: '/apps', labelKey: 'nav.apps', icon: 'box', countKey: 'apps' },
- { href: '/containers', labelKey: 'nav.containers', icon: 'containers', countKey: 'containers' },
- { href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' },
- { href: '/events', labelKey: 'nav.events', icon: 'events', countKey: 'eventsErrors', alert: true },
- { href: '/triggers', labelKey: 'nav.triggers', icon: 'deploy' },
- { href: '/event-triggers', labelKey: 'nav.eventTriggers', icon: 'events', labelOverride: 'Event Triggers' },
- { href: '/log-scan-rules', labelKey: 'nav.logScanRules', icon: 'events', labelOverride: 'Log Rules' },
- { href: '/settings', labelKey: 'nav.settings', icon: 'settings' }
+ { href: '/', labelKey: 'nav.dashboard', icon: 'dashboard', section: 'build' },
+ { href: '/apps', labelKey: 'nav.apps', icon: 'box', section: 'build', countKey: 'apps' },
+ { href: '/containers', labelKey: 'nav.containers', icon: 'containers', section: 'build', countKey: 'containers' },
+ { href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', section: 'build', countKey: 'proxies' },
+ { href: '/triggers', labelKey: 'nav.triggers', icon: 'deploy', section: 'build' },
+ { href: '/events', labelKey: 'nav.events', icon: 'events', section: 'observe', countKey: 'eventsErrors', alert: true },
+ { href: '/event-triggers', labelKey: 'nav.eventTriggers', icon: 'events', section: 'observe' },
+ { href: '/log-scan-rules', labelKey: 'nav.logScanRules', icon: 'events', section: 'observe' },
+ { href: '/settings', labelKey: 'nav.settings', icon: 'settings', section: 'system' }
];
+ // sectionLabels: eyebrow text rendered above the first item of each
+ // section. `build` is left unlabelled — it's the default and adding
+ // an eyebrow above Dashboard would feel redundant.
+ // Localized via $t — $derived so a language switch re-renders the
+ // eyebrows. `build` stays unlabelled (see above).
+ const sectionLabels: Record = $derived({
+ build: '',
+ observe: $t('nav.sectionObserve'),
+ system: $t('nav.sectionSystem')
+ });
+
+ function sectionStart(idx: number): NavSection | null {
+ const cur = navItems[idx].section;
+ if (idx === 0) return cur;
+ const prev = navItems[idx - 1].section;
+ return cur !== prev ? cur : null;
+ }
+
function isActive(href: string, pathname: string): boolean {
if (href === '/') return pathname === '/';
return pathname.startsWith(href);
@@ -194,7 +218,7 @@
@@ -269,8 +293,12 @@