2026-08-13 16:23:18 +04:00

239 lines
8.4 KiB
JavaScript

/* 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); },
};
},
};