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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user