blob: 659d1a1cbaca36bf9e1c6873c2b39e5f583d8f36 [file] [log] [blame]
import { useCallback, useEffect, useState, useRef, 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 { LogsIcon } from "lucide-react";
// ANSI escape sequence regex
// eslint-disable-next-line no-control-regex
const ANSI_ESCAPE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
function cleanAnsiEscapeSequences(text: string): string {
return text.replace(ANSI_ESCAPE_REGEX, "");
}
const WaitingIcon = () => (
<svg viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 mr-2 inline-block align-middle text-gray-500">
<circle cx="6" cy="12" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="18" cy="12" r="2" />
</svg>
);
const RunningIcon = () => (
<svg
className="animate-spin w-4 h-4 mr-2 inline-block align-middle text-blue-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
);
const SuccessIcon = () => (
<svg viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 mr-2 inline-block align-middle text-green-500">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
</svg>
);
const FailureIcon = () => (
<svg viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 mr-2 inline-block align-middle text-red-500">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
</svg>
);
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>("");
const preRef = useRef<HTMLPreElement>(null);
const wasAtBottom = useRef(true);
const checkIfAtBottom = useCallback(() => {
if (!preRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = preRef.current;
wasAtBottom.current = Math.abs(scrollHeight - clientHeight - scrollTop) < 10;
}, []);
const scrollToBottom = useCallback(() => {
if (!preRef.current) return;
preRef.current.scrollTop = preRef.current.scrollHeight;
}, []);
const fetchLogs = useCallback(
async (serviceName: string, workerId: string) => {
if (!projectId || !serviceName || !workerId) return;
try {
const resp = await fetch(`/api/project/${projectId}/logs/${serviceName}/${workerId}`);
if (!resp.ok) {
throw new Error(`Failed to fetch logs: ${resp.statusText}`);
}
const data = await resp.json();
setLogs(data.logs || "");
} catch (e) {
console.error(e);
toast({
variant: "destructive",
title: "Failed to fetch logs",
});
}
},
[projectId, toast],
);
useEffect(() => {
if (selectedServiceForLogs && selectedWorkerIdForLogs) {
fetchLogs(selectedServiceForLogs, selectedWorkerIdForLogs);
const interval = setInterval(() => {
fetchLogs(selectedServiceForLogs, selectedWorkerIdForLogs);
}, 5000);
return () => clearInterval(interval);
} else {
setLogs("");
}
}, [selectedServiceForLogs, selectedWorkerIdForLogs, fetchLogs]);
useEffect(() => {
const pre = preRef.current;
if (!pre) return;
const handleScroll = () => checkIfAtBottom();
pre.addEventListener("scroll", handleScroll);
return () => pre.removeEventListener("scroll", handleScroll);
}, [checkIfAtBottom]);
useEffect(() => {
if (wasAtBottom.current) {
scrollToBottom();
}
}, [logs, scrollToBottom]);
const sortedServices = useMemo(
() => (env?.services ? [...env.services].sort((a, b) => a.name.localeCompare(b.name)) : []),
[env?.services],
);
useEffect(() => {
if (sortedServices.length > 0 && sortedServices[0].workers && sortedServices[0].workers.length > 0) {
// Only set if no logs are currently selected, to avoid overriding user interaction
if (!selectedServiceForLogs && !selectedWorkerIdForLogs) {
handleViewLogsClick(sortedServices[0].name, sortedServices[0].workers[0].id);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortedServices]);
const handleViewLogsClick = (serviceName: string, workerId: string) => {
setSelectedServiceForLogs(serviceName);
setSelectedWorkerIdForLogs(workerId);
};
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">
<Button
onClick={() =>
handleViewLogsClick(service.name, worker.id)
}
size="sm"
variant="link"
className="!px-0"
>
<LogsIcon className="w-4 h-4" />
View Logs
</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 text-sm text-muted-foreground">
Logs for: {selectedServiceForLogs} / {selectedWorkerIdForLogs}
</div>
<pre
ref={preRef}
className="flex-1 h-full p-4 bg-muted overflow-auto font-['JetBrains_Mono'] text-xs whitespace-pre-wrap break-all"
>
{cleanAnsiEscapeSequences(logs) ||
`No logs available for ${selectedServiceForLogs} / ${selectedWorkerIdForLogs}.`}
</pre>
</>
) : (
<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 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;
}
}