Canvas: Rework monitoring page

Display worker statuses with list of commands
Commit hash

Change-Id: I7054ecc5ce81f35cad3fe26fc20677b6f50d3147
diff --git a/apps/canvas/front/src/Monitoring.tsx b/apps/canvas/front/src/Monitoring.tsx
new file mode 100644
index 0000000..b49a8d8
--- /dev/null
+++ b/apps/canvas/front/src/Monitoring.tsx
@@ -0,0 +1,305 @@
+import { useCallback, useEffect, useState, useRef, useMemo } from "react";
+import { useProjectId, useEnv } from "@/lib/state";
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { useToast } from "@/hooks/use-toast";
+import { LogsIcon } 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, "");
+}
+
+const WaitingIcon = () => (
+	<svg viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 mr-2 inline-block align-middle text-gray-500">
+		<circle cx="6" cy="12" r="2" />
+		<circle cx="12" cy="12" r="2" />
+		<circle cx="18" cy="12" r="2" />
+	</svg>
+);
+const RunningIcon = () => (
+	<svg
+		className="animate-spin w-4 h-4 mr-2 inline-block align-middle text-blue-500"
+		xmlns="http://www.w3.org/2000/svg"
+		fill="none"
+		viewBox="0 0 24 24"
+	>
+		<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
+		<path
+			className="opacity-75"
+			fill="currentColor"
+			d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+		></path>
+	</svg>
+);
+const SuccessIcon = () => (
+	<svg viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 mr-2 inline-block align-middle text-green-500">
+		<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
+	</svg>
+);
+const FailureIcon = () => (
+	<svg viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 mr-2 inline-block align-middle text-red-500">
+		<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
+	</svg>
+);
+
+export function Logs() {
+	const { toast } = useToast();
+	const projectId = useProjectId();
+	const env = useEnv();
+
+	const [selectedServiceForLogs, setSelectedServiceForLogs] = useState<string | null>(null);
+	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 {
+			setLogs("");
+		}
+	}, [selectedServiceForLogs, selectedWorkerIdForLogs, fetchLogs]);
+
+	useEffect(() => {
+		const pre = preRef.current;
+		if (!pre) return;
+		const handleScroll = () => checkIfAtBottom();
+		pre.addEventListener("scroll", handleScroll);
+		return () => pre.removeEventListener("scroll", handleScroll);
+	}, [checkIfAtBottom]);
+
+	useEffect(() => {
+		if (wasAtBottom.current) {
+			scrollToBottom();
+		}
+	}, [logs, scrollToBottom]);
+
+	const sortedServices = useMemo(
+		() => (env?.services ? [...env.services].sort((a, b) => a.name.localeCompare(b.name)) : []),
+		[env?.services],
+	);
+
+	useEffect(() => {
+		if (sortedServices.length > 0 && sortedServices[0].workers && sortedServices[0].workers.length > 0) {
+			// Only set if no logs are currently selected, to avoid overriding user interaction
+			if (!selectedServiceForLogs && !selectedWorkerIdForLogs) {
+				handleViewLogsClick(sortedServices[0].name, sortedServices[0].workers[0].id);
+			}
+		}
+		// eslint-disable-next-line react-hooks/exhaustive-deps
+	}, [sortedServices]);
+
+	const handleViewLogsClick = (serviceName: string, workerId: string) => {
+		setSelectedServiceForLogs(serviceName);
+		setSelectedWorkerIdForLogs(workerId);
+	};
+
+	const defaultExpandedServiceNames = useMemo(() => sortedServices.map((s) => s.name), [sortedServices]);
+	const defaultExpandedFirstWorkerId = useMemo(() => {
+		if (sortedServices.length > 0 && sortedServices[0].workers && sortedServices[0].workers.length > 0) {
+			return sortedServices[0].workers[0].id;
+		}
+		return undefined;
+	}, [sortedServices]);
+
+	return (
+		<ResizablePanelGroup direction="horizontal" className="h-full w-full">
+			<ResizablePanel defaultSize={15}>
+				<div className="flex flex-col h-full p-2 gap-2 overflow-y-auto">
+					{sortedServices.length > 0 ? (
+						<Accordion type="multiple" className="w-full" defaultValue={defaultExpandedServiceNames}>
+							{sortedServices.map((service, serviceIndex) => (
+								<AccordionItem value={service.name} key={service.name}>
+									<AccordionTrigger className="py-1">{service.name}</AccordionTrigger>
+									<AccordionContent className="pl-2">
+										{service.workers && service.workers.length > 0 ? (
+											<Accordion
+												type="single"
+												collapsible
+												className="w-full"
+												defaultValue={
+													serviceIndex === 0 ? defaultExpandedFirstWorkerId : undefined
+												}
+											>
+												{service.workers.map((worker) => (
+													<AccordionItem value={worker.id} key={worker.id}>
+														<AccordionTrigger className="py-1">
+															{worker.id}
+														</AccordionTrigger>
+														<AccordionContent className="pl-2">
+															<TooltipProvider>
+																<div className="text-sm">
+																	<Button
+																		onClick={() =>
+																			handleViewLogsClick(service.name, worker.id)
+																		}
+																		size="sm"
+																		variant="link"
+																		className="!px-0"
+																	>
+																		<LogsIcon className="w-4 h-4" />
+																		View Logs
+																	</Button>
+																	{!worker.repoOK && (
+																		<p className="flex items-center">
+																			<FailureIcon />
+																			Clone Repository
+																		</p>
+																	)}
+																	<p>
+																		Commit:
+																		{worker.repoOK && worker.commit && (
+																			<Tooltip>
+																				<TooltipTrigger asChild>
+																					<span className="inline-block">
+																						<Badge
+																							variant="outline"
+																							className="ml-1 font-mono"
+																						>
+																							{worker.commit.substring(
+																								0,
+																								8,
+																							)}
+																						</Badge>
+																					</span>
+																				</TooltipTrigger>
+																				<TooltipContent dir="right">
+																					<p>{worker.commit}</p>
+																				</TooltipContent>
+																			</Tooltip>
+																		)}
+																	</p>
+																	{worker.commands && worker.commands.length > 0 && (
+																		<div>
+																			Commands:
+																			<ul className="list-none pl-0 font-['JetBrains_Mono']">
+																				{worker.commands.map((cmd, index) => (
+																					<li
+																						key={index}
+																						className="rounded flex items-start"
+																					>
+																						<span className="inline-block w-6 flex-shrink-0">
+																							<CommandStateIcon
+																								state={cmd.state}
+																							/>
+																						</span>
+																						<span className="font-mono break-all">
+																							{cmd.command}
+																						</span>
+																					</li>
+																				))}
+																			</ul>
+																		</div>
+																	)}
+																	{(!worker.commands ||
+																		worker.commands.length === 0) && (
+																		<p className="text-xs text-gray-500">
+																			No commands for this worker.
+																		</p>
+																	)}
+																</div>
+															</TooltipProvider>
+														</AccordionContent>
+													</AccordionItem>
+												))}
+											</Accordion>
+										) : (
+											<p className="text-sm text-gray-500 p-2">
+												No workers found for this service.
+											</p>
+										)}
+									</AccordionContent>
+								</AccordionItem>
+							))}
+						</Accordion>
+					) : (
+						<div className="text-center text-gray-500 mt-4">No services available.</div>
+					)}
+				</div>
+			</ResizablePanel>
+			<ResizableHandle withHandle />
+			<ResizablePanel defaultSize={85}>
+				<div className="flex flex-col h-full">
+					{selectedServiceForLogs && selectedWorkerIdForLogs ? (
+						<>
+							<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 flex items-center justify-center h-full p-4 bg-muted text-sm text-gray-500">
+							Click 'View Logs' on a worker to display logs here.
+						</div>
+					)}
+				</div>
+			</ResizablePanel>
+		</ResizablePanelGroup>
+	);
+}
+
+function CommandStateIcon({ state }: { state: string | undefined }): JSX.Element | null {
+	switch (state?.toLowerCase()) {
+		case "running":
+			return <RunningIcon />;
+		case "success":
+			return <SuccessIcon />;
+		case "failure":
+			return <FailureIcon />;
+		case "waiting":
+			return <WaitingIcon />;
+		default:
+			return null;
+	}
+}