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!("--- Peer Logs {} ---",
591 filter.map(|f| format!("(filtered: '{}')", f)).unwrap_or_default());
592
593 for entry in &entries {
594 let matches = filter_lower
595 .as_ref()
596 .map(|f| entry.message.to_lowercase().contains(f))
597 .unwrap_or(true);
598
599 if matches {
600 let ts = entry.timestamp_raw.as_deref().unwrap_or("?");
601 let level = entry.level.as_deref().unwrap_or("?");
602 println!("[{}] {} [{}] {}", entry.peer_id, ts, level, entry.message);
603 count += 1;
604 }
605 }
606
607 println!("--- End Peer Logs ({} entries) ---", count);
608 }
609 Err(e) => {
610 println!("--- Failed to read logs: {} ---", e);
611 }
612 }
613 }
614
615 pub fn dump_connection_logs(&self) {
619 match self.read_logs() {
620 Ok(mut entries) => {
621 entries.sort_by(|a, b| match (&a.timestamp, &b.timestamp) {
622 (Some(ta), Some(tb)) => ta.cmp(tb),
623 (Some(_), None) => std::cmp::Ordering::Less,
624 (None, Some(_)) => std::cmp::Ordering::Greater,
625 (None, None) => std::cmp::Ordering::Equal,
626 });
627
628 let keywords = [
629 "hole", "punch", "nat", "traverse", "acceptor", "joiner",
630 "handshake", "outbound", "inbound", "connect:", "connection",
631 ];
632
633 println!("--- Connection/NAT Logs ---");
634 let mut count = 0;
635
636 for entry in &entries {
637 let msg_lower = entry.message.to_lowercase();
638 let matches = keywords.iter().any(|kw| msg_lower.contains(kw));
639
640 if matches {
641 let ts = entry.timestamp_raw.as_deref().unwrap_or("?");
642 let level = entry.level.as_deref().unwrap_or("?");
643 println!("[{}] {} [{}] {}", entry.peer_id, ts, level, entry.message);
644 count += 1;
645 }
646 }
647
648 println!("--- End Connection/NAT Logs ({} entries) ---", count);
649 }
650 Err(e) => {
651 println!("--- Failed to read logs: {} ---", e);
652 }
653 }
654 }
655
656 pub async fn dump_iptables(&self) {
660 if let Some(backend) = &self.docker_backend {
661 match backend.dump_iptables_counters().await {
662 Ok(results) => {
663 println!("--- NAT Router iptables ---");
664 for (peer_idx, output) in results.iter() {
665 println!("=== Peer {} NAT Router ===", peer_idx);
666 println!("{}", output);
667 }
668 println!("--- End NAT Router iptables ---");
669 }
670 Err(e) => {
671 println!("--- Failed to dump iptables: {} ---", e);
672 }
673 }
674 } else {
675 println!("--- No Docker NAT backend (iptables not available) ---");
676 }
677 }
678
679 pub async fn dump_conntrack(&self) {
683 if let Some(backend) = &self.docker_backend {
684 match backend.dump_conntrack_table().await {
685 Ok(results) => {
686 println!("--- NAT Router conntrack ---");
687 for (peer_idx, output) in results.iter() {
688 println!("=== Peer {} NAT Router ===", peer_idx);
689 println!("{}", output);
690 }
691 println!("--- End NAT Router conntrack ---");
692 }
693 Err(e) => {
694 println!("--- Failed to dump conntrack: {} ---", e);
695 }
696 }
697 } else {
698 println!("--- No Docker NAT backend (conntrack not available) ---");
699 }
700 }
701
702 pub async fn dump_peer_routes(&self) {
706 if let Some(backend) = &self.docker_backend {
707 match backend.dump_peer_routes().await {
708 Ok(results) => {
709 println!("--- Peer routing tables ---");
710 for (peer_idx, output) in results.iter() {
711 println!("=== Peer {} routes ===", peer_idx);
712 println!("{}", output);
713 }
714 println!("--- End peer routing tables ---");
715 }
716 Err(e) => {
717 println!("--- Failed to dump peer routes: {} ---", e);
718 }
719 }
720 } else {
721 println!("--- No Docker NAT backend (peer routes not available) ---");
722 }
723 }
724}
725
726impl TestNetwork {
727 pub(crate) fn new(
728 gateways: Vec<TestPeer>,
729 peers: Vec<TestPeer>,
730 min_connectivity: f64,
731 run_root: PathBuf,
732 ) -> Self {
733 Self {
734 gateways,
735 peers,
736 min_connectivity,
737 run_root,
738 docker_backend: None,
739 }
740 }
741
742 pub(crate) fn new_with_docker(
743 gateways: Vec<TestPeer>,
744 peers: Vec<TestPeer>,
745 min_connectivity: f64,
746 run_root: PathBuf,
747 docker_backend: Option<DockerNatBackend>,
748 ) -> Self {
749 Self {
750 gateways,
751 peers,
752 min_connectivity,
753 run_root,
754 docker_backend,
755 }
756 }
757
758 pub fn run_root(&self) -> &std::path::Path {
760 &self.run_root
761 }
762}
763
764#[derive(Debug, Clone, Serialize, Deserialize)]
766pub struct NetworkDiagnosticsSnapshot {
767 pub collected_at: chrono::DateTime<Utc>,
768 pub peers: Vec<PeerDiagnosticsSnapshot>,
769}
770
771#[derive(Debug, Clone, Serialize, Deserialize)]
773pub struct PeerDiagnosticsSnapshot {
774 pub peer_id: String,
775 pub is_gateway: bool,
776 pub ws_url: String,
777 pub location: Option<String>,
778 pub listening_address: Option<String>,
779 pub connected_peer_ids: Vec<String>,
780 pub connected_peers_detailed: Vec<ConnectedPeerInfo>,
781 pub active_connections: Option<usize>,
782 pub system_metrics: Option<SystemMetrics>,
783 pub error: Option<String>,
784}
785
786impl PeerDiagnosticsSnapshot {
787 fn new(peer: &TestPeer) -> Self {
788 Self {
789 peer_id: peer.id().to_string(),
790 is_gateway: peer.is_gateway(),
791 ws_url: peer.ws_url(),
792 location: None,
793 listening_address: None,
794 connected_peer_ids: Vec::new(),
795 connected_peers_detailed: Vec::new(),
796 active_connections: None,
797 system_metrics: None,
798 error: None,
799 }
800 }
801}
802
803#[derive(Debug, Clone, Serialize)]
805pub struct RingPeerSnapshot {
806 pub id: String,
807 pub is_gateway: bool,
808 pub ws_port: u16,
809 pub network_port: u16,
810 pub network_address: String,
811 #[serde(skip_serializing_if = "Option::is_none")]
812 pub location: Option<f64>,
813 pub connections: Vec<String>,
814 #[serde(skip_serializing_if = "Option::is_none")]
815 pub contract: Option<PeerContractStatus>,
816}
817
818pub fn ring_nodes_from_diagnostics(snapshot: &NetworkDiagnosticsSnapshot) -> Vec<RingPeerSnapshot> {
820 snapshot
821 .peers
822 .iter()
823 .map(|peer| {
824 let location = peer
825 .location
826 .as_deref()
827 .and_then(|loc| loc.parse::<f64>().ok());
828 let (network_address, network_port) =
829 parse_listening_address(peer.listening_address.as_ref(), &peer.ws_url);
830 let ws_port = parse_ws_port(&peer.ws_url);
831 let mut connections = peer.connected_peer_ids.clone();
832 connections.retain(|id| id != &peer.peer_id);
833 connections.sort();
834 connections.dedup();
835
836 RingPeerSnapshot {
837 id: peer.peer_id.clone(),
838 is_gateway: peer.is_gateway,
839 ws_port,
840 network_port,
841 network_address,
842 location,
843 connections,
844 contract: None,
845 }
846 })
847 .collect()
848}
849
850pub fn write_ring_visualization_from_diagnostics<P: AsRef<Path>, Q: AsRef<Path>>(
852 snapshot: &NetworkDiagnosticsSnapshot,
853 run_root: P,
854 output_path: Q,
855) -> Result<()> {
856 let nodes = ring_nodes_from_diagnostics(snapshot);
857 let metrics = compute_ring_metrics(&nodes);
858 let payload = json!({
859 "generated_at": snapshot.collected_at.to_rfc3339(),
860 "run_root": run_root.as_ref().display().to_string(),
861 "nodes": nodes,
862 "metrics": metrics,
863 });
864 let data_json = serde_json::to_string(&payload).map_err(|e| Error::Other(e.into()))?;
865 let html = render_ring_template(&data_json);
866 let out_path = output_path.as_ref();
867 if let Some(parent) = out_path.parent() {
868 if !parent.exists() {
869 fs::create_dir_all(parent)?;
870 }
871 }
872 fs::write(out_path, html)?;
873 tracing::info!(path = %out_path.display(), "Wrote ring visualization from diagnostics");
874 Ok(())
875}
876
877#[derive(Debug, Clone, Serialize)]
879pub struct PeerContractStatus {
880 pub stores_contract: bool,
881 pub subscribed_locally: bool,
882 pub subscriber_peer_ids: Vec<String>,
883 pub subscriber_count: usize,
884}
885
886#[derive(Debug, Clone, Serialize)]
887pub struct ContractVizData {
888 pub key: String,
889 pub location: f64,
890 pub caching_peers: Vec<String>,
891 pub put: OperationPath,
892 pub update: OperationPath,
893 pub errors: Vec<String>,
894}
895
896#[derive(Debug, Clone, Serialize)]
897pub struct OperationPath {
898 pub edges: Vec<ContractOperationEdge>,
899 #[serde(skip_serializing_if = "Option::is_none")]
900 pub completion_peer: Option<String>,
901}
902
903#[derive(Debug, Clone, Serialize)]
904pub struct ContractOperationEdge {
905 pub from: String,
906 pub to: String,
907 #[serde(skip_serializing_if = "Option::is_none")]
908 pub timestamp: Option<String>,
909 #[serde(skip_serializing_if = "Option::is_none")]
910 pub log_level: Option<String>,
911 #[serde(skip_serializing_if = "Option::is_none")]
912 pub log_source: Option<String>,
913 pub message: String,
914}
915
916#[derive(Debug, Clone, Serialize)]
918pub struct RingVizMetrics {
919 pub node_count: usize,
920 pub gateway_count: usize,
921 pub edge_count: usize,
922 pub average_degree: f64,
923 #[serde(skip_serializing_if = "Option::is_none")]
924 pub average_ring_distance: Option<f64>,
925 #[serde(skip_serializing_if = "Option::is_none")]
926 pub min_ring_distance: Option<f64>,
927 #[serde(skip_serializing_if = "Option::is_none")]
928 pub max_ring_distance: Option<f64>,
929 #[serde(skip_serializing_if = "Option::is_none")]
930 pub pct_edges_under_5pct: Option<f64>,
931 #[serde(skip_serializing_if = "Option::is_none")]
932 pub pct_edges_under_10pct: Option<f64>,
933 #[serde(skip_serializing_if = "Option::is_none")]
934 pub short_over_long_ratio: Option<f64>,
935 #[serde(skip_serializing_if = "Vec::is_empty")]
936 pub distance_histogram: Vec<RingDistanceBucket>,
937}
938
939#[derive(Debug, Clone, Serialize)]
940pub struct RingDistanceBucket {
941 pub upper_bound: f64,
942 pub count: usize,
943}
944
945#[derive(Debug, Clone, Serialize, Deserialize)]
947pub struct NetworkTopology {
948 pub peers: Vec<PeerInfo>,
949 pub connections: Vec<Connection>,
950}
951
952#[derive(Debug, Clone, Serialize, Deserialize)]
954pub struct PeerInfo {
955 pub id: String,
956 pub is_gateway: bool,
957 pub ws_port: u16,
958}
959
960#[derive(Debug, Clone, Serialize, Deserialize)]
962pub struct Connection {
963 pub from: String,
964 pub to: String,
965}
966
967#[derive(Default)]
968struct ContractFlowData {
969 put_edges: Vec<ContractOperationEdge>,
970 put_completion_peer: Option<String>,
971 update_edges: Vec<ContractOperationEdge>,
972 update_completion_peer: Option<String>,
973 errors: Vec<String>,
974}
975
976async fn query_ring_snapshot(
977 peer: &TestPeer,
978 instance_id: Option<&ContractInstanceId>,
979) -> Result<RingPeerSnapshot> {
980 use tokio_tungstenite::connect_async;
981
982 let url = format!("{}?encodingProtocol=native", peer.ws_url());
983 let (ws_stream, _) = tokio::time::timeout(Duration::from_secs(5), connect_async(&url))
984 .await
985 .map_err(|_| Error::ConnectivityFailed(format!("Timeout connecting to {}", peer.id())))?
986 .map_err(|e| {
987 Error::ConnectivityFailed(format!("Failed to connect to {}: {}", peer.id(), e))
988 })?;
989
990 let mut client = WebApi::start(ws_stream);
991 let diag_config = if let Some(id) = instance_id {
992 let placeholder_key =
995 ContractKey::from_id_and_code(*id, CodeHash::new([0u8; 32]));
996 NodeDiagnosticsConfig {
997 include_node_info: true,
998 include_network_info: true,
999 include_subscriptions: true,
1000 contract_keys: vec![placeholder_key],
1001 include_system_metrics: false,
1002 include_detailed_peer_info: true,
1003 include_subscriber_peer_ids: true,
1004 }
1005 } else {
1006 NodeDiagnosticsConfig::basic_status()
1007 };
1008
1009 client
1010 .send(ClientRequest::NodeQueries(NodeQuery::NodeDiagnostics {
1011 config: diag_config,
1012 }))
1013 .await
1014 .map_err(|e| {
1015 Error::ConnectivityFailed(format!(
1016 "Failed to send diagnostics to {}: {}",
1017 peer.id(),
1018 e
1019 ))
1020 })?;
1021
1022 let response = tokio::time::timeout(Duration::from_secs(5), client.recv())
1023 .await
1024 .map_err(|_| {
1025 Error::ConnectivityFailed(format!(
1026 "Timeout waiting for diagnostics response from {}",
1027 peer.id()
1028 ))
1029 })?;
1030
1031 let diag = match response {
1032 Ok(HostResponse::QueryResponse(QueryResponse::NodeDiagnostics(diag))) => diag,
1033 Ok(other) => {
1034 graceful_disconnect(client, "ring snapshot error").await;
1035 return Err(Error::ConnectivityFailed(format!(
1036 "Unexpected diagnostics response from {}: {:?}",
1037 peer.id(),
1038 other
1039 )));
1040 }
1041 Err(e) => {
1042 graceful_disconnect(client, "ring snapshot error").await;
1043 return Err(Error::ConnectivityFailed(format!(
1044 "Diagnostics query failed for {}: {}",
1045 peer.id(),
1046 e
1047 )));
1048 }
1049 };
1050
1051 let node_info = diag.node_info.ok_or_else(|| {
1052 Error::ConnectivityFailed(format!("{} did not return node_info", peer.id()))
1053 })?;
1054
1055 let location = node_info
1056 .location
1057 .as_deref()
1058 .and_then(|value| value.parse::<f64>().ok());
1059
1060 let mut connections: Vec<String> = diag
1061 .connected_peers_detailed
1062 .into_iter()
1063 .map(|info| info.peer_id)
1064 .collect();
1065
1066 if connections.is_empty() {
1067 if let Some(network_info) = diag.network_info {
1068 connections = network_info
1069 .connected_peers
1070 .into_iter()
1071 .map(|(peer_id, _)| peer_id)
1072 .collect();
1073 }
1074 }
1075
1076 connections.retain(|conn| conn != &node_info.peer_id);
1077 connections.sort();
1078 connections.dedup();
1079
1080 let contract_status = instance_id.and_then(|id| {
1081 let placeholder_key = ContractKey::from_id_and_code(*id, CodeHash::new([0u8; 32]));
1083 let (stores_contract, subscriber_count, subscriber_peer_ids) =
1084 if let Some(state) = diag.contract_states.get(&placeholder_key) {
1085 (
1086 true,
1087 state.subscribers as usize,
1088 state.subscriber_peer_ids.clone(),
1089 )
1090 } else {
1091 (false, 0, Vec::new())
1092 };
1093 let subscribed_locally = diag
1095 .subscriptions
1096 .iter()
1097 .any(|sub| &sub.contract_key == id);
1098 if stores_contract || subscribed_locally {
1099 Some(PeerContractStatus {
1100 stores_contract,
1101 subscribed_locally,
1102 subscriber_peer_ids,
1103 subscriber_count,
1104 })
1105 } else {
1106 None
1107 }
1108 });
1109
1110 graceful_disconnect(client, "ring snapshot complete").await;
1111
1112 Ok(RingPeerSnapshot {
1113 id: node_info.peer_id,
1114 is_gateway: node_info.is_gateway,
1115 ws_port: peer.ws_port,
1116 network_port: peer.network_port,
1117 network_address: peer.network_address.clone(),
1118 location,
1119 connections,
1120 contract: contract_status,
1121 })
1122}
1123
1124fn compute_ring_metrics(nodes: &[RingPeerSnapshot]) -> RingVizMetrics {
1125 let node_count = nodes.len();
1126 let gateway_count = nodes.iter().filter(|peer| peer.is_gateway).count();
1127 let mut total_degree = 0usize;
1128 let mut unique_edges: HashSet<(String, String)> = HashSet::new();
1129
1130 for node in nodes {
1131 total_degree += node.connections.len();
1132 for neighbor in &node.connections {
1133 if neighbor == &node.id {
1134 continue;
1135 }
1136 let edge = if node.id < *neighbor {
1137 (node.id.clone(), neighbor.clone())
1138 } else {
1139 (neighbor.clone(), node.id.clone())
1140 };
1141 unique_edges.insert(edge);
1142 }
1143 }
1144
1145 let average_degree = if node_count == 0 {
1146 0.0
1147 } else {
1148 total_degree as f64 / node_count as f64
1149 };
1150
1151 let mut location_lookup = HashMap::new();
1152 for node in nodes {
1153 if let Some(loc) = node.location {
1154 location_lookup.insert(node.id.clone(), loc);
1155 }
1156 }
1157
1158 let mut distances = Vec::new();
1159 for (a, b) in &unique_edges {
1160 if let (Some(loc_a), Some(loc_b)) = (location_lookup.get(a), location_lookup.get(b)) {
1161 let mut distance = (loc_a - loc_b).abs();
1162 if distance > 0.5 {
1163 distance = 1.0 - distance;
1164 }
1165 distances.push(distance);
1166 }
1167 }
1168
1169 let average_ring_distance = if distances.is_empty() {
1170 None
1171 } else {
1172 Some(distances.iter().sum::<f64>() / distances.len() as f64)
1173 };
1174 let min_ring_distance = distances.iter().cloned().reduce(f64::min);
1175 let max_ring_distance = distances.iter().cloned().reduce(f64::max);
1176 let pct_edges_under_5pct = calculate_percentage(&distances, 0.05);
1177 let pct_edges_under_10pct = calculate_percentage(&distances, 0.10);
1178
1179 let short_edges = distances.iter().filter(|d| **d <= 0.10).count();
1180 let long_edges = distances
1181 .iter()
1182 .filter(|d| **d > 0.10 && **d <= 0.50)
1183 .count();
1184 let short_over_long_ratio = if short_edges == 0 || long_edges == 0 {
1185 None
1186 } else {
1187 Some(short_edges as f64 / long_edges as f64)
1188 };
1189
1190 let mut distance_histogram = Vec::new();
1191 let bucket_bounds: Vec<f64> = (1..=5).map(|i| i as f64 * 0.10).collect(); let mut cumulative = 0usize;
1193 for bound in bucket_bounds {
1194 let up_to_bound = distances.iter().filter(|d| **d <= bound).count();
1195 let count = up_to_bound.saturating_sub(cumulative);
1196 cumulative = up_to_bound;
1197 distance_histogram.push(RingDistanceBucket {
1198 upper_bound: bound,
1199 count,
1200 });
1201 }
1202
1203 RingVizMetrics {
1204 node_count,
1205 gateway_count,
1206 edge_count: unique_edges.len(),
1207 average_degree,
1208 average_ring_distance,
1209 min_ring_distance,
1210 max_ring_distance,
1211 pct_edges_under_5pct,
1212 pct_edges_under_10pct,
1213 short_over_long_ratio,
1214 distance_histogram,
1215 }
1216}
1217
1218fn calculate_percentage(distances: &[f64], threshold: f64) -> Option<f64> {
1219 if distances.is_empty() {
1220 return None;
1221 }
1222 let matching = distances.iter().filter(|value| **value < threshold).count();
1223 Some((matching as f64 / distances.len() as f64) * 100.0)
1224}
1225
1226fn parse_ws_port(ws_url: &str) -> u16 {
1227 ws_url
1228 .split("://")
1229 .nth(1)
1230 .and_then(|rest| rest.split('/').next())
1231 .and_then(|host_port| host_port.split(':').nth(1))
1232 .and_then(|port| port.parse().ok())
1233 .unwrap_or(0)
1234}
1235
1236fn parse_listening_address(addr: Option<&String>, ws_url: &str) -> (String, u16) {
1237 if let Some(addr) = addr {
1238 let mut parts = addr.split(':');
1239 let host = parts.next().unwrap_or("").to_string();
1240 let port = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
1241 (host, port)
1242 } else {
1243 let host = ws_url
1244 .split("://")
1245 .nth(1)
1246 .and_then(|rest| rest.split('/').next())
1247 .and_then(|host_port| host_port.split(':').next())
1248 .unwrap_or_default()
1249 .to_string();
1250 (host, 0)
1251 }
1252}
1253
1254fn resolve_peer_id(prefix: &str, peers: &[RingPeerSnapshot]) -> Option<String> {
1255 let needle = prefix.trim().trim_matches(|c| c == '"' || c == '\'');
1256 peers
1257 .iter()
1258 .find(|peer| peer.id.starts_with(needle))
1259 .map(|peer| peer.id.clone())
1260}
1261
1262fn contract_location_from_instance_id(id: &ContractInstanceId) -> f64 {
1263 let mut value = 0.0;
1264 let mut divisor = 256.0;
1265 for byte in id.as_bytes() {
1266 value += f64::from(*byte) / divisor;
1267 divisor *= 256.0;
1268 }
1269 value.fract()
1270}
1271
1272static PUT_REQUEST_RE: LazyLock<Regex> = LazyLock::new(|| {
1273 Regex::new(r"Requesting put for contract (?P<key>\S+) from (?P<from>\S+) to (?P<to>\S+)")
1274 .expect("valid regex")
1275});
1276static PUT_COMPLETION_RE: LazyLock<Regex> = LazyLock::new(|| {
1277 Regex::new(r"Peer completed contract value put,.*key: (?P<key>\S+),.*this_peer: (?P<peer>\S+)")
1278 .expect("valid regex")
1279});
1280static UPDATE_PROPAGATION_RE: LazyLock<Regex> = LazyLock::new(|| {
1281 Regex::new(
1282 r"UPDATE_PROPAGATION: contract=(?P<contract>\S+) from=(?P<from>\S+) targets=(?P<targets>[^ ]*)\s+count=",
1283 )
1284 .expect("valid regex")
1285});
1286static UPDATE_NO_TARGETS_RE: LazyLock<Regex> = LazyLock::new(|| {
1287 Regex::new(r"UPDATE_PROPAGATION: contract=(?P<contract>\S+) from=(?P<from>\S+) NO_TARGETS")
1288 .expect("valid regex")
1289});
1290
1291const HTML_TEMPLATE: &str = r###"<!DOCTYPE html>
1292<html lang="en">
1293<head>
1294 <meta charset="UTF-8" />
1295 <title>Freenet Ring Topology</title>
1296 <style>
1297 :root {
1298 color-scheme: dark;
1299 font-family: "Inter", "Helvetica Neue", Arial, sans-serif;
1300 }
1301 body {
1302 background: #020617;
1303 color: #e2e8f0;
1304 margin: 0;
1305 padding: 32px;
1306 display: flex;
1307 justify-content: center;
1308 min-height: 100vh;
1309 }
1310 #container {
1311 max-width: 1000px;
1312 width: 100%;
1313 background: #0f172a;
1314 border-radius: 18px;
1315 padding: 28px 32px 40px;
1316 box-shadow: 0 40px 120px rgba(2, 6, 23, 0.85);
1317 }
1318 h1 {
1319 margin: 0 0 8px;
1320 font-size: 26px;
1321 letter-spacing: 0.4px;
1322 color: #f8fafc;
1323 }
1324 #meta {
1325 margin: 0 0 20px;
1326 color: #94a3b8;
1327 font-size: 14px;
1328 }
1329 canvas {
1330 width: 100%;
1331 max-width: 900px;
1332 height: auto;
1333 background: radial-gradient(circle, #020617 0%, #0f172a 70%, #020617 100%);
1334 border-radius: 16px;
1335 border: 1px solid rgba(148, 163, 184, 0.1);
1336 display: block;
1337 margin: 0 auto 24px;
1338 }
1339 #metrics {
1340 display: grid;
1341 grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
1342 gap: 12px;
1343 margin-bottom: 20px;
1344 font-size: 14px;
1345 }
1346 #metrics div {
1347 background: #1e293b;
1348 padding: 12px 14px;
1349 border-radius: 12px;
1350 border: 1px solid rgba(148, 163, 184, 0.15);
1351 }
1352 .legend {
1353 display: flex;
1354 flex-wrap: wrap;
1355 gap: 18px;
1356 margin-bottom: 24px;
1357 font-size: 14px;
1358 color: #cbd5f5;
1359 }
1360 .legend span {
1361 display: flex;
1362 align-items: center;
1363 gap: 6px;
1364 }
1365 .legend .line {
1366 width: 30px;
1367 height: 6px;
1368 border-radius: 3px;
1369 display: inline-block;
1370 }
1371 .legend .put-line {
1372 background: #22c55e;
1373 }
1374 .legend .update-line {
1375 background: #f59e0b;
1376 }
1377 .legend .contract-dot {
1378 background: #a855f7;
1379 box-shadow: 0 0 6px rgba(168, 85, 247, 0.7);
1380 }
1381 .legend .cached-dot {
1382 background: #38bdf8;
1383 }
1384 .legend .peer-dot {
1385 background: #64748b;
1386 }
1387 .legend .gateway-dot {
1388 background: #f97316;
1389 }
1390 .dot {
1391 width: 14px;
1392 height: 14px;
1393 border-radius: 50%;
1394 display: inline-block;
1395 }
1396 #contract-info {
1397 background: rgba(15, 23, 42, 0.7);
1398 border-radius: 12px;
1399 border: 1px solid rgba(148, 163, 184, 0.15);
1400 padding: 14px;
1401 margin-bottom: 20px;
1402 font-size: 13px;
1403 color: #cbd5f5;
1404 }
1405 #contract-info h2 {
1406 margin: 0 0 8px;
1407 font-size: 16px;
1408 color: #f8fafc;
1409 }
1410 #contract-info .op-block {
1411 margin-top: 10px;
1412 }
1413 #contract-info ol {
1414 padding-left: 20px;
1415 margin: 6px 0;
1416 }
1417 #contract-info .errors {
1418 margin-top: 10px;
1419 color: #f97316;
1420 }
1421 #peer-list {
1422 border-top: 1px solid rgba(148, 163, 184, 0.15);
1423 padding-top: 18px;
1424 display: grid;
1425 grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
1426 gap: 14px;
1427 font-size: 13px;
1428 }
1429 .peer {
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 }
1435 .peer strong {
1436 display: block;
1437 font-size: 13px;
1438 color: #f1f5f9;
1439 margin-bottom: 6px;
1440 word-break: break-all;
1441 }
1442 .peer span {
1443 display: block;
1444 color: #94a3b8;
1445 }
1446 .chart-card {
1447 background: rgba(15, 23, 42, 0.75);
1448 padding: 12px 14px;
1449 border-radius: 12px;
1450 border: 1px solid rgba(148, 163, 184, 0.2);
1451 margin-top: 12px;
1452 }
1453 .chart-card h3 {
1454 margin: 0 0 8px;
1455 font-size: 15px;
1456 color: #e2e8f0;
1457 }
1458 </style>
1459 <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
1460</head>
1461<body>
1462 <div id="container">
1463 <h1>Freenet Ring Topology</h1>
1464 <p id="meta"></p>
1465 <canvas id="ring" width="900" height="900"></canvas>
1466 <div id="metrics"></div>
1467 <div class="chart-card" style="height: 280px;">
1468 <h3>Ring distance distribution</h3>
1469 <canvas id="histogram-chart" style="height: 220px;"></canvas>
1470 </div>
1471 <div class="legend">
1472 <span><span class="dot peer-dot"></span>Peer</span>
1473 <span><span class="dot gateway-dot"></span>Gateway</span>
1474 <span><span class="dot cached-dot"></span>Cached peer</span>
1475 <span><span class="dot contract-dot"></span>Contract</span>
1476 <span><span class="line put-line"></span>PUT path</span>
1477 <span><span class="line update-line"></span>UPDATE path</span>
1478 <span><span class="dot" style="background:#475569"></span>Connection</span>
1479 </div>
1480 <div id="contract-info"></div>
1481 <div id="peer-list"></div>
1482 </div>
1483 <script>
1484 const vizData = __DATA__;
1485 const contractData = vizData.contract ?? null;
1486 const metaEl = document.getElementById("meta");
1487 metaEl.textContent = vizData.nodes.length
1488 ? `Captured ${vizData.nodes.length} nodes · ${vizData.metrics.edge_count} edges · Generated ${vizData.generated_at} · Run root: ${vizData.run_root}`
1489 : "No peers reported diagnostics data.";
1490
1491 const metricsEl = document.getElementById("metrics");
1492 const fmtNumber = (value, digits = 2) => (typeof value === "number" ? value.toFixed(digits) : "n/a");
1493 const fmtPercent = (value, digits = 1) => (typeof value === "number" ? `${value.toFixed(digits)}%` : "n/a");
1494
1495 metricsEl.innerHTML = `
1496 <div><strong>Total nodes</strong><br/>${vizData.metrics.node_count} (gateways: ${vizData.metrics.gateway_count})</div>
1497 <div><strong>Edges</strong><br/>${vizData.metrics.edge_count}</div>
1498 <div><strong>Average degree</strong><br/>${fmtNumber(vizData.metrics.average_degree)}</div>
1499 <div><strong>Average ring distance</strong><br/>${fmtNumber(vizData.metrics.average_ring_distance, 3)}</div>
1500 <div><strong>Min / Max ring distance</strong><br/>${fmtNumber(vizData.metrics.min_ring_distance, 3)} / ${fmtNumber(vizData.metrics.max_ring_distance, 3)}</div>
1501 <div><strong>Edges <5% / <10%</strong><br/>${fmtPercent(vizData.metrics.pct_edges_under_5pct)} / ${fmtPercent(vizData.metrics.pct_edges_under_10pct)}</div>
1502 <div><strong>Short/long edge ratio (≤0.1 / 0.1–0.5)</strong><br/>${fmtNumber(vizData.metrics.short_over_long_ratio, 2)}</div>
1503 `;
1504
1505 const histogram = vizData.metrics.distance_histogram ?? [];
1506 const labels = histogram.map((b, idx) => {
1507 const lower = idx === 0 ? 0 : histogram[idx - 1].upper_bound;
1508 return `${lower.toFixed(1)}–${b.upper_bound.toFixed(1)}`;
1509 });
1510 const counts = histogram.map((b) => b.count);
1511
1512 if (histogram.length && window.Chart) {
1513 const ctx = document.getElementById("histogram-chart").getContext("2d");
1514 new Chart(ctx, {
1515 data: {
1516 labels,
1517 datasets: [
1518 {
1519 type: "bar",
1520 label: "Edges per bucket",
1521 data: counts,
1522 backgroundColor: "rgba(56, 189, 248, 0.75)",
1523 borderColor: "#38bdf8",
1524 borderWidth: 1,
1525 },
1526 ],
1527 },
1528 options: {
1529 responsive: true,
1530 maintainAspectRatio: false,
1531 animation: false,
1532 interaction: { mode: "index", intersect: false },
1533 plugins: {
1534 legend: { position: "bottom" },
1535 tooltip: {
1536 callbacks: {
1537 label: (ctx) => {
1538 const label = ctx.dataset.label || "";
1539 const value = ctx.parsed.y;
1540 return ctx.dataset.type === "line"
1541 ? `${label}: ${value.toFixed(1)}%`
1542 : `${label}: ${value}`;
1543 },
1544 },
1545 },
1546 },
1547 scales: {
1548 x: { title: { display: true, text: "Ring distance (fraction of circumference)" } },
1549 y: {
1550 beginAtZero: true,
1551 title: { display: true, text: "Edge count" },
1552 grid: { color: "rgba(148,163,184,0.15)" },
1553 },
1554 },
1555 },
1556 });
1557 }
1558
1559 const peersEl = document.getElementById("peer-list");
1560 peersEl.innerHTML = vizData.nodes
1561 .map((node) => {
1562 const role = node.is_gateway ? "Gateway" : "Peer";
1563 const loc = typeof node.location === "number" ? node.location.toFixed(6) : "unknown";
1564 return `<div class="peer">
1565 <strong>${node.id}</strong>
1566 <span>${role}</span>
1567 <span>Location: ${loc}</span>
1568 <span>Degree: ${node.connections.length}</span>
1569 </div>`;
1570 })
1571 .join("");
1572
1573 const contractInfoEl = document.getElementById("contract-info");
1574 const canvas = document.getElementById("ring");
1575 const ctx = canvas.getContext("2d");
1576 const width = canvas.width;
1577 const height = canvas.height;
1578 const center = { x: width / 2, y: height / 2 };
1579 const radius = Math.min(width, height) * 0.37;
1580 const cachingPeers = new Set(contractData?.caching_peers ?? []);
1581 const putEdges = contractData?.put?.edges ?? [];
1582 const updateEdges = contractData?.update?.edges ?? [];
1583 const contractLocation = typeof (contractData?.location) === "number" ? contractData.location : null;
1584
1585 function shortId(id) {
1586 return id.slice(-6);
1587 }
1588
1589 function angleFromLocation(location) {
1590 return (location % 1) * Math.PI * 2;
1591 }
1592
1593 function polarToCartesian(angle, r = radius) {
1594 const theta = angle - Math.PI / 2;
1595 return {
1596 x: center.x + r * Math.cos(theta),
1597 y: center.y + r * Math.sin(theta),
1598 };
1599 }
1600
1601 const nodes = vizData.nodes.map((node, idx) => {
1602 const angle = typeof node.location === "number"
1603 ? angleFromLocation(node.location)
1604 : (idx / vizData.nodes.length) * Math.PI * 2;
1605 const coords = polarToCartesian(angle);
1606 return {
1607 ...node,
1608 angle,
1609 x: coords.x,
1610 y: coords.y,
1611 };
1612 });
1613 const nodeMap = new Map(nodes.map((node) => [node.id, node]));
1614
1615 function drawRingBase() {
1616 ctx.save();
1617 ctx.strokeStyle = "#475569";
1618 ctx.lineWidth = 2;
1619 ctx.beginPath();
1620 ctx.arc(center.x, center.y, radius, 0, Math.PI * 2);
1621 ctx.stroke();
1622 ctx.setLineDash([4, 8]);
1623 ctx.strokeStyle = "rgba(148,163,184,0.3)";
1624 [0, 0.25, 0.5, 0.75].forEach((loc) => {
1625 const angle = angleFromLocation(loc);
1626 const inner = polarToCartesian(angle, radius * 0.9);
1627 const outer = polarToCartesian(angle, radius * 1.02);
1628 ctx.beginPath();
1629 ctx.moveTo(inner.x, inner.y);
1630 ctx.lineTo(outer.x, outer.y);
1631 ctx.stroke();
1632 ctx.fillStyle = "#94a3b8";
1633 ctx.font = "11px 'Fira Code', monospace";
1634 ctx.textAlign = "center";
1635 ctx.fillText(loc.toFixed(2), outer.x, outer.y - 6);
1636 });
1637 ctx.restore();
1638 }
1639
1640 function drawConnections() {
1641 ctx.save();
1642 ctx.strokeStyle = "rgba(148, 163, 184, 0.35)";
1643 ctx.lineWidth = 1.2;
1644 const drawn = new Set();
1645 nodes.forEach((node) => {
1646 node.connections.forEach((neighborId) => {
1647 const neighbor = nodeMap.get(neighborId);
1648 if (!neighbor) return;
1649 const key = node.id < neighborId ? `${node.id}|${neighborId}` : `${neighborId}|${node.id}`;
1650 if (drawn.has(key)) return;
1651 drawn.add(key);
1652 ctx.beginPath();
1653 ctx.moveTo(node.x, node.y);
1654 ctx.lineTo(neighbor.x, neighbor.y);
1655 ctx.stroke();
1656 });
1657 });
1658 ctx.restore();
1659 }
1660
1661 function drawContractMarker() {
1662 if (typeof contractLocation !== "number") return;
1663 const pos = polarToCartesian(angleFromLocation(contractLocation));
1664 ctx.save();
1665 ctx.fillStyle = "#a855f7";
1666 ctx.beginPath();
1667 ctx.arc(pos.x, pos.y, 9, 0, Math.PI * 2);
1668 ctx.fill();
1669 ctx.lineWidth = 2;
1670 ctx.strokeStyle = "#f3e8ff";
1671 ctx.stroke();
1672 ctx.fillStyle = "#f3e8ff";
1673 ctx.font = "10px 'Fira Code', monospace";
1674 ctx.textAlign = "center";
1675 ctx.fillText("contract", pos.x, pos.y - 16);
1676 ctx.restore();
1677 }
1678
1679 function drawPeers() {
1680 nodes.forEach((node) => {
1681 const baseColor = node.is_gateway ? "#f97316" : "#64748b";
1682 const fill = cachingPeers.has(node.id) ? "#38bdf8" : baseColor;
1683 ctx.save();
1684 ctx.beginPath();
1685 ctx.fillStyle = fill;
1686 ctx.arc(node.x, node.y, 6.5, 0, Math.PI * 2);
1687 ctx.fill();
1688 ctx.lineWidth = 1.6;
1689 ctx.strokeStyle = "#0f172a";
1690 ctx.stroke();
1691 ctx.fillStyle = "#f8fafc";
1692 ctx.font = "12px 'Fira Code', monospace";
1693 ctx.textAlign = "center";
1694 ctx.fillText(shortId(node.id), node.x, node.y - 14);
1695 const locText = typeof node.location === "number" ? node.location.toFixed(3) : "n/a";
1696 ctx.fillStyle = "#94a3b8";
1697 ctx.font = "10px 'Fira Code', monospace";
1698 ctx.fillText(locText, node.x, node.y + 20);
1699 ctx.restore();
1700 });
1701 }
1702
1703 function drawOperationEdges(edges, color, dashed = false) {
1704 if (!edges.length) return;
1705 ctx.save();
1706 ctx.strokeStyle = color;
1707 ctx.lineWidth = 3;
1708 if (dashed) ctx.setLineDash([10, 8]);
1709 edges.forEach((edge, idx) => {
1710 const from = nodeMap.get(edge.from);
1711 const to = nodeMap.get(edge.to);
1712 if (!from || !to) return;
1713 ctx.beginPath();
1714 ctx.moveTo(from.x, from.y);
1715 ctx.lineTo(to.x, to.y);
1716 ctx.stroke();
1717 drawArrowhead(from, to, color);
1718 const midX = (from.x + to.x) / 2;
1719 const midY = (from.y + to.y) / 2;
1720 ctx.fillStyle = color;
1721 ctx.font = "11px 'Fira Code', monospace";
1722 ctx.fillText(`#${idx + 1}`, midX, midY - 4);
1723 });
1724 ctx.restore();
1725 }
1726
1727 function drawArrowhead(from, to, color) {
1728 const angle = Math.atan2(to.y - from.y, to.x - from.x);
1729 const length = 12;
1730 const spread = Math.PI / 6;
1731 ctx.save();
1732 ctx.fillStyle = color;
1733 ctx.beginPath();
1734 ctx.moveTo(to.x, to.y);
1735 ctx.lineTo(
1736 to.x - length * Math.cos(angle - spread),
1737 to.y - length * Math.sin(angle - spread)
1738 );
1739 ctx.lineTo(
1740 to.x - length * Math.cos(angle + spread),
1741 to.y - length * Math.sin(angle + spread)
1742 );
1743 ctx.closePath();
1744 ctx.fill();
1745 ctx.restore();
1746 }
1747
1748 function renderOperationList(title, edges) {
1749 if (!edges.length) {
1750 return `<div class="op-block"><strong>${title}:</strong> none recorded</div>`;
1751 }
1752 const items = edges
1753 .map((edge, idx) => {
1754 const ts = edge.timestamp
1755 ? new Date(edge.timestamp).toLocaleTimeString()
1756 : "no-ts";
1757 return `<li>#${idx + 1}: ${shortId(edge.from)} → ${shortId(edge.to)} (${ts})</li>`;
1758 })
1759 .join("");
1760 return `<div class="op-block"><strong>${title}:</strong><ol>${items}</ol></div>`;
1761 }
1762
1763 function renderContractInfo(data) {
1764 if (!data) {
1765 contractInfoEl.textContent = "No contract-specific data collected for this snapshot.";
1766 return;
1767 }
1768 const errors = data.errors?.length
1769 ? `<div class="errors"><strong>Notable events:</strong><ul>${data.errors
1770 .map((msg) => `<li>${msg}</li>`)
1771 .join("")}</ul></div>`
1772 : "";
1773 const putSummary = renderOperationList("PUT path", data.put.edges);
1774 const updateSummary = renderOperationList("UPDATE path", data.update.edges);
1775 contractInfoEl.innerHTML = `
1776 <h2>Contract ${data.key}</h2>
1777 <div><strong>Cached peers:</strong> ${data.caching_peers.length}</div>
1778 <div><strong>PUT completion:</strong> ${
1779 data.put.completion_peer ?? "pending"
1780 }</div>
1781 <div><strong>UPDATE completion:</strong> ${
1782 data.update.completion_peer ?? "pending"
1783 }</div>
1784 ${putSummary}
1785 ${updateSummary}
1786 ${errors}
1787 `;
1788 }
1789
1790 renderContractInfo(contractData);
1791
1792 if (nodes.length) {
1793 drawRingBase();
1794 drawConnections();
1795 drawContractMarker();
1796 drawOperationEdges(putEdges, "#22c55e", false);
1797 drawOperationEdges(updateEdges, "#f59e0b", true);
1798 drawPeers();
1799 } else {
1800 ctx.fillStyle = "#94a3b8";
1801 ctx.font = "16px Inter, sans-serif";
1802 ctx.fillText("No diagnostics available to render ring.", center.x - 140, center.y);
1803 }
1804 </script>
1805</body>
1806</html>
1807"###;
1808
1809fn render_ring_template(data_json: &str) -> String {
1810 HTML_TEMPLATE.replace("__DATA__", data_json)
1811}
1812
1813#[cfg(test)]
1814mod tests {
1815 use super::{compute_ring_metrics, RingPeerSnapshot};
1816
1817 #[test]
1818 fn ring_metrics_basic() {
1819 let nodes = vec![
1820 RingPeerSnapshot {
1821 id: "a".into(),
1822 is_gateway: false,
1823 ws_port: 0,
1824 network_port: 0,
1825 network_address: "127.1.0.1".into(),
1826 location: Some(0.1),
1827 connections: vec!["b".into(), "c".into()],
1828 contract: None,
1829 },
1830 RingPeerSnapshot {
1831 id: "b".into(),
1832 is_gateway: false,
1833 ws_port: 0,
1834 network_port: 0,
1835 network_address: "127.2.0.1".into(),
1836 location: Some(0.2),
1837 connections: vec!["a".into()],
1838 contract: None,
1839 },
1840 RingPeerSnapshot {
1841 id: "c".into(),
1842 is_gateway: true,
1843 ws_port: 0,
1844 network_port: 0,
1845 network_address: "127.3.0.1".into(),
1846 location: Some(0.8),
1847 connections: vec!["a".into()],
1848 contract: None,
1849 },
1850 ];
1851
1852 let metrics = compute_ring_metrics(&nodes);
1853 assert_eq!(metrics.node_count, 3);
1854 assert_eq!(metrics.gateway_count, 1);
1855 assert_eq!(metrics.edge_count, 2);
1856 assert!((metrics.average_degree - (4.0 / 3.0)).abs() < f64::EPSILON);
1857 assert!(metrics.average_ring_distance.is_some());
1858 }
1859}