blob: e0a858ab9a76278d34e88e7b25a792ce178f1847 [file] [log] [blame]
gio5f2f1002025-03-20 18:38:48 +04001import { Category, defaultCategories } from "./categories";
2import { CreateValidators, Validator } from "./config";
gioa71316d2025-05-24 09:41:36 +04003import { GitHubService, GitHubServiceImpl, GitHubRepository } from "./github";
gioc31bf142025-06-16 07:48:20 +00004import type { Edge, 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";
giod0026612025-05-08 13:00:36 +000016import { create } from "zustand";
giocc5ce582025-06-25 07:45:21 +040017import { AppNode, Env, NodeType, VolumeNode, GatewayTCPData, envSchema, Access } from "config";
gio5f2f1002025-03-20 18:38:48 +040018
19export function nodeLabel(n: AppNode): string {
gio48fde052025-05-14 09:48:08 +000020 try {
21 switch (n.type) {
22 case "network":
23 return n.data.domain;
24 case "app":
25 return n.data.label || "Service";
26 case "github":
27 return n.data.repository?.fullName || "Github";
28 case "gateway-https": {
gio29050d62025-05-16 04:49:26 +000029 if (n.data && n.data.subdomain) {
30 return `${n.data.subdomain}`;
gio48fde052025-05-14 09:48:08 +000031 } else {
32 return "HTTPS Gateway";
33 }
giod0026612025-05-08 13:00:36 +000034 }
gio48fde052025-05-14 09:48:08 +000035 case "gateway-tcp": {
gio29050d62025-05-16 04:49:26 +000036 if (n.data && n.data.subdomain) {
37 return `${n.data.subdomain}`;
gio48fde052025-05-14 09:48:08 +000038 } else {
39 return "TCP Gateway";
40 }
giod0026612025-05-08 13:00:36 +000041 }
gio48fde052025-05-14 09:48:08 +000042 case "mongodb":
43 return n.data.label || "MongoDB";
44 case "postgresql":
45 return n.data.label || "PostgreSQL";
46 case "volume":
47 return n.data.label || "Volume";
48 case undefined:
gio69148322025-06-19 23:16:12 +040049 throw new Error(`nodeLabel: Node type is undefined. Node ID: ${n.id}, Data: ${JSON.stringify(n.data)}`);
giod0026612025-05-08 13:00:36 +000050 }
gio48fde052025-05-14 09:48:08 +000051 } catch (e) {
52 console.error("opaa", e);
53 } finally {
54 console.log("done");
giod0026612025-05-08 13:00:36 +000055 }
gioa1efbad2025-05-21 07:16:45 +000056 return "Unknown Node";
gio5f2f1002025-03-20 18:38:48 +040057}
58
gio8fad76a2025-05-22 14:01:23 +000059export function nodeLabelFull(n: AppNode): string {
60 if (n.type === "gateway-https") {
61 return `https://${n.data.subdomain}.${n.data.network}`;
62 } else {
63 return nodeLabel(n);
64 }
65}
66
gio5f2f1002025-03-20 18:38:48 +040067export function nodeIsConnectable(n: AppNode, handle: string): boolean {
giod0026612025-05-08 13:00:36 +000068 switch (n.type) {
69 case "network":
70 return true;
71 case "app":
72 if (handle === "ports") {
73 return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
74 } else if (handle === "repository") {
75 if (!n.data || !n.data.repository || !n.data.repository.id) {
76 return true;
77 }
78 return false;
79 }
80 return false;
81 case "github":
82 if (n.data.repository?.id !== undefined) {
83 return true;
84 }
85 return false;
86 case "gateway-https":
87 if (handle === "subdomain") {
88 return n.data.network === undefined;
89 }
90 return n.data === undefined || n.data.https === undefined;
91 case "gateway-tcp":
92 if (handle === "subdomain") {
93 return n.data.network === undefined;
94 }
95 return true;
96 case "mongodb":
97 return true;
98 case "postgresql":
99 return true;
100 case "volume":
101 if (n.data === undefined || n.data.type === undefined) {
102 return false;
103 }
104 if (n.data.type === "ReadWriteOnce" || n.data.type === "ReadWriteOncePod") {
105 return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
106 }
107 return true;
108 case undefined:
gio69148322025-06-19 23:16:12 +0400109 throw new Error(
110 `nodeIsConnectable: Node type is undefined. Node ID: ${n.id}, Handle: ${handle}, Data: ${JSON.stringify(n.data)}`,
111 );
giod0026612025-05-08 13:00:36 +0000112 }
gio5f2f1002025-03-20 18:38:48 +0400113}
114
giob41ecae2025-04-24 08:46:50 +0000115export function nodeEnvVarNamePort(n: AppNode, portName: string): string {
giod0026612025-05-08 13:00:36 +0000116 return `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${portName.toUpperCase()}`;
giob41ecae2025-04-24 08:46:50 +0000117}
118
gio5f2f1002025-03-20 18:38:48 +0400119export function nodeEnvVarNames(n: AppNode): string[] {
giod0026612025-05-08 13:00:36 +0000120 switch (n.type) {
121 case "app":
122 return [
123 `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
124 ...(n.data.ports || []).map((p) => nodeEnvVarNamePort(n, p.name)),
125 ];
126 case "github":
127 return [];
128 case "gateway-https":
129 return [];
130 case "gateway-tcp":
131 return [];
132 case "mongodb":
133 return [`DODO_MONGODB_${n.data.label.toUpperCase()}_URL`];
134 case "postgresql":
135 return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_URL`];
136 case "volume":
137 return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
138 case undefined:
139 throw new Error("MUST NOT REACH");
140 default:
141 throw new Error("MUST NOT REACH");
142 }
gio5f2f1002025-03-20 18:38:48 +0400143}
144
gio5f2f1002025-03-20 18:38:48 +0400145export type MessageType = "INFO" | "WARNING" | "FATAL";
146
147export type Message = {
giod0026612025-05-08 13:00:36 +0000148 id: string;
149 type: MessageType;
150 nodeId?: string;
151 message: string;
152 onHighlight?: (state: AppState) => void;
153 onLooseHighlight?: (state: AppState) => void;
154 onClick?: (state: AppState) => void;
gio5f2f1002025-03-20 18:38:48 +0400155};
156
gio7f98e772025-05-07 11:00:14 +0000157const defaultEnv: Env = {
gioa71316d2025-05-24 09:41:36 +0400158 deployKeyPublic: undefined,
159 instanceId: undefined,
giod0026612025-05-08 13:00:36 +0000160 networks: [],
161 integrations: {
162 github: false,
gio69148322025-06-19 23:16:12 +0400163 gemini: false,
gio69ff7592025-07-03 06:27:21 +0000164 anthropic: false,
giod0026612025-05-08 13:00:36 +0000165 },
gio3a921b82025-05-10 07:36:09 +0000166 services: [],
gio3ed59592025-05-14 16:51:09 +0000167 user: {
168 id: "",
169 username: "",
170 },
giob77cb932025-05-19 09:37:14 +0000171 access: [],
gio7f98e772025-05-07 11:00:14 +0000172};
173
gio5f2f1002025-03-20 18:38:48 +0400174export type Project = {
giod0026612025-05-08 13:00:36 +0000175 id: string;
176 name: string;
177};
gio5f2f1002025-03-20 18:38:48 +0400178
gio7f98e772025-05-07 11:00:14 +0000179export type IntegrationsConfig = {
giod0026612025-05-08 13:00:36 +0000180 github: boolean;
gio7f98e772025-05-07 11:00:14 +0000181};
182
183type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
184type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
185
gioaf8db832025-05-13 14:43:05 +0000186type Viewport = {
187 transformX: number;
188 transformY: number;
189 transformZoom: number;
190 width: number;
191 height: number;
192};
193
gio918780d2025-05-22 08:24:41 +0000194let refreshEnvIntervalId: number | null = null;
195
gio5f2f1002025-03-20 18:38:48 +0400196export type AppState = {
giod0026612025-05-08 13:00:36 +0000197 projectId: string | undefined;
gio818da4e2025-05-12 14:45:35 +0000198 mode: "edit" | "deploy";
giod0026612025-05-08 13:00:36 +0000199 projects: Project[];
200 nodes: AppNode[];
201 edges: Edge[];
gio359a6852025-05-14 03:38:24 +0000202 zoom: ReactFlowViewport;
giod0026612025-05-08 13:00:36 +0000203 categories: Category[];
204 messages: Message[];
205 env: Env;
gioaf8db832025-05-13 14:43:05 +0000206 viewport: Viewport;
207 setViewport: (viewport: Viewport) => void;
giod0026612025-05-08 13:00:36 +0000208 githubService: GitHubService | null;
gioa71316d2025-05-24 09:41:36 +0400209 githubRepositories: GitHubRepository[];
210 githubRepositoriesLoading: boolean;
211 githubRepositoriesError: string | null;
giod0026612025-05-08 13:00:36 +0000212 setHighlightCategory: (name: string, active: boolean) => void;
213 onNodesChange: OnNodesChange<AppNode>;
214 onEdgesChange: OnEdgesChange;
215 onConnect: OnConnect;
gioaf8db832025-05-13 14:43:05 +0000216 addNode: (node: Omit<AppNode, "position">) => void;
giod0026612025-05-08 13:00:36 +0000217 setNodes: (nodes: AppNode[]) => void;
218 setEdges: (edges: Edge[]) => void;
gio818da4e2025-05-12 14:45:35 +0000219 setProject: (projectId: string | undefined) => Promise<void>;
220 setMode: (mode: "edit" | "deploy") => void;
giod0026612025-05-08 13:00:36 +0000221 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
222 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
223 replaceEdge: (c: Connection, id?: string) => void;
224 refreshEnv: () => Promise<void>;
gioa71316d2025-05-24 09:41:36 +0400225 fetchGithubRepositories: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400226};
227
228const projectIdSelector = (state: AppState) => state.projectId;
229const categoriesSelector = (state: AppState) => state.categories;
230const messagesSelector = (state: AppState) => state.messages;
231const envSelector = (state: AppState) => state.env;
gio359a6852025-05-14 03:38:24 +0000232const zoomSelector = (state: AppState) => state.zoom;
gioa71316d2025-05-24 09:41:36 +0400233const githubRepositoriesSelector = (state: AppState) => state.githubRepositories;
234const githubRepositoriesLoadingSelector = (state: AppState) => state.githubRepositoriesLoading;
235const githubRepositoriesErrorSelector = (state: AppState) => state.githubRepositoriesError;
gioaf8db832025-05-13 14:43:05 +0000236
gio359a6852025-05-14 03:38:24 +0000237export function useZoom(): ReactFlowViewport {
238 return useStateStore(zoomSelector);
gioaf8db832025-05-13 14:43:05 +0000239}
gio5f2f1002025-03-20 18:38:48 +0400240
241export function useProjectId(): string | undefined {
giod0026612025-05-08 13:00:36 +0000242 return useStateStore(projectIdSelector);
gio5f2f1002025-03-20 18:38:48 +0400243}
244
giob45b1862025-05-20 11:42:20 +0000245export function useSetProject(): (projectId: string | undefined) => void {
246 return useStateStore((state) => state.setProject);
247}
248
gio5f2f1002025-03-20 18:38:48 +0400249export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000250 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400251}
252
253export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000254 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400255}
256
257export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000258 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400259}
260
261export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000262 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400263}
264
265export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000266 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400267}
268
gio5f2f1002025-03-20 18:38:48 +0400269export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000270 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000271}
272
giocc5ce582025-06-25 07:45:21 +0400273export function useAgents(): Extract<Access, { type: "https" }>[] {
274 return useStateStore(envSelector).access.filter(
275 (acc): acc is Extract<Access, { type: "https" }> => acc.type === "https" && acc.agentName != null,
276 );
277}
278
gio69148322025-06-19 23:16:12 +0400279export function useGithubService(): boolean {
280 return useStateStore(envSelector).integrations.github;
281}
282
283export function useGeminiService(): boolean {
284 return useStateStore(envSelector).integrations.gemini;
gio5f2f1002025-03-20 18:38:48 +0400285}
286
gio69ff7592025-07-03 06:27:21 +0000287export function useAnthropicService(): boolean {
288 return useStateStore(envSelector).integrations.anthropic;
289}
290
gioa71316d2025-05-24 09:41:36 +0400291export function useGithubRepositories(): GitHubRepository[] {
292 return useStateStore(githubRepositoriesSelector);
293}
294
295export function useGithubRepositoriesLoading(): boolean {
296 return useStateStore(githubRepositoriesLoadingSelector);
297}
298
299export function useGithubRepositoriesError(): string | null {
300 return useStateStore(githubRepositoriesErrorSelector);
301}
302
303export function useFetchGithubRepositories(): () => Promise<void> {
304 return useStateStore((state) => state.fetchGithubRepositories);
305}
306
gio3ec94242025-05-16 12:46:57 +0000307export function useMode(): "edit" | "deploy" {
308 return useStateStore((state) => state.mode);
309}
310
gio5f2f1002025-03-20 18:38:48 +0400311const v: Validator = CreateValidators();
312
gioaf8db832025-05-13 14:43:05 +0000313function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
314 const zoomMultiplier = 1 / transformZoom;
315 const realWidth = width * zoomMultiplier;
316 const realHeight = height * zoomMultiplier;
317 const paddingMultiplier = 0.8;
318 const ret = {
319 x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
320 y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
321 };
322 return ret;
323}
324
gio3d0bf032025-06-05 06:57:26 +0000325export const useStateStore = create<AppState>((setOg, get): AppState => {
326 const set = (state: Partial<AppState>) => {
327 setOg(state);
328 };
giod0026612025-05-08 13:00:36 +0000329 const setN = (nodes: AppNode[]) => {
gio34193052025-07-03 03:55:11 +0000330 const env = get().env;
331 console.log("---env", env);
gio4b9b58a2025-05-12 11:46:08 +0000332 set({
giod0026612025-05-08 13:00:36 +0000333 nodes,
gio34193052025-07-03 03:55:11 +0000334 messages: v(nodes, env),
gio4b9b58a2025-05-12 11:46:08 +0000335 });
336 };
337
gio918780d2025-05-22 08:24:41 +0000338 const startRefreshEnvInterval = () => {
339 if (refreshEnvIntervalId) {
340 clearInterval(refreshEnvIntervalId);
341 }
342 if (get().projectId && typeof document !== "undefined" && document.visibilityState === "visible") {
343 console.log("Starting refreshEnv interval for project:", get().projectId);
344 refreshEnvIntervalId = setInterval(async () => {
345 if (get().projectId && typeof document !== "undefined" && document.visibilityState === "visible") {
346 console.log("Interval: Calling refreshEnv for project:", get().projectId);
347 await get().refreshEnv();
348 } else if (refreshEnvIntervalId) {
349 console.log(
350 "Interval: Conditions not met (project removed or tab hidden), stopping interval from inside.",
351 );
352 clearInterval(refreshEnvIntervalId);
353 refreshEnvIntervalId = null;
354 }
355 }, 5000) as unknown as number;
356 } else {
357 console.log(
358 "Not starting refreshEnv interval. Project ID:",
359 get().projectId,
360 "Visibility:",
361 typeof document !== "undefined" ? document.visibilityState : "SSR",
362 );
363 }
364 };
365
366 const stopRefreshEnvInterval = () => {
367 if (refreshEnvIntervalId) {
368 console.log("Stopping refreshEnv interval for project:", get().projectId);
369 clearInterval(refreshEnvIntervalId);
370 refreshEnvIntervalId = null;
371 }
372 };
373
374 if (typeof document !== "undefined") {
375 document.addEventListener("visibilitychange", () => {
376 if (document.visibilityState === "visible") {
377 console.log("Tab became visible, attempting to start refreshEnv interval.");
378 startRefreshEnvInterval();
379 } else {
380 console.log("Tab became hidden, stopping refreshEnv interval.");
381 stopRefreshEnvInterval();
382 }
383 });
384 }
385
gio48fde052025-05-14 09:48:08 +0000386 const injectNetworkNodes = () => {
387 const newNetworks = get().env.networks.filter(
388 (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
389 );
390 newNetworks.forEach((n) => {
391 get().addNode({
392 id: n.domain,
393 type: "network",
394 connectable: true,
395 data: {
396 domain: n.domain,
397 label: n.domain,
398 envVars: [],
399 ports: [],
400 state: "success", // TODO(gio): monitor network health
401 },
402 });
403 console.log("added network", n.domain);
404 });
405 };
406
gio4b9b58a2025-05-12 11:46:08 +0000407 const restoreSaved = async () => {
gio818da4e2025-05-12 14:45:35 +0000408 const { projectId } = get();
409 const resp = await fetch(`/api/project/${projectId}/saved/${get().mode === "deploy" ? "deploy" : "draft"}`, {
gio4b9b58a2025-05-12 11:46:08 +0000410 method: "GET",
411 });
412 const inst = await resp.json();
gioc31bf142025-06-16 07:48:20 +0000413 setN(inst.state.nodes);
414 set({ edges: inst.state.edges });
gio48fde052025-05-14 09:48:08 +0000415 injectNetworkNodes();
gio359a6852025-05-14 03:38:24 +0000416 if (
gioc31bf142025-06-16 07:48:20 +0000417 get().zoom.x !== inst.state.viewport.x ||
418 get().zoom.y !== inst.state.viewport.y ||
419 get().zoom.zoom !== inst.state.viewport.zoom
gio359a6852025-05-14 03:38:24 +0000420 ) {
gioc31bf142025-06-16 07:48:20 +0000421 set({ zoom: inst.state.viewport });
gio359a6852025-05-14 03:38:24 +0000422 }
giod0026612025-05-08 13:00:36 +0000423 };
gio7f98e772025-05-07 11:00:14 +0000424
giod0026612025-05-08 13:00:36 +0000425 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
426 setN(
427 get().nodes.map((n) => {
428 if (n.id === id) {
429 return {
430 ...n,
431 data: {
432 ...n.data,
433 ...data,
434 },
435 } as Extract<AppNode, { type: T }>;
436 }
437 return n;
438 }),
439 );
440 }
gio7f98e772025-05-07 11:00:14 +0000441
giod0026612025-05-08 13:00:36 +0000442 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
443 setN(
444 get().nodes.map((n) => {
445 if (n.id === id) {
446 return {
447 ...n,
448 ...node,
449 } as Extract<AppNode, { type: T }>;
450 }
451 return n;
452 }),
453 );
454 }
gio7f98e772025-05-07 11:00:14 +0000455
giod0026612025-05-08 13:00:36 +0000456 function onConnect(c: Connection) {
457 const { nodes, edges } = get();
458 set({
459 edges: addEdge(c, edges),
460 });
461 const sn = nodes.filter((n) => n.id === c.source)[0]!;
462 const tn = nodes.filter((n) => n.id === c.target)[0]!;
463 if (tn.type === "network") {
464 if (sn.type === "gateway-https") {
465 updateNodeData<"gateway-https">(sn.id, {
466 network: tn.data.domain,
467 });
468 } else if (sn.type === "gateway-tcp") {
469 updateNodeData<"gateway-tcp">(sn.id, {
470 network: tn.data.domain,
471 });
472 }
473 }
474 if (tn.type === "app") {
475 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
476 const sourceEnvVars = nodeEnvVarNames(sn);
477 if (sourceEnvVars.length === 0) {
gio69148322025-06-19 23:16:12 +0400478 throw new Error(
479 `onConnect (env_var): Source node ${sn.id} (type: ${sn.type}) has no env vars to connect from.`,
480 );
giod0026612025-05-08 13:00:36 +0000481 }
482 const id = uuidv4();
483 if (sourceEnvVars.length === 1) {
484 updateNode<"app">(c.target, {
485 ...tn,
486 data: {
487 ...tn.data,
488 envVars: [
489 ...(tn.data.envVars || []),
490 {
491 id: id,
492 source: c.source,
493 name: sourceEnvVars[0],
494 isEditting: false,
495 },
496 ],
497 },
498 });
499 } else {
500 updateNode<"app">(c.target, {
501 ...tn,
502 data: {
503 ...tn.data,
504 envVars: [
505 ...(tn.data.envVars || []),
506 {
507 id: id,
508 source: c.source,
509 },
510 ],
511 },
512 });
513 }
514 }
515 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
516 const sourcePorts = sn.data.ports || [];
517 const id = uuidv4();
518 if (sourcePorts.length === 1) {
519 updateNode<"app">(c.target, {
520 ...tn,
521 data: {
522 ...tn.data,
523 envVars: [
524 ...(tn.data.envVars || []),
525 {
526 id: id,
527 source: c.source,
528 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
529 portId: sourcePorts[0].id,
530 isEditting: false,
531 },
532 ],
533 },
534 });
535 }
536 }
gio3d0bf032025-06-05 06:57:26 +0000537 if (c.targetHandle === "repository") {
538 const sourceNode = nodes.find((n) => n.id === c.source);
539 if (sourceNode && sourceNode.type === "github" && sourceNode.data.repository) {
540 updateNodeData<"app">(tn.id, {
541 repository: {
542 id: sourceNode.data.repository.id,
543 repoNodeId: c.source,
544 },
545 });
546 }
547 }
giod0026612025-05-08 13:00:36 +0000548 }
549 if (c.sourceHandle === "volume") {
550 updateNodeData<"volume">(c.source, {
551 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
552 });
553 }
554 if (c.targetHandle === "volume") {
555 if (tn.type === "postgresql" || tn.type === "mongodb") {
556 updateNodeData(c.target, {
557 volumeId: c.source,
558 });
559 }
560 }
561 if (c.targetHandle === "https") {
562 if ((sn.data.ports || []).length === 1) {
563 updateNodeData<"gateway-https">(c.target, {
564 https: {
565 serviceId: c.source,
566 portId: sn.data.ports![0].id,
567 },
568 });
569 } else {
570 updateNodeData<"gateway-https">(c.target, {
571 https: {
572 serviceId: c.source,
573 portId: "", // TODO(gio)
574 },
575 });
576 }
577 }
578 if (c.targetHandle === "tcp") {
579 const td = tn.data as GatewayTCPData;
580 if ((sn.data.ports || []).length === 1) {
581 updateNodeData<"gateway-tcp">(c.target, {
582 exposed: (td.exposed || []).concat({
583 serviceId: c.source,
584 portId: sn.data.ports![0].id,
585 }),
586 });
587 } else {
588 updateNodeData<"gateway-tcp">(c.target, {
589 selected: {
590 serviceId: c.source,
591 portId: undefined,
592 },
593 });
594 }
595 }
596 if (sn.type === "app") {
597 if (c.sourceHandle === "ports") {
598 updateNodeData<"app">(sn.id, {
599 isChoosingPortToConnect: true,
600 });
601 }
602 }
giod0026612025-05-08 13:00:36 +0000603 }
gioa71316d2025-05-24 09:41:36 +0400604
605 const fetchGithubRepositories = async () => {
606 const { githubService, projectId } = get();
607 if (!githubService || !projectId) {
608 set({
609 githubRepositories: [],
610 githubRepositoriesError: "GitHub service or Project ID not available.",
611 githubRepositoriesLoading: false,
612 });
613 return;
614 }
615
616 set({ githubRepositoriesLoading: true, githubRepositoriesError: null });
617 try {
618 const repos = await githubService.getRepositories();
619 set({ githubRepositories: repos, githubRepositoriesLoading: false });
620 } catch (error) {
621 console.error("Failed to fetch GitHub repositories in store:", error);
622 const errorMessage = error instanceof Error ? error.message : "Unknown error fetching repositories";
623 set({ githubRepositories: [], githubRepositoriesError: errorMessage, githubRepositoriesLoading: false });
624 }
625 };
626
giod0026612025-05-08 13:00:36 +0000627 return {
628 projectId: undefined,
gio818da4e2025-05-12 14:45:35 +0000629 mode: "edit",
giod0026612025-05-08 13:00:36 +0000630 projects: [],
631 nodes: [],
632 edges: [],
633 categories: defaultCategories,
gio34193052025-07-03 03:55:11 +0000634 messages: [],
giod0026612025-05-08 13:00:36 +0000635 env: defaultEnv,
gioaf8db832025-05-13 14:43:05 +0000636 viewport: {
637 transformX: 0,
638 transformY: 0,
639 transformZoom: 1,
640 width: 800,
641 height: 600,
642 },
gio359a6852025-05-14 03:38:24 +0000643 zoom: {
644 x: 0,
645 y: 0,
646 zoom: 1,
647 },
giod0026612025-05-08 13:00:36 +0000648 githubService: null,
gioa71316d2025-05-24 09:41:36 +0400649 githubRepositories: [],
650 githubRepositoriesLoading: false,
651 githubRepositoriesError: null,
gioaf8db832025-05-13 14:43:05 +0000652 setViewport: (viewport) => {
653 const { viewport: vp } = get();
654 if (
655 viewport.transformX !== vp.transformX ||
656 viewport.transformY !== vp.transformY ||
657 viewport.transformZoom !== vp.transformZoom ||
658 viewport.width !== vp.width ||
659 viewport.height !== vp.height
660 ) {
661 set({ viewport });
662 }
663 },
giod0026612025-05-08 13:00:36 +0000664 setHighlightCategory: (name, active) => {
665 set({
666 categories: get().categories.map((c) => {
667 if (c.title.toLowerCase() !== name.toLowerCase()) {
668 return c;
669 } else {
670 return {
671 ...c,
672 active,
673 };
674 }
675 }),
676 });
677 },
678 onNodesChange: (changes) => {
679 const nodes = applyNodeChanges(changes, get().nodes);
680 setN(nodes);
681 },
682 onEdgesChange: (changes) => {
683 set({
684 edges: applyEdgeChanges(changes, get().edges),
685 });
686 },
gioaf8db832025-05-13 14:43:05 +0000687 addNode: (node) => {
688 const { viewport, nodes } = get();
689 setN(
690 nodes.concat({
691 ...node,
692 position: getRandomPosition(viewport),
gioa1efbad2025-05-21 07:16:45 +0000693 } as AppNode),
gioaf8db832025-05-13 14:43:05 +0000694 );
695 },
giod0026612025-05-08 13:00:36 +0000696 setNodes: (nodes) => {
697 setN(nodes);
698 },
699 setEdges: (edges) => {
700 set({ edges });
701 },
702 replaceEdge: (c, id) => {
703 let change: EdgeChange;
704 if (id === undefined) {
705 change = {
706 type: "add",
707 item: {
708 id: uuidv4(),
709 ...c,
710 },
711 };
712 onConnect(c);
713 } else {
714 change = {
715 type: "replace",
716 id,
717 item: {
718 id,
719 ...c,
720 },
721 };
722 }
723 set({
724 edges: applyEdgeChanges([change], get().edges),
725 });
726 },
727 updateNode,
728 updateNodeData,
729 onConnect,
730 refreshEnv: async () => {
731 const projectId = get().projectId;
732 let env: Env = defaultEnv;
giod0026612025-05-08 13:00:36 +0000733 try {
734 if (projectId) {
735 const response = await fetch(`/api/project/${projectId}/env`);
736 if (response.ok) {
737 const data = await response.json();
738 const result = envSchema.safeParse(data);
739 if (result.success) {
740 env = result.data;
741 } else {
742 console.error("Invalid env data:", result.error);
743 }
744 }
745 }
746 } catch (error) {
747 console.error("Failed to fetch integrations:", error);
748 } finally {
gioa71316d2025-05-24 09:41:36 +0400749 const oldEnv = get().env;
750 const oldGithubIntegrationStatus = oldEnv.integrations.github;
751 if (JSON.stringify(oldEnv) !== JSON.stringify(env)) {
gio4b9b58a2025-05-12 11:46:08 +0000752 set({ env });
gio48fde052025-05-14 09:48:08 +0000753 injectNetworkNodes();
gioa71316d2025-05-24 09:41:36 +0400754 let ghService = null;
gio4b9b58a2025-05-12 11:46:08 +0000755 if (env.integrations.github) {
gioa71316d2025-05-24 09:41:36 +0400756 ghService = new GitHubServiceImpl(projectId!);
757 }
758 if (get().githubService !== ghService || (ghService && !get().githubService)) {
759 set({ githubService: ghService });
760 }
761 if (
762 ghService &&
763 (oldGithubIntegrationStatus !== env.integrations.github || !oldEnv.integrations.github)
764 ) {
765 get().fetchGithubRepositories();
766 }
767 if (!env.integrations.github) {
768 set({
769 githubRepositories: [],
770 githubRepositoriesError: null,
771 githubRepositoriesLoading: false,
772 });
gio4b9b58a2025-05-12 11:46:08 +0000773 }
giod0026612025-05-08 13:00:36 +0000774 }
775 }
776 },
gio818da4e2025-05-12 14:45:35 +0000777 setMode: (mode) => {
778 set({ mode });
779 },
780 setProject: async (projectId) => {
gio918780d2025-05-22 08:24:41 +0000781 const currentProjectId = get().projectId;
782 if (projectId === currentProjectId) {
gio359a6852025-05-14 03:38:24 +0000783 return;
784 }
gio918780d2025-05-22 08:24:41 +0000785 stopRefreshEnvInterval();
giod0026612025-05-08 13:00:36 +0000786 set({
787 projectId,
gioa71316d2025-05-24 09:41:36 +0400788 githubRepositories: [],
789 githubRepositoriesLoading: false,
790 githubRepositoriesError: null,
giod0026612025-05-08 13:00:36 +0000791 });
792 if (projectId) {
gio818da4e2025-05-12 14:45:35 +0000793 await get().refreshEnv();
gioa71316d2025-05-24 09:41:36 +0400794 if (get().env.instanceId) {
gio818da4e2025-05-12 14:45:35 +0000795 set({ mode: "deploy" });
796 } else {
797 set({ mode: "edit" });
798 }
gio4b9b58a2025-05-12 11:46:08 +0000799 restoreSaved();
gio918780d2025-05-22 08:24:41 +0000800 startRefreshEnvInterval();
gio4b9b58a2025-05-12 11:46:08 +0000801 } else {
802 set({
803 nodes: [],
804 edges: [],
gio918780d2025-05-22 08:24:41 +0000805 env: defaultEnv,
806 githubService: null,
gioa71316d2025-05-24 09:41:36 +0400807 githubRepositories: [],
808 githubRepositoriesLoading: false,
809 githubRepositoriesError: null,
gio4b9b58a2025-05-12 11:46:08 +0000810 });
giod0026612025-05-08 13:00:36 +0000811 }
812 },
gioa71316d2025-05-24 09:41:36 +0400813 fetchGithubRepositories: fetchGithubRepositories,
giod0026612025-05-08 13:00:36 +0000814 };
gio5f2f1002025-03-20 18:38:48 +0400815});