mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Canvas mode in the template editor (lazy-loaded React Flow): service nodes for web/worker/cron/database, connect handles to set depends_on, per-node inspector (image/repo, engine, port, health check, env, volumes), live validation (dup keys, missing deps, cycles, missing source), and an Apply step that serializes back to the version-2 graph config JSON. - builder-model.ts is pure: config↔canvas round-trip, layered auto-layout, flat-legacy→single-node migration, validation — 16 vitest cases cover all of it - node positions persist under config.canvas (backend ignores unknown config keys, so no schema change) - flat legacy templates open as a single node so nothing is lost on edit Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
242 lines
9.1 KiB
TypeScript
242 lines
9.1 KiB
TypeScript
// Pure model for the visual template builder: converts between the
|
|
// template `config` JSON (version 2 service graph, or a legacy flat
|
|
// single-service config) and canvas state (nodes + positions), and
|
|
// validates the graph before save. No React, no DOM — directly testable.
|
|
|
|
export type BuilderServiceType = 'web' | 'worker' | 'cron' | 'database';
|
|
|
|
export type BuilderService = {
|
|
key: string;
|
|
name?: string;
|
|
type: BuilderServiceType;
|
|
runtime?: string;
|
|
repo?: string;
|
|
branch?: string;
|
|
build_command?: string;
|
|
start_command?: string;
|
|
port?: number;
|
|
health_check?: string;
|
|
builder?: string;
|
|
environment?: Record<string, string>;
|
|
volumes?: { type?: string; source: string; target: string; read_only?: boolean }[];
|
|
depends_on?: string[];
|
|
};
|
|
|
|
export type BuilderCanvas = {
|
|
positions: Record<string, { x: number; y: number }>;
|
|
};
|
|
|
|
export type BuilderNode = {
|
|
key: string;
|
|
service: BuilderService;
|
|
position: { x: number; y: number };
|
|
};
|
|
|
|
const KEY_RE = /^[a-z][a-z0-9_-]*$/;
|
|
const COL_X = 60;
|
|
const ROW_Y = 200;
|
|
|
|
function asService(raw: unknown): BuilderService | null {
|
|
if (!raw || typeof raw !== 'object') return null;
|
|
const s = raw as Record<string, unknown>;
|
|
const key = typeof s.key === 'string' ? s.key : '';
|
|
if (!key) return null;
|
|
const type = typeof s.type === 'string' ? s.type : 'web';
|
|
return {
|
|
key,
|
|
name: typeof s.name === 'string' ? s.name : undefined,
|
|
type: (['web', 'worker', 'cron', 'database'] as const).includes(type as BuilderServiceType)
|
|
? (type as BuilderServiceType)
|
|
: 'web',
|
|
runtime: typeof s.runtime === 'string' ? s.runtime : undefined,
|
|
repo: typeof s.repo === 'string' ? s.repo : undefined,
|
|
branch: typeof s.branch === 'string' ? s.branch : undefined,
|
|
build_command: typeof s.build_command === 'string' ? s.build_command : undefined,
|
|
start_command: typeof s.start_command === 'string' ? s.start_command : undefined,
|
|
port: typeof s.port === 'number' ? s.port : undefined,
|
|
health_check: typeof s.health_check === 'string' ? s.health_check : undefined,
|
|
builder: typeof s.builder === 'string' ? s.builder : undefined,
|
|
environment:
|
|
s.environment && typeof s.environment === 'object' && !Array.isArray(s.environment)
|
|
? Object.fromEntries(
|
|
Object.entries(s.environment as Record<string, unknown>).map(([k, v]) => [k, String(v)]),
|
|
)
|
|
: undefined,
|
|
volumes: Array.isArray(s.volumes)
|
|
? (s.volumes
|
|
.map((v) => {
|
|
if (!v || typeof v !== 'object') return null;
|
|
const vol = v as Record<string, unknown>;
|
|
if (typeof vol.target !== 'string' || !vol.target) return null;
|
|
return {
|
|
type: typeof vol.type === 'string' ? vol.type : undefined,
|
|
source: typeof vol.source === 'string' ? vol.source : '',
|
|
target: vol.target,
|
|
read_only: typeof vol.read_only === 'boolean' ? vol.read_only : undefined,
|
|
};
|
|
})
|
|
.filter((v): v is NonNullable<typeof v> => v !== null)
|
|
)
|
|
: undefined,
|
|
depends_on: Array.isArray(s.depends_on)
|
|
? s.depends_on.filter((d): d is string => typeof d === 'string')
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
// Flat legacy configs ({type, runtime, port, …} with no services array)
|
|
// map to a single service node so the builder can edit them without
|
|
// losing fields the round-trip would drop.
|
|
function flatToService(config: Record<string, unknown>): BuilderService | null {
|
|
const type = typeof config.type === 'string' ? config.type : '';
|
|
const runtime = typeof config.runtime === 'string' ? config.runtime : '';
|
|
if (!type && !runtime) return null;
|
|
const svc = asService({
|
|
key: 'app',
|
|
type: type || 'web',
|
|
runtime,
|
|
port: config.port,
|
|
health_check: config.health_check,
|
|
build_command: config.build_command,
|
|
start_command: config.start_command,
|
|
environment: config.environment,
|
|
});
|
|
return svc;
|
|
}
|
|
|
|
// Layered auto-layout: depth = longest depends_on chain; column per
|
|
// depth, row per member. Deterministic so tests can assert positions.
|
|
export function layoutPositions(services: BuilderService[]): Record<string, { x: number; y: number }> {
|
|
const depth = new Map<string, number>();
|
|
const visiting = new Set<string>();
|
|
const byKey = new Map(services.map((s) => [s.key, s]));
|
|
const depthOf = (key: string): number => {
|
|
const memo = depth.get(key);
|
|
if (memo !== undefined) return memo;
|
|
if (visiting.has(key)) return 0; // cycle guard
|
|
visiting.add(key);
|
|
const deps = byKey.get(key)?.depends_on ?? [];
|
|
let d = 0;
|
|
for (const dep of deps) {
|
|
if (byKey.has(dep)) d = Math.max(d, depthOf(dep) + 1);
|
|
}
|
|
visiting.delete(key);
|
|
depth.set(key, d);
|
|
return d;
|
|
};
|
|
const rows = new Map<number, number>();
|
|
const positions: Record<string, { x: number; y: number }> = {};
|
|
for (const svc of services) {
|
|
const d = depthOf(svc.key);
|
|
const row = rows.get(d) ?? 0;
|
|
rows.set(d, row + 1);
|
|
positions[svc.key] = { x: COL_X + d * 300, y: 40 + row * ROW_Y };
|
|
}
|
|
return positions;
|
|
}
|
|
|
|
export function canvasFromConfig(config: Record<string, unknown>): BuilderNode[] {
|
|
const canvas = (config.canvas ?? {}) as Partial<BuilderCanvas>;
|
|
const saved = canvas.positions ?? {};
|
|
const rawServices = Array.isArray(config.services) ? config.services : null;
|
|
const services = rawServices
|
|
? rawServices.map(asService).filter((s): s is BuilderService => s !== null)
|
|
: [];
|
|
if (services.length === 0) {
|
|
const flat = flatToService(config);
|
|
if (flat) services.push(flat);
|
|
}
|
|
const auto = layoutPositions(services);
|
|
return services.map((service) => ({
|
|
key: service.key,
|
|
service,
|
|
position: saved[service.key] ?? auto[service.key] ?? { x: COL_X, y: 40 },
|
|
}));
|
|
}
|
|
|
|
export function configFromCanvas(
|
|
nodes: BuilderNode[],
|
|
rest: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const services = nodes.map((node) => {
|
|
const svc = node.service;
|
|
const out: Record<string, unknown> = { key: svc.key, type: svc.type };
|
|
if (svc.name) out.name = svc.name;
|
|
if (svc.runtime) out.runtime = svc.runtime;
|
|
if (svc.repo) out.repo = svc.repo;
|
|
if (svc.branch) out.branch = svc.branch;
|
|
if (svc.build_command) out.build_command = svc.build_command;
|
|
if (svc.builder) out.builder = svc.builder;
|
|
if (svc.start_command) out.start_command = svc.start_command;
|
|
if (svc.port) out.port = svc.port;
|
|
if (svc.health_check) out.health_check = svc.health_check;
|
|
if (svc.environment && Object.keys(svc.environment).length > 0) out.environment = svc.environment;
|
|
if (svc.volumes && svc.volumes.length > 0) out.volumes = svc.volumes;
|
|
if (svc.depends_on && svc.depends_on.length > 0) out.depends_on = svc.depends_on;
|
|
return out;
|
|
});
|
|
const positions: Record<string, { x: number; y: number }> = {};
|
|
for (const node of nodes) positions[node.key] = node.position;
|
|
const restConfig = { ...rest };
|
|
// Flat (v1) fields and stale graph keys are dropped — the graph wins.
|
|
for (const k of ['type', 'runtime', 'build_command', 'start_command', 'port', 'health_check', 'environment', 'dockerfile', 'nixpacks_config', 'services', 'canvas']) {
|
|
delete restConfig[k];
|
|
}
|
|
return {
|
|
version: 2,
|
|
...restConfig,
|
|
services,
|
|
canvas: { positions },
|
|
};
|
|
}
|
|
|
|
export function validateGraph(nodes: BuilderNode[]): string[] {
|
|
const errors: string[] = [];
|
|
const keys = new Set<string>();
|
|
for (const node of nodes) {
|
|
if (!KEY_RE.test(node.service.key)) {
|
|
errors.push(`"${node.service.key || '(empty)'}" is not a valid key (lowercase, digits, - or _)`);
|
|
}
|
|
if (keys.has(node.service.key)) errors.push(`duplicate key "${node.service.key}"`);
|
|
keys.add(node.service.key);
|
|
if (node.service.type === 'database' && !node.service.runtime) {
|
|
errors.push(`"${node.service.key}" is a database with no engine selected`);
|
|
}
|
|
if (node.service.type !== 'database' && !node.service.runtime && !node.service.repo) {
|
|
errors.push(`"${node.service.key}" needs an image (runtime) or a git repo`);
|
|
}
|
|
}
|
|
for (const node of nodes) {
|
|
for (const dep of node.service.depends_on ?? []) {
|
|
if (dep === node.service.key) errors.push(`"${dep}" depends on itself`);
|
|
else if (!keys.has(dep)) errors.push(`"${node.service.key}" depends on unknown service "${dep}"`);
|
|
}
|
|
}
|
|
// Cycle detection — a cycle makes topo sort fail at deploy time.
|
|
const state = new Map<string, 0 | 1 | 2>();
|
|
const byKey = new Map(nodes.map((n) => [n.service.key, n]));
|
|
const visit = (key: string, trail: string[]): void => {
|
|
const s = state.get(key) ?? 0;
|
|
if (s === 2) return;
|
|
if (s === 1) {
|
|
errors.push(`dependency cycle: ${[...trail, key].join(' → ')}`);
|
|
return;
|
|
}
|
|
state.set(key, 1);
|
|
for (const dep of byKey.get(key)?.service.depends_on ?? []) {
|
|
if (byKey.has(dep)) visit(dep, [...trail, key]);
|
|
}
|
|
state.set(key, 2);
|
|
};
|
|
for (const node of nodes) visit(node.service.key, []);
|
|
return errors;
|
|
}
|
|
|
|
export function newServiceKey(type: BuilderServiceType, existing: Set<string>): string {
|
|
const base = type === 'database' ? 'db' : type;
|
|
if (!existing.has(base)) return base;
|
|
let i = 2;
|
|
while (existing.has(`${base}${i}`)) i += 1;
|
|
return `${base}${i}`;
|
|
}
|