Canvas: Fix layout, get rid of scroll bars

Change-Id: I3244784ee741e93565190e538472723ffadfb754
diff --git a/apps/canvas/front/src/Logs.tsx b/apps/canvas/front/src/Logs.tsx
new file mode 100644
index 0000000..97718df
--- /dev/null
+++ b/apps/canvas/front/src/Logs.tsx
@@ -0,0 +1,127 @@
+import { useCallback, useEffect, useState, useRef, useMemo } from "react";
+import { useProjectId, useEnv } from "@/lib/state";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { useToast } from "@/hooks/use-toast";
+
+// 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, "");
+}
+
+export function Logs() {
+	const { toast } = useToast();
+	const projectId = useProjectId();
+	const env = useEnv();
+	const [selectedService, setSelectedService] = useState<string>("");
+	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 (service: string) => {
+			if (!projectId || !service) return;
+
+			try {
+				const resp = await fetch(`/api/project/${projectId}/logs/${service}`);
+				if (!resp.ok) {
+					throw new Error("Failed to fetch logs");
+				}
+				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 (selectedService) {
+			// Initial fetch
+			fetchLogs(selectedService);
+
+			// Set up interval for periodic updates
+			const interval = setInterval(() => {
+				fetchLogs(selectedService);
+			}, 5000);
+
+			// Cleanup interval on unmount or when service changes
+			return () => clearInterval(interval);
+		}
+	}, [selectedService, fetchLogs]);
+
+	// Handle scroll events
+	useEffect(() => {
+		const pre = preRef.current;
+		if (!pre) return;
+
+		const handleScroll = () => {
+			checkIfAtBottom();
+		};
+
+		pre.addEventListener("scroll", handleScroll);
+		return () => pre.removeEventListener("scroll", handleScroll);
+	}, [checkIfAtBottom]);
+
+	// Auto-scroll when new logs arrive
+	useEffect(() => {
+		if (wasAtBottom.current) {
+			scrollToBottom();
+		}
+	}, [logs, scrollToBottom]);
+
+	const sortedServices = useMemo(() => (env?.services ? [...env.services].sort() : []), [env]);
+
+	// Auto-select first service when services are available
+	useEffect(() => {
+		if (sortedServices.length && !selectedService) {
+			setSelectedService(sortedServices[0]);
+		}
+	}, [sortedServices, selectedService]);
+
+	return (
+		<div className="flex flex-col h-full">
+			<div className="flex-none w-full flex flex-row justify-start items-center gap-2 px-4 py-1">
+				<div>Service</div>
+				<Select value={selectedService} onValueChange={setSelectedService}>
+					<SelectTrigger className="w-1/4">
+						<SelectValue placeholder="Select a service" />
+					</SelectTrigger>
+					<SelectContent>
+						{sortedServices.map((service) => (
+							<SelectItem key={service} value={service}>
+								{service}
+							</SelectItem>
+						))}
+					</SelectContent>
+				</Select>
+			</div>
+			{selectedService && (
+				<pre
+					ref={preRef}
+					className="flex-1 h-full p-4 bg-muted rounded-lg overflow-auto font-['JetBrains_Mono'] whitespace-pre-wrap break-all"
+				>
+					{cleanAnsiEscapeSequences(logs) || "No logs available"}
+				</pre>
+			)}
+		</div>
+	);
+}