/* Console - event log, raw ROS access, discovered graph. */ import { store, get, post, toast, clockTime, num } from '../core.js'; import { el, card, pageHead, button, note, badge, table, input, textarea, field, kv, segmented, emptyState, setChildren, } from '../ui.js'; export default { id: 'console', label: 'Console', icon: 'console', async render() { const root = el('div.stack'); root.appendChild(pageHead( 'Console', 'Everything the bridge has logged, plus direct access to publish a topic or call a service.', )); /* ================================================================== Event log ================================================================== */ let levelFilter = 'all'; let autoScroll = true; const logHost = el('div.log'); function paintLog() { const events = store.events.filter( (e) => levelFilter === 'all' || e.level === levelFilter, ); if (!events.length) { logHost.replaceChildren(emptyState('Nothing logged yet')); return; } const wasAtBottom = logHost.scrollHeight - logHost.scrollTop - logHost.clientHeight < 40; logHost.replaceChildren(...events.slice(-400).map((event) => el('div.log-row', {}, el('span.log-time', { text: clockTime(event.ts) }), el('span.log-level', { text: event.level, dataset: { level: event.level } }), el('span.log-source', { text: event.source, title: event.source }), el('span.log-msg', { text: event.message }), ))); if (autoScroll && wasAtBottom) logHost.scrollTop = logHost.scrollHeight; } const levelTabs = segmented( [ { value: 'all', label: 'All' }, { value: 'info', label: 'Info' }, { value: 'warn', label: 'Warnings' }, { value: 'error', label: 'Errors' }, ], levelFilter, (value) => { levelFilter = value; paintLog(); }, ); root.appendChild(card('Event log', { sub: 'Bridge, control and extension events', actions: [ levelTabs, button('Clear view', () => { store.events.length = 0; paintLog(); }, { size: 'sm', style: 'ghost' }), ], flush: true, }, logHost)); /* ================================================================== Raw publish / service call ================================================================== */ const pubTopic = input({ placeholder: '/aima/hal/joint/head/command' }); const pubType = input({ placeholder: 'aimdk_msgs/msg/JointCommandArray' }); const pubFields = textarea({ placeholder: '{\n "field": 1.0\n}', rows: 5 }); const publishBtn = button('Publish', async (node) => { let fields; try { fields = pubFields.value.trim() ? JSON.parse(pubFields.value) : {}; } catch (err) { toast('Invalid JSON', err.message, 'critical'); return; } node.disabled = true; try { const data = await post('/api/raw/publish', { topic: pubTopic.value.trim(), type: pubType.value.trim(), fields, }); toast('Published', data.message, data.ok ? 'good' : 'critical'); } catch (err) { toast('Publish failed', err.message, 'critical'); } finally { node.disabled = false; } }, { style: 'primary' }); const agentHost = el('div'); root.appendChild(el('div.grid.cols-2', {}, card('Publish a topic', { sub: 'Raw escape hatch' }, el('div.stack', {}, field('Topic', pubTopic), field('Message type', pubType, 'Package/kind/Name, e.g. aimdk_msgs/msg/McLocomotionVelocity'), field('Fields (JSON)', pubFields, 'Only top-level fields the message actually defines are set; the rest are ignored.'), publishBtn, note('This bypasses every guard the Control tab applies. Know what the message does ' + 'before you send it.', 'warning', '⚠'), ), ), card('Robot agent', { sub: 'The process bridging ROS 2 to this dashboard' }, agentHost), )); /* ================================================================== Discovered graph ================================================================== */ const graphHost = el('div'); let graphFilter = ''; const graphSearch = input({ placeholder: 'Filter topics and services…' }); graphSearch.addEventListener('input', () => { graphFilter = graphSearch.value.toLowerCase(); paintGraph(); }); let graph = { topics: [], services: [] }; async function refreshGraph() { try { // Enumerating the graph is a round trip to the robot, so it is opt-in // rather than part of the routine topic-stats poll. const data = await get('/api/topics?graph=true'); graph = data.graph || { topics: [], services: [], nodes: [] }; paintGraph(); } catch { /* best-effort */ } } function paintAgent() { const agent = store.bridge?.agent || store.state?.custom?.agent || null; const connection = store.state?.connection || {}; setChildren(agentHost, el('div.row', { style: { marginBottom: '12px' } }, badge(connection.online ? 'connected' : 'not connected', connection.online ? 'good' : 'critical'), store.bridge?.simulated ? badge('simulation', 'warning') : null, ), kv([ ['Address', connection.host || store.bridge?.host || '—'], ['Agent version', agent?.agent_version || '—'], ['Robot hostname', agent?.hostname || '—'], ['ROS domain', agent?.ros_domain_id ?? '—'], ['Link uptime', connection.uptime_s ? `${Math.round(connection.uptime_s)} s` : '—'], ['Cameras', (agent?.cameras || []).length || '—'], ]), connection.error ? el('div', { style: { marginTop: '10px' } }, note(connection.error, 'critical', '⚠')) : null, ); } function paintGraph() { const match = (entry) => !graphFilter || entry.name.toLowerCase().includes(graphFilter); const topics = (graph.topics || []).filter(match); const services = (graph.services || []).filter(match); if (!graph.topics?.length && !graph.services?.length) { graphHost.replaceChildren(emptyState( 'No ROS graph available', store.bridge?.simulated ? 'The simulator has no ROS graph. Connect to a real robot to enumerate topics and services.' : 'The bridge has not enumerated the graph yet — it refreshes every 5 seconds.', )); return; } graphHost.replaceChildren( el('div.grid.cols-2', {}, el('div', {}, el('div.row', { style: { marginBottom: '8px' } }, el('strong', { text: 'Topics', style: { fontSize: '12px' } }), badge(String(topics.length)), ), table([ { key: 'name', label: 'Name' }, { key: 'types', label: 'Type', get: (r) => (r.types || []).join(', ') }, ], topics.slice(0, 300), { empty: 'No matching topics' }), ), el('div', {}, el('div.row', { style: { marginBottom: '8px' } }, el('strong', { text: 'Services', style: { fontSize: '12px' } }), badge(String(services.length)), ), table([ { key: 'name', label: 'Name' }, { key: 'types', label: 'Type', get: (r) => (r.types || []).join(', ') }, ], services.slice(0, 300), { empty: 'No matching services' }), ), ), ); } root.appendChild(card('ROS graph', { sub: 'What the robot is advertising right now', actions: [ graphSearch, button('Refresh', () => refreshGraph(), { size: 'sm', style: 'ghost', iconName: 'refresh' }), ], }, graphHost)); graphSearch.style.maxWidth = '240px'; /* -- Wiring ----------------------------------------------------------- */ paintLog(); paintAgent(); refreshGraph(); const unsubscribe = store.on('event', () => paintLog()); let lastAgent = 0; const unsubState = store.on('state', () => { const now = Date.now(); if (now - lastAgent < 2000) return; lastAgent = now; paintAgent(); }); const timer = setInterval(refreshGraph, 15000); return { node: root, dispose: () => { unsubscribe(); unsubState(); clearInterval(timer); }, }; }, };