diff --git a/Source/Components/Interfaces/DebugService/DebugService.cpp b/Source/Components/Interfaces/DebugService/DebugService.cpp index 8b1368a..53fa7ec 100644 --- a/Source/Components/Interfaces/DebugService/DebugService.cpp +++ b/Source/Components/Interfaces/DebugService/DebugService.cpp @@ -324,6 +324,8 @@ ErrorManagement::ErrorType DebugService::Server(ExecutionInfo &info) { if (out.Size() > 0u) { const char8 *wPtr = out.Buffer(); uint32 remaining = (uint32)out.Size(); + lastDataTimeMs = (uint64)((float64)HighResolutionTimer::Counter() * + HighResolutionTimer::Period() * 1000.0); while (remaining > 0u) { uint32 wrote = remaining; if (!activeClient->Write(wPtr, wrote) || wrote == 0u) { @@ -331,6 +333,8 @@ ErrorManagement::ErrorType DebugService::Server(ExecutionInfo &info) { } wPtr += wrote; remaining -= wrote; + lastDataTimeMs = (uint64)((float64)HighResolutionTimer::Counter() * + HighResolutionTimer::Period() * 1000.0); } } } diff --git a/Source/Components/Interfaces/DebugService/DebugService.h b/Source/Components/Interfaces/DebugService/DebugService.h index 11b6466..6414571 100644 --- a/Source/Components/Interfaces/DebugService/DebugService.h +++ b/Source/Components/Interfaces/DebugService/DebugService.h @@ -94,7 +94,10 @@ private: // Rate-limiting and idle-timeout constants / state static const uint32 CMD_RATE_LIMIT = 100u; - static const uint32 CLIENT_IDLE_TIMEOUT_MS = 30000u; + // Idle timeout: time with no incoming bytes before the connection is closed. + // Large applications (1000+ signals) can take >30 s to process a DISCOVER/TREE + // response, during which no commands are sent — use a generous default. + static const uint32 CLIENT_IDLE_TIMEOUT_MS = 120000u; static const uint32 INPUT_BUFFER_MAX = 8192u; uint32 cmdCountInWindow; diff --git a/Source/Components/Interfaces/DebugService/DebugServiceBase.cpp b/Source/Components/Interfaces/DebugService/DebugServiceBase.cpp index a4e268d..082fad1 100644 --- a/Source/Components/Interfaces/DebugService/DebugServiceBase.cpp +++ b/Source/Components/Interfaces/DebugService/DebugServiceBase.cpp @@ -180,6 +180,8 @@ DebugServiceBase::DebugServiceBase() : ReferenceContainer() { isPaused = false; stepRemaining = 0u; manualConfigSet = false; + discoverCacheValid = false; + treeCacheValid = false; } DebugServiceBase::~DebugServiceBase() { @@ -256,6 +258,9 @@ DebugSignalInfo *DebugServiceBase::RegisterSignal(void *memoryAddress, res->breakOp = BREAK_OFF; res->breakThreshold = 0.0; signals.Append(res); + // Invalidate caches — new signal registered after the last build. + discoverCacheValid = false; + treeCacheValid = false; } if (sigIdx != 0xFFFFFFFFu) { bool found = false; @@ -716,54 +721,102 @@ void DebugServiceBase::GetSignalValue(const char8 *name, StreamString &out) { } void DebugServiceBase::Discover(StreamString &out) { - out += "{\"Signals\":[\n"; + if (!discoverCacheValid) + BuildDiscoverCache(); + out += discoverCache; +} + +void DebugServiceBase::BuildDiscoverCache() { + discoverCacheValid = false; + discoverCache = ""; + + // ----------------------------------------------------------------------- + // Phase 1 — snapshot signal metadata under the mutex (fast, no allocs on + // the RT path because this runs only during initialisation). + // ----------------------------------------------------------------------- + Vector enames; + Vector eids; + Vector etypes; + Vector edims; + Vector eelems; + mutex.FastLock(); - uint32 total = 0u; for (uint32 i = 0u; i < aliases.GetNumberOfElements(); i++) { - if (total > 0u) - out += ",\n"; DebugSignalInfo *sig = signals[aliases[i].signalIndex]; - const char8 *tn = TypeDescriptor::GetTypeNameFromTypeDescriptor(sig->type); - out.Printf(" {\"name\":\"%s\",\"id\":%u,\"type\":\"%s\"," - "\"dimensions\":%u,\"elements\":%u", - aliases[i].name.Buffer(), sig->internalID, tn ? tn : "Unknown", - sig->numberOfDimensions, sig->numberOfElements); - EnrichWithConfig(aliases[i].name.Buffer(), out); - out += "}"; - total++; + enames.Append(aliases[i].name); + eids.Append(sig->internalID); + etypes.Append(TypeDescriptor::GetTypeNameFromTypeDescriptor(sig->type)); + edims.Append(sig->numberOfDimensions); + eelems.Append(sig->numberOfElements); } for (uint32 i = 0u; i < monitoredSignals.GetNumberOfElements(); i++) { bool found = false; for (uint32 j = 0u; j < aliases.GetNumberOfElements(); j++) { - if (aliases[j].name == monitoredSignals[i].path) { - found = true; - break; - } + if (aliases[j].name == monitoredSignals[i].path) { found = true; break; } } if (!found) { - if (total > 0u) - out += ",\n"; - const char8 *tn = TypeDescriptor::GetTypeNameFromTypeDescriptor( - monitoredSignals[i].dataSource->GetSignalType( - monitoredSignals[i].signalIdx)); - uint8 dims = 0u; + uint8 dims = 0u; uint32 elems = 1u; (void)monitoredSignals[i].dataSource->GetSignalNumberOfDimensions( monitoredSignals[i].signalIdx, dims); (void)monitoredSignals[i].dataSource->GetSignalNumberOfElements( monitoredSignals[i].signalIdx, elems); - out.Printf(" {\"name\":\"%s\",\"id\":%u,\"type\":\"%s\"," - "\"dimensions\":%u,\"elements\":%u", - monitoredSignals[i].path.Buffer(), - monitoredSignals[i].internalID, tn ? tn : "Unknown", dims, - elems); - EnrichWithConfig(monitoredSignals[i].path.Buffer(), out); - out += "}"; - total++; + enames.Append(monitoredSignals[i].path); + eids.Append(monitoredSignals[i].internalID); + etypes.Append(TypeDescriptor::GetTypeNameFromTypeDescriptor( + monitoredSignals[i].dataSource->GetSignalType( + monitoredSignals[i].signalIdx))); + edims.Append(dims); + eelems.Append(elems); } } mutex.FastUnLock(); - out += "\n]}\nOK DISCOVER\n"; + + // ----------------------------------------------------------------------- + // Phase 2 — build chunked response (no mutex; fullConfig is single-threaded + // at initialisation time when this is called from SetFullConfig). + // ----------------------------------------------------------------------- + uint32 total = enames.GetNumberOfElements(); + + if (total == 0u) { + discoverCache = "{\"Signals\":[]}\nOK DISCOVER\n"; + discoverCacheValid = true; + return; + } + + for (uint32 start = 0u; start < total; start += DISCOVER_CHUNK_SIGNALS) { + uint32 end = start + DISCOVER_CHUNK_SIGNALS; + if (end > total) end = total; + bool isFinal = (end >= total); + + discoverCache += "{\"Signals\":[\n"; + for (uint32 idx = start; idx < end; idx++) { + if (idx > start) discoverCache += ",\n"; + discoverCache.Printf( + " {\"name\":\"%s\",\"id\":%u,\"type\":\"%s\"," + "\"dimensions\":%u,\"elements\":%u", + enames[idx].Buffer(), eids[idx], + etypes[idx] ? etypes[idx] : "Unknown", + edims[idx], eelems[idx]); + EnrichWithConfig(enames[idx].Buffer(), discoverCache); + discoverCache += "}"; + } + discoverCache += "\n]}"; + discoverCache += isFinal ? "\nOK DISCOVER\n" : "\nOK DISCOVER_PART\n"; + } + + discoverCacheValid = true; +} + +void DebugServiceBase::BuildTreeCache() { + treeCacheValid = false; + treeCache = ""; + treeCache += "{\"Name\":\"Root\",\"Class\":\"ObjectRegistryDatabase\"," + "\"Children\":[\n"; + (void)ExportTree(ObjectRegistryDatabase::Instance(), treeCache, + NULL_PTR(const char8 *)); + treeCache += "\n]}\nOK TREE\n"; + treeCacheValid = true; } void DebugServiceBase::InfoNode(const char8 *path, StreamString &out) { @@ -868,6 +921,8 @@ void DebugServiceBase::SetFullConfig(ConfigurationDatabase &config) { config.MoveToRoot(); config.Copy(fullConfig); manualConfigSet = true; + BuildDiscoverCache(); + BuildTreeCache(); } void DebugServiceBase::RebuildConfigFromRegistry() { @@ -1234,11 +1289,9 @@ void DebugServiceBase::HandleCommand(const StreamString &cmdIn, } else if (token == "DISCOVER") { Discover(out); } else if (token == "TREE") { - out += "{\"Name\":\"Root\",\"Class\":\"ObjectRegistryDatabase\"," - "\"Children\":[\n"; - (void)ExportTree(ObjectRegistryDatabase::Instance(), out, - NULL_PTR(const char8 *)); - out += "\n]}\nOK TREE\n"; + if (!treeCacheValid) + BuildTreeCache(); + out += treeCache; } else if (token == "INFO") { StreamString path; if (cmd.GetToken(path, delims, term)) @@ -1415,5 +1468,6 @@ void DebugServiceBase::HandleCommand(const StreamString &cmdIn, // Out-of-class constant definitions (C++98 ODR requirement) // --------------------------------------------------------------------------- const uint32 DebugServiceBase::GET_VALUE_MAX_ELEMENTS; +const uint32 DebugServiceBase::DISCOVER_CHUNK_SIGNALS; } // namespace MARTe diff --git a/Source/Components/Interfaces/DebugService/DebugServiceBase.h b/Source/Components/Interfaces/DebugService/DebugServiceBase.h index a7529aa..6f08b9f 100644 --- a/Source/Components/Interfaces/DebugService/DebugServiceBase.h +++ b/Source/Components/Interfaces/DebugService/DebugServiceBase.h @@ -125,6 +125,22 @@ public: static const uint32 GET_VALUE_MAX_ELEMENTS = 256u; + /** + * @brief Pre-build DISCOVER and TREE response caches. + * + * Called automatically by SetFullConfig() once the application is fully + * initialised. After this point, DISCOVER and TREE are served from the + * pre-built string with no mutex contention and no JSON generation cost. + * Both caches are invalidated whenever a new signal is registered (which + * normally only happens during broker init, before SetFullConfig). + */ + void BuildDiscoverCache(); + void BuildTreeCache(); + + // Number of signals per DISCOVER_PART TCP chunk. Responses larger than + // this are split so the Go client can start parsing immediately. + static const uint32 DISCOVER_CHUNK_SIGNALS = 256u; + protected: // ========================================================================= // Virtual hooks for transport subclasses @@ -183,6 +199,13 @@ protected: ConfigurationDatabase fullConfig; bool manualConfigSet; + + // Pre-built response caches. Guarded by mutex (brief lock for swap, + // none needed for reads once cacheValid is true and construction is done). + StreamString discoverCache; // full chunked DISCOVER_PART+DISCOVER payload + StreamString treeCache; // full TREE payload including sentinel + volatile bool discoverCacheValid; + volatile bool treeCacheValid; }; } // namespace MARTe diff --git a/Tools/go_web_client/Makefile b/Tools/go_web_client/Makefile new file mode 100644 index 0000000..818870f --- /dev/null +++ b/Tools/go_web_client/Makefile @@ -0,0 +1,16 @@ +BINARY := marte2-web-client +GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +TIMESTAMP := $(shell date -u +%Y%m%d-%H%M%S) +VERSION := $(GIT_HASH)-$(TIMESTAMP) + +LDFLAGS := -X main.buildVersion=$(VERSION) + +.PHONY: all build clean + +all: build + +build: + CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o $(BINARY) . + +clean: + rm -f $(BINARY) diff --git a/Tools/go_web_client/main.go b/Tools/go_web_client/main.go index 4549f1b..301cfb8 100644 --- a/Tools/go_web_client/main.go +++ b/Tools/go_web_client/main.go @@ -13,6 +13,11 @@ import ( "github.com/gorilla/websocket" ) +// buildVersion is injected at link time: +// +// go build -ldflags "-X main.buildVersion=$(git rev-parse --short HEAD)" +var buildVersion = "dev" + //go:embed static var staticFiles embed.FS @@ -159,9 +164,12 @@ func (c *WSClient) handleIncoming(in InMsg) { if d.LogPort == 0 { d.LogPort = 8082 } + log.Printf("[WS] connect request host=%s cmd=%d udp=%d log=%d", + d.Host, d.Port, d.UDPPort, d.LogPort) m.Connect(d.Host, d.Port, d.UDPPort, d.LogPort) case "disconnect": + log.Printf("[WS] disconnect request") m.Disconnect() case "cmd": @@ -232,6 +240,6 @@ func main() { fileServer.ServeHTTP(w, r) }) - log.Printf("MARTe2 Web Debug Client listening on %s", *addr) + log.Printf("MARTe2 Web Debug Client build=%s listening on %s", buildVersion, *addr) log.Fatal(http.ListenAndServe(*addr, nil)) } diff --git a/Tools/go_web_client/marte2-web-client b/Tools/go_web_client/marte2-web-client index a905101..3467bcc 100755 Binary files a/Tools/go_web_client/marte2-web-client and b/Tools/go_web_client/marte2-web-client differ diff --git a/Tools/go_web_client/marte2.go b/Tools/go_web_client/marte2.go index 8e34743..eab5229 100644 --- a/Tools/go_web_client/marte2.go +++ b/Tools/go_web_client/marte2.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "encoding/json" "fmt" + "log" "math" "net" "strings" @@ -67,8 +68,13 @@ type MarteClient struct { connected int32 // atomic bool + lastWriteMs int64 // atomic; updated on every TCP write, used by keepalive + stopCh chan struct{} + // accumulates signals across DISCOVER_PART chunks; merged on final DISCOVER + discoverAcc []discoverSignalJSON + // cached last-known ports (updated by SERVICE_INFO) host string cmdPort int @@ -144,6 +150,7 @@ func (m *MarteClient) Disconnect() { m.basesMu.Lock() m.baseTsSet = false m.basesMu.Unlock() + m.discoverAcc = nil } func (m *MarteClient) stopped() bool { @@ -180,6 +187,8 @@ func (m *MarteClient) runTCP(host string, port int) { // Send SERVICE_INFO to auto-discover ports m.writeCmd("SERVICE_INFO") + go m.runKeepalive() + m.readLoop(conn) atomic.StoreInt32(&m.connected, 0) @@ -205,8 +214,35 @@ func (m *MarteClient) writeCmd(cmd string) { if w == nil { return } + log.Printf("[→MARTe] %s", cmd) + broadcast(m.hub, map[string]any{ + "type": "log", "time": time.Now().Format("15:04:05.000"), + "level": "CMD", "message": fmt.Sprintf("→ %s", cmd), + }) w.WriteString(cmd + "\n") w.Flush() + atomic.StoreInt64(&m.lastWriteMs, time.Now().UnixMilli()) +} + +// runKeepalive sends INFO every 20 s when no other command has been written +// in the last 20 s, keeping the DebugService 30 s idle timer from firing. +func (m *MarteClient) runKeepalive() { + ticker := time.NewTicker(20 * time.Second) + defer ticker.Stop() + for { + select { + case <-m.stopCh: + return + case <-ticker.C: + if !m.IsConnected() { + continue + } + idleMs := time.Now().UnixMilli() - atomic.LoadInt64(&m.lastWriteMs) + if idleMs >= 20_000 { + m.writeCmd("INFO") + } + } + } } func (m *MarteClient) SendCommand(cmd string) { @@ -215,7 +251,7 @@ func (m *MarteClient) SendCommand(cmd string) { func (m *MarteClient) readLoop(conn net.Conn) { scanner := bufio.NewScanner(conn) - scanner.Buffer(make([]byte, 1*1024*1024), 1*1024*1024) + scanner.Buffer(make([]byte, 8*1024*1024), 8*1024*1024) var jsonAcc strings.Builder inJSON := false @@ -256,15 +292,18 @@ func (m *MarteClient) readLoop(conn net.Conn) { } } -// detectJSONDone returns (tag, true) when the line contains an "OK " sentinel. +// detectJSONDone returns (tag, true) when the line is exactly "OK ". +// DISCOVER_PART must appear before DISCOVER so partial chunks are not mistaken +// for the final chunk (though with exact matching this is not strictly +// necessary; kept for clarity). func detectJSONDone(line string) (string, bool) { tags := []string{ - "DISCOVER", "TREE", "INFO", "CONFIG", "STEP_STATUS", + "DISCOVER_PART", "DISCOVER", "TREE", "INFO", "CONFIG", "STEP_STATUS", "VALUE", "MSG", "TRACE", "FORCE", "UNFORCE", "BREAK", "PAUSE", "RESUME", "STEP", "MONITOR", "UNMONITOR", "LS", } for _, t := range tags { - if strings.Contains(line, "OK "+t) { + if line == "OK "+t { return t, true } } @@ -272,11 +311,47 @@ func detectJSONDone(line string) (string, bool) { } func (m *MarteClient) handleJSONResponse(tag, data string) { + log.Printf("[←MARTe] %s %d bytes", tag, len(data)) + broadcast(m.hub, map[string]any{ + "type": "log", "time": time.Now().Format("15:04:05.000"), + "level": "RESP", "message": fmt.Sprintf("← %s (%d B)", tag, len(data)), + }) switch tag { + case "DISCOVER_PART": + // Accumulate this chunk; do NOT broadcast — we wait for the final chunk. + var resp discoverResp + if err := json.Unmarshal([]byte(data), &resp); err != nil { + log.Printf("[DISCOVER_PART] parse error: %v", err) + return + } + m.discoverAcc = append(m.discoverAcc, resp.Signals...) + log.Printf("[DISCOVER_PART] accumulated %d signals (total so far: %d)", + len(resp.Signals), len(m.discoverAcc)) + return + case "DISCOVER": - m.parseDiscover(data) + // Merge any previously accumulated part-chunks with this final chunk. + var resp discoverResp + if err := json.Unmarshal([]byte(data), &resp); err != nil { + log.Printf("[DISCOVER] parse error: %v", err) + return + } + all := append(m.discoverAcc, resp.Signals...) + m.discoverAcc = nil + m.parseDiscoverSignals(all) + // Re-marshal the merged list so the browser gets a single consistent blob. + merged, _ := json.Marshal(discoverResp{Signals: all}) + broadcast(m.hub, map[string]any{ + "type": "response", "tag": "DISCOVER", "data": string(merged), + }) + return + + case "TREE": + // Parse server-side and stream pre-digested flat-node batches to the + // browser so it never has to JSON.parse a multi-MB string. + go m.sendTreeBatches(data) + return } - // Forward raw JSON to all browsers. broadcast(m.hub, map[string]any{ "type": "response", "tag": tag, @@ -284,6 +359,91 @@ func (m *MarteClient) handleJSONResponse(tag, data string) { }) } +// --------------------------------------------------------------------------- +// TREE streaming +// --------------------------------------------------------------------------- + +// TreeNode mirrors the DebugService TREE JSON structure. +type TreeNode struct { + Name string `json:"Name"` + Class string `json:"Class"` + IsTraceable bool `json:"IsTraceable"` + IsForcable bool `json:"IsForcable"` + Elements uint32 `json:"Elements"` + Children []*TreeNode `json:"Children"` +} + +// FlatNode is a pre-digested, compact representation sent to the browser. +type FlatNode struct { + Path string `json:"p"` + Name string `json:"n"` + Class string `json:"c"` + Parent string `json:"par"` + Tr bool `json:"tr,omitempty"` + Fo bool `json:"fo,omitempty"` + El uint32 `json:"el,omitempty"` + HasCh bool `json:"ch,omitempty"` +} + +func flattenTree(node *TreeNode, parentPath string, out *[]FlatNode) { + path := node.Name + if parentPath != "" { + path = parentPath + "." + node.Name + } + hasCh := len(node.Children) > 0 + *out = append(*out, FlatNode{ + Path: path, + Name: node.Name, + Class: node.Class, + Parent: parentPath, + Tr: node.IsTraceable, + Fo: node.IsForcable, + El: node.Elements, + HasCh: hasCh, + }) + for _, c := range node.Children { + flattenTree(c, path, out) + } +} + +// sendTreeBatches parses the raw TREE JSON and streams compact FlatNode +// batches to all browsers. Each browser processes one batch per animation +// frame, keeping the UI responsive during large tree loads. +func (m *MarteClient) sendTreeBatches(raw string) { + var root TreeNode + if err := json.Unmarshal([]byte(raw), &root); err != nil { + log.Printf("[TREE] parse error: %v — falling back to raw JSON", err) + broadcast(m.hub, map[string]any{"type": "response", "tag": "TREE", "data": raw}) + return + } + + var nodes []FlatNode + if root.Name == "Root" && len(root.Children) > 0 { + for _, c := range root.Children { + flattenTree(c, "", &nodes) + } + } else { + flattenTree(&root, "", &nodes) + } + + total := len(nodes) + log.Printf("[TREE] streaming %d flat nodes to browser", total) + broadcast(m.hub, map[string]any{"type": "tree_clear", "total": total}) + + const batchSize = 50 + for i := 0; i < total; i += batchSize { + end := i + batchSize + if end > total { + end = total + } + broadcast(m.hub, map[string]any{ + "type": "tree_batch", + "nodes": nodes[i:end], + "done": end == total, + }) + } +} + func (m *MarteClient) handleTextLine(line string) { if strings.HasPrefix(line, "OK SERVICE_INFO") { // OK SERVICE_INFO TCP_CTRL:8110 UDP_STREAM:8111 TCP_LOG:8082 STATE:RUNNING @@ -340,29 +500,35 @@ func (m *MarteClient) handleTextLine(line string) { // DISCOVER parsing // --------------------------------------------------------------------------- -type discoverResp struct { - Signals []struct { - Name string `json:"name"` - ID uint32 `json:"id"` - Type string `json:"type"` - Dimensions uint8 `json:"dimensions"` - Elements uint32 `json:"elements"` - } `json:"Signals"` +type discoverSignalJSON struct { + Name string `json:"name"` + ID uint32 `json:"id"` + Type string `json:"type"` + Dimensions uint8 `json:"dimensions"` + Elements uint32 `json:"elements"` } -func (m *MarteClient) parseDiscover(data string) { - var resp discoverResp - if err := json.Unmarshal([]byte(data), &resp); err != nil { - return - } +type discoverResp struct { + Signals []discoverSignalJSON `json:"Signals"` +} + +func (m *MarteClient) parseDiscoverSignals(sigs []discoverSignalJSON) { m.sigMu.Lock() defer m.sigMu.Unlock() - m.signals = make(map[uint32]*SignalMeta, len(resp.Signals)) - for _, s := range resp.Signals { + m.signals = make(map[uint32]*SignalMeta, len(sigs)) + for _, s := range sigs { el := s.Elements if el == 0 { el = 1 } + // Multiple DISCOVER entries can share the same ID (canonical DataSource + // path + one GAM alias per broker). Merge them into one SignalMeta so + // that Names carries every alias. This is required for the telemetry + // receiver to match whichever name the user traced. + if existing, ok := m.signals[s.ID]; ok { + existing.Names = append(existing.Names, s.Name) + continue + } meta := &SignalMeta{ Name: s.Name, ID: s.ID, @@ -373,6 +539,7 @@ func (m *MarteClient) parseDiscover(data string) { } m.signals[s.ID] = meta } + log.Printf("[DISCOVER] registered %d unique signals from %d entries", len(m.signals), len(sigs)) } // --------------------------------------------------------------------------- diff --git a/Tools/go_web_client/static/app.js b/Tools/go_web_client/static/app.js index d4788d3..f7df60b 100644 --- a/Tools/go_web_client/static/app.js +++ b/Tools/go_web_client/static/app.js @@ -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
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 +let _childrenOf = null; // Map + +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 = '' + + threads.map(t => ``).join(''); + } else if (el.tagName === 'DATALIST') { + el.innerHTML = threads.map(t => `