blob: b7413d954f4fbafb6eb2a5ddcb2af119bc3c884c [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
gio8fad76a2025-05-22 14:01:23 +0000229export function nodeLabelFull(n: AppNode): string {
230 if (n.type === "gateway-https") {
231 return `https://${n.data.subdomain}.${n.data.network}`;
232 } else {
233 return nodeLabel(n);
234 }
235}
236
gio5f2f1002025-03-20 18:38:48 +0400237export function nodeIsConnectable(n: AppNode, handle: string): boolean {
giod0026612025-05-08 13:00:36 +0000238 switch (n.type) {
239 case "network":
240 return true;
241 case "app":
242 if (handle === "ports") {
243 return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
244 } else if (handle === "repository") {
245 if (!n.data || !n.data.repository || !n.data.repository.id) {
246 return true;
247 }
248 return false;
249 }
250 return false;
251 case "github":
252 if (n.data.repository?.id !== undefined) {
253 return true;
254 }
255 return false;
256 case "gateway-https":
257 if (handle === "subdomain") {
258 return n.data.network === undefined;
259 }
260 return n.data === undefined || n.data.https === undefined;
261 case "gateway-tcp":
262 if (handle === "subdomain") {
263 return n.data.network === undefined;
264 }
265 return true;
266 case "mongodb":
267 return true;
268 case "postgresql":
269 return true;
270 case "volume":
271 if (n.data === undefined || n.data.type === undefined) {
272 return false;
273 }
274 if (n.data.type === "ReadWriteOnce" || n.data.type === "ReadWriteOncePod") {
275 return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
276 }
277 return true;
278 case undefined:
279 throw new Error("MUST NOT REACH!");
280 }
gio5f2f1002025-03-20 18:38:48 +0400281}
282
giod0026612025-05-08 13:00:36 +0000283export type BoundEnvVar =
284 | {
285 id: string;
286 source: string | null;
287 }
288 | {
289 id: string;
290 source: string | null;
291 name: string;
292 isEditting: boolean;
293 }
294 | {
295 id: string;
296 source: string | null;
297 name: string;
298 alias: string;
299 isEditting: boolean;
300 }
301 | {
302 id: string;
303 source: string | null;
304 portId: string;
305 name: string;
306 alias: string;
307 isEditting: boolean;
308 };
gio5f2f1002025-03-20 18:38:48 +0400309
310export type EnvVar = {
giod0026612025-05-08 13:00:36 +0000311 name: string;
312 value: string;
gio5f2f1002025-03-20 18:38:48 +0400313};
314
giob41ecae2025-04-24 08:46:50 +0000315export function nodeEnvVarNamePort(n: AppNode, portName: string): string {
giod0026612025-05-08 13:00:36 +0000316 return `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${portName.toUpperCase()}`;
giob41ecae2025-04-24 08:46:50 +0000317}
318
gio5f2f1002025-03-20 18:38:48 +0400319export function nodeEnvVarNames(n: AppNode): string[] {
giod0026612025-05-08 13:00:36 +0000320 switch (n.type) {
321 case "app":
322 return [
323 `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
324 ...(n.data.ports || []).map((p) => nodeEnvVarNamePort(n, p.name)),
325 ];
326 case "github":
327 return [];
328 case "gateway-https":
329 return [];
330 case "gateway-tcp":
331 return [];
332 case "mongodb":
333 return [`DODO_MONGODB_${n.data.label.toUpperCase()}_URL`];
334 case "postgresql":
335 return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_URL`];
336 case "volume":
337 return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
338 case undefined:
339 throw new Error("MUST NOT REACH");
340 default:
341 throw new Error("MUST NOT REACH");
342 }
gio5f2f1002025-03-20 18:38:48 +0400343}
344
345export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
346
347export type MessageType = "INFO" | "WARNING" | "FATAL";
348
349export type Message = {
giod0026612025-05-08 13:00:36 +0000350 id: string;
351 type: MessageType;
352 nodeId?: string;
353 message: string;
354 onHighlight?: (state: AppState) => void;
355 onLooseHighlight?: (state: AppState) => void;
356 onClick?: (state: AppState) => void;
gio5f2f1002025-03-20 18:38:48 +0400357};
358
giob77cb932025-05-19 09:37:14 +0000359export const accessSchema = z.discriminatedUnion("type", [
360 z.object({
361 type: z.literal("https"),
362 name: z.string(),
363 address: z.string(),
364 }),
365 z.object({
366 type: z.literal("ssh"),
367 name: z.string(),
368 host: z.string(),
369 port: z.number(),
370 }),
371 z.object({
372 type: z.literal("tcp"),
373 name: z.string(),
374 host: z.string(),
375 port: z.number(),
376 }),
377 z.object({
378 type: z.literal("udp"),
379 name: z.string(),
380 host: z.string(),
381 port: z.number(),
382 }),
383 z.object({
384 type: z.literal("postgresql"),
385 name: z.string(),
386 host: z.string(),
387 port: z.number(),
388 database: z.string(),
389 username: z.string(),
390 password: z.string(),
391 }),
392 z.object({
393 type: z.literal("mongodb"),
394 name: z.string(),
395 host: z.string(),
396 port: z.number(),
397 database: z.string(),
398 username: z.string(),
399 password: z.string(),
400 }),
401]);
402
gioa1efbad2025-05-21 07:16:45 +0000403export const serviceInfoSchema = z.object({
404 name: z.string(),
405 workers: z.array(
406 z.object({
407 id: z.string(),
gio0afbaee2025-05-22 04:34:33 +0000408 commit: z.optional(
409 z.object({
410 hash: z.string(),
411 message: z.string(),
412 }),
413 ),
gioa1efbad2025-05-21 07:16:45 +0000414 commands: z.optional(
415 z.array(
416 z.object({
417 command: z.string(),
418 state: z.string(),
419 }),
420 ),
421 ),
422 }),
423 ),
424});
425
gio5f2f1002025-03-20 18:38:48 +0400426export const envSchema = z.object({
gio7d813702025-05-08 18:29:52 +0000427 managerAddr: z.optional(z.string().min(1)),
gio09fcab52025-05-12 14:05:07 +0000428 deployKey: z.optional(z.nullable(z.string().min(1))),
giod0026612025-05-08 13:00:36 +0000429 networks: z
430 .array(
431 z.object({
432 name: z.string().min(1),
433 domain: z.string().min(1),
gio6d8b71c2025-05-19 12:57:35 +0000434 hasAuth: z.boolean(),
giod0026612025-05-08 13:00:36 +0000435 }),
436 )
437 .default([]),
438 integrations: z.object({
439 github: z.boolean(),
440 }),
gioa1efbad2025-05-21 07:16:45 +0000441 services: z.array(serviceInfoSchema),
gio3ed59592025-05-14 16:51:09 +0000442 user: z.object({
443 id: z.string(),
444 username: z.string(),
445 }),
giob77cb932025-05-19 09:37:14 +0000446 access: z.array(accessSchema),
gio5f2f1002025-03-20 18:38:48 +0400447});
448
gioa1efbad2025-05-21 07:16:45 +0000449export type ServiceInfo = z.infer<typeof serviceInfoSchema>;
gio5f2f1002025-03-20 18:38:48 +0400450export type Env = z.infer<typeof envSchema>;
451
gio7f98e772025-05-07 11:00:14 +0000452const defaultEnv: Env = {
gio7d813702025-05-08 18:29:52 +0000453 managerAddr: undefined,
giod0026612025-05-08 13:00:36 +0000454 deployKey: undefined,
455 networks: [],
456 integrations: {
457 github: false,
458 },
gio3a921b82025-05-10 07:36:09 +0000459 services: [],
gio3ed59592025-05-14 16:51:09 +0000460 user: {
461 id: "",
462 username: "",
463 },
giob77cb932025-05-19 09:37:14 +0000464 access: [],
gio7f98e772025-05-07 11:00:14 +0000465};
466
gio5f2f1002025-03-20 18:38:48 +0400467export type Project = {
giod0026612025-05-08 13:00:36 +0000468 id: string;
469 name: string;
470};
gio5f2f1002025-03-20 18:38:48 +0400471
gio7f98e772025-05-07 11:00:14 +0000472export type IntegrationsConfig = {
giod0026612025-05-08 13:00:36 +0000473 github: boolean;
gio7f98e772025-05-07 11:00:14 +0000474};
475
476type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
477type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
478
gioaf8db832025-05-13 14:43:05 +0000479type Viewport = {
480 transformX: number;
481 transformY: number;
482 transformZoom: number;
483 width: number;
484 height: number;
485};
486
gio918780d2025-05-22 08:24:41 +0000487let refreshEnvIntervalId: number | null = null;
488
gio5f2f1002025-03-20 18:38:48 +0400489export type AppState = {
giod0026612025-05-08 13:00:36 +0000490 projectId: string | undefined;
gio818da4e2025-05-12 14:45:35 +0000491 mode: "edit" | "deploy";
giod0026612025-05-08 13:00:36 +0000492 projects: Project[];
493 nodes: AppNode[];
494 edges: Edge[];
gio359a6852025-05-14 03:38:24 +0000495 zoom: ReactFlowViewport;
giod0026612025-05-08 13:00:36 +0000496 categories: Category[];
497 messages: Message[];
498 env: Env;
gioaf8db832025-05-13 14:43:05 +0000499 viewport: Viewport;
500 setViewport: (viewport: Viewport) => void;
giod0026612025-05-08 13:00:36 +0000501 githubService: GitHubService | null;
502 setHighlightCategory: (name: string, active: boolean) => void;
503 onNodesChange: OnNodesChange<AppNode>;
504 onEdgesChange: OnEdgesChange;
505 onConnect: OnConnect;
gioaf8db832025-05-13 14:43:05 +0000506 addNode: (node: Omit<AppNode, "position">) => void;
giod0026612025-05-08 13:00:36 +0000507 setNodes: (nodes: AppNode[]) => void;
508 setEdges: (edges: Edge[]) => void;
gio818da4e2025-05-12 14:45:35 +0000509 setProject: (projectId: string | undefined) => Promise<void>;
510 setMode: (mode: "edit" | "deploy") => void;
giod0026612025-05-08 13:00:36 +0000511 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
512 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
513 replaceEdge: (c: Connection, id?: string) => void;
514 refreshEnv: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400515};
516
517const projectIdSelector = (state: AppState) => state.projectId;
518const categoriesSelector = (state: AppState) => state.categories;
519const messagesSelector = (state: AppState) => state.messages;
gio7f98e772025-05-07 11:00:14 +0000520const githubServiceSelector = (state: AppState) => state.githubService;
gio5f2f1002025-03-20 18:38:48 +0400521const envSelector = (state: AppState) => state.env;
gio359a6852025-05-14 03:38:24 +0000522const zoomSelector = (state: AppState) => state.zoom;
gioaf8db832025-05-13 14:43:05 +0000523
gio359a6852025-05-14 03:38:24 +0000524export function useZoom(): ReactFlowViewport {
525 return useStateStore(zoomSelector);
gioaf8db832025-05-13 14:43:05 +0000526}
gio5f2f1002025-03-20 18:38:48 +0400527
528export function useProjectId(): string | undefined {
giod0026612025-05-08 13:00:36 +0000529 return useStateStore(projectIdSelector);
gio5f2f1002025-03-20 18:38:48 +0400530}
531
giob45b1862025-05-20 11:42:20 +0000532export function useSetProject(): (projectId: string | undefined) => void {
533 return useStateStore((state) => state.setProject);
534}
535
gio5f2f1002025-03-20 18:38:48 +0400536export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000537 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400538}
539
540export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000541 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400542}
543
544export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000545 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400546}
547
548export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000549 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400550}
551
552export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000553 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400554}
555
gio5f2f1002025-03-20 18:38:48 +0400556export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000557 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000558}
559
560export function useGithubService(): GitHubService | null {
giod0026612025-05-08 13:00:36 +0000561 return useStateStore(githubServiceSelector);
gio5f2f1002025-03-20 18:38:48 +0400562}
563
gio3ec94242025-05-16 12:46:57 +0000564export function useMode(): "edit" | "deploy" {
565 return useStateStore((state) => state.mode);
566}
567
gio5f2f1002025-03-20 18:38:48 +0400568const v: Validator = CreateValidators();
569
gioaf8db832025-05-13 14:43:05 +0000570function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
571 const zoomMultiplier = 1 / transformZoom;
572 const realWidth = width * zoomMultiplier;
573 const realHeight = height * zoomMultiplier;
574 const paddingMultiplier = 0.8;
575 const ret = {
576 x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
577 y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
578 };
579 return ret;
580}
581
gio5f2f1002025-03-20 18:38:48 +0400582export const useStateStore = create<AppState>((set, get): AppState => {
giod0026612025-05-08 13:00:36 +0000583 const setN = (nodes: AppNode[]) => {
gio4b9b58a2025-05-12 11:46:08 +0000584 set({
giod0026612025-05-08 13:00:36 +0000585 nodes,
gio5cf364c2025-05-08 16:01:21 +0000586 messages: v(nodes),
gio4b9b58a2025-05-12 11:46:08 +0000587 });
588 };
589
gio918780d2025-05-22 08:24:41 +0000590 const startRefreshEnvInterval = () => {
591 if (refreshEnvIntervalId) {
592 clearInterval(refreshEnvIntervalId);
593 }
594 if (get().projectId && typeof document !== "undefined" && document.visibilityState === "visible") {
595 console.log("Starting refreshEnv interval for project:", get().projectId);
596 refreshEnvIntervalId = setInterval(async () => {
597 if (get().projectId && typeof document !== "undefined" && document.visibilityState === "visible") {
598 console.log("Interval: Calling refreshEnv for project:", get().projectId);
599 await get().refreshEnv();
600 } else if (refreshEnvIntervalId) {
601 console.log(
602 "Interval: Conditions not met (project removed or tab hidden), stopping interval from inside.",
603 );
604 clearInterval(refreshEnvIntervalId);
605 refreshEnvIntervalId = null;
606 }
607 }, 5000) as unknown as number;
608 } else {
609 console.log(
610 "Not starting refreshEnv interval. Project ID:",
611 get().projectId,
612 "Visibility:",
613 typeof document !== "undefined" ? document.visibilityState : "SSR",
614 );
615 }
616 };
617
618 const stopRefreshEnvInterval = () => {
619 if (refreshEnvIntervalId) {
620 console.log("Stopping refreshEnv interval for project:", get().projectId);
621 clearInterval(refreshEnvIntervalId);
622 refreshEnvIntervalId = null;
623 }
624 };
625
626 if (typeof document !== "undefined") {
627 document.addEventListener("visibilitychange", () => {
628 if (document.visibilityState === "visible") {
629 console.log("Tab became visible, attempting to start refreshEnv interval.");
630 startRefreshEnvInterval();
631 } else {
632 console.log("Tab became hidden, stopping refreshEnv interval.");
633 stopRefreshEnvInterval();
634 }
635 });
636 }
637
gio48fde052025-05-14 09:48:08 +0000638 const injectNetworkNodes = () => {
639 const newNetworks = get().env.networks.filter(
640 (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
641 );
642 newNetworks.forEach((n) => {
643 get().addNode({
644 id: n.domain,
645 type: "network",
646 connectable: true,
647 data: {
648 domain: n.domain,
649 label: n.domain,
650 envVars: [],
651 ports: [],
652 state: "success", // TODO(gio): monitor network health
653 },
654 });
655 console.log("added network", n.domain);
656 });
657 };
658
gio4b9b58a2025-05-12 11:46:08 +0000659 const restoreSaved = async () => {
gio818da4e2025-05-12 14:45:35 +0000660 const { projectId } = get();
661 const resp = await fetch(`/api/project/${projectId}/saved/${get().mode === "deploy" ? "deploy" : "draft"}`, {
gio4b9b58a2025-05-12 11:46:08 +0000662 method: "GET",
663 });
664 const inst = await resp.json();
gio48fde052025-05-14 09:48:08 +0000665 setN(inst.nodes);
666 set({ edges: inst.edges });
667 injectNetworkNodes();
gio359a6852025-05-14 03:38:24 +0000668 if (
669 get().zoom.x !== inst.viewport.x ||
670 get().zoom.y !== inst.viewport.y ||
671 get().zoom.zoom !== inst.viewport.zoom
672 ) {
673 set({ zoom: inst.viewport });
674 }
giod0026612025-05-08 13:00:36 +0000675 };
gio7f98e772025-05-07 11:00:14 +0000676
giod0026612025-05-08 13:00:36 +0000677 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
678 setN(
679 get().nodes.map((n) => {
680 if (n.id === id) {
681 return {
682 ...n,
683 data: {
684 ...n.data,
685 ...data,
686 },
687 } as Extract<AppNode, { type: T }>;
688 }
689 return n;
690 }),
691 );
692 }
gio7f98e772025-05-07 11:00:14 +0000693
giod0026612025-05-08 13:00:36 +0000694 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
695 setN(
696 get().nodes.map((n) => {
697 if (n.id === id) {
698 return {
699 ...n,
700 ...node,
701 } as Extract<AppNode, { type: T }>;
702 }
703 return n;
704 }),
705 );
706 }
gio7f98e772025-05-07 11:00:14 +0000707
giod0026612025-05-08 13:00:36 +0000708 function onConnect(c: Connection) {
709 const { nodes, edges } = get();
710 set({
711 edges: addEdge(c, edges),
712 });
713 const sn = nodes.filter((n) => n.id === c.source)[0]!;
714 const tn = nodes.filter((n) => n.id === c.target)[0]!;
715 if (tn.type === "network") {
716 if (sn.type === "gateway-https") {
717 updateNodeData<"gateway-https">(sn.id, {
718 network: tn.data.domain,
719 });
720 } else if (sn.type === "gateway-tcp") {
721 updateNodeData<"gateway-tcp">(sn.id, {
722 network: tn.data.domain,
723 });
724 }
725 }
726 if (tn.type === "app") {
727 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
728 const sourceEnvVars = nodeEnvVarNames(sn);
729 if (sourceEnvVars.length === 0) {
730 throw new Error("MUST NOT REACH!");
731 }
732 const id = uuidv4();
733 if (sourceEnvVars.length === 1) {
734 updateNode<"app">(c.target, {
735 ...tn,
736 data: {
737 ...tn.data,
738 envVars: [
739 ...(tn.data.envVars || []),
740 {
741 id: id,
742 source: c.source,
743 name: sourceEnvVars[0],
744 isEditting: false,
745 },
746 ],
747 },
748 });
749 } else {
750 updateNode<"app">(c.target, {
751 ...tn,
752 data: {
753 ...tn.data,
754 envVars: [
755 ...(tn.data.envVars || []),
756 {
757 id: id,
758 source: c.source,
759 },
760 ],
761 },
762 });
763 }
764 }
765 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
766 const sourcePorts = sn.data.ports || [];
767 const id = uuidv4();
768 if (sourcePorts.length === 1) {
769 updateNode<"app">(c.target, {
770 ...tn,
771 data: {
772 ...tn.data,
773 envVars: [
774 ...(tn.data.envVars || []),
775 {
776 id: id,
777 source: c.source,
778 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
779 portId: sourcePorts[0].id,
780 isEditting: false,
781 },
782 ],
783 },
784 });
785 }
786 }
787 }
788 if (c.sourceHandle === "volume") {
789 updateNodeData<"volume">(c.source, {
790 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
791 });
792 }
793 if (c.targetHandle === "volume") {
794 if (tn.type === "postgresql" || tn.type === "mongodb") {
795 updateNodeData(c.target, {
796 volumeId: c.source,
797 });
798 }
799 }
800 if (c.targetHandle === "https") {
801 if ((sn.data.ports || []).length === 1) {
802 updateNodeData<"gateway-https">(c.target, {
803 https: {
804 serviceId: c.source,
805 portId: sn.data.ports![0].id,
806 },
807 });
808 } else {
809 updateNodeData<"gateway-https">(c.target, {
810 https: {
811 serviceId: c.source,
812 portId: "", // TODO(gio)
813 },
814 });
815 }
816 }
817 if (c.targetHandle === "tcp") {
818 const td = tn.data as GatewayTCPData;
819 if ((sn.data.ports || []).length === 1) {
820 updateNodeData<"gateway-tcp">(c.target, {
821 exposed: (td.exposed || []).concat({
822 serviceId: c.source,
823 portId: sn.data.ports![0].id,
824 }),
825 });
826 } else {
827 updateNodeData<"gateway-tcp">(c.target, {
828 selected: {
829 serviceId: c.source,
830 portId: undefined,
831 },
832 });
833 }
834 }
835 if (sn.type === "app") {
836 if (c.sourceHandle === "ports") {
837 updateNodeData<"app">(sn.id, {
838 isChoosingPortToConnect: true,
839 });
840 }
841 }
842 if (tn.type === "app") {
843 if (c.targetHandle === "repository") {
844 updateNodeData<"app">(tn.id, {
845 repository: {
846 id: c.source,
847 branch: "master",
848 rootDir: "/",
849 },
850 });
851 }
852 }
853 }
854 return {
855 projectId: undefined,
gio818da4e2025-05-12 14:45:35 +0000856 mode: "edit",
giod0026612025-05-08 13:00:36 +0000857 projects: [],
858 nodes: [],
859 edges: [],
860 categories: defaultCategories,
861 messages: v([]),
862 env: defaultEnv,
gioaf8db832025-05-13 14:43:05 +0000863 viewport: {
864 transformX: 0,
865 transformY: 0,
866 transformZoom: 1,
867 width: 800,
868 height: 600,
869 },
gio359a6852025-05-14 03:38:24 +0000870 zoom: {
871 x: 0,
872 y: 0,
873 zoom: 1,
874 },
giod0026612025-05-08 13:00:36 +0000875 githubService: null,
gioaf8db832025-05-13 14:43:05 +0000876 setViewport: (viewport) => {
877 const { viewport: vp } = get();
878 if (
879 viewport.transformX !== vp.transformX ||
880 viewport.transformY !== vp.transformY ||
881 viewport.transformZoom !== vp.transformZoom ||
882 viewport.width !== vp.width ||
883 viewport.height !== vp.height
884 ) {
885 set({ viewport });
886 }
887 },
giod0026612025-05-08 13:00:36 +0000888 setHighlightCategory: (name, active) => {
889 set({
890 categories: get().categories.map((c) => {
891 if (c.title.toLowerCase() !== name.toLowerCase()) {
892 return c;
893 } else {
894 return {
895 ...c,
896 active,
897 };
898 }
899 }),
900 });
901 },
902 onNodesChange: (changes) => {
903 const nodes = applyNodeChanges(changes, get().nodes);
904 setN(nodes);
905 },
906 onEdgesChange: (changes) => {
907 set({
908 edges: applyEdgeChanges(changes, get().edges),
909 });
910 },
gioaf8db832025-05-13 14:43:05 +0000911 addNode: (node) => {
912 const { viewport, nodes } = get();
913 setN(
914 nodes.concat({
915 ...node,
916 position: getRandomPosition(viewport),
gioa1efbad2025-05-21 07:16:45 +0000917 } as AppNode),
gioaf8db832025-05-13 14:43:05 +0000918 );
919 },
giod0026612025-05-08 13:00:36 +0000920 setNodes: (nodes) => {
921 setN(nodes);
922 },
923 setEdges: (edges) => {
924 set({ edges });
925 },
926 replaceEdge: (c, id) => {
927 let change: EdgeChange;
928 if (id === undefined) {
929 change = {
930 type: "add",
931 item: {
932 id: uuidv4(),
933 ...c,
934 },
935 };
936 onConnect(c);
937 } else {
938 change = {
939 type: "replace",
940 id,
941 item: {
942 id,
943 ...c,
944 },
945 };
946 }
947 set({
948 edges: applyEdgeChanges([change], get().edges),
949 });
950 },
951 updateNode,
952 updateNodeData,
953 onConnect,
954 refreshEnv: async () => {
955 const projectId = get().projectId;
956 let env: Env = defaultEnv;
giod0026612025-05-08 13:00:36 +0000957 try {
958 if (projectId) {
959 const response = await fetch(`/api/project/${projectId}/env`);
960 if (response.ok) {
961 const data = await response.json();
962 const result = envSchema.safeParse(data);
963 if (result.success) {
964 env = result.data;
965 } else {
966 console.error("Invalid env data:", result.error);
967 }
968 }
969 }
970 } catch (error) {
971 console.error("Failed to fetch integrations:", error);
972 } finally {
gio4b9b58a2025-05-12 11:46:08 +0000973 if (JSON.stringify(get().env) !== JSON.stringify(env)) {
974 set({ env });
gio48fde052025-05-14 09:48:08 +0000975 injectNetworkNodes();
gio4b9b58a2025-05-12 11:46:08 +0000976 if (env.integrations.github) {
977 set({ githubService: new GitHubServiceImpl(projectId!) });
978 } else {
979 set({ githubService: null });
980 }
giod0026612025-05-08 13:00:36 +0000981 }
982 }
983 },
gio818da4e2025-05-12 14:45:35 +0000984 setMode: (mode) => {
985 set({ mode });
986 },
987 setProject: async (projectId) => {
gio918780d2025-05-22 08:24:41 +0000988 const currentProjectId = get().projectId;
989 if (projectId === currentProjectId) {
gio359a6852025-05-14 03:38:24 +0000990 return;
991 }
gio918780d2025-05-22 08:24:41 +0000992 stopRefreshEnvInterval();
giod0026612025-05-08 13:00:36 +0000993 set({
994 projectId,
995 });
996 if (projectId) {
gio818da4e2025-05-12 14:45:35 +0000997 await get().refreshEnv();
998 if (get().env.deployKey) {
999 set({ mode: "deploy" });
1000 } else {
1001 set({ mode: "edit" });
1002 }
gio4b9b58a2025-05-12 11:46:08 +00001003 restoreSaved();
gio918780d2025-05-22 08:24:41 +00001004 startRefreshEnvInterval();
gio4b9b58a2025-05-12 11:46:08 +00001005 } else {
1006 set({
1007 nodes: [],
1008 edges: [],
gio918780d2025-05-22 08:24:41 +00001009 env: defaultEnv,
1010 githubService: null,
gio4b9b58a2025-05-12 11:46:08 +00001011 });
giod0026612025-05-08 13:00:36 +00001012 }
1013 },
1014 };
gio5f2f1002025-03-20 18:38:48 +04001015});