Canvas: Render network nodes

Change-Id: I63938da205af9377a1e210c0e972591142211a68
diff --git a/apps/canvas/back/index.js b/apps/canvas/back/index.js
deleted file mode 100644
index 32c28cd..0000000
--- a/apps/canvas/back/index.js
+++ /dev/null
@@ -1,293 +0,0 @@
-"use strict";
-var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
-    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
-    return new (P || (P = Promise))(function (resolve, reject) {
-        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
-        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
-        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
-        step((generator = generator.apply(thisArg, _arguments || [])).next());
-    });
-};
-var __importDefault = (this && this.__importDefault) || function (mod) {
-    return (mod && mod.__esModule) ? mod : { "default": mod };
-};
-Object.defineProperty(exports, "__esModule", { value: true });
-const client_1 = require("@prisma/client");
-const express_1 = __importDefault(require("express"));
-const node_process_1 = require("node:process");
-const axios_1 = __importDefault(require("axios"));
-const db = new client_1.PrismaClient();
-const handleProjectCreate = (req, resp) => __awaiter(void 0, void 0, void 0, function* () {
-    try {
-        const { id } = yield db.project.create({
-            data: {
-                userId: "gio", // req.get("x-forwarded-userid")!,
-                name: req.body.name,
-            },
-        });
-        resp.status(200);
-        resp.header("Content-Type", "application/json");
-        resp.write(JSON.stringify({
-            id,
-        }));
-    }
-    catch (e) {
-        console.log(e);
-        resp.status(500);
-    }
-    finally {
-        resp.end();
-    }
-});
-const handleProjectAll = (req, resp) => __awaiter(void 0, void 0, void 0, function* () {
-    try {
-        const r = yield db.project.findMany({
-            where: {
-                userId: "gio", // req.get("x-forwarded-userid")!,
-            },
-        });
-        resp.status(200);
-        resp.header("Content-Type", "application/json");
-        resp.write(JSON.stringify(r.map((p) => ({
-            id: p.id.toString(),
-            name: p.name,
-        }))));
-    }
-    catch (e) {
-        console.log(e);
-        resp.status(500);
-    }
-    finally {
-        resp.end();
-    }
-});
-const handleSave = (req, resp) => __awaiter(void 0, void 0, void 0, function* () {
-    try {
-        yield db.project.update({
-            where: {
-                id: Number(req.params["projectId"]),
-            },
-            data: {
-                draft: Buffer.from(JSON.stringify(req.body)),
-            },
-        });
-        resp.status(200);
-    }
-    catch (e) {
-        console.log(e);
-        resp.status(500);
-    }
-    finally {
-        resp.end();
-    }
-});
-const handleSavedGet = (req, resp) => __awaiter(void 0, void 0, void 0, function* () {
-    try {
-        const r = yield db.project.findUnique({
-            where: {
-                id: Number(req.params["projectId"]),
-            },
-            select: {
-                state: true,
-                draft: true,
-            },
-        });
-        if (r == null) {
-            resp.status(404);
-        }
-        else {
-            resp.status(200);
-            resp.header("content-type", "application/json");
-            if (r.draft == null) {
-                if (r.state == null) {
-                    resp.send({
-                        nodes: [],
-                        edges: [],
-                        viewport: { x: 0, y: 0, zoom: 1 },
-                    });
-                }
-                else {
-                    resp.send(JSON.parse(Buffer.from(r.state).toString("utf8")));
-                }
-            }
-            else {
-                resp.send(JSON.parse(Buffer.from(r.draft).toString("utf8")));
-            }
-        }
-    }
-    catch (e) {
-        console.log(e);
-        resp.status(500);
-    }
-    finally {
-        resp.end();
-    }
-});
-const handleDelete = (req, resp) => __awaiter(void 0, void 0, void 0, function* () {
-    try {
-        const projectId = Number(req.params["projectId"]);
-        const p = yield db.project.findUnique({
-            where: {
-                id: projectId,
-            },
-            select: {
-                instanceId: true,
-            },
-        });
-        if (p === null) {
-            resp.status(404);
-            return;
-        }
-        const r = yield axios_1.default.request({
-            url: `http://appmanager.hgrz-appmanager.svc.cluster.local/api/instance/${p.instanceId}/remove`,
-            method: "post",
-        });
-        if (r.status === 200) {
-            yield db.project.delete({
-                where: {
-                    id: projectId,
-                },
-            });
-        }
-        resp.status(200);
-    }
-    catch (e) {
-        console.log(e);
-        resp.status(500);
-    }
-    finally {
-        resp.end();
-    }
-});
-const handleDeploy = (req, resp) => __awaiter(void 0, void 0, void 0, function* () {
-    try {
-        const projectId = Number(req.params["projectId"]);
-        const state = Buffer.from(JSON.stringify(req.body.state));
-        const p = yield db.project.findUnique({
-            where: {
-                id: projectId,
-            },
-            select: {
-                instanceId: true,
-            },
-        });
-        if (p === null) {
-            resp.status(404);
-            return;
-        }
-        yield db.project.update({
-            where: {
-                id: projectId,
-            },
-            data: {
-                draft: state,
-            },
-        });
-        let r;
-        if (p.instanceId == null) {
-            r = yield axios_1.default.request({
-                url: "http://appmanager.hgrz-appmanager.svc.cluster.local/api/dodo-app",
-                method: "post",
-                data: {
-                    config: req.body.config,
-                },
-            });
-            if (r.status === 200) {
-                yield db.project.update({
-                    where: {
-                        id: projectId,
-                    },
-                    data: {
-                        state,
-                        draft: null,
-                        instanceId: r.data.id,
-                        deployKey: r.data.deployKey,
-                    },
-                });
-            }
-        }
-        else {
-            r = yield axios_1.default.request({
-                url: `http://appmanager.hgrz-appmanager.svc.cluster.local/api/dodo-app/${p.instanceId}`,
-                method: "put",
-                data: {
-                    config: req.body.config,
-                },
-            });
-            if (r.status === 200) {
-                yield db.project.update({
-                    where: {
-                        id: projectId,
-                    },
-                    data: {
-                        state,
-                        draft: null,
-                    },
-                });
-            }
-        }
-    }
-    catch (e) {
-        console.log(e);
-        resp.status(500);
-    }
-    finally {
-        resp.end();
-    }
-});
-const handleStatus = (req, resp) => __awaiter(void 0, void 0, void 0, function* () {
-    try {
-        const projectId = Number(req.params["projectId"]);
-        const p = yield db.project.findUnique({
-            where: {
-                id: projectId,
-            },
-            select: {
-                instanceId: true,
-            },
-        });
-        console.log(projectId, p);
-        if (p === null) {
-            resp.status(404);
-            return;
-        }
-        if (p.instanceId == null) {
-            resp.status(404);
-            return;
-        }
-        const r = yield axios_1.default.request({
-            url: `http://appmanager.hgrz-appmanager.svc.cluster.local/api/tasks/${p.instanceId}`,
-            method: "get",
-        });
-        resp.status(r.status);
-        if (r.status === 200) {
-            resp.write(JSON.stringify(r.data));
-        }
-    }
-    catch (e) {
-        console.log(e);
-        resp.status(500);
-    }
-    finally {
-        resp.end();
-    }
-});
-function start() {
-    return __awaiter(this, void 0, void 0, function* () {
-        yield db.$connect();
-        const app = (0, express_1.default)();
-        app.use(express_1.default.json());
-        app.post("/api/project/:projectId/saved", handleSave);
-        app.get("/api/project/:projectId/saved", handleSavedGet);
-        app.post("/api/project/:projectId/deploy", handleDeploy);
-        app.get("/api/project/:projectId/status", handleStatus);
-        app.delete("/api/project/:projectId", handleDelete);
-        app.get("/api/project", handleProjectAll);
-        app.post("/api/project", handleProjectCreate);
-        app.use("/", express_1.default.static("../front/dist"));
-        app.listen(node_process_1.env.DODO_PORT_WEB, () => {
-            console.log("started");
-        });
-    });
-}
-start();
diff --git a/apps/canvas/back/package.json b/apps/canvas/back/package.json
index 566a723..b05fb1e 100644
--- a/apps/canvas/back/package.json
+++ b/apps/canvas/back/package.json
@@ -5,7 +5,7 @@
   "main": "index.js",
   "scripts": {
     "build": "tsc",
-    "start": "node index.js"
+    "start": "node dist/index.js"
   },
   "author": "",
   "license": "ISC",
diff --git a/apps/canvas/back/tsconfig.json b/apps/canvas/back/tsconfig.json
index 56a8ab8..07ecd1d 100644
--- a/apps/canvas/back/tsconfig.json
+++ b/apps/canvas/back/tsconfig.json
@@ -1,6 +1,7 @@
 {
   "compilerOptions": {
     /* Visit https://aka.ms/tsconfig to read more about this file */
+    "outDir": "dist",
 
     /* Projects */
     // "incremental": true,                              /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
diff --git a/apps/canvas/front/src/components/canvas.tsx b/apps/canvas/front/src/components/canvas.tsx
index 406f2e9..3dd8b1c 100644
--- a/apps/canvas/front/src/components/canvas.tsx
+++ b/apps/canvas/front/src/components/canvas.tsx
@@ -1,8 +1,8 @@
 import '@xyflow/react/dist/style.css';
 import { ReactFlow, Background, Controls, Connection, BackgroundVariant, Edge, useReactFlow, Panel } from '@xyflow/react';
-import { useStateStore, AppState, AppNode } from '@/lib/state';
+import { useStateStore, AppState, AppNode, useEnv } from '@/lib/state';
 import { useShallow } from "zustand/react/shallow";
-import { useCallback, useMemo } from 'react';
+import { useCallback, useEffect, useMemo } from 'react';
 import { NodeGatewayHttps } from "@/components/node-gateway-https";
 import { NodeApp } from '@/components/node-app';
 import { NodeVolume } from './node-volume';
@@ -11,6 +11,7 @@
 import { NodeGithub } from './node-github';
 import { Actions } from './actions';
 import { NodeGatewayTCP } from './node-gateway-tcp';
+import { NodeNetwork } from './node-network';
 
 const selector = (state: AppState) => ({
     nodes: state.nodes,
@@ -24,8 +25,10 @@
     const { nodes, edges, onNodesChange, onEdgesChange, onConnect } = useStateStore(
         useShallow(selector),
     );
+    const store = useStateStore();
     const flow = useReactFlow();
     const nodeTypes = useMemo(() => ({
+        "network": NodeNetwork,
         "app": NodeApp,
         "gateway-https": NodeGatewayHttps,
         "gateway-tcp": NodeGatewayTCP,
@@ -59,8 +62,41 @@
             }
             return true;
         }
+        if (tn.type === "network") {
+            if (c.sourceHandle !== "subdomain") {
+                return false;
+            }
+            if (sn.type !== "gateway-https" && sn.type !== "gateway-tcp") {
+                return false;
+            }
+        }
         return true;
     }, [flow]);
+    const env = useEnv();
+    useEffect(() => {
+        const networkNodes: AppNode[] = env.networks.map((n) => ({
+            id: n.domain,
+            type: "network",
+            position: {
+                x: 0,
+                y: 0,
+            },
+            isConnectable: true,          
+            data: {
+                domain: n.domain,
+                label: n.domain,
+                envVars: [],
+                ports: [],
+                state: null,
+            },
+        }));
+        const prevNodes = store.nodes;
+        const newNodes = networkNodes.concat(prevNodes.filter((n) => n.type !== "network"));
+        // TODO(gio): actually compare
+        if (prevNodes.length !== newNodes.length) {
+            store.setNodes(newNodes);
+        }
+    }, [env, store]);
     return (
         <div style={{ width: '100%', height: '100%' }}>
             <ReactFlow
diff --git a/apps/canvas/front/src/components/details.tsx b/apps/canvas/front/src/components/details.tsx
index 290da43..5a9c030 100644
--- a/apps/canvas/front/src/components/details.tsx
+++ b/apps/canvas/front/src/components/details.tsx
@@ -17,7 +17,7 @@
   const all = useMemo(() => open.concat(selected).filter(unique), [open, selected]);
   return (
     <Accordion type="multiple" value={all} onValueChange={(v) => setOpen(v)}>
-      {nodes.map((n) => (
+      {nodes.filter((n) => n.type !== "network").map((n) => (
         <AccordionItem key={n.id} value={n.id} className="px-3">
           <AccordionTrigger>
             <div className="flex flex-row space-x-2">
diff --git a/apps/canvas/front/src/components/node-app.tsx b/apps/canvas/front/src/components/node-app.tsx
index fcfe2a1..3598a4d 100644
--- a/apps/canvas/front/src/components/node-app.tsx
+++ b/apps/canvas/front/src/components/node-app.tsx
@@ -1,6 +1,6 @@
 import { v4 as uuidv4 } from "uuid";
 import { NodeRect } from './node-rect';
-import { useStateStore, ServiceNode, ServiceTypes, nodeLabel, BoundEnvVar, AppState, nodeIsConnectable } from '@/lib/state';
+import { useStateStore, ServiceNode, ServiceTypes, nodeLabel, BoundEnvVar, AppState, nodeIsConnectable, GatewayTCPNode, GatewayHttpsNode } from '@/lib/state';
 import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from 'react';
 import { z } from "zod";
 import { DeepPartial, EventType, useForm } from 'react-hook-form';
@@ -204,16 +204,32 @@
     }
   }, [id, data, store]);
   const removePort = useCallback((portId: string) => {
+    // TODO(gio): this is ugly
+    const tcpRemoved = new Set<string>();
+    console.log(store.edges);
     store.setEdges(store.edges.filter((e) => {
       if (e.source !== id || e.sourceHandle !== "ports") {
         return true;
       }
+      const tn = store.nodes.find((n) => n.id == e.target)!;
       if (e.targetHandle === "https") {
-        return false;
+        const t = tn as GatewayHttpsNode;
+        if (t.data.https?.serviceId === id && t.data.https.portId === portId) {
+          return false;
+        }
+      }
+      if (e.targetHandle === "tcp") {
+        const t = tn as GatewayTCPNode;
+        if (tcpRemoved.has(t.id)) {
+          return true;
+        }
+        if (t.data.exposed.find((e) => e.serviceId === id && e.portId === portId)) {
+          console.log(11111, e);
+          tcpRemoved.add(t.id);
+          return false;
+        }
       }
       if (e.targetHandle === "env_var") {
-        const tn = store.nodes.find((n) => n.type === "app" && n.id == e.target);
-        console.log("111", tn!.data.envVars);
         if (tn && (tn.data.envVars || []).find((ev) => ev.source === id && "portId" in ev && ev.portId === portId)) {
           return false;
         }
@@ -225,6 +241,20 @@
         https: undefined,
       });
     });
+    store.nodes.filter((n) => n.type === "gateway-tcp").forEach((n) => {
+      const filtered = n.data.exposed.filter((e) => {
+        if (e.serviceId === id && e.portId === portId) {
+          return false;
+        } else {
+          return true;
+        }
+      })
+      if (filtered.length != n.data.exposed.length) {
+        store.updateNodeData<"gateway-tcp">(n.id, {
+          exposed: filtered,
+        });
+      }
+    });
     store.nodes.filter((n) => n.type === "app" && n.data.envVars).forEach((n) => {
       store.updateNodeData<"app">(n.id, {
         envVars: n.data.envVars.filter((ev) => {
@@ -234,7 +264,7 @@
           return true;
         })
       });
-    })
+    });
     store.updateNodeData<"app">(id, {
       ports: (data.ports || []).filter((p) => p.id !== portId),
       envVars: (data.envVars || []).filter((ev) => !(ev.source === null && "portId" in ev && ev.portId === portId)),
diff --git a/apps/canvas/front/src/components/node-gateway-https.tsx b/apps/canvas/front/src/components/node-gateway-https.tsx
index fdf710d..1397f5c 100644
--- a/apps/canvas/front/src/components/node-gateway-https.tsx
+++ b/apps/canvas/front/src/components/node-gateway-https.tsx
@@ -1,3 +1,4 @@
+import { v4 as uuidv4 } from "uuid";
 import { useStateStore, AppNode, GatewayHttpsNode, ServiceNode, nodeLabel, useEnv, nodeIsConnectable } from '@/lib/state';
 import { Handle, Position, useNodes } from '@xyflow/react';
 import { NodeRect } from './node-rect';
@@ -21,11 +22,20 @@
 
 export function NodeGatewayHttps(node: GatewayHttpsNode) {
   const { id, selected } = node;
+  const isConnectableNetwork = useMemo(() => nodeIsConnectable(node, "subdomain"), [node]);
   const isConnectable = useMemo(() => nodeIsConnectable(node, "https"), [node]);
   return (
     <NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
       {nodeLabel(node)}
       <Handle
+        type={"source"}
+        id="subdomain" 
+        position={Position.Top}
+        isConnectable={isConnectableNetwork}
+        isConnectableStart={isConnectableNetwork} 
+        isConnectableEnd={isConnectableNetwork} 
+      />
+      <Handle
         type={"target"}
         id="https" 
         position={Position.Bottom}
@@ -51,17 +61,34 @@
   useEffect(() => {
     const sub = form.watch((value: DeepPartial<z.infer<typeof schema>>, { name }: { name?: keyof z.infer<typeof schema> | undefined, type?: EventType | undefined }) => {
       if (name === "network") {
-        if (value.network !== undefined) {
-          store.updateNodeData<"gateway-https">(id, { network: value.network });
-        } else {
-          
+        let edges = store.edges;
+        if (data.network !== undefined) {
+          edges = edges.filter((e) => {
+            console.log(e);
+            if (e.source === id && e.sourceHandle === "subdomain" && e.target === data.network && e.targetHandle === "subdomain") {
+              return false;
+            } else {
+              return true;
+            }
+          });
         }
+        if (value.network !== undefined) {
+          edges = edges.concat({
+            id: uuidv4(),
+            source: id,
+            sourceHandle: "subdomain",
+            target: value.network,
+            targetHandle: "subdomain",
+          });
+        }
+        store.setEdges(edges);
+        store.updateNodeData<"gateway-https">(id, { network: value.network });
       } else if (name === "subdomain") {
         store.updateNodeData<"gateway-https">(id, { subdomain: value.subdomain });
       }
     });
     return () => sub.unsubscribe();
-  }, [form, store]);
+  }, [id, data, form, store]);
   const connectedToForm = useForm<z.infer<typeof connectedToSchema>>({
     resolver: zodResolver(connectedToSchema),
     mode: "onChange",
diff --git a/apps/canvas/front/src/components/node-gateway-tcp.tsx b/apps/canvas/front/src/components/node-gateway-tcp.tsx
index aeca41e..3588502 100644
--- a/apps/canvas/front/src/components/node-gateway-tcp.tsx
+++ b/apps/canvas/front/src/components/node-gateway-tcp.tsx
@@ -23,17 +23,26 @@
 
 export function NodeGatewayTCP(node: GatewayTCPNode) {
   const { id, selected } = node;
+  const isConnectableNetwork = useMemo(() => nodeIsConnectable(node, "subdomain"), [node]);
   const isConnectable = useMemo(() => nodeIsConnectable(node, "tcp"), [node]);
   return (
     <NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
       {nodeLabel(node)}
       <Handle
+        type={"source"}
+        id="subdomain"
+        position={Position.Top}
+        isConnectable={isConnectableNetwork}
+        isConnectableStart={isConnectableNetwork}
+        isConnectableEnd={isConnectableNetwork}
+      />
+      <Handle
         type={"target"}
-        id="tcp" 
+        id="tcp"
         position={Position.Bottom}
         isConnectable={isConnectable}
-        isConnectableStart={isConnectable} 
-        isConnectableEnd={isConnectable} 
+        isConnectableStart={isConnectable}
+        isConnectableEnd={isConnectable}
       />
     </NodeRect>
   );
@@ -46,24 +55,41 @@
     resolver: zodResolver(schema),
     mode: "onChange",
     defaultValues: {
-      network: "",
-      subdomain: "",
+      network: data.network,
+      subdomain: data.subdomain,
     },
   });
   useEffect(() => {
     const sub = form.watch((value: DeepPartial<z.infer<typeof schema>>, { name }: { name?: keyof z.infer<typeof schema> | undefined, type?: EventType | undefined }) => {
       if (name === "network") {
-        if (value.network !== undefined) {
-          store.updateNodeData<"gateway-tcp">(id, { network: value.network });
-        } else {
-          
+        let edges = store.edges;
+        if (data.network !== undefined) {
+          edges = edges.filter((e) => {
+            console.log(e);
+            if (e.source === id && e.sourceHandle === "subdomain" && e.target === data.network && e.targetHandle === "subdomain") {
+              return false;
+            } else {
+              return true;
+            }
+          });
         }
+        if (value.network !== undefined) {
+          edges = edges.concat({
+            id: uuidv4(),
+            source: id,
+            sourceHandle: "subdomain",
+            target: value.network,
+            targetHandle: "subdomain",
+          });
+        }
+        store.setEdges(edges);
+        store.updateNodeData<"gateway-tcp">(id, { network: value.network });
       } else if (name === "subdomain") {
         store.updateNodeData<"gateway-tcp">(id, { subdomain: value.subdomain });
       }
     });
     return () => sub.unsubscribe();
-  }, [form, store]);
+  }, [id, data, form, store]);
   const connectedToForm = useForm<z.infer<typeof connectedToSchema>>({
     resolver: zodResolver(connectedToSchema),
     mode: "onSubmit",
@@ -142,7 +168,7 @@
     setPortLabels(new Map((data.exposed || []).map((e) => [`${e.serviceId} - ${e.portId}`, (nodes.find((n) => n.id === e.serviceId)!.data.ports || []).find((p) => p.id === e.portId)!.name])));
   }, [nodes, data, setNodeLabels, setPortLabels]);
   const onSubmit = useCallback((values: z.infer<typeof connectedToSchema>) => {
-    store.setEdges(store.edges.filter((e) => e.target !== id));
+    const edges = store.edges.filter((e) => e.target !== id);
     const exp = (data.exposed || []).concat({
       serviceId: values.serviceId,
       portId: values.portId,
@@ -151,7 +177,7 @@
       exposed: exp,
       selected: undefined,
     });
-    store.setEdges(store.edges.concat(exp.map((e): Edge => ({
+    store.setEdges(edges.concat(exp.map((e): Edge => ({
       id: uuidv4(),
       source: e.serviceId,
       sourceHandle: "ports",
@@ -163,7 +189,7 @@
     <>
       <Form {...form}>
         <form className="space-y-2">
-          <FormField 
+          <FormField
             control={form.control}
             name="network"
             render={({ field }) => (
@@ -184,7 +210,7 @@
               </FormItem>
             )}
           />
-          <FormField 
+          <FormField
             control={form.control}
             name="subdomain"
             render={({ field }) => (
@@ -208,7 +234,7 @@
       </ul>
       <Form {...connectedToForm}>
         <form className="space-y-2" onSubmit={connectedToForm.handleSubmit(onSubmit)}>
-        <FormField
+          <FormField
             control={connectedToForm.control}
             name="serviceId"
             render={({ field }) => (
@@ -229,7 +255,7 @@
               </FormItem>
             )}
           />
-          <FormField 
+          <FormField
             control={connectedToForm.control}
             name="portId"
             render={({ field }) => (
@@ -247,7 +273,7 @@
                   </SelectContent>
                 </Select>
                 <FormMessage />
-              </FormItem>           
+              </FormItem>
             )}
           />
           <Button type="submit">Expose</Button>
diff --git a/apps/canvas/front/src/components/node-network.tsx b/apps/canvas/front/src/components/node-network.tsx
new file mode 100644
index 0000000..9a241fa
--- /dev/null
+++ b/apps/canvas/front/src/components/node-network.tsx
@@ -0,0 +1,22 @@
+import { NodeRect } from './node-rect';
+import { nodeLabel, NetworkNode } from '@/lib/state';
+import { Handle, Position } from "@xyflow/react";
+
+export function NodeNetwork(node: NetworkNode) {
+    const { id, selected } = node;
+    return (
+        <NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
+            <div style={{ padding: '10px 20px' }}>
+                {nodeLabel(node)}
+                <Handle
+                    id="subdomain"
+                    type={"target"}
+                    position={Position.Bottom}
+                    isConnectableStart={true}
+                    isConnectableEnd={true}
+                    isConnectable={true}
+                />
+            </div>
+        </NodeRect>
+    );
+}
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index 51f67a6..8d728d0 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -29,6 +29,14 @@
   portId: string;
 }
 
+export type NetworkData = NodeData & {
+  domain: string;
+};
+
+export type NetworkNode = Node<NetworkData> & {
+  type: "network";
+};
+
 export type GatewayHttpsData = NodeData & {
   network?: string;
   subdomain?: string;
@@ -118,10 +126,11 @@
   type: undefined;
 };
 
-export type AppNode = GatewayHttpsNode | GatewayTCPNode | ServiceNode | VolumeNode | PostgreSQLNode | MongoDBNode | GithubNode | NANode;
+export type AppNode = NetworkNode | GatewayHttpsNode | GatewayTCPNode | ServiceNode | VolumeNode | PostgreSQLNode | MongoDBNode | GithubNode | 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.address || "Github";
     case "gateway-https": {
@@ -147,6 +156,8 @@
 
 export function nodeIsConnectable(n: AppNode, handle: string): boolean {
   switch (n.type) {
+    case "network":
+      return true;
     case "app":
       if (handle === "ports") {
         return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
@@ -163,8 +174,14 @@
       }
       return false;
     case "gateway-https":
+      if (handle === "subdomain") {
+        return n.data.network === undefined;
+      }
       return n.data === undefined || n.data.https === undefined;
     case "gateway-tcp":
+      if (handle === "subdomain") {
+        return n.data.network === undefined;
+      }
       return true;
     case "mongodb":
       return true;
@@ -312,17 +329,19 @@
 
 let envRefresh: Promise<Env | undefined> | null = null;
 
+const fixedEnv: Env = {
+  "deployKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPK58vMu0MwIzdZT+mqpIBkhl48p9+/YwDCZv7MgTesF",
+  "networks": [{
+    "name": "Public",
+    "domain": "v1.dodo.cloud",
+  }, {
+    "name": "Private",
+    "domain": "p.v1.dodo.cloud",
+  }],
+};
+
 export function useEnv(): Env {
-  return {
-    "deployKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPK58vMu0MwIzdZT+mqpIBkhl48p9+/YwDCZv7MgTesF",
-    "networks": [{
-      "name": "Public",
-      "domain": "v1.dodo.cloud",
-    }, {
-      "name": "Private",
-      "domain": "p.v1.dodo.cloud",
-    }],
-  };
+  return fixedEnv;
   const store = useStateStore();
   const env = envSelector(store);
   console.log(env);
@@ -395,6 +414,17 @@
     });
     const sn = nodes.filter((n) => n.id === c.source)[0]!;
     const tn = nodes.filter((n) => n.id === c.target)[0]!;
+    if (tn.type === "network") {
+      if (sn.type === "gateway-https") {
+        updateNodeData<"gateway-https">(sn.id, {
+          network: tn.data.domain,
+        });
+      }else if (sn.type === "gateway-tcp") {
+        updateNodeData<"gateway-tcp">(sn.id, {
+          network: tn.data.domain,
+        });
+      }
+    }
     if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
       const sourceEnvVars = nodeEnvVarNames(sn);
       if (sourceEnvVars.length === 0) {