| import { Category, defaultCategories } from "./categories"; |
| import { CreateValidators, Validator } from "./config"; |
| import { GitHubService, GitHubServiceImpl } from "./github"; |
| import type { Edge, Node, OnConnect, OnEdgesChange, OnNodesChange, Viewport as ReactFlowViewport } from "@xyflow/react"; |
| import { |
| addEdge, |
| applyEdgeChanges, |
| applyNodeChanges, |
| Connection, |
| EdgeChange, |
| useNodes, |
| XYPosition, |
| } from "@xyflow/react"; |
| import type { DeepPartial } from "react-hook-form"; |
| import { v4 as uuidv4 } from "uuid"; |
| import { z } from "zod"; |
| import { create } from "zustand"; |
| |
| export type InitData = { |
| label: string; |
| envVars: BoundEnvVar[]; |
| ports: Port[]; |
| }; |
| |
| export type NodeData = InitData & { |
| activeField?: string | undefined; |
| state?: string | null; |
| }; |
| |
| export type PortConnectedTo = { |
| serviceId: string; |
| portId: string; |
| }; |
| |
| export type NetworkData = NodeData & { |
| domain: string; |
| }; |
| |
| export type NetworkNode = Node<NetworkData> & { |
| type: "network"; |
| }; |
| |
| export type GatewayHttpsData = NodeData & { |
| network?: string; |
| subdomain?: string; |
| https?: PortConnectedTo; |
| auth?: { |
| enabled: boolean; |
| groups: string[]; |
| noAuthPathPatterns: string[]; |
| }; |
| }; |
| |
| 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 = [ |
| "deno:2.2.0", |
| "golang:1.20.0", |
| "golang:1.22.0", |
| "golang:1.24.0", |
| "hugo:latest", |
| "php:8.2-apache", |
| "nextjs:deno-2.0.0", |
| "node-23.1.0", |
| ] as const; |
| export type ServiceType = (typeof ServiceTypes)[number]; |
| |
| export type ServiceData = NodeData & { |
| type: ServiceType; |
| repository: |
| | { |
| id: string; |
| } |
| | { |
| id: string; |
| branch: string; |
| } |
| | { |
| id: string; |
| branch: string; |
| rootDir: string; |
| }; |
| env: string[]; |
| volume: string[]; |
| preBuildCommands: 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 & { |
| repository?: { |
| id: number; |
| sshURL: string; |
| fullName: string; |
| }; |
| }; |
| |
| export type GithubNode = Node<GithubData> & { |
| type: "github"; |
| }; |
| |
| export type NANode = Node<NodeData> & { |
| type: undefined; |
| }; |
| |
| 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.repository?.fullName || "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 "network": |
| return true; |
| 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.repository?.id !== undefined) { |
| return true; |
| } |
| 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; |
| 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 | null; |
| } |
| | { |
| id: string; |
| source: string | null; |
| name: string; |
| isEditting: boolean; |
| } |
| | { |
| id: string; |
| source: string | null; |
| name: string; |
| alias: string; |
| isEditting: boolean; |
| } |
| | { |
| id: string; |
| source: string | null; |
| portId: string; |
| name: string; |
| alias: string; |
| isEditting: boolean; |
| }; |
| |
| export type EnvVar = { |
| name: string; |
| value: string; |
| }; |
| |
| export function nodeEnvVarNamePort(n: AppNode, portName: string): string { |
| return `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${portName.toUpperCase()}`; |
| } |
| |
| export function nodeEnvVarNames(n: AppNode): string[] { |
| switch (n.type) { |
| case "app": |
| return [ |
| `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`, |
| ...(n.data.ports || []).map((p) => nodeEnvVarNamePort(n, p.name)), |
| ]; |
| case "github": |
| return []; |
| case "gateway-https": |
| return []; |
| case "gateway-tcp": |
| return []; |
| case "mongodb": |
| return [`DODO_MONGODB_${n.data.label.toUpperCase()}_URL`]; |
| case "postgresql": |
| return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_URL`]; |
| case "volume": |
| return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`]; |
| case undefined: |
| throw new Error("MUST NOT REACH"); |
| default: |
| 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({ |
| managerAddr: z.optional(z.string().min(1)), |
| deployKey: z.optional(z.nullable(z.string().min(1))), |
| networks: z |
| .array( |
| z.object({ |
| name: z.string().min(1), |
| domain: z.string().min(1), |
| }), |
| ) |
| .default([]), |
| integrations: z.object({ |
| github: z.boolean(), |
| }), |
| services: z.array(z.string()), |
| }); |
| |
| export type Env = z.infer<typeof envSchema>; |
| |
| const defaultEnv: Env = { |
| managerAddr: undefined, |
| deployKey: undefined, |
| networks: [], |
| integrations: { |
| github: false, |
| }, |
| services: [], |
| }; |
| |
| export type Project = { |
| id: string; |
| name: string; |
| }; |
| |
| export type IntegrationsConfig = { |
| github: boolean; |
| }; |
| |
| type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>; |
| type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>; |
| |
| type Viewport = { |
| transformX: number; |
| transformY: number; |
| transformZoom: number; |
| width: number; |
| height: number; |
| }; |
| |
| export type AppState = { |
| projectId: string | undefined; |
| mode: "edit" | "deploy"; |
| projects: Project[]; |
| nodes: AppNode[]; |
| edges: Edge[]; |
| zoom: ReactFlowViewport; |
| categories: Category[]; |
| messages: Message[]; |
| env: Env; |
| viewport: Viewport; |
| setViewport: (viewport: Viewport) => void; |
| githubService: GitHubService | null; |
| setHighlightCategory: (name: string, active: boolean) => void; |
| onNodesChange: OnNodesChange<AppNode>; |
| onEdgesChange: OnEdgesChange; |
| onConnect: OnConnect; |
| addNode: (node: Omit<AppNode, "position">) => void; |
| setNodes: (nodes: AppNode[]) => void; |
| setEdges: (edges: Edge[]) => void; |
| setProject: (projectId: string | undefined) => Promise<void>; |
| setMode: (mode: "edit" | "deploy") => void; |
| updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void; |
| updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void; |
| replaceEdge: (c: Connection, id?: string) => void; |
| refreshEnv: () => Promise<void>; |
| }; |
| |
| const projectIdSelector = (state: AppState) => state.projectId; |
| const categoriesSelector = (state: AppState) => state.categories; |
| const messagesSelector = (state: AppState) => state.messages; |
| const githubServiceSelector = (state: AppState) => state.githubService; |
| const envSelector = (state: AppState) => state.env; |
| const zoomSelector = (state: AppState) => state.zoom; |
| |
| export function useZoom(): ReactFlowViewport { |
| return useStateStore(zoomSelector); |
| } |
| |
| 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; |
| } |
| |
| export function useEnv(): Env { |
| return useStateStore(envSelector); |
| } |
| |
| export function useGithubService(): GitHubService | null { |
| return useStateStore(githubServiceSelector); |
| } |
| |
| const v: Validator = CreateValidators(); |
| |
| function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition { |
| const zoomMultiplier = 1 / transformZoom; |
| const realWidth = width * zoomMultiplier; |
| const realHeight = height * zoomMultiplier; |
| const paddingMultiplier = 0.8; |
| const ret = { |
| x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier, |
| y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier, |
| }; |
| return ret; |
| } |
| |
| export const useStateStore = create<AppState>((set, get): AppState => { |
| const setN = (nodes: AppNode[]) => { |
| set({ |
| nodes, |
| messages: v(nodes), |
| }); |
| }; |
| |
| const restoreSaved = async () => { |
| const { projectId } = get(); |
| const resp = await fetch(`/api/project/${projectId}/saved/${get().mode === "deploy" ? "deploy" : "draft"}`, { |
| method: "GET", |
| }); |
| const inst = await resp.json(); |
| setN(inst.nodes || []); |
| set({ edges: inst.edges || [] }); |
| if ( |
| get().zoom.x !== inst.viewport.x || |
| get().zoom.y !== inst.viewport.y || |
| get().zoom.zoom !== inst.viewport.zoom |
| ) { |
| set({ zoom: inst.viewport }); |
| } |
| }; |
| |
| function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void { |
| setN( |
| get().nodes.map((n) => { |
| if (n.id === id) { |
| return { |
| ...n, |
| data: { |
| ...n.data, |
| ...data, |
| }, |
| } as Extract<AppNode, { type: T }>; |
| } |
| return n; |
| }), |
| ); |
| } |
| |
| function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void { |
| setN( |
| get().nodes.map((n) => { |
| if (n.id === id) { |
| return { |
| ...n, |
| ...node, |
| } as Extract<AppNode, { type: T }>; |
| } |
| return n; |
| }), |
| ); |
| } |
| |
| 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 (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 (tn.type === "app") { |
| 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<"app">(c.target, { |
| ...tn, |
| data: { |
| ...tn.data, |
| envVars: [ |
| ...(tn.data.envVars || []), |
| { |
| id: id, |
| source: c.source, |
| name: sourceEnvVars[0], |
| isEditting: false, |
| }, |
| ], |
| }, |
| }); |
| } else { |
| updateNode<"app">(c.target, { |
| ...tn, |
| data: { |
| ...tn.data, |
| envVars: [ |
| ...(tn.data.envVars || []), |
| { |
| id: id, |
| source: c.source, |
| }, |
| ], |
| }, |
| }); |
| } |
| } |
| if (c.sourceHandle === "ports" && c.targetHandle === "env_var") { |
| const sourcePorts = sn.data.ports || []; |
| const id = uuidv4(); |
| if (sourcePorts.length === 1) { |
| updateNode<"app">(c.target, { |
| ...tn, |
| data: { |
| ...tn.data, |
| envVars: [ |
| ...(tn.data.envVars || []), |
| { |
| id: id, |
| source: c.source, |
| name: nodeEnvVarNamePort(sn, sourcePorts[0].name), |
| portId: sourcePorts[0].id, |
| isEditting: false, |
| }, |
| ], |
| }, |
| }); |
| } |
| } |
| } |
| 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, |
| mode: "edit", |
| projects: [], |
| nodes: [], |
| edges: [], |
| categories: defaultCategories, |
| messages: v([]), |
| env: defaultEnv, |
| viewport: { |
| transformX: 0, |
| transformY: 0, |
| transformZoom: 1, |
| width: 800, |
| height: 600, |
| }, |
| zoom: { |
| x: 0, |
| y: 0, |
| zoom: 1, |
| }, |
| githubService: null, |
| setViewport: (viewport) => { |
| const { viewport: vp } = get(); |
| if ( |
| viewport.transformX !== vp.transformX || |
| viewport.transformY !== vp.transformY || |
| viewport.transformZoom !== vp.transformZoom || |
| viewport.width !== vp.width || |
| viewport.height !== vp.height |
| ) { |
| set({ viewport }); |
| } |
| }, |
| 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), |
| }); |
| }, |
| addNode: (node) => { |
| const { viewport, nodes } = get(); |
| setN( |
| nodes.concat({ |
| ...node, |
| position: getRandomPosition(viewport), |
| }), |
| ); |
| }, |
| 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 () => { |
| const projectId = get().projectId; |
| let env: Env = defaultEnv; |
| |
| try { |
| if (projectId) { |
| const response = await fetch(`/api/project/${projectId}/env`); |
| if (response.ok) { |
| const data = await response.json(); |
| const result = envSchema.safeParse(data); |
| if (result.success) { |
| env = result.data; |
| } else { |
| console.error("Invalid env data:", result.error); |
| } |
| } |
| } |
| } catch (error) { |
| console.error("Failed to fetch integrations:", error); |
| } finally { |
| if (JSON.stringify(get().env) !== JSON.stringify(env)) { |
| set({ env }); |
| const newNetworks = env.networks.filter( |
| (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain), |
| ); |
| newNetworks.forEach((n) => { |
| get().addNode({ |
| id: n.domain, |
| type: "network", |
| connectable: true, |
| data: { |
| domain: n.domain, |
| label: n.domain, |
| envVars: [], |
| ports: [], |
| state: "success", // TODO(gio): monitor network health |
| }, |
| }); |
| }); |
| |
| if (env.integrations.github) { |
| set({ githubService: new GitHubServiceImpl(projectId!) }); |
| } else { |
| set({ githubService: null }); |
| } |
| } |
| } |
| }, |
| setMode: (mode) => { |
| set({ mode }); |
| }, |
| setProject: async (projectId) => { |
| if (projectId === get().projectId) { |
| return; |
| } |
| set({ |
| projectId, |
| }); |
| if (projectId) { |
| await get().refreshEnv(); |
| if (get().env.deployKey) { |
| set({ mode: "deploy" }); |
| } else { |
| set({ mode: "edit" }); |
| } |
| restoreSaved(); |
| } else { |
| set({ |
| nodes: [], |
| edges: [], |
| }); |
| } |
| }, |
| }; |
| }); |