blob: 2f714f7e5fb2469f2987682281a6cdd3730509b6 [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
giob77cb932025-05-19 09:37:14 +0000349export const accessSchema = z.discriminatedUnion("type", [
350 z.object({
351 type: z.literal("https"),
352 name: z.string(),
353 address: z.string(),
354 }),
355 z.object({
356 type: z.literal("ssh"),
357 name: z.string(),
358 host: z.string(),
359 port: z.number(),
360 }),
361 z.object({
362 type: z.literal("tcp"),
363 name: z.string(),
364 host: z.string(),
365 port: z.number(),
366 }),
367 z.object({
368 type: z.literal("udp"),
369 name: z.string(),
370 host: z.string(),
371 port: z.number(),
372 }),
373 z.object({
374 type: z.literal("postgresql"),
375 name: z.string(),
376 host: z.string(),
377 port: z.number(),
378 database: z.string(),
379 username: z.string(),
380 password: z.string(),
381 }),
382 z.object({
383 type: z.literal("mongodb"),
384 name: z.string(),
385 host: z.string(),
386 port: z.number(),
387 database: z.string(),
388 username: z.string(),
389 password: z.string(),
390 }),
391]);
392
gio5f2f1002025-03-20 18:38:48 +0400393export const envSchema = z.object({
gio7d813702025-05-08 18:29:52 +0000394 managerAddr: z.optional(z.string().min(1)),
gio09fcab52025-05-12 14:05:07 +0000395 deployKey: z.optional(z.nullable(z.string().min(1))),
giod0026612025-05-08 13:00:36 +0000396 networks: z
397 .array(
398 z.object({
399 name: z.string().min(1),
400 domain: z.string().min(1),
401 }),
402 )
403 .default([]),
404 integrations: z.object({
405 github: z.boolean(),
406 }),
gio3a921b82025-05-10 07:36:09 +0000407 services: z.array(z.string()),
gio3ed59592025-05-14 16:51:09 +0000408 user: z.object({
409 id: z.string(),
410 username: z.string(),
411 }),
giob77cb932025-05-19 09:37:14 +0000412 access: z.array(accessSchema),
gio5f2f1002025-03-20 18:38:48 +0400413});
414
415export type Env = z.infer<typeof envSchema>;
416
gio7f98e772025-05-07 11:00:14 +0000417const defaultEnv: Env = {
gio7d813702025-05-08 18:29:52 +0000418 managerAddr: undefined,
giod0026612025-05-08 13:00:36 +0000419 deployKey: undefined,
420 networks: [],
421 integrations: {
422 github: false,
423 },
gio3a921b82025-05-10 07:36:09 +0000424 services: [],
gio3ed59592025-05-14 16:51:09 +0000425 user: {
426 id: "",
427 username: "",
428 },
giob77cb932025-05-19 09:37:14 +0000429 access: [],
gio7f98e772025-05-07 11:00:14 +0000430};
431
gio5f2f1002025-03-20 18:38:48 +0400432export type Project = {
giod0026612025-05-08 13:00:36 +0000433 id: string;
434 name: string;
435};
gio5f2f1002025-03-20 18:38:48 +0400436
gio7f98e772025-05-07 11:00:14 +0000437export type IntegrationsConfig = {
giod0026612025-05-08 13:00:36 +0000438 github: boolean;
gio7f98e772025-05-07 11:00:14 +0000439};
440
441type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
442type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
443
gioaf8db832025-05-13 14:43:05 +0000444type Viewport = {
445 transformX: number;
446 transformY: number;
447 transformZoom: number;
448 width: number;
449 height: number;
450};
451
gio5f2f1002025-03-20 18:38:48 +0400452export type AppState = {
giod0026612025-05-08 13:00:36 +0000453 projectId: string | undefined;
gio818da4e2025-05-12 14:45:35 +0000454 mode: "edit" | "deploy";
giod0026612025-05-08 13:00:36 +0000455 projects: Project[];
456 nodes: AppNode[];
457 edges: Edge[];
gio359a6852025-05-14 03:38:24 +0000458 zoom: ReactFlowViewport;
giod0026612025-05-08 13:00:36 +0000459 categories: Category[];
460 messages: Message[];
461 env: Env;
gioaf8db832025-05-13 14:43:05 +0000462 viewport: Viewport;
463 setViewport: (viewport: Viewport) => void;
giod0026612025-05-08 13:00:36 +0000464 githubService: GitHubService | null;
465 setHighlightCategory: (name: string, active: boolean) => void;
466 onNodesChange: OnNodesChange<AppNode>;
467 onEdgesChange: OnEdgesChange;
468 onConnect: OnConnect;
gioaf8db832025-05-13 14:43:05 +0000469 addNode: (node: Omit<AppNode, "position">) => void;
giod0026612025-05-08 13:00:36 +0000470 setNodes: (nodes: AppNode[]) => void;
471 setEdges: (edges: Edge[]) => void;
gio818da4e2025-05-12 14:45:35 +0000472 setProject: (projectId: string | undefined) => Promise<void>;
473 setMode: (mode: "edit" | "deploy") => void;
giod0026612025-05-08 13:00:36 +0000474 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
475 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
476 replaceEdge: (c: Connection, id?: string) => void;
477 refreshEnv: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400478};
479
480const projectIdSelector = (state: AppState) => state.projectId;
481const categoriesSelector = (state: AppState) => state.categories;
482const messagesSelector = (state: AppState) => state.messages;
gio7f98e772025-05-07 11:00:14 +0000483const githubServiceSelector = (state: AppState) => state.githubService;
gio5f2f1002025-03-20 18:38:48 +0400484const envSelector = (state: AppState) => state.env;
gio359a6852025-05-14 03:38:24 +0000485const zoomSelector = (state: AppState) => state.zoom;
gioaf8db832025-05-13 14:43:05 +0000486
gio359a6852025-05-14 03:38:24 +0000487export function useZoom(): ReactFlowViewport {
488 return useStateStore(zoomSelector);
gioaf8db832025-05-13 14:43:05 +0000489}
gio5f2f1002025-03-20 18:38:48 +0400490
491export function useProjectId(): string | undefined {
giod0026612025-05-08 13:00:36 +0000492 return useStateStore(projectIdSelector);
gio5f2f1002025-03-20 18:38:48 +0400493}
494
495export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000496 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400497}
498
499export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000500 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400501}
502
503export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000504 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400505}
506
507export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000508 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400509}
510
511export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000512 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400513}
514
gio5f2f1002025-03-20 18:38:48 +0400515export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000516 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000517}
518
519export function useGithubService(): GitHubService | null {
giod0026612025-05-08 13:00:36 +0000520 return useStateStore(githubServiceSelector);
gio5f2f1002025-03-20 18:38:48 +0400521}
522
gio3ec94242025-05-16 12:46:57 +0000523export function useMode(): "edit" | "deploy" {
524 return useStateStore((state) => state.mode);
525}
526
gio5f2f1002025-03-20 18:38:48 +0400527const v: Validator = CreateValidators();
528
gioaf8db832025-05-13 14:43:05 +0000529function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
530 const zoomMultiplier = 1 / transformZoom;
531 const realWidth = width * zoomMultiplier;
532 const realHeight = height * zoomMultiplier;
533 const paddingMultiplier = 0.8;
534 const ret = {
535 x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
536 y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
537 };
538 return ret;
539}
540
gio5f2f1002025-03-20 18:38:48 +0400541export const useStateStore = create<AppState>((set, get): AppState => {
giod0026612025-05-08 13:00:36 +0000542 const setN = (nodes: AppNode[]) => {
gio4b9b58a2025-05-12 11:46:08 +0000543 set({
giod0026612025-05-08 13:00:36 +0000544 nodes,
gio5cf364c2025-05-08 16:01:21 +0000545 messages: v(nodes),
gio4b9b58a2025-05-12 11:46:08 +0000546 });
547 };
548
gio48fde052025-05-14 09:48:08 +0000549 const injectNetworkNodes = () => {
550 const newNetworks = get().env.networks.filter(
551 (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
552 );
553 newNetworks.forEach((n) => {
554 get().addNode({
555 id: n.domain,
556 type: "network",
557 connectable: true,
558 data: {
559 domain: n.domain,
560 label: n.domain,
561 envVars: [],
562 ports: [],
563 state: "success", // TODO(gio): monitor network health
564 },
565 });
566 console.log("added network", n.domain);
567 });
568 };
569
gio4b9b58a2025-05-12 11:46:08 +0000570 const restoreSaved = async () => {
gio818da4e2025-05-12 14:45:35 +0000571 const { projectId } = get();
572 const resp = await fetch(`/api/project/${projectId}/saved/${get().mode === "deploy" ? "deploy" : "draft"}`, {
gio4b9b58a2025-05-12 11:46:08 +0000573 method: "GET",
574 });
575 const inst = await resp.json();
gio48fde052025-05-14 09:48:08 +0000576 setN(inst.nodes);
577 set({ edges: inst.edges });
578 injectNetworkNodes();
gio359a6852025-05-14 03:38:24 +0000579 if (
580 get().zoom.x !== inst.viewport.x ||
581 get().zoom.y !== inst.viewport.y ||
582 get().zoom.zoom !== inst.viewport.zoom
583 ) {
584 set({ zoom: inst.viewport });
585 }
giod0026612025-05-08 13:00:36 +0000586 };
gio7f98e772025-05-07 11:00:14 +0000587
giod0026612025-05-08 13:00:36 +0000588 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
589 setN(
590 get().nodes.map((n) => {
591 if (n.id === id) {
592 return {
593 ...n,
594 data: {
595 ...n.data,
596 ...data,
597 },
598 } as Extract<AppNode, { type: T }>;
599 }
600 return n;
601 }),
602 );
603 }
gio7f98e772025-05-07 11:00:14 +0000604
giod0026612025-05-08 13:00:36 +0000605 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
606 setN(
607 get().nodes.map((n) => {
608 if (n.id === id) {
609 return {
610 ...n,
611 ...node,
612 } as Extract<AppNode, { type: T }>;
613 }
614 return n;
615 }),
616 );
617 }
gio7f98e772025-05-07 11:00:14 +0000618
giod0026612025-05-08 13:00:36 +0000619 function onConnect(c: Connection) {
620 const { nodes, edges } = get();
621 set({
622 edges: addEdge(c, edges),
623 });
624 const sn = nodes.filter((n) => n.id === c.source)[0]!;
625 const tn = nodes.filter((n) => n.id === c.target)[0]!;
626 if (tn.type === "network") {
627 if (sn.type === "gateway-https") {
628 updateNodeData<"gateway-https">(sn.id, {
629 network: tn.data.domain,
630 });
631 } else if (sn.type === "gateway-tcp") {
632 updateNodeData<"gateway-tcp">(sn.id, {
633 network: tn.data.domain,
634 });
635 }
636 }
637 if (tn.type === "app") {
638 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
639 const sourceEnvVars = nodeEnvVarNames(sn);
640 if (sourceEnvVars.length === 0) {
641 throw new Error("MUST NOT REACH!");
642 }
643 const id = uuidv4();
644 if (sourceEnvVars.length === 1) {
645 updateNode<"app">(c.target, {
646 ...tn,
647 data: {
648 ...tn.data,
649 envVars: [
650 ...(tn.data.envVars || []),
651 {
652 id: id,
653 source: c.source,
654 name: sourceEnvVars[0],
655 isEditting: false,
656 },
657 ],
658 },
659 });
660 } else {
661 updateNode<"app">(c.target, {
662 ...tn,
663 data: {
664 ...tn.data,
665 envVars: [
666 ...(tn.data.envVars || []),
667 {
668 id: id,
669 source: c.source,
670 },
671 ],
672 },
673 });
674 }
675 }
676 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
677 const sourcePorts = sn.data.ports || [];
678 const id = uuidv4();
679 if (sourcePorts.length === 1) {
680 updateNode<"app">(c.target, {
681 ...tn,
682 data: {
683 ...tn.data,
684 envVars: [
685 ...(tn.data.envVars || []),
686 {
687 id: id,
688 source: c.source,
689 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
690 portId: sourcePorts[0].id,
691 isEditting: false,
692 },
693 ],
694 },
695 });
696 }
697 }
698 }
699 if (c.sourceHandle === "volume") {
700 updateNodeData<"volume">(c.source, {
701 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
702 });
703 }
704 if (c.targetHandle === "volume") {
705 if (tn.type === "postgresql" || tn.type === "mongodb") {
706 updateNodeData(c.target, {
707 volumeId: c.source,
708 });
709 }
710 }
711 if (c.targetHandle === "https") {
712 if ((sn.data.ports || []).length === 1) {
713 updateNodeData<"gateway-https">(c.target, {
714 https: {
715 serviceId: c.source,
716 portId: sn.data.ports![0].id,
717 },
718 });
719 } else {
720 updateNodeData<"gateway-https">(c.target, {
721 https: {
722 serviceId: c.source,
723 portId: "", // TODO(gio)
724 },
725 });
726 }
727 }
728 if (c.targetHandle === "tcp") {
729 const td = tn.data as GatewayTCPData;
730 if ((sn.data.ports || []).length === 1) {
731 updateNodeData<"gateway-tcp">(c.target, {
732 exposed: (td.exposed || []).concat({
733 serviceId: c.source,
734 portId: sn.data.ports![0].id,
735 }),
736 });
737 } else {
738 updateNodeData<"gateway-tcp">(c.target, {
739 selected: {
740 serviceId: c.source,
741 portId: undefined,
742 },
743 });
744 }
745 }
746 if (sn.type === "app") {
747 if (c.sourceHandle === "ports") {
748 updateNodeData<"app">(sn.id, {
749 isChoosingPortToConnect: true,
750 });
751 }
752 }
753 if (tn.type === "app") {
754 if (c.targetHandle === "repository") {
755 updateNodeData<"app">(tn.id, {
756 repository: {
757 id: c.source,
758 branch: "master",
759 rootDir: "/",
760 },
761 });
762 }
763 }
764 }
765 return {
766 projectId: undefined,
gio818da4e2025-05-12 14:45:35 +0000767 mode: "edit",
giod0026612025-05-08 13:00:36 +0000768 projects: [],
769 nodes: [],
770 edges: [],
771 categories: defaultCategories,
772 messages: v([]),
773 env: defaultEnv,
gioaf8db832025-05-13 14:43:05 +0000774 viewport: {
775 transformX: 0,
776 transformY: 0,
777 transformZoom: 1,
778 width: 800,
779 height: 600,
780 },
gio359a6852025-05-14 03:38:24 +0000781 zoom: {
782 x: 0,
783 y: 0,
784 zoom: 1,
785 },
giod0026612025-05-08 13:00:36 +0000786 githubService: null,
gioaf8db832025-05-13 14:43:05 +0000787 setViewport: (viewport) => {
788 const { viewport: vp } = get();
789 if (
790 viewport.transformX !== vp.transformX ||
791 viewport.transformY !== vp.transformY ||
792 viewport.transformZoom !== vp.transformZoom ||
793 viewport.width !== vp.width ||
794 viewport.height !== vp.height
795 ) {
796 set({ viewport });
797 }
798 },
giod0026612025-05-08 13:00:36 +0000799 setHighlightCategory: (name, active) => {
800 set({
801 categories: get().categories.map((c) => {
802 if (c.title.toLowerCase() !== name.toLowerCase()) {
803 return c;
804 } else {
805 return {
806 ...c,
807 active,
808 };
809 }
810 }),
811 });
812 },
813 onNodesChange: (changes) => {
814 const nodes = applyNodeChanges(changes, get().nodes);
815 setN(nodes);
816 },
817 onEdgesChange: (changes) => {
818 set({
819 edges: applyEdgeChanges(changes, get().edges),
820 });
821 },
gioaf8db832025-05-13 14:43:05 +0000822 addNode: (node) => {
823 const { viewport, nodes } = get();
824 setN(
825 nodes.concat({
826 ...node,
827 position: getRandomPosition(viewport),
828 }),
829 );
830 },
giod0026612025-05-08 13:00:36 +0000831 setNodes: (nodes) => {
832 setN(nodes);
833 },
834 setEdges: (edges) => {
835 set({ edges });
836 },
837 replaceEdge: (c, id) => {
838 let change: EdgeChange;
839 if (id === undefined) {
840 change = {
841 type: "add",
842 item: {
843 id: uuidv4(),
844 ...c,
845 },
846 };
847 onConnect(c);
848 } else {
849 change = {
850 type: "replace",
851 id,
852 item: {
853 id,
854 ...c,
855 },
856 };
857 }
858 set({
859 edges: applyEdgeChanges([change], get().edges),
860 });
861 },
862 updateNode,
863 updateNodeData,
864 onConnect,
865 refreshEnv: async () => {
866 const projectId = get().projectId;
867 let env: Env = defaultEnv;
gio7f98e772025-05-07 11:00:14 +0000868
giod0026612025-05-08 13:00:36 +0000869 try {
870 if (projectId) {
871 const response = await fetch(`/api/project/${projectId}/env`);
872 if (response.ok) {
873 const data = await response.json();
874 const result = envSchema.safeParse(data);
875 if (result.success) {
876 env = result.data;
877 } else {
878 console.error("Invalid env data:", result.error);
879 }
880 }
881 }
882 } catch (error) {
883 console.error("Failed to fetch integrations:", error);
884 } finally {
gio4b9b58a2025-05-12 11:46:08 +0000885 if (JSON.stringify(get().env) !== JSON.stringify(env)) {
886 set({ env });
gio48fde052025-05-14 09:48:08 +0000887 injectNetworkNodes();
gio4b9b58a2025-05-12 11:46:08 +0000888
889 if (env.integrations.github) {
890 set({ githubService: new GitHubServiceImpl(projectId!) });
891 } else {
892 set({ githubService: null });
893 }
giod0026612025-05-08 13:00:36 +0000894 }
895 }
896 },
gio818da4e2025-05-12 14:45:35 +0000897 setMode: (mode) => {
898 set({ mode });
899 },
900 setProject: async (projectId) => {
gio359a6852025-05-14 03:38:24 +0000901 if (projectId === get().projectId) {
902 return;
903 }
giod0026612025-05-08 13:00:36 +0000904 set({
905 projectId,
906 });
907 if (projectId) {
gio818da4e2025-05-12 14:45:35 +0000908 await get().refreshEnv();
909 if (get().env.deployKey) {
910 set({ mode: "deploy" });
911 } else {
912 set({ mode: "edit" });
913 }
gio4b9b58a2025-05-12 11:46:08 +0000914 restoreSaved();
915 } else {
916 set({
917 nodes: [],
918 edges: [],
919 });
giod0026612025-05-08 13:00:36 +0000920 }
921 },
922 };
gio5f2f1002025-03-20 18:38:48 +0400923});