457 lines
18 KiB
C++
457 lines
18 KiB
C++
#pragma once
|
|
// Only active when KPN_WEB_DEBUG is defined.
|
|
|
|
#ifdef KPN_WEB_DEBUG
|
|
#include "diagnostics.hpp"
|
|
#include "web_debug.hpp"
|
|
|
|
#include <functional>
|
|
#include <memory>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
namespace kpn {
|
|
|
|
// ── Hub HTML ──────────────────────────────────────────────────────────────────
|
|
// Multi-tab UI: one tab per registered network (force-directed graph) +
|
|
// an "All Networks" tab showing shared resource cards and a cross-network
|
|
// node table.
|
|
|
|
static const char* HUB_HTML = R"html(<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>KPN++ Debug Hub</title>
|
|
<style>
|
|
*{box-sizing:border-box}
|
|
body{margin:0;background:#1a1a2e;color:#eee;font-family:monospace}
|
|
#hdr{display:flex;align-items:center;padding:0 16px;background:#16213e;
|
|
border-bottom:1px solid #0f3460;height:44px;gap:8px;overflow-x:auto}
|
|
#hdr h1{margin:0;font-size:16px;color:#e94560;white-space:nowrap;margin-right:12px}
|
|
#tab-bar{display:flex;gap:2px;flex:1}
|
|
.tab{padding:4px 14px;border:none;background:#0f3460;color:#aaa;
|
|
cursor:pointer;font-family:monospace;font-size:11px;border-radius:2px;white-space:nowrap}
|
|
.tab.active{background:#e94560;color:#fff}
|
|
.tab:hover:not(.active){background:#1e3a6e;color:#eee}
|
|
#status{font-size:10px;color:#555;white-space:nowrap}
|
|
|
|
.panel{display:none}
|
|
.panel.active{display:block}
|
|
|
|
/* ── All Networks tab ─────────────────────────────────────── */
|
|
#panel-all{height:calc(100vh - 44px);overflow-y:auto;padding:16px;
|
|
display:none;gap:16px;grid-template-columns:300px 1fr}
|
|
#panel-all.active{display:grid;align-content:start}
|
|
#panel-all h2{font-size:11px;color:#e94560;margin:0 0 8px;
|
|
letter-spacing:1px;text-transform:uppercase}
|
|
|
|
#res-col{grid-column:1}
|
|
.res-card{background:#16213e;border:1px solid #0f3460;border-radius:4px;
|
|
padding:10px 12px;margin-bottom:8px}
|
|
.res-head{display:flex;justify-content:space-between;align-items:center}
|
|
.res-name{font-size:12px}
|
|
.badge{font-size:9px;padding:2px 6px;border-radius:2px}
|
|
.held{background:#e94560}.free{background:#4CAF50;color:#111}
|
|
.res-meta{font-size:9px;color:#666;margin-top:5px;display:flex;gap:12px;flex-wrap:wrap}
|
|
.bar-wrap{height:3px;background:#0f3460;border-radius:2px;margin-top:7px}
|
|
.bar{height:3px;border-radius:2px;transition:width 0.4s}
|
|
|
|
#nodes-col{grid-column:2;overflow-y:auto;max-height:calc(100vh - 76px)}
|
|
table{width:100%;border-collapse:collapse;font-size:10px}
|
|
th{padding:4px 8px;color:#555;border-bottom:1px solid #0f3460;text-align:left;
|
|
position:sticky;top:0;background:#1a1a2e;z-index:1}
|
|
td{padding:2px 8px;border-bottom:1px solid #16213e}
|
|
tr:hover td{background:#16213e}
|
|
.ntag{font-size:9px;background:#0f3460;padding:1px 4px;border-radius:2px;color:#4CAF50}
|
|
|
|
/* ── Per-network graph panels ─────────────────────────────── */
|
|
.graph-panel{width:100vw;height:calc(100vh - 44px)}
|
|
svg.net{width:100%;height:100%}
|
|
.node circle{stroke:#fff;stroke-width:1.5px}
|
|
.node text{font-size:11px;fill:#eee;pointer-events:none;text-anchor:middle}
|
|
.node .st{font-size:9px;fill:#aaa}
|
|
.link{fill:none;stroke-width:2px}
|
|
.lbl{font-size:9px;fill:#ccc}
|
|
#tip{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="hdr">
|
|
<h1>KPN++ Debug Hub</h1>
|
|
<div id="tab-bar"></div>
|
|
<span id="status">connecting…</span>
|
|
</div>
|
|
<div id="panels">
|
|
<div id="panel-all" class="panel"></div>
|
|
</div>
|
|
<div id="tip"></div>
|
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
<script>
|
|
const R = 28;
|
|
const tip = d3.select('#tip');
|
|
|
|
const nc = ema => ema>100?'#e94560':ema>50?'#e07040':ema>10?'#f0c040':'#4CAF50';
|
|
const ec = pct => pct>=80?'#e94560':pct>=50?'#f0c040':'#4CAF50';
|
|
const ea = pct => pct>=80?'url(#a2)':pct>=50?'url(#a1)':'url(#a0)';
|
|
|
|
// ── Tab management ────────────────────────────────────────────────────────────
|
|
let activeTab = null;
|
|
|
|
function ensureTab(id, label) {
|
|
if (document.getElementById('tab-' + id)) return;
|
|
const b = document.createElement('button');
|
|
b.className = 'tab'; b.id = 'tab-' + id; b.textContent = label;
|
|
b.onclick = () => showTab(id);
|
|
document.getElementById('tab-bar').appendChild(b);
|
|
}
|
|
|
|
function showTab(id) {
|
|
activeTab = id;
|
|
document.querySelectorAll('.tab').forEach(b =>
|
|
b.classList.toggle('active', b.id === 'tab-' + id));
|
|
document.querySelectorAll('.panel').forEach(p =>
|
|
p.classList.toggle('active', p.id === 'panel-' + id));
|
|
}
|
|
|
|
// ── All Networks tab ──────────────────────────────────────────────────────────
|
|
function renderAll(data) {
|
|
const panel = document.getElementById('panel-all');
|
|
|
|
// Resources column
|
|
let rhtml = '<div id="res-col"><h2>Shared Resources</h2>';
|
|
if (!data.resources || !data.resources.length)
|
|
rhtml += '<div style="color:#444;font-size:11px">None registered</div>';
|
|
for (const r of (data.resources || [])) {
|
|
const wpct = Math.min(100, r.avg_wait_ms).toFixed(1);
|
|
const bc = r.current_waiters > 0 ? '#e94560' : '#4CAF50';
|
|
rhtml += `<div class="res-card">
|
|
<div class="res-head">
|
|
<span class="res-name">${r.name}</span>
|
|
<span class="badge ${r.held ? 'held' : 'free'}">${r.held ? 'HELD' : 'free'}</span>
|
|
</div>
|
|
<div class="res-meta">
|
|
<span>avg wait ${r.avg_wait_ms.toFixed(1)} ms</span>
|
|
<span>waiters ${r.current_waiters} / peak ${r.peak_waiters}</span>
|
|
<span>${r.acquisitions} acq</span>
|
|
</div>
|
|
<div class="bar-wrap">
|
|
<div class="bar" style="width:${wpct}%;background:${bc}"></div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
rhtml += '</div>';
|
|
|
|
// Nodes column — all networks in one table
|
|
let rows = '';
|
|
for (const net of data.networks) {
|
|
for (const n of net.nodes) {
|
|
rows += `<tr>
|
|
<td><span class="ntag">${net.name}</span></td>
|
|
<td>${n.id}</td>
|
|
<td>${n.fps.toFixed(1)}</td>
|
|
<td>${n.ema_exec_ms.toFixed(2)}</td>
|
|
<td>${n.max_exec_ms.toFixed(2)}</td>
|
|
<td>${n.blocked_ms.toFixed(2)}</td>
|
|
<td>${n.cpu_util_pct.toFixed(1)}</td>
|
|
</tr>`;
|
|
}
|
|
}
|
|
const thtml = `<div id="nodes-col"><h2>All Nodes</h2>
|
|
<table>
|
|
<tr><th>Network</th><th>Node</th><th>fps</th>
|
|
<th>exec ema (ms)</th><th>exec max (ms)</th>
|
|
<th>blocked (ms)</th><th>cpu %</th></tr>
|
|
${rows}
|
|
</table></div>`;
|
|
|
|
panel.innerHTML = rhtml + thtml;
|
|
}
|
|
|
|
// ── Per-network graph ─────────────────────────────────────────────────────────
|
|
const nets = {};
|
|
|
|
function initNet(netData) {
|
|
const name = netData.name;
|
|
|
|
const div = document.createElement('div');
|
|
div.id = 'panel-' + name;
|
|
div.className = 'panel graph-panel';
|
|
document.getElementById('panels').appendChild(div);
|
|
|
|
const svg = d3.select(div).append('svg').attr('class', 'net');
|
|
const W = () => div.clientWidth || window.innerWidth;
|
|
const H = () => div.clientHeight || (window.innerHeight - 44);
|
|
|
|
const defs = svg.append('defs');
|
|
['#4CAF50','#f0c040','#e94560'].forEach((col, i) =>
|
|
defs.append('marker').attr('id','a'+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)));
|
|
|
|
const nodes = netData.nodes.map(n => ({...n, x: W()/2, y: H()/2}));
|
|
const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
|
|
const links = netData.edges
|
|
.map(e => ({...e, source: byId[e.source], target: byId[e.target]}))
|
|
.filter(e => e.source && e.target);
|
|
|
|
const sim = d3.forceSimulation(nodes)
|
|
.force('link', d3.forceLink(links).distance(150).strength(0.5))
|
|
.force('charge', d3.forceManyBody().strength(-350))
|
|
.force('center', d3.forceCenter(W()/2, H()/2))
|
|
.force('collide', d3.forceCollide(R + 18))
|
|
.on('tick', tick);
|
|
|
|
const lsel = g.append('g').selectAll('line').data(links).join('line')
|
|
.attr('class','link')
|
|
.attr('stroke', d => ec(d.fill_pct))
|
|
.attr('marker-end', d => ea(d.fill_pct));
|
|
|
|
const llbl = g.append('g').selectAll('text').data(links).join('text')
|
|
.attr('class','lbl').text(d => `${d.fill_pct.toFixed(0)}%`);
|
|
|
|
const ng = 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; }));
|
|
|
|
ng.append('circle').attr('r', R).attr('fill', d => nc(d.ema_exec_ms));
|
|
ng.append('text').attr('dy', 4).text(d => d.id);
|
|
ng.append('text').attr('class','st').attr('dy', 18)
|
|
.text(d => `${d.ema_exec_ms.toFixed(1)}ms ${d.fps.toFixed(1)}fps`);
|
|
|
|
ng.on('mousemove', (e,d) =>
|
|
tip.style('display','block')
|
|
.style('left',(e.pageX+12)+'px').style('top',(e.pageY+12)+'px')
|
|
.text(`${d.id}\nframes: ${d.frames} fps: ${d.fps.toFixed(2)}\n` +
|
|
`exec ema: ${d.ema_exec_ms.toFixed(2)}ms max: ${d.max_exec_ms.toFixed(2)}ms\n` +
|
|
`blocked: ${d.blocked_ms.toFixed(2)}ms cpu: ${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(`${d.name}\nfill: ${d.fill_pct.toFixed(1)}% peak: ${d.peak_pct.toFixed(1)}%\n` +
|
|
`cap: ${d.capacity} pushes: ${d.pushes} drops: ${d.drops}\n` +
|
|
`bandwidth: ${(d.bw_mbs||0).toFixed(2)} MB/s`))
|
|
.on('mouseleave', () => tip.style('display','none'));
|
|
|
|
function tick() {
|
|
const w = W(), h = H();
|
|
nodes.forEach(d => {
|
|
d.x = Math.max(R, Math.min(w - R, d.x));
|
|
d.y = Math.max(R, Math.min(h - R, d.y));
|
|
});
|
|
lsel
|
|
.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
|
|
.attr('x2', d => { const dx=d.target.x-d.source.x, dy=d.target.y-d.source.y,
|
|
dist=Math.sqrt(dx*dx+dy*dy)||1;
|
|
return d.target.x-(dx/dist)*(R+8); })
|
|
.attr('y2', d => { const dx=d.target.x-d.source.x, dy=d.target.y-d.source.y,
|
|
dist=Math.sqrt(dx*dx+dy*dy)||1;
|
|
return d.target.y-(dy/dist)*(R+8); });
|
|
llbl.attr('x', d => (d.source.x+d.target.x)/2)
|
|
.attr('y', d => (d.source.y+d.target.y)/2 - 6);
|
|
ng.attr('transform', d => `translate(${d.x},${d.y})`);
|
|
}
|
|
|
|
nets[name] = {nodes, links, sim, ng, lsel, llbl};
|
|
}
|
|
|
|
function updateNet(netData) {
|
|
const st = nets[netData.name];
|
|
if (!st) return;
|
|
const byId = Object.fromEntries(netData.nodes.map(n => [n.id, n]));
|
|
st.nodes.forEach(n => {
|
|
const f = byId[n.id];
|
|
if (f) Object.assign(n, {frames:f.frames, ema_exec_ms:f.ema_exec_ms,
|
|
max_exec_ms:f.max_exec_ms, blocked_ms:f.blocked_ms, fps:f.fps,
|
|
total_cpu_ms:f.total_cpu_ms, cpu_util_pct:f.cpu_util_pct});
|
|
});
|
|
netData.edges.forEach((e,i) => {
|
|
if (st.links[i]) Object.assign(st.links[i], {fill_pct:e.fill_pct,
|
|
peak_pct:e.peak_pct, pushes:e.pushes, drops:e.drops,
|
|
overflows:e.overflows, current:e.current, bw_mbs:e.bw_mbs});
|
|
});
|
|
st.ng.select('circle').attr('fill', d => nc(d.ema_exec_ms));
|
|
st.ng.select('.st').text(d => `${d.ema_exec_ms.toFixed(1)}ms ${d.fps.toFixed(1)}fps`);
|
|
st.lsel.attr('stroke', d => ec(d.fill_pct)).attr('marker-end', d => ea(d.fill_pct));
|
|
st.llbl.text(d => `${d.fill_pct.toFixed(0)}%`);
|
|
}
|
|
|
|
// ── Poll loop ─────────────────────────────────────────────────────────────────
|
|
let init = false;
|
|
|
|
async function poll() {
|
|
try {
|
|
const r = await fetch('/api/snapshot');
|
|
if (!r.ok) throw new Error(r.status);
|
|
const data = await r.json();
|
|
|
|
if (!init) {
|
|
ensureTab('all', 'All Networks');
|
|
data.networks.forEach(net => ensureTab(net.name, net.name));
|
|
showTab('all');
|
|
data.networks.forEach(initNet);
|
|
init = true;
|
|
}
|
|
|
|
renderAll(data);
|
|
data.networks.forEach(updateNet);
|
|
|
|
document.getElementById('status').textContent =
|
|
`${new Date().toLocaleTimeString()} · ${data.networks.length} nets · ${(data.resources||[]).length} resources`;
|
|
} catch(e) {
|
|
document.getElementById('status').textContent = 'error: ' + e;
|
|
}
|
|
}
|
|
|
|
poll();
|
|
setInterval(poll, 500);
|
|
window.addEventListener('resize', () =>
|
|
Object.values(nets).forEach(st =>
|
|
st.sim.force('center', d3.forceCenter(
|
|
(document.getElementById('panel-' + Object.keys(nets).find(k => nets[k] === st))?.clientWidth || window.innerWidth) / 2,
|
|
(document.getElementById('panel-' + Object.keys(nets).find(k => nets[k] === st))?.clientHeight || window.innerHeight - 44) / 2
|
|
)).alpha(0.1).restart()));
|
|
</script>
|
|
</body>
|
|
</html>
|
|
)html";
|
|
|
|
// ── DebugHub ──────────────────────────────────────────────────────────────────
|
|
|
|
class DebugHub {
|
|
public:
|
|
explicit DebugHub(uint16_t port = 9090) : port_(port) {}
|
|
|
|
~DebugHub() { stop(); }
|
|
DebugHub(const DebugHub&) = delete;
|
|
DebugHub& operator=(const DebugHub&) = delete;
|
|
|
|
// Register a network. Disables that network's own web server so the hub
|
|
// becomes the single debug endpoint. Call before network.start().
|
|
template<typename Net>
|
|
void register_network(const std::string& name, Net& net) {
|
|
net.disable_web_server();
|
|
networks_.push_back({name, [&net, name] {
|
|
auto s = net.network_snapshot();
|
|
s.name = name;
|
|
return s;
|
|
}});
|
|
}
|
|
|
|
// Register a shared resource — appears in the "All Networks" resource panel.
|
|
void register_resource(const std::string& name, IResourceProbe* probe) {
|
|
resources_.emplace_back(name, probe);
|
|
}
|
|
|
|
void start() {
|
|
server_ = std::make_unique<web_debug::WebDebugServer>(
|
|
port_,
|
|
[this] { return build_json(); },
|
|
HUB_HTML);
|
|
server_->start();
|
|
std::cerr << "[kpn] hub debug UI: http://localhost:" << port_ << "\n";
|
|
}
|
|
|
|
void stop() { if (server_) server_->stop(); }
|
|
|
|
private:
|
|
// Serialise nodes array for one network snapshot
|
|
static void write_nodes(std::ostream& o, const NetworkSnapshot& s) {
|
|
o << "[";
|
|
for (std::size_t i = 0; i < s.nodes.size(); ++i) {
|
|
const auto& n = s.nodes[i];
|
|
if (i) o << ',';
|
|
o << "{\"id\":\"" << web_debug::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 << "]";
|
|
}
|
|
|
|
// Serialise edges array for one network snapshot
|
|
static void write_edges(std::ostream& o, const NetworkSnapshot& s) {
|
|
o << "[";
|
|
for (std::size_t i = 0; i < s.channels.size(); ++i) {
|
|
const auto& c = s.channels[i];
|
|
if (i) o << ',';
|
|
auto [src, dst] = web_debug::parse_edge_name(c.name);
|
|
o << "{\"name\":\"" << web_debug::escape_json(c.name) << "\""
|
|
<< ",\"source\":\"" << web_debug::escape_json(src) << "\""
|
|
<< ",\"target\":\"" << web_debug::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(s.elapsed_s)
|
|
<< "}";
|
|
}
|
|
o << "]";
|
|
}
|
|
|
|
std::string build_json() const {
|
|
std::ostringstream o;
|
|
o << std::fixed;
|
|
o.precision(2);
|
|
|
|
o << "{\"networks\":[";
|
|
for (std::size_t i = 0; i < networks_.size(); ++i) {
|
|
if (i) o << ',';
|
|
auto s = networks_[i].fn();
|
|
o << "{\"name\":\"" << web_debug::escape_json(networks_[i].name) << "\""
|
|
<< ",\"nodes\":"; write_nodes(o, s);
|
|
o << ",\"edges\":"; write_edges(o, s);
|
|
o << "}";
|
|
}
|
|
|
|
o << "],\"resources\":[";
|
|
for (std::size_t i = 0; i < resources_.size(); ++i) {
|
|
if (i) o << ',';
|
|
const auto r = resources_[i].second->snapshot(resources_[i].first);
|
|
o << "{\"name\":\"" << web_debug::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();
|
|
}
|
|
|
|
struct Entry {
|
|
std::string name;
|
|
std::function<NetworkSnapshot()> fn;
|
|
};
|
|
|
|
uint16_t port_;
|
|
std::vector<Entry> networks_;
|
|
std::vector<std::pair<std::string,IResourceProbe*>> resources_;
|
|
std::unique_ptr<web_debug::WebDebugServer> server_;
|
|
};
|
|
|
|
} // namespace kpn
|
|
#endif // KPN_WEB_DEBUG
|