blob: b0237a9c9f1f0ef728945dbf1306b6d63a38cfdf [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";
gio74c6f752025-07-05 04:10:58 +000017import { AppNode, Env, NodeType, VolumeNode, GatewayTCPData, envSchema, AgentAccess } 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";
gio678746b2025-07-06 14:45:27 +0000199 buildMode: "overview" | "canvas";
giod0026612025-05-08 13:00:36 +0000200 projects: Project[];
201 nodes: AppNode[];
202 edges: Edge[];
gio359a6852025-05-14 03:38:24 +0000203 zoom: ReactFlowViewport;
giod0026612025-05-08 13:00:36 +0000204 categories: Category[];
205 messages: Message[];
206 env: Env;
gioaf8db832025-05-13 14:43:05 +0000207 viewport: Viewport;
208 setViewport: (viewport: Viewport) => void;
giod0026612025-05-08 13:00:36 +0000209 githubService: GitHubService | null;
gioa71316d2025-05-24 09:41:36 +0400210 githubRepositories: GitHubRepository[];
211 githubRepositoriesLoading: boolean;
212 githubRepositoriesError: string | null;
gio8a5f12f2025-07-05 07:02:31 +0000213 stateEventSource: EventSource | null;
giod0026612025-05-08 13:00:36 +0000214 setHighlightCategory: (name: string, active: boolean) => void;
215 onNodesChange: OnNodesChange<AppNode>;
216 onEdgesChange: OnEdgesChange;
217 onConnect: OnConnect;
gioaf8db832025-05-13 14:43:05 +0000218 addNode: (node: Omit<AppNode, "position">) => void;
giod0026612025-05-08 13:00:36 +0000219 setNodes: (nodes: AppNode[]) => void;
220 setEdges: (edges: Edge[]) => void;
gio818da4e2025-05-12 14:45:35 +0000221 setProject: (projectId: string | undefined) => Promise<void>;
222 setMode: (mode: "edit" | "deploy") => void;
gio678746b2025-07-06 14:45:27 +0000223 setBuildMode: (buildMode: "overview" | "canvas") => void;
giod0026612025-05-08 13:00:36 +0000224 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
225 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
226 replaceEdge: (c: Connection, id?: string) => void;
227 refreshEnv: () => Promise<void>;
gioa71316d2025-05-24 09:41:36 +0400228 fetchGithubRepositories: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400229};
230
231const projectIdSelector = (state: AppState) => state.projectId;
232const categoriesSelector = (state: AppState) => state.categories;
233const messagesSelector = (state: AppState) => state.messages;
234const envSelector = (state: AppState) => state.env;
gio359a6852025-05-14 03:38:24 +0000235const zoomSelector = (state: AppState) => state.zoom;
gioa71316d2025-05-24 09:41:36 +0400236const githubRepositoriesSelector = (state: AppState) => state.githubRepositories;
237const githubRepositoriesLoadingSelector = (state: AppState) => state.githubRepositoriesLoading;
238const githubRepositoriesErrorSelector = (state: AppState) => state.githubRepositoriesError;
gio678746b2025-07-06 14:45:27 +0000239const buildModeSelector = (state: AppState) => state.buildMode;
gioaf8db832025-05-13 14:43:05 +0000240
gio359a6852025-05-14 03:38:24 +0000241export function useZoom(): ReactFlowViewport {
242 return useStateStore(zoomSelector);
gioaf8db832025-05-13 14:43:05 +0000243}
gio5f2f1002025-03-20 18:38:48 +0400244
245export function useProjectId(): string | undefined {
giod0026612025-05-08 13:00:36 +0000246 return useStateStore(projectIdSelector);
gio5f2f1002025-03-20 18:38:48 +0400247}
248
giob45b1862025-05-20 11:42:20 +0000249export function useSetProject(): (projectId: string | undefined) => void {
250 return useStateStore((state) => state.setProject);
251}
252
gio5f2f1002025-03-20 18:38:48 +0400253export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000254 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400255}
256
257export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000258 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400259}
260
261export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000262 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400263}
264
265export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000266 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400267}
268
269export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000270 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400271}
272
gio5f2f1002025-03-20 18:38:48 +0400273export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000274 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000275}
276
gio74c6f752025-07-05 04:10:58 +0000277export function useAgents(): AgentAccess[] {
giocc5ce582025-06-25 07:45:21 +0400278 return useStateStore(envSelector).access.filter(
gio74c6f752025-07-05 04:10:58 +0000279 (acc): acc is AgentAccess => acc.type === "https" && acc.agentName != null,
giocc5ce582025-06-25 07:45:21 +0400280 );
281}
282
gio74c6f752025-07-05 04:10:58 +0000283export function useLeadAgent(): AgentAccess | undefined {
284 const agents = useAgents();
285 return agents.find((a) => a.agentName === "dodo") || agents[0];
286}
287
gio69148322025-06-19 23:16:12 +0400288export function useGithubService(): boolean {
289 return useStateStore(envSelector).integrations.github;
290}
291
292export function useGeminiService(): boolean {
293 return useStateStore(envSelector).integrations.gemini;
gio5f2f1002025-03-20 18:38:48 +0400294}
295
gio69ff7592025-07-03 06:27:21 +0000296export function useAnthropicService(): boolean {
297 return useStateStore(envSelector).integrations.anthropic;
298}
299
gioa71316d2025-05-24 09:41:36 +0400300export function useGithubRepositories(): GitHubRepository[] {
301 return useStateStore(githubRepositoriesSelector);
302}
303
304export function useGithubRepositoriesLoading(): boolean {
305 return useStateStore(githubRepositoriesLoadingSelector);
306}
307
308export function useGithubRepositoriesError(): string | null {
309 return useStateStore(githubRepositoriesErrorSelector);
310}
311
312export function useFetchGithubRepositories(): () => Promise<void> {
313 return useStateStore((state) => state.fetchGithubRepositories);
314}
315
gio3ec94242025-05-16 12:46:57 +0000316export function useMode(): "edit" | "deploy" {
317 return useStateStore((state) => state.mode);
318}
319
gio678746b2025-07-06 14:45:27 +0000320export function useBuildMode(): "overview" | "canvas" {
321 return useStateStore(buildModeSelector);
322}
323
gio5f2f1002025-03-20 18:38:48 +0400324const v: Validator = CreateValidators();
325
gioaf8db832025-05-13 14:43:05 +0000326function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
327 const zoomMultiplier = 1 / transformZoom;
328 const realWidth = width * zoomMultiplier;
329 const realHeight = height * zoomMultiplier;
330 const paddingMultiplier = 0.8;
331 const ret = {
332 x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
333 y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
334 };
335 return ret;
336}
337
gio3d0bf032025-06-05 06:57:26 +0000338export const useStateStore = create<AppState>((setOg, get): AppState => {
339 const set = (state: Partial<AppState>) => {
340 setOg(state);
341 };
giod0026612025-05-08 13:00:36 +0000342 const setN = (nodes: AppNode[]) => {
gio34193052025-07-03 03:55:11 +0000343 const env = get().env;
344 console.log("---env", env);
gio4b9b58a2025-05-12 11:46:08 +0000345 set({
giod0026612025-05-08 13:00:36 +0000346 nodes,
gio34193052025-07-03 03:55:11 +0000347 messages: v(nodes, env),
gio4b9b58a2025-05-12 11:46:08 +0000348 });
349 };
350
gio918780d2025-05-22 08:24:41 +0000351 const startRefreshEnvInterval = () => {
352 if (refreshEnvIntervalId) {
353 clearInterval(refreshEnvIntervalId);
354 }
355 if (get().projectId && typeof document !== "undefined" && document.visibilityState === "visible") {
356 console.log("Starting refreshEnv interval for project:", get().projectId);
357 refreshEnvIntervalId = setInterval(async () => {
358 if (get().projectId && typeof document !== "undefined" && document.visibilityState === "visible") {
359 console.log("Interval: Calling refreshEnv for project:", get().projectId);
360 await get().refreshEnv();
361 } else if (refreshEnvIntervalId) {
362 console.log(
363 "Interval: Conditions not met (project removed or tab hidden), stopping interval from inside.",
364 );
365 clearInterval(refreshEnvIntervalId);
366 refreshEnvIntervalId = null;
367 }
368 }, 5000) as unknown as number;
369 } else {
370 console.log(
371 "Not starting refreshEnv interval. Project ID:",
372 get().projectId,
373 "Visibility:",
374 typeof document !== "undefined" ? document.visibilityState : "SSR",
375 );
376 }
377 };
378
379 const stopRefreshEnvInterval = () => {
380 if (refreshEnvIntervalId) {
381 console.log("Stopping refreshEnv interval for project:", get().projectId);
382 clearInterval(refreshEnvIntervalId);
383 refreshEnvIntervalId = null;
384 }
385 };
386
387 if (typeof document !== "undefined") {
388 document.addEventListener("visibilitychange", () => {
389 if (document.visibilityState === "visible") {
390 console.log("Tab became visible, attempting to start refreshEnv interval.");
391 startRefreshEnvInterval();
392 } else {
393 console.log("Tab became hidden, stopping refreshEnv interval.");
394 stopRefreshEnvInterval();
395 }
396 });
397 }
398
gio48fde052025-05-14 09:48:08 +0000399 const injectNetworkNodes = () => {
400 const newNetworks = get().env.networks.filter(
401 (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
402 );
403 newNetworks.forEach((n) => {
404 get().addNode({
405 id: n.domain,
406 type: "network",
407 connectable: true,
408 data: {
409 domain: n.domain,
410 label: n.domain,
411 envVars: [],
412 ports: [],
413 state: "success", // TODO(gio): monitor network health
414 },
415 });
416 console.log("added network", n.domain);
417 });
418 };
419
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 }
giod0026612025-05-08 13:00:36 +0000549 if (c.targetHandle === "https") {
550 if ((sn.data.ports || []).length === 1) {
551 updateNodeData<"gateway-https">(c.target, {
552 https: {
553 serviceId: c.source,
554 portId: sn.data.ports![0].id,
555 },
556 });
557 } else {
558 updateNodeData<"gateway-https">(c.target, {
559 https: {
560 serviceId: c.source,
561 portId: "", // TODO(gio)
562 },
563 });
564 }
565 }
566 if (c.targetHandle === "tcp") {
567 const td = tn.data as GatewayTCPData;
568 if ((sn.data.ports || []).length === 1) {
569 updateNodeData<"gateway-tcp">(c.target, {
570 exposed: (td.exposed || []).concat({
571 serviceId: c.source,
572 portId: sn.data.ports![0].id,
573 }),
574 });
575 } else {
576 updateNodeData<"gateway-tcp">(c.target, {
577 selected: {
578 serviceId: c.source,
579 portId: undefined,
580 },
581 });
582 }
583 }
584 if (sn.type === "app") {
585 if (c.sourceHandle === "ports") {
586 updateNodeData<"app">(sn.id, {
587 isChoosingPortToConnect: true,
588 });
589 }
590 }
giod0026612025-05-08 13:00:36 +0000591 }
gioa71316d2025-05-24 09:41:36 +0400592
593 const fetchGithubRepositories = async () => {
594 const { githubService, projectId } = get();
595 if (!githubService || !projectId) {
596 set({
597 githubRepositories: [],
598 githubRepositoriesError: "GitHub service or Project ID not available.",
599 githubRepositoriesLoading: false,
600 });
601 return;
602 }
603
604 set({ githubRepositoriesLoading: true, githubRepositoriesError: null });
605 try {
606 const repos = await githubService.getRepositories();
607 set({ githubRepositories: repos, githubRepositoriesLoading: false });
608 } catch (error) {
609 console.error("Failed to fetch GitHub repositories in store:", error);
610 const errorMessage = error instanceof Error ? error.message : "Unknown error fetching repositories";
611 set({ githubRepositories: [], githubRepositoriesError: errorMessage, githubRepositoriesLoading: false });
612 }
613 };
614
gio8a5f12f2025-07-05 07:02:31 +0000615 const disconnectFromStateStream = () => {
616 const { stateEventSource } = get();
617 if (stateEventSource) {
618 stateEventSource.close();
619 set({ stateEventSource: null });
620 }
621 };
622
623 const connectToStateStream = (projectId: string, mode: "deploy" | "edit") => {
624 disconnectFromStateStream();
625
626 const eventSource = new EventSource(
627 `/api/project/${projectId}/state/stream/${mode === "edit" ? "draft" : "deploy"}`,
628 );
629 set({ stateEventSource: eventSource });
630
631 eventSource.onmessage = (event) => {
632 const inst = JSON.parse(event.data);
633 setN(inst.nodes);
634 set({ edges: inst.edges });
635 injectNetworkNodes();
gio678746b2025-07-06 14:45:27 +0000636 // TODO(gio): set viewport
gio8a5f12f2025-07-05 07:02:31 +0000637 };
638
639 eventSource.onerror = (err) => {
640 console.error("EventSource failed:", err);
641 eventSource.close();
642 set({ stateEventSource: null });
643 };
644 };
645
giod0026612025-05-08 13:00:36 +0000646 return {
647 projectId: undefined,
gio818da4e2025-05-12 14:45:35 +0000648 mode: "edit",
gio678746b2025-07-06 14:45:27 +0000649 buildMode: "overview",
giod0026612025-05-08 13:00:36 +0000650 projects: [],
651 nodes: [],
652 edges: [],
653 categories: defaultCategories,
gio34193052025-07-03 03:55:11 +0000654 messages: [],
giod0026612025-05-08 13:00:36 +0000655 env: defaultEnv,
gioaf8db832025-05-13 14:43:05 +0000656 viewport: {
657 transformX: 0,
658 transformY: 0,
659 transformZoom: 1,
660 width: 800,
661 height: 600,
662 },
gio359a6852025-05-14 03:38:24 +0000663 zoom: {
664 x: 0,
665 y: 0,
666 zoom: 1,
667 },
giod0026612025-05-08 13:00:36 +0000668 githubService: null,
gioa71316d2025-05-24 09:41:36 +0400669 githubRepositories: [],
670 githubRepositoriesLoading: false,
671 githubRepositoriesError: null,
gio8a5f12f2025-07-05 07:02:31 +0000672 stateEventSource: 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),
gioa1efbad2025-05-21 07:16:45 +0000714 } as AppNode),
gioaf8db832025-05-13 14:43:05 +0000715 );
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;
giod0026612025-05-08 13:00:36 +0000754 try {
755 if (projectId) {
756 const response = await fetch(`/api/project/${projectId}/env`);
757 if (response.ok) {
758 const data = await response.json();
759 const result = envSchema.safeParse(data);
760 if (result.success) {
761 env = result.data;
762 } else {
763 console.error("Invalid env data:", result.error);
764 }
765 }
766 }
767 } catch (error) {
768 console.error("Failed to fetch integrations:", error);
769 } finally {
gioa71316d2025-05-24 09:41:36 +0400770 const oldEnv = get().env;
771 const oldGithubIntegrationStatus = oldEnv.integrations.github;
772 if (JSON.stringify(oldEnv) !== JSON.stringify(env)) {
gio4b9b58a2025-05-12 11:46:08 +0000773 set({ env });
gio48fde052025-05-14 09:48:08 +0000774 injectNetworkNodes();
gioa71316d2025-05-24 09:41:36 +0400775 let ghService = null;
gio4b9b58a2025-05-12 11:46:08 +0000776 if (env.integrations.github) {
gioa71316d2025-05-24 09:41:36 +0400777 ghService = new GitHubServiceImpl(projectId!);
778 }
779 if (get().githubService !== ghService || (ghService && !get().githubService)) {
780 set({ githubService: ghService });
781 }
782 if (
783 ghService &&
784 (oldGithubIntegrationStatus !== env.integrations.github || !oldEnv.integrations.github)
785 ) {
786 get().fetchGithubRepositories();
787 }
788 if (!env.integrations.github) {
789 set({
790 githubRepositories: [],
791 githubRepositoriesError: null,
792 githubRepositoriesLoading: false,
793 });
gio4b9b58a2025-05-12 11:46:08 +0000794 }
giod0026612025-05-08 13:00:36 +0000795 }
796 }
797 },
gio818da4e2025-05-12 14:45:35 +0000798 setMode: (mode) => {
gio8a5f12f2025-07-05 07:02:31 +0000799 disconnectFromStateStream();
gio818da4e2025-05-12 14:45:35 +0000800 set({ mode });
gio8a5f12f2025-07-05 07:02:31 +0000801 const projectId = get().projectId;
802 if (projectId) {
803 connectToStateStream(projectId, mode);
804 }
gio818da4e2025-05-12 14:45:35 +0000805 },
gio678746b2025-07-06 14:45:27 +0000806 setBuildMode: (buildMode) => {
807 set({ buildMode });
808 },
gio818da4e2025-05-12 14:45:35 +0000809 setProject: async (projectId) => {
gio918780d2025-05-22 08:24:41 +0000810 const currentProjectId = get().projectId;
811 if (projectId === currentProjectId) {
gio359a6852025-05-14 03:38:24 +0000812 return;
813 }
gio918780d2025-05-22 08:24:41 +0000814 stopRefreshEnvInterval();
gio8a5f12f2025-07-05 07:02:31 +0000815 disconnectFromStateStream();
giod0026612025-05-08 13:00:36 +0000816 set({
817 projectId,
gioa71316d2025-05-24 09:41:36 +0400818 githubRepositories: [],
819 githubRepositoriesLoading: false,
820 githubRepositoriesError: null,
giod0026612025-05-08 13:00:36 +0000821 });
822 if (projectId) {
gio818da4e2025-05-12 14:45:35 +0000823 await get().refreshEnv();
gioa71316d2025-05-24 09:41:36 +0400824 if (get().env.instanceId) {
gio818da4e2025-05-12 14:45:35 +0000825 set({ mode: "deploy" });
826 } else {
827 set({ mode: "edit" });
828 }
gio8a5f12f2025-07-05 07:02:31 +0000829 const mode = get().mode;
830 connectToStateStream(projectId, mode);
gio918780d2025-05-22 08:24:41 +0000831 startRefreshEnvInterval();
gio4b9b58a2025-05-12 11:46:08 +0000832 } else {
833 set({
834 nodes: [],
835 edges: [],
gio918780d2025-05-22 08:24:41 +0000836 env: defaultEnv,
837 githubService: null,
gioa71316d2025-05-24 09:41:36 +0400838 githubRepositories: [],
839 githubRepositoriesLoading: false,
840 githubRepositoriesError: null,
gio4b9b58a2025-05-12 11:46:08 +0000841 });
giod0026612025-05-08 13:00:36 +0000842 }
843 },
gioa71316d2025-05-24 09:41:36 +0400844 fetchGithubRepositories: fetchGithubRepositories,
giod0026612025-05-08 13:00:36 +0000845 };
gio5f2f1002025-03-20 18:38:48 +0400846});