Add a unified observability interface for applications with multiple networks
All checks were successful
🧪 Test / test (push) Successful in 6m9s
All checks were successful
🧪 Test / test (push) Successful in 6m9s
This commit is contained in:
parent
278c122e8f
commit
1e9ba5ee66
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,6 +1,6 @@
|
||||
# Build output
|
||||
build/
|
||||
|
||||
build_debug/
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
159
examples/14_debug_hub/main.cpp
Normal file
159
examples/14_debug_hub/main.cpp
Normal file
@ -0,0 +1,159 @@
|
||||
// Example 14 — DebugHub with Shared Resource Token
|
||||
//
|
||||
// Two independent KPN networks compete for one shared inference resource
|
||||
// (simulating a small GPU or single-session ONNX runtime). The DebugHub
|
||||
// serves a single web UI at http://localhost:9090 with:
|
||||
//
|
||||
// [All Networks] — resource utilisation cards + cross-network node table
|
||||
// [detect] — force-directed graph for the detection pipeline
|
||||
// [classify] — force-directed graph for the classification pipeline
|
||||
//
|
||||
// Topology:
|
||||
//
|
||||
// detect pipeline:
|
||||
// [source_detect] ──> [run_detect] ──> [sink_detect]
|
||||
//
|
||||
// classify pipeline:
|
||||
// [source_classify] ──> [run_classify] ──> [sink_classify]
|
||||
//
|
||||
// Both [run_detect] and [run_classify] call gpu.acquire() before touching the
|
||||
// simulated device. The priority-based token awards the next slot to the
|
||||
// waiter that is more likely to make useful progress (higher priority score).
|
||||
//
|
||||
// Build: cmake -DKPN_WEB_DEBUG=ON .. && cmake --build .
|
||||
// Run: ./14_debug_hub
|
||||
// UI: http://localhost:9090
|
||||
|
||||
#ifdef KPN_WEB_DEBUG
|
||||
|
||||
#include <kpn/kpn.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
|
||||
using namespace kpn;
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
// ── Simulated inference device ────────────────────────────────────────────────
|
||||
//
|
||||
// Represents any exclusive, serialised accelerator: GPU session, ONNX runtime,
|
||||
// hardware encoder, etc. Only one caller can hold it at a time.
|
||||
|
||||
struct GPU {
|
||||
// Detection model: fast, 8 ms per frame.
|
||||
int detect(int frame_id) {
|
||||
std::this_thread::sleep_for(8ms);
|
||||
return frame_id * 2; // synthetic "score"
|
||||
}
|
||||
|
||||
// Classification model: heavier, 14 ms per frame.
|
||||
int classify(int frame_id) {
|
||||
std::this_thread::sleep_for(14ms);
|
||||
return frame_id % 10; // synthetic "label"
|
||||
}
|
||||
};
|
||||
|
||||
// Global pointer so free-function nodes can reach the resource.
|
||||
// In production code, capture by reference inside an ObjectNode functor instead.
|
||||
static SharedResource<GPU>* g_gpu = nullptr;
|
||||
|
||||
// ── Detection pipeline ────────────────────────────────────────────────────────
|
||||
|
||||
static int source_detect() {
|
||||
static std::atomic<int> id{0};
|
||||
std::this_thread::sleep_for(25ms); // ~40 fps source rate
|
||||
return id.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
static int run_detect(int frame_id) {
|
||||
// Higher priority: detection is latency-critical.
|
||||
auto guard = g_gpu->acquire([] { return 0.7f; });
|
||||
return guard->detect(frame_id);
|
||||
}
|
||||
|
||||
static std::atomic<uint64_t> detect_out{0};
|
||||
static void sink_detect(int) {
|
||||
detect_out.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
// ── Classification pipeline ───────────────────────────────────────────────────
|
||||
|
||||
static int source_classify() {
|
||||
static std::atomic<int> id{0};
|
||||
std::this_thread::sleep_for(40ms); // ~25 fps source rate
|
||||
return id.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
static int run_classify(int frame_id) {
|
||||
// Lower priority: classification is best-effort.
|
||||
auto guard = g_gpu->acquire([] { return 0.3f; });
|
||||
return guard->classify(frame_id);
|
||||
}
|
||||
|
||||
static std::atomic<uint64_t> classify_out{0};
|
||||
static void sink_classify(int) {
|
||||
classify_out.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
// ── main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
int main() {
|
||||
SharedResource<GPU> gpu;
|
||||
g_gpu = &gpu;
|
||||
|
||||
// ── Detection network ─────────────────────────────────────────────────────
|
||||
auto src_det = make_node<source_detect, "source_detect">(4);
|
||||
auto inf_det = make_node<run_detect, "run_detect" >(4);
|
||||
auto snk_det = make_node<sink_detect, "sink_detect" >(4);
|
||||
|
||||
auto net_detect = make_network(
|
||||
edge(src_det.output<0>(), inf_det.input<0>()),
|
||||
edge(inf_det.output<0>(), snk_det.input<0>())
|
||||
);
|
||||
|
||||
// ── Classification network ────────────────────────────────────────────────
|
||||
auto src_cls = make_node<source_classify, "source_classify">(4);
|
||||
auto inf_cls = make_node<run_classify, "run_classify" >(4);
|
||||
auto snk_cls = make_node<sink_classify, "sink_classify" >(4);
|
||||
|
||||
auto net_classify = make_network(
|
||||
edge(src_cls.output<0>(), inf_cls.input<0>()),
|
||||
edge(inf_cls.output<0>(), snk_cls.input<0>())
|
||||
);
|
||||
|
||||
// ── Hub — one debug server for both networks + the shared resource ─────────
|
||||
DebugHub hub(9090);
|
||||
hub.register_network("detect", net_detect);
|
||||
hub.register_network("classify", net_classify);
|
||||
hub.register_resource("gpu", &gpu);
|
||||
|
||||
net_detect.start();
|
||||
net_classify.start();
|
||||
hub.start();
|
||||
|
||||
std::cout << "Running — open http://localhost:9090\n"
|
||||
<< "Tabs: [All Networks] [detect] [classify]\n"
|
||||
<< "Press Enter to stop.\n";
|
||||
std::cin.get();
|
||||
|
||||
net_detect.stop();
|
||||
net_classify.stop();
|
||||
|
||||
std::cout << "\nResults:\n"
|
||||
<< " detect: " << detect_out.load() << " frames\n"
|
||||
<< " classify: " << classify_out.load() << " frames\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
#else // no KPN_WEB_DEBUG
|
||||
|
||||
#include <iostream>
|
||||
int main() {
|
||||
std::cerr << "This example requires KPN_WEB_DEBUG.\n"
|
||||
<< "Rebuild with: cmake -DKPN_WEB_DEBUG=ON ..\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
#endif
|
||||
@ -15,6 +15,10 @@ kpn_example(10_static_hello_pipeline)
|
||||
kpn_example(11_static_fanout)
|
||||
if(KPN_WEB_DEBUG)
|
||||
kpn_target_enable_web_debug(06_watchdog)
|
||||
|
||||
add_executable(14_debug_hub 14_debug_hub/main.cpp)
|
||||
target_link_libraries(14_debug_hub PRIVATE kpn)
|
||||
kpn_target_enable_web_debug(14_debug_hub)
|
||||
endif()
|
||||
# 07 and 08 require the Python bindings — only add if built
|
||||
if(KPN_BUILD_PYTHON)
|
||||
|
||||
456
include/kpn/debug_hub.hpp
Normal file
456
include/kpn/debug_hub.hpp
Normal file
@ -0,0 +1,456 @@
|
||||
#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
|
||||
@ -134,6 +134,15 @@ struct NodeSnapshot {
|
||||
double cpu_util_pct; // exec_ms / (exec_ms + blocked_ms) * 100
|
||||
};
|
||||
|
||||
// ── Cross-network snapshot (used by DebugHub) ─────────────────────────────────
|
||||
|
||||
struct NetworkSnapshot {
|
||||
std::string name;
|
||||
std::vector<NodeSnapshot> nodes;
|
||||
std::vector<ChannelSnapshot> channels;
|
||||
double elapsed_s;
|
||||
};
|
||||
|
||||
// ── Resource statistics + snapshot ───────────────────────────────────────────
|
||||
|
||||
struct ResourceSnapshot {
|
||||
|
||||
@ -9,5 +9,6 @@
|
||||
#include "fanout.hpp"
|
||||
#include "shared_resource.hpp"
|
||||
#include "static_network.hpp"
|
||||
#include "debug_hub.hpp"
|
||||
#include "main_thread_node.hpp"
|
||||
#include "network.hpp"
|
||||
|
||||
@ -126,7 +126,7 @@ public:
|
||||
web_debug_port_,
|
||||
[this]() {
|
||||
auto s = collect_snapshots();
|
||||
return web_debug::to_json(s.nodes, s.channels, s.elapsed_s);
|
||||
return web_debug::to_json(s.nodes, s.channels, {}, s.elapsed_s);
|
||||
});
|
||||
web_server_->start();
|
||||
std::cerr << "[kpn] web debug UI: http://localhost:" << web_debug_port_ << "\n";
|
||||
|
||||
@ -110,6 +110,7 @@ public:
|
||||
for (auto* n : user_nodes_topo_) n->start();
|
||||
for (auto* n : fanout_nodes_ptr_) n->start();
|
||||
#ifdef KPN_WEB_DEBUG
|
||||
if (web_server_enabled_) {
|
||||
web_server_ = std::make_unique<web_debug::WebDebugServer>(
|
||||
web_debug_port_,
|
||||
[this]() {
|
||||
@ -118,6 +119,7 @@ public:
|
||||
});
|
||||
web_server_->start();
|
||||
std::cerr << "[kpn] web debug UI: http://localhost:" << web_debug_port_ << "\n";
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ -142,8 +144,16 @@ public:
|
||||
|
||||
#ifdef KPN_WEB_DEBUG
|
||||
void set_web_debug_port(uint16_t port) { web_debug_port_ = port; }
|
||||
// Called by DebugHub::register_network() so the hub owns the debug server.
|
||||
void disable_web_server() { web_server_enabled_ = false; }
|
||||
#endif
|
||||
|
||||
// Returns a snapshot of this network's nodes and channels for the DebugHub.
|
||||
NetworkSnapshot network_snapshot() const {
|
||||
auto s = collect_snapshots();
|
||||
return {"", std::move(s.nodes), std::move(s.channels), s.elapsed_s};
|
||||
}
|
||||
|
||||
// Register a shared resource so it appears in diagnostics and the debug UI.
|
||||
// The probe must outlive this network (typically the resource is on the same stack).
|
||||
void register_resource(const std::string& name, IResourceProbe* probe) {
|
||||
@ -205,6 +215,7 @@ private:
|
||||
clock_t::time_point start_time_;
|
||||
#ifdef KPN_WEB_DEBUG
|
||||
uint16_t web_debug_port_{9090};
|
||||
bool web_server_enabled_{true};
|
||||
std::unique_ptr<web_debug::WebDebugServer> web_server_;
|
||||
#endif
|
||||
};
|
||||
|
||||
@ -355,12 +355,14 @@ class WebDebugServer {
|
||||
public:
|
||||
using SnapshotFn = std::function<std::string()>;
|
||||
|
||||
explicit WebDebugServer(uint16_t port, SnapshotFn fn)
|
||||
: port_(port), snapshot_fn_(std::move(fn)) {}
|
||||
explicit WebDebugServer(uint16_t port, SnapshotFn fn,
|
||||
const char* custom_html = nullptr)
|
||||
: port_(port), snapshot_fn_(std::move(fn))
|
||||
, html_(custom_html ? custom_html : HTML) {}
|
||||
|
||||
void start() {
|
||||
svr_.Get("/", [](const httplib::Request&, httplib::Response& res) {
|
||||
res.set_content(HTML, "text/html");
|
||||
svr_.Get("/", [this](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");
|
||||
@ -383,6 +385,7 @@ public:
|
||||
private:
|
||||
uint16_t port_;
|
||||
SnapshotFn snapshot_fn_;
|
||||
const char* html_;
|
||||
httplib::Server svr_;
|
||||
std::thread thread_;
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user