blob: df658afc4af6fe36f7f82f104cbe935279fa7a75 [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(),
gio0afbaee2025-05-22 04:34:33 +0000400 commit: z.optional(
401 z.object({
402 hash: z.string(),
403 message: z.string(),
404 }),
405 ),
gioa1efbad2025-05-21 07:16:45 +0000406 commands: z.optional(
407 z.array(
408 z.object({
409 command: z.string(),
410 state: z.string(),
411 }),
412 ),
413 ),
414 }),
415 ),
416});
417
gio5f2f1002025-03-20 18:38:48 +0400418export const envSchema = z.object({
gio7d813702025-05-08 18:29:52 +0000419 managerAddr: z.optional(z.string().min(1)),
gio09fcab52025-05-12 14:05:07 +0000420 deployKey: z.optional(z.nullable(z.string().min(1))),
giod0026612025-05-08 13:00:36 +0000421 networks: z
422 .array(
423 z.object({
424 name: z.string().min(1),
425 domain: z.string().min(1),
gio6d8b71c2025-05-19 12:57:35 +0000426 hasAuth: z.boolean(),
giod0026612025-05-08 13:00:36 +0000427 }),
428 )
429 .default([]),
430 integrations: z.object({
431 github: z.boolean(),
432 }),
gioa1efbad2025-05-21 07:16:45 +0000433 services: z.array(serviceInfoSchema),
gio3ed59592025-05-14 16:51:09 +0000434 user: z.object({
435 id: z.string(),
436 username: z.string(),
437 }),
giob77cb932025-05-19 09:37:14 +0000438 access: z.array(accessSchema),
gio5f2f1002025-03-20 18:38:48 +0400439});
440
gioa1efbad2025-05-21 07:16:45 +0000441export type ServiceInfo = z.infer<typeof serviceInfoSchema>;
gio5f2f1002025-03-20 18:38:48 +0400442export type Env = z.infer<typeof envSchema>;
443
gio7f98e772025-05-07 11:00:14 +0000444const defaultEnv: Env = {
gio7d813702025-05-08 18:29:52 +0000445 managerAddr: undefined,
giod0026612025-05-08 13:00:36 +0000446 deployKey: undefined,
447 networks: [],
448 integrations: {
449 github: false,
450 },
gio3a921b82025-05-10 07:36:09 +0000451 services: [],
gio3ed59592025-05-14 16:51:09 +0000452 user: {
453 id: "",
454 username: "",
455 },
giob77cb932025-05-19 09:37:14 +0000456 access: [],
gio7f98e772025-05-07 11:00:14 +0000457};
458
gio5f2f1002025-03-20 18:38:48 +0400459export type Project = {
giod0026612025-05-08 13:00:36 +0000460 id: string;
461 name: string;
462};
gio5f2f1002025-03-20 18:38:48 +0400463
gio7f98e772025-05-07 11:00:14 +0000464export type IntegrationsConfig = {
giod0026612025-05-08 13:00:36 +0000465 github: boolean;
gio7f98e772025-05-07 11:00:14 +0000466};
467
468type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
469type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
470
gioaf8db832025-05-13 14:43:05 +0000471type Viewport = {
472 transformX: number;
473 transformY: number;
474 transformZoom: number;
475 width: number;
476 height: number;
477};
478
gio5f2f1002025-03-20 18:38:48 +0400479export type AppState = {
giod0026612025-05-08 13:00:36 +0000480 projectId: string | undefined;
gio818da4e2025-05-12 14:45:35 +0000481 mode: "edit" | "deploy";
giod0026612025-05-08 13:00:36 +0000482 projects: Project[];
483 nodes: AppNode[];
484 edges: Edge[];
gio359a6852025-05-14 03:38:24 +0000485 zoom: ReactFlowViewport;
giod0026612025-05-08 13:00:36 +0000486 categories: Category[];
487 messages: Message[];
488 env: Env;
gioaf8db832025-05-13 14:43:05 +0000489 viewport: Viewport;
490 setViewport: (viewport: Viewport) => void;
giod0026612025-05-08 13:00:36 +0000491 githubService: GitHubService | null;
492 setHighlightCategory: (name: string, active: boolean) => void;
493 onNodesChange: OnNodesChange<AppNode>;
494 onEdgesChange: OnEdgesChange;
495 onConnect: OnConnect;
gioaf8db832025-05-13 14:43:05 +0000496 addNode: (node: Omit<AppNode, "position">) => void;
giod0026612025-05-08 13:00:36 +0000497 setNodes: (nodes: AppNode[]) => void;
498 setEdges: (edges: Edge[]) => void;
gio818da4e2025-05-12 14:45:35 +0000499 setProject: (projectId: string | undefined) => Promise<void>;
500 setMode: (mode: "edit" | "deploy") => void;
giod0026612025-05-08 13:00:36 +0000501 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
502 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
503 replaceEdge: (c: Connection, id?: string) => void;
504 refreshEnv: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400505};
506
507const projectIdSelector = (state: AppState) => state.projectId;
508const categoriesSelector = (state: AppState) => state.categories;
509const messagesSelector = (state: AppState) => state.messages;
gio7f98e772025-05-07 11:00:14 +0000510const githubServiceSelector = (state: AppState) => state.githubService;
gio5f2f1002025-03-20 18:38:48 +0400511const envSelector = (state: AppState) => state.env;
gio359a6852025-05-14 03:38:24 +0000512const zoomSelector = (state: AppState) => state.zoom;
gioaf8db832025-05-13 14:43:05 +0000513
gio359a6852025-05-14 03:38:24 +0000514export function useZoom(): ReactFlowViewport {
515 return useStateStore(zoomSelector);
gioaf8db832025-05-13 14:43:05 +0000516}
gio5f2f1002025-03-20 18:38:48 +0400517
518export function useProjectId(): string | undefined {
giod0026612025-05-08 13:00:36 +0000519 return useStateStore(projectIdSelector);
gio5f2f1002025-03-20 18:38:48 +0400520}
521
giob45b1862025-05-20 11:42:20 +0000522export function useSetProject(): (projectId: string | undefined) => void {
523 return useStateStore((state) => state.setProject);
524}
525
gio5f2f1002025-03-20 18:38:48 +0400526export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000527 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400528}
529
530export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000531 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400532}
533
534export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000535 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400536}
537
538export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000539 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400540}
541
542export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000543 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400544}
545
gio5f2f1002025-03-20 18:38:48 +0400546export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000547 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000548}
549
550export function useGithubService(): GitHubService | null {
giod0026612025-05-08 13:00:36 +0000551 return useStateStore(githubServiceSelector);
gio5f2f1002025-03-20 18:38:48 +0400552}
553
gio3ec94242025-05-16 12:46:57 +0000554export function useMode(): "edit" | "deploy" {
555 return useStateStore((state) => state.mode);
556}
557
gio5f2f1002025-03-20 18:38:48 +0400558const v: Validator = CreateValidators();
559
gioaf8db832025-05-13 14:43:05 +0000560function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
561 const zoomMultiplier = 1 / transformZoom;
562 const realWidth = width * zoomMultiplier;
563 const realHeight = height * zoomMultiplier;
564 const paddingMultiplier = 0.8;
565 const ret = {
566 x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
567 y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
568 };
569 return ret;
570}
571
gio5f2f1002025-03-20 18:38:48 +0400572export const useStateStore = create<AppState>((set, get): AppState => {
giod0026612025-05-08 13:00:36 +0000573 const setN = (nodes: AppNode[]) => {
gio4b9b58a2025-05-12 11:46:08 +0000574 set({
giod0026612025-05-08 13:00:36 +0000575 nodes,
gio5cf364c2025-05-08 16:01:21 +0000576 messages: v(nodes),
gio4b9b58a2025-05-12 11:46:08 +0000577 });
578 };
579
gio48fde052025-05-14 09:48:08 +0000580 const injectNetworkNodes = () => {
581 const newNetworks = get().env.networks.filter(
582 (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
583 );
584 newNetworks.forEach((n) => {
585 get().addNode({
586 id: n.domain,
587 type: "network",
588 connectable: true,
589 data: {
590 domain: n.domain,
591 label: n.domain,
592 envVars: [],
593 ports: [],
594 state: "success", // TODO(gio): monitor network health
595 },
596 });
597 console.log("added network", n.domain);
598 });
599 };
600
gio4b9b58a2025-05-12 11:46:08 +0000601 const restoreSaved = async () => {
gio818da4e2025-05-12 14:45:35 +0000602 const { projectId } = get();
603 const resp = await fetch(`/api/project/${projectId}/saved/${get().mode === "deploy" ? "deploy" : "draft"}`, {
gio4b9b58a2025-05-12 11:46:08 +0000604 method: "GET",
605 });
606 const inst = await resp.json();
gio48fde052025-05-14 09:48:08 +0000607 setN(inst.nodes);
608 set({ edges: inst.edges });
609 injectNetworkNodes();
gio359a6852025-05-14 03:38:24 +0000610 if (
611 get().zoom.x !== inst.viewport.x ||
612 get().zoom.y !== inst.viewport.y ||
613 get().zoom.zoom !== inst.viewport.zoom
614 ) {
615 set({ zoom: inst.viewport });
616 }
giod0026612025-05-08 13:00:36 +0000617 };
gio7f98e772025-05-07 11:00:14 +0000618
giod0026612025-05-08 13:00:36 +0000619 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
620 setN(
621 get().nodes.map((n) => {
622 if (n.id === id) {
623 return {
624 ...n,
625 data: {
626 ...n.data,
627 ...data,
628 },
629 } as Extract<AppNode, { type: T }>;
630 }
631 return n;
632 }),
633 );
634 }
gio7f98e772025-05-07 11:00:14 +0000635
giod0026612025-05-08 13:00:36 +0000636 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
637 setN(
638 get().nodes.map((n) => {
639 if (n.id === id) {
640 return {
641 ...n,
642 ...node,
643 } as Extract<AppNode, { type: T }>;
644 }
645 return n;
646 }),
647 );
648 }
gio7f98e772025-05-07 11:00:14 +0000649
giod0026612025-05-08 13:00:36 +0000650 function onConnect(c: Connection) {
651 const { nodes, edges } = get();
652 set({
653 edges: addEdge(c, edges),
654 });
655 const sn = nodes.filter((n) => n.id === c.source)[0]!;
656 const tn = nodes.filter((n) => n.id === c.target)[0]!;
657 if (tn.type === "network") {
658 if (sn.type === "gateway-https") {
659 updateNodeData<"gateway-https">(sn.id, {
660 network: tn.data.domain,
661 });
662 } else if (sn.type === "gateway-tcp") {
663 updateNodeData<"gateway-tcp">(sn.id, {
664 network: tn.data.domain,
665 });
666 }
667 }
668 if (tn.type === "app") {
669 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
670 const sourceEnvVars = nodeEnvVarNames(sn);
671 if (sourceEnvVars.length === 0) {
672 throw new Error("MUST NOT REACH!");
673 }
674 const id = uuidv4();
675 if (sourceEnvVars.length === 1) {
676 updateNode<"app">(c.target, {
677 ...tn,
678 data: {
679 ...tn.data,
680 envVars: [
681 ...(tn.data.envVars || []),
682 {
683 id: id,
684 source: c.source,
685 name: sourceEnvVars[0],
686 isEditting: false,
687 },
688 ],
689 },
690 });
691 } else {
692 updateNode<"app">(c.target, {
693 ...tn,
694 data: {
695 ...tn.data,
696 envVars: [
697 ...(tn.data.envVars || []),
698 {
699 id: id,
700 source: c.source,
701 },
702 ],
703 },
704 });
705 }
706 }
707 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
708 const sourcePorts = sn.data.ports || [];
709 const id = uuidv4();
710 if (sourcePorts.length === 1) {
711 updateNode<"app">(c.target, {
712 ...tn,
713 data: {
714 ...tn.data,
715 envVars: [
716 ...(tn.data.envVars || []),
717 {
718 id: id,
719 source: c.source,
720 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
721 portId: sourcePorts[0].id,
722 isEditting: false,
723 },
724 ],
725 },
726 });
727 }
728 }
729 }
730 if (c.sourceHandle === "volume") {
731 updateNodeData<"volume">(c.source, {
732 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
733 });
734 }
735 if (c.targetHandle === "volume") {
736 if (tn.type === "postgresql" || tn.type === "mongodb") {
737 updateNodeData(c.target, {
738 volumeId: c.source,
739 });
740 }
741 }
742 if (c.targetHandle === "https") {
743 if ((sn.data.ports || []).length === 1) {
744 updateNodeData<"gateway-https">(c.target, {
745 https: {
746 serviceId: c.source,
747 portId: sn.data.ports![0].id,
748 },
749 });
750 } else {
751 updateNodeData<"gateway-https">(c.target, {
752 https: {
753 serviceId: c.source,
754 portId: "", // TODO(gio)
755 },
756 });
757 }
758 }
759 if (c.targetHandle === "tcp") {
760 const td = tn.data as GatewayTCPData;
761 if ((sn.data.ports || []).length === 1) {
762 updateNodeData<"gateway-tcp">(c.target, {
763 exposed: (td.exposed || []).concat({
764 serviceId: c.source,
765 portId: sn.data.ports![0].id,
766 }),
767 });
768 } else {
769 updateNodeData<"gateway-tcp">(c.target, {
770 selected: {
771 serviceId: c.source,
772 portId: undefined,
773 },
774 });
775 }
776 }
777 if (sn.type === "app") {
778 if (c.sourceHandle === "ports") {
779 updateNodeData<"app">(sn.id, {
780 isChoosingPortToConnect: true,
781 });
782 }
783 }
784 if (tn.type === "app") {
785 if (c.targetHandle === "repository") {
786 updateNodeData<"app">(tn.id, {
787 repository: {
788 id: c.source,
789 branch: "master",
790 rootDir: "/",
791 },
792 });
793 }
794 }
795 }
796 return {
797 projectId: undefined,
gio818da4e2025-05-12 14:45:35 +0000798 mode: "edit",
giod0026612025-05-08 13:00:36 +0000799 projects: [],
800 nodes: [],
801 edges: [],
802 categories: defaultCategories,
803 messages: v([]),
804 env: defaultEnv,
gioaf8db832025-05-13 14:43:05 +0000805 viewport: {
806 transformX: 0,
807 transformY: 0,
808 transformZoom: 1,
809 width: 800,
810 height: 600,
811 },
gio359a6852025-05-14 03:38:24 +0000812 zoom: {
813 x: 0,
814 y: 0,
815 zoom: 1,
816 },
giod0026612025-05-08 13:00:36 +0000817 githubService: null,
gioaf8db832025-05-13 14:43:05 +0000818 setViewport: (viewport) => {
819 const { viewport: vp } = get();
820 if (
821 viewport.transformX !== vp.transformX ||
822 viewport.transformY !== vp.transformY ||
823 viewport.transformZoom !== vp.transformZoom ||
824 viewport.width !== vp.width ||
825 viewport.height !== vp.height
826 ) {
827 set({ viewport });
828 }
829 },
giod0026612025-05-08 13:00:36 +0000830 setHighlightCategory: (name, active) => {
831 set({
832 categories: get().categories.map((c) => {
833 if (c.title.toLowerCase() !== name.toLowerCase()) {
834 return c;
835 } else {
836 return {
837 ...c,
838 active,
839 };
840 }
841 }),
842 });
843 },
844 onNodesChange: (changes) => {
845 const nodes = applyNodeChanges(changes, get().nodes);
846 setN(nodes);
847 },
848 onEdgesChange: (changes) => {
849 set({
850 edges: applyEdgeChanges(changes, get().edges),
851 });
852 },
gioaf8db832025-05-13 14:43:05 +0000853 addNode: (node) => {
854 const { viewport, nodes } = get();
855 setN(
856 nodes.concat({
857 ...node,
858 position: getRandomPosition(viewport),
gioa1efbad2025-05-21 07:16:45 +0000859 } as AppNode),
gioaf8db832025-05-13 14:43:05 +0000860 );
861 },
giod0026612025-05-08 13:00:36 +0000862 setNodes: (nodes) => {
863 setN(nodes);
864 },
865 setEdges: (edges) => {
866 set({ edges });
867 },
868 replaceEdge: (c, id) => {
869 let change: EdgeChange;
870 if (id === undefined) {
871 change = {
872 type: "add",
873 item: {
874 id: uuidv4(),
875 ...c,
876 },
877 };
878 onConnect(c);
879 } else {
880 change = {
881 type: "replace",
882 id,
883 item: {
884 id,
885 ...c,
886 },
887 };
888 }
889 set({
890 edges: applyEdgeChanges([change], get().edges),
891 });
892 },
893 updateNode,
894 updateNodeData,
895 onConnect,
896 refreshEnv: async () => {
897 const projectId = get().projectId;
898 let env: Env = defaultEnv;
gio7f98e772025-05-07 11:00:14 +0000899
giod0026612025-05-08 13:00:36 +0000900 try {
901 if (projectId) {
902 const response = await fetch(`/api/project/${projectId}/env`);
903 if (response.ok) {
904 const data = await response.json();
905 const result = envSchema.safeParse(data);
906 if (result.success) {
907 env = result.data;
908 } else {
909 console.error("Invalid env data:", result.error);
910 }
911 }
912 }
913 } catch (error) {
914 console.error("Failed to fetch integrations:", error);
915 } finally {
gio4b9b58a2025-05-12 11:46:08 +0000916 if (JSON.stringify(get().env) !== JSON.stringify(env)) {
917 set({ env });
gio48fde052025-05-14 09:48:08 +0000918 injectNetworkNodes();
gio4b9b58a2025-05-12 11:46:08 +0000919
920 if (env.integrations.github) {
921 set({ githubService: new GitHubServiceImpl(projectId!) });
922 } else {
923 set({ githubService: null });
924 }
giod0026612025-05-08 13:00:36 +0000925 }
926 }
927 },
gio818da4e2025-05-12 14:45:35 +0000928 setMode: (mode) => {
929 set({ mode });
930 },
931 setProject: async (projectId) => {
gio359a6852025-05-14 03:38:24 +0000932 if (projectId === get().projectId) {
933 return;
934 }
giod0026612025-05-08 13:00:36 +0000935 set({
936 projectId,
937 });
938 if (projectId) {
gio818da4e2025-05-12 14:45:35 +0000939 await get().refreshEnv();
940 if (get().env.deployKey) {
941 set({ mode: "deploy" });
942 } else {
943 set({ mode: "edit" });
944 }
gio4b9b58a2025-05-12 11:46:08 +0000945 restoreSaved();
946 } else {
947 set({
948 nodes: [],
949 edges: [],
950 });
giod0026612025-05-08 13:00:36 +0000951 }
952 },
953 };
gio5f2f1002025-03-20 18:38:48 +0400954});