blob: 8d728d008375b6e5e4e1e5f9992240794804b729 [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
70export const ServiceTypes = ["node-23.1.0", "nextjs:deno-2.0.0"] as const;
71export type ServiceType = typeof ServiceTypes[number];
72
73export type ServiceData = NodeData & {
74 type: ServiceType;
75 repository: {
76 id: string;
77 branch: string;
78 rootDir: string;
79 };
80 env: string[];
81 volume: string[];
82 isChoosingPortToConnect: boolean;
83};
84
85export type ServiceNode = Node<ServiceData> & {
86 type: "app";
87};
88
89export type VolumeType = "ReadWriteOnce" | "ReadOnlyMany" | "ReadWriteMany" | "ReadWriteOncePod";
90
91export type VolumeData = NodeData & {
92 type: VolumeType;
93 size: string;
94 attachedTo: string[];
95};
96
97export type VolumeNode = Node<VolumeData> & {
98 type: "volume";
99};
100
101export type PostgreSQLData = NodeData & {
102 volumeId: string;
103};
104
105export type PostgreSQLNode = Node<PostgreSQLData> & {
106 type: "postgresql";
107};
108
109export type MongoDBData = NodeData & {
110 volumeId: string;
111};
112
113export type MongoDBNode = Node<MongoDBData> & {
114 type: "mongodb";
115};
116
117export type GithubData = NodeData & {
118 address: string;
119};
120
121export type GithubNode = Node<GithubData> & {
122 type: "github";
123};
124
125export type NANode = Node<NodeData> & {
126 type: undefined;
127};
128
gioaba9a962025-04-25 14:19:40 +0000129export type AppNode = NetworkNode | GatewayHttpsNode | GatewayTCPNode | ServiceNode | VolumeNode | PostgreSQLNode | MongoDBNode | GithubNode | NANode;
gio5f2f1002025-03-20 18:38:48 +0400130
131export function nodeLabel(n: AppNode): string {
132 switch (n.type) {
gioaba9a962025-04-25 14:19:40 +0000133 case "network": return n.data.domain;
gio5f2f1002025-03-20 18:38:48 +0400134 case "app": return n.data.label || "Service";
135 case "github": return n.data.address || "Github";
136 case "gateway-https": {
137 if (n.data && n.data.network && n.data.subdomain) {
138 return `https://${n.data.subdomain}.${n.data.network}`;
139 } else {
140 return "HTTPS Gateway";
141 }
142 }
143 case "gateway-tcp": {
144 if (n.data && n.data.network && n.data.subdomain) {
145 return `${n.data.subdomain}.${n.data.network}`;
146 } else {
147 return "TCP Gateway";
148 }
149 }
150 case "mongodb": return n.data.label || "MongoDB";
151 case "postgresql": return n.data.label || "PostgreSQL";
152 case "volume": return n.data.label || "Volume";
153 case undefined: throw new Error("MUST NOT REACH!");
154 }
155}
156
157export function nodeIsConnectable(n: AppNode, handle: string): boolean {
158 switch (n.type) {
gioaba9a962025-04-25 14:19:40 +0000159 case "network":
160 return true;
gio5f2f1002025-03-20 18:38:48 +0400161 case "app":
162 if (handle === "ports") {
163 return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
164 } else if (handle === "repository") {
165 if (!n.data || !n.data.repository || !n.data.repository.id) {
166 return true;
167 }
168 return false;
169 }
170 return false;
171 case "github":
172 if (n.data !== undefined && n.data.address) {
173 return true;
174 }
175 return false;
176 case "gateway-https":
gioaba9a962025-04-25 14:19:40 +0000177 if (handle === "subdomain") {
178 return n.data.network === undefined;
179 }
gio5f2f1002025-03-20 18:38:48 +0400180 return n.data === undefined || n.data.https === undefined;
181 case "gateway-tcp":
gioaba9a962025-04-25 14:19:40 +0000182 if (handle === "subdomain") {
183 return n.data.network === undefined;
184 }
gio5f2f1002025-03-20 18:38:48 +0400185 return true;
186 case "mongodb":
187 return true;
188 case "postgresql":
189 return true;
190 case "volume":
191 if (n.data === undefined || n.data.type === undefined) {
192 return false;
193 }
194 if (n.data.type === "ReadWriteOnce" || n.data.type === "ReadWriteOncePod") {
195 return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
196 }
197 return true;
198 case undefined: throw new Error("MUST NOT REACH!");
199 }
200}
201
202export type BoundEnvVar = {
203 id: string;
gio355883e2025-04-23 14:10:51 +0000204 source: string | null;
gio5f2f1002025-03-20 18:38:48 +0400205} | {
206 id: string;
gio355883e2025-04-23 14:10:51 +0000207 source: string | null;
gio5f2f1002025-03-20 18:38:48 +0400208 name: string;
209 isEditting: boolean;
210} | {
211 id: string;
gio355883e2025-04-23 14:10:51 +0000212 source: string | null;
gio5f2f1002025-03-20 18:38:48 +0400213 name: string;
214 alias: string;
215 isEditting: boolean;
giob41ecae2025-04-24 08:46:50 +0000216} | {
217 id: string;
218 source: string | null;
219 portId: string;
220 name: string;
221 alias: string;
222 isEditting: boolean;
gio5f2f1002025-03-20 18:38:48 +0400223};
224
225export type EnvVar = {
226 name: string;
227 value: string;
228};
229
giob41ecae2025-04-24 08:46:50 +0000230export function nodeEnvVarNamePort(n: AppNode, portName: string): string {
231 return `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${portName.toUpperCase()}`;
232}
233
gio5f2f1002025-03-20 18:38:48 +0400234export function nodeEnvVarNames(n: AppNode): string[] {
235 switch (n.type) {
236 case "app": return [
237 `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
giob41ecae2025-04-24 08:46:50 +0000238 ...(n.data.ports || []).map((p) => nodeEnvVarNamePort(n, p.name)),
gio5f2f1002025-03-20 18:38:48 +0400239 ];
240 case "github": return [];
241 case "gateway-https": return [];
242 case "gateway-tcp": return [];
gio355883e2025-04-23 14:10:51 +0000243 case "mongodb": return [`DODO_MONGODB_${n.data.label.toUpperCase()}_URL`];
244 case "postgresql": return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_URL`];
gio5f2f1002025-03-20 18:38:48 +0400245 case "volume": return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
246 case undefined: throw new Error("MUST NOT REACH");
247 }
248}
249
250export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
251
252export type MessageType = "INFO" | "WARNING" | "FATAL";
253
254export type Message = {
255 id: string;
256 type: MessageType;
257 nodeId?: string;
258 message: string;
259 onHighlight?: (state: AppState) => void;
260 onLooseHighlight?: (state: AppState) => void;
261 onClick?: (state: AppState) => void;
262};
263
264export const envSchema = z.object({
265 deployKey: z.string(),
266 networks: z.array(z.object({
267 name: z.string(),
268 domain: z.string(),
269 })),
270});
271
272export type Env = z.infer<typeof envSchema>;
273
274export type Project = {
275 id: string;
276 name: string;
277}
278
279export type AppState = {
280 projectId: string | undefined;
281 projects: Project[];
282 nodes: AppNode[];
283 edges: Edge[];
284 categories: Category[];
285 messages: Message[];
286 env?: Env;
287 setHighlightCategory: (name: string, active: boolean) => void;
288 onNodesChange: OnNodesChange<AppNode>;
289 onEdgesChange: OnEdgesChange;
290 onConnect: OnConnect;
291 setNodes: (nodes: AppNode[]) => void;
292 setEdges: (edges: Edge[]) => void;
giob68003c2025-04-25 03:05:21 +0000293 setProject: (projectId: string | undefined) => void;
gio5f2f1002025-03-20 18:38:48 +0400294 setProjects: (projects: Project[]) => void;
295 updateNode: <T extends NodeType>(id: string, data: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))>) => void;
296 updateNodeData: <T extends NodeType>(id: string, data: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))["data"]>) => void;
297 replaceEdge: (c: Connection, id?: string) => void;
298 refreshEnv: () => Promise<Env | undefined>;
299};
300
301const projectIdSelector = (state: AppState) => state.projectId;
302const categoriesSelector = (state: AppState) => state.categories;
303const messagesSelector = (state: AppState) => state.messages;
304const envSelector = (state: AppState) => state.env;
305
306export function useProjectId(): string | undefined {
307 return useStateStore(projectIdSelector);
308}
309
310export function useCategories(): Category[] {
311 return useStateStore(categoriesSelector);
312}
313
314export function useMessages(): Message[] {
315 return useStateStore(messagesSelector);
316}
317
318export function useNodeMessages(id: string): Message[] {
319 return useMessages().filter((m) => m.nodeId === id);
320}
321
322export function useNodeLabel(id: string): string {
323 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
324}
325
326export function useNodePortName(id: string, portId: string): string {
327 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
328}
329
330let envRefresh: Promise<Env | undefined> | null = null;
331
gioaba9a962025-04-25 14:19:40 +0000332const fixedEnv: Env = {
333 "deployKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPK58vMu0MwIzdZT+mqpIBkhl48p9+/YwDCZv7MgTesF",
334 "networks": [{
335 "name": "Public",
336 "domain": "v1.dodo.cloud",
337 }, {
338 "name": "Private",
339 "domain": "p.v1.dodo.cloud",
340 }],
341};
342
gio5f2f1002025-03-20 18:38:48 +0400343export function useEnv(): Env {
gioaba9a962025-04-25 14:19:40 +0000344 return fixedEnv;
gio5f2f1002025-03-20 18:38:48 +0400345 const store = useStateStore();
346 const env = envSelector(store);
347 console.log(env);
348 if (env != null) {
349 return env;
350 }
351 if (envRefresh == null) {
352 envRefresh = store.refreshEnv();
353 envRefresh.finally(() => envRefresh = null);
354 }
355 return {
356 deployKey: "",
357 networks: [],
358 };
359}
360
361const v: Validator = CreateValidators();
362
363export const useStateStore = create<AppState>((set, get): AppState => {
364 set({ env: {
365 "deployKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPK58vMu0MwIzdZT+mqpIBkhl48p9+/YwDCZv7MgTesF",
366 "networks": [{
367 "name": "Public",
368 "domain": "v1.dodo.cloud",
369 }, {
370 "name": "Private",
371 "domain": "p.v1.dodo.cloud",
372 }],
373 }});
374 console.log(get().env);
375 const setN = (nodes: AppNode[]) => {
376 set({
377 nodes: nodes,
378 messages: v(nodes),
379 })
380 };
381 function updateNodeData<T extends NodeType>(id: string, d: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))["data"]>): void {
382 setN(get().nodes.map((n) => {
383 if (n.id !== id) {
384 return n;
385 }
386 const nd = {
387 ...n,
388 data: {
389 ...n.data,
390 ...d,
391 },
392 };
393 return nd;
394 })
395 );
396 };
397 function updateNode<T extends NodeType>(id: string, d: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))>): void {
398 setN(
399 get().nodes.map((n) => {
400 if (n.id !== id) {
401 return n;
402 }
403 return {
404 ...n,
405 ...d,
406 };
407 })
408 );
409 };
410 function onConnect(c: Connection) {
411 const { nodes, edges } = get();
412 set({
413 edges: addEdge(c, edges),
414 });
415 const sn = nodes.filter((n) => n.id === c.source)[0]!;
416 const tn = nodes.filter((n) => n.id === c.target)[0]!;
gioaba9a962025-04-25 14:19:40 +0000417 if (tn.type === "network") {
418 if (sn.type === "gateway-https") {
419 updateNodeData<"gateway-https">(sn.id, {
420 network: tn.data.domain,
421 });
422 }else if (sn.type === "gateway-tcp") {
423 updateNodeData<"gateway-tcp">(sn.id, {
424 network: tn.data.domain,
425 });
426 }
427 }
gio5f2f1002025-03-20 18:38:48 +0400428 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
429 const sourceEnvVars = nodeEnvVarNames(sn);
430 if (sourceEnvVars.length === 0) {
431 throw new Error("MUST NOT REACH!");
432 }
433 const id = uuidv4();
434 if (sourceEnvVars.length === 1) {
435 updateNode(c.target, {
436 ...tn,
437 data: {
438 ...tn.data,
439 envVars: [
440 ...(tn.data.envVars || []),
441 {
442 id: id,
443 source: c.source,
444 name: sourceEnvVars[0],
445 isEditting: false,
446 },
447 ],
448 },
449 });
450 } else {
451 updateNode(c.target, {
452 ...tn,
453 data: {
454 ...tn.data,
455 envVars: [
456 ...(tn.data.envVars || []),
457 {
458 id: id,
459 source: c.source,
460 },
461 ],
462 },
463 });
464 }
465 }
giob41ecae2025-04-24 08:46:50 +0000466 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
467 const sourcePorts = sn.data.ports || [];
468 const id = uuidv4();
469 if (sourcePorts.length === 1) {
470 updateNode(c.target, {
471 ...tn,
472 data: {
473 ...tn.data,
474 envVars: [
475 ...(tn.data.envVars || []),
476 {
477 id: id,
478 source: c.source,
479 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
480 portId: sourcePorts[0].id,
481 isEditting: false,
482 },
483 ],
484 },
485 });
486 }
487 }
gio5f2f1002025-03-20 18:38:48 +0400488 if (c.sourceHandle === "volume") {
489 updateNodeData<"volume">(c.source, {
490 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
491 });
492 }
493 if (c.targetHandle === "volume") {
494 if (tn.type === "postgresql" || tn.type === "mongodb") {
495 updateNodeData(c.target, {
496 volumeId: c.source,
497 });
498 }
499 }
500 if (c.targetHandle === "https") {
501 if ((sn.data.ports || []).length === 1) {
502 updateNodeData<"gateway-https">(c.target, {
503 https: {
504 serviceId: c.source,
505 portId: sn.data.ports![0].id,
506 }
507 });
508 } else {
509 updateNodeData<"gateway-https">(c.target, {
510 https: {
511 serviceId: c.source,
512 portId: "", // TODO(gio)
513 }
514 });
515 }
516 }
517 if (c.targetHandle === "tcp") {
518 const td = tn.data as GatewayTCPData;
519 if ((sn.data.ports || []).length === 1) {
520 updateNodeData<"gateway-tcp">(c.target, {
521 exposed: (td.exposed || []).concat({
522 serviceId: c.source,
523 portId: sn.data.ports![0].id,
524 }),
525 });
526 } else {
527 updateNodeData<"gateway-tcp">(c.target, {
528 selected: {
529 serviceId: c.source,
530 portId: undefined,
531 },
532 });
533 }
534 }
535 if (sn.type === "app") {
536 if (c.sourceHandle === "ports") {
537 updateNodeData<"app">(sn.id, {
538 isChoosingPortToConnect: true,
539 });
540 }
541 }
542 if (tn.type === "app") {
543 if (c.targetHandle === "repository") {
544 updateNodeData<"app">(tn.id, {
545 repository: {
546 id: c.source,
547 branch: "master",
548 rootDir: "/",
549 }
550 });
551 }
552 }
553 }
554 return {
555 projectId: undefined,
556 projects: [],
557 nodes: [],
558 edges: [],
559 categories: defaultCategories,
560 messages: v([]),
561 setHighlightCategory: (name, active) => {
562 set({
563 categories: get().categories.map(
564 (c) => {
565 if (c.title.toLowerCase() !== name.toLowerCase()) {
566 return c;
567 } else {
568 return {
569 ...c,
570 active,
571 }
572 }
573 })
574 });
575 },
576 onNodesChange: (changes) => {
577 const nodes = applyNodeChanges(changes, get().nodes);
578 setN(nodes);
579 },
580 onEdgesChange: (changes) => {
581 set({
582 edges: applyEdgeChanges(changes, get().edges),
583 });
584 },
585 setNodes: (nodes) => {
586 setN(nodes);
587 },
588 setEdges: (edges) => {
589 set({ edges });
590 },
591 replaceEdge: (c, id) => {
592 let change: EdgeChange;
593 if (id === undefined) {
594 change = {
595 type: "add",
596 item: {
597 id: uuidv4(),
598 ...c,
599 }
600 };
601 onConnect(c);
602 } else {
603 change = {
604 type: "replace",
605 id,
606 item: {
607 id,
608 ...c,
609 }
610 };
611 }
612 set({
613 edges: applyEdgeChanges([change], get().edges),
614 })
615 },
616 updateNode,
617 updateNodeData,
618 onConnect,
619 refreshEnv: async () => {
620 return get().env;
621 const resp = await fetch("/env");
622 if (!resp.ok) {
623 throw new Error("failed to fetch env config");
624 }
625 set({ env: envSchema.parse(await resp.json()) });
626 return get().env;
627 },
628 setProject: (projectId) => set({ projectId }),
629 setProjects: (projects) => set({ projects }),
630 };
631});