MQTTDebugModal.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  2. import { X, Play, Square, Trash2, RefreshCw, ArrowDown, ArrowUp, Search } from 'lucide-react';
  3. import { api, type MQTTLogEntry } from '../api/client';
  4. import { Button } from './Button';
  5. import { useState, useEffect, useRef, useMemo } from 'react';
  6. interface MQTTDebugModalProps {
  7. printerId: number;
  8. printerName: string;
  9. onClose: () => void;
  10. }
  11. export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugModalProps) {
  12. const queryClient = useQueryClient();
  13. const [autoScroll, setAutoScroll] = useState(true);
  14. const [expandedLogs, setExpandedLogs] = useState<Set<number>>(new Set());
  15. const [searchQuery, setSearchQuery] = useState('');
  16. const [directionFilter, setDirectionFilter] = useState<'all' | 'in' | 'out'>('all');
  17. const logContainerRef = useRef<HTMLDivElement>(null);
  18. const { data, isLoading, refetch } = useQuery({
  19. queryKey: ['mqtt-logs', printerId],
  20. queryFn: () => api.getMQTTLogs(printerId),
  21. refetchInterval: 1000, // Poll every second when logging is enabled
  22. });
  23. const enableMutation = useMutation({
  24. mutationFn: () => api.enableMQTTLogging(printerId),
  25. onSuccess: () => {
  26. queryClient.invalidateQueries({ queryKey: ['mqtt-logs', printerId] });
  27. },
  28. });
  29. const disableMutation = useMutation({
  30. mutationFn: () => api.disableMQTTLogging(printerId),
  31. onSuccess: () => {
  32. queryClient.invalidateQueries({ queryKey: ['mqtt-logs', printerId] });
  33. },
  34. });
  35. const clearMutation = useMutation({
  36. mutationFn: () => api.clearMQTTLogs(printerId),
  37. onSuccess: () => {
  38. queryClient.invalidateQueries({ queryKey: ['mqtt-logs', printerId] });
  39. },
  40. });
  41. // Close on Escape key
  42. useEffect(() => {
  43. const handleKeyDown = (e: KeyboardEvent) => {
  44. if (e.key === 'Escape') onClose();
  45. };
  46. window.addEventListener('keydown', handleKeyDown);
  47. return () => window.removeEventListener('keydown', handleKeyDown);
  48. }, [onClose]);
  49. // Auto-scroll to bottom when new logs arrive
  50. useEffect(() => {
  51. if (autoScroll && logContainerRef.current) {
  52. logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
  53. }
  54. }, [data?.logs, autoScroll]);
  55. const toggleExpand = (index: number) => {
  56. setExpandedLogs((prev) => {
  57. const newSet = new Set(prev);
  58. if (newSet.has(index)) {
  59. newSet.delete(index);
  60. } else {
  61. newSet.add(index);
  62. }
  63. return newSet;
  64. });
  65. };
  66. const formatTimestamp = (timestamp: string) => {
  67. const date = new Date(timestamp);
  68. return date.toLocaleTimeString('en-US', { hour12: false, fractionalSecondDigits: 3 });
  69. };
  70. const formatPayload = (payload: unknown, expanded: boolean): string => {
  71. if (payload === undefined || payload === null) {
  72. return '<empty>';
  73. }
  74. // If payload is already a string, parse it first to format nicely
  75. const obj = typeof payload === 'string' ? JSON.parse(payload) : payload;
  76. const json = JSON.stringify(obj, null, expanded ? 2 : 0);
  77. if (!expanded && json.length > 100) {
  78. return json.substring(0, 100) + '...';
  79. }
  80. return json;
  81. };
  82. const loggingEnabled = data?.logging_enabled ?? false;
  83. const logs = data?.logs ?? [];
  84. // Filter logs based on search query and direction filter
  85. const filteredLogs = useMemo(() => {
  86. return logs.filter((log) => {
  87. // Direction filter
  88. if (directionFilter !== 'all' && log.direction !== directionFilter) {
  89. return false;
  90. }
  91. // Search filter
  92. if (searchQuery.trim()) {
  93. const query = searchQuery.toLowerCase();
  94. const topicMatch = log.topic.toLowerCase().includes(query);
  95. const payloadStr = JSON.stringify(log.payload).toLowerCase();
  96. const payloadMatch = payloadStr.includes(query);
  97. return topicMatch || payloadMatch;
  98. }
  99. return true;
  100. });
  101. }, [logs, searchQuery, directionFilter]);
  102. return (
  103. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  104. <div className="bg-bambu-dark-secondary rounded-lg max-w-4xl w-full max-h-[85vh] flex flex-col">
  105. {/* Header */}
  106. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  107. <div>
  108. <h2 className="text-lg font-semibold text-white">MQTT Debug Log</h2>
  109. <p className="text-sm text-bambu-gray">{printerName}</p>
  110. </div>
  111. <button
  112. onClick={onClose}
  113. className="text-bambu-gray hover:text-white transition-colors"
  114. >
  115. <X className="w-5 h-5" />
  116. </button>
  117. </div>
  118. {/* Controls */}
  119. <div className="flex flex-col gap-2 p-4 border-b border-bambu-dark-tertiary">
  120. <div className="flex items-center gap-2">
  121. {loggingEnabled ? (
  122. <Button
  123. size="sm"
  124. variant="secondary"
  125. onClick={() => disableMutation.mutate()}
  126. disabled={disableMutation.isPending}
  127. >
  128. <Square className="w-4 h-4" />
  129. Stop
  130. </Button>
  131. ) : (
  132. <Button
  133. size="sm"
  134. onClick={() => enableMutation.mutate()}
  135. disabled={enableMutation.isPending}
  136. >
  137. <Play className="w-4 h-4" />
  138. Start Logging
  139. </Button>
  140. )}
  141. <Button
  142. size="sm"
  143. variant="secondary"
  144. onClick={() => clearMutation.mutate()}
  145. disabled={clearMutation.isPending || logs.length === 0}
  146. >
  147. <Trash2 className="w-4 h-4" />
  148. Clear
  149. </Button>
  150. <Button
  151. size="sm"
  152. variant="secondary"
  153. onClick={() => refetch()}
  154. disabled={isLoading}
  155. >
  156. <RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
  157. </Button>
  158. <div className="flex-1" />
  159. <label className="flex items-center gap-2 text-sm text-bambu-gray cursor-pointer">
  160. <input
  161. type="checkbox"
  162. checked={autoScroll}
  163. onChange={(e) => setAutoScroll(e.target.checked)}
  164. className="rounded border-bambu-dark-tertiary"
  165. />
  166. Auto-scroll
  167. </label>
  168. <span className="text-sm text-bambu-gray">
  169. {filteredLogs.length}/{logs.length}
  170. </span>
  171. </div>
  172. {/* Search and Filter Row */}
  173. <div className="flex items-center gap-2">
  174. <div className="relative flex-1">
  175. <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
  176. <input
  177. type="text"
  178. placeholder="Search topic or payload..."
  179. value={searchQuery}
  180. onChange={(e) => setSearchQuery(e.target.value)}
  181. className="w-full pl-8 pr-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
  182. />
  183. {searchQuery && (
  184. <button
  185. onClick={() => setSearchQuery('')}
  186. className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
  187. >
  188. <X className="w-4 h-4" />
  189. </button>
  190. )}
  191. </div>
  192. <div className="flex items-center gap-1 bg-bambu-dark rounded border border-bambu-dark-tertiary">
  193. <button
  194. onClick={() => setDirectionFilter('all')}
  195. className={`px-2 py-1.5 text-xs rounded-l transition-colors ${
  196. directionFilter === 'all'
  197. ? 'bg-bambu-green text-white'
  198. : 'text-bambu-gray hover:text-white'
  199. }`}
  200. >
  201. All
  202. </button>
  203. <button
  204. onClick={() => setDirectionFilter('in')}
  205. className={`px-2 py-1.5 text-xs transition-colors flex items-center gap-1 ${
  206. directionFilter === 'in'
  207. ? 'bg-blue-500 text-white'
  208. : 'text-bambu-gray hover:text-white'
  209. }`}
  210. >
  211. <ArrowDown className="w-3 h-3" />
  212. In
  213. </button>
  214. <button
  215. onClick={() => setDirectionFilter('out')}
  216. className={`px-2 py-1.5 text-xs rounded-r transition-colors flex items-center gap-1 ${
  217. directionFilter === 'out'
  218. ? 'bg-green-500 text-white'
  219. : 'text-bambu-gray hover:text-white'
  220. }`}
  221. >
  222. <ArrowUp className="w-3 h-3" />
  223. Out
  224. </button>
  225. </div>
  226. </div>
  227. </div>
  228. {/* Log Content */}
  229. <div
  230. ref={logContainerRef}
  231. className="flex-1 overflow-auto p-4 font-mono text-xs bg-black min-h-[400px]"
  232. >
  233. {logs.length === 0 ? (
  234. <div className="flex flex-col items-center justify-center h-full text-bambu-gray">
  235. <p className="mb-2">No messages logged yet</p>
  236. {!loggingEnabled && (
  237. <p className="text-sm">Click "Start Logging" to begin capturing MQTT messages</p>
  238. )}
  239. </div>
  240. ) : filteredLogs.length === 0 ? (
  241. <div className="flex flex-col items-center justify-center h-full text-bambu-gray">
  242. <p className="mb-2">No messages match your filter</p>
  243. <p className="text-sm">Try adjusting your search or filter criteria</p>
  244. </div>
  245. ) : (
  246. <div className="space-y-1">
  247. {filteredLogs.map((log: MQTTLogEntry, index: number) => {
  248. const isExpanded = expandedLogs.has(index);
  249. const isIncoming = log.direction === 'in';
  250. return (
  251. <div
  252. key={index}
  253. className={`p-2 rounded cursor-pointer hover:bg-bambu-dark-secondary transition-colors ${
  254. isExpanded ? 'bg-bambu-dark-secondary' : ''
  255. }`}
  256. onClick={() => toggleExpand(index)}
  257. >
  258. <div className="flex items-start gap-2">
  259. <span className="text-bambu-gray shrink-0">
  260. {formatTimestamp(log.timestamp)}
  261. </span>
  262. <span
  263. className={`shrink-0 ${
  264. isIncoming ? 'text-blue-400' : 'text-green-400'
  265. }`}
  266. title={isIncoming ? 'Incoming' : 'Outgoing'}
  267. >
  268. {isIncoming ? (
  269. <ArrowDown className="w-3 h-3" />
  270. ) : (
  271. <ArrowUp className="w-3 h-3" />
  272. )}
  273. </span>
  274. <span className="text-purple-400 shrink-0">{log.topic}</span>
  275. </div>
  276. {isExpanded ? (
  277. <pre className="mt-2 p-3 bg-gray-900 border border-gray-700 rounded text-green-400 overflow-x-auto whitespace-pre-wrap break-all max-h-96 overflow-y-auto text-xs">
  278. {formatPayload(log.payload, true)}
  279. </pre>
  280. ) : (
  281. <pre className="mt-1 text-white/80 truncate">
  282. {formatPayload(log.payload, false)}
  283. </pre>
  284. )}
  285. </div>
  286. );
  287. })}
  288. </div>
  289. )}
  290. </div>
  291. {/* Footer */}
  292. <div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary">
  293. <div className="text-sm text-bambu-gray">
  294. {loggingEnabled ? (
  295. <span className="flex items-center gap-2">
  296. <span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
  297. Logging active - messages will auto-refresh
  298. </span>
  299. ) : (
  300. <span>Logging stopped</span>
  301. )}
  302. </div>
  303. <Button variant="secondary" onClick={onClose}>
  304. Close
  305. </Button>
  306. </div>
  307. </div>
  308. </div>
  309. );
  310. }