LogViewer.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. import { useState, useEffect, useRef, useMemo } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import {
  4. Play,
  5. Square,
  6. Trash2,
  7. RefreshCw,
  8. Search,
  9. X,
  10. ChevronDown,
  11. ChevronUp,
  12. AlertCircle,
  13. AlertTriangle,
  14. Info,
  15. Bug,
  16. } from 'lucide-react';
  17. import { supportApi, type LogEntry } from '../api/client';
  18. const LOG_LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR'] as const;
  19. type LogLevel = (typeof LOG_LEVELS)[number];
  20. const levelColors: Record<LogLevel, string> = {
  21. DEBUG: 'text-gray-400',
  22. INFO: 'text-blue-400',
  23. WARNING: 'text-yellow-400',
  24. ERROR: 'text-red-400',
  25. };
  26. const levelIcons: Record<LogLevel, typeof Info> = {
  27. DEBUG: Bug,
  28. INFO: Info,
  29. WARNING: AlertTriangle,
  30. ERROR: AlertCircle,
  31. };
  32. export function LogViewer() {
  33. const queryClient = useQueryClient();
  34. const [autoScroll, setAutoScroll] = useState(true);
  35. const [expandedLogs, setExpandedLogs] = useState<Set<number>>(new Set());
  36. const [searchQuery, setSearchQuery] = useState('');
  37. const [levelFilter, setLevelFilter] = useState<LogLevel | 'ALL'>('ALL');
  38. const [isExpanded, setIsExpanded] = useState(false);
  39. const [isStreaming, setIsStreaming] = useState(false);
  40. const logContainerRef = useRef<HTMLDivElement>(null);
  41. // Fetch logs with polling when streaming is enabled
  42. const { data, isLoading, refetch } = useQuery({
  43. queryKey: ['application-logs', levelFilter, searchQuery],
  44. queryFn: () =>
  45. supportApi.getLogs({
  46. limit: 200,
  47. level: levelFilter === 'ALL' ? undefined : levelFilter,
  48. search: searchQuery || undefined,
  49. }),
  50. refetchInterval: isStreaming ? 2000 : false, // Poll every 2 seconds when streaming
  51. enabled: isExpanded, // Only fetch when viewer is expanded
  52. });
  53. // Stop streaming when viewer is collapsed
  54. useEffect(() => {
  55. if (!isExpanded) {
  56. setIsStreaming(false);
  57. }
  58. }, [isExpanded]);
  59. const clearMutation = useMutation({
  60. mutationFn: () => supportApi.clearLogs(),
  61. onSuccess: () => {
  62. queryClient.invalidateQueries({ queryKey: ['application-logs'] });
  63. },
  64. });
  65. // Auto-scroll to bottom when new logs arrive
  66. useEffect(() => {
  67. if (autoScroll && logContainerRef.current && data?.entries) {
  68. logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
  69. }
  70. }, [data?.entries, autoScroll]);
  71. const toggleExpand = (index: number) => {
  72. setExpandedLogs((prev) => {
  73. const newSet = new Set(prev);
  74. if (newSet.has(index)) {
  75. newSet.delete(index);
  76. } else {
  77. newSet.add(index);
  78. }
  79. return newSet;
  80. });
  81. };
  82. const formatTimestamp = (timestamp: string) => {
  83. // Input format: "2024-01-15 10:30:45,123"
  84. const parts = timestamp.split(' ');
  85. if (parts.length >= 2) {
  86. return parts[1]; // Return just the time part
  87. }
  88. return timestamp;
  89. };
  90. const entries = useMemo(() => data?.entries ?? [], [data?.entries]);
  91. // Reverse to show newest at bottom (better for auto-scroll UX)
  92. const displayEntries = useMemo(() => [...entries].reverse(), [entries]);
  93. const LevelIcon = ({ level }: { level: string }) => {
  94. const Icon = levelIcons[level as LogLevel] || Info;
  95. return <Icon className={`w-3.5 h-3.5 ${levelColors[level as LogLevel] || 'text-gray-400'}`} />;
  96. };
  97. return (
  98. <div className="bg-bambu-dark rounded-lg overflow-hidden">
  99. {/* Header - always visible */}
  100. <button
  101. onClick={() => setIsExpanded(!isExpanded)}
  102. className="w-full flex items-center justify-between p-4 hover:bg-bambu-dark-tertiary/50 transition-colors"
  103. >
  104. <div className="flex items-center gap-3">
  105. <div
  106. className={`p-2 rounded-lg ${
  107. isStreaming
  108. ? 'bg-bambu-green/20 text-bambu-green'
  109. : 'bg-bambu-dark-tertiary text-bambu-gray'
  110. }`}
  111. >
  112. <Bug className="w-5 h-5" />
  113. </div>
  114. <div className="text-left">
  115. <p className="font-medium text-white">Application Logs</p>
  116. <p className="text-sm text-bambu-gray">
  117. {isStreaming
  118. ? `Live streaming - ${data?.filtered_count ?? 0} entries`
  119. : 'View and filter application logs'}
  120. </p>
  121. </div>
  122. </div>
  123. <div className="flex items-center gap-2">
  124. {isStreaming && (
  125. <span className="flex items-center gap-1.5 px-2 py-1 bg-bambu-green/20 rounded text-bambu-green text-xs">
  126. <span className="w-1.5 h-1.5 bg-bambu-green rounded-full animate-pulse" />
  127. Live
  128. </span>
  129. )}
  130. {isExpanded ? (
  131. <ChevronUp className="w-5 h-5 text-bambu-gray" />
  132. ) : (
  133. <ChevronDown className="w-5 h-5 text-bambu-gray" />
  134. )}
  135. </div>
  136. </button>
  137. {/* Expanded content */}
  138. {isExpanded && (
  139. <div className="border-t border-bambu-dark-tertiary">
  140. {/* Controls */}
  141. <div className="flex flex-col gap-2 p-4 border-b border-bambu-dark-tertiary">
  142. <div className="flex items-center gap-2 flex-wrap">
  143. {/* Start/Stop streaming button */}
  144. {isStreaming ? (
  145. <button
  146. onClick={(e) => {
  147. e.stopPropagation();
  148. setIsStreaming(false);
  149. }}
  150. className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded transition-colors"
  151. >
  152. <Square className="w-4 h-4" />
  153. Stop
  154. </button>
  155. ) : (
  156. <button
  157. onClick={(e) => {
  158. e.stopPropagation();
  159. setIsStreaming(true);
  160. refetch(); // Immediately fetch when starting
  161. }}
  162. className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 rounded transition-colors"
  163. >
  164. <Play className="w-4 h-4" />
  165. Start
  166. </button>
  167. )}
  168. {/* Clear button */}
  169. <button
  170. onClick={() => clearMutation.mutate()}
  171. disabled={clearMutation.isPending || entries.length === 0}
  172. className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-bambu-dark-tertiary text-bambu-gray hover:text-white hover:bg-bambu-dark-secondary rounded transition-colors disabled:opacity-50"
  173. >
  174. <Trash2 className="w-4 h-4" />
  175. Clear
  176. </button>
  177. {/* Refresh button */}
  178. <button
  179. onClick={() => refetch()}
  180. disabled={isLoading}
  181. className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-bambu-dark-tertiary text-bambu-gray hover:text-white hover:bg-bambu-dark-secondary rounded transition-colors disabled:opacity-50"
  182. >
  183. <RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
  184. </button>
  185. <div className="flex-1" />
  186. {/* Auto-scroll toggle */}
  187. <label className="flex items-center gap-2 text-sm text-bambu-gray cursor-pointer">
  188. <input
  189. type="checkbox"
  190. checked={autoScroll}
  191. onChange={(e) => setAutoScroll(e.target.checked)}
  192. className="rounded border-bambu-dark-tertiary bg-bambu-dark-tertiary"
  193. />
  194. Auto-scroll
  195. </label>
  196. {/* Entry count */}
  197. <span className="text-sm text-bambu-gray">
  198. {data?.filtered_count ?? 0}/{data?.total_in_file ?? 0}
  199. </span>
  200. </div>
  201. {/* Search and Filter Row */}
  202. <div className="flex items-center gap-2">
  203. {/* Search input */}
  204. <div className="relative flex-1">
  205. <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
  206. <input
  207. type="text"
  208. placeholder="Search message or logger name..."
  209. value={searchQuery}
  210. onChange={(e) => setSearchQuery(e.target.value)}
  211. className="w-full pl-8 pr-8 py-1.5 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
  212. />
  213. {searchQuery && (
  214. <button
  215. onClick={() => setSearchQuery('')}
  216. className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
  217. >
  218. <X className="w-4 h-4" />
  219. </button>
  220. )}
  221. </div>
  222. {/* Level filter */}
  223. <div className="flex items-center gap-1 bg-bambu-dark-secondary rounded border border-bambu-dark-tertiary">
  224. <button
  225. onClick={() => setLevelFilter('ALL')}
  226. className={`px-2 py-1.5 text-xs rounded-l transition-colors ${
  227. levelFilter === 'ALL'
  228. ? 'bg-bambu-green text-white'
  229. : 'text-bambu-gray hover:text-white'
  230. }`}
  231. >
  232. All
  233. </button>
  234. {LOG_LEVELS.map((level, idx) => (
  235. <button
  236. key={level}
  237. onClick={() => setLevelFilter(level)}
  238. className={`px-2 py-1.5 text-xs transition-colors flex items-center gap-1 ${
  239. idx === LOG_LEVELS.length - 1 ? 'rounded-r' : ''
  240. } ${
  241. levelFilter === level
  242. ? `${levelColors[level]} bg-bambu-dark-tertiary`
  243. : 'text-bambu-gray hover:text-white'
  244. }`}
  245. >
  246. {level}
  247. </button>
  248. ))}
  249. </div>
  250. </div>
  251. </div>
  252. {/* Log Content */}
  253. <div
  254. ref={logContainerRef}
  255. className="overflow-auto font-mono text-xs bg-black min-h-[300px] max-h-[500px]"
  256. >
  257. {entries.length === 0 ? (
  258. <div className="flex flex-col items-center justify-center h-[300px] text-bambu-gray">
  259. <p className="mb-2">No log entries found</p>
  260. <p className="text-sm">Log file may be empty or cleared</p>
  261. </div>
  262. ) : (
  263. <div className="divide-y divide-bambu-dark-tertiary/30">
  264. {displayEntries.map((log: LogEntry, index: number) => {
  265. const isEntryExpanded = expandedLogs.has(index);
  266. const hasMultiLine = log.message.includes('\n');
  267. return (
  268. <div
  269. key={index}
  270. className={`p-2 cursor-pointer hover:bg-bambu-dark-secondary/50 transition-colors ${
  271. isEntryExpanded ? 'bg-bambu-dark-secondary/30' : ''
  272. }`}
  273. onClick={() => hasMultiLine && toggleExpand(index)}
  274. >
  275. <div className="flex items-start gap-2">
  276. <span className="text-bambu-gray/70 shrink-0 w-20">
  277. {formatTimestamp(log.timestamp)}
  278. </span>
  279. <span className="shrink-0">
  280. <LevelIcon level={log.level} />
  281. </span>
  282. <span className="text-purple-400/80 shrink-0 max-w-[200px] truncate" title={log.logger_name}>
  283. [{log.logger_name}]
  284. </span>
  285. <span
  286. className={`flex-1 ${levelColors[log.level as LogLevel] || 'text-white/80'} ${
  287. !isEntryExpanded && hasMultiLine ? 'truncate' : ''
  288. }`}
  289. >
  290. {isEntryExpanded ? (
  291. <pre className="whitespace-pre-wrap break-all">{log.message}</pre>
  292. ) : (
  293. log.message.split('\n')[0]
  294. )}
  295. </span>
  296. {hasMultiLine && (
  297. <span className="text-bambu-gray/50 shrink-0">
  298. {isEntryExpanded ? (
  299. <ChevronUp className="w-3.5 h-3.5" />
  300. ) : (
  301. <ChevronDown className="w-3.5 h-3.5" />
  302. )}
  303. </span>
  304. )}
  305. </div>
  306. </div>
  307. );
  308. })}
  309. </div>
  310. )}
  311. </div>
  312. {/* Footer */}
  313. <div className="flex items-center justify-between p-3 border-t border-bambu-dark-tertiary text-sm text-bambu-gray">
  314. {isStreaming ? (
  315. <span className="flex items-center gap-2">
  316. <span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
  317. Auto-refreshing every 2 seconds
  318. </span>
  319. ) : (
  320. <span>Click Start to enable live log streaming</span>
  321. )}
  322. </div>
  323. </div>
  324. )}
  325. </div>
  326. );
  327. }