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/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 };