blob: f7181345b8589ddfde837e6234dd8ac5fc267128 [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;
186 source: string;
187} | {
188 id: string;
189 source: string;
190 name: string;
191 isEditting: boolean;
192} | {
193 id: string;
194 source: string;
195 name: string;
196 alias: string;
197 isEditting: boolean;
198};
199
200export type EnvVar = {
201 name: string;
202 value: string;
203};
204
205export function nodeEnvVarNames(n: AppNode): string[] {
206 switch (n.type) {
207 case "app": return [
208 `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
209 ...(n.data.ports || []).map((p) => `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${p.name.toUpperCase()}`),
210 ];
211 case "github": return [];
212 case "gateway-https": return [];
213 case "gateway-tcp": return [];
214 case "mongodb": return [`DODO_MONGODB_${n.data.label.toUpperCase()}_CONNECTION_URL`];
215 case "postgresql": return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_CONNECTION_URL`];
216 case "volume": return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
217 case undefined: throw new Error("MUST NOT REACH");
218 }
219}
220
221export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
222
223export type MessageType = "INFO" | "WARNING" | "FATAL";
224
225export type Message = {
226 id: string;
227 type: MessageType;
228 nodeId?: string;
229 message: string;
230 onHighlight?: (state: AppState) => void;
231 onLooseHighlight?: (state: AppState) => void;
232 onClick?: (state: AppState) => void;
233};
234
235export const envSchema = z.object({
236 deployKey: z.string(),
237 networks: z.array(z.object({
238 name: z.string(),
239 domain: z.string(),
240 })),
241});
242
243export type Env = z.infer<typeof envSchema>;
244
245export type Project = {
246 id: string;
247 name: string;
248}
249
250export type AppState = {
251 projectId: string | undefined;
252 projects: Project[];
253 nodes: AppNode[];
254 edges: Edge[];
255 categories: Category[];
256 messages: Message[];
257 env?: Env;
258 setHighlightCategory: (name: string, active: boolean) => void;
259 onNodesChange: OnNodesChange<AppNode>;
260 onEdgesChange: OnEdgesChange;
261 onConnect: OnConnect;
262 setNodes: (nodes: AppNode[]) => void;
263 setEdges: (edges: Edge[]) => void;
264 setProject: (projectId: string) => void;
265 setProjects: (projects: Project[]) => void;
266 updateNode: <T extends NodeType>(id: string, data: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))>) => void;
267 updateNodeData: <T extends NodeType>(id: string, data: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))["data"]>) => void;
268 replaceEdge: (c: Connection, id?: string) => void;
269 refreshEnv: () => Promise<Env | undefined>;
270};
271
272const projectIdSelector = (state: AppState) => state.projectId;
273const categoriesSelector = (state: AppState) => state.categories;
274const messagesSelector = (state: AppState) => state.messages;
275const envSelector = (state: AppState) => state.env;
276
277export function useProjectId(): string | undefined {
278 return useStateStore(projectIdSelector);
279}
280
281export function useCategories(): Category[] {
282 return useStateStore(categoriesSelector);
283}
284
285export function useMessages(): Message[] {
286 return useStateStore(messagesSelector);
287}
288
289export function useNodeMessages(id: string): Message[] {
290 return useMessages().filter((m) => m.nodeId === id);
291}
292
293export function useNodeLabel(id: string): string {
294 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
295}
296
297export function useNodePortName(id: string, portId: string): string {
298 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
299}
300
301let envRefresh: Promise<Env | undefined> | null = null;
302
303export function useEnv(): Env {
304 return {
305 "deployKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPK58vMu0MwIzdZT+mqpIBkhl48p9+/YwDCZv7MgTesF",
306 "networks": [{
307 "name": "Public",
308 "domain": "v1.dodo.cloud",
309 }, {
310 "name": "Private",
311 "domain": "p.v1.dodo.cloud",
312 }],
313 };
314 const store = useStateStore();
315 const env = envSelector(store);
316 console.log(env);
317 if (env != null) {
318 return env;
319 }
320 if (envRefresh == null) {
321 envRefresh = store.refreshEnv();
322 envRefresh.finally(() => envRefresh = null);
323 }
324 return {
325 deployKey: "",
326 networks: [],
327 };
328}
329
330const v: Validator = CreateValidators();
331
332export const useStateStore = create<AppState>((set, get): AppState => {
333 set({ env: {
334 "deployKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPK58vMu0MwIzdZT+mqpIBkhl48p9+/YwDCZv7MgTesF",
335 "networks": [{
336 "name": "Public",
337 "domain": "v1.dodo.cloud",
338 }, {
339 "name": "Private",
340 "domain": "p.v1.dodo.cloud",
341 }],
342 }});
343 console.log(get().env);
344 const setN = (nodes: AppNode[]) => {
345 set({
346 nodes: nodes,
347 messages: v(nodes),
348 })
349 };
350 function updateNodeData<T extends NodeType>(id: string, d: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))["data"]>): void {
351 setN(get().nodes.map((n) => {
352 if (n.id !== id) {
353 return n;
354 }
355 const nd = {
356 ...n,
357 data: {
358 ...n.data,
359 ...d,
360 },
361 };
362 return nd;
363 })
364 );
365 };
366 function updateNode<T extends NodeType>(id: string, d: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))>): void {
367 setN(
368 get().nodes.map((n) => {
369 if (n.id !== id) {
370 return n;
371 }
372 return {
373 ...n,
374 ...d,
375 };
376 })
377 );
378 };
379 function onConnect(c: Connection) {
380 const { nodes, edges } = get();
381 set({
382 edges: addEdge(c, edges),
383 });
384 const sn = nodes.filter((n) => n.id === c.source)[0]!;
385 const tn = nodes.filter((n) => n.id === c.target)[0]!;
386 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
387 const sourceEnvVars = nodeEnvVarNames(sn);
388 if (sourceEnvVars.length === 0) {
389 throw new Error("MUST NOT REACH!");
390 }
391 const id = uuidv4();
392 if (sourceEnvVars.length === 1) {
393 updateNode(c.target, {
394 ...tn,
395 data: {
396 ...tn.data,
397 envVars: [
398 ...(tn.data.envVars || []),
399 {
400 id: id,
401 source: c.source,
402 name: sourceEnvVars[0],
403 isEditting: false,
404 },
405 ],
406 },
407 });
408 } else {
409 updateNode(c.target, {
410 ...tn,
411 data: {
412 ...tn.data,
413 envVars: [
414 ...(tn.data.envVars || []),
415 {
416 id: id,
417 source: c.source,
418 },
419 ],
420 },
421 });
422 }
423 }
424 if (c.sourceHandle === "volume") {
425 updateNodeData<"volume">(c.source, {
426 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
427 });
428 }
429 if (c.targetHandle === "volume") {
430 if (tn.type === "postgresql" || tn.type === "mongodb") {
431 updateNodeData(c.target, {
432 volumeId: c.source,
433 });
434 }
435 }
436 if (c.targetHandle === "https") {
437 if ((sn.data.ports || []).length === 1) {
438 updateNodeData<"gateway-https">(c.target, {
439 https: {
440 serviceId: c.source,
441 portId: sn.data.ports![0].id,
442 }
443 });
444 } else {
445 updateNodeData<"gateway-https">(c.target, {
446 https: {
447 serviceId: c.source,
448 portId: "", // TODO(gio)
449 }
450 });
451 }
452 }
453 if (c.targetHandle === "tcp") {
454 const td = tn.data as GatewayTCPData;
455 if ((sn.data.ports || []).length === 1) {
456 updateNodeData<"gateway-tcp">(c.target, {
457 exposed: (td.exposed || []).concat({
458 serviceId: c.source,
459 portId: sn.data.ports![0].id,
460 }),
461 });
462 } else {
463 updateNodeData<"gateway-tcp">(c.target, {
464 selected: {
465 serviceId: c.source,
466 portId: undefined,
467 },
468 });
469 }
470 }
471 if (sn.type === "app") {
472 if (c.sourceHandle === "ports") {
473 updateNodeData<"app">(sn.id, {
474 isChoosingPortToConnect: true,
475 });
476 }
477 }
478 if (tn.type === "app") {
479 if (c.targetHandle === "repository") {
480 updateNodeData<"app">(tn.id, {
481 repository: {
482 id: c.source,
483 branch: "master",
484 rootDir: "/",
485 }
486 });
487 }
488 }
489 }
490 return {
491 projectId: undefined,
492 projects: [],
493 nodes: [],
494 edges: [],
495 categories: defaultCategories,
496 messages: v([]),
497 setHighlightCategory: (name, active) => {
498 set({
499 categories: get().categories.map(
500 (c) => {
501 if (c.title.toLowerCase() !== name.toLowerCase()) {
502 return c;
503 } else {
504 return {
505 ...c,
506 active,
507 }
508 }
509 })
510 });
511 },
512 onNodesChange: (changes) => {
513 const nodes = applyNodeChanges(changes, get().nodes);
514 setN(nodes);
515 },
516 onEdgesChange: (changes) => {
517 set({
518 edges: applyEdgeChanges(changes, get().edges),
519 });
520 },
521 setNodes: (nodes) => {
522 setN(nodes);
523 },
524 setEdges: (edges) => {
525 set({ edges });
526 },
527 replaceEdge: (c, id) => {
528 let change: EdgeChange;
529 if (id === undefined) {
530 change = {
531 type: "add",
532 item: {
533 id: uuidv4(),
534 ...c,
535 }
536 };
537 onConnect(c);
538 } else {
539 change = {
540 type: "replace",
541 id,
542 item: {
543 id,
544 ...c,
545 }
546 };
547 }
548 set({
549 edges: applyEdgeChanges([change], get().edges),
550 })
551 },
552 updateNode,
553 updateNodeData,
554 onConnect,
555 refreshEnv: async () => {
556 return get().env;
557 const resp = await fetch("/env");
558 if (!resp.ok) {
559 throw new Error("failed to fetch env config");
560 }
561 set({ env: envSchema.parse(await resp.json()) });
562 return get().env;
563 },
564 setProject: (projectId) => set({ projectId }),
565 setProjects: (projects) => set({ projects }),
566 };
567});