blob: b8db5b530f3985aa76510aa0acb05c73690f4d10 [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 & {
giod0026612025-05-08 13:00:36 +000044 network?: string;
45 subdomain?: string;
46 https?: PortConnectedTo;
47 auth?: {
48 enabled: boolean;
49 groups: string[];
50 noAuthPathPatterns: string[];
51 };
gio5f2f1002025-03-20 18:38:48 +040052};
53
54export type GatewayHttpsNode = Node<GatewayHttpsData> & {
giod0026612025-05-08 13:00:36 +000055 type: "gateway-https";
gio5f2f1002025-03-20 18:38:48 +040056};
57
58export type GatewayTCPData = NodeData & {
giod0026612025-05-08 13:00:36 +000059 network?: string;
60 subdomain?: string;
61 exposed: PortConnectedTo[];
62 selected?: {
63 serviceId?: string;
64 portId?: string;
65 };
gio5f2f1002025-03-20 18:38:48 +040066};
67
68export type GatewayTCPNode = Node<GatewayTCPData> & {
giod0026612025-05-08 13:00:36 +000069 type: "gateway-tcp";
gio5f2f1002025-03-20 18:38:48 +040070};
71
72export type Port = {
giod0026612025-05-08 13:00:36 +000073 id: string;
74 name: string;
75 value: number;
gio5f2f1002025-03-20 18:38:48 +040076};
77
gio91165612025-05-03 17:07:38 +000078export const ServiceTypes = [
giod0026612025-05-08 13:00:36 +000079 "deno:2.2.0",
80 "golang:1.20.0",
81 "golang:1.22.0",
82 "golang:1.24.0",
83 "hugo:latest",
84 "php:8.2-apache",
85 "nextjs:deno-2.0.0",
86 "node-23.1.0",
gio91165612025-05-03 17:07:38 +000087] as const;
giod0026612025-05-08 13:00:36 +000088export type ServiceType = (typeof ServiceTypes)[number];
gio5f2f1002025-03-20 18:38:48 +040089
90export type ServiceData = NodeData & {
giod0026612025-05-08 13:00:36 +000091 type: ServiceType;
92 repository:
93 | {
94 id: string;
95 }
96 | {
97 id: string;
98 branch: string;
99 }
100 | {
101 id: string;
102 branch: string;
103 rootDir: string;
104 };
105 env: string[];
106 volume: string[];
107 preBuildCommands: string;
108 isChoosingPortToConnect: boolean;
gio5f2f1002025-03-20 18:38:48 +0400109};
110
111export type ServiceNode = Node<ServiceData> & {
giod0026612025-05-08 13:00:36 +0000112 type: "app";
gio5f2f1002025-03-20 18:38:48 +0400113};
114
115export type VolumeType = "ReadWriteOnce" | "ReadOnlyMany" | "ReadWriteMany" | "ReadWriteOncePod";
116
117export type VolumeData = NodeData & {
giod0026612025-05-08 13:00:36 +0000118 type: VolumeType;
119 size: string;
120 attachedTo: string[];
gio5f2f1002025-03-20 18:38:48 +0400121};
122
123export type VolumeNode = Node<VolumeData> & {
giod0026612025-05-08 13:00:36 +0000124 type: "volume";
gio5f2f1002025-03-20 18:38:48 +0400125};
126
127export type PostgreSQLData = NodeData & {
giod0026612025-05-08 13:00:36 +0000128 volumeId: string;
gio5f2f1002025-03-20 18:38:48 +0400129};
130
131export type PostgreSQLNode = Node<PostgreSQLData> & {
giod0026612025-05-08 13:00:36 +0000132 type: "postgresql";
gio5f2f1002025-03-20 18:38:48 +0400133};
134
135export type MongoDBData = NodeData & {
giod0026612025-05-08 13:00:36 +0000136 volumeId: string;
gio5f2f1002025-03-20 18:38:48 +0400137};
138
139export type MongoDBNode = Node<MongoDBData> & {
giod0026612025-05-08 13:00:36 +0000140 type: "mongodb";
gio5f2f1002025-03-20 18:38:48 +0400141};
142
143export type GithubData = NodeData & {
giod0026612025-05-08 13:00:36 +0000144 repository?: {
145 id: number;
146 sshURL: string;
gio818da4e2025-05-12 14:45:35 +0000147 fullName: string;
giod0026612025-05-08 13:00:36 +0000148 };
gio5f2f1002025-03-20 18:38:48 +0400149};
150
151export type GithubNode = Node<GithubData> & {
giod0026612025-05-08 13:00:36 +0000152 type: "github";
gio5f2f1002025-03-20 18:38:48 +0400153};
154
155export type NANode = Node<NodeData> & {
giod0026612025-05-08 13:00:36 +0000156 type: undefined;
gio5f2f1002025-03-20 18:38:48 +0400157};
158
giod0026612025-05-08 13:00:36 +0000159export type AppNode =
160 | NetworkNode
161 | GatewayHttpsNode
162 | GatewayTCPNode
163 | ServiceNode
164 | VolumeNode
165 | PostgreSQLNode
166 | MongoDBNode
167 | GithubNode
168 | NANode;
gio5f2f1002025-03-20 18:38:48 +0400169
170export function nodeLabel(n: AppNode): string {
giod0026612025-05-08 13:00:36 +0000171 switch (n.type) {
172 case "network":
173 return n.data.domain;
174 case "app":
175 return n.data.label || "Service";
176 case "github":
gio818da4e2025-05-12 14:45:35 +0000177 return n.data.repository?.fullName || "Github";
giod0026612025-05-08 13:00:36 +0000178 case "gateway-https": {
179 if (n.data && n.data.network && n.data.subdomain) {
180 return `https://${n.data.subdomain}.${n.data.network}`;
181 } else {
182 return "HTTPS Gateway";
183 }
184 }
185 case "gateway-tcp": {
186 if (n.data && n.data.network && n.data.subdomain) {
187 return `${n.data.subdomain}.${n.data.network}`;
188 } else {
189 return "TCP Gateway";
190 }
191 }
192 case "mongodb":
193 return n.data.label || "MongoDB";
194 case "postgresql":
195 return n.data.label || "PostgreSQL";
196 case "volume":
197 return n.data.label || "Volume";
198 case undefined:
199 throw new Error("MUST NOT REACH!");
200 }
gio5f2f1002025-03-20 18:38:48 +0400201}
202
203export function nodeIsConnectable(n: AppNode, handle: string): boolean {
giod0026612025-05-08 13:00:36 +0000204 switch (n.type) {
205 case "network":
206 return true;
207 case "app":
208 if (handle === "ports") {
209 return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
210 } else if (handle === "repository") {
211 if (!n.data || !n.data.repository || !n.data.repository.id) {
212 return true;
213 }
214 return false;
215 }
216 return false;
217 case "github":
218 if (n.data.repository?.id !== undefined) {
219 return true;
220 }
221 return false;
222 case "gateway-https":
223 if (handle === "subdomain") {
224 return n.data.network === undefined;
225 }
226 return n.data === undefined || n.data.https === undefined;
227 case "gateway-tcp":
228 if (handle === "subdomain") {
229 return n.data.network === undefined;
230 }
231 return true;
232 case "mongodb":
233 return true;
234 case "postgresql":
235 return true;
236 case "volume":
237 if (n.data === undefined || n.data.type === undefined) {
238 return false;
239 }
240 if (n.data.type === "ReadWriteOnce" || n.data.type === "ReadWriteOncePod") {
241 return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
242 }
243 return true;
244 case undefined:
245 throw new Error("MUST NOT REACH!");
246 }
gio5f2f1002025-03-20 18:38:48 +0400247}
248
giod0026612025-05-08 13:00:36 +0000249export type BoundEnvVar =
250 | {
251 id: string;
252 source: string | null;
253 }
254 | {
255 id: string;
256 source: string | null;
257 name: string;
258 isEditting: boolean;
259 }
260 | {
261 id: string;
262 source: string | null;
263 name: string;
264 alias: string;
265 isEditting: boolean;
266 }
267 | {
268 id: string;
269 source: string | null;
270 portId: string;
271 name: string;
272 alias: string;
273 isEditting: boolean;
274 };
gio5f2f1002025-03-20 18:38:48 +0400275
276export type EnvVar = {
giod0026612025-05-08 13:00:36 +0000277 name: string;
278 value: string;
gio5f2f1002025-03-20 18:38:48 +0400279};
280
giob41ecae2025-04-24 08:46:50 +0000281export function nodeEnvVarNamePort(n: AppNode, portName: string): string {
giod0026612025-05-08 13:00:36 +0000282 return `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${portName.toUpperCase()}`;
giob41ecae2025-04-24 08:46:50 +0000283}
284
gio5f2f1002025-03-20 18:38:48 +0400285export function nodeEnvVarNames(n: AppNode): string[] {
giod0026612025-05-08 13:00:36 +0000286 switch (n.type) {
287 case "app":
288 return [
289 `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
290 ...(n.data.ports || []).map((p) => nodeEnvVarNamePort(n, p.name)),
291 ];
292 case "github":
293 return [];
294 case "gateway-https":
295 return [];
296 case "gateway-tcp":
297 return [];
298 case "mongodb":
299 return [`DODO_MONGODB_${n.data.label.toUpperCase()}_URL`];
300 case "postgresql":
301 return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_URL`];
302 case "volume":
303 return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
304 case undefined:
305 throw new Error("MUST NOT REACH");
306 default:
307 throw new Error("MUST NOT REACH");
308 }
gio5f2f1002025-03-20 18:38:48 +0400309}
310
311export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
312
313export type MessageType = "INFO" | "WARNING" | "FATAL";
314
315export type Message = {
giod0026612025-05-08 13:00:36 +0000316 id: string;
317 type: MessageType;
318 nodeId?: string;
319 message: string;
320 onHighlight?: (state: AppState) => void;
321 onLooseHighlight?: (state: AppState) => void;
322 onClick?: (state: AppState) => void;
gio5f2f1002025-03-20 18:38:48 +0400323};
324
325export const envSchema = z.object({
gio7d813702025-05-08 18:29:52 +0000326 managerAddr: z.optional(z.string().min(1)),
gio09fcab52025-05-12 14:05:07 +0000327 deployKey: z.optional(z.nullable(z.string().min(1))),
giod0026612025-05-08 13:00:36 +0000328 networks: z
329 .array(
330 z.object({
331 name: z.string().min(1),
332 domain: z.string().min(1),
333 }),
334 )
335 .default([]),
336 integrations: z.object({
337 github: z.boolean(),
338 }),
gio3a921b82025-05-10 07:36:09 +0000339 services: z.array(z.string()),
gio5f2f1002025-03-20 18:38:48 +0400340});
341
342export type Env = z.infer<typeof envSchema>;
343
gio7f98e772025-05-07 11:00:14 +0000344const defaultEnv: Env = {
gio7d813702025-05-08 18:29:52 +0000345 managerAddr: undefined,
giod0026612025-05-08 13:00:36 +0000346 deployKey: undefined,
347 networks: [],
348 integrations: {
349 github: false,
350 },
gio3a921b82025-05-10 07:36:09 +0000351 services: [],
gio7f98e772025-05-07 11:00:14 +0000352};
353
gio5f2f1002025-03-20 18:38:48 +0400354export type Project = {
giod0026612025-05-08 13:00:36 +0000355 id: string;
356 name: string;
357};
gio5f2f1002025-03-20 18:38:48 +0400358
gio7f98e772025-05-07 11:00:14 +0000359export type IntegrationsConfig = {
giod0026612025-05-08 13:00:36 +0000360 github: boolean;
gio7f98e772025-05-07 11:00:14 +0000361};
362
363type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
364type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
365
gioaf8db832025-05-13 14:43:05 +0000366type Viewport = {
367 transformX: number;
368 transformY: number;
369 transformZoom: number;
370 width: number;
371 height: number;
372};
373
gio5f2f1002025-03-20 18:38:48 +0400374export type AppState = {
giod0026612025-05-08 13:00:36 +0000375 projectId: string | undefined;
gio818da4e2025-05-12 14:45:35 +0000376 mode: "edit" | "deploy";
giod0026612025-05-08 13:00:36 +0000377 projects: Project[];
378 nodes: AppNode[];
379 edges: Edge[];
gio359a6852025-05-14 03:38:24 +0000380 zoom: ReactFlowViewport;
giod0026612025-05-08 13:00:36 +0000381 categories: Category[];
382 messages: Message[];
383 env: Env;
gioaf8db832025-05-13 14:43:05 +0000384 viewport: Viewport;
385 setViewport: (viewport: Viewport) => void;
giod0026612025-05-08 13:00:36 +0000386 githubService: GitHubService | null;
387 setHighlightCategory: (name: string, active: boolean) => void;
388 onNodesChange: OnNodesChange<AppNode>;
389 onEdgesChange: OnEdgesChange;
390 onConnect: OnConnect;
gioaf8db832025-05-13 14:43:05 +0000391 addNode: (node: Omit<AppNode, "position">) => void;
giod0026612025-05-08 13:00:36 +0000392 setNodes: (nodes: AppNode[]) => void;
393 setEdges: (edges: Edge[]) => void;
gio818da4e2025-05-12 14:45:35 +0000394 setProject: (projectId: string | undefined) => Promise<void>;
395 setMode: (mode: "edit" | "deploy") => void;
giod0026612025-05-08 13:00:36 +0000396 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
397 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
398 replaceEdge: (c: Connection, id?: string) => void;
399 refreshEnv: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400400};
401
402const projectIdSelector = (state: AppState) => state.projectId;
403const categoriesSelector = (state: AppState) => state.categories;
404const messagesSelector = (state: AppState) => state.messages;
gio7f98e772025-05-07 11:00:14 +0000405const githubServiceSelector = (state: AppState) => state.githubService;
gio5f2f1002025-03-20 18:38:48 +0400406const envSelector = (state: AppState) => state.env;
gio359a6852025-05-14 03:38:24 +0000407const zoomSelector = (state: AppState) => state.zoom;
gioaf8db832025-05-13 14:43:05 +0000408
gio359a6852025-05-14 03:38:24 +0000409export function useZoom(): ReactFlowViewport {
410 return useStateStore(zoomSelector);
gioaf8db832025-05-13 14:43:05 +0000411}
gio5f2f1002025-03-20 18:38:48 +0400412
413export function useProjectId(): string | undefined {
giod0026612025-05-08 13:00:36 +0000414 return useStateStore(projectIdSelector);
gio5f2f1002025-03-20 18:38:48 +0400415}
416
417export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000418 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400419}
420
421export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000422 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400423}
424
425export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000426 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400427}
428
429export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000430 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400431}
432
433export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000434 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400435}
436
gio5f2f1002025-03-20 18:38:48 +0400437export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000438 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000439}
440
441export function useGithubService(): GitHubService | null {
giod0026612025-05-08 13:00:36 +0000442 return useStateStore(githubServiceSelector);
gio5f2f1002025-03-20 18:38:48 +0400443}
444
445const v: Validator = CreateValidators();
446
gioaf8db832025-05-13 14:43:05 +0000447function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
448 const zoomMultiplier = 1 / transformZoom;
449 const realWidth = width * zoomMultiplier;
450 const realHeight = height * zoomMultiplier;
451 const paddingMultiplier = 0.8;
452 const ret = {
453 x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
454 y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
455 };
456 return ret;
457}
458
gio5f2f1002025-03-20 18:38:48 +0400459export const useStateStore = create<AppState>((set, get): AppState => {
giod0026612025-05-08 13:00:36 +0000460 const setN = (nodes: AppNode[]) => {
gio4b9b58a2025-05-12 11:46:08 +0000461 set({
giod0026612025-05-08 13:00:36 +0000462 nodes,
gio5cf364c2025-05-08 16:01:21 +0000463 messages: v(nodes),
gio4b9b58a2025-05-12 11:46:08 +0000464 });
465 };
466
467 const restoreSaved = async () => {
gio818da4e2025-05-12 14:45:35 +0000468 const { projectId } = get();
469 const resp = await fetch(`/api/project/${projectId}/saved/${get().mode === "deploy" ? "deploy" : "draft"}`, {
gio4b9b58a2025-05-12 11:46:08 +0000470 method: "GET",
471 });
472 const inst = await resp.json();
gio4b9b58a2025-05-12 11:46:08 +0000473 setN(inst.nodes || []);
gio359a6852025-05-14 03:38:24 +0000474 set({ edges: inst.edges || [] });
475 if (
476 get().zoom.x !== inst.viewport.x ||
477 get().zoom.y !== inst.viewport.y ||
478 get().zoom.zoom !== inst.viewport.zoom
479 ) {
480 set({ zoom: inst.viewport });
481 }
giod0026612025-05-08 13:00:36 +0000482 };
gio7f98e772025-05-07 11:00:14 +0000483
giod0026612025-05-08 13:00:36 +0000484 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
485 setN(
486 get().nodes.map((n) => {
487 if (n.id === id) {
488 return {
489 ...n,
490 data: {
491 ...n.data,
492 ...data,
493 },
494 } as Extract<AppNode, { type: T }>;
495 }
496 return n;
497 }),
498 );
499 }
gio7f98e772025-05-07 11:00:14 +0000500
giod0026612025-05-08 13:00:36 +0000501 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
502 setN(
503 get().nodes.map((n) => {
504 if (n.id === id) {
505 return {
506 ...n,
507 ...node,
508 } as Extract<AppNode, { type: T }>;
509 }
510 return n;
511 }),
512 );
513 }
gio7f98e772025-05-07 11:00:14 +0000514
giod0026612025-05-08 13:00:36 +0000515 function onConnect(c: Connection) {
516 const { nodes, edges } = get();
517 set({
518 edges: addEdge(c, edges),
519 });
520 const sn = nodes.filter((n) => n.id === c.source)[0]!;
521 const tn = nodes.filter((n) => n.id === c.target)[0]!;
522 if (tn.type === "network") {
523 if (sn.type === "gateway-https") {
524 updateNodeData<"gateway-https">(sn.id, {
525 network: tn.data.domain,
526 });
527 } else if (sn.type === "gateway-tcp") {
528 updateNodeData<"gateway-tcp">(sn.id, {
529 network: tn.data.domain,
530 });
531 }
532 }
533 if (tn.type === "app") {
534 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
535 const sourceEnvVars = nodeEnvVarNames(sn);
536 if (sourceEnvVars.length === 0) {
537 throw new Error("MUST NOT REACH!");
538 }
539 const id = uuidv4();
540 if (sourceEnvVars.length === 1) {
541 updateNode<"app">(c.target, {
542 ...tn,
543 data: {
544 ...tn.data,
545 envVars: [
546 ...(tn.data.envVars || []),
547 {
548 id: id,
549 source: c.source,
550 name: sourceEnvVars[0],
551 isEditting: false,
552 },
553 ],
554 },
555 });
556 } else {
557 updateNode<"app">(c.target, {
558 ...tn,
559 data: {
560 ...tn.data,
561 envVars: [
562 ...(tn.data.envVars || []),
563 {
564 id: id,
565 source: c.source,
566 },
567 ],
568 },
569 });
570 }
571 }
572 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
573 const sourcePorts = sn.data.ports || [];
574 const id = uuidv4();
575 if (sourcePorts.length === 1) {
576 updateNode<"app">(c.target, {
577 ...tn,
578 data: {
579 ...tn.data,
580 envVars: [
581 ...(tn.data.envVars || []),
582 {
583 id: id,
584 source: c.source,
585 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
586 portId: sourcePorts[0].id,
587 isEditting: false,
588 },
589 ],
590 },
591 });
592 }
593 }
594 }
595 if (c.sourceHandle === "volume") {
596 updateNodeData<"volume">(c.source, {
597 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
598 });
599 }
600 if (c.targetHandle === "volume") {
601 if (tn.type === "postgresql" || tn.type === "mongodb") {
602 updateNodeData(c.target, {
603 volumeId: c.source,
604 });
605 }
606 }
607 if (c.targetHandle === "https") {
608 if ((sn.data.ports || []).length === 1) {
609 updateNodeData<"gateway-https">(c.target, {
610 https: {
611 serviceId: c.source,
612 portId: sn.data.ports![0].id,
613 },
614 });
615 } else {
616 updateNodeData<"gateway-https">(c.target, {
617 https: {
618 serviceId: c.source,
619 portId: "", // TODO(gio)
620 },
621 });
622 }
623 }
624 if (c.targetHandle === "tcp") {
625 const td = tn.data as GatewayTCPData;
626 if ((sn.data.ports || []).length === 1) {
627 updateNodeData<"gateway-tcp">(c.target, {
628 exposed: (td.exposed || []).concat({
629 serviceId: c.source,
630 portId: sn.data.ports![0].id,
631 }),
632 });
633 } else {
634 updateNodeData<"gateway-tcp">(c.target, {
635 selected: {
636 serviceId: c.source,
637 portId: undefined,
638 },
639 });
640 }
641 }
642 if (sn.type === "app") {
643 if (c.sourceHandle === "ports") {
644 updateNodeData<"app">(sn.id, {
645 isChoosingPortToConnect: true,
646 });
647 }
648 }
649 if (tn.type === "app") {
650 if (c.targetHandle === "repository") {
651 updateNodeData<"app">(tn.id, {
652 repository: {
653 id: c.source,
654 branch: "master",
655 rootDir: "/",
656 },
657 });
658 }
659 }
660 }
661 return {
662 projectId: undefined,
gio818da4e2025-05-12 14:45:35 +0000663 mode: "edit",
giod0026612025-05-08 13:00:36 +0000664 projects: [],
665 nodes: [],
666 edges: [],
667 categories: defaultCategories,
668 messages: v([]),
669 env: defaultEnv,
gioaf8db832025-05-13 14:43:05 +0000670 viewport: {
671 transformX: 0,
672 transformY: 0,
673 transformZoom: 1,
674 width: 800,
675 height: 600,
676 },
gio359a6852025-05-14 03:38:24 +0000677 zoom: {
678 x: 0,
679 y: 0,
680 zoom: 1,
681 },
giod0026612025-05-08 13:00:36 +0000682 githubService: null,
gioaf8db832025-05-13 14:43:05 +0000683 setViewport: (viewport) => {
684 const { viewport: vp } = get();
685 if (
686 viewport.transformX !== vp.transformX ||
687 viewport.transformY !== vp.transformY ||
688 viewport.transformZoom !== vp.transformZoom ||
689 viewport.width !== vp.width ||
690 viewport.height !== vp.height
691 ) {
692 set({ viewport });
693 }
694 },
giod0026612025-05-08 13:00:36 +0000695 setHighlightCategory: (name, active) => {
696 set({
697 categories: get().categories.map((c) => {
698 if (c.title.toLowerCase() !== name.toLowerCase()) {
699 return c;
700 } else {
701 return {
702 ...c,
703 active,
704 };
705 }
706 }),
707 });
708 },
709 onNodesChange: (changes) => {
710 const nodes = applyNodeChanges(changes, get().nodes);
711 setN(nodes);
712 },
713 onEdgesChange: (changes) => {
714 set({
715 edges: applyEdgeChanges(changes, get().edges),
716 });
717 },
gioaf8db832025-05-13 14:43:05 +0000718 addNode: (node) => {
719 const { viewport, nodes } = get();
720 setN(
721 nodes.concat({
722 ...node,
723 position: getRandomPosition(viewport),
724 }),
725 );
726 },
giod0026612025-05-08 13:00:36 +0000727 setNodes: (nodes) => {
728 setN(nodes);
729 },
730 setEdges: (edges) => {
731 set({ edges });
732 },
733 replaceEdge: (c, id) => {
734 let change: EdgeChange;
735 if (id === undefined) {
736 change = {
737 type: "add",
738 item: {
739 id: uuidv4(),
740 ...c,
741 },
742 };
743 onConnect(c);
744 } else {
745 change = {
746 type: "replace",
747 id,
748 item: {
749 id,
750 ...c,
751 },
752 };
753 }
754 set({
755 edges: applyEdgeChanges([change], get().edges),
756 });
757 },
758 updateNode,
759 updateNodeData,
760 onConnect,
761 refreshEnv: async () => {
762 const projectId = get().projectId;
763 let env: Env = defaultEnv;
gio7f98e772025-05-07 11:00:14 +0000764
giod0026612025-05-08 13:00:36 +0000765 try {
766 if (projectId) {
767 const response = await fetch(`/api/project/${projectId}/env`);
768 if (response.ok) {
769 const data = await response.json();
770 const result = envSchema.safeParse(data);
771 if (result.success) {
772 env = result.data;
773 } else {
774 console.error("Invalid env data:", result.error);
775 }
776 }
777 }
778 } catch (error) {
779 console.error("Failed to fetch integrations:", error);
780 } finally {
gio4b9b58a2025-05-12 11:46:08 +0000781 if (JSON.stringify(get().env) !== JSON.stringify(env)) {
782 set({ env });
gioaf8db832025-05-13 14:43:05 +0000783 const newNetworks = env.networks.filter(
784 (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
785 );
786 newNetworks.forEach((n) => {
787 get().addNode({
788 id: n.domain,
789 type: "network",
790 connectable: true,
791 data: {
792 domain: n.domain,
793 label: n.domain,
794 envVars: [],
795 ports: [],
796 state: "success", // TODO(gio): monitor network health
797 },
798 });
799 });
gio4b9b58a2025-05-12 11:46:08 +0000800
801 if (env.integrations.github) {
802 set({ githubService: new GitHubServiceImpl(projectId!) });
803 } else {
804 set({ githubService: null });
805 }
giod0026612025-05-08 13:00:36 +0000806 }
807 }
808 },
gio818da4e2025-05-12 14:45:35 +0000809 setMode: (mode) => {
810 set({ mode });
811 },
812 setProject: async (projectId) => {
gio359a6852025-05-14 03:38:24 +0000813 if (projectId === get().projectId) {
814 return;
815 }
giod0026612025-05-08 13:00:36 +0000816 set({
817 projectId,
818 });
819 if (projectId) {
gio818da4e2025-05-12 14:45:35 +0000820 await get().refreshEnv();
821 if (get().env.deployKey) {
822 set({ mode: "deploy" });
823 } else {
824 set({ mode: "edit" });
825 }
gio4b9b58a2025-05-12 11:46:08 +0000826 restoreSaved();
827 } else {
828 set({
829 nodes: [],
830 edges: [],
831 });
giod0026612025-05-08 13:00:36 +0000832 }
833 },
834 };
gio5f2f1002025-03-20 18:38:48 +0400835});