blob: a0e4e9100996bd5b1ef213ff29f7618a6569fc31 [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";
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
gioa71316d2025-05-24 09:41:36 +040019export const serviceAnalyzisSchema = z.object({
20 name: z.string(),
21 location: z.string(),
22 configVars: z.array(
23 z.object({
24 name: z.string(),
25 category: z.enum(["CommandLineFlag", "EnvironmentVariable"]),
26 type: z.optional(z.enum(["String", "Number", "Boolean"])),
27 semanticType: z.optional(
28 z.enum([
29 "EXPANDED_ENV_VAR",
30 "PORT",
31 "FILESYSTEM_PATH",
32 "DATABASE_URL",
33 "SQLITE_PATH",
34 "POSTGRES_URL",
35 "POSTGRES_PASSWORD",
36 "POSTGRES_USER",
37 "POSTGRES_DB",
38 "POSTGRES_PORT",
39 "POSTGRES_HOST",
40 "POSTGRES_SSL",
41 "MONGO_URL",
42 "MONGO_PASSWORD",
43 "MONGO_USER",
44 "MONGO_DB",
45 "MONGO_PORT",
46 "MONGO_HOST",
47 "MONGO_SSL",
48 ]),
49 ),
50 }),
51 ),
52});
53
gio5f2f1002025-03-20 18:38:48 +040054export type InitData = {
giod0026612025-05-08 13:00:36 +000055 label: string;
56 envVars: BoundEnvVar[];
57 ports: Port[];
gio5f2f1002025-03-20 18:38:48 +040058};
59
60export type NodeData = InitData & {
giod0026612025-05-08 13:00:36 +000061 activeField?: string | undefined;
gio818da4e2025-05-12 14:45:35 +000062 state?: string | null;
gio5f2f1002025-03-20 18:38:48 +040063};
64
65export type PortConnectedTo = {
giod0026612025-05-08 13:00:36 +000066 serviceId: string;
67 portId: string;
68};
gio5f2f1002025-03-20 18:38:48 +040069
gioaba9a962025-04-25 14:19:40 +000070export type NetworkData = NodeData & {
giod0026612025-05-08 13:00:36 +000071 domain: string;
gioaba9a962025-04-25 14:19:40 +000072};
73
74export type NetworkNode = Node<NetworkData> & {
giod0026612025-05-08 13:00:36 +000075 type: "network";
gioaba9a962025-04-25 14:19:40 +000076};
77
gio5f2f1002025-03-20 18:38:48 +040078export type GatewayHttpsData = NodeData & {
gio48fde052025-05-14 09:48:08 +000079 readonly?: boolean;
giod0026612025-05-08 13:00:36 +000080 network?: string;
81 subdomain?: string;
82 https?: PortConnectedTo;
83 auth?: {
84 enabled: boolean;
85 groups: string[];
86 noAuthPathPatterns: string[];
87 };
gio5f2f1002025-03-20 18:38:48 +040088};
89
90export type GatewayHttpsNode = Node<GatewayHttpsData> & {
giod0026612025-05-08 13:00:36 +000091 type: "gateway-https";
gio5f2f1002025-03-20 18:38:48 +040092};
93
94export type GatewayTCPData = NodeData & {
gio48fde052025-05-14 09:48:08 +000095 readonly?: boolean;
giod0026612025-05-08 13:00:36 +000096 network?: string;
97 subdomain?: string;
98 exposed: PortConnectedTo[];
99 selected?: {
100 serviceId?: string;
101 portId?: string;
102 };
gio5f2f1002025-03-20 18:38:48 +0400103};
104
105export type GatewayTCPNode = Node<GatewayTCPData> & {
giod0026612025-05-08 13:00:36 +0000106 type: "gateway-tcp";
gio5f2f1002025-03-20 18:38:48 +0400107};
108
109export type Port = {
giod0026612025-05-08 13:00:36 +0000110 id: string;
111 name: string;
112 value: number;
gio5f2f1002025-03-20 18:38:48 +0400113};
114
gio91165612025-05-03 17:07:38 +0000115export const ServiceTypes = [
giod0026612025-05-08 13:00:36 +0000116 "deno:2.2.0",
117 "golang:1.20.0",
118 "golang:1.22.0",
119 "golang:1.24.0",
120 "hugo:latest",
121 "php:8.2-apache",
122 "nextjs:deno-2.0.0",
gio33046722025-05-16 14:49:55 +0000123 "nodejs:23.1.0",
giobceb0852025-05-20 13:15:18 +0400124 "nodejs:24.0.2",
gio91165612025-05-03 17:07:38 +0000125] as const;
giod0026612025-05-08 13:00:36 +0000126export type ServiceType = (typeof ServiceTypes)[number];
gio5f2f1002025-03-20 18:38:48 +0400127
gio48fde052025-05-14 09:48:08 +0000128export type Domain = {
129 network: string;
130 subdomain: string;
131};
132
gio5f2f1002025-03-20 18:38:48 +0400133export type ServiceData = NodeData & {
giod0026612025-05-08 13:00:36 +0000134 type: ServiceType;
gio3d0bf032025-06-05 06:57:26 +0000135 repository?:
giod0026612025-05-08 13:00:36 +0000136 | {
gio3d0bf032025-06-05 06:57:26 +0000137 id: number;
138 repoNodeId: string;
giod0026612025-05-08 13:00:36 +0000139 }
140 | {
gio3d0bf032025-06-05 06:57:26 +0000141 id: number;
142 repoNodeId: string;
giod0026612025-05-08 13:00:36 +0000143 branch: string;
144 }
145 | {
gio3d0bf032025-06-05 06:57:26 +0000146 id: number;
147 repoNodeId: string;
giod0026612025-05-08 13:00:36 +0000148 branch: string;
149 rootDir: string;
150 };
151 env: string[];
152 volume: string[];
153 preBuildCommands: string;
154 isChoosingPortToConnect: boolean;
gio48fde052025-05-14 09:48:08 +0000155 dev?:
156 | {
157 enabled: false;
158 expose?: Domain;
159 }
160 | {
161 enabled: true;
162 expose?: Domain;
163 codeServerNodeId: string;
164 sshNodeId: string;
165 };
gioa71316d2025-05-24 09:41:36 +0400166 info?: z.infer<typeof serviceAnalyzisSchema>;
gio5f2f1002025-03-20 18:38:48 +0400167};
168
169export type ServiceNode = Node<ServiceData> & {
giod0026612025-05-08 13:00:36 +0000170 type: "app";
gio5f2f1002025-03-20 18:38:48 +0400171};
172
173export type VolumeType = "ReadWriteOnce" | "ReadOnlyMany" | "ReadWriteMany" | "ReadWriteOncePod";
174
175export type VolumeData = NodeData & {
giod0026612025-05-08 13:00:36 +0000176 type: VolumeType;
177 size: string;
178 attachedTo: string[];
gio5f2f1002025-03-20 18:38:48 +0400179};
180
181export type VolumeNode = Node<VolumeData> & {
giod0026612025-05-08 13:00:36 +0000182 type: "volume";
gio5f2f1002025-03-20 18:38:48 +0400183};
184
185export type PostgreSQLData = NodeData & {
giod0026612025-05-08 13:00:36 +0000186 volumeId: string;
gio5f2f1002025-03-20 18:38:48 +0400187};
188
189export type PostgreSQLNode = Node<PostgreSQLData> & {
giod0026612025-05-08 13:00:36 +0000190 type: "postgresql";
gio5f2f1002025-03-20 18:38:48 +0400191};
192
193export type MongoDBData = NodeData & {
giod0026612025-05-08 13:00:36 +0000194 volumeId: string;
gio5f2f1002025-03-20 18:38:48 +0400195};
196
197export type MongoDBNode = Node<MongoDBData> & {
giod0026612025-05-08 13:00:36 +0000198 type: "mongodb";
gio5f2f1002025-03-20 18:38:48 +0400199};
200
201export type GithubData = NodeData & {
giod0026612025-05-08 13:00:36 +0000202 repository?: {
203 id: number;
204 sshURL: string;
gio818da4e2025-05-12 14:45:35 +0000205 fullName: string;
giod0026612025-05-08 13:00:36 +0000206 };
gio5f2f1002025-03-20 18:38:48 +0400207};
208
209export type GithubNode = Node<GithubData> & {
giod0026612025-05-08 13:00:36 +0000210 type: "github";
gio5f2f1002025-03-20 18:38:48 +0400211};
212
213export type NANode = Node<NodeData> & {
giod0026612025-05-08 13:00:36 +0000214 type: undefined;
gio5f2f1002025-03-20 18:38:48 +0400215};
216
giod0026612025-05-08 13:00:36 +0000217export type AppNode =
218 | NetworkNode
219 | GatewayHttpsNode
220 | GatewayTCPNode
221 | ServiceNode
222 | VolumeNode
223 | PostgreSQLNode
224 | MongoDBNode
225 | GithubNode
226 | NANode;
gio5f2f1002025-03-20 18:38:48 +0400227
228export function nodeLabel(n: AppNode): string {
gio48fde052025-05-14 09:48:08 +0000229 try {
230 switch (n.type) {
231 case "network":
232 return n.data.domain;
233 case "app":
234 return n.data.label || "Service";
235 case "github":
236 return n.data.repository?.fullName || "Github";
237 case "gateway-https": {
gio29050d62025-05-16 04:49:26 +0000238 if (n.data && n.data.subdomain) {
239 return `${n.data.subdomain}`;
gio48fde052025-05-14 09:48:08 +0000240 } else {
241 return "HTTPS Gateway";
242 }
giod0026612025-05-08 13:00:36 +0000243 }
gio48fde052025-05-14 09:48:08 +0000244 case "gateway-tcp": {
gio29050d62025-05-16 04:49:26 +0000245 if (n.data && n.data.subdomain) {
246 return `${n.data.subdomain}`;
gio48fde052025-05-14 09:48:08 +0000247 } else {
248 return "TCP Gateway";
249 }
giod0026612025-05-08 13:00:36 +0000250 }
gio48fde052025-05-14 09:48:08 +0000251 case "mongodb":
252 return n.data.label || "MongoDB";
253 case "postgresql":
254 return n.data.label || "PostgreSQL";
255 case "volume":
256 return n.data.label || "Volume";
257 case undefined:
258 throw new Error("MUST NOT REACH!");
giod0026612025-05-08 13:00:36 +0000259 }
gio48fde052025-05-14 09:48:08 +0000260 } catch (e) {
261 console.error("opaa", e);
262 } finally {
263 console.log("done");
giod0026612025-05-08 13:00:36 +0000264 }
gioa1efbad2025-05-21 07:16:45 +0000265 return "Unknown Node";
gio5f2f1002025-03-20 18:38:48 +0400266}
267
gio8fad76a2025-05-22 14:01:23 +0000268export function nodeLabelFull(n: AppNode): string {
269 if (n.type === "gateway-https") {
270 return `https://${n.data.subdomain}.${n.data.network}`;
271 } else {
272 return nodeLabel(n);
273 }
274}
275
gio5f2f1002025-03-20 18:38:48 +0400276export function nodeIsConnectable(n: AppNode, handle: string): boolean {
giod0026612025-05-08 13:00:36 +0000277 switch (n.type) {
278 case "network":
279 return true;
280 case "app":
281 if (handle === "ports") {
282 return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
283 } else if (handle === "repository") {
284 if (!n.data || !n.data.repository || !n.data.repository.id) {
285 return true;
286 }
287 return false;
288 }
289 return false;
290 case "github":
291 if (n.data.repository?.id !== undefined) {
292 return true;
293 }
294 return false;
295 case "gateway-https":
296 if (handle === "subdomain") {
297 return n.data.network === undefined;
298 }
299 return n.data === undefined || n.data.https === undefined;
300 case "gateway-tcp":
301 if (handle === "subdomain") {
302 return n.data.network === undefined;
303 }
304 return true;
305 case "mongodb":
306 return true;
307 case "postgresql":
308 return true;
309 case "volume":
310 if (n.data === undefined || n.data.type === undefined) {
311 return false;
312 }
313 if (n.data.type === "ReadWriteOnce" || n.data.type === "ReadWriteOncePod") {
314 return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
315 }
316 return true;
317 case undefined:
318 throw new Error("MUST NOT REACH!");
319 }
gio5f2f1002025-03-20 18:38:48 +0400320}
321
giod0026612025-05-08 13:00:36 +0000322export type BoundEnvVar =
323 | {
324 id: string;
325 source: string | null;
326 }
327 | {
328 id: string;
329 source: string | null;
330 name: string;
331 isEditting: boolean;
332 }
333 | {
334 id: string;
335 source: string | null;
336 name: string;
337 alias: string;
338 isEditting: boolean;
339 }
340 | {
341 id: string;
342 source: string | null;
343 portId: string;
344 name: string;
345 alias: string;
346 isEditting: boolean;
347 };
gio5f2f1002025-03-20 18:38:48 +0400348
349export type EnvVar = {
giod0026612025-05-08 13:00:36 +0000350 name: string;
351 value: string;
gio5f2f1002025-03-20 18:38:48 +0400352};
353
giob41ecae2025-04-24 08:46:50 +0000354export function nodeEnvVarNamePort(n: AppNode, portName: string): string {
giod0026612025-05-08 13:00:36 +0000355 return `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${portName.toUpperCase()}`;
giob41ecae2025-04-24 08:46:50 +0000356}
357
gio5f2f1002025-03-20 18:38:48 +0400358export function nodeEnvVarNames(n: AppNode): string[] {
giod0026612025-05-08 13:00:36 +0000359 switch (n.type) {
360 case "app":
361 return [
362 `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
363 ...(n.data.ports || []).map((p) => nodeEnvVarNamePort(n, p.name)),
364 ];
365 case "github":
366 return [];
367 case "gateway-https":
368 return [];
369 case "gateway-tcp":
370 return [];
371 case "mongodb":
372 return [`DODO_MONGODB_${n.data.label.toUpperCase()}_URL`];
373 case "postgresql":
374 return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_URL`];
375 case "volume":
376 return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
377 case undefined:
378 throw new Error("MUST NOT REACH");
379 default:
380 throw new Error("MUST NOT REACH");
381 }
gio5f2f1002025-03-20 18:38:48 +0400382}
383
384export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
385
386export type MessageType = "INFO" | "WARNING" | "FATAL";
387
388export type Message = {
giod0026612025-05-08 13:00:36 +0000389 id: string;
390 type: MessageType;
391 nodeId?: string;
392 message: string;
393 onHighlight?: (state: AppState) => void;
394 onLooseHighlight?: (state: AppState) => void;
395 onClick?: (state: AppState) => void;
gio5f2f1002025-03-20 18:38:48 +0400396};
397
giob77cb932025-05-19 09:37:14 +0000398export const accessSchema = z.discriminatedUnion("type", [
399 z.object({
400 type: z.literal("https"),
401 name: z.string(),
402 address: z.string(),
403 }),
404 z.object({
405 type: z.literal("ssh"),
406 name: z.string(),
407 host: z.string(),
408 port: z.number(),
409 }),
410 z.object({
411 type: z.literal("tcp"),
412 name: z.string(),
413 host: z.string(),
414 port: z.number(),
415 }),
416 z.object({
417 type: z.literal("udp"),
418 name: z.string(),
419 host: z.string(),
420 port: z.number(),
421 }),
422 z.object({
423 type: z.literal("postgresql"),
424 name: z.string(),
425 host: z.string(),
426 port: z.number(),
427 database: z.string(),
428 username: z.string(),
429 password: z.string(),
430 }),
431 z.object({
432 type: z.literal("mongodb"),
433 name: z.string(),
434 host: z.string(),
435 port: z.number(),
436 database: z.string(),
437 username: z.string(),
438 password: z.string(),
439 }),
440]);
441
gioa1efbad2025-05-21 07:16:45 +0000442export const serviceInfoSchema = z.object({
443 name: z.string(),
444 workers: z.array(
445 z.object({
446 id: z.string(),
gio0afbaee2025-05-22 04:34:33 +0000447 commit: z.optional(
448 z.object({
449 hash: z.string(),
450 message: z.string(),
451 }),
452 ),
gioa1efbad2025-05-21 07:16:45 +0000453 commands: z.optional(
454 z.array(
455 z.object({
456 command: z.string(),
457 state: z.string(),
458 }),
459 ),
460 ),
461 }),
462 ),
463});
464
gio5f2f1002025-03-20 18:38:48 +0400465export const envSchema = z.object({
gio7d813702025-05-08 18:29:52 +0000466 managerAddr: z.optional(z.string().min(1)),
gioa71316d2025-05-24 09:41:36 +0400467 instanceId: z.optional(z.string().min(1)),
468 deployKeyPublic: z.optional(z.nullable(z.string().min(1))),
giod0026612025-05-08 13:00:36 +0000469 networks: z
470 .array(
471 z.object({
472 name: z.string().min(1),
473 domain: z.string().min(1),
gio6d8b71c2025-05-19 12:57:35 +0000474 hasAuth: z.boolean(),
giod0026612025-05-08 13:00:36 +0000475 }),
476 )
477 .default([]),
478 integrations: z.object({
479 github: z.boolean(),
480 }),
gioa1efbad2025-05-21 07:16:45 +0000481 services: z.array(serviceInfoSchema),
gio3ed59592025-05-14 16:51:09 +0000482 user: z.object({
483 id: z.string(),
484 username: z.string(),
485 }),
giob77cb932025-05-19 09:37:14 +0000486 access: z.array(accessSchema),
gio5f2f1002025-03-20 18:38:48 +0400487});
488
gioa1efbad2025-05-21 07:16:45 +0000489export type ServiceInfo = z.infer<typeof serviceInfoSchema>;
gio5f2f1002025-03-20 18:38:48 +0400490export type Env = z.infer<typeof envSchema>;
491
gio7f98e772025-05-07 11:00:14 +0000492const defaultEnv: Env = {
gio7d813702025-05-08 18:29:52 +0000493 managerAddr: undefined,
gioa71316d2025-05-24 09:41:36 +0400494 deployKeyPublic: undefined,
495 instanceId: undefined,
giod0026612025-05-08 13:00:36 +0000496 networks: [],
497 integrations: {
498 github: false,
499 },
gio3a921b82025-05-10 07:36:09 +0000500 services: [],
gio3ed59592025-05-14 16:51:09 +0000501 user: {
502 id: "",
503 username: "",
504 },
giob77cb932025-05-19 09:37:14 +0000505 access: [],
gio7f98e772025-05-07 11:00:14 +0000506};
507
gio5f2f1002025-03-20 18:38:48 +0400508export type Project = {
giod0026612025-05-08 13:00:36 +0000509 id: string;
510 name: string;
511};
gio5f2f1002025-03-20 18:38:48 +0400512
gio7f98e772025-05-07 11:00:14 +0000513export type IntegrationsConfig = {
giod0026612025-05-08 13:00:36 +0000514 github: boolean;
gio7f98e772025-05-07 11:00:14 +0000515};
516
517type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
518type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
519
gioaf8db832025-05-13 14:43:05 +0000520type Viewport = {
521 transformX: number;
522 transformY: number;
523 transformZoom: number;
524 width: number;
525 height: number;
526};
527
gio918780d2025-05-22 08:24:41 +0000528let refreshEnvIntervalId: number | null = null;
529
gio5f2f1002025-03-20 18:38:48 +0400530export type AppState = {
giod0026612025-05-08 13:00:36 +0000531 projectId: string | undefined;
gio818da4e2025-05-12 14:45:35 +0000532 mode: "edit" | "deploy";
giod0026612025-05-08 13:00:36 +0000533 projects: Project[];
534 nodes: AppNode[];
535 edges: Edge[];
gio359a6852025-05-14 03:38:24 +0000536 zoom: ReactFlowViewport;
giod0026612025-05-08 13:00:36 +0000537 categories: Category[];
538 messages: Message[];
539 env: Env;
gioaf8db832025-05-13 14:43:05 +0000540 viewport: Viewport;
541 setViewport: (viewport: Viewport) => void;
giod0026612025-05-08 13:00:36 +0000542 githubService: GitHubService | null;
gioa71316d2025-05-24 09:41:36 +0400543 githubRepositories: GitHubRepository[];
544 githubRepositoriesLoading: boolean;
545 githubRepositoriesError: string | null;
giod0026612025-05-08 13:00:36 +0000546 setHighlightCategory: (name: string, active: boolean) => void;
547 onNodesChange: OnNodesChange<AppNode>;
548 onEdgesChange: OnEdgesChange;
549 onConnect: OnConnect;
gioaf8db832025-05-13 14:43:05 +0000550 addNode: (node: Omit<AppNode, "position">) => void;
giod0026612025-05-08 13:00:36 +0000551 setNodes: (nodes: AppNode[]) => void;
552 setEdges: (edges: Edge[]) => void;
gio818da4e2025-05-12 14:45:35 +0000553 setProject: (projectId: string | undefined) => Promise<void>;
554 setMode: (mode: "edit" | "deploy") => void;
giod0026612025-05-08 13:00:36 +0000555 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
556 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
557 replaceEdge: (c: Connection, id?: string) => void;
558 refreshEnv: () => Promise<void>;
gioa71316d2025-05-24 09:41:36 +0400559 fetchGithubRepositories: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400560};
561
562const projectIdSelector = (state: AppState) => state.projectId;
563const categoriesSelector = (state: AppState) => state.categories;
564const messagesSelector = (state: AppState) => state.messages;
gio7f98e772025-05-07 11:00:14 +0000565const githubServiceSelector = (state: AppState) => state.githubService;
gio5f2f1002025-03-20 18:38:48 +0400566const envSelector = (state: AppState) => state.env;
gio359a6852025-05-14 03:38:24 +0000567const zoomSelector = (state: AppState) => state.zoom;
gioa71316d2025-05-24 09:41:36 +0400568const githubRepositoriesSelector = (state: AppState) => state.githubRepositories;
569const githubRepositoriesLoadingSelector = (state: AppState) => state.githubRepositoriesLoading;
570const githubRepositoriesErrorSelector = (state: AppState) => state.githubRepositoriesError;
gioaf8db832025-05-13 14:43:05 +0000571
gio359a6852025-05-14 03:38:24 +0000572export function useZoom(): ReactFlowViewport {
573 return useStateStore(zoomSelector);
gioaf8db832025-05-13 14:43:05 +0000574}
gio5f2f1002025-03-20 18:38:48 +0400575
576export function useProjectId(): string | undefined {
giod0026612025-05-08 13:00:36 +0000577 return useStateStore(projectIdSelector);
gio5f2f1002025-03-20 18:38:48 +0400578}
579
giob45b1862025-05-20 11:42:20 +0000580export function useSetProject(): (projectId: string | undefined) => void {
581 return useStateStore((state) => state.setProject);
582}
583
gio5f2f1002025-03-20 18:38:48 +0400584export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000585 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400586}
587
588export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000589 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400590}
591
592export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000593 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400594}
595
596export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000597 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400598}
599
600export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000601 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400602}
603
gio5f2f1002025-03-20 18:38:48 +0400604export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000605 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000606}
607
608export function useGithubService(): GitHubService | null {
giod0026612025-05-08 13:00:36 +0000609 return useStateStore(githubServiceSelector);
gio5f2f1002025-03-20 18:38:48 +0400610}
611
gioa71316d2025-05-24 09:41:36 +0400612export function useGithubRepositories(): GitHubRepository[] {
613 return useStateStore(githubRepositoriesSelector);
614}
615
616export function useGithubRepositoriesLoading(): boolean {
617 return useStateStore(githubRepositoriesLoadingSelector);
618}
619
620export function useGithubRepositoriesError(): string | null {
621 return useStateStore(githubRepositoriesErrorSelector);
622}
623
624export function useFetchGithubRepositories(): () => Promise<void> {
625 return useStateStore((state) => state.fetchGithubRepositories);
626}
627
gio3ec94242025-05-16 12:46:57 +0000628export function useMode(): "edit" | "deploy" {
629 return useStateStore((state) => state.mode);
630}
631
gio5f2f1002025-03-20 18:38:48 +0400632const v: Validator = CreateValidators();
633
gioaf8db832025-05-13 14:43:05 +0000634function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
635 const zoomMultiplier = 1 / transformZoom;
636 const realWidth = width * zoomMultiplier;
637 const realHeight = height * zoomMultiplier;
638 const paddingMultiplier = 0.8;
639 const ret = {
640 x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
641 y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
642 };
643 return ret;
644}
645
gio3d0bf032025-06-05 06:57:26 +0000646export const useStateStore = create<AppState>((setOg, get): AppState => {
647 const set = (state: Partial<AppState>) => {
648 setOg(state);
649 };
giod0026612025-05-08 13:00:36 +0000650 const setN = (nodes: AppNode[]) => {
gio4b9b58a2025-05-12 11:46:08 +0000651 set({
giod0026612025-05-08 13:00:36 +0000652 nodes,
gio5cf364c2025-05-08 16:01:21 +0000653 messages: v(nodes),
gio4b9b58a2025-05-12 11:46:08 +0000654 });
655 };
656
gio918780d2025-05-22 08:24:41 +0000657 const startRefreshEnvInterval = () => {
658 if (refreshEnvIntervalId) {
659 clearInterval(refreshEnvIntervalId);
660 }
661 if (get().projectId && typeof document !== "undefined" && document.visibilityState === "visible") {
662 console.log("Starting refreshEnv interval for project:", get().projectId);
663 refreshEnvIntervalId = setInterval(async () => {
664 if (get().projectId && typeof document !== "undefined" && document.visibilityState === "visible") {
665 console.log("Interval: Calling refreshEnv for project:", get().projectId);
666 await get().refreshEnv();
667 } else if (refreshEnvIntervalId) {
668 console.log(
669 "Interval: Conditions not met (project removed or tab hidden), stopping interval from inside.",
670 );
671 clearInterval(refreshEnvIntervalId);
672 refreshEnvIntervalId = null;
673 }
674 }, 5000) as unknown as number;
675 } else {
676 console.log(
677 "Not starting refreshEnv interval. Project ID:",
678 get().projectId,
679 "Visibility:",
680 typeof document !== "undefined" ? document.visibilityState : "SSR",
681 );
682 }
683 };
684
685 const stopRefreshEnvInterval = () => {
686 if (refreshEnvIntervalId) {
687 console.log("Stopping refreshEnv interval for project:", get().projectId);
688 clearInterval(refreshEnvIntervalId);
689 refreshEnvIntervalId = null;
690 }
691 };
692
693 if (typeof document !== "undefined") {
694 document.addEventListener("visibilitychange", () => {
695 if (document.visibilityState === "visible") {
696 console.log("Tab became visible, attempting to start refreshEnv interval.");
697 startRefreshEnvInterval();
698 } else {
699 console.log("Tab became hidden, stopping refreshEnv interval.");
700 stopRefreshEnvInterval();
701 }
702 });
703 }
704
gio48fde052025-05-14 09:48:08 +0000705 const injectNetworkNodes = () => {
706 const newNetworks = get().env.networks.filter(
707 (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
708 );
709 newNetworks.forEach((n) => {
710 get().addNode({
711 id: n.domain,
712 type: "network",
713 connectable: true,
714 data: {
715 domain: n.domain,
716 label: n.domain,
717 envVars: [],
718 ports: [],
719 state: "success", // TODO(gio): monitor network health
720 },
721 });
722 console.log("added network", n.domain);
723 });
724 };
725
gio4b9b58a2025-05-12 11:46:08 +0000726 const restoreSaved = async () => {
gio818da4e2025-05-12 14:45:35 +0000727 const { projectId } = get();
728 const resp = await fetch(`/api/project/${projectId}/saved/${get().mode === "deploy" ? "deploy" : "draft"}`, {
gio4b9b58a2025-05-12 11:46:08 +0000729 method: "GET",
730 });
731 const inst = await resp.json();
gio48fde052025-05-14 09:48:08 +0000732 setN(inst.nodes);
733 set({ edges: inst.edges });
734 injectNetworkNodes();
gio359a6852025-05-14 03:38:24 +0000735 if (
736 get().zoom.x !== inst.viewport.x ||
737 get().zoom.y !== inst.viewport.y ||
738 get().zoom.zoom !== inst.viewport.zoom
739 ) {
740 set({ zoom: inst.viewport });
741 }
giod0026612025-05-08 13:00:36 +0000742 };
gio7f98e772025-05-07 11:00:14 +0000743
giod0026612025-05-08 13:00:36 +0000744 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
745 setN(
746 get().nodes.map((n) => {
747 if (n.id === id) {
748 return {
749 ...n,
750 data: {
751 ...n.data,
752 ...data,
753 },
754 } as Extract<AppNode, { type: T }>;
755 }
756 return n;
757 }),
758 );
759 }
gio7f98e772025-05-07 11:00:14 +0000760
giod0026612025-05-08 13:00:36 +0000761 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
762 setN(
763 get().nodes.map((n) => {
764 if (n.id === id) {
765 return {
766 ...n,
767 ...node,
768 } as Extract<AppNode, { type: T }>;
769 }
770 return n;
771 }),
772 );
773 }
gio7f98e772025-05-07 11:00:14 +0000774
giod0026612025-05-08 13:00:36 +0000775 function onConnect(c: Connection) {
776 const { nodes, edges } = get();
777 set({
778 edges: addEdge(c, edges),
779 });
780 const sn = nodes.filter((n) => n.id === c.source)[0]!;
781 const tn = nodes.filter((n) => n.id === c.target)[0]!;
782 if (tn.type === "network") {
783 if (sn.type === "gateway-https") {
784 updateNodeData<"gateway-https">(sn.id, {
785 network: tn.data.domain,
786 });
787 } else if (sn.type === "gateway-tcp") {
788 updateNodeData<"gateway-tcp">(sn.id, {
789 network: tn.data.domain,
790 });
791 }
792 }
793 if (tn.type === "app") {
794 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
795 const sourceEnvVars = nodeEnvVarNames(sn);
796 if (sourceEnvVars.length === 0) {
797 throw new Error("MUST NOT REACH!");
798 }
799 const id = uuidv4();
800 if (sourceEnvVars.length === 1) {
801 updateNode<"app">(c.target, {
802 ...tn,
803 data: {
804 ...tn.data,
805 envVars: [
806 ...(tn.data.envVars || []),
807 {
808 id: id,
809 source: c.source,
810 name: sourceEnvVars[0],
811 isEditting: false,
812 },
813 ],
814 },
815 });
816 } else {
817 updateNode<"app">(c.target, {
818 ...tn,
819 data: {
820 ...tn.data,
821 envVars: [
822 ...(tn.data.envVars || []),
823 {
824 id: id,
825 source: c.source,
826 },
827 ],
828 },
829 });
830 }
831 }
832 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
833 const sourcePorts = sn.data.ports || [];
834 const id = uuidv4();
835 if (sourcePorts.length === 1) {
836 updateNode<"app">(c.target, {
837 ...tn,
838 data: {
839 ...tn.data,
840 envVars: [
841 ...(tn.data.envVars || []),
842 {
843 id: id,
844 source: c.source,
845 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
846 portId: sourcePorts[0].id,
847 isEditting: false,
848 },
849 ],
850 },
851 });
852 }
853 }
gio3d0bf032025-06-05 06:57:26 +0000854 if (c.targetHandle === "repository") {
855 const sourceNode = nodes.find((n) => n.id === c.source);
856 if (sourceNode && sourceNode.type === "github" && sourceNode.data.repository) {
857 updateNodeData<"app">(tn.id, {
858 repository: {
859 id: sourceNode.data.repository.id,
860 repoNodeId: c.source,
861 },
862 });
863 }
864 }
giod0026612025-05-08 13:00:36 +0000865 }
866 if (c.sourceHandle === "volume") {
867 updateNodeData<"volume">(c.source, {
868 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
869 });
870 }
871 if (c.targetHandle === "volume") {
872 if (tn.type === "postgresql" || tn.type === "mongodb") {
873 updateNodeData(c.target, {
874 volumeId: c.source,
875 });
876 }
877 }
878 if (c.targetHandle === "https") {
879 if ((sn.data.ports || []).length === 1) {
880 updateNodeData<"gateway-https">(c.target, {
881 https: {
882 serviceId: c.source,
883 portId: sn.data.ports![0].id,
884 },
885 });
886 } else {
887 updateNodeData<"gateway-https">(c.target, {
888 https: {
889 serviceId: c.source,
890 portId: "", // TODO(gio)
891 },
892 });
893 }
894 }
895 if (c.targetHandle === "tcp") {
896 const td = tn.data as GatewayTCPData;
897 if ((sn.data.ports || []).length === 1) {
898 updateNodeData<"gateway-tcp">(c.target, {
899 exposed: (td.exposed || []).concat({
900 serviceId: c.source,
901 portId: sn.data.ports![0].id,
902 }),
903 });
904 } else {
905 updateNodeData<"gateway-tcp">(c.target, {
906 selected: {
907 serviceId: c.source,
908 portId: undefined,
909 },
910 });
911 }
912 }
913 if (sn.type === "app") {
914 if (c.sourceHandle === "ports") {
915 updateNodeData<"app">(sn.id, {
916 isChoosingPortToConnect: true,
917 });
918 }
919 }
giod0026612025-05-08 13:00:36 +0000920 }
gioa71316d2025-05-24 09:41:36 +0400921
922 const fetchGithubRepositories = async () => {
923 const { githubService, projectId } = get();
924 if (!githubService || !projectId) {
925 set({
926 githubRepositories: [],
927 githubRepositoriesError: "GitHub service or Project ID not available.",
928 githubRepositoriesLoading: false,
929 });
930 return;
931 }
932
933 set({ githubRepositoriesLoading: true, githubRepositoriesError: null });
934 try {
935 const repos = await githubService.getRepositories();
936 set({ githubRepositories: repos, githubRepositoriesLoading: false });
937 } catch (error) {
938 console.error("Failed to fetch GitHub repositories in store:", error);
939 const errorMessage = error instanceof Error ? error.message : "Unknown error fetching repositories";
940 set({ githubRepositories: [], githubRepositoriesError: errorMessage, githubRepositoriesLoading: false });
941 }
942 };
943
giod0026612025-05-08 13:00:36 +0000944 return {
945 projectId: undefined,
gio818da4e2025-05-12 14:45:35 +0000946 mode: "edit",
giod0026612025-05-08 13:00:36 +0000947 projects: [],
948 nodes: [],
949 edges: [],
950 categories: defaultCategories,
951 messages: v([]),
952 env: defaultEnv,
gioaf8db832025-05-13 14:43:05 +0000953 viewport: {
954 transformX: 0,
955 transformY: 0,
956 transformZoom: 1,
957 width: 800,
958 height: 600,
959 },
gio359a6852025-05-14 03:38:24 +0000960 zoom: {
961 x: 0,
962 y: 0,
963 zoom: 1,
964 },
giod0026612025-05-08 13:00:36 +0000965 githubService: null,
gioa71316d2025-05-24 09:41:36 +0400966 githubRepositories: [],
967 githubRepositoriesLoading: false,
968 githubRepositoriesError: null,
gioaf8db832025-05-13 14:43:05 +0000969 setViewport: (viewport) => {
970 const { viewport: vp } = get();
971 if (
972 viewport.transformX !== vp.transformX ||
973 viewport.transformY !== vp.transformY ||
974 viewport.transformZoom !== vp.transformZoom ||
975 viewport.width !== vp.width ||
976 viewport.height !== vp.height
977 ) {
978 set({ viewport });
979 }
980 },
giod0026612025-05-08 13:00:36 +0000981 setHighlightCategory: (name, active) => {
982 set({
983 categories: get().categories.map((c) => {
984 if (c.title.toLowerCase() !== name.toLowerCase()) {
985 return c;
986 } else {
987 return {
988 ...c,
989 active,
990 };
991 }
992 }),
993 });
994 },
995 onNodesChange: (changes) => {
996 const nodes = applyNodeChanges(changes, get().nodes);
997 setN(nodes);
998 },
999 onEdgesChange: (changes) => {
1000 set({
1001 edges: applyEdgeChanges(changes, get().edges),
1002 });
1003 },
gioaf8db832025-05-13 14:43:05 +00001004 addNode: (node) => {
1005 const { viewport, nodes } = get();
1006 setN(
1007 nodes.concat({
1008 ...node,
1009 position: getRandomPosition(viewport),
gioa1efbad2025-05-21 07:16:45 +00001010 } as AppNode),
gioaf8db832025-05-13 14:43:05 +00001011 );
1012 },
giod0026612025-05-08 13:00:36 +00001013 setNodes: (nodes) => {
1014 setN(nodes);
1015 },
1016 setEdges: (edges) => {
1017 set({ edges });
1018 },
1019 replaceEdge: (c, id) => {
1020 let change: EdgeChange;
1021 if (id === undefined) {
1022 change = {
1023 type: "add",
1024 item: {
1025 id: uuidv4(),
1026 ...c,
1027 },
1028 };
1029 onConnect(c);
1030 } else {
1031 change = {
1032 type: "replace",
1033 id,
1034 item: {
1035 id,
1036 ...c,
1037 },
1038 };
1039 }
1040 set({
1041 edges: applyEdgeChanges([change], get().edges),
1042 });
1043 },
1044 updateNode,
1045 updateNodeData,
1046 onConnect,
1047 refreshEnv: async () => {
1048 const projectId = get().projectId;
1049 let env: Env = defaultEnv;
giod0026612025-05-08 13:00:36 +00001050 try {
1051 if (projectId) {
1052 const response = await fetch(`/api/project/${projectId}/env`);
1053 if (response.ok) {
1054 const data = await response.json();
1055 const result = envSchema.safeParse(data);
1056 if (result.success) {
1057 env = result.data;
1058 } else {
1059 console.error("Invalid env data:", result.error);
1060 }
1061 }
1062 }
1063 } catch (error) {
1064 console.error("Failed to fetch integrations:", error);
1065 } finally {
gioa71316d2025-05-24 09:41:36 +04001066 const oldEnv = get().env;
1067 const oldGithubIntegrationStatus = oldEnv.integrations.github;
1068 if (JSON.stringify(oldEnv) !== JSON.stringify(env)) {
gio4b9b58a2025-05-12 11:46:08 +00001069 set({ env });
gio48fde052025-05-14 09:48:08 +00001070 injectNetworkNodes();
gioa71316d2025-05-24 09:41:36 +04001071 let ghService = null;
gio4b9b58a2025-05-12 11:46:08 +00001072 if (env.integrations.github) {
gioa71316d2025-05-24 09:41:36 +04001073 ghService = new GitHubServiceImpl(projectId!);
1074 }
1075 if (get().githubService !== ghService || (ghService && !get().githubService)) {
1076 set({ githubService: ghService });
1077 }
1078 if (
1079 ghService &&
1080 (oldGithubIntegrationStatus !== env.integrations.github || !oldEnv.integrations.github)
1081 ) {
1082 get().fetchGithubRepositories();
1083 }
1084 if (!env.integrations.github) {
1085 set({
1086 githubRepositories: [],
1087 githubRepositoriesError: null,
1088 githubRepositoriesLoading: false,
1089 });
gio4b9b58a2025-05-12 11:46:08 +00001090 }
giod0026612025-05-08 13:00:36 +00001091 }
1092 }
1093 },
gio818da4e2025-05-12 14:45:35 +00001094 setMode: (mode) => {
1095 set({ mode });
1096 },
1097 setProject: async (projectId) => {
gio918780d2025-05-22 08:24:41 +00001098 const currentProjectId = get().projectId;
1099 if (projectId === currentProjectId) {
gio359a6852025-05-14 03:38:24 +00001100 return;
1101 }
gio918780d2025-05-22 08:24:41 +00001102 stopRefreshEnvInterval();
giod0026612025-05-08 13:00:36 +00001103 set({
1104 projectId,
gioa71316d2025-05-24 09:41:36 +04001105 githubRepositories: [],
1106 githubRepositoriesLoading: false,
1107 githubRepositoriesError: null,
giod0026612025-05-08 13:00:36 +00001108 });
1109 if (projectId) {
gio818da4e2025-05-12 14:45:35 +00001110 await get().refreshEnv();
gioa71316d2025-05-24 09:41:36 +04001111 if (get().env.instanceId) {
gio818da4e2025-05-12 14:45:35 +00001112 set({ mode: "deploy" });
1113 } else {
1114 set({ mode: "edit" });
1115 }
gio4b9b58a2025-05-12 11:46:08 +00001116 restoreSaved();
gio918780d2025-05-22 08:24:41 +00001117 startRefreshEnvInterval();
gio4b9b58a2025-05-12 11:46:08 +00001118 } else {
1119 set({
1120 nodes: [],
1121 edges: [],
gio918780d2025-05-22 08:24:41 +00001122 env: defaultEnv,
1123 githubService: null,
gioa71316d2025-05-24 09:41:36 +04001124 githubRepositories: [],
1125 githubRepositoriesLoading: false,
1126 githubRepositoriesError: null,
gio4b9b58a2025-05-12 11:46:08 +00001127 });
giod0026612025-05-08 13:00:36 +00001128 }
1129 },
gioa71316d2025-05-24 09:41:36 +04001130 fetchGithubRepositories: fetchGithubRepositories,
giod0026612025-05-08 13:00:36 +00001131 };
gio5f2f1002025-03-20 18:38:48 +04001132});