Canvas: Render logs using XTerm

Use Server Sent Events to stream logs.

Change-Id: I3790a22a39b71409636a81dbe2a2cc8bf4977cb4
diff --git a/apps/canvas/front/src/Monitoring.tsx b/apps/canvas/front/src/Monitoring.tsx
index 6f7446c..3bc0b63 100644
--- a/apps/canvas/front/src/Monitoring.tsx
+++ b/apps/canvas/front/src/Monitoring.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState, useRef, useMemo } from "react";
+import { useCallback, useEffect, useState, useMemo } from "react";
 import { useProjectId, useEnv } from "@/lib/state";
 import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
 import { Button } from "@/components/ui/button";
@@ -7,14 +7,7 @@
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
 import { useToast } from "@/hooks/use-toast";
 import { Check, Ellipsis, LoaderCircle, LogsIcon, X, RefreshCw } from "lucide-react";
-
-// ANSI escape sequence regex
-// eslint-disable-next-line no-control-regex
-const ANSI_ESCAPE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
-
-function cleanAnsiEscapeSequences(text: string): string {
-	return text.replace(ANSI_ESCAPE_REGEX, "");
-}
+import { XTerm } from "@/components/XTerm";
 
 export function Logs() {
 	const { toast } = useToast();
@@ -25,66 +18,44 @@
 	const [selectedWorkerIdForLogs, setSelectedWorkerIdForLogs] = useState<string | null>(null);
 
 	const [logs, setLogs] = useState<string>("");
-	const preRef = useRef<HTMLPreElement>(null);
-	const wasAtBottom = useRef(true);
-
-	const checkIfAtBottom = useCallback(() => {
-		if (!preRef.current) return;
-		const { scrollTop, scrollHeight, clientHeight } = preRef.current;
-		wasAtBottom.current = Math.abs(scrollHeight - clientHeight - scrollTop) < 10;
-	}, []);
-
-	const scrollToBottom = useCallback(() => {
-		if (!preRef.current) return;
-		preRef.current.scrollTop = preRef.current.scrollHeight;
-	}, []);
-
-	const fetchLogs = useCallback(
-		async (serviceName: string, workerId: string) => {
-			if (!projectId || !serviceName || !workerId) return;
-			try {
-				const resp = await fetch(`/api/project/${projectId}/logs/${serviceName}/${workerId}`);
-				if (!resp.ok) {
-					throw new Error(`Failed to fetch logs: ${resp.statusText}`);
-				}
-				const data = await resp.json();
-				setLogs(data.logs || "");
-			} catch (e) {
-				console.error(e);
-				toast({
-					variant: "destructive",
-					title: "Failed to fetch logs",
-				});
-			}
-		},
-		[projectId, toast],
-	);
 
 	useEffect(() => {
-		if (selectedServiceForLogs && selectedWorkerIdForLogs) {
-			fetchLogs(selectedServiceForLogs, selectedWorkerIdForLogs);
-			const interval = setInterval(() => {
-				fetchLogs(selectedServiceForLogs, selectedWorkerIdForLogs);
-			}, 5000);
-			return () => clearInterval(interval);
-		} else {
+		console.log("selectedServiceForLogs", selectedServiceForLogs);
+		if (!selectedServiceForLogs || !selectedWorkerIdForLogs || !projectId) {
 			setLogs("");
+			return;
 		}
-	}, [selectedServiceForLogs, selectedWorkerIdForLogs, fetchLogs]);
 
-	useEffect(() => {
-		const pre = preRef.current;
-		if (!pre) return;
-		const handleScroll = () => checkIfAtBottom();
-		pre.addEventListener("scroll", handleScroll);
-		return () => pre.removeEventListener("scroll", handleScroll);
-	}, [checkIfAtBottom]);
+		setLogs(""); // Clear logs for the new selection
 
-	useEffect(() => {
-		if (wasAtBottom.current) {
-			scrollToBottom();
-		}
-	}, [logs, scrollToBottom]);
+		const eventSource = new EventSource(
+			`/api/project/${projectId}/logs/${selectedServiceForLogs}/${selectedWorkerIdForLogs}`,
+		);
+
+		eventSource.onmessage = (event) => {
+			try {
+				const data = JSON.parse(event.data);
+				if (data.logs) {
+					setLogs((prevLogs) => prevLogs + data.logs + "\n");
+				}
+			} catch (e) {
+				console.error("Failed to parse log data:", e);
+			}
+		};
+
+		eventSource.onerror = () => {
+			toast({
+				variant: "destructive",
+				title: "Log stream error",
+				description: "Connection to the log stream has been closed.",
+			});
+			eventSource.close();
+		};
+
+		return () => {
+			eventSource.close();
+		};
+	}, [selectedServiceForLogs, selectedWorkerIdForLogs, projectId, toast]);
 
 	const sortedServices = useMemo(
 		() => (env?.services ? [...env.services].sort((a, b) => a.name.localeCompare(b.name)) : []),
@@ -285,13 +256,9 @@
 							<div className="p-2 border-b text-sm text-muted-foreground">
 								Logs for: {selectedServiceForLogs} / {selectedWorkerIdForLogs}
 							</div>
-							<pre
-								ref={preRef}
-								className="flex-1 h-full p-4 bg-muted overflow-auto font-['JetBrains_Mono'] text-xs whitespace-pre-wrap break-all"
-							>
-								{cleanAnsiEscapeSequences(logs) ||
-									`No logs available for ${selectedServiceForLogs} / ${selectedWorkerIdForLogs}.`}
-							</pre>
+							<div className="flex-1 h-full p-4 bg-muted overflow-auto">
+								<XTerm logs={logs} />
+							</div>
 						</>
 					) : (
 						<div className="flex-1 flex items-center justify-center h-full p-4 bg-muted text-sm text-gray-500">