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

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

Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
This commit is contained in:
2026-05-29 02:09:54 +03:00
parent 956943edbb
commit 410a131cec
112 changed files with 13285 additions and 2765 deletions
+143 -15
View File
@@ -15,7 +15,7 @@
"nav": {
"dashboard": "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 (165535).",
"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 <code>api/</code> 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 <code>.md</code> 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 <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.",
"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 <code>branch_pattern</code>, and torn down when the branch is deleted. Each gets its own slug-prefixed subdomain."
},
"volumesTitle": "Volumes",
"volumesEmpty": "No mounts",
"volumesCountSingular": "{count} mount",