Canvas: Rework Deployment/Gateways tab

Change-Id: I938262b9a6ba2af060531e7dcdf91ddd66721385
diff --git a/apps/canvas/back/prisma/migrations/20250517144829_access/migration.sql b/apps/canvas/back/prisma/migrations/20250517144829_access/migration.sql
new file mode 100644
index 0000000..0127521
--- /dev/null
+++ b/apps/canvas/back/prisma/migrations/20250517144829_access/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Project" ADD COLUMN "access" TEXT;
diff --git a/apps/canvas/back/prisma/schema.prisma b/apps/canvas/back/prisma/schema.prisma
index 09ddc69..68ff022 100644
--- a/apps/canvas/back/prisma/schema.prisma
+++ b/apps/canvas/back/prisma/schema.prisma
@@ -22,4 +22,5 @@
   instanceId String?
   deployKey String?
   githubToken String?
+  access String?
 }
\ No newline at end of file
diff --git a/apps/canvas/back/src/app_manager.ts b/apps/canvas/back/src/app_manager.ts
index 2f62ddd..803192c 100644
--- a/apps/canvas/back/src/app_manager.ts
+++ b/apps/canvas/back/src/app_manager.ts
@@ -1,9 +1,54 @@
 import axios from "axios";
 import { z } from "zod";
 
+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 DeployResponseSchema = z.object({
 	id: z.string(),
 	deployKey: z.string(),
+	access: z.array(accessSchema),
 });
 
 export type DeployResponse = z.infer<typeof DeployResponseSchema>;
@@ -24,6 +69,7 @@
 		if (response.status !== 200) {
 			throw new Error(`Failed to deploy application: ${response.statusText}`);
 		}
+		console.log(response.data);
 		const result = DeployResponseSchema.safeParse(response.data);
 		if (!result.success) {
 			throw new Error(`Invalid deploy response format: ${result.error.message}`);
@@ -31,13 +77,20 @@
 		return result.data;
 	}
 
-	async update(instanceId: string, config: unknown): Promise<boolean> {
+	async update(instanceId: string, config: unknown): Promise<DeployResponse> {
 		const response = await axios.request({
 			url: `${this.baseUrl}/api/dodo-app/${instanceId}`,
 			method: "put",
 			data: { config },
 		});
-		return response.status === 200;
+		if (response.status !== 200) {
+			throw new Error(`Failed to update application: ${response.statusText}`);
+		}
+		const result = DeployResponseSchema.safeParse(response.data);
+		if (!result.success) {
+			throw new Error(`Invalid update response format: ${result.error.message}`);
+		}
+		return result.data;
 	}
 
 	async getStatus(instanceId: string): Promise<unknown> {
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index 6dede14..4dc942f 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -261,29 +261,25 @@
 						draft: null,
 						instanceId: deployResponse.id,
 						deployKey: deployResponse.deployKey,
+						access: JSON.stringify(deployResponse.access),
 					},
 				});
 				diff = { toAdd: extractGithubRepos(state) };
 				deployKey = deployResponse.deployKey;
 			} else {
-				const success = await appManager.update(p.instanceId, req.body.config);
-				if (success) {
-					diff = calculateRepoDiff(extractGithubRepos(p.state), extractGithubRepos(state));
-					deployKey = p.deployKey;
-					await db.project.update({
-						where: {
-							id: projectId,
-						},
-						data: {
-							state,
-							draft: null,
-						},
-					});
-				} else {
-					resp.status(500);
-					resp.write(JSON.stringify({ error: "Failed to update deployment" }));
-					return;
-				}
+				const deployResponse = await appManager.update(p.instanceId, req.body.config);
+				diff = calculateRepoDiff(extractGithubRepos(p.state), extractGithubRepos(state));
+				deployKey = p.deployKey;
+				await db.project.update({
+					where: {
+						id: projectId,
+					},
+					data: {
+						state,
+						draft: null,
+						access: JSON.stringify(deployResponse.access),
+					},
+				});
 			}
 			if (diff && p.githubToken && deployKey) {
 				const github = new GithubClient(p.githubToken);
@@ -388,6 +384,7 @@
 			data: {
 				instanceId: null,
 				deployKey: null,
+				access: null,
 				state: null,
 				draft: p.draft ?? p.state,
 			},
@@ -465,6 +462,7 @@
 			select: {
 				deployKey: true,
 				githubToken: true,
+				access: true,
 			},
 		});
 		if (!project) {
@@ -480,6 +478,7 @@
 				// TODO(gio): get from env or command line flags
 				managerAddr: "http://10.42.0.95:8081",
 				deployKey: project.deployKey,
+				access: JSON.parse(project.access ?? "[]"),
 				integrations: {
 					github: !!project.githubToken,
 				},
diff --git a/apps/canvas/back/start.sh b/apps/canvas/back/start.sh
new file mode 100755
index 0000000..23bc786
--- /dev/null
+++ b/apps/canvas/back/start.sh
@@ -0,0 +1 @@
+DODO_PORT_WEB=8080 DODO_PORT_API=8081 npm run start
diff --git a/apps/canvas/front/package.json b/apps/canvas/front/package.json
index 55116cb..b25d17b 100644
--- a/apps/canvas/front/package.json
+++ b/apps/canvas/front/package.json
@@ -6,7 +6,7 @@
 	"scripts": {
 		"dev": "vite --port=$DODO_PORT_WEB --host",
 		"build": "vite build",
-		"format": "prettier --write src/**/*.{js,ts,jsx,tsx}",
+		"format": "prettier --write src/**/*.{js,ts,jsx,tsx} --list-different",
 		"format-check": "prettier --check src/**/*.{js,ts,jsx,tsx}",
 		"lint": "eslint .",
 		"preview": "vite preview --port=$DODO_PORT_WEB --host",
diff --git a/apps/canvas/front/src/Canvas.tsx b/apps/canvas/front/src/Canvas.tsx
index 3bdf00b..ae7e137 100644
--- a/apps/canvas/front/src/Canvas.tsx
+++ b/apps/canvas/front/src/Canvas.tsx
@@ -2,7 +2,7 @@
 import { Canvas } from "@/components/canvas";
 import { Details } from "@/components/details";
 import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./components/ui/resizable";
-import { Tools } from "./Tootls";
+import { Tools } from "./Tools";
 import { useStateStore } from "./lib/state";
 
 export function CanvasBuilder() {
diff --git a/apps/canvas/front/src/Deployment.tsx b/apps/canvas/front/src/Deployment.tsx
deleted file mode 100644
index 62eccf6..0000000
--- a/apps/canvas/front/src/Deployment.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { useNodes } from "@xyflow/react";
-import { AppNode, nodeLabel, ServiceNode } from "./lib/state";
-import { useMemo } from "react";
-import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "./components/ui/table";
-
-function ingress(nodes: AppNode[]) {
-	const nm = new Map(nodes.map((n) => [n.id, n]));
-	return nodes
-		.filter((n) => n.type === "gateway-https")
-		.map((i) => {
-			console.log(i.data);
-			if (!i.data || !i.data.network || !i.data.subdomain) {
-				return null;
-			}
-			if (!i.data.https || !i.data.https.serviceId || !i.data.https.portId) {
-				return null;
-			}
-			console.log("1231");
-			const svc = nm.get(i.data.https.serviceId)! as ServiceNode;
-			const port = svc.data.ports.find((p) => p.id === i.data.https!.portId)!;
-			console.log({ svc, port });
-			return {
-				id: `${i.id} - ${port.id}`,
-				service: svc,
-				port: port,
-				endpoint: `https://${i.data.subdomain}.${i.data.network}`,
-			};
-		})
-		.filter((i) => i != null);
-}
-
-export function Deployment() {
-	const nodes = useNodes<AppNode>();
-	const ing = useMemo(() => ingress(nodes), [nodes]);
-	return (
-		<>
-			<Table>
-				<TableCaption>HTTPS Gateways</TableCaption>
-				<TableHeader>
-					<TableRow>
-						<TableHead>Service</TableHead>
-						<TableHead>Port</TableHead>
-						<TableHead>Endpoint</TableHead>
-					</TableRow>
-				</TableHeader>
-				<TableBody>
-					{ing.map((i) => (
-						<TableRow>
-							<TableCell>{nodeLabel(i.service)}</TableCell>
-							<TableCell>{i.port.name}</TableCell>
-							<TableCell>
-								<a href={i.endpoint} target="_blank">
-									{i.endpoint}
-								</a>
-							</TableCell>
-						</TableRow>
-					))}
-				</TableBody>
-			</Table>
-		</>
-	);
-}
diff --git a/apps/canvas/front/src/Gateways.tsx b/apps/canvas/front/src/Gateways.tsx
new file mode 100644
index 0000000..a1fce28
--- /dev/null
+++ b/apps/canvas/front/src/Gateways.tsx
@@ -0,0 +1,93 @@
+import { z } from "zod";
+import { accessSchema, useEnv } from "./lib/state";
+import { Copy, Globe, Terminal, Network, Database, Check } from "lucide-react";
+import { Button } from "./components/ui/button";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./components/ui/tooltip";
+import { useCallback, useState } from "react";
+
+export function Gateways() {
+	const env = useEnv();
+	const groupedAccess = env.access.reduce((acc, curr) => {
+		if (!acc.has(curr.name)) {
+			acc.set(curr.name, []);
+		}
+		acc.get(curr.name)!.push(curr);
+		return acc;
+	}, new Map<string, typeof env.access>());
+	return (
+		<ul>
+			{Array.from(groupedAccess.entries()).map(([name, access]) => (
+				<li key={name}>
+					{access.map((a) => (
+						<Gateway g={a} />
+					))}
+				</li>
+			))}
+		</ul>
+	);
+}
+
+function Gateway({ g }: { g: z.infer<typeof accessSchema> }) {
+	const [hidden, content] = (() => {
+		switch (g.type) {
+			case "https":
+				return [g.address, g.address];
+			case "ssh":
+			case "tcp":
+			case "udp":
+				return [`${g.host}:${g.port}`, `${g.host}:${g.port}`];
+			case "postgresql":
+				return [
+					`postgresql://${g.username}:*****@${g.host}:${g.port}/${g.database}`,
+					`postgresql://${g.username}:${g.password}@${g.host}:${g.port}/${g.database}`,
+				];
+			case "mongodb":
+				return [
+					`mongodb://${g.username}:*****@${g.host}:${g.port}/${g.database}`,
+					`mongodb://${g.username}:${g.password}@${g.host}:${g.port}/${g.database}`,
+				];
+		}
+	})();
+	const [clicked, setClicked] = useState(false);
+	const [open, setOpen] = useState(false);
+	const copy = useCallback(() => {
+		navigator.clipboard.writeText(content);
+		setClicked(true);
+		setOpen(true);
+		setTimeout(() => {
+			setClicked(false);
+			setOpen(false);
+		}, 1000);
+	}, [content, setClicked, setOpen]);
+	return (
+		<TooltipProvider>
+			<Tooltip delayDuration={100} open={open} onOpenChange={setOpen}>
+				<TooltipTrigger asChild>
+					<Button variant="ghost" onClick={copy}>
+						<AccessType type={g.type} className="w-4 h-4" />
+						<div className="hover:bg-gray-200 p-x-1">{hidden}</div>
+					</Button>
+				</TooltipTrigger>
+				<TooltipContent side="right" className="!bg-transparent cursor-pointer !p-0" sideOffset={1}>
+					{!clicked && <Copy className="w-4 h-4 !bg-transparent" color="black" />}
+					{clicked && <Check className="w-4 h-4 !bg-transparent" color="black" />}
+				</TooltipContent>
+			</Tooltip>
+		</TooltipProvider>
+	);
+}
+
+function AccessType({ type, className }: { type: z.infer<typeof accessSchema>["type"]; className?: string }) {
+	switch (type) {
+		case "https":
+			return <Globe className={className} />;
+		case "ssh":
+			return <Terminal className={className} />;
+		case "tcp":
+		case "udp":
+			return <Network className={className} />;
+		case "postgresql":
+		case "mongodb":
+			return <Database className={className} />;
+	}
+}
diff --git a/apps/canvas/front/src/Tootls.tsx b/apps/canvas/front/src/Tools.tsx
similarity index 84%
rename from apps/canvas/front/src/Tootls.tsx
rename to apps/canvas/front/src/Tools.tsx
index b60e55c..c185d45 100644
--- a/apps/canvas/front/src/Tootls.tsx
+++ b/apps/canvas/front/src/Tools.tsx
@@ -1,6 +1,6 @@
 import { Badge } from "./components/ui/badge";
 import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs";
-import { Deployment } from "./Deployment";
+import { Gateways } from "./Gateways";
 import { useEnv, useMessages } from "./lib/state";
 import { Messages } from "./Messages";
 
@@ -14,15 +14,15 @@
 					<div>Messages</div>
 					<Badge>{messages.length}</Badge>
 				</TabsTrigger>
-				<TabsTrigger value="deployment">Deployment</TabsTrigger>
+				<TabsTrigger value="gateways">Gateways</TabsTrigger>
 				<TabsTrigger value="deployKeys">Deploy keys</TabsTrigger>
 			</TabsList>
 			<div className="!overflow-y-auto p-1">
 				<TabsContent value="messages">
 					<Messages />
 				</TabsContent>
-				<TabsContent value="deployment">
-					<Deployment />
+				<TabsContent value="gateways">
+					<Gateways />
 				</TabsContent>
 				<TabsContent value="deployKeys">{env && <>{env.deployKey}</>}</TabsContent>
 			</div>
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index aae7600..2f714f7 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -346,6 +346,50 @@
 	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 envSchema = z.object({
 	managerAddr: z.optional(z.string().min(1)),
 	deployKey: z.optional(z.nullable(z.string().min(1))),
@@ -365,6 +409,7 @@
 		id: z.string(),
 		username: z.string(),
 	}),
+	access: z.array(accessSchema),
 });
 
 export type Env = z.infer<typeof envSchema>;
@@ -381,6 +426,7 @@
 		id: "",
 		username: "",
 	},
+	access: [],
 };
 
 export type Project = {