mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
Fixed bug in MQTT debug viewer; Added filter and search for MQTT messages
This commit is contained in:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user