blob: b0c2560bec7aabf6d78c1b2386f5216ffd660d00 [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",
giobceb0852025-05-20 13:15:18 +040089 "nodejs:24.0.2",
gio91165612025-05-03 17:07:38 +000090] as const;
giod0026612025-05-08 13:00:36 +000091export type ServiceType = (typeof ServiceTypes)[number];
gio5f2f1002025-03-20 18:38:48 +040092
gio48fde052025-05-14 09:48:08 +000093export type Domain = {
94 network: string;
95 subdomain: string;
96};
97
gio5f2f1002025-03-20 18:38:48 +040098export type ServiceData = NodeData & {
giod0026612025-05-08 13:00:36 +000099 type: ServiceType;
100 repository:
101 | {
102 id: string;
103 }
104 | {
105 id: string;
106 branch: string;
107 }
108 | {
109 id: string;
110 branch: string;
111 rootDir: string;
112 };
113 env: string[];
114 volume: string[];
115 preBuildCommands: string;
116 isChoosingPortToConnect: boolean;
gio48fde052025-05-14 09:48:08 +0000117 dev?:
118 | {
119 enabled: false;
120 expose?: Domain;
121 }
122 | {
123 enabled: true;
124 expose?: Domain;
125 codeServerNodeId: string;
126 sshNodeId: string;
127 };
gio5f2f1002025-03-20 18:38:48 +0400128};
129
130export type ServiceNode = Node<ServiceData> & {
giod0026612025-05-08 13:00:36 +0000131 type: "app";
gio5f2f1002025-03-20 18:38:48 +0400132};
133
134export type VolumeType = "ReadWriteOnce" | "ReadOnlyMany" | "ReadWriteMany" | "ReadWriteOncePod";
135
136export type VolumeData = NodeData & {
giod0026612025-05-08 13:00:36 +0000137 type: VolumeType;
138 size: string;
139 attachedTo: string[];
gio5f2f1002025-03-20 18:38:48 +0400140};
141
142export type VolumeNode = Node<VolumeData> & {
giod0026612025-05-08 13:00:36 +0000143 type: "volume";
gio5f2f1002025-03-20 18:38:48 +0400144};
145
146export type PostgreSQLData = NodeData & {
giod0026612025-05-08 13:00:36 +0000147 volumeId: string;
gio5f2f1002025-03-20 18:38:48 +0400148};
149
150export type PostgreSQLNode = Node<PostgreSQLData> & {
giod0026612025-05-08 13:00:36 +0000151 type: "postgresql";
gio5f2f1002025-03-20 18:38:48 +0400152};
153
154export type MongoDBData = NodeData & {
giod0026612025-05-08 13:00:36 +0000155 volumeId: string;
gio5f2f1002025-03-20 18:38:48 +0400156};
157
158export type MongoDBNode = Node<MongoDBData> & {
giod0026612025-05-08 13:00:36 +0000159 type: "mongodb";
gio5f2f1002025-03-20 18:38:48 +0400160};
161
162export type GithubData = NodeData & {
giod0026612025-05-08 13:00:36 +0000163 repository?: {
164 id: number;
165 sshURL: string;
gio818da4e2025-05-12 14:45:35 +0000166 fullName: string;
giod0026612025-05-08 13:00:36 +0000167 };
gio5f2f1002025-03-20 18:38:48 +0400168};
169
170export type GithubNode = Node<GithubData> & {
giod0026612025-05-08 13:00:36 +0000171 type: "github";
gio5f2f1002025-03-20 18:38:48 +0400172};
173
174export type NANode = Node<NodeData> & {
giod0026612025-05-08 13:00:36 +0000175 type: undefined;
gio5f2f1002025-03-20 18:38:48 +0400176};
177
giod0026612025-05-08 13:00:36 +0000178export type AppNode =
179 | NetworkNode
180 | GatewayHttpsNode
181 | GatewayTCPNode
182 | ServiceNode
183 | VolumeNode
184 | PostgreSQLNode
185 | MongoDBNode
186 | GithubNode
187 | NANode;
gio5f2f1002025-03-20 18:38:48 +0400188
189export function nodeLabel(n: AppNode): string {
gio48fde052025-05-14 09:48:08 +0000190 try {
191 switch (n.type) {
192 case "network":
193 return n.data.domain;
194 case "app":
195 return n.data.label || "Service";
196 case "github":
197 return n.data.repository?.fullName || "Github";
198 case "gateway-https": {
gio29050d62025-05-16 04:49:26 +0000199 if (n.data && n.data.subdomain) {
200 return `${n.data.subdomain}`;
gio48fde052025-05-14 09:48:08 +0000201 } else {
202 return "HTTPS Gateway";
203 }
giod0026612025-05-08 13:00:36 +0000204 }
gio48fde052025-05-14 09:48:08 +0000205 case "gateway-tcp": {
gio29050d62025-05-16 04:49:26 +0000206 if (n.data && n.data.subdomain) {
207 return `${n.data.subdomain}`;
gio48fde052025-05-14 09:48:08 +0000208 } else {
209 return "TCP Gateway";
210 }
giod0026612025-05-08 13:00:36 +0000211 }
gio48fde052025-05-14 09:48:08 +0000212 case "mongodb":
213 return n.data.label || "MongoDB";
214 case "postgresql":
215 return n.data.label || "PostgreSQL";
216 case "volume":
217 return n.data.label || "Volume";
218 case undefined:
219 throw new Error("MUST NOT REACH!");
giod0026612025-05-08 13:00:36 +0000220 }
gio48fde052025-05-14 09:48:08 +0000221 } catch (e) {
222 console.error("opaa", e);
223 } finally {
224 console.log("done");
giod0026612025-05-08 13:00:36 +0000225 }
gio5f2f1002025-03-20 18:38:48 +0400226}
227
228export function nodeIsConnectable(n: AppNode, handle: string): boolean {
giod0026612025-05-08 13:00:36 +0000229 switch (n.type) {
230 case "network":
231 return true;
232 case "app":
233 if (handle === "ports") {
234 return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
235 } else if (handle === "repository") {
236 if (!n.data || !n.data.repository || !n.data.repository.id) {
237 return true;
238 }
239 return false;
240 }
241 return false;
242 case "github":
243 if (n.data.repository?.id !== undefined) {
244 return true;
245 }
246 return false;
247 case "gateway-https":
248 if (handle === "subdomain") {
249 return n.data.network === undefined;
250 }
251 return n.data === undefined || n.data.https === undefined;
252 case "gateway-tcp":
253 if (handle === "subdomain") {
254 return n.data.network === undefined;
255 }
256 return true;
257 case "mongodb":
258 return true;
259 case "postgresql":
260 return true;
261 case "volume":
262 if (n.data === undefined || n.data.type === undefined) {
263 return false;
264 }
265 if (n.data.type === "ReadWriteOnce" || n.data.type === "ReadWriteOncePod") {
266 return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
267 }
268 return true;
269 case undefined:
270 throw new Error("MUST NOT REACH!");
271 }
gio5f2f1002025-03-20 18:38:48 +0400272}
273
giod0026612025-05-08 13:00:36 +0000274export type BoundEnvVar =
275 | {
276 id: string;
277 source: string | null;
278 }
279 | {
280 id: string;
281 source: string | null;
282 name: string;
283 isEditting: boolean;
284 }
285 | {
286 id: string;
287 source: string | null;
288 name: string;
289 alias: string;
290 isEditting: boolean;
291 }
292 | {
293 id: string;
294 source: string | null;
295 portId: string;
296 name: string;
297 alias: string;
298 isEditting: boolean;
299 };
gio5f2f1002025-03-20 18:38:48 +0400300
301export type EnvVar = {
giod0026612025-05-08 13:00:36 +0000302 name: string;
303 value: string;
gio5f2f1002025-03-20 18:38:48 +0400304};
305
giob41ecae2025-04-24 08:46:50 +0000306export function nodeEnvVarNamePort(n: AppNode, portName: string): string {
giod0026612025-05-08 13:00:36 +0000307 return `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${portName.toUpperCase()}`;
giob41ecae2025-04-24 08:46:50 +0000308}
309
gio5f2f1002025-03-20 18:38:48 +0400310export function nodeEnvVarNames(n: AppNode): string[] {
giod0026612025-05-08 13:00:36 +0000311 switch (n.type) {
312 case "app":
313 return [
314 `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
315 ...(n.data.ports || []).map((p) => nodeEnvVarNamePort(n, p.name)),
316 ];
317 case "github":
318 return [];
319 case "gateway-https":
320 return [];
321 case "gateway-tcp":
322 return [];
323 case "mongodb":
324 return [`DODO_MONGODB_${n.data.label.toUpperCase()}_URL`];
325 case "postgresql":
326 return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_URL`];
327 case "volume":
328 return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
329 case undefined:
330 throw new Error("MUST NOT REACH");
331 default:
332 throw new Error("MUST NOT REACH");
333 }
gio5f2f1002025-03-20 18:38:48 +0400334}
335
336export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
337
338export type MessageType = "INFO" | "WARNING" | "FATAL";
339
340export type Message = {
giod0026612025-05-08 13:00:36 +0000341 id: string;
342 type: MessageType;
343 nodeId?: string;
344 message: string;
345 onHighlight?: (state: AppState) => void;
346 onLooseHighlight?: (state: AppState) => void;
347 onClick?: (state: AppState) => void;
gio5f2f1002025-03-20 18:38:48 +0400348};
349
giob77cb932025-05-19 09:37:14 +0000350export const accessSchema = z.discriminatedUnion("type", [
351 z.object({
352 type: z.literal("https"),
353 name: z.string(),
354 address: z.string(),
355 }),
356 z.object({
357 type: z.literal("ssh"),
358 name: z.string(),
359 host: z.string(),
360 port: z.number(),
361 }),
362 z.object({
363 type: z.literal("tcp"),
364 name: z.string(),
365 host: z.string(),
366 port: z.number(),
367 }),
368 z.object({
369 type: z.literal("udp"),
370 name: z.string(),
371 host: z.string(),
372 port: z.number(),
373 }),
374 z.object({
375 type: z.literal("postgresql"),
376 name: z.string(),
377 host: z.string(),
378 port: z.number(),
379 database: z.string(),
380 username: z.string(),
381 password: z.string(),
382 }),
383 z.object({
384 type: z.literal("mongodb"),
385 name: z.string(),
386 host: z.string(),
387 port: z.number(),
388 database: z.string(),
389 username: z.string(),
390 password: z.string(),
391 }),
392]);
393
gio5f2f1002025-03-20 18:38:48 +0400394export const envSchema = z.object({
gio7d813702025-05-08 18:29:52 +0000395 managerAddr: z.optional(z.string().min(1)),
gio09fcab52025-05-12 14:05:07 +0000396 deployKey: z.optional(z.nullable(z.string().min(1))),
giod0026612025-05-08 13:00:36 +0000397 networks: z
398 .array(
399 z.object({
400 name: z.string().min(1),
401 domain: z.string().min(1),
gio6d8b71c2025-05-19 12:57:35 +0000402 hasAuth: z.boolean(),
giod0026612025-05-08 13:00:36 +0000403 }),
404 )
405 .default([]),
406 integrations: z.object({
407 github: z.boolean(),
408 }),
gio3a921b82025-05-10 07:36:09 +0000409 services: z.array(z.string()),
gio3ed59592025-05-14 16:51:09 +0000410 user: z.object({
411 id: z.string(),
412 username: z.string(),
413 }),
giob77cb932025-05-19 09:37:14 +0000414 access: z.array(accessSchema),
gio5f2f1002025-03-20 18:38:48 +0400415});
416
417export type Env = z.infer<typeof envSchema>;
418
gio7f98e772025-05-07 11:00:14 +0000419const defaultEnv: Env = {
gio7d813702025-05-08 18:29:52 +0000420 managerAddr: undefined,
giod0026612025-05-08 13:00:36 +0000421 deployKey: undefined,
422 networks: [],
423 integrations: {
424 github: false,
425 },
gio3a921b82025-05-10 07:36:09 +0000426 services: [],
gio3ed59592025-05-14 16:51:09 +0000427 user: {
428 id: "",
429 username: "",
430 },
giob77cb932025-05-19 09:37:14 +0000431 access: [],
gio7f98e772025-05-07 11:00:14 +0000432};
433
gio5f2f1002025-03-20 18:38:48 +0400434export type Project = {
giod0026612025-05-08 13:00:36 +0000435 id: string;
436 name: string;
437};
gio5f2f1002025-03-20 18:38:48 +0400438
gio7f98e772025-05-07 11:00:14 +0000439export type IntegrationsConfig = {
giod0026612025-05-08 13:00:36 +0000440 github: boolean;
gio7f98e772025-05-07 11:00:14 +0000441};
442
443type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
444type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
445
gioaf8db832025-05-13 14:43:05 +0000446type Viewport = {
447 transformX: number;
448 transformY: number;
449 transformZoom: number;
450 width: number;
451 height: number;
452};
453
gio5f2f1002025-03-20 18:38:48 +0400454export type AppState = {
giod0026612025-05-08 13:00:36 +0000455 projectId: string | undefined;
gio818da4e2025-05-12 14:45:35 +0000456 mode: "edit" | "deploy";
giod0026612025-05-08 13:00:36 +0000457 projects: Project[];
458 nodes: AppNode[];
459 edges: Edge[];
gio359a6852025-05-14 03:38:24 +0000460 zoom: ReactFlowViewport;
giod0026612025-05-08 13:00:36 +0000461 categories: Category[];
462 messages: Message[];
463 env: Env;
gioaf8db832025-05-13 14:43:05 +0000464 viewport: Viewport;
465 setViewport: (viewport: Viewport) => void;
giod0026612025-05-08 13:00:36 +0000466 githubService: GitHubService | null;
467 setHighlightCategory: (name: string, active: boolean) => void;
468 onNodesChange: OnNodesChange<AppNode>;
469 onEdgesChange: OnEdgesChange;
470 onConnect: OnConnect;
gioaf8db832025-05-13 14:43:05 +0000471 addNode: (node: Omit<AppNode, "position">) => void;
giod0026612025-05-08 13:00:36 +0000472 setNodes: (nodes: AppNode[]) => void;
473 setEdges: (edges: Edge[]) => void;
gio818da4e2025-05-12 14:45:35 +0000474 setProject: (projectId: string | undefined) => Promise<void>;
475 setMode: (mode: "edit" | "deploy") => void;
giod0026612025-05-08 13:00:36 +0000476 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
477 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
478 replaceEdge: (c: Connection, id?: string) => void;
479 refreshEnv: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400480};
481
482const projectIdSelector = (state: AppState) => state.projectId;
483const categoriesSelector = (state: AppState) => state.categories;
484const messagesSelector = (state: AppState) => state.messages;
gio7f98e772025-05-07 11:00:14 +0000485const githubServiceSelector = (state: AppState) => state.githubService;
gio5f2f1002025-03-20 18:38:48 +0400486const envSelector = (state: AppState) => state.env;
gio359a6852025-05-14 03:38:24 +0000487const zoomSelector = (state: AppState) => state.zoom;
gioaf8db832025-05-13 14:43:05 +0000488
gio359a6852025-05-14 03:38:24 +0000489export function useZoom(): ReactFlowViewport {
490 return useStateStore(zoomSelector);
gioaf8db832025-05-13 14:43:05 +0000491}
gio5f2f1002025-03-20 18:38:48 +0400492
493export function useProjectId(): string | undefined {
giod0026612025-05-08 13:00:36 +0000494 return useStateStore(projectIdSelector);
gio5f2f1002025-03-20 18:38:48 +0400495}
496
giob45b1862025-05-20 11:42:20 +0000497export function useSetProject(): (projectId: string | undefined) => void {
498 return useStateStore((state) => state.setProject);
499}
500
gio5f2f1002025-03-20 18:38:48 +0400501export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000502 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400503}
504
505export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000506 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400507}
508
509export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000510 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400511}
512
513export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000514 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400515}
516
517export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000518 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400519}
520
gio5f2f1002025-03-20 18:38:48 +0400521export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000522 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000523}
524
525export function useGithubService(): GitHubService | null {
giod0026612025-05-08 13:00:36 +0000526 return useStateStore(githubServiceSelector);
gio5f2f1002025-03-20 18:38:48 +0400527}
528
gio3ec94242025-05-16 12:46:57 +0000529export function useMode(): "edit" | "deploy" {
530 return useStateStore((state) => state.mode);
531}
532
gio5f2f1002025-03-20 18:38:48 +0400533const v: Validator = CreateValidators();
534
gioaf8db832025-05-13 14:43:05 +0000535function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
536 const zoomMultiplier = 1 / transformZoom;
537 const realWidth = width * zoomMultiplier;
538 const realHeight = height * zoomMultiplier;
539 const paddingMultiplier = 0.8;
540 const ret = {
541 x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
542 y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
543 };
544 return ret;
545}
546
gio5f2f1002025-03-20 18:38:48 +0400547export const useStateStore = create<AppState>((set, get): AppState => {
giod0026612025-05-08 13:00:36 +0000548 const setN = (nodes: AppNode[]) => {
gio4b9b58a2025-05-12 11:46:08 +0000549 set({
giod0026612025-05-08 13:00:36 +0000550 nodes,
gio5cf364c2025-05-08 16:01:21 +0000551 messages: v(nodes),
gio4b9b58a2025-05-12 11:46:08 +0000552 });
553 };
554
gio48fde052025-05-14 09:48:08 +0000555 const injectNetworkNodes = () => {
556 const newNetworks = get().env.networks.filter(
557 (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
558 );
559 newNetworks.forEach((n) => {
560 get().addNode({
561 id: n.domain,
562 type: "network",
563 connectable: true,
564 data: {
565 domain: n.domain,
566 label: n.domain,
567 envVars: [],
568 ports: [],
569 state: "success", // TODO(gio): monitor network health
570 },
571 });
572 console.log("added network", n.domain);
573 });
574 };
575
gio4b9b58a2025-05-12 11:46:08 +0000576 const restoreSaved = async () => {
gio818da4e2025-05-12 14:45:35 +0000577 const { projectId } = get();
578 const resp = await fetch(`/api/project/${projectId}/saved/${get().mode === "deploy" ? "deploy" : "draft"}`, {
gio4b9b58a2025-05-12 11:46:08 +0000579 method: "GET",
580 });
581 const inst = await resp.json();
gio48fde052025-05-14 09:48:08 +0000582 setN(inst.nodes);
583 set({ edges: inst.edges });
584 injectNetworkNodes();
gio359a6852025-05-14 03:38:24 +0000585 if (
586 get().zoom.x !== inst.viewport.x ||
587 get().zoom.y !== inst.viewport.y ||
588 get().zoom.zoom !== inst.viewport.zoom
589 ) {
590 set({ zoom: inst.viewport });
591 }
giod0026612025-05-08 13:00:36 +0000592 };
gio7f98e772025-05-07 11:00:14 +0000593
giod0026612025-05-08 13:00:36 +0000594 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
595 setN(
596 get().nodes.map((n) => {
597 if (n.id === id) {
598 return {
599 ...n,
600 data: {
601 ...n.data,
602 ...data,
603 },
604 } as Extract<AppNode, { type: T }>;
605 }
606 return n;
607 }),
608 );
609 }
gio7f98e772025-05-07 11:00:14 +0000610
giod0026612025-05-08 13:00:36 +0000611 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
612 setN(
613 get().nodes.map((n) => {
614 if (n.id === id) {
615 return {
616 ...n,
617 ...node,
618 } as Extract<AppNode, { type: T }>;
619 }
620 return n;
621 }),
622 );
623 }
gio7f98e772025-05-07 11:00:14 +0000624
giod0026612025-05-08 13:00:36 +0000625 function onConnect(c: Connection) {
626 const { nodes, edges } = get();
627 set({
628 edges: addEdge(c, edges),
629 });
630 const sn = nodes.filter((n) => n.id === c.source)[0]!;
631 const tn = nodes.filter((n) => n.id === c.target)[0]!;
632 if (tn.type === "network") {
633 if (sn.type === "gateway-https") {
634 updateNodeData<"gateway-https">(sn.id, {
635 network: tn.data.domain,
636 });
637 } else if (sn.type === "gateway-tcp") {
638 updateNodeData<"gateway-tcp">(sn.id, {
639 network: tn.data.domain,
640 });
641 }
642 }
643 if (tn.type === "app") {
644 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
645 const sourceEnvVars = nodeEnvVarNames(sn);
646 if (sourceEnvVars.length === 0) {
647 throw new Error("MUST NOT REACH!");
648 }
649 const id = uuidv4();
650 if (sourceEnvVars.length === 1) {
651 updateNode<"app">(c.target, {
652 ...tn,
653 data: {
654 ...tn.data,
655 envVars: [
656 ...(tn.data.envVars || []),
657 {
658 id: id,
659 source: c.source,
660 name: sourceEnvVars[0],
661 isEditting: false,
662 },
663 ],
664 },
665 });
666 } else {
667 updateNode<"app">(c.target, {
668 ...tn,
669 data: {
670 ...tn.data,
671 envVars: [
672 ...(tn.data.envVars || []),
673 {
674 id: id,
675 source: c.source,
676 },
677 ],
678 },
679 });
680 }
681 }
682 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
683 const sourcePorts = sn.data.ports || [];
684 const id = uuidv4();
685 if (sourcePorts.length === 1) {
686 updateNode<"app">(c.target, {
687 ...tn,
688 data: {
689 ...tn.data,
690 envVars: [
691 ...(tn.data.envVars || []),
692 {
693 id: id,
694 source: c.source,
695 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
696 portId: sourcePorts[0].id,
697 isEditting: false,
698 },
699 ],
700 },
701 });
702 }
703 }
704 }
705 if (c.sourceHandle === "volume") {
706 updateNodeData<"volume">(c.source, {
707 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
708 });
709 }
710 if (c.targetHandle === "volume") {
711 if (tn.type === "postgresql" || tn.type === "mongodb") {
712 updateNodeData(c.target, {
713 volumeId: c.source,
714 });
715 }
716 }
717 if (c.targetHandle === "https") {
718 if ((sn.data.ports || []).length === 1) {
719 updateNodeData<"gateway-https">(c.target, {
720 https: {
721 serviceId: c.source,
722 portId: sn.data.ports![0].id,
723 },
724 });
725 } else {
726 updateNodeData<"gateway-https">(c.target, {
727 https: {
728 serviceId: c.source,
729 portId: "", // TODO(gio)
730 },
731 });
732 }
733 }
734 if (c.targetHandle === "tcp") {
735 const td = tn.data as GatewayTCPData;
736 if ((sn.data.ports || []).length === 1) {
737 updateNodeData<"gateway-tcp">(c.target, {
738 exposed: (td.exposed || []).concat({
739 serviceId: c.source,
740 portId: sn.data.ports![0].id,
741 }),
742 });
743 } else {
744 updateNodeData<"gateway-tcp">(c.target, {
745 selected: {
746 serviceId: c.source,
747 portId: undefined,
748 },
749 });
750 }
751 }
752 if (sn.type === "app") {
753 if (c.sourceHandle === "ports") {
754 updateNodeData<"app">(sn.id, {
755 isChoosingPortToConnect: true,
756 });
757 }
758 }
759 if (tn.type === "app") {
760 if (c.targetHandle === "repository") {
761 updateNodeData<"app">(tn.id, {
762 repository: {
763 id: c.source,
764 branch: "master",
765 rootDir: "/",
766 },
767 });
768 }
769 }
770 }
771 return {
772 projectId: undefined,
gio818da4e2025-05-12 14:45:35 +0000773 mode: "edit",
giod0026612025-05-08 13:00:36 +0000774 projects: [],
775 nodes: [],
776 edges: [],
777 categories: defaultCategories,
778 messages: v([]),
779 env: defaultEnv,
gioaf8db832025-05-13 14:43:05 +0000780 viewport: {
781 transformX: 0,
782 transformY: 0,
783 transformZoom: 1,
784 width: 800,
785 height: 600,
786 },
gio359a6852025-05-14 03:38:24 +0000787 zoom: {
788 x: 0,
789 y: 0,
790 zoom: 1,
791 },
giod0026612025-05-08 13:00:36 +0000792 githubService: null,
gioaf8db832025-05-13 14:43:05 +0000793 setViewport: (viewport) => {
794 const { viewport: vp } = get();
795 if (
796 viewport.transformX !== vp.transformX ||
797 viewport.transformY !== vp.transformY ||
798 viewport.transformZoom !== vp.transformZoom ||
799 viewport.width !== vp.width ||
800 viewport.height !== vp.height
801 ) {
802 set({ viewport });
803 }
804 },
giod0026612025-05-08 13:00:36 +0000805 setHighlightCategory: (name, active) => {
806 set({
807 categories: get().categories.map((c) => {
808 if (c.title.toLowerCase() !== name.toLowerCase()) {
809 return c;
810 } else {
811 return {
812 ...c,
813 active,
814 };
815 }
816 }),
817 });
818 },
819 onNodesChange: (changes) => {
820 const nodes = applyNodeChanges(changes, get().nodes);
821 setN(nodes);
822 },
823 onEdgesChange: (changes) => {
824 set({
825 edges: applyEdgeChanges(changes, get().edges),
826 });
827 },
gioaf8db832025-05-13 14:43:05 +0000828 addNode: (node) => {
829 const { viewport, nodes } = get();
830 setN(
831 nodes.concat({
832 ...node,
833 position: getRandomPosition(viewport),
834 }),
835 );
836 },
giod0026612025-05-08 13:00:36 +0000837 setNodes: (nodes) => {
838 setN(nodes);
839 },
840 setEdges: (edges) => {
841 set({ edges });
842 },
843 replaceEdge: (c, id) => {
844 let change: EdgeChange;
845 if (id === undefined) {
846 change = {
847 type: "add",
848 item: {
849 id: uuidv4(),
850 ...c,
851 },
852 };
853 onConnect(c);
854 } else {
855 change = {
856 type: "replace",
857 id,
858 item: {
859 id,
860 ...c,
861 },
862 };
863 }
864 set({
865 edges: applyEdgeChanges([change], get().edges),
866 });
867 },
868 updateNode,
869 updateNodeData,
870 onConnect,
871 refreshEnv: async () => {
872 const projectId = get().projectId;
873 let env: Env = defaultEnv;
gio7f98e772025-05-07 11:00:14 +0000874
giod0026612025-05-08 13:00:36 +0000875 try {
876 if (projectId) {
877 const response = await fetch(`/api/project/${projectId}/env`);
878 if (response.ok) {
879 const data = await response.json();
880 const result = envSchema.safeParse(data);
881 if (result.success) {
882 env = result.data;
883 } else {
884 console.error("Invalid env data:", result.error);
885 }
886 }
887 }
888 } catch (error) {
889 console.error("Failed to fetch integrations:", error);
890 } finally {
gio4b9b58a2025-05-12 11:46:08 +0000891 if (JSON.stringify(get().env) !== JSON.stringify(env)) {
892 set({ env });
gio48fde052025-05-14 09:48:08 +0000893 injectNetworkNodes();
gio4b9b58a2025-05-12 11:46:08 +0000894
895 if (env.integrations.github) {
896 set({ githubService: new GitHubServiceImpl(projectId!) });
897 } else {
898 set({ githubService: null });
899 }
giod0026612025-05-08 13:00:36 +0000900 }
901 }
902 },
gio818da4e2025-05-12 14:45:35 +0000903 setMode: (mode) => {
904 set({ mode });
905 },
906 setProject: async (projectId) => {
gio359a6852025-05-14 03:38:24 +0000907 if (projectId === get().projectId) {
908 return;
909 }
giod0026612025-05-08 13:00:36 +0000910 set({
911 projectId,
912 });
913 if (projectId) {
gio818da4e2025-05-12 14:45:35 +0000914 await get().refreshEnv();
915 if (get().env.deployKey) {
916 set({ mode: "deploy" });
917 } else {
918 set({ mode: "edit" });
919 }
gio4b9b58a2025-05-12 11:46:08 +0000920 restoreSaved();
921 } else {
922 set({
923 nodes: [],
924 edges: [],
925 });
giod0026612025-05-08 13:00:36 +0000926 }
927 },
928 };
gio5f2f1002025-03-20 18:38:48 +0400929});