blob: b49a8d8636bcdd14ba7876337ebf3f8f076d4a1a [file] [log] [blame]
gioa1efbad2025-05-21 07:16:45 +00001import { useCallback, useEffect, useState, useRef, useMemo } from "react";
2import { useProjectId, useEnv } from "@/lib/state";
3import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
4import { Button } from "@/components/ui/button";
5import { Badge } from "@/components/ui/badge";
6import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
7import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
8import { useToast } from "@/hooks/use-toast";
9import { LogsIcon } from "lucide-react";
10
11// ANSI escape sequence regex
12// eslint-disable-next-line no-control-regex
13const ANSI_ESCAPE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
14
15function cleanAnsiEscapeSequences(text: string): string {
16 return text.replace(ANSI_ESCAPE_REGEX, "");
17}
18
19const 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);
26const 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);
41const 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);
46const 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
52export 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
292function 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}