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