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">