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