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