blob: ee6f6d286408600a1752d2c8f20dcb31d2049b17 [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",
gio33990c62025-05-06 07:51:24 +000077 "nextjs:deno-2.0.0",
gio91165612025-05-03 17:07:38 +000078 "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;
gio33990c62025-05-06 07:51:24 +000086 } | {
87 id: string;
88 branch: string;
89 } | {
90 id: string;
gio5f2f1002025-03-20 18:38:48 +040091 branch: string;
92 rootDir: string;
93 };
94 env: string[];
95 volume: string[];
gio91165612025-05-03 17:07:38 +000096 preBuildCommands: string;
gio5f2f1002025-03-20 18:38:48 +040097 isChoosingPortToConnect: boolean;
98};
99
100export type ServiceNode = Node<ServiceData> & {
101 type: "app";
102};
103
104export type VolumeType = "ReadWriteOnce" | "ReadOnlyMany" | "ReadWriteMany" | "ReadWriteOncePod";
105
106export type VolumeData = NodeData & {
107 type: VolumeType;
108 size: string;
109 attachedTo: string[];
110};
111
112export type VolumeNode = Node<VolumeData> & {
113 type: "volume";
114};
115
116export type PostgreSQLData = NodeData & {
117 volumeId: string;
118};
119
120export type PostgreSQLNode = Node<PostgreSQLData> & {
121 type: "postgresql";
122};
123
124export type MongoDBData = NodeData & {
125 volumeId: string;
126};
127
128export type MongoDBNode = Node<MongoDBData> & {
129 type: "mongodb";
130};
131
132export type GithubData = NodeData & {
133 address: string;
134};
135
136export type GithubNode = Node<GithubData> & {
137 type: "github";
138};
139
140export type NANode = Node<NodeData> & {
141 type: undefined;
142};
143
gioaba9a962025-04-25 14:19:40 +0000144export type AppNode = NetworkNode | GatewayHttpsNode | GatewayTCPNode | ServiceNode | VolumeNode | PostgreSQLNode | MongoDBNode | GithubNode | NANode;
gio5f2f1002025-03-20 18:38:48 +0400145
146export function nodeLabel(n: AppNode): string {
147 switch (n.type) {
gioaba9a962025-04-25 14:19:40 +0000148 case "network": return n.data.domain;
gio5f2f1002025-03-20 18:38:48 +0400149 case "app": return n.data.label || "Service";
150 case "github": return n.data.address || "Github";
151 case "gateway-https": {
152 if (n.data && n.data.network && n.data.subdomain) {
153 return `https://${n.data.subdomain}.${n.data.network}`;
154 } else {
155 return "HTTPS Gateway";
156 }
157 }
158 case "gateway-tcp": {
159 if (n.data && n.data.network && n.data.subdomain) {
160 return `${n.data.subdomain}.${n.data.network}`;
161 } else {
162 return "TCP Gateway";
163 }
164 }
165 case "mongodb": return n.data.label || "MongoDB";
166 case "postgresql": return n.data.label || "PostgreSQL";
167 case "volume": return n.data.label || "Volume";
168 case undefined: throw new Error("MUST NOT REACH!");
169 }
170}
171
172export function nodeIsConnectable(n: AppNode, handle: string): boolean {
173 switch (n.type) {
gioaba9a962025-04-25 14:19:40 +0000174 case "network":
175 return true;
gio5f2f1002025-03-20 18:38:48 +0400176 case "app":
177 if (handle === "ports") {
178 return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
179 } else if (handle === "repository") {
180 if (!n.data || !n.data.repository || !n.data.repository.id) {
gio33990c62025-05-06 07:51:24 +0000181 return true;
gio5f2f1002025-03-20 18:38:48 +0400182 }
183 return false;
184 }
gio33990c62025-05-06 07:51:24 +0000185 return false;
gio5f2f1002025-03-20 18:38:48 +0400186 case "github":
187 if (n.data !== undefined && n.data.address) {
188 return true;
189 }
190 return false;
191 case "gateway-https":
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 n.data === undefined || n.data.https === undefined;
196 case "gateway-tcp":
gioaba9a962025-04-25 14:19:40 +0000197 if (handle === "subdomain") {
198 return n.data.network === undefined;
199 }
gio5f2f1002025-03-20 18:38:48 +0400200 return true;
201 case "mongodb":
202 return true;
203 case "postgresql":
204 return true;
205 case "volume":
206 if (n.data === undefined || n.data.type === undefined) {
207 return false;
208 }
209 if (n.data.type === "ReadWriteOnce" || n.data.type === "ReadWriteOncePod") {
gio33990c62025-05-06 07:51:24 +0000210 return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
gio5f2f1002025-03-20 18:38:48 +0400211 }
212 return true;
213 case undefined: throw new Error("MUST NOT REACH!");
214 }
215}
216
217export type BoundEnvVar = {
218 id: string;
gio355883e2025-04-23 14:10:51 +0000219 source: string | null;
gio5f2f1002025-03-20 18:38:48 +0400220} | {
221 id: string;
gio355883e2025-04-23 14:10:51 +0000222 source: string | null;
gio5f2f1002025-03-20 18:38:48 +0400223 name: string;
224 isEditting: boolean;
225} | {
226 id: string;
gio355883e2025-04-23 14:10:51 +0000227 source: string | null;
gio5f2f1002025-03-20 18:38:48 +0400228 name: string;
229 alias: string;
230 isEditting: boolean;
giob41ecae2025-04-24 08:46:50 +0000231} | {
232 id: string;
233 source: string | null;
234 portId: string;
235 name: string;
236 alias: string;
237 isEditting: boolean;
gio5f2f1002025-03-20 18:38:48 +0400238};
239
240export type EnvVar = {
241 name: string;
242 value: string;
243};
244
giob41ecae2025-04-24 08:46:50 +0000245export function nodeEnvVarNamePort(n: AppNode, portName: string): string {
246 return `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${portName.toUpperCase()}`;
247}
248
gio5f2f1002025-03-20 18:38:48 +0400249export function nodeEnvVarNames(n: AppNode): string[] {
250 switch (n.type) {
251 case "app": return [
gio33990c62025-05-06 07:51:24 +0000252 `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
giob41ecae2025-04-24 08:46:50 +0000253 ...(n.data.ports || []).map((p) => nodeEnvVarNamePort(n, p.name)),
gio5f2f1002025-03-20 18:38:48 +0400254 ];
255 case "github": return [];
256 case "gateway-https": return [];
257 case "gateway-tcp": return [];
gio355883e2025-04-23 14:10:51 +0000258 case "mongodb": return [`DODO_MONGODB_${n.data.label.toUpperCase()}_URL`];
259 case "postgresql": return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_URL`];
gio5f2f1002025-03-20 18:38:48 +0400260 case "volume": return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
261 case undefined: throw new Error("MUST NOT REACH");
262 }
263}
264
265export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
266
267export type MessageType = "INFO" | "WARNING" | "FATAL";
268
269export type Message = {
270 id: string;
271 type: MessageType;
272 nodeId?: string;
273 message: string;
274 onHighlight?: (state: AppState) => void;
275 onLooseHighlight?: (state: AppState) => void;
276 onClick?: (state: AppState) => void;
277};
278
279export const envSchema = z.object({
280 deployKey: z.string(),
281 networks: z.array(z.object({
282 name: z.string(),
283 domain: z.string(),
284 })),
285});
286
287export type Env = z.infer<typeof envSchema>;
288
289export type Project = {
290 id: string;
291 name: string;
292}
293
294export type AppState = {
295 projectId: string | undefined;
296 projects: Project[];
297 nodes: AppNode[];
298 edges: Edge[];
299 categories: Category[];
300 messages: Message[];
301 env?: Env;
302 setHighlightCategory: (name: string, active: boolean) => void;
303 onNodesChange: OnNodesChange<AppNode>;
304 onEdgesChange: OnEdgesChange;
305 onConnect: OnConnect;
306 setNodes: (nodes: AppNode[]) => void;
307 setEdges: (edges: Edge[]) => void;
giob68003c2025-04-25 03:05:21 +0000308 setProject: (projectId: string | undefined) => void;
gio5f2f1002025-03-20 18:38:48 +0400309 setProjects: (projects: Project[]) => void;
310 updateNode: <T extends NodeType>(id: string, data: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))>) => void;
311 updateNodeData: <T extends NodeType>(id: string, data: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))["data"]>) => void;
312 replaceEdge: (c: Connection, id?: string) => void;
313 refreshEnv: () => Promise<Env | undefined>;
314};
315
316const projectIdSelector = (state: AppState) => state.projectId;
317const categoriesSelector = (state: AppState) => state.categories;
318const messagesSelector = (state: AppState) => state.messages;
319const envSelector = (state: AppState) => state.env;
320
321export function useProjectId(): string | undefined {
322 return useStateStore(projectIdSelector);
323}
324
325export function useCategories(): Category[] {
326 return useStateStore(categoriesSelector);
327}
328
329export function useMessages(): Message[] {
330 return useStateStore(messagesSelector);
331}
332
333export function useNodeMessages(id: string): Message[] {
334 return useMessages().filter((m) => m.nodeId === id);
335}
336
337export function useNodeLabel(id: string): string {
338 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
339}
340
341export function useNodePortName(id: string, portId: string): string {
342 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
343}
344
345let envRefresh: Promise<Env | undefined> | null = null;
346
gioaba9a962025-04-25 14:19:40 +0000347const fixedEnv: Env = {
348 "deployKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPK58vMu0MwIzdZT+mqpIBkhl48p9+/YwDCZv7MgTesF",
349 "networks": [{
350 "name": "Public",
351 "domain": "v1.dodo.cloud",
352 }, {
353 "name": "Private",
354 "domain": "p.v1.dodo.cloud",
355 }],
356};
357
gio5f2f1002025-03-20 18:38:48 +0400358export function useEnv(): Env {
gioaba9a962025-04-25 14:19:40 +0000359 return fixedEnv;
gio5f2f1002025-03-20 18:38:48 +0400360 const store = useStateStore();
361 const env = envSelector(store);
362 console.log(env);
363 if (env != null) {
364 return env;
365 }
366 if (envRefresh == null) {
367 envRefresh = store.refreshEnv();
368 envRefresh.finally(() => envRefresh = null);
369 }
370 return {
371 deployKey: "",
372 networks: [],
373 };
374}
375
376const v: Validator = CreateValidators();
377
378export const useStateStore = create<AppState>((set, get): AppState => {
gio33990c62025-05-06 07:51:24 +0000379 set({
380 env: {
381 "deployKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPK58vMu0MwIzdZT+mqpIBkhl48p9+/YwDCZv7MgTesF",
382 "networks": [{
383 "name": "Public",
384 "domain": "v1.dodo.cloud",
385 }, {
386 "name": "Private",
387 "domain": "p.v1.dodo.cloud",
388 }],
389 }
390 });
gio5f2f1002025-03-20 18:38:48 +0400391 console.log(get().env);
392 const setN = (nodes: AppNode[]) => {
393 set({
394 nodes: nodes,
395 messages: v(nodes),
396 })
397 };
398 function updateNodeData<T extends NodeType>(id: string, d: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))["data"]>): void {
399 setN(get().nodes.map((n) => {
gio33990c62025-05-06 07:51:24 +0000400 if (n.id !== id) {
401 return n;
402 }
403 const nd = {
404 ...n,
405 data: {
406 ...n.data,
407 ...d,
408 },
409 };
410 return nd;
411 })
gio5f2f1002025-03-20 18:38:48 +0400412 );
413 };
414 function updateNode<T extends NodeType>(id: string, d: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))>): void {
415 setN(
416 get().nodes.map((n) => {
417 if (n.id !== id) {
418 return n;
419 }
420 return {
421 ...n,
422 ...d,
423 };
424 })
425 );
426 };
427 function onConnect(c: Connection) {
428 const { nodes, edges } = get();
429 set({
430 edges: addEdge(c, edges),
431 });
432 const sn = nodes.filter((n) => n.id === c.source)[0]!;
433 const tn = nodes.filter((n) => n.id === c.target)[0]!;
gioaba9a962025-04-25 14:19:40 +0000434 if (tn.type === "network") {
435 if (sn.type === "gateway-https") {
436 updateNodeData<"gateway-https">(sn.id, {
437 network: tn.data.domain,
438 });
gio33990c62025-05-06 07:51:24 +0000439 } else if (sn.type === "gateway-tcp") {
gioaba9a962025-04-25 14:19:40 +0000440 updateNodeData<"gateway-tcp">(sn.id, {
441 network: tn.data.domain,
442 });
443 }
444 }
gio5f2f1002025-03-20 18:38:48 +0400445 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
446 const sourceEnvVars = nodeEnvVarNames(sn);
447 if (sourceEnvVars.length === 0) {
448 throw new Error("MUST NOT REACH!");
449 }
450 const id = uuidv4();
451 if (sourceEnvVars.length === 1) {
452 updateNode(c.target, {
453 ...tn,
454 data: {
455 ...tn.data,
456 envVars: [
457 ...(tn.data.envVars || []),
458 {
459 id: id,
460 source: c.source,
461 name: sourceEnvVars[0],
462 isEditting: false,
463 },
464 ],
465 },
466 });
467 } else {
468 updateNode(c.target, {
469 ...tn,
470 data: {
471 ...tn.data,
472 envVars: [
473 ...(tn.data.envVars || []),
474 {
475 id: id,
476 source: c.source,
477 },
478 ],
479 },
480 });
481 }
482 }
giob41ecae2025-04-24 08:46:50 +0000483 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
484 const sourcePorts = sn.data.ports || [];
485 const id = uuidv4();
486 if (sourcePorts.length === 1) {
487 updateNode(c.target, {
488 ...tn,
489 data: {
490 ...tn.data,
491 envVars: [
492 ...(tn.data.envVars || []),
493 {
494 id: id,
495 source: c.source,
496 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
497 portId: sourcePorts[0].id,
498 isEditting: false,
499 },
500 ],
501 },
502 });
503 }
504 }
gio5f2f1002025-03-20 18:38:48 +0400505 if (c.sourceHandle === "volume") {
506 updateNodeData<"volume">(c.source, {
507 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
508 });
509 }
510 if (c.targetHandle === "volume") {
511 if (tn.type === "postgresql" || tn.type === "mongodb") {
512 updateNodeData(c.target, {
513 volumeId: c.source,
514 });
515 }
516 }
517 if (c.targetHandle === "https") {
518 if ((sn.data.ports || []).length === 1) {
519 updateNodeData<"gateway-https">(c.target, {
520 https: {
521 serviceId: c.source,
522 portId: sn.data.ports![0].id,
523 }
524 });
525 } else {
526 updateNodeData<"gateway-https">(c.target, {
527 https: {
528 serviceId: c.source,
529 portId: "", // TODO(gio)
530 }
531 });
532 }
533 }
534 if (c.targetHandle === "tcp") {
535 const td = tn.data as GatewayTCPData;
536 if ((sn.data.ports || []).length === 1) {
537 updateNodeData<"gateway-tcp">(c.target, {
538 exposed: (td.exposed || []).concat({
539 serviceId: c.source,
540 portId: sn.data.ports![0].id,
541 }),
542 });
543 } else {
544 updateNodeData<"gateway-tcp">(c.target, {
545 selected: {
546 serviceId: c.source,
547 portId: undefined,
548 },
549 });
550 }
551 }
552 if (sn.type === "app") {
553 if (c.sourceHandle === "ports") {
554 updateNodeData<"app">(sn.id, {
555 isChoosingPortToConnect: true,
556 });
557 }
558 }
559 if (tn.type === "app") {
560 if (c.targetHandle === "repository") {
561 updateNodeData<"app">(tn.id, {
562 repository: {
563 id: c.source,
564 branch: "master",
565 rootDir: "/",
566 }
567 });
568 }
569 }
570 }
571 return {
572 projectId: undefined,
573 projects: [],
574 nodes: [],
575 edges: [],
576 categories: defaultCategories,
577 messages: v([]),
578 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 () => {
637 return get().env;
gio33990c62025-05-06 07:51:24 +0000638 const resp = await fetch("/env");
639 if (!resp.ok) {
640 throw new Error("failed to fetch env config");
641 }
642 set({ env: envSchema.parse(await resp.json()) });
643 return get().env;
gio5f2f1002025-03-20 18:38:48 +0400644 },
645 setProject: (projectId) => set({ projectId }),
646 setProjects: (projects) => set({ projects }),
647 };
648});