blob: 83890b21cfe9bc7bbb1ed8c0b3a0d73687987564 [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
497export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000498 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400499}
500
501export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000502 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400503}
504
505export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000506 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400507}
508
509export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000510 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400511}
512
513export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000514 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400515}
516
gio5f2f1002025-03-20 18:38:48 +0400517export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000518 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000519}
520
521export function useGithubService(): GitHubService | null {
giod0026612025-05-08 13:00:36 +0000522 return useStateStore(githubServiceSelector);
gio5f2f1002025-03-20 18:38:48 +0400523}
524
gio3ec94242025-05-16 12:46:57 +0000525export function useMode(): "edit" | "deploy" {
526 return useStateStore((state) => state.mode);
527}
528
gio5f2f1002025-03-20 18:38:48 +0400529const v: Validator = CreateValidators();
530
gioaf8db832025-05-13 14:43:05 +0000531function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
532 const zoomMultiplier = 1 / transformZoom;
533 const realWidth = width * zoomMultiplier;
534 const realHeight = height * zoomMultiplier;
535 const paddingMultiplier = 0.8;
536 const ret = {
537 x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
538 y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
539 };
540 return ret;
541}
542
gio5f2f1002025-03-20 18:38:48 +0400543export const useStateStore = create<AppState>((set, get): AppState => {
giod0026612025-05-08 13:00:36 +0000544 const setN = (nodes: AppNode[]) => {
gio4b9b58a2025-05-12 11:46:08 +0000545 set({
giod0026612025-05-08 13:00:36 +0000546 nodes,
gio5cf364c2025-05-08 16:01:21 +0000547 messages: v(nodes),
gio4b9b58a2025-05-12 11:46:08 +0000548 });
549 };
550
gio48fde052025-05-14 09:48:08 +0000551 const injectNetworkNodes = () => {
552 const newNetworks = get().env.networks.filter(
553 (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
554 );
555 newNetworks.forEach((n) => {
556 get().addNode({
557 id: n.domain,
558 type: "network",
559 connectable: true,
560 data: {
561 domain: n.domain,
562 label: n.domain,
563 envVars: [],
564 ports: [],
565 state: "success", // TODO(gio): monitor network health
566 },
567 });
568 console.log("added network", n.domain);
569 });
570 };
571
gio4b9b58a2025-05-12 11:46:08 +0000572 const restoreSaved = async () => {
gio818da4e2025-05-12 14:45:35 +0000573 const { projectId } = get();
574 const resp = await fetch(`/api/project/${projectId}/saved/${get().mode === "deploy" ? "deploy" : "draft"}`, {
gio4b9b58a2025-05-12 11:46:08 +0000575 method: "GET",
576 });
577 const inst = await resp.json();
gio48fde052025-05-14 09:48:08 +0000578 setN(inst.nodes);
579 set({ edges: inst.edges });
580 injectNetworkNodes();
gio359a6852025-05-14 03:38:24 +0000581 if (
582 get().zoom.x !== inst.viewport.x ||
583 get().zoom.y !== inst.viewport.y ||
584 get().zoom.zoom !== inst.viewport.zoom
585 ) {
586 set({ zoom: inst.viewport });
587 }
giod0026612025-05-08 13:00:36 +0000588 };
gio7f98e772025-05-07 11:00:14 +0000589
giod0026612025-05-08 13:00:36 +0000590 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
591 setN(
592 get().nodes.map((n) => {
593 if (n.id === id) {
594 return {
595 ...n,
596 data: {
597 ...n.data,
598 ...data,
599 },
600 } as Extract<AppNode, { type: T }>;
601 }
602 return n;
603 }),
604 );
605 }
gio7f98e772025-05-07 11:00:14 +0000606
giod0026612025-05-08 13:00:36 +0000607 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
608 setN(
609 get().nodes.map((n) => {
610 if (n.id === id) {
611 return {
612 ...n,
613 ...node,
614 } as Extract<AppNode, { type: T }>;
615 }
616 return n;
617 }),
618 );
619 }
gio7f98e772025-05-07 11:00:14 +0000620
giod0026612025-05-08 13:00:36 +0000621 function onConnect(c: Connection) {
622 const { nodes, edges } = get();
623 set({
624 edges: addEdge(c, edges),
625 });
626 const sn = nodes.filter((n) => n.id === c.source)[0]!;
627 const tn = nodes.filter((n) => n.id === c.target)[0]!;
628 if (tn.type === "network") {
629 if (sn.type === "gateway-https") {
630 updateNodeData<"gateway-https">(sn.id, {
631 network: tn.data.domain,
632 });
633 } else if (sn.type === "gateway-tcp") {
634 updateNodeData<"gateway-tcp">(sn.id, {
635 network: tn.data.domain,
636 });
637 }
638 }
639 if (tn.type === "app") {
640 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
641 const sourceEnvVars = nodeEnvVarNames(sn);
642 if (sourceEnvVars.length === 0) {
643 throw new Error("MUST NOT REACH!");
644 }
645 const id = uuidv4();
646 if (sourceEnvVars.length === 1) {
647 updateNode<"app">(c.target, {
648 ...tn,
649 data: {
650 ...tn.data,
651 envVars: [
652 ...(tn.data.envVars || []),
653 {
654 id: id,
655 source: c.source,
656 name: sourceEnvVars[0],
657 isEditting: false,
658 },
659 ],
660 },
661 });
662 } else {
663 updateNode<"app">(c.target, {
664 ...tn,
665 data: {
666 ...tn.data,
667 envVars: [
668 ...(tn.data.envVars || []),
669 {
670 id: id,
671 source: c.source,
672 },
673 ],
674 },
675 });
676 }
677 }
678 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
679 const sourcePorts = sn.data.ports || [];
680 const id = uuidv4();
681 if (sourcePorts.length === 1) {
682 updateNode<"app">(c.target, {
683 ...tn,
684 data: {
685 ...tn.data,
686 envVars: [
687 ...(tn.data.envVars || []),
688 {
689 id: id,
690 source: c.source,
691 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
692 portId: sourcePorts[0].id,
693 isEditting: false,
694 },
695 ],
696 },
697 });
698 }
699 }
700 }
701 if (c.sourceHandle === "volume") {
702 updateNodeData<"volume">(c.source, {
703 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
704 });
705 }
706 if (c.targetHandle === "volume") {
707 if (tn.type === "postgresql" || tn.type === "mongodb") {
708 updateNodeData(c.target, {
709 volumeId: c.source,
710 });
711 }
712 }
713 if (c.targetHandle === "https") {
714 if ((sn.data.ports || []).length === 1) {
715 updateNodeData<"gateway-https">(c.target, {
716 https: {
717 serviceId: c.source,
718 portId: sn.data.ports![0].id,
719 },
720 });
721 } else {
722 updateNodeData<"gateway-https">(c.target, {
723 https: {
724 serviceId: c.source,
725 portId: "", // TODO(gio)
726 },
727 });
728 }
729 }
730 if (c.targetHandle === "tcp") {
731 const td = tn.data as GatewayTCPData;
732 if ((sn.data.ports || []).length === 1) {
733 updateNodeData<"gateway-tcp">(c.target, {
734 exposed: (td.exposed || []).concat({
735 serviceId: c.source,
736 portId: sn.data.ports![0].id,
737 }),
738 });
739 } else {
740 updateNodeData<"gateway-tcp">(c.target, {
741 selected: {
742 serviceId: c.source,
743 portId: undefined,
744 },
745 });
746 }
747 }
748 if (sn.type === "app") {
749 if (c.sourceHandle === "ports") {
750 updateNodeData<"app">(sn.id, {
751 isChoosingPortToConnect: true,
752 });
753 }
754 }
755 if (tn.type === "app") {
756 if (c.targetHandle === "repository") {
757 updateNodeData<"app">(tn.id, {
758 repository: {
759 id: c.source,
760 branch: "master",
761 rootDir: "/",
762 },
763 });
764 }
765 }
766 }
767 return {
768 projectId: undefined,
gio818da4e2025-05-12 14:45:35 +0000769 mode: "edit",
giod0026612025-05-08 13:00:36 +0000770 projects: [],
771 nodes: [],
772 edges: [],
773 categories: defaultCategories,
774 messages: v([]),
775 env: defaultEnv,
gioaf8db832025-05-13 14:43:05 +0000776 viewport: {
777 transformX: 0,
778 transformY: 0,
779 transformZoom: 1,
780 width: 800,
781 height: 600,
782 },
gio359a6852025-05-14 03:38:24 +0000783 zoom: {
784 x: 0,
785 y: 0,
786 zoom: 1,
787 },
giod0026612025-05-08 13:00:36 +0000788 githubService: null,
gioaf8db832025-05-13 14:43:05 +0000789 setViewport: (viewport) => {
790 const { viewport: vp } = get();
791 if (
792 viewport.transformX !== vp.transformX ||
793 viewport.transformY !== vp.transformY ||
794 viewport.transformZoom !== vp.transformZoom ||
795 viewport.width !== vp.width ||
796 viewport.height !== vp.height
797 ) {
798 set({ viewport });
799 }
800 },
giod0026612025-05-08 13:00:36 +0000801 setHighlightCategory: (name, active) => {
802 set({
803 categories: get().categories.map((c) => {
804 if (c.title.toLowerCase() !== name.toLowerCase()) {
805 return c;
806 } else {
807 return {
808 ...c,
809 active,
810 };
811 }
812 }),
813 });
814 },
815 onNodesChange: (changes) => {
816 const nodes = applyNodeChanges(changes, get().nodes);
817 setN(nodes);
818 },
819 onEdgesChange: (changes) => {
820 set({
821 edges: applyEdgeChanges(changes, get().edges),
822 });
823 },
gioaf8db832025-05-13 14:43:05 +0000824 addNode: (node) => {
825 const { viewport, nodes } = get();
826 setN(
827 nodes.concat({
828 ...node,
829 position: getRandomPosition(viewport),
830 }),
831 );
832 },
giod0026612025-05-08 13:00:36 +0000833 setNodes: (nodes) => {
834 setN(nodes);
835 },
836 setEdges: (edges) => {
837 set({ edges });
838 },
839 replaceEdge: (c, id) => {
840 let change: EdgeChange;
841 if (id === undefined) {
842 change = {
843 type: "add",
844 item: {
845 id: uuidv4(),
846 ...c,
847 },
848 };
849 onConnect(c);
850 } else {
851 change = {
852 type: "replace",
853 id,
854 item: {
855 id,
856 ...c,
857 },
858 };
859 }
860 set({
861 edges: applyEdgeChanges([change], get().edges),
862 });
863 },
864 updateNode,
865 updateNodeData,
866 onConnect,
867 refreshEnv: async () => {
868 const projectId = get().projectId;
869 let env: Env = defaultEnv;
gio7f98e772025-05-07 11:00:14 +0000870
giod0026612025-05-08 13:00:36 +0000871 try {
872 if (projectId) {
873 const response = await fetch(`/api/project/${projectId}/env`);
874 if (response.ok) {
875 const data = await response.json();
876 const result = envSchema.safeParse(data);
877 if (result.success) {
878 env = result.data;
879 } else {
880 console.error("Invalid env data:", result.error);
881 }
882 }
883 }
884 } catch (error) {
885 console.error("Failed to fetch integrations:", error);
886 } finally {
gio4b9b58a2025-05-12 11:46:08 +0000887 if (JSON.stringify(get().env) !== JSON.stringify(env)) {
888 set({ env });
gio48fde052025-05-14 09:48:08 +0000889 injectNetworkNodes();
gio4b9b58a2025-05-12 11:46:08 +0000890
891 if (env.integrations.github) {
892 set({ githubService: new GitHubServiceImpl(projectId!) });
893 } else {
894 set({ githubService: null });
895 }
giod0026612025-05-08 13:00:36 +0000896 }
897 }
898 },
gio818da4e2025-05-12 14:45:35 +0000899 setMode: (mode) => {
900 set({ mode });
901 },
902 setProject: async (projectId) => {
gio359a6852025-05-14 03:38:24 +0000903 if (projectId === get().projectId) {
904 return;
905 }
giod0026612025-05-08 13:00:36 +0000906 set({
907 projectId,
908 });
909 if (projectId) {
gio818da4e2025-05-12 14:45:35 +0000910 await get().refreshEnv();
911 if (get().env.deployKey) {
912 set({ mode: "deploy" });
913 } else {
914 set({ mode: "edit" });
915 }
gio4b9b58a2025-05-12 11:46:08 +0000916 restoreSaved();
917 } else {
918 set({
919 nodes: [],
920 edges: [],
921 });
giod0026612025-05-08 13:00:36 +0000922 }
923 },
924 };
gio5f2f1002025-03-20 18:38:48 +0400925});