| import { useCallback, useEffect, useState, useMemo } from "react"; |
| import { useProjectId, useEnv } from "@/lib/state"; |
| import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; |
| import { Button } from "@/components/ui/button"; |
| import { Badge } from "@/components/ui/badge"; |
| import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"; |
| import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; |
| import { useToast } from "@/hooks/use-toast"; |
| import { Check, Ellipsis, LoaderCircle, LogsIcon, X, RefreshCw, Power } from "lucide-react"; |
| import { XTerm } from "@/components/XTerm"; |
| |
| export function Logs() { |
| const { toast } = useToast(); |
| const projectId = useProjectId(); |
| const env = useEnv(); |
| |
| const [selectedServiceForLogs, setSelectedServiceForLogs] = useState<string | null>(null); |
| const [selectedWorkerIdForLogs, setSelectedWorkerIdForLogs] = useState<string | null>(null); |
| |
| const [logs, setLogs] = useState<string>(""); |
| |
| useEffect(() => { |
| console.log("selectedServiceForLogs", selectedServiceForLogs); |
| if (!selectedServiceForLogs || !selectedWorkerIdForLogs || !projectId) { |
| setLogs(""); |
| return; |
| } |
| |
| setLogs(""); // Clear logs for the new selection |
| |
| const eventSource = new EventSource( |
| `/api/project/${projectId}/logs/${selectedServiceForLogs}/${selectedWorkerIdForLogs}`, |
| ); |
| |
| eventSource.onmessage = (event) => { |
| try { |
| const data = JSON.parse(event.data); |
| if (data.logs) { |
| setLogs((prevLogs) => prevLogs + data.logs + "\n"); |
| } |
| } catch (e) { |
| console.error("Failed to parse log data:", e); |
| } |
| }; |
| |
| eventSource.onerror = () => { |
| toast({ |
| variant: "destructive", |
| title: "Log stream error", |
| description: "Connection to the log stream has been closed.", |
| }); |
| eventSource.close(); |
| }; |
| |
| return () => { |
| eventSource.close(); |
| }; |
| }, [selectedServiceForLogs, selectedWorkerIdForLogs, projectId, toast]); |
| |
| const sortedServices = useMemo( |
| () => (env?.services ? [...env.services].sort((a, b) => a.name.localeCompare(b.name)) : []), |
| [env?.services], |
| ); |
| |
| const handleViewLogsClick = useCallback( |
| (serviceName: string, workerId: string) => { |
| setSelectedServiceForLogs(serviceName); |
| setSelectedWorkerIdForLogs(workerId); |
| }, |
| [setSelectedServiceForLogs, setSelectedWorkerIdForLogs], |
| ); |
| |
| useEffect(() => { |
| if (sortedServices.length > 0 && sortedServices[0].workers && sortedServices[0].workers.length > 0) { |
| if (!selectedServiceForLogs || !selectedWorkerIdForLogs) { |
| handleViewLogsClick(sortedServices[0].name, sortedServices[0].workers[0].id); |
| return; |
| } |
| const service = sortedServices.find((s) => s.name === selectedServiceForLogs); |
| if (service == null) { |
| handleViewLogsClick(sortedServices[0].name, sortedServices[0].workers[0].id); |
| return; |
| } |
| const worker = service.workers.find((w) => w.id === selectedWorkerIdForLogs); |
| if (worker == null) { |
| handleViewLogsClick(sortedServices[0].name, sortedServices[0].workers[0].id); |
| } |
| } |
| }, [sortedServices, selectedServiceForLogs, selectedWorkerIdForLogs, handleViewLogsClick]); |
| |
| const handleReloadWorkerClick = useCallback( |
| (serviceName: string, workerId: string) => { |
| if (!projectId) return; |
| toast({ |
| title: "Worker reload initiated", |
| description: `Worker ${serviceName} / ${workerId} is reloading.`, |
| }); |
| fetch(`/api/project/${projectId}/reload/${serviceName}/${workerId}`, { |
| method: "POST", |
| }) |
| .then((resp) => { |
| if (!resp.ok) { |
| throw new Error(`Failed to reload worker: ${resp.statusText}`); |
| } |
| toast({ |
| title: "Worker reloaded", |
| description: `Successfully reloaded worker ${serviceName} / ${workerId}`, |
| }); |
| }) |
| .catch((e) => { |
| console.error(e); |
| toast({ |
| variant: "destructive", |
| title: "Failed to reload worker", |
| description: `Failed to reload worker ${serviceName} / ${workerId} in service`, |
| }); |
| }); |
| }, |
| [projectId, toast], |
| ); |
| |
| const handleQuitWorkerClick = useCallback( |
| (serviceName: string, workerId: string) => { |
| if (!projectId) return; |
| if (!window.confirm(`Are you sure you want to terminate worker ${workerId} for service ${serviceName}?`)) { |
| return; |
| } |
| toast({ |
| title: "Worker ${serviceName} / ${workerId} is being terminated.", |
| }); |
| fetch(`/api/project/${projectId}/quitquitquit/${serviceName}/${workerId}`, { |
| method: "POST", |
| }) |
| .then((resp) => { |
| if (!resp.ok) { |
| toast({ |
| title: `Failed to terminate worker ${serviceName} / ${workerId}`, |
| variant: "destructive", |
| }); |
| } else { |
| toast({ |
| title: `Successfully terminated worker ${serviceName} / ${workerId}`, |
| }); |
| } |
| }) |
| .catch((e) => { |
| console.error(e); |
| toast({ |
| title: `Failed to terminate worker ${serviceName} / ${workerId}`, |
| variant: "destructive", |
| }); |
| }); |
| }, |
| [projectId, toast], |
| ); |
| |
| const defaultExpandedServiceNames = useMemo(() => sortedServices.map((s) => s.name), [sortedServices]); |
| const defaultExpandedFirstWorkerId = useMemo(() => { |
| if (sortedServices.length > 0 && sortedServices[0].workers && sortedServices[0].workers.length > 0) { |
| return sortedServices[0].workers[0].id; |
| } |
| return undefined; |
| }, [sortedServices]); |
| |
| return ( |
| <ResizablePanelGroup direction="horizontal" className="h-full w-full"> |
| <ResizablePanel defaultSize={15}> |
| <div className="flex flex-col h-full p-2 gap-2 overflow-y-auto"> |
| {sortedServices.length > 0 ? ( |
| <Accordion type="multiple" className="w-full" defaultValue={defaultExpandedServiceNames}> |
| {sortedServices.map((service, serviceIndex) => ( |
| <AccordionItem value={service.name} key={service.name}> |
| <AccordionTrigger className="py-1">{service.name}</AccordionTrigger> |
| <AccordionContent className="pl-2"> |
| {service.workers && service.workers.length > 0 ? ( |
| <Accordion |
| type="single" |
| collapsible |
| className="w-full" |
| defaultValue={ |
| serviceIndex === 0 ? defaultExpandedFirstWorkerId : undefined |
| } |
| > |
| {service.workers.map((worker) => ( |
| <AccordionItem value={worker.id} key={worker.id}> |
| <AccordionTrigger className="py-1"> |
| {worker.id} |
| </AccordionTrigger> |
| <AccordionContent className="pl-2"> |
| <TooltipProvider> |
| <div className="text-sm flex flex-col gap-1 items-start"> |
| <Button |
| onClick={() => |
| handleViewLogsClick(service.name, worker.id) |
| } |
| size="sm" |
| variant="link" |
| className="!px-0" |
| > |
| <LogsIcon className="w-4 h-4" /> |
| View Logs |
| </Button> |
| <Button |
| onClick={() => |
| handleReloadWorkerClick( |
| service.name, |
| worker.id, |
| ) |
| } |
| size="sm" |
| variant="link" |
| className="!px-0" |
| > |
| <RefreshCw className="w-4 h-4" /> |
| Reload Worker |
| </Button> |
| <Button |
| onClick={() => |
| handleQuitWorkerClick( |
| service.name, |
| worker.id, |
| ) |
| } |
| size="sm" |
| variant="link" |
| className="!px-0" |
| > |
| <Power className="w-4 h-4" /> |
| Quit Worker |
| </Button> |
| {!worker.commit && ( |
| <p className="flex items-center"> |
| <FailureIcon /> |
| Clone Repository |
| </p> |
| )} |
| <p> |
| Commit: |
| {worker.commit && ( |
| <Tooltip> |
| <TooltipTrigger asChild> |
| <span className="inline-block"> |
| <Badge |
| variant="outline" |
| className="ml-1 font-mono" |
| > |
| {worker.commit.hash.substring( |
| 0, |
| 8, |
| )} |
| </Badge> |
| </span> |
| </TooltipTrigger> |
| <TooltipContent |
| side="right" |
| className="flex flex-col gap-1" |
| > |
| <p>{worker.commit.message}</p> |
| <p>{worker.commit.hash}</p> |
| </TooltipContent> |
| </Tooltip> |
| )} |
| </p> |
| {worker.commands && worker.commands.length > 0 && ( |
| <div> |
| Commands: |
| <ul className="list-none pl-0 font-['JetBrains_Mono']"> |
| {worker.commands.map((cmd, index) => ( |
| <li |
| key={index} |
| className="rounded flex items-start" |
| > |
| <span className="inline-block w-6 flex-shrink-0"> |
| <CommandStateIcon |
| state={cmd.state} |
| /> |
| </span> |
| <span className="font-mono break-all"> |
| {cmd.command} |
| </span> |
| </li> |
| ))} |
| </ul> |
| </div> |
| )} |
| {(!worker.commands || |
| worker.commands.length === 0) && ( |
| <p className="text-xs text-gray-500"> |
| No commands for this worker. |
| </p> |
| )} |
| </div> |
| </TooltipProvider> |
| </AccordionContent> |
| </AccordionItem> |
| ))} |
| </Accordion> |
| ) : ( |
| <p className="text-sm text-gray-500 p-2"> |
| No workers found for this service. |
| </p> |
| )} |
| </AccordionContent> |
| </AccordionItem> |
| ))} |
| </Accordion> |
| ) : ( |
| <div className="text-center text-gray-500 mt-4">No services available.</div> |
| )} |
| </div> |
| </ResizablePanel> |
| <ResizableHandle withHandle /> |
| <ResizablePanel defaultSize={85}> |
| <div className="flex flex-col h-full"> |
| {selectedServiceForLogs && selectedWorkerIdForLogs ? ( |
| <> |
| <div className="p-2 border-b"> |
| Logs for: {selectedServiceForLogs} / {selectedWorkerIdForLogs} |
| </div> |
| <div className="flex-1 h-full p-4 bg-muted overflow-auto"> |
| <XTerm logs={logs} /> |
| </div> |
| </> |
| ) : ( |
| <div className="flex-1 flex items-center justify-center h-full p-4 bg-muted text-sm text-gray-500"> |
| Click 'View Logs' on a worker to display logs here. |
| </div> |
| )} |
| </div> |
| </ResizablePanel> |
| </ResizablePanelGroup> |
| ); |
| } |
| |
| function WaitingIcon(): JSX.Element { |
| return <Ellipsis className="w-4 h-4 mr-2 inline-block align-middle" />; |
| } |
| function RunningIcon(): JSX.Element { |
| return <LoaderCircle className="animate-spin w-4 h-4 mr-2 inline-block align-middle" />; |
| } |
| |
| function SuccessIcon(): JSX.Element { |
| return <Check className="w-4 h-4 mr-2 inline-block align-middle" />; |
| } |
| |
| function FailureIcon(): JSX.Element { |
| return <X className="w-4 h-4 mr-2 inline-block align-middle" />; |
| } |
| |
| function CommandStateIcon({ state }: { state: string | undefined }): JSX.Element | null { |
| switch (state?.toLowerCase()) { |
| case "running": |
| return <RunningIcon />; |
| case "success": |
| return <SuccessIcon />; |
| case "failure": |
| return <FailureIcon />; |
| case "waiting": |
| return <WaitingIcon />; |
| default: |
| return null; |
| } |
| } |