blob: d64f81f1854eb23d7554d7eefc3caa6bc2f85fbb [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";
4import type { Edge, Node, OnConnect, OnEdgesChange, OnNodesChange } 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[];
380 categories: Category[];
381 messages: Message[];
382 env: Env;
gioaf8db832025-05-13 14:43:05 +0000383 viewport: Viewport;
384 setViewport: (viewport: Viewport) => void;
giod0026612025-05-08 13:00:36 +0000385 githubService: GitHubService | null;
386 setHighlightCategory: (name: string, active: boolean) => void;
387 onNodesChange: OnNodesChange<AppNode>;
388 onEdgesChange: OnEdgesChange;
389 onConnect: OnConnect;
gioaf8db832025-05-13 14:43:05 +0000390 addNode: (node: Omit<AppNode, "position">) => void;
giod0026612025-05-08 13:00:36 +0000391 setNodes: (nodes: AppNode[]) => void;
392 setEdges: (edges: Edge[]) => void;
gio818da4e2025-05-12 14:45:35 +0000393 setProject: (projectId: string | undefined) => Promise<void>;
394 setMode: (mode: "edit" | "deploy") => void;
giod0026612025-05-08 13:00:36 +0000395 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
396 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
397 replaceEdge: (c: Connection, id?: string) => void;
398 refreshEnv: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400399};
400
401const projectIdSelector = (state: AppState) => state.projectId;
402const categoriesSelector = (state: AppState) => state.categories;
403const messagesSelector = (state: AppState) => state.messages;
gio7f98e772025-05-07 11:00:14 +0000404const githubServiceSelector = (state: AppState) => state.githubService;
gio5f2f1002025-03-20 18:38:48 +0400405const envSelector = (state: AppState) => state.env;
gioaf8db832025-05-13 14:43:05 +0000406const dimensionsSelector = (state: AppState) => state.dimensions;
407
408export function useDimensions(): Dimensions {
409 return useStateStore(dimensionsSelector);
410}
gio5f2f1002025-03-20 18:38:48 +0400411
412export function useProjectId(): string | undefined {
giod0026612025-05-08 13:00:36 +0000413 return useStateStore(projectIdSelector);
gio5f2f1002025-03-20 18:38:48 +0400414}
415
416export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000417 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400418}
419
420export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000421 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400422}
423
424export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000425 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400426}
427
428export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000429 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400430}
431
432export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000433 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400434}
435
gio5f2f1002025-03-20 18:38:48 +0400436export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000437 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000438}
439
440export function useGithubService(): GitHubService | null {
giod0026612025-05-08 13:00:36 +0000441 return useStateStore(githubServiceSelector);
gio5f2f1002025-03-20 18:38:48 +0400442}
443
444const v: Validator = CreateValidators();
445
gioaf8db832025-05-13 14:43:05 +0000446function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
447 const zoomMultiplier = 1 / transformZoom;
448 const realWidth = width * zoomMultiplier;
449 const realHeight = height * zoomMultiplier;
450 const paddingMultiplier = 0.8;
451 const ret = {
452 x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
453 y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
454 };
455 return ret;
456}
457
gio5f2f1002025-03-20 18:38:48 +0400458export const useStateStore = create<AppState>((set, get): AppState => {
giod0026612025-05-08 13:00:36 +0000459 const setN = (nodes: AppNode[]) => {
gio4b9b58a2025-05-12 11:46:08 +0000460 if (nodes.length == 0) {
461 console.trace("setN", nodes);
462 }
463 set({
giod0026612025-05-08 13:00:36 +0000464 nodes,
gio5cf364c2025-05-08 16:01:21 +0000465 messages: v(nodes),
gio4b9b58a2025-05-12 11:46:08 +0000466 });
467 };
468
469 const restoreSaved = async () => {
gio818da4e2025-05-12 14:45:35 +0000470 const { projectId } = get();
471 const resp = await fetch(`/api/project/${projectId}/saved/${get().mode === "deploy" ? "deploy" : "draft"}`, {
gio4b9b58a2025-05-12 11:46:08 +0000472 method: "GET",
473 });
474 const inst = await resp.json();
gio4b9b58a2025-05-12 11:46:08 +0000475 setN(inst.nodes || []);
476 get().setEdges(inst.edges || []);
giod0026612025-05-08 13:00:36 +0000477 };
gio7f98e772025-05-07 11:00:14 +0000478
giod0026612025-05-08 13:00:36 +0000479 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
480 setN(
481 get().nodes.map((n) => {
482 if (n.id === id) {
483 return {
484 ...n,
485 data: {
486 ...n.data,
487 ...data,
488 },
489 } as Extract<AppNode, { type: T }>;
490 }
491 return n;
492 }),
493 );
494 }
gio7f98e772025-05-07 11:00:14 +0000495
giod0026612025-05-08 13:00:36 +0000496 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
497 setN(
498 get().nodes.map((n) => {
499 if (n.id === id) {
500 return {
501 ...n,
502 ...node,
503 } as Extract<AppNode, { type: T }>;
504 }
505 return n;
506 }),
507 );
508 }
gio7f98e772025-05-07 11:00:14 +0000509
giod0026612025-05-08 13:00:36 +0000510 function onConnect(c: Connection) {
511 const { nodes, edges } = get();
512 set({
513 edges: addEdge(c, edges),
514 });
515 const sn = nodes.filter((n) => n.id === c.source)[0]!;
516 const tn = nodes.filter((n) => n.id === c.target)[0]!;
517 if (tn.type === "network") {
518 if (sn.type === "gateway-https") {
519 updateNodeData<"gateway-https">(sn.id, {
520 network: tn.data.domain,
521 });
522 } else if (sn.type === "gateway-tcp") {
523 updateNodeData<"gateway-tcp">(sn.id, {
524 network: tn.data.domain,
525 });
526 }
527 }
528 if (tn.type === "app") {
529 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
530 const sourceEnvVars = nodeEnvVarNames(sn);
531 if (sourceEnvVars.length === 0) {
532 throw new Error("MUST NOT REACH!");
533 }
534 const id = uuidv4();
535 if (sourceEnvVars.length === 1) {
536 updateNode<"app">(c.target, {
537 ...tn,
538 data: {
539 ...tn.data,
540 envVars: [
541 ...(tn.data.envVars || []),
542 {
543 id: id,
544 source: c.source,
545 name: sourceEnvVars[0],
546 isEditting: false,
547 },
548 ],
549 },
550 });
551 } else {
552 updateNode<"app">(c.target, {
553 ...tn,
554 data: {
555 ...tn.data,
556 envVars: [
557 ...(tn.data.envVars || []),
558 {
559 id: id,
560 source: c.source,
561 },
562 ],
563 },
564 });
565 }
566 }
567 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
568 const sourcePorts = sn.data.ports || [];
569 const id = uuidv4();
570 if (sourcePorts.length === 1) {
571 updateNode<"app">(c.target, {
572 ...tn,
573 data: {
574 ...tn.data,
575 envVars: [
576 ...(tn.data.envVars || []),
577 {
578 id: id,
579 source: c.source,
580 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
581 portId: sourcePorts[0].id,
582 isEditting: false,
583 },
584 ],
585 },
586 });
587 }
588 }
589 }
590 if (c.sourceHandle === "volume") {
591 updateNodeData<"volume">(c.source, {
592 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
593 });
594 }
595 if (c.targetHandle === "volume") {
596 if (tn.type === "postgresql" || tn.type === "mongodb") {
597 updateNodeData(c.target, {
598 volumeId: c.source,
599 });
600 }
601 }
602 if (c.targetHandle === "https") {
603 if ((sn.data.ports || []).length === 1) {
604 updateNodeData<"gateway-https">(c.target, {
605 https: {
606 serviceId: c.source,
607 portId: sn.data.ports![0].id,
608 },
609 });
610 } else {
611 updateNodeData<"gateway-https">(c.target, {
612 https: {
613 serviceId: c.source,
614 portId: "", // TODO(gio)
615 },
616 });
617 }
618 }
619 if (c.targetHandle === "tcp") {
620 const td = tn.data as GatewayTCPData;
621 if ((sn.data.ports || []).length === 1) {
622 updateNodeData<"gateway-tcp">(c.target, {
623 exposed: (td.exposed || []).concat({
624 serviceId: c.source,
625 portId: sn.data.ports![0].id,
626 }),
627 });
628 } else {
629 updateNodeData<"gateway-tcp">(c.target, {
630 selected: {
631 serviceId: c.source,
632 portId: undefined,
633 },
634 });
635 }
636 }
637 if (sn.type === "app") {
638 if (c.sourceHandle === "ports") {
639 updateNodeData<"app">(sn.id, {
640 isChoosingPortToConnect: true,
641 });
642 }
643 }
644 if (tn.type === "app") {
645 if (c.targetHandle === "repository") {
646 updateNodeData<"app">(tn.id, {
647 repository: {
648 id: c.source,
649 branch: "master",
650 rootDir: "/",
651 },
652 });
653 }
654 }
655 }
656 return {
657 projectId: undefined,
gio818da4e2025-05-12 14:45:35 +0000658 mode: "edit",
giod0026612025-05-08 13:00:36 +0000659 projects: [],
660 nodes: [],
661 edges: [],
662 categories: defaultCategories,
663 messages: v([]),
664 env: defaultEnv,
gioaf8db832025-05-13 14:43:05 +0000665 viewport: {
666 transformX: 0,
667 transformY: 0,
668 transformZoom: 1,
669 width: 800,
670 height: 600,
671 },
giod0026612025-05-08 13:00:36 +0000672 githubService: null,
gioaf8db832025-05-13 14:43:05 +0000673 setViewport: (viewport) => {
674 const { viewport: vp } = get();
675 if (
676 viewport.transformX !== vp.transformX ||
677 viewport.transformY !== vp.transformY ||
678 viewport.transformZoom !== vp.transformZoom ||
679 viewport.width !== vp.width ||
680 viewport.height !== vp.height
681 ) {
682 set({ viewport });
683 }
684 },
giod0026612025-05-08 13:00:36 +0000685 setHighlightCategory: (name, active) => {
686 set({
687 categories: get().categories.map((c) => {
688 if (c.title.toLowerCase() !== name.toLowerCase()) {
689 return c;
690 } else {
691 return {
692 ...c,
693 active,
694 };
695 }
696 }),
697 });
698 },
699 onNodesChange: (changes) => {
700 const nodes = applyNodeChanges(changes, get().nodes);
701 setN(nodes);
702 },
703 onEdgesChange: (changes) => {
704 set({
705 edges: applyEdgeChanges(changes, get().edges),
706 });
707 },
gioaf8db832025-05-13 14:43:05 +0000708 addNode: (node) => {
709 const { viewport, nodes } = get();
710 setN(
711 nodes.concat({
712 ...node,
713 position: getRandomPosition(viewport),
714 }),
715 );
716 },
giod0026612025-05-08 13:00:36 +0000717 setNodes: (nodes) => {
718 setN(nodes);
719 },
720 setEdges: (edges) => {
721 set({ edges });
722 },
723 replaceEdge: (c, id) => {
724 let change: EdgeChange;
725 if (id === undefined) {
726 change = {
727 type: "add",
728 item: {
729 id: uuidv4(),
730 ...c,
731 },
732 };
733 onConnect(c);
734 } else {
735 change = {
736 type: "replace",
737 id,
738 item: {
739 id,
740 ...c,
741 },
742 };
743 }
744 set({
745 edges: applyEdgeChanges([change], get().edges),
746 });
747 },
748 updateNode,
749 updateNodeData,
750 onConnect,
751 refreshEnv: async () => {
752 const projectId = get().projectId;
753 let env: Env = defaultEnv;
gio7f98e772025-05-07 11:00:14 +0000754
giod0026612025-05-08 13:00:36 +0000755 try {
756 if (projectId) {
757 const response = await fetch(`/api/project/${projectId}/env`);
758 if (response.ok) {
759 const data = await response.json();
760 const result = envSchema.safeParse(data);
761 if (result.success) {
762 env = result.data;
763 } else {
764 console.error("Invalid env data:", result.error);
765 }
766 }
767 }
768 } catch (error) {
769 console.error("Failed to fetch integrations:", error);
770 } finally {
gio4b9b58a2025-05-12 11:46:08 +0000771 if (JSON.stringify(get().env) !== JSON.stringify(env)) {
gioaf8db832025-05-13 14:43:05 +0000772 console.log(env);
gio4b9b58a2025-05-12 11:46:08 +0000773 set({ env });
gioaf8db832025-05-13 14:43:05 +0000774 const newNetworks = env.networks.filter(
775 (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
776 );
777 newNetworks.forEach((n) => {
778 get().addNode({
779 id: n.domain,
780 type: "network",
781 connectable: true,
782 data: {
783 domain: n.domain,
784 label: n.domain,
785 envVars: [],
786 ports: [],
787 state: "success", // TODO(gio): monitor network health
788 },
789 });
790 });
gio4b9b58a2025-05-12 11:46:08 +0000791
792 if (env.integrations.github) {
793 set({ githubService: new GitHubServiceImpl(projectId!) });
794 } else {
795 set({ githubService: null });
796 }
giod0026612025-05-08 13:00:36 +0000797 }
798 }
799 },
gio818da4e2025-05-12 14:45:35 +0000800 setMode: (mode) => {
801 set({ mode });
802 },
803 setProject: async (projectId) => {
giod0026612025-05-08 13:00:36 +0000804 set({
805 projectId,
806 });
807 if (projectId) {
gio818da4e2025-05-12 14:45:35 +0000808 await get().refreshEnv();
809 if (get().env.deployKey) {
810 set({ mode: "deploy" });
811 } else {
812 set({ mode: "edit" });
813 }
gio4b9b58a2025-05-12 11:46:08 +0000814 restoreSaved();
815 } else {
816 set({
817 nodes: [],
818 edges: [],
819 });
giod0026612025-05-08 13:00:36 +0000820 }
821 },
822 };
gio5f2f1002025-03-20 18:38:48 +0400823});