Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d967098ead | |||
| 242b1c4406 |
@@ -7,3 +7,4 @@ bin/
|
||||
dependency/
|
||||
.cache/
|
||||
target/
|
||||
CLAUDE.md
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
module marte2-web-client
|
||||
|
||||
go 1.21
|
||||
|
||||
require github.com/gorilla/websocket v1.5.1
|
||||
|
||||
require golang.org/x/net v0.17.0 // indirect
|
||||
@@ -0,0 +1,4 @@
|
||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
@@ -0,0 +1,237 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var staticFiles embed.FS
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire message types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type InMsg struct {
|
||||
Type string `json:"type"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type ConnectData struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
UDPPort int `json:"udp_port"`
|
||||
LogPort int `json:"log_port"`
|
||||
}
|
||||
|
||||
type CmdData struct {
|
||||
Cmd string `json:"cmd"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hub – fan-out WS messages to all connected browsers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*WSClient]struct{}
|
||||
marte *MarteClient
|
||||
}
|
||||
|
||||
func newHub() *Hub {
|
||||
return &Hub{clients: make(map[*WSClient]struct{})}
|
||||
}
|
||||
|
||||
func (h *Hub) add(c *WSClient) {
|
||||
h.mu.Lock()
|
||||
h.clients[c] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Hub) remove(c *WSClient) {
|
||||
h.mu.Lock()
|
||||
delete(h.clients, c)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Hub) broadcast(msg []byte) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for c := range h.clients {
|
||||
select {
|
||||
case c.send <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WSClient – one browser tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type WSClient struct {
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
func (c *WSClient) writePump() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
c.conn.Close()
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-c.send:
|
||||
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if !ok {
|
||||
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) readPump() {
|
||||
defer func() {
|
||||
c.hub.remove(c)
|
||||
c.conn.Close()
|
||||
}()
|
||||
c.conn.SetReadLimit(256 * 1024)
|
||||
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
for {
|
||||
_, raw, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
|
||||
var in InMsg
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
continue
|
||||
}
|
||||
c.handleIncoming(in)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WSClient) handleIncoming(in InMsg) {
|
||||
m := c.hub.marte
|
||||
switch in.Type {
|
||||
case "connect":
|
||||
var d ConnectData
|
||||
if err := json.Unmarshal(in.Data, &d); err != nil {
|
||||
return
|
||||
}
|
||||
if d.Host == "" {
|
||||
d.Host = "127.0.0.1"
|
||||
}
|
||||
if d.Port == 0 {
|
||||
d.Port = 8080
|
||||
}
|
||||
if d.UDPPort == 0 {
|
||||
d.UDPPort = 8081
|
||||
}
|
||||
if d.LogPort == 0 {
|
||||
d.LogPort = 8082
|
||||
}
|
||||
m.Connect(d.Host, d.Port, d.UDPPort, d.LogPort)
|
||||
|
||||
case "disconnect":
|
||||
m.Disconnect()
|
||||
|
||||
case "cmd":
|
||||
var d CmdData
|
||||
if err := json.Unmarshal(in.Data, &d); err != nil {
|
||||
return
|
||||
}
|
||||
m.SendCommand(d.Cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func wsHandler(hub *Hub, w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Println("upgrade:", err)
|
||||
return
|
||||
}
|
||||
c := &WSClient{hub: hub, conn: conn, send: make(chan []byte, 256)}
|
||||
hub.add(c)
|
||||
|
||||
// Send current connection status to newly joined client.
|
||||
if hub.marte.IsConnected() {
|
||||
b, _ := json.Marshal(map[string]any{"type": "connected"})
|
||||
c.send <- b
|
||||
// Also push current signal list if already discovered.
|
||||
hub.marte.SendCachedState(c)
|
||||
}
|
||||
|
||||
go c.writePump()
|
||||
c.readPump() // blocks until client disconnects
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", ":7777", "HTTP listen address")
|
||||
flag.Parse()
|
||||
|
||||
hub := newHub()
|
||||
hub.marte = newMarteClient(hub)
|
||||
|
||||
sub, err := fs.Sub(staticFiles, "static")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fileServer := http.FileServer(http.FS(sub))
|
||||
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
wsHandler(hub, w, r)
|
||||
})
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Serve index.html for the root path explicitly.
|
||||
if r.URL.Path == "/" {
|
||||
data, err := staticFiles.ReadFile("static/index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write(data)
|
||||
return
|
||||
}
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
log.Printf("MARTe2 Web Debug Client listening on %s", *addr)
|
||||
log.Fatal(http.ListenAndServe(*addr, nil))
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,590 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal metadata (populated by DISCOVER)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SignalMeta struct {
|
||||
Name string `json:"name"`
|
||||
ID uint32 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Dimensions uint8 `json:"dimensions"`
|
||||
Elements uint32 `json:"elements"`
|
||||
Names []string // canonical + alias names mapping to this ID
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outbound WS message helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TelemetrySample struct {
|
||||
ID uint32 `json:"id"`
|
||||
Names []string `json:"names"`
|
||||
Ts float64 `json:"ts"`
|
||||
Values []float64 `json:"values"`
|
||||
}
|
||||
|
||||
func broadcast(hub *Hub, v any) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
hub.broadcast(b)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MarteClient
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type MarteClient struct {
|
||||
hub *Hub
|
||||
|
||||
mu sync.Mutex
|
||||
tcpConn net.Conn
|
||||
writer *bufio.Writer
|
||||
|
||||
cmdMu sync.Mutex // serialise TCP writes
|
||||
|
||||
sigMu sync.RWMutex
|
||||
signals map[uint32]*SignalMeta // id -> meta
|
||||
|
||||
baseTs uint64
|
||||
baseTsSet bool
|
||||
basesMu sync.Mutex
|
||||
|
||||
connected int32 // atomic bool
|
||||
|
||||
stopCh chan struct{}
|
||||
|
||||
// cached last-known ports (updated by SERVICE_INFO)
|
||||
host string
|
||||
cmdPort int
|
||||
udpPort int
|
||||
logPort int
|
||||
}
|
||||
|
||||
func newMarteClient(hub *Hub) *MarteClient {
|
||||
return &MarteClient{
|
||||
hub: hub,
|
||||
signals: make(map[uint32]*SignalMeta),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MarteClient) IsConnected() bool {
|
||||
return atomic.LoadInt32(&m.connected) == 1
|
||||
}
|
||||
|
||||
// SendCachedState sends the current signal list to a freshly connected browser.
|
||||
func (m *MarteClient) SendCachedState(c *WSClient) {
|
||||
m.sigMu.RLock()
|
||||
defer m.sigMu.RUnlock()
|
||||
if len(m.signals) == 0 {
|
||||
return
|
||||
}
|
||||
sigs := make([]*SignalMeta, 0, len(m.signals))
|
||||
for _, s := range m.signals {
|
||||
sigs = append(sigs, s)
|
||||
}
|
||||
b, _ := json.Marshal(map[string]any{"type": "signal_cache", "signals": sigs})
|
||||
select {
|
||||
case c.send <- b:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connect / Disconnect
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (m *MarteClient) Connect(host string, cmdPort, udpPort, logPort int) {
|
||||
m.Disconnect()
|
||||
|
||||
m.mu.Lock()
|
||||
m.host = host
|
||||
m.cmdPort = cmdPort
|
||||
m.udpPort = udpPort
|
||||
m.logPort = logPort
|
||||
m.stopCh = make(chan struct{})
|
||||
m.mu.Unlock()
|
||||
|
||||
go m.runTCP(host, cmdPort)
|
||||
go m.runUDP(host, udpPort)
|
||||
go m.runLog(host, logPort)
|
||||
}
|
||||
|
||||
func (m *MarteClient) Disconnect() {
|
||||
m.mu.Lock()
|
||||
select {
|
||||
case <-m.stopCh:
|
||||
// already closed
|
||||
default:
|
||||
close(m.stopCh)
|
||||
}
|
||||
if m.tcpConn != nil {
|
||||
m.tcpConn.Close()
|
||||
m.tcpConn = nil
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
atomic.StoreInt32(&m.connected, 0)
|
||||
m.basesMu.Lock()
|
||||
m.baseTsSet = false
|
||||
m.basesMu.Unlock()
|
||||
}
|
||||
|
||||
func (m *MarteClient) stopped() bool {
|
||||
select {
|
||||
case <-m.stopCh:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TCP command channel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (m *MarteClient) runTCP(host string, port int) {
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
for !m.stopped() {
|
||||
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
||||
if err != nil {
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
conn.(*net.TCPConn).SetNoDelay(true)
|
||||
|
||||
m.mu.Lock()
|
||||
m.tcpConn = conn
|
||||
m.writer = bufio.NewWriter(conn)
|
||||
m.mu.Unlock()
|
||||
|
||||
atomic.StoreInt32(&m.connected, 1)
|
||||
broadcast(m.hub, map[string]any{"type": "connected"})
|
||||
|
||||
// Send SERVICE_INFO to auto-discover ports
|
||||
m.writeCmd("SERVICE_INFO")
|
||||
|
||||
m.readLoop(conn)
|
||||
|
||||
atomic.StoreInt32(&m.connected, 0)
|
||||
broadcast(m.hub, map[string]any{"type": "disconnected"})
|
||||
|
||||
m.mu.Lock()
|
||||
m.tcpConn = nil
|
||||
m.writer = nil
|
||||
m.mu.Unlock()
|
||||
|
||||
if !m.stopped() {
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MarteClient) writeCmd(cmd string) {
|
||||
m.cmdMu.Lock()
|
||||
defer m.cmdMu.Unlock()
|
||||
m.mu.Lock()
|
||||
w := m.writer
|
||||
m.mu.Unlock()
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.WriteString(cmd + "\n")
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
func (m *MarteClient) SendCommand(cmd string) {
|
||||
m.writeCmd(cmd)
|
||||
}
|
||||
|
||||
func (m *MarteClient) readLoop(conn net.Conn) {
|
||||
scanner := bufio.NewScanner(conn)
|
||||
scanner.Buffer(make([]byte, 1*1024*1024), 1*1024*1024)
|
||||
|
||||
var jsonAcc strings.Builder
|
||||
inJSON := false
|
||||
|
||||
for scanner.Scan() {
|
||||
if m.stopped() {
|
||||
return
|
||||
}
|
||||
line := scanner.Text()
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Detect start of JSON block
|
||||
if !inJSON && strings.HasPrefix(trimmed, "{") {
|
||||
inJSON = true
|
||||
jsonAcc.Reset()
|
||||
}
|
||||
|
||||
if inJSON {
|
||||
jsonAcc.WriteString(trimmed)
|
||||
tag, done := detectJSONDone(trimmed)
|
||||
if done {
|
||||
inJSON = false
|
||||
raw := jsonAcc.String()
|
||||
// Strip trailing sentinel
|
||||
idx := strings.Index(raw, "OK "+tag)
|
||||
if idx >= 0 {
|
||||
raw = strings.TrimSpace(raw[:idx])
|
||||
}
|
||||
m.handleJSONResponse(tag, raw)
|
||||
jsonAcc.Reset()
|
||||
}
|
||||
} else {
|
||||
m.handleTextLine(trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// detectJSONDone returns (tag, true) when the line contains an "OK <TAG>" sentinel.
|
||||
func detectJSONDone(line string) (string, bool) {
|
||||
tags := []string{
|
||||
"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) {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (m *MarteClient) handleJSONResponse(tag, data string) {
|
||||
switch tag {
|
||||
case "DISCOVER":
|
||||
m.parseDiscover(data)
|
||||
}
|
||||
// Forward raw JSON to all browsers.
|
||||
broadcast(m.hub, map[string]any{
|
||||
"type": "response",
|
||||
"tag": tag,
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
parts := strings.Fields(line)
|
||||
newUDP, newLog := 0, 0
|
||||
for _, p := range parts {
|
||||
if strings.HasPrefix(p, "UDP_STREAM:") {
|
||||
fmt.Sscanf(p[11:], "%d", &newUDP)
|
||||
}
|
||||
if strings.HasPrefix(p, "TCP_LOG:") {
|
||||
fmt.Sscanf(p[8:], "%d", &newLog)
|
||||
}
|
||||
}
|
||||
if newUDP > 0 || newLog > 0 {
|
||||
broadcast(m.hub, map[string]any{
|
||||
"type": "service_config",
|
||||
"udp_port": newUDP,
|
||||
"log_port": newLog,
|
||||
})
|
||||
// Restart UDP/log workers with updated ports if they differ
|
||||
m.mu.Lock()
|
||||
host := m.host
|
||||
oldUDP := m.udpPort
|
||||
oldLog := m.logPort
|
||||
if newUDP > 0 {
|
||||
m.udpPort = newUDP
|
||||
}
|
||||
if newLog > 0 {
|
||||
m.logPort = newLog
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if newUDP > 0 && newUDP != oldUDP {
|
||||
go m.runUDP(host, newUDP)
|
||||
}
|
||||
if newLog > 0 && newLog != oldLog {
|
||||
go m.runLog(host, newLog)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Forward all text lines to browsers.
|
||||
broadcast(m.hub, map[string]any{
|
||||
"type": "text_line",
|
||||
"data": line,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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"`
|
||||
}
|
||||
|
||||
func (m *MarteClient) parseDiscover(data string) {
|
||||
var resp discoverResp
|
||||
if err := json.Unmarshal([]byte(data), &resp); err != nil {
|
||||
return
|
||||
}
|
||||
m.sigMu.Lock()
|
||||
defer m.sigMu.Unlock()
|
||||
m.signals = make(map[uint32]*SignalMeta, len(resp.Signals))
|
||||
for _, s := range resp.Signals {
|
||||
el := s.Elements
|
||||
if el == 0 {
|
||||
el = 1
|
||||
}
|
||||
meta := &SignalMeta{
|
||||
Name: s.Name,
|
||||
ID: s.ID,
|
||||
Type: s.Type,
|
||||
Dimensions: s.Dimensions,
|
||||
Elements: el,
|
||||
Names: []string{s.Name},
|
||||
}
|
||||
m.signals[s.ID] = meta
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UDP telemetry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const udpMagic uint32 = 0xDA7A57AD
|
||||
|
||||
func (m *MarteClient) runUDP(host string, port int) {
|
||||
addr := fmt.Sprintf("0.0.0.0:%d", port)
|
||||
udpAddr, err := net.ResolveUDPAddr("udp4", addr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn, err := net.ListenUDP("udp4", udpAddr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
conn.SetReadBuffer(10 * 1024 * 1024)
|
||||
conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||
|
||||
buf := make([]byte, 65535)
|
||||
var lastSeq uint32
|
||||
var hasLastSeq bool
|
||||
var totalPkts uint64
|
||||
var dropped uint64
|
||||
|
||||
for !m.stopped() {
|
||||
conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||
n, _, err := conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if n < 20 {
|
||||
continue
|
||||
}
|
||||
if binary.LittleEndian.Uint32(buf[0:4]) != udpMagic {
|
||||
continue
|
||||
}
|
||||
|
||||
seq := binary.LittleEndian.Uint32(buf[4:8])
|
||||
if hasLastSeq && seq > lastSeq+1 {
|
||||
dropped += uint64(seq - lastSeq - 1)
|
||||
}
|
||||
lastSeq = seq
|
||||
hasLastSeq = true
|
||||
|
||||
totalPkts++
|
||||
if totalPkts%500 == 0 {
|
||||
broadcast(m.hub, map[string]any{
|
||||
"type": "udp_stats",
|
||||
"packets": totalPkts,
|
||||
"dropped": dropped,
|
||||
})
|
||||
}
|
||||
|
||||
count := binary.LittleEndian.Uint32(buf[16:20])
|
||||
offset := 20
|
||||
|
||||
// Resolve base timestamp once per packet.
|
||||
m.basesMu.Lock()
|
||||
if !m.baseTsSet && n >= 20+12 {
|
||||
m.baseTs = binary.LittleEndian.Uint64(buf[20+4 : 20+12])
|
||||
m.baseTsSet = true
|
||||
}
|
||||
baseTs := m.baseTs
|
||||
baseTsSet := m.baseTsSet
|
||||
m.basesMu.Unlock()
|
||||
|
||||
m.sigMu.RLock()
|
||||
samples := make([]TelemetrySample, 0, count)
|
||||
for i := uint32(0); i < count; i++ {
|
||||
if offset+16 > n {
|
||||
break
|
||||
}
|
||||
id := binary.LittleEndian.Uint32(buf[offset : offset+4])
|
||||
tsRaw := binary.LittleEndian.Uint64(buf[offset+4 : offset+12])
|
||||
size := binary.LittleEndian.Uint32(buf[offset+12 : offset+16])
|
||||
offset += 16
|
||||
|
||||
if offset+int(size) > n {
|
||||
break
|
||||
}
|
||||
dataSlice := buf[offset : offset+int(size)]
|
||||
offset += int(size)
|
||||
|
||||
tsS := 0.0
|
||||
if baseTsSet && tsRaw >= baseTs {
|
||||
tsS = float64(tsRaw-baseTs) / 1_000_000.0
|
||||
}
|
||||
|
||||
meta, ok := m.signals[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
elements := meta.Elements
|
||||
if elements == 0 {
|
||||
elements = 1
|
||||
}
|
||||
typeSize := uint32(size)
|
||||
if elements > 0 {
|
||||
typeSize = uint32(size) / elements
|
||||
}
|
||||
vals := make([]float64, 0, elements)
|
||||
t := meta.Type
|
||||
|
||||
for e := uint32(0); e < elements; e++ {
|
||||
off := e * typeSize
|
||||
if off+typeSize > uint32(len(dataSlice)) {
|
||||
break
|
||||
}
|
||||
elem := dataSlice[off : off+typeSize]
|
||||
vals = append(vals, parseTypedValue(elem, typeSize, t))
|
||||
}
|
||||
|
||||
samples = append(samples, TelemetrySample{
|
||||
ID: id,
|
||||
Names: meta.Names,
|
||||
Ts: tsS,
|
||||
Values: vals,
|
||||
})
|
||||
}
|
||||
m.sigMu.RUnlock()
|
||||
|
||||
if len(samples) > 0 {
|
||||
broadcast(m.hub, map[string]any{
|
||||
"type": "telemetry",
|
||||
"seq": seq,
|
||||
"signals": samples,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseTypedValue(data []byte, size uint32, t string) float64 {
|
||||
switch size {
|
||||
case 1:
|
||||
if strings.Contains(t, "u") {
|
||||
return float64(data[0])
|
||||
}
|
||||
return float64(int8(data[0]))
|
||||
case 2:
|
||||
if len(data) < 2 {
|
||||
return 0
|
||||
}
|
||||
v := binary.LittleEndian.Uint16(data[:2])
|
||||
if strings.Contains(t, "u") {
|
||||
return float64(v)
|
||||
}
|
||||
return float64(int16(v))
|
||||
case 4:
|
||||
if len(data) < 4 {
|
||||
return 0
|
||||
}
|
||||
v := binary.LittleEndian.Uint32(data[:4])
|
||||
if strings.Contains(t, "float") || strings.Contains(t, "float32") {
|
||||
return float64(math.Float32frombits(v))
|
||||
}
|
||||
if strings.Contains(t, "u") {
|
||||
return float64(v)
|
||||
}
|
||||
return float64(int32(v))
|
||||
case 8:
|
||||
if len(data) < 8 {
|
||||
return 0
|
||||
}
|
||||
v := binary.LittleEndian.Uint64(data[:8])
|
||||
if strings.Contains(t, "float") || strings.Contains(t, "float64") || strings.Contains(t, "double") {
|
||||
return math.Float64frombits(v)
|
||||
}
|
||||
if strings.Contains(t, "u") {
|
||||
return float64(v)
|
||||
}
|
||||
return float64(int64(v))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TCP log channel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (m *MarteClient) runLog(host string, port int) {
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
for !m.stopped() {
|
||||
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
||||
if err != nil {
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
scanner := bufio.NewScanner(conn)
|
||||
for scanner.Scan() {
|
||||
if m.stopped() {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "LOG ") {
|
||||
rest := line[4:]
|
||||
idx := strings.Index(rest, " ")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
level := rest[:idx]
|
||||
msg := rest[idx+1:]
|
||||
broadcast(m.hub, map[string]any{
|
||||
"type": "log",
|
||||
"time": time.Now().Format("15:04:05.000"),
|
||||
"level": level,
|
||||
"message": msg,
|
||||
})
|
||||
}
|
||||
}
|
||||
conn.Close()
|
||||
if !m.stopped() {
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="5" fill="#1e1e2e"/>
|
||||
<!-- oscilloscope grid lines -->
|
||||
<line x1="2" y1="16" x2="30" y2="16" stroke="#313244" stroke-width="0.8"/>
|
||||
<line x1="16" y1="3" x2="16" y2="29" stroke="#313244" stroke-width="0.8"/>
|
||||
<!-- waveform -->
|
||||
<polyline points="2,16 7,16 9,7 12,25 16,7 20,25 23,16 25,16 27,11 30,11"
|
||||
fill="none" stroke="#89b4fa" stroke-width="2"
|
||||
stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<!-- scope bezel -->
|
||||
<rect x="1" y="1" width="30" height="30" rx="4"
|
||||
fill="none" stroke="#45475a" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 638 B |
@@ -0,0 +1,398 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MARTe2 Debug Client</title>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="uplot.min.css">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html,body{height:100%;overflow:hidden;font-family:'Segoe UI',system-ui,sans-serif;font-size:12px;background:#1e1e2e;color:#cdd6f4}
|
||||
|
||||
/* ── layout ── */
|
||||
#app{display:flex;flex-direction:column;height:100vh}
|
||||
#toolbar{display:flex;align-items:center;gap:6px;padding:4px 8px;background:#181825;border-bottom:1px solid #313244;flex-shrink:0;flex-wrap:wrap}
|
||||
#main{display:flex;flex:1;overflow:hidden}
|
||||
#left-panel{width:240px;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid #313244;overflow:hidden}
|
||||
#center-panel{flex:1;overflow:hidden;display:flex;flex-direction:column;gap:6px;padding:6px}
|
||||
#right-panel{width:260px;flex-shrink:0;display:flex;flex-direction:column;border-left:1px solid #313244;overflow:hidden}
|
||||
#log-panel{height:160px;flex-shrink:0;border-top:1px solid #313244;display:flex;flex-direction:column;overflow:hidden}
|
||||
|
||||
/* ── toolbar ── */
|
||||
.tb-group{display:flex;align-items:center;gap:4px;padding:0 6px;border-right:1px solid #313244}
|
||||
.tb-group:last-child{border-right:none}
|
||||
input[type=text],input[type=number]{background:#313244;border:1px solid #45475a;color:#cdd6f4;padding:2px 6px;border-radius:4px;width:100px}
|
||||
input[type=number]{width:60px}
|
||||
button{background:#313244;border:1px solid #45475a;color:#cdd6f4;padding:2px 8px;border-radius:4px;cursor:pointer;white-space:nowrap}
|
||||
button:hover{background:#45475a}
|
||||
button.active{background:#89b4fa;color:#1e1e2e;border-color:#89b4fa}
|
||||
button.danger{background:#f38ba8;color:#1e1e2e;border-color:#f38ba8}
|
||||
button.warn{background:#fab387;color:#1e1e2e;border-color:#fab387}
|
||||
button.ok{background:#a6e3a1;color:#1e1e2e;border-color:#a6e3a1}
|
||||
label{color:#a6adc8;user-select:none}
|
||||
#conn-status{width:8px;height:8px;border-radius:50%;background:#f38ba8;display:inline-block;flex-shrink:0;vertical-align:middle}
|
||||
#conn-status.ok{background:#a6e3a1}
|
||||
#udp-stats{color:#585b70;font-size:11px;margin-left:4px}
|
||||
|
||||
/* ── dropdown menus ── */
|
||||
.menu-wrap{position:relative}
|
||||
.dropdown{position:absolute;top:calc(100% + 4px);left:0;z-index:200;background:#1e1e2e;border:1px solid #45475a;border-radius:6px;padding:8px;min-width:260px;display:none;flex-direction:column;gap:6px;box-shadow:0 4px 16px rgba(0,0,0,.5)}
|
||||
.dropdown.open{display:flex}
|
||||
.menu-sep{border:none;border-top:1px solid #313244;margin:2px 0}
|
||||
.menu-row{display:flex;gap:6px;align-items:center}
|
||||
.menu-row input[type=text]{flex:1;min-width:0;width:auto}
|
||||
.menu-row input[type=number]{width:64px}
|
||||
.menu-btn-row{display:flex;gap:6px}
|
||||
.menu-btn-row button{flex:1}
|
||||
|
||||
/* ── panels ── */
|
||||
.panel-header{padding:4px 8px;background:#181825;border-bottom:1px solid #313244;font-weight:600;color:#89b4fa;display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
|
||||
.panel-search{padding:4px;border-bottom:1px solid #313244;flex-shrink:0}
|
||||
.panel-search input{width:100%;background:#313244;border:1px solid #45475a;color:#cdd6f4;padding:3px 6px;border-radius:4px}
|
||||
.panel-body{flex:1;overflow-y:auto}
|
||||
.panel-toggle{background:transparent;border:none;color:#585b70;cursor:pointer;padding:0 2px;font-size:11px;line-height:1}
|
||||
.panel-toggle:hover{color:#cdd6f4;background:transparent}
|
||||
|
||||
/* ── collapsible panels ── */
|
||||
#left-panel{transition:width .15s;position:relative}
|
||||
#left-panel.collapsed{width:0!important;border:none;overflow:hidden}
|
||||
#right-panel{transition:width .15s;position:relative}
|
||||
#right-panel.collapsed{width:0!important;border:none;overflow:hidden}
|
||||
#log-panel{transition:height .15s}
|
||||
#log-panel.collapsed{height:28px;overflow:hidden}
|
||||
/* collapse toggle 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:hover{background:#313244;color:#cdd6f4}
|
||||
#left-strip{border-left:none;border-top:none;border-bottom:none}
|
||||
#right-strip{border-right:none;border-top:none;border-bottom:none}
|
||||
|
||||
/* ── tree ── */
|
||||
.tree-node{padding:1px 0}
|
||||
.tree-leaf{display:flex;align-items:center;gap:2px;padding:1px 4px;cursor:default;user-select:none}
|
||||
.tree-leaf:hover{background:#313244}
|
||||
.tree-leaf.selected{background:#45475a}
|
||||
.tree-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.tree-class{color:#585b70;font-size:10px;flex-shrink:0}
|
||||
details>summary{list-style:none;cursor:pointer;padding:1px 4px;display:flex;align-items:center;gap:4px;user-select:none}
|
||||
details>summary:hover{background:#313244}
|
||||
details>summary::before{content:'▶';font-size:9px;color:#585b70;width:10px;flex-shrink:0}
|
||||
details[open]>summary::before{content:'▼'}
|
||||
details>.children{padding-left:14px}
|
||||
.tree-btn{background:transparent;border:1px solid #45475a;color:#a6adc8;padding:0 4px;border-radius:3px;cursor:pointer;font-size:10px;line-height:14px}
|
||||
.tree-btn:hover{background:#45475a;color:#cdd6f4}
|
||||
.tree-btn.t{border-color:#89b4fa;color:#89b4fa}
|
||||
.tree-btn.f{border-color:#a6e3a1;color:#a6e3a1}
|
||||
.tree-btn.b{border-color:#fab387;color:#fab387}
|
||||
|
||||
/* ── right panel tabs ── */
|
||||
.tabs{display:flex;border-bottom:1px solid #313244;flex-shrink:0}
|
||||
.tab{flex:1;padding:4px;text-align:center;cursor:pointer;color:#585b70;border-bottom:2px solid transparent}
|
||||
.tab.active{color:#89b4fa;border-bottom-color:#89b4fa}
|
||||
.tab-content{display:none;flex:1;overflow-y:auto;flex-direction:column}
|
||||
.tab-content.active{display:flex}
|
||||
|
||||
/* ── signal items (right panel) ── */
|
||||
.sig-item{display:flex;align-items:center;gap:4px;padding:3px 6px;border-bottom:1px solid #181825}
|
||||
.sig-item:hover{background:#313244}
|
||||
.sig-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px}
|
||||
.sig-val{color:#a6e3a1;font-size:11px;min-width:60px;text-align:right;font-family:monospace}
|
||||
.sig-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
||||
|
||||
/* ── plots ── */
|
||||
.plot-card{background:#181825;border:1px solid #313244;border-radius:6px;overflow:hidden;flex:1;min-height:160px;display:flex;flex-direction:column}
|
||||
.plot-card.drop-active{border-color:#89b4fa}
|
||||
.plot-header{display:flex;align-items:center;gap:6px;padding:4px 8px;background:#11111b;border-bottom:1px solid #313244;flex-shrink:0}
|
||||
.plot-title{font-weight:600;color:#89b4fa;flex:1}
|
||||
.plot-series-bar{display:flex;flex-wrap:wrap;gap:4px;padding:3px 8px;background:#11111b;border-bottom:1px solid #313244;flex-shrink:0}
|
||||
.plot-series-bar:empty{display:none;padding:0;border:none}
|
||||
.series-chip{display:inline-flex;align-items:center;gap:3px;background:#1e1e2e;border:1px solid #45475a;border-radius:10px;padding:1px 3px 1px 6px;font-size:10px}
|
||||
.series-chip-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
|
||||
.series-chip-name{max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#cdd6f4}
|
||||
.series-chip-rm{background:transparent;border:none;color:#585b70;cursor:pointer;padding:0 2px;font-size:12px;line-height:1}
|
||||
.series-chip-rm:hover{color:#f38ba8;background:transparent}
|
||||
.plot-drop{flex:1;display:flex;align-items:center;justify-content:center;color:#45475a;border:2px dashed #313244;margin:8px;border-radius:4px}
|
||||
.plot-drop.over{border-color:#89b4fa;color:#89b4fa}
|
||||
.uplot-wrap{flex:1;min-height:0;padding:2px 4px 4px;display:flex;flex-direction:column}
|
||||
|
||||
/* ── logs ── */
|
||||
#log-toolbar{display:flex;align-items:center;gap:6px;padding:3px 8px;background:#181825;border-bottom:1px solid #313244;flex-shrink:0}
|
||||
#log-body{flex:1;overflow-y:auto;font-family:monospace;font-size:11px;padding:2px 0}
|
||||
.log-line{padding:1px 8px;display:flex;gap:8px}
|
||||
.log-time{color:#585b70;flex-shrink:0}
|
||||
.log-lvl{flex-shrink:0;min-width:50px;font-weight:600}
|
||||
.log-msg{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
|
||||
.log-line.DEBUG .log-lvl{color:#89b4fa}
|
||||
.log-line.INFO .log-lvl{color:#a6e3a1}
|
||||
.log-line.WARNING .log-lvl{color:#fab387}
|
||||
.log-line.ERROR,.log-line.FatalError,.log-line.FATAL{background:#2d1b1b}
|
||||
.log-line.ERROR .log-lvl,.log-line.FatalError .log-lvl,.log-line.FATAL .log-lvl{color:#f38ba8}
|
||||
.log-hidden{display:none}
|
||||
|
||||
/* ── dialogs ── */
|
||||
.dialog-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:100}
|
||||
.dialog{background:#1e1e2e;border:1px solid #45475a;border-radius:8px;padding:16px;min-width:320px;max-width:500px}
|
||||
.dialog h3{margin-bottom:12px;color:#89b4fa}
|
||||
.dialog label{display:block;margin-bottom:4px;color:#a6adc8}
|
||||
.dialog input,.dialog select,.dialog textarea{width:100%;background:#313244;border:1px solid #45475a;color:#cdd6f4;padding:4px 8px;border-radius:4px;margin-bottom:10px}
|
||||
.dialog textarea{height:80px;resize:vertical;font-family:monospace}
|
||||
.dialog .btns{display:flex;gap:8px;justify-content:flex-end;margin-top:4px}
|
||||
.dialog select option{background:#1e1e2e}
|
||||
.form-row{display:flex;gap:8px}
|
||||
.form-row>*{flex:1}
|
||||
.form-check{display:flex;align-items:center;gap:8px;margin-bottom:10px}
|
||||
.form-check input[type=checkbox]{width:auto;margin:0}
|
||||
.form-check label{margin:0;color:#a6adc8}
|
||||
|
||||
/* ── step status bar ── */
|
||||
#step-bar{background:#2d2b45;border-bottom:1px solid #313244;padding:4px 8px;display:none;align-items:center;gap:8px;flex-shrink:0}
|
||||
#step-bar.visible{display:flex}
|
||||
|
||||
/* ── scrollbars ── */
|
||||
::-webkit-scrollbar{width:6px;height:6px}
|
||||
::-webkit-scrollbar-track{background:#181825}
|
||||
::-webkit-scrollbar-thumb{background:#45475a;border-radius:3px}
|
||||
::-webkit-scrollbar-thumb:hover{background:#585b70}
|
||||
|
||||
/* ── misc ── */
|
||||
.empty-hint{padding:16px;color:#45475a;text-align:center}
|
||||
.break-item{padding:3px 6px;border-bottom:1px solid #181825;display:flex;align-items:center;gap:4px;font-size:11px}
|
||||
.break-sig{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#fab387}
|
||||
.msg-item{padding:3px 6px;border-bottom:1px solid #181825;font-size:11px}
|
||||
.msg-status{width:14px;text-align:center;flex-shrink:0}
|
||||
select{background:#313244;border:1px solid #45475a;color:#cdd6f4;padding:2px 4px;border-radius:4px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
|
||||
<!-- ═══ TOOLBAR ═══ -->
|
||||
<div id="toolbar">
|
||||
|
||||
<!-- Connection menu -->
|
||||
<div class="menu-wrap tb-group">
|
||||
<button id="conn-menu-btn" onclick="toggleMenu('conn-dropdown')">
|
||||
<span id="conn-status"></span> Connection ▾
|
||||
</button>
|
||||
<div class="dropdown" id="conn-dropdown">
|
||||
<div class="menu-row">
|
||||
<input type="text" id="host" value="127.0.0.1" placeholder="host">
|
||||
<input type="number" id="port" value="8080" placeholder="port">
|
||||
<button id="btn-connect" onclick="toggleConnect()">Connect</button>
|
||||
</div>
|
||||
<hr class="menu-sep">
|
||||
<div class="menu-btn-row">
|
||||
<button onclick="sendCmd('DISCOVER');closeAllMenus()">Discover</button>
|
||||
<button onclick="sendCmd('TREE');closeAllMenus()">Tree</button>
|
||||
<button onclick="sendCmd('SERVICE_INFO');closeAllMenus()">Info</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tb-group">
|
||||
<button id="btn-pause" onclick="togglePause()">⏸ Pause</button>
|
||||
<button onclick="openStepDialog()">⚙ Step</button>
|
||||
</div>
|
||||
<div class="tb-group">
|
||||
<button onclick="addPlot()">+ Plot</button>
|
||||
</div>
|
||||
<div class="tb-group">
|
||||
<button onclick="openForceDialog()">⚡ Force</button>
|
||||
<button onclick="openBreakDialog()">🔴 Break</button>
|
||||
<button onclick="openMsgDialog()">✉ Msg</button>
|
||||
</div>
|
||||
<div class="tb-group" style="border:none;margin-left:auto">
|
||||
<span id="udp-stats" style="color:#585b70;font-size:11px">0 pkts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ STEP STATUS BAR ═══ -->
|
||||
<div id="step-bar">
|
||||
<span>⏹ PAUSED at <b id="paused-gam">—</b></span>
|
||||
<span id="step-remaining"></span>
|
||||
<select id="step-thread" style="width:120px"></select>
|
||||
<button onclick="step(1)">Step 1</button>
|
||||
<button onclick="step(5)">Step 5</button>
|
||||
<button onclick="step(20)">Step 20</button>
|
||||
<button class="ok" onclick="togglePause()">▶ Resume</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ MAIN ═══ -->
|
||||
<div id="main">
|
||||
|
||||
<!-- LEFT: object tree -->
|
||||
<div id="left-panel">
|
||||
<div class="panel-header">
|
||||
<span>Object Tree</span>
|
||||
<button style="font-size:10px;padding:1px 6px" onclick="sendCmd('TREE')">↻</button>
|
||||
</div>
|
||||
<div class="panel-search"><input id="tree-search" oninput="filterTree(this.value)" placeholder="Search…"></div>
|
||||
<div class="panel-body" id="tree-body">
|
||||
<div class="empty-hint">Connect to MARTe2 then click Tree</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Left collapse strip -->
|
||||
<div id="left-strip" class="panel-strip" title="Toggle left panel" onclick="togglePanelStrip('left-panel','left-strip','◀','▶')">◀</div>
|
||||
|
||||
<!-- CENTER: plots -->
|
||||
<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>
|
||||
|
||||
<!-- Right collapse strip -->
|
||||
<div id="right-strip" class="panel-strip" title="Toggle right panel" onclick="togglePanelStrip('right-panel','right-strip','▶','◀')">▶</div>
|
||||
|
||||
<!-- RIGHT: tabs -->
|
||||
<div id="right-panel">
|
||||
<div class="tabs">
|
||||
<div class="tab active" onclick="switchTab('traced')">Traced</div>
|
||||
<div class="tab" onclick="switchTab('forced')">Forced</div>
|
||||
<div class="tab" onclick="switchTab('breaks')">Breaks</div>
|
||||
<div class="tab" onclick="switchTab('msgs')">Msgs</div>
|
||||
</div>
|
||||
<div class="tab-content active" id="tab-traced">
|
||||
<div class="empty-hint" id="no-traced-hint">No signals traced</div>
|
||||
</div>
|
||||
<div class="tab-content" id="tab-forced">
|
||||
<div class="empty-hint" id="no-forced-hint">No signals forced</div>
|
||||
</div>
|
||||
<div class="tab-content" id="tab-breaks">
|
||||
<div class="empty-hint" id="no-breaks-hint">No breakpoints set</div>
|
||||
</div>
|
||||
<div class="tab-content" id="tab-msgs">
|
||||
<div class="empty-hint" id="no-msgs-hint">No messages sent</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ═══ LOGS ═══ -->
|
||||
<div id="log-panel">
|
||||
<div id="log-toolbar">
|
||||
<button class="panel-toggle" title="Collapse logs" onclick="togglePanel('log-panel','▼','▲',this)" id="log-toggle-btn">▼</button>
|
||||
<span style="font-weight:600;color:#89b4fa">Logs</span>
|
||||
<label><input type="checkbox" id="lf-debug" checked onchange="renderLogs()"> Debug</label>
|
||||
<label><input type="checkbox" id="lf-info" checked onchange="renderLogs()"> Info</label>
|
||||
<label><input type="checkbox" id="lf-warn" checked onchange="renderLogs()"> Warn</label>
|
||||
<label><input type="checkbox" id="lf-error" checked onchange="renderLogs()"> Error</label>
|
||||
<input type="text" id="log-filter" placeholder="Filter…" oninput="renderLogs()" style="width:150px">
|
||||
<button onclick="logs=[];renderLogs()" style="margin-left:auto">Clear</button>
|
||||
<label><input type="checkbox" id="log-autoscroll" checked> Auto-scroll</label>
|
||||
</div>
|
||||
<div id="log-body"></div>
|
||||
</div>
|
||||
|
||||
</div><!-- #app -->
|
||||
|
||||
<!-- ═══ DIALOGS ═══ -->
|
||||
|
||||
<!-- Force dialog -->
|
||||
<div class="dialog-overlay" id="dlg-force" style="display:none" onclick="if(event.target===this)closeDlg('dlg-force')">
|
||||
<div class="dialog">
|
||||
<h3>⚡ Force Signal</h3>
|
||||
<label>Signal</label>
|
||||
<input id="force-sig" list="force-sig-list" placeholder="Signal path…">
|
||||
<datalist id="force-sig-list"></datalist>
|
||||
<label>Value</label>
|
||||
<input id="force-val" placeholder="e.g. 42">
|
||||
<div class="btns">
|
||||
<button onclick="closeDlg('dlg-force')">Cancel</button>
|
||||
<button class="active" onclick="doForce()">Force</button>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<!-- Unforce confirm -->
|
||||
<div class="dialog-overlay" id="dlg-unforce" style="display:none" onclick="if(event.target===this)closeDlg('dlg-unforce')">
|
||||
<div class="dialog">
|
||||
<h3>Remove Force</h3>
|
||||
<p id="unforce-msg" style="margin-bottom:12px;color:#cdd6f4"></p>
|
||||
<div class="btns">
|
||||
<button onclick="closeDlg('dlg-unforce')">Cancel</button>
|
||||
<button class="danger" onclick="doUnforce()">Unforce</button>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<!-- Break dialog -->
|
||||
<div class="dialog-overlay" id="dlg-break" style="display:none" onclick="if(event.target===this)closeDlg('dlg-break')">
|
||||
<div class="dialog">
|
||||
<h3>🔴 Set Breakpoint</h3>
|
||||
<label>Signal</label>
|
||||
<input id="break-sig" list="break-sig-list" placeholder="Signal path…">
|
||||
<datalist id="break-sig-list"></datalist>
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label>Operator</label>
|
||||
<select id="break-op">
|
||||
<option>></option><option>>=</option>
|
||||
<option><</option><option><=</option>
|
||||
<option>==</option><option>!=</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Threshold</label>
|
||||
<input id="break-thresh" placeholder="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="btns">
|
||||
<button onclick="closeDlg('dlg-break')">Cancel</button>
|
||||
<button class="active" onclick="doBreak()">Set Break</button>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<!-- Message dialog -->
|
||||
<div class="dialog-overlay" id="dlg-msg" style="display:none" onclick="if(event.target===this)closeDlg('dlg-msg')">
|
||||
<div class="dialog">
|
||||
<h3>✉ Send Message</h3>
|
||||
<label>Destination</label>
|
||||
<input id="msg-dest" list="msg-dest-list" placeholder="e.g. App.Functions.GAM1">
|
||||
<datalist id="msg-dest-list"></datalist>
|
||||
<label>Function</label>
|
||||
<input id="msg-func" placeholder="FunctionName">
|
||||
<label>Payload (key=value lines)</label>
|
||||
<textarea id="msg-payload" placeholder="Key = Value Key2 = Value2"></textarea>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" id="msg-wait">
|
||||
<label for="msg-wait">Wait for reply</label>
|
||||
</div>
|
||||
<div class="btns">
|
||||
<button onclick="closeDlg('dlg-msg')">Cancel</button>
|
||||
<button class="active" onclick="doSendMsg()">Send</button>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<!-- Info dialog -->
|
||||
<div class="dialog-overlay" id="dlg-info" style="display:none" onclick="if(event.target===this)closeDlg('dlg-info')">
|
||||
<div class="dialog" style="max-width:600px;width:90vw">
|
||||
<h3 id="info-title">Info</h3>
|
||||
<pre id="info-body" style="background:#11111b;padding:8px;border-radius:4px;overflow:auto;max-height:400px;font-size:11px;color:#cdd6f4;white-space:pre-wrap"></pre>
|
||||
<div class="btns" style="margin-top:12px">
|
||||
<button onclick="closeDlg('dlg-info')">Close</button>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<!-- Step dialog -->
|
||||
<div class="dialog-overlay" id="dlg-step" style="display:none" onclick="if(event.target===this)closeDlg('dlg-step')">
|
||||
<div class="dialog">
|
||||
<h3>⚙ Step Execution</h3>
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label>Count</label>
|
||||
<input type="number" id="step-count" value="1" min="1">
|
||||
</div>
|
||||
<div>
|
||||
<label>Thread (optional)</label>
|
||||
<input id="step-thread-inp" list="step-thread-list" placeholder="all">
|
||||
<datalist id="step-thread-list"></datalist>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btns">
|
||||
<button onclick="closeDlg('dlg-step')">Cancel</button>
|
||||
<button class="active" onclick="doStep()">Step</button>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<script src="uplot.min.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1
@@ -0,0 +1 @@
|
||||
.uplot, .uplot *, .uplot *::before, .uplot *::after {box-sizing: border-box;}.uplot {font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";line-height: 1.5;width: min-content;}.u-title {text-align: center;font-size: 18px;font-weight: bold;}.u-wrap {position: relative;user-select: none;}.u-over, .u-under {position: absolute;}.u-under {overflow: hidden;}.uplot canvas {display: block;position: relative;width: 100%;height: 100%;}.u-axis {position: absolute;}.u-legend {font-size: 14px;margin: auto;text-align: center;}.u-inline {display: block;}.u-inline * {display: inline-block;}.u-inline tr {margin-right: 16px;}.u-legend th {font-weight: 600;}.u-legend th > * {vertical-align: middle;display: inline-block;}.u-legend .u-marker {width: 1em;height: 1em;margin-right: 4px;background-clip: padding-box !important;}.u-inline.u-live th::after {content: ":";vertical-align: middle;}.u-inline:not(.u-live) .u-value {display: none;}.u-series > * {padding: 4px;}.u-series th {cursor: pointer;}.u-legend .u-off > * {opacity: 0.3;}.u-select {background: rgba(0,0,0,0.07);position: absolute;pointer-events: none;}.u-cursor-x, .u-cursor-y {position: absolute;left: 0;top: 0;pointer-events: none;will-change: transform;}.u-hz .u-cursor-x, .u-vt .u-cursor-y {height: 100%;border-right: 1px dashed #607D8B;}.u-hz .u-cursor-y, .u-vt .u-cursor-x {width: 100%;border-bottom: 1px dashed #607D8B;}.u-cursor-pt {position: absolute;top: 0;left: 0;border-radius: 50%;border: 0 solid;pointer-events: none;will-change: transform;/*this has to be !important since we set inline "background" shorthand */background-clip: padding-box !important;}.u-axis.u-off, .u-select.u-off, .u-cursor-x.u-off, .u-cursor-y.u-off, .u-cursor-pt.u-off {display: none;}
|
||||
+2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user