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