|
|
@@ -1,8 +1,8 @@
|
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
|
-import { X, Play, Square, Trash2, RefreshCw, ArrowDown, ArrowUp } from 'lucide-react';
|
|
|
+import { X, Play, Square, Trash2, RefreshCw, ArrowDown, ArrowUp, Search } from 'lucide-react';
|
|
|
import { api, type MQTTLogEntry } from '../api/client';
|
|
|
import { Button } from './Button';
|
|
|
-import { useState, useEffect, useRef } from 'react';
|
|
|
+import { useState, useEffect, useRef, useMemo } from 'react';
|
|
|
|
|
|
interface MQTTDebugModalProps {
|
|
|
printerId: number;
|
|
|
@@ -14,6 +14,8 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
|
|
|
const queryClient = useQueryClient();
|
|
|
const [autoScroll, setAutoScroll] = useState(true);
|
|
|
const [expandedLogs, setExpandedLogs] = useState<Set<number>>(new Set());
|
|
|
+ const [searchQuery, setSearchQuery] = useState('');
|
|
|
+ const [directionFilter, setDirectionFilter] = useState<'all' | 'in' | 'out'>('all');
|
|
|
const logContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
const { data, isLoading, refetch } = useQuery({
|
|
|
@@ -76,8 +78,13 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
|
|
|
return date.toLocaleTimeString('en-US', { hour12: false, fractionalSecondDigits: 3 });
|
|
|
};
|
|
|
|
|
|
- const formatPayload = (payload: Record<string, unknown>, expanded: boolean) => {
|
|
|
- const json = JSON.stringify(payload, null, expanded ? 2 : 0);
|
|
|
+ const formatPayload = (payload: unknown, expanded: boolean): string => {
|
|
|
+ if (payload === undefined || payload === null) {
|
|
|
+ return '<empty>';
|
|
|
+ }
|
|
|
+ // If payload is already a string, parse it first to format nicely
|
|
|
+ const obj = typeof payload === 'string' ? JSON.parse(payload) : payload;
|
|
|
+ const json = JSON.stringify(obj, null, expanded ? 2 : 0);
|
|
|
if (!expanded && json.length > 100) {
|
|
|
return json.substring(0, 100) + '...';
|
|
|
}
|
|
|
@@ -87,6 +94,25 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
|
|
|
const loggingEnabled = data?.logging_enabled ?? false;
|
|
|
const logs = data?.logs ?? [];
|
|
|
|
|
|
+ // Filter logs based on search query and direction filter
|
|
|
+ const filteredLogs = useMemo(() => {
|
|
|
+ return logs.filter((log) => {
|
|
|
+ // Direction filter
|
|
|
+ if (directionFilter !== 'all' && log.direction !== directionFilter) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ // Search filter
|
|
|
+ if (searchQuery.trim()) {
|
|
|
+ const query = searchQuery.toLowerCase();
|
|
|
+ const topicMatch = log.topic.toLowerCase().includes(query);
|
|
|
+ const payloadStr = JSON.stringify(log.payload).toLowerCase();
|
|
|
+ const payloadMatch = payloadStr.includes(query);
|
|
|
+ return topicMatch || payloadMatch;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ });
|
|
|
+ }, [logs, searchQuery, directionFilter]);
|
|
|
+
|
|
|
return (
|
|
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
|
|
<div className="bg-bambu-dark-secondary rounded-lg max-w-4xl w-full max-h-[85vh] flex flex-col">
|
|
|
@@ -105,63 +131,121 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
|
|
|
</div>
|
|
|
|
|
|
{/* Controls */}
|
|
|
- <div className="flex items-center gap-2 p-4 border-b border-bambu-dark-tertiary">
|
|
|
- {loggingEnabled ? (
|
|
|
+ <div className="flex flex-col gap-2 p-4 border-b border-bambu-dark-tertiary">
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ {loggingEnabled ? (
|
|
|
+ <Button
|
|
|
+ size="sm"
|
|
|
+ variant="secondary"
|
|
|
+ onClick={() => disableMutation.mutate()}
|
|
|
+ disabled={disableMutation.isPending}
|
|
|
+ >
|
|
|
+ <Square className="w-4 h-4" />
|
|
|
+ Stop
|
|
|
+ </Button>
|
|
|
+ ) : (
|
|
|
+ <Button
|
|
|
+ size="sm"
|
|
|
+ onClick={() => enableMutation.mutate()}
|
|
|
+ disabled={enableMutation.isPending}
|
|
|
+ >
|
|
|
+ <Play className="w-4 h-4" />
|
|
|
+ Start Logging
|
|
|
+ </Button>
|
|
|
+ )}
|
|
|
<Button
|
|
|
size="sm"
|
|
|
variant="secondary"
|
|
|
- onClick={() => disableMutation.mutate()}
|
|
|
- disabled={disableMutation.isPending}
|
|
|
+ onClick={() => clearMutation.mutate()}
|
|
|
+ disabled={clearMutation.isPending || logs.length === 0}
|
|
|
>
|
|
|
- <Square className="w-4 h-4" />
|
|
|
- Stop
|
|
|
+ <Trash2 className="w-4 h-4" />
|
|
|
+ Clear
|
|
|
</Button>
|
|
|
- ) : (
|
|
|
<Button
|
|
|
size="sm"
|
|
|
- onClick={() => enableMutation.mutate()}
|
|
|
- disabled={enableMutation.isPending}
|
|
|
+ variant="secondary"
|
|
|
+ onClick={() => refetch()}
|
|
|
+ disabled={isLoading}
|
|
|
>
|
|
|
- <Play className="w-4 h-4" />
|
|
|
- Start Logging
|
|
|
+ <RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
|
|
</Button>
|
|
|
- )}
|
|
|
- <Button
|
|
|
- size="sm"
|
|
|
- variant="secondary"
|
|
|
- onClick={() => clearMutation.mutate()}
|
|
|
- disabled={clearMutation.isPending || logs.length === 0}
|
|
|
- >
|
|
|
- <Trash2 className="w-4 h-4" />
|
|
|
- Clear
|
|
|
- </Button>
|
|
|
- <Button
|
|
|
- size="sm"
|
|
|
- variant="secondary"
|
|
|
- onClick={() => refetch()}
|
|
|
- disabled={isLoading}
|
|
|
- >
|
|
|
- <RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
|
|
- </Button>
|
|
|
- <div className="flex-1" />
|
|
|
- <label className="flex items-center gap-2 text-sm text-bambu-gray cursor-pointer">
|
|
|
- <input
|
|
|
- type="checkbox"
|
|
|
- checked={autoScroll}
|
|
|
- onChange={(e) => setAutoScroll(e.target.checked)}
|
|
|
- className="rounded border-bambu-dark-tertiary"
|
|
|
- />
|
|
|
- Auto-scroll
|
|
|
- </label>
|
|
|
- <span className="text-sm text-bambu-gray">
|
|
|
- {logs.length} message{logs.length !== 1 ? 's' : ''}
|
|
|
- </span>
|
|
|
+ <div className="flex-1" />
|
|
|
+ <label className="flex items-center gap-2 text-sm text-bambu-gray cursor-pointer">
|
|
|
+ <input
|
|
|
+ type="checkbox"
|
|
|
+ checked={autoScroll}
|
|
|
+ onChange={(e) => setAutoScroll(e.target.checked)}
|
|
|
+ className="rounded border-bambu-dark-tertiary"
|
|
|
+ />
|
|
|
+ Auto-scroll
|
|
|
+ </label>
|
|
|
+ <span className="text-sm text-bambu-gray">
|
|
|
+ {filteredLogs.length}/{logs.length}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Search and Filter Row */}
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <div className="relative flex-1">
|
|
|
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ placeholder="Search topic or payload..."
|
|
|
+ value={searchQuery}
|
|
|
+ onChange={(e) => setSearchQuery(e.target.value)}
|
|
|
+ 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"
|
|
|
+ />
|
|
|
+ {searchQuery && (
|
|
|
+ <button
|
|
|
+ onClick={() => setSearchQuery('')}
|
|
|
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
|
|
|
+ >
|
|
|
+ <X className="w-4 h-4" />
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ <div className="flex items-center gap-1 bg-bambu-dark rounded border border-bambu-dark-tertiary">
|
|
|
+ <button
|
|
|
+ onClick={() => setDirectionFilter('all')}
|
|
|
+ className={`px-2 py-1.5 text-xs rounded-l transition-colors ${
|
|
|
+ directionFilter === 'all'
|
|
|
+ ? 'bg-bambu-green text-white'
|
|
|
+ : 'text-bambu-gray hover:text-white'
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ All
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={() => setDirectionFilter('in')}
|
|
|
+ className={`px-2 py-1.5 text-xs transition-colors flex items-center gap-1 ${
|
|
|
+ directionFilter === 'in'
|
|
|
+ ? 'bg-blue-500 text-white'
|
|
|
+ : 'text-bambu-gray hover:text-white'
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ <ArrowDown className="w-3 h-3" />
|
|
|
+ In
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={() => setDirectionFilter('out')}
|
|
|
+ className={`px-2 py-1.5 text-xs rounded-r transition-colors flex items-center gap-1 ${
|
|
|
+ directionFilter === 'out'
|
|
|
+ ? 'bg-green-500 text-white'
|
|
|
+ : 'text-bambu-gray hover:text-white'
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ <ArrowUp className="w-3 h-3" />
|
|
|
+ Out
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
|
|
|
{/* Log Content */}
|
|
|
<div
|
|
|
ref={logContainerRef}
|
|
|
- className="flex-1 overflow-auto p-4 font-mono text-xs bg-bambu-dark min-h-[400px]"
|
|
|
+ className="flex-1 overflow-auto p-4 font-mono text-xs bg-black min-h-[400px]"
|
|
|
>
|
|
|
{logs.length === 0 ? (
|
|
|
<div className="flex flex-col items-center justify-center h-full text-bambu-gray">
|
|
|
@@ -170,9 +254,14 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
|
|
|
<p className="text-sm">Click "Start Logging" to begin capturing MQTT messages</p>
|
|
|
)}
|
|
|
</div>
|
|
|
+ ) : filteredLogs.length === 0 ? (
|
|
|
+ <div className="flex flex-col items-center justify-center h-full text-bambu-gray">
|
|
|
+ <p className="mb-2">No messages match your filter</p>
|
|
|
+ <p className="text-sm">Try adjusting your search or filter criteria</p>
|
|
|
+ </div>
|
|
|
) : (
|
|
|
<div className="space-y-1">
|
|
|
- {logs.map((log: MQTTLogEntry, index: number) => {
|
|
|
+ {filteredLogs.map((log: MQTTLogEntry, index: number) => {
|
|
|
const isExpanded = expandedLogs.has(index);
|
|
|
const isIncoming = log.direction === 'in';
|
|
|
|
|
|
@@ -202,13 +291,15 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
|
|
|
</span>
|
|
|
<span className="text-purple-400 shrink-0">{log.topic}</span>
|
|
|
</div>
|
|
|
- <pre
|
|
|
- className={`mt-1 text-white/80 overflow-x-auto ${
|
|
|
- isExpanded ? 'whitespace-pre-wrap' : 'truncate'
|
|
|
- }`}
|
|
|
- >
|
|
|
- {formatPayload(log.payload, isExpanded)}
|
|
|
- </pre>
|
|
|
+ {isExpanded ? (
|
|
|
+ <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">
|
|
|
+ {formatPayload(log.payload, true)}
|
|
|
+ </pre>
|
|
|
+ ) : (
|
|
|
+ <pre className="mt-1 text-white/80 truncate">
|
|
|
+ {formatPayload(log.payload, false)}
|
|
|
+ </pre>
|
|
|
+ )}
|
|
|
</div>
|
|
|
);
|
|
|
})}
|