blob: 02fd983c740998e7dbc643a03d6aa1b1c2beb3c2 [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;
gio9b2d4962025-05-07 04:59:39 +000044 auth?: {
45 enabled: boolean;
46 groups: string[];
47 noAuthPathPatterns: string[];
48 }
gio5f2f1002025-03-20 18:38:48 +040049};
50
51export type GatewayHttpsNode = Node<GatewayHttpsData> & {
52 type: "gateway-https";
53};
54
55export type GatewayTCPData = NodeData & {
56 network?: string;
57 subdomain?: string;
58 exposed: PortConnectedTo[];
59 selected?: {
60 serviceId?: string;
61 portId?: string;
62 };
63};
64
65export type GatewayTCPNode = Node<GatewayTCPData> & {
66 type: "gateway-tcp";
67};
68
69export type Port = {
70 id: string;
71 name: string;
72 value: number;
73};
74
gio91165612025-05-03 17:07:38 +000075export const ServiceTypes = [
76 "deno:2.2.0",
77 "golang:1.20.0",
78 "golang:1.22.0",
79 "golang:1.24.0",
80 "hugo:latest",
81 "php:8.2-apache",
gio33990c62025-05-06 07:51:24 +000082 "nextjs:deno-2.0.0",
gio91165612025-05-03 17:07:38 +000083 "node-23.1.0"
84] as const;
gio5f2f1002025-03-20 18:38:48 +040085export type ServiceType = typeof ServiceTypes[number];
86
87export type ServiceData = NodeData & {
88 type: ServiceType;
89 repository: {
90 id: string;
gio33990c62025-05-06 07:51:24 +000091 } | {
92 id: string;
93 branch: string;
94 } | {
95 id: string;
gio5f2f1002025-03-20 18:38:48 +040096 branch: string;
97 rootDir: string;
98 };
99 env: string[];
100 volume: string[];
gio91165612025-05-03 17:07:38 +0000101 preBuildCommands: string;
gio5f2f1002025-03-20 18:38:48 +0400102 isChoosingPortToConnect: boolean;
103};
104
105export type ServiceNode = Node<ServiceData> & {
106 type: "app";
107};
108
109export type VolumeType = "ReadWriteOnce" | "ReadOnlyMany" | "ReadWriteMany" | "ReadWriteOncePod";
110
111export type VolumeData = NodeData & {
112 type: VolumeType;
113 size: string;
114 attachedTo: string[];
115};
116
117export type VolumeNode = Node<VolumeData> & {
118 type: "volume";
119};
120
121export type PostgreSQLData = NodeData & {
122 volumeId: string;
123};
124
125export type PostgreSQLNode = Node<PostgreSQLData> & {
126 type: "postgresql";
127};
128
129export type MongoDBData = NodeData & {
130 volumeId: string;
131};
132
133export type MongoDBNode = Node<MongoDBData> & {
134 type: "mongodb";
135};
136
137export type GithubData = NodeData & {
138 address: string;
139};
140
141export type GithubNode = Node<GithubData> & {
142 type: "github";
143};
144
145export type NANode = Node<NodeData> & {
146 type: undefined;
147};
148
gioaba9a962025-04-25 14:19:40 +0000149export type AppNode = NetworkNode | GatewayHttpsNode | GatewayTCPNode | ServiceNode | VolumeNode | PostgreSQLNode | MongoDBNode | GithubNode | NANode;
gio5f2f1002025-03-20 18:38:48 +0400150
151export function nodeLabel(n: AppNode): string {
152 switch (n.type) {
gioaba9a962025-04-25 14:19:40 +0000153 case "network": return n.data.domain;
gio5f2f1002025-03-20 18:38:48 +0400154 case "app": return n.data.label || "Service";
155 case "github": return n.data.address || "Github";
156 case "gateway-https": {
157 if (n.data && n.data.network && n.data.subdomain) {
158 return `https://${n.data.subdomain}.${n.data.network}`;
159 } else {
160 return "HTTPS Gateway";
161 }
162 }
163 case "gateway-tcp": {
164 if (n.data && n.data.network && n.data.subdomain) {
165 return `${n.data.subdomain}.${n.data.network}`;
166 } else {
167 return "TCP Gateway";
168 }
169 }
170 case "mongodb": return n.data.label || "MongoDB";
171 case "postgresql": return n.data.label || "PostgreSQL";
172 case "volume": return n.data.label || "Volume";
173 case undefined: throw new Error("MUST NOT REACH!");
174 }
175}
176
177export function nodeIsConnectable(n: AppNode, handle: string): boolean {
178 switch (n.type) {
gioaba9a962025-04-25 14:19:40 +0000179 case "network":
180 return true;
gio5f2f1002025-03-20 18:38:48 +0400181 case "app":
182 if (handle === "ports") {
183 return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
184 } else if (handle === "repository") {
185 if (!n.data || !n.data.repository || !n.data.repository.id) {
gio33990c62025-05-06 07:51:24 +0000186 return true;
gio5f2f1002025-03-20 18:38:48 +0400187 }
188 return false;
189 }
gio33990c62025-05-06 07:51:24 +0000190 return false;
gio5f2f1002025-03-20 18:38:48 +0400191 case "github":
192 if (n.data !== undefined && n.data.address) {
193 return true;
194 }
195 return false;
196 case "gateway-https":
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 n.data === undefined || n.data.https === undefined;
201 case "gateway-tcp":
gioaba9a962025-04-25 14:19:40 +0000202 if (handle === "subdomain") {
203 return n.data.network === undefined;
204 }
gio5f2f1002025-03-20 18:38:48 +0400205 return true;
206 case "mongodb":
207 return true;
208 case "postgresql":
209 return true;
210 case "volume":
211 if (n.data === undefined || n.data.type === undefined) {
212 return false;
213 }
214 if (n.data.type === "ReadWriteOnce" || n.data.type === "ReadWriteOncePod") {
gio33990c62025-05-06 07:51:24 +0000215 return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
gio5f2f1002025-03-20 18:38:48 +0400216 }
217 return true;
218 case undefined: throw new Error("MUST NOT REACH!");
219 }
220}
221
222export type BoundEnvVar = {
223 id: string;
gio355883e2025-04-23 14:10:51 +0000224 source: string | null;
gio5f2f1002025-03-20 18:38:48 +0400225} | {
226 id: string;
gio355883e2025-04-23 14:10:51 +0000227 source: string | null;
gio5f2f1002025-03-20 18:38:48 +0400228 name: string;
229 isEditting: boolean;
230} | {
231 id: string;
gio355883e2025-04-23 14:10:51 +0000232 source: string | null;
gio5f2f1002025-03-20 18:38:48 +0400233 name: string;
234 alias: string;
235 isEditting: boolean;
giob41ecae2025-04-24 08:46:50 +0000236} | {
237 id: string;
238 source: string | null;
239 portId: string;
240 name: string;
241 alias: string;
242 isEditting: boolean;
gio5f2f1002025-03-20 18:38:48 +0400243};
244
245export type EnvVar = {
246 name: string;
247 value: string;
248};
249
giob41ecae2025-04-24 08:46:50 +0000250export function nodeEnvVarNamePort(n: AppNode, portName: string): string {
251 return `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${portName.toUpperCase()}`;
252}
253
gio5f2f1002025-03-20 18:38:48 +0400254export function nodeEnvVarNames(n: AppNode): string[] {
255 switch (n.type) {
256 case "app": return [
gio33990c62025-05-06 07:51:24 +0000257 `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
giob41ecae2025-04-24 08:46:50 +0000258 ...(n.data.ports || []).map((p) => nodeEnvVarNamePort(n, p.name)),
gio5f2f1002025-03-20 18:38:48 +0400259 ];
260 case "github": return [];
261 case "gateway-https": return [];
262 case "gateway-tcp": return [];
gio355883e2025-04-23 14:10:51 +0000263 case "mongodb": return [`DODO_MONGODB_${n.data.label.toUpperCase()}_URL`];
264 case "postgresql": return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_URL`];
gio5f2f1002025-03-20 18:38:48 +0400265 case "volume": return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
266 case undefined: throw new Error("MUST NOT REACH");
267 }
268}
269
270export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
271
272export type MessageType = "INFO" | "WARNING" | "FATAL";
273
274export type Message = {
275 id: string;
276 type: MessageType;
277 nodeId?: string;
278 message: string;
279 onHighlight?: (state: AppState) => void;
280 onLooseHighlight?: (state: AppState) => void;
281 onClick?: (state: AppState) => void;
282};
283
284export const envSchema = z.object({
285 deployKey: z.string(),
286 networks: z.array(z.object({
287 name: z.string(),
288 domain: z.string(),
289 })),
290});
291
292export type Env = z.infer<typeof envSchema>;
293
294export type Project = {
295 id: string;
296 name: string;
297}
298
299export type AppState = {
300 projectId: string | undefined;
301 projects: Project[];
302 nodes: AppNode[];
303 edges: Edge[];
304 categories: Category[];
305 messages: Message[];
306 env?: Env;
307 setHighlightCategory: (name: string, active: boolean) => void;
308 onNodesChange: OnNodesChange<AppNode>;
309 onEdgesChange: OnEdgesChange;
310 onConnect: OnConnect;
311 setNodes: (nodes: AppNode[]) => void;
312 setEdges: (edges: Edge[]) => void;
giob68003c2025-04-25 03:05:21 +0000313 setProject: (projectId: string | undefined) => void;
gio5f2f1002025-03-20 18:38:48 +0400314 setProjects: (projects: Project[]) => void;
315 updateNode: <T extends NodeType>(id: string, data: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))>) => void;
316 updateNodeData: <T extends NodeType>(id: string, data: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))["data"]>) => void;
317 replaceEdge: (c: Connection, id?: string) => void;
318 refreshEnv: () => Promise<Env | undefined>;
319};
320
321const projectIdSelector = (state: AppState) => state.projectId;
322const categoriesSelector = (state: AppState) => state.categories;
323const messagesSelector = (state: AppState) => state.messages;
324const envSelector = (state: AppState) => state.env;
325
326export function useProjectId(): string | undefined {
327 return useStateStore(projectIdSelector);
328}
329
330export function useCategories(): Category[] {
331 return useStateStore(categoriesSelector);
332}
333
334export function useMessages(): Message[] {
335 return useStateStore(messagesSelector);
336}
337
338export function useNodeMessages(id: string): Message[] {
339 return useMessages().filter((m) => m.nodeId === id);
340}
341
342export function useNodeLabel(id: string): string {
343 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
344}
345
346export function useNodePortName(id: string, portId: string): string {
347 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
348}
349
350let envRefresh: Promise<Env | undefined> | null = null;
351
gioaba9a962025-04-25 14:19:40 +0000352const fixedEnv: Env = {
353 "deployKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPK58vMu0MwIzdZT+mqpIBkhl48p9+/YwDCZv7MgTesF",
354 "networks": [{
355 "name": "Public",
356 "domain": "v1.dodo.cloud",
357 }, {
358 "name": "Private",
359 "domain": "p.v1.dodo.cloud",
360 }],
361};
362
gio5f2f1002025-03-20 18:38:48 +0400363export function useEnv(): Env {
gioaba9a962025-04-25 14:19:40 +0000364 return fixedEnv;
gio5f2f1002025-03-20 18:38:48 +0400365 const store = useStateStore();
366 const env = envSelector(store);
367 console.log(env);
368 if (env != null) {
369 return env;
370 }
371 if (envRefresh == null) {
372 envRefresh = store.refreshEnv();
373 envRefresh.finally(() => envRefresh = null);
374 }
375 return {
376 deployKey: "",
377 networks: [],
378 };
379}
380
381const v: Validator = CreateValidators();
382
383export const useStateStore = create<AppState>((set, get): AppState => {
gio33990c62025-05-06 07:51:24 +0000384 set({
385 env: {
386 "deployKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPK58vMu0MwIzdZT+mqpIBkhl48p9+/YwDCZv7MgTesF",
387 "networks": [{
388 "name": "Public",
389 "domain": "v1.dodo.cloud",
390 }, {
391 "name": "Private",
392 "domain": "p.v1.dodo.cloud",
393 }],
394 }
395 });
gio5f2f1002025-03-20 18:38:48 +0400396 console.log(get().env);
397 const setN = (nodes: AppNode[]) => {
398 set({
399 nodes: nodes,
400 messages: v(nodes),
401 })
402 };
403 function updateNodeData<T extends NodeType>(id: string, d: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))["data"]>): void {
404 setN(get().nodes.map((n) => {
gio33990c62025-05-06 07:51:24 +0000405 if (n.id !== id) {
406 return n;
407 }
408 const nd = {
409 ...n,
410 data: {
411 ...n.data,
412 ...d,
413 },
414 };
415 return nd;
416 })
gio5f2f1002025-03-20 18:38:48 +0400417 );
418 };
419 function updateNode<T extends NodeType>(id: string, d: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))>): void {
420 setN(
421 get().nodes.map((n) => {
422 if (n.id !== id) {
423 return n;
424 }
425 return {
426 ...n,
427 ...d,
428 };
429 })
430 );
431 };
432 function onConnect(c: Connection) {
433 const { nodes, edges } = get();
434 set({
435 edges: addEdge(c, edges),
436 });
437 const sn = nodes.filter((n) => n.id === c.source)[0]!;
438 const tn = nodes.filter((n) => n.id === c.target)[0]!;
gioaba9a962025-04-25 14:19:40 +0000439 if (tn.type === "network") {
440 if (sn.type === "gateway-https") {
441 updateNodeData<"gateway-https">(sn.id, {
442 network: tn.data.domain,
443 });
gio33990c62025-05-06 07:51:24 +0000444 } else if (sn.type === "gateway-tcp") {
gioaba9a962025-04-25 14:19:40 +0000445 updateNodeData<"gateway-tcp">(sn.id, {
446 network: tn.data.domain,
447 });
448 }
449 }
gio5f2f1002025-03-20 18:38:48 +0400450 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
451 const sourceEnvVars = nodeEnvVarNames(sn);
452 if (sourceEnvVars.length === 0) {
453 throw new Error("MUST NOT REACH!");
454 }
455 const id = uuidv4();
456 if (sourceEnvVars.length === 1) {
457 updateNode(c.target, {
458 ...tn,
459 data: {
460 ...tn.data,
461 envVars: [
462 ...(tn.data.envVars || []),
463 {
464 id: id,
465 source: c.source,
466 name: sourceEnvVars[0],
467 isEditting: false,
468 },
469 ],
470 },
471 });
472 } else {
473 updateNode(c.target, {
474 ...tn,
475 data: {
476 ...tn.data,
477 envVars: [
478 ...(tn.data.envVars || []),
479 {
480 id: id,
481 source: c.source,
482 },
483 ],
484 },
485 });
486 }
487 }
giob41ecae2025-04-24 08:46:50 +0000488 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
489 const sourcePorts = sn.data.ports || [];
490 const id = uuidv4();
491 if (sourcePorts.length === 1) {
492 updateNode(c.target, {
493 ...tn,
494 data: {
495 ...tn.data,
496 envVars: [
497 ...(tn.data.envVars || []),
498 {
499 id: id,
500 source: c.source,
501 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
502 portId: sourcePorts[0].id,
503 isEditting: false,
504 },
505 ],
506 },
507 });
508 }
509 }
gio5f2f1002025-03-20 18:38:48 +0400510 if (c.sourceHandle === "volume") {
511 updateNodeData<"volume">(c.source, {
512 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
513 });
514 }
515 if (c.targetHandle === "volume") {
516 if (tn.type === "postgresql" || tn.type === "mongodb") {
517 updateNodeData(c.target, {
518 volumeId: c.source,
519 });
520 }
521 }
522 if (c.targetHandle === "https") {
523 if ((sn.data.ports || []).length === 1) {
524 updateNodeData<"gateway-https">(c.target, {
525 https: {
526 serviceId: c.source,
527 portId: sn.data.ports![0].id,
528 }
529 });
530 } else {
531 updateNodeData<"gateway-https">(c.target, {
532 https: {
533 serviceId: c.source,
534 portId: "", // TODO(gio)
535 }
536 });
537 }
538 }
539 if (c.targetHandle === "tcp") {
540 const td = tn.data as GatewayTCPData;
541 if ((sn.data.ports || []).length === 1) {
542 updateNodeData<"gateway-tcp">(c.target, {
543 exposed: (td.exposed || []).concat({
544 serviceId: c.source,
545 portId: sn.data.ports![0].id,
546 }),
547 });
548 } else {
549 updateNodeData<"gateway-tcp">(c.target, {
550 selected: {
551 serviceId: c.source,
552 portId: undefined,
553 },
554 });
555 }
556 }
557 if (sn.type === "app") {
558 if (c.sourceHandle === "ports") {
559 updateNodeData<"app">(sn.id, {
560 isChoosingPortToConnect: true,
561 });
562 }
563 }
564 if (tn.type === "app") {
565 if (c.targetHandle === "repository") {
566 updateNodeData<"app">(tn.id, {
567 repository: {
568 id: c.source,
569 branch: "master",
570 rootDir: "/",
571 }
572 });
573 }
574 }
575 }
576 return {
577 projectId: undefined,
578 projects: [],
579 nodes: [],
580 edges: [],
581 categories: defaultCategories,
582 messages: v([]),
583 setHighlightCategory: (name, active) => {
584 set({
585 categories: get().categories.map(
586 (c) => {
587 if (c.title.toLowerCase() !== name.toLowerCase()) {
588 return c;
589 } else {
590 return {
591 ...c,
592 active,
593 }
594 }
595 })
596 });
597 },
598 onNodesChange: (changes) => {
599 const nodes = applyNodeChanges(changes, get().nodes);
600 setN(nodes);
601 },
602 onEdgesChange: (changes) => {
603 set({
604 edges: applyEdgeChanges(changes, get().edges),
605 });
606 },
607 setNodes: (nodes) => {
608 setN(nodes);
609 },
610 setEdges: (edges) => {
611 set({ edges });
612 },
613 replaceEdge: (c, id) => {
614 let change: EdgeChange;
615 if (id === undefined) {
616 change = {
617 type: "add",
618 item: {
619 id: uuidv4(),
620 ...c,
621 }
622 };
623 onConnect(c);
624 } else {
625 change = {
626 type: "replace",
627 id,
628 item: {
629 id,
630 ...c,
631 }
632 };
633 }
634 set({
635 edges: applyEdgeChanges([change], get().edges),
636 })
637 },
638 updateNode,
639 updateNodeData,
640 onConnect,
641 refreshEnv: async () => {
642 return get().env;
gio33990c62025-05-06 07:51:24 +0000643 const resp = await fetch("/env");
644 if (!resp.ok) {
645 throw new Error("failed to fetch env config");
646 }
647 set({ env: envSchema.parse(await resp.json()) });
648 return get().env;
gio5f2f1002025-03-20 18:38:48 +0400649 },
650 setProject: (projectId) => set({ projectId }),
651 setProjects: (projects) => set({ projects }),
652 };
653});