blob: 5918cf7434c802475fd3e33f2a81a77113e6c160 [file] [log] [blame]
gio3a921b82025-05-10 07:36:09 +00001import { useCallback, useEffect, useState, useRef, useMemo } from "react";
2import { useProjectId, useEnv } from "@/lib/state";
3import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
4import { Card, CardContent, CardHeader } from "@/components/ui/card";
5import { useToast } from "@/hooks/use-toast";
6
7// ANSI escape sequence regex
8const ANSI_ESCAPE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
9
10function cleanAnsiEscapeSequences(text: string): string {
11 return text.replace(ANSI_ESCAPE_REGEX, "");
12}
13
14export function Logs() {
15 const { toast } = useToast();
16 const projectId = useProjectId();
17 const env = useEnv();
18 const [selectedService, setSelectedService] = useState<string>("");
19 const [logs, setLogs] = useState<string>("");
20 const preRef = useRef<HTMLPreElement>(null);
21 const wasAtBottom = useRef(true);
22
23 const checkIfAtBottom = useCallback(() => {
24 if (!preRef.current) return;
25 const { scrollTop, scrollHeight, clientHeight } = preRef.current;
26 wasAtBottom.current = Math.abs(scrollHeight - clientHeight - scrollTop) < 10;
27 }, []);
28
29 const scrollToBottom = useCallback(() => {
30 if (!preRef.current) return;
31 preRef.current.scrollTop = preRef.current.scrollHeight;
32 }, []);
33
34 const fetchLogs = useCallback(
35 async (service: string) => {
36 if (!projectId || !service) return;
37
38 try {
39 const resp = await fetch(`/api/project/${projectId}/logs/${service}`);
40 if (!resp.ok) {
41 throw new Error("Failed to fetch logs");
42 }
43 const data = await resp.json();
44 setLogs(data.logs || "");
45 } catch (e) {
46 console.error(e);
47 toast({
48 variant: "destructive",
49 title: "Failed to fetch logs",
50 });
51 }
52 },
53 [projectId, toast],
54 );
55
56 useEffect(() => {
57 if (selectedService) {
58 // Initial fetch
59 fetchLogs(selectedService);
60
61 // Set up interval for periodic updates
62 const interval = setInterval(() => {
63 fetchLogs(selectedService);
64 }, 5000);
65
66 // Cleanup interval on unmount or when service changes
67 return () => clearInterval(interval);
68 }
69 }, [selectedService, fetchLogs]);
70
71 // Handle scroll events
72 useEffect(() => {
73 const pre = preRef.current;
74 if (!pre) return;
75
76 const handleScroll = () => {
77 checkIfAtBottom();
78 };
79
80 pre.addEventListener("scroll", handleScroll);
81 return () => pre.removeEventListener("scroll", handleScroll);
82 }, [checkIfAtBottom]);
83
84 // Auto-scroll when new logs arrive
85 useEffect(() => {
86 if (wasAtBottom.current) {
87 scrollToBottom();
88 }
89 }, [logs, scrollToBottom]);
90
91 const sortedServices = useMemo(() => (env?.services ? [...env.services].sort() : []), [env]);
92
93 // Auto-select first service when services are available
94 useEffect(() => {
95 if (sortedServices.length && !selectedService) {
96 setSelectedService(sortedServices[0]);
97 }
98 }, [sortedServices, selectedService]);
99
100 return (
101 <Card>
102 <CardHeader>
103 <Select value={selectedService} onValueChange={setSelectedService}>
104 <SelectTrigger>
105 <SelectValue placeholder="Select a service" />
106 </SelectTrigger>
107 <SelectContent>
108 {sortedServices.map((service) => (
109 <SelectItem key={service} value={service}>
110 {service}
111 </SelectItem>
112 ))}
113 </SelectContent>
114 </Select>
115 </CardHeader>
116 <CardContent className="h-full">
117 {selectedService && (
118 <pre
119 ref={preRef}
120 className="p-4 bg-muted rounded-lg overflow-auto max-h-[500px] font-['JetBrains_Mono'] whitespace-pre-wrap break-all"
121 >
122 {cleanAnsiEscapeSequences(logs) || "No logs available"}
123 </pre>
124 )}
125 </CardContent>
126 </Card>
127 );
128}