KPN/include/kpn/web_debug.hpp

347 lines
13 KiB
C++

#pragma once
// Only compiled when KPN_WEB_DEBUG is defined. network.hpp includes this conditionally.
#include "diagnostics.hpp"
#include <httplib.h>
#include <atomic>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
namespace kpn::web_debug {
// ── Minimal JSON serialiser ───────────────────────────────────────────────────
static std::string escape_json(const std::string& s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
if (c == '"') out += "\\\"";
else if (c == '\\') out += "\\\\";
else if (c == '\n') out += "\\n";
else if (c == '\r') out += "\\r";
else if (c == '\t') out += "\\t";
else out += c;
}
return out;
}
// Parse "src_name:N → dst_name:M" into {src_name, dst_name}.
// Stored separately so the browser doesn't need to regex-parse a UTF-8 arrow.
static std::pair<std::string,std::string> parse_edge_name(const std::string& name) {
// Format: "<src>:<idx> → <dst>:<idx>"
auto arrow = name.find(" \xe2\x86\x92 "); // UTF-8 for →
if (arrow == std::string::npos) return {name, name};
std::string src_part = name.substr(0, arrow);
std::string dst_part = name.substr(arrow + 5); // " → " = 5 bytes (space + 3-byte arrow + space)
// Strip ":N" index suffix
auto sc1 = src_part.rfind(':');
auto sc2 = dst_part.rfind(':');
if (sc1 != std::string::npos) src_part = src_part.substr(0, sc1);
if (sc2 != std::string::npos) dst_part = dst_part.substr(0, sc2);
return {src_part, dst_part};
}
static std::string to_json(const std::vector<NodeSnapshot>& nodes,
const std::vector<ChannelSnapshot>& channels,
double elapsed_s = 0.0) {
std::ostringstream o;
o << std::fixed;
o.precision(2);
o << "{\"nodes\":[";
for (std::size_t i = 0; i < nodes.size(); ++i) {
const auto& n = nodes[i];
if (i) o << ',';
o << "{\"id\":\"" << escape_json(n.name) << "\""
<< ",\"frames\":" << n.frames_processed
<< ",\"ema_exec_ms\":" << n.ema_exec_ms
<< ",\"max_exec_ms\":" << n.max_exec_ms
<< ",\"blocked_ms\":" << n.total_blocked_ms
<< ",\"fps\":" << n.throughput_fps
<< ",\"total_cpu_ms\":" << n.total_cpu_ms
<< ",\"cpu_util_pct\":" << n.cpu_util_pct
<< "}";
}
o << "],\"edges\":[";
for (std::size_t i = 0; i < channels.size(); ++i) {
const auto& c = channels[i];
if (i) o << ',';
auto [src, dst] = parse_edge_name(c.name);
o << "{\"name\":\"" << escape_json(c.name) << "\""
<< ",\"source\":\"" << escape_json(src) << "\""
<< ",\"target\":\"" << escape_json(dst) << "\""
<< ",\"capacity\":" << c.capacity
<< ",\"current\":" << c.current_fill
<< ",\"fill_pct\":" << c.fill_pct()
<< ",\"peak_pct\":" << c.peak_pct()
<< ",\"pushes\":" << c.pushes
<< ",\"drops\":" << c.drops
<< ",\"overflows\":" << c.overflows
<< ",\"item_bytes\":" << c.item_bytes
<< ",\"bw_mbs\":" << c.bandwidth_mbs(elapsed_s)
<< "}";
}
o << "]}";
return o.str();
}
// ── Embedded single-page HTML ─────────────────────────────────────────────────
static const char* HTML = R"html(<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>KPN++ Web Debug</title>
<style>
body { margin: 0; background: #1a1a2e; color: #eee; font-family: monospace; }
#header { padding: 12px 20px; background: #16213e; border-bottom: 1px solid #0f3460; }
#header h1 { margin: 0; font-size: 18px; color: #e94560; }
#header span { font-size: 12px; color: #888; margin-left: 16px; }
#graph { width: 100vw; height: calc(100vh - 50px); }
.node circle { stroke: #fff; stroke-width: 1.5px; }
.node text { font-size: 11px; fill: #eee; pointer-events: none; text-anchor: middle; }
.node .stats { font-size: 9px; fill: #aaa; }
.link { fill: none; stroke-width: 2px; }
.link-label { font-size: 9px; fill: #ccc; }
.arrowhead { fill: #888; }
#tooltip {
position: absolute; background: #0f3460; border: 1px solid #e94560;
border-radius: 4px; padding: 8px 12px; font-size: 11px; pointer-events: none;
display: none; white-space: pre; line-height: 1.6;
}
</style>
</head>
<body>
<div id="header"><h1>KPN++ Web Debug</h1><span id="status">connecting...</span></div>
<svg id="graph"></svg>
<div id="tooltip"></div>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
const nodeRadius = 30;
const svg = d3.select('#graph');
const width = () => window.innerWidth;
const height = () => window.innerHeight - 50;
// Arrow marker defs
const defs = svg.append('defs');
['green','#f0c040','#e94560'].forEach((col, i) => {
defs.append('marker')
.attr('id', 'arrow' + i)
.attr('viewBox', '0 -5 10 10').attr('refX', 10).attr('refY', 0)
.attr('markerWidth', 6).attr('markerHeight', 6).attr('orient', 'auto')
.append('path').attr('d', 'M0,-5L10,0L0,5').attr('fill', col);
});
const g = svg.append('g');
svg.call(d3.zoom().on('zoom', e => g.attr('transform', e.transform)));
let sim, linkSel, nodeSel, labelSel, edgeLabelSel;
let nodes = [], links = [];
function edgeColor(fill_pct) {
if (fill_pct >= 80) return '#e94560';
if (fill_pct >= 50) return '#f0c040';
return '#4CAF50';
}
function edgeArrow(fill_pct) {
if (fill_pct >= 80) return 'url(#arrow2)';
if (fill_pct >= 50) return 'url(#arrow1)';
return 'url(#arrow0)';
}
function nodeColor(ema) {
if (ema > 100) return '#e94560';
if (ema > 50) return '#e07040';
if (ema > 10) return '#f0c040';
return '#4CAF50';
}
function init(data) {
nodes = data.nodes.map(n => ({ ...n, x: width()/2, y: height()/2 }));
const nodeById = Object.fromEntries(nodes.map(n => [n.id, n]));
links = data.edges.map(e => ({
...e,
source: nodeById[e.source],
target: nodeById[e.target],
})).filter(e => e.source && e.target);
sim = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).distance(160).strength(0.5))
.force('charge', d3.forceManyBody().strength(-400))
.force('center', d3.forceCenter(width()/2, height()/2))
.force('collide', d3.forceCollide(nodeRadius + 20))
.on('tick', ticked);
// Links
linkSel = g.append('g').selectAll('line').data(links).join('line')
.attr('class', 'link')
.attr('stroke', d => edgeColor(d.fill_pct))
.attr('marker-end', d => edgeArrow(d.fill_pct));
edgeLabelSel = g.append('g').selectAll('text').data(links).join('text')
.attr('class', 'link-label')
.text(d => `${d.fill_pct.toFixed(0)}%`);
// Nodes
const nodeG = g.append('g').selectAll('g').data(nodes).join('g')
.attr('class', 'node')
.call(d3.drag()
.on('start', (e,d) => { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; })
.on('drag', (e,d) => { d.fx=e.x; d.fy=e.y; })
.on('end', (e,d) => { if (!e.active) sim.alphaTarget(0); d.fx=null; d.fy=null; }));
nodeG.append('circle').attr('r', nodeRadius).attr('fill', d => nodeColor(d.ema_exec_ms));
nodeG.append('text').attr('dy', 4).text(d => d.id);
nodeG.append('text').attr('class', 'stats').attr('dy', 18)
.text(d => `${d.ema_exec_ms.toFixed(1)}ms ${d.fps.toFixed(1)}fps`);
nodeSel = nodeG;
// Tooltips
const tip = d3.select('#tooltip');
nodeG.on('mousemove', (e, d) => {
tip.style('display','block')
.style('left', (e.pageX+12)+'px').style('top', (e.pageY+12)+'px')
.text(
`node: ${d.id}\nframes: ${d.frames}\nexec (ema): ${d.ema_exec_ms.toFixed(2)} ms` +
`\nexec (max): ${d.max_exec_ms.toFixed(2)} ms\nblocked: ${d.blocked_ms.toFixed(2)} ms` +
`\nfps: ${d.fps.toFixed(2)}\ncpu total: ${d.total_cpu_ms.toFixed(1)} ms` +
`\ncpu util: ${d.cpu_util_pct.toFixed(1)}%`);
}).on('mouseleave', () => tip.style('display','none'));
g.selectAll('.link').on('mousemove', (e, d) => {
tip.style('display','block')
.style('left', (e.pageX+12)+'px').style('top', (e.pageY+12)+'px')
.text(
`channel: ${d.name}\nfill: ${d.fill_pct.toFixed(1)}% peak: ${d.peak_pct.toFixed(1)}%` +
`\ncapacity: ${d.capacity} current: ${d.current}` +
`\npushes: ${d.pushes} drops: ${d.drops} overflows: ${d.overflows}` +
`\nbandwidth: ${d.bw_mbs.toFixed(2)} MB/s item: ${d.item_bytes} B`);
}).on('mouseleave', () => tip.style('display','none'));
}
function update(data) {
// Update node stats in-place (preserve simulation x/y positions)
const byId = Object.fromEntries(data.nodes.map(n => [n.id, n]));
nodes.forEach(n => {
const fresh = byId[n.id];
if (fresh) {
n.frames = fresh.frames; n.ema_exec_ms = fresh.ema_exec_ms;
n.max_exec_ms = fresh.max_exec_ms; n.blocked_ms = fresh.blocked_ms;
n.fps = fresh.fps; n.total_cpu_ms = fresh.total_cpu_ms;
n.cpu_util_pct = fresh.cpu_util_pct;
}
});
// Update edge stats in-place (source/target are already D3 node refs — don't overwrite)
data.edges.forEach((e, i) => {
if (!links[i]) return;
links[i].fill_pct = e.fill_pct; links[i].peak_pct = e.peak_pct;
links[i].pushes = e.pushes; links[i].drops = e.drops;
links[i].overflows = e.overflows; links[i].current = e.current;
links[i].bw_mbs = e.bw_mbs;
});
// Re-color
nodeSel.select('circle').attr('fill', d => nodeColor(d.ema_exec_ms));
nodeSel.select('.stats').text(d => `${d.ema_exec_ms.toFixed(1)}ms ${d.fps.toFixed(1)}fps`);
linkSel.attr('stroke', d => edgeColor(d.fill_pct))
.attr('marker-end', d => edgeArrow(d.fill_pct));
edgeLabelSel.text(d => `${d.fill_pct.toFixed(0)}%`);
}
function ticked() {
// Clamp nodes to viewport
nodes.forEach(d => {
d.x = Math.max(nodeRadius, Math.min(width() - nodeRadius, d.x));
d.y = Math.max(nodeRadius, Math.min(height() - nodeRadius, d.y));
});
linkSel
.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
.attr('x2', d => { // shorten to node edge
const dx = d.target.x - d.source.x, dy = d.target.y - d.source.y;
const dist = Math.sqrt(dx*dx+dy*dy) || 1;
return d.target.x - (dx/dist)*(nodeRadius+8);
})
.attr('y2', d => {
const dx = d.target.x - d.source.x, dy = d.target.y - d.source.y;
const dist = Math.sqrt(dx*dx+dy*dy) || 1;
return d.target.y - (dy/dist)*(nodeRadius+8);
});
edgeLabelSel
.attr('x', d => (d.source.x + d.target.x)/2)
.attr('y', d => (d.source.y + d.target.y)/2 - 6);
nodeSel.attr('transform', d => `translate(${d.x},${d.y})`);
}
let initialised = false;
async function poll() {
try {
const r = await fetch('/api/snapshot');
if (!r.ok) throw new Error(r.status);
const data = await r.json();
document.getElementById('status').textContent =
`last update: ${new Date().toLocaleTimeString()} ${data.nodes.length} nodes, ${data.edges.length} edges`;
if (!initialised) { init(data); initialised = true; }
else { update(data); }
} catch(e) {
document.getElementById('status').textContent = `error: ${e}`;
}
}
poll();
setInterval(poll, 500);
window.addEventListener('resize', () => sim && sim.force('center', d3.forceCenter(width()/2, height()/2)).alpha(0.1).restart());
</script>
</body>
</html>
)html";
// ── WebDebugServer ────────────────────────────────────────────────────────────
class WebDebugServer {
public:
using SnapshotFn = std::function<std::string()>;
explicit WebDebugServer(uint16_t port, SnapshotFn fn)
: port_(port), snapshot_fn_(std::move(fn)) {}
void start() {
svr_.Get("/", [](const httplib::Request&, httplib::Response& res) {
res.set_content(HTML, "text/html");
});
svr_.Get("/api/snapshot", [this](const httplib::Request&, httplib::Response& res) {
res.set_content(snapshot_fn_(), "application/json");
});
thread_ = std::thread([this] {
svr_.listen("0.0.0.0", static_cast<int>(port_));
});
}
void stop() {
svr_.stop();
if (thread_.joinable()) thread_.join();
}
~WebDebugServer() { stop(); }
WebDebugServer(const WebDebugServer&) = delete;
WebDebugServer& operator=(const WebDebugServer&) = delete;
private:
uint16_t port_;
SnapshotFn snapshot_fn_;
httplib::Server svr_;
std::thread thread_;
};
} // namespace kpn::web_debug