major improvements for big apps
This commit is contained in:
@@ -324,6 +324,8 @@ ErrorManagement::ErrorType DebugService::Server(ExecutionInfo &info) {
|
|||||||
if (out.Size() > 0u) {
|
if (out.Size() > 0u) {
|
||||||
const char8 *wPtr = out.Buffer();
|
const char8 *wPtr = out.Buffer();
|
||||||
uint32 remaining = (uint32)out.Size();
|
uint32 remaining = (uint32)out.Size();
|
||||||
|
lastDataTimeMs = (uint64)((float64)HighResolutionTimer::Counter() *
|
||||||
|
HighResolutionTimer::Period() * 1000.0);
|
||||||
while (remaining > 0u) {
|
while (remaining > 0u) {
|
||||||
uint32 wrote = remaining;
|
uint32 wrote = remaining;
|
||||||
if (!activeClient->Write(wPtr, wrote) || wrote == 0u) {
|
if (!activeClient->Write(wPtr, wrote) || wrote == 0u) {
|
||||||
@@ -331,6 +333,8 @@ ErrorManagement::ErrorType DebugService::Server(ExecutionInfo &info) {
|
|||||||
}
|
}
|
||||||
wPtr += wrote;
|
wPtr += wrote;
|
||||||
remaining -= wrote;
|
remaining -= wrote;
|
||||||
|
lastDataTimeMs = (uint64)((float64)HighResolutionTimer::Counter() *
|
||||||
|
HighResolutionTimer::Period() * 1000.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,10 @@ private:
|
|||||||
|
|
||||||
// Rate-limiting and idle-timeout constants / state
|
// Rate-limiting and idle-timeout constants / state
|
||||||
static const uint32 CMD_RATE_LIMIT = 100u;
|
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;
|
static const uint32 INPUT_BUFFER_MAX = 8192u;
|
||||||
|
|
||||||
uint32 cmdCountInWindow;
|
uint32 cmdCountInWindow;
|
||||||
|
|||||||
@@ -180,6 +180,8 @@ DebugServiceBase::DebugServiceBase() : ReferenceContainer() {
|
|||||||
isPaused = false;
|
isPaused = false;
|
||||||
stepRemaining = 0u;
|
stepRemaining = 0u;
|
||||||
manualConfigSet = false;
|
manualConfigSet = false;
|
||||||
|
discoverCacheValid = false;
|
||||||
|
treeCacheValid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
DebugServiceBase::~DebugServiceBase() {
|
DebugServiceBase::~DebugServiceBase() {
|
||||||
@@ -256,6 +258,9 @@ DebugSignalInfo *DebugServiceBase::RegisterSignal(void *memoryAddress,
|
|||||||
res->breakOp = BREAK_OFF;
|
res->breakOp = BREAK_OFF;
|
||||||
res->breakThreshold = 0.0;
|
res->breakThreshold = 0.0;
|
||||||
signals.Append(res);
|
signals.Append(res);
|
||||||
|
// Invalidate caches — new signal registered after the last build.
|
||||||
|
discoverCacheValid = false;
|
||||||
|
treeCacheValid = false;
|
||||||
}
|
}
|
||||||
if (sigIdx != 0xFFFFFFFFu) {
|
if (sigIdx != 0xFFFFFFFFu) {
|
||||||
bool found = false;
|
bool found = false;
|
||||||
@@ -716,54 +721,102 @@ void DebugServiceBase::GetSignalValue(const char8 *name, StreamString &out) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void DebugServiceBase::Discover(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<StreamString> enames;
|
||||||
|
Vector<uint32> eids;
|
||||||
|
Vector<const char8*> etypes;
|
||||||
|
Vector<uint8> edims;
|
||||||
|
Vector<uint32> eelems;
|
||||||
|
|
||||||
mutex.FastLock();
|
mutex.FastLock();
|
||||||
uint32 total = 0u;
|
|
||||||
for (uint32 i = 0u; i < aliases.GetNumberOfElements(); i++) {
|
for (uint32 i = 0u; i < aliases.GetNumberOfElements(); i++) {
|
||||||
if (total > 0u)
|
|
||||||
out += ",\n";
|
|
||||||
DebugSignalInfo *sig = signals[aliases[i].signalIndex];
|
DebugSignalInfo *sig = signals[aliases[i].signalIndex];
|
||||||
const char8 *tn = TypeDescriptor::GetTypeNameFromTypeDescriptor(sig->type);
|
enames.Append(aliases[i].name);
|
||||||
out.Printf(" {\"name\":\"%s\",\"id\":%u,\"type\":\"%s\","
|
eids.Append(sig->internalID);
|
||||||
"\"dimensions\":%u,\"elements\":%u",
|
etypes.Append(TypeDescriptor::GetTypeNameFromTypeDescriptor(sig->type));
|
||||||
aliases[i].name.Buffer(), sig->internalID, tn ? tn : "Unknown",
|
edims.Append(sig->numberOfDimensions);
|
||||||
sig->numberOfDimensions, sig->numberOfElements);
|
eelems.Append(sig->numberOfElements);
|
||||||
EnrichWithConfig(aliases[i].name.Buffer(), out);
|
|
||||||
out += "}";
|
|
||||||
total++;
|
|
||||||
}
|
}
|
||||||
for (uint32 i = 0u; i < monitoredSignals.GetNumberOfElements(); i++) {
|
for (uint32 i = 0u; i < monitoredSignals.GetNumberOfElements(); i++) {
|
||||||
bool found = false;
|
bool found = false;
|
||||||
for (uint32 j = 0u; j < aliases.GetNumberOfElements(); j++) {
|
for (uint32 j = 0u; j < aliases.GetNumberOfElements(); j++) {
|
||||||
if (aliases[j].name == monitoredSignals[i].path) {
|
if (aliases[j].name == monitoredSignals[i].path) { found = true; break; }
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!found) {
|
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;
|
uint32 elems = 1u;
|
||||||
(void)monitoredSignals[i].dataSource->GetSignalNumberOfDimensions(
|
(void)monitoredSignals[i].dataSource->GetSignalNumberOfDimensions(
|
||||||
monitoredSignals[i].signalIdx, dims);
|
monitoredSignals[i].signalIdx, dims);
|
||||||
(void)monitoredSignals[i].dataSource->GetSignalNumberOfElements(
|
(void)monitoredSignals[i].dataSource->GetSignalNumberOfElements(
|
||||||
monitoredSignals[i].signalIdx, elems);
|
monitoredSignals[i].signalIdx, elems);
|
||||||
out.Printf(" {\"name\":\"%s\",\"id\":%u,\"type\":\"%s\","
|
enames.Append(monitoredSignals[i].path);
|
||||||
"\"dimensions\":%u,\"elements\":%u",
|
eids.Append(monitoredSignals[i].internalID);
|
||||||
monitoredSignals[i].path.Buffer(),
|
etypes.Append(TypeDescriptor::GetTypeNameFromTypeDescriptor(
|
||||||
monitoredSignals[i].internalID, tn ? tn : "Unknown", dims,
|
monitoredSignals[i].dataSource->GetSignalType(
|
||||||
elems);
|
monitoredSignals[i].signalIdx)));
|
||||||
EnrichWithConfig(monitoredSignals[i].path.Buffer(), out);
|
edims.Append(dims);
|
||||||
out += "}";
|
eelems.Append(elems);
|
||||||
total++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mutex.FastUnLock();
|
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) {
|
void DebugServiceBase::InfoNode(const char8 *path, StreamString &out) {
|
||||||
@@ -868,6 +921,8 @@ void DebugServiceBase::SetFullConfig(ConfigurationDatabase &config) {
|
|||||||
config.MoveToRoot();
|
config.MoveToRoot();
|
||||||
config.Copy(fullConfig);
|
config.Copy(fullConfig);
|
||||||
manualConfigSet = true;
|
manualConfigSet = true;
|
||||||
|
BuildDiscoverCache();
|
||||||
|
BuildTreeCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
void DebugServiceBase::RebuildConfigFromRegistry() {
|
void DebugServiceBase::RebuildConfigFromRegistry() {
|
||||||
@@ -1234,11 +1289,9 @@ void DebugServiceBase::HandleCommand(const StreamString &cmdIn,
|
|||||||
} else if (token == "DISCOVER") {
|
} else if (token == "DISCOVER") {
|
||||||
Discover(out);
|
Discover(out);
|
||||||
} else if (token == "TREE") {
|
} else if (token == "TREE") {
|
||||||
out += "{\"Name\":\"Root\",\"Class\":\"ObjectRegistryDatabase\","
|
if (!treeCacheValid)
|
||||||
"\"Children\":[\n";
|
BuildTreeCache();
|
||||||
(void)ExportTree(ObjectRegistryDatabase::Instance(), out,
|
out += treeCache;
|
||||||
NULL_PTR(const char8 *));
|
|
||||||
out += "\n]}\nOK TREE\n";
|
|
||||||
} else if (token == "INFO") {
|
} else if (token == "INFO") {
|
||||||
StreamString path;
|
StreamString path;
|
||||||
if (cmd.GetToken(path, delims, term))
|
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)
|
// Out-of-class constant definitions (C++98 ODR requirement)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const uint32 DebugServiceBase::GET_VALUE_MAX_ELEMENTS;
|
const uint32 DebugServiceBase::GET_VALUE_MAX_ELEMENTS;
|
||||||
|
const uint32 DebugServiceBase::DISCOVER_CHUNK_SIGNALS;
|
||||||
|
|
||||||
} // namespace MARTe
|
} // namespace MARTe
|
||||||
|
|||||||
@@ -125,6 +125,22 @@ public:
|
|||||||
|
|
||||||
static const uint32 GET_VALUE_MAX_ELEMENTS = 256u;
|
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:
|
protected:
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Virtual hooks for transport subclasses
|
// Virtual hooks for transport subclasses
|
||||||
@@ -183,6 +199,13 @@ protected:
|
|||||||
|
|
||||||
ConfigurationDatabase fullConfig;
|
ConfigurationDatabase fullConfig;
|
||||||
bool manualConfigSet;
|
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
|
} // namespace MARTe
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -13,6 +13,11 @@ import (
|
|||||||
"github.com/gorilla/websocket"
|
"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
|
//go:embed static
|
||||||
var staticFiles embed.FS
|
var staticFiles embed.FS
|
||||||
|
|
||||||
@@ -159,9 +164,12 @@ func (c *WSClient) handleIncoming(in InMsg) {
|
|||||||
if d.LogPort == 0 {
|
if d.LogPort == 0 {
|
||||||
d.LogPort = 8082
|
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)
|
m.Connect(d.Host, d.Port, d.UDPPort, d.LogPort)
|
||||||
|
|
||||||
case "disconnect":
|
case "disconnect":
|
||||||
|
log.Printf("[WS] disconnect request")
|
||||||
m.Disconnect()
|
m.Disconnect()
|
||||||
|
|
||||||
case "cmd":
|
case "cmd":
|
||||||
@@ -232,6 +240,6 @@ func main() {
|
|||||||
fileServer.ServeHTTP(w, r)
|
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))
|
log.Fatal(http.ListenAndServe(*addr, nil))
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
+183
-16
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -67,8 +68,13 @@ type MarteClient struct {
|
|||||||
|
|
||||||
connected int32 // atomic bool
|
connected int32 // atomic bool
|
||||||
|
|
||||||
|
lastWriteMs int64 // atomic; updated on every TCP write, used by keepalive
|
||||||
|
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
|
|
||||||
|
// accumulates signals across DISCOVER_PART chunks; merged on final DISCOVER
|
||||||
|
discoverAcc []discoverSignalJSON
|
||||||
|
|
||||||
// cached last-known ports (updated by SERVICE_INFO)
|
// cached last-known ports (updated by SERVICE_INFO)
|
||||||
host string
|
host string
|
||||||
cmdPort int
|
cmdPort int
|
||||||
@@ -144,6 +150,7 @@ func (m *MarteClient) Disconnect() {
|
|||||||
m.basesMu.Lock()
|
m.basesMu.Lock()
|
||||||
m.baseTsSet = false
|
m.baseTsSet = false
|
||||||
m.basesMu.Unlock()
|
m.basesMu.Unlock()
|
||||||
|
m.discoverAcc = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MarteClient) stopped() bool {
|
func (m *MarteClient) stopped() bool {
|
||||||
@@ -180,6 +187,8 @@ func (m *MarteClient) runTCP(host string, port int) {
|
|||||||
// Send SERVICE_INFO to auto-discover ports
|
// Send SERVICE_INFO to auto-discover ports
|
||||||
m.writeCmd("SERVICE_INFO")
|
m.writeCmd("SERVICE_INFO")
|
||||||
|
|
||||||
|
go m.runKeepalive()
|
||||||
|
|
||||||
m.readLoop(conn)
|
m.readLoop(conn)
|
||||||
|
|
||||||
atomic.StoreInt32(&m.connected, 0)
|
atomic.StoreInt32(&m.connected, 0)
|
||||||
@@ -205,8 +214,35 @@ func (m *MarteClient) writeCmd(cmd string) {
|
|||||||
if w == nil {
|
if w == nil {
|
||||||
return
|
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.WriteString(cmd + "\n")
|
||||||
w.Flush()
|
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) {
|
func (m *MarteClient) SendCommand(cmd string) {
|
||||||
@@ -215,7 +251,7 @@ func (m *MarteClient) SendCommand(cmd string) {
|
|||||||
|
|
||||||
func (m *MarteClient) readLoop(conn net.Conn) {
|
func (m *MarteClient) readLoop(conn net.Conn) {
|
||||||
scanner := bufio.NewScanner(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
|
var jsonAcc strings.Builder
|
||||||
inJSON := false
|
inJSON := false
|
||||||
@@ -256,15 +292,18 @@ func (m *MarteClient) readLoop(conn net.Conn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectJSONDone returns (tag, true) when the line contains an "OK <TAG>" sentinel.
|
// detectJSONDone returns (tag, true) when the line is exactly "OK <TAG>".
|
||||||
|
// 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) {
|
func detectJSONDone(line string) (string, bool) {
|
||||||
tags := []string{
|
tags := []string{
|
||||||
"DISCOVER", "TREE", "INFO", "CONFIG", "STEP_STATUS",
|
"DISCOVER_PART", "DISCOVER", "TREE", "INFO", "CONFIG", "STEP_STATUS",
|
||||||
"VALUE", "MSG", "TRACE", "FORCE", "UNFORCE", "BREAK",
|
"VALUE", "MSG", "TRACE", "FORCE", "UNFORCE", "BREAK",
|
||||||
"PAUSE", "RESUME", "STEP", "MONITOR", "UNMONITOR", "LS",
|
"PAUSE", "RESUME", "STEP", "MONITOR", "UNMONITOR", "LS",
|
||||||
}
|
}
|
||||||
for _, t := range tags {
|
for _, t := range tags {
|
||||||
if strings.Contains(line, "OK "+t) {
|
if line == "OK "+t {
|
||||||
return t, true
|
return t, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,11 +311,47 @@ func detectJSONDone(line string) (string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *MarteClient) handleJSONResponse(tag, data string) {
|
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 {
|
switch tag {
|
||||||
case "DISCOVER":
|
case "DISCOVER_PART":
|
||||||
m.parseDiscover(data)
|
// 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":
|
||||||
|
// 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{
|
broadcast(m.hub, map[string]any{
|
||||||
"type": "response",
|
"type": "response",
|
||||||
"tag": tag,
|
"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) {
|
func (m *MarteClient) handleTextLine(line string) {
|
||||||
if strings.HasPrefix(line, "OK SERVICE_INFO") {
|
if strings.HasPrefix(line, "OK SERVICE_INFO") {
|
||||||
// OK SERVICE_INFO TCP_CTRL:8110 UDP_STREAM:8111 TCP_LOG:8082 STATE:RUNNING
|
// 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
|
// DISCOVER parsing
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type discoverResp struct {
|
type discoverSignalJSON struct {
|
||||||
Signals []struct {
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ID uint32 `json:"id"`
|
ID uint32 `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Dimensions uint8 `json:"dimensions"`
|
Dimensions uint8 `json:"dimensions"`
|
||||||
Elements uint32 `json:"elements"`
|
Elements uint32 `json:"elements"`
|
||||||
} `json:"Signals"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MarteClient) parseDiscover(data string) {
|
type discoverResp struct {
|
||||||
var resp discoverResp
|
Signals []discoverSignalJSON `json:"Signals"`
|
||||||
if err := json.Unmarshal([]byte(data), &resp); err != nil {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MarteClient) parseDiscoverSignals(sigs []discoverSignalJSON) {
|
||||||
m.sigMu.Lock()
|
m.sigMu.Lock()
|
||||||
defer m.sigMu.Unlock()
|
defer m.sigMu.Unlock()
|
||||||
m.signals = make(map[uint32]*SignalMeta, len(resp.Signals))
|
m.signals = make(map[uint32]*SignalMeta, len(sigs))
|
||||||
for _, s := range resp.Signals {
|
for _, s := range sigs {
|
||||||
el := s.Elements
|
el := s.Elements
|
||||||
if el == 0 {
|
if el == 0 {
|
||||||
el = 1
|
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{
|
meta := &SignalMeta{
|
||||||
Name: s.Name,
|
Name: s.Name,
|
||||||
ID: s.ID,
|
ID: s.ID,
|
||||||
@@ -373,6 +539,7 @@ func (m *MarteClient) parseDiscover(data string) {
|
|||||||
}
|
}
|
||||||
m.signals[s.ID] = meta
|
m.signals[s.ID] = meta
|
||||||
}
|
}
|
||||||
|
log.Printf("[DISCOVER] registered %d unique signals from %d entries", len(m.signals), len(sigs))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -84,23 +84,180 @@ function handleMsg(msg) {
|
|||||||
case 'log': onLog(msg); break;
|
case 'log': onLog(msg); break;
|
||||||
case 'service_config': onServiceConfig(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() {
|
function onMarteConnected() {
|
||||||
marteConnected = true;
|
marteConnected = true;
|
||||||
|
_clearStartupState();
|
||||||
document.getElementById('conn-status').classList.add('ok');
|
document.getElementById('conn-status').classList.add('ok');
|
||||||
document.getElementById('btn-connect').textContent = 'Disconnect';
|
document.getElementById('btn-connect').textContent = 'Disconnect';
|
||||||
appendLog('sys','INFO','Connected to MARTe2');
|
appendLog('sys','INFO','Connected to MARTe2');
|
||||||
// Auto-discover
|
// Send DISCOVER immediately so the user gets feedback without waiting for
|
||||||
sendCmd('DISCOVER');
|
// SERVICE_INFO. STEP_STATUS is gated by _treeReady so the server is not
|
||||||
sendCmd('TREE');
|
// flooded with status polls during the (potentially long) init phase.
|
||||||
// Start polling step status
|
sendOnce('DISCOVER');
|
||||||
startStepPoll();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMarteDisconnected() {
|
function onMarteDisconnected() {
|
||||||
marteConnected = false;
|
marteConnected = false;
|
||||||
|
_clearStartupState();
|
||||||
document.getElementById('conn-status').classList.remove('ok');
|
document.getElementById('conn-status').classList.remove('ok');
|
||||||
document.getElementById('btn-connect').textContent = 'Connect';
|
document.getElementById('btn-connect').textContent = 'Connect';
|
||||||
appendLog('sys','WARNING','Disconnected from MARTe2');
|
appendLog('sys','WARNING','Disconnected from MARTe2');
|
||||||
@@ -108,16 +265,26 @@ function onMarteDisconnected() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onSignalCache(msg) {
|
function onSignalCache(msg) {
|
||||||
// Server sends cached signal list when a new browser connects mid-session
|
// Go server has cached signals from a previous DISCOVER — skip DISCOVER,
|
||||||
if (msg.signals) processDiscovery(msg.signals);
|
// 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) {
|
function onResponse(msg) {
|
||||||
try {
|
try {
|
||||||
const data = msg.data ? JSON.parse(msg.data) : null;
|
const data = msg.data ? JSON.parse(msg.data) : null;
|
||||||
switch (msg.tag) {
|
switch (msg.tag) {
|
||||||
case 'DISCOVER': if (data) processDiscovery(data.Signals || []); break;
|
case 'DISCOVER':
|
||||||
case 'TREE': if (data) processTree(data); break;
|
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 'INFO': showInfo(msg.tag, msg.data); break;
|
||||||
case 'CONFIG': showInfo('CONFIG', msg.data); break;
|
case 'CONFIG': showInfo('CONFIG', msg.data); break;
|
||||||
case 'SERVICE_INFO': showInfo('Service Info', 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) {
|
for (const s of msg.signals) {
|
||||||
const names = s.names || [];
|
const names = s.names || [];
|
||||||
const vals = s.values || [];
|
const vals = s.values || [];
|
||||||
|
let plotDirty = false;
|
||||||
for (let e = 0; e < vals.length; e++) {
|
for (let e = 0; e < vals.length; e++) {
|
||||||
const n = vals.length > 1 ? `${names[0]}[${e}]` : (names[0] || '');
|
// A signal can have multiple names (canonical DataSource path + GAM
|
||||||
if (!n || !tracedSet.has(n)) continue;
|
// 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];
|
let td = traceData[n];
|
||||||
if (!td) {
|
if (!td) {
|
||||||
td = {ts:[], vals:[], lastVal:0, color: nextColor()};
|
td = {ts:[], vals:[], lastVal:0, color: nextColor()};
|
||||||
@@ -176,25 +350,26 @@ function onTelemetry(msg) {
|
|||||||
td.ts.push(s.ts);
|
td.ts.push(s.ts);
|
||||||
td.vals.push(vals[e]);
|
td.vals.push(vals[e]);
|
||||||
td.lastVal = vals[e];
|
td.lastVal = vals[e];
|
||||||
// Trim to max points
|
|
||||||
if (td.ts.length > MAX_TRACE_PTS) {
|
if (td.ts.length > MAX_TRACE_PTS) {
|
||||||
const trim = td.ts.length - MAX_TRACE_PTS;
|
const trim = td.ts.length - MAX_TRACE_PTS;
|
||||||
td.ts.splice(0, trim);
|
td.ts.splice(0, trim);
|
||||||
td.vals.splice(0, trim);
|
td.vals.splice(0, trim);
|
||||||
}
|
}
|
||||||
|
plotDirty = true;
|
||||||
}
|
}
|
||||||
// Mark plots dirty
|
// Mark plots dirty if any of this signal's names appear in a series.
|
||||||
|
if (plotDirty) {
|
||||||
for (const p of plots) {
|
for (const p of plots) {
|
||||||
for (const sr of p.series) {
|
for (const sr of p.series) {
|
||||||
if (names.includes(sr.name) || (vals.length>1 && names.length>0 &&
|
if (names.some(nm => sr.name === nm ||
|
||||||
p.series.some(x => x.name.startsWith(names[0])))) {
|
(vals.length > 1 && sr.name.startsWith(nm + '[')))) {
|
||||||
p.dirty = true;
|
p.dirty = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Update only the value spans — never rebuild the DOM here
|
}
|
||||||
updateTracedValues();
|
updateTracedValues();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,13 +409,25 @@ function updateSignalAutocomplete() {
|
|||||||
function processTree(root) {
|
function processTree(root) {
|
||||||
threads = [];
|
threads = [];
|
||||||
allObjects = [];
|
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');
|
const body = document.getElementById('tree-body');
|
||||||
body.innerHTML = '';
|
body.innerHTML = '';
|
||||||
if (root.Name === 'Root' && root.Children) {
|
const frag = document.createDocumentFragment();
|
||||||
for (const child of root.Children) body.appendChild(buildTreeNode(child, ''));
|
const topNodes = (root.Name === 'Root' && root.Children) ? root.Children : [root];
|
||||||
} else {
|
for (const child of topNodes) frag.appendChild(buildTreeNode(child, ''));
|
||||||
body.appendChild(buildTreeNode(root, ''));
|
body.appendChild(frag);
|
||||||
}
|
|
||||||
// Populate thread selects
|
// Populate thread selects
|
||||||
['step-thread','step-thread-list','step-thread-inp'].forEach(id=>{
|
['step-thread','step-thread-list','step-thread-inp'].forEach(id=>{
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
@@ -255,28 +442,33 @@ function processTree(root) {
|
|||||||
// Populate msg dest autocomplete
|
// Populate msg dest autocomplete
|
||||||
const dl = document.getElementById('msg-dest-list');
|
const dl = document.getElementById('msg-dest-list');
|
||||||
dl.innerHTML = allObjects.map(n=>`<option value="${esc(n)}">`).join('');
|
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) {
|
function buildTreeNode(item, parentPath) {
|
||||||
const path = parentPath ? parentPath + '.' + item.Name : item.Name;
|
const path = parentPath ? parentPath + '.' + item.Name : item.Name;
|
||||||
const isSig = item.Class === 'Signal' || item.IsTraceable;
|
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) {
|
if (item.Children && item.Children.length > 0) {
|
||||||
const details = document.createElement('details');
|
const details = document.createElement('details');
|
||||||
const summary = document.createElement('summary');
|
const summary = document.createElement('summary');
|
||||||
summary.innerHTML = `<span class="tree-name">${esc(item.Name)}</span><span class="tree-class">[${esc(item.Class)}]</span>`;
|
summary.innerHTML = `<span class="tree-name">${esc(item.Name)}</span><span class="tree-class">[${esc(item.Class)}]</span>`;
|
||||||
details.appendChild(summary);
|
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');
|
const children = document.createElement('div');
|
||||||
children.className = 'children';
|
children.className = 'children';
|
||||||
// Info button for container nodes
|
|
||||||
const leaf = makeLeafButtons(path);
|
const leaf = makeLeafButtons(path);
|
||||||
if (leaf) children.appendChild(leaf);
|
if (leaf) children.appendChild(leaf);
|
||||||
for (const c of item.Children) children.appendChild(buildTreeNode(c, path));
|
for (const c of item.Children) children.appendChild(buildTreeNode(c, path));
|
||||||
details.appendChild(children);
|
details.appendChild(children);
|
||||||
|
});
|
||||||
|
|
||||||
return details;
|
return details;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,14 +555,16 @@ function mkBtn(label, cls, fn) {
|
|||||||
function filterTree(q) {
|
function filterTree(q) {
|
||||||
const body = document.getElementById('tree-body');
|
const body = document.getElementById('tree-body');
|
||||||
q = q.toLowerCase();
|
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 => {
|
body.querySelectorAll('.tree-leaf').forEach(el => {
|
||||||
if (!q) { el.style.display = ''; return; }
|
if (!q) { el.style.display = ''; return; }
|
||||||
const t = (el.querySelector('.tree-name')?.title || '').toLowerCase();
|
const t = (el.querySelector('.tree-name')?.title || '').toLowerCase();
|
||||||
el.style.display = t.includes(q) ? '' : 'none';
|
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() {
|
function startStepPoll() {
|
||||||
|
if (!_treeReady) return; // hard gate: never poll before tree is fully built
|
||||||
stopStepPoll();
|
stopStepPoll();
|
||||||
stepPollTimer = setInterval(() => sendCmd('STEP_STATUS'), 2000);
|
stepPollTimer = setInterval(() => sendCmd('STEP_STATUS'), 2000);
|
||||||
}
|
}
|
||||||
@@ -647,8 +842,10 @@ function processStepStatus(data) {
|
|||||||
data.StepRemaining > 0 ? `(${data.StepRemaining} remaining)` : '';
|
data.StepRemaining > 0 ? `(${data.StepRemaining} remaining)` : '';
|
||||||
document.getElementById('btn-pause').textContent = '▶ Resume';
|
document.getElementById('btn-pause').textContent = '▶ Resume';
|
||||||
// Faster polling while paused
|
// Faster polling while paused
|
||||||
|
if (_treeReady) {
|
||||||
stopStepPoll();
|
stopStepPoll();
|
||||||
stepPollTimer = setInterval(() => sendCmd('STEP_STATUS'), 500);
|
stepPollTimer = setInterval(() => sendCmd('STEP_STATUS'), 500);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (isPaused) {
|
if (isPaused) {
|
||||||
isPaused = false;
|
isPaused = false;
|
||||||
@@ -1130,12 +1327,15 @@ document.addEventListener('click', e => {
|
|||||||
// Panel collapse
|
// Panel collapse
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function togglePanelStrip(panelId, stripId, collapseIcon, expandIcon) {
|
function togglePanelStrip(panelId) {
|
||||||
const panel = document.getElementById(panelId);
|
const panel = document.getElementById(panelId);
|
||||||
const strip = document.getElementById(stripId);
|
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
const collapsed = panel.classList.toggle('collapsed');
|
const wasCollapsed = panel.classList.toggle('collapsed');
|
||||||
if (strip) strip.textContent = collapsed ? expandIcon : collapseIcon;
|
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) {
|
function togglePanel(panelId, collapseIcon, expandIcon, btn) {
|
||||||
@@ -1145,9 +1345,62 @@ function togglePanel(panelId, collapseIcon, expandIcon, btn) {
|
|||||||
if (btn) btn.textContent = collapsed ? expandIcon : collapseIcon;
|
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
|
// Boot
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
connectWS();
|
connectWS();
|
||||||
requestAnimationFrame(renderLoop);
|
requestAnimationFrame(renderLoop);
|
||||||
|
setupPanelResize('left-strip', 'left-panel', 'right');
|
||||||
|
setupPanelResize('right-strip', 'right-panel', 'left');
|
||||||
|
addPlot();
|
||||||
|
|||||||
@@ -61,11 +61,13 @@ label{color:#a6adc8;user-select:none}
|
|||||||
#right-panel.collapsed{width:0!important;border:none;overflow:hidden}
|
#right-panel.collapsed{width:0!important;border:none;overflow:hidden}
|
||||||
#log-panel{transition:height .15s}
|
#log-panel{transition:height .15s}
|
||||||
#log-panel.collapsed{height:28px;overflow:hidden}
|
#log-panel.collapsed{height:28px;overflow:hidden}
|
||||||
/* collapse toggle strips */
|
/* collapse/resize strips */
|
||||||
.panel-strip{width:14px;flex-shrink:0;display:flex;align-items:center;justify-content:center;cursor:pointer;background:#181825;border:1px solid #313244;color:#585b70;font-size:10px;writing-mode:horizontal-tb;user-select:none}
|
.panel-strip{width:6px;flex-shrink:0;display:flex;align-items:center;justify-content:center;cursor:col-resize;background:#181825;border:none;color:#585b70;font-size:9px;user-select:none;position:relative}
|
||||||
.panel-strip:hover{background:#313244;color:#cdd6f4}
|
.panel-strip::after{content:'';position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:2px;height:32px;background:#45475a;border-radius:1px;pointer-events:none}
|
||||||
#left-strip{border-left:none;border-top:none;border-bottom:none}
|
.panel-strip:hover{background:#313244}
|
||||||
#right-strip{border-right:none;border-top:none;border-bottom:none}
|
.panel-strip:hover::after{background:#89b4fa}
|
||||||
|
#left-strip{border-right:1px solid #313244}
|
||||||
|
#right-strip{border-left:1px solid #313244}
|
||||||
|
|
||||||
/* ── tree ── */
|
/* ── tree ── */
|
||||||
.tree-node{padding:1px 0}
|
.tree-node{padding:1px 0}
|
||||||
@@ -244,16 +246,16 @@ select{background:#313244;border:1px solid #45475a;color:#cdd6f4;padding:2px 4px
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Left collapse strip -->
|
<!-- Left resize/collapse strip -->
|
||||||
<div id="left-strip" class="panel-strip" title="Toggle left panel" onclick="togglePanelStrip('left-panel','left-strip','◀','▶')">◀</div>
|
<div id="left-strip" class="panel-strip" title="Drag to resize · Click to collapse"></div>
|
||||||
|
|
||||||
<!-- CENTER: plots -->
|
<!-- CENTER: plots -->
|
||||||
<div id="center-panel">
|
<div id="center-panel">
|
||||||
<div class="empty-hint" id="no-plots-hint">Add a plot panel to begin — drag signals from the tree or traced list</div>
|
<div class="empty-hint" id="no-plots-hint">Add a plot panel to begin — drag signals from the tree or traced list</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right collapse strip -->
|
<!-- Right resize/collapse strip -->
|
||||||
<div id="right-strip" class="panel-strip" title="Toggle right panel" onclick="togglePanelStrip('right-panel','right-strip','▶','◀')">▶</div>
|
<div id="right-strip" class="panel-strip" title="Drag to resize · Click to collapse"></div>
|
||||||
|
|
||||||
<!-- RIGHT: tabs -->
|
<!-- RIGHT: tabs -->
|
||||||
<div id="right-panel">
|
<div id="right-panel">
|
||||||
|
|||||||
Reference in New Issue
Block a user