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