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