1use crate::{docker::DockerNatBackend, peer::TestPeer, Error, Result};
2use chrono::Utc;
3use freenet_stdlib::{
4 client_api::{
5 ClientRequest, ConnectedPeerInfo, HostResponse, NodeDiagnosticsConfig, NodeQuery,
6 QueryResponse, SystemMetrics, WebApi,
7 },
8 prelude::ContractKey,
9};
10use regex::Regex;
11use serde::{Deserialize, Serialize};
12use serde_json::json;
13use std::{
14 collections::{HashMap, HashSet},
15 fs,
16 path::{Path, PathBuf},
17 sync::LazyLock,
18 time::Duration,
19};
20
21#[derive(Debug, Clone)]
23pub struct PeerConnectivityStatus {
24 pub peer_id: String,
25 pub connections: Option<usize>,
26 pub error: Option<String>,
27}
28
29#[derive(Debug)]
31pub struct ConnectivityStatus {
32 pub total_peers: usize,
33 pub connected_peers: usize,
34 pub ratio: f64,
35 pub peer_status: Vec<PeerConnectivityStatus>,
36}
37
38pub struct TestNetwork {
40 pub(crate) gateways: Vec<TestPeer>,
41 pub(crate) peers: Vec<TestPeer>,
42 pub(crate) min_connectivity: f64,
43 pub(crate) run_root: PathBuf,
44 pub(crate) docker_backend: Option<DockerNatBackend>,
46}
47
48impl TestNetwork {
49 pub fn builder() -> crate::builder::NetworkBuilder {
51 crate::builder::NetworkBuilder::new()
52 }
53
54 pub fn gateway(&self, index: usize) -> &TestPeer {
56 &self.gateways[index]
57 }
58
59 pub fn peer(&self, index: usize) -> &TestPeer {
61 &self.peers[index]
62 }
63
64 pub fn gateway_ws_urls(&self) -> Vec<String> {
66 self.gateways.iter().map(|p| p.ws_url()).collect()
67 }
68
69 pub fn peer_ws_urls(&self) -> Vec<String> {
71 self.peers.iter().map(|p| p.ws_url()).collect()
72 }
73
74 pub async fn wait_until_ready(&self) -> Result<()> {
79 self.wait_until_ready_with_timeout(Duration::from_secs(30))
80 .await
81 }
82
83 pub async fn wait_until_ready_with_timeout(&self, timeout: Duration) -> Result<()> {
85 let start = std::time::Instant::now();
86 let mut last_progress_log = std::time::Instant::now();
87 let progress_interval = Duration::from_secs(10);
88
89 tracing::info!(
90 "Waiting for network connectivity (timeout: {}s, required: {}%)",
91 timeout.as_secs(),
92 (self.min_connectivity * 100.0) as u8
93 );
94
95 loop {
96 if start.elapsed() > timeout {
97 let status = self.check_connectivity_detailed().await;
99 let details = Self::format_connectivity_status(&status);
100 tracing::error!(
101 "Connectivity timeout: {}/{} peers connected ({:.1}%) - {}",
102 status.connected_peers,
103 status.total_peers,
104 status.ratio * 100.0,
105 details
106 );
107 return Err(Error::ConnectivityFailed(format!(
108 "Network did not reach {}% connectivity within {}s",
109 (self.min_connectivity * 100.0) as u8,
110 timeout.as_secs()
111 )));
112 }
113
114 let status = self.check_connectivity_detailed().await;
116
117 if status.ratio >= self.min_connectivity {
118 tracing::info!("Network ready: {:.1}% connectivity", status.ratio * 100.0);
119 return Ok(());
120 }
121
122 if last_progress_log.elapsed() >= progress_interval {
124 let elapsed = start.elapsed().as_secs();
125 let details = Self::format_connectivity_status(&status);
126 tracing::info!(
127 "[{}s] Connectivity: {}/{} ({:.0}%) - {}",
128 elapsed,
129 status.connected_peers,
130 status.total_peers,
131 status.ratio * 100.0,
132 details
133 );
134 last_progress_log = std::time::Instant::now();
135 } else {
136 tracing::debug!(
137 "Network connectivity: {}/{} ({:.1}%)",
138 status.connected_peers,
139 status.total_peers,
140 status.ratio * 100.0
141 );
142 }
143
144 tokio::time::sleep(Duration::from_millis(500)).await;
145 }
146 }
147
148 pub async fn check_connectivity_detailed(&self) -> ConnectivityStatus {
150 let all_peers: Vec<_> = self.gateways.iter().chain(self.peers.iter()).collect();
151 let total = all_peers.len();
152
153 if total == 0 {
154 return ConnectivityStatus {
155 total_peers: 0,
156 connected_peers: 0,
157 ratio: 1.0,
158 peer_status: vec![],
159 };
160 }
161
162 let mut connected_count = 0;
163 let mut peer_status = Vec::with_capacity(total);
164
165 for peer in &all_peers {
166 match self.query_peer_connections(peer).await {
167 Ok(0) => {
168 peer_status.push(PeerConnectivityStatus {
169 peer_id: peer.id().to_string(),
170 connections: Some(0),
171 error: None,
172 });
173 }
174 Ok(connections) => {
175 connected_count += 1;
176 peer_status.push(PeerConnectivityStatus {
177 peer_id: peer.id().to_string(),
178 connections: Some(connections),
179 error: None,
180 });
181 }
182 Err(e) => {
183 peer_status.push(PeerConnectivityStatus {
184 peer_id: peer.id().to_string(),
185 connections: None,
186 error: Some(e.to_string()),
187 });
188 }
189 }
190 }
191
192 let ratio = connected_count as f64 / total as f64;
193 ConnectivityStatus {
194 total_peers: total,
195 connected_peers: connected_count,
196 ratio,
197 peer_status,
198 }
199 }
200
201 async fn check_connectivity(&self) -> Result<f64> {
203 let status = self.check_connectivity_detailed().await;
204 Ok(status.ratio)
205 }
206
207 fn format_connectivity_status(status: &ConnectivityStatus) -> String {
209 let mut parts: Vec<String> = status
210 .peer_status
211 .iter()
212 .map(|p| match (&p.connections, &p.error) {
213 (Some(c), _) => format!("{}:{}", p.peer_id, c),
214 (None, Some(_)) => format!("{}:err", p.peer_id),
215 (None, None) => format!("{}:?", p.peer_id),
216 })
217 .collect();
218 parts.sort();
219 parts.join(", ")
220 }
221
222 async fn query_peer_connections(&self, peer: &TestPeer) -> Result<usize> {
224 use tokio_tungstenite::connect_async;
225
226 let url = format!("{}?encodingProtocol=native", peer.ws_url());
227 let (ws_stream, _) =
228 tokio::time::timeout(std::time::Duration::from_secs(5), connect_async(&url))
229 .await
230 .map_err(|_| Error::ConnectivityFailed(format!("Timeout connecting to {}", url)))?
231 .map_err(|e| {
232 Error::ConnectivityFailed(format!("Failed to connect to {}: {}", url, e))
233 })?;
234
235 let mut client = WebApi::start(ws_stream);
236
237 client
238 .send(ClientRequest::NodeQueries(NodeQuery::ConnectedPeers))
239 .await
240 .map_err(|e| Error::ConnectivityFailed(format!("Failed to send query: {}", e)))?;
241
242 let response = tokio::time::timeout(std::time::Duration::from_secs(5), client.recv())
243 .await
244 .map_err(|_| Error::ConnectivityFailed("Timeout waiting for response".into()))?;
245
246 let result = match response {
247 Ok(HostResponse::QueryResponse(QueryResponse::ConnectedPeers { peers })) => {
248 Ok(peers.len())
249 }
250 Ok(other) => Err(Error::ConnectivityFailed(format!(
251 "Unexpected response: {:?}",
252 other
253 ))),
254 Err(e) => Err(Error::ConnectivityFailed(format!("Query failed: {}", e))),
255 };
256
257 client.disconnect("connectivity probe").await;
258
259 result
260 }
261
262 pub async fn topology(&self) -> Result<NetworkTopology> {
264 Ok(NetworkTopology {
266 peers: vec![],
267 connections: vec![],
268 })
269 }
270
271 pub fn export_for_viz(&self) -> String {
273 let peers: Vec<_> = self
274 .gateways
275 .iter()
276 .chain(self.peers.iter())
277 .map(|p| {
278 serde_json::json!({
279 "id": p.id(),
280 "is_gateway": p.is_gateway(),
281 "ws_port": p.ws_port,
282 "network_port": p.network_port,
283 })
284 })
285 .collect();
286
287 serde_json::to_string_pretty(&serde_json::json!({
288 "peers": peers
289 }))
290 .unwrap_or_default()
291 }
292
293 pub async fn collect_diagnostics(&self) -> Result<NetworkDiagnosticsSnapshot> {
296 let mut peers = Vec::with_capacity(self.gateways.len() + self.peers.len());
297 for peer in self.gateways.iter().chain(self.peers.iter()) {
298 peers.push(self.query_peer_diagnostics(peer).await);
299 }
300 peers.sort_by(|a, b| a.peer_id.cmp(&b.peer_id));
301 Ok(NetworkDiagnosticsSnapshot {
302 collected_at: Utc::now(),
303 peers,
304 })
305 }
306
307 async fn query_peer_diagnostics(&self, peer: &TestPeer) -> PeerDiagnosticsSnapshot {
308 use tokio_tungstenite::connect_async;
309
310 let mut snapshot = PeerDiagnosticsSnapshot::new(peer);
311 let url = format!("{}?encodingProtocol=native", peer.ws_url());
312 match tokio::time::timeout(std::time::Duration::from_secs(10), connect_async(&url)).await {
313 Ok(Ok((ws_stream, _))) => {
314 let mut client = WebApi::start(ws_stream);
315 let config = NodeDiagnosticsConfig {
316 include_node_info: true,
317 include_network_info: true,
318 include_subscriptions: false,
319 contract_keys: vec![],
320 include_system_metrics: true,
321 include_detailed_peer_info: true,
322 include_subscriber_peer_ids: false,
323 };
324 if let Err(err) = client
325 .send(ClientRequest::NodeQueries(NodeQuery::NodeDiagnostics {
326 config,
327 }))
328 .await
329 {
330 snapshot.error = Some(format!("failed to send diagnostics request: {err}"));
331 return snapshot;
332 }
333 match tokio::time::timeout(std::time::Duration::from_secs(10), client.recv()).await
334 {
335 Ok(Ok(HostResponse::QueryResponse(QueryResponse::NodeDiagnostics(
336 response,
337 )))) => {
338 let node_info = response.node_info;
339 let network_info = response.network_info;
340 snapshot.peer_id = node_info
341 .as_ref()
342 .map(|info| info.peer_id.clone())
343 .unwrap_or_else(|| peer.id().to_string());
344 snapshot.is_gateway = node_info
345 .as_ref()
346 .map(|info| info.is_gateway)
347 .unwrap_or_else(|| peer.is_gateway());
348 snapshot.location =
349 node_info.as_ref().and_then(|info| info.location.clone());
350 snapshot.listening_address = node_info
351 .as_ref()
352 .and_then(|info| info.listening_address.clone());
353 if let Some(info) = network_info {
354 snapshot.active_connections = Some(info.active_connections);
355 snapshot.connected_peer_ids = info
356 .connected_peers
357 .into_iter()
358 .map(|(peer_id, _)| peer_id)
359 .collect();
360 }
361 snapshot.connected_peers_detailed = response.connected_peers_detailed;
362 snapshot.system_metrics = response.system_metrics;
363 }
364 Ok(Ok(other)) => {
365 snapshot.error =
366 Some(format!("unexpected diagnostics response: {:?}", other));
367 }
368 Ok(Err(err)) => {
369 snapshot.error = Some(format!("diagnostics channel error: {err}"));
370 }
371 Err(_) => {
372 snapshot.error = Some("timeout waiting for diagnostics response".into());
373 }
374 }
375 }
376 Ok(Err(err)) => {
377 snapshot.error = Some(format!("failed to connect websocket: {err}"));
378 }
379 Err(_) => {
380 snapshot.error = Some("timeout establishing diagnostics websocket".into());
381 }
382 }
383 snapshot
384 }
385
386 pub async fn ring_snapshot(&self) -> Result<Vec<RingPeerSnapshot>> {
388 self.collect_ring_snapshot(None).await
389 }
390
391 async fn collect_ring_snapshot(
392 &self,
393 contract_key: Option<&ContractKey>,
394 ) -> Result<Vec<RingPeerSnapshot>> {
395 let mut snapshots = Vec::with_capacity(self.gateways.len() + self.peers.len());
396 for peer in self.gateways.iter().chain(self.peers.iter()) {
397 snapshots.push(query_ring_snapshot(peer, contract_key).await?);
398 }
399 snapshots.sort_by(|a, b| a.id.cmp(&b.id));
400 Ok(snapshots)
401 }
402
403 pub async fn write_ring_visualization<P: AsRef<Path>>(&self, output_path: P) -> Result<()> {
405 self.write_ring_visualization_internal(output_path, None)
406 .await
407 }
408
409 pub async fn write_ring_visualization_for_contract<P: AsRef<Path>>(
411 &self,
412 output_path: P,
413 contract_id: &str,
414 ) -> Result<()> {
415 let contract_id_owned = contract_id.to_string();
416 let contract_key = ContractKey::from_id(contract_id_owned.clone()).map_err(|err| {
417 Error::Other(anyhow::anyhow!(
418 "invalid contract key {}: {}",
419 contract_id,
420 err
421 ))
422 })?;
423 self.write_ring_visualization_internal(
424 output_path,
425 Some((&contract_key, contract_id_owned.as_str())),
426 )
427 .await
428 }
429
430 async fn write_ring_visualization_internal<P: AsRef<Path>>(
431 &self,
432 output_path: P,
433 contract: Option<(&ContractKey, &str)>,
434 ) -> Result<()> {
435 let (snapshots, contract_viz) = if let Some((contract_key, contract_id)) = contract {
436 let snapshots = self.collect_ring_snapshot(Some(contract_key)).await?;
437 let caching_peers = snapshots
438 .iter()
439 .filter(|peer| {
440 peer.contract
441 .as_ref()
442 .map(|state| state.stores_contract)
443 .unwrap_or(false)
444 })
445 .map(|peer| peer.id.clone())
446 .collect::<Vec<_>>();
447 let contract_location = contract_location_from_key(contract_key);
448 let flow = self.collect_contract_flow(contract_id, &snapshots)?;
449 let viz = ContractVizData {
450 key: contract_id.to_string(),
451 location: contract_location,
452 caching_peers,
453 put: OperationPath {
454 edges: flow.put_edges,
455 completion_peer: flow.put_completion_peer,
456 },
457 update: OperationPath {
458 edges: flow.update_edges,
459 completion_peer: flow.update_completion_peer,
460 },
461 errors: flow.errors,
462 };
463 (snapshots, Some(viz))
464 } else {
465 (self.collect_ring_snapshot(None).await?, None)
466 };
467
468 let metrics = compute_ring_metrics(&snapshots);
469 let payload = json!({
470 "generated_at": Utc::now().to_rfc3339(),
471 "run_root": self.run_root.display().to_string(),
472 "nodes": snapshots,
473 "metrics": metrics,
474 "contract": contract_viz,
475 });
476 let data_json = serde_json::to_string(&payload).map_err(|e| Error::Other(e.into()))?;
477 let html = render_ring_template(&data_json);
478 let out_path = output_path.as_ref();
479 if let Some(parent) = out_path.parent() {
480 if !parent.exists() {
481 fs::create_dir_all(parent)?;
482 }
483 }
484 fs::write(out_path, html)?;
485 tracing::info!(path = %out_path.display(), "Wrote ring visualization");
486 Ok(())
487 }
488
489 fn collect_contract_flow(
490 &self,
491 contract_id: &str,
492 peers: &[RingPeerSnapshot],
493 ) -> Result<ContractFlowData> {
494 let mut data = ContractFlowData::default();
495 let logs = self.read_logs()?;
496 for entry in logs {
497 if !entry.message.contains(contract_id) {
498 continue;
499 }
500 if let Some(caps) = PUT_REQUEST_RE.captures(&entry.message) {
501 if &caps["key"] != contract_id {
502 continue;
503 }
504 data.put_edges.push(ContractOperationEdge {
505 from: caps["from"].to_string(),
506 to: caps["to"].to_string(),
507 timestamp: entry.timestamp.map(|ts| ts.to_rfc3339()),
508 log_level: entry.level.clone(),
509 log_source: Some(entry.peer_id.clone()),
510 message: entry.message.clone(),
511 });
512 continue;
513 }
514 if let Some(caps) = PUT_COMPLETION_RE.captures(&entry.message) {
515 if &caps["key"] != contract_id {
516 continue;
517 }
518 data.put_completion_peer = Some(caps["peer"].to_string());
519 continue;
520 }
521 if let Some(caps) = UPDATE_PROPAGATION_RE.captures(&entry.message) {
522 if &caps["contract"] != contract_id {
523 continue;
524 }
525 let from = caps["from"].to_string();
526 let ts = entry.timestamp.map(|ts| ts.to_rfc3339());
527 let level = entry.level.clone();
528 let source = Some(entry.peer_id.clone());
529 let targets = caps["targets"].trim();
530 if !targets.is_empty() {
531 for prefix in targets.split(',').filter(|s| !s.is_empty()) {
532 if let Some(resolved) = resolve_peer_id(prefix, peers) {
533 data.update_edges.push(ContractOperationEdge {
534 from: from.clone(),
535 to: resolved,
536 timestamp: ts.clone(),
537 log_level: level.clone(),
538 log_source: source.clone(),
539 message: entry.message.clone(),
540 });
541 }
542 }
543 }
544 continue;
545 }
546 if let Some(caps) = UPDATE_NO_TARGETS_RE.captures(&entry.message) {
547 if &caps["contract"] != contract_id {
548 continue;
549 }
550 data.errors.push(entry.message.clone());
551 continue;
552 }
553 if entry.message.contains("update will not propagate") {
554 data.errors.push(entry.message.clone());
555 }
556 }
557 Ok(data)
558 }
559
560 pub fn dump_logs(&self, filter: Option<&str>) {
567 match self.read_logs() {
568 Ok(mut entries) => {
569 entries.sort_by(|a, b| match (&a.timestamp, &b.timestamp) {
571 (Some(ta), Some(tb)) => ta.cmp(tb),
572 (Some(_), None) => std::cmp::Ordering::Less,
573 (None, Some(_)) => std::cmp::Ordering::Greater,
574 (None, None) => std::cmp::Ordering::Equal,
575 });
576
577 let filter_lower = filter.map(|f| f.to_lowercase());
578 let mut count = 0;
579
580 println!("--- Peer Logs {} ---",
581 filter.map(|f| format!("(filtered: '{}')", f)).unwrap_or_default());
582
583 for entry in &entries {
584 let matches = filter_lower
585 .as_ref()
586 .map(|f| entry.message.to_lowercase().contains(f))
587 .unwrap_or(true);
588
589 if matches {
590 let ts = entry.timestamp_raw.as_deref().unwrap_or("?");
591 let level = entry.level.as_deref().unwrap_or("?");
592 println!("[{}] {} [{}] {}", entry.peer_id, ts, level, entry.message);
593 count += 1;
594 }
595 }
596
597 println!("--- End Peer Logs ({} entries) ---", count);
598 }
599 Err(e) => {
600 println!("--- Failed to read logs: {} ---", e);
601 }
602 }
603 }
604
605 pub fn dump_connection_logs(&self) {
609 match self.read_logs() {
610 Ok(mut entries) => {
611 entries.sort_by(|a, b| match (&a.timestamp, &b.timestamp) {
612 (Some(ta), Some(tb)) => ta.cmp(tb),
613 (Some(_), None) => std::cmp::Ordering::Less,
614 (None, Some(_)) => std::cmp::Ordering::Greater,
615 (None, None) => std::cmp::Ordering::Equal,
616 });
617
618 let keywords = [
619 "hole", "punch", "nat", "traverse", "acceptor", "joiner",
620 "handshake", "outbound", "inbound", "connect:", "connection",
621 ];
622
623 println!("--- Connection/NAT Logs ---");
624 let mut count = 0;
625
626 for entry in &entries {
627 let msg_lower = entry.message.to_lowercase();
628 let matches = keywords.iter().any(|kw| msg_lower.contains(kw));
629
630 if matches {
631 let ts = entry.timestamp_raw.as_deref().unwrap_or("?");
632 let level = entry.level.as_deref().unwrap_or("?");
633 println!("[{}] {} [{}] {}", entry.peer_id, ts, level, entry.message);
634 count += 1;
635 }
636 }
637
638 println!("--- End Connection/NAT Logs ({} entries) ---", count);
639 }
640 Err(e) => {
641 println!("--- Failed to read logs: {} ---", e);
642 }
643 }
644 }
645
646 pub async fn dump_iptables(&self) {
650 if let Some(backend) = &self.docker_backend {
651 match backend.dump_iptables_counters().await {
652 Ok(results) => {
653 println!("--- NAT Router iptables ---");
654 for (peer_idx, output) in results.iter() {
655 println!("=== Peer {} NAT Router ===", peer_idx);
656 println!("{}", output);
657 }
658 println!("--- End NAT Router iptables ---");
659 }
660 Err(e) => {
661 println!("--- Failed to dump iptables: {} ---", e);
662 }
663 }
664 } else {
665 println!("--- No Docker NAT backend (iptables not available) ---");
666 }
667 }
668
669 pub async fn dump_conntrack(&self) {
673 if let Some(backend) = &self.docker_backend {
674 match backend.dump_conntrack_table().await {
675 Ok(results) => {
676 println!("--- NAT Router conntrack ---");
677 for (peer_idx, output) in results.iter() {
678 println!("=== Peer {} NAT Router ===", peer_idx);
679 println!("{}", output);
680 }
681 println!("--- End NAT Router conntrack ---");
682 }
683 Err(e) => {
684 println!("--- Failed to dump conntrack: {} ---", e);
685 }
686 }
687 } else {
688 println!("--- No Docker NAT backend (conntrack not available) ---");
689 }
690 }
691
692 pub async fn dump_peer_routes(&self) {
696 if let Some(backend) = &self.docker_backend {
697 match backend.dump_peer_routes().await {
698 Ok(results) => {
699 println!("--- Peer routing tables ---");
700 for (peer_idx, output) in results.iter() {
701 println!("=== Peer {} routes ===", peer_idx);
702 println!("{}", output);
703 }
704 println!("--- End peer routing tables ---");
705 }
706 Err(e) => {
707 println!("--- Failed to dump peer routes: {} ---", e);
708 }
709 }
710 } else {
711 println!("--- No Docker NAT backend (peer routes not available) ---");
712 }
713 }
714}
715
716impl TestNetwork {
717 pub(crate) fn new(
718 gateways: Vec<TestPeer>,
719 peers: Vec<TestPeer>,
720 min_connectivity: f64,
721 run_root: PathBuf,
722 ) -> Self {
723 Self {
724 gateways,
725 peers,
726 min_connectivity,
727 run_root,
728 docker_backend: None,
729 }
730 }
731
732 pub(crate) fn new_with_docker(
733 gateways: Vec<TestPeer>,
734 peers: Vec<TestPeer>,
735 min_connectivity: f64,
736 run_root: PathBuf,
737 docker_backend: Option<DockerNatBackend>,
738 ) -> Self {
739 Self {
740 gateways,
741 peers,
742 min_connectivity,
743 run_root,
744 docker_backend,
745 }
746 }
747
748 pub fn run_root(&self) -> &std::path::Path {
750 &self.run_root
751 }
752}
753
754#[derive(Debug, Clone, Serialize, Deserialize)]
756pub struct NetworkDiagnosticsSnapshot {
757 pub collected_at: chrono::DateTime<Utc>,
758 pub peers: Vec<PeerDiagnosticsSnapshot>,
759}
760
761#[derive(Debug, Clone, Serialize, Deserialize)]
763pub struct PeerDiagnosticsSnapshot {
764 pub peer_id: String,
765 pub is_gateway: bool,
766 pub ws_url: String,
767 pub location: Option<String>,
768 pub listening_address: Option<String>,
769 pub connected_peer_ids: Vec<String>,
770 pub connected_peers_detailed: Vec<ConnectedPeerInfo>,
771 pub active_connections: Option<usize>,
772 pub system_metrics: Option<SystemMetrics>,
773 pub error: Option<String>,
774}
775
776impl PeerDiagnosticsSnapshot {
777 fn new(peer: &TestPeer) -> Self {
778 Self {
779 peer_id: peer.id().to_string(),
780 is_gateway: peer.is_gateway(),
781 ws_url: peer.ws_url(),
782 location: None,
783 listening_address: None,
784 connected_peer_ids: Vec::new(),
785 connected_peers_detailed: Vec::new(),
786 active_connections: None,
787 system_metrics: None,
788 error: None,
789 }
790 }
791}
792
793#[derive(Debug, Clone, Serialize)]
795pub struct RingPeerSnapshot {
796 pub id: String,
797 pub is_gateway: bool,
798 pub ws_port: u16,
799 pub network_port: u16,
800 pub network_address: String,
801 #[serde(skip_serializing_if = "Option::is_none")]
802 pub location: Option<f64>,
803 pub connections: Vec<String>,
804 #[serde(skip_serializing_if = "Option::is_none")]
805 pub contract: Option<PeerContractStatus>,
806}
807
808pub fn ring_nodes_from_diagnostics(snapshot: &NetworkDiagnosticsSnapshot) -> Vec<RingPeerSnapshot> {
810 snapshot
811 .peers
812 .iter()
813 .map(|peer| {
814 let location = peer
815 .location
816 .as_deref()
817 .and_then(|loc| loc.parse::<f64>().ok());
818 let (network_address, network_port) =
819 parse_listening_address(peer.listening_address.as_ref(), &peer.ws_url);
820 let ws_port = parse_ws_port(&peer.ws_url);
821 let mut connections = peer.connected_peer_ids.clone();
822 connections.retain(|id| id != &peer.peer_id);
823 connections.sort();
824 connections.dedup();
825
826 RingPeerSnapshot {
827 id: peer.peer_id.clone(),
828 is_gateway: peer.is_gateway,
829 ws_port,
830 network_port,
831 network_address,
832 location,
833 connections,
834 contract: None,
835 }
836 })
837 .collect()
838}
839
840pub fn write_ring_visualization_from_diagnostics<P: AsRef<Path>, Q: AsRef<Path>>(
842 snapshot: &NetworkDiagnosticsSnapshot,
843 run_root: P,
844 output_path: Q,
845) -> Result<()> {
846 let nodes = ring_nodes_from_diagnostics(snapshot);
847 let metrics = compute_ring_metrics(&nodes);
848 let payload = json!({
849 "generated_at": snapshot.collected_at.to_rfc3339(),
850 "run_root": run_root.as_ref().display().to_string(),
851 "nodes": nodes,
852 "metrics": metrics,
853 });
854 let data_json = serde_json::to_string(&payload).map_err(|e| Error::Other(e.into()))?;
855 let html = render_ring_template(&data_json);
856 let out_path = output_path.as_ref();
857 if let Some(parent) = out_path.parent() {
858 if !parent.exists() {
859 fs::create_dir_all(parent)?;
860 }
861 }
862 fs::write(out_path, html)?;
863 tracing::info!(path = %out_path.display(), "Wrote ring visualization from diagnostics");
864 Ok(())
865}
866
867#[derive(Debug, Clone, Serialize)]
869pub struct PeerContractStatus {
870 pub stores_contract: bool,
871 pub subscribed_locally: bool,
872 pub subscriber_peer_ids: Vec<String>,
873 pub subscriber_count: usize,
874}
875
876#[derive(Debug, Clone, Serialize)]
877pub struct ContractVizData {
878 pub key: String,
879 pub location: f64,
880 pub caching_peers: Vec<String>,
881 pub put: OperationPath,
882 pub update: OperationPath,
883 pub errors: Vec<String>,
884}
885
886#[derive(Debug, Clone, Serialize)]
887pub struct OperationPath {
888 pub edges: Vec<ContractOperationEdge>,
889 #[serde(skip_serializing_if = "Option::is_none")]
890 pub completion_peer: Option<String>,
891}
892
893#[derive(Debug, Clone, Serialize)]
894pub struct ContractOperationEdge {
895 pub from: String,
896 pub to: String,
897 #[serde(skip_serializing_if = "Option::is_none")]
898 pub timestamp: Option<String>,
899 #[serde(skip_serializing_if = "Option::is_none")]
900 pub log_level: Option<String>,
901 #[serde(skip_serializing_if = "Option::is_none")]
902 pub log_source: Option<String>,
903 pub message: String,
904}
905
906#[derive(Debug, Clone, Serialize)]
908pub struct RingVizMetrics {
909 pub node_count: usize,
910 pub gateway_count: usize,
911 pub edge_count: usize,
912 pub average_degree: f64,
913 #[serde(skip_serializing_if = "Option::is_none")]
914 pub average_ring_distance: Option<f64>,
915 #[serde(skip_serializing_if = "Option::is_none")]
916 pub min_ring_distance: Option<f64>,
917 #[serde(skip_serializing_if = "Option::is_none")]
918 pub max_ring_distance: Option<f64>,
919 #[serde(skip_serializing_if = "Option::is_none")]
920 pub pct_edges_under_5pct: Option<f64>,
921 #[serde(skip_serializing_if = "Option::is_none")]
922 pub pct_edges_under_10pct: Option<f64>,
923 #[serde(skip_serializing_if = "Option::is_none")]
924 pub short_over_long_ratio: Option<f64>,
925 #[serde(skip_serializing_if = "Vec::is_empty")]
926 pub distance_histogram: Vec<RingDistanceBucket>,
927}
928
929#[derive(Debug, Clone, Serialize)]
930pub struct RingDistanceBucket {
931 pub upper_bound: f64,
932 pub count: usize,
933}
934
935#[derive(Debug, Clone, Serialize, Deserialize)]
937pub struct NetworkTopology {
938 pub peers: Vec<PeerInfo>,
939 pub connections: Vec<Connection>,
940}
941
942#[derive(Debug, Clone, Serialize, Deserialize)]
944pub struct PeerInfo {
945 pub id: String,
946 pub is_gateway: bool,
947 pub ws_port: u16,
948}
949
950#[derive(Debug, Clone, Serialize, Deserialize)]
952pub struct Connection {
953 pub from: String,
954 pub to: String,
955}
956
957#[derive(Default)]
958struct ContractFlowData {
959 put_edges: Vec<ContractOperationEdge>,
960 put_completion_peer: Option<String>,
961 update_edges: Vec<ContractOperationEdge>,
962 update_completion_peer: Option<String>,
963 errors: Vec<String>,
964}
965
966async fn query_ring_snapshot(
967 peer: &TestPeer,
968 contract_key: Option<&ContractKey>,
969) -> Result<RingPeerSnapshot> {
970 use tokio_tungstenite::connect_async;
971
972 let url = format!("{}?encodingProtocol=native", peer.ws_url());
973 let (ws_stream, _) = tokio::time::timeout(Duration::from_secs(5), connect_async(&url))
974 .await
975 .map_err(|_| Error::ConnectivityFailed(format!("Timeout connecting to {}", peer.id())))?
976 .map_err(|e| {
977 Error::ConnectivityFailed(format!("Failed to connect to {}: {}", peer.id(), e))
978 })?;
979
980 let mut client = WebApi::start(ws_stream);
981 let diag_config = if let Some(key) = contract_key {
982 NodeDiagnosticsConfig {
983 include_node_info: true,
984 include_network_info: true,
985 include_subscriptions: true,
986 contract_keys: vec![key.clone()],
987 include_system_metrics: false,
988 include_detailed_peer_info: true,
989 include_subscriber_peer_ids: true,
990 }
991 } else {
992 NodeDiagnosticsConfig::basic_status()
993 };
994
995 client
996 .send(ClientRequest::NodeQueries(NodeQuery::NodeDiagnostics {
997 config: diag_config,
998 }))
999 .await
1000 .map_err(|e| {
1001 Error::ConnectivityFailed(format!(
1002 "Failed to send diagnostics to {}: {}",
1003 peer.id(),
1004 e
1005 ))
1006 })?;
1007
1008 let response = tokio::time::timeout(Duration::from_secs(5), client.recv())
1009 .await
1010 .map_err(|_| {
1011 Error::ConnectivityFailed(format!(
1012 "Timeout waiting for diagnostics response from {}",
1013 peer.id()
1014 ))
1015 })?;
1016
1017 let diag = match response {
1018 Ok(HostResponse::QueryResponse(QueryResponse::NodeDiagnostics(diag))) => diag,
1019 Ok(other) => {
1020 client.disconnect("ring snapshot error").await;
1021 return Err(Error::ConnectivityFailed(format!(
1022 "Unexpected diagnostics response from {}: {:?}",
1023 peer.id(),
1024 other
1025 )));
1026 }
1027 Err(e) => {
1028 client.disconnect("ring snapshot error").await;
1029 return Err(Error::ConnectivityFailed(format!(
1030 "Diagnostics query failed for {}: {}",
1031 peer.id(),
1032 e
1033 )));
1034 }
1035 };
1036
1037 let node_info = diag.node_info.ok_or_else(|| {
1038 Error::ConnectivityFailed(format!("{} did not return node_info", peer.id()))
1039 })?;
1040
1041 let location = node_info
1042 .location
1043 .as_deref()
1044 .and_then(|value| value.parse::<f64>().ok());
1045
1046 let mut connections: Vec<String> = diag
1047 .connected_peers_detailed
1048 .into_iter()
1049 .map(|info| info.peer_id)
1050 .collect();
1051
1052 if connections.is_empty() {
1053 if let Some(network_info) = diag.network_info {
1054 connections = network_info
1055 .connected_peers
1056 .into_iter()
1057 .map(|(peer_id, _)| peer_id)
1058 .collect();
1059 }
1060 }
1061
1062 connections.retain(|conn| conn != &node_info.peer_id);
1063 connections.sort();
1064 connections.dedup();
1065
1066 let contract_status = contract_key.and_then(|key| {
1067 let (stores_contract, subscriber_count, subscriber_peer_ids) =
1068 if let Some(state) = diag.contract_states.get(key) {
1069 (
1070 true,
1071 state.subscribers as usize,
1072 state.subscriber_peer_ids.clone(),
1073 )
1074 } else {
1075 (false, 0, Vec::new())
1076 };
1077 let subscribed_locally = diag
1078 .subscriptions
1079 .iter()
1080 .any(|sub| &sub.contract_key == key);
1081 if stores_contract || subscribed_locally {
1082 Some(PeerContractStatus {
1083 stores_contract,
1084 subscribed_locally,
1085 subscriber_peer_ids,
1086 subscriber_count,
1087 })
1088 } else {
1089 None
1090 }
1091 });
1092
1093 client.disconnect("ring snapshot complete").await;
1094
1095 Ok(RingPeerSnapshot {
1096 id: node_info.peer_id,
1097 is_gateway: node_info.is_gateway,
1098 ws_port: peer.ws_port,
1099 network_port: peer.network_port,
1100 network_address: peer.network_address.clone(),
1101 location,
1102 connections,
1103 contract: contract_status,
1104 })
1105}
1106
1107fn compute_ring_metrics(nodes: &[RingPeerSnapshot]) -> RingVizMetrics {
1108 let node_count = nodes.len();
1109 let gateway_count = nodes.iter().filter(|peer| peer.is_gateway).count();
1110 let mut total_degree = 0usize;
1111 let mut unique_edges: HashSet<(String, String)> = HashSet::new();
1112
1113 for node in nodes {
1114 total_degree += node.connections.len();
1115 for neighbor in &node.connections {
1116 if neighbor == &node.id {
1117 continue;
1118 }
1119 let edge = if node.id < *neighbor {
1120 (node.id.clone(), neighbor.clone())
1121 } else {
1122 (neighbor.clone(), node.id.clone())
1123 };
1124 unique_edges.insert(edge);
1125 }
1126 }
1127
1128 let average_degree = if node_count == 0 {
1129 0.0
1130 } else {
1131 total_degree as f64 / node_count as f64
1132 };
1133
1134 let mut location_lookup = HashMap::new();
1135 for node in nodes {
1136 if let Some(loc) = node.location {
1137 location_lookup.insert(node.id.clone(), loc);
1138 }
1139 }
1140
1141 let mut distances = Vec::new();
1142 for (a, b) in &unique_edges {
1143 if let (Some(loc_a), Some(loc_b)) = (location_lookup.get(a), location_lookup.get(b)) {
1144 let mut distance = (loc_a - loc_b).abs();
1145 if distance > 0.5 {
1146 distance = 1.0 - distance;
1147 }
1148 distances.push(distance);
1149 }
1150 }
1151
1152 let average_ring_distance = if distances.is_empty() {
1153 None
1154 } else {
1155 Some(distances.iter().sum::<f64>() / distances.len() as f64)
1156 };
1157 let min_ring_distance = distances.iter().cloned().reduce(f64::min);
1158 let max_ring_distance = distances.iter().cloned().reduce(f64::max);
1159 let pct_edges_under_5pct = calculate_percentage(&distances, 0.05);
1160 let pct_edges_under_10pct = calculate_percentage(&distances, 0.10);
1161
1162 let short_edges = distances.iter().filter(|d| **d <= 0.10).count();
1163 let long_edges = distances
1164 .iter()
1165 .filter(|d| **d > 0.10 && **d <= 0.50)
1166 .count();
1167 let short_over_long_ratio = if short_edges == 0 || long_edges == 0 {
1168 None
1169 } else {
1170 Some(short_edges as f64 / long_edges as f64)
1171 };
1172
1173 let mut distance_histogram = Vec::new();
1174 let bucket_bounds: Vec<f64> = (1..=5).map(|i| i as f64 * 0.10).collect(); let mut cumulative = 0usize;
1176 for bound in bucket_bounds {
1177 let up_to_bound = distances.iter().filter(|d| **d <= bound).count();
1178 let count = up_to_bound.saturating_sub(cumulative);
1179 cumulative = up_to_bound;
1180 distance_histogram.push(RingDistanceBucket {
1181 upper_bound: bound,
1182 count,
1183 });
1184 }
1185
1186 RingVizMetrics {
1187 node_count,
1188 gateway_count,
1189 edge_count: unique_edges.len(),
1190 average_degree,
1191 average_ring_distance,
1192 min_ring_distance,
1193 max_ring_distance,
1194 pct_edges_under_5pct,
1195 pct_edges_under_10pct,
1196 short_over_long_ratio,
1197 distance_histogram,
1198 }
1199}
1200
1201fn calculate_percentage(distances: &[f64], threshold: f64) -> Option<f64> {
1202 if distances.is_empty() {
1203 return None;
1204 }
1205 let matching = distances.iter().filter(|value| **value < threshold).count();
1206 Some((matching as f64 / distances.len() as f64) * 100.0)
1207}
1208
1209fn parse_ws_port(ws_url: &str) -> u16 {
1210 ws_url
1211 .split("://")
1212 .nth(1)
1213 .and_then(|rest| rest.split('/').next())
1214 .and_then(|host_port| host_port.split(':').nth(1))
1215 .and_then(|port| port.parse().ok())
1216 .unwrap_or(0)
1217}
1218
1219fn parse_listening_address(addr: Option<&String>, ws_url: &str) -> (String, u16) {
1220 if let Some(addr) = addr {
1221 let mut parts = addr.split(':');
1222 let host = parts.next().unwrap_or("").to_string();
1223 let port = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
1224 (host, port)
1225 } else {
1226 let host = ws_url
1227 .split("://")
1228 .nth(1)
1229 .and_then(|rest| rest.split('/').next())
1230 .and_then(|host_port| host_port.split(':').next())
1231 .unwrap_or_default()
1232 .to_string();
1233 (host, 0)
1234 }
1235}
1236
1237fn resolve_peer_id(prefix: &str, peers: &[RingPeerSnapshot]) -> Option<String> {
1238 let needle = prefix.trim().trim_matches(|c| c == '"' || c == '\'');
1239 peers
1240 .iter()
1241 .find(|peer| peer.id.starts_with(needle))
1242 .map(|peer| peer.id.clone())
1243}
1244
1245fn contract_location_from_key(key: &ContractKey) -> f64 {
1246 let mut value = 0.0;
1247 let mut divisor = 256.0;
1248 for byte in key.as_bytes() {
1249 value += f64::from(*byte) / divisor;
1250 divisor *= 256.0;
1251 }
1252 value.fract()
1253}
1254
1255static PUT_REQUEST_RE: LazyLock<Regex> = LazyLock::new(|| {
1256 Regex::new(r"Requesting put for contract (?P<key>\S+) from (?P<from>\S+) to (?P<to>\S+)")
1257 .expect("valid regex")
1258});
1259static PUT_COMPLETION_RE: LazyLock<Regex> = LazyLock::new(|| {
1260 Regex::new(r"Peer completed contract value put,.*key: (?P<key>\S+),.*this_peer: (?P<peer>\S+)")
1261 .expect("valid regex")
1262});
1263static UPDATE_PROPAGATION_RE: LazyLock<Regex> = LazyLock::new(|| {
1264 Regex::new(
1265 r"UPDATE_PROPAGATION: contract=(?P<contract>\S+) from=(?P<from>\S+) targets=(?P<targets>[^ ]*)\s+count=",
1266 )
1267 .expect("valid regex")
1268});
1269static UPDATE_NO_TARGETS_RE: LazyLock<Regex> = LazyLock::new(|| {
1270 Regex::new(r"UPDATE_PROPAGATION: contract=(?P<contract>\S+) from=(?P<from>\S+) NO_TARGETS")
1271 .expect("valid regex")
1272});
1273
1274const HTML_TEMPLATE: &str = r###"<!DOCTYPE html>
1275<html lang="en">
1276<head>
1277 <meta charset="UTF-8" />
1278 <title>Freenet Ring Topology</title>
1279 <style>
1280 :root {
1281 color-scheme: dark;
1282 font-family: "Inter", "Helvetica Neue", Arial, sans-serif;
1283 }
1284 body {
1285 background: #020617;
1286 color: #e2e8f0;
1287 margin: 0;
1288 padding: 32px;
1289 display: flex;
1290 justify-content: center;
1291 min-height: 100vh;
1292 }
1293 #container {
1294 max-width: 1000px;
1295 width: 100%;
1296 background: #0f172a;
1297 border-radius: 18px;
1298 padding: 28px 32px 40px;
1299 box-shadow: 0 40px 120px rgba(2, 6, 23, 0.85);
1300 }
1301 h1 {
1302 margin: 0 0 8px;
1303 font-size: 26px;
1304 letter-spacing: 0.4px;
1305 color: #f8fafc;
1306 }
1307 #meta {
1308 margin: 0 0 20px;
1309 color: #94a3b8;
1310 font-size: 14px;
1311 }
1312 canvas {
1313 width: 100%;
1314 max-width: 900px;
1315 height: auto;
1316 background: radial-gradient(circle, #020617 0%, #0f172a 70%, #020617 100%);
1317 border-radius: 16px;
1318 border: 1px solid rgba(148, 163, 184, 0.1);
1319 display: block;
1320 margin: 0 auto 24px;
1321 }
1322 #metrics {
1323 display: grid;
1324 grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
1325 gap: 12px;
1326 margin-bottom: 20px;
1327 font-size: 14px;
1328 }
1329 #metrics div {
1330 background: #1e293b;
1331 padding: 12px 14px;
1332 border-radius: 12px;
1333 border: 1px solid rgba(148, 163, 184, 0.15);
1334 }
1335 .legend {
1336 display: flex;
1337 flex-wrap: wrap;
1338 gap: 18px;
1339 margin-bottom: 24px;
1340 font-size: 14px;
1341 color: #cbd5f5;
1342 }
1343 .legend span {
1344 display: flex;
1345 align-items: center;
1346 gap: 6px;
1347 }
1348 .legend .line {
1349 width: 30px;
1350 height: 6px;
1351 border-radius: 3px;
1352 display: inline-block;
1353 }
1354 .legend .put-line {
1355 background: #22c55e;
1356 }
1357 .legend .update-line {
1358 background: #f59e0b;
1359 }
1360 .legend .contract-dot {
1361 background: #a855f7;
1362 box-shadow: 0 0 6px rgba(168, 85, 247, 0.7);
1363 }
1364 .legend .cached-dot {
1365 background: #38bdf8;
1366 }
1367 .legend .peer-dot {
1368 background: #64748b;
1369 }
1370 .legend .gateway-dot {
1371 background: #f97316;
1372 }
1373 .dot {
1374 width: 14px;
1375 height: 14px;
1376 border-radius: 50%;
1377 display: inline-block;
1378 }
1379 #contract-info {
1380 background: rgba(15, 23, 42, 0.7);
1381 border-radius: 12px;
1382 border: 1px solid rgba(148, 163, 184, 0.15);
1383 padding: 14px;
1384 margin-bottom: 20px;
1385 font-size: 13px;
1386 color: #cbd5f5;
1387 }
1388 #contract-info h2 {
1389 margin: 0 0 8px;
1390 font-size: 16px;
1391 color: #f8fafc;
1392 }
1393 #contract-info .op-block {
1394 margin-top: 10px;
1395 }
1396 #contract-info ol {
1397 padding-left: 20px;
1398 margin: 6px 0;
1399 }
1400 #contract-info .errors {
1401 margin-top: 10px;
1402 color: #f97316;
1403 }
1404 #peer-list {
1405 border-top: 1px solid rgba(148, 163, 184, 0.15);
1406 padding-top: 18px;
1407 display: grid;
1408 grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
1409 gap: 14px;
1410 font-size: 13px;
1411 }
1412 .peer {
1413 background: rgba(15, 23, 42, 0.75);
1414 padding: 12px 14px;
1415 border-radius: 12px;
1416 border: 1px solid rgba(148, 163, 184, 0.2);
1417 }
1418 .peer strong {
1419 display: block;
1420 font-size: 13px;
1421 color: #f1f5f9;
1422 margin-bottom: 6px;
1423 word-break: break-all;
1424 }
1425 .peer span {
1426 display: block;
1427 color: #94a3b8;
1428 }
1429 .chart-card {
1430 background: rgba(15, 23, 42, 0.75);
1431 padding: 12px 14px;
1432 border-radius: 12px;
1433 border: 1px solid rgba(148, 163, 184, 0.2);
1434 margin-top: 12px;
1435 }
1436 .chart-card h3 {
1437 margin: 0 0 8px;
1438 font-size: 15px;
1439 color: #e2e8f0;
1440 }
1441 </style>
1442 <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
1443</head>
1444<body>
1445 <div id="container">
1446 <h1>Freenet Ring Topology</h1>
1447 <p id="meta"></p>
1448 <canvas id="ring" width="900" height="900"></canvas>
1449 <div id="metrics"></div>
1450 <div class="chart-card" style="height: 280px;">
1451 <h3>Ring distance distribution</h3>
1452 <canvas id="histogram-chart" style="height: 220px;"></canvas>
1453 </div>
1454 <div class="legend">
1455 <span><span class="dot peer-dot"></span>Peer</span>
1456 <span><span class="dot gateway-dot"></span>Gateway</span>
1457 <span><span class="dot cached-dot"></span>Cached peer</span>
1458 <span><span class="dot contract-dot"></span>Contract</span>
1459 <span><span class="line put-line"></span>PUT path</span>
1460 <span><span class="line update-line"></span>UPDATE path</span>
1461 <span><span class="dot" style="background:#475569"></span>Connection</span>
1462 </div>
1463 <div id="contract-info"></div>
1464 <div id="peer-list"></div>
1465 </div>
1466 <script>
1467 const vizData = __DATA__;
1468 const contractData = vizData.contract ?? null;
1469 const metaEl = document.getElementById("meta");
1470 metaEl.textContent = vizData.nodes.length
1471 ? `Captured ${vizData.nodes.length} nodes · ${vizData.metrics.edge_count} edges · Generated ${vizData.generated_at} · Run root: ${vizData.run_root}`
1472 : "No peers reported diagnostics data.";
1473
1474 const metricsEl = document.getElementById("metrics");
1475 const fmtNumber = (value, digits = 2) => (typeof value === "number" ? value.toFixed(digits) : "n/a");
1476 const fmtPercent = (value, digits = 1) => (typeof value === "number" ? `${value.toFixed(digits)}%` : "n/a");
1477
1478 metricsEl.innerHTML = `
1479 <div><strong>Total nodes</strong><br/>${vizData.metrics.node_count} (gateways: ${vizData.metrics.gateway_count})</div>
1480 <div><strong>Edges</strong><br/>${vizData.metrics.edge_count}</div>
1481 <div><strong>Average degree</strong><br/>${fmtNumber(vizData.metrics.average_degree)}</div>
1482 <div><strong>Average ring distance</strong><br/>${fmtNumber(vizData.metrics.average_ring_distance, 3)}</div>
1483 <div><strong>Min / Max ring distance</strong><br/>${fmtNumber(vizData.metrics.min_ring_distance, 3)} / ${fmtNumber(vizData.metrics.max_ring_distance, 3)}</div>
1484 <div><strong>Edges <5% / <10%</strong><br/>${fmtPercent(vizData.metrics.pct_edges_under_5pct)} / ${fmtPercent(vizData.metrics.pct_edges_under_10pct)}</div>
1485 <div><strong>Short/long edge ratio (≤0.1 / 0.1–0.5)</strong><br/>${fmtNumber(vizData.metrics.short_over_long_ratio, 2)}</div>
1486 `;
1487
1488 const histogram = vizData.metrics.distance_histogram ?? [];
1489 const labels = histogram.map((b, idx) => {
1490 const lower = idx === 0 ? 0 : histogram[idx - 1].upper_bound;
1491 return `${lower.toFixed(1)}–${b.upper_bound.toFixed(1)}`;
1492 });
1493 const counts = histogram.map((b) => b.count);
1494
1495 if (histogram.length && window.Chart) {
1496 const ctx = document.getElementById("histogram-chart").getContext("2d");
1497 new Chart(ctx, {
1498 data: {
1499 labels,
1500 datasets: [
1501 {
1502 type: "bar",
1503 label: "Edges per bucket",
1504 data: counts,
1505 backgroundColor: "rgba(56, 189, 248, 0.75)",
1506 borderColor: "#38bdf8",
1507 borderWidth: 1,
1508 },
1509 ],
1510 },
1511 options: {
1512 responsive: true,
1513 maintainAspectRatio: false,
1514 animation: false,
1515 interaction: { mode: "index", intersect: false },
1516 plugins: {
1517 legend: { position: "bottom" },
1518 tooltip: {
1519 callbacks: {
1520 label: (ctx) => {
1521 const label = ctx.dataset.label || "";
1522 const value = ctx.parsed.y;
1523 return ctx.dataset.type === "line"
1524 ? `${label}: ${value.toFixed(1)}%`
1525 : `${label}: ${value}`;
1526 },
1527 },
1528 },
1529 },
1530 scales: {
1531 x: { title: { display: true, text: "Ring distance (fraction of circumference)" } },
1532 y: {
1533 beginAtZero: true,
1534 title: { display: true, text: "Edge count" },
1535 grid: { color: "rgba(148,163,184,0.15)" },
1536 },
1537 },
1538 },
1539 });
1540 }
1541
1542 const peersEl = document.getElementById("peer-list");
1543 peersEl.innerHTML = vizData.nodes
1544 .map((node) => {
1545 const role = node.is_gateway ? "Gateway" : "Peer";
1546 const loc = typeof node.location === "number" ? node.location.toFixed(6) : "unknown";
1547 return `<div class="peer">
1548 <strong>${node.id}</strong>
1549 <span>${role}</span>
1550 <span>Location: ${loc}</span>
1551 <span>Degree: ${node.connections.length}</span>
1552 </div>`;
1553 })
1554 .join("");
1555
1556 const contractInfoEl = document.getElementById("contract-info");
1557 const canvas = document.getElementById("ring");
1558 const ctx = canvas.getContext("2d");
1559 const width = canvas.width;
1560 const height = canvas.height;
1561 const center = { x: width / 2, y: height / 2 };
1562 const radius = Math.min(width, height) * 0.37;
1563 const cachingPeers = new Set(contractData?.caching_peers ?? []);
1564 const putEdges = contractData?.put?.edges ?? [];
1565 const updateEdges = contractData?.update?.edges ?? [];
1566 const contractLocation = typeof (contractData?.location) === "number" ? contractData.location : null;
1567
1568 function shortId(id) {
1569 return id.slice(-6);
1570 }
1571
1572 function angleFromLocation(location) {
1573 return (location % 1) * Math.PI * 2;
1574 }
1575
1576 function polarToCartesian(angle, r = radius) {
1577 const theta = angle - Math.PI / 2;
1578 return {
1579 x: center.x + r * Math.cos(theta),
1580 y: center.y + r * Math.sin(theta),
1581 };
1582 }
1583
1584 const nodes = vizData.nodes.map((node, idx) => {
1585 const angle = typeof node.location === "number"
1586 ? angleFromLocation(node.location)
1587 : (idx / vizData.nodes.length) * Math.PI * 2;
1588 const coords = polarToCartesian(angle);
1589 return {
1590 ...node,
1591 angle,
1592 x: coords.x,
1593 y: coords.y,
1594 };
1595 });
1596 const nodeMap = new Map(nodes.map((node) => [node.id, node]));
1597
1598 function drawRingBase() {
1599 ctx.save();
1600 ctx.strokeStyle = "#475569";
1601 ctx.lineWidth = 2;
1602 ctx.beginPath();
1603 ctx.arc(center.x, center.y, radius, 0, Math.PI * 2);
1604 ctx.stroke();
1605 ctx.setLineDash([4, 8]);
1606 ctx.strokeStyle = "rgba(148,163,184,0.3)";
1607 [0, 0.25, 0.5, 0.75].forEach((loc) => {
1608 const angle = angleFromLocation(loc);
1609 const inner = polarToCartesian(angle, radius * 0.9);
1610 const outer = polarToCartesian(angle, radius * 1.02);
1611 ctx.beginPath();
1612 ctx.moveTo(inner.x, inner.y);
1613 ctx.lineTo(outer.x, outer.y);
1614 ctx.stroke();
1615 ctx.fillStyle = "#94a3b8";
1616 ctx.font = "11px 'Fira Code', monospace";
1617 ctx.textAlign = "center";
1618 ctx.fillText(loc.toFixed(2), outer.x, outer.y - 6);
1619 });
1620 ctx.restore();
1621 }
1622
1623 function drawConnections() {
1624 ctx.save();
1625 ctx.strokeStyle = "rgba(148, 163, 184, 0.35)";
1626 ctx.lineWidth = 1.2;
1627 const drawn = new Set();
1628 nodes.forEach((node) => {
1629 node.connections.forEach((neighborId) => {
1630 const neighbor = nodeMap.get(neighborId);
1631 if (!neighbor) return;
1632 const key = node.id < neighborId ? `${node.id}|${neighborId}` : `${neighborId}|${node.id}`;
1633 if (drawn.has(key)) return;
1634 drawn.add(key);
1635 ctx.beginPath();
1636 ctx.moveTo(node.x, node.y);
1637 ctx.lineTo(neighbor.x, neighbor.y);
1638 ctx.stroke();
1639 });
1640 });
1641 ctx.restore();
1642 }
1643
1644 function drawContractMarker() {
1645 if (typeof contractLocation !== "number") return;
1646 const pos = polarToCartesian(angleFromLocation(contractLocation));
1647 ctx.save();
1648 ctx.fillStyle = "#a855f7";
1649 ctx.beginPath();
1650 ctx.arc(pos.x, pos.y, 9, 0, Math.PI * 2);
1651 ctx.fill();
1652 ctx.lineWidth = 2;
1653 ctx.strokeStyle = "#f3e8ff";
1654 ctx.stroke();
1655 ctx.fillStyle = "#f3e8ff";
1656 ctx.font = "10px 'Fira Code', monospace";
1657 ctx.textAlign = "center";
1658 ctx.fillText("contract", pos.x, pos.y - 16);
1659 ctx.restore();
1660 }
1661
1662 function drawPeers() {
1663 nodes.forEach((node) => {
1664 const baseColor = node.is_gateway ? "#f97316" : "#64748b";
1665 const fill = cachingPeers.has(node.id) ? "#38bdf8" : baseColor;
1666 ctx.save();
1667 ctx.beginPath();
1668 ctx.fillStyle = fill;
1669 ctx.arc(node.x, node.y, 6.5, 0, Math.PI * 2);
1670 ctx.fill();
1671 ctx.lineWidth = 1.6;
1672 ctx.strokeStyle = "#0f172a";
1673 ctx.stroke();
1674 ctx.fillStyle = "#f8fafc";
1675 ctx.font = "12px 'Fira Code', monospace";
1676 ctx.textAlign = "center";
1677 ctx.fillText(shortId(node.id), node.x, node.y - 14);
1678 const locText = typeof node.location === "number" ? node.location.toFixed(3) : "n/a";
1679 ctx.fillStyle = "#94a3b8";
1680 ctx.font = "10px 'Fira Code', monospace";
1681 ctx.fillText(locText, node.x, node.y + 20);
1682 ctx.restore();
1683 });
1684 }
1685
1686 function drawOperationEdges(edges, color, dashed = false) {
1687 if (!edges.length) return;
1688 ctx.save();
1689 ctx.strokeStyle = color;
1690 ctx.lineWidth = 3;
1691 if (dashed) ctx.setLineDash([10, 8]);
1692 edges.forEach((edge, idx) => {
1693 const from = nodeMap.get(edge.from);
1694 const to = nodeMap.get(edge.to);
1695 if (!from || !to) return;
1696 ctx.beginPath();
1697 ctx.moveTo(from.x, from.y);
1698 ctx.lineTo(to.x, to.y);
1699 ctx.stroke();
1700 drawArrowhead(from, to, color);
1701 const midX = (from.x + to.x) / 2;
1702 const midY = (from.y + to.y) / 2;
1703 ctx.fillStyle = color;
1704 ctx.font = "11px 'Fira Code', monospace";
1705 ctx.fillText(`#${idx + 1}`, midX, midY - 4);
1706 });
1707 ctx.restore();
1708 }
1709
1710 function drawArrowhead(from, to, color) {
1711 const angle = Math.atan2(to.y - from.y, to.x - from.x);
1712 const length = 12;
1713 const spread = Math.PI / 6;
1714 ctx.save();
1715 ctx.fillStyle = color;
1716 ctx.beginPath();
1717 ctx.moveTo(to.x, to.y);
1718 ctx.lineTo(
1719 to.x - length * Math.cos(angle - spread),
1720 to.y - length * Math.sin(angle - spread)
1721 );
1722 ctx.lineTo(
1723 to.x - length * Math.cos(angle + spread),
1724 to.y - length * Math.sin(angle + spread)
1725 );
1726 ctx.closePath();
1727 ctx.fill();
1728 ctx.restore();
1729 }
1730
1731 function renderOperationList(title, edges) {
1732 if (!edges.length) {
1733 return `<div class="op-block"><strong>${title}:</strong> none recorded</div>`;
1734 }
1735 const items = edges
1736 .map((edge, idx) => {
1737 const ts = edge.timestamp
1738 ? new Date(edge.timestamp).toLocaleTimeString()
1739 : "no-ts";
1740 return `<li>#${idx + 1}: ${shortId(edge.from)} → ${shortId(edge.to)} (${ts})</li>`;
1741 })
1742 .join("");
1743 return `<div class="op-block"><strong>${title}:</strong><ol>${items}</ol></div>`;
1744 }
1745
1746 function renderContractInfo(data) {
1747 if (!data) {
1748 contractInfoEl.textContent = "No contract-specific data collected for this snapshot.";
1749 return;
1750 }
1751 const errors = data.errors?.length
1752 ? `<div class="errors"><strong>Notable events:</strong><ul>${data.errors
1753 .map((msg) => `<li>${msg}</li>`)
1754 .join("")}</ul></div>`
1755 : "";
1756 const putSummary = renderOperationList("PUT path", data.put.edges);
1757 const updateSummary = renderOperationList("UPDATE path", data.update.edges);
1758 contractInfoEl.innerHTML = `
1759 <h2>Contract ${data.key}</h2>
1760 <div><strong>Cached peers:</strong> ${data.caching_peers.length}</div>
1761 <div><strong>PUT completion:</strong> ${
1762 data.put.completion_peer ?? "pending"
1763 }</div>
1764 <div><strong>UPDATE completion:</strong> ${
1765 data.update.completion_peer ?? "pending"
1766 }</div>
1767 ${putSummary}
1768 ${updateSummary}
1769 ${errors}
1770 `;
1771 }
1772
1773 renderContractInfo(contractData);
1774
1775 if (nodes.length) {
1776 drawRingBase();
1777 drawConnections();
1778 drawContractMarker();
1779 drawOperationEdges(putEdges, "#22c55e", false);
1780 drawOperationEdges(updateEdges, "#f59e0b", true);
1781 drawPeers();
1782 } else {
1783 ctx.fillStyle = "#94a3b8";
1784 ctx.font = "16px Inter, sans-serif";
1785 ctx.fillText("No diagnostics available to render ring.", center.x - 140, center.y);
1786 }
1787 </script>
1788</body>
1789</html>
1790"###;
1791
1792fn render_ring_template(data_json: &str) -> String {
1793 HTML_TEMPLATE.replace("__DATA__", data_json)
1794}
1795
1796#[cfg(test)]
1797mod tests {
1798 use super::{compute_ring_metrics, RingPeerSnapshot};
1799
1800 #[test]
1801 fn ring_metrics_basic() {
1802 let nodes = vec![
1803 RingPeerSnapshot {
1804 id: "a".into(),
1805 is_gateway: false,
1806 ws_port: 0,
1807 network_port: 0,
1808 network_address: "127.1.0.1".into(),
1809 location: Some(0.1),
1810 connections: vec!["b".into(), "c".into()],
1811 contract: None,
1812 },
1813 RingPeerSnapshot {
1814 id: "b".into(),
1815 is_gateway: false,
1816 ws_port: 0,
1817 network_port: 0,
1818 network_address: "127.2.0.1".into(),
1819 location: Some(0.2),
1820 connections: vec!["a".into()],
1821 contract: None,
1822 },
1823 RingPeerSnapshot {
1824 id: "c".into(),
1825 is_gateway: true,
1826 ws_port: 0,
1827 network_port: 0,
1828 network_address: "127.3.0.1".into(),
1829 location: Some(0.8),
1830 connections: vec!["a".into()],
1831 contract: None,
1832 },
1833 ];
1834
1835 let metrics = compute_ring_metrics(&nodes);
1836 assert_eq!(metrics.node_count, 3);
1837 assert_eq!(metrics.gateway_count, 1);
1838 assert_eq!(metrics.edge_count, 2);
1839 assert!((metrics.average_degree - (4.0 / 3.0)).abs() < f64::EPSILON);
1840 assert!(metrics.average_ring_distance.is_some());
1841 }
1842}