Fixed bug in MQTT debug viewer; Added filter and search for MQTT messages

This commit is contained in:
maziggy
2025-12-07 11:03:11 +00:00
parent 29327d0f16
commit 0c452a12e9
6 changed files with 720 additions and 452 deletions
+146 -55
View File
@@ -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>
);
})}
+177
View File
@@ -18,6 +18,7 @@ import {
Zap,
Wrench,
ChevronDown,
Pencil,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
@@ -104,6 +105,7 @@ function PrinterCard({
const navigate = useNavigate();
const [showMenu, setShowMenu] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showFileManager, setShowFileManager] = useState(false);
const [showMQTTDebug, setShowMQTTDebug] = useState(false);
const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false);
@@ -254,6 +256,16 @@ function PrinterCard({
</Button>
{showMenu && (
<div className="absolute right-0 mt-2 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10">
<button
className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
onClick={() => {
setShowEditModal(true);
setShowMenu(false);
}}
>
<Pencil className="w-4 h-4" />
Edit
</button>
<button
className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
onClick={() => {
@@ -566,6 +578,14 @@ function PrinterCard({
onClose={() => setShowHMSModal(false)}
/>
)}
{/* Edit Printer Modal */}
{showEditModal && (
<EditPrinterModal
printer={printer}
onClose={() => setShowEditModal(false)}
/>
)}
</Card>
);
}
@@ -711,6 +731,163 @@ function AddPrinterModal({
);
}
function EditPrinterModal({
printer,
onClose,
}: {
printer: Printer;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [form, setForm] = useState({
name: printer.name,
ip_address: printer.ip_address,
access_code: '',
model: printer.model || '',
auto_archive: printer.auto_archive,
});
const updateMutation = useMutation({
mutationFn: (data: Partial<PrinterCreate>) => api.updatePrinter(printer.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['printers'] });
queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
onClose();
},
});
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data: Partial<PrinterCreate> = {
name: form.name,
ip_address: form.ip_address,
model: form.model || undefined,
auto_archive: form.auto_archive,
};
// Only include access_code if it was changed
if (form.access_code) {
data.access_code = form.access_code;
}
updateMutation.mutate(data);
};
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={onClose}
>
<Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<CardContent>
<h2 className="text-xl font-semibold mb-4">Edit Printer</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-bambu-gray mb-1">Name</label>
<input
type="text"
required
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="My Printer"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">IP Address</label>
<input
type="text"
required
pattern="\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={form.ip_address}
onChange={(e) => setForm({ ...form, ip_address: e.target.value })}
placeholder="192.168.1.100"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Serial Number</label>
<input
type="text"
disabled
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray cursor-not-allowed"
value={printer.serial_number}
/>
<p className="text-xs text-bambu-gray mt-1">Serial number cannot be changed</p>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Access Code</label>
<input
type="password"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={form.access_code}
onChange={(e) => setForm({ ...form, access_code: e.target.value })}
placeholder="Leave empty to keep current"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Model</label>
<select
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={form.model}
onChange={(e) => setForm({ ...form, model: e.target.value })}
>
<option value="">Select model...</option>
<optgroup label="H2 Series">
<option value="H2C">H2C</option>
<option value="H2D">H2D</option>
<option value="H2S">H2S</option>
</optgroup>
<optgroup label="X1 Series">
<option value="X1E">X1E</option>
<option value="X1C">X1 Carbon</option>
<option value="X1">X1</option>
</optgroup>
<optgroup label="P Series">
<option value="P2S">P2S</option>
<option value="P1S">P1S</option>
<option value="P1P">P1P</option>
</optgroup>
<optgroup label="A1 Series">
<option value="A1">A1</option>
<option value="A1 Mini">A1 Mini</option>
</optgroup>
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="edit_auto_archive"
checked={form.auto_archive}
onChange={(e) => setForm({ ...form, auto_archive: e.target.checked })}
className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<label htmlFor="edit_auto_archive" className="text-sm text-bambu-gray">
Auto-archive completed prints
</label>
</div>
<div className="flex gap-3 pt-4">
<Button type="button" variant="secondary" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button type="submit" className="flex-1" disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
// Component to check if a printer is offline (for power dropdown)
function usePrinterOfflineStatus(printerId: number) {
const { data: status } = useQuery({
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
<script type="module" crossorigin src="/assets/index-zCm77ErN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cx4S-qVJ.css">
<script type="module" crossorigin src="/assets/index-BFqTr5zP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B01KJJLK.css">
</head>
<body>
<div id="root"></div>