blob: 6f7446c4dc46f2b3edc833292c98bcbf71bb29c4 [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";
gio918780d2025-05-22 08:24:41 +00009import { Check, Ellipsis, LoaderCircle, LogsIcon, X, RefreshCw } from "lucide-react";
gioa1efbad2025-05-21 07:16:45 +000010
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
gioa1efbad2025-05-21 07:16:45 +000019export 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) {
gioa1efbad2025-05-21 07:16:45 +000096 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
gio918780d2025-05-22 08:24:41 +0000108 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
gioa1efbad2025-05-21 07:16:45 +0000139 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>
gio918780d2025-05-22 08:24:41 +0000173 <div className="text-sm flex flex-col gap-1 items-start">
gioa1efbad2025-05-21 07:16:45 +0000174 <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>
gio918780d2025-05-22 08:24:41 +0000185 <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>
gio0afbaee2025-05-22 04:34:33 +0000199 {!worker.commit && (
gioa1efbad2025-05-21 07:16:45 +0000200 <p className="flex items-center">
201 <FailureIcon />
202 Clone Repository
203 </p>
204 )}
205 <p>
206 Commit:
gio0afbaee2025-05-22 04:34:33 +0000207 {worker.commit && (
gioa1efbad2025-05-21 07:16:45 +0000208 <Tooltip>
209 <TooltipTrigger asChild>
210 <span className="inline-block">
211 <Badge
212 variant="outline"
213 className="ml-1 font-mono"
214 >
gio0afbaee2025-05-22 04:34:33 +0000215 {worker.commit.hash.substring(
gioa1efbad2025-05-21 07:16:45 +0000216 0,
217 8,
218 )}
219 </Badge>
220 </span>
221 </TooltipTrigger>
gio0afbaee2025-05-22 04:34:33 +0000222 <TooltipContent
223 side="right"
224 className="flex flex-col gap-1"
225 >
226 <p>{worker.commit.message}</p>
227 <p>{worker.commit.hash}</p>
gioa1efbad2025-05-21 07:16:45 +0000228 </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
gio918780d2025-05-22 08:24:41 +0000307function WaitingIcon(): JSX.Element {
308 return <Ellipsis className="w-4 h-4 mr-2 inline-block align-middle" />;
309}
310function RunningIcon(): JSX.Element {
311 return <LoaderCircle className="animate-spin w-4 h-4 mr-2 inline-block align-middle" />;
312}
313
314function SuccessIcon(): JSX.Element {
315 return <Check className="w-4 h-4 mr-2 inline-block align-middle" />;
316}
317
318function FailureIcon(): JSX.Element {
319 return <X className="w-4 h-4 mr-2 inline-block align-middle" />;
320}
321
gioa1efbad2025-05-21 07:16:45 +0000322function 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}