Canvas: Add Reload button on Monitoring page

Change-Id: I593d9068870bcd5f0d43680af4a08d814a18a2a9
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index de18582..cfd8955 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -662,6 +662,27 @@
 	}
 };
 
+const handleReloadWorker: express.Handler = async (req, resp) => {
+	const projectId = Number(req.params["projectId"]);
+	const serviceName = req.params["serviceName"];
+	const workerId = req.params["workerId"];
+
+	const projectMonitor = projectMonitors.get(projectId);
+	if (!projectMonitor) {
+		resp.status(404).send({ error: "Project monitor not found" });
+		return;
+	}
+
+	try {
+		await projectMonitor.reloadWorker(serviceName, workerId);
+		resp.status(200).send({ message: "Worker reload initiated" });
+	} catch (error) {
+		console.error(`Failed to reload worker ${workerId} in service ${serviceName} for project ${projectId}:`, error);
+		const errorMessage = error instanceof Error ? error.message : "Unknown error";
+		resp.status(500).send({ error: `Failed to reload worker: ${errorMessage}` });
+	}
+};
+
 const auth = (req: express.Request, resp: express.Response, next: express.NextFunction) => {
 	const userId = req.get("x-forwarded-userid");
 	const username = req.get("x-forwarded-user");
@@ -750,11 +771,13 @@
 	projectRouter.get("/:projectId/repos/github", handleGithubRepos);
 	projectRouter.post("/:projectId/github-token", handleUpdateGithubToken);
 	projectRouter.get("/:projectId/env", handleEnv);
+	projectRouter.post("/:projectId/reload/:serviceName/:workerId", handleReloadWorker);
 	projectRouter.post("/:projectId/reload", handleReload);
 	projectRouter.get("/:projectId/logs/:service/:workerId", handleServiceLogs);
 	projectRouter.post("/:projectId/remove-deployment", handleRemoveDeployment);
 	projectRouter.get("/", handleProjectAll);
 	projectRouter.post("/", handleProjectCreate);
+
 	app.use("/api/project", projectRouter); // Mount the authenticated router
 
 	app.use("/", express.static("../front/dist"));
diff --git a/apps/canvas/back/src/project_monitor.ts b/apps/canvas/back/src/project_monitor.ts
index c0ea56c..b494854 100644
--- a/apps/canvas/back/src/project_monitor.ts
+++ b/apps/canvas/back/src/project_monitor.ts
@@ -75,6 +75,25 @@
 	hasLogs(): boolean {
 		return this.logs.size > 0;
 	}
+
+	async reloadWorker(workerId: string): Promise<void> {
+		const workerAddress = this.workers.get(workerId);
+		if (!workerAddress) {
+			throw new Error(`Worker ${workerId} not found in service ${this.serviceName}`);
+		}
+		try {
+			const response = await fetch(`${workerAddress}/update`, { method: "POST" });
+			if (!response.ok) {
+				throw new Error(
+					`Failed to trigger reload for worker ${workerId} at ${workerAddress}: ${response.statusText}`,
+				);
+			}
+			console.log(`Reload triggered for worker ${workerId} in service ${this.serviceName}`);
+		} catch (error) {
+			console.error(`Error reloading worker ${workerId} in service ${this.serviceName}:`, error);
+			throw error; // Re-throw to be caught by ProjectMonitor
+		}
+	}
 }
 
 export class ProjectMonitor {
@@ -131,4 +150,12 @@
 		}
 		return new Map();
 	}
+
+	async reloadWorker(serviceName: string, workerId: string): Promise<void> {
+		const serviceMonitor = this.serviceMonitors.get(serviceName);
+		if (!serviceMonitor) {
+			throw new Error(`Service ${serviceName} not found`);
+		}
+		await serviceMonitor.reloadWorker(workerId);
+	}
 }
diff --git a/apps/canvas/front/src/Monitoring.tsx b/apps/canvas/front/src/Monitoring.tsx
index 659d1a1..6f7446c 100644
--- a/apps/canvas/front/src/Monitoring.tsx
+++ b/apps/canvas/front/src/Monitoring.tsx
@@ -6,7 +6,7 @@
 import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
 import { useToast } from "@/hooks/use-toast";
-import { LogsIcon } from "lucide-react";
+import { Check, Ellipsis, LoaderCircle, LogsIcon, X, RefreshCw } from "lucide-react";
 
 // ANSI escape sequence regex
 // eslint-disable-next-line no-control-regex
@@ -16,39 +16,6 @@
 	return text.replace(ANSI_ESCAPE_REGEX, "");
 }
 
-const WaitingIcon = () => (
-	<svg viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 mr-2 inline-block align-middle text-gray-500">
-		<circle cx="6" cy="12" r="2" />
-		<circle cx="12" cy="12" r="2" />
-		<circle cx="18" cy="12" r="2" />
-	</svg>
-);
-const RunningIcon = () => (
-	<svg
-		className="animate-spin w-4 h-4 mr-2 inline-block align-middle text-blue-500"
-		xmlns="http://www.w3.org/2000/svg"
-		fill="none"
-		viewBox="0 0 24 24"
-	>
-		<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
-		<path
-			className="opacity-75"
-			fill="currentColor"
-			d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
-		></path>
-	</svg>
-);
-const SuccessIcon = () => (
-	<svg viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 mr-2 inline-block align-middle text-green-500">
-		<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
-	</svg>
-);
-const FailureIcon = () => (
-	<svg viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 mr-2 inline-block align-middle text-red-500">
-		<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
-	</svg>
-);
-
 export function Logs() {
 	const { toast } = useToast();
 	const projectId = useProjectId();
@@ -126,7 +93,6 @@
 
 	useEffect(() => {
 		if (sortedServices.length > 0 && sortedServices[0].workers && sortedServices[0].workers.length > 0) {
-			// Only set if no logs are currently selected, to avoid overriding user interaction
 			if (!selectedServiceForLogs && !selectedWorkerIdForLogs) {
 				handleViewLogsClick(sortedServices[0].name, sortedServices[0].workers[0].id);
 			}
@@ -139,6 +105,37 @@
 		setSelectedWorkerIdForLogs(workerId);
 	};
 
+	const handleReloadWorkerClick = useCallback(
+		(serviceName: string, workerId: string) => {
+			if (!projectId) return;
+			toast({
+				title: "Worker reload initiated",
+				description: `Worker ${serviceName} / ${workerId} is reloading.`,
+			});
+			fetch(`/api/project/${projectId}/reload/${serviceName}/${workerId}`, {
+				method: "POST",
+			})
+				.then((resp) => {
+					if (!resp.ok) {
+						throw new Error(`Failed to reload worker: ${resp.statusText}`);
+					}
+					toast({
+						title: "Worker reloaded",
+						description: `Successfully reloaded worker ${serviceName} / ${workerId}`,
+					});
+				})
+				.catch((e) => {
+					console.error(e);
+					toast({
+						variant: "destructive",
+						title: "Failed to reload worker",
+						description: `Failed to reload worker ${serviceName} / ${workerId} in service`,
+					});
+				});
+		},
+		[projectId, toast],
+	);
+
 	const defaultExpandedServiceNames = useMemo(() => sortedServices.map((s) => s.name), [sortedServices]);
 	const defaultExpandedFirstWorkerId = useMemo(() => {
 		if (sortedServices.length > 0 && sortedServices[0].workers && sortedServices[0].workers.length > 0) {
@@ -173,7 +170,7 @@
 														</AccordionTrigger>
 														<AccordionContent className="pl-2">
 															<TooltipProvider>
-																<div className="text-sm">
+																<div className="text-sm flex flex-col gap-1 items-start">
 																	<Button
 																		onClick={() =>
 																			handleViewLogsClick(service.name, worker.id)
@@ -185,6 +182,20 @@
 																		<LogsIcon className="w-4 h-4" />
 																		View Logs
 																	</Button>
+																	<Button
+																		onClick={() =>
+																			handleReloadWorkerClick(
+																				service.name,
+																				worker.id,
+																			)
+																		}
+																		size="sm"
+																		variant="link"
+																		className="!px-0"
+																	>
+																		<RefreshCw className="w-4 h-4" />
+																		Reload Worker
+																	</Button>
 																	{!worker.commit && (
 																		<p className="flex items-center">
 																			<FailureIcon />
@@ -293,6 +304,21 @@
 	);
 }
 
+function WaitingIcon(): JSX.Element {
+	return <Ellipsis className="w-4 h-4 mr-2 inline-block align-middle" />;
+}
+function RunningIcon(): JSX.Element {
+	return <LoaderCircle className="animate-spin w-4 h-4 mr-2 inline-block align-middle" />;
+}
+
+function SuccessIcon(): JSX.Element {
+	return <Check className="w-4 h-4 mr-2 inline-block align-middle" />;
+}
+
+function FailureIcon(): JSX.Element {
+	return <X className="w-4 h-4 mr-2 inline-block align-middle" />;
+}
+
 function CommandStateIcon({ state }: { state: string | undefined }): JSX.Element | null {
 	switch (state?.toLowerCase()) {
 		case "running":
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index df658af..c86199f 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -476,6 +476,8 @@
 	height: number;
 };
 
+let refreshEnvIntervalId: number | null = null;
+
 export type AppState = {
 	projectId: string | undefined;
 	mode: "edit" | "deploy";
@@ -577,6 +579,54 @@
 		});
 	};
 
+	const startRefreshEnvInterval = () => {
+		if (refreshEnvIntervalId) {
+			clearInterval(refreshEnvIntervalId);
+		}
+		if (get().projectId && typeof document !== "undefined" && document.visibilityState === "visible") {
+			console.log("Starting refreshEnv interval for project:", get().projectId);
+			refreshEnvIntervalId = setInterval(async () => {
+				if (get().projectId && typeof document !== "undefined" && document.visibilityState === "visible") {
+					console.log("Interval: Calling refreshEnv for project:", get().projectId);
+					await get().refreshEnv();
+				} else if (refreshEnvIntervalId) {
+					console.log(
+						"Interval: Conditions not met (project removed or tab hidden), stopping interval from inside.",
+					);
+					clearInterval(refreshEnvIntervalId);
+					refreshEnvIntervalId = null;
+				}
+			}, 5000) as unknown as number;
+		} else {
+			console.log(
+				"Not starting refreshEnv interval. Project ID:",
+				get().projectId,
+				"Visibility:",
+				typeof document !== "undefined" ? document.visibilityState : "SSR",
+			);
+		}
+	};
+
+	const stopRefreshEnvInterval = () => {
+		if (refreshEnvIntervalId) {
+			console.log("Stopping refreshEnv interval for project:", get().projectId);
+			clearInterval(refreshEnvIntervalId);
+			refreshEnvIntervalId = null;
+		}
+	};
+
+	if (typeof document !== "undefined") {
+		document.addEventListener("visibilitychange", () => {
+			if (document.visibilityState === "visible") {
+				console.log("Tab became visible, attempting to start refreshEnv interval.");
+				startRefreshEnvInterval();
+			} else {
+				console.log("Tab became hidden, stopping refreshEnv interval.");
+				stopRefreshEnvInterval();
+			}
+		});
+	}
+
 	const injectNetworkNodes = () => {
 		const newNetworks = get().env.networks.filter(
 			(x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
@@ -896,7 +946,6 @@
 		refreshEnv: async () => {
 			const projectId = get().projectId;
 			let env: Env = defaultEnv;
-
 			try {
 				if (projectId) {
 					const response = await fetch(`/api/project/${projectId}/env`);
@@ -916,7 +965,6 @@
 				if (JSON.stringify(get().env) !== JSON.stringify(env)) {
 					set({ env });
 					injectNetworkNodes();
-
 					if (env.integrations.github) {
 						set({ githubService: new GitHubServiceImpl(projectId!) });
 					} else {
@@ -929,9 +977,11 @@
 			set({ mode });
 		},
 		setProject: async (projectId) => {
-			if (projectId === get().projectId) {
+			const currentProjectId = get().projectId;
+			if (projectId === currentProjectId) {
 				return;
 			}
+			stopRefreshEnvInterval();
 			set({
 				projectId,
 			});
@@ -943,10 +993,13 @@
 					set({ mode: "edit" });
 				}
 				restoreSaved();
+				startRefreshEnvInterval();
 			} else {
 				set({
 					nodes: [],
 					edges: [],
+					env: defaultEnv,
+					githubService: null,
 				});
 			}
 		},