blob: c27d4209ece9113d0a2a64f8b09f0695cee8f724 [file] [log] [blame]
import { useCallback, useEffect, useState, useRef, useMemo } from "react";
import { useProjectId, useEnv } from "@/lib/state";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
// 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, "");
}
export function Logs() {
const { toast } = useToast();
const projectId = useProjectId();
const env = useEnv();
const [selectedService, setSelectedService] = useState<string>("");
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 (service: string) => {
if (!projectId || !service) return;
try {
const resp = await fetch(`/api/project/${projectId}/logs/${service}`);
if (!resp.ok) {
throw new Error("Failed to fetch logs");
}
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 (selectedService) {
// Initial fetch
fetchLogs(selectedService);
// Set up interval for periodic updates
const interval = setInterval(() => {
fetchLogs(selectedService);
}, 5000);
// Cleanup interval on unmount or when service changes
return () => clearInterval(interval);
}
}, [selectedService, fetchLogs]);
// Handle scroll events
useEffect(() => {
const pre = preRef.current;
if (!pre) return;
const handleScroll = () => {
checkIfAtBottom();
};
pre.addEventListener("scroll", handleScroll);
return () => pre.removeEventListener("scroll", handleScroll);
}, [checkIfAtBottom]);
// Auto-scroll when new logs arrive
useEffect(() => {
if (wasAtBottom.current) {
scrollToBottom();
}
}, [logs, scrollToBottom]);
const sortedServices = useMemo(() => (env?.services ? [...env.services].sort() : []), [env]);
// Auto-select first service when services are available
useEffect(() => {
if (sortedServices.length && !selectedService) {
setSelectedService(sortedServices[0]);
}
}, [sortedServices, selectedService]);
return (
<div className="flex flex-col h-full">
<div className="flex-none w-full flex flex-row justify-start items-center gap-2 px-4 py-1">
<div>Service</div>
<Select value={selectedService} onValueChange={setSelectedService}>
<SelectTrigger className="w-1/4">
<SelectValue placeholder="Select a service" />
</SelectTrigger>
<SelectContent>
{sortedServices.map((service) => (
<SelectItem key={service} value={service}>
{service}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedService && (
<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"}
</pre>
)}
</div>
);
}