Skip to main content

zerodds_dashboard/
web.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Eingebettetes Single-Page-Dashboard (HTML + vanilla JS + d3).
5//!
6//! Wird vom Server als `GET /` ausgeliefert. d3.js wird vom CDN
7//! geladen — kein npm-Tooling, keine bundler-Pipeline.
8
9/// Die HTML-Seite mit eingebettetem JS.
10pub const INDEX_HTML: &str = r#"<!doctype html>
11<html lang="de">
12<head>
13<meta charset="utf-8">
14<title>ZeroDDS Dashboard</title>
15<style>
16  body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
17         background: #1a1a1a; color: #ddd; margin: 0; padding: 12px; }
18  h1, h2 { color: #fff; }
19  h1 { font-size: 22px; margin: 0 0 16px 0; }
20  h2 { font-size: 16px; margin: 16px 0 8px 0; border-bottom: 1px solid #444; padding-bottom: 4px; }
21  table { width: 100%; border-collapse: collapse; font-size: 13px; }
22  th { text-align: left; color: #aaa; font-weight: normal; padding: 4px 8px; border-bottom: 1px solid #333; }
23  td { padding: 4px 8px; border-bottom: 1px solid #2a2a2a; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
24  td.num { text-align: right; }
25  .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
26  .card { background: #232323; border-radius: 6px; padding: 12px; }
27  #graph svg { width: 100%; height: 360px; background: #1f1f1f; border-radius: 4px; }
28  #graph .node { fill: #4a9eff; }
29  #graph .edge { stroke: #555; stroke-width: 1.5; }
30  #graph .label { fill: #ddd; font-size: 11px; }
31  button { background: #2a4d7a; color: #fff; border: 0; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 13px; }
32  button:hover { background: #356da4; }
33  .status-on { color: #5fa05f; font-weight: bold; }
34  .status-off { color: #888; }
35  .meta { color: #888; font-size: 11px; }
36</style>
37</head>
38<body>
39<h1>ZeroDDS Live Dashboard</h1>
40<div class="meta">refresh every 1 s — <span id="status">connecting…</span></div>
41
42<div class="grid">
43  <div class="card">
44    <h2>Topics</h2>
45    <table id="topics-tbl">
46      <thead><tr><th>name</th><th>type</th><th class="num">pub</th><th class="num">sub</th><th class="num">rate hz</th></tr></thead>
47      <tbody></tbody>
48    </table>
49  </div>
50  <div class="card">
51    <h2>Participants</h2>
52    <table id="participants-tbl">
53      <thead><tr><th>name</th><th>guid prefix</th><th class="num">domain</th><th>vendor</th></tr></thead>
54      <tbody></tbody>
55    </table>
56  </div>
57</div>
58
59<div class="grid">
60  <div class="card">
61    <h2>Histograms</h2>
62    <table id="hist-tbl">
63      <thead><tr><th>metric</th><th class="num">count</th><th class="num">p50</th><th class="num">p99</th><th class="num">max</th></tr></thead>
64      <tbody></tbody>
65    </table>
66  </div>
67  <div class="card">
68    <h2>Recording</h2>
69    <p>Status: <span id="rec-status" class="status-off">off</span></p>
70    <p>Path: <span id="rec-path">—</span></p>
71    <p>Frames: <span id="rec-frames">0</span></p>
72    <button id="rec-toggle">Toggle</button>
73  </div>
74</div>
75
76<div class="card">
77  <h2>Discovery Graph</h2>
78  <div id="graph"></div>
79</div>
80
81<script src="https://d3js.org/d3.v7.min.js"></script>
82<script>
83const fmtNs = ns => ns >= 1_000_000 ? (ns/1_000_000).toFixed(2) + ' ms'
84                : ns >= 1_000 ? (ns/1_000).toFixed(1) + ' µs'
85                : ns + ' ns';
86
87async function refresh() {
88  try {
89    const [topics, parts, hists, graph, rec] = await Promise.all([
90      fetch('/api/topics').then(r => r.json()),
91      fetch('/api/participants').then(r => r.json()),
92      fetch('/api/histograms').then(r => r.json()),
93      fetch('/api/graph').then(r => r.json()),
94      fetch('/api/recording').then(r => r.json()),
95    ]);
96    document.getElementById('status').textContent = 'connected (' + new Date().toLocaleTimeString() + ')';
97    renderTopics(topics);
98    renderParticipants(parts);
99    renderHistograms(hists);
100    renderGraph(graph);
101    renderRecording(rec);
102  } catch (e) {
103    document.getElementById('status').textContent = 'error: ' + e.message;
104  }
105}
106
107function renderTopics(items) {
108  const tbody = document.querySelector('#topics-tbl tbody');
109  tbody.innerHTML = items.map(t =>
110    `<tr><td>${esc(t.name)}</td><td>${esc(t.type_name)}</td>` +
111    `<td class="num">${t.publishers}</td><td class="num">${t.subscribers}</td>` +
112    `<td class="num">${t.sample_rate_hz.toFixed(1)}</td></tr>`
113  ).join('');
114}
115function renderParticipants(items) {
116  const tbody = document.querySelector('#participants-tbl tbody');
117  tbody.innerHTML = items.map(p =>
118    `<tr><td>${esc(p.name)}</td><td>${esc(p.guid_prefix_hex)}</td>` +
119    `<td class="num">${p.domain_id}</td><td>${esc(p.vendor_id_hex)}</td></tr>`
120  ).join('');
121}
122function renderHistograms(items) {
123  const tbody = document.querySelector('#hist-tbl tbody');
124  tbody.innerHTML = items.map(h =>
125    `<tr><td>${esc(h.name)}</td><td class="num">${h.count}</td>` +
126    `<td class="num">${fmtNs(h.p50_ns)}</td><td class="num">${fmtNs(h.p99_ns)}</td>` +
127    `<td class="num">${fmtNs(h.max_ns)}</td></tr>`
128  ).join('');
129}
130function renderRecording(r) {
131  const st = document.getElementById('rec-status');
132  st.textContent = r.active ? 'on' : 'off';
133  st.className = r.active ? 'status-on' : 'status-off';
134  document.getElementById('rec-path').textContent = r.output_path || '—';
135  document.getElementById('rec-frames').textContent = r.frames;
136}
137document.getElementById('rec-toggle').onclick = async () => {
138  await fetch('/api/recording/toggle', { method: 'POST' });
139  refresh();
140};
141
142let graphSim = null;
143function renderGraph(g) {
144  const div = document.getElementById('graph');
145  div.innerHTML = '';
146  const svg = d3.select('#graph').append('svg');
147  const width = div.clientWidth, height = 360;
148  svg.attr('viewBox', `0 0 ${width} ${height}`);
149
150  const link = svg.append('g').selectAll('line').data(g.edges)
151    .join('line').attr('class', 'edge');
152  const node = svg.append('g').selectAll('circle').data(g.nodes)
153    .join('circle').attr('class', 'node').attr('r', 8);
154  const labels = svg.append('g').selectAll('text').data(g.nodes)
155    .join('text').attr('class', 'label').text(d => d.label).attr('dx', 10).attr('dy', 4);
156
157  graphSim = d3.forceSimulation(g.nodes)
158    .force('link', d3.forceLink(g.edges).id(d => d.guid).distance(80))
159    .force('charge', d3.forceManyBody().strength(-200))
160    .force('center', d3.forceCenter(width / 2, height / 2))
161    .on('tick', () => {
162      link.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
163          .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
164      node.attr('cx', d => d.x).attr('cy', d => d.y);
165      labels.attr('x', d => d.x).attr('y', d => d.y);
166    });
167  // Edge-Source/Target sind im JSON GUIDs — d3.forceLink mapped sie via id().
168  g.edges.forEach(e => { e.source = e.from_guid; e.target = e.to_guid; });
169}
170function esc(s) { return String(s).replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c])); }
171
172refresh();
173setInterval(refresh, 1000);
174</script>
175</body>
176</html>
177"#;