blob: 46197ef2ca4b5cb17ac4ea5ea080b55a1e4f7298 [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
gio166d9922025-07-07 17:30:21 +000065 const handleViewLogsClick = useCallback(
66 (serviceName: string, workerId: string) => {
67 setSelectedServiceForLogs(serviceName);
68 setSelectedWorkerIdForLogs(workerId);
69 },
70 [setSelectedServiceForLogs, setSelectedWorkerIdForLogs],
71 );
72
gioa1efbad2025-05-21 07:16:45 +000073 useEffect(() => {
74 if (sortedServices.length > 0 && sortedServices[0].workers && sortedServices[0].workers.length > 0) {
gio166d9922025-07-07 17:30:21 +000075 if (!selectedServiceForLogs || !selectedWorkerIdForLogs) {
76 handleViewLogsClick(sortedServices[0].name, sortedServices[0].workers[0].id);
77 return;
78 }
79 const service = sortedServices.find((s) => s.name === selectedServiceForLogs);
80 if (service == null) {
81 handleViewLogsClick(sortedServices[0].name, sortedServices[0].workers[0].id);
82 return;
83 }
84 const worker = service.workers.find((w) => w.id === selectedWorkerIdForLogs);
85 if (worker == null) {
gioa1efbad2025-05-21 07:16:45 +000086 handleViewLogsClick(sortedServices[0].name, sortedServices[0].workers[0].id);
87 }
88 }
gio166d9922025-07-07 17:30:21 +000089 }, [sortedServices, selectedServiceForLogs, selectedWorkerIdForLogs, handleViewLogsClick]);
gioa1efbad2025-05-21 07:16:45 +000090
gio918780d2025-05-22 08:24:41 +000091 const handleReloadWorkerClick = useCallback(
92 (serviceName: string, workerId: string) => {
93 if (!projectId) return;
94 toast({
95 title: "Worker reload initiated",
96 description: `Worker ${serviceName} / ${workerId} is reloading.`,
97 });
98 fetch(`/api/project/${projectId}/reload/${serviceName}/${workerId}`, {
99 method: "POST",
100 })
101 .then((resp) => {
102 if (!resp.ok) {
103 throw new Error(`Failed to reload worker: ${resp.statusText}`);
104 }
105 toast({
106 title: "Worker reloaded",
107 description: `Successfully reloaded worker ${serviceName} / ${workerId}`,
108 });
109 })
110 .catch((e) => {
111 console.error(e);
112 toast({
113 variant: "destructive",
114 title: "Failed to reload worker",
115 description: `Failed to reload worker ${serviceName} / ${workerId} in service`,
116 });
117 });
118 },
119 [projectId, toast],
120 );
121
gio577d2342025-07-03 12:50:18 +0000122 const handleQuitWorkerClick = useCallback(
123 (serviceName: string, workerId: string) => {
124 if (!projectId) return;
125 if (!window.confirm(`Are you sure you want to terminate worker ${workerId} for service ${serviceName}?`)) {
126 return;
127 }
128 toast({
129 title: "Worker ${serviceName} / ${workerId} is being terminated.",
130 });
131 fetch(`/api/project/${projectId}/quitquitquit/${serviceName}/${workerId}`, {
132 method: "POST",
133 })
134 .then((resp) => {
135 if (!resp.ok) {
136 toast({
137 title: `Failed to terminate worker ${serviceName} / ${workerId}`,
138 variant: "destructive",
139 });
140 } else {
141 toast({
142 title: `Successfully terminated worker ${serviceName} / ${workerId}`,
143 });
144 }
145 })
146 .catch((e) => {
147 console.error(e);
148 toast({
149 title: `Failed to terminate worker ${serviceName} / ${workerId}`,
150 variant: "destructive",
151 });
152 });
153 },
154 [projectId, toast],
155 );
156
gioa1efbad2025-05-21 07:16:45 +0000157 const defaultExpandedServiceNames = useMemo(() => sortedServices.map((s) => s.name), [sortedServices]);
158 const defaultExpandedFirstWorkerId = useMemo(() => {
159 if (sortedServices.length > 0 && sortedServices[0].workers && sortedServices[0].workers.length > 0) {
160 return sortedServices[0].workers[0].id;
161 }
162 return undefined;
163 }, [sortedServices]);
164
165 return (
166 <ResizablePanelGroup direction="horizontal" className="h-full w-full">
167 <ResizablePanel defaultSize={15}>
168 <div className="flex flex-col h-full p-2 gap-2 overflow-y-auto">
169 {sortedServices.length > 0 ? (
170 <Accordion type="multiple" className="w-full" defaultValue={defaultExpandedServiceNames}>
171 {sortedServices.map((service, serviceIndex) => (
172 <AccordionItem value={service.name} key={service.name}>
173 <AccordionTrigger className="py-1">{service.name}</AccordionTrigger>
174 <AccordionContent className="pl-2">
175 {service.workers && service.workers.length > 0 ? (
176 <Accordion
177 type="single"
178 collapsible
179 className="w-full"
180 defaultValue={
181 serviceIndex === 0 ? defaultExpandedFirstWorkerId : undefined
182 }
183 >
184 {service.workers.map((worker) => (
185 <AccordionItem value={worker.id} key={worker.id}>
186 <AccordionTrigger className="py-1">
187 {worker.id}
188 </AccordionTrigger>
189 <AccordionContent className="pl-2">
190 <TooltipProvider>
gio918780d2025-05-22 08:24:41 +0000191 <div className="text-sm flex flex-col gap-1 items-start">
gioa1efbad2025-05-21 07:16:45 +0000192 <Button
193 onClick={() =>
194 handleViewLogsClick(service.name, worker.id)
195 }
196 size="sm"
197 variant="link"
198 className="!px-0"
199 >
200 <LogsIcon className="w-4 h-4" />
201 View Logs
202 </Button>
gio918780d2025-05-22 08:24:41 +0000203 <Button
204 onClick={() =>
205 handleReloadWorkerClick(
206 service.name,
207 worker.id,
208 )
209 }
210 size="sm"
211 variant="link"
212 className="!px-0"
213 >
214 <RefreshCw className="w-4 h-4" />
215 Reload Worker
216 </Button>
gio577d2342025-07-03 12:50:18 +0000217 <Button
218 onClick={() =>
219 handleQuitWorkerClick(
220 service.name,
221 worker.id,
222 )
223 }
224 size="sm"
225 variant="link"
226 className="!px-0"
227 >
228 <Power className="w-4 h-4" />
229 Quit Worker
230 </Button>
gio0afbaee2025-05-22 04:34:33 +0000231 {!worker.commit && (
gioa1efbad2025-05-21 07:16:45 +0000232 <p className="flex items-center">
233 <FailureIcon />
234 Clone Repository
235 </p>
236 )}
237 <p>
238 Commit:
gio0afbaee2025-05-22 04:34:33 +0000239 {worker.commit && (
gioa1efbad2025-05-21 07:16:45 +0000240 <Tooltip>
241 <TooltipTrigger asChild>
242 <span className="inline-block">
243 <Badge
244 variant="outline"
245 className="ml-1 font-mono"
246 >
gio0afbaee2025-05-22 04:34:33 +0000247 {worker.commit.hash.substring(
gioa1efbad2025-05-21 07:16:45 +0000248 0,
249 8,
250 )}
251 </Badge>
252 </span>
253 </TooltipTrigger>
gio0afbaee2025-05-22 04:34:33 +0000254 <TooltipContent
255 side="right"
256 className="flex flex-col gap-1"
257 >
258 <p>{worker.commit.message}</p>
259 <p>{worker.commit.hash}</p>
gioa1efbad2025-05-21 07:16:45 +0000260 </TooltipContent>
261 </Tooltip>
262 )}
263 </p>
264 {worker.commands && worker.commands.length > 0 && (
265 <div>
266 Commands:
267 <ul className="list-none pl-0 font-['JetBrains_Mono']">
268 {worker.commands.map((cmd, index) => (
269 <li
270 key={index}
271 className="rounded flex items-start"
272 >
273 <span className="inline-block w-6 flex-shrink-0">
274 <CommandStateIcon
275 state={cmd.state}
276 />
277 </span>
278 <span className="font-mono break-all">
279 {cmd.command}
280 </span>
281 </li>
282 ))}
283 </ul>
284 </div>
285 )}
286 {(!worker.commands ||
287 worker.commands.length === 0) && (
288 <p className="text-xs text-gray-500">
289 No commands for this worker.
290 </p>
291 )}
292 </div>
293 </TooltipProvider>
294 </AccordionContent>
295 </AccordionItem>
296 ))}
297 </Accordion>
298 ) : (
299 <p className="text-sm text-gray-500 p-2">
300 No workers found for this service.
301 </p>
302 )}
303 </AccordionContent>
304 </AccordionItem>
305 ))}
306 </Accordion>
307 ) : (
308 <div className="text-center text-gray-500 mt-4">No services available.</div>
309 )}
310 </div>
311 </ResizablePanel>
312 <ResizableHandle withHandle />
313 <ResizablePanel defaultSize={85}>
314 <div className="flex flex-col h-full">
315 {selectedServiceForLogs && selectedWorkerIdForLogs ? (
316 <>
gio166d9922025-07-07 17:30:21 +0000317 <div className="p-2 border-b">
gioa1efbad2025-05-21 07:16:45 +0000318 Logs for: {selectedServiceForLogs} / {selectedWorkerIdForLogs}
319 </div>
gio78a22882025-07-01 18:56:01 +0000320 <div className="flex-1 h-full p-4 bg-muted overflow-auto">
321 <XTerm logs={logs} />
322 </div>
gioa1efbad2025-05-21 07:16:45 +0000323 </>
324 ) : (
325 <div className="flex-1 flex items-center justify-center h-full p-4 bg-muted text-sm text-gray-500">
326 Click 'View Logs' on a worker to display logs here.
327 </div>
328 )}
329 </div>
330 </ResizablePanel>
331 </ResizablePanelGroup>
332 );
333}
334
gio918780d2025-05-22 08:24:41 +0000335function WaitingIcon(): JSX.Element {
336 return <Ellipsis className="w-4 h-4 mr-2 inline-block align-middle" />;
337}
338function RunningIcon(): JSX.Element {
339 return <LoaderCircle className="animate-spin w-4 h-4 mr-2 inline-block align-middle" />;
340}
341
342function SuccessIcon(): JSX.Element {
343 return <Check className="w-4 h-4 mr-2 inline-block align-middle" />;
344}
345
346function FailureIcon(): JSX.Element {
347 return <X className="w-4 h-4 mr-2 inline-block align-middle" />;
348}
349
gioa1efbad2025-05-21 07:16:45 +0000350function CommandStateIcon({ state }: { state: string | undefined }): JSX.Element | null {
351 switch (state?.toLowerCase()) {
352 case "running":
353 return <RunningIcon />;
354 case "success":
355 return <SuccessIcon />;
356 case "failure":
357 return <FailureIcon />;
358 case "waiting":
359 return <WaitingIcon />;
360 default:
361 return null;
362 }
363}