| gio | a1efbad | 2025-05-21 07:16:45 +0000 | [diff] [blame^] | 1 | import { useCallback, useEffect, useState, useRef, useMemo } from "react"; |
| 2 | import { useProjectId, useEnv } from "@/lib/state"; |
| 3 | import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; |
| 4 | import { Button } from "@/components/ui/button"; |
| 5 | import { Badge } from "@/components/ui/badge"; |
| 6 | import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"; |
| 7 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; |
| 8 | import { useToast } from "@/hooks/use-toast"; |
| 9 | import { LogsIcon } from "lucide-react"; |
| 10 | |
| 11 | // ANSI escape sequence regex |
| 12 | // eslint-disable-next-line no-control-regex |
| 13 | const ANSI_ESCAPE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g; |
| 14 | |
| 15 | function cleanAnsiEscapeSequences(text: string): string { |
| 16 | return text.replace(ANSI_ESCAPE_REGEX, ""); |
| 17 | } |
| 18 | |
| 19 | const WaitingIcon = () => ( |
| 20 | <svg viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 mr-2 inline-block align-middle text-gray-500"> |
| 21 | <circle cx="6" cy="12" r="2" /> |
| 22 | <circle cx="12" cy="12" r="2" /> |
| 23 | <circle cx="18" cy="12" r="2" /> |
| 24 | </svg> |
| 25 | ); |
| 26 | const RunningIcon = () => ( |
| 27 | <svg |
| 28 | className="animate-spin w-4 h-4 mr-2 inline-block align-middle text-blue-500" |
| 29 | xmlns="http://www.w3.org/2000/svg" |
| 30 | fill="none" |
| 31 | viewBox="0 0 24 24" |
| 32 | > |
| 33 | <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> |
| 34 | <path |
| 35 | className="opacity-75" |
| 36 | fill="currentColor" |
| 37 | 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" |
| 38 | ></path> |
| 39 | </svg> |
| 40 | ); |
| 41 | const SuccessIcon = () => ( |
| 42 | <svg viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 mr-2 inline-block align-middle text-green-500"> |
| 43 | <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" /> |
| 44 | </svg> |
| 45 | ); |
| 46 | const FailureIcon = () => ( |
| 47 | <svg viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 mr-2 inline-block align-middle text-red-500"> |
| 48 | <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" /> |
| 49 | </svg> |
| 50 | ); |
| 51 | |
| 52 | export function Logs() { |
| 53 | const { toast } = useToast(); |
| 54 | const projectId = useProjectId(); |
| 55 | const env = useEnv(); |
| 56 | |
| 57 | const [selectedServiceForLogs, setSelectedServiceForLogs] = useState<string | null>(null); |
| 58 | const [selectedWorkerIdForLogs, setSelectedWorkerIdForLogs] = useState<string | null>(null); |
| 59 | |
| 60 | const [logs, setLogs] = useState<string>(""); |
| 61 | const preRef = useRef<HTMLPreElement>(null); |
| 62 | const wasAtBottom = useRef(true); |
| 63 | |
| 64 | const checkIfAtBottom = useCallback(() => { |
| 65 | if (!preRef.current) return; |
| 66 | const { scrollTop, scrollHeight, clientHeight } = preRef.current; |
| 67 | wasAtBottom.current = Math.abs(scrollHeight - clientHeight - scrollTop) < 10; |
| 68 | }, []); |
| 69 | |
| 70 | const scrollToBottom = useCallback(() => { |
| 71 | if (!preRef.current) return; |
| 72 | preRef.current.scrollTop = preRef.current.scrollHeight; |
| 73 | }, []); |
| 74 | |
| 75 | const fetchLogs = useCallback( |
| 76 | async (serviceName: string, workerId: string) => { |
| 77 | if (!projectId || !serviceName || !workerId) return; |
| 78 | try { |
| 79 | const resp = await fetch(`/api/project/${projectId}/logs/${serviceName}/${workerId}`); |
| 80 | if (!resp.ok) { |
| 81 | throw new Error(`Failed to fetch logs: ${resp.statusText}`); |
| 82 | } |
| 83 | const data = await resp.json(); |
| 84 | setLogs(data.logs || ""); |
| 85 | } catch (e) { |
| 86 | console.error(e); |
| 87 | toast({ |
| 88 | variant: "destructive", |
| 89 | title: "Failed to fetch logs", |
| 90 | }); |
| 91 | } |
| 92 | }, |
| 93 | [projectId, toast], |
| 94 | ); |
| 95 | |
| 96 | useEffect(() => { |
| 97 | if (selectedServiceForLogs && selectedWorkerIdForLogs) { |
| 98 | fetchLogs(selectedServiceForLogs, selectedWorkerIdForLogs); |
| 99 | const interval = setInterval(() => { |
| 100 | fetchLogs(selectedServiceForLogs, selectedWorkerIdForLogs); |
| 101 | }, 5000); |
| 102 | return () => clearInterval(interval); |
| 103 | } else { |
| 104 | setLogs(""); |
| 105 | } |
| 106 | }, [selectedServiceForLogs, selectedWorkerIdForLogs, fetchLogs]); |
| 107 | |
| 108 | useEffect(() => { |
| 109 | const pre = preRef.current; |
| 110 | if (!pre) return; |
| 111 | const handleScroll = () => checkIfAtBottom(); |
| 112 | pre.addEventListener("scroll", handleScroll); |
| 113 | return () => pre.removeEventListener("scroll", handleScroll); |
| 114 | }, [checkIfAtBottom]); |
| 115 | |
| 116 | useEffect(() => { |
| 117 | if (wasAtBottom.current) { |
| 118 | scrollToBottom(); |
| 119 | } |
| 120 | }, [logs, scrollToBottom]); |
| 121 | |
| 122 | const sortedServices = useMemo( |
| 123 | () => (env?.services ? [...env.services].sort((a, b) => a.name.localeCompare(b.name)) : []), |
| 124 | [env?.services], |
| 125 | ); |
| 126 | |
| 127 | useEffect(() => { |
| 128 | if (sortedServices.length > 0 && sortedServices[0].workers && sortedServices[0].workers.length > 0) { |
| 129 | // Only set if no logs are currently selected, to avoid overriding user interaction |
| 130 | if (!selectedServiceForLogs && !selectedWorkerIdForLogs) { |
| 131 | handleViewLogsClick(sortedServices[0].name, sortedServices[0].workers[0].id); |
| 132 | } |
| 133 | } |
| 134 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 135 | }, [sortedServices]); |
| 136 | |
| 137 | const handleViewLogsClick = (serviceName: string, workerId: string) => { |
| 138 | setSelectedServiceForLogs(serviceName); |
| 139 | setSelectedWorkerIdForLogs(workerId); |
| 140 | }; |
| 141 | |
| 142 | const defaultExpandedServiceNames = useMemo(() => sortedServices.map((s) => s.name), [sortedServices]); |
| 143 | const defaultExpandedFirstWorkerId = useMemo(() => { |
| 144 | if (sortedServices.length > 0 && sortedServices[0].workers && sortedServices[0].workers.length > 0) { |
| 145 | return sortedServices[0].workers[0].id; |
| 146 | } |
| 147 | return undefined; |
| 148 | }, [sortedServices]); |
| 149 | |
| 150 | return ( |
| 151 | <ResizablePanelGroup direction="horizontal" className="h-full w-full"> |
| 152 | <ResizablePanel defaultSize={15}> |
| 153 | <div className="flex flex-col h-full p-2 gap-2 overflow-y-auto"> |
| 154 | {sortedServices.length > 0 ? ( |
| 155 | <Accordion type="multiple" className="w-full" defaultValue={defaultExpandedServiceNames}> |
| 156 | {sortedServices.map((service, serviceIndex) => ( |
| 157 | <AccordionItem value={service.name} key={service.name}> |
| 158 | <AccordionTrigger className="py-1">{service.name}</AccordionTrigger> |
| 159 | <AccordionContent className="pl-2"> |
| 160 | {service.workers && service.workers.length > 0 ? ( |
| 161 | <Accordion |
| 162 | type="single" |
| 163 | collapsible |
| 164 | className="w-full" |
| 165 | defaultValue={ |
| 166 | serviceIndex === 0 ? defaultExpandedFirstWorkerId : undefined |
| 167 | } |
| 168 | > |
| 169 | {service.workers.map((worker) => ( |
| 170 | <AccordionItem value={worker.id} key={worker.id}> |
| 171 | <AccordionTrigger className="py-1"> |
| 172 | {worker.id} |
| 173 | </AccordionTrigger> |
| 174 | <AccordionContent className="pl-2"> |
| 175 | <TooltipProvider> |
| 176 | <div className="text-sm"> |
| 177 | <Button |
| 178 | onClick={() => |
| 179 | handleViewLogsClick(service.name, worker.id) |
| 180 | } |
| 181 | size="sm" |
| 182 | variant="link" |
| 183 | className="!px-0" |
| 184 | > |
| 185 | <LogsIcon className="w-4 h-4" /> |
| 186 | View Logs |
| 187 | </Button> |
| 188 | {!worker.repoOK && ( |
| 189 | <p className="flex items-center"> |
| 190 | <FailureIcon /> |
| 191 | Clone Repository |
| 192 | </p> |
| 193 | )} |
| 194 | <p> |
| 195 | Commit: |
| 196 | {worker.repoOK && worker.commit && ( |
| 197 | <Tooltip> |
| 198 | <TooltipTrigger asChild> |
| 199 | <span className="inline-block"> |
| 200 | <Badge |
| 201 | variant="outline" |
| 202 | className="ml-1 font-mono" |
| 203 | > |
| 204 | {worker.commit.substring( |
| 205 | 0, |
| 206 | 8, |
| 207 | )} |
| 208 | </Badge> |
| 209 | </span> |
| 210 | </TooltipTrigger> |
| 211 | <TooltipContent dir="right"> |
| 212 | <p>{worker.commit}</p> |
| 213 | </TooltipContent> |
| 214 | </Tooltip> |
| 215 | )} |
| 216 | </p> |
| 217 | {worker.commands && worker.commands.length > 0 && ( |
| 218 | <div> |
| 219 | Commands: |
| 220 | <ul className="list-none pl-0 font-['JetBrains_Mono']"> |
| 221 | {worker.commands.map((cmd, index) => ( |
| 222 | <li |
| 223 | key={index} |
| 224 | className="rounded flex items-start" |
| 225 | > |
| 226 | <span className="inline-block w-6 flex-shrink-0"> |
| 227 | <CommandStateIcon |
| 228 | state={cmd.state} |
| 229 | /> |
| 230 | </span> |
| 231 | <span className="font-mono break-all"> |
| 232 | {cmd.command} |
| 233 | </span> |
| 234 | </li> |
| 235 | ))} |
| 236 | </ul> |
| 237 | </div> |
| 238 | )} |
| 239 | {(!worker.commands || |
| 240 | worker.commands.length === 0) && ( |
| 241 | <p className="text-xs text-gray-500"> |
| 242 | No commands for this worker. |
| 243 | </p> |
| 244 | )} |
| 245 | </div> |
| 246 | </TooltipProvider> |
| 247 | </AccordionContent> |
| 248 | </AccordionItem> |
| 249 | ))} |
| 250 | </Accordion> |
| 251 | ) : ( |
| 252 | <p className="text-sm text-gray-500 p-2"> |
| 253 | No workers found for this service. |
| 254 | </p> |
| 255 | )} |
| 256 | </AccordionContent> |
| 257 | </AccordionItem> |
| 258 | ))} |
| 259 | </Accordion> |
| 260 | ) : ( |
| 261 | <div className="text-center text-gray-500 mt-4">No services available.</div> |
| 262 | )} |
| 263 | </div> |
| 264 | </ResizablePanel> |
| 265 | <ResizableHandle withHandle /> |
| 266 | <ResizablePanel defaultSize={85}> |
| 267 | <div className="flex flex-col h-full"> |
| 268 | {selectedServiceForLogs && selectedWorkerIdForLogs ? ( |
| 269 | <> |
| 270 | <div className="p-2 border-b text-sm text-muted-foreground"> |
| 271 | Logs for: {selectedServiceForLogs} / {selectedWorkerIdForLogs} |
| 272 | </div> |
| 273 | <pre |
| 274 | ref={preRef} |
| 275 | className="flex-1 h-full p-4 bg-muted overflow-auto font-['JetBrains_Mono'] text-xs whitespace-pre-wrap break-all" |
| 276 | > |
| 277 | {cleanAnsiEscapeSequences(logs) || |
| 278 | `No logs available for ${selectedServiceForLogs} / ${selectedWorkerIdForLogs}.`} |
| 279 | </pre> |
| 280 | </> |
| 281 | ) : ( |
| 282 | <div className="flex-1 flex items-center justify-center h-full p-4 bg-muted text-sm text-gray-500"> |
| 283 | Click 'View Logs' on a worker to display logs here. |
| 284 | </div> |
| 285 | )} |
| 286 | </div> |
| 287 | </ResizablePanel> |
| 288 | </ResizablePanelGroup> |
| 289 | ); |
| 290 | } |
| 291 | |
| 292 | function CommandStateIcon({ state }: { state: string | undefined }): JSX.Element | null { |
| 293 | switch (state?.toLowerCase()) { |
| 294 | case "running": |
| 295 | return <RunningIcon />; |
| 296 | case "success": |
| 297 | return <SuccessIcon />; |
| 298 | case "failure": |
| 299 | return <FailureIcon />; |
| 300 | case "waiting": |
| 301 | return <WaitingIcon />; |
| 302 | default: |
| 303 | return null; |
| 304 | } |
| 305 | } |