blob: aae7600683961e1786eecb076f0621dd687aac5b [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",
gio33046722025-05-16 14:49:55 +000088 "nodejs: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": {
gio29050d62025-05-16 04:49:26 +0000198 if (n.data && n.data.subdomain) {
199 return `${n.data.subdomain}`;
gio48fde052025-05-14 09:48:08 +0000200 } else {
201 return "HTTPS Gateway";
202 }
giod0026612025-05-08 13:00:36 +0000203 }
gio48fde052025-05-14 09:48:08 +0000204 case "gateway-tcp": {
gio29050d62025-05-16 04:49:26 +0000205 if (n.data && n.data.subdomain) {
206 return `${n.data.subdomain}`;
gio48fde052025-05-14 09:48:08 +0000207 } 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
gio3ec94242025-05-16 12:46:57 +0000477export function useMode(): "edit" | "deploy" {
478 return useStateStore((state) => state.mode);
479}
480
gio5f2f1002025-03-20 18:38:48 +0400481const v: Validator = CreateValidators();
482
gioaf8db832025-05-13 14:43:05 +0000483function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
484 const zoomMultiplier = 1 / transformZoom;
485 const realWidth = width * zoomMultiplier;
486 const realHeight = height * zoomMultiplier;
487 const paddingMultiplier = 0.8;
488 const ret = {
489 x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
490 y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
491 };
492 return ret;
493}
494
gio5f2f1002025-03-20 18:38:48 +0400495export const useStateStore = create<AppState>((set, get): AppState => {
giod0026612025-05-08 13:00:36 +0000496 const setN = (nodes: AppNode[]) => {
gio4b9b58a2025-05-12 11:46:08 +0000497 set({
giod0026612025-05-08 13:00:36 +0000498 nodes,
gio5cf364c2025-05-08 16:01:21 +0000499 messages: v(nodes),
gio4b9b58a2025-05-12 11:46:08 +0000500 });
501 };
502
gio48fde052025-05-14 09:48:08 +0000503 const injectNetworkNodes = () => {
504 const newNetworks = get().env.networks.filter(
505 (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
506 );
507 newNetworks.forEach((n) => {
508 get().addNode({
509 id: n.domain,
510 type: "network",
511 connectable: true,
512 data: {
513 domain: n.domain,
514 label: n.domain,
515 envVars: [],
516 ports: [],
517 state: "success", // TODO(gio): monitor network health
518 },
519 });
520 console.log("added network", n.domain);
521 });
522 };
523
gio4b9b58a2025-05-12 11:46:08 +0000524 const restoreSaved = async () => {
gio818da4e2025-05-12 14:45:35 +0000525 const { projectId } = get();
526 const resp = await fetch(`/api/project/${projectId}/saved/${get().mode === "deploy" ? "deploy" : "draft"}`, {
gio4b9b58a2025-05-12 11:46:08 +0000527 method: "GET",
528 });
529 const inst = await resp.json();
gio48fde052025-05-14 09:48:08 +0000530 setN(inst.nodes);
531 set({ edges: inst.edges });
532 injectNetworkNodes();
gio359a6852025-05-14 03:38:24 +0000533 if (
534 get().zoom.x !== inst.viewport.x ||
535 get().zoom.y !== inst.viewport.y ||
536 get().zoom.zoom !== inst.viewport.zoom
537 ) {
538 set({ zoom: inst.viewport });
539 }
giod0026612025-05-08 13:00:36 +0000540 };
gio7f98e772025-05-07 11:00:14 +0000541
giod0026612025-05-08 13:00:36 +0000542 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
543 setN(
544 get().nodes.map((n) => {
545 if (n.id === id) {
546 return {
547 ...n,
548 data: {
549 ...n.data,
550 ...data,
551 },
552 } as Extract<AppNode, { type: T }>;
553 }
554 return n;
555 }),
556 );
557 }
gio7f98e772025-05-07 11:00:14 +0000558
giod0026612025-05-08 13:00:36 +0000559 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
560 setN(
561 get().nodes.map((n) => {
562 if (n.id === id) {
563 return {
564 ...n,
565 ...node,
566 } as Extract<AppNode, { type: T }>;
567 }
568 return n;
569 }),
570 );
571 }
gio7f98e772025-05-07 11:00:14 +0000572
giod0026612025-05-08 13:00:36 +0000573 function onConnect(c: Connection) {
574 const { nodes, edges } = get();
575 set({
576 edges: addEdge(c, edges),
577 });
578 const sn = nodes.filter((n) => n.id === c.source)[0]!;
579 const tn = nodes.filter((n) => n.id === c.target)[0]!;
580 if (tn.type === "network") {
581 if (sn.type === "gateway-https") {
582 updateNodeData<"gateway-https">(sn.id, {
583 network: tn.data.domain,
584 });
585 } else if (sn.type === "gateway-tcp") {
586 updateNodeData<"gateway-tcp">(sn.id, {
587 network: tn.data.domain,
588 });
589 }
590 }
591 if (tn.type === "app") {
592 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
593 const sourceEnvVars = nodeEnvVarNames(sn);
594 if (sourceEnvVars.length === 0) {
595 throw new Error("MUST NOT REACH!");
596 }
597 const id = uuidv4();
598 if (sourceEnvVars.length === 1) {
599 updateNode<"app">(c.target, {
600 ...tn,
601 data: {
602 ...tn.data,
603 envVars: [
604 ...(tn.data.envVars || []),
605 {
606 id: id,
607 source: c.source,
608 name: sourceEnvVars[0],
609 isEditting: false,
610 },
611 ],
612 },
613 });
614 } else {
615 updateNode<"app">(c.target, {
616 ...tn,
617 data: {
618 ...tn.data,
619 envVars: [
620 ...(tn.data.envVars || []),
621 {
622 id: id,
623 source: c.source,
624 },
625 ],
626 },
627 });
628 }
629 }
630 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
631 const sourcePorts = sn.data.ports || [];
632 const id = uuidv4();
633 if (sourcePorts.length === 1) {
634 updateNode<"app">(c.target, {
635 ...tn,
636 data: {
637 ...tn.data,
638 envVars: [
639 ...(tn.data.envVars || []),
640 {
641 id: id,
642 source: c.source,
643 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
644 portId: sourcePorts[0].id,
645 isEditting: false,
646 },
647 ],
648 },
649 });
650 }
651 }
652 }
653 if (c.sourceHandle === "volume") {
654 updateNodeData<"volume">(c.source, {
655 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
656 });
657 }
658 if (c.targetHandle === "volume") {
659 if (tn.type === "postgresql" || tn.type === "mongodb") {
660 updateNodeData(c.target, {
661 volumeId: c.source,
662 });
663 }
664 }
665 if (c.targetHandle === "https") {
666 if ((sn.data.ports || []).length === 1) {
667 updateNodeData<"gateway-https">(c.target, {
668 https: {
669 serviceId: c.source,
670 portId: sn.data.ports![0].id,
671 },
672 });
673 } else {
674 updateNodeData<"gateway-https">(c.target, {
675 https: {
676 serviceId: c.source,
677 portId: "", // TODO(gio)
678 },
679 });
680 }
681 }
682 if (c.targetHandle === "tcp") {
683 const td = tn.data as GatewayTCPData;
684 if ((sn.data.ports || []).length === 1) {
685 updateNodeData<"gateway-tcp">(c.target, {
686 exposed: (td.exposed || []).concat({
687 serviceId: c.source,
688 portId: sn.data.ports![0].id,
689 }),
690 });
691 } else {
692 updateNodeData<"gateway-tcp">(c.target, {
693 selected: {
694 serviceId: c.source,
695 portId: undefined,
696 },
697 });
698 }
699 }
700 if (sn.type === "app") {
701 if (c.sourceHandle === "ports") {
702 updateNodeData<"app">(sn.id, {
703 isChoosingPortToConnect: true,
704 });
705 }
706 }
707 if (tn.type === "app") {
708 if (c.targetHandle === "repository") {
709 updateNodeData<"app">(tn.id, {
710 repository: {
711 id: c.source,
712 branch: "master",
713 rootDir: "/",
714 },
715 });
716 }
717 }
718 }
719 return {
720 projectId: undefined,
gio818da4e2025-05-12 14:45:35 +0000721 mode: "edit",
giod0026612025-05-08 13:00:36 +0000722 projects: [],
723 nodes: [],
724 edges: [],
725 categories: defaultCategories,
726 messages: v([]),
727 env: defaultEnv,
gioaf8db832025-05-13 14:43:05 +0000728 viewport: {
729 transformX: 0,
730 transformY: 0,
731 transformZoom: 1,
732 width: 800,
733 height: 600,
734 },
gio359a6852025-05-14 03:38:24 +0000735 zoom: {
736 x: 0,
737 y: 0,
738 zoom: 1,
739 },
giod0026612025-05-08 13:00:36 +0000740 githubService: null,
gioaf8db832025-05-13 14:43:05 +0000741 setViewport: (viewport) => {
742 const { viewport: vp } = get();
743 if (
744 viewport.transformX !== vp.transformX ||
745 viewport.transformY !== vp.transformY ||
746 viewport.transformZoom !== vp.transformZoom ||
747 viewport.width !== vp.width ||
748 viewport.height !== vp.height
749 ) {
750 set({ viewport });
751 }
752 },
giod0026612025-05-08 13:00:36 +0000753 setHighlightCategory: (name, active) => {
754 set({
755 categories: get().categories.map((c) => {
756 if (c.title.toLowerCase() !== name.toLowerCase()) {
757 return c;
758 } else {
759 return {
760 ...c,
761 active,
762 };
763 }
764 }),
765 });
766 },
767 onNodesChange: (changes) => {
768 const nodes = applyNodeChanges(changes, get().nodes);
769 setN(nodes);
770 },
771 onEdgesChange: (changes) => {
772 set({
773 edges: applyEdgeChanges(changes, get().edges),
774 });
775 },
gioaf8db832025-05-13 14:43:05 +0000776 addNode: (node) => {
777 const { viewport, nodes } = get();
778 setN(
779 nodes.concat({
780 ...node,
781 position: getRandomPosition(viewport),
782 }),
783 );
784 },
giod0026612025-05-08 13:00:36 +0000785 setNodes: (nodes) => {
786 setN(nodes);
787 },
788 setEdges: (edges) => {
789 set({ edges });
790 },
791 replaceEdge: (c, id) => {
792 let change: EdgeChange;
793 if (id === undefined) {
794 change = {
795 type: "add",
796 item: {
797 id: uuidv4(),
798 ...c,
799 },
800 };
801 onConnect(c);
802 } else {
803 change = {
804 type: "replace",
805 id,
806 item: {
807 id,
808 ...c,
809 },
810 };
811 }
812 set({
813 edges: applyEdgeChanges([change], get().edges),
814 });
815 },
816 updateNode,
817 updateNodeData,
818 onConnect,
819 refreshEnv: async () => {
820 const projectId = get().projectId;
821 let env: Env = defaultEnv;
gio7f98e772025-05-07 11:00:14 +0000822
giod0026612025-05-08 13:00:36 +0000823 try {
824 if (projectId) {
825 const response = await fetch(`/api/project/${projectId}/env`);
826 if (response.ok) {
827 const data = await response.json();
828 const result = envSchema.safeParse(data);
829 if (result.success) {
830 env = result.data;
831 } else {
832 console.error("Invalid env data:", result.error);
833 }
834 }
835 }
836 } catch (error) {
837 console.error("Failed to fetch integrations:", error);
838 } finally {
gio4b9b58a2025-05-12 11:46:08 +0000839 if (JSON.stringify(get().env) !== JSON.stringify(env)) {
840 set({ env });
gio48fde052025-05-14 09:48:08 +0000841 injectNetworkNodes();
gio4b9b58a2025-05-12 11:46:08 +0000842
843 if (env.integrations.github) {
844 set({ githubService: new GitHubServiceImpl(projectId!) });
845 } else {
846 set({ githubService: null });
847 }
giod0026612025-05-08 13:00:36 +0000848 }
849 }
850 },
gio818da4e2025-05-12 14:45:35 +0000851 setMode: (mode) => {
852 set({ mode });
853 },
854 setProject: async (projectId) => {
gio359a6852025-05-14 03:38:24 +0000855 if (projectId === get().projectId) {
856 return;
857 }
giod0026612025-05-08 13:00:36 +0000858 set({
859 projectId,
860 });
861 if (projectId) {
gio818da4e2025-05-12 14:45:35 +0000862 await get().refreshEnv();
863 if (get().env.deployKey) {
864 set({ mode: "deploy" });
865 } else {
866 set({ mode: "edit" });
867 }
gio4b9b58a2025-05-12 11:46:08 +0000868 restoreSaved();
869 } else {
870 set({
871 nodes: [],
872 edges: [],
873 });
giod0026612025-05-08 13:00:36 +0000874 }
875 },
876 };
gio5f2f1002025-03-20 18:38:48 +0400877});