major improvements for big apps
This commit is contained in:
@@ -75,32 +75,189 @@ function sendCmd(cmd) { sendWS({type:'cmd', data:{cmd}}); }
|
||||
|
||||
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 '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 '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');
|
||||
// Auto-discover
|
||||
sendCmd('DISCOVER');
|
||||
sendCmd('TREE');
|
||||
// Start polling step status
|
||||
startStepPoll();
|
||||
// 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');
|
||||
@@ -108,16 +265,26 @@ function onMarteDisconnected() {
|
||||
}
|
||||
|
||||
function onSignalCache(msg) {
|
||||
// Server sends cached signal list when a new browser connects mid-session
|
||||
if (msg.signals) processDiscovery(msg.signals);
|
||||
// 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 || []); break;
|
||||
case 'TREE': if (data) processTree(data); break;
|
||||
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;
|
||||
@@ -165,9 +332,16 @@ function onTelemetry(msg) {
|
||||
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++) {
|
||||
const n = vals.length > 1 ? `${names[0]}[${e}]` : (names[0] || '');
|
||||
if (!n || !tracedSet.has(n)) continue;
|
||||
// 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()};
|
||||
@@ -176,25 +350,26 @@ function onTelemetry(msg) {
|
||||
td.ts.push(s.ts);
|
||||
td.vals.push(vals[e]);
|
||||
td.lastVal = vals[e];
|
||||
// Trim to max points
|
||||
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
|
||||
for (const p of plots) {
|
||||
for (const sr of p.series) {
|
||||
if (names.includes(sr.name) || (vals.length>1 && names.length>0 &&
|
||||
p.series.some(x => x.name.startsWith(names[0])))) {
|
||||
p.dirty = true;
|
||||
break;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update only the value spans — never rebuild the DOM here
|
||||
updateTracedValues();
|
||||
}
|
||||
|
||||
@@ -234,13 +409,25 @@ function updateSignalAutocomplete() {
|
||||
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 = '';
|
||||
if (root.Name === 'Root' && root.Children) {
|
||||
for (const child of root.Children) body.appendChild(buildTreeNode(child, ''));
|
||||
} else {
|
||||
body.appendChild(buildTreeNode(root, ''));
|
||||
}
|
||||
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);
|
||||
@@ -255,28 +442,33 @@ function processTree(root) {
|
||||
// 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;
|
||||
const isThread = item.Class === 'RealTimeThread';
|
||||
|
||||
if (isThread && item.Name) threads.push(item.Name);
|
||||
if (item.Class && item.Class !== 'Signal' && item.Name) allObjects.push(path);
|
||||
|
||||
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);
|
||||
const children = document.createElement('div');
|
||||
children.className = 'children';
|
||||
// Info button for container nodes
|
||||
const leaf = makeLeafButtons(path);
|
||||
if (leaf) children.appendChild(leaf);
|
||||
for (const c of item.Children) children.appendChild(buildTreeNode(c, path));
|
||||
details.appendChild(children);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -363,14 +555,16 @@ function mkBtn(label, cls, fn) {
|
||||
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';
|
||||
});
|
||||
if (q) {
|
||||
body.querySelectorAll('details').forEach(d => d.open = true);
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
@@ -628,6 +822,7 @@ function step(n) {
|
||||
}
|
||||
|
||||
function startStepPoll() {
|
||||
if (!_treeReady) return; // hard gate: never poll before tree is fully built
|
||||
stopStepPoll();
|
||||
stepPollTimer = setInterval(() => sendCmd('STEP_STATUS'), 2000);
|
||||
}
|
||||
@@ -647,8 +842,10 @@ function processStepStatus(data) {
|
||||
data.StepRemaining > 0 ? `(${data.StepRemaining} remaining)` : '';
|
||||
document.getElementById('btn-pause').textContent = '▶ Resume';
|
||||
// Faster polling while paused
|
||||
stopStepPoll();
|
||||
stepPollTimer = setInterval(() => sendCmd('STEP_STATUS'), 500);
|
||||
if (_treeReady) {
|
||||
stopStepPoll();
|
||||
stepPollTimer = setInterval(() => sendCmd('STEP_STATUS'), 500);
|
||||
}
|
||||
} else {
|
||||
if (isPaused) {
|
||||
isPaused = false;
|
||||
@@ -1130,12 +1327,15 @@ document.addEventListener('click', e => {
|
||||
// Panel collapse
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
function togglePanelStrip(panelId, stripId, collapseIcon, expandIcon) {
|
||||
function togglePanelStrip(panelId) {
|
||||
const panel = document.getElementById(panelId);
|
||||
const strip = document.getElementById(stripId);
|
||||
if (!panel) return;
|
||||
const collapsed = panel.classList.toggle('collapsed');
|
||||
if (strip) strip.textContent = collapsed ? expandIcon : collapseIcon;
|
||||
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) {
|
||||
@@ -1145,9 +1345,62 @@ function togglePanel(panelId, collapseIcon, expandIcon, btn) {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user