Canvas: Add VM/PROXY dev modes support
- Update ServiceSchema to discriminate between VM and PROXY dev modes
- Add DevDisabled, DevVM, DevProxy TypeScript types
- Update ServiceData type in graph.ts for new dev structure
- Update generateDodoConfig to handle both VM and PROXY modes
- Update configToGraph to properly convert dev configurations
- Maintain backward compatibility with existing dev configurations
- Update UI and introduce two new DevVM and DevProxy components
- Fetch user machine list from headscale API
Change-Id: I8f9df4ab9bd34c049fffadb748115335e8260a54
diff --git a/apps/canvas/front/package-lock.json b/apps/canvas/front/package-lock.json
index 247a513..2f44d68 100644
--- a/apps/canvas/front/package-lock.json
+++ b/apps/canvas/front/package-lock.json
@@ -18,6 +18,7 @@
"@radix-ui/react-icons": "^1.3.1",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.2",
+ "@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
@@ -2762,6 +2763,311 @@
}
}
},
+ "node_modules/@radix-ui/react-radio-group": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.7.tgz",
+ "integrity": "sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-presence": "1.1.4",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.10",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/primitive": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
+ "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-collection": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
+ "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-context": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
+ "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-direction": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
+ "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-id": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
+ "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-presence": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz",
+ "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-roving-focus": {
+ "version": "1.1.10",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz",
+ "integrity": "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
+ "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
+ "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-effect-event": "0.0.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-previous": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
+ "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-size": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
+ "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz",
diff --git a/apps/canvas/front/package.json b/apps/canvas/front/package.json
index ea53fe0..43aa538 100644
--- a/apps/canvas/front/package.json
+++ b/apps/canvas/front/package.json
@@ -26,6 +26,7 @@
"@radix-ui/react-icons": "^1.3.1",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.2",
+ "@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
diff --git a/apps/canvas/front/src/components/node-app.tsx b/apps/canvas/front/src/components/node-app.tsx
index 15e2fa3..317b7e0 100644
--- a/apps/canvas/front/src/components/node-app.tsx
+++ b/apps/canvas/front/src/components/node-app.tsx
@@ -1,8 +1,19 @@
import { v4 as uuidv4 } from "uuid";
import { NodeRect } from "./node-rect";
import { useStateStore, nodeLabel, AppState, nodeIsConnectable, useEnv, useGithubRepositories } from "@/lib/state";
-import { ServiceNode, ServiceTypes, GatewayHttpsNode, GatewayTCPNode, BoundEnvVar, AppNode, GithubNode } from "config";
-import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from "react";
+import {
+ ServiceNode,
+ ServiceTypes,
+ GatewayHttpsNode,
+ GatewayTCPNode,
+ BoundEnvVar,
+ AppNode,
+ GithubNode,
+ Machines,
+ Machine,
+ MachinesSchema,
+} from "config";
+import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState, useRef } from "react";
import { z } from "zod";
import { useForm, EventType, DeepPartial } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -24,6 +35,9 @@
import { Gateway } from "@/Gateways";
import { Port } from "config";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
+import { useToast } from "@/hooks/use-toast";
+import { LoaderCircle } from "lucide-react";
const sourceSchema = z.object({
id: z.string().min(1, "required"),
@@ -33,6 +47,7 @@
const devSchema = z.object({
enabled: z.boolean(),
+ mode: z.enum(["VM", "PROXY"]).optional(),
});
const exposeSchema = z.object({
@@ -45,6 +60,10 @@
apiKey: z.string().optional(),
});
+const proxySchema = z.object({
+ address: z.string().min(1, "required"),
+});
+
const portExposeSchema = z
.object({
type: z.enum(["https", "tcp"]),
@@ -322,10 +341,16 @@
export function NodeAppDetails({ node, disabled, showName = true, isOverview = false }: NodeDetailsProps<ServiceNode>) {
const { data } = node;
+ const defaultTab = useMemo(() => {
+ if (data.dev?.enabled) {
+ return "dev";
+ }
+ return "runtime";
+ }, [data]);
return (
<>
{showName ? <Name node={node} disabled={disabled} /> : null}
- <Tabs defaultValue="runtime">
+ <Tabs defaultValue={defaultTab}>
<TabsList className="w-full flex flex-row justify-between">
<TabsTrigger value="runtime">
{isOverview ? (
@@ -1157,140 +1182,163 @@
);
}
-function Dev({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
+function usePrevious<T>(value: T) {
+ const ref = useRef<T>();
+ useEffect(() => {
+ ref.current = value;
+ }, [value]);
+ return ref.current;
+}
+
+function DevVM({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
const { id, data } = node;
+ const { dev } = data;
+ const prevDev = usePrevious(dev);
const env = useEnv();
const store = useStateStore();
- const devForm = useForm<z.infer<typeof devSchema>>({
- resolver: zodResolver(devSchema),
- mode: "onChange",
- defaultValues: {
- enabled: data.dev ? data.dev.enabled : false,
- },
- });
useEffect(() => {
- const sub = devForm.watch((value, { name }) => {
- if (name === "enabled") {
- if (value.enabled) {
- const csGateway: Omit<GatewayHttpsNode, "position"> = {
- id: uuidv4(),
- type: "gateway-https",
- data: {
- readonly: true,
- https: {
- serviceId: id,
- portId: `${id}-code-server`,
- },
- network: data.dev?.expose?.network,
- subdomain: data.dev?.expose?.subdomain,
- label: "",
- envVars: [],
- ports: [],
- },
- };
- const sshGateway: Omit<GatewayTCPNode, "position"> = {
- id: uuidv4(),
- type: "gateway-tcp",
- data: {
- readonly: true,
- exposed: [
- {
- serviceId: id,
- portId: `${id}-ssh`,
- },
- ],
- network: data.dev?.expose?.network,
- subdomain: data.dev?.expose?.subdomain,
- label: "",
- envVars: [],
- ports: [],
- },
- };
- store.addNode(csGateway);
- store.addNode(sshGateway);
+ console.log("DDDEV", prevDev, dev);
+ if (!dev && !prevDev) {
+ return;
+ }
+ if (
+ dev &&
+ prevDev &&
+ dev.enabled === prevDev.enabled &&
+ "mode" in dev &&
+ "mode" in prevDev &&
+ dev.mode === prevDev.mode
+ ) {
+ return;
+ }
+ if (!dev?.enabled || dev.mode !== "VM") {
+ if (prevDev?.enabled && prevDev.mode === "VM") {
+ store.setNodes(
+ store.nodes.filter((n) => n.id !== prevDev.codeServerNodeId && n.id !== prevDev.sshNodeId),
+ );
+ store.setEdges(
+ store.edges.filter((e) => e.target !== prevDev.codeServerNodeId && e.target !== prevDev.sshNodeId),
+ );
+ if (dev?.enabled) {
store.updateNodeData<"app">(id, {
dev: {
- enabled: true,
- expose: data.dev?.expose,
- codeServerNodeId: csGateway.id,
- sshNodeId: sshGateway.id,
+ enabled: dev.enabled,
+ mode: dev.mode,
},
- ports: (data.ports || []).concat(
- {
- id: `${id}-code-server`,
- name: "code-server",
- value: 9090,
- },
- {
- id: `${id}-ssh`,
- name: "ssh",
- value: 22,
- },
- ),
+ ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"),
});
- let edges = store.edges.concat([
- {
- id: uuidv4(),
- source: id,
- sourceHandle: "ports",
- target: csGateway.id,
- targetHandle: "https",
- },
- {
- id: uuidv4(),
- source: id,
- sourceHandle: "ports",
- target: sshGateway.id,
- targetHandle: "tcp",
- },
- ]);
- if (data.dev?.expose?.network !== undefined) {
- edges = edges.concat([
- {
- id: uuidv4(),
- source: csGateway.id,
- sourceHandle: "subdomain",
- target: data.dev.expose.network,
- targetHandle: "subdomain",
- },
- {
- id: uuidv4(),
- source: sshGateway.id,
- sourceHandle: "subdomain",
- target: data.dev.expose.network,
- targetHandle: "subdomain",
- },
- ]);
- }
- store.setEdges(edges);
} else {
- const { dev } = data;
- if (dev?.enabled) {
- store.setNodes(
- store.nodes.filter((n) => n.id !== dev.codeServerNodeId && n.id !== dev.sshNodeId),
- );
- store.setEdges(
- store.edges.filter((e) => e.target !== dev.codeServerNodeId && e.target !== dev.sshNodeId),
- );
- }
store.updateNodeData<"app">(id, {
dev: {
enabled: false,
- expose: dev?.expose,
},
ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"),
});
}
}
- });
- return () => sub.unsubscribe();
- }, [id, data, devForm, store]);
+ } else {
+ if (!prevDev?.enabled || prevDev.mode !== "VM") {
+ const csGateway: Omit<GatewayHttpsNode, "position"> = {
+ id: uuidv4(),
+ type: "gateway-https",
+ data: {
+ readonly: true,
+ https: {
+ serviceId: id,
+ portId: `${id}-code-server`,
+ },
+ network: dev?.expose?.network,
+ subdomain: dev?.expose?.subdomain,
+ label: "",
+ envVars: [],
+ ports: [],
+ },
+ };
+ const sshGateway: Omit<GatewayTCPNode, "position"> = {
+ id: uuidv4(),
+ type: "gateway-tcp",
+ data: {
+ readonly: true,
+ exposed: [
+ {
+ serviceId: id,
+ portId: `${id}-ssh`,
+ },
+ ],
+ network: dev?.expose?.network,
+ subdomain: dev?.expose?.subdomain,
+ label: "",
+ envVars: [],
+ ports: [],
+ },
+ };
+ store.addNode(csGateway);
+ store.addNode(sshGateway);
+ store.updateNodeData<"app">(id, {
+ dev: {
+ enabled: true,
+ mode: "VM",
+ expose: dev?.expose,
+ codeServerNodeId: csGateway.id,
+ sshNodeId: sshGateway.id,
+ },
+ ports: (data.ports || []).concat(
+ {
+ id: `${id}-code-server`,
+ name: "code-server",
+ value: 9090,
+ },
+ {
+ id: `${id}-ssh`,
+ name: "ssh",
+ value: 22,
+ },
+ ),
+ });
+ let edges = store.edges.concat([
+ {
+ id: uuidv4(),
+ source: id,
+ sourceHandle: "ports",
+ target: csGateway.id,
+ targetHandle: "https",
+ },
+ {
+ id: uuidv4(),
+ source: id,
+ sourceHandle: "ports",
+ target: sshGateway.id,
+ targetHandle: "tcp",
+ },
+ ]);
+ if (dev?.expose?.network !== undefined) {
+ edges = edges.concat([
+ {
+ id: uuidv4(),
+ source: csGateway.id,
+ sourceHandle: "subdomain",
+ target: dev.expose.network,
+ targetHandle: "subdomain",
+ },
+ {
+ id: uuidv4(),
+ source: sshGateway.id,
+ sourceHandle: "subdomain",
+ target: dev.expose.network,
+ targetHandle: "subdomain",
+ },
+ ]);
+ }
+ store.setEdges(edges);
+ }
+ }
+ }, [id, data, dev, prevDev, store]);
const exposeForm = useForm<z.infer<typeof exposeSchema>>({
resolver: zodResolver(exposeSchema),
mode: "onChange",
defaultValues: {
- network: data.dev?.expose?.network,
- subdomain: data.dev?.expose?.subdomain,
+ network: dev && "expose" in dev ? dev.expose?.network : undefined,
+ subdomain: dev && "expose" in dev ? dev.expose?.subdomain : undefined,
},
});
useEffect(() => {
@@ -1300,7 +1348,7 @@
{ name }: { name?: keyof z.infer<typeof exposeSchema> | undefined; type?: EventType | undefined },
) => {
const { dev } = data;
- if (!dev?.enabled) {
+ if (!dev?.enabled || dev.mode !== "VM") {
return;
}
if (name === "network") {
@@ -1365,31 +1413,12 @@
},
);
return () => sub.unsubscribe();
- }, [id, data, exposeForm, store]);
+ }, [id, data, dev, prevDev, exposeForm, store]);
+ if (!dev?.enabled || dev.mode !== "VM") {
+ return null;
+ }
return (
- <>
- <Form {...devForm}>
- <form className="space-y-2">
- <FormField
- control={devForm.control}
- name="enabled"
- render={({ field }) => (
- <FormItem>
- <div className="flex flex-row gap-1 items-center">
- <Switch
- id="devEnabled"
- onCheckedChange={field.onChange}
- checked={field.value}
- disabled={disabled}
- />
- <Label htmlFor="devEnabled">Dev VM</Label>
- </div>
- <FormMessage />
- </FormItem>
- )}
- />
- </form>
- </Form>
+ <div>
{data.dev && data.dev.enabled && (
<Form {...exposeForm}>
<form className="space-y-2">
@@ -1438,6 +1467,250 @@
</form>
</Form>
)}
+ </div>
+ );
+}
+
+function DevProxy({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
+ const { id, data } = node;
+ const store = useStateStore();
+ const { toast } = useToast();
+ const [machines, setMachines] = useState<Machines>([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const fetchMachines = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await fetch("/api/machines", {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch machines: ${response.statusText}`);
+ }
+
+ const machinesData = MachinesSchema.safeParse(await response.json());
+ if (machinesData.success) {
+ setMachines(machinesData.data);
+ } else {
+ throw new Error("Invalid machines data");
+ }
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : "Failed to fetch machines";
+ setError(errorMessage);
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: errorMessage,
+ });
+ } finally {
+ setLoading(false);
+ }
+ }, [toast]);
+
+ useEffect(() => {
+ if (data.dev?.enabled && "mode" in data.dev && data.dev.mode === "PROXY") {
+ fetchMachines();
+ }
+ }, [data.dev, fetchMachines]);
+
+ const proxyForm = useForm<z.infer<typeof proxySchema>>({
+ resolver: zodResolver(proxySchema),
+ mode: "onChange",
+ defaultValues: {
+ address: data.dev && "address" in data.dev ? data.dev.address : undefined,
+ },
+ });
+
+ useEffect(() => {
+ const sub = proxyForm.watch((value, { name }) => {
+ if (name === "address" && value.address) {
+ store.updateNodeData<"app">(id, {
+ dev: {
+ enabled: true,
+ mode: "PROXY",
+ address: value.address,
+ },
+ });
+ }
+ });
+ return () => sub.unsubscribe();
+ }, [id, proxyForm, store]);
+
+ if (!data.dev?.enabled || data.dev.mode !== "PROXY") {
+ return null;
+ }
+ return (
+ <div className="space-y-2">
+ <Form {...proxyForm}>
+ <form className="space-y-2">
+ <FormField
+ control={proxyForm.control}
+ name="address"
+ render={({ field }) => (
+ <FormItem>
+ <Select
+ onValueChange={field.onChange}
+ value={field.value || ""}
+ disabled={disabled || loading}
+ >
+ <FormControl>
+ <SelectTrigger>
+ {loading ? (
+ <div className="flex items-center gap-2">
+ <LoaderCircle className="h-4 w-4 animate-spin" />
+ <span>Loading machines...</span>
+ </div>
+ ) : (
+ <SelectValue placeholder="Select a machine" />
+ )}
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {loading ? (
+ <div className="flex items-center justify-center p-4">
+ <LoaderCircle className="h-4 w-4 animate-spin" />
+ <span className="ml-2">Loading...</span>
+ </div>
+ ) : error ? (
+ <div className="flex flex-col items-center justify-center p-4 text-destructive">
+ <span className="text-sm">Failed to load machines</span>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="mt-2"
+ onClick={fetchMachines}
+ >
+ Retry
+ </Button>
+ </div>
+ ) : machines.length === 0 ? (
+ <div className="flex items-center justify-center p-4 text-muted-foreground">
+ <span className="text-sm">No machines available</span>
+ </div>
+ ) : (
+ machines.map((machine: Machine) => (
+ <SelectItem key={machine.name} value={machine.name}>
+ {machine.name}
+ </SelectItem>
+ ))
+ )}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </form>
+ </Form>
+ </div>
+ );
+}
+
+function Dev({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
+ const { id, data } = node;
+ const store = useStateStore();
+ const devForm = useForm<z.infer<typeof devSchema>>({
+ resolver: zodResolver(devSchema),
+ mode: "onChange",
+ defaultValues: {
+ enabled: data.dev ? data.dev.enabled : false,
+ mode: data.dev?.enabled ? data.dev.mode : undefined,
+ },
+ });
+ useEffect(() => {
+ const sub = devForm.watch((value, { name }) => {
+ console.log("DDDEVV", name, value, data.dev);
+ if (name === "enabled") {
+ if (value.enabled) {
+ if (data.dev?.enabled && data.dev.mode === "VM") {
+ return;
+ }
+ store.updateNodeData<"app">(id, {
+ dev: {
+ enabled: true,
+ mode: "VM",
+ },
+ });
+ devForm.setValue("mode", "VM");
+ } else {
+ store.updateNodeData<"app">(id, {
+ dev: {
+ enabled: false,
+ },
+ });
+ }
+ } else if (name === "mode") {
+ if (data.dev?.enabled && data.dev.mode === value.mode) {
+ return;
+ }
+ store.updateNodeData<"app">(id, {
+ dev: {
+ enabled: true,
+ mode: value.mode,
+ },
+ });
+ }
+ });
+ return () => sub.unsubscribe();
+ }, [id, data, devForm, store]);
+ return (
+ <>
+ <Form {...devForm}>
+ <form className="space-y-2">
+ <FormField
+ control={devForm.control}
+ name="enabled"
+ render={({ field }) => (
+ <FormItem>
+ <div className="flex flex-row gap-1 items-center">
+ <Switch
+ id="devEnabled"
+ onCheckedChange={field.onChange}
+ checked={field.value}
+ disabled={disabled}
+ />
+ <Label htmlFor="devEnabled">Development Mode</Label>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {data.dev?.enabled && (
+ <FormField
+ control={devForm.control}
+ name="mode"
+ render={({ field }) => (
+ <FormItem>
+ <div className="flex flex-row gap-1 items-center">
+ <RadioGroup
+ onValueChange={field.onChange}
+ value={field.value}
+ disabled={disabled}
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="VM" id="vm" />
+ <Label htmlFor="vm">Create a VM</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="PROXY" id="proxy" />
+ <Label htmlFor="proxy">Proxy to existing machine</Label>
+ </div>
+ </RadioGroup>
+ </div>
+ </FormItem>
+ )}
+ />
+ )}
+ </form>
+ </Form>
+ <DevVM node={node} disabled={disabled} />
+ <DevProxy node={node} disabled={disabled} />
</>
);
}
diff --git a/apps/canvas/front/src/components/ui/radio-group.tsx b/apps/canvas/front/src/components/ui/radio-group.tsx
new file mode 100644
index 0000000..6e25b18
--- /dev/null
+++ b/apps/canvas/front/src/components/ui/radio-group.tsx
@@ -0,0 +1,35 @@
+import * as React from "react";
+import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
+import { cn } from "@/lib/utils";
+import { DotFilledIcon } from "@radix-ui/react-icons";
+
+const RadioGroup = React.forwardRef<
+ React.ElementRef<typeof RadioGroupPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
+>(({ className, ...props }, ref) => {
+ return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />;
+});
+RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
+
+const RadioGroupItem = React.forwardRef<
+ React.ElementRef<typeof RadioGroupPrimitive.Item>,
+ React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
+>(({ className, ...props }, ref) => {
+ return (
+ <RadioGroupPrimitive.Item
+ ref={ref}
+ className={cn(
+ "aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ >
+ <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
+ <DotFilledIcon className="h-3.5 w-3.5 fill-primary" />
+ </RadioGroupPrimitive.Indicator>
+ </RadioGroupPrimitive.Item>
+ );
+});
+RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
+
+export { RadioGroup, RadioGroupItem };