blob: f9c1f569e66340378bc2cb3aaaad82c1f5641102 [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,
gio5cf364c2025-05-08 16:01:21 +0000420 messages: v(nodes),
giod0026612025-05-08 13:00:36 +0000421 }));
422 };
gio7f98e772025-05-07 11:00:14 +0000423
giod0026612025-05-08 13:00:36 +0000424 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
425 setN(
426 get().nodes.map((n) => {
427 if (n.id === id) {
428 return {
429 ...n,
430 data: {
431 ...n.data,
432 ...data,
433 },
434 } as Extract<AppNode, { type: T }>;
435 }
436 return n;
437 }),
438 );
439 }
gio7f98e772025-05-07 11:00:14 +0000440
giod0026612025-05-08 13:00:36 +0000441 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
442 setN(
443 get().nodes.map((n) => {
444 if (n.id === id) {
445 return {
446 ...n,
447 ...node,
448 } as Extract<AppNode, { type: T }>;
449 }
450 return n;
451 }),
452 );
453 }
gio7f98e772025-05-07 11:00:14 +0000454
giod0026612025-05-08 13:00:36 +0000455 function onConnect(c: Connection) {
456 const { nodes, edges } = get();
457 set({
458 edges: addEdge(c, edges),
459 });
460 const sn = nodes.filter((n) => n.id === c.source)[0]!;
461 const tn = nodes.filter((n) => n.id === c.target)[0]!;
462 if (tn.type === "network") {
463 if (sn.type === "gateway-https") {
464 updateNodeData<"gateway-https">(sn.id, {
465 network: tn.data.domain,
466 });
467 } else if (sn.type === "gateway-tcp") {
468 updateNodeData<"gateway-tcp">(sn.id, {
469 network: tn.data.domain,
470 });
471 }
472 }
473 if (tn.type === "app") {
474 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
475 const sourceEnvVars = nodeEnvVarNames(sn);
476 if (sourceEnvVars.length === 0) {
477 throw new Error("MUST NOT REACH!");
478 }
479 const id = uuidv4();
480 if (sourceEnvVars.length === 1) {
481 updateNode<"app">(c.target, {
482 ...tn,
483 data: {
484 ...tn.data,
485 envVars: [
486 ...(tn.data.envVars || []),
487 {
488 id: id,
489 source: c.source,
490 name: sourceEnvVars[0],
491 isEditting: false,
492 },
493 ],
494 },
495 });
496 } else {
497 updateNode<"app">(c.target, {
498 ...tn,
499 data: {
500 ...tn.data,
501 envVars: [
502 ...(tn.data.envVars || []),
503 {
504 id: id,
505 source: c.source,
506 },
507 ],
508 },
509 });
510 }
511 }
512 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
513 const sourcePorts = sn.data.ports || [];
514 const id = uuidv4();
515 if (sourcePorts.length === 1) {
516 updateNode<"app">(c.target, {
517 ...tn,
518 data: {
519 ...tn.data,
520 envVars: [
521 ...(tn.data.envVars || []),
522 {
523 id: id,
524 source: c.source,
525 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
526 portId: sourcePorts[0].id,
527 isEditting: false,
528 },
529 ],
530 },
531 });
532 }
533 }
534 }
535 if (c.sourceHandle === "volume") {
536 updateNodeData<"volume">(c.source, {
537 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
538 });
539 }
540 if (c.targetHandle === "volume") {
541 if (tn.type === "postgresql" || tn.type === "mongodb") {
542 updateNodeData(c.target, {
543 volumeId: c.source,
544 });
545 }
546 }
547 if (c.targetHandle === "https") {
548 if ((sn.data.ports || []).length === 1) {
549 updateNodeData<"gateway-https">(c.target, {
550 https: {
551 serviceId: c.source,
552 portId: sn.data.ports![0].id,
553 },
554 });
555 } else {
556 updateNodeData<"gateway-https">(c.target, {
557 https: {
558 serviceId: c.source,
559 portId: "", // TODO(gio)
560 },
561 });
562 }
563 }
564 if (c.targetHandle === "tcp") {
565 const td = tn.data as GatewayTCPData;
566 if ((sn.data.ports || []).length === 1) {
567 updateNodeData<"gateway-tcp">(c.target, {
568 exposed: (td.exposed || []).concat({
569 serviceId: c.source,
570 portId: sn.data.ports![0].id,
571 }),
572 });
573 } else {
574 updateNodeData<"gateway-tcp">(c.target, {
575 selected: {
576 serviceId: c.source,
577 portId: undefined,
578 },
579 });
580 }
581 }
582 if (sn.type === "app") {
583 if (c.sourceHandle === "ports") {
584 updateNodeData<"app">(sn.id, {
585 isChoosingPortToConnect: true,
586 });
587 }
588 }
589 if (tn.type === "app") {
590 if (c.targetHandle === "repository") {
591 updateNodeData<"app">(tn.id, {
592 repository: {
593 id: c.source,
594 branch: "master",
595 rootDir: "/",
596 },
597 });
598 }
599 }
600 }
601 return {
602 projectId: undefined,
603 projects: [],
604 nodes: [],
605 edges: [],
606 categories: defaultCategories,
607 messages: v([]),
608 env: defaultEnv,
609 githubService: null,
610 setHighlightCategory: (name, active) => {
611 set({
612 categories: get().categories.map((c) => {
613 if (c.title.toLowerCase() !== name.toLowerCase()) {
614 return c;
615 } else {
616 return {
617 ...c,
618 active,
619 };
620 }
621 }),
622 });
623 },
624 onNodesChange: (changes) => {
625 const nodes = applyNodeChanges(changes, get().nodes);
626 setN(nodes);
627 },
628 onEdgesChange: (changes) => {
629 set({
630 edges: applyEdgeChanges(changes, get().edges),
631 });
632 },
633 setNodes: (nodes) => {
634 setN(nodes);
635 },
636 setEdges: (edges) => {
637 set({ edges });
638 },
639 replaceEdge: (c, id) => {
640 let change: EdgeChange;
641 if (id === undefined) {
642 change = {
643 type: "add",
644 item: {
645 id: uuidv4(),
646 ...c,
647 },
648 };
649 onConnect(c);
650 } else {
651 change = {
652 type: "replace",
653 id,
654 item: {
655 id,
656 ...c,
657 },
658 };
659 }
660 set({
661 edges: applyEdgeChanges([change], get().edges),
662 });
663 },
664 updateNode,
665 updateNodeData,
666 onConnect,
667 refreshEnv: async () => {
668 const projectId = get().projectId;
669 let env: Env = defaultEnv;
gio7f98e772025-05-07 11:00:14 +0000670
giod0026612025-05-08 13:00:36 +0000671 try {
672 if (projectId) {
673 const response = await fetch(`/api/project/${projectId}/env`);
674 if (response.ok) {
675 const data = await response.json();
676 const result = envSchema.safeParse(data);
677 if (result.success) {
678 env = result.data;
679 } else {
680 console.error("Invalid env data:", result.error);
681 }
682 }
683 }
684 } catch (error) {
685 console.error("Failed to fetch integrations:", error);
686 } finally {
687 set({ env: env });
688 if (env.integrations.github) {
689 set({ githubService: new GitHubServiceImpl(projectId!) });
690 } else {
691 set({ githubService: null });
692 }
693 }
694 },
695 setProject: (projectId) => {
696 set({
697 projectId,
698 });
699 if (projectId) {
700 get().refreshEnv();
701 }
702 },
703 };
gio5f2f1002025-03-20 18:38:48 +0400704});