Canvas: Service dev UI

Change-Id: I11968dbf5ec51c5fd234ad927d40b0b3983e71dd
diff --git a/apps/canvas/front/src/components/node-app.tsx b/apps/canvas/front/src/components/node-app.tsx
index b7aa7dc..3970558 100644
--- a/apps/canvas/front/src/components/node-app.tsx
+++ b/apps/canvas/front/src/components/node-app.tsx
@@ -12,6 +12,7 @@
 	GatewayHttpsNode,
 	AppNode,
 	GithubNode,
+	useEnv,
 } from "@/lib/state";
 import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from "react";
 import { z } from "zod";
@@ -25,6 +26,8 @@
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
 import { Textarea } from "./ui/textarea";
 import { Input } from "./ui/input";
+import { Checkbox } from "./ui/checkbox";
+import { Label } from "./ui/label";
 
 export function NodeApp(node: ServiceNode) {
 	const { id, selected } = node;
@@ -79,9 +82,19 @@
 	rootDir: z.string(),
 });
 
+const devSchema = z.object({
+	enabled: z.boolean(),
+});
+
+const exposeSchema = z.object({
+	network: z.string().min(1, "reqired"),
+	subdomain: z.string().min(1, "required"),
+});
+
 export function NodeAppDetails({ id, data }: ServiceNode) {
 	const store = useStateStore();
 	const nodes = useNodes<AppNode>();
+	const env = useEnv();
 	const form = useForm<z.infer<typeof schema>>({
 		resolver: zodResolver(schema),
 		mode: "onChange",
@@ -407,10 +420,214 @@
 		);
 		return () => sub.unsubscribe();
 	}, [id, data, sourceForm, store]);
-
+	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);
+					store.updateNodeData<"app">(id, {
+						dev: {
+							enabled: true,
+							expose: data.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 (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]);
+	const exposeForm = useForm<z.infer<typeof exposeSchema>>({
+		resolver: zodResolver(exposeSchema),
+		mode: "onChange",
+		defaultValues: {
+			network: data.dev?.expose?.network,
+			subdomain: data.dev?.expose?.subdomain,
+		},
+	});
+	useEffect(() => {
+		const sub = exposeForm.watch(
+			(
+				value: DeepPartial<z.infer<typeof exposeSchema>>,
+				{ name }: { name?: keyof z.infer<typeof exposeSchema> | undefined; type?: EventType | undefined },
+			) => {
+				const { dev } = data;
+				if (!dev?.enabled) {
+					return;
+				}
+				if (name === "network") {
+					let edges = store.edges;
+					if (dev.enabled && dev.expose?.network !== undefined) {
+						edges = edges.filter((e) => {
+							if (
+								(e.source === dev.codeServerNodeId || e.source === dev.sshNodeId) &&
+								e.sourceHandle === "subdomain" &&
+								e.target === dev.expose?.network &&
+								e.targetHandle === "subdomain"
+							) {
+								return false;
+							} else {
+								return true;
+							}
+						});
+					}
+					if (value.network !== undefined) {
+						edges = edges.concat(
+							{
+								id: uuidv4(),
+								source: dev.codeServerNodeId,
+								sourceHandle: "subdomain",
+								target: value.network,
+								targetHandle: "subdomain",
+							},
+							{
+								id: uuidv4(),
+								source: dev.sshNodeId,
+								sourceHandle: "subdomain",
+								target: value.network,
+								targetHandle: "subdomain",
+							},
+						);
+					}
+					store.setEdges(edges);
+					store.updateNodeData<"app">(id, {
+						dev: {
+							...dev,
+							expose: {
+								network: value.network,
+								subdomain: dev.expose?.subdomain,
+							},
+						},
+					});
+					store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { network: value.network });
+					store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { network: value.network });
+				} else if (name === "subdomain") {
+					store.updateNodeData<"app">(id, {
+						dev: {
+							...dev,
+							expose: {
+								network: dev.expose?.network,
+								subdomain: value.subdomain,
+							},
+						},
+					});
+					store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { subdomain: value.subdomain });
+					store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { subdomain: value.subdomain });
+				}
+			},
+		);
+		return () => sub.unsubscribe();
+	}, [id, data, exposeForm, store]);
 	return (
 		<>
-			<Form {...form}>
+			<Form {...exposeForm}>
 				<form className="space-y-2">
 					<FormField
 						control={form.control}
@@ -602,6 +819,64 @@
 				value={data.preBuildCommands}
 				onChange={setPreBuildCommands}
 			/>
+			Dev
+			<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">
+									<Checkbox id="devEnabled" onCheckedChange={field.onChange} checked={field.value} />
+									<Label htmlFor="devEnabled">Enabled</Label>
+								</div>
+								<FormMessage />
+							</FormItem>
+						)}
+					/>
+				</form>
+			</Form>
+			<Form {...exposeForm}>
+				<form className="space-y-2">
+					<FormField
+						control={exposeForm.control}
+						name="network"
+						render={({ field }) => (
+							<FormItem>
+								<Select onValueChange={field.onChange} defaultValue={field.value}>
+									<FormControl>
+										<SelectTrigger>
+											<SelectValue placeholder="Network" />
+										</SelectTrigger>
+									</FormControl>
+									<SelectContent>
+										{env.networks.map((n) => (
+											<SelectItem
+												key={n.name}
+												value={n.domain}
+											>{`${n.name} - ${n.domain}`}</SelectItem>
+										))}
+									</SelectContent>
+								</Select>
+								<FormMessage />
+							</FormItem>
+						)}
+					/>
+					<FormField
+						control={exposeForm.control}
+						name="subdomain"
+						render={({ field }) => (
+							<FormItem>
+								<FormControl>
+									<Input placeholder="subdomain" {...field} />
+								</FormControl>
+								<FormMessage />
+							</FormItem>
+						)}
+					/>
+				</form>
+			</Form>
 		</>
 	);
 }