blob: 0749fbba97b4ebd2206bd52bc1258f45d7df4cdf [file] [log] [blame]
gio5f2f1002025-03-20 18:38:48 +04001import { Category, defaultCategories } from "./categories";
2import { CreateValidators, Validator } from "./config";
giod0026612025-05-08 13:00:36 +00003import { GitHubService, GitHubServiceImpl } from "./github";
4import type { Edge, Node, OnConnect, OnEdgesChange, OnNodesChange } from "@xyflow/react";
5import { addEdge, applyEdgeChanges, applyNodeChanges, Connection, EdgeChange, useNodes } from "@xyflow/react";
6import type { DeepPartial } from "react-hook-form";
7import { v4 as uuidv4 } from "uuid";
gio5f2f1002025-03-20 18:38:48 +04008import { z } from "zod";
giod0026612025-05-08 13:00:36 +00009import { create } from "zustand";
gio5f2f1002025-03-20 18:38:48 +040010
11export type InitData = {
giod0026612025-05-08 13:00:36 +000012 label: string;
13 envVars: BoundEnvVar[];
14 ports: Port[];
gio5f2f1002025-03-20 18:38:48 +040015};
16
17export type NodeData = InitData & {
giod0026612025-05-08 13:00:36 +000018 activeField?: string | undefined;
19 state: string | null;
gio5f2f1002025-03-20 18:38:48 +040020};
21
22export type PortConnectedTo = {
giod0026612025-05-08 13:00:36 +000023 serviceId: string;
24 portId: string;
25};
gio5f2f1002025-03-20 18:38:48 +040026
gioaba9a962025-04-25 14:19:40 +000027export type NetworkData = NodeData & {
giod0026612025-05-08 13:00:36 +000028 domain: string;
gioaba9a962025-04-25 14:19:40 +000029};
30
31export type NetworkNode = Node<NetworkData> & {
giod0026612025-05-08 13:00:36 +000032 type: "network";
gioaba9a962025-04-25 14:19:40 +000033};
34
gio5f2f1002025-03-20 18:38:48 +040035export type GatewayHttpsData = NodeData & {
giod0026612025-05-08 13:00:36 +000036 network?: string;
37 subdomain?: string;
38 https?: PortConnectedTo;
39 auth?: {
40 enabled: boolean;
41 groups: string[];
42 noAuthPathPatterns: string[];
43 };
gio5f2f1002025-03-20 18:38:48 +040044};
45
46export type GatewayHttpsNode = Node<GatewayHttpsData> & {
giod0026612025-05-08 13:00:36 +000047 type: "gateway-https";
gio5f2f1002025-03-20 18:38:48 +040048};
49
50export type GatewayTCPData = NodeData & {
giod0026612025-05-08 13:00:36 +000051 network?: string;
52 subdomain?: string;
53 exposed: PortConnectedTo[];
54 selected?: {
55 serviceId?: string;
56 portId?: string;
57 };
gio5f2f1002025-03-20 18:38:48 +040058};
59
60export type GatewayTCPNode = Node<GatewayTCPData> & {
giod0026612025-05-08 13:00:36 +000061 type: "gateway-tcp";
gio5f2f1002025-03-20 18:38:48 +040062};
63
64export type Port = {
giod0026612025-05-08 13:00:36 +000065 id: string;
66 name: string;
67 value: number;
gio5f2f1002025-03-20 18:38:48 +040068};
69
gio91165612025-05-03 17:07:38 +000070export const ServiceTypes = [
giod0026612025-05-08 13:00:36 +000071 "deno:2.2.0",
72 "golang:1.20.0",
73 "golang:1.22.0",
74 "golang:1.24.0",
75 "hugo:latest",
76 "php:8.2-apache",
77 "nextjs:deno-2.0.0",
78 "node-23.1.0",
gio91165612025-05-03 17:07:38 +000079] as const;
giod0026612025-05-08 13:00:36 +000080export type ServiceType = (typeof ServiceTypes)[number];
gio5f2f1002025-03-20 18:38:48 +040081
82export type ServiceData = NodeData & {
giod0026612025-05-08 13:00:36 +000083 type: ServiceType;
84 repository:
85 | {
86 id: string;
87 }
88 | {
89 id: string;
90 branch: string;
91 }
92 | {
93 id: string;
94 branch: string;
95 rootDir: string;
96 };
97 env: string[];
98 volume: string[];
99 preBuildCommands: string;
100 isChoosingPortToConnect: boolean;
gio5f2f1002025-03-20 18:38:48 +0400101};
102
103export type ServiceNode = Node<ServiceData> & {
giod0026612025-05-08 13:00:36 +0000104 type: "app";
gio5f2f1002025-03-20 18:38:48 +0400105};
106
107export type VolumeType = "ReadWriteOnce" | "ReadOnlyMany" | "ReadWriteMany" | "ReadWriteOncePod";
108
109export type VolumeData = NodeData & {
giod0026612025-05-08 13:00:36 +0000110 type: VolumeType;
111 size: string;
112 attachedTo: string[];
gio5f2f1002025-03-20 18:38:48 +0400113};
114
115export type VolumeNode = Node<VolumeData> & {
giod0026612025-05-08 13:00:36 +0000116 type: "volume";
gio5f2f1002025-03-20 18:38:48 +0400117};
118
119export type PostgreSQLData = NodeData & {
giod0026612025-05-08 13:00:36 +0000120 volumeId: string;
gio5f2f1002025-03-20 18:38:48 +0400121};
122
123export type PostgreSQLNode = Node<PostgreSQLData> & {
giod0026612025-05-08 13:00:36 +0000124 type: "postgresql";
gio5f2f1002025-03-20 18:38:48 +0400125};
126
127export type MongoDBData = NodeData & {
giod0026612025-05-08 13:00:36 +0000128 volumeId: string;
gio5f2f1002025-03-20 18:38:48 +0400129};
130
131export type MongoDBNode = Node<MongoDBData> & {
giod0026612025-05-08 13:00:36 +0000132 type: "mongodb";
gio5f2f1002025-03-20 18:38:48 +0400133};
134
135export type GithubData = NodeData & {
giod0026612025-05-08 13:00:36 +0000136 repository?: {
137 id: number;
138 sshURL: string;
139 };
gio5f2f1002025-03-20 18:38:48 +0400140};
141
142export type GithubNode = Node<GithubData> & {
giod0026612025-05-08 13:00:36 +0000143 type: "github";
gio5f2f1002025-03-20 18:38:48 +0400144};
145
146export type NANode = Node<NodeData> & {
giod0026612025-05-08 13:00:36 +0000147 type: undefined;
gio5f2f1002025-03-20 18:38:48 +0400148};
149
giod0026612025-05-08 13:00:36 +0000150export type AppNode =
151 | NetworkNode
152 | GatewayHttpsNode
153 | GatewayTCPNode
154 | ServiceNode
155 | VolumeNode
156 | PostgreSQLNode
157 | MongoDBNode
158 | GithubNode
159 | NANode;
gio5f2f1002025-03-20 18:38:48 +0400160
161export function nodeLabel(n: AppNode): string {
giod0026612025-05-08 13:00:36 +0000162 switch (n.type) {
163 case "network":
164 return n.data.domain;
165 case "app":
166 return n.data.label || "Service";
167 case "github":
168 return n.data.repository?.sshURL || "Github";
169 case "gateway-https": {
170 if (n.data && n.data.network && n.data.subdomain) {
171 return `https://${n.data.subdomain}.${n.data.network}`;
172 } else {
173 return "HTTPS Gateway";
174 }
175 }
176 case "gateway-tcp": {
177 if (n.data && n.data.network && n.data.subdomain) {
178 return `${n.data.subdomain}.${n.data.network}`;
179 } else {
180 return "TCP Gateway";
181 }
182 }
183 case "mongodb":
184 return n.data.label || "MongoDB";
185 case "postgresql":
186 return n.data.label || "PostgreSQL";
187 case "volume":
188 return n.data.label || "Volume";
189 case undefined:
190 throw new Error("MUST NOT REACH!");
191 }
gio5f2f1002025-03-20 18:38:48 +0400192}
193
194export function nodeIsConnectable(n: AppNode, handle: string): boolean {
giod0026612025-05-08 13:00:36 +0000195 switch (n.type) {
196 case "network":
197 return true;
198 case "app":
199 if (handle === "ports") {
200 return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
201 } else if (handle === "repository") {
202 if (!n.data || !n.data.repository || !n.data.repository.id) {
203 return true;
204 }
205 return false;
206 }
207 return false;
208 case "github":
209 if (n.data.repository?.id !== undefined) {
210 return true;
211 }
212 return false;
213 case "gateway-https":
214 if (handle === "subdomain") {
215 return n.data.network === undefined;
216 }
217 return n.data === undefined || n.data.https === undefined;
218 case "gateway-tcp":
219 if (handle === "subdomain") {
220 return n.data.network === undefined;
221 }
222 return true;
223 case "mongodb":
224 return true;
225 case "postgresql":
226 return true;
227 case "volume":
228 if (n.data === undefined || n.data.type === undefined) {
229 return false;
230 }
231 if (n.data.type === "ReadWriteOnce" || n.data.type === "ReadWriteOncePod") {
232 return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
233 }
234 return true;
235 case undefined:
236 throw new Error("MUST NOT REACH!");
237 }
gio5f2f1002025-03-20 18:38:48 +0400238}
239
giod0026612025-05-08 13:00:36 +0000240export type BoundEnvVar =
241 | {
242 id: string;
243 source: string | null;
244 }
245 | {
246 id: string;
247 source: string | null;
248 name: string;
249 isEditting: boolean;
250 }
251 | {
252 id: string;
253 source: string | null;
254 name: string;
255 alias: string;
256 isEditting: boolean;
257 }
258 | {
259 id: string;
260 source: string | null;
261 portId: string;
262 name: string;
263 alias: string;
264 isEditting: boolean;
265 };
gio5f2f1002025-03-20 18:38:48 +0400266
267export type EnvVar = {
giod0026612025-05-08 13:00:36 +0000268 name: string;
269 value: string;
gio5f2f1002025-03-20 18:38:48 +0400270};
271
giob41ecae2025-04-24 08:46:50 +0000272export function nodeEnvVarNamePort(n: AppNode, portName: string): string {
giod0026612025-05-08 13:00:36 +0000273 return `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${portName.toUpperCase()}`;
giob41ecae2025-04-24 08:46:50 +0000274}
275
gio5f2f1002025-03-20 18:38:48 +0400276export function nodeEnvVarNames(n: AppNode): string[] {
giod0026612025-05-08 13:00:36 +0000277 switch (n.type) {
278 case "app":
279 return [
280 `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
281 ...(n.data.ports || []).map((p) => nodeEnvVarNamePort(n, p.name)),
282 ];
283 case "github":
284 return [];
285 case "gateway-https":
286 return [];
287 case "gateway-tcp":
288 return [];
289 case "mongodb":
290 return [`DODO_MONGODB_${n.data.label.toUpperCase()}_URL`];
291 case "postgresql":
292 return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_URL`];
293 case "volume":
294 return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
295 case undefined:
296 throw new Error("MUST NOT REACH");
297 default:
298 throw new Error("MUST NOT REACH");
299 }
gio5f2f1002025-03-20 18:38:48 +0400300}
301
302export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
303
304export type MessageType = "INFO" | "WARNING" | "FATAL";
305
306export type Message = {
giod0026612025-05-08 13:00:36 +0000307 id: string;
308 type: MessageType;
309 nodeId?: string;
310 message: string;
311 onHighlight?: (state: AppState) => void;
312 onLooseHighlight?: (state: AppState) => void;
313 onClick?: (state: AppState) => void;
gio5f2f1002025-03-20 18:38:48 +0400314};
315
316export const envSchema = z.object({
giod0026612025-05-08 13:00:36 +0000317 deployKey: z.optional(z.string().min(1)),
318 networks: z
319 .array(
320 z.object({
321 name: z.string().min(1),
322 domain: z.string().min(1),
323 }),
324 )
325 .default([]),
326 integrations: z.object({
327 github: z.boolean(),
328 }),
gio5f2f1002025-03-20 18:38:48 +0400329});
330
331export type Env = z.infer<typeof envSchema>;
332
gio7f98e772025-05-07 11:00:14 +0000333const defaultEnv: Env = {
giod0026612025-05-08 13:00:36 +0000334 deployKey: undefined,
335 networks: [],
336 integrations: {
337 github: false,
338 },
gio7f98e772025-05-07 11:00:14 +0000339};
340
gio5f2f1002025-03-20 18:38:48 +0400341export type Project = {
giod0026612025-05-08 13:00:36 +0000342 id: string;
343 name: string;
344};
gio5f2f1002025-03-20 18:38:48 +0400345
gio7f98e772025-05-07 11:00:14 +0000346export type IntegrationsConfig = {
giod0026612025-05-08 13:00:36 +0000347 github: boolean;
gio7f98e772025-05-07 11:00:14 +0000348};
349
350type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
351type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
352
gio5f2f1002025-03-20 18:38:48 +0400353export type AppState = {
giod0026612025-05-08 13:00:36 +0000354 projectId: string | undefined;
355 projects: Project[];
356 nodes: AppNode[];
357 edges: Edge[];
358 categories: Category[];
359 messages: Message[];
360 env: Env;
361 githubService: GitHubService | null;
362 setHighlightCategory: (name: string, active: boolean) => void;
363 onNodesChange: OnNodesChange<AppNode>;
364 onEdgesChange: OnEdgesChange;
365 onConnect: OnConnect;
366 setNodes: (nodes: AppNode[]) => void;
367 setEdges: (edges: Edge[]) => void;
368 setProject: (projectId: string | undefined) => void;
369 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
370 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
371 replaceEdge: (c: Connection, id?: string) => void;
372 refreshEnv: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400373};
374
375const projectIdSelector = (state: AppState) => state.projectId;
376const categoriesSelector = (state: AppState) => state.categories;
377const messagesSelector = (state: AppState) => state.messages;
gio7f98e772025-05-07 11:00:14 +0000378const githubServiceSelector = (state: AppState) => state.githubService;
gio5f2f1002025-03-20 18:38:48 +0400379const envSelector = (state: AppState) => state.env;
380
381export function useProjectId(): string | undefined {
giod0026612025-05-08 13:00:36 +0000382 return useStateStore(projectIdSelector);
gio5f2f1002025-03-20 18:38:48 +0400383}
384
385export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000386 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400387}
388
389export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000390 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400391}
392
393export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000394 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400395}
396
397export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000398 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400399}
400
401export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000402 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400403}
404
gio5f2f1002025-03-20 18:38:48 +0400405export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000406 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000407}
408
409export function useGithubService(): GitHubService | null {
giod0026612025-05-08 13:00:36 +0000410 return useStateStore(githubServiceSelector);
gio5f2f1002025-03-20 18:38:48 +0400411}
412
413const v: Validator = CreateValidators();
414
415export const useStateStore = create<AppState>((set, get): AppState => {
giod0026612025-05-08 13:00:36 +0000416 const setN = (nodes: AppNode[]) => {
417 set((state) => ({
418 ...state,
419 nodes,
420 }));
421 };
gio7f98e772025-05-07 11:00:14 +0000422
giod0026612025-05-08 13:00:36 +0000423 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
424 setN(
425 get().nodes.map((n) => {
426 if (n.id === id) {
427 return {
428 ...n,
429 data: {
430 ...n.data,
431 ...data,
432 },
433 } as Extract<AppNode, { type: T }>;
434 }
435 return n;
436 }),
437 );
438 }
gio7f98e772025-05-07 11:00:14 +0000439
giod0026612025-05-08 13:00:36 +0000440 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
441 setN(
442 get().nodes.map((n) => {
443 if (n.id === id) {
444 return {
445 ...n,
446 ...node,
447 } as Extract<AppNode, { type: T }>;
448 }
449 return n;
450 }),
451 );
452 }
gio7f98e772025-05-07 11:00:14 +0000453
giod0026612025-05-08 13:00:36 +0000454 function onConnect(c: Connection) {
455 const { nodes, edges } = get();
456 set({
457 edges: addEdge(c, edges),
458 });
459 const sn = nodes.filter((n) => n.id === c.source)[0]!;
460 const tn = nodes.filter((n) => n.id === c.target)[0]!;
461 if (tn.type === "network") {
462 if (sn.type === "gateway-https") {
463 updateNodeData<"gateway-https">(sn.id, {
464 network: tn.data.domain,
465 });
466 } else if (sn.type === "gateway-tcp") {
467 updateNodeData<"gateway-tcp">(sn.id, {
468 network: tn.data.domain,
469 });
470 }
471 }
472 if (tn.type === "app") {
473 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
474 const sourceEnvVars = nodeEnvVarNames(sn);
475 if (sourceEnvVars.length === 0) {
476 throw new Error("MUST NOT REACH!");
477 }
478 const id = uuidv4();
479 if (sourceEnvVars.length === 1) {
480 updateNode<"app">(c.target, {
481 ...tn,
482 data: {
483 ...tn.data,
484 envVars: [
485 ...(tn.data.envVars || []),
486 {
487 id: id,
488 source: c.source,
489 name: sourceEnvVars[0],
490 isEditting: false,
491 },
492 ],
493 },
494 });
495 } else {
496 updateNode<"app">(c.target, {
497 ...tn,
498 data: {
499 ...tn.data,
500 envVars: [
501 ...(tn.data.envVars || []),
502 {
503 id: id,
504 source: c.source,
505 },
506 ],
507 },
508 });
509 }
510 }
511 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
512 const sourcePorts = sn.data.ports || [];
513 const id = uuidv4();
514 if (sourcePorts.length === 1) {
515 updateNode<"app">(c.target, {
516 ...tn,
517 data: {
518 ...tn.data,
519 envVars: [
520 ...(tn.data.envVars || []),
521 {
522 id: id,
523 source: c.source,
524 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
525 portId: sourcePorts[0].id,
526 isEditting: false,
527 },
528 ],
529 },
530 });
531 }
532 }
533 }
534 if (c.sourceHandle === "volume") {
535 updateNodeData<"volume">(c.source, {
536 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
537 });
538 }
539 if (c.targetHandle === "volume") {
540 if (tn.type === "postgresql" || tn.type === "mongodb") {
541 updateNodeData(c.target, {
542 volumeId: c.source,
543 });
544 }
545 }
546 if (c.targetHandle === "https") {
547 if ((sn.data.ports || []).length === 1) {
548 updateNodeData<"gateway-https">(c.target, {
549 https: {
550 serviceId: c.source,
551 portId: sn.data.ports![0].id,
552 },
553 });
554 } else {
555 updateNodeData<"gateway-https">(c.target, {
556 https: {
557 serviceId: c.source,
558 portId: "", // TODO(gio)
559 },
560 });
561 }
562 }
563 if (c.targetHandle === "tcp") {
564 const td = tn.data as GatewayTCPData;
565 if ((sn.data.ports || []).length === 1) {
566 updateNodeData<"gateway-tcp">(c.target, {
567 exposed: (td.exposed || []).concat({
568 serviceId: c.source,
569 portId: sn.data.ports![0].id,
570 }),
571 });
572 } else {
573 updateNodeData<"gateway-tcp">(c.target, {
574 selected: {
575 serviceId: c.source,
576 portId: undefined,
577 },
578 });
579 }
580 }
581 if (sn.type === "app") {
582 if (c.sourceHandle === "ports") {
583 updateNodeData<"app">(sn.id, {
584 isChoosingPortToConnect: true,
585 });
586 }
587 }
588 if (tn.type === "app") {
589 if (c.targetHandle === "repository") {
590 updateNodeData<"app">(tn.id, {
591 repository: {
592 id: c.source,
593 branch: "master",
594 rootDir: "/",
595 },
596 });
597 }
598 }
599 }
600 return {
601 projectId: undefined,
602 projects: [],
603 nodes: [],
604 edges: [],
605 categories: defaultCategories,
606 messages: v([]),
607 env: defaultEnv,
608 githubService: null,
609 setHighlightCategory: (name, active) => {
610 set({
611 categories: get().categories.map((c) => {
612 if (c.title.toLowerCase() !== name.toLowerCase()) {
613 return c;
614 } else {
615 return {
616 ...c,
617 active,
618 };
619 }
620 }),
621 });
622 },
623 onNodesChange: (changes) => {
624 const nodes = applyNodeChanges(changes, get().nodes);
625 setN(nodes);
626 },
627 onEdgesChange: (changes) => {
628 set({
629 edges: applyEdgeChanges(changes, get().edges),
630 });
631 },
632 setNodes: (nodes) => {
633 setN(nodes);
634 },
635 setEdges: (edges) => {
636 set({ edges });
637 },
638 replaceEdge: (c, id) => {
639 let change: EdgeChange;
640 if (id === undefined) {
641 change = {
642 type: "add",
643 item: {
644 id: uuidv4(),
645 ...c,
646 },
647 };
648 onConnect(c);
649 } else {
650 change = {
651 type: "replace",
652 id,
653 item: {
654 id,
655 ...c,
656 },
657 };
658 }
659 set({
660 edges: applyEdgeChanges([change], get().edges),
661 });
662 },
663 updateNode,
664 updateNodeData,
665 onConnect,
666 refreshEnv: async () => {
667 const projectId = get().projectId;
668 let env: Env = defaultEnv;
gio7f98e772025-05-07 11:00:14 +0000669
giod0026612025-05-08 13:00:36 +0000670 try {
671 if (projectId) {
672 const response = await fetch(`/api/project/${projectId}/env`);
673 if (response.ok) {
674 const data = await response.json();
675 const result = envSchema.safeParse(data);
676 if (result.success) {
677 env = result.data;
678 } else {
679 console.error("Invalid env data:", result.error);
680 }
681 }
682 }
683 } catch (error) {
684 console.error("Failed to fetch integrations:", error);
685 } finally {
686 set({ env: env });
687 if (env.integrations.github) {
688 set({ githubService: new GitHubServiceImpl(projectId!) });
689 } else {
690 set({ githubService: null });
691 }
692 }
693 },
694 setProject: (projectId) => {
695 set({
696 projectId,
697 });
698 if (projectId) {
699 get().refreshEnv();
700 }
701 },
702 };
gio5f2f1002025-03-20 18:38:48 +0400703});