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