blob: 8f21c26dda9ded731d8609bd949f3561ec74871e [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,
giod0026612025-05-08 13:00:36 +0000164 },
gio3a921b82025-05-10 07:36:09 +0000165 services: [],
gio3ed59592025-05-14 16:51:09 +0000166 user: {
167 id: "",
168 username: "",
169 },
giob77cb932025-05-19 09:37:14 +0000170 access: [],
gio7f98e772025-05-07 11:00:14 +0000171};
172
gio5f2f1002025-03-20 18:38:48 +0400173export type Project = {
giod0026612025-05-08 13:00:36 +0000174 id: string;
175 name: string;
176};
gio5f2f1002025-03-20 18:38:48 +0400177
gio7f98e772025-05-07 11:00:14 +0000178export type IntegrationsConfig = {
giod0026612025-05-08 13:00:36 +0000179 github: boolean;
gio7f98e772025-05-07 11:00:14 +0000180};
181
182type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
183type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
184
gioaf8db832025-05-13 14:43:05 +0000185type Viewport = {
186 transformX: number;
187 transformY: number;
188 transformZoom: number;
189 width: number;
190 height: number;
191};
192
gio918780d2025-05-22 08:24:41 +0000193let refreshEnvIntervalId: number | null = null;
194
gio5f2f1002025-03-20 18:38:48 +0400195export type AppState = {
giod0026612025-05-08 13:00:36 +0000196 projectId: string | undefined;
gio818da4e2025-05-12 14:45:35 +0000197 mode: "edit" | "deploy";
giod0026612025-05-08 13:00:36 +0000198 projects: Project[];
199 nodes: AppNode[];
200 edges: Edge[];
gio359a6852025-05-14 03:38:24 +0000201 zoom: ReactFlowViewport;
giod0026612025-05-08 13:00:36 +0000202 categories: Category[];
203 messages: Message[];
204 env: Env;
gioaf8db832025-05-13 14:43:05 +0000205 viewport: Viewport;
206 setViewport: (viewport: Viewport) => void;
giod0026612025-05-08 13:00:36 +0000207 githubService: GitHubService | null;
gioa71316d2025-05-24 09:41:36 +0400208 githubRepositories: GitHubRepository[];
209 githubRepositoriesLoading: boolean;
210 githubRepositoriesError: string | null;
giod0026612025-05-08 13:00:36 +0000211 setHighlightCategory: (name: string, active: boolean) => void;
212 onNodesChange: OnNodesChange<AppNode>;
213 onEdgesChange: OnEdgesChange;
214 onConnect: OnConnect;
gioaf8db832025-05-13 14:43:05 +0000215 addNode: (node: Omit<AppNode, "position">) => void;
giod0026612025-05-08 13:00:36 +0000216 setNodes: (nodes: AppNode[]) => void;
217 setEdges: (edges: Edge[]) => void;
gio818da4e2025-05-12 14:45:35 +0000218 setProject: (projectId: string | undefined) => Promise<void>;
219 setMode: (mode: "edit" | "deploy") => void;
giod0026612025-05-08 13:00:36 +0000220 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
221 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
222 replaceEdge: (c: Connection, id?: string) => void;
223 refreshEnv: () => Promise<void>;
gioa71316d2025-05-24 09:41:36 +0400224 fetchGithubRepositories: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400225};
226
227const projectIdSelector = (state: AppState) => state.projectId;
228const categoriesSelector = (state: AppState) => state.categories;
229const messagesSelector = (state: AppState) => state.messages;
230const envSelector = (state: AppState) => state.env;
gio359a6852025-05-14 03:38:24 +0000231const zoomSelector = (state: AppState) => state.zoom;
gioa71316d2025-05-24 09:41:36 +0400232const githubRepositoriesSelector = (state: AppState) => state.githubRepositories;
233const githubRepositoriesLoadingSelector = (state: AppState) => state.githubRepositoriesLoading;
234const githubRepositoriesErrorSelector = (state: AppState) => state.githubRepositoriesError;
gioaf8db832025-05-13 14:43:05 +0000235
gio359a6852025-05-14 03:38:24 +0000236export function useZoom(): ReactFlowViewport {
237 return useStateStore(zoomSelector);
gioaf8db832025-05-13 14:43:05 +0000238}
gio5f2f1002025-03-20 18:38:48 +0400239
240export function useProjectId(): string | undefined {
giod0026612025-05-08 13:00:36 +0000241 return useStateStore(projectIdSelector);
gio5f2f1002025-03-20 18:38:48 +0400242}
243
giob45b1862025-05-20 11:42:20 +0000244export function useSetProject(): (projectId: string | undefined) => void {
245 return useStateStore((state) => state.setProject);
246}
247
gio5f2f1002025-03-20 18:38:48 +0400248export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000249 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400250}
251
252export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000253 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400254}
255
256export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000257 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400258}
259
260export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000261 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400262}
263
264export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000265 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400266}
267
gio5f2f1002025-03-20 18:38:48 +0400268export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000269 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000270}
271
giocc5ce582025-06-25 07:45:21 +0400272export function useAgents(): Extract<Access, { type: "https" }>[] {
273 return useStateStore(envSelector).access.filter(
274 (acc): acc is Extract<Access, { type: "https" }> => acc.type === "https" && acc.agentName != null,
275 );
276}
277
gio69148322025-06-19 23:16:12 +0400278export function useGithubService(): boolean {
279 return useStateStore(envSelector).integrations.github;
280}
281
282export function useGeminiService(): boolean {
283 return useStateStore(envSelector).integrations.gemini;
gio5f2f1002025-03-20 18:38:48 +0400284}
285
gioa71316d2025-05-24 09:41:36 +0400286export function useGithubRepositories(): GitHubRepository[] {
287 return useStateStore(githubRepositoriesSelector);
288}
289
290export function useGithubRepositoriesLoading(): boolean {
291 return useStateStore(githubRepositoriesLoadingSelector);
292}
293
294export function useGithubRepositoriesError(): string | null {
295 return useStateStore(githubRepositoriesErrorSelector);
296}
297
298export function useFetchGithubRepositories(): () => Promise<void> {
299 return useStateStore((state) => state.fetchGithubRepositories);
300}
301
gio3ec94242025-05-16 12:46:57 +0000302export function useMode(): "edit" | "deploy" {
303 return useStateStore((state) => state.mode);
304}
305
gio5f2f1002025-03-20 18:38:48 +0400306const v: Validator = CreateValidators();
307
gioaf8db832025-05-13 14:43:05 +0000308function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
309 const zoomMultiplier = 1 / transformZoom;
310 const realWidth = width * zoomMultiplier;
311 const realHeight = height * zoomMultiplier;
312 const paddingMultiplier = 0.8;
313 const ret = {
314 x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
315 y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
316 };
317 return ret;
318}
319
gio3d0bf032025-06-05 06:57:26 +0000320export const useStateStore = create<AppState>((setOg, get): AppState => {
321 const set = (state: Partial<AppState>) => {
322 setOg(state);
323 };
giod0026612025-05-08 13:00:36 +0000324 const setN = (nodes: AppNode[]) => {
gio34193052025-07-03 03:55:11 +0000325 const env = get().env;
326 console.log("---env", env);
gio4b9b58a2025-05-12 11:46:08 +0000327 set({
giod0026612025-05-08 13:00:36 +0000328 nodes,
gio34193052025-07-03 03:55:11 +0000329 messages: v(nodes, env),
gio4b9b58a2025-05-12 11:46:08 +0000330 });
331 };
332
gio918780d2025-05-22 08:24:41 +0000333 const startRefreshEnvInterval = () => {
334 if (refreshEnvIntervalId) {
335 clearInterval(refreshEnvIntervalId);
336 }
337 if (get().projectId && typeof document !== "undefined" && document.visibilityState === "visible") {
338 console.log("Starting refreshEnv interval for project:", get().projectId);
339 refreshEnvIntervalId = setInterval(async () => {
340 if (get().projectId && typeof document !== "undefined" && document.visibilityState === "visible") {
341 console.log("Interval: Calling refreshEnv for project:", get().projectId);
342 await get().refreshEnv();
343 } else if (refreshEnvIntervalId) {
344 console.log(
345 "Interval: Conditions not met (project removed or tab hidden), stopping interval from inside.",
346 );
347 clearInterval(refreshEnvIntervalId);
348 refreshEnvIntervalId = null;
349 }
350 }, 5000) as unknown as number;
351 } else {
352 console.log(
353 "Not starting refreshEnv interval. Project ID:",
354 get().projectId,
355 "Visibility:",
356 typeof document !== "undefined" ? document.visibilityState : "SSR",
357 );
358 }
359 };
360
361 const stopRefreshEnvInterval = () => {
362 if (refreshEnvIntervalId) {
363 console.log("Stopping refreshEnv interval for project:", get().projectId);
364 clearInterval(refreshEnvIntervalId);
365 refreshEnvIntervalId = null;
366 }
367 };
368
369 if (typeof document !== "undefined") {
370 document.addEventListener("visibilitychange", () => {
371 if (document.visibilityState === "visible") {
372 console.log("Tab became visible, attempting to start refreshEnv interval.");
373 startRefreshEnvInterval();
374 } else {
375 console.log("Tab became hidden, stopping refreshEnv interval.");
376 stopRefreshEnvInterval();
377 }
378 });
379 }
380
gio48fde052025-05-14 09:48:08 +0000381 const injectNetworkNodes = () => {
382 const newNetworks = get().env.networks.filter(
383 (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
384 );
385 newNetworks.forEach((n) => {
386 get().addNode({
387 id: n.domain,
388 type: "network",
389 connectable: true,
390 data: {
391 domain: n.domain,
392 label: n.domain,
393 envVars: [],
394 ports: [],
395 state: "success", // TODO(gio): monitor network health
396 },
397 });
398 console.log("added network", n.domain);
399 });
400 };
401
gio4b9b58a2025-05-12 11:46:08 +0000402 const restoreSaved = async () => {
gio818da4e2025-05-12 14:45:35 +0000403 const { projectId } = get();
404 const resp = await fetch(`/api/project/${projectId}/saved/${get().mode === "deploy" ? "deploy" : "draft"}`, {
gio4b9b58a2025-05-12 11:46:08 +0000405 method: "GET",
406 });
407 const inst = await resp.json();
gioc31bf142025-06-16 07:48:20 +0000408 setN(inst.state.nodes);
409 set({ edges: inst.state.edges });
gio48fde052025-05-14 09:48:08 +0000410 injectNetworkNodes();
gio359a6852025-05-14 03:38:24 +0000411 if (
gioc31bf142025-06-16 07:48:20 +0000412 get().zoom.x !== inst.state.viewport.x ||
413 get().zoom.y !== inst.state.viewport.y ||
414 get().zoom.zoom !== inst.state.viewport.zoom
gio359a6852025-05-14 03:38:24 +0000415 ) {
gioc31bf142025-06-16 07:48:20 +0000416 set({ zoom: inst.state.viewport });
gio359a6852025-05-14 03:38:24 +0000417 }
giod0026612025-05-08 13:00:36 +0000418 };
gio7f98e772025-05-07 11:00:14 +0000419
giod0026612025-05-08 13:00:36 +0000420 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
421 setN(
422 get().nodes.map((n) => {
423 if (n.id === id) {
424 return {
425 ...n,
426 data: {
427 ...n.data,
428 ...data,
429 },
430 } as Extract<AppNode, { type: T }>;
431 }
432 return n;
433 }),
434 );
435 }
gio7f98e772025-05-07 11:00:14 +0000436
giod0026612025-05-08 13:00:36 +0000437 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
438 setN(
439 get().nodes.map((n) => {
440 if (n.id === id) {
441 return {
442 ...n,
443 ...node,
444 } as Extract<AppNode, { type: T }>;
445 }
446 return n;
447 }),
448 );
449 }
gio7f98e772025-05-07 11:00:14 +0000450
giod0026612025-05-08 13:00:36 +0000451 function onConnect(c: Connection) {
452 const { nodes, edges } = get();
453 set({
454 edges: addEdge(c, edges),
455 });
456 const sn = nodes.filter((n) => n.id === c.source)[0]!;
457 const tn = nodes.filter((n) => n.id === c.target)[0]!;
458 if (tn.type === "network") {
459 if (sn.type === "gateway-https") {
460 updateNodeData<"gateway-https">(sn.id, {
461 network: tn.data.domain,
462 });
463 } else if (sn.type === "gateway-tcp") {
464 updateNodeData<"gateway-tcp">(sn.id, {
465 network: tn.data.domain,
466 });
467 }
468 }
469 if (tn.type === "app") {
470 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
471 const sourceEnvVars = nodeEnvVarNames(sn);
472 if (sourceEnvVars.length === 0) {
gio69148322025-06-19 23:16:12 +0400473 throw new Error(
474 `onConnect (env_var): Source node ${sn.id} (type: ${sn.type}) has no env vars to connect from.`,
475 );
giod0026612025-05-08 13:00:36 +0000476 }
477 const id = uuidv4();
478 if (sourceEnvVars.length === 1) {
479 updateNode<"app">(c.target, {
480 ...tn,
481 data: {
482 ...tn.data,
483 envVars: [
484 ...(tn.data.envVars || []),
485 {
486 id: id,
487 source: c.source,
488 name: sourceEnvVars[0],
489 isEditting: false,
490 },
491 ],
492 },
493 });
494 } else {
495 updateNode<"app">(c.target, {
496 ...tn,
497 data: {
498 ...tn.data,
499 envVars: [
500 ...(tn.data.envVars || []),
501 {
502 id: id,
503 source: c.source,
504 },
505 ],
506 },
507 });
508 }
509 }
510 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
511 const sourcePorts = sn.data.ports || [];
512 const id = uuidv4();
513 if (sourcePorts.length === 1) {
514 updateNode<"app">(c.target, {
515 ...tn,
516 data: {
517 ...tn.data,
518 envVars: [
519 ...(tn.data.envVars || []),
520 {
521 id: id,
522 source: c.source,
523 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
524 portId: sourcePorts[0].id,
525 isEditting: false,
526 },
527 ],
528 },
529 });
530 }
531 }
gio3d0bf032025-06-05 06:57:26 +0000532 if (c.targetHandle === "repository") {
533 const sourceNode = nodes.find((n) => n.id === c.source);
534 if (sourceNode && sourceNode.type === "github" && sourceNode.data.repository) {
535 updateNodeData<"app">(tn.id, {
536 repository: {
537 id: sourceNode.data.repository.id,
538 repoNodeId: c.source,
539 },
540 });
541 }
542 }
giod0026612025-05-08 13:00:36 +0000543 }
544 if (c.sourceHandle === "volume") {
545 updateNodeData<"volume">(c.source, {
546 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
547 });
548 }
549 if (c.targetHandle === "volume") {
550 if (tn.type === "postgresql" || tn.type === "mongodb") {
551 updateNodeData(c.target, {
552 volumeId: c.source,
553 });
554 }
555 }
556 if (c.targetHandle === "https") {
557 if ((sn.data.ports || []).length === 1) {
558 updateNodeData<"gateway-https">(c.target, {
559 https: {
560 serviceId: c.source,
561 portId: sn.data.ports![0].id,
562 },
563 });
564 } else {
565 updateNodeData<"gateway-https">(c.target, {
566 https: {
567 serviceId: c.source,
568 portId: "", // TODO(gio)
569 },
570 });
571 }
572 }
573 if (c.targetHandle === "tcp") {
574 const td = tn.data as GatewayTCPData;
575 if ((sn.data.ports || []).length === 1) {
576 updateNodeData<"gateway-tcp">(c.target, {
577 exposed: (td.exposed || []).concat({
578 serviceId: c.source,
579 portId: sn.data.ports![0].id,
580 }),
581 });
582 } else {
583 updateNodeData<"gateway-tcp">(c.target, {
584 selected: {
585 serviceId: c.source,
586 portId: undefined,
587 },
588 });
589 }
590 }
591 if (sn.type === "app") {
592 if (c.sourceHandle === "ports") {
593 updateNodeData<"app">(sn.id, {
594 isChoosingPortToConnect: true,
595 });
596 }
597 }
giod0026612025-05-08 13:00:36 +0000598 }
gioa71316d2025-05-24 09:41:36 +0400599
600 const fetchGithubRepositories = async () => {
601 const { githubService, projectId } = get();
602 if (!githubService || !projectId) {
603 set({
604 githubRepositories: [],
605 githubRepositoriesError: "GitHub service or Project ID not available.",
606 githubRepositoriesLoading: false,
607 });
608 return;
609 }
610
611 set({ githubRepositoriesLoading: true, githubRepositoriesError: null });
612 try {
613 const repos = await githubService.getRepositories();
614 set({ githubRepositories: repos, githubRepositoriesLoading: false });
615 } catch (error) {
616 console.error("Failed to fetch GitHub repositories in store:", error);
617 const errorMessage = error instanceof Error ? error.message : "Unknown error fetching repositories";
618 set({ githubRepositories: [], githubRepositoriesError: errorMessage, githubRepositoriesLoading: false });
619 }
620 };
621
giod0026612025-05-08 13:00:36 +0000622 return {
623 projectId: undefined,
gio818da4e2025-05-12 14:45:35 +0000624 mode: "edit",
giod0026612025-05-08 13:00:36 +0000625 projects: [],
626 nodes: [],
627 edges: [],
628 categories: defaultCategories,
gio34193052025-07-03 03:55:11 +0000629 messages: [],
giod0026612025-05-08 13:00:36 +0000630 env: defaultEnv,
gioaf8db832025-05-13 14:43:05 +0000631 viewport: {
632 transformX: 0,
633 transformY: 0,
634 transformZoom: 1,
635 width: 800,
636 height: 600,
637 },
gio359a6852025-05-14 03:38:24 +0000638 zoom: {
639 x: 0,
640 y: 0,
641 zoom: 1,
642 },
giod0026612025-05-08 13:00:36 +0000643 githubService: null,
gioa71316d2025-05-24 09:41:36 +0400644 githubRepositories: [],
645 githubRepositoriesLoading: false,
646 githubRepositoriesError: null,
gioaf8db832025-05-13 14:43:05 +0000647 setViewport: (viewport) => {
648 const { viewport: vp } = get();
649 if (
650 viewport.transformX !== vp.transformX ||
651 viewport.transformY !== vp.transformY ||
652 viewport.transformZoom !== vp.transformZoom ||
653 viewport.width !== vp.width ||
654 viewport.height !== vp.height
655 ) {
656 set({ viewport });
657 }
658 },
giod0026612025-05-08 13:00:36 +0000659 setHighlightCategory: (name, active) => {
660 set({
661 categories: get().categories.map((c) => {
662 if (c.title.toLowerCase() !== name.toLowerCase()) {
663 return c;
664 } else {
665 return {
666 ...c,
667 active,
668 };
669 }
670 }),
671 });
672 },
673 onNodesChange: (changes) => {
674 const nodes = applyNodeChanges(changes, get().nodes);
675 setN(nodes);
676 },
677 onEdgesChange: (changes) => {
678 set({
679 edges: applyEdgeChanges(changes, get().edges),
680 });
681 },
gioaf8db832025-05-13 14:43:05 +0000682 addNode: (node) => {
683 const { viewport, nodes } = get();
684 setN(
685 nodes.concat({
686 ...node,
687 position: getRandomPosition(viewport),
gioa1efbad2025-05-21 07:16:45 +0000688 } as AppNode),
gioaf8db832025-05-13 14:43:05 +0000689 );
690 },
giod0026612025-05-08 13:00:36 +0000691 setNodes: (nodes) => {
692 setN(nodes);
693 },
694 setEdges: (edges) => {
695 set({ edges });
696 },
697 replaceEdge: (c, id) => {
698 let change: EdgeChange;
699 if (id === undefined) {
700 change = {
701 type: "add",
702 item: {
703 id: uuidv4(),
704 ...c,
705 },
706 };
707 onConnect(c);
708 } else {
709 change = {
710 type: "replace",
711 id,
712 item: {
713 id,
714 ...c,
715 },
716 };
717 }
718 set({
719 edges: applyEdgeChanges([change], get().edges),
720 });
721 },
722 updateNode,
723 updateNodeData,
724 onConnect,
725 refreshEnv: async () => {
726 const projectId = get().projectId;
727 let env: Env = defaultEnv;
giod0026612025-05-08 13:00:36 +0000728 try {
729 if (projectId) {
730 const response = await fetch(`/api/project/${projectId}/env`);
731 if (response.ok) {
732 const data = await response.json();
733 const result = envSchema.safeParse(data);
734 if (result.success) {
735 env = result.data;
736 } else {
737 console.error("Invalid env data:", result.error);
738 }
739 }
740 }
741 } catch (error) {
742 console.error("Failed to fetch integrations:", error);
743 } finally {
gioa71316d2025-05-24 09:41:36 +0400744 const oldEnv = get().env;
745 const oldGithubIntegrationStatus = oldEnv.integrations.github;
746 if (JSON.stringify(oldEnv) !== JSON.stringify(env)) {
gio4b9b58a2025-05-12 11:46:08 +0000747 set({ env });
gio48fde052025-05-14 09:48:08 +0000748 injectNetworkNodes();
gioa71316d2025-05-24 09:41:36 +0400749 let ghService = null;
gio4b9b58a2025-05-12 11:46:08 +0000750 if (env.integrations.github) {
gioa71316d2025-05-24 09:41:36 +0400751 ghService = new GitHubServiceImpl(projectId!);
752 }
753 if (get().githubService !== ghService || (ghService && !get().githubService)) {
754 set({ githubService: ghService });
755 }
756 if (
757 ghService &&
758 (oldGithubIntegrationStatus !== env.integrations.github || !oldEnv.integrations.github)
759 ) {
760 get().fetchGithubRepositories();
761 }
762 if (!env.integrations.github) {
763 set({
764 githubRepositories: [],
765 githubRepositoriesError: null,
766 githubRepositoriesLoading: false,
767 });
gio4b9b58a2025-05-12 11:46:08 +0000768 }
giod0026612025-05-08 13:00:36 +0000769 }
770 }
771 },
gio818da4e2025-05-12 14:45:35 +0000772 setMode: (mode) => {
773 set({ mode });
774 },
775 setProject: async (projectId) => {
gio918780d2025-05-22 08:24:41 +0000776 const currentProjectId = get().projectId;
777 if (projectId === currentProjectId) {
gio359a6852025-05-14 03:38:24 +0000778 return;
779 }
gio918780d2025-05-22 08:24:41 +0000780 stopRefreshEnvInterval();
giod0026612025-05-08 13:00:36 +0000781 set({
782 projectId,
gioa71316d2025-05-24 09:41:36 +0400783 githubRepositories: [],
784 githubRepositoriesLoading: false,
785 githubRepositoriesError: null,
giod0026612025-05-08 13:00:36 +0000786 });
787 if (projectId) {
gio818da4e2025-05-12 14:45:35 +0000788 await get().refreshEnv();
gioa71316d2025-05-24 09:41:36 +0400789 if (get().env.instanceId) {
gio818da4e2025-05-12 14:45:35 +0000790 set({ mode: "deploy" });
791 } else {
792 set({ mode: "edit" });
793 }
gio4b9b58a2025-05-12 11:46:08 +0000794 restoreSaved();
gio918780d2025-05-22 08:24:41 +0000795 startRefreshEnvInterval();
gio4b9b58a2025-05-12 11:46:08 +0000796 } else {
797 set({
798 nodes: [],
799 edges: [],
gio918780d2025-05-22 08:24:41 +0000800 env: defaultEnv,
801 githubService: null,
gioa71316d2025-05-24 09:41:36 +0400802 githubRepositories: [],
803 githubRepositoriesLoading: false,
804 githubRepositoriesError: null,
gio4b9b58a2025-05-12 11:46:08 +0000805 });
giod0026612025-05-08 13:00:36 +0000806 }
807 },
gioa71316d2025-05-24 09:41:36 +0400808 fetchGithubRepositories: fetchGithubRepositories,
giod0026612025-05-08 13:00:36 +0000809 };
gio5f2f1002025-03-20 18:38:48 +0400810});