Canvas: Remove deployment action

Change-Id: I5887f130f5d11880271c943f58284d62f7d07a23
diff --git a/apps/canvas/back/prisma/migrations/20250515043851_state_string/migration.sql b/apps/canvas/back/prisma/migrations/20250515043851_state_string/migration.sql
new file mode 100644
index 0000000..b3b6605
--- /dev/null
+++ b/apps/canvas/back/prisma/migrations/20250515043851_state_string/migration.sql
@@ -0,0 +1,18 @@
+-- RedefineTables
+PRAGMA defer_foreign_keys=ON;
+PRAGMA foreign_keys=OFF;
+CREATE TABLE "new_Project" (
+    "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+    "userId" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    "state" TEXT,
+    "draft" TEXT,
+    "instanceId" TEXT,
+    "deployKey" TEXT,
+    "githubToken" TEXT
+);
+INSERT INTO "new_Project" ("deployKey", "draft", "githubToken", "id", "instanceId", "name", "state", "userId") SELECT "deployKey", "draft", "githubToken", "id", "instanceId", "name", "state", "userId" FROM "Project";
+DROP TABLE "Project";
+ALTER TABLE "new_Project" RENAME TO "Project";
+PRAGMA foreign_keys=ON;
+PRAGMA defer_foreign_keys=OFF;
diff --git a/apps/canvas/back/prisma/schema.prisma b/apps/canvas/back/prisma/schema.prisma
index 8dd7ceb..09ddc69 100644
--- a/apps/canvas/back/prisma/schema.prisma
+++ b/apps/canvas/back/prisma/schema.prisma
@@ -17,8 +17,8 @@
   id Int @id @default(autoincrement())
   userId String
   name String
-  state Bytes?
-  draft Bytes?
+  state String?
+  draft String?
   instanceId String?
   deployKey String?
   githubToken String?
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index 7c0fffe..cd8249d 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -68,7 +68,7 @@
 				userId: resp.locals.userId,
 			},
 			data: {
-				draft: Buffer.from(JSON.stringify(req.body)),
+				draft: JSON.stringify(req.body),
 			},
 		});
 		resp.status(200);
@@ -171,12 +171,12 @@
 	}
 };
 
-function extractGithubRepos(serializedState: Buffer | Uint8Array | null): string[] {
+function extractGithubRepos(serializedState: string | null): string[] {
 	if (!serializedState) {
 		return [];
 	}
 	try {
-		const stateObj = JSON.parse(serializedState.toString());
+		const stateObj = JSON.parse(serializedState);
 		const githubNodes = stateObj.nodes.filter(
 			// eslint-disable-next-line @typescript-eslint/no-explicit-any
 			(n: any) => n.type === "github" && n.data?.repository?.id,
@@ -222,7 +222,7 @@
 const handleDeploy: express.Handler = async (req, resp) => {
 	try {
 		const projectId = Number(req.params["projectId"]);
-		const state = Buffer.from(JSON.stringify(req.body.state));
+		const state = JSON.stringify(req.body.state);
 		const p = await db.project.findUnique({
 			where: {
 				id: projectId,
@@ -339,6 +339,70 @@
 	}
 };
 
+const handleRemoveDeployment: express.Handler = async (req, resp) => {
+	try {
+		const projectId = Number(req.params["projectId"]);
+		const p = await db.project.findUnique({
+			where: {
+				id: projectId,
+				userId: resp.locals.userId,
+			},
+			select: {
+				instanceId: true,
+				githubToken: true,
+				deployKey: true,
+				state: true,
+				draft: true,
+			},
+		});
+		if (p === null) {
+			resp.status(404);
+			resp.write(JSON.stringify({ error: "Project not found" }));
+			return;
+		}
+		if (p.instanceId == null) {
+			resp.status(400);
+			resp.write(JSON.stringify({ error: "Project not deployed" }));
+			return;
+		}
+		const removed = await appManager.removeInstance(p.instanceId);
+		if (!removed) {
+			resp.status(500);
+			resp.write(JSON.stringify({ error: "Failed to remove deployment from cluster" }));
+			return;
+		}
+		if (p.githubToken && p.deployKey && p.state) {
+			try {
+				const github = new GithubClient(p.githubToken);
+				const repos = extractGithubRepos(p.state);
+				const diff = { toDelete: repos, toAdd: [] };
+				await manageGithubKeys(github, p.deployKey, diff);
+			} catch (error) {
+				console.error("Error removing GitHub deploy keys:", error);
+			}
+		}
+		await db.project.update({
+			where: {
+				id: projectId,
+			},
+			data: {
+				instanceId: null,
+				deployKey: null,
+				state: null,
+				draft: p.draft ?? p.state,
+			},
+		});
+		resp.status(200);
+		resp.write(JSON.stringify({ success: true }));
+	} catch (e) {
+		console.error("Error removing deployment:", e);
+		resp.status(500);
+		resp.write(JSON.stringify({ error: "Internal server error" }));
+	} finally {
+		resp.end();
+	}
+};
+
 const handleGithubRepos: express.Handler = async (req, resp) => {
 	try {
 		const projectId = Number(req.params["projectId"]);
@@ -603,6 +667,7 @@
 	app.get("/api/project/:projectId/env", handleEnv);
 	app.post("/api/project/:projectId/reload", handleReload);
 	app.get("/api/project/:projectId/logs/:service", handleServiceLogs);
+	app.post("/api/project/:projectId/remove-deployment", handleRemoveDeployment);
 	app.use("/", express.static("../front/dist"));
 
 	const api = express();
diff --git a/apps/canvas/front/src/components/actions.tsx b/apps/canvas/front/src/components/actions.tsx
index de04e9e..ec0d28a 100644
--- a/apps/canvas/front/src/components/actions.tsx
+++ b/apps/canvas/front/src/components/actions.tsx
@@ -84,7 +84,7 @@
 					"Content-Type": "application/json",
 				},
 				body: JSON.stringify({
-					state: JSON.stringify(instance.toObject()),
+					state: instance.toObject(),
 					config,
 				}),
 			});
@@ -210,6 +210,44 @@
 			setReloading(false);
 		}
 	}, [projectId, toast]);
+	const removeDeployment = useCallback(async () => {
+		if (projectId == null) {
+			return;
+		}
+		if (!confirm("Are you sure you want to remove this deployment? This action cannot be undone.")) {
+			return;
+		}
+		setReloading(true);
+		try {
+			const resp = await fetch(`/api/project/${projectId}/remove-deployment`, {
+				method: "POST",
+				headers: {
+					"Content-Type": "application/json",
+				},
+			});
+			if (resp.ok) {
+				toast({
+					title: "Deployment removed successfully",
+				});
+				store.setMode("edit");
+			} else {
+				const errorData = await resp.json();
+				toast({
+					variant: "destructive",
+					title: "Failed to remove deployment",
+					description: errorData.error || "Unknown error",
+				});
+			}
+		} catch (e) {
+			console.log(e);
+			toast({
+				variant: "destructive",
+				title: "Failed to remove deployment",
+			});
+		} finally {
+			setReloading(false);
+		}
+	}, [projectId, toast, store]);
 	const [deployProps, setDeployProps] = useState({});
 	const [reloadProps, setReloadProps] = useState({});
 	useEffect(() => {
@@ -247,6 +285,13 @@
 								Reload Services
 							</DropdownMenuItem>
 							<DropdownMenuItem
+								onClick={removeDeployment}
+								disabled={projectId === undefined}
+								className="cursor-pointer hover:bg-gray-200"
+							>
+								Remove Deployment
+							</DropdownMenuItem>
+							<DropdownMenuItem
 								onClick={deleteProject}
 								disabled={projectId === undefined}
 								className="cursor-pointer hover:bg-gray-200"