391 lines
14 KiB
C++
391 lines
14 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,
|
|
const std::vector<ResourceSnapshot>& resources = {},
|
|
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 << "],\"resources\":[";
|
|
for (std::size_t i = 0; i < resources.size(); ++i) {
|
|
const auto& r = resources[i];
|
|
if (i) o << ',';
|
|
o << "{\"name\":\"" << escape_json(r.name) << "\""
|
|
<< ",\"acquisitions\":" << r.acquisitions
|
|
<< ",\"avg_wait_ms\":" << r.avg_wait_ms
|
|
<< ",\"peak_waiters\":" << r.peak_waiters
|
|
<< ",\"current_waiters\":" << r.current_waiters
|
|
<< ",\"held\":" << (r.held ? "true" : "false")
|
|
<< "}";
|
|
}
|
|
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;
|
|
}
|
|
#resources {
|
|
position: absolute; bottom: 12px; right: 12px;
|
|
background: #16213e; border: 1px solid #0f3460; border-radius: 4px;
|
|
padding: 8px 12px; font-size: 10px; min-width: 220px;
|
|
display: none;
|
|
}
|
|
#resources h2 { margin: 0 0 6px; font-size: 11px; color: #e94560; }
|
|
.res-row { display: flex; justify-content: space-between; gap: 12px; margin-top: 3px; }
|
|
.res-name { color: #eee; }
|
|
.res-held-y { color: #e94560; }
|
|
.res-held-n { color: #4CAF50; }
|
|
</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>
|
|
<div id="resources"><h2>Shared Resources</h2><div id="res-list"></div></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;
|
|
renderResources(data.resources);
|
|
|
|
// 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 renderResources(resources) {
|
|
const panel = document.getElementById('resources');
|
|
const list = document.getElementById('res-list');
|
|
if (!resources || resources.length === 0) { panel.style.display = 'none'; return; }
|
|
panel.style.display = 'block';
|
|
list.innerHTML = resources.map(r => {
|
|
const heldCls = r.held ? 'res-held-y' : 'res-held-n';
|
|
const heldTxt = r.held ? 'HELD' : 'free';
|
|
return `<div class="res-row">
|
|
<span class="res-name">${r.name}</span>
|
|
<span class="${heldCls}">${heldTxt}</span>
|
|
<span>wait ${r.avg_wait_ms.toFixed(1)}ms</span>
|
|
<span>waiters ${r.current_waiters}/${r.peak_waiters}</span>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function update(data) {
|
|
renderResources(data.resources);
|
|
// 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
|