Add HTML/JS visualizer with plots and graphs
This commit is contained in:
parent
5fb5a2908d
commit
6fc4df205c
1
impl/utils/visualizer/.gitignore
vendored
Normal file
1
impl/utils/visualizer/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/visualizer
|
7
impl/utils/visualizer/Makefile
Normal file
7
impl/utils/visualizer/Makefile
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
all:
|
||||||
|
go build
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f visualizer
|
||||||
|
|
||||||
|
.PHONY: all clean
|
5
impl/utils/visualizer/go.mod
Normal file
5
impl/utils/visualizer/go.mod
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
module visualizer
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require golang.org/x/net v0.25.0
|
2
impl/utils/visualizer/go.sum
Normal file
2
impl/utils/visualizer/go.sum
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||||
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
125
impl/utils/visualizer/main.go
Normal file
125
impl/utils/visualizer/main.go
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
// Visualize debug information from hamnet70.
|
||||||
|
//
|
||||||
|
// This program is a HTTP server which reads JSON messages (one per line) from
|
||||||
|
// a FIFO filled by hamnet70 and passes them to the browser via WebSocket.
|
||||||
|
// Concurrent browser sessions and reconnects are supported. This binary is
|
||||||
|
// standalone and can be used without the static/ directory.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed static/*
|
||||||
|
var static embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) != 3 {
|
||||||
|
log.SetFlags(0)
|
||||||
|
log.Fatalf("usage: %s <fifo> <json-dup-prefix>", os.Args[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
var mutex sync.Mutex
|
||||||
|
listeners := make(map[*chan string]struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
path := os.Args[1]
|
||||||
|
dupPath := os.Args[2] + "." + time.Now().Format(time.RFC3339)
|
||||||
|
|
||||||
|
fh, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
dupFh, err := os.Create(dupPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := bufio.NewReader(fh)
|
||||||
|
for {
|
||||||
|
line, err := r.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("read %q: %v", path, err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate JSON input to a file so it can be replayed later
|
||||||
|
// if necessary
|
||||||
|
_, err = dupFh.WriteString(line)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("write %q: %v", dupPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to all listeners
|
||||||
|
mutex.Lock()
|
||||||
|
for x := range listeners {
|
||||||
|
*x <- line
|
||||||
|
}
|
||||||
|
mutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
err = fh.Close()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("close %q: %v", path, err)
|
||||||
|
}
|
||||||
|
err = dupFh.Close()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("close %q: %v", dupPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Use existing "./static/" directory for quick changes
|
||||||
|
var staticFS http.FileSystem
|
||||||
|
_, err := os.Stat("static")
|
||||||
|
if err == nil {
|
||||||
|
staticFS = http.Dir("static")
|
||||||
|
} else {
|
||||||
|
x, err := fs.Sub(static, "static")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
staticFS = http.FS(x)
|
||||||
|
}
|
||||||
|
http.Handle("/", http.FileServer(staticFS))
|
||||||
|
|
||||||
|
http.Handle("/events", websocket.Handler(func(conn *websocket.Conn) {
|
||||||
|
// Register new listener for this connection
|
||||||
|
ch := make(chan string, 512) // buffer up to x number of messages
|
||||||
|
mutex.Lock()
|
||||||
|
listeners[&ch] = struct{}{}
|
||||||
|
mutex.Unlock()
|
||||||
|
defer func() {
|
||||||
|
mutex.Lock()
|
||||||
|
delete(listeners, &ch)
|
||||||
|
mutex.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
x := <-ch
|
||||||
|
err := websocket.Message.Send(conn, x)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Websocket error: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
err = http.ListenAndServe("localhost:8080", nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// vi: set noet ts=4 sw=4 sts=4:
|
20
impl/utils/visualizer/static/chart-v4.4.2.js
Normal file
20
impl/utils/visualizer/static/chart-v4.4.2.js
Normal file
File diff suppressed because one or more lines are too long
25
impl/utils/visualizer/static/index.css
Normal file
25
impl/utils/visualizer/static/index.css
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
#error {
|
||||||
|
font-weight: bold;
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
#trace {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
#constellation {
|
||||||
|
height: 85vh;
|
||||||
|
width: 30vw;
|
||||||
|
}
|
||||||
|
#data {
|
||||||
|
padding-top: 1em;
|
||||||
|
padding-right: 1em;
|
||||||
|
text-wrap: nowrap;
|
||||||
|
}
|
||||||
|
#history {
|
||||||
|
max-height: 80vh;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
42
impl/utils/visualizer/static/index.html
Normal file
42
impl/utils/visualizer/static/index.html
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
<title>hamnet70 Visualizer</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="index.css"/>
|
||||||
|
|
||||||
|
<script src="chart-v4.4.2.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="error" hidden></div>
|
||||||
|
<div>
|
||||||
|
<input id="pause" type="button" value="">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Trace: <span id="trace"></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Preambles found: <span id="preambles_found"></span>,
|
||||||
|
Successful decodes: <span id="successful_decodes"></span>,
|
||||||
|
Header errors: <span id="header_errors"></span>,
|
||||||
|
Failed decodes: <span id="failed_decodes"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="main">
|
||||||
|
<div id="constellation">
|
||||||
|
<canvas id="constellation-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
<div id="data"></div>
|
||||||
|
<div id="history">
|
||||||
|
<canvas id="history-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="index.js"></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
271
impl/utils/visualizer/static/index.js
Normal file
271
impl/utils/visualizer/static/index.js
Normal file
|
@ -0,0 +1,271 @@
|
||||||
|
const tracePlaceholder = Symbol('trace-placeholder');
|
||||||
|
const traceEvent = Symbol('trace-event');
|
||||||
|
const tracePacket = Symbol('trace-packet');
|
||||||
|
|
||||||
|
const traceMaxEvents = 200;
|
||||||
|
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
start: new Date(),
|
||||||
|
paused: false,
|
||||||
|
global: {
|
||||||
|
preambles_found: 0,
|
||||||
|
successful_decodes: 0,
|
||||||
|
failed_decodes: 0,
|
||||||
|
header_errors: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function updatePause(pause) {
|
||||||
|
const button = document.querySelector('#pause');
|
||||||
|
if (pause) {
|
||||||
|
button.value = 'Continue';
|
||||||
|
} else {
|
||||||
|
button.value = 'Pause';
|
||||||
|
}
|
||||||
|
state.paused = pause;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGlobal() {
|
||||||
|
for (const key in state.global) {
|
||||||
|
document.querySelector(`#${key}`).textContent = state.global[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTime() {
|
||||||
|
const x = new Date();
|
||||||
|
// HH:MM:SS.ms, thanks JavaScript
|
||||||
|
const hh = ('0' + x.getHours()).slice(-2);
|
||||||
|
const mm = ('0' + x.getMinutes()).slice(-2);
|
||||||
|
const ss = ('0' + x.getSeconds()).slice(-2);
|
||||||
|
const ms = ('00' + x.getMilliseconds()).slice(-3);
|
||||||
|
return `${hh}:${mm}:${ss}.${ms}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTrace(type, data) {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = data.string;
|
||||||
|
span.style.color = data.color;
|
||||||
|
span.style.cursor = 'default';
|
||||||
|
if (type !== tracePlaceholder) {
|
||||||
|
span.title = getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === tracePacket) {
|
||||||
|
span.onclick = () => {
|
||||||
|
updatePause(true);
|
||||||
|
updateChart(data.packet);
|
||||||
|
updateData(data.packet);
|
||||||
|
};
|
||||||
|
span.style.cursor = 'pointer';
|
||||||
|
}
|
||||||
|
|
||||||
|
const trace = document.querySelector('#trace');
|
||||||
|
trace.prepend(span);
|
||||||
|
if (trace.childNodes.length > traceMaxEvents) {
|
||||||
|
trace.removeChild(trace.lastChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const chartConstellation = new Chart(
|
||||||
|
document.querySelector('#constellation-canvas'), {
|
||||||
|
type: 'scatter',
|
||||||
|
data: {
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Preamble',
|
||||||
|
data: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Header',
|
||||||
|
data: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Data',
|
||||||
|
data: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
animation: false,
|
||||||
|
aspectRatio: 1,
|
||||||
|
elements: {
|
||||||
|
point: {
|
||||||
|
radius: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
min: -1.5,
|
||||||
|
max: 1.5,
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
min: -1.5,
|
||||||
|
max: 1.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartHistory = new Chart(
|
||||||
|
document.querySelector('#history-canvas'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
animation: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateChart(packet) {
|
||||||
|
chartConstellation.data.datasets[0].data = packet['preamble_symbols'];
|
||||||
|
chartConstellation.data.datasets[1].data = packet['header_symbols'];
|
||||||
|
chartConstellation.data.datasets[2].data = packet['data_symbols'];
|
||||||
|
chartConstellation.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateData(packet) {
|
||||||
|
const keys = [];
|
||||||
|
for (const x in packet) {
|
||||||
|
if (x === 'preamble_symbols'
|
||||||
|
|| x === 'header_symbols'
|
||||||
|
|| x === 'data_symbols') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys.push(x);
|
||||||
|
}
|
||||||
|
keys.sort();
|
||||||
|
|
||||||
|
const divs = [];
|
||||||
|
for (const x of keys) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = `${x}: ${packet[x]}`;
|
||||||
|
divs.push(div);
|
||||||
|
}
|
||||||
|
document.querySelector('#data').replaceChildren(...divs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addHistory(packet) {
|
||||||
|
const keys = [];
|
||||||
|
for (const x in packet) {
|
||||||
|
if (x === 'preamble_symbols'
|
||||||
|
|| x === 'header_symbols'
|
||||||
|
|| x === 'data_symbols') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (x === 'header_evm' || x === 'data_evm') {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
keys.push(x);
|
||||||
|
}
|
||||||
|
keys.sort();
|
||||||
|
|
||||||
|
if (chartHistory.data.datasets.length === 0) {
|
||||||
|
for (const x of keys) {
|
||||||
|
chartHistory.data.datasets.push({
|
||||||
|
label: x,
|
||||||
|
data: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
for (const x of keys) {
|
||||||
|
chartHistory.data.datasets[i].data.push(packet[x]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
chartHistory.data.labels.push((new Date() - state.start)/1000);
|
||||||
|
chartHistory.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const ws = new WebSocket(location.origin.replace(/^http/, 'ws') + '/events');
|
||||||
|
ws.onmessage = (evt) => {
|
||||||
|
if (state.paused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = JSON.parse(evt.data);
|
||||||
|
|
||||||
|
if ('preambles_found' in msg) {
|
||||||
|
const old = {};
|
||||||
|
for (const x in state.global) {
|
||||||
|
old[x] = state.global[x];
|
||||||
|
}
|
||||||
|
for (const x in msg) {
|
||||||
|
state.global[x] = msg[x];
|
||||||
|
old[x] = msg[x] - old[x]; // delta
|
||||||
|
}
|
||||||
|
updateGlobal();
|
||||||
|
|
||||||
|
// Update trace with new events
|
||||||
|
for (const x in old) {
|
||||||
|
if (old[x] === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let string, color;
|
||||||
|
if (x === 'preambles_found') {
|
||||||
|
string = 'p';
|
||||||
|
color = 'gray';
|
||||||
|
} else if (x === 'successful_decodes') {
|
||||||
|
// Nothing to do as packet will be added to trace
|
||||||
|
continue;
|
||||||
|
} else if (x === 'header_errors') {
|
||||||
|
string = 'H';
|
||||||
|
color = 'red';
|
||||||
|
} else if (x === 'failed_decodes') {
|
||||||
|
string = 'D';
|
||||||
|
color = 'red';
|
||||||
|
} else {
|
||||||
|
console.log(`Unknown event "${x}"!`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
addTrace(traceEvent, {
|
||||||
|
string: string,
|
||||||
|
color: color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if ('data_symbols' in msg) {
|
||||||
|
updateChart(msg);
|
||||||
|
updateData(msg);
|
||||||
|
addHistory(msg);
|
||||||
|
|
||||||
|
addTrace(tracePacket, {
|
||||||
|
string: 'P',
|
||||||
|
color: 'black',
|
||||||
|
packet: msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onclose = (evt) => {
|
||||||
|
const div = document.querySelector('#error');
|
||||||
|
div.textContent = 'Websocket connection closed!';
|
||||||
|
div.hidden = false;
|
||||||
|
// Log details
|
||||||
|
console.log(evt);
|
||||||
|
};
|
||||||
|
ws.onerror = ws.onclose;
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
const pause = document.querySelector('#pause');
|
||||||
|
pause.onclick = () => {
|
||||||
|
updatePause(!state.paused);
|
||||||
|
}
|
||||||
|
updatePause(false);
|
||||||
|
|
||||||
|
// Initialize trace
|
||||||
|
for (let i = 0; i < traceMaxEvents; i++) {
|
||||||
|
addTrace(tracePlaceholder, {
|
||||||
|
string: '.',
|
||||||
|
color: 'lightgray',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGlobal();
|
||||||
|
}
|
Loading…
Reference in a new issue