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

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

Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
This commit is contained in:
2026-05-29 02:09:54 +03:00
parent 956943edbb
commit 410a131cec
112 changed files with 13285 additions and 2765 deletions
+353
View File
@@ -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;
}