blob: 7060327721ca555f0cb65856eab48eb307e77727 [file] [log] [blame]
gio5f2f1002025-03-20 18:38:48 +04001import { Category, defaultCategories } from "./categories";
2import { CreateValidators, Validator } from "./config";
giod0026612025-05-08 13:00:36 +00003import { GitHubService, GitHubServiceImpl } from "./github";
gio359a6852025-05-14 03:38:24 +00004import type { Edge, Node, OnConnect, OnEdgesChange, OnNodesChange, Viewport as ReactFlowViewport } from "@xyflow/react";
gioaf8db832025-05-13 14:43:05 +00005import {
6 addEdge,
7 applyEdgeChanges,
8 applyNodeChanges,
9 Connection,
10 EdgeChange,
11 useNodes,
12 XYPosition,
13} from "@xyflow/react";
giod0026612025-05-08 13:00:36 +000014import type { DeepPartial } from "react-hook-form";
15import { v4 as uuidv4 } from "uuid";
gio5f2f1002025-03-20 18:38:48 +040016import { z } from "zod";
giod0026612025-05-08 13:00:36 +000017import { create } from "zustand";
gio5f2f1002025-03-20 18:38:48 +040018
19export type InitData = {
giod0026612025-05-08 13:00:36 +000020 label: string;
21 envVars: BoundEnvVar[];
22 ports: Port[];
gio5f2f1002025-03-20 18:38:48 +040023};
24
25export type NodeData = InitData & {
giod0026612025-05-08 13:00:36 +000026 activeField?: string | undefined;
gio818da4e2025-05-12 14:45:35 +000027 state?: string | null;
gio5f2f1002025-03-20 18:38:48 +040028};
29
30export type PortConnectedTo = {
giod0026612025-05-08 13:00:36 +000031 serviceId: string;
32 portId: string;
33};
gio5f2f1002025-03-20 18:38:48 +040034
gioaba9a962025-04-25 14:19:40 +000035export type NetworkData = NodeData & {
giod0026612025-05-08 13:00:36 +000036 domain: string;
gioaba9a962025-04-25 14:19:40 +000037};
38
39export type NetworkNode = Node<NetworkData> & {
giod0026612025-05-08 13:00:36 +000040 type: "network";
gioaba9a962025-04-25 14:19:40 +000041};
42
gio5f2f1002025-03-20 18:38:48 +040043export type GatewayHttpsData = NodeData & {
gio48fde052025-05-14 09:48:08 +000044 readonly?: boolean;
giod0026612025-05-08 13:00:36 +000045 network?: string;
46 subdomain?: string;
47 https?: PortConnectedTo;
48 auth?: {
49 enabled: boolean;
50 groups: string[];
51 noAuthPathPatterns: string[];
52 };
gio5f2f1002025-03-20 18:38:48 +040053};
54
55export type GatewayHttpsNode = Node<GatewayHttpsData> & {
giod0026612025-05-08 13:00:36 +000056 type: "gateway-https";
gio5f2f1002025-03-20 18:38:48 +040057};
58
59export type GatewayTCPData = NodeData & {
gio48fde052025-05-14 09:48:08 +000060 readonly?: boolean;
giod0026612025-05-08 13:00:36 +000061 network?: string;
62 subdomain?: string;
63 exposed: PortConnectedTo[];
64 selected?: {
65 serviceId?: string;
66 portId?: string;
67 };
gio5f2f1002025-03-20 18:38:48 +040068};
69
70export type GatewayTCPNode = Node<GatewayTCPData> & {
giod0026612025-05-08 13:00:36 +000071 type: "gateway-tcp";
gio5f2f1002025-03-20 18:38:48 +040072};
73
74export type Port = {
giod0026612025-05-08 13:00:36 +000075 id: string;
76 name: string;
77 value: number;
gio5f2f1002025-03-20 18:38:48 +040078};
79
gio91165612025-05-03 17:07:38 +000080export const ServiceTypes = [
giod0026612025-05-08 13:00:36 +000081 "deno:2.2.0",
82 "golang:1.20.0",
83 "golang:1.22.0",
84 "golang:1.24.0",
85 "hugo:latest",
86 "php:8.2-apache",
87 "nextjs:deno-2.0.0",
88 "node-23.1.0",
gio91165612025-05-03 17:07:38 +000089] as const;
giod0026612025-05-08 13:00:36 +000090export type ServiceType = (typeof ServiceTypes)[number];
gio5f2f1002025-03-20 18:38:48 +040091
gio48fde052025-05-14 09:48:08 +000092export type Domain = {
93 network: string;
94 subdomain: string;
95};
96
gio5f2f1002025-03-20 18:38:48 +040097export type ServiceData = NodeData & {
giod0026612025-05-08 13:00:36 +000098 type: ServiceType;
99 repository:
100 | {
101 id: string;
102 }
103 | {
104 id: string;
105 branch: string;
106 }
107 | {
108 id: string;
109 branch: string;
110 rootDir: string;
111 };
112 env: string[];
113 volume: string[];
114 preBuildCommands: string;
115 isChoosingPortToConnect: boolean;
gio48fde052025-05-14 09:48:08 +0000116 dev?:
117 | {
118 enabled: false;
119 expose?: Domain;
120 }
121 | {
122 enabled: true;
123 expose?: Domain;
124 codeServerNodeId: string;
125 sshNodeId: string;
126 };
gio5f2f1002025-03-20 18:38:48 +0400127};
128
129export type ServiceNode = Node<ServiceData> & {
giod0026612025-05-08 13:00:36 +0000130 type: "app";
gio5f2f1002025-03-20 18:38:48 +0400131};
132
133export type VolumeType = "ReadWriteOnce" | "ReadOnlyMany" | "ReadWriteMany" | "ReadWriteOncePod";
134
135export type VolumeData = NodeData & {
giod0026612025-05-08 13:00:36 +0000136 type: VolumeType;
137 size: string;
138 attachedTo: string[];
gio5f2f1002025-03-20 18:38:48 +0400139};
140
141export type VolumeNode = Node<VolumeData> & {
giod0026612025-05-08 13:00:36 +0000142 type: "volume";
gio5f2f1002025-03-20 18:38:48 +0400143};
144
145export type PostgreSQLData = NodeData & {
giod0026612025-05-08 13:00:36 +0000146 volumeId: string;
gio5f2f1002025-03-20 18:38:48 +0400147};
148
149export type PostgreSQLNode = Node<PostgreSQLData> & {
giod0026612025-05-08 13:00:36 +0000150 type: "postgresql";
gio5f2f1002025-03-20 18:38:48 +0400151};
152
153export type MongoDBData = NodeData & {
giod0026612025-05-08 13:00:36 +0000154 volumeId: string;
gio5f2f1002025-03-20 18:38:48 +0400155};
156
157export type MongoDBNode = Node<MongoDBData> & {
giod0026612025-05-08 13:00:36 +0000158 type: "mongodb";
gio5f2f1002025-03-20 18:38:48 +0400159};
160
161export type GithubData = NodeData & {
giod0026612025-05-08 13:00:36 +0000162 repository?: {
163 id: number;
164 sshURL: string;
gio818da4e2025-05-12 14:45:35 +0000165 fullName: string;
giod0026612025-05-08 13:00:36 +0000166 };
gio5f2f1002025-03-20 18:38:48 +0400167};
168
169export type GithubNode = Node<GithubData> & {
giod0026612025-05-08 13:00:36 +0000170 type: "github";
gio5f2f1002025-03-20 18:38:48 +0400171};
172
173export type NANode = Node<NodeData> & {
giod0026612025-05-08 13:00:36 +0000174 type: undefined;
gio5f2f1002025-03-20 18:38:48 +0400175};
176
giod0026612025-05-08 13:00:36 +0000177export type AppNode =
178 | NetworkNode
179 | GatewayHttpsNode
180 | GatewayTCPNode
181 | ServiceNode
182 | VolumeNode
183 | PostgreSQLNode
184 | MongoDBNode
185 | GithubNode
186 | NANode;
gio5f2f1002025-03-20 18:38:48 +0400187
188export function nodeLabel(n: AppNode): string {
gio48fde052025-05-14 09:48:08 +0000189 try {
190 switch (n.type) {
191 case "network":
192 return n.data.domain;
193 case "app":
194 return n.data.label || "Service";
195 case "github":
196 return n.data.repository?.fullName || "Github";
197 case "gateway-https": {
198 if (n.data && n.data.network && n.data.subdomain) {
199 return `https://${n.data.subdomain}.${n.data.network}`;
200 } else {
201 return "HTTPS Gateway";
202 }
giod0026612025-05-08 13:00:36 +0000203 }
gio48fde052025-05-14 09:48:08 +0000204 case "gateway-tcp": {
205 if (n.data && n.data.network && n.data.subdomain) {
206 return `${n.data.subdomain}.${n.data.network}`;
207 } else {
208 return "TCP Gateway";
209 }
giod0026612025-05-08 13:00:36 +0000210 }
gio48fde052025-05-14 09:48:08 +0000211 case "mongodb":
212 return n.data.label || "MongoDB";
213 case "postgresql":
214 return n.data.label || "PostgreSQL";
215 case "volume":
216 return n.data.label || "Volume";
217 case undefined:
218 throw new Error("MUST NOT REACH!");
giod0026612025-05-08 13:00:36 +0000219 }
gio48fde052025-05-14 09:48:08 +0000220 } catch (e) {
221 console.error("opaa", e);
222 } finally {
223 console.log("done");
giod0026612025-05-08 13:00:36 +0000224 }
gio5f2f1002025-03-20 18:38:48 +0400225}
226
227export function nodeIsConnectable(n: AppNode, handle: string): boolean {
giod0026612025-05-08 13:00:36 +0000228 switch (n.type) {
229 case "network":
230 return true;
231 case "app":
232 if (handle === "ports") {
233 return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
234 } else if (handle === "repository") {
235 if (!n.data || !n.data.repository || !n.data.repository.id) {
236 return true;
237 }
238 return false;
239 }
240 return false;
241 case "github":
242 if (n.data.repository?.id !== undefined) {
243 return true;
244 }
245 return false;
246 case "gateway-https":
247 if (handle === "subdomain") {
248 return n.data.network === undefined;
249 }
250 return n.data === undefined || n.data.https === undefined;
251 case "gateway-tcp":
252 if (handle === "subdomain") {
253 return n.data.network === undefined;
254 }
255 return true;
256 case "mongodb":
257 return true;
258 case "postgresql":
259 return true;
260 case "volume":
261 if (n.data === undefined || n.data.type === undefined) {
262 return false;
263 }
264 if (n.data.type === "ReadWriteOnce" || n.data.type === "ReadWriteOncePod") {
265 return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
266 }
267 return true;
268 case undefined:
269 throw new Error("MUST NOT REACH!");
270 }
gio5f2f1002025-03-20 18:38:48 +0400271}
272
giod0026612025-05-08 13:00:36 +0000273export type BoundEnvVar =
274 | {
275 id: string;
276 source: string | null;
277 }
278 | {
279 id: string;
280 source: string | null;
281 name: string;
282 isEditting: boolean;
283 }
284 | {
285 id: string;
286 source: string | null;
287 name: string;
288 alias: string;
289 isEditting: boolean;
290 }
291 | {
292 id: string;
293 source: string | null;
294 portId: string;
295 name: string;
296 alias: string;
297 isEditting: boolean;
298 };
gio5f2f1002025-03-20 18:38:48 +0400299
300export type EnvVar = {
giod0026612025-05-08 13:00:36 +0000301 name: string;
302 value: string;
gio5f2f1002025-03-20 18:38:48 +0400303};
304
giob41ecae2025-04-24 08:46:50 +0000305export function nodeEnvVarNamePort(n: AppNode, portName: string): string {
giod0026612025-05-08 13:00:36 +0000306 return `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${portName.toUpperCase()}`;
giob41ecae2025-04-24 08:46:50 +0000307}
308
gio5f2f1002025-03-20 18:38:48 +0400309export function nodeEnvVarNames(n: AppNode): string[] {
giod0026612025-05-08 13:00:36 +0000310 switch (n.type) {
311 case "app":
312 return [
313 `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
314 ...(n.data.ports || []).map((p) => nodeEnvVarNamePort(n, p.name)),
315 ];
316 case "github":
317 return [];
318 case "gateway-https":
319 return [];
320 case "gateway-tcp":
321 return [];
322 case "mongodb":
323 return [`DODO_MONGODB_${n.data.label.toUpperCase()}_URL`];
324 case "postgresql":
325 return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_URL`];
326 case "volume":
327 return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
328 case undefined:
329 throw new Error("MUST NOT REACH");
330 default:
331 throw new Error("MUST NOT REACH");
332 }
gio5f2f1002025-03-20 18:38:48 +0400333}
334
335export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
336
337export type MessageType = "INFO" | "WARNING" | "FATAL";
338
339export type Message = {
giod0026612025-05-08 13:00:36 +0000340 id: string;
341 type: MessageType;
342 nodeId?: string;
343 message: string;
344 onHighlight?: (state: AppState) => void;
345 onLooseHighlight?: (state: AppState) => void;
346 onClick?: (state: AppState) => void;
gio5f2f1002025-03-20 18:38:48 +0400347};
348
349export const envSchema = z.object({
gio7d813702025-05-08 18:29:52 +0000350 managerAddr: z.optional(z.string().min(1)),
gio09fcab52025-05-12 14:05:07 +0000351 deployKey: z.optional(z.nullable(z.string().min(1))),
giod0026612025-05-08 13:00:36 +0000352 networks: z
353 .array(
354 z.object({
355 name: z.string().min(1),
356 domain: z.string().min(1),
357 }),
358 )
359 .default([]),
360 integrations: z.object({
361 github: z.boolean(),
362 }),
gio3a921b82025-05-10 07:36:09 +0000363 services: z.array(z.string()),
gio3ed59592025-05-14 16:51:09 +0000364 user: z.object({
365 id: z.string(),
366 username: z.string(),
367 }),
gio5f2f1002025-03-20 18:38:48 +0400368});
369
370export type Env = z.infer<typeof envSchema>;
371
gio7f98e772025-05-07 11:00:14 +0000372const defaultEnv: Env = {
gio7d813702025-05-08 18:29:52 +0000373 managerAddr: undefined,
giod0026612025-05-08 13:00:36 +0000374 deployKey: undefined,
375 networks: [],
376 integrations: {
377 github: false,
378 },
gio3a921b82025-05-10 07:36:09 +0000379 services: [],
gio3ed59592025-05-14 16:51:09 +0000380 user: {
381 id: "",
382 username: "",
383 },
gio7f98e772025-05-07 11:00:14 +0000384};
385
gio5f2f1002025-03-20 18:38:48 +0400386export type Project = {
giod0026612025-05-08 13:00:36 +0000387 id: string;
388 name: string;
389};
gio5f2f1002025-03-20 18:38:48 +0400390
gio7f98e772025-05-07 11:00:14 +0000391export type IntegrationsConfig = {
giod0026612025-05-08 13:00:36 +0000392 github: boolean;
gio7f98e772025-05-07 11:00:14 +0000393};
394
395type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
396type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
397
gioaf8db832025-05-13 14:43:05 +0000398type Viewport = {
399 transformX: number;
400 transformY: number;
401 transformZoom: number;
402 width: number;
403 height: number;
404};
405
gio5f2f1002025-03-20 18:38:48 +0400406export type AppState = {
giod0026612025-05-08 13:00:36 +0000407 projectId: string | undefined;
gio818da4e2025-05-12 14:45:35 +0000408 mode: "edit" | "deploy";
giod0026612025-05-08 13:00:36 +0000409 projects: Project[];
410 nodes: AppNode[];
411 edges: Edge[];
gio359a6852025-05-14 03:38:24 +0000412 zoom: ReactFlowViewport;
giod0026612025-05-08 13:00:36 +0000413 categories: Category[];
414 messages: Message[];
415 env: Env;
gioaf8db832025-05-13 14:43:05 +0000416 viewport: Viewport;
417 setViewport: (viewport: Viewport) => void;
giod0026612025-05-08 13:00:36 +0000418 githubService: GitHubService | null;
419 setHighlightCategory: (name: string, active: boolean) => void;
420 onNodesChange: OnNodesChange<AppNode>;
421 onEdgesChange: OnEdgesChange;
422 onConnect: OnConnect;
gioaf8db832025-05-13 14:43:05 +0000423 addNode: (node: Omit<AppNode, "position">) => void;
giod0026612025-05-08 13:00:36 +0000424 setNodes: (nodes: AppNode[]) => void;
425 setEdges: (edges: Edge[]) => void;
gio818da4e2025-05-12 14:45:35 +0000426 setProject: (projectId: string | undefined) => Promise<void>;
427 setMode: (mode: "edit" | "deploy") => void;
giod0026612025-05-08 13:00:36 +0000428 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
429 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
430 replaceEdge: (c: Connection, id?: string) => void;
431 refreshEnv: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400432};
433
434const projectIdSelector = (state: AppState) => state.projectId;
435const categoriesSelector = (state: AppState) => state.categories;
436const messagesSelector = (state: AppState) => state.messages;
gio7f98e772025-05-07 11:00:14 +0000437const githubServiceSelector = (state: AppState) => state.githubService;
gio5f2f1002025-03-20 18:38:48 +0400438const envSelector = (state: AppState) => state.env;
gio359a6852025-05-14 03:38:24 +0000439const zoomSelector = (state: AppState) => state.zoom;
gioaf8db832025-05-13 14:43:05 +0000440
gio359a6852025-05-14 03:38:24 +0000441export function useZoom(): ReactFlowViewport {
442 return useStateStore(zoomSelector);
gioaf8db832025-05-13 14:43:05 +0000443}
gio5f2f1002025-03-20 18:38:48 +0400444
445export function useProjectId(): string | undefined {
giod0026612025-05-08 13:00:36 +0000446 return useStateStore(projectIdSelector);
gio5f2f1002025-03-20 18:38:48 +0400447}
448
449export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000450 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400451}
452
453export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000454 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400455}
456
457export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000458 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400459}
460
461export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000462 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400463}
464
465export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000466 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400467}
468
gio5f2f1002025-03-20 18:38:48 +0400469export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000470 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000471}
472
473export function useGithubService(): GitHubService | null {
giod0026612025-05-08 13:00:36 +0000474 return useStateStore(githubServiceSelector);
gio5f2f1002025-03-20 18:38:48 +0400475}
476
477const v: Validator = CreateValidators();
478
gioaf8db832025-05-13 14:43:05 +0000479function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
480 const zoomMultiplier = 1 / transformZoom;
481 const realWidth = width * zoomMultiplier;
482 const realHeight = height * zoomMultiplier;
483 const paddingMultiplier = 0.8;
484 const ret = {
485 x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
486 y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
487 };
488 return ret;
489}
490
gio5f2f1002025-03-20 18:38:48 +0400491export const useStateStore = create<AppState>((set, get): AppState => {
giod0026612025-05-08 13:00:36 +0000492 const setN = (nodes: AppNode[]) => {
gio4b9b58a2025-05-12 11:46:08 +0000493 set({
giod0026612025-05-08 13:00:36 +0000494 nodes,
gio5cf364c2025-05-08 16:01:21 +0000495 messages: v(nodes),
gio4b9b58a2025-05-12 11:46:08 +0000496 });
497 };
498
gio48fde052025-05-14 09:48:08 +0000499 const injectNetworkNodes = () => {
500 const newNetworks = get().env.networks.filter(
501 (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
502 );
503 newNetworks.forEach((n) => {
504 get().addNode({
505 id: n.domain,
506 type: "network",
507 connectable: true,
508 data: {
509 domain: n.domain,
510 label: n.domain,
511 envVars: [],
512 ports: [],
513 state: "success", // TODO(gio): monitor network health
514 },
515 });
516 console.log("added network", n.domain);
517 });
518 };
519
gio4b9b58a2025-05-12 11:46:08 +0000520 const restoreSaved = async () => {
gio818da4e2025-05-12 14:45:35 +0000521 const { projectId } = get();
522 const resp = await fetch(`/api/project/${projectId}/saved/${get().mode === "deploy" ? "deploy" : "draft"}`, {
gio4b9b58a2025-05-12 11:46:08 +0000523 method: "GET",
524 });
525 const inst = await resp.json();
gio48fde052025-05-14 09:48:08 +0000526 setN(inst.nodes);
527 set({ edges: inst.edges });
528 injectNetworkNodes();
gio359a6852025-05-14 03:38:24 +0000529 if (
530 get().zoom.x !== inst.viewport.x ||
531 get().zoom.y !== inst.viewport.y ||
532 get().zoom.zoom !== inst.viewport.zoom
533 ) {
534 set({ zoom: inst.viewport });
535 }
giod0026612025-05-08 13:00:36 +0000536 };
gio7f98e772025-05-07 11:00:14 +0000537
giod0026612025-05-08 13:00:36 +0000538 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
539 setN(
540 get().nodes.map((n) => {
541 if (n.id === id) {
542 return {
543 ...n,
544 data: {
545 ...n.data,
546 ...data,
547 },
548 } as Extract<AppNode, { type: T }>;
549 }
550 return n;
551 }),
552 );
553 }
gio7f98e772025-05-07 11:00:14 +0000554
giod0026612025-05-08 13:00:36 +0000555 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
556 setN(
557 get().nodes.map((n) => {
558 if (n.id === id) {
559 return {
560 ...n,
561 ...node,
562 } as Extract<AppNode, { type: T }>;
563 }
564 return n;
565 }),
566 );
567 }
gio7f98e772025-05-07 11:00:14 +0000568
giod0026612025-05-08 13:00:36 +0000569 function onConnect(c: Connection) {
570 const { nodes, edges } = get();
571 set({
572 edges: addEdge(c, edges),
573 });
574 const sn = nodes.filter((n) => n.id === c.source)[0]!;
575 const tn = nodes.filter((n) => n.id === c.target)[0]!;
576 if (tn.type === "network") {
577 if (sn.type === "gateway-https") {
578 updateNodeData<"gateway-https">(sn.id, {
579 network: tn.data.domain,
580 });
581 } else if (sn.type === "gateway-tcp") {
582 updateNodeData<"gateway-tcp">(sn.id, {
583 network: tn.data.domain,
584 });
585 }
586 }
587 if (tn.type === "app") {
588 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
589 const sourceEnvVars = nodeEnvVarNames(sn);
590 if (sourceEnvVars.length === 0) {
591 throw new Error("MUST NOT REACH!");
592 }
593 const id = uuidv4();
594 if (sourceEnvVars.length === 1) {
595 updateNode<"app">(c.target, {
596 ...tn,
597 data: {
598 ...tn.data,
599 envVars: [
600 ...(tn.data.envVars || []),
601 {
602 id: id,
603 source: c.source,
604 name: sourceEnvVars[0],
605 isEditting: false,
606 },
607 ],
608 },
609 });
610 } else {
611 updateNode<"app">(c.target, {
612 ...tn,
613 data: {
614 ...tn.data,
615 envVars: [
616 ...(tn.data.envVars || []),
617 {
618 id: id,
619 source: c.source,
620 },
621 ],
622 },
623 });
624 }
625 }
626 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
627 const sourcePorts = sn.data.ports || [];
628 const id = uuidv4();
629 if (sourcePorts.length === 1) {
630 updateNode<"app">(c.target, {
631 ...tn,
632 data: {
633 ...tn.data,
634 envVars: [
635 ...(tn.data.envVars || []),
636 {
637 id: id,
638 source: c.source,
639 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
640 portId: sourcePorts[0].id,
641 isEditting: false,
642 },
643 ],
644 },
645 });
646 }
647 }
648 }
649 if (c.sourceHandle === "volume") {
650 updateNodeData<"volume">(c.source, {
651 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
652 });
653 }
654 if (c.targetHandle === "volume") {
655 if (tn.type === "postgresql" || tn.type === "mongodb") {
656 updateNodeData(c.target, {
657 volumeId: c.source,
658 });
659 }
660 }
661 if (c.targetHandle === "https") {
662 if ((sn.data.ports || []).length === 1) {
663 updateNodeData<"gateway-https">(c.target, {
664 https: {
665 serviceId: c.source,
666 portId: sn.data.ports![0].id,
667 },
668 });
669 } else {
670 updateNodeData<"gateway-https">(c.target, {
671 https: {
672 serviceId: c.source,
673 portId: "", // TODO(gio)
674 },
675 });
676 }
677 }
678 if (c.targetHandle === "tcp") {
679 const td = tn.data as GatewayTCPData;
680 if ((sn.data.ports || []).length === 1) {
681 updateNodeData<"gateway-tcp">(c.target, {
682 exposed: (td.exposed || []).concat({
683 serviceId: c.source,
684 portId: sn.data.ports![0].id,
685 }),
686 });
687 } else {
688 updateNodeData<"gateway-tcp">(c.target, {
689 selected: {
690 serviceId: c.source,
691 portId: undefined,
692 },
693 });
694 }
695 }
696 if (sn.type === "app") {
697 if (c.sourceHandle === "ports") {
698 updateNodeData<"app">(sn.id, {
699 isChoosingPortToConnect: true,
700 });
701 }
702 }
703 if (tn.type === "app") {
704 if (c.targetHandle === "repository") {
705 updateNodeData<"app">(tn.id, {
706 repository: {
707 id: c.source,
708 branch: "master",
709 rootDir: "/",
710 },
711 });
712 }
713 }
714 }
715 return {
716 projectId: undefined,
gio818da4e2025-05-12 14:45:35 +0000717 mode: "edit",
giod0026612025-05-08 13:00:36 +0000718 projects: [],
719 nodes: [],
720 edges: [],
721 categories: defaultCategories,
722 messages: v([]),
723 env: defaultEnv,
gioaf8db832025-05-13 14:43:05 +0000724 viewport: {
725 transformX: 0,
726 transformY: 0,
727 transformZoom: 1,
728 width: 800,
729 height: 600,
730 },
gio359a6852025-05-14 03:38:24 +0000731 zoom: {
732 x: 0,
733 y: 0,
734 zoom: 1,
735 },
giod0026612025-05-08 13:00:36 +0000736 githubService: null,
gioaf8db832025-05-13 14:43:05 +0000737 setViewport: (viewport) => {
738 const { viewport: vp } = get();
739 if (
740 viewport.transformX !== vp.transformX ||
741 viewport.transformY !== vp.transformY ||
742 viewport.transformZoom !== vp.transformZoom ||
743 viewport.width !== vp.width ||
744 viewport.height !== vp.height
745 ) {
746 set({ viewport });
747 }
748 },
giod0026612025-05-08 13:00:36 +0000749 setHighlightCategory: (name, active) => {
750 set({
751 categories: get().categories.map((c) => {
752 if (c.title.toLowerCase() !== name.toLowerCase()) {
753 return c;
754 } else {
755 return {
756 ...c,
757 active,
758 };
759 }
760 }),
761 });
762 },
763 onNodesChange: (changes) => {
764 const nodes = applyNodeChanges(changes, get().nodes);
765 setN(nodes);
766 },
767 onEdgesChange: (changes) => {
768 set({
769 edges: applyEdgeChanges(changes, get().edges),
770 });
771 },
gioaf8db832025-05-13 14:43:05 +0000772 addNode: (node) => {
773 const { viewport, nodes } = get();
774 setN(
775 nodes.concat({
776 ...node,
777 position: getRandomPosition(viewport),
778 }),
779 );
780 },
giod0026612025-05-08 13:00:36 +0000781 setNodes: (nodes) => {
782 setN(nodes);
783 },
784 setEdges: (edges) => {
785 set({ edges });
786 },
787 replaceEdge: (c, id) => {
788 let change: EdgeChange;
789 if (id === undefined) {
790 change = {
791 type: "add",
792 item: {
793 id: uuidv4(),
794 ...c,
795 },
796 };
797 onConnect(c);
798 } else {
799 change = {
800 type: "replace",
801 id,
802 item: {
803 id,
804 ...c,
805 },
806 };
807 }
808 set({
809 edges: applyEdgeChanges([change], get().edges),
810 });
811 },
812 updateNode,
813 updateNodeData,
814 onConnect,
815 refreshEnv: async () => {
816 const projectId = get().projectId;
817 let env: Env = defaultEnv;
gio7f98e772025-05-07 11:00:14 +0000818
giod0026612025-05-08 13:00:36 +0000819 try {
820 if (projectId) {
821 const response = await fetch(`/api/project/${projectId}/env`);
822 if (response.ok) {
823 const data = await response.json();
824 const result = envSchema.safeParse(data);
825 if (result.success) {
826 env = result.data;
827 } else {
828 console.error("Invalid env data:", result.error);
829 }
830 }
831 }
832 } catch (error) {
833 console.error("Failed to fetch integrations:", error);
834 } finally {
gio4b9b58a2025-05-12 11:46:08 +0000835 if (JSON.stringify(get().env) !== JSON.stringify(env)) {
836 set({ env });
gio48fde052025-05-14 09:48:08 +0000837 injectNetworkNodes();
gio4b9b58a2025-05-12 11:46:08 +0000838
839 if (env.integrations.github) {
840 set({ githubService: new GitHubServiceImpl(projectId!) });
841 } else {
842 set({ githubService: null });
843 }
giod0026612025-05-08 13:00:36 +0000844 }
845 }
846 },
gio818da4e2025-05-12 14:45:35 +0000847 setMode: (mode) => {
848 set({ mode });
849 },
850 setProject: async (projectId) => {
gio359a6852025-05-14 03:38:24 +0000851 if (projectId === get().projectId) {
852 return;
853 }
giod0026612025-05-08 13:00:36 +0000854 set({
855 projectId,
856 });
857 if (projectId) {
gio818da4e2025-05-12 14:45:35 +0000858 await get().refreshEnv();
859 if (get().env.deployKey) {
860 set({ mode: "deploy" });
861 } else {
862 set({ mode: "edit" });
863 }
gio4b9b58a2025-05-12 11:46:08 +0000864 restoreSaved();
865 } else {
866 set({
867 nodes: [],
868 edges: [],
869 });
giod0026612025-05-08 13:00:36 +0000870 }
871 },
872 };
gio5f2f1002025-03-20 18:38:48 +0400873});