LogsTab.tsx raw
1 import { useCallback, useEffect, useRef, useState } from 'react'
2 import relayAdmin from '@/services/relay-admin.service'
3 import { Button } from '@/components/ui/button'
4 import { cn } from '@/lib/utils'
5 import { toast } from 'sonner'
6
7 interface LogEntry {
8 timestamp: string
9 level: string
10 message: string
11 file?: string
12 line?: number
13 }
14
15 const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'fatal']
16
17 function levelColor(level: string): string {
18 switch (level?.toUpperCase()) {
19 case 'TRC':
20 case 'TRACE':
21 return 'bg-gray-500 text-white'
22 case 'DBG':
23 case 'DEBUG':
24 return 'bg-cyan-600 text-white'
25 case 'INF':
26 case 'INFO':
27 return 'bg-green-600 text-white'
28 case 'WRN':
29 case 'WARN':
30 return 'bg-yellow-500 text-black'
31 case 'ERR':
32 case 'ERROR':
33 return 'bg-red-600 text-white'
34 case 'FTL':
35 case 'FATAL':
36 return 'bg-red-900 text-white'
37 default:
38 return 'bg-green-600 text-white'
39 }
40 }
41
42 export default function LogsTab() {
43 const [logs, setLogs] = useState<LogEntry[]>([])
44 const [isLoading, setIsLoading] = useState(false)
45 const [hasMore, setHasMore] = useState(true)
46 const [totalLogs, setTotalLogs] = useState(0)
47 const [currentLevel, setCurrentLevel] = useState('info')
48 const [selectedLevel, setSelectedLevel] = useState('info')
49 const [error, setError] = useState('')
50 const offsetRef = useRef(0)
51 const triggerRef = useRef<HTMLDivElement>(null)
52
53 const loadLogs = useCallback(
54 async (refresh = false) => {
55 if (isLoading) return
56 setIsLoading(true)
57 setError('')
58
59 if (refresh) {
60 offsetRef.current = 0
61 setLogs([])
62 }
63
64 try {
65 const data = await relayAdmin.getLogs(
66 String(offsetRef.current),
67 100
68 ) as { logs?: LogEntry[]; total?: number; has_more?: boolean }
69 const newLogs = data.logs || []
70 if (refresh) {
71 setLogs(newLogs)
72 } else {
73 setLogs((prev) => [...prev, ...newLogs])
74 }
75 setTotalLogs(data.total || 0)
76 setHasMore(data.has_more || false)
77 offsetRef.current += newLogs.length
78 } catch (e) {
79 setError(e instanceof Error ? e.message : 'Failed to load logs')
80 } finally {
81 setIsLoading(false)
82 }
83 },
84 [isLoading]
85 )
86
87 useEffect(() => {
88 loadLogs(true)
89 relayAdmin.getLogLevel().then((level) => {
90 setCurrentLevel(level)
91 setSelectedLevel(level)
92 })
93 // eslint-disable-next-line react-hooks/exhaustive-deps
94 }, [])
95
96 // IntersectionObserver for infinite scroll
97 useEffect(() => {
98 const el = triggerRef.current
99 if (!el) return
100 const observer = new IntersectionObserver(
101 (entries) => {
102 if (entries[0].isIntersecting && hasMore && !isLoading) {
103 loadLogs(false)
104 }
105 },
106 { threshold: 0.1 }
107 )
108 observer.observe(el)
109 return () => observer.disconnect()
110 }, [hasMore, isLoading, loadLogs])
111
112 const handleLevelChange = async (level: string) => {
113 setSelectedLevel(level)
114 if (level === currentLevel) return
115 try {
116 await relayAdmin.setLogLevel(level)
117 setCurrentLevel(level)
118 toast.success(`Log level set to ${level}`)
119 } catch (e) {
120 setSelectedLevel(currentLevel)
121 toast.error(`Failed to set log level: ${e instanceof Error ? e.message : String(e)}`)
122 }
123 }
124
125 const handleClear = async () => {
126 if (!confirm('Clear all logs?')) return
127 try {
128 await relayAdmin.clearLogs()
129 setLogs([])
130 setTotalLogs(0)
131 setHasMore(false)
132 offsetRef.current = 0
133 toast.success('Logs cleared')
134 } catch (e) {
135 toast.error(`Failed: ${e instanceof Error ? e.message : String(e)}`)
136 }
137 }
138
139 return (
140 <div className="p-4 space-y-3 w-full">
141 <div className="flex flex-wrap items-center justify-between gap-2">
142 <h3 className="text-lg font-semibold">Logs</h3>
143 <div className="flex items-center gap-2 flex-wrap">
144 <label className="text-sm text-muted-foreground">Level:</label>
145 <select
146 value={selectedLevel}
147 onChange={(e) => handleLevelChange(e.target.value)}
148 className="rounded-md border bg-card px-2 py-1 text-sm"
149 >
150 {LOG_LEVELS.map((l) => (
151 <option key={l} value={l}>
152 {l}
153 </option>
154 ))}
155 </select>
156 <Button variant="outline" size="sm" onClick={handleClear} disabled={isLoading || logs.length === 0}>
157 Clear
158 </Button>
159 <Button size="sm" onClick={() => loadLogs(true)} disabled={isLoading}>
160 {isLoading ? 'Loading...' : 'Refresh'}
161 </Button>
162 </div>
163 </div>
164
165 {error && <div className="rounded-md bg-destructive/10 text-destructive p-3 text-sm">{error}</div>}
166
167 <div className="text-xs text-muted-foreground">
168 Showing {logs.length} of {totalLogs} logs (Level: {currentLevel})
169 </div>
170
171 <div className="space-y-1">
172 {logs.length === 0 && !isLoading ? (
173 <div className="text-center py-8 text-muted-foreground">No logs available.</div>
174 ) : (
175 logs.map((log, i) => (
176 <div
177 key={i}
178 className="flex items-start gap-2 rounded-md bg-card px-3 py-2 font-mono text-xs"
179 >
180 <span className="text-muted-foreground whitespace-nowrap shrink-0">
181 {log.timestamp ? new Date(log.timestamp).toLocaleString() : ''}
182 </span>
183 <span
184 className={cn(
185 'rounded px-1.5 py-0.5 text-center font-bold uppercase shrink-0 min-w-[3.5em]',
186 levelColor(log.level)
187 )}
188 >
189 {log.level}
190 </span>
191 {log.file && (
192 <span className="text-muted-foreground/50 shrink-0">
193 {log.file}:{log.line}
194 </span>
195 )}
196 <span className="flex-1 break-all">{log.message}</span>
197 </div>
198 ))
199 )}
200 <div ref={triggerRef} className="text-center py-4 text-xs text-muted-foreground">
201 {isLoading ? 'Loading more...' : hasMore ? 'Scroll for more' : logs.length > 0 ? 'End of logs' : ''}
202 </div>
203 </div>
204 </div>
205 )
206 }
207