blob: a3cd755646d6aeba22803119ba9e86fad82a4af4 [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;
gio5f2f1002025-03-20 18:38:48 +0400338 setProjects: (projects: Project[]) => void;
gio7f98e772025-05-07 11:00:14 +0000339 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
340 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
gio5f2f1002025-03-20 18:38:48 +0400341 replaceEdge: (c: Connection, id?: string) => void;
gio7f98e772025-05-07 11:00:14 +0000342 refreshEnv: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400343};
344
345const projectIdSelector = (state: AppState) => state.projectId;
346const categoriesSelector = (state: AppState) => state.categories;
347const messagesSelector = (state: AppState) => state.messages;
gio7f98e772025-05-07 11:00:14 +0000348const githubServiceSelector = (state: AppState) => state.githubService;
gio5f2f1002025-03-20 18:38:48 +0400349const envSelector = (state: AppState) => state.env;
350
351export function useProjectId(): string | undefined {
352 return useStateStore(projectIdSelector);
353}
354
355export function useCategories(): Category[] {
356 return useStateStore(categoriesSelector);
357}
358
359export function useMessages(): Message[] {
360 return useStateStore(messagesSelector);
361}
362
363export function useNodeMessages(id: string): Message[] {
364 return useMessages().filter((m) => m.nodeId === id);
365}
366
367export function useNodeLabel(id: string): string {
368 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
369}
370
371export function useNodePortName(id: string, portId: string): string {
372 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
373}
374
gio5f2f1002025-03-20 18:38:48 +0400375export function useEnv(): Env {
gio7f98e772025-05-07 11:00:14 +0000376 return useStateStore(envSelector);
377}
378
379export function useGithubService(): GitHubService | null {
380 return useStateStore(githubServiceSelector);
gio5f2f1002025-03-20 18:38:48 +0400381}
382
383const v: Validator = CreateValidators();
384
385export const useStateStore = create<AppState>((set, get): AppState => {
gio5f2f1002025-03-20 18:38:48 +0400386 const setN = (nodes: AppNode[]) => {
gio7f98e772025-05-07 11:00:14 +0000387 set((state) => ({
388 ...state,
389 nodes,
390 }));
gio5f2f1002025-03-20 18:38:48 +0400391 };
gio7f98e772025-05-07 11:00:14 +0000392
393 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
gio5f2f1002025-03-20 18:38:48 +0400394 setN(
395 get().nodes.map((n) => {
gio7f98e772025-05-07 11:00:14 +0000396 if (n.id === id) {
397 return {
398 ...n,
399 data: {
400 ...n.data,
401 ...data,
402 },
403 } as Extract<AppNode, { type: T }>;
gio5f2f1002025-03-20 18:38:48 +0400404 }
gio7f98e772025-05-07 11:00:14 +0000405 return n;
gio5f2f1002025-03-20 18:38:48 +0400406 })
407 );
gio7f98e772025-05-07 11:00:14 +0000408 }
409
410 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
411 setN(
412 get().nodes.map((n) => {
413 if (n.id === id) {
414 return {
415 ...n,
416 ...node,
417 } as Extract<AppNode, { type: T }>;
418 }
419 return n;
420 })
421 );
422 }
423
gio5f2f1002025-03-20 18:38:48 +0400424 function onConnect(c: Connection) {
425 const { nodes, edges } = get();
426 set({
427 edges: addEdge(c, edges),
428 });
429 const sn = nodes.filter((n) => n.id === c.source)[0]!;
430 const tn = nodes.filter((n) => n.id === c.target)[0]!;
gioaba9a962025-04-25 14:19:40 +0000431 if (tn.type === "network") {
432 if (sn.type === "gateway-https") {
433 updateNodeData<"gateway-https">(sn.id, {
434 network: tn.data.domain,
435 });
gio33990c62025-05-06 07:51:24 +0000436 } else if (sn.type === "gateway-tcp") {
gioaba9a962025-04-25 14:19:40 +0000437 updateNodeData<"gateway-tcp">(sn.id, {
438 network: tn.data.domain,
439 });
440 }
441 }
gio7f98e772025-05-07 11:00:14 +0000442 if (tn.type === "app") {
443 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
444 const sourceEnvVars = nodeEnvVarNames(sn);
445 if (sourceEnvVars.length === 0) {
446 throw new Error("MUST NOT REACH!");
447 }
448 const id = uuidv4();
449 if (sourceEnvVars.length === 1) {
450 updateNode<"app">(c.target, {
451 ...tn,
452 data: {
453 ...tn.data,
454 envVars: [
455 ...(tn.data.envVars || []),
456 {
457 id: id,
458 source: c.source,
459 name: sourceEnvVars[0],
460 isEditting: false,
461 },
462 ],
463 },
464 });
465 } else {
466 updateNode<"app">(c.target, {
467 ...tn,
468 data: {
469 ...tn.data,
470 envVars: [
471 ...(tn.data.envVars || []),
472 {
473 id: id,
474 source: c.source,
475 },
476 ],
477 },
478 });
479 }
gio5f2f1002025-03-20 18:38:48 +0400480 }
gio7f98e772025-05-07 11:00:14 +0000481 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
482 const sourcePorts = sn.data.ports || [];
483 const id = uuidv4();
484 if (sourcePorts.length === 1) {
485 updateNode<"app">(c.target, {
486 ...tn,
487 data: {
488 ...tn.data,
489 envVars: [
490 ...(tn.data.envVars || []),
491 {
492 id: id,
493 source: c.source,
494 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
495 portId: sourcePorts[0].id,
496 isEditting: false,
497 },
498 ],
499 },
500 });
501 }
giob41ecae2025-04-24 08:46:50 +0000502 }
503 }
gio5f2f1002025-03-20 18:38:48 +0400504 if (c.sourceHandle === "volume") {
505 updateNodeData<"volume">(c.source, {
506 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
507 });
508 }
509 if (c.targetHandle === "volume") {
510 if (tn.type === "postgresql" || tn.type === "mongodb") {
511 updateNodeData(c.target, {
512 volumeId: c.source,
513 });
514 }
515 }
516 if (c.targetHandle === "https") {
517 if ((sn.data.ports || []).length === 1) {
518 updateNodeData<"gateway-https">(c.target, {
519 https: {
520 serviceId: c.source,
521 portId: sn.data.ports![0].id,
522 }
523 });
524 } else {
525 updateNodeData<"gateway-https">(c.target, {
526 https: {
527 serviceId: c.source,
528 portId: "", // TODO(gio)
529 }
530 });
531 }
532 }
533 if (c.targetHandle === "tcp") {
534 const td = tn.data as GatewayTCPData;
535 if ((sn.data.ports || []).length === 1) {
536 updateNodeData<"gateway-tcp">(c.target, {
537 exposed: (td.exposed || []).concat({
538 serviceId: c.source,
539 portId: sn.data.ports![0].id,
540 }),
541 });
542 } else {
543 updateNodeData<"gateway-tcp">(c.target, {
544 selected: {
545 serviceId: c.source,
546 portId: undefined,
547 },
548 });
549 }
550 }
551 if (sn.type === "app") {
552 if (c.sourceHandle === "ports") {
553 updateNodeData<"app">(sn.id, {
554 isChoosingPortToConnect: true,
555 });
556 }
557 }
558 if (tn.type === "app") {
559 if (c.targetHandle === "repository") {
560 updateNodeData<"app">(tn.id, {
561 repository: {
562 id: c.source,
563 branch: "master",
564 rootDir: "/",
565 }
566 });
567 }
568 }
569 }
570 return {
571 projectId: undefined,
572 projects: [],
573 nodes: [],
574 edges: [],
575 categories: defaultCategories,
576 messages: v([]),
gio7f98e772025-05-07 11:00:14 +0000577 env: defaultEnv,
578 githubService: null,
gio5f2f1002025-03-20 18:38:48 +0400579 setHighlightCategory: (name, active) => {
580 set({
581 categories: get().categories.map(
582 (c) => {
583 if (c.title.toLowerCase() !== name.toLowerCase()) {
584 return c;
585 } else {
586 return {
587 ...c,
588 active,
589 }
590 }
591 })
592 });
593 },
594 onNodesChange: (changes) => {
595 const nodes = applyNodeChanges(changes, get().nodes);
596 setN(nodes);
597 },
598 onEdgesChange: (changes) => {
599 set({
600 edges: applyEdgeChanges(changes, get().edges),
601 });
602 },
603 setNodes: (nodes) => {
604 setN(nodes);
605 },
606 setEdges: (edges) => {
607 set({ edges });
608 },
609 replaceEdge: (c, id) => {
610 let change: EdgeChange;
611 if (id === undefined) {
612 change = {
613 type: "add",
614 item: {
615 id: uuidv4(),
616 ...c,
617 }
618 };
619 onConnect(c);
620 } else {
621 change = {
622 type: "replace",
623 id,
624 item: {
625 id,
626 ...c,
627 }
628 };
629 }
630 set({
631 edges: applyEdgeChanges([change], get().edges),
632 })
633 },
634 updateNode,
635 updateNodeData,
636 onConnect,
637 refreshEnv: async () => {
gio7f98e772025-05-07 11:00:14 +0000638 const projectId = get().projectId;
639 let env: Env = defaultEnv;
640
641 try {
642 if (projectId) {
643 const response = await fetch(`/api/project/${projectId}/env`);
644 if (response.ok) {
645 const data = await response.json();
646 const result = envSchema.safeParse(data);
647 if (result.success) {
648 env = result.data;
649 } else {
650 console.error("Invalid env data:", result.error);
651 }
652 }
653 }
654 } catch (error) {
655 console.error("Failed to fetch integrations:", error);
656 } finally {
657 set({ env: env });
658 if (env.integrations.github) {
659 set({ githubService: new GitHubServiceImpl(projectId!) });
660 } else {
661 set({ githubService: null });
662 }
gio33990c62025-05-06 07:51:24 +0000663 }
gio5f2f1002025-03-20 18:38:48 +0400664 },
gio7f98e772025-05-07 11:00:14 +0000665 setProject: (projectId) => {
666 set({
667 projectId,
668 });
669 if (projectId) {
670 get().refreshEnv();
671 }
672 },
gio5f2f1002025-03-20 18:38:48 +0400673 setProjects: (projects) => set({ projects }),
674 };
675});