Merge pull request 'feat(webui): system log tail viewer (#275)' (#277) from sl-webui/issue-275-log-viewer into main
This commit is contained in:
commit
54bc37926b
@ -267,6 +267,8 @@ export default function App() {
|
||||
|
||||
{activeTab === 'eventlog' && <EventLog subscribe={subscribe} />}
|
||||
|
||||
{activeTab === 'logs' && <LogViewer subscribe={subscribe} />}
|
||||
|
||||
{activeTab === 'network' && <NetworkPanel subscribe={subscribe} connected={connected} wsUrl={wsUrl} />}
|
||||
|
||||
{activeTab === 'settings' && <SettingsPanel subscribe={subscribe} callService={callService} connected={connected} wsUrl={wsUrl} />}
|
||||
|
||||
251
ui/social-bot/src/components/LogViewer.jsx
Normal file
251
ui/social-bot/src/components/LogViewer.jsx
Normal file
@ -0,0 +1,251 @@
|
||||
/**
|
||||
* LogViewer.jsx — System log tail viewer
|
||||
*
|
||||
* Features:
|
||||
* - Subscribes to /rosout (rcl_interfaces/Log)
|
||||
* - Real-time scrolling log output
|
||||
* - Severity-based color coding (DEBUG=grey, INFO=white, WARN=yellow, ERROR=red, FATAL=magenta)
|
||||
* - Filter by severity level
|
||||
* - Filter by node name
|
||||
* - Auto-scroll to latest logs
|
||||
* - Configurable max log history (default 500)
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const LOG_LEVELS = {
|
||||
DEBUG: 'DEBUG',
|
||||
INFO: 'INFO',
|
||||
WARN: 'WARN',
|
||||
ERROR: 'ERROR',
|
||||
FATAL: 'FATAL',
|
||||
};
|
||||
|
||||
const LOG_LEVEL_VALUES = {
|
||||
DEBUG: 10,
|
||||
INFO: 20,
|
||||
WARN: 30,
|
||||
ERROR: 40,
|
||||
FATAL: 50,
|
||||
};
|
||||
|
||||
const LOG_COLORS = {
|
||||
DEBUG: { bg: 'bg-gray-950', border: 'border-gray-800', text: 'text-gray-500', label: 'text-gray-500' },
|
||||
INFO: { bg: 'bg-gray-950', border: 'border-gray-800', text: 'text-gray-300', label: 'text-white' },
|
||||
WARN: { bg: 'bg-gray-950', border: 'border-yellow-900', text: 'text-yellow-400', label: 'text-yellow-500' },
|
||||
ERROR: { bg: 'bg-gray-950', border: 'border-red-900', text: 'text-red-400', label: 'text-red-500' },
|
||||
FATAL: { bg: 'bg-gray-950', border: 'border-magenta-900', text: 'text-magenta-400', label: 'text-magenta-500' },
|
||||
};
|
||||
|
||||
const MAX_LOGS = 500;
|
||||
|
||||
function formatTimestamp(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function getLevelName(level) {
|
||||
// Convert numeric level to name
|
||||
if (level <= LOG_LEVEL_VALUES.DEBUG) return LOG_LEVELS.DEBUG;
|
||||
if (level <= LOG_LEVEL_VALUES.INFO) return LOG_LEVELS.INFO;
|
||||
if (level <= LOG_LEVEL_VALUES.WARN) return LOG_LEVELS.WARN;
|
||||
if (level <= LOG_LEVEL_VALUES.ERROR) return LOG_LEVELS.ERROR;
|
||||
return LOG_LEVELS.FATAL;
|
||||
}
|
||||
|
||||
function LogLine({ log, colors }) {
|
||||
return (
|
||||
<div className={`font-mono text-xs py-1 px-2 border-l-2 ${colors.border} ${colors.bg}`}>
|
||||
<div className="flex gap-2 items-start">
|
||||
<span className={`font-bold text-xs whitespace-nowrap flex-shrink-0 ${colors.label}`}>
|
||||
{log.level.padEnd(5)}
|
||||
</span>
|
||||
<span className="text-gray-600 whitespace-nowrap flex-shrink-0">
|
||||
{formatTimestamp(log.timestamp)}
|
||||
</span>
|
||||
<span className="text-cyan-600 whitespace-nowrap flex-shrink-0 min-w-32 truncate">
|
||||
[{log.node}]
|
||||
</span>
|
||||
<span className={`${colors.text} flex-1 break-words`}>
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogViewer({ subscribe }) {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [selectedLevels, setSelectedLevels] = useState(new Set(['INFO', 'WARN', 'ERROR', 'FATAL']));
|
||||
const [nodeFilter, setNodeFilter] = useState('');
|
||||
const scrollRef = useRef(null);
|
||||
const logIdRef = useRef(0);
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
setTimeout(() => {
|
||||
scrollRef.current?.scrollIntoView({ behavior: 'auto', block: 'end' });
|
||||
}, 0);
|
||||
}
|
||||
}, [logs.length]);
|
||||
|
||||
// Subscribe to ROS logs
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribe(
|
||||
'/rosout',
|
||||
'rcl_interfaces/Log',
|
||||
(msg) => {
|
||||
try {
|
||||
const levelName = getLevelName(msg.level);
|
||||
const logEntry = {
|
||||
id: ++logIdRef.current,
|
||||
timestamp: msg.stamp ? msg.stamp.sec * 1000 + msg.stamp.nanosec / 1000000 : Date.now(),
|
||||
level: levelName,
|
||||
node: msg.name || 'unknown',
|
||||
message: msg.msg || '',
|
||||
file: msg.file || '',
|
||||
function: msg.function || '',
|
||||
line: msg.line || 0,
|
||||
};
|
||||
|
||||
setLogs((prev) => [...prev, logEntry].slice(-MAX_LOGS));
|
||||
} catch (e) {
|
||||
console.error('Error parsing log message:', e);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, [subscribe]);
|
||||
|
||||
// Toggle level selection
|
||||
const toggleLevel = (level) => {
|
||||
const updated = new Set(selectedLevels);
|
||||
if (updated.has(level)) {
|
||||
updated.delete(level);
|
||||
} else {
|
||||
updated.add(level);
|
||||
}
|
||||
setSelectedLevels(updated);
|
||||
};
|
||||
|
||||
// Filter logs based on selected levels and node filter
|
||||
const filteredLogs = logs.filter((log) => {
|
||||
const matchesLevel = selectedLevels.has(log.level);
|
||||
const matchesNode = nodeFilter === '' || log.node.toLowerCase().includes(nodeFilter.toLowerCase());
|
||||
return matchesLevel && matchesNode;
|
||||
});
|
||||
|
||||
const clearLogs = () => {
|
||||
setLogs([]);
|
||||
logIdRef.current = 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-3">
|
||||
{/* Controls */}
|
||||
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 space-y-3">
|
||||
<div className="flex justify-between items-center flex-wrap gap-2">
|
||||
<div className="text-cyan-700 text-xs font-bold tracking-widest">
|
||||
SYSTEM LOG VIEWER
|
||||
</div>
|
||||
<div className="text-gray-600 text-xs">
|
||||
{filteredLogs.length} / {logs.length} logs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Severity filter buttons */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-gray-700 text-xs font-bold">SEVERITY FILTER:</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{Object.keys(LOG_COLORS).map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => toggleLevel(level)}
|
||||
className={`px-2 py-1 text-xs font-bold rounded border transition-colors ${
|
||||
selectedLevels.has(level)
|
||||
? `${LOG_COLORS[level].border} ${LOG_COLORS[level].bg} ${LOG_COLORS[level].label}`
|
||||
: 'border-gray-700 bg-gray-900 text-gray-600 hover:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{level}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node filter input */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-gray-700 text-xs font-bold">NODE FILTER:</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by node name..."
|
||||
value={nodeFilter}
|
||||
onChange={(e) => setNodeFilter(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-xs bg-gray-900 border border-gray-800 rounded text-gray-300 focus:outline-none focus:border-cyan-700 placeholder-gray-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={clearLogs}
|
||||
className="px-3 py-1.5 text-xs font-bold tracking-widest rounded border border-gray-700 bg-gray-900 text-gray-400 hover:text-red-400 hover:border-red-700 transition-colors"
|
||||
>
|
||||
CLEAR
|
||||
</button>
|
||||
<div className="text-gray-600 text-xs flex items-center">
|
||||
Auto-scrolls to latest logs
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log viewer area */}
|
||||
<div className="flex-1 bg-gray-950 rounded-lg border border-cyan-950 overflow-y-auto space-y-0">
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-600">
|
||||
<div className="text-center">
|
||||
<div className="text-sm mb-2">No logs to display</div>
|
||||
<div className="text-xs text-gray-700">
|
||||
Logs from /rosout will appear here
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredLogs.map((log) => (
|
||||
<LogLine
|
||||
key={log.id}
|
||||
log={log}
|
||||
colors={LOG_COLORS[log.level]}
|
||||
/>
|
||||
))}
|
||||
<div ref={scrollRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Topic info */}
|
||||
<div className="bg-gray-950 rounded border border-gray-800 p-2 text-xs text-gray-600 space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Topic:</span>
|
||||
<span className="text-gray-500">/rosout (rcl_interfaces/Log)</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Max History:</span>
|
||||
<span className="text-gray-500">{MAX_LOGS} entries</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Colors:</span>
|
||||
<span className="text-gray-500">DEBUG=grey | INFO=white | WARN=yellow | ERROR=red | FATAL=magenta</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user