blob: 3bc0b630872dda2ecb1d28e427759ffdf696dd16 [file] [log] [blame]
gio78a22882025-07-01 18:56:01 +00001import { useCallback, useEffect, useState, useMemo } from "react";
gioa1efbad2025-05-21 07:16:45 +00002import { 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";
gio78a22882025-07-01 18:56:01 +000010import { XTerm } from "@/components/XTerm";
gioa1efbad2025-05-21 07:16:45 +000011
gioa1efbad2025-05-21 07:16:45 +000012export 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>("");
gioa1efbad2025-05-21 07:16:45 +000021
22 useEffect(() => {
gio78a22882025-07-01 18:56:01 +000023 console.log("selectedServiceForLogs", selectedServiceForLogs);
24 if (!selectedServiceForLogs || !selectedWorkerIdForLogs || !projectId) {
gioa1efbad2025-05-21 07:16:45 +000025 setLogs("");
gio78a22882025-07-01 18:56:01 +000026 return;
gioa1efbad2025-05-21 07:16:45 +000027 }
gioa1efbad2025-05-21 07:16:45 +000028
gio78a22882025-07-01 18:56:01 +000029 setLogs(""); // Clear logs for the new selection
gioa1efbad2025-05-21 07:16:45 +000030
gio78a22882025-07-01 18:56:01 +000031 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]);
gioa1efbad2025-05-21 07:16:45 +000059
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) {
gioa1efbad2025-05-21 07:16:45 +000067 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
gio918780d2025-05-22 08:24:41 +000079 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
gioa1efbad2025-05-21 07:16:45 +0000110 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>
gio918780d2025-05-22 08:24:41 +0000144 <div className="text-sm flex flex-col gap-1 items-start">
gioa1efbad2025-05-21 07:16:45 +0000145 <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>
gio918780d2025-05-22 08:24:41 +0000156 <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>
gio0afbaee2025-05-22 04:34:33 +0000170 {!worker.commit && (
gioa1efbad2025-05-21 07:16:45 +0000171 <p className="flex items-center">
172 <FailureIcon />
173 Clone Repository
174 </p>
175 )}
176 <p>
177 Commit:
gio0afbaee2025-05-22 04:34:33 +0000178 {worker.commit && (
gioa1efbad2025-05-21 07:16:45 +0000179 <Tooltip>
180 <TooltipTrigger asChild>
181 <span className="inline-block">
182 <Badge
183 variant="outline"
184 className="ml-1 font-mono"
185 >
gio0afbaee2025-05-22 04:34:33 +0000186 {worker.commit.hash.substring(
gioa1efbad2025-05-21 07:16:45 +0000187 0,
188 8,
189 )}
190 </Badge>
191 </span>
192 </TooltipTrigger>
gio0afbaee2025-05-22 04:34:33 +0000193 <TooltipContent
194 side="right"
195 className="flex flex-col gap-1"
196 >
197 <p>{worker.commit.message}</p>
198 <p>{worker.commit.hash}</p>
gioa1efbad2025-05-21 07:16:45 +0000199 </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>
gio78a22882025-07-01 18:56:01 +0000259 <div className="flex-1 h-full p-4 bg-muted overflow-auto">
260 <XTerm logs={logs} />
261 </div>
gioa1efbad2025-05-21 07:16:45 +0000262 </>
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
gio918780d2025-05-22 08:24:41 +0000274function WaitingIcon(): JSX.Element {
275 return <Ellipsis className="w-4 h-4 mr-2 inline-block align-middle" />;
276}
277function RunningIcon(): JSX.Element {
278 return <LoaderCircle className="animate-spin w-4 h-4 mr-2 inline-block align-middle" />;
279}
280
281function SuccessIcon(): JSX.Element {
282 return <Check className="w-4 h-4 mr-2 inline-block align-middle" />;
283}
284
285function FailureIcon(): JSX.Element {
286 return <X className="w-4 h-4 mr-2 inline-block align-middle" />;
287}
288
gioa1efbad2025-05-21 07:16:45 +0000289function 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}