blob: 9f0aeb3b2ca35e08497417d126386d2162fab50a [file] [log] [blame]
gio5f2f1002025-03-20 18:38:48 +04001import { Category, defaultCategories } from "./categories";
2import { CreateValidators, Validator } from "./config";
giod0026612025-05-08 13:00:36 +00003import { GitHubService, GitHubServiceImpl } from "./github";
gio359a6852025-05-14 03:38:24 +00004import type { Edge, Node, OnConnect, OnEdgesChange, OnNodesChange, Viewport as ReactFlowViewport } from "@xyflow/react";
gioaf8db832025-05-13 14:43:05 +00005import {
6 addEdge,
7 applyEdgeChanges,
8 applyNodeChanges,
9 Connection,
10 EdgeChange,
11 useNodes,
12 XYPosition,
13} from "@xyflow/react";
giod0026612025-05-08 13:00:36 +000014import type { DeepPartial } from "react-hook-form";
15import { v4 as uuidv4 } from "uuid";
gio5f2f1002025-03-20 18:38:48 +040016import { z } from "zod";
giod0026612025-05-08 13:00:36 +000017import { create } from "zustand";
gio5f2f1002025-03-20 18:38:48 +040018
19export type InitData = {
giod0026612025-05-08 13:00:36 +000020 label: string;
21 envVars: BoundEnvVar[];
22 ports: Port[];
gio5f2f1002025-03-20 18:38:48 +040023};
24
25export type NodeData = InitData & {
giod0026612025-05-08 13:00:36 +000026 activeField?: string | undefined;
gio818da4e2025-05-12 14:45:35 +000027 state?: string | null;
gio5f2f1002025-03-20 18:38:48 +040028};
29
30export type PortConnectedTo = {
giod0026612025-05-08 13:00:36 +000031 serviceId: string;
32 portId: string;
33};
gio5f2f1002025-03-20 18:38:48 +040034
gioaba9a962025-04-25 14:19:40 +000035export type NetworkData = NodeData & {
giod0026612025-05-08 13:00:36 +000036 domain: string;
gioaba9a962025-04-25 14:19:40 +000037};
38
39export type NetworkNode = Node<NetworkData> & {
giod0026612025-05-08 13:00:36 +000040 type: "network";
gioaba9a962025-04-25 14:19:40 +000041};
42
gio5f2f1002025-03-20 18:38:48 +040043export type GatewayHttpsData = NodeData & {
gio48fde052025-05-14 09:48:08 +000044 readonly?: boolean;
giod0026612025-05-08 13:00:36 +000045 network?: string;
46 subdomain?: string;
47 https?: PortConnectedTo;
48 auth?: {
49 enabled: boolean;
50 groups: string[];
51 noAuthPathPatterns: string[];
52 };
gio5f2f1002025-03-20 18:38:48 +040053};
54
55export type GatewayHttpsNode = Node<GatewayHttpsData> & {
giod0026612025-05-08 13:00:36 +000056 type: "gateway-https";
gio5f2f1002025-03-20 18:38:48 +040057};
58
59export type GatewayTCPData = NodeData & {
gio48fde052025-05-14 09:48:08 +000060 readonly?: boolean;
giod0026612025-05-08 13:00:36 +000061 network?: string;
62 subdomain?: string;
63 exposed: PortConnectedTo[];
64 selected?: {
65 serviceId?: string;
66 portId?: string;
67 };
gio5f2f1002025-03-20 18:38:48 +040068};
69
70export type GatewayTCPNode = Node<GatewayTCPData> & {
giod0026612025-05-08 13:00:36 +000071 type: "gateway-tcp";
gio5f2f1002025-03-20 18:38:48 +040072};
73
74export type Port = {
giod0026612025-05-08 13:00:36 +000075 id: string;
76 name: string;
77 value: number;
gio5f2f1002025-03-20 18:38:48 +040078};
79
gio91165612025-05-03 17:07:38 +000080export const ServiceTypes = [
giod0026612025-05-08 13:00:36 +000081 "deno:2.2.0",
82 "golang:1.20.0",
83 "golang:1.22.0",
84 "golang:1.24.0",
85 "hugo:latest",
86 "php:8.2-apache",
87 "nextjs:deno-2.0.0",
gio33046722025-05-16 14:49:55 +000088 "nodejs:23.1.0",
giobceb0852025-05-20 13:15:18 +040089 "nodejs:24.0.2",
gio91165612025-05-03 17:07:38 +000090] as const;
giod0026612025-05-08 13:00:36 +000091export type ServiceType = (typeof ServiceTypes)[number];
gio5f2f1002025-03-20 18:38:48 +040092
gio48fde052025-05-14 09:48:08 +000093export type Domain = {
94 network: string;
95 subdomain: string;
96};
97
gio5f2f1002025-03-20 18:38:48 +040098export type ServiceData = NodeData & {
giod0026612025-05-08 13:00:36 +000099 type: ServiceType;
100 repository:
101 | {
102 id: string;
103 }
104 | {
105 id: string;
106 branch: string;
107 }
108 | {
109 id: string;
110 branch: string;
111 rootDir: string;
112 };
113 env: string[];
114 volume: string[];
115 preBuildCommands: string;
116 isChoosingPortToConnect: boolean;
gio48fde052025-05-14 09:48:08 +0000117 dev?:
118 | {
119 enabled: false;
120 expose?: Domain;
121 }
122 | {
123 enabled: true;
124 expose?: Domain;
125 codeServerNodeId: string;
126 sshNodeId: string;
127 };
gio5f2f1002025-03-20 18:38:48 +0400128};
129
130export type ServiceNode = Node<ServiceData> & {
giod0026612025-05-08 13:00:36 +0000131 type: "app";
gio5f2f1002025-03-20 18:38:48 +0400132};
133
134export type VolumeType = "ReadWriteOnce" | "ReadOnlyMany" | "ReadWriteMany" | "ReadWriteOncePod";
135
136export type VolumeData = NodeData & {
giod0026612025-05-08 13:00:36 +0000137 type: VolumeType;
138 size: string;
139 attachedTo: string[];
gio5f2f1002025-03-20 18:38:48 +0400140};
141
142export type VolumeNode = Node<VolumeData> & {
giod0026612025-05-08 13:00:36 +0000143 type: "volume";
gio5f2f1002025-03-20 18:38:48 +0400144};
145
146export type PostgreSQLData = NodeData & {
giod0026612025-05-08 13:00:36 +0000147 volumeId: string;
gio5f2f1002025-03-20 18:38:48 +0400148};
149
150export type PostgreSQLNode = Node<PostgreSQLData> & {
giod0026612025-05-08 13:00:36 +0000151 type: "postgresql";
gio5f2f1002025-03-20 18:38:48 +0400152};
153
154export type MongoDBData = NodeData & {
giod0026612025-05-08 13:00:36 +0000155 volumeId: string;
gio5f2f1002025-03-20 18:38:48 +0400156};
157
158export type MongoDBNode = Node<MongoDBData> & {
giod0026612025-05-08 13:00:36 +0000159 type: "mongodb";
gio5f2f1002025-03-20 18:38:48 +0400160};
161
162export type GithubData = NodeData & {
giod0026612025-05-08 13:00:36 +0000163 repository?: {
164 id: number;
165 sshURL: string;
gio818da4e2025-05-12 14:45:35 +0000166 fullName: string;
giod0026612025-05-08 13:00:36 +0000167 };
gio5f2f1002025-03-20 18:38:48 +0400168};
169
170export type GithubNode = Node<GithubData> & {
giod0026612025-05-08 13:00:36 +0000171 type: "github";
gio5f2f1002025-03-20 18:38:48 +0400172};
173
174export type NANode = Node<NodeData> & {
giod0026612025-05-08 13:00:36 +0000175 type: undefined;
gio5f2f1002025-03-20 18:38:48 +0400176};
177
giod0026612025-05-08 13:00:36 +0000178export type AppNode =
179 | NetworkNode
180 | GatewayHttpsNode
181 | GatewayTCPNode
182 | ServiceNode
183 | VolumeNode
184 | PostgreSQLNode
185 | MongoDBNode
186 | GithubNode
187 | NANode;
gio5f2f1002025-03-20 18:38:48 +0400188
189export function nodeLabel(n: AppNode): string {
gio48fde052025-05-14 09:48:08 +0000190 try {
191 switch (n.type) {
192 case "network":
193 return n.data.domain;
194 case "app":
195 return n.data.label || "Service";
196 case "github":
197 return n.data.repository?.fullName || "Github";
198 case "gateway-https": {
gio29050d62025-05-16 04:49:26 +0000199 if (n.data && n.data.subdomain) {
200 return `${n.data.subdomain}`;
gio48fde052025-05-14 09:48:08 +0000201 } else {
202 return "HTTPS Gateway";
203 }
giod0026612025-05-08 13:00:36 +0000204 }
gio48fde052025-05-14 09:48:08 +0000205 case "gateway-tcp": {
gio29050d62025-05-16 04:49:26 +0000206 if (n.data && n.data.subdomain) {
207 return `${n.data.subdomain}`;
gio48fde052025-05-14 09:48:08 +0000208 } else {
209 return "TCP Gateway";
210 }
giod0026612025-05-08 13:00:36 +0000211 }
gio48fde052025-05-14 09:48:08 +0000212 case "mongodb":
213 return n.data.label || "MongoDB";
214 case "postgresql":
215 return n.data.label || "PostgreSQL";
216 case "volume":
217 return n.data.label || "Volume";
218 case undefined:
219 throw new Error("MUST NOT REACH!");
giod0026612025-05-08 13:00:36 +0000220 }
gio48fde052025-05-14 09:48:08 +0000221 } catch (e) {
222 console.error("opaa", e);
223 } finally {
224 console.log("done");
giod0026612025-05-08 13:00:36 +0000225 }
gioa1efbad2025-05-21 07:16:45 +0000226 return "Unknown Node";
gio5f2f1002025-03-20 18:38:48 +0400227}
228
229export function nodeIsConnectable(n: AppNode, handle: string): boolean {
giod0026612025-05-08 13:00:36 +0000230 switch (n.type) {
231 case "network":
232 return true;
233 case "app":
234 if (handle === "ports") {
235 return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
236 } else if (handle === "repository") {
237 if (!n.data || !n.data.repository || !n.data.repository.id) {
238 return true;
239 }
240 return false;
241 }
242 return false;
243 case "github":
244 if (n.data.repository?.id !== undefined) {
245 return true;
246 }
247 return false;
248 case "gateway-https":
249 if (handle === "subdomain") {
250 return n.data.network === undefined;
251 }
252 return n.data === undefined || n.data.https === undefined;
253 case "gateway-tcp":
254 if (handle === "subdomain") {
255 return n.data.network === undefined;
256 }
257 return true;
258 case "mongodb":
259 return true;
260 case "postgresql":
261 return true;
262 case "volume":
263 if (n.data === undefined || n.data.type === undefined) {
264 return false;
265 }
266 if (n.data.type === "ReadWriteOnce" || n.data.type === "ReadWriteOncePod") {
267 return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
268 }
269 return true;
270 case undefined:
271 throw new Error("MUST NOT REACH!");
272 }
gio5f2f1002025-03-20 18:38:48 +0400273}
274
giod0026612025-05-08 13:00:36 +0000275export type BoundEnvVar =
276 | {
277 id: string;
278 source: string | null;
279 }
280 | {
281 id: string;
282 source: string | null;
283 name: string;
284 isEditting: boolean;
285 }
286 | {
287 id: string;
288 source: string | null;
289 name: string;
290 alias: string;
291 isEditting: boolean;
292 }
293 | {
294 id: string;
295 source: string | null;
296 portId: string;
297 name: string;
298 alias: string;
299 isEditting: boolean;
300 };
gio5f2f1002025-03-20 18:38:48 +0400301
302export type EnvVar = {
giod0026612025-05-08 13:00:36 +0000303 name: string;
304 value: string;
gio5f2f1002025-03-20 18:38:48 +0400305};
306
giob41ecae2025-04-24 08:46:50 +0000307export function nodeEnvVarNamePort(n: AppNode, portName: string): string {
giod0026612025-05-08 13:00:36 +0000308 return `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${portName.toUpperCase()}`;
giob41ecae2025-04-24 08:46:50 +0000309}
310
gio5f2f1002025-03-20 18:38:48 +0400311export function nodeEnvVarNames(n: AppNode): string[] {
giod0026612025-05-08 13:00:36 +0000312 switch (n.type) {
313 case "app":
314 return [
315 `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
316 ...(n.data.ports || []).map((p) => nodeEnvVarNamePort(n, p.name)),
317 ];
318 case "github":
319 return [];
320 case "gateway-https":
321 return [];
322 case "gateway-tcp":
323 return [];
324 case "mongodb":
325 return [`DODO_MONGODB_${n.data.label.toUpperCase()}_URL`];
326 case "postgresql":
327 return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_URL`];
328 case "volume":
329 return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
330 case undefined:
331 throw new Error("MUST NOT REACH");
332 default:
333 throw new Error("MUST NOT REACH");
334 }
gio5f2f1002025-03-20 18:38:48 +0400335}
336
337export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
338
339export type MessageType = "INFO" | "WARNING" | "FATAL";
340
341export type Message = {
giod0026612025-05-08 13:00:36 +0000342 id: string;
343 type: MessageType;
344 nodeId?: string;
345 message: string;
346 onHighlight?: (state: AppState) => void;
347 onLooseHighlight?: (state: AppState) => void;
348 onClick?: (state: AppState) => void;
gio5f2f1002025-03-20 18:38:48 +0400349};
350
giob77cb932025-05-19 09:37:14 +0000351export const accessSchema = z.discriminatedUnion("type", [
352 z.object({
353 type: z.literal("https"),
354 name: z.string(),
355 address: z.string(),
356 }),
357 z.object({
358 type: z.literal("ssh"),
359 name: z.string(),
360 host: z.string(),
361 port: z.number(),
362 }),
363 z.object({
364 type: z.literal("tcp"),
365 name: z.string(),
366 host: z.string(),
367 port: z.number(),
368 }),
369 z.object({
370 type: z.literal("udp"),
371 name: z.string(),
372 host: z.string(),
373 port: z.number(),
374 }),
375 z.object({
376 type: z.literal("postgresql"),
377 name: z.string(),
378 host: z.string(),
379 port: z.number(),
380 database: z.string(),
381 username: z.string(),
382 password: z.string(),
383 }),
384 z.object({
385 type: z.literal("mongodb"),
386 name: z.string(),
387 host: z.string(),
388 port: z.number(),
389 database: z.string(),
390 username: z.string(),
391 password: z.string(),
392 }),
393]);
394
gioa1efbad2025-05-21 07:16:45 +0000395export const serviceInfoSchema = z.object({
396 name: z.string(),
397 workers: z.array(
398 z.object({
399 id: z.string(),
400 repoOK: z.boolean(),
401 commit: z.string(),
402 commands: z.optional(
403 z.array(
404 z.object({
405 command: z.string(),
406 state: z.string(),
407 }),
408 ),
409 ),
410 }),
411 ),
412});
413
gio5f2f1002025-03-20 18:38:48 +0400414export const envSchema = z.object({
gio7d813702025-05-08 18:29:52 +0000415 managerAddr: z.optional(z.string().min(1)),
gio09fcab52025-05-12 14:05:07 +0000416 deployKey: z.optional(z.nullable(z.string().min(1))),
giod0026612025-05-08 13:00:36 +0000417 networks: z
418 .array(
419 z.object({
420 name: z.string().min(1),
421 domain: z.string().min(1),
gio6d8b71c2025-05-19 12:57:35 +0000422 hasAuth: z.boolean(),
giod0026612025-05-08 13:00:36 +0000423 }),
424 )
425 .default([]),
426 integrations: z.object({
427 github: z.boolean(),
428 }),
gioa1efbad2025-05-21 07:16:45 +0000429 services: z.array(serviceInfoSchema),
gio3ed59592025-05-14 16:51:09 +0000430 user: z.object({
431 id: z.string(),
432 username: z.string(),
433 }),
giob77cb932025-05-19 09:37:14 +0000434 access: z.array(accessSchema),
gio5f2f1002025-03-20 18:38:48 +0400435});
436
gioa1efbad2025-05-21 07:16:45 +0000437export type ServiceInfo = z.infer<typeof serviceInfoSchema>;
gio5f2f1002025-03-20 18:38:48 +0400438export type Env = z.infer<typeof envSchema>;
439
gio7f98e772025-05-07 11:00:14 +0000440const defaultEnv: Env = {
gio7d813702025-05-08 18:29:52 +0000441 managerAddr: undefined,
giod0026612025-05-08 13:00:36 +0000442 deployKey: undefined,
443 networks: [],
444 integrations: {
445 github: false,
446 },
gio3a921b82025-05-10 07:36:09 +0000447 services: [],
gio3ed59592025-05-14 16:51:09 +0000448 user: {
449 id: "",
450 username: "",
451 },
giob77cb932025-05-19 09:37:14 +0000452 access: [],
gio7f98e772025-05-07 11:00:14 +0000453};
454
gio5f2f1002025-03-20 18:38:48 +0400455export type Project = {
giod0026612025-05-08 13:00:36 +0000456 id: string;
457 name: string;
458};
gio5f2f1002025-03-20 18:38:48 +0400459
gio7f98e772025-05-07 11:00:14 +0000460export type IntegrationsConfig = {
giod0026612025-05-08 13:00:36 +0000461 github: boolean;
gio7f98e772025-05-07 11:00:14 +0000462};
463
464type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
465type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
466
gioaf8db832025-05-13 14:43:05 +0000467type Viewport = {
468 transformX: number;
469 transformY: number;
470 transformZoom: number;
471 width: number;
472 height: number;
473};
474
gio5f2f1002025-03-20 18:38:48 +0400475export type AppState = {
giod0026612025-05-08 13:00:36 +0000476 projectId: string | undefined;
gio818da4e2025-05-12 14:45:35 +0000477 mode: "edit" | "deploy";
giod0026612025-05-08 13:00:36 +0000478 projects: Project[];
479 nodes: AppNode[];
480 edges: Edge[];
gio359a6852025-05-14 03:38:24 +0000481 zoom: ReactFlowViewport;
giod0026612025-05-08 13:00:36 +0000482 categories: Category[];
483 messages: Message[];
484 env: Env;
gioaf8db832025-05-13 14:43:05 +0000485 viewport: Viewport;
486 setViewport: (viewport: Viewport) => void;
giod0026612025-05-08 13:00:36 +0000487 githubService: GitHubService | null;
488 setHighlightCategory: (name: string, active: boolean) => void;
489 onNodesChange: OnNodesChange<AppNode>;
490 onEdgesChange: OnEdgesChange;
491 onConnect: OnConnect;
gioaf8db832025-05-13 14:43:05 +0000492 addNode: (node: Omit<AppNode, "position">) => void;
giod0026612025-05-08 13:00:36 +0000493 setNodes: (nodes: AppNode[]) => void;
494 setEdges: (edges: Edge[]) => void;
gio818da4e2025-05-12 14:45:35 +0000495 setProject: (projectId: string | undefined) => Promise<void>;
496 setMode: (mode: "edit" | "deploy") => void;
giod0026612025-05-08 13:00:36 +0000497 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
498 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
499 replaceEdge: (c: Connection, id?: string) => void;
500 refreshEnv: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400501};
502
503const projectIdSelector = (state: AppState) => state.projectId;
504const categoriesSelector = (state: AppState) => state.categories;
505const messagesSelector = (state: AppState) => state.messages;
gio7f98e772025-05-07 11:00:14 +0000506const githubServiceSelector = (state: AppState) => state.githubService;
gio5f2f1002025-03-20 18:38:48 +0400507const envSelector = (state: AppState) => state.env;
gio359a6852025-05-14 03:38:24 +0000508const zoomSelector = (state: AppState) => state.zoom;
gioaf8db832025-05-13 14:43:05 +0000509
gio359a6852025-05-14 03:38:24 +0000510export function useZoom(): ReactFlowViewport {
511 return useStateStore(zoomSelector);
gioaf8db832025-05-13 14:43:05 +0000512}
gio5f2f1002025-03-20 18:38:48 +0400513
514export function useProjectId(): string | undefined {
giod0026612025-05-08 13:00:36 +0000515 return useStateStore(projectIdSelector);
gio5f2f1002025-03-20 18:38:48 +0400516}
517
giob45b1862025-05-20 11:42:20 +0000518export function useSetProject(): (projectId: string | undefined) => void {
519 return useStateStore((state) => state.setProject);
520}
521
gio5f2f1002025-03-20 18:38:48 +0400522export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000523 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400524}
525
526export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000527 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400528}
529
530export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000531 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400532}
533
534export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000535 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400536}
537
538export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000539 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400540}
541
gio5f2f1002025-03-20 18:38:48 +0400542export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000543 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000544}
545
546export function useGithubService(): GitHubService | null {
giod0026612025-05-08 13:00:36 +0000547 return useStateStore(githubServiceSelector);
gio5f2f1002025-03-20 18:38:48 +0400548}
549
gio3ec94242025-05-16 12:46:57 +0000550export function useMode(): "edit" | "deploy" {
551 return useStateStore((state) => state.mode);
552}
553
gio5f2f1002025-03-20 18:38:48 +0400554const v: Validator = CreateValidators();
555
gioaf8db832025-05-13 14:43:05 +0000556function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
557 const zoomMultiplier = 1 / transformZoom;
558 const realWidth = width * zoomMultiplier;
559 const realHeight = height * zoomMultiplier;
560 const paddingMultiplier = 0.8;
561 const ret = {
562 x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
563 y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
564 };
565 return ret;
566}
567
gio5f2f1002025-03-20 18:38:48 +0400568export const useStateStore = create<AppState>((set, get): AppState => {
giod0026612025-05-08 13:00:36 +0000569 const setN = (nodes: AppNode[]) => {
gio4b9b58a2025-05-12 11:46:08 +0000570 set({
giod0026612025-05-08 13:00:36 +0000571 nodes,
gio5cf364c2025-05-08 16:01:21 +0000572 messages: v(nodes),
gio4b9b58a2025-05-12 11:46:08 +0000573 });
574 };
575
gio48fde052025-05-14 09:48:08 +0000576 const injectNetworkNodes = () => {
577 const newNetworks = get().env.networks.filter(
578 (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
579 );
580 newNetworks.forEach((n) => {
581 get().addNode({
582 id: n.domain,
583 type: "network",
584 connectable: true,
585 data: {
586 domain: n.domain,
587 label: n.domain,
588 envVars: [],
589 ports: [],
590 state: "success", // TODO(gio): monitor network health
591 },
592 });
593 console.log("added network", n.domain);
594 });
595 };
596
gio4b9b58a2025-05-12 11:46:08 +0000597 const restoreSaved = async () => {
gio818da4e2025-05-12 14:45:35 +0000598 const { projectId } = get();
599 const resp = await fetch(`/api/project/${projectId}/saved/${get().mode === "deploy" ? "deploy" : "draft"}`, {
gio4b9b58a2025-05-12 11:46:08 +0000600 method: "GET",
601 });
602 const inst = await resp.json();
gio48fde052025-05-14 09:48:08 +0000603 setN(inst.nodes);
604 set({ edges: inst.edges });
605 injectNetworkNodes();
gio359a6852025-05-14 03:38:24 +0000606 if (
607 get().zoom.x !== inst.viewport.x ||
608 get().zoom.y !== inst.viewport.y ||
609 get().zoom.zoom !== inst.viewport.zoom
610 ) {
611 set({ zoom: inst.viewport });
612 }
giod0026612025-05-08 13:00:36 +0000613 };
gio7f98e772025-05-07 11:00:14 +0000614
giod0026612025-05-08 13:00:36 +0000615 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
616 setN(
617 get().nodes.map((n) => {
618 if (n.id === id) {
619 return {
620 ...n,
621 data: {
622 ...n.data,
623 ...data,
624 },
625 } as Extract<AppNode, { type: T }>;
626 }
627 return n;
628 }),
629 );
630 }
gio7f98e772025-05-07 11:00:14 +0000631
giod0026612025-05-08 13:00:36 +0000632 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
633 setN(
634 get().nodes.map((n) => {
635 if (n.id === id) {
636 return {
637 ...n,
638 ...node,
639 } as Extract<AppNode, { type: T }>;
640 }
641 return n;
642 }),
643 );
644 }
gio7f98e772025-05-07 11:00:14 +0000645
giod0026612025-05-08 13:00:36 +0000646 function onConnect(c: Connection) {
647 const { nodes, edges } = get();
648 set({
649 edges: addEdge(c, edges),
650 });
651 const sn = nodes.filter((n) => n.id === c.source)[0]!;
652 const tn = nodes.filter((n) => n.id === c.target)[0]!;
653 if (tn.type === "network") {
654 if (sn.type === "gateway-https") {
655 updateNodeData<"gateway-https">(sn.id, {
656 network: tn.data.domain,
657 });
658 } else if (sn.type === "gateway-tcp") {
659 updateNodeData<"gateway-tcp">(sn.id, {
660 network: tn.data.domain,
661 });
662 }
663 }
664 if (tn.type === "app") {
665 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
666 const sourceEnvVars = nodeEnvVarNames(sn);
667 if (sourceEnvVars.length === 0) {
668 throw new Error("MUST NOT REACH!");
669 }
670 const id = uuidv4();
671 if (sourceEnvVars.length === 1) {
672 updateNode<"app">(c.target, {
673 ...tn,
674 data: {
675 ...tn.data,
676 envVars: [
677 ...(tn.data.envVars || []),
678 {
679 id: id,
680 source: c.source,
681 name: sourceEnvVars[0],
682 isEditting: false,
683 },
684 ],
685 },
686 });
687 } else {
688 updateNode<"app">(c.target, {
689 ...tn,
690 data: {
691 ...tn.data,
692 envVars: [
693 ...(tn.data.envVars || []),
694 {
695 id: id,
696 source: c.source,
697 },
698 ],
699 },
700 });
701 }
702 }
703 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
704 const sourcePorts = sn.data.ports || [];
705 const id = uuidv4();
706 if (sourcePorts.length === 1) {
707 updateNode<"app">(c.target, {
708 ...tn,
709 data: {
710 ...tn.data,
711 envVars: [
712 ...(tn.data.envVars || []),
713 {
714 id: id,
715 source: c.source,
716 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
717 portId: sourcePorts[0].id,
718 isEditting: false,
719 },
720 ],
721 },
722 });
723 }
724 }
725 }
726 if (c.sourceHandle === "volume") {
727 updateNodeData<"volume">(c.source, {
728 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
729 });
730 }
731 if (c.targetHandle === "volume") {
732 if (tn.type === "postgresql" || tn.type === "mongodb") {
733 updateNodeData(c.target, {
734 volumeId: c.source,
735 });
736 }
737 }
738 if (c.targetHandle === "https") {
739 if ((sn.data.ports || []).length === 1) {
740 updateNodeData<"gateway-https">(c.target, {
741 https: {
742 serviceId: c.source,
743 portId: sn.data.ports![0].id,
744 },
745 });
746 } else {
747 updateNodeData<"gateway-https">(c.target, {
748 https: {
749 serviceId: c.source,
750 portId: "", // TODO(gio)
751 },
752 });
753 }
754 }
755 if (c.targetHandle === "tcp") {
756 const td = tn.data as GatewayTCPData;
757 if ((sn.data.ports || []).length === 1) {
758 updateNodeData<"gateway-tcp">(c.target, {
759 exposed: (td.exposed || []).concat({
760 serviceId: c.source,
761 portId: sn.data.ports![0].id,
762 }),
763 });
764 } else {
765 updateNodeData<"gateway-tcp">(c.target, {
766 selected: {
767 serviceId: c.source,
768 portId: undefined,
769 },
770 });
771 }
772 }
773 if (sn.type === "app") {
774 if (c.sourceHandle === "ports") {
775 updateNodeData<"app">(sn.id, {
776 isChoosingPortToConnect: true,
777 });
778 }
779 }
780 if (tn.type === "app") {
781 if (c.targetHandle === "repository") {
782 updateNodeData<"app">(tn.id, {
783 repository: {
784 id: c.source,
785 branch: "master",
786 rootDir: "/",
787 },
788 });
789 }
790 }
791 }
792 return {
793 projectId: undefined,
gio818da4e2025-05-12 14:45:35 +0000794 mode: "edit",
giod0026612025-05-08 13:00:36 +0000795 projects: [],
796 nodes: [],
797 edges: [],
798 categories: defaultCategories,
799 messages: v([]),
800 env: defaultEnv,
gioaf8db832025-05-13 14:43:05 +0000801 viewport: {
802 transformX: 0,
803 transformY: 0,
804 transformZoom: 1,
805 width: 800,
806 height: 600,
807 },
gio359a6852025-05-14 03:38:24 +0000808 zoom: {
809 x: 0,
810 y: 0,
811 zoom: 1,
812 },
giod0026612025-05-08 13:00:36 +0000813 githubService: null,
gioaf8db832025-05-13 14:43:05 +0000814 setViewport: (viewport) => {
815 const { viewport: vp } = get();
816 if (
817 viewport.transformX !== vp.transformX ||
818 viewport.transformY !== vp.transformY ||
819 viewport.transformZoom !== vp.transformZoom ||
820 viewport.width !== vp.width ||
821 viewport.height !== vp.height
822 ) {
823 set({ viewport });
824 }
825 },
giod0026612025-05-08 13:00:36 +0000826 setHighlightCategory: (name, active) => {
827 set({
828 categories: get().categories.map((c) => {
829 if (c.title.toLowerCase() !== name.toLowerCase()) {
830 return c;
831 } else {
832 return {
833 ...c,
834 active,
835 };
836 }
837 }),
838 });
839 },
840 onNodesChange: (changes) => {
841 const nodes = applyNodeChanges(changes, get().nodes);
842 setN(nodes);
843 },
844 onEdgesChange: (changes) => {
845 set({
846 edges: applyEdgeChanges(changes, get().edges),
847 });
848 },
gioaf8db832025-05-13 14:43:05 +0000849 addNode: (node) => {
850 const { viewport, nodes } = get();
851 setN(
852 nodes.concat({
853 ...node,
854 position: getRandomPosition(viewport),
gioa1efbad2025-05-21 07:16:45 +0000855 } as AppNode),
gioaf8db832025-05-13 14:43:05 +0000856 );
857 },
giod0026612025-05-08 13:00:36 +0000858 setNodes: (nodes) => {
859 setN(nodes);
860 },
861 setEdges: (edges) => {
862 set({ edges });
863 },
864 replaceEdge: (c, id) => {
865 let change: EdgeChange;
866 if (id === undefined) {
867 change = {
868 type: "add",
869 item: {
870 id: uuidv4(),
871 ...c,
872 },
873 };
874 onConnect(c);
875 } else {
876 change = {
877 type: "replace",
878 id,
879 item: {
880 id,
881 ...c,
882 },
883 };
884 }
885 set({
886 edges: applyEdgeChanges([change], get().edges),
887 });
888 },
889 updateNode,
890 updateNodeData,
891 onConnect,
892 refreshEnv: async () => {
893 const projectId = get().projectId;
894 let env: Env = defaultEnv;
gio7f98e772025-05-07 11:00:14 +0000895
giod0026612025-05-08 13:00:36 +0000896 try {
897 if (projectId) {
898 const response = await fetch(`/api/project/${projectId}/env`);
899 if (response.ok) {
900 const data = await response.json();
901 const result = envSchema.safeParse(data);
902 if (result.success) {
903 env = result.data;
904 } else {
905 console.error("Invalid env data:", result.error);
906 }
907 }
908 }
909 } catch (error) {
910 console.error("Failed to fetch integrations:", error);
911 } finally {
gio4b9b58a2025-05-12 11:46:08 +0000912 if (JSON.stringify(get().env) !== JSON.stringify(env)) {
913 set({ env });
gio48fde052025-05-14 09:48:08 +0000914 injectNetworkNodes();
gio4b9b58a2025-05-12 11:46:08 +0000915
916 if (env.integrations.github) {
917 set({ githubService: new GitHubServiceImpl(projectId!) });
918 } else {
919 set({ githubService: null });
920 }
giod0026612025-05-08 13:00:36 +0000921 }
922 }
923 },
gio818da4e2025-05-12 14:45:35 +0000924 setMode: (mode) => {
925 set({ mode });
926 },
927 setProject: async (projectId) => {
gio359a6852025-05-14 03:38:24 +0000928 if (projectId === get().projectId) {
929 return;
930 }
giod0026612025-05-08 13:00:36 +0000931 set({
932 projectId,
933 });
934 if (projectId) {
gio818da4e2025-05-12 14:45:35 +0000935 await get().refreshEnv();
936 if (get().env.deployKey) {
937 set({ mode: "deploy" });
938 } else {
939 set({ mode: "edit" });
940 }
gio4b9b58a2025-05-12 11:46:08 +0000941 restoreSaved();
942 } else {
943 set({
944 nodes: [],
945 edges: [],
946 });
giod0026612025-05-08 13:00:36 +0000947 }
948 },
949 };
gio5f2f1002025-03-20 18:38:48 +0400950});