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>
 		</>
 	);
 }
diff --git a/apps/canvas/front/src/components/node-gateway-https.tsx b/apps/canvas/front/src/components/node-gateway-https.tsx
index 0119305..3785d59 100644
--- a/apps/canvas/front/src/components/node-gateway-https.tsx
+++ b/apps/canvas/front/src/components/node-gateway-https.tsx
@@ -298,7 +298,11 @@
 						name="network"
 						render={({ field }) => (
 							<FormItem>
-								<Select onValueChange={field.onChange} defaultValue={field.value}>
+								<Select
+									onValueChange={field.onChange}
+									defaultValue={field.value}
+									disabled={data.readonly}
+								>
 									<FormControl>
 										<SelectTrigger>
 											<SelectValue placeholder="Network" />
@@ -323,7 +327,7 @@
 						render={({ field }) => (
 							<FormItem>
 								<FormControl>
-									<Input placeholder="subdomain" {...field} />
+									<Input placeholder="subdomain" {...field} disabled={data.readonly} />
 								</FormControl>
 								<FormMessage />
 							</FormItem>
@@ -338,7 +342,11 @@
 						name="id"
 						render={({ field }) => (
 							<FormItem>
-								<Select onValueChange={field.onChange} defaultValue={field.value}>
+								<Select
+									onValueChange={field.onChange}
+									defaultValue={field.value}
+									disabled={data.readonly}
+								>
 									<FormControl>
 										<SelectTrigger>
 											<SelectValue placeholder="Service" />
@@ -361,7 +369,11 @@
 						name="portId"
 						render={({ field }) => (
 							<FormItem>
-								<Select onValueChange={field.onChange} defaultValue={field.value}>
+								<Select
+									onValueChange={field.onChange}
+									defaultValue={field.value}
+									disabled={data.readonly}
+								>
 									<FormControl>
 										<SelectTrigger>
 											<SelectValue placeholder="Port" />
diff --git a/apps/canvas/front/src/components/node-gateway-tcp.tsx b/apps/canvas/front/src/components/node-gateway-tcp.tsx
index bb8b9be..86fa493 100644
--- a/apps/canvas/front/src/components/node-gateway-tcp.tsx
+++ b/apps/canvas/front/src/components/node-gateway-tcp.tsx
@@ -232,7 +232,11 @@
 						name="network"
 						render={({ field }) => (
 							<FormItem>
-								<Select onValueChange={field.onChange} defaultValue={field.value}>
+								<Select
+									onValueChange={field.onChange}
+									defaultValue={field.value}
+									disabled={data.readonly}
+								>
 									<FormControl>
 										<SelectTrigger>
 											<SelectValue placeholder="Network" />
@@ -257,7 +261,7 @@
 						render={({ field }) => (
 							<FormItem>
 								<FormControl>
-									<Input placeholder="subdomain" {...field} />
+									<Input placeholder="subdomain" {...field} disabled={data.readonly} />
 								</FormControl>
 								<FormMessage />
 							</FormItem>
@@ -280,7 +284,11 @@
 						name="serviceId"
 						render={({ field }) => (
 							<FormItem>
-								<Select onValueChange={field.onChange} defaultValue={field.value}>
+								<Select
+									onValueChange={field.onChange}
+									defaultValue={field.value}
+									disabled={data.readonly}
+								>
 									<FormControl>
 										<SelectTrigger>
 											<SelectValue placeholder="Service" />
@@ -301,7 +309,11 @@
 						name="portId"
 						render={({ field }) => (
 							<FormItem>
-								<Select onValueChange={field.onChange} defaultValue={field.value}>
+								<Select
+									onValueChange={field.onChange}
+									defaultValue={field.value}
+									disabled={data.readonly}
+								>
 									<FormControl>
 										<SelectTrigger>
 											<SelectValue placeholder="Port" />
diff --git a/apps/canvas/front/src/lib/config.ts b/apps/canvas/front/src/lib/config.ts
index a3784c1..60d9966 100644
--- a/apps/canvas/front/src/lib/config.ts
+++ b/apps/canvas/front/src/lib/config.ts
@@ -1,4 +1,4 @@
-import { AppNode, Env, GatewayHttpsNode, Message, MessageType, NodeType, ServiceType, VolumeType } from "./state";
+import { AppNode, Env, Message, MessageType, NodeType, ServiceType, VolumeType } from "./state";
 
 export type AuthDisabled = {
 	enabled: false;
@@ -57,6 +57,11 @@
 	expose?: PortDomain[];
 	volume?: string[];
 	preBuildCommands?: { bin: string }[];
+	dev?: {
+		enabled: boolean;
+		ssh?: Domain;
+		codeServer?: Domain;
+	};
 };
 
 export type Volume = {
@@ -143,7 +148,7 @@
 						ingress: ingressNodes
 							.filter((i) => i.data.https!.serviceId === n.id)
 							.map(
-								(i: GatewayHttpsNode): Ingress => ({
+								(i): Ingress => ({
 									network: networkMap.get(i.data.network!)!,
 									subdomain: i.data.subdomain!,
 									port: {
@@ -165,6 +170,23 @@
 						preBuildCommands: n.data.preBuildCommands
 							? n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))
 							: [],
+						dev: {
+							enabled: n.data.dev ? n.data.dev.enabled : false,
+							codeServer:
+								n.data.dev?.enabled && n.data.dev.expose != null
+									? {
+											network: n.data.dev.expose.network,
+											subdomain: n.data.dev.expose.subdomain,
+										}
+									: undefined,
+							ssh:
+								n.data.dev?.enabled && n.data.dev.expose != null
+									? {
+											network: n.data.dev.expose.network,
+											subdomain: n.data.dev.expose.subdomain,
+										}
+									: undefined,
+						},
 					};
 				}),
 			volume: nodes
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index b8db5b5..6e04e4c 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -41,6 +41,7 @@
 };
 
 export type GatewayHttpsData = NodeData & {
+	readonly?: boolean;
 	network?: string;
 	subdomain?: string;
 	https?: PortConnectedTo;
@@ -56,6 +57,7 @@
 };
 
 export type GatewayTCPData = NodeData & {
+	readonly?: boolean;
 	network?: string;
 	subdomain?: string;
 	exposed: PortConnectedTo[];
@@ -87,6 +89,11 @@
 ] as const;
 export type ServiceType = (typeof ServiceTypes)[number];
 
+export type Domain = {
+	network: string;
+	subdomain: string;
+};
+
 export type ServiceData = NodeData & {
 	type: ServiceType;
 	repository:
@@ -106,6 +113,17 @@
 	volume: string[];
 	preBuildCommands: string;
 	isChoosingPortToConnect: boolean;
+	dev?:
+		| {
+				enabled: false;
+				expose?: Domain;
+		  }
+		| {
+				enabled: true;
+				expose?: Domain;
+				codeServerNodeId: string;
+				sshNodeId: string;
+		  };
 };
 
 export type ServiceNode = Node<ServiceData> & {
@@ -168,35 +186,41 @@
 	| NANode;
 
 export function nodeLabel(n: AppNode): string {
-	switch (n.type) {
-		case "network":
-			return n.data.domain;
-		case "app":
-			return n.data.label || "Service";
-		case "github":
-			return n.data.repository?.fullName || "Github";
-		case "gateway-https": {
-			if (n.data && n.data.network && n.data.subdomain) {
-				return `https://${n.data.subdomain}.${n.data.network}`;
-			} else {
-				return "HTTPS Gateway";
+	try {
+		switch (n.type) {
+			case "network":
+				return n.data.domain;
+			case "app":
+				return n.data.label || "Service";
+			case "github":
+				return n.data.repository?.fullName || "Github";
+			case "gateway-https": {
+				if (n.data && n.data.network && n.data.subdomain) {
+					return `https://${n.data.subdomain}.${n.data.network}`;
+				} else {
+					return "HTTPS Gateway";
+				}
 			}
-		}
-		case "gateway-tcp": {
-			if (n.data && n.data.network && n.data.subdomain) {
-				return `${n.data.subdomain}.${n.data.network}`;
-			} else {
-				return "TCP Gateway";
+			case "gateway-tcp": {
+				if (n.data && n.data.network && n.data.subdomain) {
+					return `${n.data.subdomain}.${n.data.network}`;
+				} else {
+					return "TCP Gateway";
+				}
 			}
+			case "mongodb":
+				return n.data.label || "MongoDB";
+			case "postgresql":
+				return n.data.label || "PostgreSQL";
+			case "volume":
+				return n.data.label || "Volume";
+			case undefined:
+				throw new Error("MUST NOT REACH!");
 		}
-		case "mongodb":
-			return n.data.label || "MongoDB";
-		case "postgresql":
-			return n.data.label || "PostgreSQL";
-		case "volume":
-			return n.data.label || "Volume";
-		case undefined:
-			throw new Error("MUST NOT REACH!");
+	} catch (e) {
+		console.error("opaa", e);
+	} finally {
+		console.log("done");
 	}
 }
 
@@ -464,14 +488,36 @@
 		});
 	};
 
+	const injectNetworkNodes = () => {
+		const newNetworks = get().env.networks.filter(
+			(x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
+		);
+		newNetworks.forEach((n) => {
+			get().addNode({
+				id: n.domain,
+				type: "network",
+				connectable: true,
+				data: {
+					domain: n.domain,
+					label: n.domain,
+					envVars: [],
+					ports: [],
+					state: "success", // TODO(gio): monitor network health
+				},
+			});
+			console.log("added network", n.domain);
+		});
+	};
+
 	const restoreSaved = async () => {
 		const { projectId } = get();
 		const resp = await fetch(`/api/project/${projectId}/saved/${get().mode === "deploy" ? "deploy" : "draft"}`, {
 			method: "GET",
 		});
 		const inst = await resp.json();
-		setN(inst.nodes || []);
-		set({ edges: inst.edges || [] });
+		setN(inst.nodes);
+		set({ edges: inst.edges });
+		injectNetworkNodes();
 		if (
 			get().zoom.x !== inst.viewport.x ||
 			get().zoom.y !== inst.viewport.y ||
@@ -780,23 +826,7 @@
 			} finally {
 				if (JSON.stringify(get().env) !== JSON.stringify(env)) {
 					set({ env });
-					const newNetworks = env.networks.filter(
-						(x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
-					);
-					newNetworks.forEach((n) => {
-						get().addNode({
-							id: n.domain,
-							type: "network",
-							connectable: true,
-							data: {
-								domain: n.domain,
-								label: n.domain,
-								envVars: [],
-								ports: [],
-								state: "success", // TODO(gio): monitor network health
-							},
-						});
-					});
+					injectNetworkNodes();
 
 					if (env.integrations.github) {
 						set({ githubService: new GitHubServiceImpl(projectId!) });