blob: 2492642e8ad7a3f571f6a0c862e53a0b02b51926 [file] [log] [blame]
gio5f2f1002025-03-20 18:38:48 +04001import { v4 as uuidv4 } from "uuid";
2import { create } from 'zustand';
3import { addEdge, applyNodeChanges, applyEdgeChanges, Connection, EdgeChange, useNodes } from '@xyflow/react';
gio7f98e772025-05-07 11:00:14 +00004import type {
5 Edge,
6 Node,
7 OnNodesChange,
8 OnEdgesChange,
9 OnConnect,
gio5f2f1002025-03-20 18:38:48 +040010} from '@xyflow/react';
gio7f98e772025-05-07 11:00:14 +000011import type { DeepPartial } from "react-hook-form";
gio5f2f1002025-03-20 18:38:48 +040012import { Category, defaultCategories } from "./categories";
13import { CreateValidators, Validator } from "./config";
14import { z } from "zod";
gio7f98e772025-05-07 11:00:14 +000015import { GitHubService, GitHubServiceImpl } from './github';
gio5f2f1002025-03-20 18:38:48 +040016
17export type InitData = {
18 label: string;
19 envVars: BoundEnvVar[];
20 ports: Port[];
21};
22
23export type NodeData = InitData & {
24 activeField?: string | undefined;
gio1dc800a2025-04-24 17:15:43 +000025 state: string | null;
gio5f2f1002025-03-20 18:38:48 +040026};
27
28export type PortConnectedTo = {
29 serviceId: string;
30 portId: string;
31}
32
gioaba9a962025-04-25 14:19:40 +000033export type NetworkData = NodeData & {
34 domain: string;
35};
36
37export type NetworkNode = Node<NetworkData> & {
38 type: "network";
39};
40
gio5f2f1002025-03-20 18:38:48 +040041export type GatewayHttpsData = NodeData & {
42 network?: string;
43 subdomain?: string;
44 https?: PortConnectedTo;
gio9b2d4962025-05-07 04:59:39 +000045 auth?: {
46 enabled: boolean;
47 groups: string[];
48 noAuthPathPatterns: string[];
49 }
gio5f2f1002025-03-20 18:38:48 +040050};
51
52export type GatewayHttpsNode = Node<GatewayHttpsData> & {
53 type: "gateway-https";
54};
55
56export type GatewayTCPData = NodeData & {
57 network?: string;
58 subdomain?: string;
59 exposed: PortConnectedTo[];
60 selected?: {
61 serviceId?: string;
62 portId?: string;
63 };
64};
65
66export type GatewayTCPNode = Node<GatewayTCPData> & {
67 type: "gateway-tcp";
68};
69
70export type Port = {
71 id: string;
72 name: string;
73 value: number;
74};
75
gio91165612025-05-03 17:07:38 +000076export const ServiceTypes = [
77 "deno:2.2.0",
78 "golang:1.20.0",
79 "golang:1.22.0",
80 "golang:1.24.0",
81 "hugo:latest",
82 "php:8.2-apache",
gio33990c62025-05-06 07:51:24 +000083 "nextjs:deno-2.0.0",
gio91165612025-05-03 17:07:38 +000084 "node-23.1.0"
85] as const;
gio5f2f1002025-03-20 18:38:48 +040086export type ServiceType = typeof ServiceTypes[number];
87
88export type ServiceData = NodeData & {
89 type: ServiceType;
90 repository: {
91 id: string;
gio33990c62025-05-06 07:51:24 +000092 } | {
93 id: string;
94 branch: string;
95 } | {
96 id: string;
gio5f2f1002025-03-20 18:38:48 +040097 branch: string;
98 rootDir: string;
99 };
100 env: string[];
101 volume: string[];
gio91165612025-05-03 17:07:38 +0000102 preBuildCommands: string;
gio5f2f1002025-03-20 18:38:48 +0400103 isChoosingPortToConnect: boolean;
104};
105
106export type ServiceNode = Node<ServiceData> & {
107 type: "app";
108};
109
110export type VolumeType = "ReadWriteOnce" | "ReadOnlyMany" | "ReadWriteMany" | "ReadWriteOncePod";
111
112export type VolumeData = NodeData & {
113 type: VolumeType;
114 size: string;
115 attachedTo: string[];
116};
117
118export type VolumeNode = Node<VolumeData> & {
119 type: "volume";
120};
121
122export type PostgreSQLData = NodeData & {
123 volumeId: string;
124};
125
126export type PostgreSQLNode = Node<PostgreSQLData> & {
127 type: "postgresql";
128};
129
130export type MongoDBData = NodeData & {
131 volumeId: string;
132};
133
134export type MongoDBNode = Node<MongoDBData> & {
135 type: "mongodb";
136};
137
138export type GithubData = NodeData & {
gio7f98e772025-05-07 11:00:14 +0000139 repository?: {
140 id: number;
141 sshURL: string;
142 };
gio5f2f1002025-03-20 18:38:48 +0400143};
144
145export type GithubNode = Node<GithubData> & {
146 type: "github";
147};
148
149export type NANode = Node<NodeData> & {
150 type: undefined;
151};
152
gioaba9a962025-04-25 14:19:40 +0000153export type AppNode = NetworkNode | GatewayHttpsNode | GatewayTCPNode | ServiceNode | VolumeNode | PostgreSQLNode | MongoDBNode | GithubNode | NANode;
gio5f2f1002025-03-20 18:38:48 +0400154
155export function nodeLabel(n: AppNode): string {
156 switch (n.type) {
gioaba9a962025-04-25 14:19:40 +0000157 case "network": return n.data.domain;
gio5f2f1002025-03-20 18:38:48 +0400158 case "app": return n.data.label || "Service";
gio7f98e772025-05-07 11:00:14 +0000159 case "github": return n.data.repository?.sshURL || "Github";
gio5f2f1002025-03-20 18:38:48 +0400160 case "gateway-https": {
161 if (n.data && n.data.network && n.data.subdomain) {
162 return `https://${n.data.subdomain}.${n.data.network}`;
163 } else {
164 return "HTTPS Gateway";
165 }
166 }
167 case "gateway-tcp": {
168 if (n.data && n.data.network && n.data.subdomain) {
169 return `${n.data.subdomain}.${n.data.network}`;
170 } else {
171 return "TCP Gateway";
172 }
173 }
174 case "mongodb": return n.data.label || "MongoDB";
175 case "postgresql": return n.data.label || "PostgreSQL";
176 case "volume": return n.data.label || "Volume";
177 case undefined: throw new Error("MUST NOT REACH!");
178 }
179}
180
181export function nodeIsConnectable(n: AppNode, handle: string): boolean {
182 switch (n.type) {
gioaba9a962025-04-25 14:19:40 +0000183 case "network":
184 return true;
gio5f2f1002025-03-20 18:38:48 +0400185 case "app":
186 if (handle === "ports") {
187 return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
188 } else if (handle === "repository") {
189 if (!n.data || !n.data.repository || !n.data.repository.id) {
gio33990c62025-05-06 07:51:24 +0000190 return true;
gio5f2f1002025-03-20 18:38:48 +0400191 }
192 return false;
193 }
gio33990c62025-05-06 07:51:24 +0000194 return false;
gio5f2f1002025-03-20 18:38:48 +0400195 case "github":
gio7f98e772025-05-07 11:00:14 +0000196 if (n.data.repository?.id !== undefined) {
gio5f2f1002025-03-20 18:38:48 +0400197 return true;
198 }
199 return false;
200 case "gateway-https":
gioaba9a962025-04-25 14:19:40 +0000201 if (handle === "subdomain") {
202 return n.data.network === undefined;
203 }
gio5f2f1002025-03-20 18:38:48 +0400204 return n.data === undefined || n.data.https === undefined;
205 case "gateway-tcp":
gioaba9a962025-04-25 14:19:40 +0000206 if (handle === "subdomain") {
207 return n.data.network === undefined;
208 }
gio5f2f1002025-03-20 18:38:48 +0400209 return true;
210 case "mongodb":
211 return true;
212 case "postgresql":
213 return true;
214 case "volume":
215 if (n.data === undefined || n.data.type === undefined) {
216 return false;
217 }
218 if (n.data.type === "ReadWriteOnce" || n.data.type === "ReadWriteOncePod") {
gio33990c62025-05-06 07:51:24 +0000219 return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
gio5f2f1002025-03-20 18:38:48 +0400220 }
221 return true;
222 case undefined: throw new Error("MUST NOT REACH!");
223 }
224}
225
226export type BoundEnvVar = {
227 id: string;
gio355883e2025-04-23 14:10:51 +0000228 source: string | null;
gio5f2f1002025-03-20 18:38:48 +0400229} | {
230 id: string;
gio355883e2025-04-23 14:10:51 +0000231 source: string | null;
gio5f2f1002025-03-20 18:38:48 +0400232 name: string;
233 isEditting: boolean;
234} | {
235 id: string;
gio355883e2025-04-23 14:10:51 +0000236 source: string | null;
gio5f2f1002025-03-20 18:38:48 +0400237 name: string;
238 alias: string;
239 isEditting: boolean;
giob41ecae2025-04-24 08:46:50 +0000240} | {
241 id: string;
242 source: string | null;
243 portId: string;
244 name: string;
245 alias: string;
246 isEditting: boolean;
gio5f2f1002025-03-20 18:38:48 +0400247};
248
249export type EnvVar = {
250 name: string;
251 value: string;
252};
253
giob41ecae2025-04-24 08:46:50 +0000254export function nodeEnvVarNamePort(n: AppNode, portName: string): string {
255 return `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${portName.toUpperCase()}`;
256}
257
gio5f2f1002025-03-20 18:38:48 +0400258export function nodeEnvVarNames(n: AppNode): string[] {
259 switch (n.type) {
260 case "app": return [
gio33990c62025-05-06 07:51:24 +0000261 `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
giob41ecae2025-04-24 08:46:50 +0000262 ...(n.data.ports || []).map((p) => nodeEnvVarNamePort(n, p.name)),
gio5f2f1002025-03-20 18:38:48 +0400263 ];
264 case "github": return [];
265 case "gateway-https": return [];
266 case "gateway-tcp": return [];
gio355883e2025-04-23 14:10:51 +0000267 case "mongodb": return [`DODO_MONGODB_${n.data.label.toUpperCase()}_URL`];
268 case "postgresql": return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_URL`];
gio5f2f1002025-03-20 18:38:48 +0400269 case "volume": return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
270 case undefined: throw new Error("MUST NOT REACH");
gio7f98e772025-05-07 11:00:14 +0000271 default: throw new Error("MUST NOT REACH");
gio5f2f1002025-03-20 18:38:48 +0400272 }
273}
274
275export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
276
277export type MessageType = "INFO" | "WARNING" | "FATAL";
278
279export type Message = {
280 id: string;
281 type: MessageType;
282 nodeId?: string;
283 message: string;
284 onHighlight?: (state: AppState) => void;
285 onLooseHighlight?: (state: AppState) => void;
286 onClick?: (state: AppState) => void;
287};
288
289export const envSchema = z.object({
gio7f98e772025-05-07 11:00:14 +0000290 deployKey: z.optional(z.string().min(1)),
gio5f2f1002025-03-20 18:38:48 +0400291 networks: z.array(z.object({
gio7f98e772025-05-07 11:00:14 +0000292 name: z.string().min(1),
293 domain: z.string().min(1),
294 })).default([]),
295 integrations: z.object({
296 github: z.boolean(),
297 }),
gio5f2f1002025-03-20 18:38:48 +0400298});
299
300export type Env = z.infer<typeof envSchema>;
301
gio7f98e772025-05-07 11:00:14 +0000302const defaultEnv: Env = {
303 deployKey: undefined,
304 networks: [],
305 integrations: {
306 github: false,
307 }
308};
309
gio5f2f1002025-03-20 18:38:48 +0400310export type Project = {
311 id: string;
312 name: string;
313}
314
gio7f98e772025-05-07 11:00:14 +0000315export type IntegrationsConfig = {
316 github: boolean;
317};
318
319type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
320type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
321
gio5f2f1002025-03-20 18:38:48 +0400322export type AppState = {
323 projectId: string | undefined;
324 projects: Project[];
325 nodes: AppNode[];
326 edges: Edge[];
327 categories: Category[];
328 messages: Message[];
gio7f98e772025-05-07 11:00:14 +0000329 env: Env;
330 githubService: GitHubService | null;
gio5f2f1002025-03-20 18:38:48 +0400331 setHighlightCategory: (name: string, active: boolean) => void;
332 onNodesChange: OnNodesChange<AppNode>;
333 onEdgesChange: OnEdgesChange;
334 onConnect: OnConnect;
335 setNodes: (nodes: AppNode[]) => void;
336 setEdges: (edges: Edge[]) => void;
giob68003c2025-04-25 03:05:21 +0000337 setProject: (projectId: string | undefined) => void;
gio7f98e772025-05-07 11:00:14 +0000338 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
339 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
gio5f2f1002025-03-20 18:38:48 +0400340 replaceEdge: (c: Connection, id?: string) => void;
gio7f98e772025-05-07 11:00:14 +0000341 refreshEnv: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400342};
343
344const projectIdSelector = (state: AppState) => state.projectId;
345const categoriesSelector = (state: AppState) => state.categories;
346const messagesSelector = (state: AppState) => state.messages;
gio7f98e772025-05-07 11:00:14 +0000347const githubServiceSelector = (state: AppState) => state.githubService;
gio5f2f1002025-03-20 18:38:48 +0400348const envSelector = (state: AppState) => state.env;
349
350export function useProjectId(): string | undefined {
351 return useStateStore(projectIdSelector);
352}
353
354export function useCategories(): Category[] {
355 return useStateStore(categoriesSelector);
356}
357
358export function useMessages(): Message[] {
359 return useStateStore(messagesSelector);
360}
361
362export function useNodeMessages(id: string): Message[] {
363 return useMessages().filter((m) => m.nodeId === id);
364}
365
366export function useNodeLabel(id: string): string {
367 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
368}
369
370export function useNodePortName(id: string, portId: string): string {
371 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
372}
373
gio5f2f1002025-03-20 18:38:48 +0400374export function useEnv(): Env {
gio7f98e772025-05-07 11:00:14 +0000375 return useStateStore(envSelector);
376}
377
378export function useGithubService(): GitHubService | null {
379 return useStateStore(githubServiceSelector);
gio5f2f1002025-03-20 18:38:48 +0400380}
381
382const v: Validator = CreateValidators();
383
384export const useStateStore = create<AppState>((set, get): AppState => {
gio5f2f1002025-03-20 18:38:48 +0400385 const setN = (nodes: AppNode[]) => {
gio7f98e772025-05-07 11:00:14 +0000386 set((state) => ({
387 ...state,
388 nodes,
389 }));
gio5f2f1002025-03-20 18:38:48 +0400390 };
gio7f98e772025-05-07 11:00:14 +0000391
392 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
gio5f2f1002025-03-20 18:38:48 +0400393 setN(
394 get().nodes.map((n) => {
gio7f98e772025-05-07 11:00:14 +0000395 if (n.id === id) {
396 return {
397 ...n,
398 data: {
399 ...n.data,
400 ...data,
401 },
402 } as Extract<AppNode, { type: T }>;
gio5f2f1002025-03-20 18:38:48 +0400403 }
gio7f98e772025-05-07 11:00:14 +0000404 return n;
gio5f2f1002025-03-20 18:38:48 +0400405 })
406 );
gio7f98e772025-05-07 11:00:14 +0000407 }
408
409 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
410 setN(
411 get().nodes.map((n) => {
412 if (n.id === id) {
413 return {
414 ...n,
415 ...node,
416 } as Extract<AppNode, { type: T }>;
417 }
418 return n;
419 })
420 );
421 }
422
gio5f2f1002025-03-20 18:38:48 +0400423 function onConnect(c: Connection) {
424 const { nodes, edges } = get();
425 set({
426 edges: addEdge(c, edges),
427 });
428 const sn = nodes.filter((n) => n.id === c.source)[0]!;
429 const tn = nodes.filter((n) => n.id === c.target)[0]!;
gioaba9a962025-04-25 14:19:40 +0000430 if (tn.type === "network") {
431 if (sn.type === "gateway-https") {
432 updateNodeData<"gateway-https">(sn.id, {
433 network: tn.data.domain,
434 });
gio33990c62025-05-06 07:51:24 +0000435 } else if (sn.type === "gateway-tcp") {
gioaba9a962025-04-25 14:19:40 +0000436 updateNodeData<"gateway-tcp">(sn.id, {
437 network: tn.data.domain,
438 });
439 }
440 }
gio7f98e772025-05-07 11:00:14 +0000441 if (tn.type === "app") {
442 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
443 const sourceEnvVars = nodeEnvVarNames(sn);
444 if (sourceEnvVars.length === 0) {
445 throw new Error("MUST NOT REACH!");
446 }
447 const id = uuidv4();
448 if (sourceEnvVars.length === 1) {
449 updateNode<"app">(c.target, {
450 ...tn,
451 data: {
452 ...tn.data,
453 envVars: [
454 ...(tn.data.envVars || []),
455 {
456 id: id,
457 source: c.source,
458 name: sourceEnvVars[0],
459 isEditting: false,
460 },
461 ],
462 },
463 });
464 } else {
465 updateNode<"app">(c.target, {
466 ...tn,
467 data: {
468 ...tn.data,
469 envVars: [
470 ...(tn.data.envVars || []),
471 {
472 id: id,
473 source: c.source,
474 },
475 ],
476 },
477 });
478 }
gio5f2f1002025-03-20 18:38:48 +0400479 }
gio7f98e772025-05-07 11:00:14 +0000480 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
481 const sourcePorts = sn.data.ports || [];
482 const id = uuidv4();
483 if (sourcePorts.length === 1) {
484 updateNode<"app">(c.target, {
485 ...tn,
486 data: {
487 ...tn.data,
488 envVars: [
489 ...(tn.data.envVars || []),
490 {
491 id: id,
492 source: c.source,
493 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
494 portId: sourcePorts[0].id,
495 isEditting: false,
496 },
497 ],
498 },
499 });
500 }
giob41ecae2025-04-24 08:46:50 +0000501 }
502 }
gio5f2f1002025-03-20 18:38:48 +0400503 if (c.sourceHandle === "volume") {
504 updateNodeData<"volume">(c.source, {
505 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
506 });
507 }
508 if (c.targetHandle === "volume") {
509 if (tn.type === "postgresql" || tn.type === "mongodb") {
510 updateNodeData(c.target, {
511 volumeId: c.source,
512 });
513 }
514 }
515 if (c.targetHandle === "https") {
516 if ((sn.data.ports || []).length === 1) {
517 updateNodeData<"gateway-https">(c.target, {
518 https: {
519 serviceId: c.source,
520 portId: sn.data.ports![0].id,
521 }
522 });
523 } else {
524 updateNodeData<"gateway-https">(c.target, {
525 https: {
526 serviceId: c.source,
527 portId: "", // TODO(gio)
528 }
529 });
530 }
531 }
532 if (c.targetHandle === "tcp") {
533 const td = tn.data as GatewayTCPData;
534 if ((sn.data.ports || []).length === 1) {
535 updateNodeData<"gateway-tcp">(c.target, {
536 exposed: (td.exposed || []).concat({
537 serviceId: c.source,
538 portId: sn.data.ports![0].id,
539 }),
540 });
541 } else {
542 updateNodeData<"gateway-tcp">(c.target, {
543 selected: {
544 serviceId: c.source,
545 portId: undefined,
546 },
547 });
548 }
549 }
550 if (sn.type === "app") {
551 if (c.sourceHandle === "ports") {
552 updateNodeData<"app">(sn.id, {
553 isChoosingPortToConnect: true,
554 });
555 }
556 }
557 if (tn.type === "app") {
558 if (c.targetHandle === "repository") {
559 updateNodeData<"app">(tn.id, {
560 repository: {
561 id: c.source,
562 branch: "master",
563 rootDir: "/",
564 }
565 });
566 }
567 }
568 }
569 return {
570 projectId: undefined,
571 projects: [],
572 nodes: [],
573 edges: [],
574 categories: defaultCategories,
575 messages: v([]),
gio7f98e772025-05-07 11:00:14 +0000576 env: defaultEnv,
577 githubService: null,
gio5f2f1002025-03-20 18:38:48 +0400578 setHighlightCategory: (name, active) => {
579 set({
580 categories: get().categories.map(
581 (c) => {
582 if (c.title.toLowerCase() !== name.toLowerCase()) {
583 return c;
584 } else {
585 return {
586 ...c,
587 active,
588 }
589 }
590 })
591 });
592 },
593 onNodesChange: (changes) => {
594 const nodes = applyNodeChanges(changes, get().nodes);
595 setN(nodes);
596 },
597 onEdgesChange: (changes) => {
598 set({
599 edges: applyEdgeChanges(changes, get().edges),
600 });
601 },
602 setNodes: (nodes) => {
603 setN(nodes);
604 },
605 setEdges: (edges) => {
606 set({ edges });
607 },
608 replaceEdge: (c, id) => {
609 let change: EdgeChange;
610 if (id === undefined) {
611 change = {
612 type: "add",
613 item: {
614 id: uuidv4(),
615 ...c,
616 }
617 };
618 onConnect(c);
619 } else {
620 change = {
621 type: "replace",
622 id,
623 item: {
624 id,
625 ...c,
626 }
627 };
628 }
629 set({
630 edges: applyEdgeChanges([change], get().edges),
631 })
632 },
633 updateNode,
634 updateNodeData,
635 onConnect,
636 refreshEnv: async () => {
gio7f98e772025-05-07 11:00:14 +0000637 const projectId = get().projectId;
638 let env: Env = defaultEnv;
639
640 try {
641 if (projectId) {
642 const response = await fetch(`/api/project/${projectId}/env`);
643 if (response.ok) {
644 const data = await response.json();
645 const result = envSchema.safeParse(data);
646 if (result.success) {
647 env = result.data;
648 } else {
649 console.error("Invalid env data:", result.error);
650 }
651 }
652 }
653 } catch (error) {
654 console.error("Failed to fetch integrations:", error);
655 } finally {
656 set({ env: env });
657 if (env.integrations.github) {
658 set({ githubService: new GitHubServiceImpl(projectId!) });
659 } else {
660 set({ githubService: null });
661 }
gio33990c62025-05-06 07:51:24 +0000662 }
gio5f2f1002025-03-20 18:38:48 +0400663 },
gio7f98e772025-05-07 11:00:14 +0000664 setProject: (projectId) => {
665 set({
666 projectId,
667 });
668 if (projectId) {
669 get().refreshEnv();
670 }
671 },
gio5f2f1002025-03-20 18:38:48 +0400672 };
673});