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">
diff --git a/apps/canvas/front/src/components/XTerm.tsx b/apps/canvas/front/src/components/XTerm.tsx
new file mode 100644
index 0000000..4f542f1
--- /dev/null
+++ b/apps/canvas/front/src/components/XTerm.tsx
@@ -0,0 +1,98 @@
+import { useEffect, useRef } from "react";
+import { Terminal } from "xterm";
+import { FitAddon } from "xterm-addon-fit";
+import "xterm/css/xterm.css";
+
+interface XTermProps {
+	logs: string;
+}
+
+const scrollbarHack = `
+.xterm-viewport {
+	scrollbar-width: auto;
+	scrollbar-color: #a0a0a0 #f1f5f9;
+}
+.xterm-viewport::-webkit-scrollbar {
+	width: 8px;
+}
+.xterm-viewport::-webkit-scrollbar-track {
+	background: #f1f5f9;
+}
+.xterm-viewport::-webkit-scrollbar-thumb {
+	background-color: #a0a0a0;
+	border-radius: 4px;
+	border: 2px solid #f1f5f9;
+}
+`;
+
+export function XTerm({ logs }: XTermProps) {
+	const termRef = useRef<HTMLDivElement>(null);
+	const termInstance = useRef<Terminal | null>(null);
+	const fitAddon = useRef<FitAddon | null>(null);
+	const prevLogs = useRef<string>("");
+
+	useEffect(() => {
+		if (termRef.current && !termInstance.current) {
+			const term = new Terminal({
+				disableStdin: true,
+				convertEol: true,
+				fontFamily: `'JetBrains Mono', monospace`,
+				fontSize: 12,
+				theme: {
+					background: "#f1f5f9", // bg-muted color
+					foreground: "#020817", // text-foreground color
+					cursor: "#f1f5f9",
+					selectionBackground: "#bfdbfe", // blue-200
+					selectionInactiveBackground: "#e2e8f0", // slate-200
+				},
+			});
+			const addon = new FitAddon();
+			fitAddon.current = addon;
+			term.loadAddon(addon);
+			term.open(termRef.current);
+			addon.fit();
+			termInstance.current = term;
+
+			const resizeObserver = new ResizeObserver(() => {
+				fitAddon.current?.fit();
+			});
+			resizeObserver.observe(termRef.current);
+
+			return () => {
+				resizeObserver.disconnect();
+				term.dispose();
+			};
+		}
+	}, []);
+
+	useEffect(() => {
+		if (termInstance.current) {
+			if (logs === "") {
+				termInstance.current.clear();
+				prevLogs.current = "";
+				return;
+			}
+
+			const buffer = termInstance.current.buffer.active;
+			const wasAtBottom = buffer.viewportY + termInstance.current.rows >= buffer.length;
+
+			const newLogContent = logs.substring(prevLogs.current.length);
+			prevLogs.current = logs;
+
+			if (newLogContent) {
+				termInstance.current.write(newLogContent, () => {
+					if (wasAtBottom) {
+						termInstance.current?.scrollToBottom();
+					}
+				});
+			}
+		}
+	}, [logs]);
+
+	return (
+		<>
+			<style>{scrollbarHack}</style>
+			<div ref={termRef} className="h-full w-full" />
+		</>
+	);
+}