Canvas: Implement worker to manager communication

Register workers on manager side.
Let user force reload service workers.

Change-Id: I2635a04167e7c853151d8a1f5c3511646181a063
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index 65eab15..0a35dfe 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -3,9 +3,13 @@
 import { env } from "node:process";
 import axios from "axios";
 import { GithubClient } from "./github";
+import { z } from "zod";
 
 const db = new PrismaClient();
 
+// Map to store worker addresses by project ID
+const workers = new Map<number, string[]>();
+
 const handleProjectCreate: express.Handler = async (req, resp) => {
 	try {
 		const { id } = await db.project.create({
@@ -346,6 +350,8 @@
 		resp.status(200);
 		resp.write(
 			JSON.stringify({
+				// TODO(gio): get from env or command line flags
+				managerAddr: "http://10.42.0.239:8080",
 				deployKey: project.deployKey,
 				integrations: {
 					github: !!project.githubToken,
@@ -371,6 +377,88 @@
 	}
 };
 
+const WorkerSchema = z.object({
+	address: z.string().url(),
+});
+
+const handleRegisterWorker: express.Handler = async (req, resp) => {
+	try {
+		const projectId = Number(req.params["projectId"]);
+
+		const result = WorkerSchema.safeParse(req.body);
+		console.log(result);
+		if (!result.success) {
+			resp.status(400);
+			resp.write(
+				JSON.stringify({
+					error: "Invalid request data",
+					details: result.error.format(),
+				}),
+			);
+			return;
+		}
+
+		const { address } = result.data;
+
+		// Get existing workers or initialize empty array
+		const projectWorkers = workers.get(projectId) || [];
+
+		// Add new worker if not already present
+		if (!projectWorkers.includes(address)) {
+			projectWorkers.push(address);
+		}
+
+		workers.set(projectId, projectWorkers);
+
+		resp.status(200);
+		resp.write(
+			JSON.stringify({
+				success: true,
+				workers: projectWorkers,
+			}),
+		);
+	} catch (e) {
+		console.log(e);
+		resp.status(500);
+		resp.write(JSON.stringify({ error: "Failed to register worker" }));
+	} finally {
+		resp.end();
+	}
+};
+
+const handleReload: express.Handler = async (req, resp) => {
+	try {
+		const projectId = Number(req.params["projectId"]);
+		const projectWorkers = workers.get(projectId) || [];
+
+		if (projectWorkers.length === 0) {
+			resp.status(404);
+			resp.write(JSON.stringify({ error: "No workers registered for this project" }));
+			return;
+		}
+
+		await Promise.all(
+			projectWorkers.map(async (workerAddress) => {
+				try {
+					const updateEndpoint = `${workerAddress}/update`;
+					await axios.post(updateEndpoint);
+				} catch (error: any) {
+					console.log(`Failed to update worker ${workerAddress}: ${error.message || "Unknown error"}`);
+				}
+			}),
+		);
+
+		resp.status(200);
+		resp.write(JSON.stringify({ success: true }));
+	} catch (e) {
+		console.log(e);
+		resp.status(500);
+		resp.write(JSON.stringify({ error: "Failed to reload workers" }));
+	} finally {
+		resp.end();
+	}
+};
+
 async function start() {
 	await db.$connect();
 	const app = express();
@@ -385,6 +473,8 @@
 	app.get("/api/project/:projectId/repos/github", handleGithubRepos);
 	app.post("/api/project/:projectId/github-token", handleUpdateGithubToken);
 	app.get("/api/project/:projectId/env", handleEnv);
+	app.post("/api/project/:projectId/workers", handleRegisterWorker);
+	app.post("/api/project/:projectId/reload", handleReload);
 	app.use("/", express.static("../front/dist"));
 	app.listen(env.DODO_PORT_WEB, () => {
 		console.log("started");
diff --git a/apps/canvas/front/src/Config.tsx b/apps/canvas/front/src/Config.tsx
index d2f1aed..7306456 100644
--- a/apps/canvas/front/src/Config.tsx
+++ b/apps/canvas/front/src/Config.tsx
@@ -1,10 +1,11 @@
 import { useNodes } from "@xyflow/react";
-import { AppNode, useEnv } from "./lib/state";
+import { AppNode, useEnv, useProjectId } from "./lib/state";
 import { generateDodoConfig } from "./lib/config";
 import { useEffect, useMemo, useState } from "react";
 
 export function Config() {
 	const env = useEnv();
+	const projectId = useProjectId();
 	const [nodes, setNodes] = useState<AppNode[]>([]);
 	const n = useNodes<AppNode>();
 	useEffect(() => {
@@ -13,7 +14,7 @@
 			setNodes(n);
 		}
 	}, [n, setNodes]);
-	const config = useMemo(() => generateDodoConfig(nodes, env), [nodes, env]);
+	const config = useMemo(() => generateDodoConfig(projectId, nodes, env), [nodes, env]);
 	const configS = useMemo(() => JSON.stringify(config, undefined, 4), [config]);
 	return (
 		<div className="px-5">
diff --git a/apps/canvas/front/src/components/actions.tsx b/apps/canvas/front/src/components/actions.tsx
index eb89b8a..509b51c 100644
--- a/apps/canvas/front/src/components/actions.tsx
+++ b/apps/canvas/front/src/components/actions.tsx
@@ -25,6 +25,7 @@
 	const instance = useReactFlow();
 	const [ok, setOk] = useState(false);
 	const [loading, setLoading] = useState(false);
+	const [reloading, setReloading] = useState(false);
 	useEffect(() => {
 		setOk(!messages.some((m) => m.type === "FATAL"));
 	}, [messages, setOk]);
@@ -64,7 +65,7 @@
 		}
 		setLoading(true);
 		try {
-			const config = generateDodoConfig(nodes, env);
+			const config = generateDodoConfig(projectId, nodes, env);
 			if (config == null) {
 				throw new Error("MUST NOT REACH!");
 			}
@@ -163,21 +164,64 @@
 			});
 		}
 	}, [store, clear, projectId, toast]);
-	const [props, setProps] = useState({});
+	const reload = useCallback(async () => {
+		if (projectId == null) {
+			return;
+		}
+		setReloading(true);
+		try {
+			const resp = await fetch(`/api/project/${projectId}/reload`, {
+				method: "POST",
+				headers: {
+					"Content-Type": "application/json",
+				},
+			});
+			if (resp.ok) {
+				toast({
+					title: "Reload triggered successfully",
+				});
+			} else {
+				toast({
+					variant: "destructive",
+					title: "Reload failed",
+					description: await resp.text(),
+				});
+			}
+		} catch (e) {
+			console.log(e);
+			toast({
+				variant: "destructive",
+				title: "Reload failed",
+			});
+		} finally {
+			setReloading(false);
+		}
+	}, [projectId, toast]);
+	const [deployProps, setDeployProps] = useState({});
+	const [reloadProps, setReloadProps] = useState({});
 	useEffect(() => {
 		if (loading) {
-			setProps({ loading: true });
+			setDeployProps({ loading: true });
 		} else if (ok) {
-			setProps({ disabled: false });
+			setDeployProps({ disabled: false });
 		} else {
-			setProps({ disabled: true });
+			setDeployProps({ disabled: true });
 		}
-	}, [ok, loading, setProps]);
+
+		if (reloading) {
+			setReloadProps({ loading: true });
+		} else {
+			setReloadProps({ disabled: projectId === undefined });
+		}
+	}, [ok, loading, reloading, projectId]);
 	return (
 		<>
-			<Button onClick={deploy} {...props}>
+			<Button onClick={deploy} {...deployProps}>
 				Deploy
 			</Button>
+			<Button onClick={reload} {...reloadProps}>
+				Reload
+			</Button>
 			<Button onClick={save}>Save</Button>
 			<Button onClick={restoreSaved}>Restore</Button>
 			<Button onClick={clear} variant="destructive">
diff --git a/apps/canvas/front/src/lib/config.ts b/apps/canvas/front/src/lib/config.ts
index 187f51c..a3784c1 100644
--- a/apps/canvas/front/src/lib/config.ts
+++ b/apps/canvas/front/src/lib/config.ts
@@ -78,14 +78,21 @@
 };
 
 export type Config = {
+	input: {
+		appId: string;
+		managerAddr: string;
+	};
 	service?: Service[];
 	volume?: Volume[];
 	postgresql?: PostgreSQL[];
 	mongodb?: MongoDB[];
 };
 
-export function generateDodoConfig(nodes: AppNode[], env: Env): Config | null {
+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);
 		const tcpNodes = nodes.filter((n) => n.type === "gateway-tcp").filter((n) => n.data.exposed !== undefined);
@@ -105,6 +112,10 @@
 				});
 		};
 		return {
+			input: {
+				appId: appId,
+				managerAddr: env.managerAddr,
+			},
 			service: nodes
 				.filter((n) => n.type === "app")
 				.map((n): Service => {
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index f9c1f56..0b3507d 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -314,6 +314,7 @@
 };
 
 export const envSchema = z.object({
+	managerAddr: z.optional(z.string().min(1)),
 	deployKey: z.optional(z.string().min(1)),
 	networks: z
 		.array(
@@ -331,6 +332,7 @@
 export type Env = z.infer<typeof envSchema>;
 
 const defaultEnv: Env = {
+	managerAddr: undefined,
 	deployKey: undefined,
 	networks: [],
 	integrations: {