| 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 }), |
| }; |
| }); |