blob: 5d8013d3fb458983e4e449627769b00dcf52f527 [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;
135 repository:
136 | {
137 id: string;
138 }
139 | {
140 id: string;
141 branch: string;
142 }
143 | {
144 id: string;
145 branch: string;
146 rootDir: string;
147 };
148 env: string[];
149 volume: string[];
150 preBuildCommands: string;
151 isChoosingPortToConnect: boolean;
gio48fde052025-05-14 09:48:08 +0000152 dev?:
153 | {
154 enabled: false;
155 expose?: Domain;
156 }
157 | {
158 enabled: true;
159 expose?: Domain;
160 codeServerNodeId: string;
161 sshNodeId: string;
162 };
gioa71316d2025-05-24 09:41:36 +0400163 info?: z.infer<typeof serviceAnalyzisSchema>;
gio5f2f1002025-03-20 18:38:48 +0400164};
165
166export type ServiceNode = Node<ServiceData> & {
giod0026612025-05-08 13:00:36 +0000167 type: "app";
gio5f2f1002025-03-20 18:38:48 +0400168};
169
170export type VolumeType = "ReadWriteOnce" | "ReadOnlyMany" | "ReadWriteMany" | "ReadWriteOncePod";
171
172export type VolumeData = NodeData & {
giod0026612025-05-08 13:00:36 +0000173 type: VolumeType;
174 size: string;
175 attachedTo: string[];
gio5f2f1002025-03-20 18:38:48 +0400176};
177
178export type VolumeNode = Node<VolumeData> & {
giod0026612025-05-08 13:00:36 +0000179 type: "volume";
gio5f2f1002025-03-20 18:38:48 +0400180};
181
182export type PostgreSQLData = NodeData & {
giod0026612025-05-08 13:00:36 +0000183 volumeId: string;
gio5f2f1002025-03-20 18:38:48 +0400184};
185
186export type PostgreSQLNode = Node<PostgreSQLData> & {
giod0026612025-05-08 13:00:36 +0000187 type: "postgresql";
gio5f2f1002025-03-20 18:38:48 +0400188};
189
190export type MongoDBData = NodeData & {
giod0026612025-05-08 13:00:36 +0000191 volumeId: string;
gio5f2f1002025-03-20 18:38:48 +0400192};
193
194export type MongoDBNode = Node<MongoDBData> & {
giod0026612025-05-08 13:00:36 +0000195 type: "mongodb";
gio5f2f1002025-03-20 18:38:48 +0400196};
197
198export type GithubData = NodeData & {
giod0026612025-05-08 13:00:36 +0000199 repository?: {
200 id: number;
201 sshURL: string;
gio818da4e2025-05-12 14:45:35 +0000202 fullName: string;
giod0026612025-05-08 13:00:36 +0000203 };
gio5f2f1002025-03-20 18:38:48 +0400204};
205
206export type GithubNode = Node<GithubData> & {
giod0026612025-05-08 13:00:36 +0000207 type: "github";
gio5f2f1002025-03-20 18:38:48 +0400208};
209
210export type NANode = Node<NodeData> & {
giod0026612025-05-08 13:00:36 +0000211 type: undefined;
gio5f2f1002025-03-20 18:38:48 +0400212};
213
giod0026612025-05-08 13:00:36 +0000214export type AppNode =
215 | NetworkNode
216 | GatewayHttpsNode
217 | GatewayTCPNode
218 | ServiceNode
219 | VolumeNode
220 | PostgreSQLNode
221 | MongoDBNode
222 | GithubNode
223 | NANode;
gio5f2f1002025-03-20 18:38:48 +0400224
225export function nodeLabel(n: AppNode): string {
gio48fde052025-05-14 09:48:08 +0000226 try {
227 switch (n.type) {
228 case "network":
229 return n.data.domain;
230 case "app":
231 return n.data.label || "Service";
232 case "github":
233 return n.data.repository?.fullName || "Github";
234 case "gateway-https": {
gio29050d62025-05-16 04:49:26 +0000235 if (n.data && n.data.subdomain) {
236 return `${n.data.subdomain}`;
gio48fde052025-05-14 09:48:08 +0000237 } else {
238 return "HTTPS Gateway";
239 }
giod0026612025-05-08 13:00:36 +0000240 }
gio48fde052025-05-14 09:48:08 +0000241 case "gateway-tcp": {
gio29050d62025-05-16 04:49:26 +0000242 if (n.data && n.data.subdomain) {
243 return `${n.data.subdomain}`;
gio48fde052025-05-14 09:48:08 +0000244 } else {
245 return "TCP Gateway";
246 }
giod0026612025-05-08 13:00:36 +0000247 }
gio48fde052025-05-14 09:48:08 +0000248 case "mongodb":
249 return n.data.label || "MongoDB";
250 case "postgresql":
251 return n.data.label || "PostgreSQL";
252 case "volume":
253 return n.data.label || "Volume";
254 case undefined:
255 throw new Error("MUST NOT REACH!");
giod0026612025-05-08 13:00:36 +0000256 }
gio48fde052025-05-14 09:48:08 +0000257 } catch (e) {
258 console.error("opaa", e);
259 } finally {
260 console.log("done");
giod0026612025-05-08 13:00:36 +0000261 }
gioa1efbad2025-05-21 07:16:45 +0000262 return "Unknown Node";
gio5f2f1002025-03-20 18:38:48 +0400263}
264
gio8fad76a2025-05-22 14:01:23 +0000265export function nodeLabelFull(n: AppNode): string {
266 if (n.type === "gateway-https") {
267 return `https://${n.data.subdomain}.${n.data.network}`;
268 } else {
269 return nodeLabel(n);
270 }
271}
272
gio5f2f1002025-03-20 18:38:48 +0400273export function nodeIsConnectable(n: AppNode, handle: string): boolean {
giod0026612025-05-08 13:00:36 +0000274 switch (n.type) {
275 case "network":
276 return true;
277 case "app":
278 if (handle === "ports") {
279 return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
280 } else if (handle === "repository") {
281 if (!n.data || !n.data.repository || !n.data.repository.id) {
282 return true;
283 }
284 return false;
285 }
286 return false;
287 case "github":
288 if (n.data.repository?.id !== undefined) {
289 return true;
290 }
291 return false;
292 case "gateway-https":
293 if (handle === "subdomain") {
294 return n.data.network === undefined;
295 }
296 return n.data === undefined || n.data.https === undefined;
297 case "gateway-tcp":
298 if (handle === "subdomain") {
299 return n.data.network === undefined;
300 }
301 return true;
302 case "mongodb":
303 return true;
304 case "postgresql":
305 return true;
306 case "volume":
307 if (n.data === undefined || n.data.type === undefined) {
308 return false;
309 }
310 if (n.data.type === "ReadWriteOnce" || n.data.type === "ReadWriteOncePod") {
311 return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
312 }
313 return true;
314 case undefined:
315 throw new Error("MUST NOT REACH!");
316 }
gio5f2f1002025-03-20 18:38:48 +0400317}
318
giod0026612025-05-08 13:00:36 +0000319export type BoundEnvVar =
320 | {
321 id: string;
322 source: string | null;
323 }
324 | {
325 id: string;
326 source: string | null;
327 name: string;
328 isEditting: boolean;
329 }
330 | {
331 id: string;
332 source: string | null;
333 name: string;
334 alias: string;
335 isEditting: boolean;
336 }
337 | {
338 id: string;
339 source: string | null;
340 portId: string;
341 name: string;
342 alias: string;
343 isEditting: boolean;
344 };
gio5f2f1002025-03-20 18:38:48 +0400345
346export type EnvVar = {
giod0026612025-05-08 13:00:36 +0000347 name: string;
348 value: string;
gio5f2f1002025-03-20 18:38:48 +0400349};
350
giob41ecae2025-04-24 08:46:50 +0000351export function nodeEnvVarNamePort(n: AppNode, portName: string): string {
giod0026612025-05-08 13:00:36 +0000352 return `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${portName.toUpperCase()}`;
giob41ecae2025-04-24 08:46:50 +0000353}
354
gio5f2f1002025-03-20 18:38:48 +0400355export function nodeEnvVarNames(n: AppNode): string[] {
giod0026612025-05-08 13:00:36 +0000356 switch (n.type) {
357 case "app":
358 return [
359 `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
360 ...(n.data.ports || []).map((p) => nodeEnvVarNamePort(n, p.name)),
361 ];
362 case "github":
363 return [];
364 case "gateway-https":
365 return [];
366 case "gateway-tcp":
367 return [];
368 case "mongodb":
369 return [`DODO_MONGODB_${n.data.label.toUpperCase()}_URL`];
370 case "postgresql":
371 return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_URL`];
372 case "volume":
373 return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
374 case undefined:
375 throw new Error("MUST NOT REACH");
376 default:
377 throw new Error("MUST NOT REACH");
378 }
gio5f2f1002025-03-20 18:38:48 +0400379}
380
381export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
382
383export type MessageType = "INFO" | "WARNING" | "FATAL";
384
385export type Message = {
giod0026612025-05-08 13:00:36 +0000386 id: string;
387 type: MessageType;
388 nodeId?: string;
389 message: string;
390 onHighlight?: (state: AppState) => void;
391 onLooseHighlight?: (state: AppState) => void;
392 onClick?: (state: AppState) => void;
gio5f2f1002025-03-20 18:38:48 +0400393};
394
giob77cb932025-05-19 09:37:14 +0000395export const accessSchema = z.discriminatedUnion("type", [
396 z.object({
397 type: z.literal("https"),
398 name: z.string(),
399 address: z.string(),
400 }),
401 z.object({
402 type: z.literal("ssh"),
403 name: z.string(),
404 host: z.string(),
405 port: z.number(),
406 }),
407 z.object({
408 type: z.literal("tcp"),
409 name: z.string(),
410 host: z.string(),
411 port: z.number(),
412 }),
413 z.object({
414 type: z.literal("udp"),
415 name: z.string(),
416 host: z.string(),
417 port: z.number(),
418 }),
419 z.object({
420 type: z.literal("postgresql"),
421 name: z.string(),
422 host: z.string(),
423 port: z.number(),
424 database: z.string(),
425 username: z.string(),
426 password: z.string(),
427 }),
428 z.object({
429 type: z.literal("mongodb"),
430 name: z.string(),
431 host: z.string(),
432 port: z.number(),
433 database: z.string(),
434 username: z.string(),
435 password: z.string(),
436 }),
437]);
438
gioa1efbad2025-05-21 07:16:45 +0000439export const serviceInfoSchema = z.object({
440 name: z.string(),
441 workers: z.array(
442 z.object({
443 id: z.string(),
gio0afbaee2025-05-22 04:34:33 +0000444 commit: z.optional(
445 z.object({
446 hash: z.string(),
447 message: z.string(),
448 }),
449 ),
gioa1efbad2025-05-21 07:16:45 +0000450 commands: z.optional(
451 z.array(
452 z.object({
453 command: z.string(),
454 state: z.string(),
455 }),
456 ),
457 ),
458 }),
459 ),
460});
461
gio5f2f1002025-03-20 18:38:48 +0400462export const envSchema = z.object({
gio7d813702025-05-08 18:29:52 +0000463 managerAddr: z.optional(z.string().min(1)),
gioa71316d2025-05-24 09:41:36 +0400464 instanceId: z.optional(z.string().min(1)),
465 deployKeyPublic: z.optional(z.nullable(z.string().min(1))),
giod0026612025-05-08 13:00:36 +0000466 networks: z
467 .array(
468 z.object({
469 name: z.string().min(1),
470 domain: z.string().min(1),
gio6d8b71c2025-05-19 12:57:35 +0000471 hasAuth: z.boolean(),
giod0026612025-05-08 13:00:36 +0000472 }),
473 )
474 .default([]),
475 integrations: z.object({
476 github: z.boolean(),
477 }),
gioa1efbad2025-05-21 07:16:45 +0000478 services: z.array(serviceInfoSchema),
gio3ed59592025-05-14 16:51:09 +0000479 user: z.object({
480 id: z.string(),
481 username: z.string(),
482 }),
giob77cb932025-05-19 09:37:14 +0000483 access: z.array(accessSchema),
gio5f2f1002025-03-20 18:38:48 +0400484});
485
gioa1efbad2025-05-21 07:16:45 +0000486export type ServiceInfo = z.infer<typeof serviceInfoSchema>;
gio5f2f1002025-03-20 18:38:48 +0400487export type Env = z.infer<typeof envSchema>;
488
gio7f98e772025-05-07 11:00:14 +0000489const defaultEnv: Env = {
gio7d813702025-05-08 18:29:52 +0000490 managerAddr: undefined,
gioa71316d2025-05-24 09:41:36 +0400491 deployKeyPublic: undefined,
492 instanceId: undefined,
giod0026612025-05-08 13:00:36 +0000493 networks: [],
494 integrations: {
495 github: false,
496 },
gio3a921b82025-05-10 07:36:09 +0000497 services: [],
gio3ed59592025-05-14 16:51:09 +0000498 user: {
499 id: "",
500 username: "",
501 },
giob77cb932025-05-19 09:37:14 +0000502 access: [],
gio7f98e772025-05-07 11:00:14 +0000503};
504
gio5f2f1002025-03-20 18:38:48 +0400505export type Project = {
giod0026612025-05-08 13:00:36 +0000506 id: string;
507 name: string;
508};
gio5f2f1002025-03-20 18:38:48 +0400509
gio7f98e772025-05-07 11:00:14 +0000510export type IntegrationsConfig = {
giod0026612025-05-08 13:00:36 +0000511 github: boolean;
gio7f98e772025-05-07 11:00:14 +0000512};
513
514type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
515type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
516
gioaf8db832025-05-13 14:43:05 +0000517type Viewport = {
518 transformX: number;
519 transformY: number;
520 transformZoom: number;
521 width: number;
522 height: number;
523};
524
gio918780d2025-05-22 08:24:41 +0000525let refreshEnvIntervalId: number | null = null;
526
gio5f2f1002025-03-20 18:38:48 +0400527export type AppState = {
giod0026612025-05-08 13:00:36 +0000528 projectId: string | undefined;
gio818da4e2025-05-12 14:45:35 +0000529 mode: "edit" | "deploy";
giod0026612025-05-08 13:00:36 +0000530 projects: Project[];
531 nodes: AppNode[];
532 edges: Edge[];
gio359a6852025-05-14 03:38:24 +0000533 zoom: ReactFlowViewport;
giod0026612025-05-08 13:00:36 +0000534 categories: Category[];
535 messages: Message[];
536 env: Env;
gioaf8db832025-05-13 14:43:05 +0000537 viewport: Viewport;
538 setViewport: (viewport: Viewport) => void;
giod0026612025-05-08 13:00:36 +0000539 githubService: GitHubService | null;
gioa71316d2025-05-24 09:41:36 +0400540 githubRepositories: GitHubRepository[];
541 githubRepositoriesLoading: boolean;
542 githubRepositoriesError: string | null;
giod0026612025-05-08 13:00:36 +0000543 setHighlightCategory: (name: string, active: boolean) => void;
544 onNodesChange: OnNodesChange<AppNode>;
545 onEdgesChange: OnEdgesChange;
546 onConnect: OnConnect;
gioaf8db832025-05-13 14:43:05 +0000547 addNode: (node: Omit<AppNode, "position">) => void;
giod0026612025-05-08 13:00:36 +0000548 setNodes: (nodes: AppNode[]) => void;
549 setEdges: (edges: Edge[]) => void;
gio818da4e2025-05-12 14:45:35 +0000550 setProject: (projectId: string | undefined) => Promise<void>;
551 setMode: (mode: "edit" | "deploy") => void;
giod0026612025-05-08 13:00:36 +0000552 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
553 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
554 replaceEdge: (c: Connection, id?: string) => void;
555 refreshEnv: () => Promise<void>;
gioa71316d2025-05-24 09:41:36 +0400556 fetchGithubRepositories: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400557};
558
559const projectIdSelector = (state: AppState) => state.projectId;
560const categoriesSelector = (state: AppState) => state.categories;
561const messagesSelector = (state: AppState) => state.messages;
gio7f98e772025-05-07 11:00:14 +0000562const githubServiceSelector = (state: AppState) => state.githubService;
gio5f2f1002025-03-20 18:38:48 +0400563const envSelector = (state: AppState) => state.env;
gio359a6852025-05-14 03:38:24 +0000564const zoomSelector = (state: AppState) => state.zoom;
gioa71316d2025-05-24 09:41:36 +0400565const githubRepositoriesSelector = (state: AppState) => state.githubRepositories;
566const githubRepositoriesLoadingSelector = (state: AppState) => state.githubRepositoriesLoading;
567const githubRepositoriesErrorSelector = (state: AppState) => state.githubRepositoriesError;
gioaf8db832025-05-13 14:43:05 +0000568
gio359a6852025-05-14 03:38:24 +0000569export function useZoom(): ReactFlowViewport {
570 return useStateStore(zoomSelector);
gioaf8db832025-05-13 14:43:05 +0000571}
gio5f2f1002025-03-20 18:38:48 +0400572
573export function useProjectId(): string | undefined {
giod0026612025-05-08 13:00:36 +0000574 return useStateStore(projectIdSelector);
gio5f2f1002025-03-20 18:38:48 +0400575}
576
giob45b1862025-05-20 11:42:20 +0000577export function useSetProject(): (projectId: string | undefined) => void {
578 return useStateStore((state) => state.setProject);
579}
580
gio5f2f1002025-03-20 18:38:48 +0400581export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000582 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400583}
584
585export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000586 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400587}
588
589export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000590 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400591}
592
593export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000594 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400595}
596
597export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000598 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400599}
600
gio5f2f1002025-03-20 18:38:48 +0400601export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000602 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000603}
604
605export function useGithubService(): GitHubService | null {
giod0026612025-05-08 13:00:36 +0000606 return useStateStore(githubServiceSelector);
gio5f2f1002025-03-20 18:38:48 +0400607}
608
gioa71316d2025-05-24 09:41:36 +0400609export function useGithubRepositories(): GitHubRepository[] {
610 return useStateStore(githubRepositoriesSelector);
611}
612
613export function useGithubRepositoriesLoading(): boolean {
614 return useStateStore(githubRepositoriesLoadingSelector);
615}
616
617export function useGithubRepositoriesError(): string | null {
618 return useStateStore(githubRepositoriesErrorSelector);
619}
620
621export function useFetchGithubRepositories(): () => Promise<void> {
622 return useStateStore((state) => state.fetchGithubRepositories);
623}
624
gio3ec94242025-05-16 12:46:57 +0000625export function useMode(): "edit" | "deploy" {
626 return useStateStore((state) => state.mode);
627}
628
gio5f2f1002025-03-20 18:38:48 +0400629const v: Validator = CreateValidators();
630
gioaf8db832025-05-13 14:43:05 +0000631function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
632 const zoomMultiplier = 1 / transformZoom;
633 const realWidth = width * zoomMultiplier;
634 const realHeight = height * zoomMultiplier;
635 const paddingMultiplier = 0.8;
636 const ret = {
637 x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
638 y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
639 };
640 return ret;
641}
642
gio5f2f1002025-03-20 18:38:48 +0400643export const useStateStore = create<AppState>((set, get): AppState => {
giod0026612025-05-08 13:00:36 +0000644 const setN = (nodes: AppNode[]) => {
gio4b9b58a2025-05-12 11:46:08 +0000645 set({
giod0026612025-05-08 13:00:36 +0000646 nodes,
gio5cf364c2025-05-08 16:01:21 +0000647 messages: v(nodes),
gio4b9b58a2025-05-12 11:46:08 +0000648 });
649 };
650
gio918780d2025-05-22 08:24:41 +0000651 const startRefreshEnvInterval = () => {
652 if (refreshEnvIntervalId) {
653 clearInterval(refreshEnvIntervalId);
654 }
655 if (get().projectId && typeof document !== "undefined" && document.visibilityState === "visible") {
656 console.log("Starting refreshEnv interval for project:", get().projectId);
657 refreshEnvIntervalId = setInterval(async () => {
658 if (get().projectId && typeof document !== "undefined" && document.visibilityState === "visible") {
659 console.log("Interval: Calling refreshEnv for project:", get().projectId);
660 await get().refreshEnv();
661 } else if (refreshEnvIntervalId) {
662 console.log(
663 "Interval: Conditions not met (project removed or tab hidden), stopping interval from inside.",
664 );
665 clearInterval(refreshEnvIntervalId);
666 refreshEnvIntervalId = null;
667 }
668 }, 5000) as unknown as number;
669 } else {
670 console.log(
671 "Not starting refreshEnv interval. Project ID:",
672 get().projectId,
673 "Visibility:",
674 typeof document !== "undefined" ? document.visibilityState : "SSR",
675 );
676 }
677 };
678
679 const stopRefreshEnvInterval = () => {
680 if (refreshEnvIntervalId) {
681 console.log("Stopping refreshEnv interval for project:", get().projectId);
682 clearInterval(refreshEnvIntervalId);
683 refreshEnvIntervalId = null;
684 }
685 };
686
687 if (typeof document !== "undefined") {
688 document.addEventListener("visibilitychange", () => {
689 if (document.visibilityState === "visible") {
690 console.log("Tab became visible, attempting to start refreshEnv interval.");
691 startRefreshEnvInterval();
692 } else {
693 console.log("Tab became hidden, stopping refreshEnv interval.");
694 stopRefreshEnvInterval();
695 }
696 });
697 }
698
gio48fde052025-05-14 09:48:08 +0000699 const injectNetworkNodes = () => {
700 const newNetworks = get().env.networks.filter(
701 (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
702 );
703 newNetworks.forEach((n) => {
704 get().addNode({
705 id: n.domain,
706 type: "network",
707 connectable: true,
708 data: {
709 domain: n.domain,
710 label: n.domain,
711 envVars: [],
712 ports: [],
713 state: "success", // TODO(gio): monitor network health
714 },
715 });
716 console.log("added network", n.domain);
717 });
718 };
719
gio4b9b58a2025-05-12 11:46:08 +0000720 const restoreSaved = async () => {
gio818da4e2025-05-12 14:45:35 +0000721 const { projectId } = get();
722 const resp = await fetch(`/api/project/${projectId}/saved/${get().mode === "deploy" ? "deploy" : "draft"}`, {
gio4b9b58a2025-05-12 11:46:08 +0000723 method: "GET",
724 });
725 const inst = await resp.json();
gio48fde052025-05-14 09:48:08 +0000726 setN(inst.nodes);
727 set({ edges: inst.edges });
728 injectNetworkNodes();
gio359a6852025-05-14 03:38:24 +0000729 if (
730 get().zoom.x !== inst.viewport.x ||
731 get().zoom.y !== inst.viewport.y ||
732 get().zoom.zoom !== inst.viewport.zoom
733 ) {
734 set({ zoom: inst.viewport });
735 }
giod0026612025-05-08 13:00:36 +0000736 };
gio7f98e772025-05-07 11:00:14 +0000737
giod0026612025-05-08 13:00:36 +0000738 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
739 setN(
740 get().nodes.map((n) => {
741 if (n.id === id) {
742 return {
743 ...n,
744 data: {
745 ...n.data,
746 ...data,
747 },
748 } as Extract<AppNode, { type: T }>;
749 }
750 return n;
751 }),
752 );
753 }
gio7f98e772025-05-07 11:00:14 +0000754
giod0026612025-05-08 13:00:36 +0000755 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
756 setN(
757 get().nodes.map((n) => {
758 if (n.id === id) {
759 return {
760 ...n,
761 ...node,
762 } as Extract<AppNode, { type: T }>;
763 }
764 return n;
765 }),
766 );
767 }
gio7f98e772025-05-07 11:00:14 +0000768
giod0026612025-05-08 13:00:36 +0000769 function onConnect(c: Connection) {
770 const { nodes, edges } = get();
771 set({
772 edges: addEdge(c, edges),
773 });
774 const sn = nodes.filter((n) => n.id === c.source)[0]!;
775 const tn = nodes.filter((n) => n.id === c.target)[0]!;
776 if (tn.type === "network") {
777 if (sn.type === "gateway-https") {
778 updateNodeData<"gateway-https">(sn.id, {
779 network: tn.data.domain,
780 });
781 } else if (sn.type === "gateway-tcp") {
782 updateNodeData<"gateway-tcp">(sn.id, {
783 network: tn.data.domain,
784 });
785 }
786 }
787 if (tn.type === "app") {
788 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
789 const sourceEnvVars = nodeEnvVarNames(sn);
790 if (sourceEnvVars.length === 0) {
791 throw new Error("MUST NOT REACH!");
792 }
793 const id = uuidv4();
794 if (sourceEnvVars.length === 1) {
795 updateNode<"app">(c.target, {
796 ...tn,
797 data: {
798 ...tn.data,
799 envVars: [
800 ...(tn.data.envVars || []),
801 {
802 id: id,
803 source: c.source,
804 name: sourceEnvVars[0],
805 isEditting: false,
806 },
807 ],
808 },
809 });
810 } else {
811 updateNode<"app">(c.target, {
812 ...tn,
813 data: {
814 ...tn.data,
815 envVars: [
816 ...(tn.data.envVars || []),
817 {
818 id: id,
819 source: c.source,
820 },
821 ],
822 },
823 });
824 }
825 }
826 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
827 const sourcePorts = sn.data.ports || [];
828 const id = uuidv4();
829 if (sourcePorts.length === 1) {
830 updateNode<"app">(c.target, {
831 ...tn,
832 data: {
833 ...tn.data,
834 envVars: [
835 ...(tn.data.envVars || []),
836 {
837 id: id,
838 source: c.source,
839 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
840 portId: sourcePorts[0].id,
841 isEditting: false,
842 },
843 ],
844 },
845 });
846 }
847 }
848 }
849 if (c.sourceHandle === "volume") {
850 updateNodeData<"volume">(c.source, {
851 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
852 });
853 }
854 if (c.targetHandle === "volume") {
855 if (tn.type === "postgresql" || tn.type === "mongodb") {
856 updateNodeData(c.target, {
857 volumeId: c.source,
858 });
859 }
860 }
861 if (c.targetHandle === "https") {
862 if ((sn.data.ports || []).length === 1) {
863 updateNodeData<"gateway-https">(c.target, {
864 https: {
865 serviceId: c.source,
866 portId: sn.data.ports![0].id,
867 },
868 });
869 } else {
870 updateNodeData<"gateway-https">(c.target, {
871 https: {
872 serviceId: c.source,
873 portId: "", // TODO(gio)
874 },
875 });
876 }
877 }
878 if (c.targetHandle === "tcp") {
879 const td = tn.data as GatewayTCPData;
880 if ((sn.data.ports || []).length === 1) {
881 updateNodeData<"gateway-tcp">(c.target, {
882 exposed: (td.exposed || []).concat({
883 serviceId: c.source,
884 portId: sn.data.ports![0].id,
885 }),
886 });
887 } else {
888 updateNodeData<"gateway-tcp">(c.target, {
889 selected: {
890 serviceId: c.source,
891 portId: undefined,
892 },
893 });
894 }
895 }
896 if (sn.type === "app") {
897 if (c.sourceHandle === "ports") {
898 updateNodeData<"app">(sn.id, {
899 isChoosingPortToConnect: true,
900 });
901 }
902 }
903 if (tn.type === "app") {
904 if (c.targetHandle === "repository") {
905 updateNodeData<"app">(tn.id, {
906 repository: {
907 id: c.source,
908 branch: "master",
909 rootDir: "/",
910 },
911 });
912 }
913 }
914 }
gioa71316d2025-05-24 09:41:36 +0400915
916 const fetchGithubRepositories = async () => {
917 const { githubService, projectId } = get();
918 if (!githubService || !projectId) {
919 set({
920 githubRepositories: [],
921 githubRepositoriesError: "GitHub service or Project ID not available.",
922 githubRepositoriesLoading: false,
923 });
924 return;
925 }
926
927 set({ githubRepositoriesLoading: true, githubRepositoriesError: null });
928 try {
929 const repos = await githubService.getRepositories();
930 set({ githubRepositories: repos, githubRepositoriesLoading: false });
931 } catch (error) {
932 console.error("Failed to fetch GitHub repositories in store:", error);
933 const errorMessage = error instanceof Error ? error.message : "Unknown error fetching repositories";
934 set({ githubRepositories: [], githubRepositoriesError: errorMessage, githubRepositoriesLoading: false });
935 }
936 };
937
giod0026612025-05-08 13:00:36 +0000938 return {
939 projectId: undefined,
gio818da4e2025-05-12 14:45:35 +0000940 mode: "edit",
giod0026612025-05-08 13:00:36 +0000941 projects: [],
942 nodes: [],
943 edges: [],
944 categories: defaultCategories,
945 messages: v([]),
946 env: defaultEnv,
gioaf8db832025-05-13 14:43:05 +0000947 viewport: {
948 transformX: 0,
949 transformY: 0,
950 transformZoom: 1,
951 width: 800,
952 height: 600,
953 },
gio359a6852025-05-14 03:38:24 +0000954 zoom: {
955 x: 0,
956 y: 0,
957 zoom: 1,
958 },
giod0026612025-05-08 13:00:36 +0000959 githubService: null,
gioa71316d2025-05-24 09:41:36 +0400960 githubRepositories: [],
961 githubRepositoriesLoading: false,
962 githubRepositoriesError: null,
gioaf8db832025-05-13 14:43:05 +0000963 setViewport: (viewport) => {
964 const { viewport: vp } = get();
965 if (
966 viewport.transformX !== vp.transformX ||
967 viewport.transformY !== vp.transformY ||
968 viewport.transformZoom !== vp.transformZoom ||
969 viewport.width !== vp.width ||
970 viewport.height !== vp.height
971 ) {
972 set({ viewport });
973 }
974 },
giod0026612025-05-08 13:00:36 +0000975 setHighlightCategory: (name, active) => {
976 set({
977 categories: get().categories.map((c) => {
978 if (c.title.toLowerCase() !== name.toLowerCase()) {
979 return c;
980 } else {
981 return {
982 ...c,
983 active,
984 };
985 }
986 }),
987 });
988 },
989 onNodesChange: (changes) => {
990 const nodes = applyNodeChanges(changes, get().nodes);
991 setN(nodes);
992 },
993 onEdgesChange: (changes) => {
994 set({
995 edges: applyEdgeChanges(changes, get().edges),
996 });
997 },
gioaf8db832025-05-13 14:43:05 +0000998 addNode: (node) => {
999 const { viewport, nodes } = get();
1000 setN(
1001 nodes.concat({
1002 ...node,
1003 position: getRandomPosition(viewport),
gioa1efbad2025-05-21 07:16:45 +00001004 } as AppNode),
gioaf8db832025-05-13 14:43:05 +00001005 );
1006 },
giod0026612025-05-08 13:00:36 +00001007 setNodes: (nodes) => {
1008 setN(nodes);
1009 },
1010 setEdges: (edges) => {
1011 set({ edges });
1012 },
1013 replaceEdge: (c, id) => {
1014 let change: EdgeChange;
1015 if (id === undefined) {
1016 change = {
1017 type: "add",
1018 item: {
1019 id: uuidv4(),
1020 ...c,
1021 },
1022 };
1023 onConnect(c);
1024 } else {
1025 change = {
1026 type: "replace",
1027 id,
1028 item: {
1029 id,
1030 ...c,
1031 },
1032 };
1033 }
1034 set({
1035 edges: applyEdgeChanges([change], get().edges),
1036 });
1037 },
1038 updateNode,
1039 updateNodeData,
1040 onConnect,
1041 refreshEnv: async () => {
1042 const projectId = get().projectId;
1043 let env: Env = defaultEnv;
giod0026612025-05-08 13:00:36 +00001044 try {
1045 if (projectId) {
1046 const response = await fetch(`/api/project/${projectId}/env`);
1047 if (response.ok) {
1048 const data = await response.json();
1049 const result = envSchema.safeParse(data);
1050 if (result.success) {
1051 env = result.data;
1052 } else {
1053 console.error("Invalid env data:", result.error);
1054 }
1055 }
1056 }
1057 } catch (error) {
1058 console.error("Failed to fetch integrations:", error);
1059 } finally {
gioa71316d2025-05-24 09:41:36 +04001060 const oldEnv = get().env;
1061 const oldGithubIntegrationStatus = oldEnv.integrations.github;
1062 if (JSON.stringify(oldEnv) !== JSON.stringify(env)) {
gio4b9b58a2025-05-12 11:46:08 +00001063 set({ env });
gio48fde052025-05-14 09:48:08 +00001064 injectNetworkNodes();
gioa71316d2025-05-24 09:41:36 +04001065 let ghService = null;
gio4b9b58a2025-05-12 11:46:08 +00001066 if (env.integrations.github) {
gioa71316d2025-05-24 09:41:36 +04001067 ghService = new GitHubServiceImpl(projectId!);
1068 }
1069 if (get().githubService !== ghService || (ghService && !get().githubService)) {
1070 set({ githubService: ghService });
1071 }
1072 if (
1073 ghService &&
1074 (oldGithubIntegrationStatus !== env.integrations.github || !oldEnv.integrations.github)
1075 ) {
1076 get().fetchGithubRepositories();
1077 }
1078 if (!env.integrations.github) {
1079 set({
1080 githubRepositories: [],
1081 githubRepositoriesError: null,
1082 githubRepositoriesLoading: false,
1083 });
gio4b9b58a2025-05-12 11:46:08 +00001084 }
giod0026612025-05-08 13:00:36 +00001085 }
1086 }
1087 },
gio818da4e2025-05-12 14:45:35 +00001088 setMode: (mode) => {
1089 set({ mode });
1090 },
1091 setProject: async (projectId) => {
gio918780d2025-05-22 08:24:41 +00001092 const currentProjectId = get().projectId;
1093 if (projectId === currentProjectId) {
gio359a6852025-05-14 03:38:24 +00001094 return;
1095 }
gio918780d2025-05-22 08:24:41 +00001096 stopRefreshEnvInterval();
giod0026612025-05-08 13:00:36 +00001097 set({
1098 projectId,
gioa71316d2025-05-24 09:41:36 +04001099 githubRepositories: [],
1100 githubRepositoriesLoading: false,
1101 githubRepositoriesError: null,
giod0026612025-05-08 13:00:36 +00001102 });
1103 if (projectId) {
gio818da4e2025-05-12 14:45:35 +00001104 await get().refreshEnv();
gioa71316d2025-05-24 09:41:36 +04001105 if (get().env.instanceId) {
gio818da4e2025-05-12 14:45:35 +00001106 set({ mode: "deploy" });
1107 } else {
1108 set({ mode: "edit" });
1109 }
gio4b9b58a2025-05-12 11:46:08 +00001110 restoreSaved();
gio918780d2025-05-22 08:24:41 +00001111 startRefreshEnvInterval();
gio4b9b58a2025-05-12 11:46:08 +00001112 } else {
1113 set({
1114 nodes: [],
1115 edges: [],
gio918780d2025-05-22 08:24:41 +00001116 env: defaultEnv,
1117 githubService: null,
gioa71316d2025-05-24 09:41:36 +04001118 githubRepositories: [],
1119 githubRepositoriesLoading: false,
1120 githubRepositoriesError: null,
gio4b9b58a2025-05-12 11:46:08 +00001121 });
giod0026612025-05-08 13:00:36 +00001122 }
1123 },
gioa71316d2025-05-24 09:41:36 +04001124 fetchGithubRepositories: fetchGithubRepositories,
giod0026612025-05-08 13:00:36 +00001125 };
gio5f2f1002025-03-20 18:38:48 +04001126});