Visualizer #3
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)
|
||||
thomas marked this conversation as resolved
Outdated
|
||||
|
||||
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
Eine Idee dazu noch: wenn man wirklich ein Replay von einem früheren JSON-Dump startet, indem man diesen statt dem FIFO als Args[1] angibt, wäre es cool, wenn man Args[2] weglassen könnte oder zumindest /dev/null angeben könnte. Letzteres geht leider nicht wegen dem Anhängen der Uhrzeit (was an sich aber schon sinnvoll ist).
Ist implementiert. Habs auch gleich rebased.
Schaut gut aus. Dann merge ich das mal :)