| 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> |
| ); |
| } |