mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(templates): visual graph builder (#74)
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>
This commit is contained in:
1 parent
a6b8397dd8
commit
ca8eab4a65
5 files changed
+965
-14
No files matched your search
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
canvasFromConfig,
|
||||
configFromCanvas,
|
||||
layoutPositions,
|
||||
newServiceKey,
|
||||
validateGraph,
|
||||
type BuilderNode,
|
||||
} from './builder-model';
|
||||
|
||||
const plausibleConfig = {
|
||||
version: 2,
|
||||
services: [
|
||||
{ key: 'db', type: 'database', runtime: 'postgresql', environment: { POSTGRES_DB: 'plausible' } },
|
||||
{ key: 'events', type: 'database', runtime: 'clickhouse' },
|
||||
{
|
||||
key: 'web',
|
||||
name: 'plausible',
|
||||
type: 'web',
|
||||
runtime: 'ghcr.io/plausible/community-edition:v2',
|
||||
port: 8000,
|
||||
health_check: '/api/health',
|
||||
depends_on: ['db', 'events'],
|
||||
environment: { DATABASE_URL: '{{service.db.url}}' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('canvasFromConfig', () => {
|
||||
it('parses graph services into nodes with auto-layout', () => {
|
||||
const nodes = canvasFromConfig(plausibleConfig);
|
||||
expect(nodes.map((n) => n.key)).toEqual(['db', 'events', 'web']);
|
||||
// databases are roots (depth 0), web depends on both (depth 1)
|
||||
expect(nodes[0].position.x).toBeLessThan(nodes[2].position.x);
|
||||
expect(nodes[1].position.y).toBeGreaterThan(nodes[0].position.y);
|
||||
});
|
||||
|
||||
it('restores saved canvas positions', () => {
|
||||
const nodes = canvasFromConfig({
|
||||
...plausibleConfig,
|
||||
canvas: { positions: { db: { x: 500, y: 500 } } },
|
||||
});
|
||||
expect(nodes[0].position).toEqual({ x: 500, y: 500 });
|
||||
});
|
||||
|
||||
it('migrates flat legacy configs to a single node', () => {
|
||||
const nodes = canvasFromConfig({
|
||||
type: 'web',
|
||||
runtime: 'nginx:latest',
|
||||
port: 80,
|
||||
environment: { FOO: 'bar' },
|
||||
});
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0].service).toMatchObject({ key: 'app', type: 'web', runtime: 'nginx:latest', port: 80 });
|
||||
});
|
||||
|
||||
it('drops entries without a key', () => {
|
||||
const nodes = canvasFromConfig({ version: 2, services: [{ type: 'web', runtime: 'x' }, { key: 'ok', type: 'web', runtime: 'y' }] });
|
||||
expect(nodes.map((n) => n.key)).toEqual(['ok']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('configFromCanvas', () => {
|
||||
it('round-trips a graph config', () => {
|
||||
const nodes = canvasFromConfig(plausibleConfig);
|
||||
const config = configFromCanvas(nodes, plausibleConfig);
|
||||
const services = config.services as Record<string, unknown>[];
|
||||
expect(config.version).toBe(2);
|
||||
expect(services).toHaveLength(3);
|
||||
const web = services.find((s) => s.key === 'web')!;
|
||||
expect(web.depends_on).toEqual(['db', 'events']);
|
||||
expect(web.port).toBe(8000);
|
||||
expect((web.environment as Record<string, string>).DATABASE_URL).toBe('{{service.db.url}}');
|
||||
});
|
||||
|
||||
it('persists node positions under config.canvas', () => {
|
||||
const nodes = canvasFromConfig(plausibleConfig);
|
||||
nodes[0].position = { x: 321, y: 123 };
|
||||
const config = configFromCanvas(nodes, {});
|
||||
expect((config.canvas as { positions: Record<string, { x: number; y: number }> }).positions.db).toEqual({ x: 321, y: 123 });
|
||||
// and parses back to the same spot
|
||||
expect(canvasFromConfig(config)[0].position).toEqual({ x: 321, y: 123 });
|
||||
});
|
||||
|
||||
it('strips flat fields when converting a legacy config', () => {
|
||||
const nodes = canvasFromConfig({ type: 'web', runtime: 'nginx:latest', port: 80 });
|
||||
const config = configFromCanvas(nodes, { type: 'web', runtime: 'nginx:latest', port: 80 });
|
||||
expect(config.type).toBeUndefined();
|
||||
expect(config.runtime).toBeUndefined();
|
||||
expect((config.services as unknown[]).length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layoutPositions', () => {
|
||||
it('depths follow the depends_on chain', () => {
|
||||
const pos = layoutPositions([
|
||||
{ key: 'a', type: 'database', runtime: 'postgresql' },
|
||||
{ key: 'b', type: 'web', runtime: 'img', depends_on: ['a'] },
|
||||
{ key: 'c', type: 'web', runtime: 'img', depends_on: ['b'] },
|
||||
]);
|
||||
expect(pos.a.x).toBeLessThan(pos.b.x);
|
||||
expect(pos.b.x).toBeLessThan(pos.c.x);
|
||||
});
|
||||
|
||||
it('a dependency cycle does not hang', () => {
|
||||
const pos = layoutPositions([
|
||||
{ key: 'a', type: 'web', runtime: 'x', depends_on: ['b'] },
|
||||
{ key: 'b', type: 'web', runtime: 'x', depends_on: ['a'] },
|
||||
]);
|
||||
expect(pos.a).toBeDefined();
|
||||
expect(pos.b).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateGraph', () => {
|
||||
const node = (key: string, extra: Partial<BuilderNode['service']> = {}): BuilderNode => ({
|
||||
key,
|
||||
position: { x: 0, y: 0 },
|
||||
service: { key, type: 'web', runtime: 'img:1', ...extra },
|
||||
});
|
||||
|
||||
it('accepts a valid graph', () => {
|
||||
expect(validateGraph([node('db', { type: 'database', runtime: 'postgresql' }), node('web', { depends_on: ['db'] })])).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects duplicate keys', () => {
|
||||
expect(validateGraph([node('a'), node('a')])).toContain('duplicate key "a"');
|
||||
});
|
||||
|
||||
it('rejects bad keys and self-deps', () => {
|
||||
const errs = validateGraph([node('BAD KEY'), node('x', { depends_on: ['x'] })]);
|
||||
expect(errs.some((e) => e.includes('not a valid key'))).toBe(true);
|
||||
expect(errs).toContain('"x" depends on itself');
|
||||
});
|
||||
|
||||
it('rejects deps on missing services', () => {
|
||||
expect(validateGraph([node('web', { depends_on: ['ghost'] })])).toContain(
|
||||
'"web" depends on unknown service "ghost"',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects database without engine and web without source', () => {
|
||||
const errs = validateGraph([node('db', { type: 'database', runtime: undefined }), node('web', { runtime: undefined })]);
|
||||
expect(errs).toContain('"db" is a database with no engine selected');
|
||||
expect(errs).toContain('"web" needs an image (runtime) or a git repo');
|
||||
});
|
||||
|
||||
it('reports dependency cycles', () => {
|
||||
const errs = validateGraph([node('a', { depends_on: ['b'] }), node('b', { depends_on: ['a'] })]);
|
||||
expect(errs.some((e) => e.startsWith('dependency cycle'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('newServiceKey', () => {
|
||||
it('picks unused keys', () => {
|
||||
expect(newServiceKey('database', new Set())).toBe('db');
|
||||
expect(newServiceKey('database', new Set(['db']))).toBe('db2');
|
||||
expect(newServiceKey('web', new Set(['web', 'web2']))).toBe('web3');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user