blob: c86199f0faa2f8b5e961e8563b81710ee31adb84 [file] [log] [blame]
gio5f2f1002025-03-20 18:38:48 +04001import { Category, defaultCategories } from "./categories";
2import { CreateValidators, Validator } from "./config";
giod0026612025-05-08 13:00:36 +00003import { GitHubService, GitHubServiceImpl } from "./github";
gio359a6852025-05-14 03:38:24 +00004import type { Edge, Node, 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";
gio5f2f1002025-03-20 18:38:48 +040016import { z } from "zod";
giod0026612025-05-08 13:00:36 +000017import { create } from "zustand";
gio5f2f1002025-03-20 18:38:48 +040018
19export type InitData = {
giod0026612025-05-08 13:00:36 +000020 label: string;
21 envVars: BoundEnvVar[];
22 ports: Port[];
gio5f2f1002025-03-20 18:38:48 +040023};
24
25export type NodeData = InitData & {
giod0026612025-05-08 13:00:36 +000026 activeField?: string | undefined;
gio818da4e2025-05-12 14:45:35 +000027 state?: string | null;
gio5f2f1002025-03-20 18:38:48 +040028};
29
30export type PortConnectedTo = {
giod0026612025-05-08 13:00:36 +000031 serviceId: string;
32 portId: string;
33};
gio5f2f1002025-03-20 18:38:48 +040034
gioaba9a962025-04-25 14:19:40 +000035export type NetworkData = NodeData & {
giod0026612025-05-08 13:00:36 +000036 domain: string;
gioaba9a962025-04-25 14:19:40 +000037};
38
39export type NetworkNode = Node<NetworkData> & {
giod0026612025-05-08 13:00:36 +000040 type: "network";
gioaba9a962025-04-25 14:19:40 +000041};
42
gio5f2f1002025-03-20 18:38:48 +040043export type GatewayHttpsData = NodeData & {
gio48fde052025-05-14 09:48:08 +000044 readonly?: boolean;
giod0026612025-05-08 13:00:36 +000045 network?: string;
46 subdomain?: string;
47 https?: PortConnectedTo;
48 auth?: {
49 enabled: boolean;
50 groups: string[];
51 noAuthPathPatterns: string[];
52 };
gio5f2f1002025-03-20 18:38:48 +040053};
54
55export type GatewayHttpsNode = Node<GatewayHttpsData> & {
giod0026612025-05-08 13:00:36 +000056 type: "gateway-https";
gio5f2f1002025-03-20 18:38:48 +040057};
58
59export type GatewayTCPData = NodeData & {
gio48fde052025-05-14 09:48:08 +000060 readonly?: boolean;
giod0026612025-05-08 13:00:36 +000061 network?: string;
62 subdomain?: string;
63 exposed: PortConnectedTo[];
64 selected?: {
65 serviceId?: string;
66 portId?: string;
67 };
gio5f2f1002025-03-20 18:38:48 +040068};
69
70export type GatewayTCPNode = Node<GatewayTCPData> & {
giod0026612025-05-08 13:00:36 +000071 type: "gateway-tcp";
gio5f2f1002025-03-20 18:38:48 +040072};
73
74export type Port = {
giod0026612025-05-08 13:00:36 +000075 id: string;
76 name: string;
77 value: number;
gio5f2f1002025-03-20 18:38:48 +040078};
79
gio91165612025-05-03 17:07:38 +000080export const ServiceTypes = [
giod0026612025-05-08 13:00:36 +000081 "deno:2.2.0",
82 "golang:1.20.0",
83 "golang:1.22.0",
84 "golang:1.24.0",
85 "hugo:latest",
86 "php:8.2-apache",
87 "nextjs:deno-2.0.0",
gio33046722025-05-16 14:49:55 +000088 "nodejs:23.1.0",
giobceb0852025-05-20 13:15:18 +040089 "nodejs:24.0.2",
gio91165612025-05-03 17:07:38 +000090] as const;
giod0026612025-05-08 13:00:36 +000091export type ServiceType = (typeof ServiceTypes)[number];
gio5f2f1002025-03-20 18:38:48 +040092
gio48fde052025-05-14 09:48:08 +000093export type Domain = {
94 network: string;
95 subdomain: string;
96};
97
gio5f2f1002025-03-20 18:38:48 +040098export type ServiceData = NodeData & {
giod0026612025-05-08 13:00:36 +000099 type: ServiceType;
100 repository:
101 | {
102 id: string;
103 }
104 | {
105 id: string;
106 branch: string;
107 }
108 | {
109 id: string;
110 branch: string;
111 rootDir: string;
112 };
113 env: string[];
114 volume: string[];
115 preBuildCommands: string;
116 isChoosingPortToConnect: boolean;
gio48fde052025-05-14 09:48:08 +0000117 dev?:
118 | {
119 enabled: false;
120 expose?: Domain;
121 }
122 | {
123 enabled: true;
124 expose?: Domain;
125 codeServerNodeId: string;
126 sshNodeId: string;
127 };
gio5f2f1002025-03-20 18:38:48 +0400128};
129
130export type ServiceNode = Node<ServiceData> & {
giod0026612025-05-08 13:00:36 +0000131 type: "app";
gio5f2f1002025-03-20 18:38:48 +0400132};
133
134export type VolumeType = "ReadWriteOnce" | "ReadOnlyMany" | "ReadWriteMany" | "ReadWriteOncePod";
135
136export type VolumeData = NodeData & {
giod0026612025-05-08 13:00:36 +0000137 type: VolumeType;
138 size: string;
139 attachedTo: string[];
gio5f2f1002025-03-20 18:38:48 +0400140};
141
142export type VolumeNode = Node<VolumeData> & {
giod0026612025-05-08 13:00:36 +0000143 type: "volume";
gio5f2f1002025-03-20 18:38:48 +0400144};
145
146export type PostgreSQLData = NodeData & {
giod0026612025-05-08 13:00:36 +0000147 volumeId: string;
gio5f2f1002025-03-20 18:38:48 +0400148};
149
150export type PostgreSQLNode = Node<PostgreSQLData> & {
giod0026612025-05-08 13:00:36 +0000151 type: "postgresql";
gio5f2f1002025-03-20 18:38:48 +0400152};
153
154export type MongoDBData = NodeData & {
giod0026612025-05-08 13:00:36 +0000155 volumeId: string;
gio5f2f1002025-03-20 18:38:48 +0400156};
157
158export type MongoDBNode = Node<MongoDBData> & {
giod0026612025-05-08 13:00:36 +0000159 type: "mongodb";
gio5f2f1002025-03-20 18:38:48 +0400160};
161
162export type GithubData = NodeData & {
giod0026612025-05-08 13:00:36 +0000163 repository?: {
164 id: number;
165 sshURL: string;
gio818da4e2025-05-12 14:45:35 +0000166 fullName: string;
giod0026612025-05-08 13:00:36 +0000167 };
gio5f2f1002025-03-20 18:38:48 +0400168};
169
170export type GithubNode = Node<GithubData> & {
giod0026612025-05-08 13:00:36 +0000171 type: "github";
gio5f2f1002025-03-20 18:38:48 +0400172};
173
174export type NANode = Node<NodeData> & {
giod0026612025-05-08 13:00:36 +0000175 type: undefined;
gio5f2f1002025-03-20 18:38:48 +0400176};
177
giod0026612025-05-08 13:00:36 +0000178export type AppNode =
179 | NetworkNode
180 | GatewayHttpsNode
181 | GatewayTCPNode
182 | ServiceNode
183 | VolumeNode
184 | PostgreSQLNode
185 | MongoDBNode
186 | GithubNode
187 | NANode;
gio5f2f1002025-03-20 18:38:48 +0400188
189export function nodeLabel(n: AppNode): string {
gio48fde052025-05-14 09:48:08 +0000190 try {
191 switch (n.type) {
192 case "network":
193 return n.data.domain;
194 case "app":
195 return n.data.label || "Service";
196 case "github":
197 return n.data.repository?.fullName || "Github";
198 case "gateway-https": {
gio29050d62025-05-16 04:49:26 +0000199 if (n.data && n.data.subdomain) {
200 return `${n.data.subdomain}`;
gio48fde052025-05-14 09:48:08 +0000201 } else {
202 return "HTTPS Gateway";
203 }
giod0026612025-05-08 13:00:36 +0000204 }
gio48fde052025-05-14 09:48:08 +0000205 case "gateway-tcp": {
gio29050d62025-05-16 04:49:26 +0000206 if (n.data && n.data.subdomain) {
207 return `${n.data.subdomain}`;
gio48fde052025-05-14 09:48:08 +0000208 } else {
209 return "TCP Gateway";
210 }
giod0026612025-05-08 13:00:36 +0000211 }
gio48fde052025-05-14 09:48:08 +0000212 case "mongodb":
213 return n.data.label || "MongoDB";
214 case "postgresql":
215 return n.data.label || "PostgreSQL";
216 case "volume":
217 return n.data.label || "Volume";
218 case undefined:
219 throw new Error("MUST NOT REACH!");
giod0026612025-05-08 13:00:36 +0000220 }
gio48fde052025-05-14 09:48:08 +0000221 } catch (e) {
222 console.error("opaa", e);
223 } finally {
224 console.log("done");
giod0026612025-05-08 13:00:36 +0000225 }
gioa1efbad2025-05-21 07:16:45 +0000226 return "Unknown Node";
gio5f2f1002025-03-20 18:38:48 +0400227}
228
229export function nodeIsConnectable(n: AppNode, handle: string): boolean {
giod0026612025-05-08 13:00:36 +0000230 switch (n.type) {
231 case "network":
232 return true;
233 case "app":
234 if (handle === "ports") {
235 return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
236 } else if (handle === "repository") {
237 if (!n.data || !n.data.repository || !n.data.repository.id) {
238 return true;
239 }
240 return false;
241 }
242 return false;
243 case "github":
244 if (n.data.repository?.id !== undefined) {
245 return true;
246 }
247 return false;
248 case "gateway-https":
249 if (handle === "subdomain") {
250 return n.data.network === undefined;
251 }
252 return n.data === undefined || n.data.https === undefined;
253 case "gateway-tcp":
254 if (handle === "subdomain") {
255 return n.data.network === undefined;
256 }
257 return true;
258 case "mongodb":
259 return true;
260 case "postgresql":
261 return true;
262 case "volume":
263 if (n.data === undefined || n.data.type === undefined) {
264 return false;
265 }
266 if (n.data.type === "ReadWriteOnce" || n.data.type === "ReadWriteOncePod") {
267 return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
268 }
269 return true;
270 case undefined:
271 throw new Error("MUST NOT REACH!");
272 }
gio5f2f1002025-03-20 18:38:48 +0400273}
274
giod0026612025-05-08 13:00:36 +0000275export type BoundEnvVar =
276 | {
277 id: string;
278 source: string | null;
279 }
280 | {
281 id: string;
282 source: string | null;
283 name: string;
284 isEditting: boolean;
285 }
286 | {
287 id: string;
288 source: string | null;
289 name: string;
290 alias: string;
291 isEditting: boolean;
292 }
293 | {
294 id: string;
295 source: string | null;
296 portId: string;
297 name: string;
298 alias: string;
299 isEditting: boolean;
300 };
gio5f2f1002025-03-20 18:38:48 +0400301
302export type EnvVar = {
giod0026612025-05-08 13:00:36 +0000303 name: string;
304 value: string;
gio5f2f1002025-03-20 18:38:48 +0400305};
306
giob41ecae2025-04-24 08:46:50 +0000307export function nodeEnvVarNamePort(n: AppNode, portName: string): string {
giod0026612025-05-08 13:00:36 +0000308 return `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${portName.toUpperCase()}`;
giob41ecae2025-04-24 08:46:50 +0000309}
310
gio5f2f1002025-03-20 18:38:48 +0400311export function nodeEnvVarNames(n: AppNode): string[] {
giod0026612025-05-08 13:00:36 +0000312 switch (n.type) {
313 case "app":
314 return [
315 `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
316 ...(n.data.ports || []).map((p) => nodeEnvVarNamePort(n, p.name)),
317 ];
318 case "github":
319 return [];
320 case "gateway-https":
321 return [];
322 case "gateway-tcp":
323 return [];
324 case "mongodb":
325 return [`DODO_MONGODB_${n.data.label.toUpperCase()}_URL`];
326 case "postgresql":
327 return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_URL`];
328 case "volume":
329 return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
330 case undefined:
331 throw new Error("MUST NOT REACH");
332 default:
333 throw new Error("MUST NOT REACH");
334 }
gio5f2f1002025-03-20 18:38:48 +0400335}
336
337export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
338
339export type MessageType = "INFO" | "WARNING" | "FATAL";
340
341export type Message = {
giod0026612025-05-08 13:00:36 +0000342 id: string;
343 type: MessageType;
344 nodeId?: string;
345 message: string;
346 onHighlight?: (state: AppState) => void;
347 onLooseHighlight?: (state: AppState) => void;
348 onClick?: (state: AppState) => void;
gio5f2f1002025-03-20 18:38:48 +0400349};
350
giob77cb932025-05-19 09:37:14 +0000351export const accessSchema = z.discriminatedUnion("type", [
352 z.object({
353 type: z.literal("https"),
354 name: z.string(),
355 address: z.string(),
356 }),
357 z.object({
358 type: z.literal("ssh"),
359 name: z.string(),
360 host: z.string(),
361 port: z.number(),
362 }),
363 z.object({
364 type: z.literal("tcp"),
365 name: z.string(),
366 host: z.string(),
367 port: z.number(),
368 }),
369 z.object({
370 type: z.literal("udp"),
371 name: z.string(),
372 host: z.string(),
373 port: z.number(),
374 }),
375 z.object({
376 type: z.literal("postgresql"),
377 name: z.string(),
378 host: z.string(),
379 port: z.number(),
380 database: z.string(),
381 username: z.string(),
382 password: z.string(),
383 }),
384 z.object({
385 type: z.literal("mongodb"),
386 name: z.string(),
387 host: z.string(),
388 port: z.number(),
389 database: z.string(),
390 username: z.string(),
391 password: z.string(),
392 }),
393]);
394
gioa1efbad2025-05-21 07:16:45 +0000395export const serviceInfoSchema = z.object({
396 name: z.string(),
397 workers: z.array(
398 z.object({
399 id: z.string(),
gio0afbaee2025-05-22 04:34:33 +0000400 commit: z.optional(
401 z.object({
402 hash: z.string(),
403 message: z.string(),
404 }),
405 ),
gioa1efbad2025-05-21 07:16:45 +0000406 commands: z.optional(
407 z.array(
408 z.object({
409 command: z.string(),
410 state: z.string(),
411 }),
412 ),
413 ),
414 }),
415 ),
416});
417
gio5f2f1002025-03-20 18:38:48 +0400418export const envSchema = z.object({
gio7d813702025-05-08 18:29:52 +0000419 managerAddr: z.optional(z.string().min(1)),
gio09fcab52025-05-12 14:05:07 +0000420 deployKey: z.optional(z.nullable(z.string().min(1))),
giod0026612025-05-08 13:00:36 +0000421 networks: z
422 .array(
423 z.object({
424 name: z.string().min(1),
425 domain: z.string().min(1),
gio6d8b71c2025-05-19 12:57:35 +0000426 hasAuth: z.boolean(),
giod0026612025-05-08 13:00:36 +0000427 }),
428 )
429 .default([]),
430 integrations: z.object({
431 github: z.boolean(),
432 }),
gioa1efbad2025-05-21 07:16:45 +0000433 services: z.array(serviceInfoSchema),
gio3ed59592025-05-14 16:51:09 +0000434 user: z.object({
435 id: z.string(),
436 username: z.string(),
437 }),
giob77cb932025-05-19 09:37:14 +0000438 access: z.array(accessSchema),
gio5f2f1002025-03-20 18:38:48 +0400439});
440
gioa1efbad2025-05-21 07:16:45 +0000441export type ServiceInfo = z.infer<typeof serviceInfoSchema>;
gio5f2f1002025-03-20 18:38:48 +0400442export type Env = z.infer<typeof envSchema>;
443
gio7f98e772025-05-07 11:00:14 +0000444const defaultEnv: Env = {
gio7d813702025-05-08 18:29:52 +0000445 managerAddr: undefined,
giod0026612025-05-08 13:00:36 +0000446 deployKey: undefined,
447 networks: [],
448 integrations: {
449 github: false,
450 },
gio3a921b82025-05-10 07:36:09 +0000451 services: [],
gio3ed59592025-05-14 16:51:09 +0000452 user: {
453 id: "",
454 username: "",
455 },
giob77cb932025-05-19 09:37:14 +0000456 access: [],
gio7f98e772025-05-07 11:00:14 +0000457};
458
gio5f2f1002025-03-20 18:38:48 +0400459export type Project = {
giod0026612025-05-08 13:00:36 +0000460 id: string;
461 name: string;
462};
gio5f2f1002025-03-20 18:38:48 +0400463
gio7f98e772025-05-07 11:00:14 +0000464export type IntegrationsConfig = {
giod0026612025-05-08 13:00:36 +0000465 github: boolean;
gio7f98e772025-05-07 11:00:14 +0000466};
467
468type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
469type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
470
gioaf8db832025-05-13 14:43:05 +0000471type Viewport = {
472 transformX: number;
473 transformY: number;
474 transformZoom: number;
475 width: number;
476 height: number;
477};
478
gio918780d2025-05-22 08:24:41 +0000479let refreshEnvIntervalId: number | null = null;
480
gio5f2f1002025-03-20 18:38:48 +0400481export type AppState = {
giod0026612025-05-08 13:00:36 +0000482 projectId: string | undefined;
gio818da4e2025-05-12 14:45:35 +0000483 mode: "edit" | "deploy";
giod0026612025-05-08 13:00:36 +0000484 projects: Project[];
485 nodes: AppNode[];
486 edges: Edge[];
gio359a6852025-05-14 03:38:24 +0000487 zoom: ReactFlowViewport;
giod0026612025-05-08 13:00:36 +0000488 categories: Category[];
489 messages: Message[];
490 env: Env;
gioaf8db832025-05-13 14:43:05 +0000491 viewport: Viewport;
492 setViewport: (viewport: Viewport) => void;
giod0026612025-05-08 13:00:36 +0000493 githubService: GitHubService | null;
494 setHighlightCategory: (name: string, active: boolean) => void;
495 onNodesChange: OnNodesChange<AppNode>;
496 onEdgesChange: OnEdgesChange;
497 onConnect: OnConnect;
gioaf8db832025-05-13 14:43:05 +0000498 addNode: (node: Omit<AppNode, "position">) => void;
giod0026612025-05-08 13:00:36 +0000499 setNodes: (nodes: AppNode[]) => void;
500 setEdges: (edges: Edge[]) => void;
gio818da4e2025-05-12 14:45:35 +0000501 setProject: (projectId: string | undefined) => Promise<void>;
502 setMode: (mode: "edit" | "deploy") => void;
giod0026612025-05-08 13:00:36 +0000503 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
504 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
505 replaceEdge: (c: Connection, id?: string) => void;
506 refreshEnv: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400507};
508
509const projectIdSelector = (state: AppState) => state.projectId;
510const categoriesSelector = (state: AppState) => state.categories;
511const messagesSelector = (state: AppState) => state.messages;
gio7f98e772025-05-07 11:00:14 +0000512const githubServiceSelector = (state: AppState) => state.githubService;
gio5f2f1002025-03-20 18:38:48 +0400513const envSelector = (state: AppState) => state.env;
gio359a6852025-05-14 03:38:24 +0000514const zoomSelector = (state: AppState) => state.zoom;
gioaf8db832025-05-13 14:43:05 +0000515
gio359a6852025-05-14 03:38:24 +0000516export function useZoom(): ReactFlowViewport {
517 return useStateStore(zoomSelector);
gioaf8db832025-05-13 14:43:05 +0000518}
gio5f2f1002025-03-20 18:38:48 +0400519
520export function useProjectId(): string | undefined {
giod0026612025-05-08 13:00:36 +0000521 return useStateStore(projectIdSelector);
gio5f2f1002025-03-20 18:38:48 +0400522}
523
giob45b1862025-05-20 11:42:20 +0000524export function useSetProject(): (projectId: string | undefined) => void {
525 return useStateStore((state) => state.setProject);
526}
527
gio5f2f1002025-03-20 18:38:48 +0400528export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000529 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400530}
531
532export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000533 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400534}
535
536export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000537 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400538}
539
540export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000541 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400542}
543
544export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000545 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400546}
547
gio5f2f1002025-03-20 18:38:48 +0400548export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000549 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000550}
551
552export function useGithubService(): GitHubService | null {
giod0026612025-05-08 13:00:36 +0000553 return useStateStore(githubServiceSelector);
gio5f2f1002025-03-20 18:38:48 +0400554}
555
gio3ec94242025-05-16 12:46:57 +0000556export function useMode(): "edit" | "deploy" {
557 return useStateStore((state) => state.mode);
558}
559
gio5f2f1002025-03-20 18:38:48 +0400560const v: Validator = CreateValidators();
561
gioaf8db832025-05-13 14:43:05 +0000562function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
563 const zoomMultiplier = 1 / transformZoom;
564 const realWidth = width * zoomMultiplier;
565 const realHeight = height * zoomMultiplier;
566 const paddingMultiplier = 0.8;
567 const ret = {
568 x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
569 y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
570 };
571 return ret;
572}
573
gio5f2f1002025-03-20 18:38:48 +0400574export const useStateStore = create<AppState>((set, get): AppState => {
giod0026612025-05-08 13:00:36 +0000575 const setN = (nodes: AppNode[]) => {
gio4b9b58a2025-05-12 11:46:08 +0000576 set({
giod0026612025-05-08 13:00:36 +0000577 nodes,
gio5cf364c2025-05-08 16:01:21 +0000578 messages: v(nodes),
gio4b9b58a2025-05-12 11:46:08 +0000579 });
580 };
581
gio918780d2025-05-22 08:24:41 +0000582 const startRefreshEnvInterval = () => {
583 if (refreshEnvIntervalId) {
584 clearInterval(refreshEnvIntervalId);
585 }
586 if (get().projectId && typeof document !== "undefined" && document.visibilityState === "visible") {
587 console.log("Starting refreshEnv interval for project:", get().projectId);
588 refreshEnvIntervalId = setInterval(async () => {
589 if (get().projectId && typeof document !== "undefined" && document.visibilityState === "visible") {
590 console.log("Interval: Calling refreshEnv for project:", get().projectId);
591 await get().refreshEnv();
592 } else if (refreshEnvIntervalId) {
593 console.log(
594 "Interval: Conditions not met (project removed or tab hidden), stopping interval from inside.",
595 );
596 clearInterval(refreshEnvIntervalId);
597 refreshEnvIntervalId = null;
598 }
599 }, 5000) as unknown as number;
600 } else {
601 console.log(
602 "Not starting refreshEnv interval. Project ID:",
603 get().projectId,
604 "Visibility:",
605 typeof document !== "undefined" ? document.visibilityState : "SSR",
606 );
607 }
608 };
609
610 const stopRefreshEnvInterval = () => {
611 if (refreshEnvIntervalId) {
612 console.log("Stopping refreshEnv interval for project:", get().projectId);
613 clearInterval(refreshEnvIntervalId);
614 refreshEnvIntervalId = null;
615 }
616 };
617
618 if (typeof document !== "undefined") {
619 document.addEventListener("visibilitychange", () => {
620 if (document.visibilityState === "visible") {
621 console.log("Tab became visible, attempting to start refreshEnv interval.");
622 startRefreshEnvInterval();
623 } else {
624 console.log("Tab became hidden, stopping refreshEnv interval.");
625 stopRefreshEnvInterval();
626 }
627 });
628 }
629
gio48fde052025-05-14 09:48:08 +0000630 const injectNetworkNodes = () => {
631 const newNetworks = get().env.networks.filter(
632 (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
633 );
634 newNetworks.forEach((n) => {
635 get().addNode({
636 id: n.domain,
637 type: "network",
638 connectable: true,
639 data: {
640 domain: n.domain,
641 label: n.domain,
642 envVars: [],
643 ports: [],
644 state: "success", // TODO(gio): monitor network health
645 },
646 });
647 console.log("added network", n.domain);
648 });
649 };
650
gio4b9b58a2025-05-12 11:46:08 +0000651 const restoreSaved = async () => {
gio818da4e2025-05-12 14:45:35 +0000652 const { projectId } = get();
653 const resp = await fetch(`/api/project/${projectId}/saved/${get().mode === "deploy" ? "deploy" : "draft"}`, {
gio4b9b58a2025-05-12 11:46:08 +0000654 method: "GET",
655 });
656 const inst = await resp.json();
gio48fde052025-05-14 09:48:08 +0000657 setN(inst.nodes);
658 set({ edges: inst.edges });
659 injectNetworkNodes();
gio359a6852025-05-14 03:38:24 +0000660 if (
661 get().zoom.x !== inst.viewport.x ||
662 get().zoom.y !== inst.viewport.y ||
663 get().zoom.zoom !== inst.viewport.zoom
664 ) {
665 set({ zoom: inst.viewport });
666 }
giod0026612025-05-08 13:00:36 +0000667 };
gio7f98e772025-05-07 11:00:14 +0000668
giod0026612025-05-08 13:00:36 +0000669 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
670 setN(
671 get().nodes.map((n) => {
672 if (n.id === id) {
673 return {
674 ...n,
675 data: {
676 ...n.data,
677 ...data,
678 },
679 } as Extract<AppNode, { type: T }>;
680 }
681 return n;
682 }),
683 );
684 }
gio7f98e772025-05-07 11:00:14 +0000685
giod0026612025-05-08 13:00:36 +0000686 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
687 setN(
688 get().nodes.map((n) => {
689 if (n.id === id) {
690 return {
691 ...n,
692 ...node,
693 } as Extract<AppNode, { type: T }>;
694 }
695 return n;
696 }),
697 );
698 }
gio7f98e772025-05-07 11:00:14 +0000699
giod0026612025-05-08 13:00:36 +0000700 function onConnect(c: Connection) {
701 const { nodes, edges } = get();
702 set({
703 edges: addEdge(c, edges),
704 });
705 const sn = nodes.filter((n) => n.id === c.source)[0]!;
706 const tn = nodes.filter((n) => n.id === c.target)[0]!;
707 if (tn.type === "network") {
708 if (sn.type === "gateway-https") {
709 updateNodeData<"gateway-https">(sn.id, {
710 network: tn.data.domain,
711 });
712 } else if (sn.type === "gateway-tcp") {
713 updateNodeData<"gateway-tcp">(sn.id, {
714 network: tn.data.domain,
715 });
716 }
717 }
718 if (tn.type === "app") {
719 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
720 const sourceEnvVars = nodeEnvVarNames(sn);
721 if (sourceEnvVars.length === 0) {
722 throw new Error("MUST NOT REACH!");
723 }
724 const id = uuidv4();
725 if (sourceEnvVars.length === 1) {
726 updateNode<"app">(c.target, {
727 ...tn,
728 data: {
729 ...tn.data,
730 envVars: [
731 ...(tn.data.envVars || []),
732 {
733 id: id,
734 source: c.source,
735 name: sourceEnvVars[0],
736 isEditting: false,
737 },
738 ],
739 },
740 });
741 } else {
742 updateNode<"app">(c.target, {
743 ...tn,
744 data: {
745 ...tn.data,
746 envVars: [
747 ...(tn.data.envVars || []),
748 {
749 id: id,
750 source: c.source,
751 },
752 ],
753 },
754 });
755 }
756 }
757 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
758 const sourcePorts = sn.data.ports || [];
759 const id = uuidv4();
760 if (sourcePorts.length === 1) {
761 updateNode<"app">(c.target, {
762 ...tn,
763 data: {
764 ...tn.data,
765 envVars: [
766 ...(tn.data.envVars || []),
767 {
768 id: id,
769 source: c.source,
770 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
771 portId: sourcePorts[0].id,
772 isEditting: false,
773 },
774 ],
775 },
776 });
777 }
778 }
779 }
780 if (c.sourceHandle === "volume") {
781 updateNodeData<"volume">(c.source, {
782 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
783 });
784 }
785 if (c.targetHandle === "volume") {
786 if (tn.type === "postgresql" || tn.type === "mongodb") {
787 updateNodeData(c.target, {
788 volumeId: c.source,
789 });
790 }
791 }
792 if (c.targetHandle === "https") {
793 if ((sn.data.ports || []).length === 1) {
794 updateNodeData<"gateway-https">(c.target, {
795 https: {
796 serviceId: c.source,
797 portId: sn.data.ports![0].id,
798 },
799 });
800 } else {
801 updateNodeData<"gateway-https">(c.target, {
802 https: {
803 serviceId: c.source,
804 portId: "", // TODO(gio)
805 },
806 });
807 }
808 }
809 if (c.targetHandle === "tcp") {
810 const td = tn.data as GatewayTCPData;
811 if ((sn.data.ports || []).length === 1) {
812 updateNodeData<"gateway-tcp">(c.target, {
813 exposed: (td.exposed || []).concat({
814 serviceId: c.source,
815 portId: sn.data.ports![0].id,
816 }),
817 });
818 } else {
819 updateNodeData<"gateway-tcp">(c.target, {
820 selected: {
821 serviceId: c.source,
822 portId: undefined,
823 },
824 });
825 }
826 }
827 if (sn.type === "app") {
828 if (c.sourceHandle === "ports") {
829 updateNodeData<"app">(sn.id, {
830 isChoosingPortToConnect: true,
831 });
832 }
833 }
834 if (tn.type === "app") {
835 if (c.targetHandle === "repository") {
836 updateNodeData<"app">(tn.id, {
837 repository: {
838 id: c.source,
839 branch: "master",
840 rootDir: "/",
841 },
842 });
843 }
844 }
845 }
846 return {
847 projectId: undefined,
gio818da4e2025-05-12 14:45:35 +0000848 mode: "edit",
giod0026612025-05-08 13:00:36 +0000849 projects: [],
850 nodes: [],
851 edges: [],
852 categories: defaultCategories,
853 messages: v([]),
854 env: defaultEnv,
gioaf8db832025-05-13 14:43:05 +0000855 viewport: {
856 transformX: 0,
857 transformY: 0,
858 transformZoom: 1,
859 width: 800,
860 height: 600,
861 },
gio359a6852025-05-14 03:38:24 +0000862 zoom: {
863 x: 0,
864 y: 0,
865 zoom: 1,
866 },
giod0026612025-05-08 13:00:36 +0000867 githubService: null,
gioaf8db832025-05-13 14:43:05 +0000868 setViewport: (viewport) => {
869 const { viewport: vp } = get();
870 if (
871 viewport.transformX !== vp.transformX ||
872 viewport.transformY !== vp.transformY ||
873 viewport.transformZoom !== vp.transformZoom ||
874 viewport.width !== vp.width ||
875 viewport.height !== vp.height
876 ) {
877 set({ viewport });
878 }
879 },
giod0026612025-05-08 13:00:36 +0000880 setHighlightCategory: (name, active) => {
881 set({
882 categories: get().categories.map((c) => {
883 if (c.title.toLowerCase() !== name.toLowerCase()) {
884 return c;
885 } else {
886 return {
887 ...c,
888 active,
889 };
890 }
891 }),
892 });
893 },
894 onNodesChange: (changes) => {
895 const nodes = applyNodeChanges(changes, get().nodes);
896 setN(nodes);
897 },
898 onEdgesChange: (changes) => {
899 set({
900 edges: applyEdgeChanges(changes, get().edges),
901 });
902 },
gioaf8db832025-05-13 14:43:05 +0000903 addNode: (node) => {
904 const { viewport, nodes } = get();
905 setN(
906 nodes.concat({
907 ...node,
908 position: getRandomPosition(viewport),
gioa1efbad2025-05-21 07:16:45 +0000909 } as AppNode),
gioaf8db832025-05-13 14:43:05 +0000910 );
911 },
giod0026612025-05-08 13:00:36 +0000912 setNodes: (nodes) => {
913 setN(nodes);
914 },
915 setEdges: (edges) => {
916 set({ edges });
917 },
918 replaceEdge: (c, id) => {
919 let change: EdgeChange;
920 if (id === undefined) {
921 change = {
922 type: "add",
923 item: {
924 id: uuidv4(),
925 ...c,
926 },
927 };
928 onConnect(c);
929 } else {
930 change = {
931 type: "replace",
932 id,
933 item: {
934 id,
935 ...c,
936 },
937 };
938 }
939 set({
940 edges: applyEdgeChanges([change], get().edges),
941 });
942 },
943 updateNode,
944 updateNodeData,
945 onConnect,
946 refreshEnv: async () => {
947 const projectId = get().projectId;
948 let env: Env = defaultEnv;
giod0026612025-05-08 13:00:36 +0000949 try {
950 if (projectId) {
951 const response = await fetch(`/api/project/${projectId}/env`);
952 if (response.ok) {
953 const data = await response.json();
954 const result = envSchema.safeParse(data);
955 if (result.success) {
956 env = result.data;
957 } else {
958 console.error("Invalid env data:", result.error);
959 }
960 }
961 }
962 } catch (error) {
963 console.error("Failed to fetch integrations:", error);
964 } finally {
gio4b9b58a2025-05-12 11:46:08 +0000965 if (JSON.stringify(get().env) !== JSON.stringify(env)) {
966 set({ env });
gio48fde052025-05-14 09:48:08 +0000967 injectNetworkNodes();
gio4b9b58a2025-05-12 11:46:08 +0000968 if (env.integrations.github) {
969 set({ githubService: new GitHubServiceImpl(projectId!) });
970 } else {
971 set({ githubService: null });
972 }
giod0026612025-05-08 13:00:36 +0000973 }
974 }
975 },
gio818da4e2025-05-12 14:45:35 +0000976 setMode: (mode) => {
977 set({ mode });
978 },
979 setProject: async (projectId) => {
gio918780d2025-05-22 08:24:41 +0000980 const currentProjectId = get().projectId;
981 if (projectId === currentProjectId) {
gio359a6852025-05-14 03:38:24 +0000982 return;
983 }
gio918780d2025-05-22 08:24:41 +0000984 stopRefreshEnvInterval();
giod0026612025-05-08 13:00:36 +0000985 set({
986 projectId,
987 });
988 if (projectId) {
gio818da4e2025-05-12 14:45:35 +0000989 await get().refreshEnv();
990 if (get().env.deployKey) {
991 set({ mode: "deploy" });
992 } else {
993 set({ mode: "edit" });
994 }
gio4b9b58a2025-05-12 11:46:08 +0000995 restoreSaved();
gio918780d2025-05-22 08:24:41 +0000996 startRefreshEnvInterval();
gio4b9b58a2025-05-12 11:46:08 +0000997 } else {
998 set({
999 nodes: [],
1000 edges: [],
gio918780d2025-05-22 08:24:41 +00001001 env: defaultEnv,
1002 githubService: null,
gio4b9b58a2025-05-12 11:46:08 +00001003 });
giod0026612025-05-08 13:00:36 +00001004 }
1005 },
1006 };
gio5f2f1002025-03-20 18:38:48 +04001007});