Canvas: Port expose form

Change-Id: I421c67230075778fad3359e11a5c573cd83882c9
diff --git a/apps/canvas/front/src/components/node-app.tsx b/apps/canvas/front/src/components/node-app.tsx
index 9eeda45..15e2fa3 100644
--- a/apps/canvas/front/src/components/node-app.tsx
+++ b/apps/canvas/front/src/components/node-app.tsx
@@ -22,6 +22,49 @@
 import { Name } from "./node-name";
 import { NodeDetailsProps } from "@/lib/types";
 import { Gateway } from "@/Gateways";
+import { Port } from "config";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+
+const sourceSchema = z.object({
+	id: z.string().min(1, "required"),
+	branch: z.string(),
+	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"),
+});
+
+const agentSchema = z.object({
+	model: z.enum(["gemini", "claude"]),
+	apiKey: z.string().optional(),
+});
+
+const portExposeSchema = z
+	.object({
+		type: z.enum(["https", "tcp"]),
+		network: z.string().min(1, "Required"),
+		subdomain: z.string().optional(),
+	})
+	.refine(
+		(data) => {
+			if (data.type === "https" || data.type === "tcp") {
+				return !!data.subdomain && data.subdomain.length > 0;
+			}
+			return true;
+		},
+		{
+			message: "Subdomain is required",
+			path: ["subdomain"],
+		},
+	);
+
+type PortExposeFormValues = z.infer<typeof portExposeSchema>;
 
 export function NodeApp(node: ServiceNode) {
 	const { id, selected } = node;
@@ -65,25 +108,217 @@
 	type: z.enum(ServiceTypes),
 });
 
-const sourceSchema = z.object({
-	id: z.string().min(1, "required"),
-	branch: z.string(),
-	rootDir: z.string(),
-});
+function ExposeForm({
+	node,
+	port,
+	onDone,
+	disabled,
+}: {
+	node: ServiceNode;
+	port: Port;
+	onDone: () => void;
+	disabled?: boolean;
+}) {
+	const store = useStateStore();
+	const nodes = useNodes<AppNode>();
+	const env = useEnv();
+	const form = useForm<PortExposeFormValues>({
+		resolver: zodResolver(portExposeSchema),
+		mode: "onChange",
+		defaultValues: {
+			type: "https",
+		},
+	});
 
-const devSchema = z.object({
-	enabled: z.boolean(),
-});
+	const onSubmit = (data: PortExposeFormValues) => {
+		const networkNode = nodes.find((n) => n.type === "network" && n.data.domain === data.network);
+		if (!networkNode) {
+			// TODO: should show an error to the user
+			return;
+		}
+		if (data.type === "https") {
+			const newNode: Omit<GatewayHttpsNode, "position"> = {
+				id: uuidv4(),
+				type: "gateway-https",
+				data: {
+					https: {
+						serviceId: node.id,
+						portId: port.id,
+					},
+					network: data.network,
+					subdomain: data.subdomain!,
+					label: "",
+					envVars: [],
+					ports: [],
+				},
+			};
+			store.addNode(newNode);
+			store.setEdges(
+				store.edges.concat(
+					{
+						id: uuidv4(),
+						source: node.id,
+						sourceHandle: "ports",
+						target: newNode.id,
+						targetHandle: "https",
+					},
+					{
+						id: uuidv4(),
+						source: newNode.id,
+						sourceHandle: "subdomain",
+						target: networkNode.id,
+						targetHandle: "subdomain",
+					},
+				),
+			);
+		} else if (data.type === "tcp") {
+			const existingGateway = nodes.find(
+				(n): n is GatewayTCPNode =>
+					n.type === "gateway-tcp" && n.data.network === data.network && n.data.subdomain === data.subdomain,
+			);
+			if (existingGateway) {
+				store.updateNodeData<"gateway-tcp">(existingGateway.id, {
+					exposed: [...existingGateway.data.exposed, { serviceId: node.id, portId: port.id }],
+				});
+				let edges = store.edges.concat({
+					id: uuidv4(),
+					source: node.id,
+					sourceHandle: "ports",
+					target: existingGateway.id,
+					targetHandle: "tcp",
+				});
+				if (
+					!edges.find(
+						(e) =>
+							e.source === existingGateway.id &&
+							e.target === networkNode.id &&
+							e.sourceHandle === "subdomain" &&
+							e.targetHandle === "subdomain",
+					)
+				) {
+					edges = edges.concat({
+						id: uuidv4(),
+						source: existingGateway.id,
+						sourceHandle: "subdomain",
+						target: networkNode.id,
+						targetHandle: "subdomain",
+					});
+				}
+				store.setEdges(edges);
+			} else {
+				const newNode: Omit<GatewayTCPNode, "position"> = {
+					id: uuidv4(),
+					type: "gateway-tcp",
+					data: {
+						exposed: [{ serviceId: node.id, portId: port.id }],
+						network: data.network,
+						subdomain: data.subdomain,
+						label: "",
+						envVars: [],
+						ports: [],
+					},
+				};
+				store.addNode(newNode);
+				store.setEdges(
+					store.edges.concat(
+						{
+							id: uuidv4(),
+							source: node.id,
+							sourceHandle: "ports",
+							target: newNode.id,
+							targetHandle: "tcp",
+						},
+						{
+							id: uuidv4(),
+							source: newNode.id,
+							sourceHandle: "subdomain",
+							target: networkNode.id,
+							targetHandle: "subdomain",
+						},
+					),
+				);
+			}
+		}
+		onDone();
+	};
 
-const exposeSchema = z.object({
-	network: z.string().min(1, "reqired"),
-	subdomain: z.string().min(1, "required"),
-});
+	const type = form.watch("type");
 
-const agentSchema = z.object({
-	model: z.enum(["gemini", "claude"]),
-	apiKey: z.string().optional(),
-});
+	return (
+		<Form {...form}>
+			<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 border-t mt-2 pt-2">
+				<FormField
+					control={form.control}
+					name="type"
+					render={({ field }) => (
+						<FormItem>
+							<FormLabel>Gateway Type</FormLabel>
+							<Select onValueChange={field.onChange} defaultValue={field.value} disabled={disabled}>
+								<FormControl>
+									<SelectTrigger>
+										<SelectValue placeholder="Select a type" />
+									</SelectTrigger>
+								</FormControl>
+								<SelectContent>
+									<SelectItem value="https">HTTPS</SelectItem>
+									<SelectItem value="tcp">TCP</SelectItem>
+								</SelectContent>
+							</Select>
+							<FormMessage />
+						</FormItem>
+					)}
+				/>
+				<FormField
+					control={form.control}
+					name="network"
+					render={({ field }) => (
+						<FormItem>
+							<FormLabel>Network</FormLabel>
+							<Select onValueChange={field.onChange} defaultValue={field.value} disabled={disabled}>
+								<FormControl>
+									<SelectTrigger>
+										<SelectValue placeholder="Select a network" />
+									</SelectTrigger>
+								</FormControl>
+								<SelectContent>
+									{env.networks.map((n) => (
+										<SelectItem key={n.domain} value={n.domain}>
+											{n.name} - {n.domain}
+										</SelectItem>
+									))}
+								</SelectContent>
+							</Select>
+							<FormMessage />
+						</FormItem>
+					)}
+				/>
+				{(type === "https" || type === "tcp") && (
+					<FormField
+						control={form.control}
+						name="subdomain"
+						render={({ field }) => (
+							<FormItem>
+								<FormLabel>Subdomain</FormLabel>
+								<FormControl>
+									<Input placeholder="subdomain" {...field} disabled={disabled} />
+								</FormControl>
+								<FormMessage />
+							</FormItem>
+						)}
+					/>
+				)}
+				<div className="flex justify-end gap-2">
+					<Button type="button" variant="ghost" onClick={onDone} disabled={disabled}>
+						Cancel
+					</Button>
+					<Button type="submit" disabled={disabled || !form.formState.isValid}>
+						Expose
+					</Button>
+				</div>
+			</form>
+		</Form>
+	);
+}
 
 export function NodeAppDetails({ node, disabled, showName = true, isOverview = false }: NodeDetailsProps<ServiceNode>) {
 	const { data } = node;
@@ -175,7 +410,7 @@
 					<Runtime node={node} disabled={disabled} />
 				</TabsContent>
 				<TabsContent value="ports">
-					<Ports node={node} disabled={disabled} />
+					<Ports node={node} disabled={disabled} isOverview={isOverview} />
 				</TabsContent>
 				<TabsContent value="vars">
 					<EnvVars node={node} disabled={disabled} />
@@ -386,11 +621,20 @@
 	);
 }
 
-function Ports({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
+function Ports({
+	node,
+	disabled,
+	isOverview,
+}: {
+	node: ServiceNode;
+	disabled?: boolean;
+	isOverview?: boolean;
+}): React.ReactNode {
 	const { id, data } = node;
 	const store = useStateStore();
 	const nodes = useNodes<AppNode>();
 	const [portIngresses, setPortIngresses] = useState<Record<string, string[]>>({});
+	const [exposingPortId, setExposingPortId] = useState<string | null>(null);
 
 	const httpsGateways = useMemo(
 		() => nodes.filter((n): n is GatewayHttpsNode => n.type === "gateway-https"),
@@ -545,11 +789,20 @@
 				{data &&
 					data.ports &&
 					data.ports.map((p) => (
-						<>
+						<div key={p.id} className="contents">
 							<div className="contents">
 								<div className="flex items-center px-3">{p.name.toUpperCase()}</div>
 								<div className="flex items-center px-3">{p.value}</div>
-								<div className="flex items-center">
+								<div className="flex items-center gap-1">
+									{isOverview && (
+										<Button
+											variant="outline"
+											onClick={() => setExposingPortId(p.id)}
+											disabled={disabled}
+										>
+											Expose
+										</Button>
+									)}
 									<Button
 										variant="destructive"
 										className="w-full"
@@ -563,11 +816,28 @@
 							{portIngresses[p.id]?.length > 0 && (
 								<div key={p.id} className="col-span-full pl-6">
 									{portIngresses[p.id].map((url) => (
-										<Gateway g={{ type: "https", address: url, name: p.name }} />
+										<Gateway key={url} g={{ type: "https", address: url, name: p.name }} />
 									))}
 								</div>
 							)}
-						</>
+							{exposingPortId === p.id && (
+								<Dialog open={true} onOpenChange={() => setExposingPortId(null)}>
+									<DialogContent>
+										<DialogHeader>
+											<DialogTitle>
+												Expose Port {p.name}:{p.value}
+											</DialogTitle>
+										</DialogHeader>
+										<ExposeForm
+											node={node}
+											port={p}
+											onDone={() => setExposingPortId(null)}
+											disabled={disabled}
+										/>
+									</DialogContent>
+								</Dialog>
+							)}
+						</div>
 					))}
 				<div>
 					<Input