239 lines
8.4 KiB
JavaScript
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); },
|
|
};
|
|
},
|
|
};
|