blob: fcfe0118c876261aa925789dc31fdbfbab5f790a [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";
gio52441602025-07-06 18:22:56 +000017import {
18 AppNode,
19 Env,
20 NodeType,
21 VolumeNode,
22 GatewayTCPData,
23 envSchema,
24 AgentAccess,
25 ViewportTransform,
26 GraphSchema,
27} from "config";
gio5f2f1002025-03-20 18:38:48 +040028
29export function nodeLabel(n: AppNode): string {
gio48fde052025-05-14 09:48:08 +000030 try {
31 switch (n.type) {
32 case "network":
33 return n.data.domain;
34 case "app":
35 return n.data.label || "Service";
36 case "github":
37 return n.data.repository?.fullName || "Github";
38 case "gateway-https": {
gio29050d62025-05-16 04:49:26 +000039 if (n.data && n.data.subdomain) {
40 return `${n.data.subdomain}`;
gio48fde052025-05-14 09:48:08 +000041 } else {
42 return "HTTPS Gateway";
43 }
giod0026612025-05-08 13:00:36 +000044 }
gio48fde052025-05-14 09:48:08 +000045 case "gateway-tcp": {
gio29050d62025-05-16 04:49:26 +000046 if (n.data && n.data.subdomain) {
47 return `${n.data.subdomain}`;
gio48fde052025-05-14 09:48:08 +000048 } else {
49 return "TCP Gateway";
50 }
giod0026612025-05-08 13:00:36 +000051 }
gio48fde052025-05-14 09:48:08 +000052 case "mongodb":
53 return n.data.label || "MongoDB";
54 case "postgresql":
55 return n.data.label || "PostgreSQL";
56 case "volume":
57 return n.data.label || "Volume";
58 case undefined:
gio69148322025-06-19 23:16:12 +040059 throw new Error(`nodeLabel: Node type is undefined. Node ID: ${n.id}, Data: ${JSON.stringify(n.data)}`);
giod0026612025-05-08 13:00:36 +000060 }
gio48fde052025-05-14 09:48:08 +000061 } catch (e) {
62 console.error("opaa", e);
63 } finally {
64 console.log("done");
giod0026612025-05-08 13:00:36 +000065 }
gioa1efbad2025-05-21 07:16:45 +000066 return "Unknown Node";
gio5f2f1002025-03-20 18:38:48 +040067}
68
gio8fad76a2025-05-22 14:01:23 +000069export function nodeLabelFull(n: AppNode): string {
70 if (n.type === "gateway-https") {
71 return `https://${n.data.subdomain}.${n.data.network}`;
72 } else {
73 return nodeLabel(n);
74 }
75}
76
gio5f2f1002025-03-20 18:38:48 +040077export function nodeIsConnectable(n: AppNode, handle: string): boolean {
giod0026612025-05-08 13:00:36 +000078 switch (n.type) {
79 case "network":
80 return true;
81 case "app":
82 if (handle === "ports") {
83 return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
84 } else if (handle === "repository") {
85 if (!n.data || !n.data.repository || !n.data.repository.id) {
86 return true;
87 }
88 return false;
89 }
90 return false;
91 case "github":
92 if (n.data.repository?.id !== undefined) {
93 return true;
94 }
95 return false;
96 case "gateway-https":
97 if (handle === "subdomain") {
98 return n.data.network === undefined;
99 }
100 return n.data === undefined || n.data.https === undefined;
101 case "gateway-tcp":
102 if (handle === "subdomain") {
103 return n.data.network === undefined;
104 }
105 return true;
106 case "mongodb":
107 return true;
108 case "postgresql":
109 return true;
110 case "volume":
111 if (n.data === undefined || n.data.type === undefined) {
112 return false;
113 }
114 if (n.data.type === "ReadWriteOnce" || n.data.type === "ReadWriteOncePod") {
115 return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
116 }
117 return true;
118 case undefined:
gio69148322025-06-19 23:16:12 +0400119 throw new Error(
120 `nodeIsConnectable: Node type is undefined. Node ID: ${n.id}, Handle: ${handle}, Data: ${JSON.stringify(n.data)}`,
121 );
giod0026612025-05-08 13:00:36 +0000122 }
gio5f2f1002025-03-20 18:38:48 +0400123}
124
giob41ecae2025-04-24 08:46:50 +0000125export function nodeEnvVarNamePort(n: AppNode, portName: string): string {
giod0026612025-05-08 13:00:36 +0000126 return `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${portName.toUpperCase()}`;
giob41ecae2025-04-24 08:46:50 +0000127}
128
gio5f2f1002025-03-20 18:38:48 +0400129export function nodeEnvVarNames(n: AppNode): string[] {
giod0026612025-05-08 13:00:36 +0000130 switch (n.type) {
131 case "app":
132 return [
133 `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
134 ...(n.data.ports || []).map((p) => nodeEnvVarNamePort(n, p.name)),
135 ];
136 case "github":
137 return [];
138 case "gateway-https":
139 return [];
140 case "gateway-tcp":
141 return [];
142 case "mongodb":
143 return [`DODO_MONGODB_${n.data.label.toUpperCase()}_URL`];
144 case "postgresql":
145 return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_URL`];
146 case "volume":
147 return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
148 case undefined:
149 throw new Error("MUST NOT REACH");
150 default:
151 throw new Error("MUST NOT REACH");
152 }
gio5f2f1002025-03-20 18:38:48 +0400153}
154
gio5f2f1002025-03-20 18:38:48 +0400155export type MessageType = "INFO" | "WARNING" | "FATAL";
156
157export type Message = {
giod0026612025-05-08 13:00:36 +0000158 id: string;
159 type: MessageType;
160 nodeId?: string;
161 message: string;
162 onHighlight?: (state: AppState) => void;
163 onLooseHighlight?: (state: AppState) => void;
164 onClick?: (state: AppState) => void;
gio5f2f1002025-03-20 18:38:48 +0400165};
166
gio7f98e772025-05-07 11:00:14 +0000167const defaultEnv: Env = {
gioa71316d2025-05-24 09:41:36 +0400168 deployKeyPublic: undefined,
169 instanceId: undefined,
giod0026612025-05-08 13:00:36 +0000170 networks: [],
171 integrations: {
172 github: false,
gio69148322025-06-19 23:16:12 +0400173 gemini: false,
gio69ff7592025-07-03 06:27:21 +0000174 anthropic: false,
giod0026612025-05-08 13:00:36 +0000175 },
gio3a921b82025-05-10 07:36:09 +0000176 services: [],
gio3ed59592025-05-14 16:51:09 +0000177 user: {
178 id: "",
179 username: "",
180 },
giob77cb932025-05-19 09:37:14 +0000181 access: [],
gio7f98e772025-05-07 11:00:14 +0000182};
183
gio5f2f1002025-03-20 18:38:48 +0400184export type Project = {
giod0026612025-05-08 13:00:36 +0000185 id: string;
186 name: string;
187};
gio5f2f1002025-03-20 18:38:48 +0400188
gio7f98e772025-05-07 11:00:14 +0000189export type IntegrationsConfig = {
giod0026612025-05-08 13:00:36 +0000190 github: boolean;
gio7f98e772025-05-07 11:00:14 +0000191};
192
193type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
194type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
195
gio918780d2025-05-22 08:24:41 +0000196let refreshEnvIntervalId: number | null = null;
197
gio5f2f1002025-03-20 18:38:48 +0400198export type AppState = {
giod0026612025-05-08 13:00:36 +0000199 projectId: string | undefined;
gio818da4e2025-05-12 14:45:35 +0000200 mode: "edit" | "deploy";
gio678746b2025-07-06 14:45:27 +0000201 buildMode: "overview" | "canvas";
giod0026612025-05-08 13:00:36 +0000202 projects: Project[];
203 nodes: AppNode[];
204 edges: Edge[];
gio359a6852025-05-14 03:38:24 +0000205 zoom: ReactFlowViewport;
giod0026612025-05-08 13:00:36 +0000206 categories: Category[];
207 messages: Message[];
208 env: Env;
gio52441602025-07-06 18:22:56 +0000209 viewport: ViewportTransform;
210 setViewport: (viewport: ViewportTransform) => void;
giod0026612025-05-08 13:00:36 +0000211 githubService: GitHubService | null;
gioa71316d2025-05-24 09:41:36 +0400212 githubRepositories: GitHubRepository[];
213 githubRepositoriesLoading: boolean;
214 githubRepositoriesError: string | null;
gio8a5f12f2025-07-05 07:02:31 +0000215 stateEventSource: EventSource | null;
giod0026612025-05-08 13:00:36 +0000216 setHighlightCategory: (name: string, active: boolean) => void;
217 onNodesChange: OnNodesChange<AppNode>;
218 onEdgesChange: OnEdgesChange;
219 onConnect: OnConnect;
gioaf8db832025-05-13 14:43:05 +0000220 addNode: (node: Omit<AppNode, "position">) => void;
giod0026612025-05-08 13:00:36 +0000221 setNodes: (nodes: AppNode[]) => void;
222 setEdges: (edges: Edge[]) => void;
gio818da4e2025-05-12 14:45:35 +0000223 setProject: (projectId: string | undefined) => Promise<void>;
224 setMode: (mode: "edit" | "deploy") => void;
gio678746b2025-07-06 14:45:27 +0000225 setBuildMode: (buildMode: "overview" | "canvas") => void;
giod0026612025-05-08 13:00:36 +0000226 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
227 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
228 replaceEdge: (c: Connection, id?: string) => void;
229 refreshEnv: () => Promise<void>;
gioa71316d2025-05-24 09:41:36 +0400230 fetchGithubRepositories: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400231};
232
233const projectIdSelector = (state: AppState) => state.projectId;
234const categoriesSelector = (state: AppState) => state.categories;
235const messagesSelector = (state: AppState) => state.messages;
236const envSelector = (state: AppState) => state.env;
gio359a6852025-05-14 03:38:24 +0000237const zoomSelector = (state: AppState) => state.zoom;
gioa71316d2025-05-24 09:41:36 +0400238const githubRepositoriesSelector = (state: AppState) => state.githubRepositories;
239const githubRepositoriesLoadingSelector = (state: AppState) => state.githubRepositoriesLoading;
240const githubRepositoriesErrorSelector = (state: AppState) => state.githubRepositoriesError;
gio678746b2025-07-06 14:45:27 +0000241const buildModeSelector = (state: AppState) => state.buildMode;
gioaf8db832025-05-13 14:43:05 +0000242
gio359a6852025-05-14 03:38:24 +0000243export function useZoom(): ReactFlowViewport {
244 return useStateStore(zoomSelector);
gioaf8db832025-05-13 14:43:05 +0000245}
gio5f2f1002025-03-20 18:38:48 +0400246
247export function useProjectId(): string | undefined {
giod0026612025-05-08 13:00:36 +0000248 return useStateStore(projectIdSelector);
gio5f2f1002025-03-20 18:38:48 +0400249}
250
giob45b1862025-05-20 11:42:20 +0000251export function useSetProject(): (projectId: string | undefined) => void {
252 return useStateStore((state) => state.setProject);
253}
254
gio5f2f1002025-03-20 18:38:48 +0400255export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000256 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400257}
258
259export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000260 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400261}
262
263export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000264 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400265}
266
267export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000268 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400269}
270
271export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000272 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400273}
274
gio5f2f1002025-03-20 18:38:48 +0400275export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000276 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000277}
278
gio74c6f752025-07-05 04:10:58 +0000279export function useAgents(): AgentAccess[] {
giocc5ce582025-06-25 07:45:21 +0400280 return useStateStore(envSelector).access.filter(
gio74c6f752025-07-05 04:10:58 +0000281 (acc): acc is AgentAccess => acc.type === "https" && acc.agentName != null,
giocc5ce582025-06-25 07:45:21 +0400282 );
283}
284
gio74c6f752025-07-05 04:10:58 +0000285export function useLeadAgent(): AgentAccess | undefined {
286 const agents = useAgents();
287 return agents.find((a) => a.agentName === "dodo") || agents[0];
288}
289
gio69148322025-06-19 23:16:12 +0400290export function useGithubService(): boolean {
291 return useStateStore(envSelector).integrations.github;
292}
293
294export function useGeminiService(): boolean {
295 return useStateStore(envSelector).integrations.gemini;
gio5f2f1002025-03-20 18:38:48 +0400296}
297
gio69ff7592025-07-03 06:27:21 +0000298export function useAnthropicService(): boolean {
299 return useStateStore(envSelector).integrations.anthropic;
300}
301
gioa71316d2025-05-24 09:41:36 +0400302export function useGithubRepositories(): GitHubRepository[] {
303 return useStateStore(githubRepositoriesSelector);
304}
305
306export function useGithubRepositoriesLoading(): boolean {
307 return useStateStore(githubRepositoriesLoadingSelector);
308}
309
310export function useGithubRepositoriesError(): string | null {
311 return useStateStore(githubRepositoriesErrorSelector);
312}
313
314export function useFetchGithubRepositories(): () => Promise<void> {
315 return useStateStore((state) => state.fetchGithubRepositories);
316}
317
gio3ec94242025-05-16 12:46:57 +0000318export function useMode(): "edit" | "deploy" {
319 return useStateStore((state) => state.mode);
320}
321
gio678746b2025-07-06 14:45:27 +0000322export function useBuildMode(): "overview" | "canvas" {
323 return useStateStore(buildModeSelector);
324}
325
gio5f2f1002025-03-20 18:38:48 +0400326const v: Validator = CreateValidators();
327
gio52441602025-07-06 18:22:56 +0000328function getRandomPosition({ width, height, transformX, transformY, transformZoom }: ViewportTransform): XYPosition {
gioaf8db832025-05-13 14:43:05 +0000329 const zoomMultiplier = 1 / transformZoom;
330 const realWidth = width * zoomMultiplier;
331 const realHeight = height * zoomMultiplier;
332 const paddingMultiplier = 0.8;
333 const ret = {
334 x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
335 y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
336 };
337 return ret;
338}
339
gio3d0bf032025-06-05 06:57:26 +0000340export const useStateStore = create<AppState>((setOg, get): AppState => {
341 const set = (state: Partial<AppState>) => {
342 setOg(state);
343 };
giod0026612025-05-08 13:00:36 +0000344 const setN = (nodes: AppNode[]) => {
gio34193052025-07-03 03:55:11 +0000345 const env = get().env;
346 console.log("---env", env);
gio4b9b58a2025-05-12 11:46:08 +0000347 set({
giod0026612025-05-08 13:00:36 +0000348 nodes,
gio34193052025-07-03 03:55:11 +0000349 messages: v(nodes, env),
gio4b9b58a2025-05-12 11:46:08 +0000350 });
351 };
352
gio918780d2025-05-22 08:24:41 +0000353 const startRefreshEnvInterval = () => {
354 if (refreshEnvIntervalId) {
355 clearInterval(refreshEnvIntervalId);
356 }
357 if (get().projectId && typeof document !== "undefined" && document.visibilityState === "visible") {
358 console.log("Starting refreshEnv interval for project:", get().projectId);
359 refreshEnvIntervalId = setInterval(async () => {
360 if (get().projectId && typeof document !== "undefined" && document.visibilityState === "visible") {
361 console.log("Interval: Calling refreshEnv for project:", get().projectId);
362 await get().refreshEnv();
363 } else if (refreshEnvIntervalId) {
364 console.log(
365 "Interval: Conditions not met (project removed or tab hidden), stopping interval from inside.",
366 );
367 clearInterval(refreshEnvIntervalId);
368 refreshEnvIntervalId = null;
369 }
370 }, 5000) as unknown as number;
371 } else {
372 console.log(
373 "Not starting refreshEnv interval. Project ID:",
374 get().projectId,
375 "Visibility:",
376 typeof document !== "undefined" ? document.visibilityState : "SSR",
377 );
378 }
379 };
380
381 const stopRefreshEnvInterval = () => {
382 if (refreshEnvIntervalId) {
383 console.log("Stopping refreshEnv interval for project:", get().projectId);
384 clearInterval(refreshEnvIntervalId);
385 refreshEnvIntervalId = null;
386 }
387 };
388
389 if (typeof document !== "undefined") {
390 document.addEventListener("visibilitychange", () => {
391 if (document.visibilityState === "visible") {
392 console.log("Tab became visible, attempting to start refreshEnv interval.");
393 startRefreshEnvInterval();
394 } else {
395 console.log("Tab became hidden, stopping refreshEnv interval.");
396 stopRefreshEnvInterval();
397 }
398 });
399 }
400
gio48fde052025-05-14 09:48:08 +0000401 const injectNetworkNodes = () => {
402 const newNetworks = get().env.networks.filter(
403 (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
404 );
405 newNetworks.forEach((n) => {
406 get().addNode({
407 id: n.domain,
408 type: "network",
409 connectable: true,
410 data: {
411 domain: n.domain,
412 label: n.domain,
413 envVars: [],
414 ports: [],
415 state: "success", // TODO(gio): monitor network health
416 },
417 });
418 console.log("added network", n.domain);
419 });
420 };
421
giod0026612025-05-08 13:00:36 +0000422 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
423 setN(
424 get().nodes.map((n) => {
425 if (n.id === id) {
426 return {
427 ...n,
428 data: {
429 ...n.data,
430 ...data,
431 },
432 } as Extract<AppNode, { type: T }>;
433 }
434 return n;
435 }),
436 );
437 }
gio7f98e772025-05-07 11:00:14 +0000438
giod0026612025-05-08 13:00:36 +0000439 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
440 setN(
441 get().nodes.map((n) => {
442 if (n.id === id) {
443 return {
444 ...n,
445 ...node,
446 } as Extract<AppNode, { type: T }>;
447 }
448 return n;
449 }),
450 );
451 }
gio7f98e772025-05-07 11:00:14 +0000452
giod0026612025-05-08 13:00:36 +0000453 function onConnect(c: Connection) {
454 const { nodes, edges } = get();
455 set({
456 edges: addEdge(c, edges),
457 });
458 const sn = nodes.filter((n) => n.id === c.source)[0]!;
459 const tn = nodes.filter((n) => n.id === c.target)[0]!;
460 if (tn.type === "network") {
461 if (sn.type === "gateway-https") {
462 updateNodeData<"gateway-https">(sn.id, {
463 network: tn.data.domain,
464 });
465 } else if (sn.type === "gateway-tcp") {
466 updateNodeData<"gateway-tcp">(sn.id, {
467 network: tn.data.domain,
468 });
469 }
470 }
471 if (tn.type === "app") {
472 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
473 const sourceEnvVars = nodeEnvVarNames(sn);
474 if (sourceEnvVars.length === 0) {
gio69148322025-06-19 23:16:12 +0400475 throw new Error(
476 `onConnect (env_var): Source node ${sn.id} (type: ${sn.type}) has no env vars to connect from.`,
477 );
giod0026612025-05-08 13:00:36 +0000478 }
479 const id = uuidv4();
480 if (sourceEnvVars.length === 1) {
481 updateNode<"app">(c.target, {
482 ...tn,
483 data: {
484 ...tn.data,
485 envVars: [
486 ...(tn.data.envVars || []),
487 {
488 id: id,
489 source: c.source,
490 name: sourceEnvVars[0],
491 isEditting: false,
492 },
493 ],
494 },
495 });
496 } else {
497 updateNode<"app">(c.target, {
498 ...tn,
499 data: {
500 ...tn.data,
501 envVars: [
502 ...(tn.data.envVars || []),
503 {
504 id: id,
505 source: c.source,
506 },
507 ],
508 },
509 });
510 }
511 }
512 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
513 const sourcePorts = sn.data.ports || [];
514 const id = uuidv4();
515 if (sourcePorts.length === 1) {
516 updateNode<"app">(c.target, {
517 ...tn,
518 data: {
519 ...tn.data,
520 envVars: [
521 ...(tn.data.envVars || []),
522 {
523 id: id,
524 source: c.source,
525 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
526 portId: sourcePorts[0].id,
527 isEditting: false,
528 },
529 ],
530 },
531 });
532 }
533 }
gio3d0bf032025-06-05 06:57:26 +0000534 if (c.targetHandle === "repository") {
535 const sourceNode = nodes.find((n) => n.id === c.source);
536 if (sourceNode && sourceNode.type === "github" && sourceNode.data.repository) {
537 updateNodeData<"app">(tn.id, {
538 repository: {
539 id: sourceNode.data.repository.id,
540 repoNodeId: c.source,
541 },
542 });
543 }
544 }
giod0026612025-05-08 13:00:36 +0000545 }
546 if (c.sourceHandle === "volume") {
547 updateNodeData<"volume">(c.source, {
548 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
549 });
550 }
giod0026612025-05-08 13:00:36 +0000551 if (c.targetHandle === "https") {
552 if ((sn.data.ports || []).length === 1) {
553 updateNodeData<"gateway-https">(c.target, {
554 https: {
555 serviceId: c.source,
556 portId: sn.data.ports![0].id,
557 },
558 });
559 } else {
560 updateNodeData<"gateway-https">(c.target, {
561 https: {
562 serviceId: c.source,
563 portId: "", // TODO(gio)
564 },
565 });
566 }
567 }
568 if (c.targetHandle === "tcp") {
569 const td = tn.data as GatewayTCPData;
570 if ((sn.data.ports || []).length === 1) {
571 updateNodeData<"gateway-tcp">(c.target, {
572 exposed: (td.exposed || []).concat({
573 serviceId: c.source,
574 portId: sn.data.ports![0].id,
575 }),
576 });
577 } else {
578 updateNodeData<"gateway-tcp">(c.target, {
579 selected: {
580 serviceId: c.source,
581 portId: undefined,
582 },
583 });
584 }
585 }
586 if (sn.type === "app") {
587 if (c.sourceHandle === "ports") {
588 updateNodeData<"app">(sn.id, {
589 isChoosingPortToConnect: true,
590 });
591 }
592 }
giod0026612025-05-08 13:00:36 +0000593 }
gioa71316d2025-05-24 09:41:36 +0400594
595 const fetchGithubRepositories = async () => {
596 const { githubService, projectId } = get();
597 if (!githubService || !projectId) {
598 set({
599 githubRepositories: [],
600 githubRepositoriesError: "GitHub service or Project ID not available.",
601 githubRepositoriesLoading: false,
602 });
603 return;
604 }
605
606 set({ githubRepositoriesLoading: true, githubRepositoriesError: null });
607 try {
608 const repos = await githubService.getRepositories();
609 set({ githubRepositories: repos, githubRepositoriesLoading: false });
610 } catch (error) {
611 console.error("Failed to fetch GitHub repositories in store:", error);
612 const errorMessage = error instanceof Error ? error.message : "Unknown error fetching repositories";
613 set({ githubRepositories: [], githubRepositoriesError: errorMessage, githubRepositoriesLoading: false });
614 }
615 };
616
gio8a5f12f2025-07-05 07:02:31 +0000617 const disconnectFromStateStream = () => {
618 const { stateEventSource } = get();
619 if (stateEventSource) {
620 stateEventSource.close();
621 set({ stateEventSource: null });
622 }
623 };
624
625 const connectToStateStream = (projectId: string, mode: "deploy" | "edit") => {
626 disconnectFromStateStream();
627
628 const eventSource = new EventSource(
629 `/api/project/${projectId}/state/stream/${mode === "edit" ? "draft" : "deploy"}`,
630 );
631 set({ stateEventSource: eventSource });
632
633 eventSource.onmessage = (event) => {
gio52441602025-07-06 18:22:56 +0000634 const { mode, viewport } = get();
635 const inst = GraphSchema.parse(JSON.parse(event.data));
636 let positionChanged = false;
637 let nodes = inst.nodes;
638 if (mode === "edit") {
639 nodes = inst.nodes.map((n) => {
640 if (n.position.x === 0 && n.position.y === 0) {
641 positionChanged = true;
642 return {
643 ...n,
644 position: getRandomPosition(viewport),
645 };
646 } else {
647 return n;
648 }
649 });
650 }
651 setN(nodes);
gio8a5f12f2025-07-05 07:02:31 +0000652 set({ edges: inst.edges });
653 injectNetworkNodes();
gio52441602025-07-06 18:22:56 +0000654 if (positionChanged) {
655 fetch(`/api/project/${projectId}/saved`, {
656 method: "POST",
657 headers: {
658 "Content-Type": "application/json",
659 },
660 body: JSON.stringify({
661 type: "graph",
662 graph: {
663 ...inst,
664 nodes,
665 },
666 }),
667 });
668 }
gio8a5f12f2025-07-05 07:02:31 +0000669 };
670
671 eventSource.onerror = (err) => {
672 console.error("EventSource failed:", err);
673 eventSource.close();
674 set({ stateEventSource: null });
675 };
676 };
677
giod0026612025-05-08 13:00:36 +0000678 return {
679 projectId: undefined,
gio818da4e2025-05-12 14:45:35 +0000680 mode: "edit",
gio678746b2025-07-06 14:45:27 +0000681 buildMode: "overview",
giod0026612025-05-08 13:00:36 +0000682 projects: [],
683 nodes: [],
684 edges: [],
685 categories: defaultCategories,
gio34193052025-07-03 03:55:11 +0000686 messages: [],
giod0026612025-05-08 13:00:36 +0000687 env: defaultEnv,
gioaf8db832025-05-13 14:43:05 +0000688 viewport: {
689 transformX: 0,
690 transformY: 0,
691 transformZoom: 1,
692 width: 800,
693 height: 600,
694 },
gio359a6852025-05-14 03:38:24 +0000695 zoom: {
696 x: 0,
697 y: 0,
698 zoom: 1,
699 },
giod0026612025-05-08 13:00:36 +0000700 githubService: null,
gioa71316d2025-05-24 09:41:36 +0400701 githubRepositories: [],
702 githubRepositoriesLoading: false,
703 githubRepositoriesError: null,
gio8a5f12f2025-07-05 07:02:31 +0000704 stateEventSource: null,
gioaf8db832025-05-13 14:43:05 +0000705 setViewport: (viewport) => {
706 const { viewport: vp } = get();
707 if (
708 viewport.transformX !== vp.transformX ||
709 viewport.transformY !== vp.transformY ||
710 viewport.transformZoom !== vp.transformZoom ||
711 viewport.width !== vp.width ||
712 viewport.height !== vp.height
713 ) {
714 set({ viewport });
715 }
716 },
giod0026612025-05-08 13:00:36 +0000717 setHighlightCategory: (name, active) => {
718 set({
719 categories: get().categories.map((c) => {
720 if (c.title.toLowerCase() !== name.toLowerCase()) {
721 return c;
722 } else {
723 return {
724 ...c,
725 active,
726 };
727 }
728 }),
729 });
730 },
731 onNodesChange: (changes) => {
732 const nodes = applyNodeChanges(changes, get().nodes);
733 setN(nodes);
734 },
735 onEdgesChange: (changes) => {
736 set({
737 edges: applyEdgeChanges(changes, get().edges),
738 });
739 },
gioaf8db832025-05-13 14:43:05 +0000740 addNode: (node) => {
741 const { viewport, nodes } = get();
742 setN(
743 nodes.concat({
744 ...node,
745 position: getRandomPosition(viewport),
gioa1efbad2025-05-21 07:16:45 +0000746 } as AppNode),
gioaf8db832025-05-13 14:43:05 +0000747 );
748 },
giod0026612025-05-08 13:00:36 +0000749 setNodes: (nodes) => {
750 setN(nodes);
751 },
752 setEdges: (edges) => {
753 set({ edges });
754 },
755 replaceEdge: (c, id) => {
756 let change: EdgeChange;
757 if (id === undefined) {
758 change = {
759 type: "add",
760 item: {
761 id: uuidv4(),
762 ...c,
763 },
764 };
765 onConnect(c);
766 } else {
767 change = {
768 type: "replace",
769 id,
770 item: {
771 id,
772 ...c,
773 },
774 };
775 }
776 set({
777 edges: applyEdgeChanges([change], get().edges),
778 });
779 },
780 updateNode,
781 updateNodeData,
782 onConnect,
783 refreshEnv: async () => {
784 const projectId = get().projectId;
785 let env: Env = defaultEnv;
giod0026612025-05-08 13:00:36 +0000786 try {
787 if (projectId) {
788 const response = await fetch(`/api/project/${projectId}/env`);
789 if (response.ok) {
790 const data = await response.json();
791 const result = envSchema.safeParse(data);
792 if (result.success) {
793 env = result.data;
794 } else {
795 console.error("Invalid env data:", result.error);
796 }
797 }
798 }
799 } catch (error) {
800 console.error("Failed to fetch integrations:", error);
801 } finally {
gioa71316d2025-05-24 09:41:36 +0400802 const oldEnv = get().env;
803 const oldGithubIntegrationStatus = oldEnv.integrations.github;
804 if (JSON.stringify(oldEnv) !== JSON.stringify(env)) {
gio4b9b58a2025-05-12 11:46:08 +0000805 set({ env });
gio48fde052025-05-14 09:48:08 +0000806 injectNetworkNodes();
gioa71316d2025-05-24 09:41:36 +0400807 let ghService = null;
gio4b9b58a2025-05-12 11:46:08 +0000808 if (env.integrations.github) {
gioa71316d2025-05-24 09:41:36 +0400809 ghService = new GitHubServiceImpl(projectId!);
810 }
811 if (get().githubService !== ghService || (ghService && !get().githubService)) {
812 set({ githubService: ghService });
813 }
814 if (
815 ghService &&
816 (oldGithubIntegrationStatus !== env.integrations.github || !oldEnv.integrations.github)
817 ) {
818 get().fetchGithubRepositories();
819 }
820 if (!env.integrations.github) {
821 set({
822 githubRepositories: [],
823 githubRepositoriesError: null,
824 githubRepositoriesLoading: false,
825 });
gio4b9b58a2025-05-12 11:46:08 +0000826 }
giod0026612025-05-08 13:00:36 +0000827 }
828 }
829 },
gio818da4e2025-05-12 14:45:35 +0000830 setMode: (mode) => {
gio8a5f12f2025-07-05 07:02:31 +0000831 disconnectFromStateStream();
gio818da4e2025-05-12 14:45:35 +0000832 set({ mode });
gio8a5f12f2025-07-05 07:02:31 +0000833 const projectId = get().projectId;
834 if (projectId) {
835 connectToStateStream(projectId, mode);
836 }
gio818da4e2025-05-12 14:45:35 +0000837 },
gio678746b2025-07-06 14:45:27 +0000838 setBuildMode: (buildMode) => {
839 set({ buildMode });
840 },
gio818da4e2025-05-12 14:45:35 +0000841 setProject: async (projectId) => {
gio918780d2025-05-22 08:24:41 +0000842 const currentProjectId = get().projectId;
843 if (projectId === currentProjectId) {
gio359a6852025-05-14 03:38:24 +0000844 return;
845 }
gio918780d2025-05-22 08:24:41 +0000846 stopRefreshEnvInterval();
gio8a5f12f2025-07-05 07:02:31 +0000847 disconnectFromStateStream();
giod0026612025-05-08 13:00:36 +0000848 set({
849 projectId,
gioa71316d2025-05-24 09:41:36 +0400850 githubRepositories: [],
851 githubRepositoriesLoading: false,
852 githubRepositoriesError: null,
giod0026612025-05-08 13:00:36 +0000853 });
854 if (projectId) {
gio818da4e2025-05-12 14:45:35 +0000855 await get().refreshEnv();
gioa71316d2025-05-24 09:41:36 +0400856 if (get().env.instanceId) {
gio818da4e2025-05-12 14:45:35 +0000857 set({ mode: "deploy" });
858 } else {
859 set({ mode: "edit" });
860 }
gio8a5f12f2025-07-05 07:02:31 +0000861 const mode = get().mode;
862 connectToStateStream(projectId, mode);
gio918780d2025-05-22 08:24:41 +0000863 startRefreshEnvInterval();
gio4b9b58a2025-05-12 11:46:08 +0000864 } else {
865 set({
866 nodes: [],
867 edges: [],
gio918780d2025-05-22 08:24:41 +0000868 env: defaultEnv,
869 githubService: null,
gioa71316d2025-05-24 09:41:36 +0400870 githubRepositories: [],
871 githubRepositoriesLoading: false,
872 githubRepositoriesError: null,
gio4b9b58a2025-05-12 11:46:08 +0000873 });
giod0026612025-05-08 13:00:36 +0000874 }
875 },
gioa71316d2025-05-24 09:41:36 +0400876 fetchGithubRepositories: fetchGithubRepositories,
giod0026612025-05-08 13:00:36 +0000877 };
gio5f2f1002025-03-20 18:38:48 +0400878});