blob: 01327213786c56311bb09ddc5efcee9b0d720eea [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";
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;
gio8a5f12f2025-07-05 07:02:31 +0000212 stateEventSource: EventSource | null;
giod0026612025-05-08 13:00:36 +0000213 setHighlightCategory: (name: string, active: boolean) => void;
214 onNodesChange: OnNodesChange<AppNode>;
215 onEdgesChange: OnEdgesChange;
216 onConnect: OnConnect;
gioaf8db832025-05-13 14:43:05 +0000217 addNode: (node: Omit<AppNode, "position">) => void;
giod0026612025-05-08 13:00:36 +0000218 setNodes: (nodes: AppNode[]) => void;
219 setEdges: (edges: Edge[]) => void;
gio818da4e2025-05-12 14:45:35 +0000220 setProject: (projectId: string | undefined) => Promise<void>;
221 setMode: (mode: "edit" | "deploy") => void;
giod0026612025-05-08 13:00:36 +0000222 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
223 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
224 replaceEdge: (c: Connection, id?: string) => void;
225 refreshEnv: () => Promise<void>;
gioa71316d2025-05-24 09:41:36 +0400226 fetchGithubRepositories: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400227};
228
229const projectIdSelector = (state: AppState) => state.projectId;
230const categoriesSelector = (state: AppState) => state.categories;
231const messagesSelector = (state: AppState) => state.messages;
232const envSelector = (state: AppState) => state.env;
gio359a6852025-05-14 03:38:24 +0000233const zoomSelector = (state: AppState) => state.zoom;
gioa71316d2025-05-24 09:41:36 +0400234const githubRepositoriesSelector = (state: AppState) => state.githubRepositories;
235const githubRepositoriesLoadingSelector = (state: AppState) => state.githubRepositoriesLoading;
236const githubRepositoriesErrorSelector = (state: AppState) => state.githubRepositoriesError;
gioaf8db832025-05-13 14:43:05 +0000237
gio359a6852025-05-14 03:38:24 +0000238export function useZoom(): ReactFlowViewport {
239 return useStateStore(zoomSelector);
gioaf8db832025-05-13 14:43:05 +0000240}
gio5f2f1002025-03-20 18:38:48 +0400241
242export function useProjectId(): string | undefined {
giod0026612025-05-08 13:00:36 +0000243 return useStateStore(projectIdSelector);
gio5f2f1002025-03-20 18:38:48 +0400244}
245
giob45b1862025-05-20 11:42:20 +0000246export function useSetProject(): (projectId: string | undefined) => void {
247 return useStateStore((state) => state.setProject);
248}
249
gio5f2f1002025-03-20 18:38:48 +0400250export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000251 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400252}
253
254export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000255 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400256}
257
258export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000259 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400260}
261
262export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000263 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400264}
265
266export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000267 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400268}
269
gio5f2f1002025-03-20 18:38:48 +0400270export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000271 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000272}
273
gio74c6f752025-07-05 04:10:58 +0000274export function useAgents(): AgentAccess[] {
giocc5ce582025-06-25 07:45:21 +0400275 return useStateStore(envSelector).access.filter(
gio74c6f752025-07-05 04:10:58 +0000276 (acc): acc is AgentAccess => acc.type === "https" && acc.agentName != null,
giocc5ce582025-06-25 07:45:21 +0400277 );
278}
279
gio74c6f752025-07-05 04:10:58 +0000280export function useLeadAgent(): AgentAccess | undefined {
281 const agents = useAgents();
282 return agents.find((a) => a.agentName === "dodo") || agents[0];
283}
284
gio69148322025-06-19 23:16:12 +0400285export function useGithubService(): boolean {
286 return useStateStore(envSelector).integrations.github;
287}
288
289export function useGeminiService(): boolean {
290 return useStateStore(envSelector).integrations.gemini;
gio5f2f1002025-03-20 18:38:48 +0400291}
292
gio69ff7592025-07-03 06:27:21 +0000293export function useAnthropicService(): boolean {
294 return useStateStore(envSelector).integrations.anthropic;
295}
296
gioa71316d2025-05-24 09:41:36 +0400297export function useGithubRepositories(): GitHubRepository[] {
298 return useStateStore(githubRepositoriesSelector);
299}
300
301export function useGithubRepositoriesLoading(): boolean {
302 return useStateStore(githubRepositoriesLoadingSelector);
303}
304
305export function useGithubRepositoriesError(): string | null {
306 return useStateStore(githubRepositoriesErrorSelector);
307}
308
309export function useFetchGithubRepositories(): () => Promise<void> {
310 return useStateStore((state) => state.fetchGithubRepositories);
311}
312
gio3ec94242025-05-16 12:46:57 +0000313export function useMode(): "edit" | "deploy" {
314 return useStateStore((state) => state.mode);
315}
316
gio5f2f1002025-03-20 18:38:48 +0400317const v: Validator = CreateValidators();
318
gioaf8db832025-05-13 14:43:05 +0000319function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
320 const zoomMultiplier = 1 / transformZoom;
321 const realWidth = width * zoomMultiplier;
322 const realHeight = height * zoomMultiplier;
323 const paddingMultiplier = 0.8;
324 const ret = {
325 x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
326 y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
327 };
328 return ret;
329}
330
gio3d0bf032025-06-05 06:57:26 +0000331export const useStateStore = create<AppState>((setOg, get): AppState => {
332 const set = (state: Partial<AppState>) => {
333 setOg(state);
334 };
giod0026612025-05-08 13:00:36 +0000335 const setN = (nodes: AppNode[]) => {
gio34193052025-07-03 03:55:11 +0000336 const env = get().env;
337 console.log("---env", env);
gio4b9b58a2025-05-12 11:46:08 +0000338 set({
giod0026612025-05-08 13:00:36 +0000339 nodes,
gio34193052025-07-03 03:55:11 +0000340 messages: v(nodes, env),
gio4b9b58a2025-05-12 11:46:08 +0000341 });
342 };
343
gio918780d2025-05-22 08:24:41 +0000344 const startRefreshEnvInterval = () => {
345 if (refreshEnvIntervalId) {
346 clearInterval(refreshEnvIntervalId);
347 }
348 if (get().projectId && typeof document !== "undefined" && document.visibilityState === "visible") {
349 console.log("Starting refreshEnv interval for project:", get().projectId);
350 refreshEnvIntervalId = setInterval(async () => {
351 if (get().projectId && typeof document !== "undefined" && document.visibilityState === "visible") {
352 console.log("Interval: Calling refreshEnv for project:", get().projectId);
353 await get().refreshEnv();
354 } else if (refreshEnvIntervalId) {
355 console.log(
356 "Interval: Conditions not met (project removed or tab hidden), stopping interval from inside.",
357 );
358 clearInterval(refreshEnvIntervalId);
359 refreshEnvIntervalId = null;
360 }
361 }, 5000) as unknown as number;
362 } else {
363 console.log(
364 "Not starting refreshEnv interval. Project ID:",
365 get().projectId,
366 "Visibility:",
367 typeof document !== "undefined" ? document.visibilityState : "SSR",
368 );
369 }
370 };
371
372 const stopRefreshEnvInterval = () => {
373 if (refreshEnvIntervalId) {
374 console.log("Stopping refreshEnv interval for project:", get().projectId);
375 clearInterval(refreshEnvIntervalId);
376 refreshEnvIntervalId = null;
377 }
378 };
379
380 if (typeof document !== "undefined") {
381 document.addEventListener("visibilitychange", () => {
382 if (document.visibilityState === "visible") {
383 console.log("Tab became visible, attempting to start refreshEnv interval.");
384 startRefreshEnvInterval();
385 } else {
386 console.log("Tab became hidden, stopping refreshEnv interval.");
387 stopRefreshEnvInterval();
388 }
389 });
390 }
391
gio48fde052025-05-14 09:48:08 +0000392 const injectNetworkNodes = () => {
393 const newNetworks = get().env.networks.filter(
394 (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
395 );
396 newNetworks.forEach((n) => {
397 get().addNode({
398 id: n.domain,
399 type: "network",
400 connectable: true,
401 data: {
402 domain: n.domain,
403 label: n.domain,
404 envVars: [],
405 ports: [],
406 state: "success", // TODO(gio): monitor network health
407 },
408 });
409 console.log("added network", n.domain);
410 });
411 };
412
giod0026612025-05-08 13:00:36 +0000413 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
414 setN(
415 get().nodes.map((n) => {
416 if (n.id === id) {
417 return {
418 ...n,
419 data: {
420 ...n.data,
421 ...data,
422 },
423 } as Extract<AppNode, { type: T }>;
424 }
425 return n;
426 }),
427 );
428 }
gio7f98e772025-05-07 11:00:14 +0000429
giod0026612025-05-08 13:00:36 +0000430 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
431 setN(
432 get().nodes.map((n) => {
433 if (n.id === id) {
434 return {
435 ...n,
436 ...node,
437 } as Extract<AppNode, { type: T }>;
438 }
439 return n;
440 }),
441 );
442 }
gio7f98e772025-05-07 11:00:14 +0000443
giod0026612025-05-08 13:00:36 +0000444 function onConnect(c: Connection) {
445 const { nodes, edges } = get();
446 set({
447 edges: addEdge(c, edges),
448 });
449 const sn = nodes.filter((n) => n.id === c.source)[0]!;
450 const tn = nodes.filter((n) => n.id === c.target)[0]!;
451 if (tn.type === "network") {
452 if (sn.type === "gateway-https") {
453 updateNodeData<"gateway-https">(sn.id, {
454 network: tn.data.domain,
455 });
456 } else if (sn.type === "gateway-tcp") {
457 updateNodeData<"gateway-tcp">(sn.id, {
458 network: tn.data.domain,
459 });
460 }
461 }
462 if (tn.type === "app") {
463 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
464 const sourceEnvVars = nodeEnvVarNames(sn);
465 if (sourceEnvVars.length === 0) {
gio69148322025-06-19 23:16:12 +0400466 throw new Error(
467 `onConnect (env_var): Source node ${sn.id} (type: ${sn.type}) has no env vars to connect from.`,
468 );
giod0026612025-05-08 13:00:36 +0000469 }
470 const id = uuidv4();
471 if (sourceEnvVars.length === 1) {
472 updateNode<"app">(c.target, {
473 ...tn,
474 data: {
475 ...tn.data,
476 envVars: [
477 ...(tn.data.envVars || []),
478 {
479 id: id,
480 source: c.source,
481 name: sourceEnvVars[0],
482 isEditting: false,
483 },
484 ],
485 },
486 });
487 } else {
488 updateNode<"app">(c.target, {
489 ...tn,
490 data: {
491 ...tn.data,
492 envVars: [
493 ...(tn.data.envVars || []),
494 {
495 id: id,
496 source: c.source,
497 },
498 ],
499 },
500 });
501 }
502 }
503 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
504 const sourcePorts = sn.data.ports || [];
505 const id = uuidv4();
506 if (sourcePorts.length === 1) {
507 updateNode<"app">(c.target, {
508 ...tn,
509 data: {
510 ...tn.data,
511 envVars: [
512 ...(tn.data.envVars || []),
513 {
514 id: id,
515 source: c.source,
516 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
517 portId: sourcePorts[0].id,
518 isEditting: false,
519 },
520 ],
521 },
522 });
523 }
524 }
gio3d0bf032025-06-05 06:57:26 +0000525 if (c.targetHandle === "repository") {
526 const sourceNode = nodes.find((n) => n.id === c.source);
527 if (sourceNode && sourceNode.type === "github" && sourceNode.data.repository) {
528 updateNodeData<"app">(tn.id, {
529 repository: {
530 id: sourceNode.data.repository.id,
531 repoNodeId: c.source,
532 },
533 });
534 }
535 }
giod0026612025-05-08 13:00:36 +0000536 }
537 if (c.sourceHandle === "volume") {
538 updateNodeData<"volume">(c.source, {
539 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
540 });
541 }
542 if (c.targetHandle === "volume") {
543 if (tn.type === "postgresql" || tn.type === "mongodb") {
544 updateNodeData(c.target, {
545 volumeId: c.source,
546 });
547 }
548 }
549 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();
636 if (
637 get().zoom.x !== inst.viewport.x ||
638 get().zoom.y !== inst.viewport.y ||
639 get().zoom.zoom !== inst.viewport.zoom
640 ) {
641 set({ zoom: inst.viewport });
642 }
643 };
644
645 eventSource.onerror = (err) => {
646 console.error("EventSource failed:", err);
647 eventSource.close();
648 set({ stateEventSource: null });
649 };
650 };
651
giod0026612025-05-08 13:00:36 +0000652 return {
653 projectId: undefined,
gio818da4e2025-05-12 14:45:35 +0000654 mode: "edit",
giod0026612025-05-08 13:00:36 +0000655 projects: [],
656 nodes: [],
657 edges: [],
658 categories: defaultCategories,
gio34193052025-07-03 03:55:11 +0000659 messages: [],
giod0026612025-05-08 13:00:36 +0000660 env: defaultEnv,
gioaf8db832025-05-13 14:43:05 +0000661 viewport: {
662 transformX: 0,
663 transformY: 0,
664 transformZoom: 1,
665 width: 800,
666 height: 600,
667 },
gio359a6852025-05-14 03:38:24 +0000668 zoom: {
669 x: 0,
670 y: 0,
671 zoom: 1,
672 },
giod0026612025-05-08 13:00:36 +0000673 githubService: null,
gioa71316d2025-05-24 09:41:36 +0400674 githubRepositories: [],
675 githubRepositoriesLoading: false,
676 githubRepositoriesError: null,
gio8a5f12f2025-07-05 07:02:31 +0000677 stateEventSource: null,
gioaf8db832025-05-13 14:43:05 +0000678 setViewport: (viewport) => {
679 const { viewport: vp } = get();
680 if (
681 viewport.transformX !== vp.transformX ||
682 viewport.transformY !== vp.transformY ||
683 viewport.transformZoom !== vp.transformZoom ||
684 viewport.width !== vp.width ||
685 viewport.height !== vp.height
686 ) {
687 set({ viewport });
688 }
689 },
giod0026612025-05-08 13:00:36 +0000690 setHighlightCategory: (name, active) => {
691 set({
692 categories: get().categories.map((c) => {
693 if (c.title.toLowerCase() !== name.toLowerCase()) {
694 return c;
695 } else {
696 return {
697 ...c,
698 active,
699 };
700 }
701 }),
702 });
703 },
704 onNodesChange: (changes) => {
705 const nodes = applyNodeChanges(changes, get().nodes);
706 setN(nodes);
707 },
708 onEdgesChange: (changes) => {
709 set({
710 edges: applyEdgeChanges(changes, get().edges),
711 });
712 },
gioaf8db832025-05-13 14:43:05 +0000713 addNode: (node) => {
714 const { viewport, nodes } = get();
715 setN(
716 nodes.concat({
717 ...node,
718 position: getRandomPosition(viewport),
gioa1efbad2025-05-21 07:16:45 +0000719 } as AppNode),
gioaf8db832025-05-13 14:43:05 +0000720 );
721 },
giod0026612025-05-08 13:00:36 +0000722 setNodes: (nodes) => {
723 setN(nodes);
724 },
725 setEdges: (edges) => {
726 set({ edges });
727 },
728 replaceEdge: (c, id) => {
729 let change: EdgeChange;
730 if (id === undefined) {
731 change = {
732 type: "add",
733 item: {
734 id: uuidv4(),
735 ...c,
736 },
737 };
738 onConnect(c);
739 } else {
740 change = {
741 type: "replace",
742 id,
743 item: {
744 id,
745 ...c,
746 },
747 };
748 }
749 set({
750 edges: applyEdgeChanges([change], get().edges),
751 });
752 },
753 updateNode,
754 updateNodeData,
755 onConnect,
756 refreshEnv: async () => {
757 const projectId = get().projectId;
758 let env: Env = defaultEnv;
giod0026612025-05-08 13:00:36 +0000759 try {
760 if (projectId) {
761 const response = await fetch(`/api/project/${projectId}/env`);
762 if (response.ok) {
763 const data = await response.json();
764 const result = envSchema.safeParse(data);
765 if (result.success) {
766 env = result.data;
767 } else {
768 console.error("Invalid env data:", result.error);
769 }
770 }
771 }
772 } catch (error) {
773 console.error("Failed to fetch integrations:", error);
774 } finally {
gioa71316d2025-05-24 09:41:36 +0400775 const oldEnv = get().env;
776 const oldGithubIntegrationStatus = oldEnv.integrations.github;
777 if (JSON.stringify(oldEnv) !== JSON.stringify(env)) {
gio4b9b58a2025-05-12 11:46:08 +0000778 set({ env });
gio48fde052025-05-14 09:48:08 +0000779 injectNetworkNodes();
gioa71316d2025-05-24 09:41:36 +0400780 let ghService = null;
gio4b9b58a2025-05-12 11:46:08 +0000781 if (env.integrations.github) {
gioa71316d2025-05-24 09:41:36 +0400782 ghService = new GitHubServiceImpl(projectId!);
783 }
784 if (get().githubService !== ghService || (ghService && !get().githubService)) {
785 set({ githubService: ghService });
786 }
787 if (
788 ghService &&
789 (oldGithubIntegrationStatus !== env.integrations.github || !oldEnv.integrations.github)
790 ) {
791 get().fetchGithubRepositories();
792 }
793 if (!env.integrations.github) {
794 set({
795 githubRepositories: [],
796 githubRepositoriesError: null,
797 githubRepositoriesLoading: false,
798 });
gio4b9b58a2025-05-12 11:46:08 +0000799 }
giod0026612025-05-08 13:00:36 +0000800 }
801 }
802 },
gio818da4e2025-05-12 14:45:35 +0000803 setMode: (mode) => {
gio8a5f12f2025-07-05 07:02:31 +0000804 disconnectFromStateStream();
gio818da4e2025-05-12 14:45:35 +0000805 set({ mode });
gio8a5f12f2025-07-05 07:02:31 +0000806 const projectId = get().projectId;
807 if (projectId) {
808 connectToStateStream(projectId, mode);
809 }
gio818da4e2025-05-12 14:45:35 +0000810 },
811 setProject: async (projectId) => {
gio918780d2025-05-22 08:24:41 +0000812 const currentProjectId = get().projectId;
813 if (projectId === currentProjectId) {
gio359a6852025-05-14 03:38:24 +0000814 return;
815 }
gio918780d2025-05-22 08:24:41 +0000816 stopRefreshEnvInterval();
gio8a5f12f2025-07-05 07:02:31 +0000817 disconnectFromStateStream();
giod0026612025-05-08 13:00:36 +0000818 set({
819 projectId,
gioa71316d2025-05-24 09:41:36 +0400820 githubRepositories: [],
821 githubRepositoriesLoading: false,
822 githubRepositoriesError: null,
giod0026612025-05-08 13:00:36 +0000823 });
824 if (projectId) {
gio818da4e2025-05-12 14:45:35 +0000825 await get().refreshEnv();
gioa71316d2025-05-24 09:41:36 +0400826 if (get().env.instanceId) {
gio818da4e2025-05-12 14:45:35 +0000827 set({ mode: "deploy" });
828 } else {
829 set({ mode: "edit" });
830 }
gio8a5f12f2025-07-05 07:02:31 +0000831 const mode = get().mode;
832 connectToStateStream(projectId, mode);
gio918780d2025-05-22 08:24:41 +0000833 startRefreshEnvInterval();
gio4b9b58a2025-05-12 11:46:08 +0000834 } else {
835 set({
836 nodes: [],
837 edges: [],
gio918780d2025-05-22 08:24:41 +0000838 env: defaultEnv,
839 githubService: null,
gioa71316d2025-05-24 09:41:36 +0400840 githubRepositories: [],
841 githubRepositoriesLoading: false,
842 githubRepositoriesError: null,
gio4b9b58a2025-05-12 11:46:08 +0000843 });
giod0026612025-05-08 13:00:36 +0000844 }
845 },
gioa71316d2025-05-24 09:41:36 +0400846 fetchGithubRepositories: fetchGithubRepositories,
giod0026612025-05-08 13:00:36 +0000847 };
gio5f2f1002025-03-20 18:38:48 +0400848});