blob: 664ba6e6b211b1b6bbe6e40219135f5c2df6b6f1 [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';
4import {
5 type Edge,
6 type Node,
7 type OnNodesChange,
8 type OnEdgesChange,
9 type OnConnect,
10} from '@xyflow/react';
11import { DeepPartial } from "react-hook-form";
12import { Category, defaultCategories } from "./categories";
13import { CreateValidators, Validator } from "./config";
14import { z } from "zod";
15
16export type InitData = {
17 label: string;
18 envVars: BoundEnvVar[];
19 ports: Port[];
20};
21
22export type NodeData = InitData & {
23 activeField?: string | undefined;
gio1dc800a2025-04-24 17:15:43 +000024 state: string | null;
gio5f2f1002025-03-20 18:38:48 +040025};
26
27export type PortConnectedTo = {
28 serviceId: string;
29 portId: string;
30}
31
gioaba9a962025-04-25 14:19:40 +000032export type NetworkData = NodeData & {
33 domain: string;
34};
35
36export type NetworkNode = Node<NetworkData> & {
37 type: "network";
38};
39
gio5f2f1002025-03-20 18:38:48 +040040export type GatewayHttpsData = NodeData & {
41 network?: string;
42 subdomain?: string;
43 https?: PortConnectedTo;
44};
45
46export type GatewayHttpsNode = Node<GatewayHttpsData> & {
47 type: "gateway-https";
48};
49
50export type GatewayTCPData = NodeData & {
51 network?: string;
52 subdomain?: string;
53 exposed: PortConnectedTo[];
54 selected?: {
55 serviceId?: string;
56 portId?: string;
57 };
58};
59
60export type GatewayTCPNode = Node<GatewayTCPData> & {
61 type: "gateway-tcp";
62};
63
64export type Port = {
65 id: string;
66 name: string;
67 value: number;
68};
69
gio91165612025-05-03 17:07:38 +000070export const ServiceTypes = [
71 "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"
79] as const;
gio5f2f1002025-03-20 18:38:48 +040080export type ServiceType = typeof ServiceTypes[number];
81
82export type ServiceData = NodeData & {
83 type: ServiceType;
84 repository: {
85 id: string;
86 branch: string;
87 rootDir: string;
88 };
89 env: string[];
90 volume: string[];
gio91165612025-05-03 17:07:38 +000091 preBuildCommands: string;
gio5f2f1002025-03-20 18:38:48 +040092 isChoosingPortToConnect: boolean;
93};
94
95export type ServiceNode = Node<ServiceData> & {
96 type: "app";
97};
98
99export type VolumeType = "ReadWriteOnce" | "ReadOnlyMany" | "ReadWriteMany" | "ReadWriteOncePod";
100
101export type VolumeData = NodeData & {
102 type: VolumeType;
103 size: string;
104 attachedTo: string[];
105};
106
107export type VolumeNode = Node<VolumeData> & {
108 type: "volume";
109};
110
111export type PostgreSQLData = NodeData & {
112 volumeId: string;
113};
114
115export type PostgreSQLNode = Node<PostgreSQLData> & {
116 type: "postgresql";
117};
118
119export type MongoDBData = NodeData & {
120 volumeId: string;
121};
122
123export type MongoDBNode = Node<MongoDBData> & {
124 type: "mongodb";
125};
126
127export type GithubData = NodeData & {
128 address: string;
129};
130
131export type GithubNode = Node<GithubData> & {
132 type: "github";
133};
134
135export type NANode = Node<NodeData> & {
136 type: undefined;
137};
138
gioaba9a962025-04-25 14:19:40 +0000139export type AppNode = NetworkNode | GatewayHttpsNode | GatewayTCPNode | ServiceNode | VolumeNode | PostgreSQLNode | MongoDBNode | GithubNode | NANode;
gio5f2f1002025-03-20 18:38:48 +0400140
141export function nodeLabel(n: AppNode): string {
142 switch (n.type) {
gioaba9a962025-04-25 14:19:40 +0000143 case "network": return n.data.domain;
gio5f2f1002025-03-20 18:38:48 +0400144 case "app": return n.data.label || "Service";
145 case "github": return n.data.address || "Github";
146 case "gateway-https": {
147 if (n.data && n.data.network && n.data.subdomain) {
148 return `https://${n.data.subdomain}.${n.data.network}`;
149 } else {
150 return "HTTPS Gateway";
151 }
152 }
153 case "gateway-tcp": {
154 if (n.data && n.data.network && n.data.subdomain) {
155 return `${n.data.subdomain}.${n.data.network}`;
156 } else {
157 return "TCP Gateway";
158 }
159 }
160 case "mongodb": return n.data.label || "MongoDB";
161 case "postgresql": return n.data.label || "PostgreSQL";
162 case "volume": return n.data.label || "Volume";
163 case undefined: throw new Error("MUST NOT REACH!");
164 }
165}
166
167export function nodeIsConnectable(n: AppNode, handle: string): boolean {
168 switch (n.type) {
gioaba9a962025-04-25 14:19:40 +0000169 case "network":
170 return true;
gio5f2f1002025-03-20 18:38:48 +0400171 case "app":
172 if (handle === "ports") {
173 return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
174 } else if (handle === "repository") {
175 if (!n.data || !n.data.repository || !n.data.repository.id) {
176 return true;
177 }
178 return false;
179 }
180 return false;
181 case "github":
182 if (n.data !== undefined && n.data.address) {
183 return true;
184 }
185 return false;
186 case "gateway-https":
gioaba9a962025-04-25 14:19:40 +0000187 if (handle === "subdomain") {
188 return n.data.network === undefined;
189 }
gio5f2f1002025-03-20 18:38:48 +0400190 return n.data === undefined || n.data.https === undefined;
191 case "gateway-tcp":
gioaba9a962025-04-25 14:19:40 +0000192 if (handle === "subdomain") {
193 return n.data.network === undefined;
194 }
gio5f2f1002025-03-20 18:38:48 +0400195 return true;
196 case "mongodb":
197 return true;
198 case "postgresql":
199 return true;
200 case "volume":
201 if (n.data === undefined || n.data.type === undefined) {
202 return false;
203 }
204 if (n.data.type === "ReadWriteOnce" || n.data.type === "ReadWriteOncePod") {
205 return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
206 }
207 return true;
208 case undefined: throw new Error("MUST NOT REACH!");
209 }
210}
211
212export type BoundEnvVar = {
213 id: string;
gio355883e2025-04-23 14:10:51 +0000214 source: string | null;
gio5f2f1002025-03-20 18:38:48 +0400215} | {
216 id: string;
gio355883e2025-04-23 14:10:51 +0000217 source: string | null;
gio5f2f1002025-03-20 18:38:48 +0400218 name: string;
219 isEditting: boolean;
220} | {
221 id: string;
gio355883e2025-04-23 14:10:51 +0000222 source: string | null;
gio5f2f1002025-03-20 18:38:48 +0400223 name: string;
224 alias: string;
225 isEditting: boolean;
giob41ecae2025-04-24 08:46:50 +0000226} | {
227 id: string;
228 source: string | null;
229 portId: string;
230 name: string;
231 alias: string;
232 isEditting: boolean;
gio5f2f1002025-03-20 18:38:48 +0400233};
234
235export type EnvVar = {
236 name: string;
237 value: string;
238};
239
giob41ecae2025-04-24 08:46:50 +0000240export function nodeEnvVarNamePort(n: AppNode, portName: string): string {
241 return `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${portName.toUpperCase()}`;
242}
243
gio5f2f1002025-03-20 18:38:48 +0400244export function nodeEnvVarNames(n: AppNode): string[] {
245 switch (n.type) {
246 case "app": return [
247 `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
giob41ecae2025-04-24 08:46:50 +0000248 ...(n.data.ports || []).map((p) => nodeEnvVarNamePort(n, p.name)),
gio5f2f1002025-03-20 18:38:48 +0400249 ];
250 case "github": return [];
251 case "gateway-https": return [];
252 case "gateway-tcp": return [];
gio355883e2025-04-23 14:10:51 +0000253 case "mongodb": return [`DODO_MONGODB_${n.data.label.toUpperCase()}_URL`];
254 case "postgresql": return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_URL`];
gio5f2f1002025-03-20 18:38:48 +0400255 case "volume": return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
256 case undefined: throw new Error("MUST NOT REACH");
257 }
258}
259
260export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
261
262export type MessageType = "INFO" | "WARNING" | "FATAL";
263
264export type Message = {
265 id: string;
266 type: MessageType;
267 nodeId?: string;
268 message: string;
269 onHighlight?: (state: AppState) => void;
270 onLooseHighlight?: (state: AppState) => void;
271 onClick?: (state: AppState) => void;
272};
273
274export const envSchema = z.object({
275 deployKey: z.string(),
276 networks: z.array(z.object({
277 name: z.string(),
278 domain: z.string(),
279 })),
280});
281
282export type Env = z.infer<typeof envSchema>;
283
284export type Project = {
285 id: string;
286 name: string;
287}
288
289export type AppState = {
290 projectId: string | undefined;
291 projects: Project[];
292 nodes: AppNode[];
293 edges: Edge[];
294 categories: Category[];
295 messages: Message[];
296 env?: Env;
297 setHighlightCategory: (name: string, active: boolean) => void;
298 onNodesChange: OnNodesChange<AppNode>;
299 onEdgesChange: OnEdgesChange;
300 onConnect: OnConnect;
301 setNodes: (nodes: AppNode[]) => void;
302 setEdges: (edges: Edge[]) => void;
giob68003c2025-04-25 03:05:21 +0000303 setProject: (projectId: string | undefined) => void;
gio5f2f1002025-03-20 18:38:48 +0400304 setProjects: (projects: Project[]) => void;
305 updateNode: <T extends NodeType>(id: string, data: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))>) => void;
306 updateNodeData: <T extends NodeType>(id: string, data: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))["data"]>) => void;
307 replaceEdge: (c: Connection, id?: string) => void;
308 refreshEnv: () => Promise<Env | undefined>;
309};
310
311const projectIdSelector = (state: AppState) => state.projectId;
312const categoriesSelector = (state: AppState) => state.categories;
313const messagesSelector = (state: AppState) => state.messages;
314const envSelector = (state: AppState) => state.env;
315
316export function useProjectId(): string | undefined {
317 return useStateStore(projectIdSelector);
318}
319
320export function useCategories(): Category[] {
321 return useStateStore(categoriesSelector);
322}
323
324export function useMessages(): Message[] {
325 return useStateStore(messagesSelector);
326}
327
328export function useNodeMessages(id: string): Message[] {
329 return useMessages().filter((m) => m.nodeId === id);
330}
331
332export function useNodeLabel(id: string): string {
333 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
334}
335
336export function useNodePortName(id: string, portId: string): string {
337 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
338}
339
340let envRefresh: Promise<Env | undefined> | null = null;
341
gioaba9a962025-04-25 14:19:40 +0000342const fixedEnv: Env = {
343 "deployKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPK58vMu0MwIzdZT+mqpIBkhl48p9+/YwDCZv7MgTesF",
344 "networks": [{
345 "name": "Public",
346 "domain": "v1.dodo.cloud",
347 }, {
348 "name": "Private",
349 "domain": "p.v1.dodo.cloud",
350 }],
351};
352
gio5f2f1002025-03-20 18:38:48 +0400353export function useEnv(): Env {
gioaba9a962025-04-25 14:19:40 +0000354 return fixedEnv;
gio5f2f1002025-03-20 18:38:48 +0400355 const store = useStateStore();
356 const env = envSelector(store);
357 console.log(env);
358 if (env != null) {
359 return env;
360 }
361 if (envRefresh == null) {
362 envRefresh = store.refreshEnv();
363 envRefresh.finally(() => envRefresh = null);
364 }
365 return {
366 deployKey: "",
367 networks: [],
368 };
369}
370
371const v: Validator = CreateValidators();
372
373export const useStateStore = create<AppState>((set, get): AppState => {
374 set({ env: {
375 "deployKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPK58vMu0MwIzdZT+mqpIBkhl48p9+/YwDCZv7MgTesF",
376 "networks": [{
377 "name": "Public",
378 "domain": "v1.dodo.cloud",
379 }, {
380 "name": "Private",
381 "domain": "p.v1.dodo.cloud",
382 }],
383 }});
384 console.log(get().env);
385 const setN = (nodes: AppNode[]) => {
386 set({
387 nodes: nodes,
388 messages: v(nodes),
389 })
390 };
391 function updateNodeData<T extends NodeType>(id: string, d: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))["data"]>): void {
392 setN(get().nodes.map((n) => {
393 if (n.id !== id) {
394 return n;
395 }
396 const nd = {
397 ...n,
398 data: {
399 ...n.data,
400 ...d,
401 },
402 };
403 return nd;
404 })
405 );
406 };
407 function updateNode<T extends NodeType>(id: string, d: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))>): void {
408 setN(
409 get().nodes.map((n) => {
410 if (n.id !== id) {
411 return n;
412 }
413 return {
414 ...n,
415 ...d,
416 };
417 })
418 );
419 };
420 function onConnect(c: Connection) {
421 const { nodes, edges } = get();
422 set({
423 edges: addEdge(c, edges),
424 });
425 const sn = nodes.filter((n) => n.id === c.source)[0]!;
426 const tn = nodes.filter((n) => n.id === c.target)[0]!;
gioaba9a962025-04-25 14:19:40 +0000427 if (tn.type === "network") {
428 if (sn.type === "gateway-https") {
429 updateNodeData<"gateway-https">(sn.id, {
430 network: tn.data.domain,
431 });
432 }else if (sn.type === "gateway-tcp") {
433 updateNodeData<"gateway-tcp">(sn.id, {
434 network: tn.data.domain,
435 });
436 }
437 }
gio5f2f1002025-03-20 18:38:48 +0400438 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
439 const sourceEnvVars = nodeEnvVarNames(sn);
440 if (sourceEnvVars.length === 0) {
441 throw new Error("MUST NOT REACH!");
442 }
443 const id = uuidv4();
444 if (sourceEnvVars.length === 1) {
445 updateNode(c.target, {
446 ...tn,
447 data: {
448 ...tn.data,
449 envVars: [
450 ...(tn.data.envVars || []),
451 {
452 id: id,
453 source: c.source,
454 name: sourceEnvVars[0],
455 isEditting: false,
456 },
457 ],
458 },
459 });
460 } else {
461 updateNode(c.target, {
462 ...tn,
463 data: {
464 ...tn.data,
465 envVars: [
466 ...(tn.data.envVars || []),
467 {
468 id: id,
469 source: c.source,
470 },
471 ],
472 },
473 });
474 }
475 }
giob41ecae2025-04-24 08:46:50 +0000476 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
477 const sourcePorts = sn.data.ports || [];
478 const id = uuidv4();
479 if (sourcePorts.length === 1) {
480 updateNode(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: nodeEnvVarNamePort(sn, sourcePorts[0].name),
490 portId: sourcePorts[0].id,
491 isEditting: false,
492 },
493 ],
494 },
495 });
496 }
497 }
gio5f2f1002025-03-20 18:38:48 +0400498 if (c.sourceHandle === "volume") {
499 updateNodeData<"volume">(c.source, {
500 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
501 });
502 }
503 if (c.targetHandle === "volume") {
504 if (tn.type === "postgresql" || tn.type === "mongodb") {
505 updateNodeData(c.target, {
506 volumeId: c.source,
507 });
508 }
509 }
510 if (c.targetHandle === "https") {
511 if ((sn.data.ports || []).length === 1) {
512 updateNodeData<"gateway-https">(c.target, {
513 https: {
514 serviceId: c.source,
515 portId: sn.data.ports![0].id,
516 }
517 });
518 } else {
519 updateNodeData<"gateway-https">(c.target, {
520 https: {
521 serviceId: c.source,
522 portId: "", // TODO(gio)
523 }
524 });
525 }
526 }
527 if (c.targetHandle === "tcp") {
528 const td = tn.data as GatewayTCPData;
529 if ((sn.data.ports || []).length === 1) {
530 updateNodeData<"gateway-tcp">(c.target, {
531 exposed: (td.exposed || []).concat({
532 serviceId: c.source,
533 portId: sn.data.ports![0].id,
534 }),
535 });
536 } else {
537 updateNodeData<"gateway-tcp">(c.target, {
538 selected: {
539 serviceId: c.source,
540 portId: undefined,
541 },
542 });
543 }
544 }
545 if (sn.type === "app") {
546 if (c.sourceHandle === "ports") {
547 updateNodeData<"app">(sn.id, {
548 isChoosingPortToConnect: true,
549 });
550 }
551 }
552 if (tn.type === "app") {
553 if (c.targetHandle === "repository") {
554 updateNodeData<"app">(tn.id, {
555 repository: {
556 id: c.source,
557 branch: "master",
558 rootDir: "/",
559 }
560 });
561 }
562 }
563 }
564 return {
565 projectId: undefined,
566 projects: [],
567 nodes: [],
568 edges: [],
569 categories: defaultCategories,
570 messages: v([]),
571 setHighlightCategory: (name, active) => {
572 set({
573 categories: get().categories.map(
574 (c) => {
575 if (c.title.toLowerCase() !== name.toLowerCase()) {
576 return c;
577 } else {
578 return {
579 ...c,
580 active,
581 }
582 }
583 })
584 });
585 },
586 onNodesChange: (changes) => {
587 const nodes = applyNodeChanges(changes, get().nodes);
588 setN(nodes);
589 },
590 onEdgesChange: (changes) => {
591 set({
592 edges: applyEdgeChanges(changes, get().edges),
593 });
594 },
595 setNodes: (nodes) => {
596 setN(nodes);
597 },
598 setEdges: (edges) => {
599 set({ edges });
600 },
601 replaceEdge: (c, id) => {
602 let change: EdgeChange;
603 if (id === undefined) {
604 change = {
605 type: "add",
606 item: {
607 id: uuidv4(),
608 ...c,
609 }
610 };
611 onConnect(c);
612 } else {
613 change = {
614 type: "replace",
615 id,
616 item: {
617 id,
618 ...c,
619 }
620 };
621 }
622 set({
623 edges: applyEdgeChanges([change], get().edges),
624 })
625 },
626 updateNode,
627 updateNodeData,
628 onConnect,
629 refreshEnv: async () => {
630 return get().env;
631 const resp = await fetch("/env");
632 if (!resp.ok) {
633 throw new Error("failed to fetch env config");
634 }
635 set({ env: envSchema.parse(await resp.json()) });
636 return get().env;
637 },
638 setProject: (projectId) => set({ projectId }),
639 setProjects: (projects) => set({ projects }),
640 };
641});