blob: 0d86432b05b1c4cc86e96acd7237c7ad0bd90141 [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";
gio577d2342025-07-03 12:50:18 +00009import { Check, Ellipsis, LoaderCircle, LogsIcon, X, RefreshCw, Power } 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
gio577d2342025-07-03 12:50:18 +0000110 const handleQuitWorkerClick = useCallback(
111 (serviceName: string, workerId: string) => {
112 if (!projectId) return;
113 if (!window.confirm(`Are you sure you want to terminate worker ${workerId} for service ${serviceName}?`)) {
114 return;
115 }
116 toast({
117 title: "Worker ${serviceName} / ${workerId} is being terminated.",
118 });
119 fetch(`/api/project/${projectId}/quitquitquit/${serviceName}/${workerId}`, {
120 method: "POST",
121 })
122 .then((resp) => {
123 if (!resp.ok) {
124 toast({
125 title: `Failed to terminate worker ${serviceName} / ${workerId}`,
126 variant: "destructive",
127 });
128 } else {
129 toast({
130 title: `Successfully terminated worker ${serviceName} / ${workerId}`,
131 });
132 }
133 })
134 .catch((e) => {
135 console.error(e);
136 toast({
137 title: `Failed to terminate worker ${serviceName} / ${workerId}`,
138 variant: "destructive",
139 });
140 });
141 },
142 [projectId, toast],
143 );
144
gioa1efbad2025-05-21 07:16:45 +0000145 const defaultExpandedServiceNames = useMemo(() => sortedServices.map((s) => s.name), [sortedServices]);
146 const defaultExpandedFirstWorkerId = useMemo(() => {
147 if (sortedServices.length > 0 && sortedServices[0].workers && sortedServices[0].workers.length > 0) {
148 return sortedServices[0].workers[0].id;
149 }
150 return undefined;
151 }, [sortedServices]);
152
153 return (
154 <ResizablePanelGroup direction="horizontal" className="h-full w-full">
155 <ResizablePanel defaultSize={15}>
156 <div className="flex flex-col h-full p-2 gap-2 overflow-y-auto">
157 {sortedServices.length > 0 ? (
158 <Accordion type="multiple" className="w-full" defaultValue={defaultExpandedServiceNames}>
159 {sortedServices.map((service, serviceIndex) => (
160 <AccordionItem value={service.name} key={service.name}>
161 <AccordionTrigger className="py-1">{service.name}</AccordionTrigger>
162 <AccordionContent className="pl-2">
163 {service.workers && service.workers.length > 0 ? (
164 <Accordion
165 type="single"
166 collapsible
167 className="w-full"
168 defaultValue={
169 serviceIndex === 0 ? defaultExpandedFirstWorkerId : undefined
170 }
171 >
172 {service.workers.map((worker) => (
173 <AccordionItem value={worker.id} key={worker.id}>
174 <AccordionTrigger className="py-1">
175 {worker.id}
176 </AccordionTrigger>
177 <AccordionContent className="pl-2">
178 <TooltipProvider>
gio918780d2025-05-22 08:24:41 +0000179 <div className="text-sm flex flex-col gap-1 items-start">
gioa1efbad2025-05-21 07:16:45 +0000180 <Button
181 onClick={() =>
182 handleViewLogsClick(service.name, worker.id)
183 }
184 size="sm"
185 variant="link"
186 className="!px-0"
187 >
188 <LogsIcon className="w-4 h-4" />
189 View Logs
190 </Button>
gio918780d2025-05-22 08:24:41 +0000191 <Button
192 onClick={() =>
193 handleReloadWorkerClick(
194 service.name,
195 worker.id,
196 )
197 }
198 size="sm"
199 variant="link"
200 className="!px-0"
201 >
202 <RefreshCw className="w-4 h-4" />
203 Reload Worker
204 </Button>
gio577d2342025-07-03 12:50:18 +0000205 <Button
206 onClick={() =>
207 handleQuitWorkerClick(
208 service.name,
209 worker.id,
210 )
211 }
212 size="sm"
213 variant="link"
214 className="!px-0"
215 >
216 <Power className="w-4 h-4" />
217 Quit Worker
218 </Button>
gio0afbaee2025-05-22 04:34:33 +0000219 {!worker.commit && (
gioa1efbad2025-05-21 07:16:45 +0000220 <p className="flex items-center">
221 <FailureIcon />
222 Clone Repository
223 </p>
224 )}
225 <p>
226 Commit:
gio0afbaee2025-05-22 04:34:33 +0000227 {worker.commit && (
gioa1efbad2025-05-21 07:16:45 +0000228 <Tooltip>
229 <TooltipTrigger asChild>
230 <span className="inline-block">
231 <Badge
232 variant="outline"
233 className="ml-1 font-mono"
234 >
gio0afbaee2025-05-22 04:34:33 +0000235 {worker.commit.hash.substring(
gioa1efbad2025-05-21 07:16:45 +0000236 0,
237 8,
238 )}
239 </Badge>
240 </span>
241 </TooltipTrigger>
gio0afbaee2025-05-22 04:34:33 +0000242 <TooltipContent
243 side="right"
244 className="flex flex-col gap-1"
245 >
246 <p>{worker.commit.message}</p>
247 <p>{worker.commit.hash}</p>
gioa1efbad2025-05-21 07:16:45 +0000248 </TooltipContent>
249 </Tooltip>
250 )}
251 </p>
252 {worker.commands && worker.commands.length > 0 && (
253 <div>
254 Commands:
255 <ul className="list-none pl-0 font-['JetBrains_Mono']">
256 {worker.commands.map((cmd, index) => (
257 <li
258 key={index}
259 className="rounded flex items-start"
260 >
261 <span className="inline-block w-6 flex-shrink-0">
262 <CommandStateIcon
263 state={cmd.state}
264 />
265 </span>
266 <span className="font-mono break-all">
267 {cmd.command}
268 </span>
269 </li>
270 ))}
271 </ul>
272 </div>
273 )}
274 {(!worker.commands ||
275 worker.commands.length === 0) && (
276 <p className="text-xs text-gray-500">
277 No commands for this worker.
278 </p>
279 )}
280 </div>
281 </TooltipProvider>
282 </AccordionContent>
283 </AccordionItem>
284 ))}
285 </Accordion>
286 ) : (
287 <p className="text-sm text-gray-500 p-2">
288 No workers found for this service.
289 </p>
290 )}
291 </AccordionContent>
292 </AccordionItem>
293 ))}
294 </Accordion>
295 ) : (
296 <div className="text-center text-gray-500 mt-4">No services available.</div>
297 )}
298 </div>
299 </ResizablePanel>
300 <ResizableHandle withHandle />
301 <ResizablePanel defaultSize={85}>
302 <div className="flex flex-col h-full">
303 {selectedServiceForLogs && selectedWorkerIdForLogs ? (
304 <>
305 <div className="p-2 border-b text-sm text-muted-foreground">
306 Logs for: {selectedServiceForLogs} / {selectedWorkerIdForLogs}
307 </div>
gio78a22882025-07-01 18:56:01 +0000308 <div className="flex-1 h-full p-4 bg-muted overflow-auto">
309 <XTerm logs={logs} />
310 </div>
gioa1efbad2025-05-21 07:16:45 +0000311 </>
312 ) : (
313 <div className="flex-1 flex items-center justify-center h-full p-4 bg-muted text-sm text-gray-500">
314 Click 'View Logs' on a worker to display logs here.
315 </div>
316 )}
317 </div>
318 </ResizablePanel>
319 </ResizablePanelGroup>
320 );
321}
322
gio918780d2025-05-22 08:24:41 +0000323function WaitingIcon(): JSX.Element {
324 return <Ellipsis className="w-4 h-4 mr-2 inline-block align-middle" />;
325}
326function RunningIcon(): JSX.Element {
327 return <LoaderCircle className="animate-spin w-4 h-4 mr-2 inline-block align-middle" />;
328}
329
330function SuccessIcon(): JSX.Element {
331 return <Check className="w-4 h-4 mr-2 inline-block align-middle" />;
332}
333
334function FailureIcon(): JSX.Element {
335 return <X className="w-4 h-4 mr-2 inline-block align-middle" />;
336}
337
gioa1efbad2025-05-21 07:16:45 +0000338function CommandStateIcon({ state }: { state: string | undefined }): JSX.Element | null {
339 switch (state?.toLowerCase()) {
340 case "running":
341 return <RunningIcon />;
342 case "success":
343 return <SuccessIcon />;
344 case "failure":
345 return <FailureIcon />;
346 case "waiting":
347 return <WaitingIcon />;
348 default:
349 return null;
350 }
351}