Files
marte-debug/Tools/go_web_client/static/app.js
T
2026-05-20 17:21:33 +02:00

1407 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* global uPlot */
'use strict';
// ════════════════════════════════════════════════════════════════════
// State
// ════════════════════════════════════════════════════════════════════
const MAX_TRACE_PTS = 100000;
const COLORS = [
'#89b4fa','#f38ba8','#a6e3a1','#fab387','#f9e2af',
'#cba6f7','#89dceb','#b4befe','#eba0ac','#94e2d5'
];
let ws = null;
let marteConnected = false;
// Signal registry: id(number) → {id, name, type, elements, dimensions, names:[]}
let signalsById = {};
// name → meta (same objects, multiple name entries per signal for array elems)
let signalsByName = {};
// Trace data: signalName → {ts:[], vals:[], lastVal:0, color:''}
let traceData = {};
// Set of names currently being traced
let tracedSet = new Set();
// Forced: name → value string
let forcedSignals = {};
// Breakpoints: name → {op, threshold}
let breakpoints = {};
// Thread list from TREE
let threads = [];
// All object paths (for autocomplete)
let allObjects = [];
// Plots: [{id, container, chart, series:[{name, color, gain, offset}], maxPts, follow, dirty}]
let plots = [];
let plotSeq = 0;
// Logs
let logs = [];
const MAX_LOGS = 2000;
let logDirty = false;
// Step status
let stepStatus = null; // {paused, gam, remaining, thread}
let stepPollTimer = null;
// Pending unforce target
let unforceTarget = '';
// UDP stats
let udpPackets = 0, udpDropped = 0;
// ════════════════════════════════════════════════════════════════════
// WebSocket
// ════════════════════════════════════════════════════════════════════
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(proto + '//' + location.host + '/ws');
ws.onopen = () => { appendLog('sys','INFO','WebSocket connected to server'); };
ws.onclose = () => { appendLog('sys','WARNING','WebSocket disconnected — reconnecting…'); setTimeout(connectWS, 2000); };
ws.onerror = () => {};
ws.onmessage = (e) => handleMsg(JSON.parse(e.data));
}
function sendWS(obj) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
}
function sendCmd(cmd) { sendWS({type:'cmd', data:{cmd}}); }
// ════════════════════════════════════════════════════════════════════
// Message handlers
// ════════════════════════════════════════════════════════════════════
function handleMsg(msg) {
switch (msg.type) {
case 'connected': onMarteConnected(); break;
case 'disconnected': onMarteDisconnected(); break;
case 'signal_cache': onSignalCache(msg); break;
case 'response': onResponse(msg); break;
case 'text_line': onTextLine(msg.data); break;
case 'telemetry': onTelemetry(msg); break;
case 'log': onLog(msg); break;
case 'service_config': onServiceConfig(msg); break;
case 'udp_stats': onUdpStats(msg); break;
case 'tree_clear': onTreeClear(msg.total); break;
case 'tree_batch': onTreeBatch(msg); break;
}
}
// ════════════════════════════════════════════════════════════════════
// Incremental tree rendering (server-side pre-processed batches)
//
// Batches are stored purely in memory — no DOM work during receipt.
// DOM nodes are created lazily: only the top-level containers are
// built immediately; everything inside a closed <details> is deferred
// until the user opens it. This keeps the initial render O(1) in the
// number of signals regardless of tree size.
// ════════════════════════════════════════════════════════════════════
let _treeBuild = null; // { threads:[], objects:[] } — null when idle
let _flatNodes = null; // Map<path, FlatNode>
let _childrenOf = null; // Map<parentPath|'', FlatNode[]>
function onTreeClear(total) {
_treeBuild = { threads: [], objects: [] };
_flatNodes = new Map();
_childrenOf = new Map();
document.getElementById('tree-body').innerHTML = '';
appendLog('sys', 'INFO', `Receiving tree (${total} nodes)…`);
}
function onTreeBatch(msg) {
if (!_treeBuild) return;
// Pure memory work — no DOM touched here.
for (const node of (msg.nodes || [])) {
_flatNodes.set(node.p, node);
const pk = node.par || '';
let ch = _childrenOf.get(pk);
if (!ch) { ch = []; _childrenOf.set(pk, ch); }
ch.push(node);
if (node.c === 'RealTimeThread' && node.n) _treeBuild.threads.push(node.n);
if (node.c && node.c !== 'Signal' && node.n) _treeBuild.objects.push(node.p);
}
if (!msg.done) return;
// All batches received — build only top-level nodes now.
const body = document.getElementById('tree-body');
const frag = document.createDocumentFragment();
for (const node of (_childrenOf.get('') || [])) {
frag.appendChild(_buildFlatNode(node));
}
body.appendChild(frag);
// Finalise autocompletes.
threads = _treeBuild.threads;
allObjects = _treeBuild.objects;
['step-thread','step-thread-list','step-thread-inp'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
if (el.tagName === 'SELECT') {
el.innerHTML = '<option value="">— all threads —</option>' +
threads.map(t => `<option value="${esc(t)}">${esc(t)}</option>`).join('');
} else if (el.tagName === 'DATALIST') {
el.innerHTML = threads.map(t => `<option value="${esc(t)}">`).join('');
}
});
document.getElementById('msg-dest-list').innerHTML =
allObjects.map(n => `<option value="${esc(n)}">`).join('');
_treeBuild = null;
_treeReady = true;
appendLog('sys', 'INFO', `Tree ready — ${allObjects.length} objects, ${threads.length} threads`);
startStepPoll();
}
// Build one flat node into a DOM element. Containers install a toggle
// listener that builds their children on first open (lazy load).
function _buildFlatNode(node) {
if (node.ch) {
const details = document.createElement('details');
const summary = document.createElement('summary');
summary.innerHTML =
`<span class="tree-name">${esc(node.n)}</span>` +
`<span class="tree-class">[${esc(node.c)}]</span>`;
details.appendChild(summary);
details.addEventListener('toggle', function() {
if (!details.open || details._childrenBuilt) return;
details._childrenBuilt = true;
const childDiv = document.createElement('div');
childDiv.className = 'children';
const infoBtn = makeLeafButtons(node.p);
if (infoBtn) childDiv.appendChild(infoBtn);
const f = document.createDocumentFragment();
for (const child of (_childrenOf.get(node.p) || [])) {
f.appendChild(_buildFlatNode(child));
}
childDiv.appendChild(f);
details.appendChild(childDiv);
});
return details;
}
// Leaf node.
const isSig = node.c === 'Signal' || node.tr;
const item = { Name: node.n, Class: node.c, IsTraceable: node.tr, IsForcable: node.fo };
const row = document.createElement('div');
row.className = 'tree-node';
row.appendChild(makeLeafRow(node.p, item, isSig));
if (isSig && node.el > 1) {
const sub = document.createElement('div');
sub.style.paddingLeft = '14px';
for (let i = 0; i < node.el; i++) {
const en = `${node.p}[${i}]`;
sub.appendChild(makeLeafRow(en,
{ Name:`[${i}]`, Class:'Signal', IsTraceable:true, IsForcable:node.fo }, true));
}
row.appendChild(sub);
}
return row;
}
// Force-build all lazy containers (used before a DOM-wide filter search).
function _buildAllTreeNodes(root) {
let dirty = true;
while (dirty) {
dirty = false;
root.querySelectorAll('details').forEach(d => {
if (!d._childrenBuilt) { d.open = true; dirty = true; }
});
}
}
// ════════════════════════════════════════════════════════════════════
// Startup sequencing
//
// The server (DebugService) can be very busy during application init
// (1000+ signals, registry patching, broker setup). Sending DISCOVER
// or STEP_STATUS before it is ready just fills its receive buffer,
// delaying every subsequent response and triggering idle timeouts.
//
// Correct sequence:
// connect → SERVICE_INFO → DISCOVER → TREE → STEP_STATUS poll
//
// Each step is gated on the previous one completing. sendOnce()
// prevents the same command from being queued twice across reconnects.
// ════════════════════════════════════════════════════════════════════
const _cmdSent = new Set();
let _treeReady = false; // hard gate: STEP_STATUS never starts before tree is built
function sendOnce(cmd) {
if (_cmdSent.has(cmd)) return;
_cmdSent.add(cmd);
sendCmd(cmd);
}
function _clearStartupState() {
_cmdSent.clear();
_treeReady = false;
stopStepPoll(); // always kill any running poll when state is reset
}
function onMarteConnected() {
marteConnected = true;
_clearStartupState();
document.getElementById('conn-status').classList.add('ok');
document.getElementById('btn-connect').textContent = 'Disconnect';
appendLog('sys','INFO','Connected to MARTe2');
// Send DISCOVER immediately so the user gets feedback without waiting for
// SERVICE_INFO. STEP_STATUS is gated by _treeReady so the server is not
// flooded with status polls during the (potentially long) init phase.
sendOnce('DISCOVER');
}
function onMarteDisconnected() {
marteConnected = false;
_clearStartupState();
document.getElementById('conn-status').classList.remove('ok');
document.getElementById('btn-connect').textContent = 'Connect';
appendLog('sys','WARNING','Disconnected from MARTe2');
stopStepPoll();
}
function onSignalCache(msg) {
// Go server has cached signals from a previous DISCOVER — skip DISCOVER,
// go straight to TREE (still need the object hierarchy).
if (msg.signals) {
processDiscovery(msg.signals);
_cmdSent.add('DISCOVER'); // mark as done so fallback timer doesn't re-send
sendOnce('TREE');
}
}
function onResponse(msg) {
try {
const data = msg.data ? JSON.parse(msg.data) : null;
switch (msg.tag) {
case 'DISCOVER':
if (data) {
processDiscovery(data.Signals || []);
sendOnce('TREE');
}
break;
case 'TREE': if (data) processTree(data); break; // fallback if Go parse fails
case 'INFO': showInfo(msg.tag, msg.data); break;
case 'CONFIG': showInfo('CONFIG', msg.data); break;
case 'SERVICE_INFO': showInfo('Service Info', msg.data); break;
case 'STEP_STATUS': processStepStatus(data); break;
case 'VALUE': processValue(data); break;
}
} catch(e) {
// non-JSON responses (TRACE, FORCE, etc.) — silently ignore
}
}
function onTextLine(line) {
if (line.startsWith('OK ')) return; // handled by response handler
appendLog('sys','DEBUG', line);
}
function onLog(msg) {
// Normalise level names from MARTe2
let lv = msg.level;
if (lv === 'Information') lv = 'INFO';
else if (lv === 'Warning') lv = 'WARNING';
else if (lv === 'Debug') lv = 'DEBUG';
else if (lv === 'FatalError' || lv === 'ParametersError' || lv === 'OSError') lv = 'ERROR';
appendLog(msg.time, lv, msg.message);
}
function onServiceConfig(msg) {
const auto = document.getElementById('auto-ports')?.checked ?? true;
if (auto) {
if (msg.udp_port) document.getElementById('udp-port').value = msg.udp_port;
if (msg.log_port) document.getElementById('log-port').value = msg.log_port;
}
appendLog('sys','INFO',`ServiceConfig: UDP=${msg.udp_port} LOG=${msg.log_port}${auto ? '' : ' (ignored — manual ports)'}`);
}
function onUdpStats(msg) {
udpPackets = msg.packets;
udpDropped = msg.dropped;
document.getElementById('udp-stats').textContent =
`${udpPackets} pkts${udpDropped>0?' ⚠'+udpDropped+' drop':''}`;
}
function onTelemetry(msg) {
if (!msg.signals) return;
for (const s of msg.signals) {
const names = s.names || [];
const vals = s.values || [];
let plotDirty = false;
for (let e = 0; e < vals.length; e++) {
// A signal can have multiple names (canonical DataSource path + GAM
// aliases). Find whichever name the user actually traced.
let n = '';
for (const nm of names) {
const candidate = vals.length > 1 ? `${nm}[${e}]` : nm;
if (tracedSet.has(candidate)) { n = candidate; break; }
}
if (!n) continue;
let td = traceData[n];
if (!td) {
td = {ts:[], vals:[], lastVal:0, color: nextColor()};
traceData[n] = td;
}
td.ts.push(s.ts);
td.vals.push(vals[e]);
td.lastVal = vals[e];
if (td.ts.length > MAX_TRACE_PTS) {
const trim = td.ts.length - MAX_TRACE_PTS;
td.ts.splice(0, trim);
td.vals.splice(0, trim);
}
plotDirty = true;
}
// Mark plots dirty if any of this signal's names appear in a series.
if (plotDirty) {
for (const p of plots) {
for (const sr of p.series) {
if (names.some(nm => sr.name === nm ||
(vals.length > 1 && sr.name.startsWith(nm + '[')))) {
p.dirty = true;
break;
}
}
}
}
}
updateTracedValues();
}
// ════════════════════════════════════════════════════════════════════
// Discovery
// ════════════════════════════════════════════════════════════════════
function processDiscovery(sigs) {
signalsById = {};
signalsByName = {};
for (const s of sigs) {
const el = s.elements || 1;
const meta = {id:s.id, name:s.name, type:s.type||'', elements:el, dimensions:s.dimensions||0, names:[s.name]};
signalsById[s.id] = meta;
signalsByName[s.name] = meta;
// Array element names
if (el > 1) {
for (let i=0; i<el; i++) signalsByName[`${s.name}[${i}]`] = meta;
}
}
updateSignalAutocomplete();
appendLog('sys','INFO',`Discovered ${sigs.length} signals`);
}
function updateSignalAutocomplete() {
const names = Object.keys(signalsByName).filter(n=>!n.includes('['));
['force-sig-list','break-sig-list'].forEach(id=>{
const dl = document.getElementById(id);
dl.innerHTML = names.map(n=>`<option value="${esc(n)}">`).join('');
});
}
// ════════════════════════════════════════════════════════════════════
// Tree
// ════════════════════════════════════════════════════════════════════
function processTree(root) {
threads = [];
allObjects = [];
// Fast metadata pass: traverse the raw JSON tree to collect threads and
// object names without touching the DOM at all.
function collectMeta(item, parentPath) {
const path = parentPath ? parentPath + '.' + item.Name : item.Name;
if (item.Class === 'RealTimeThread' && item.Name) threads.push(item.Name);
if (item.Class && item.Class !== 'Signal' && item.Name) allObjects.push(path);
if (item.Children) for (const c of item.Children) collectMeta(c, path);
}
collectMeta(root, '');
// Build only the top-level nodes now; everything deeper is lazy.
const body = document.getElementById('tree-body');
body.innerHTML = '';
const frag = document.createDocumentFragment();
const topNodes = (root.Name === 'Root' && root.Children) ? root.Children : [root];
for (const child of topNodes) frag.appendChild(buildTreeNode(child, ''));
body.appendChild(frag);
// Populate thread selects
['step-thread','step-thread-list','step-thread-inp'].forEach(id=>{
const el = document.getElementById(id);
if (!el) return;
if (el.tagName === 'SELECT') {
el.innerHTML = '<option value="">— all threads —</option>' +
threads.map(t=>`<option value="${esc(t)}">${esc(t)}</option>`).join('');
} else if (el.tagName === 'DATALIST') {
el.innerHTML = threads.map(t=>`<option value="${esc(t)}">`).join('');
}
});
// Populate msg dest autocomplete
const dl = document.getElementById('msg-dest-list');
dl.innerHTML = allObjects.map(n=>`<option value="${esc(n)}">`).join('');
_treeReady = true;
startStepPoll(); // fallback path: Go couldn't parse TREE, tree_batch never fires
}
function buildTreeNode(item, parentPath) {
const path = parentPath ? parentPath + '.' + item.Name : item.Name;
const isSig = item.Class === 'Signal' || item.IsTraceable;
if (item.Children && item.Children.length > 0) {
const details = document.createElement('details');
const summary = document.createElement('summary');
summary.innerHTML = `<span class="tree-name">${esc(item.Name)}</span><span class="tree-class">[${esc(item.Class)}]</span>`;
details.appendChild(summary);
// Children are built lazily on first open — avoids creating thousands of
// DOM nodes for a large app when the user may never open most branches.
details.addEventListener('toggle', function() {
if (!details.open || details._childrenBuilt) return;
details._childrenBuilt = true;
const children = document.createElement('div');
children.className = 'children';
const leaf = makeLeafButtons(path);
if (leaf) children.appendChild(leaf);
for (const c of item.Children) children.appendChild(buildTreeNode(c, path));
details.appendChild(children);
});
return details;
}
// Leaf node
const row = document.createElement('div');
row.className = 'tree-node';
const leaf = makeLeafRow(path, item, isSig);
row.appendChild(leaf);
// Array expansion
if (isSig && item.Elements > 1) {
const sub = document.createElement('div');
sub.style.paddingLeft = '14px';
for (let i = 0; i < item.Elements; i++) {
const en = `${path}[${i}]`;
sub.appendChild(makeLeafRow(en, {Name:`[${i}]`, Class:'Signal', IsTraceable:true, IsForcable:item.IsForcable}, true));
}
row.appendChild(sub);
}
return row;
}
function makeLeafRow(path, item, isSig) {
const div = document.createElement('div');
div.className = 'tree-leaf';
div.draggable = isSig;
if (isSig) {
div.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', path);
e.dataTransfer.effectAllowed = 'copy';
});
}
const name = document.createElement('span');
name.className = 'tree-name';
name.title = path;
name.textContent = item.Name;
div.appendChild(name);
const cls = document.createElement('span');
cls.className = 'tree-class';
cls.textContent = `[${item.Class}]`;
div.appendChild(cls);
// Info
const btnI = mkBtn('i', '', () => showNodeInfo(path));
div.appendChild(btnI);
if (isSig || item.IsTraceable) {
const btnT = mkBtn('T', 't', () => traceSignal(path, true));
btnT.title = 'Trace';
div.appendChild(btnT);
}
if (item.IsForcable || isSig) {
const btnF = mkBtn('F', 'f', () => openForceDialogFor(path));
btnF.title = 'Force';
div.appendChild(btnF);
}
if (isSig || item.IsTraceable) {
const btnB = mkBtn('B', 'b', () => openBreakDialogFor(path));
btnB.title = 'Break';
div.appendChild(btnB);
}
return div;
}
function makeLeafButtons(path) {
// Returns a row of buttons for container nodes (no name/class display)
const div = document.createElement('div');
div.style.cssText = 'display:flex;gap:4px;padding:2px 4px 2px 0';
const btnI = mkBtn(' info', '', () => showNodeInfo(path));
div.appendChild(btnI);
return div;
}
function mkBtn(label, cls, fn) {
const b = document.createElement('button');
b.className = `tree-btn${cls?' '+cls:''}`;
b.textContent = label;
b.addEventListener('click', e => { e.stopPropagation(); fn(); });
return b;
}
function filterTree(q) {
const body = document.getElementById('tree-body');
q = q.toLowerCase();
if (q) {
// Build all lazy nodes so the querySelectorAll below sees everything.
_buildAllTreeNodes(body);
body.querySelectorAll('details').forEach(d => d.open = true);
}
body.querySelectorAll('.tree-leaf').forEach(el => {
if (!q) { el.style.display = ''; return; }
const t = (el.querySelector('.tree-name')?.title || '').toLowerCase();
el.style.display = t.includes(q) ? '' : 'none';
});
}
// ════════════════════════════════════════════════════════════════════
// Tracing
// ════════════════════════════════════════════════════════════════════
function traceSignal(name, enable) {
if (enable) {
tracedSet.add(name);
if (!traceData[name]) traceData[name] = {ts:[], vals:[], lastVal:0, color: nextColor()};
sendCmd(`TRACE ${name} 1`);
} else {
tracedSet.delete(name);
sendCmd(`TRACE ${name} 0`);
}
renderTracedTab();
}
function renderTracedTab() {
const tab = document.getElementById('tab-traced');
const hint = document.getElementById('no-traced-hint');
hint.style.display = tracedSet.size ? 'none' : 'block';
// Remove old sig-items
tab.querySelectorAll('.sig-item').forEach(e=>e.remove());
let colorIdx = 0;
for (const name of tracedSet) {
const td = traceData[name];
const color = td ? td.color : COLORS[colorIdx++ % COLORS.length];
const div = document.createElement('div');
div.className = 'sig-item';
div.draggable = true;
div.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', name);
e.dataTransfer.effectAllowed = 'copy';
});
const dot = document.createElement('span');
dot.className = 'sig-dot';
dot.style.background = color;
dot.draggable = false;
const nm = document.createElement('span');
nm.className = 'sig-name';
nm.title = name;
nm.textContent = shortName(name);
nm.draggable = false;
const val = document.createElement('span');
val.className = 'sig-val';
val.textContent = td ? fmtVal(td.lastVal) : '—';
val.id = `tval-${CSS.escape(name)}`;
val.draggable = false;
const btnR = mkBtn('×', 'danger', () => traceSignal(name, false));
btnR.style.cssText = 'font-size:11px;padding:0 4px;border-color:#f38ba8;color:#f38ba8;background:transparent';
// Stop drag from starting when clicking the button
btnR.addEventListener('dragstart', e => e.preventDefault());
div.append(dot, nm, val, btnR);
tab.appendChild(div);
}
}
// Update only the value spans without touching the DOM structure.
// Called on every telemetry packet — must be fast and non-destructive.
function updateTracedValues() {
for (const name of tracedSet) {
const td = traceData[name];
if (!td) continue;
const el = document.getElementById(`tval-${CSS.escape(name)}`);
if (el) el.textContent = fmtVal(td.lastVal);
}
}
// ════════════════════════════════════════════════════════════════════
// Force
// ════════════════════════════════════════════════════════════════════
function openForceDialog() { openDlg('dlg-force'); }
function openForceDialogFor(name) {
document.getElementById('force-sig').value = name;
openDlg('dlg-force');
}
function doForce() {
const sig = document.getElementById('force-sig').value.trim();
const val = document.getElementById('force-val').value.trim();
if (!sig || !val) return;
sendCmd(`FORCE ${sig} ${val}`);
forcedSignals[sig] = val;
closeDlg('dlg-force');
renderForcedTab();
}
function doUnforce() {
if (unforceTarget) {
sendCmd(`UNFORCE ${unforceTarget}`);
delete forcedSignals[unforceTarget];
unforceTarget = '';
renderForcedTab();
}
closeDlg('dlg-unforce');
}
function confirmUnforce(name) {
unforceTarget = name;
document.getElementById('unforce-msg').textContent = `Remove force on "${name}"?`;
openDlg('dlg-unforce');
}
function renderForcedTab() {
const tab = document.getElementById('tab-forced');
tab.querySelectorAll('.sig-item').forEach(e=>e.remove());
const hint = document.getElementById('no-forced-hint');
const keys = Object.keys(forcedSignals);
hint.style.display = keys.length ? 'none' : 'block';
for (const name of keys) {
const div = document.createElement('div');
div.className = 'sig-item';
const nm = document.createElement('span');
nm.className = 'sig-name';
nm.title = name;
nm.textContent = shortName(name);
const val = document.createElement('span');
val.className = 'sig-val';
val.style.color = '#f9e2af';
val.textContent = forcedSignals[name];
const btnR = mkBtn('×', '', () => confirmUnforce(name));
btnR.style.cssText = 'font-size:11px;padding:0 4px;border-color:#f38ba8;color:#f38ba8;background:transparent';
div.append(nm, val, btnR);
tab.appendChild(div);
}
}
// ════════════════════════════════════════════════════════════════════
// Breakpoints
// ════════════════════════════════════════════════════════════════════
function openBreakDialog() { openDlg('dlg-break'); }
function openBreakDialogFor(name) {
document.getElementById('break-sig').value = name;
openDlg('dlg-break');
}
function doBreak() {
const sig = document.getElementById('break-sig').value.trim();
const op = document.getElementById('break-op').value;
const thresh = document.getElementById('break-thresh').value.trim();
if (!sig || !thresh) return;
sendCmd(`BREAK ${sig} ${op} ${thresh}`);
breakpoints[sig] = {op, threshold: thresh};
closeDlg('dlg-break');
renderBreaksTab();
}
function clearBreak(name) {
sendCmd(`BREAK ${name} OFF`);
delete breakpoints[name];
renderBreaksTab();
}
function renderBreaksTab() {
const tab = document.getElementById('tab-breaks');
tab.querySelectorAll('.break-item').forEach(e=>e.remove());
const hint = document.getElementById('no-breaks-hint');
const keys = Object.keys(breakpoints);
hint.style.display = keys.length ? 'none' : 'block';
for (const name of keys) {
const bp = breakpoints[name];
const div = document.createElement('div');
div.className = 'break-item';
const sig = document.createElement('span');
sig.className = 'break-sig';
sig.title = name;
sig.textContent = shortName(name);
const cond = document.createElement('span');
cond.style.color = '#fab387';
cond.textContent = `${bp.op} ${bp.threshold}`;
const btnR = mkBtn('×', '', () => clearBreak(name));
btnR.style.cssText = 'font-size:11px;padding:0 4px;border-color:#f38ba8;color:#f38ba8;background:transparent';
div.append(sig, cond, btnR);
tab.appendChild(div);
}
}
// ════════════════════════════════════════════════════════════════════
// Messages
// ════════════════════════════════════════════════════════════════════
let msgHistory = [];
function openMsgDialog() { openDlg('dlg-msg'); }
function doSendMsg() {
const dest = document.getElementById('msg-dest').value.trim();
const func = document.getElementById('msg-func').value.trim();
const payload = document.getElementById('msg-payload').value.trim();
const wait = document.getElementById('msg-wait').checked ? 1 : 0;
if (!dest || !func) return;
// Build key=value pairs on one line: K=V K2=V2
const kvLine = payload.split('\n')
.map(l=>l.trim()).filter(Boolean)
.map(l=>l.replace(/\s*=\s*/,'=')).join(' ');
const cmd = `MSG ${dest} ${func} ${wait}${kvLine ? ' '+kvLine : ''}`;
sendCmd(cmd);
const entry = {time: now(), dest, func, payload, cmd, status:'pending'};
msgHistory.unshift(entry);
closeDlg('dlg-msg');
renderMsgsTab();
}
function renderMsgsTab() {
const tab = document.getElementById('tab-msgs');
tab.querySelectorAll('.msg-item').forEach(e=>e.remove());
const hint = document.getElementById('no-msgs-hint');
hint.style.display = msgHistory.length ? 'none' : 'block';
for (const m of msgHistory.slice(0,50)) {
const div = document.createElement('div');
div.className = 'msg-item';
const icon = m.status==='ok'?'✔':m.status==='err'?'✘':'…';
div.innerHTML = `<div style="display:flex;gap:6px;align-items:center">
<span style="color:${m.status==='ok'?'#a6e3a1':m.status==='err'?'#f38ba8':'#585b70'}">${icon}</span>
<span style="color:#585b70;flex-shrink:0">${m.time}</span>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1" title="${esc(m.cmd)}">${esc(m.dest)}.${esc(m.func)}</span>
<button class="tree-btn" onclick="resendMsg(${msgHistory.indexOf(m)})">↺</button>
</div>`;
tab.appendChild(div);
}
}
function resendMsg(idx) {
const m = msgHistory[idx];
if (!m) return;
sendCmd(m.cmd);
}
// ════════════════════════════════════════════════════════════════════
// Step / Pause
// ════════════════════════════════════════════════════════════════════
let isPaused = false;
function togglePause() {
if (isPaused) {
sendCmd('RESUME');
isPaused = false;
document.getElementById('step-bar').classList.remove('visible');
} else {
sendCmd('PAUSE');
isPaused = true;
}
document.getElementById('btn-pause').textContent = isPaused ? '▶ Resume' : '⏸ Pause';
}
function openStepDialog() { openDlg('dlg-step'); }
function doStep() {
const count = document.getElementById('step-count').value || 1;
const thread = document.getElementById('step-thread-inp').value.trim();
const cmd = thread ? `STEP ${count} ${thread}` : `STEP ${count}`;
sendCmd(cmd);
closeDlg('dlg-step');
}
function step(n) {
const thread = document.getElementById('step-thread').value;
const cmd = thread ? `STEP ${n} ${thread}` : `STEP ${n}`;
sendCmd(cmd);
}
function startStepPoll() {
if (!_treeReady) return; // hard gate: never poll before tree is fully built
stopStepPoll();
stepPollTimer = setInterval(() => sendCmd('STEP_STATUS'), 2000);
}
function stopStepPoll() {
if (stepPollTimer) { clearInterval(stepPollTimer); stepPollTimer = null; }
}
function processStepStatus(data) {
if (!data) return;
stepStatus = data;
const bar = document.getElementById('step-bar');
if (data.Paused) {
isPaused = true;
bar.classList.add('visible');
document.getElementById('paused-gam').textContent = data.PausedAtGam || '—';
document.getElementById('step-remaining').textContent =
data.StepRemaining > 0 ? `(${data.StepRemaining} remaining)` : '';
document.getElementById('btn-pause').textContent = '▶ Resume';
// Faster polling while paused
if (_treeReady) {
stopStepPoll();
stepPollTimer = setInterval(() => sendCmd('STEP_STATUS'), 500);
}
} else {
if (isPaused) {
isPaused = false;
bar.classList.remove('visible');
document.getElementById('btn-pause').textContent = '⏸ Pause';
// Back to slow poll
startStepPoll();
}
}
}
function processValue(data) {
if (!data || data.Error) return;
const title = `Value: ${data.Name}`;
const body = `${data.Value}\n(${data.Elements} element${data.Elements>1?'s':''})`;
showInfo(title, body);
}
// ════════════════════════════════════════════════════════════════════
// Info dialog
// ════════════════════════════════════════════════════════════════════
function showNodeInfo(path) {
document.getElementById('info-title').textContent = path;
document.getElementById('info-body').textContent = 'Loading…';
openDlg('dlg-info');
sendCmd(`INFO ${path}`);
}
function showInfo(title, data) {
let pretty = data;
try { pretty = JSON.stringify(JSON.parse(data), null, 2); } catch(e){}
document.getElementById('info-title').textContent = title;
document.getElementById('info-body').textContent = pretty;
// If dialog not visible, open it
if (document.getElementById('dlg-info').style.display === 'none') openDlg('dlg-info');
}
// ════════════════════════════════════════════════════════════════════
// Connect / Disconnect
// ════════════════════════════════════════════════════════════════════
function requireConnected() {
if (!marteConnected) {
appendLog('sys', 'WARNING', 'Not connected to MARTe2');
return false;
}
return true;
}
function discoverCmd() { if (requireConnected()) { sendCmd('DISCOVER'); closeAllMenus(); } }
function treeCmd() { if (requireConnected()) { sendCmd('TREE'); closeAllMenus(); } }
function serviceInfoCmd() { if (requireConnected()) { sendCmd('SERVICE_INFO'); closeAllMenus(); } }
function toggleAutoPorts(auto) {
const row = document.getElementById('port-manual-row');
if (row) row.style.display = auto ? 'none' : 'flex';
}
function toggleConnect() {
if (marteConnected) {
sendWS({type:'disconnect'});
} else {
const host = document.getElementById('host').value.trim() || '127.0.0.1';
const port = parseInt(document.getElementById('port').value) || 8080;
const auto = document.getElementById('auto-ports').checked;
const udpPort = auto ? port + 1 : (parseInt(document.getElementById('udp-port').value) || port + 1);
const logPort = auto ? port + 2 : (parseInt(document.getElementById('log-port').value) || port + 2);
sendWS({type:'connect', data:{host, port, udp_port:udpPort, log_port:logPort}});
}
closeAllMenus();
}
// ════════════════════════════════════════════════════════════════════
// Plots
// ════════════════════════════════════════════════════════════════════
function addPlot() {
const id = ++plotSeq;
document.getElementById('no-plots-hint').style.display = 'none';
const card = document.createElement('div');
card.className = 'plot-card';
card.id = `plot-card-${id}`;
card.innerHTML = `
<div class="plot-header">
<span class="plot-title">Plot ${id}</span>
<label style="font-size:10px;color:#a6adc8">Max pts:
<input type="number" value="50000" min="100" max="100000" step="1000"
style="width:70px" onchange="setMaxPts(${id},this.value)">
</label>
<label style="font-size:10px"><input type="checkbox" checked onchange="setFollow(${id},this.checked)"> Follow</label>
<button onclick="fitPlot(${id})" style="font-size:10px;padding:1px 6px">Fit</button>
<button onclick="deletePlot(${id})" style="font-size:10px;padding:1px 6px;color:#f38ba8;border-color:#f38ba8">✕</button>
</div>
<div id="plot-series-${id}" class="plot-series-bar"></div>
<div id="plot-drop-${id}" class="plot-drop">Drop signals here to plot</div>
<div id="plot-wrap-${id}" class="uplot-wrap" style="display:none"></div>`;
document.getElementById('center-panel').appendChild(card);
// Counter to handle dragenter/dragleave across child elements reliably
let dragDepth = 0;
card.addEventListener('dragenter', e => {
e.preventDefault();
dragDepth++;
card.classList.add('drop-active');
document.getElementById(`plot-drop-${id}`)?.classList.add('over');
});
card.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
card.addEventListener('dragleave', () => {
dragDepth--;
if (dragDepth <= 0) {
dragDepth = 0;
card.classList.remove('drop-active');
document.getElementById(`plot-drop-${id}`)?.classList.remove('over');
}
});
card.addEventListener('drop', e => {
e.preventDefault();
dragDepth = 0;
card.classList.remove('drop-active');
document.getElementById(`plot-drop-${id}`)?.classList.remove('over');
const name = e.dataTransfer.getData('text/plain');
if (name) addSignalToPlot(id, name);
});
const plotObj = {id, card, chart:null, series:[], maxPts:50000, follow:true, dirty:false};
plots.push(plotObj);
}
function findPlot(id) { return plots.find(p=>p.id===id); }
function deletePlot(id) {
const idx = plots.findIndex(p=>p.id===id);
if (idx>=0) { plots.splice(idx,1); }
document.getElementById(`plot-card-${id}`)?.remove();
if (plots.length === 0) document.getElementById('no-plots-hint').style.display = 'block';
}
function setMaxPts(id, v) { const p = findPlot(id); if(p) p.maxPts = parseInt(v)||50000; }
function setFollow(id, v) { const p = findPlot(id); if(p) { p.follow = v; if(v && p.chart) p.chart.setScale('x',{min:null,max:null}); } }
function fitPlot(id) { const p = findPlot(id); if(p&&p.chart) p.chart.setScale('x',{min:null,max:null}); }
function addSignalToPlot(id, name) {
const p = findPlot(id);
if (!p) return;
if (p.series.find(s=>s.name===name)) return; // already added
// Ensure tracing
traceSignal(name, true);
const color = traceData[name]?.color || nextColor();
p.series.push({name, color, gain:1, offset:0});
updateSeriesBar(p);
rebuildChart(p);
p.dirty = true;
}
function removeSignalFromPlot(id, name) {
const p = findPlot(id);
if (!p) return;
p.series = p.series.filter(s=>s.name!==name);
updateSeriesBar(p);
rebuildChart(p);
}
function updateSeriesBar(p) {
const bar = document.getElementById(`plot-series-${p.id}`);
if (!bar) return;
bar.innerHTML = '';
for (const s of p.series) {
const chip = document.createElement('span');
chip.className = 'series-chip';
chip.innerHTML =
`<span class="series-chip-dot" style="background:${s.color}"></span>` +
`<span class="series-chip-name" title="${esc(s.name)}">${esc(shortName(s.name))}</span>` +
`<button class="series-chip-rm" title="Remove signal">×</button>`;
chip.querySelector('.series-chip-rm').addEventListener('click', () => removeSignalFromPlot(p.id, s.name));
bar.appendChild(chip);
}
}
function rebuildChart(p) {
const wrap = document.getElementById(`plot-wrap-${p.id}`);
const drop = document.getElementById(`plot-drop-${p.id}`);
if (!wrap) return;
if (p.series.length === 0) {
if (p.chart) { p.chart.destroy(); p.chart = null; }
plotRO.unobserve(wrap);
wrap.innerHTML = '';
wrap.style.display = 'none';
if (drop) drop.style.display = 'flex';
return;
}
if (drop) drop.style.display = 'none';
wrap.style.display = 'flex';
if (p.chart) { p.chart.destroy(); p.chart = null; }
wrap.innerHTML = '';
// Build after the browser has laid out the flex container so clientHeight is real.
requestAnimationFrame(() => {
if (!document.getElementById(`plot-wrap-${p.id}`)) return; // deleted meanwhile
const w = Math.max(wrap.clientWidth - 8, 200);
const h = Math.max(wrap.clientHeight - 6, 120);
const seriesOpts = [{label:'time'}];
for (const s of p.series) {
seriesOpts.push({
label: shortName(s.name),
stroke: s.color,
width: 1.5,
points: {show: false},
});
}
const opts = {
width: w,
height: h,
series: seriesOpts,
axes: [
{
stroke: '#585b70',
ticks: {stroke:'#313244'},
grid: {stroke:'#313244'},
values: (_u, ts) => ts.map(t => fmtTime(t)),
},
{
stroke: '#585b70',
ticks: {stroke:'#313244'},
grid: {stroke:'#313244'},
size: 60,
}
],
legend: { show: true, live: false },
cursor: { drag: {x:true, y:false} },
hooks: {
ready: [uInst => {
const legend = uInst.root.querySelector('.u-legend');
if (legend) {
legend.querySelectorAll('.u-label').forEach((lbl, i) => {
if (i === 0) return;
lbl.title = 'Right-click to remove';
lbl.addEventListener('contextmenu', e => {
e.preventDefault();
const sName = p.series[i-1]?.name;
if (sName) removeSignalFromPlot(p.id, sName);
});
});
}
}]
},
};
const data = buildPlotData(p);
try {
p.chart = new uPlot(opts, data, wrap);
plotRO.observe(wrap);
} catch(e) {
console.error('uPlot error', e);
}
});
}
function buildPlotData(p) {
const maxPts = p.maxPts;
// Find common time range: union of all series timestamps
// uPlot requires aligned data — we use each series' own timestamps
// For simplicity use the first non-empty series timestamps as X axis,
// and for other series linearly interpolate.
// Simpler approach: collect all unique timestamps (sorted) and use NaN for missing.
// Per-series data
const seriesTs = p.series.map(s => {
const td = traceData[s.name];
return td ? td.ts : [];
});
const seriesVs = p.series.map(s => {
const td = traceData[s.name];
if (!td) return [];
return td.vals.map(v => v * s.gain + s.offset);
});
// Use first series with data as the X reference, or merge all
let xRef = [];
for (const ts of seriesTs) if (ts.length > 0) { xRef = ts; break; }
if (xRef.length === 0) return [[]];
// Trim to maxPts
const start = Math.max(0, xRef.length - maxPts);
const times = xRef.slice(start);
const data = [times];
for (let i = 0; i < p.series.length; i++) {
const ts = seriesTs[i];
const vs = seriesVs[i];
if (ts.length === 0) {
data.push(new Array(times.length).fill(NaN));
continue;
}
const s = Math.max(0, ts.length - maxPts);
data.push(vs.slice(s));
}
return data;
}
// ════════════════════════════════════════════════════════════════════
// Render loop
// ════════════════════════════════════════════════════════════════════
function renderLoop() {
for (const p of plots) {
if (p.dirty && p.chart && p.series.length > 0) {
const data = buildPlotData(p);
try { p.chart.setData(data, p.follow); } catch(e){}
p.dirty = false;
}
}
if (logDirty) { flushLogs(); logDirty = false; }
requestAnimationFrame(renderLoop);
}
// ════════════════════════════════════════════════════════════════════
// Logs
// ════════════════════════════════════════════════════════════════════
function appendLog(time, level, message) {
logs.push({time: time==='sys'?now():time, level, message});
if (logs.length > MAX_LOGS) logs.shift();
logDirty = true;
}
function flushLogs() {
renderLogs();
}
function renderLogs() {
logDirty = false;
const body = document.getElementById('log-body');
const showDebug = document.getElementById('lf-debug').checked;
const showInfo = document.getElementById('lf-info').checked;
const showWarn = document.getElementById('lf-warn').checked;
const showError = document.getElementById('lf-error').checked;
const filter = document.getElementById('log-filter').value.toLowerCase();
const visible = logs.filter(l => {
const lv = l.level;
if (lv==='DEBUG' && !showDebug) return false;
if ((lv==='INFO'||lv==='Information') && !showInfo) return false;
if ((lv==='WARNING'||lv==='Warning') && !showWarn) return false;
if ((lv==='ERROR'||lv==='FatalError'||lv==='FATAL'||lv==='WARN') && !showError) return false;
if (filter && !l.message.toLowerCase().includes(filter)) return false;
return true;
}).slice(-500);
body.innerHTML = visible.map(l =>
`<div class="log-line ${esc(l.level)}">` +
`<span class="log-time">${esc(l.time)}</span>` +
`<span class="log-lvl">${esc(l.level)}</span>` +
`<span class="log-msg" title="${esc(l.message)}">${esc(l.message)}</span>` +
`</div>`
).join('');
if (document.getElementById('log-autoscroll').checked) {
body.scrollTop = body.scrollHeight;
}
}
// ════════════════════════════════════════════════════════════════════
// Tab switching
// ════════════════════════════════════════════════════════════════════
function switchTab(name) {
document.querySelectorAll('.tab').forEach((t,i)=>{
const names=['traced','forced','breaks','msgs'];
t.classList.toggle('active', names[i]===name);
});
document.querySelectorAll('.tab-content').forEach(t=>{
t.classList.remove('active');
});
document.getElementById(`tab-${name}`)?.classList.add('active');
}
// ════════════════════════════════════════════════════════════════════
// Dialogs
// ════════════════════════════════════════════════════════════════════
function openDlg(id) { document.getElementById(id).style.display='flex'; }
function closeDlg(id) { document.getElementById(id).style.display='none'; }
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
document.querySelectorAll('.dialog-overlay').forEach(d => d.style.display='none');
}
});
// ════════════════════════════════════════════════════════════════════
// Helpers
// ════════════════════════════════════════════════════════════════════
let _colorIdx = 0;
function nextColor() { return COLORS[_colorIdx++ % COLORS.length]; }
function shortName(name) {
const parts = name.split('.');
return parts.length > 2 ? '…'+parts.slice(-2).join('.') : name;
}
function fmtVal(v) {
if (v === undefined || v === null || isNaN(v)) return '—';
if (Math.abs(v) < 1e-3 && v !== 0) return v.toExponential(3);
if (Math.abs(v) >= 1e6) return v.toExponential(3);
return v.toPrecision(6).replace(/\.?0+$/, '');
}
function fmtTime(t) {
if (t == null) return '';
const s = t;
const h = Math.floor(s/3600);
const m = Math.floor((s%3600)/60);
const sec = (s%60).toFixed(2);
if (h>0) return `${h}:${String(m).padStart(2,'0')}:${sec.padStart(5,'0')}`;
if (m>0) return `${m}:${sec.padStart(5,'0')}`;
return `${sec}s`;
}
function now() {
return new Date().toLocaleTimeString('en',{hour12:false,hour:'2-digit',minute:'2-digit',second:'2-digit',fractionalSecondDigits:3});
}
function esc(s) {
if (s==null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ════════════════════════════════════════════════════════════════════
// Resize observer — per plot-wrap, keeps uPlot size in sync
// ════════════════════════════════════════════════════════════════════
const plotRO = new ResizeObserver(entries => {
for (const entry of entries) {
const wrap = entry.target;
const m = wrap.id.match(/^plot-wrap-(\d+)$/);
if (!m) continue;
const p = findPlot(parseInt(m[1]));
if (!p || !p.chart) continue;
const w = Math.max(wrap.clientWidth - 8, 200);
const h = Math.max(wrap.clientHeight - 6, 120);
try { p.chart.setSize({width: w, height: h}); } catch(e) {}
}
});
// ════════════════════════════════════════════════════════════════════
// Dropdown menus
// ════════════════════════════════════════════════════════════════════
function toggleMenu(id) {
const el = document.getElementById(id);
if (!el) return;
const isOpen = el.classList.contains('open');
closeAllMenus();
if (!isOpen) el.classList.add('open');
}
function closeAllMenus() {
document.querySelectorAll('.dropdown.open').forEach(d => d.classList.remove('open'));
}
// Close menus when clicking outside
document.addEventListener('click', e => {
if (!e.target.closest('.menu-wrap')) closeAllMenus();
});
// ════════════════════════════════════════════════════════════════════
// Panel collapse
// ════════════════════════════════════════════════════════════════════
function togglePanelStrip(panelId) {
const panel = document.getElementById(panelId);
if (!panel) return;
const wasCollapsed = panel.classList.toggle('collapsed');
if (!wasCollapsed) {
// Expanding: restore the last manual width if one was saved
const saved = panel.dataset.savedWidth;
if (saved) panel.style.width = saved;
}
}
function togglePanel(panelId, collapseIcon, expandIcon, btn) {
const panel = document.getElementById(panelId);
if (!panel) return;
const collapsed = panel.classList.toggle('collapsed');
if (btn) btn.textContent = collapsed ? expandIcon : collapseIcon;
}
function setupPanelResize(stripId, panelId, growDir) {
// growDir: 'right' means dragging right widens the panel (left panel),
// 'left' means dragging left widens the panel (right panel).
const strip = document.getElementById(stripId);
const panel = document.getElementById(panelId);
if (!strip || !panel) return;
strip.addEventListener('mousedown', e => {
if (e.button !== 0) return;
e.preventDefault();
const startX = e.clientX;
const startW = panel.offsetWidth;
let moved = false;
const onMove = ev => {
const dx = ev.clientX - startX;
if (!moved && Math.abs(dx) < 4) return;
moved = true;
// Expand collapsed panel as soon as drag begins
if (panel.classList.contains('collapsed')) {
panel.classList.remove('collapsed');
}
panel.style.transition = 'none';
document.body.style.cursor = 'col-resize';
const newW = Math.max(120, Math.min(700,
growDir === 'right' ? startW + dx : startW - dx));
panel.style.width = newW + 'px';
panel.dataset.savedWidth = panel.style.width;
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
panel.style.transition = '';
document.body.style.cursor = '';
if (!moved) {
// Plain click — toggle collapse
togglePanelStrip(panelId);
}
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
// ════════════════════════════════════════════════════════════════════
// Boot
// ════════════════════════════════════════════════════════════════════
connectWS();
requestAnimationFrame(renderLoop);
setupPanelResize('left-strip', 'left-panel', 'right');
setupPanelResize('right-strip', 'right-panel', 'left');
addPlot();