blob: ec6478286140bca0381a29f670ec7878a30084a5 [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),
gio6d8b71c2025-05-19 12:57:35 +0000401 hasAuth: z.boolean(),
giod0026612025-05-08 13:00:36 +0000402 }),
403 )
404 .default([]),
405 integrations: z.object({
406 github: z.boolean(),
407 }),
gio3a921b82025-05-10 07:36:09 +0000408 services: z.array(z.string()),
gio3ed59592025-05-14 16:51:09 +0000409 user: z.object({
410 id: z.string(),
411 username: z.string(),
412 }),
giob77cb932025-05-19 09:37:14 +0000413 access: z.array(accessSchema),
gio5f2f1002025-03-20 18:38:48 +0400414});
415
416export type Env = z.infer<typeof envSchema>;
417
gio7f98e772025-05-07 11:00:14 +0000418const defaultEnv: Env = {
gio7d813702025-05-08 18:29:52 +0000419 managerAddr: undefined,
giod0026612025-05-08 13:00:36 +0000420 deployKey: undefined,
421 networks: [],
422 integrations: {
423 github: false,
424 },
gio3a921b82025-05-10 07:36:09 +0000425 services: [],
gio3ed59592025-05-14 16:51:09 +0000426 user: {
427 id: "",
428 username: "",
429 },
giob77cb932025-05-19 09:37:14 +0000430 access: [],
gio7f98e772025-05-07 11:00:14 +0000431};
432
gio5f2f1002025-03-20 18:38:48 +0400433export type Project = {
giod0026612025-05-08 13:00:36 +0000434 id: string;
435 name: string;
436};
gio5f2f1002025-03-20 18:38:48 +0400437
gio7f98e772025-05-07 11:00:14 +0000438export type IntegrationsConfig = {
giod0026612025-05-08 13:00:36 +0000439 github: boolean;
gio7f98e772025-05-07 11:00:14 +0000440};
441
442type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
443type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
444
gioaf8db832025-05-13 14:43:05 +0000445type Viewport = {
446 transformX: number;
447 transformY: number;
448 transformZoom: number;
449 width: number;
450 height: number;
451};
452
gio5f2f1002025-03-20 18:38:48 +0400453export type AppState = {
giod0026612025-05-08 13:00:36 +0000454 projectId: string | undefined;
gio818da4e2025-05-12 14:45:35 +0000455 mode: "edit" | "deploy";
giod0026612025-05-08 13:00:36 +0000456 projects: Project[];
457 nodes: AppNode[];
458 edges: Edge[];
gio359a6852025-05-14 03:38:24 +0000459 zoom: ReactFlowViewport;
giod0026612025-05-08 13:00:36 +0000460 categories: Category[];
461 messages: Message[];
462 env: Env;
gioaf8db832025-05-13 14:43:05 +0000463 viewport: Viewport;
464 setViewport: (viewport: Viewport) => void;
giod0026612025-05-08 13:00:36 +0000465 githubService: GitHubService | null;
466 setHighlightCategory: (name: string, active: boolean) => void;
467 onNodesChange: OnNodesChange<AppNode>;
468 onEdgesChange: OnEdgesChange;
469 onConnect: OnConnect;
gioaf8db832025-05-13 14:43:05 +0000470 addNode: (node: Omit<AppNode, "position">) => void;
giod0026612025-05-08 13:00:36 +0000471 setNodes: (nodes: AppNode[]) => void;
472 setEdges: (edges: Edge[]) => void;
gio818da4e2025-05-12 14:45:35 +0000473 setProject: (projectId: string | undefined) => Promise<void>;
474 setMode: (mode: "edit" | "deploy") => void;
giod0026612025-05-08 13:00:36 +0000475 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
476 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
477 replaceEdge: (c: Connection, id?: string) => void;
478 refreshEnv: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400479};
480
481const projectIdSelector = (state: AppState) => state.projectId;
482const categoriesSelector = (state: AppState) => state.categories;
483const messagesSelector = (state: AppState) => state.messages;
gio7f98e772025-05-07 11:00:14 +0000484const githubServiceSelector = (state: AppState) => state.githubService;
gio5f2f1002025-03-20 18:38:48 +0400485const envSelector = (state: AppState) => state.env;
gio359a6852025-05-14 03:38:24 +0000486const zoomSelector = (state: AppState) => state.zoom;
gioaf8db832025-05-13 14:43:05 +0000487
gio359a6852025-05-14 03:38:24 +0000488export function useZoom(): ReactFlowViewport {
489 return useStateStore(zoomSelector);
gioaf8db832025-05-13 14:43:05 +0000490}
gio5f2f1002025-03-20 18:38:48 +0400491
492export function useProjectId(): string | undefined {
giod0026612025-05-08 13:00:36 +0000493 return useStateStore(projectIdSelector);
gio5f2f1002025-03-20 18:38:48 +0400494}
495
496export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000497 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400498}
499
500export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000501 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400502}
503
504export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000505 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400506}
507
508export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000509 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400510}
511
512export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000513 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400514}
515
gio5f2f1002025-03-20 18:38:48 +0400516export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000517 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000518}
519
520export function useGithubService(): GitHubService | null {
giod0026612025-05-08 13:00:36 +0000521 return useStateStore(githubServiceSelector);
gio5f2f1002025-03-20 18:38:48 +0400522}
523
gio3ec94242025-05-16 12:46:57 +0000524export function useMode(): "edit" | "deploy" {
525 return useStateStore((state) => state.mode);
526}
527
gio5f2f1002025-03-20 18:38:48 +0400528const v: Validator = CreateValidators();
529
gioaf8db832025-05-13 14:43:05 +0000530function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
531 const zoomMultiplier = 1 / transformZoom;
532 const realWidth = width * zoomMultiplier;
533 const realHeight = height * zoomMultiplier;
534 const paddingMultiplier = 0.8;
535 const ret = {
536 x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
537 y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
538 };
539 return ret;
540}
541
gio5f2f1002025-03-20 18:38:48 +0400542export const useStateStore = create<AppState>((set, get): AppState => {
giod0026612025-05-08 13:00:36 +0000543 const setN = (nodes: AppNode[]) => {
gio4b9b58a2025-05-12 11:46:08 +0000544 set({
giod0026612025-05-08 13:00:36 +0000545 nodes,
gio5cf364c2025-05-08 16:01:21 +0000546 messages: v(nodes),
gio4b9b58a2025-05-12 11:46:08 +0000547 });
548 };
549
gio48fde052025-05-14 09:48:08 +0000550 const injectNetworkNodes = () => {
551 const newNetworks = get().env.networks.filter(
552 (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
553 );
554 newNetworks.forEach((n) => {
555 get().addNode({
556 id: n.domain,
557 type: "network",
558 connectable: true,
559 data: {
560 domain: n.domain,
561 label: n.domain,
562 envVars: [],
563 ports: [],
564 state: "success", // TODO(gio): monitor network health
565 },
566 });
567 console.log("added network", n.domain);
568 });
569 };
570
gio4b9b58a2025-05-12 11:46:08 +0000571 const restoreSaved = async () => {
gio818da4e2025-05-12 14:45:35 +0000572 const { projectId } = get();
573 const resp = await fetch(`/api/project/${projectId}/saved/${get().mode === "deploy" ? "deploy" : "draft"}`, {
gio4b9b58a2025-05-12 11:46:08 +0000574 method: "GET",
575 });
576 const inst = await resp.json();
gio48fde052025-05-14 09:48:08 +0000577 setN(inst.nodes);
578 set({ edges: inst.edges });
579 injectNetworkNodes();
gio359a6852025-05-14 03:38:24 +0000580 if (
581 get().zoom.x !== inst.viewport.x ||
582 get().zoom.y !== inst.viewport.y ||
583 get().zoom.zoom !== inst.viewport.zoom
584 ) {
585 set({ zoom: inst.viewport });
586 }
giod0026612025-05-08 13:00:36 +0000587 };
gio7f98e772025-05-07 11:00:14 +0000588
giod0026612025-05-08 13:00:36 +0000589 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
590 setN(
591 get().nodes.map((n) => {
592 if (n.id === id) {
593 return {
594 ...n,
595 data: {
596 ...n.data,
597 ...data,
598 },
599 } as Extract<AppNode, { type: T }>;
600 }
601 return n;
602 }),
603 );
604 }
gio7f98e772025-05-07 11:00:14 +0000605
giod0026612025-05-08 13:00:36 +0000606 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
607 setN(
608 get().nodes.map((n) => {
609 if (n.id === id) {
610 return {
611 ...n,
612 ...node,
613 } as Extract<AppNode, { type: T }>;
614 }
615 return n;
616 }),
617 );
618 }
gio7f98e772025-05-07 11:00:14 +0000619
giod0026612025-05-08 13:00:36 +0000620 function onConnect(c: Connection) {
621 const { nodes, edges } = get();
622 set({
623 edges: addEdge(c, edges),
624 });
625 const sn = nodes.filter((n) => n.id === c.source)[0]!;
626 const tn = nodes.filter((n) => n.id === c.target)[0]!;
627 if (tn.type === "network") {
628 if (sn.type === "gateway-https") {
629 updateNodeData<"gateway-https">(sn.id, {
630 network: tn.data.domain,
631 });
632 } else if (sn.type === "gateway-tcp") {
633 updateNodeData<"gateway-tcp">(sn.id, {
634 network: tn.data.domain,
635 });
636 }
637 }
638 if (tn.type === "app") {
639 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
640 const sourceEnvVars = nodeEnvVarNames(sn);
641 if (sourceEnvVars.length === 0) {
642 throw new Error("MUST NOT REACH!");
643 }
644 const id = uuidv4();
645 if (sourceEnvVars.length === 1) {
646 updateNode<"app">(c.target, {
647 ...tn,
648 data: {
649 ...tn.data,
650 envVars: [
651 ...(tn.data.envVars || []),
652 {
653 id: id,
654 source: c.source,
655 name: sourceEnvVars[0],
656 isEditting: false,
657 },
658 ],
659 },
660 });
661 } else {
662 updateNode<"app">(c.target, {
663 ...tn,
664 data: {
665 ...tn.data,
666 envVars: [
667 ...(tn.data.envVars || []),
668 {
669 id: id,
670 source: c.source,
671 },
672 ],
673 },
674 });
675 }
676 }
677 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
678 const sourcePorts = sn.data.ports || [];
679 const id = uuidv4();
680 if (sourcePorts.length === 1) {
681 updateNode<"app">(c.target, {
682 ...tn,
683 data: {
684 ...tn.data,
685 envVars: [
686 ...(tn.data.envVars || []),
687 {
688 id: id,
689 source: c.source,
690 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
691 portId: sourcePorts[0].id,
692 isEditting: false,
693 },
694 ],
695 },
696 });
697 }
698 }
699 }
700 if (c.sourceHandle === "volume") {
701 updateNodeData<"volume">(c.source, {
702 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
703 });
704 }
705 if (c.targetHandle === "volume") {
706 if (tn.type === "postgresql" || tn.type === "mongodb") {
707 updateNodeData(c.target, {
708 volumeId: c.source,
709 });
710 }
711 }
712 if (c.targetHandle === "https") {
713 if ((sn.data.ports || []).length === 1) {
714 updateNodeData<"gateway-https">(c.target, {
715 https: {
716 serviceId: c.source,
717 portId: sn.data.ports![0].id,
718 },
719 });
720 } else {
721 updateNodeData<"gateway-https">(c.target, {
722 https: {
723 serviceId: c.source,
724 portId: "", // TODO(gio)
725 },
726 });
727 }
728 }
729 if (c.targetHandle === "tcp") {
730 const td = tn.data as GatewayTCPData;
731 if ((sn.data.ports || []).length === 1) {
732 updateNodeData<"gateway-tcp">(c.target, {
733 exposed: (td.exposed || []).concat({
734 serviceId: c.source,
735 portId: sn.data.ports![0].id,
736 }),
737 });
738 } else {
739 updateNodeData<"gateway-tcp">(c.target, {
740 selected: {
741 serviceId: c.source,
742 portId: undefined,
743 },
744 });
745 }
746 }
747 if (sn.type === "app") {
748 if (c.sourceHandle === "ports") {
749 updateNodeData<"app">(sn.id, {
750 isChoosingPortToConnect: true,
751 });
752 }
753 }
754 if (tn.type === "app") {
755 if (c.targetHandle === "repository") {
756 updateNodeData<"app">(tn.id, {
757 repository: {
758 id: c.source,
759 branch: "master",
760 rootDir: "/",
761 },
762 });
763 }
764 }
765 }
766 return {
767 projectId: undefined,
gio818da4e2025-05-12 14:45:35 +0000768 mode: "edit",
giod0026612025-05-08 13:00:36 +0000769 projects: [],
770 nodes: [],
771 edges: [],
772 categories: defaultCategories,
773 messages: v([]),
774 env: defaultEnv,
gioaf8db832025-05-13 14:43:05 +0000775 viewport: {
776 transformX: 0,
777 transformY: 0,
778 transformZoom: 1,
779 width: 800,
780 height: 600,
781 },
gio359a6852025-05-14 03:38:24 +0000782 zoom: {
783 x: 0,
784 y: 0,
785 zoom: 1,
786 },
giod0026612025-05-08 13:00:36 +0000787 githubService: null,
gioaf8db832025-05-13 14:43:05 +0000788 setViewport: (viewport) => {
789 const { viewport: vp } = get();
790 if (
791 viewport.transformX !== vp.transformX ||
792 viewport.transformY !== vp.transformY ||
793 viewport.transformZoom !== vp.transformZoom ||
794 viewport.width !== vp.width ||
795 viewport.height !== vp.height
796 ) {
797 set({ viewport });
798 }
799 },
giod0026612025-05-08 13:00:36 +0000800 setHighlightCategory: (name, active) => {
801 set({
802 categories: get().categories.map((c) => {
803 if (c.title.toLowerCase() !== name.toLowerCase()) {
804 return c;
805 } else {
806 return {
807 ...c,
808 active,
809 };
810 }
811 }),
812 });
813 },
814 onNodesChange: (changes) => {
815 const nodes = applyNodeChanges(changes, get().nodes);
816 setN(nodes);
817 },
818 onEdgesChange: (changes) => {
819 set({
820 edges: applyEdgeChanges(changes, get().edges),
821 });
822 },
gioaf8db832025-05-13 14:43:05 +0000823 addNode: (node) => {
824 const { viewport, nodes } = get();
825 setN(
826 nodes.concat({
827 ...node,
828 position: getRandomPosition(viewport),
829 }),
830 );
831 },
giod0026612025-05-08 13:00:36 +0000832 setNodes: (nodes) => {
833 setN(nodes);
834 },
835 setEdges: (edges) => {
836 set({ edges });
837 },
838 replaceEdge: (c, id) => {
839 let change: EdgeChange;
840 if (id === undefined) {
841 change = {
842 type: "add",
843 item: {
844 id: uuidv4(),
845 ...c,
846 },
847 };
848 onConnect(c);
849 } else {
850 change = {
851 type: "replace",
852 id,
853 item: {
854 id,
855 ...c,
856 },
857 };
858 }
859 set({
860 edges: applyEdgeChanges([change], get().edges),
861 });
862 },
863 updateNode,
864 updateNodeData,
865 onConnect,
866 refreshEnv: async () => {
867 const projectId = get().projectId;
868 let env: Env = defaultEnv;
gio7f98e772025-05-07 11:00:14 +0000869
giod0026612025-05-08 13:00:36 +0000870 try {
871 if (projectId) {
872 const response = await fetch(`/api/project/${projectId}/env`);
873 if (response.ok) {
874 const data = await response.json();
875 const result = envSchema.safeParse(data);
876 if (result.success) {
877 env = result.data;
878 } else {
879 console.error("Invalid env data:", result.error);
880 }
881 }
882 }
883 } catch (error) {
884 console.error("Failed to fetch integrations:", error);
885 } finally {
gio4b9b58a2025-05-12 11:46:08 +0000886 if (JSON.stringify(get().env) !== JSON.stringify(env)) {
887 set({ env });
gio48fde052025-05-14 09:48:08 +0000888 injectNetworkNodes();
gio4b9b58a2025-05-12 11:46:08 +0000889
890 if (env.integrations.github) {
891 set({ githubService: new GitHubServiceImpl(projectId!) });
892 } else {
893 set({ githubService: null });
894 }
giod0026612025-05-08 13:00:36 +0000895 }
896 }
897 },
gio818da4e2025-05-12 14:45:35 +0000898 setMode: (mode) => {
899 set({ mode });
900 },
901 setProject: async (projectId) => {
gio359a6852025-05-14 03:38:24 +0000902 if (projectId === get().projectId) {
903 return;
904 }
giod0026612025-05-08 13:00:36 +0000905 set({
906 projectId,
907 });
908 if (projectId) {
gio818da4e2025-05-12 14:45:35 +0000909 await get().refreshEnv();
910 if (get().env.deployKey) {
911 set({ mode: "deploy" });
912 } else {
913 set({ mode: "edit" });
914 }
gio4b9b58a2025-05-12 11:46:08 +0000915 restoreSaved();
916 } else {
917 set({
918 nodes: [],
919 edges: [],
920 });
giod0026612025-05-08 13:00:36 +0000921 }
922 },
923 };
gio5f2f1002025-03-20 18:38:48 +0400924});