Canvas: Generate graph state out of dodo-app config

Restructure code, create shared config lib.

Change-Id: I2cf06d35c486d4557484daf8618a2c215316fa7e
diff --git a/apps/canvas/front/src/Config.tsx b/apps/canvas/front/src/Config.tsx
index bdad346..eb024ea 100644
--- a/apps/canvas/front/src/Config.tsx
+++ b/apps/canvas/front/src/Config.tsx
@@ -1,5 +1,5 @@
 import { useStateStore } from "./lib/state";
-import { generateDodoConfig } from "./lib/config";
+import { generateDodoConfig } from "../../config/src/config";
 import JSONView from "@microlink/react-json-view";
 import { useMemo } from "react";
 
diff --git a/apps/canvas/front/src/components/actions.tsx b/apps/canvas/front/src/components/actions.tsx
index d4bd5ea..1442b9a 100644
--- a/apps/canvas/front/src/components/actions.tsx
+++ b/apps/canvas/front/src/components/actions.tsx
@@ -1,7 +1,7 @@
-import { AppNode, nodeLabelFull, useEnv, useMessages, useProjectId, useStateStore } from "@/lib/state";
+import { nodeLabelFull, useEnv, useMessages, useProjectId, useStateStore } from "@/lib/state";
 import { Button } from "./ui/button";
 import { useCallback, useEffect, useState } from "react";
-import { generateDodoConfig } from "@/lib/config";
+import { generateDodoConfig, AppNode } from "config";
 import { useNodes, useReactFlow } from "@xyflow/react";
 import { useToast } from "@/hooks/use-toast";
 import {
@@ -148,9 +148,9 @@
 			method: "GET",
 		});
 		const inst = await resp.json();
-		const { x = 0, y = 0, zoom = 1 } = inst.viewport;
-		store.setNodes(inst.nodes || []);
-		store.setEdges(inst.edges || []);
+		const { x = 0, y = 0, zoom = 1 } = inst.state.viewport;
+		store.setNodes(inst.state.nodes || []);
+		store.setEdges(inst.state.edges || []);
 		instance.setViewport({ x, y, zoom });
 	}, [projectId, instance, store]);
 	const clear = useCallback(() => {
diff --git a/apps/canvas/front/src/components/import-modal.tsx b/apps/canvas/front/src/components/import-modal.tsx
index ea9a06c..44f66d5 100644
--- a/apps/canvas/front/src/components/import-modal.tsx
+++ b/apps/canvas/front/src/components/import-modal.tsx
@@ -11,9 +11,6 @@
 	useGithubRepositoriesLoading,
 	useGithubRepositoriesError,
 	useFetchGithubRepositories,
-	serviceAnalyzisSchema,
-	ServiceType,
-	ServiceData,
 	useStateStore,
 } from "@/lib/state";
 import { Alert, AlertDescription } from "./ui/alert";
@@ -24,6 +21,7 @@
 import { Switch } from "./ui/switch";
 import { Label } from "./ui/label";
 import { useToast } from "@/hooks/use-toast";
+import { serviceAnalyzisSchema, ServiceType, ServiceData } from "config";
 
 const schema = z.object({
 	repositoryId: z.number().optional(),
diff --git a/apps/canvas/front/src/components/node-app.tsx b/apps/canvas/front/src/components/node-app.tsx
index d9eea6d..7eb632c 100644
--- a/apps/canvas/front/src/components/node-app.tsx
+++ b/apps/canvas/front/src/components/node-app.tsx
@@ -1,20 +1,7 @@
 import { v4 as uuidv4 } from "uuid";
 import { NodeRect } from "./node-rect";
-import {
-	useStateStore,
-	ServiceNode,
-	ServiceTypes,
-	nodeLabel,
-	BoundEnvVar,
-	AppState,
-	nodeIsConnectable,
-	GatewayTCPNode,
-	GatewayHttpsNode,
-	AppNode,
-	GithubNode,
-	useEnv,
-	useGithubRepositories,
-} from "@/lib/state";
+import { useStateStore, nodeLabel, AppState, nodeIsConnectable, useEnv, useGithubRepositories } from "@/lib/state";
+import { ServiceNode, ServiceTypes } from "config";
 import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from "react";
 import { z } from "zod";
 import { useForm, EventType, DeepPartial } from "react-hook-form";
diff --git a/apps/canvas/front/src/lib/config.ts b/apps/canvas/front/src/lib/config.ts
index 513f1f0..39db5b4 100644
--- a/apps/canvas/front/src/lib/config.ts
+++ b/apps/canvas/front/src/lib/config.ts
@@ -1,235 +1,5 @@
-import { AppNode, Env, Message, MessageType, NodeType, ServiceType, VolumeType } from "./state";
-
-export type AuthDisabled = {
-	enabled: false;
-};
-
-export type AuthEnabled = {
-	enabled: true;
-	groups: string[];
-	noAuthPathPatterns: string[];
-};
-
-export type Auth = AuthDisabled | AuthEnabled;
-
-export type Ingress = {
-	network: string;
-	subdomain: string;
-	port: { name: string } | { value: string };
-	auth: Auth;
-};
-
-export type Domain = {
-	network: string;
-	subdomain: string;
-};
-
-export type PortValue =
-	| {
-			name: string;
-	  }
-	| {
-			value: number;
-	  };
-
-export type PortDomain = Domain & {
-	port: PortValue;
-};
-
-export type Service = {
-	type: ServiceType;
-	name: string;
-	source: {
-		repository: string;
-		branch: string;
-		rootDir: string;
-	};
-	ports?: {
-		name: string;
-		value: number;
-		protocol: "TCP" | "UDP";
-	}[];
-	env?: {
-		name: string;
-		alias?: string;
-	}[];
-	ingress?: Ingress[];
-	expose?: PortDomain[];
-	volume?: string[];
-	preBuildCommands?: { bin: string }[];
-	dev?: {
-		enabled: boolean;
-		username?: string;
-		ssh?: Domain;
-		codeServer?: Domain;
-	};
-};
-
-export type Volume = {
-	name: string;
-	accessMode: VolumeType;
-	size: string;
-};
-
-export type PostgreSQL = {
-	name: string;
-	size: string;
-	expose?: Domain[];
-};
-
-export type MongoDB = {
-	name: string;
-	size: string;
-	expose?: Domain[];
-};
-
-export type Config = {
-	input: {
-		appId: string;
-		managerAddr: string;
-	};
-	service?: Service[];
-	volume?: Volume[];
-	postgresql?: PostgreSQL[];
-	mongodb?: MongoDB[];
-};
-
-export function generateDodoConfig(appId: string | undefined, nodes: AppNode[], env: Env): Config | null {
-	try {
-		if (appId == null || env.managerAddr == null) {
-			return null;
-		}
-		const networkMap = new Map(env.networks.map((n) => [n.domain, n.name]));
-		const ingressNodes = nodes
-			.filter((n) => n.type === "gateway-https")
-			.filter((n) => n.data.https !== undefined && !n.data.readonly);
-		const tcpNodes = nodes
-			.filter((n) => n.type === "gateway-tcp")
-			.filter((n) => n.data.exposed !== undefined && !n.data.readonly);
-		const findExpose = (n: AppNode): PortDomain[] => {
-			return n.data.ports
-				.map((p) => [n.id, p.id, p.name])
-				.flatMap((sp) => {
-					return tcpNodes.flatMap((i) =>
-						(i.data.exposed || [])
-							.filter((t) => t.serviceId === sp[0] && t.portId === sp[1])
-							.map(() => ({
-								network: networkMap.get(i.data.network!)!,
-								subdomain: i.data.subdomain!,
-								port: { name: sp[2] },
-							})),
-					);
-				});
-		};
-		return {
-			input: {
-				appId: appId,
-				managerAddr: env.managerAddr,
-			},
-			service: nodes
-				.filter((n) => n.type === "app")
-				.map((n): Service => {
-					return {
-						type: n.data.type,
-						name: n.data.label,
-						source: {
-							repository: nodes
-								.filter((i) => i.type === "github")
-								.find((i) => i.id === n.data.repository.id)!.data.repository!.sshURL,
-							branch: n.data.repository.branch,
-							rootDir: n.data.repository.rootDir,
-						},
-						ports: (n.data.ports || [])
-							.filter((p) => !n.data.dev?.enabled || (p.value != 22 && p.value != 9090))
-							.map((p) => ({
-								name: p.name,
-								value: p.value,
-								protocol: "TCP", // TODO(gio)
-							})),
-						env: (n.data.envVars || [])
-							.filter((e) => "name" in e)
-							.map((e) => ({
-								name: e.name,
-								alias: "alias" in e ? e.alias : undefined,
-							})),
-						ingress: ingressNodes
-							.filter((i) => i.data.https!.serviceId === n.id)
-							.map(
-								(i): Ingress => ({
-									network: networkMap.get(i.data.network!)!,
-									subdomain: i.data.subdomain!,
-									port: {
-										name: n.data.ports.find((p) => p.id === i.data.https!.portId)!.name,
-									},
-									auth:
-										i.data.auth?.enabled || false
-											? {
-													enabled: true,
-													groups: i.data.auth!.groups,
-													noAuthPathPatterns: i.data.auth!.noAuthPathPatterns,
-												}
-											: {
-													enabled: false,
-												},
-								}),
-							),
-						expose: findExpose(n),
-						preBuildCommands: n.data.preBuildCommands
-							? n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))
-							: [],
-						dev: {
-							enabled: n.data.dev ? n.data.dev.enabled : false,
-							username: n.data.dev && n.data.dev.enabled ? env.user.username : undefined,
-							codeServer:
-								n.data.dev?.enabled && n.data.dev.expose != null
-									? {
-											network: networkMap.get(n.data.dev.expose.network)!,
-											subdomain: n.data.dev.expose.subdomain,
-										}
-									: undefined,
-							ssh:
-								n.data.dev?.enabled && n.data.dev.expose != null
-									? {
-											network: networkMap.get(n.data.dev.expose.network)!,
-											subdomain: n.data.dev.expose.subdomain,
-										}
-									: undefined,
-						},
-					};
-				}),
-			volume: nodes
-				.filter((n) => n.type === "volume")
-				.map(
-					(n): Volume => ({
-						name: n.data.label,
-						accessMode: n.data.type,
-						size: n.data.size,
-					}),
-				),
-			postgresql: nodes
-				.filter((n) => n.type === "postgresql")
-				.map(
-					(n): PostgreSQL => ({
-						name: n.data.label,
-						size: "1Gi", // TODO(gio)
-						expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
-					}),
-				),
-			mongodb: nodes
-				.filter((n) => n.type === "mongodb")
-				.map(
-					(n): MongoDB => ({
-						name: n.data.label,
-						size: "1Gi", // TODO(gio)
-						expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
-					}),
-				),
-		};
-	} catch (e) {
-		console.log(e);
-		return null;
-	}
-}
+import { AppNode, NodeType } from "config";
+import { Message, MessageType } from "./state";
 
 export interface Validator {
 	(nodes: AppNode[]): Message[];
@@ -347,7 +117,7 @@
 				}) satisfies Message,
 		);
 	const noApp = git
-		.filter((n) => !nodes.some((i) => i.type === "app" && i.data?.repository?.id === n.id))
+		.filter((n) => !nodes.some((i) => i.type === "app" && i.data?.repository?.repoNodeId === n.id))
 		.map(
 			(n) =>
 				({
@@ -383,7 +153,7 @@
 			}),
 		);
 	const noSource = apps
-		.filter((n) => n.data == null || n.data.repository == null || n.data.repository.id === "")
+		.filter((n) => n.data == null || n.data.repository == null || n.data.repository.repoNodeId === "")
 		.map(
 			(n): Message => ({
 				id: `${n.id}-no-repo`,
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index a0e4e91..8ada938 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -1,7 +1,7 @@
 import { Category, defaultCategories } from "./categories";
 import { CreateValidators, Validator } from "./config";
 import { GitHubService, GitHubServiceImpl, GitHubRepository } from "./github";
-import type { Edge, Node, OnConnect, OnEdgesChange, OnNodesChange, Viewport as ReactFlowViewport } from "@xyflow/react";
+import type { Edge, OnConnect, OnEdgesChange, OnNodesChange, Viewport as ReactFlowViewport } from "@xyflow/react";
 import {
 	addEdge,
 	applyEdgeChanges,
@@ -13,217 +13,8 @@
 } from "@xyflow/react";
 import type { DeepPartial } from "react-hook-form";
 import { v4 as uuidv4 } from "uuid";
-import { z } from "zod";
 import { create } from "zustand";
-
-export const serviceAnalyzisSchema = z.object({
-	name: z.string(),
-	location: z.string(),
-	configVars: z.array(
-		z.object({
-			name: z.string(),
-			category: z.enum(["CommandLineFlag", "EnvironmentVariable"]),
-			type: z.optional(z.enum(["String", "Number", "Boolean"])),
-			semanticType: z.optional(
-				z.enum([
-					"EXPANDED_ENV_VAR",
-					"PORT",
-					"FILESYSTEM_PATH",
-					"DATABASE_URL",
-					"SQLITE_PATH",
-					"POSTGRES_URL",
-					"POSTGRES_PASSWORD",
-					"POSTGRES_USER",
-					"POSTGRES_DB",
-					"POSTGRES_PORT",
-					"POSTGRES_HOST",
-					"POSTGRES_SSL",
-					"MONGO_URL",
-					"MONGO_PASSWORD",
-					"MONGO_USER",
-					"MONGO_DB",
-					"MONGO_PORT",
-					"MONGO_HOST",
-					"MONGO_SSL",
-				]),
-			),
-		}),
-	),
-});
-
-export type InitData = {
-	label: string;
-	envVars: BoundEnvVar[];
-	ports: Port[];
-};
-
-export type NodeData = InitData & {
-	activeField?: string | undefined;
-	state?: string | null;
-};
-
-export type PortConnectedTo = {
-	serviceId: string;
-	portId: string;
-};
-
-export type NetworkData = NodeData & {
-	domain: string;
-};
-
-export type NetworkNode = Node<NetworkData> & {
-	type: "network";
-};
-
-export type GatewayHttpsData = NodeData & {
-	readonly?: boolean;
-	network?: string;
-	subdomain?: string;
-	https?: PortConnectedTo;
-	auth?: {
-		enabled: boolean;
-		groups: string[];
-		noAuthPathPatterns: string[];
-	};
-};
-
-export type GatewayHttpsNode = Node<GatewayHttpsData> & {
-	type: "gateway-https";
-};
-
-export type GatewayTCPData = NodeData & {
-	readonly?: boolean;
-	network?: string;
-	subdomain?: string;
-	exposed: PortConnectedTo[];
-	selected?: {
-		serviceId?: string;
-		portId?: string;
-	};
-};
-
-export type GatewayTCPNode = Node<GatewayTCPData> & {
-	type: "gateway-tcp";
-};
-
-export type Port = {
-	id: string;
-	name: string;
-	value: number;
-};
-
-export const ServiceTypes = [
-	"deno:2.2.0",
-	"golang:1.20.0",
-	"golang:1.22.0",
-	"golang:1.24.0",
-	"hugo:latest",
-	"php:8.2-apache",
-	"nextjs:deno-2.0.0",
-	"nodejs:23.1.0",
-	"nodejs:24.0.2",
-] as const;
-export type ServiceType = (typeof ServiceTypes)[number];
-
-export type Domain = {
-	network: string;
-	subdomain: string;
-};
-
-export type ServiceData = NodeData & {
-	type: ServiceType;
-	repository?:
-		| {
-				id: number;
-				repoNodeId: string;
-		  }
-		| {
-				id: number;
-				repoNodeId: string;
-				branch: string;
-		  }
-		| {
-				id: number;
-				repoNodeId: string;
-				branch: string;
-				rootDir: string;
-		  };
-	env: string[];
-	volume: string[];
-	preBuildCommands: string;
-	isChoosingPortToConnect: boolean;
-	dev?:
-		| {
-				enabled: false;
-				expose?: Domain;
-		  }
-		| {
-				enabled: true;
-				expose?: Domain;
-				codeServerNodeId: string;
-				sshNodeId: string;
-		  };
-	info?: z.infer<typeof serviceAnalyzisSchema>;
-};
-
-export type ServiceNode = Node<ServiceData> & {
-	type: "app";
-};
-
-export type VolumeType = "ReadWriteOnce" | "ReadOnlyMany" | "ReadWriteMany" | "ReadWriteOncePod";
-
-export type VolumeData = NodeData & {
-	type: VolumeType;
-	size: string;
-	attachedTo: string[];
-};
-
-export type VolumeNode = Node<VolumeData> & {
-	type: "volume";
-};
-
-export type PostgreSQLData = NodeData & {
-	volumeId: string;
-};
-
-export type PostgreSQLNode = Node<PostgreSQLData> & {
-	type: "postgresql";
-};
-
-export type MongoDBData = NodeData & {
-	volumeId: string;
-};
-
-export type MongoDBNode = Node<MongoDBData> & {
-	type: "mongodb";
-};
-
-export type GithubData = NodeData & {
-	repository?: {
-		id: number;
-		sshURL: string;
-		fullName: string;
-	};
-};
-
-export type GithubNode = Node<GithubData> & {
-	type: "github";
-};
-
-export type NANode = Node<NodeData> & {
-	type: undefined;
-};
-
-export type AppNode =
-	| NetworkNode
-	| GatewayHttpsNode
-	| GatewayTCPNode
-	| ServiceNode
-	| VolumeNode
-	| PostgreSQLNode
-	| MongoDBNode
-	| GithubNode
-	| NANode;
+import { AppNode, Env, NodeType, VolumeNode, GatewayTCPData, envSchema } from "config";
 
 export function nodeLabel(n: AppNode): string {
 	try {
@@ -319,38 +110,6 @@
 	}
 }
 
-export type BoundEnvVar =
-	| {
-			id: string;
-			source: string | null;
-	  }
-	| {
-			id: string;
-			source: string | null;
-			name: string;
-			isEditting: boolean;
-	  }
-	| {
-			id: string;
-			source: string | null;
-			name: string;
-			alias: string;
-			isEditting: boolean;
-	  }
-	| {
-			id: string;
-			source: string | null;
-			portId: string;
-			name: string;
-			alias: string;
-			isEditting: boolean;
-	  };
-
-export type EnvVar = {
-	name: string;
-	value: string;
-};
-
 export function nodeEnvVarNamePort(n: AppNode, portName: string): string {
 	return `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${portName.toUpperCase()}`;
 }
@@ -381,8 +140,6 @@
 	}
 }
 
-export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
-
 export type MessageType = "INFO" | "WARNING" | "FATAL";
 
 export type Message = {
@@ -395,100 +152,6 @@
 	onClick?: (state: AppState) => void;
 };
 
-export const accessSchema = z.discriminatedUnion("type", [
-	z.object({
-		type: z.literal("https"),
-		name: z.string(),
-		address: z.string(),
-	}),
-	z.object({
-		type: z.literal("ssh"),
-		name: z.string(),
-		host: z.string(),
-		port: z.number(),
-	}),
-	z.object({
-		type: z.literal("tcp"),
-		name: z.string(),
-		host: z.string(),
-		port: z.number(),
-	}),
-	z.object({
-		type: z.literal("udp"),
-		name: z.string(),
-		host: z.string(),
-		port: z.number(),
-	}),
-	z.object({
-		type: z.literal("postgresql"),
-		name: z.string(),
-		host: z.string(),
-		port: z.number(),
-		database: z.string(),
-		username: z.string(),
-		password: z.string(),
-	}),
-	z.object({
-		type: z.literal("mongodb"),
-		name: z.string(),
-		host: z.string(),
-		port: z.number(),
-		database: z.string(),
-		username: z.string(),
-		password: z.string(),
-	}),
-]);
-
-export const serviceInfoSchema = z.object({
-	name: z.string(),
-	workers: z.array(
-		z.object({
-			id: z.string(),
-			commit: z.optional(
-				z.object({
-					hash: z.string(),
-					message: z.string(),
-				}),
-			),
-			commands: z.optional(
-				z.array(
-					z.object({
-						command: z.string(),
-						state: z.string(),
-					}),
-				),
-			),
-		}),
-	),
-});
-
-export const envSchema = z.object({
-	managerAddr: z.optional(z.string().min(1)),
-	instanceId: z.optional(z.string().min(1)),
-	deployKeyPublic: z.optional(z.nullable(z.string().min(1))),
-	networks: z
-		.array(
-			z.object({
-				name: z.string().min(1),
-				domain: z.string().min(1),
-				hasAuth: z.boolean(),
-			}),
-		)
-		.default([]),
-	integrations: z.object({
-		github: z.boolean(),
-	}),
-	services: z.array(serviceInfoSchema),
-	user: z.object({
-		id: z.string(),
-		username: z.string(),
-	}),
-	access: z.array(accessSchema),
-});
-
-export type ServiceInfo = z.infer<typeof serviceInfoSchema>;
-export type Env = z.infer<typeof envSchema>;
-
 const defaultEnv: Env = {
 	managerAddr: undefined,
 	deployKeyPublic: undefined,
@@ -729,15 +392,15 @@
 			method: "GET",
 		});
 		const inst = await resp.json();
-		setN(inst.nodes);
-		set({ edges: inst.edges });
+		setN(inst.state.nodes);
+		set({ edges: inst.state.edges });
 		injectNetworkNodes();
 		if (
-			get().zoom.x !== inst.viewport.x ||
-			get().zoom.y !== inst.viewport.y ||
-			get().zoom.zoom !== inst.viewport.zoom
+			get().zoom.x !== inst.state.viewport.x ||
+			get().zoom.y !== inst.state.viewport.y ||
+			get().zoom.zoom !== inst.state.viewport.zoom
 		) {
-			set({ zoom: inst.viewport });
+			set({ zoom: inst.state.viewport });
 		}
 	};