blob: 6e04e4c5454e45593002a34fe2ee6615dce08964 [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()),
gio5f2f1002025-03-20 18:38:48 +0400364});
365
366export type Env = z.infer<typeof envSchema>;
367
gio7f98e772025-05-07 11:00:14 +0000368const defaultEnv: Env = {
gio7d813702025-05-08 18:29:52 +0000369 managerAddr: undefined,
giod0026612025-05-08 13:00:36 +0000370 deployKey: undefined,
371 networks: [],
372 integrations: {
373 github: false,
374 },
gio3a921b82025-05-10 07:36:09 +0000375 services: [],
gio7f98e772025-05-07 11:00:14 +0000376};
377
gio5f2f1002025-03-20 18:38:48 +0400378export type Project = {
giod0026612025-05-08 13:00:36 +0000379 id: string;
380 name: string;
381};
gio5f2f1002025-03-20 18:38:48 +0400382
gio7f98e772025-05-07 11:00:14 +0000383export type IntegrationsConfig = {
giod0026612025-05-08 13:00:36 +0000384 github: boolean;
gio7f98e772025-05-07 11:00:14 +0000385};
386
387type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
388type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
389
gioaf8db832025-05-13 14:43:05 +0000390type Viewport = {
391 transformX: number;
392 transformY: number;
393 transformZoom: number;
394 width: number;
395 height: number;
396};
397
gio5f2f1002025-03-20 18:38:48 +0400398export type AppState = {
giod0026612025-05-08 13:00:36 +0000399 projectId: string | undefined;
gio818da4e2025-05-12 14:45:35 +0000400 mode: "edit" | "deploy";
giod0026612025-05-08 13:00:36 +0000401 projects: Project[];
402 nodes: AppNode[];
403 edges: Edge[];
gio359a6852025-05-14 03:38:24 +0000404 zoom: ReactFlowViewport;
giod0026612025-05-08 13:00:36 +0000405 categories: Category[];
406 messages: Message[];
407 env: Env;
gioaf8db832025-05-13 14:43:05 +0000408 viewport: Viewport;
409 setViewport: (viewport: Viewport) => void;
giod0026612025-05-08 13:00:36 +0000410 githubService: GitHubService | null;
411 setHighlightCategory: (name: string, active: boolean) => void;
412 onNodesChange: OnNodesChange<AppNode>;
413 onEdgesChange: OnEdgesChange;
414 onConnect: OnConnect;
gioaf8db832025-05-13 14:43:05 +0000415 addNode: (node: Omit<AppNode, "position">) => void;
giod0026612025-05-08 13:00:36 +0000416 setNodes: (nodes: AppNode[]) => void;
417 setEdges: (edges: Edge[]) => void;
gio818da4e2025-05-12 14:45:35 +0000418 setProject: (projectId: string | undefined) => Promise<void>;
419 setMode: (mode: "edit" | "deploy") => void;
giod0026612025-05-08 13:00:36 +0000420 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
421 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
422 replaceEdge: (c: Connection, id?: string) => void;
423 refreshEnv: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400424};
425
426const projectIdSelector = (state: AppState) => state.projectId;
427const categoriesSelector = (state: AppState) => state.categories;
428const messagesSelector = (state: AppState) => state.messages;
gio7f98e772025-05-07 11:00:14 +0000429const githubServiceSelector = (state: AppState) => state.githubService;
gio5f2f1002025-03-20 18:38:48 +0400430const envSelector = (state: AppState) => state.env;
gio359a6852025-05-14 03:38:24 +0000431const zoomSelector = (state: AppState) => state.zoom;
gioaf8db832025-05-13 14:43:05 +0000432
gio359a6852025-05-14 03:38:24 +0000433export function useZoom(): ReactFlowViewport {
434 return useStateStore(zoomSelector);
gioaf8db832025-05-13 14:43:05 +0000435}
gio5f2f1002025-03-20 18:38:48 +0400436
437export function useProjectId(): string | undefined {
giod0026612025-05-08 13:00:36 +0000438 return useStateStore(projectIdSelector);
gio5f2f1002025-03-20 18:38:48 +0400439}
440
441export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000442 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400443}
444
445export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000446 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400447}
448
449export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000450 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400451}
452
453export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000454 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400455}
456
457export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000458 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400459}
460
gio5f2f1002025-03-20 18:38:48 +0400461export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000462 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000463}
464
465export function useGithubService(): GitHubService | null {
giod0026612025-05-08 13:00:36 +0000466 return useStateStore(githubServiceSelector);
gio5f2f1002025-03-20 18:38:48 +0400467}
468
469const v: Validator = CreateValidators();
470
gioaf8db832025-05-13 14:43:05 +0000471function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
472 const zoomMultiplier = 1 / transformZoom;
473 const realWidth = width * zoomMultiplier;
474 const realHeight = height * zoomMultiplier;
475 const paddingMultiplier = 0.8;
476 const ret = {
477 x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
478 y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
479 };
480 return ret;
481}
482
gio5f2f1002025-03-20 18:38:48 +0400483export const useStateStore = create<AppState>((set, get): AppState => {
giod0026612025-05-08 13:00:36 +0000484 const setN = (nodes: AppNode[]) => {
gio4b9b58a2025-05-12 11:46:08 +0000485 set({
giod0026612025-05-08 13:00:36 +0000486 nodes,
gio5cf364c2025-05-08 16:01:21 +0000487 messages: v(nodes),
gio4b9b58a2025-05-12 11:46:08 +0000488 });
489 };
490
gio48fde052025-05-14 09:48:08 +0000491 const injectNetworkNodes = () => {
492 const newNetworks = get().env.networks.filter(
493 (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
494 );
495 newNetworks.forEach((n) => {
496 get().addNode({
497 id: n.domain,
498 type: "network",
499 connectable: true,
500 data: {
501 domain: n.domain,
502 label: n.domain,
503 envVars: [],
504 ports: [],
505 state: "success", // TODO(gio): monitor network health
506 },
507 });
508 console.log("added network", n.domain);
509 });
510 };
511
gio4b9b58a2025-05-12 11:46:08 +0000512 const restoreSaved = async () => {
gio818da4e2025-05-12 14:45:35 +0000513 const { projectId } = get();
514 const resp = await fetch(`/api/project/${projectId}/saved/${get().mode === "deploy" ? "deploy" : "draft"}`, {
gio4b9b58a2025-05-12 11:46:08 +0000515 method: "GET",
516 });
517 const inst = await resp.json();
gio48fde052025-05-14 09:48:08 +0000518 setN(inst.nodes);
519 set({ edges: inst.edges });
520 injectNetworkNodes();
gio359a6852025-05-14 03:38:24 +0000521 if (
522 get().zoom.x !== inst.viewport.x ||
523 get().zoom.y !== inst.viewport.y ||
524 get().zoom.zoom !== inst.viewport.zoom
525 ) {
526 set({ zoom: inst.viewport });
527 }
giod0026612025-05-08 13:00:36 +0000528 };
gio7f98e772025-05-07 11:00:14 +0000529
giod0026612025-05-08 13:00:36 +0000530 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
531 setN(
532 get().nodes.map((n) => {
533 if (n.id === id) {
534 return {
535 ...n,
536 data: {
537 ...n.data,
538 ...data,
539 },
540 } as Extract<AppNode, { type: T }>;
541 }
542 return n;
543 }),
544 );
545 }
gio7f98e772025-05-07 11:00:14 +0000546
giod0026612025-05-08 13:00:36 +0000547 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
548 setN(
549 get().nodes.map((n) => {
550 if (n.id === id) {
551 return {
552 ...n,
553 ...node,
554 } as Extract<AppNode, { type: T }>;
555 }
556 return n;
557 }),
558 );
559 }
gio7f98e772025-05-07 11:00:14 +0000560
giod0026612025-05-08 13:00:36 +0000561 function onConnect(c: Connection) {
562 const { nodes, edges } = get();
563 set({
564 edges: addEdge(c, edges),
565 });
566 const sn = nodes.filter((n) => n.id === c.source)[0]!;
567 const tn = nodes.filter((n) => n.id === c.target)[0]!;
568 if (tn.type === "network") {
569 if (sn.type === "gateway-https") {
570 updateNodeData<"gateway-https">(sn.id, {
571 network: tn.data.domain,
572 });
573 } else if (sn.type === "gateway-tcp") {
574 updateNodeData<"gateway-tcp">(sn.id, {
575 network: tn.data.domain,
576 });
577 }
578 }
579 if (tn.type === "app") {
580 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
581 const sourceEnvVars = nodeEnvVarNames(sn);
582 if (sourceEnvVars.length === 0) {
583 throw new Error("MUST NOT REACH!");
584 }
585 const id = uuidv4();
586 if (sourceEnvVars.length === 1) {
587 updateNode<"app">(c.target, {
588 ...tn,
589 data: {
590 ...tn.data,
591 envVars: [
592 ...(tn.data.envVars || []),
593 {
594 id: id,
595 source: c.source,
596 name: sourceEnvVars[0],
597 isEditting: false,
598 },
599 ],
600 },
601 });
602 } else {
603 updateNode<"app">(c.target, {
604 ...tn,
605 data: {
606 ...tn.data,
607 envVars: [
608 ...(tn.data.envVars || []),
609 {
610 id: id,
611 source: c.source,
612 },
613 ],
614 },
615 });
616 }
617 }
618 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
619 const sourcePorts = sn.data.ports || [];
620 const id = uuidv4();
621 if (sourcePorts.length === 1) {
622 updateNode<"app">(c.target, {
623 ...tn,
624 data: {
625 ...tn.data,
626 envVars: [
627 ...(tn.data.envVars || []),
628 {
629 id: id,
630 source: c.source,
631 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
632 portId: sourcePorts[0].id,
633 isEditting: false,
634 },
635 ],
636 },
637 });
638 }
639 }
640 }
641 if (c.sourceHandle === "volume") {
642 updateNodeData<"volume">(c.source, {
643 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
644 });
645 }
646 if (c.targetHandle === "volume") {
647 if (tn.type === "postgresql" || tn.type === "mongodb") {
648 updateNodeData(c.target, {
649 volumeId: c.source,
650 });
651 }
652 }
653 if (c.targetHandle === "https") {
654 if ((sn.data.ports || []).length === 1) {
655 updateNodeData<"gateway-https">(c.target, {
656 https: {
657 serviceId: c.source,
658 portId: sn.data.ports![0].id,
659 },
660 });
661 } else {
662 updateNodeData<"gateway-https">(c.target, {
663 https: {
664 serviceId: c.source,
665 portId: "", // TODO(gio)
666 },
667 });
668 }
669 }
670 if (c.targetHandle === "tcp") {
671 const td = tn.data as GatewayTCPData;
672 if ((sn.data.ports || []).length === 1) {
673 updateNodeData<"gateway-tcp">(c.target, {
674 exposed: (td.exposed || []).concat({
675 serviceId: c.source,
676 portId: sn.data.ports![0].id,
677 }),
678 });
679 } else {
680 updateNodeData<"gateway-tcp">(c.target, {
681 selected: {
682 serviceId: c.source,
683 portId: undefined,
684 },
685 });
686 }
687 }
688 if (sn.type === "app") {
689 if (c.sourceHandle === "ports") {
690 updateNodeData<"app">(sn.id, {
691 isChoosingPortToConnect: true,
692 });
693 }
694 }
695 if (tn.type === "app") {
696 if (c.targetHandle === "repository") {
697 updateNodeData<"app">(tn.id, {
698 repository: {
699 id: c.source,
700 branch: "master",
701 rootDir: "/",
702 },
703 });
704 }
705 }
706 }
707 return {
708 projectId: undefined,
gio818da4e2025-05-12 14:45:35 +0000709 mode: "edit",
giod0026612025-05-08 13:00:36 +0000710 projects: [],
711 nodes: [],
712 edges: [],
713 categories: defaultCategories,
714 messages: v([]),
715 env: defaultEnv,
gioaf8db832025-05-13 14:43:05 +0000716 viewport: {
717 transformX: 0,
718 transformY: 0,
719 transformZoom: 1,
720 width: 800,
721 height: 600,
722 },
gio359a6852025-05-14 03:38:24 +0000723 zoom: {
724 x: 0,
725 y: 0,
726 zoom: 1,
727 },
giod0026612025-05-08 13:00:36 +0000728 githubService: null,
gioaf8db832025-05-13 14:43:05 +0000729 setViewport: (viewport) => {
730 const { viewport: vp } = get();
731 if (
732 viewport.transformX !== vp.transformX ||
733 viewport.transformY !== vp.transformY ||
734 viewport.transformZoom !== vp.transformZoom ||
735 viewport.width !== vp.width ||
736 viewport.height !== vp.height
737 ) {
738 set({ viewport });
739 }
740 },
giod0026612025-05-08 13:00:36 +0000741 setHighlightCategory: (name, active) => {
742 set({
743 categories: get().categories.map((c) => {
744 if (c.title.toLowerCase() !== name.toLowerCase()) {
745 return c;
746 } else {
747 return {
748 ...c,
749 active,
750 };
751 }
752 }),
753 });
754 },
755 onNodesChange: (changes) => {
756 const nodes = applyNodeChanges(changes, get().nodes);
757 setN(nodes);
758 },
759 onEdgesChange: (changes) => {
760 set({
761 edges: applyEdgeChanges(changes, get().edges),
762 });
763 },
gioaf8db832025-05-13 14:43:05 +0000764 addNode: (node) => {
765 const { viewport, nodes } = get();
766 setN(
767 nodes.concat({
768 ...node,
769 position: getRandomPosition(viewport),
770 }),
771 );
772 },
giod0026612025-05-08 13:00:36 +0000773 setNodes: (nodes) => {
774 setN(nodes);
775 },
776 setEdges: (edges) => {
777 set({ edges });
778 },
779 replaceEdge: (c, id) => {
780 let change: EdgeChange;
781 if (id === undefined) {
782 change = {
783 type: "add",
784 item: {
785 id: uuidv4(),
786 ...c,
787 },
788 };
789 onConnect(c);
790 } else {
791 change = {
792 type: "replace",
793 id,
794 item: {
795 id,
796 ...c,
797 },
798 };
799 }
800 set({
801 edges: applyEdgeChanges([change], get().edges),
802 });
803 },
804 updateNode,
805 updateNodeData,
806 onConnect,
807 refreshEnv: async () => {
808 const projectId = get().projectId;
809 let env: Env = defaultEnv;
gio7f98e772025-05-07 11:00:14 +0000810
giod0026612025-05-08 13:00:36 +0000811 try {
812 if (projectId) {
813 const response = await fetch(`/api/project/${projectId}/env`);
814 if (response.ok) {
815 const data = await response.json();
816 const result = envSchema.safeParse(data);
817 if (result.success) {
818 env = result.data;
819 } else {
820 console.error("Invalid env data:", result.error);
821 }
822 }
823 }
824 } catch (error) {
825 console.error("Failed to fetch integrations:", error);
826 } finally {
gio4b9b58a2025-05-12 11:46:08 +0000827 if (JSON.stringify(get().env) !== JSON.stringify(env)) {
828 set({ env });
gio48fde052025-05-14 09:48:08 +0000829 injectNetworkNodes();
gio4b9b58a2025-05-12 11:46:08 +0000830
831 if (env.integrations.github) {
832 set({ githubService: new GitHubServiceImpl(projectId!) });
833 } else {
834 set({ githubService: null });
835 }
giod0026612025-05-08 13:00:36 +0000836 }
837 }
838 },
gio818da4e2025-05-12 14:45:35 +0000839 setMode: (mode) => {
840 set({ mode });
841 },
842 setProject: async (projectId) => {
gio359a6852025-05-14 03:38:24 +0000843 if (projectId === get().projectId) {
844 return;
845 }
giod0026612025-05-08 13:00:36 +0000846 set({
847 projectId,
848 });
849 if (projectId) {
gio818da4e2025-05-12 14:45:35 +0000850 await get().refreshEnv();
851 if (get().env.deployKey) {
852 set({ mode: "deploy" });
853 } else {
854 set({ mode: "edit" });
855 }
gio4b9b58a2025-05-12 11:46:08 +0000856 restoreSaved();
857 } else {
858 set({
859 nodes: [],
860 edges: [],
861 });
giod0026612025-05-08 13:00:36 +0000862 }
863 },
864 };
gio5f2f1002025-03-20 18:38:48 +0400865});