diff --git a/ROADMAP2.md b/ROADMAP2.md index c81e6b8..c21b2a6 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -252,9 +252,12 @@ goroutines; this fixes correctness *and* unlocks the ops surface. - [x] **Graph deploy pipeline** — creates services in dependency order, resolves vars/secrets, triggers builds. `deployTemplateGraph`, `POST /templates/deploy` (inline), `POST /projects/:id/import-compose`. -- [ ] **Visual template builder** — canvas mode: drop git/image/db - nodes, wire refs, set order, save/publish. Reuses existing canvas - primitives (dflow's `templates/compose` is exactly this). +- [x] **Visual template builder** — canvas mode in the template editor: + web/worker/cron/database nodes, handles wire `depends_on`, inspector + edits image/repo/engine/port/env/volumes, live validation, applies + back to the version-2 graph JSON. Positions persist under + `config.canvas`; flat legacy configs open as a single node. + `builder-model.ts` + `TemplateBuilder.tsx`. - [x] **Catalog growth** — seeded graph stacks for n8n, Gitea, Nextcloud, Vaultwarden, Umami, Immich (Plausible already shipped). `TestSeedTemplatesResolveCleanly` keeps every seed parseable, @@ -543,7 +546,12 @@ and wired refs, one click or one CLI call. optional Cloudflare tunnel token → done. Shell redirects signed-in users to `/setup` while `needs_setup`; installs with existing projects auto-complete so nobody gets nagged retroactively. -- [ ] **Self-upgrade loop verification**, docs freshness, v1.0.0 release. +- [x] **Self-upgrade loop verification** — live-verified: admin + `POST /agents/:id/upgrade` → `self_upgrade` command → agent downloads + `/api/agents/download/linux-amd64` → sha256 verify → `--version` + sanity → atomic rename → result posts → `syscall.Exec` re-exec. Agent + ran `v0.0.1-old`, came back `v9.9.9-test`, command row `completed`. +- [ ] **Docs freshness, v1.0.0 release.** ## 11. Deliberately not imported from dflow diff --git a/app/frontend/src/features/templates/builder/TemplateBuilder.tsx b/app/frontend/src/features/templates/builder/TemplateBuilder.tsx new file mode 100644 index 0000000..3d8d5a3 --- /dev/null +++ b/app/frontend/src/features/templates/builder/TemplateBuilder.tsx @@ -0,0 +1,467 @@ +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} + + +