Canvas: Render network nodes
Change-Id: I63938da205af9377a1e210c0e972591142211a68
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>
+ );
+}