import { useCallback, useMemo, useState } from 'react'; import { Background, BackgroundVariant, Handle, MarkerType, Position, ReactFlow, ReactFlowProvider, useReactFlow, type Connection, type Edge, type EdgeChange, type Node, type NodeChange, type NodeProps, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import { Database, GitBranch, Package, Timer, Trash2, X } from 'lucide-react'; import { canvasFromConfig, configFromCanvas, newServiceKey, validateGraph, type BuilderNode, type BuilderService, type BuilderServiceType, } from './builder-model'; type ServiceBuilderNode = Node<{ svc: BuilderService }, 'tplService'>; const TYPE_META: Record = { web: { label: 'Web', icon: Package, cls: 'text-[var(--accent-primary)]' }, worker: { label: 'Worker', icon: GitBranch, cls: 'text-[var(--info,#5eb3f6)]' }, cron: { label: 'Cron', icon: Timer, cls: 'text-[var(--warning)]' }, database: { label: 'Database', icon: Database, cls: 'text-[var(--success,#7bd88a)]' }, }; const DATABASE_ENGINES = ['postgresql', 'mysql', 'mariadb', 'mongodb', 'redis', 'dragonfly', 'clickhouse']; function ServiceBuilderNodeView({ data, selected }: NodeProps) { const meta = TYPE_META[data.svc.type] ?? TYPE_META.web; const Icon = meta.icon; return (
{data.svc.name || data.svc.key}
{meta.label} {data.svc.runtime || data.svc.repo || '—'}
); } const nodeTypes = { tplService: ServiceBuilderNodeView }; // Edges run dependency → dependent: connecting A → B means "B depends_on A". export function TemplateBuilder(props: { config: Record; onApply: (config: Record) => void; onCancel: () => void; }) { return ( ); } function BuilderInner({ config, onApply, onCancel, }: { config: Record; onApply: (config: Record) => void; onCancel: () => void; }) { const { screenToFlowPosition } = useReactFlow(); const initial = useMemo(() => canvasFromConfig(config), [config]); const [services, setServices] = useState>( () => new Map(initial.map((n) => [n.key, { ...n.service }])), ); const [positions, setPositions] = useState>( () => Object.fromEntries(initial.map((n) => [n.key, n.position])), ); const [selectedKey, setSelectedKey] = useState(null); const flowNodes: ServiceBuilderNode[] = useMemo( () => [...services.values()].map((svc) => ({ id: svc.key, type: 'tplService' as const, position: positions[svc.key] ?? { x: 0, y: 0 }, data: { svc }, selected: svc.key === selectedKey, })), [services, positions, selectedKey], ); const flowEdges: Edge[] = useMemo( () => [...services.values()].flatMap((svc) => (svc.depends_on ?? []) .filter((dep) => services.has(dep)) .map((dep) => ({ id: `${dep}->${svc.key}`, source: dep, target: svc.key, markerEnd: { type: MarkerType.ArrowClosed, width: 14, height: 14, color: 'rgba(180,227,74,0.55)' }, style: { stroke: 'rgba(180,227,74,0.42)', strokeWidth: 1.6 }, className: 'edge-premium', })), ), [services], ); const builderNodes: BuilderNode[] = useMemo( () => [...services.values()].map((svc) => ({ key: svc.key, service: svc, position: positions[svc.key] ?? { x: 0, y: 0 }, })), [services, positions], ); const errors = useMemo(() => validateGraph(builderNodes), [builderNodes]); const patchService = useCallback((key: string, patch: Partial) => { setServices((prev) => { const svc = prev.get(key); if (!svc) return prev; const next = new Map(prev); next.set(key, { ...svc, ...patch }); return next; }); }, []); const addService = useCallback((type: BuilderServiceType, position?: { x: number; y: number }) => { setServices((prev) => { const key = newServiceKey(type, new Set(prev.keys())); const next = new Map(prev); next.set(key, { key, type, runtime: type === 'database' ? 'postgresql' : undefined, }); setPositions((pos) => ({ ...pos, [key]: position ?? { x: 80, y: 40 + (next.size - 1) * 150 } })); setSelectedKey(key); return next; }); }, []); const removeService = useCallback((key: string) => { setServices((prev) => { const next = new Map(prev); next.delete(key); for (const svc of next.values()) { svc.depends_on = svc.depends_on?.filter((d) => d !== key); } return next; }); setPositions((prev) => { const next = { ...prev }; delete next[key]; return next; }); setSelectedKey((cur) => (cur === key ? null : cur)); }, []); const onConnect = useCallback( (conn: Connection) => { if (!conn.source || !conn.target || conn.source === conn.target) return; const target = services.get(conn.target); if (!target) return; patchService(conn.target, { depends_on: [...new Set([...(target.depends_on ?? []), conn.source])], }); }, [patchService, services], ); const onNodesChange = useCallback( (changes: NodeChange[]) => { for (const ch of changes) { if (ch.type === 'position' && ch.position) { setPositions((prev) => ({ ...prev, [ch.id]: ch.position! })); } else if (ch.type === 'remove') { removeService(ch.id); } else if (ch.type === 'select') { if (ch.selected) setSelectedKey(ch.id); } } }, [removeService], ); const onEdgesChange = useCallback( (changes: EdgeChange[]) => { for (const ch of changes) { if (ch.type !== 'remove') continue; const edge = flowEdges.find((e) => e.id === ch.id); if (!edge) continue; const svc = services.get(edge.target); if (svc) { patchService(edge.target, { depends_on: (svc.depends_on ?? []).filter((d) => d !== edge.source), }); } } }, [flowEdges, patchService, services], ); const selected = selectedKey ? services.get(selectedKey) : null; return (
{(['web', 'database', 'cron', 'worker'] as const).map((t) => { const meta = TYPE_META[t]; const Icon = meta.icon; return ( ); })}
setSelectedKey(null)} onDoubleClick={(e) => { if ((e.target as HTMLElement).closest('.react-flow__node')) return; addService('web', screenToFlowPosition({ x: e.clientX, y: e.clientY })); }} fitView deleteKeyCode={['Backspace', 'Delete']} proOptions={{ hideAttribution: true }} >
drag = move · connect handles = depends_on · double-click = add web · Del = remove
{selected ? ( patchService(selected.key, patch)} onRemove={() => removeService(selected.key)} /> ) : (

Select a node to edit it, or add one from the toolbar.

)}
{errors.length > 0 ? (
{errors.map((e) => (

• {e}

))}
) : null}
); } const FIELD_CLS = 'w-full rounded-[var(--radius-sm)] border border-[var(--border-default)] bg-[var(--bg-void)] px-2.5 py-1.5 text-[12px] text-[var(--text-primary)] outline-none focus:border-[var(--accent-primary)]'; const LABEL_CLS = 'mb-1 block text-[10.5px] font-medium uppercase tracking-wide text-[var(--text-tertiary)]'; function Inspector({ svc, onPatch, onRemove, }: { svc: BuilderService; onPatch: (patch: Partial) => void; onRemove: () => void; }) { const envText = Object.entries(svc.environment ?? {}) .map(([k, v]) => `${k}=${v}`) .join('\n'); const volText = (svc.volumes ?? []).map((v) => `${v.source}:${v.target}${v.read_only ? ':ro' : ''}`).join('\n'); return (
{svc.key}
onPatch({ name: e.target.value || undefined })} placeholder="optional" /> {svc.type === 'database' ? ( <> ) : ( <> onPatch({ runtime: e.target.value || undefined })} placeholder="nginx:latest" /> onPatch({ repo: e.target.value || undefined })} placeholder="owner/repo or clone URL" /> {svc.repo ? ( <> onPatch({ branch: e.target.value || undefined })} placeholder="main" /> onPatch({ build_command: e.target.value || undefined })} /> ) : null} onPatch({ start_command: e.target.value || undefined })} /> )} {svc.type === 'web' ? ( <> onPatch({ port: e.target.value ? Number(e.target.value) : undefined })} /> onPatch({ health_check: e.target.value || undefined })} placeholder="/health" /> ) : null}