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:
Generated
+549
-1
@@ -22,7 +22,8 @@
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
@@ -1201,12 +1202,28 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1232,6 +1249,112 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz",
|
||||
"integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz",
|
||||
"integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.7",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz",
|
||||
"integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz",
|
||||
"integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.7",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz",
|
||||
"integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz",
|
||||
"integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz",
|
||||
"integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -1253,6 +1376,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
@@ -1262,6 +1394,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
@@ -1286,6 +1427,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
@@ -1358,6 +1505,12 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
||||
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@@ -1415,6 +1568,24 @@
|
||||
"@typescript-eslint/types": "^8.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -1785,6 +1956,22 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
]
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1906,6 +2093,12 @@
|
||||
"integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/sirv": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||
@@ -1929,6 +2122,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "5.55.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz",
|
||||
@@ -1998,6 +2203,21 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz",
|
||||
"integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -2014,6 +2234,15 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/totalist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
@@ -2129,6 +2358,111 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz",
|
||||
"integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.7",
|
||||
"@vitest/mocker": "4.1.7",
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"@vitest/runner": "4.1.7",
|
||||
"@vitest/snapshot": "4.1.7",
|
||||
"@vitest/spy": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.7",
|
||||
"@vitest/browser-preview": "4.1.7",
|
||||
"@vitest/browser-webdriverio": "4.1.7",
|
||||
"@vitest/coverage-istanbul": "4.1.7",
|
||||
"@vitest/coverage-v8": "4.1.7",
|
||||
"@vitest/ui": "4.1.7",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/zimmerframe": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
@@ -2766,12 +3100,28 @@
|
||||
"tailwindcss": "4.2.2"
|
||||
}
|
||||
},
|
||||
"@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -2790,6 +3140,79 @@
|
||||
"integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==",
|
||||
"dev": true
|
||||
},
|
||||
"@vitest/expect": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz",
|
||||
"integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"@vitest/mocker": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz",
|
||||
"integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@vitest/spy": "4.1.7",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
}
|
||||
},
|
||||
"@vitest/pretty-format": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz",
|
||||
"integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"@vitest/runner": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz",
|
||||
"integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@vitest/utils": "4.1.7",
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"@vitest/snapshot": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz",
|
||||
"integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"@vitest/spy": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz",
|
||||
"integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==",
|
||||
"dev": true
|
||||
},
|
||||
"@vitest/utils": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz",
|
||||
"integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -2802,12 +3225,24 @@
|
||||
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
|
||||
"dev": true
|
||||
},
|
||||
"assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true
|
||||
},
|
||||
"axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||
"dev": true
|
||||
},
|
||||
"chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true
|
||||
},
|
||||
"chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
@@ -2823,6 +3258,12 @@
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"dev": true
|
||||
},
|
||||
"convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true
|
||||
},
|
||||
"cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
@@ -2875,6 +3316,12 @@
|
||||
"tapable": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"es-module-lexer": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
||||
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
|
||||
"dev": true
|
||||
},
|
||||
"esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@@ -2925,6 +3372,21 @@
|
||||
"@typescript-eslint/types": "^8.2.0"
|
||||
}
|
||||
},
|
||||
"estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||
"dev": true
|
||||
},
|
||||
"fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -3102,6 +3564,18 @@
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true
|
||||
},
|
||||
"obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
||||
"dev": true
|
||||
},
|
||||
"pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true
|
||||
},
|
||||
"picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -3181,6 +3655,12 @@
|
||||
"integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
|
||||
"dev": true
|
||||
},
|
||||
"siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true
|
||||
},
|
||||
"sirv": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||
@@ -3198,6 +3678,18 @@
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true
|
||||
},
|
||||
"stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true
|
||||
},
|
||||
"std-env": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||
"dev": true
|
||||
},
|
||||
"svelte": {
|
||||
"version": "5.55.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz",
|
||||
@@ -3247,6 +3739,18 @@
|
||||
"integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
|
||||
"dev": true
|
||||
},
|
||||
"tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true
|
||||
},
|
||||
"tinyexec": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz",
|
||||
"integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==",
|
||||
"dev": true
|
||||
},
|
||||
"tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -3257,6 +3761,12 @@
|
||||
"picomatch": "^4.0.3"
|
||||
}
|
||||
},
|
||||
"tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||
"dev": true
|
||||
},
|
||||
"totalist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
@@ -3296,6 +3806,44 @@
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"vitest": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz",
|
||||
"integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@vitest/expect": "4.1.7",
|
||||
"@vitest/mocker": "4.1.7",
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"@vitest/runner": "4.1.7",
|
||||
"@vitest/snapshot": "4.1.7",
|
||||
"@vitest/spy": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
}
|
||||
},
|
||||
"zimmerframe": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
|
||||
+4
-2
@@ -6,7 +6,8 @@
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
@@ -17,7 +18,8 @@
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^4.1.7"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
+193
-26
@@ -168,13 +168,14 @@ function patch<T>(path: string, body: unknown): Promise<T> {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Deploys (inspect only; quick-deploy retired with /deploy page) ────
|
||||
// `inspectImage` survives because the new-app wizard can use it to pre-fill
|
||||
// image port/healthcheck. `quickDeploy` (POST /api/deploy/quick) is gone:
|
||||
// it created a legacy Project + Stage in the now-dead path.
|
||||
// ── Image inspect (new-app wizard pre-fill) ────────────────────────────
|
||||
// `inspectImage` lets the new-app wizard pre-fill image port/healthcheck
|
||||
// from a LOCAL image's metadata. It posts to the admin-gated discovery
|
||||
// endpoint (the legacy POST /api/deploy/inspect route was dropped in the
|
||||
// cutover). Local-only: it does not pull.
|
||||
|
||||
export function inspectImage(image: string, signal?: AbortSignal): Promise<InspectResult> {
|
||||
return post<InspectResult>('/api/deploy/inspect', { image }, signal);
|
||||
return post<InspectResult>('/api/discovery/image/inspect', { image }, signal);
|
||||
}
|
||||
|
||||
// ── Discovery (/apps/new wizard helpers) ───────────────────────────
|
||||
@@ -441,12 +442,76 @@ export function testNpmConnection(data: { npm_url?: string; npm_email?: string;
|
||||
return post<{ status: string }>('/api/settings/npm/test', data);
|
||||
}
|
||||
|
||||
export function listNpmCertificates(): Promise<NpmCertificate[]> {
|
||||
return get<NpmCertificate[]>('/api/settings/npm-certificates');
|
||||
// ── NPM friendly-name cache ─────────────────────────────────────────
|
||||
// The settings/NPM page first renders "Certificate #<id>" / "Access List
|
||||
// #<id>" then swaps to the friendly name once the list resolves — a visible
|
||||
// flicker on every tab re-entry and after a reload. Back the two list calls
|
||||
// with a short-lived sessionStorage cache so names resolve instantly within
|
||||
// a session (survives reloads, scoped to the tab). Pass `force` to bypass it
|
||||
// — the picker's "browse" action wants fresh data.
|
||||
const NPM_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
interface NpmCacheEntry<T> {
|
||||
ts: number;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export function listNpmAccessLists(): Promise<NpmAccessList[]> {
|
||||
return get<NpmAccessList[]>('/api/settings/npm-access-lists');
|
||||
function readNpmCache<T>(key: string): T | null {
|
||||
if (typeof sessionStorage === 'undefined') return null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const entry = JSON.parse(raw) as NpmCacheEntry<T>;
|
||||
if (!entry || typeof entry.ts !== 'number') return null;
|
||||
if (Date.now() - entry.ts > NPM_CACHE_TTL_MS) return null;
|
||||
return entry.data;
|
||||
} catch {
|
||||
// Corrupt/unparseable cache — treat as a miss.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeNpmCache<T>(key: string, data: T): void {
|
||||
if (typeof sessionStorage === 'undefined') return;
|
||||
try {
|
||||
const entry: NpmCacheEntry<T> = { ts: Date.now(), data };
|
||||
sessionStorage.setItem(key, JSON.stringify(entry));
|
||||
} catch {
|
||||
// Quota/serialization failure is non-fatal — the call still returned
|
||||
// fresh data; we just don't get the cache speedup next time.
|
||||
}
|
||||
}
|
||||
|
||||
const NPM_CERTS_CACHE_KEY = 'dw_npm_certs';
|
||||
const NPM_ACCESS_LISTS_CACHE_KEY = 'dw_npm_access_lists';
|
||||
|
||||
/**
|
||||
* List NPM SSL certificates. Cached in sessionStorage for ~5 minutes so the
|
||||
* settings page resolves friendly names without a flicker on re-entry/reload.
|
||||
* Pass `force` to skip the cache and refresh (e.g. opening the picker).
|
||||
*/
|
||||
export async function listNpmCertificates(force = false): Promise<NpmCertificate[]> {
|
||||
if (!force) {
|
||||
const cached = readNpmCache<NpmCertificate[]>(NPM_CERTS_CACHE_KEY);
|
||||
if (cached) return cached;
|
||||
}
|
||||
const data = await get<NpmCertificate[]>('/api/settings/npm-certificates');
|
||||
writeNpmCache(NPM_CERTS_CACHE_KEY, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* List NPM access lists. Cached in sessionStorage for ~5 minutes (see
|
||||
* {@link listNpmCertificates}). Pass `force` to skip the cache and refresh.
|
||||
*/
|
||||
export async function listNpmAccessLists(force = false): Promise<NpmAccessList[]> {
|
||||
if (!force) {
|
||||
const cached = readNpmCache<NpmAccessList[]>(NPM_ACCESS_LISTS_CACHE_KEY);
|
||||
if (cached) return cached;
|
||||
}
|
||||
const data = await get<NpmAccessList[]>('/api/settings/npm-access-lists');
|
||||
writeNpmCache(NPM_ACCESS_LISTS_CACHE_KEY, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Volume scopes (metadata only) ───────────────────────────────────
|
||||
@@ -495,7 +560,14 @@ export function deleteBackup(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
export function restoreBackup(id: string): Promise<{ status: string; message: string }> {
|
||||
return post<{ status: string; message: string }>(`/api/backups/${id}/restore`);
|
||||
// X-Confirm-Restore echoes the backup id. The backend rejects any
|
||||
// POST whose header doesn't match the path param — this defeats
|
||||
// blind CSRF (a cross-origin form/image-tag POST can't set custom
|
||||
// headers without a preflight). Sent alongside the bearer JWT.
|
||||
return request<{ status: string; message: string }>(`/api/backups/${id}/restore`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Confirm-Restore': id }
|
||||
});
|
||||
}
|
||||
|
||||
export function backupDownloadUrl(id: string): string {
|
||||
@@ -518,49 +590,81 @@ export function getCurrentUser(): Promise<{ id: string; username: string; email:
|
||||
return get<{ id: string; username: string; email: string; role: string }>('/api/auth/me');
|
||||
}
|
||||
|
||||
// Auth settings
|
||||
export async function getAuthSettings(): Promise<any> {
|
||||
return request<any>('/api/auth/settings');
|
||||
// ── Auth settings & user management ─────────────────────────────────
|
||||
// Previously typed as `any`, which silently disabled type checking on
|
||||
// the entire user/auth surface — including the password-change call.
|
||||
// All routes are admin-gated server-side.
|
||||
|
||||
export interface AuthSettings {
|
||||
auth_mode: 'local' | 'oidc';
|
||||
oidc_client_id: string;
|
||||
oidc_client_secret: string;
|
||||
oidc_issuer_url: string;
|
||||
oidc_redirect_url: string;
|
||||
}
|
||||
|
||||
export async function updateAuthSettings(settings: any): Promise<any> {
|
||||
return request<any>('/api/auth/settings', {
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: 'admin' | 'viewer' | string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateUserInput {
|
||||
username: string;
|
||||
password: string;
|
||||
email?: string;
|
||||
role?: 'admin' | 'viewer' | string;
|
||||
}
|
||||
|
||||
export interface UpdateUserInput {
|
||||
email?: string;
|
||||
role?: 'admin' | 'viewer' | string;
|
||||
}
|
||||
|
||||
export async function getAuthSettings(): Promise<AuthSettings> {
|
||||
return request<AuthSettings>('/api/auth/settings');
|
||||
}
|
||||
|
||||
export async function updateAuthSettings(settings: AuthSettings): Promise<AuthSettings> {
|
||||
return request<AuthSettings>('/api/auth/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
}
|
||||
|
||||
export async function listUsers(): Promise<any[]> {
|
||||
return request<any[]>('/api/auth/users');
|
||||
export async function listUsers(): Promise<AuthUser[]> {
|
||||
return request<AuthUser[]>('/api/auth/users');
|
||||
}
|
||||
|
||||
export async function createUser(data: { username: string; password: string; email?: string; role?: string }): Promise<any> {
|
||||
return request<any>('/api/auth/users', {
|
||||
export async function createUser(data: CreateUserInput): Promise<AuthUser> {
|
||||
return request<AuthUser>('/api/auth/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateUser(uid: string, data: { email?: string; role?: string }): Promise<any> {
|
||||
return request<any>(`/api/auth/users/${uid}`, {
|
||||
export async function updateUser(uid: string, data: UpdateUserInput): Promise<AuthUser> {
|
||||
return request<AuthUser>(`/api/auth/users/${uid}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function changeUserPassword(uid: string, password: string): Promise<any> {
|
||||
return request<any>(`/api/auth/users/${uid}/password`, {
|
||||
export async function changeUserPassword(uid: string, password: string): Promise<void> {
|
||||
await request<void>(`/api/auth/users/${uid}/password`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteUser(uid: string): Promise<any> {
|
||||
return request<any>(`/api/auth/users/${uid}`, { method: 'DELETE' });
|
||||
export async function deleteUser(uid: string): Promise<void> {
|
||||
await request<void>(`/api/auth/users/${uid}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await request<any>('/api/auth/logout', { method: 'POST' });
|
||||
await request<void>('/api/auth/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
// ── Config Export ────────────────────────────────────────────────────
|
||||
@@ -804,6 +908,62 @@ export function deleteWorkloadEnv(id: string, envID: string): Promise<{ deleted:
|
||||
// workload to inbound deploys, create or bind a Trigger via the
|
||||
// /triggers UI (which mints a /api/webhook/triggers/{secret} URL).
|
||||
|
||||
// Per-workload outbound notification routes (Slack/Discord/etc).
|
||||
// Multi-destination fan-out — the dispatcher sends to every enabled
|
||||
// row whose event_types allow-list matches the event. An empty
|
||||
// event_types means "match every event". Secret round-trips as
|
||||
// write-only: the API returns secret_set, never the ciphertext.
|
||||
export interface WorkloadNotification {
|
||||
id: string;
|
||||
workload_id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
secret_set: boolean;
|
||||
event_types: string;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WorkloadNotificationInput {
|
||||
name: string;
|
||||
url: string;
|
||||
secret?: string;
|
||||
event_types?: string;
|
||||
enabled?: boolean;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export function listWorkloadNotifications(
|
||||
id: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkloadNotification[]> {
|
||||
return get<WorkloadNotification[]>(`/api/workloads/${id}/notifications`, signal);
|
||||
}
|
||||
|
||||
export function createWorkloadNotification(
|
||||
id: string,
|
||||
body: WorkloadNotificationInput
|
||||
): Promise<WorkloadNotification> {
|
||||
return post<WorkloadNotification>(`/api/workloads/${id}/notifications`, body);
|
||||
}
|
||||
|
||||
export function updateWorkloadNotification(
|
||||
id: string,
|
||||
nid: string,
|
||||
body: WorkloadNotificationInput
|
||||
): Promise<WorkloadNotification> {
|
||||
return put<WorkloadNotification>(`/api/workloads/${id}/notifications/${nid}`, body);
|
||||
}
|
||||
|
||||
export function deleteWorkloadNotification(
|
||||
id: string,
|
||||
nid: string
|
||||
): Promise<{ success: boolean }> {
|
||||
return del<{ success: boolean }>(`/api/workloads/${id}/notifications/${nid}`);
|
||||
}
|
||||
|
||||
export function fetchWorkloadContainerLogs(
|
||||
workloadId: string,
|
||||
containerRowId: string,
|
||||
@@ -993,6 +1153,13 @@ export interface WorkloadChainNode {
|
||||
name: string;
|
||||
source_kind: string;
|
||||
trigger_kind: string;
|
||||
/** True when this child was materialized as a branch preview of the chain's
|
||||
* `self` workload (vs. an operator-created stage child). Always false for
|
||||
* the parent and self nodes. */
|
||||
is_preview?: boolean;
|
||||
/** The git branch a preview child was deployed for. Empty for non-preview
|
||||
* nodes (omitted by the server). */
|
||||
preview_branch?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<!--
|
||||
Confirm dialog with fade/scale-in animation.
|
||||
Confirm dialog with fade/scale-in animation. Adds Escape-to-cancel,
|
||||
autofocus on the confirm button, and aria-modal so assistive tech
|
||||
treats the rest of the document as inert while the dialog is open.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { IconAlert } from '$lib/components/icons';
|
||||
@@ -36,14 +38,41 @@
|
||||
? 'text-[var(--color-danger)]'
|
||||
: 'text-[var(--color-brand-600)]'
|
||||
);
|
||||
|
||||
let confirmButton: HTMLButtonElement | null = $state(null);
|
||||
|
||||
// Focus the confirm button when the dialog opens so keyboard
|
||||
// users can hit Enter immediately. Scoped to the open transition;
|
||||
// repeated opens re-focus.
|
||||
$effect(() => {
|
||||
if (open && confirmButton) {
|
||||
// queueMicrotask so the DOM is mounted before focus().
|
||||
queueMicrotask(() => confirmButton?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (!open) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
oncancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKey} />
|
||||
|
||||
{#if open}
|
||||
<!-- Backdrop -->
|
||||
<div class="fixed inset-0 z-40 bg-[var(--surface-overlay)] animate-fade-in" role="presentation" onclick={oncancel}></div>
|
||||
|
||||
<!-- Dialog -->
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
>
|
||||
<div class="dlg w-full max-w-md animate-scale-in">
|
||||
<span class="dlg-reg dlg-reg-tl"></span>
|
||||
<span class="dlg-reg dlg-reg-tr"></span>
|
||||
@@ -55,7 +84,7 @@
|
||||
<IconAlert size={20} class={iconColorClass} />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="dlg-title">{title}</h3>
|
||||
<h3 id="confirm-dialog-title" class="dlg-title">{title}</h3>
|
||||
<p class="dlg-msg">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,6 +97,7 @@
|
||||
type="button"
|
||||
class="dlg-confirm {confirmVariant}"
|
||||
onclick={onconfirm}
|
||||
bind:this={confirmButton}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
actionLabel?: string;
|
||||
actionHref?: string;
|
||||
onaction?: () => void;
|
||||
/**
|
||||
* The icon prop was historically accepted but never rendered —
|
||||
* the markup uses the breathing-dot "empty-mark" element instead.
|
||||
* Kept for source compatibility (call sites still pass it) and
|
||||
* to avoid a noisy svelte-check sweep, but the value is ignored.
|
||||
*/
|
||||
icon?: 'projects' | 'instances' | 'deploys' | 'registries' | 'volumes' | 'users';
|
||||
}
|
||||
|
||||
|
||||
@@ -91,12 +91,16 @@
|
||||
switch (event.key) {
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
// Guard against an empty filtered list — `% 0` is NaN, which
|
||||
// would poison highlightIndex for subsequent keystrokes.
|
||||
if (flatFiltered.length === 0) return;
|
||||
highlightIndex = (highlightIndex + 1) % flatFiltered.length;
|
||||
scrollHighlightedIntoView();
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault();
|
||||
if (flatFiltered.length === 0) return;
|
||||
highlightIndex = (highlightIndex - 1 + flatFiltered.length) % flatFiltered.length;
|
||||
scrollHighlightedIntoView();
|
||||
break;
|
||||
@@ -156,7 +160,7 @@
|
||||
type="button"
|
||||
class="entity-picker-close"
|
||||
onclick={onclose}
|
||||
aria-label="Close"
|
||||
aria-label={$t('common.close')}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</button>
|
||||
@@ -180,7 +184,7 @@
|
||||
type="button"
|
||||
class="entity-picker-close-inline"
|
||||
onclick={onclose}
|
||||
aria-label="Close"
|
||||
aria-label={$t('common.close')}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
@@ -213,8 +217,22 @@
|
||||
onmouseenter={() => { highlightIndex = flatIdx; }}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{#if item.icon}
|
||||
<span class="entity-picker-item-icon">{@html item.icon}</span>
|
||||
{#if item.icon === 'lock'}
|
||||
<span class="entity-picker-item-icon" aria-hidden="true">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
</span>
|
||||
{:else if item.icon === 'box'}
|
||||
<span class="entity-picker-item-icon" aria-hidden="true">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>
|
||||
</span>
|
||||
{:else if item.icon === 'folder'}
|
||||
<span class="entity-picker-item-icon" aria-hidden="true">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>
|
||||
</span>
|
||||
{:else if item.icon === 'branch'}
|
||||
<span class="entity-picker-item-icon" aria-hidden="true">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
|
||||
</span>
|
||||
{/if}
|
||||
<span class="entity-picker-item-content">
|
||||
<span class="entity-picker-item-label">{item.label}</span>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<!--
|
||||
RegistryImagePicker — optional "browse images" affordance for the
|
||||
registry trigger's image field.
|
||||
|
||||
The registry trigger config carries only a fully-qualified image `ref`
|
||||
(no registry id). On open, this loads every configured registry's
|
||||
images and lists them in a single command palette, GROUPED by registry,
|
||||
so the operator picks a fully-qualified ref without typing it. Selecting
|
||||
a row calls `onpick(full_ref, registry_name)`; the parent writes the ref
|
||||
into its config and may use the registry name to auto-select the source
|
||||
registry. Manual text entry in the parent input is always preserved — the
|
||||
picker is purely additive convenience.
|
||||
|
||||
Lives in its own component (rather than inline in TriggerKindForm)
|
||||
because that form binds a prop named `state`, which shadows Svelte 5's
|
||||
`$state` rune. Isolating the reactive picker state here sidesteps the
|
||||
collision, and keeps the only in-flow element a single button so it
|
||||
drops cleanly into the field's input row.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||
import { IconBox } from '$lib/components/icons';
|
||||
import * as api from '$lib/api';
|
||||
import type { EntityPickerItem } from '$lib/types';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
/** Current image ref — highlights the matching row in the picker. */
|
||||
current?: string;
|
||||
/**
|
||||
* Called with the chosen image's full_ref and the name of the registry
|
||||
* it came from. Callers that only care about the ref (e.g. the registry
|
||||
* trigger form) can ignore the second argument.
|
||||
*/
|
||||
onpick: (fullRef: string, registryName: string) => void;
|
||||
}
|
||||
|
||||
let { current = '', onpick }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let loading = $state(false);
|
||||
let loaded = $state(false);
|
||||
let items = $state<EntityPickerItem[]>([]);
|
||||
|
||||
// Lazily load every registry's images the first time the operator
|
||||
// opens the picker, flattened into one grouped list. Per-registry
|
||||
// failures are tolerated (other registries still populate). A total
|
||||
// failure leaves the list empty — the picker then shows its built-in
|
||||
// "no results" state — and never blocks manual entry in the field.
|
||||
async function ensureLoaded(): Promise<void> {
|
||||
if (loaded || loading) return;
|
||||
loading = true;
|
||||
try {
|
||||
const registries = await api.listRegistries();
|
||||
const collected: EntityPickerItem[] = [];
|
||||
await Promise.all(
|
||||
registries.map(async (reg) => {
|
||||
try {
|
||||
const images = await api.listRegistryImages(reg.id);
|
||||
for (const img of images) {
|
||||
collected.push({
|
||||
value: img.full_ref,
|
||||
label: img.full_ref,
|
||||
description: img.owner ? `${img.owner}/${img.name}` : img.name,
|
||||
group: reg.name,
|
||||
icon: 'box'
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip a registry we can't reach; others still load.
|
||||
}
|
||||
})
|
||||
);
|
||||
items = collected;
|
||||
loaded = true;
|
||||
} catch {
|
||||
// Total failure (e.g. /api/registries unreachable): leave items
|
||||
// empty so the picker shows its empty state. Do NOT mark loaded
|
||||
// so a later open retries.
|
||||
items = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openPicker(): Promise<void> {
|
||||
await ensureLoaded();
|
||||
open = true;
|
||||
}
|
||||
|
||||
function handleSelect(value: string): void {
|
||||
// Each picker row carries its source registry name in `group` (set when
|
||||
// the list was built). Surface it so callers can auto-select the
|
||||
// registry the image came from. Falls back to '' (public) if not found.
|
||||
const picked = items.find((i) => i.value === value);
|
||||
onpick(value, picked?.group ?? '');
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="browse-btn"
|
||||
onclick={openPicker}
|
||||
disabled={loading}
|
||||
title={$t('redeployTriggers.form.browseImagesHint')}
|
||||
>
|
||||
<IconBox size={13} />
|
||||
<span>{$t('redeployTriggers.form.browseImages')}</span>
|
||||
</button>
|
||||
|
||||
<EntityPicker
|
||||
bind:open
|
||||
{items}
|
||||
{current}
|
||||
title={$t('redeployTriggers.form.browseImagesTitle')}
|
||||
placeholder={$t('redeployTriggers.form.browseImagesSearch')}
|
||||
onselect={handleSelect}
|
||||
onclose={() => (open = false)}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.browse-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0 0.7rem;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.58rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.browse-btn:hover:not(:disabled) {
|
||||
border-color: var(--forge-accent);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-card-hover);
|
||||
}
|
||||
.browse-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.browse-btn:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -210,7 +210,7 @@
|
||||
</div>
|
||||
{:else if !proxyConnected}
|
||||
<div class="panel-error">
|
||||
<code>{proxy.error ?? `${proxyProvider.toUpperCase()} is not reachable.`}</code>
|
||||
<code>{proxy.error ?? $t('daemons.notReachable', { provider: proxyProvider.toUpperCase() })}</code>
|
||||
<p>{$t('daemons.proxyHint')}</p>
|
||||
{#if proxy.url}
|
||||
<p class="url"><span class="kdim">URL</span> <code>{proxy.url}</code></p>
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
|
||||
{#if !loading}
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
|
||||
<h3 class="mb-4 text-sm font-semibold text-[var(--text-primary)]">{$t('systemHealth.title')}</h3>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<!-- Containers -->
|
||||
<a href="/containers" class="flex items-center gap-3 rounded-lg p-3 transition-colors hover:bg-[var(--surface-card-hover)]">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<script lang="ts">
|
||||
import { toasts, type ToastType } from '$lib/stores/toast';
|
||||
import { IconCheck, IconX, IconAlert, IconInfo } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
const bgMap: Record<ToastType, string> = {
|
||||
success: 'bg-[var(--color-success)]',
|
||||
@@ -34,7 +35,7 @@
|
||||
<button
|
||||
class="ml-2 rounded-md p-0.5 text-white/70 hover:text-white transition-colors"
|
||||
onclick={() => toasts.remove(toast.id)}
|
||||
aria-label="Dismiss notification"
|
||||
aria-label={$t('common.dismissNotification')}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<!--
|
||||
Task 6: Toggle switch to replace checkboxes.
|
||||
Toggle switch replacing raw checkboxes. Accepts an `ariaLabel` /
|
||||
`ariaLabelledby` so the switch announces its purpose to assistive
|
||||
tech regardless of surrounding markup. `label` remains a thin alias
|
||||
that defaults aria-label without forcing a visible <label>.
|
||||
-->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
checked?: boolean;
|
||||
/** Accessible name. Falls back to ariaLabel for clarity. */
|
||||
label?: string;
|
||||
/** Explicit aria-label override. Takes precedence over label. */
|
||||
ariaLabel?: string;
|
||||
/** id of a visible label element. Takes precedence over ariaLabel. */
|
||||
ariaLabelledby?: string;
|
||||
disabled?: boolean;
|
||||
onchange?: (checked: boolean) => void;
|
||||
}
|
||||
@@ -12,6 +20,8 @@
|
||||
let {
|
||||
checked = $bindable(false),
|
||||
label = '',
|
||||
ariaLabel,
|
||||
ariaLabelledby,
|
||||
disabled = false,
|
||||
onchange
|
||||
}: Props = $props();
|
||||
@@ -21,15 +31,34 @@
|
||||
checked = !checked;
|
||||
onchange?.(checked);
|
||||
}
|
||||
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (disabled) return;
|
||||
// Native <button> already handles Space/Enter, but role="switch"
|
||||
// best-practice docs (WAI-ARIA APG) call out Space explicitly —
|
||||
// pin the behaviour so we don't depend on browser defaults.
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
}
|
||||
|
||||
const computedAriaLabel = $derived(ariaLabel ?? (label || undefined));
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={computedAriaLabel}
|
||||
aria-labelledby={ariaLabelledby}
|
||||
aria-disabled={disabled ? 'true' : undefined}
|
||||
class="toggle-switch {disabled ? 'opacity-50 cursor-not-allowed' : ''}"
|
||||
onclick={toggle}
|
||||
onkeydown={handleKey}
|
||||
{disabled}
|
||||
>
|
||||
<span class="sr-only">{label}</span>
|
||||
{#if label}
|
||||
<span class="sr-only">{label}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
gitRepo: string;
|
||||
gitMode: 'push' | 'tag';
|
||||
gitBranch: string;
|
||||
gitBranchPattern: string;
|
||||
gitTagPattern: string;
|
||||
// schedule
|
||||
schInterval: string;
|
||||
@@ -70,6 +71,7 @@
|
||||
gitRepo: init.gitRepo ?? '',
|
||||
gitMode: init.gitMode ?? 'push',
|
||||
gitBranch: init.gitBranch ?? 'main',
|
||||
gitBranchPattern: init.gitBranchPattern ?? '',
|
||||
gitTagPattern: init.gitTagPattern ?? 'v*',
|
||||
schInterval: init.schInterval ?? '24h',
|
||||
schReference: init.schReference ?? '',
|
||||
@@ -191,6 +193,7 @@
|
||||
// understands the silent rewrite.
|
||||
s.gitMode = cfg.mode === 'tag' ? 'tag' : 'push';
|
||||
s.gitBranch = typeof cfg.branch === 'string' ? cfg.branch : 'main';
|
||||
s.gitBranchPattern = typeof cfg.branch_pattern === 'string' ? cfg.branch_pattern : '';
|
||||
s.gitTagPattern = typeof cfg.tag_pattern === 'string' ? cfg.tag_pattern : 'v*';
|
||||
break;
|
||||
case 'manual':
|
||||
@@ -213,14 +216,22 @@
|
||||
tag_pattern: s.regTagPattern.trim() || '*'
|
||||
};
|
||||
} else if (s.kind === 'git') {
|
||||
config =
|
||||
s.gitMode === 'push'
|
||||
? { repo: s.gitRepo.trim(), mode: 'push', branch: s.gitBranch.trim() || 'main' }
|
||||
: {
|
||||
repo: s.gitRepo.trim(),
|
||||
mode: 'tag',
|
||||
tag_pattern: s.gitTagPattern.trim() || '*'
|
||||
};
|
||||
if (s.gitMode === 'push') {
|
||||
const branchPattern = s.gitBranchPattern.trim();
|
||||
const pushCfg: Record<string, unknown> = {
|
||||
repo: s.gitRepo.trim(),
|
||||
mode: 'push',
|
||||
branch: s.gitBranch.trim() || 'main'
|
||||
};
|
||||
if (branchPattern) pushCfg.branch_pattern = branchPattern;
|
||||
config = pushCfg;
|
||||
} else {
|
||||
config = {
|
||||
repo: s.gitRepo.trim(),
|
||||
mode: 'tag',
|
||||
tag_pattern: s.gitTagPattern.trim() || '*'
|
||||
};
|
||||
}
|
||||
} else if (s.kind === 'manual') {
|
||||
config = {};
|
||||
} else if (s.kind === 'schedule') {
|
||||
@@ -243,6 +254,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import RegistryImagePicker from '$lib/components/RegistryImagePicker.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -296,6 +308,14 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Registry-image picker writes the chosen full_ref back into the
|
||||
// registry trigger's image field. The picker's own reactive state
|
||||
// lives in RegistryImagePicker (a prop named `state` here shadows the
|
||||
// $state rune, so the picker is isolated in its own component).
|
||||
function onRegistryImagePicked(fullRef: string): void {
|
||||
state.regImage = fullRef;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tk-form">
|
||||
@@ -392,16 +412,19 @@
|
||||
{:else if state.kind === 'registry'}
|
||||
<label class="sub" for="{idPrefix}-image">
|
||||
<span class="sub-label">{$t('redeployTriggers.form.image')}</span>
|
||||
<input
|
||||
id="{idPrefix}-image"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={state.regImage}
|
||||
placeholder={$t('redeployTriggers.form.imagePlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<div class="input-with-button">
|
||||
<input
|
||||
id="{idPrefix}-image"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={state.regImage}
|
||||
placeholder={$t('redeployTriggers.form.imagePlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<RegistryImagePicker current={state.regImage} onpick={onRegistryImagePicked} />
|
||||
</div>
|
||||
<span class="hint">{$t('redeployTriggers.form.imageHint')}</span>
|
||||
</label>
|
||||
<label class="sub" for="{idPrefix}-tag">
|
||||
@@ -475,6 +498,19 @@
|
||||
/>
|
||||
<span class="hint">{$t('redeployTriggers.form.branchHint')}</span>
|
||||
</label>
|
||||
<label class="sub" for="{idPrefix}-branch-pattern">
|
||||
<span class="sub-label">{$t('redeployTriggers.form.branchPattern')}</span>
|
||||
<input
|
||||
id="{idPrefix}-branch-pattern"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={state.gitBranchPattern}
|
||||
placeholder={$t('redeployTriggers.form.branchPatternPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span class="hint">{$t('redeployTriggers.form.branchPatternHint')}</span>
|
||||
</label>
|
||||
{:else}
|
||||
<label class="sub" for="{idPrefix}-gtag">
|
||||
<span class="sub-label">{$t('redeployTriggers.form.tagPattern')}</span>
|
||||
@@ -711,6 +747,20 @@
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* ── Registry image picker affordance ─────────────
|
||||
The image field becomes an input + "browse" button row (the button
|
||||
is rendered by RegistryImagePicker). Manual text entry stays fully
|
||||
functional — the picker is purely additive. */
|
||||
.input-with-button {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.input-with-button > .input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kind-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* WorkloadNotificationsPanel
|
||||
*
|
||||
* Per-workload outbound notification routes. Multi-destination
|
||||
* fan-out: each row is one Slack channel / Discord webhook /
|
||||
* generic receiver, optionally filtered to a comma-separated
|
||||
* allow-list of event types. When zero rows exist the dispatcher
|
||||
* falls back to the legacy single notification URL on the workload.
|
||||
*
|
||||
* Secret is write-only: the API returns secret_set so the UI shows
|
||||
* "secret configured" / "no secret" without ever round-tripping the
|
||||
* ciphertext. To rotate, submit a new plaintext value.
|
||||
*/
|
||||
import * as api from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import ToggleSwitch from './ToggleSwitch.svelte';
|
||||
import ConfirmDialog from './ConfirmDialog.svelte';
|
||||
import { IconPlus, IconTrash, IconEdit } from './icons';
|
||||
|
||||
interface Props {
|
||||
workloadId: string;
|
||||
}
|
||||
let { workloadId }: Props = $props();
|
||||
|
||||
let rows = $state<api.WorkloadNotification[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let saving = $state(false);
|
||||
let confirmDeleteId = $state<string | null>(null);
|
||||
|
||||
// Edit form. editingId === '' means we're creating, otherwise it's
|
||||
// the row being edited. Form fields are kept flat (string) so the
|
||||
// API payload assembly stays trivial.
|
||||
let editingId = $state<string | null>(null);
|
||||
let formName = $state('');
|
||||
let formURL = $state('');
|
||||
let formSecret = $state('');
|
||||
let formEventTypes = $state('');
|
||||
let formEnabled = $state(true);
|
||||
|
||||
const formValid = $derived(formURL.trim().length > 0);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
rows = await api.listWorkloadNotifications(workloadId);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reload whenever the workloadId prop changes — the parent (/apps/[id])
|
||||
// reuses this component instance across /apps/A → /apps/B navigation, so
|
||||
// onMount(load) alone would keep showing the previous workload's rows.
|
||||
$effect(() => {
|
||||
const _ = workloadId; // explicit dependency
|
||||
load();
|
||||
});
|
||||
|
||||
function startAdd(): void {
|
||||
editingId = '';
|
||||
formName = '';
|
||||
formURL = '';
|
||||
formSecret = '';
|
||||
formEventTypes = '';
|
||||
formEnabled = true;
|
||||
}
|
||||
|
||||
function startEdit(row: api.WorkloadNotification): void {
|
||||
editingId = row.id;
|
||||
formName = row.name;
|
||||
formURL = row.url;
|
||||
formSecret = ''; // write-only; empty means "leave unchanged"
|
||||
formEventTypes = row.event_types;
|
||||
formEnabled = row.enabled;
|
||||
}
|
||||
|
||||
function cancelEdit(): void {
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!formValid || saving) return;
|
||||
saving = true;
|
||||
try {
|
||||
const body: api.WorkloadNotificationInput = {
|
||||
name: formName.trim(),
|
||||
url: formURL.trim(),
|
||||
event_types: formEventTypes.trim(),
|
||||
enabled: formEnabled
|
||||
};
|
||||
if (formSecret.trim()) body.secret = formSecret.trim();
|
||||
if (editingId === '') {
|
||||
await api.createWorkloadNotification(workloadId, body);
|
||||
} else if (editingId) {
|
||||
await api.updateWorkloadNotification(workloadId, editingId, body);
|
||||
}
|
||||
editingId = null;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete(id: string): Promise<void> {
|
||||
saving = true;
|
||||
try {
|
||||
await api.deleteWorkloadNotification(workloadId, id);
|
||||
confirmDeleteId = null;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel notif-panel" aria-labelledby="notif-heading">
|
||||
<span class="reg reg-tl" aria-hidden="true"></span>
|
||||
<span class="reg reg-tr" aria-hidden="true"></span>
|
||||
<span class="reg reg-bl" aria-hidden="true"></span>
|
||||
<span class="reg reg-br" aria-hidden="true"></span>
|
||||
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title" id="notif-heading">
|
||||
{$t('apps.detail.notifications.title')}<span class="title-accent">.</span>
|
||||
</h2>
|
||||
<span class="panel-sub">{$t('apps.detail.notifications.sub')}</span>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="alert inline-alert" role="alert">
|
||||
<span class="alert-tag">ERR</span><span>{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="hint">{$t('apps.detail.notifications.loading')}</p>
|
||||
{:else if rows.length === 0 && editingId === null}
|
||||
<p class="hint">{$t('apps.detail.notifications.empty')}</p>
|
||||
<button class="forge-btn" onclick={startAdd}>
|
||||
<IconPlus size={13} />
|
||||
<span>{$t('apps.detail.notifications.addFirst')}</span>
|
||||
</button>
|
||||
{:else}
|
||||
{#if rows.length > 0}
|
||||
<ul class="notif-list">
|
||||
{#each rows as row (row.id)}
|
||||
<li class="notif-row" class:disabled={!row.enabled}>
|
||||
<div class="notif-main">
|
||||
<span class="notif-name">{row.name || '(unnamed)'}</span>
|
||||
<span class="notif-url mono">{row.url}</span>
|
||||
<span class="notif-meta">
|
||||
{#if row.event_types}
|
||||
<span class="notif-tag">{row.event_types}</span>
|
||||
{:else}
|
||||
<span class="notif-tag muted">{$t('apps.detail.notifications.allEvents')}</span>
|
||||
{/if}
|
||||
{#if row.secret_set}
|
||||
<span class="notif-tag secret">{$t('apps.detail.notifications.signed')}</span>
|
||||
{/if}
|
||||
{#if !row.enabled}
|
||||
<span class="notif-tag muted">{$t('apps.detail.notifications.disabled')}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="notif-actions">
|
||||
<button
|
||||
class="forge-btn-ghost"
|
||||
onclick={() => startEdit(row)}
|
||||
aria-label={$t('apps.detail.notifications.edit')}
|
||||
>
|
||||
<IconEdit size={13} />
|
||||
</button>
|
||||
<button
|
||||
class="forge-btn-ghost danger"
|
||||
onclick={() => (confirmDeleteId = row.id)}
|
||||
aria-label={$t('apps.detail.notifications.delete')}
|
||||
>
|
||||
<IconTrash size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if editingId === null}
|
||||
<button class="forge-btn-ghost notif-add" onclick={startAdd}>
|
||||
<IconPlus size={13} />
|
||||
<span>{$t('apps.detail.notifications.add')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if editingId !== null}
|
||||
<div class="notif-form">
|
||||
<label class="sub">
|
||||
<span class="sub-label">{$t('apps.detail.notifications.name')}</span>
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={formName}
|
||||
placeholder={$t('apps.detail.notifications.namePlaceholder')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label class="sub">
|
||||
<span class="sub-label">{$t('apps.detail.notifications.url')}</span>
|
||||
<input
|
||||
type="url"
|
||||
class="input mono"
|
||||
bind:value={formURL}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label class="sub">
|
||||
<span class="sub-label">{$t('apps.detail.notifications.secret')}</span>
|
||||
<input
|
||||
type="password"
|
||||
class="input mono"
|
||||
bind:value={formSecret}
|
||||
placeholder={editingId
|
||||
? $t('apps.detail.notifications.secretEditPlaceholder')
|
||||
: $t('apps.detail.notifications.secretPlaceholder')}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<span class="hint">{$t('apps.detail.notifications.secretHint')}</span>
|
||||
</label>
|
||||
<label class="sub">
|
||||
<span class="sub-label">{$t('apps.detail.notifications.eventTypes')}</span>
|
||||
<input
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={formEventTypes}
|
||||
placeholder={$t('apps.detail.notifications.eventTypesPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span class="hint">{$t('apps.detail.notifications.eventTypesHint')}</span>
|
||||
</label>
|
||||
<div class="sub toggle-row">
|
||||
<span class="sub-label">{$t('apps.detail.notifications.enabled')}</span>
|
||||
<ToggleSwitch bind:checked={formEnabled} ariaLabel={$t('apps.detail.notifications.enabled')} />
|
||||
</div>
|
||||
<div class="notif-form-actions">
|
||||
<button class="forge-btn-ghost" onclick={cancelEdit} disabled={saving}>
|
||||
{$t('apps.detail.notifications.cancel')}
|
||||
</button>
|
||||
<button class="forge-btn" onclick={save} disabled={!formValid || saving}>
|
||||
{saving ? $t('apps.detail.notifications.saving') : $t('apps.detail.notifications.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if confirmDeleteId}
|
||||
<ConfirmDialog
|
||||
open={true}
|
||||
title={$t('apps.detail.notifications.confirmDeleteTitle')}
|
||||
message={$t('apps.detail.notifications.confirmDeleteMessage')}
|
||||
confirmLabel={$t('apps.detail.notifications.delete')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={() => confirmDeleteId && doDelete(confirmDeleteId)}
|
||||
oncancel={() => (confirmDeleteId = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.notif-panel {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.notif-list {
|
||||
list-style: none;
|
||||
margin: 0.7rem 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.notif-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.85rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: var(--surface-card-hover);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.notif-row.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.notif-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.notif-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.notif-url {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.notif-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.notif-tag {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.68rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.notif-tag.secret {
|
||||
border-color: var(--accent-warm, #c08458);
|
||||
}
|
||||
.notif-tag.muted {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.notif-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.notif-add {
|
||||
margin-top: 0.85rem;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.notif-form {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.95rem;
|
||||
border-top: 1px dashed var(--border-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.notif-form .sub {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.notif-form .sub-label {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.notif-form .toggle-row {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.notif-form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,210 @@
|
||||
<script lang="ts">
|
||||
// Vertical step rail for multi-step wizards. Renders numbered steps with
|
||||
// a "molten" ember fill connecting completed steps — the forge control-
|
||||
// panel motif. Steps up to `maxReached` are clickable to jump back;
|
||||
// upcoming steps are inert. Collapses to a horizontal bar on narrow
|
||||
// viewports via CSS.
|
||||
interface WizardStep {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
steps: WizardStep[];
|
||||
/** 1-based index of the active step. */
|
||||
current: number;
|
||||
/** 1-based index of the furthest step the user may navigate to. */
|
||||
maxReached: number;
|
||||
onselect: (step: number) => void;
|
||||
}
|
||||
|
||||
let { steps, current, maxReached, onselect }: Props = $props();
|
||||
|
||||
function stepState(index1: number): 'done' | 'active' | 'upcoming' {
|
||||
if (index1 < current) return 'done';
|
||||
if (index1 === current) return 'active';
|
||||
return 'upcoming';
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
// Up/Down arrows move focus among the reachable (enabled) step buttons
|
||||
// so the rail reads as one grouped control to keyboard users.
|
||||
function handleKeydown(e: KeyboardEvent): void {
|
||||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||
const list = (e.currentTarget as HTMLElement).closest('.rail-list');
|
||||
if (!list) return;
|
||||
const btns = Array.from(list.querySelectorAll<HTMLButtonElement>('button:not([disabled])'));
|
||||
const idx = btns.indexOf(e.currentTarget as HTMLButtonElement);
|
||||
if (idx === -1) return;
|
||||
e.preventDefault();
|
||||
const target =
|
||||
e.key === 'ArrowDown' ? Math.min(idx + 1, btns.length - 1) : Math.max(idx - 1, 0);
|
||||
btns[target]?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="rail" aria-label="Progress">
|
||||
<ol class="rail-list">
|
||||
{#each steps as step, i (i)}
|
||||
{@const num = i + 1}
|
||||
{@const st = stepState(num)}
|
||||
{@const reachable = num <= maxReached && num !== current}
|
||||
<li class="rail-item rail-{st}">
|
||||
<button
|
||||
type="button"
|
||||
class="rail-btn"
|
||||
disabled={!reachable}
|
||||
aria-current={st === 'active' ? 'step' : undefined}
|
||||
onclick={() => reachable && onselect(num)}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<span class="rail-marker" aria-hidden="true">
|
||||
{#if st === 'done'}
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8.5l3.5 3.5L13 4" /></svg>
|
||||
{:else}
|
||||
{pad(num)}
|
||||
{/if}
|
||||
</span>
|
||||
<span class="rail-label">{step.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.rail {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rail-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.rail-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Connector line between markers. Drawn from this marker's centre
|
||||
upward to the previous one; ember-filled once the step is reached. */
|
||||
.rail-item:not(:first-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: calc(0.875rem - 1px);
|
||||
top: calc(-0.5rem);
|
||||
height: 0.5rem;
|
||||
width: 2px;
|
||||
background: var(--border-primary);
|
||||
transition: background var(--transition-slow);
|
||||
}
|
||||
|
||||
.rail-done:not(:first-child)::before,
|
||||
.rail-active:not(:first-child)::before {
|
||||
background: var(--forge-ember);
|
||||
}
|
||||
|
||||
.rail-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-2);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text-tertiary);
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
.rail-btn:not(:disabled):hover {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.rail-btn:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.rail-marker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: var(--radius-full);
|
||||
border: 2px solid var(--border-primary);
|
||||
background: var(--surface-card);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-tertiary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.rail-done .rail-marker {
|
||||
border-color: var(--forge-ember);
|
||||
background: var(--forge-ember);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.rail-active .rail-marker {
|
||||
border-color: var(--forge-ember);
|
||||
color: var(--forge-ember);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--forge-ember) 18%, transparent);
|
||||
}
|
||||
|
||||
.rail-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
.rail-active .rail-label {
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
.rail-done .rail-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Horizontal rail on narrow screens: markers in a row, labels hidden. */
|
||||
@media (max-width: 820px) {
|
||||
.rail-list {
|
||||
flex-direction: row;
|
||||
gap: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.rail-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.rail-item:not(:first-child)::before {
|
||||
left: auto;
|
||||
right: 50%;
|
||||
top: calc(0.875rem - 1px);
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
}
|
||||
.rail-btn {
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
width: auto;
|
||||
z-index: 1;
|
||||
}
|
||||
.rail-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
<!--
|
||||
App manifest — a forged "spec-sheet" summarizing the whole workload on the
|
||||
wizard's Review step so the operator can confirm everything before Create.
|
||||
|
||||
Pure presentation: the page computes the row set + source-kind via `$derived`
|
||||
and passes them in. Values are rendered in a definition grid (mono uppercase
|
||||
labels in the left column, values in the right). Machine-readable values
|
||||
(image refs, repo paths, branches, ports, FQDNs) are set `mono` by the caller
|
||||
so they read as the literals they are. The source kind is shown as an ember
|
||||
badge in the manifest header.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
export interface ManifestRow {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Definition rows: Name / Source / Trigger / Public face. */
|
||||
rows: ManifestRow[];
|
||||
/** Source-kind string (image / compose / static / dockerfile) — badge. */
|
||||
sourceKind: string;
|
||||
}
|
||||
|
||||
let { rows, sourceKind }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section class="manifest" aria-label={$t('apps.new.manifest.title')}>
|
||||
<span class="reg reg-tl" aria-hidden="true"></span>
|
||||
<span class="reg reg-tr" aria-hidden="true"></span>
|
||||
<span class="reg reg-bl" aria-hidden="true"></span>
|
||||
<span class="reg reg-br" aria-hidden="true"></span>
|
||||
|
||||
<header class="manifest-head">
|
||||
<span class="forge-eyebrow">
|
||||
<span class="forge-ember"></span>
|
||||
<span class="eb-word">{$t('apps.new.manifest.title')}</span>
|
||||
</span>
|
||||
{#if sourceKind}
|
||||
<span class="kind-badge mono">{sourceKind}</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<dl class="manifest-grid">
|
||||
{#each rows as row (row.label)}
|
||||
<div class="manifest-row">
|
||||
<dt class="manifest-label">{row.label}</dt>
|
||||
<dd class="manifest-value" class:mono={row.mono}>{row.value}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Forged spec-sheet: subtle bordered panel with registration corners,
|
||||
ember eyebrow header, and a label/value definition grid. Reuses the
|
||||
forge token system end-to-end — no ad-hoc colours. */
|
||||
.manifest {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5) var(--space-5) var(--space-6);
|
||||
background: var(--surface-card-hover);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.reg {
|
||||
position: absolute;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-color: var(--forge-accent);
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.reg-tl {
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
border-top-width: 2px;
|
||||
border-left-width: 2px;
|
||||
border-top-left-radius: var(--radius-xl);
|
||||
}
|
||||
.reg-tr {
|
||||
top: -1px;
|
||||
right: -1px;
|
||||
border-top-width: 2px;
|
||||
border-right-width: 2px;
|
||||
border-top-right-radius: var(--radius-xl);
|
||||
}
|
||||
.reg-bl {
|
||||
bottom: -1px;
|
||||
left: -1px;
|
||||
border-bottom-width: 2px;
|
||||
border-left-width: 2px;
|
||||
border-bottom-left-radius: var(--radius-xl);
|
||||
}
|
||||
.reg-br {
|
||||
bottom: -1px;
|
||||
right: -1px;
|
||||
border-bottom-width: 2px;
|
||||
border-right-width: 2px;
|
||||
border-bottom-right-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.manifest-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
.eb-word {
|
||||
font-weight: 700;
|
||||
}
|
||||
.kind-badge {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
padding: 0.2rem 0.55rem;
|
||||
background: var(--forge-accent);
|
||||
color: #fff;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Definition grid — mono uppercase labels, values aligned in a second
|
||||
column. Collapses to stacked rows on narrow viewports. */
|
||||
.manifest-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
}
|
||||
.manifest-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(7rem, 0.32fr) 1fr;
|
||||
gap: var(--space-4);
|
||||
align-items: baseline;
|
||||
padding: var(--space-2) 0;
|
||||
border-bottom: 1px dashed var(--border-secondary);
|
||||
}
|
||||
.manifest-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.manifest-label {
|
||||
margin: 0;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.manifest-value {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
.manifest-value.mono {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.manifest-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,222 @@
|
||||
<!--
|
||||
Compose source form. Surfaces the YAML stack + optional project name as
|
||||
proper controls instead of forcing the operator to hand-escape YAML inside
|
||||
a JSON string. The parent owns the `ComposeFormState` (from
|
||||
`$lib/workload/sourceForms`) and binds it here; serialization to the
|
||||
`source_config` object is done by the parent via `composeToConfig` so the
|
||||
shape stays byte-identical to the legacy inline path.
|
||||
|
||||
The "Advanced JSON" chip is rendered by the parent (it owns the raw-editor
|
||||
toggle); this component is purely the form-field body.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { ComposeFormState } from '$lib/workload/sourceForms';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
form: ComposeFormState;
|
||||
/** Flip to the raw-JSON editor (owned by the parent). */
|
||||
onAdvanced: () => void;
|
||||
}
|
||||
|
||||
let { form = $bindable(), onAdvanced }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="editor">
|
||||
<div class="editor-head">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span class="editor-title">{$t('apps.new.composeHeader')}</span>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="json-escape" onclick={onAdvanced} title={$t('apps.new.switchToJsonTitle')}>
|
||||
{$t('apps.new.advancedJson')}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
id="app-compose-yaml"
|
||||
bind:value={form.yaml}
|
||||
rows="12"
|
||||
spellcheck="false"
|
||||
class="code-area"
|
||||
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>
|
||||
{$t('apps.new.fieldConfigYaml')}
|
||||
</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{form.yaml.split('\n').length} {$t('apps.new.linesUnit')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<label class="sub" for="app-compose-project">
|
||||
<span class="sub-label">{$t('apps.new.composeProjectLabel')}</span>
|
||||
<input
|
||||
id="app-compose-project"
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={form.projectName}
|
||||
placeholder={$t('apps.new.composeProjectPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<style>
|
||||
.input {
|
||||
width: 100%;
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
.input:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.sub {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.sub-label {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Code editor frame ─────────────────────────── */
|
||||
.editor {
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--surface-input);
|
||||
transition: border-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
.editor:focus-within {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.editor-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.8rem;
|
||||
background: var(--surface-card-hover);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.editor-head .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--border-input);
|
||||
}
|
||||
.editor-head .dot:nth-of-type(1) {
|
||||
background: #ef4444aa;
|
||||
}
|
||||
.editor-head .dot:nth-of-type(2) {
|
||||
background: #f59e0baa;
|
||||
}
|
||||
.editor-head .dot:nth-of-type(3) {
|
||||
background: #10b981aa;
|
||||
}
|
||||
.editor-title {
|
||||
margin-left: 0.4rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── "Edit as JSON" escape hatch ─────────────────────────
|
||||
Quiet secondary text-link rather than a prominent chip, so it doesn't
|
||||
compete with the form title. */
|
||||
.json-escape {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-underline-offset: 3px;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
.json-escape:hover {
|
||||
color: var(--forge-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.json-escape:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.code-area {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0.85rem 1rem;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
color: var(--text-primary);
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
tab-size: 2;
|
||||
}
|
||||
.code-area::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.editor-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.8rem;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
background: var(--surface-card-hover);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.foot-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--color-success-dark);
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.foot-status .foot-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-success);
|
||||
}
|
||||
:global([data-theme='dark']) .foot-status {
|
||||
color: #86efac;
|
||||
}
|
||||
.sep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
<!--
|
||||
Dockerfile source form. Shares the provider + repo + branch + token
|
||||
git-discovery wiring with the static source (StaticDiscoveryWizard in its
|
||||
compact `dockerfile` variant — same handlers, no folder tree). The
|
||||
build-step controls (context path, dockerfile path, port) are the only
|
||||
dockerfile-specific UI.
|
||||
|
||||
The parent owns the `DockerfileFormState` (from `$lib/workload/sourceForms`)
|
||||
and binds it here; serialization to `source_config` is done by the parent
|
||||
via `dockerfileToConfig` so the shape (incl. preserved unknown keys + the
|
||||
scrubbed static-only keys) stays byte-identical. `DockerfileFormState
|
||||
extends GitSourceState`, so the same object is bound into the wizard.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { DockerfileFormState } from '$lib/workload/sourceForms';
|
||||
import StaticDiscoveryWizard from '$lib/components/workload/StaticDiscoveryWizard.svelte';
|
||||
import { IconX } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
form: DockerfileFormState;
|
||||
/** Flip to the raw-JSON editor (owned by the parent). */
|
||||
onAdvanced: () => void;
|
||||
/**
|
||||
* Transient discovery status — OPTIONAL pass-through to
|
||||
* StaticDiscoveryWizard (dockerfile variant: detect + test only, no
|
||||
* folder tree / mode). Each defaults to this component's own internal
|
||||
* `$state`, so a parent that doesn't bind them keeps the original
|
||||
* "resets on remount" behaviour (the detail/edit page `apps/[id]`
|
||||
* binds none of these). The create wizard `apps/new` binds them up to
|
||||
* the PAGE so the detect/test pills survive the form unmounting under
|
||||
* the Advanced-JSON / source-kind toggles.
|
||||
*/
|
||||
detectStatus?: 'idle' | 'pending' | 'ok' | 'error';
|
||||
detectError?: string;
|
||||
testStatus?: 'idle' | 'pending' | 'ok' | 'error';
|
||||
testError?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
form = $bindable(),
|
||||
onAdvanced,
|
||||
detectStatus = $bindable('idle'),
|
||||
detectError = $bindable(''),
|
||||
testStatus = $bindable('idle'),
|
||||
testError = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
// `touched` flips true on first blur — used by the pill to avoid shouting
|
||||
// "required" the instant the user lands on the form.
|
||||
let portTouched = $state(false);
|
||||
|
||||
// A cleared <input type="number"> binds to null (not 0) in Svelte 5, and
|
||||
// `null <= 0` is false — so a bare `port <= 0` check would pass validation
|
||||
// on an empty field. Guard against null/NaN/non-positive here.
|
||||
const portValid = $derived(
|
||||
typeof form.port === 'number' && Number.isFinite(form.port) && form.port > 0
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="image-form">
|
||||
<div class="image-form-head">
|
||||
<span class="editor-title">{$t('apps.new.dockerfileHeader')}</span>
|
||||
<button type="button" class="json-escape" onclick={onAdvanced} title={$t('apps.new.switchToJsonTitle')}>
|
||||
{$t('apps.new.advancedJson')}
|
||||
</button>
|
||||
</div>
|
||||
<StaticDiscoveryWizard
|
||||
bind:git={form}
|
||||
variant="dockerfile"
|
||||
bind:detectStatus
|
||||
bind:detectError
|
||||
bind:testStatus
|
||||
bind:testError
|
||||
idPrefix="app-df"
|
||||
/>
|
||||
|
||||
<!-- Build-step controls — the only dockerfile-only UI. The form is a
|
||||
two-phase form (locate the code, describe how to build it). A
|
||||
forge-eyebrow divider phrases the conceptual break. -->
|
||||
<div class="df-section-break" aria-hidden="false">
|
||||
<span class="forge-eyebrow">
|
||||
<span class="forge-ember"></span>
|
||||
<span class="eb-word">{$t('apps.new.dockerfileBuildEyebrow')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub" for="app-df-context">
|
||||
<span class="sub-label">{$t('apps.new.dockerfileContextPath')}</span>
|
||||
<input
|
||||
id="app-df-context"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={form.contextPath}
|
||||
placeholder={$t('apps.new.dockerfileContextPathPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-df-dockerfile">
|
||||
<span class="sub-label">{$t('apps.new.dockerfilePath')}</span>
|
||||
<input
|
||||
id="app-df-dockerfile"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={form.dockerfilePath}
|
||||
placeholder="Dockerfile"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub" for="app-df-port">
|
||||
<span class="sub-label"
|
||||
>{$t('apps.new.dockerfilePort')}<span class="req-star" aria-label={$t('apps.new.fieldRequired')}
|
||||
>*</span
|
||||
></span
|
||||
>
|
||||
<input
|
||||
id="app-df-port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
class="input mono"
|
||||
bind:value={form.port}
|
||||
onblur={() => (portTouched = true)}
|
||||
placeholder="8080"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{#if portTouched && !portValid}
|
||||
<div class="discover-pill discover-pill-bad">
|
||||
<IconX size={12} />
|
||||
<span>{$t('apps.new.dockerfilePortRequired')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="hint image-form-foot">{$t('apps.new.dockerfileFoot')}</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.input {
|
||||
width: 100%;
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
.input:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.sub {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.sub-label {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* Required-field marker — same danger hue as the page-level `.req`
|
||||
badge, rendered as a compact asterisk. */
|
||||
.req-star {
|
||||
margin-left: 0.2rem;
|
||||
color: var(--color-danger);
|
||||
font-weight: 700;
|
||||
}
|
||||
.editor-title {
|
||||
margin-left: 0.4rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
/* ── "Edit as JSON" escape hatch ─────────────────────────
|
||||
Quiet secondary text-link rather than a prominent chip, so it doesn't
|
||||
compete with the form title. */
|
||||
.json-escape {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-underline-offset: 3px;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
.json-escape:hover {
|
||||
color: var(--forge-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.json-escape:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ── Image source form shell (shared visual vocabulary) ── */
|
||||
.image-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-input);
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
.image-form-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.55rem;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.image-form-foot {
|
||||
margin-top: 0.2rem;
|
||||
padding-top: 0.55rem;
|
||||
border-top: 1px dashed var(--border-primary);
|
||||
}
|
||||
/* Conceptual section divider — separates git-discovery from build-step.
|
||||
Same dashed border vocabulary as image-form-foot so it reads as a
|
||||
sibling of the foot hint, not a new pattern. */
|
||||
.df-section-break {
|
||||
margin-top: 0.45rem;
|
||||
padding-top: 0.55rem;
|
||||
border-top: 1px dashed var(--border-primary);
|
||||
}
|
||||
|
||||
.discover-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.28rem 0.55rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.discover-pill-bad {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: var(--color-danger-dark);
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger) 40%, transparent);
|
||||
}
|
||||
:global([data-theme='dark']) .discover-pill-bad {
|
||||
color: #fca5a5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,741 @@
|
||||
<!--
|
||||
Image source form. Surfaces the most-used image-source fields as proper
|
||||
controls (ref, port, healthcheck, default tag, registry, resource limits).
|
||||
Env + volumes stay on the detail page where they have dedicated panels.
|
||||
|
||||
The parent owns the `ImageFormState` (from `$lib/workload/sourceForms`) and
|
||||
binds it here; serialization to `source_config` is done by the parent via
|
||||
`imageToConfig` so the shape stays byte-identical.
|
||||
|
||||
Two pieces of async UX live in this component but write their results back
|
||||
through bindable props so the parent's submit gate can read them:
|
||||
|
||||
• Inspect — pulls port + healthcheck from the image metadata. Guarded by
|
||||
an AbortController + ref re-check so a late response can't relabel a
|
||||
newer ref. Touch sentinels stop Inspect overwriting fields the operator
|
||||
already edited.
|
||||
• Conflict lookup — debounced /api/discovery/image/conflicts call,
|
||||
guarded by a sequence token so a slow earlier response can't clobber a
|
||||
faster later one. The `conflicts` / `conflictAcknowledged` /
|
||||
`conflictBlocked` triplet is bound to the parent which runs the
|
||||
two-click "submit anyway" gate.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { ImageFormState } from '$lib/workload/sourceForms';
|
||||
import * as api from '$lib/api';
|
||||
import { IconSearch, IconLoader } from '$lib/components/icons';
|
||||
import RegistryImagePicker from '$lib/components/RegistryImagePicker.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
form: ImageFormState;
|
||||
/** Registry list for the picker; empty falls back to a text input. */
|
||||
registries?: { name: string; url: string }[];
|
||||
/** Bound submitting flag — gates the debounced conflict lookup. */
|
||||
submitting?: boolean;
|
||||
/** Bound conflict triplet — the parent's submit gate reads these. */
|
||||
conflicts?: api.ImageConflict[];
|
||||
conflictAcknowledged?: boolean;
|
||||
conflictBlocked?: boolean;
|
||||
/**
|
||||
* Gate the debounced /api/discovery/image/conflicts lookup + the conflict
|
||||
* warning panel. The create wizard wants it (default `true`) so an
|
||||
* operator about to deploy a duplicate image is warned. The detail-page
|
||||
* EDIT form must turn it OFF — there the workload would flag itself as a
|
||||
* conflict with its own image. When off, the conflict triplet / submitting
|
||||
* / registries props are unused and may be omitted by the parent.
|
||||
*/
|
||||
enableConflicts?: boolean;
|
||||
/** Flip to the raw-JSON editor (owned by the parent). */
|
||||
onAdvanced: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
form = $bindable(),
|
||||
registries = [],
|
||||
submitting = $bindable(false),
|
||||
conflicts = $bindable([]),
|
||||
conflictAcknowledged = $bindable(false),
|
||||
conflictBlocked = $bindable(false),
|
||||
enableConflicts = true,
|
||||
onAdvanced
|
||||
}: Props = $props();
|
||||
|
||||
// ── Inspect state ─────────────────────────────────────────────────
|
||||
type InspectStatus = 'idle' | 'pending' | 'ok' | 'error';
|
||||
let inspectStatus = $state<InspectStatus>('idle');
|
||||
// AbortController + sequence guard so a late inspect response cannot
|
||||
// mislabel the *current* image ref after the user typed a new one.
|
||||
let inspectAbort: AbortController | null = null;
|
||||
// Touch sentinels for fields with a "0 == empty" sentinel value (port,
|
||||
// healthcheck). Once the user interacts, Inspect leaves them alone — even
|
||||
// when still `0` / "" (some images really do listen on port 0 / have no
|
||||
// healthcheck).
|
||||
let portTouched = $state(false);
|
||||
let healthcheckTouched = $state(false);
|
||||
|
||||
// The legacy inline seedImageFromJSON reset the touched sentinels on every
|
||||
// reseed (mount, kind switch, Advanced↔form toggle). The parent reseeds by
|
||||
// REASSIGNING the form object (a fresh seed* result), so the object's
|
||||
// identity changes on reseed but not on in-place field edits. Track that
|
||||
// identity to reset the sentinels exactly when a reseed happens — keeping
|
||||
// Inspect's "only fill untouched fields" behaviour identical to before.
|
||||
let lastSeenForm: ImageFormState | null = null;
|
||||
$effect(() => {
|
||||
if (form !== lastSeenForm) {
|
||||
lastSeenForm = form;
|
||||
portTouched = false;
|
||||
healthcheckTouched = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Conflict-lookup state ─────────────────────────────────────────
|
||||
let conflictLoading = $state(false);
|
||||
let conflictDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
// Race token so a slow earlier response cannot overwrite a faster later one.
|
||||
let conflictReqSeq = 0;
|
||||
|
||||
// Query the backend for workloads already using this image. Failures are
|
||||
// silent (the existing list stays) — a transient network blip should never
|
||||
// clear a real warning. The caller guards against empty / too-short refs.
|
||||
async function fetchImageConflicts(ref: string): Promise<void> {
|
||||
const mine = ++conflictReqSeq;
|
||||
conflictLoading = true;
|
||||
try {
|
||||
const result = await api.listImageConflicts(ref);
|
||||
if (mine === conflictReqSeq) {
|
||||
conflicts = result;
|
||||
}
|
||||
} catch (e) {
|
||||
if (mine === conflictReqSeq) {
|
||||
// no-op; intentionally preserve prior conflicts
|
||||
void e;
|
||||
}
|
||||
} finally {
|
||||
if (mine === conflictReqSeq) {
|
||||
conflictLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleImageConflictLookup(ref: string) {
|
||||
if (conflictDebounce) {
|
||||
clearTimeout(conflictDebounce);
|
||||
conflictDebounce = null;
|
||||
}
|
||||
// Conflict detection disabled (edit form) — never probe the backend.
|
||||
if (!enableConflicts) return;
|
||||
const trimmed = ref.trim();
|
||||
if (trimmed.length < 3 || submitting) return;
|
||||
conflictDebounce = setTimeout(() => {
|
||||
conflictDebounce = null;
|
||||
void fetchImageConflicts(trimmed);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function onImageRefInput() {
|
||||
// Typing invalidates any prior acknowledgement and clears the stale
|
||||
// list so the panel doesn't lie about the current ref; the debounced
|
||||
// lookup will repopulate it.
|
||||
conflictAcknowledged = false;
|
||||
conflictBlocked = false;
|
||||
conflicts = [];
|
||||
// Also reset the inspect pill — its OK/error status belongs to the
|
||||
// *previous* ref and would mislead the user otherwise.
|
||||
inspectStatus = 'idle';
|
||||
inspectAbort?.abort();
|
||||
scheduleImageConflictLookup(form.ref);
|
||||
}
|
||||
|
||||
function onImageRefBlur() {
|
||||
if (!enableConflicts) return;
|
||||
const trimmed = form.ref.trim();
|
||||
if (trimmed.length < 3 || submitting) return;
|
||||
if (conflictDebounce) {
|
||||
clearTimeout(conflictDebounce);
|
||||
conflictDebounce = null;
|
||||
}
|
||||
void fetchImageConflicts(trimmed);
|
||||
}
|
||||
|
||||
// Tear down the pending debounce timer + cancel any in-flight inspect
|
||||
// request if the user navigates away mid-window — otherwise the late
|
||||
// resolve mutates dead state.
|
||||
onDestroy(() => {
|
||||
if (conflictDebounce) {
|
||||
clearTimeout(conflictDebounce);
|
||||
conflictDebounce = null;
|
||||
}
|
||||
inspectAbort?.abort();
|
||||
});
|
||||
|
||||
// Pull port + healthcheck from the image's exposed metadata. Only
|
||||
// overwrites untouched fields. A new call aborts any in-flight one, and we
|
||||
// re-check the ref after the await so a late response can't relabel the
|
||||
// *new* image ref the user just typed.
|
||||
async function inspectImageRef() {
|
||||
const ref = form.ref.trim();
|
||||
if (!ref) return;
|
||||
if (inspectStatus === 'pending') return;
|
||||
inspectAbort?.abort();
|
||||
const controller = new AbortController();
|
||||
inspectAbort = controller;
|
||||
inspectStatus = 'pending';
|
||||
try {
|
||||
const result = await api.inspectImage(ref, controller.signal);
|
||||
// Late-arrival guard: if the user edited the ref during the flight,
|
||||
// our success belongs to a stale value — discard.
|
||||
if (form.ref.trim() !== ref) return;
|
||||
// Only fill fields the operator hasn't touched. The sentinel is the
|
||||
// touched flag, not the value — a user who deliberately types `0`
|
||||
// or clears the healthcheck still owns the field.
|
||||
if (!portTouched && typeof result.port === 'number') form.port = result.port;
|
||||
if (!healthcheckTouched && typeof result.healthcheck === 'string') {
|
||||
form.healthcheck = result.healthcheck;
|
||||
}
|
||||
inspectStatus = 'ok';
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (form.ref.trim() !== ref) return;
|
||||
// Show a friendly, localized message — never the raw backend
|
||||
// string (the discovery handlers were just hardened to drop
|
||||
// leaky daemon errors, so there is nothing useful to surface).
|
||||
void e;
|
||||
inspectStatus = 'error';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="image-form">
|
||||
<div class="image-form-head">
|
||||
<span class="editor-title">{$t('apps.new.imageHeader')}</span>
|
||||
<button type="button" class="json-escape" onclick={onAdvanced} title={$t('apps.new.switchToJsonTitle')}>
|
||||
{$t('apps.new.advancedJson')}
|
||||
</button>
|
||||
</div>
|
||||
<label class="sub" for="app-image-ref">
|
||||
<span class="sub-label"
|
||||
>{$t('apps.new.imageRefLabel')}<span class="req-star" aria-label={$t('apps.new.fieldRequired')}>*</span></span
|
||||
>
|
||||
<div class="input-with-button">
|
||||
<input
|
||||
id="app-image-ref"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={form.ref}
|
||||
oninput={onImageRefInput}
|
||||
onblur={onImageRefBlur}
|
||||
placeholder={$t('apps.new.imageRefPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="discover-btn"
|
||||
onclick={inspectImageRef}
|
||||
disabled={!form.ref.trim() || inspectStatus === 'pending'}
|
||||
title={$t('apps.new.imageInspectHint')}
|
||||
>
|
||||
{#if inspectStatus === 'pending'}
|
||||
<IconLoader size={14} />
|
||||
{:else}
|
||||
<IconSearch size={14} />
|
||||
{/if}
|
||||
<span>{$t('apps.new.imageInspect')}</span>
|
||||
</button>
|
||||
<RegistryImagePicker
|
||||
current={form.ref}
|
||||
onpick={(ref, registryName) => {
|
||||
form.ref = ref;
|
||||
// Auto-select the registry the image came from so private
|
||||
// images pull with the right credentials without a second
|
||||
// manual step. Only adopt it when the picker surfaced a
|
||||
// non-empty name (public images carry '') so we never wipe a
|
||||
// registry the operator already chose.
|
||||
if (registryName) form.registryName = registryName;
|
||||
onImageRefInput();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p class="hint">{$t('apps.new.imageRefHint')}</p>
|
||||
{#if inspectStatus === 'ok'}
|
||||
<span class="discover-pill discover-pill-ok inline">{$t('apps.new.imageInspectOk')}</span>
|
||||
{:else if inspectStatus === 'error'}
|
||||
<span class="discover-pill discover-pill-bad inline">{$t('apps.new.errors.inspectFailed')}</span>
|
||||
{/if}
|
||||
<!--
|
||||
Conflict-checking indicator. Reserves no layout when idle and is a
|
||||
quiet inline hint (not the full panel) while a lookup is in flight,
|
||||
so a no-conflict blur no longer flashes the warning panel in then
|
||||
out. The panel itself renders only for REAL conflicts below.
|
||||
-->
|
||||
{#if enableConflicts && conflictLoading}
|
||||
<span class="conflict-checking" role="status" aria-live="polite">
|
||||
<IconLoader size={12} />
|
||||
<span>{$t('apps.new.imageConflictChecking')}</span>
|
||||
</span>
|
||||
{/if}
|
||||
</label>
|
||||
{#if enableConflicts && conflicts.length > 0}
|
||||
<div class="conflict-panel" role="status" aria-live="polite">
|
||||
<div class="conflict-panel-head">
|
||||
<span class="conflict-tag">{$t('apps.new.imageConflictTag')}</span>
|
||||
</div>
|
||||
<p class="conflict-heading">
|
||||
{$t('apps.new.imageConflictHeading', { count: String(conflicts.length) })}
|
||||
<code class="conflict-ref mono">{form.ref.trim()}</code>
|
||||
</p>
|
||||
<ul class="conflict-list">
|
||||
{#each conflicts as conflict (conflict.id)}
|
||||
<li class="conflict-row">
|
||||
<div class="conflict-row-text">
|
||||
<span class="conflict-name">{conflict.name}</span>
|
||||
<span class="conflict-image mono">{conflict.image}</span>
|
||||
</div>
|
||||
<a
|
||||
href={`/apps/${conflict.id}`}
|
||||
class="editor-chip conflict-open"
|
||||
title={$t('apps.new.imageConflictOpenBtn')}
|
||||
>
|
||||
{$t('apps.new.imageConflictOpenBtn')}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="conflict-foot">{$t('apps.new.imageConflictAcknowledgeNote')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="row three">
|
||||
<label class="sub" for="app-image-port">
|
||||
<span class="sub-label">{$t('apps.new.imagePort')}</span>
|
||||
<input
|
||||
id="app-image-port"
|
||||
type="number"
|
||||
min="0"
|
||||
max="65535"
|
||||
class="input"
|
||||
bind:value={form.port}
|
||||
oninput={() => (portTouched = true)}
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-image-healthcheck">
|
||||
<span class="sub-label">{$t('apps.new.imageHealthcheck')}</span>
|
||||
<input
|
||||
id="app-image-healthcheck"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={form.healthcheck}
|
||||
oninput={() => (healthcheckTouched = true)}
|
||||
placeholder="/healthz"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-image-default-tag">
|
||||
<span class="sub-label">{$t('apps.new.imageDefaultTag')}</span>
|
||||
<input
|
||||
id="app-image-default-tag"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={form.defaultTag}
|
||||
placeholder="latest"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label class="sub" for="app-image-registry">
|
||||
<span class="sub-label">{$t('apps.new.imageRegistryLabel')}</span>
|
||||
{#if registries.length > 0}
|
||||
<select id="app-image-registry" class="input" bind:value={form.registryName}>
|
||||
<option value="">{$t('apps.new.imageRegistryPublic')}</option>
|
||||
{#each registries as r}
|
||||
<option value={r.name}>{r.name} — {r.url}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<input
|
||||
id="app-image-registry"
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={form.registryName}
|
||||
placeholder={$t('apps.new.imageRegistryPublic')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{/if}
|
||||
<p class="hint">{$t('apps.new.imageRegistryHint')}</p>
|
||||
</label>
|
||||
<div class="row three">
|
||||
<label class="sub" for="app-image-cpu">
|
||||
<span class="sub-label">{$t('apps.new.imageCpu')}</span>
|
||||
<input id="app-image-cpu" type="number" min="0" step="0.1" class="input" bind:value={form.cpuLimit} />
|
||||
<p class="hint">{$t('apps.new.imageCpuHint')}</p>
|
||||
</label>
|
||||
<label class="sub" for="app-image-memory">
|
||||
<span class="sub-label">{$t('apps.new.imageMemory')}</span>
|
||||
<input id="app-image-memory" type="number" min="0" class="input" bind:value={form.memoryLimit} />
|
||||
<p class="hint">{$t('apps.new.imageMemoryHint')}</p>
|
||||
</label>
|
||||
<label class="sub" for="app-image-max">
|
||||
<span class="sub-label">{$t('apps.new.imageMax')}</span>
|
||||
<input id="app-image-max" type="number" min="1" class="input" bind:value={form.maxInstances} />
|
||||
<p class="hint">{$t('apps.new.imageMaxHint')}</p>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint image-form-foot">{$t('apps.new.imageFoot')}</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.input {
|
||||
width: 100%;
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
.input:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.sub {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.sub-label {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* Required-field marker — same danger hue as the page-level `.req`
|
||||
badge, rendered as a compact asterisk so it doesn't bloat the mono
|
||||
sub-label. The aria-label carries the meaning for assistive tech. */
|
||||
.req-star {
|
||||
margin-left: 0.2rem;
|
||||
color: var(--color-danger);
|
||||
font-weight: 700;
|
||||
}
|
||||
.editor-title {
|
||||
margin-left: 0.4rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.editor-chip {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.22rem 0.55rem;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
.editor-chip:hover {
|
||||
border-color: var(--color-brand-400);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-card-hover);
|
||||
}
|
||||
.editor-chip:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── "Edit as JSON" escape hatch ─────────────────────────
|
||||
A quiet secondary text-link, not a prominent chip — it must not
|
||||
compete with the form title. Mono, muted, underline-on-hover so it
|
||||
reads as the rarely-used power-user door it is. */
|
||||
.json-escape {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-underline-offset: 3px;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
.json-escape:hover {
|
||||
color: var(--forge-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.json-escape:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ── Image source form ──────────────────────────────────
|
||||
Same overall shell as the editor box (border + radius) but the
|
||||
contents are a stack of labelled form rows. */
|
||||
.image-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-input);
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
.image-form-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.55rem;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.image-form-foot {
|
||||
margin-top: 0.2rem;
|
||||
padding-top: 0.55rem;
|
||||
border-top: 1px dashed var(--border-primary);
|
||||
}
|
||||
.row.three {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.row.three {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.row.three {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Discovery-style input+button row + status pills ─── */
|
||||
.input-with-button {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.input-with-button > .input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.discover-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0 0.7rem;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.discover-btn:hover:not(:disabled) {
|
||||
border-color: var(--color-brand-400);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-card-hover);
|
||||
}
|
||||
.discover-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.discover-btn:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.discover-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.28rem 0.55rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.discover-pill.inline {
|
||||
align-self: center;
|
||||
}
|
||||
.discover-pill-ok {
|
||||
background: color-mix(in srgb, var(--color-success) 14%, transparent);
|
||||
color: var(--color-success-dark);
|
||||
border: 1px solid color-mix(in srgb, var(--color-success) 40%, transparent);
|
||||
}
|
||||
.discover-pill-bad {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: var(--color-danger-dark);
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger) 40%, transparent);
|
||||
}
|
||||
:global([data-theme='dark']) .discover-pill-ok {
|
||||
color: #86efac;
|
||||
}
|
||||
:global([data-theme='dark']) .discover-pill-bad {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* ── Image-source conflict panel ──────────────────
|
||||
Sibling of .image-form-foot. Reuses the dashed border + soft card
|
||||
surface treatment lifted into a loud amber-leaning warning tag. */
|
||||
.conflict-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
margin-top: 0.2rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
background: var(--surface-card-hover);
|
||||
border: 1px dashed var(--color-warning, var(--forge-accent));
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.conflict-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.conflict-tag {
|
||||
display: inline-flex;
|
||||
padding: 0.18rem 0.5rem;
|
||||
background: var(--color-warning, var(--forge-accent));
|
||||
color: var(--surface-card);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
}
|
||||
/* Quiet inline "checking…" hint shown near the image-ref input while a
|
||||
conflict lookup is in flight. Deliberately NOT the full panel, so a
|
||||
no-conflict blur doesn't flash a panel in and out. Self-aligned so it
|
||||
sits with the inspect status pills without shifting form layout. */
|
||||
.conflict-checking {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
align-self: flex-start;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.conflict-checking :global(svg) {
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.conflict-heading {
|
||||
margin: 0;
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.conflict-ref {
|
||||
padding: 0.1rem 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.conflict-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.conflict-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.conflict-row-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.conflict-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.conflict-image {
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
.conflict-open {
|
||||
flex: 0 0 auto;
|
||||
text-decoration: none;
|
||||
}
|
||||
.conflict-foot {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
// Card-grid selector for the workload Source kind, replacing a bare
|
||||
// <select>. Mirrors the trigger-mode card pattern already used in the
|
||||
// wizard (role=radio buttons, mono tag + name + optional hint) so the
|
||||
// two pickers read as one design language. Kinds come from the backend
|
||||
// plugin registry; descriptions are optional (passed in when i18n keys
|
||||
// exist) so this component never hardcodes copy.
|
||||
interface Props {
|
||||
kinds: string[];
|
||||
value: string;
|
||||
onchange: () => void;
|
||||
descriptions?: Record<string, string>;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
let { kinds, value = $bindable(), onchange, descriptions = {}, ariaLabel }: Props = $props();
|
||||
|
||||
function select(kind: string): void {
|
||||
if (kind === value) return;
|
||||
value = kind;
|
||||
onchange();
|
||||
}
|
||||
|
||||
// Radiogroup keyboard semantics: arrows move selection (and focus) to
|
||||
// the adjacent card, wrapping at the ends.
|
||||
function onKeydown(e: KeyboardEvent, index: number): void {
|
||||
const k = e.key;
|
||||
if (k !== 'ArrowRight' && k !== 'ArrowDown' && k !== 'ArrowLeft' && k !== 'ArrowUp') return;
|
||||
e.preventDefault();
|
||||
const dir = k === 'ArrowRight' || k === 'ArrowDown' ? 1 : -1;
|
||||
const next = (index + dir + kinds.length) % kinds.length;
|
||||
select(kinds[next]);
|
||||
const grid = (e.currentTarget as HTMLElement).closest('.kind-grid');
|
||||
grid?.querySelectorAll<HTMLButtonElement>('button')[next]?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="kind-grid" role="radiogroup" aria-label={ariaLabel}>
|
||||
{#each kinds as kind, i (kind)}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value === kind}
|
||||
tabindex={value === kind ? 0 : -1}
|
||||
class="kind-card"
|
||||
class:active={value === kind}
|
||||
onclick={() => select(kind)}
|
||||
onkeydown={(e) => onKeydown(e, i)}
|
||||
>
|
||||
<span class="kind-tag mono">{kind.toUpperCase()}</span>
|
||||
<span class="kind-name">{kind}</span>
|
||||
{#if descriptions[kind]}
|
||||
<span class="kind-hint">{descriptions[kind]}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.kind-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.kind-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-4);
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
background var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.kind-card:hover {
|
||||
border-color: var(--border-input);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.kind-card.active {
|
||||
border-color: var(--forge-ember);
|
||||
background: color-mix(in srgb, var(--forge-ember) 6%, var(--surface-card));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--forge-ember) 16%, transparent);
|
||||
}
|
||||
|
||||
.kind-tag {
|
||||
font-size: 0.625rem;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-tertiary);
|
||||
padding: 2px var(--space-2);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.kind-card.active .kind-tag {
|
||||
color: var(--forge-ember-deep);
|
||||
border-color: color-mix(in srgb, var(--forge-ember) 40%, transparent);
|
||||
}
|
||||
|
||||
.kind-name {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.kind-hint {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
line-height: var(--leading-normal);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,240 @@
|
||||
<!--
|
||||
Static source form — Gitea Pages-alike with optional Deno runtime mode.
|
||||
Delegates the git-discovery block (provider/repo/branch/token + folder
|
||||
tree) to StaticDiscoveryWizard, then adds the static-only mode radio and
|
||||
the render-markdown toggle.
|
||||
|
||||
The parent owns the `StaticFormState` (from `$lib/workload/sourceForms`)
|
||||
and binds it here; serialization to `source_config` is done by the parent
|
||||
via `staticToConfig` so the shape (incl. preserved storage_* keys) stays
|
||||
byte-identical. `StaticFormState extends GitSourceState`, so the same
|
||||
object is bound straight into the wizard's `git` slice.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { StaticFormState } from '$lib/workload/sourceForms';
|
||||
import type { FolderEntry } from '$lib/api';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import StaticDiscoveryWizard from '$lib/components/workload/StaticDiscoveryWizard.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
form: StaticFormState;
|
||||
/** Flip to the raw-JSON editor (owned by the parent). */
|
||||
onAdvanced: () => void;
|
||||
/**
|
||||
* Transient discovery status — OPTIONAL pass-through to
|
||||
* StaticDiscoveryWizard. Each defaults to this component's own
|
||||
* internal `$state`, so a parent that doesn't bind them gets the
|
||||
* original "resets on remount" behaviour (this is what the detail/
|
||||
* edit page `apps/[id]` relies on — it binds none of these). The
|
||||
* create wizard `apps/new` binds them up to the PAGE so the loaded
|
||||
* tree + detect/test pills + mode override survive the form
|
||||
* unmounting under the Advanced-JSON / source-kind toggles.
|
||||
*/
|
||||
modeUserOverride?: boolean;
|
||||
treeLoaded?: boolean;
|
||||
tree?: FolderEntry[];
|
||||
detectStatus?: 'idle' | 'pending' | 'ok' | 'error';
|
||||
detectError?: string;
|
||||
testStatus?: 'idle' | 'pending' | 'ok' | 'error';
|
||||
testError?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
form = $bindable(),
|
||||
onAdvanced,
|
||||
// Sentinel: once the user manually toggles the static/deno radio,
|
||||
// auto-detection stops overwriting their choice on subsequent tree loads.
|
||||
modeUserOverride = $bindable(false),
|
||||
// Reflects whether the discovery wizard has loaded a folder tree — gates
|
||||
// the "auto-detected Deno" hint exactly like the legacy
|
||||
// `staticTree.length > 0` guard did.
|
||||
treeLoaded = $bindable(false),
|
||||
tree = $bindable([]),
|
||||
detectStatus = $bindable('idle'),
|
||||
detectError = $bindable(''),
|
||||
testStatus = $bindable('idle'),
|
||||
testError = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
function onModeChange() {
|
||||
modeUserOverride = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="image-form">
|
||||
<div class="image-form-head">
|
||||
<span class="editor-title">{$t('apps.new.staticHeader')}</span>
|
||||
<button type="button" class="json-escape" onclick={onAdvanced} title={$t('apps.new.switchToJsonTitle')}>
|
||||
{$t('apps.new.advancedJson')}
|
||||
</button>
|
||||
</div>
|
||||
<StaticDiscoveryWizard
|
||||
bind:git={form}
|
||||
variant="static"
|
||||
showFolderTree={true}
|
||||
bind:folderPath={form.folderPath}
|
||||
bind:mode={form.mode}
|
||||
bind:modeUserOverride
|
||||
bind:treeLoaded
|
||||
bind:tree
|
||||
bind:detectStatus
|
||||
bind:detectError
|
||||
bind:testStatus
|
||||
bind:testError
|
||||
idPrefix="app-static"
|
||||
/>
|
||||
<fieldset class="static-mode">
|
||||
<legend class="sub-label">{$t('apps.new.staticMode')}</legend>
|
||||
<label class="radio">
|
||||
<input type="radio" name="static-mode" value="static" bind:group={form.mode} onchange={onModeChange} />
|
||||
<span>
|
||||
<strong>static</strong> {$t('apps.new.staticModeStaticDesc')}
|
||||
</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input type="radio" name="static-mode" value="deno" bind:group={form.mode} onchange={onModeChange} />
|
||||
<span>
|
||||
<strong>deno</strong> {$t('apps.new.staticModeDenoDesc')}
|
||||
</span>
|
||||
</label>
|
||||
{#if !modeUserOverride && form.mode === 'deno' && treeLoaded}
|
||||
<p class="hint static-deno-auto">{$t('apps.new.staticDenoAutoDetected')}</p>
|
||||
{/if}
|
||||
</fieldset>
|
||||
<label class="toggle-row">
|
||||
<ToggleSwitch bind:checked={form.renderMarkdown} label={$t('apps.new.staticRenderMarkdown')} />
|
||||
<span>
|
||||
<strong>{$t('apps.new.staticRenderMarkdown')}</strong> {@html $t('apps.new.staticRenderMarkdownDesc')}
|
||||
</span>
|
||||
</label>
|
||||
<p class="hint image-form-foot">{$t('apps.new.staticFoot')}</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.sub-label {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.editor-title {
|
||||
margin-left: 0.4rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
/* ── "Edit as JSON" escape hatch ─────────────────────────
|
||||
Quiet secondary text-link rather than a prominent chip, so it doesn't
|
||||
compete with the form title. */
|
||||
.json-escape {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-underline-offset: 3px;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
.json-escape:hover {
|
||||
color: var(--forge-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.json-escape:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ── Image source form shell (shared visual vocabulary) ── */
|
||||
.image-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-input);
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
.image-form-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.55rem;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.image-form-foot {
|
||||
margin-top: 0.2rem;
|
||||
padding-top: 0.55rem;
|
||||
border-top: 1px dashed var(--border-primary);
|
||||
}
|
||||
|
||||
/* ── Static source extras ────────────────────────────── */
|
||||
.static-mode {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
margin: 0;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.static-mode legend {
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
.radio {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.55rem;
|
||||
padding: 0.35rem 0;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.radio input {
|
||||
margin-top: 0.18rem;
|
||||
accent-color: var(--color-brand-500);
|
||||
}
|
||||
.radio strong,
|
||||
.toggle-row strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.55rem;
|
||||
padding: 0.35rem 0;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle-row :global(.toggle-switch) {
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.static-deno-auto {
|
||||
margin-top: 0.35rem;
|
||||
padding-top: 0.35rem;
|
||||
border-top: 1px dashed var(--border-primary);
|
||||
color: var(--color-success-dark);
|
||||
}
|
||||
:global([data-theme='dark']) .static-deno-auto {
|
||||
color: #86efac;
|
||||
}
|
||||
</style>
|
||||
+143
-15
@@ -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 <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",
|
||||
|
||||
@@ -53,7 +53,7 @@ function getNestedValue(obj: Record<string, unknown>, 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;
|
||||
|
||||
+143
-15
@@ -15,7 +15,7 @@
|
||||
"nav": {
|
||||
"dashboard": "Панель",
|
||||
"apps": "Приложения",
|
||||
"eventTriggers": "Триггеры",
|
||||
"eventTriggers": "Триггеры событий",
|
||||
"logScanRules": "Лог-правила",
|
||||
"triggers": "Триггеры",
|
||||
"proxies": "Прокси",
|
||||
@@ -23,7 +23,13 @@
|
||||
"settings": "Настройки",
|
||||
"logout": "Выйти",
|
||||
"dns": "DNS-записи",
|
||||
"containers": "Контейнеры"
|
||||
"containers": "Контейнеры",
|
||||
"sectionObserve": "Наблюдение",
|
||||
"sectionSystem": "Система",
|
||||
"closeSidebar": "Закрыть боковую панель",
|
||||
"openSidebar": "Открыть боковую панель",
|
||||
"quickNavTitle": "Нажмите «g», затем букву для перехода между разделами",
|
||||
"quickNavLabel": "быстрая навигация"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Панель управления",
|
||||
@@ -42,7 +48,11 @@
|
||||
"systemHealth": "Состояние системы",
|
||||
"daemons": "Демоны",
|
||||
"systemResources": "Системные ресурсы",
|
||||
"systemResourcesSubtitle": "CPU, память, диск и топ потребителей"
|
||||
"systemResourcesSubtitle": "CPU, память, диск и топ потребителей",
|
||||
"statSubWorkloads": "нагрузки →",
|
||||
"statSubRunning": "запущено",
|
||||
"statSubNeedAttention": "требует внимания",
|
||||
"statSubStale": "устаревшие →"
|
||||
},
|
||||
"resources": {
|
||||
"cpuCores": "Ядра CPU",
|
||||
@@ -237,6 +247,7 @@
|
||||
"deleteFailed": "Не удалось удалить реестр",
|
||||
"testFailed": "Тест подключения не удался",
|
||||
"loadFailed": "Не удалось загрузить реестры",
|
||||
"deleteTitle": "Удалить реестр?",
|
||||
"deleteConfirm": "Удалить реестр «{name}»? Это действие необратимо.",
|
||||
"healthChecking": "Проверка...",
|
||||
"healthConnected": "Подключено",
|
||||
@@ -354,6 +365,7 @@
|
||||
"createFailed": "Не удалось создать пользователя",
|
||||
"deleteFailed": "Не удалось удалить пользователя",
|
||||
"deleteConfirm": "Вы уверены, что хотите удалить этого пользователя?",
|
||||
"deleteConfirmMessage": "Удалить пользователя «{username}»? Это действие необратимо.",
|
||||
"usernameRequired": "Имя пользователя и пароль обязательны",
|
||||
"networkError": "Ошибка сети",
|
||||
"password": "Пароль"
|
||||
@@ -400,6 +412,9 @@
|
||||
"common": {
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Подтвердить",
|
||||
"close": "Закрыть",
|
||||
"toggle": "Переключить",
|
||||
"dismissNotification": "Закрыть уведомление",
|
||||
"delete": "Удалить",
|
||||
"edit": "Изменить",
|
||||
"change": "Изменить",
|
||||
@@ -429,6 +444,7 @@
|
||||
"missing": "Отсутствует"
|
||||
},
|
||||
"containers": {
|
||||
"eyebrowSuffix": "ГЛОБАЛЬНО",
|
||||
"errLoad": "Не удалось загрузить контейнеры",
|
||||
"searchPlaceholder": "Поиск по нагрузке, роли, образу, поддомену…",
|
||||
"kindFilterLabel": "Тип нагрузки",
|
||||
@@ -476,6 +492,7 @@
|
||||
},
|
||||
"stale": {
|
||||
"title": "Устаревшие контейнеры",
|
||||
"eyebrowSuffix": "УСТАРЕВШИЕ",
|
||||
"noStale": "Нет устаревших контейнеров",
|
||||
"noStaleDesc": "Все контейнеры исправны и работают.",
|
||||
"cleanup": "Очистить",
|
||||
@@ -541,13 +558,13 @@
|
||||
"unavailable": "Статистика недоступна"
|
||||
},
|
||||
"systemHealth": {
|
||||
"title": "Состояние системы",
|
||||
"containers": "Контейнеры",
|
||||
"proxies": "Прокси",
|
||||
"recentErrors": "Недавние ошибки"
|
||||
},
|
||||
"daemons": {
|
||||
"title": "Демоны",
|
||||
"notReachable": "{provider} недоступен.",
|
||||
"refresh": "Обновить",
|
||||
"refreshing": "Обновление",
|
||||
"docker": "Docker Engine",
|
||||
@@ -1110,6 +1127,10 @@
|
||||
"image": "Ссылка на образ",
|
||||
"imagePlaceholder": "registry.example.com/owner/app",
|
||||
"imageHint": "Полная ссылка на образ без тега — Tinyforge ловит новые теги, выкладываемые под этой ссылкой.",
|
||||
"browseImages": "Выбрать",
|
||||
"browseImagesHint": "Выберите образ из настроенного реестра вместо ручного ввода ссылки.",
|
||||
"browseImagesTitle": "Выбор образа",
|
||||
"browseImagesSearch": "Поиск образов…",
|
||||
"tagPattern": "Шаблон тега",
|
||||
"tagPatternPlaceholder": "*",
|
||||
"tagPatternHint": "Glob path.Match (например, v*, release-*). * совпадает с любым тегом.",
|
||||
@@ -1122,6 +1143,9 @@
|
||||
"branch": "Ветка",
|
||||
"branchPlaceholder": "main",
|
||||
"branchHint": "Только push'и, продвигающие эту ветку, дёргают триггер.",
|
||||
"branchPattern": "Шаблон ветки (preview-деплои)",
|
||||
"branchPatternPlaceholder": "feat/* или * для любой ветки",
|
||||
"branchPatternHint": "Если задан, любой push в подходящую ветку создаёт отдельный preview-деплой. Оставьте пустым, чтобы выключить.",
|
||||
"manualNote": "У ручных триггеров нет конфига. Они срабатывают только через кнопку Deploy на странице нагрузки или POST /workloads/{id}/deploy.",
|
||||
"scheduleNote": "Срабатывает по фиксированному интервалу, который ведёт внутренний планировщик Tinyforge. Внешний webhook не нужен — включите его ниже только если CI тоже должен запускать триггер вручную.",
|
||||
"intervalPresets": "Быстрые пресеты",
|
||||
@@ -1186,6 +1210,14 @@
|
||||
},
|
||||
"new": {
|
||||
"pageTitle": "Новое приложение · Tinyforge",
|
||||
"wizard": {
|
||||
"stepBasics": "Основное",
|
||||
"stepConfigure": "Настройка",
|
||||
"stepTrigger": "Триггер",
|
||||
"stepReview": "Обзор",
|
||||
"next": "Далее",
|
||||
"back": "Назад"
|
||||
},
|
||||
"backLabel": "К приложениям",
|
||||
"eyebrowSuffix": "НОВОЕ ПРИЛОЖЕНИЕ",
|
||||
"title": "Создать приложение",
|
||||
@@ -1198,6 +1230,7 @@
|
||||
"alertTag": "ОШ",
|
||||
"fieldName": "Имя",
|
||||
"fieldNameRequired": "ОБЯЗАТЕЛЬНО",
|
||||
"fieldRequired": "Обязательно",
|
||||
"fieldNamePlaceholder": "my-app",
|
||||
"fieldNameHint": "В нижнем регистре, без пробелов. Используется в именах контейнеров и поддоменах.",
|
||||
"fieldSourcePlugin": "Source-плагин",
|
||||
@@ -1207,7 +1240,7 @@
|
||||
"fieldConfigYaml": "YAML",
|
||||
"fieldConfigForm": "ФОРМА",
|
||||
"fieldConfigJson": "JSON",
|
||||
"advancedJson": "Расширенный JSON",
|
||||
"advancedJson": "Редактировать JSON",
|
||||
"backToForm": "К форме",
|
||||
"resetSample": "Сбросить к примеру",
|
||||
"switchToJsonTitle": "Переключиться на сырой JSON-редактор",
|
||||
@@ -1230,11 +1263,21 @@
|
||||
"imageRegistryLabel": "Реестр (для приватных pull-ов)",
|
||||
"imageRegistryPublic": "(публичный — без авторизации)",
|
||||
"imageRegistryHint": "Имя должно совпадать с записью на странице «Реестры». Оставьте пустым для публичных образов.",
|
||||
"imageCpu": "Лимит CPU (ядра, 0 = ∞)",
|
||||
"imageMemory": "Лимит памяти (МБ, 0 = ∞)",
|
||||
"imageCpu": "Лимит CPU",
|
||||
"imageCpuHint": "Ядра, 0 = ∞",
|
||||
"imageMemory": "Лимит памяти",
|
||||
"imageMemoryHint": "МБ, 0 = ∞",
|
||||
"imageMax": "Макс. инстансов",
|
||||
"imageMaxHint": "1 = строгий blue-green.",
|
||||
"imageFoot": "Переменные окружения и тома задаются в отдельных панелях на странице нагрузки после создания.",
|
||||
"dockerfileHeader": "dockerfile-источник · сборка из git-репозитория",
|
||||
"dockerfileBuildEyebrow": "сборка · dockerfile",
|
||||
"dockerfileContextPath": "Контекст сборки",
|
||||
"dockerfileContextPathPlaceholder": "(пусто = корень репо)",
|
||||
"dockerfilePath": "Путь к Dockerfile",
|
||||
"dockerfilePort": "Порт контейнера",
|
||||
"dockerfilePortRequired": "Укажите порт, который слушает приложение (1–65535).",
|
||||
"dockerfileFoot": "Tinyforge склонирует репо, соберёт образ из Dockerfile и запустит контейнер. Переменные окружения и тома — на странице нагрузки после создания.",
|
||||
"staticHeader": "static-источник · страницы из репозитория",
|
||||
"staticProvider": "Провайдер",
|
||||
"staticBaseUrl": "Base URL",
|
||||
@@ -1262,7 +1305,7 @@
|
||||
"staticTestConnection": "Проверить соединение",
|
||||
"staticConnectionOk": "Соединение установлено",
|
||||
"staticConnectionFailed": "Ошибка соединения: {error}",
|
||||
"staticBrowseRepos": "Выбрать репозиторий",
|
||||
"staticBrowseRepos": "Обзор",
|
||||
"staticBrowseBranches": "Выбрать ветку",
|
||||
"staticBrowseFolders": "Выбрать папку",
|
||||
"staticPickerRepoTitle": "Выбор репозитория",
|
||||
@@ -1275,6 +1318,7 @@
|
||||
"staticTreeEmpty": "В этой ветке нет папок.",
|
||||
"staticDenoAutoDetected": "Обнаружена папка <code>api/</code> — режим автоматически переключён на Deno.",
|
||||
"imageConflictTag": "ОБРАЗ УЖЕ ИСПОЛЬЗУЕТСЯ",
|
||||
"imageConflictChecking": "Проверка конфликтов…",
|
||||
"imageConflictHeading": "Этот образ уже используется в {count} нагрузке(ах):",
|
||||
"imageConflictOpenBtn": "Открыть",
|
||||
"imageConflictAcknowledgeNote": "Если это намеренно (например, отдельный этап), нажмите «Создать» ещё раз для продолжения.",
|
||||
@@ -1300,21 +1344,23 @@
|
||||
"submit": "Создать приложение",
|
||||
"submitting": "Создание…",
|
||||
"submitAnyway": "Всё равно создать",
|
||||
"unsavedChanges": "В этом приложении есть несохранённые изменения. Покинуть страницу, не создавая его?",
|
||||
"unsavedChangesTitle": "Несохранённые изменения",
|
||||
"unsavedChangesConfirm": "Покинуть",
|
||||
"errors": {
|
||||
"detectionFailed": "Не удалось определить провайдера.",
|
||||
"connectionFailed": "Ошибка соединения.",
|
||||
"reposFailed": "Не удалось загрузить репозитории.",
|
||||
"branchesFailed": "Не удалось загрузить ветки.",
|
||||
"treeFailed": "Не удалось загрузить дерево папок.",
|
||||
"detectionFailed": "Не удалось определить Git-провайдера по этому URL. Проверьте, что базовый URL верен и доступен.",
|
||||
"connectionFailed": "Не удалось подключиться к репозиторию. Проверьте URL провайдера, владельца/репозиторий и токен доступа (для приватных репозиториев).",
|
||||
"reposFailed": "Не удалось получить список репозиториев. Проверьте базовый URL и токен доступа.",
|
||||
"branchesFailed": "Не удалось получить список веток. Проверьте репозиторий и токен доступа.",
|
||||
"treeFailed": "Не удалось загрузить дерево папок. Проверьте репозиторий, ветку и токен доступа.",
|
||||
"sourceConfigInvalid": "source_config не является корректным JSON.",
|
||||
"triggerBindUnknown": "неизвестная ошибка",
|
||||
"createFailed": "Не удалось создать нагрузку.",
|
||||
"inspectFailed": "Не удалось проинспектировать образ."
|
||||
"inspectFailed": "Не удалось проинспектировать образ — убедитесь, что он скачан локально и ссылка указана верно."
|
||||
},
|
||||
"imageInspect": "Инспектировать",
|
||||
"imageInspectHint": "Подставляет порт и healthcheck из образа, чтобы не вводить вручную.",
|
||||
"imageInspectOk": "Готово — порт и healthcheck подставлены.",
|
||||
"imageInspectError": "Ошибка инспекции: {error}",
|
||||
"triggers": {
|
||||
"section": "Триггер",
|
||||
"sectionSub": "Необязательно. Выберите, откуда придёт сигнал передеплоя — слежение за реестром, git-событие или ручная кнопка.",
|
||||
@@ -1334,6 +1380,18 @@
|
||||
"pickWebhookOn": "ВЕБХУК ВКЛ",
|
||||
"skippedNote": "Триггер не будет привязан. Добавьте его из панели «Триггеры» в карточке приложения после создания.",
|
||||
"bindError": "Приложение создано, но привязка триггера не удалась: {error}. Откройте панель «Триггеры» в карточке, чтобы повторить."
|
||||
},
|
||||
"manifest": {
|
||||
"title": "Манифест",
|
||||
"name": "Имя",
|
||||
"source": "Источник",
|
||||
"trigger": "Триггер",
|
||||
"publicFace": "Публичный фронт",
|
||||
"unnamed": "(без имени)",
|
||||
"registryPublic": "публичный реестр",
|
||||
"folderRoot": "корень",
|
||||
"triggerManual": "Только вручную",
|
||||
"internalOnly": "Только внутренний"
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
@@ -1365,6 +1423,40 @@
|
||||
"unavailable": "Не удалось получить размер (контейнер мог быть остановлен).",
|
||||
"loading": "Вычисление размера…"
|
||||
},
|
||||
"buildLog": {
|
||||
"title": "Журнал сборки",
|
||||
"sub": "Живой поток вывода сборки Docker.",
|
||||
"clear": "Очистить"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Маршруты уведомлений",
|
||||
"sub": "Множественные точки доставки для событий деплоя/сборки. При пустом списке используется устаревший единственный URL.",
|
||||
"loading": "Загрузка маршрутов…",
|
||||
"empty": "Нет настроенных маршрутов уведомлений. Добавьте, чтобы получать события в отдельный канал.",
|
||||
"addFirst": "Добавить первый маршрут",
|
||||
"add": "Добавить маршрут",
|
||||
"edit": "Изменить",
|
||||
"delete": "Удалить",
|
||||
"name": "Имя",
|
||||
"namePlaceholder": "Slack #alerts",
|
||||
"url": "URL вебхука",
|
||||
"secret": "Секрет подписи",
|
||||
"secretPlaceholder": "Опционально — приёмник проверяет HMAC",
|
||||
"secretEditPlaceholder": "Оставьте пустым, чтобы сохранить текущий секрет",
|
||||
"secretHint": "HMAC-SHA256 от тела запроса, заголовок X-Hub-Signature-256.",
|
||||
"eventTypes": "Типы событий",
|
||||
"eventTypesPlaceholder": "deploy_failure,build_failure (пусто = все)",
|
||||
"eventTypesHint": "Список через запятую. Пусто — маршрут срабатывает на любое событие.",
|
||||
"enabled": "Включён",
|
||||
"save": "Сохранить",
|
||||
"saving": "Сохранение…",
|
||||
"cancel": "Отмена",
|
||||
"allEvents": "все события",
|
||||
"signed": "подписан",
|
||||
"disabled": "выключен",
|
||||
"confirmDeleteTitle": "Удалить маршрут уведомлений?",
|
||||
"confirmDeleteMessage": "Маршрут перестанет срабатывать. Устаревший URL уведомлений на workload (если задан) снова возьмёт события на себя."
|
||||
},
|
||||
"toolbar": {
|
||||
"stop": "Стоп",
|
||||
"start": "Старт",
|
||||
@@ -1455,6 +1547,19 @@
|
||||
"editStaticModeDenoDesc": "— Deno-рантайм с динамической маршрутизацией.",
|
||||
"editStaticRenderMarkdown": "Рендерить markdown",
|
||||
"editStaticRenderMarkdownDesc": "— автоматически отдавать <code>.md</code> как HTML.",
|
||||
"editDockerfileHeader": "dockerfile-источник · сборка из git-репозитория",
|
||||
"editDockerfileBuildEyebrow": "сборка · dockerfile",
|
||||
"editDockerfileContextPath": "Контекст сборки",
|
||||
"editDockerfileContextPathPlaceholder": "(пусто = корень репо)",
|
||||
"editDockerfilePath": "Путь к Dockerfile",
|
||||
"editDockerfilePort": "Порт контейнера",
|
||||
"editTestConnection": "Проверить соединение",
|
||||
"editTestConnectionOk": "Соединение установлено",
|
||||
"editTestConnectionFailed": "Ошибка соединения: {error}",
|
||||
"editTestConnectionUnknownError": "Неизвестная ошибка",
|
||||
"overrideKeyUnitSingular": "КЛЮЧ",
|
||||
"overrideKeyUnitPlural": "КЛЮЧИ",
|
||||
"editTestConnectionIncomplete": "Заполните провайдера, base URL, owner и name.",
|
||||
"editSourceJsonHeader": "source_config.json",
|
||||
"editSourceJsonAria": "Конфигурация source-плагина (JSON)",
|
||||
"editPublicFaces": "Публичные фронты",
|
||||
@@ -1494,6 +1599,29 @@
|
||||
"chainPromoteButton": "Продвинуть от родителя",
|
||||
"chainPromoting": "Продвижение…",
|
||||
"chainHint": "Задайте <code>parent_workload_id</code> у нагрузки, чтобы построить цепочку. Дочерние image-источники могут одним кликом продвинуть текущий запущенный тег родителя.",
|
||||
"previews": {
|
||||
"title": "Превью-окружения",
|
||||
"subEmpty": "нет активных превью",
|
||||
"subCountOne": "1 активное превью",
|
||||
"subCount": "активных превью: {count}",
|
||||
"tag": "Превью",
|
||||
"tagTitle": "Превью-развёртывание этой нагрузки для отдельной ветки",
|
||||
"armedEmpty": "Нет активных превью — отправьте пуш в ветку, соответствующую шаблону",
|
||||
"noneEmpty": "Пока нет активных превью.",
|
||||
"open": "Открыть",
|
||||
"noUrl": "нет публичного URL",
|
||||
"teardown": "Удалить",
|
||||
"teardownTitle": "Удалить превью?",
|
||||
"teardownMessage": "Это удалит превью для ветки «{name}» вместе с его контейнерами и маршрутами прокси. Новый пуш в эту ветку создаст его заново.",
|
||||
"teardownConfirm": "Удалить",
|
||||
"teardownPending": "Удаление…",
|
||||
"teardownFailed": "Не удалось удалить",
|
||||
"stateRunning": "Работает",
|
||||
"statePending": "Запускается",
|
||||
"stateStopped": "Остановлено",
|
||||
"stateUnknown": "Неизвестно",
|
||||
"hint": "Превью создаются автоматически, когда пуш приходит в ветку, соответствующую <code>branch_pattern</code> git-триггера, и удаляются при удалении ветки. Каждое получает собственный поддомен с префиксом-слагом."
|
||||
},
|
||||
"volumesTitle": "Тома",
|
||||
"volumesEmpty": "Нет монтирований",
|
||||
"volumesCountSingular": "{count} монтирование",
|
||||
|
||||
+37
-3
@@ -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<T = unknown> {
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
+14
-4
@@ -111,19 +111,29 @@ export interface ApiEnvelope<T> {
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(jsonText);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Parse for preserve helpers; malformed JSON -> null (caller guards). */
|
||||
function tryParse(jsonText: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(jsonText);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} 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<string, string>;
|
||||
volumes: unknown[];
|
||||
} {
|
||||
const existing = tryParse(existingJson);
|
||||
let env: Record<string, string> = {};
|
||||
let volumes: unknown[] = [];
|
||||
if (existing) {
|
||||
if (existing.env && typeof existing.env === 'object') {
|
||||
env = existing.env as Record<string, string>;
|
||||
}
|
||||
if (Array.isArray(existing.volumes)) {
|
||||
volumes = existing.volumes;
|
||||
}
|
||||
}
|
||||
return { env, volumes };
|
||||
}
|
||||
|
||||
export function imageToConfig(s: ImageFormState, existingJson: string): Record<string, unknown> {
|
||||
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<string, unknown> {
|
||||
return { compose_yaml: s.yaml, compose_project_name: s.projectName };
|
||||
}
|
||||
|
||||
export function staticToConfig(s: StaticFormState, existingJson: string): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {
|
||||
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<string> = 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<string, unknown> {
|
||||
const preserved: Record<string, unknown> = {};
|
||||
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, unknown>): 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;
|
||||
}
|
||||
@@ -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<NavSection, string> = $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 @@
|
||||
<button
|
||||
class="ml-auto rounded-md p-1 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] lg:hidden"
|
||||
onclick={() => { sidebarOpen = false; }}
|
||||
aria-label="Close sidebar"
|
||||
aria-label={$t('nav.closeSidebar')}
|
||||
>
|
||||
<IconX size={20} />
|
||||
</button>
|
||||
@@ -269,8 +293,12 @@
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 space-y-0.5 px-3 py-3">
|
||||
{#each navItems as item}
|
||||
{#each navItems as item, idx}
|
||||
{@const active = isActive(item.href, $page.url.pathname)}
|
||||
{@const newSection = sectionStart(idx)}
|
||||
{#if newSection && sectionLabels[newSection]}
|
||||
<div class="nav-section-eyebrow" aria-hidden="true">{sectionLabels[newSection]}</div>
|
||||
{/if}
|
||||
<a
|
||||
href={item.href}
|
||||
class="nav-item group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
|
||||
@@ -291,7 +319,7 @@
|
||||
{:else if item.icon === 'settings'}
|
||||
<IconSettings size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{/if}
|
||||
<span class="nav-label">{item.labelOverride ?? $t(item.labelKey)}</span>
|
||||
<span class="nav-label">{$t(item.labelKey)}</span>
|
||||
|
||||
{#if item.countKey}
|
||||
{@const count = $navCounts[item.countKey]}
|
||||
@@ -338,9 +366,9 @@
|
||||
<span class="clock-suffix">{clockOffset}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p class="forge-nav-hint" title="Press 'g' then a letter to jump between sections">
|
||||
<p class="forge-nav-hint" title={$t('nav.quickNavTitle')}>
|
||||
<kbd>g</kbd><span class="arr">→</span><kbd>d</kbd><kbd>a</kbd><kbd>n</kbd><kbd>t</kbd>
|
||||
<span class="hint-label">quick-nav</span>
|
||||
<span class="hint-label">{$t('nav.quickNavLabel')}</span>
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -352,7 +380,7 @@
|
||||
<button
|
||||
class="rounded-md p-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] transition-colors"
|
||||
onclick={() => { sidebarOpen = true; }}
|
||||
aria-label="Open sidebar"
|
||||
aria-label={$t('nav.openSidebar')}
|
||||
>
|
||||
<IconMenu size={22} />
|
||||
</button>
|
||||
@@ -550,6 +578,15 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.nav-section-eyebrow {
|
||||
margin: 0.85rem 0.25rem 0.25rem;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.nav-active {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary) !important;
|
||||
|
||||
@@ -70,7 +70,12 @@
|
||||
return () => { loadController?.abort(); };
|
||||
});
|
||||
|
||||
const totalWorkloads = $derived(workloads.length);
|
||||
// Plugin-native workloads only. Legacy pre-cutover rows (kind project/
|
||||
// stack/site) carry an empty source_kind and have no UI home post-cutover,
|
||||
// so they must not inflate the headline count or the recent strip — this
|
||||
// matches the /apps list, which shows the same set.
|
||||
const pluginWorkloads = $derived(workloads.filter((w) => w.source_kind !== ''));
|
||||
const totalWorkloads = $derived(pluginWorkloads.length);
|
||||
const totalRunning = $derived(containers.filter((c) => c.state === 'running').length);
|
||||
const totalFailed = $derived(containers.filter((c) => c.state === 'failed').length);
|
||||
const totalStale = $derived(staleContainers.length);
|
||||
@@ -78,7 +83,7 @@
|
||||
// Latest 6 workloads by updated_at desc — enough for an at-a-glance
|
||||
// recent-activity strip without paging the entire list.
|
||||
const recentWorkloads = $derived(
|
||||
[...workloads]
|
||||
[...pluginWorkloads]
|
||||
.sort((a, b) => (b.updated_at ?? '').localeCompare(a.updated_at ?? ''))
|
||||
.slice(0, 6)
|
||||
);
|
||||
@@ -113,7 +118,7 @@
|
||||
{/snippet}
|
||||
<ForgeHero
|
||||
eyebrow="THE FORGE"
|
||||
eyebrowSuffix="DASHBOARD"
|
||||
eyebrowSuffix={$t('nav.dashboard').toUpperCase()}
|
||||
title={$t('dashboard.title')}
|
||||
accent="."
|
||||
size="lg"
|
||||
@@ -125,22 +130,22 @@
|
||||
<a href="/apps" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.totalWorkloads')}</span>
|
||||
<span class="forge-stat-value">{String(totalWorkloads).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">workloads →</span>
|
||||
<span class="forge-stat-sub">{$t('dashboard.statSubWorkloads')}</span>
|
||||
</a>
|
||||
<a href="/containers" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.runningContainers')}</span>
|
||||
<span class="forge-stat-value accent">{String(totalRunning).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">running</span>
|
||||
<span class="forge-stat-sub">{$t('dashboard.statSubRunning')}</span>
|
||||
</a>
|
||||
<a href="/containers?state=failed" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.failedContainers')}</span>
|
||||
<span class="forge-stat-value" class:fail={totalFailed > 0}>{String(totalFailed).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">need attention</span>
|
||||
<span class="forge-stat-sub">{$t('dashboard.statSubNeedAttention')}</span>
|
||||
</a>
|
||||
<a href="/containers/stale" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.staleContainers')}</span>
|
||||
<span class="forge-stat-value" class:warn={totalStale > 0}>{String(totalStale).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">stale →</span>
|
||||
<span class="forge-stat-sub">{$t('dashboard.statSubStale')}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
let error = $state('');
|
||||
let filter = $state<'all' | string>('all');
|
||||
|
||||
// Plugin-native rows are the ones with both source_kind and trigger_kind
|
||||
// populated. Legacy project/stack/site rows still appear in
|
||||
// /api/workloads — those are surfaced under their own sections.
|
||||
const pluginRows = $derived(
|
||||
workloads.filter((w) => w.source_kind !== '' && w.trigger_kind !== '')
|
||||
);
|
||||
// Plugin-native rows are those with a source_kind. trigger_kind is no
|
||||
// longer on the workload row (triggers are first-class bindings now), so a
|
||||
// manual/binding-trigger app legitimately has an empty trigger_kind and
|
||||
// must NOT be filtered out. Legacy pre-cutover project/stack/site rows
|
||||
// carry an empty source_kind and are excluded — they have no UI home.
|
||||
const pluginRows = $derived(workloads.filter((w) => w.source_kind !== ''));
|
||||
const filtered = $derived(
|
||||
filter === 'all' ? pluginRows : pluginRows.filter((w) => w.source_kind === filter)
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+648
-1766
File diff suppressed because it is too large
Load Diff
@@ -152,7 +152,7 @@
|
||||
{/snippet}
|
||||
<ForgeHero
|
||||
backHref="/"
|
||||
eyebrowSuffix="GLOBAL"
|
||||
eyebrowSuffix={$t('containers.eyebrowSuffix')}
|
||||
title={$t('nav.containers')}
|
||||
size="lg"
|
||||
toolbar={heroToolbar}
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
{/snippet}
|
||||
<ForgeHero
|
||||
backHref="/"
|
||||
eyebrowSuffix="STALE"
|
||||
eyebrowSuffix={$t('stale.eyebrowSuffix')}
|
||||
title={$t('stale.title')}
|
||||
size="lg"
|
||||
toolbar={heroToolbar}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import * as api from '$lib/api';
|
||||
@@ -145,7 +144,12 @@
|
||||
testResult !== null && testResult.status_code >= 200 && testResult.status_code < 300
|
||||
);
|
||||
|
||||
onMount(load);
|
||||
// Reload when route id changes — see the apps/[id] page for the
|
||||
// same rationale (SvelteKit reuses the component instance).
|
||||
$effect(() => {
|
||||
const _ = id;
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
@@ -227,7 +227,7 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
<ForgeHero
|
||||
eyebrowSuffix="EVENTS"
|
||||
eyebrowSuffix={$t('nav.events').toUpperCase()}
|
||||
title={$t('events.title')}
|
||||
size="lg"
|
||||
toolbar={heroToolbar}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import * as api from '$lib/api';
|
||||
@@ -152,7 +151,13 @@
|
||||
return $t('logscan.scope.global');
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
// Reload when route id changes — SvelteKit reuses the component
|
||||
// instance across [id] transitions, so onMount alone would leave
|
||||
// stale data when navigating between sibling pages.
|
||||
$effect(() => {
|
||||
const _ = id;
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
|
||||
<div class="space-y-6">
|
||||
<ForgeHero
|
||||
eyebrowSuffix="PROXIES"
|
||||
eyebrowSuffix={$t('nav.proxies').toUpperCase()}
|
||||
title={$t('proxies.title')}
|
||||
lede={$t('proxies.description')}
|
||||
size="lg"
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
<div class="mx-auto max-w-5xl">
|
||||
<ForgeHero
|
||||
eyebrowSuffix="SETTINGS"
|
||||
eyebrowSuffix={$t('nav.settings').toUpperCase()}
|
||||
title={$t('settings.title')}
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
@@ -5,30 +5,19 @@
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import {
|
||||
getAuthSettings,
|
||||
updateAuthSettings,
|
||||
listUsers as apiListUsers,
|
||||
createUser,
|
||||
deleteUser as apiDeleteUser,
|
||||
ApiError
|
||||
ApiError,
|
||||
type AuthSettings,
|
||||
type AuthUser
|
||||
} from '$lib/api';
|
||||
|
||||
interface AuthSettings {
|
||||
auth_mode: string;
|
||||
oidc_client_id: string;
|
||||
oidc_client_secret: string;
|
||||
oidc_issuer_url: string;
|
||||
oidc_redirect_url: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
created_at: string;
|
||||
}
|
||||
type User = AuthUser;
|
||||
|
||||
let loading = $state(true);
|
||||
let settings = $state<AuthSettings>({
|
||||
@@ -92,8 +81,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteUser(id: string) {
|
||||
if (!confirm($t('settingsAuth.deleteConfirm'))) return;
|
||||
// Replace native window.confirm() with the project's ConfirmDialog so
|
||||
// destructive user-delete matches the rest of the app's modal pattern
|
||||
// (CLAUDE.md `feedback_secret_actions_use_dialog`).
|
||||
let confirmDeleteUserId = $state<string | null>(null);
|
||||
let confirmDeleteUsername = $state('');
|
||||
|
||||
function askDeleteUser(id: string, username: string) {
|
||||
confirmDeleteUserId = id;
|
||||
confirmDeleteUsername = username;
|
||||
}
|
||||
|
||||
async function handleDeleteUser() {
|
||||
const id = confirmDeleteUserId;
|
||||
confirmDeleteUserId = null;
|
||||
if (!id) return;
|
||||
try {
|
||||
await apiDeleteUser(id);
|
||||
await loadUsers();
|
||||
@@ -198,7 +200,7 @@
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-sm text-[var(--text-secondary)]">{user.created_at}</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<button onclick={() => handleDeleteUser(user.id)} class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" title={$t('common.delete')} aria-label={$t('common.delete')}>
|
||||
<button onclick={() => askDeleteUser(user.id, user.username)} class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" title={$t('common.delete')} aria-label={$t('common.delete')}>
|
||||
<IconTrash size={16} />
|
||||
</button>
|
||||
</td>
|
||||
@@ -235,3 +237,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDeleteUserId !== null}
|
||||
title={$t('settingsAuth.deleteConfirm')}
|
||||
message={confirmDeleteUsername ? $t('settingsAuth.deleteConfirmMessage', { username: confirmDeleteUsername }) : ''}
|
||||
confirmLabel={$t('common.delete')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={handleDeleteUser}
|
||||
oncancel={() => { confirmDeleteUserId = null; }}
|
||||
/>
|
||||
|
||||
@@ -101,7 +101,9 @@
|
||||
async function openCertPicker() {
|
||||
loadingCerts = true;
|
||||
try {
|
||||
const certs = await listNpmCertificates();
|
||||
// Browse = force-refresh so the list reflects any certs added in NPM
|
||||
// since the cached snapshot.
|
||||
const certs = await listNpmCertificates(true);
|
||||
if (certs.length === 0) { toasts.info($t('settingsGeneral.noCertificatesFound')); return; }
|
||||
certPickerItems = certs.map((cert): EntityPickerItem => ({
|
||||
value: String(cert.id),
|
||||
@@ -132,7 +134,8 @@
|
||||
async function openAccessListPicker() {
|
||||
loadingAccessLists = true;
|
||||
try {
|
||||
const lists = await listNpmAccessLists();
|
||||
// Browse = force-refresh (see openCertPicker).
|
||||
const lists = await listNpmAccessLists(true);
|
||||
if (lists.length === 0) { toasts.info($t('settingsNpm.noAccessLists')); return; }
|
||||
accessListPickerItems = lists.map((al): EntityPickerItem => ({
|
||||
value: String(al.id),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import * as api from '$lib/api';
|
||||
@@ -257,7 +257,13 @@
|
||||
return v === key ? k : v;
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
// SvelteKit reuses this component instance across /triggers/A → /triggers/B,
|
||||
// so onMount(load) would only fire once. The id-keyed effect reloads on
|
||||
// param change.
|
||||
$effect(() => {
|
||||
const _ = id;
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
// Isolated from vite.config.ts on purpose: the unit suite covers pure
|
||||
// TypeScript modules (e.g. lib/workload/sourceForms.ts), so it must NOT
|
||||
// load the SvelteKit / Tailwind plugins. Keeping this config plain makes
|
||||
// the suite fast and decoupled from the SvelteKit build graph.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.ts'],
|
||||
environment: 'node'
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user