Canvas: build application infrastructure with drag and drop

Change-Id: I5cfd12e67794f3376c5c025af29470d52d77cf16
diff --git a/apps/canvas/src/lib/state.ts b/apps/canvas/src/lib/state.ts
new file mode 100644
index 0000000..f718134
--- /dev/null
+++ b/apps/canvas/src/lib/state.ts
@@ -0,0 +1,567 @@
+import { v4 as uuidv4 } from "uuid";
+import { create } from 'zustand';
+import { addEdge, applyNodeChanges, applyEdgeChanges, Connection, EdgeChange, useNodes } from '@xyflow/react';
+import {
+  type Edge,
+  type Node,
+  type OnNodesChange,
+  type OnEdgesChange,
+  type OnConnect,
+} from '@xyflow/react';
+import { DeepPartial } from "react-hook-form";
+import { Category, defaultCategories } from "./categories";
+import { CreateValidators, Validator } from "./config";
+import { z } from "zod";
+
+export type InitData = {
+  label: string;
+  envVars: BoundEnvVar[];
+  ports: Port[];
+};
+
+export type NodeData = InitData & {
+  activeField?: string | undefined;
+};
+
+export type PortConnectedTo = {
+  serviceId: string;
+  portId: string;
+}
+
+export type GatewayHttpsData = NodeData & {
+  network?: string;
+  subdomain?: string;
+  https?: PortConnectedTo;
+};
+
+export type GatewayHttpsNode = Node<GatewayHttpsData> & {
+  type: "gateway-https";
+};
+
+export type GatewayTCPData = NodeData & {
+  network?: string;
+  subdomain?: string;
+  exposed: PortConnectedTo[];
+  selected?: {
+    serviceId?: string;
+    portId?: string;
+  };
+};
+
+export type GatewayTCPNode = Node<GatewayTCPData> & {
+  type: "gateway-tcp";
+};
+
+export type Port = {
+  id: string;
+  name: string;
+  value: number;
+};
+
+export const ServiceTypes = ["node-23.1.0", "nextjs:deno-2.0.0"] as const;
+export type ServiceType = typeof ServiceTypes[number];
+
+export type ServiceData = NodeData & {
+  type: ServiceType;
+  repository: {
+    id: string;
+    branch: string;
+    rootDir: string;
+  };
+  env: string[];
+  volume: string[];
+  isChoosingPortToConnect: boolean;
+};
+
+export type ServiceNode = Node<ServiceData> & {
+  type: "app";
+};
+
+export type VolumeType = "ReadWriteOnce" | "ReadOnlyMany" | "ReadWriteMany" | "ReadWriteOncePod";
+
+export type VolumeData = NodeData & {
+  type: VolumeType;
+  size: string;
+  attachedTo: string[];
+};
+
+export type VolumeNode = Node<VolumeData> & {
+  type: "volume";
+};
+
+export type PostgreSQLData = NodeData & {
+  volumeId: string;
+};
+
+export type PostgreSQLNode = Node<PostgreSQLData> & {
+  type: "postgresql";
+};
+
+export type MongoDBData = NodeData & {
+  volumeId: string;
+};
+
+export type MongoDBNode = Node<MongoDBData> & {
+  type: "mongodb";
+};
+
+export type GithubData = NodeData & {
+  address: string;
+};
+
+export type GithubNode = Node<GithubData> & {
+  type: "github";
+};
+
+export type NANode = Node<NodeData> & {
+  type: undefined;
+};
+
+export type AppNode = GatewayHttpsNode | GatewayTCPNode | ServiceNode | VolumeNode | PostgreSQLNode | MongoDBNode | GithubNode | NANode;
+
+export function nodeLabel(n: AppNode): string {
+  switch (n.type) {
+    case "app": return n.data.label || "Service";
+    case "github": return n.data.address || "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 "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!");
+  }
+}
+
+export function nodeIsConnectable(n: AppNode, handle: string): boolean {
+  switch (n.type) {
+    case "app":
+      if (handle === "ports") {
+        return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
+      } else if (handle === "repository") {
+        if (!n.data || !n.data.repository || !n.data.repository.id) {
+            return true;
+        }
+        return false;
+      }
+      return false;   
+    case "github":
+      if (n.data !== undefined && n.data.address) {
+        return true;
+      }
+      return false;
+    case "gateway-https":
+      return n.data === undefined || n.data.https === undefined;
+    case "gateway-tcp":
+      return true;
+    case "mongodb":
+      return true;
+    case "postgresql":
+      return true;
+    case "volume":
+      if (n.data === undefined || n.data.type === undefined) {
+        return false;
+      }
+      if (n.data.type === "ReadWriteOnce" || n.data.type === "ReadWriteOncePod") {
+          return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
+      }
+      return true;
+    case undefined: throw new Error("MUST NOT REACH!");
+  }
+}
+
+export type BoundEnvVar = {
+  id: string;
+  source: string;
+} | {
+  id: string;
+  source: string;
+  name: string;
+  isEditting: boolean;
+} | {
+  id: string;
+  source: string;
+  name: string;
+  alias: string;
+  isEditting: boolean;
+};
+
+export type EnvVar = {
+  name: string;
+  value: string;
+};
+
+export function nodeEnvVarNames(n: AppNode): string[] {
+  switch (n.type) {
+    case "app": return [
+      `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`, 
+      ...(n.data.ports || []).map((p) => `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${p.name.toUpperCase()}`),
+    ];
+    case "github": return [];
+    case "gateway-https": return [];
+    case "gateway-tcp": return [];
+    case "mongodb": return [`DODO_MONGODB_${n.data.label.toUpperCase()}_CONNECTION_URL`];
+    case "postgresql": return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_CONNECTION_URL`];
+    case "volume": return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
+    case undefined: throw new Error("MUST NOT REACH");
+  }
+}
+
+export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
+
+export type MessageType = "INFO" | "WARNING" | "FATAL";
+
+export type Message = {
+  id: string;
+  type: MessageType;
+  nodeId?: string;
+  message: string;
+  onHighlight?: (state: AppState) => void;
+  onLooseHighlight?: (state: AppState) => void;
+  onClick?: (state: AppState) => void;
+};
+
+export const envSchema = z.object({
+  deployKey: z.string(),
+  networks: z.array(z.object({
+    name: z.string(),
+    domain: z.string(),
+  })),
+});
+
+export type Env = z.infer<typeof envSchema>;
+
+export type Project = {
+  id: string;
+  name: string;
+}
+
+export type AppState = {
+  projectId: string | undefined;
+  projects: Project[];
+  nodes: AppNode[];
+  edges: Edge[];
+  categories: Category[];
+  messages: Message[];
+  env?: Env;
+  setHighlightCategory: (name: string, active: boolean) => void;
+  onNodesChange: OnNodesChange<AppNode>;
+  onEdgesChange: OnEdgesChange;
+  onConnect: OnConnect;
+  setNodes: (nodes: AppNode[]) => void;
+  setEdges: (edges: Edge[]) => void;
+  setProject: (projectId: string) => void;
+  setProjects: (projects: Project[]) => void;
+  updateNode: <T extends NodeType>(id: string, data: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))>) => void;
+  updateNodeData: <T extends NodeType>(id: string, data: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))["data"]>) => void;
+  replaceEdge: (c: Connection, id?: string) => void;
+  refreshEnv: () => Promise<Env | undefined>;
+};
+
+const projectIdSelector = (state: AppState) => state.projectId;
+const categoriesSelector = (state: AppState) => state.categories;
+const messagesSelector = (state: AppState) => state.messages;
+const envSelector = (state: AppState) => state.env;
+
+export function useProjectId(): string | undefined {
+  return useStateStore(projectIdSelector);
+}
+
+export function useCategories(): Category[] {
+  return useStateStore(categoriesSelector);
+}
+
+export function useMessages(): Message[] {
+  return useStateStore(messagesSelector);
+}
+
+export function useNodeMessages(id: string): Message[] {
+  return useMessages().filter((m) => m.nodeId === id);
+}
+
+export function useNodeLabel(id: string): string {
+  return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
+}
+
+export function useNodePortName(id: string, portId: string): string {
+  return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
+}
+
+let envRefresh: Promise<Env | undefined> | null = null;
+
+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",
+    }],
+  };
+  const store = useStateStore();
+  const env = envSelector(store);
+  console.log(env);
+  if (env != null) {
+    return env;
+  }
+  if (envRefresh == null) {
+    envRefresh = store.refreshEnv();
+    envRefresh.finally(() => envRefresh = null);
+  }
+  return {
+    deployKey: "",
+    networks: [],
+  };
+}
+
+const v: Validator = CreateValidators();
+
+export const useStateStore = create<AppState>((set, get): AppState => {
+  set({ env: {
+    "deployKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPK58vMu0MwIzdZT+mqpIBkhl48p9+/YwDCZv7MgTesF",
+    "networks": [{
+      "name": "Public",
+      "domain": "v1.dodo.cloud",
+    }, {
+      "name": "Private",
+      "domain": "p.v1.dodo.cloud",
+    }],
+  }});
+  console.log(get().env);
+  const setN = (nodes: AppNode[]) => {
+    set({
+      nodes: nodes,
+      messages: v(nodes),
+    })
+  };
+  function updateNodeData<T extends NodeType>(id: string, d: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))["data"]>): void {
+    setN(get().nodes.map((n) => {
+        if (n.id !== id) {
+          return n;
+        }
+        const nd = {
+          ...n,
+          data: {
+            ...n.data,
+            ...d,
+          },
+        };
+        return nd;
+      })
+    );
+  };
+  function updateNode<T extends NodeType>(id: string, d: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))>): void {
+    setN(
+      get().nodes.map((n) => {
+        if (n.id !== id) {
+          return n;
+        }
+        return {
+          ...n,
+          ...d,
+        };
+      })
+    );
+  };
+  function onConnect(c: Connection) {
+    const { nodes, edges } = get();
+    set({
+      edges: addEdge(c, edges),
+    });
+    const sn = nodes.filter((n) => n.id === c.source)[0]!;
+    const tn = nodes.filter((n) => n.id === c.target)[0]!;
+    if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
+      const sourceEnvVars = nodeEnvVarNames(sn);
+      if (sourceEnvVars.length === 0) {
+        throw new Error("MUST NOT REACH!");
+      }
+      const id = uuidv4();
+      if (sourceEnvVars.length === 1) {
+        updateNode(c.target, {
+          ...tn,
+          data: {
+            ...tn.data,
+            envVars: [
+              ...(tn.data.envVars || []),
+              {
+                id: id,
+                source: c.source,
+                name: sourceEnvVars[0],
+                isEditting: false,
+              },
+            ],
+          },
+        });
+      } else {
+        updateNode(c.target, {
+          ...tn,
+          data: {
+            ...tn.data,
+            envVars: [
+              ...(tn.data.envVars || []),
+              {
+                id: id,
+                source: c.source,
+              },
+            ],
+          },
+        });
+      }
+    }
+    if (c.sourceHandle === "volume") {
+      updateNodeData<"volume">(c.source, {
+        attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
+      });
+    }
+    if (c.targetHandle === "volume") {
+      if (tn.type === "postgresql" || tn.type === "mongodb") {
+        updateNodeData(c.target, {
+          volumeId: c.source,
+        });
+      }
+    }
+    if (c.targetHandle === "https") {
+      if ((sn.data.ports || []).length === 1) {
+        updateNodeData<"gateway-https">(c.target, {
+          https: {
+            serviceId: c.source,
+            portId: sn.data.ports![0].id,
+          }
+        });
+      } else {
+        updateNodeData<"gateway-https">(c.target, {
+          https: {
+            serviceId: c.source,
+            portId: "", // TODO(gio)
+          }
+        });
+      }
+    }
+    if (c.targetHandle === "tcp") {
+      const td = tn.data as GatewayTCPData;
+      if ((sn.data.ports || []).length === 1) {
+        updateNodeData<"gateway-tcp">(c.target, {
+          exposed: (td.exposed || []).concat({
+            serviceId: c.source,
+            portId: sn.data.ports![0].id,
+          }),
+        });
+      } else {
+        updateNodeData<"gateway-tcp">(c.target, {
+          selected: {
+            serviceId: c.source,
+            portId: undefined,
+          },
+        });
+      }
+    }
+    if (sn.type === "app") {
+      if (c.sourceHandle === "ports") {
+        updateNodeData<"app">(sn.id, {
+          isChoosingPortToConnect: true,
+        });
+      }
+    }
+    if (tn.type === "app") {
+      if (c.targetHandle === "repository") {
+        updateNodeData<"app">(tn.id, {
+          repository: {
+            id: c.source,
+            branch: "master",
+            rootDir: "/",
+          }
+        });
+      }
+    }
+  }
+  return {
+    projectId: undefined,
+    projects: [],
+    nodes: [],
+    edges: [],
+    categories: defaultCategories,
+    messages: v([]),
+    setHighlightCategory: (name, active) => {
+      set({
+        categories: get().categories.map(
+          (c) => {
+            if (c.title.toLowerCase() !== name.toLowerCase()) {
+              return c;
+            } else {
+              return {
+                ...c,
+                active,
+              }
+            }
+          })
+      });
+    },
+    onNodesChange: (changes) => {
+      const nodes = applyNodeChanges(changes, get().nodes);
+      setN(nodes);
+    },
+    onEdgesChange: (changes) => {
+      set({
+        edges: applyEdgeChanges(changes, get().edges),
+      });
+    },
+    setNodes: (nodes) => {
+      setN(nodes);
+    },
+    setEdges: (edges) => {
+      set({ edges });
+    },
+    replaceEdge: (c, id) => {
+      let change: EdgeChange;
+      if (id === undefined) {
+        change = {
+          type: "add",
+          item: {
+            id: uuidv4(),
+            ...c,
+          }
+        };
+        onConnect(c);
+      } else {
+        change = {
+          type: "replace",
+          id,
+          item: {
+            id,
+            ...c,
+          }
+        };
+      }
+      set({
+        edges: applyEdgeChanges([change], get().edges),
+      })
+    },
+    updateNode,
+    updateNodeData,
+    onConnect,
+    refreshEnv: async () => {
+      return get().env;
+        const resp = await fetch("/env");
+        if (!resp.ok) {
+          throw new Error("failed to fetch env config");
+        }
+        set({ env: envSchema.parse(await resp.json()) });
+        return get().env;
+    },
+    setProject: (projectId) => set({ projectId }),
+    setProjects: (projects) => set({ projects }),
+  };
+});