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