diff --git a/ui/social-bot/src/App.jsx b/ui/social-bot/src/App.jsx index d64246b..cc16def 100644 --- a/ui/social-bot/src/App.jsx +++ b/ui/social-bot/src/App.jsx @@ -267,6 +267,8 @@ export default function App() { {activeTab === 'eventlog' && } + {activeTab === 'logs' && } + {activeTab === 'network' && } {activeTab === 'settings' && } diff --git a/ui/social-bot/src/components/LogViewer.jsx b/ui/social-bot/src/components/LogViewer.jsx new file mode 100644 index 0000000..0111cf7 --- /dev/null +++ b/ui/social-bot/src/components/LogViewer.jsx @@ -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 ( +
+
+ + {log.level.padEnd(5)} + + + {formatTimestamp(log.timestamp)} + + + [{log.node}] + + + {log.message} + +
+
+ ); +} + +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 ( +
+ {/* Controls */} +
+
+
+ SYSTEM LOG VIEWER +
+
+ {filteredLogs.length} / {logs.length} logs +
+
+ + {/* Severity filter buttons */} +
+
SEVERITY FILTER:
+
+ {Object.keys(LOG_COLORS).map((level) => ( + + ))} +
+
+ + {/* Node filter input */} +
+
NODE FILTER:
+ 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" + /> +
+ + {/* Action buttons */} +
+ +
+ Auto-scrolls to latest logs +
+
+
+ + {/* Log viewer area */} +
+ {filteredLogs.length === 0 ? ( +
+
+
No logs to display
+
+ Logs from /rosout will appear here +
+
+
+ ) : ( + <> + {filteredLogs.map((log) => ( + + ))} +
+ + )} +
+ + {/* Topic info */} +
+
+ Topic: + /rosout (rcl_interfaces/Log) +
+
+ Max History: + {MAX_LOGS} entries +
+
+ Colors: + DEBUG=grey | INFO=white | WARN=yellow | ERROR=red | FATAL=magenta +
+
+
+ ); +}