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
+
+
+
+ );
+}