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>
+ );
+}