Skip to main content

freenet_test_network/
network.rs

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
21/// Gracefully close a WebSocket client connection.
22///
23/// This sends a Disconnect message and waits briefly for the close handshake to complete,
24/// preventing "Connection reset without closing handshake" errors on the server.
25async fn graceful_disconnect(client: WebApi, reason: &'static str) {
26    client.disconnect(reason).await;
27    // Brief delay to allow the close handshake to complete
28    tokio::time::sleep(Duration::from_millis(50)).await;
29}
30
31/// Detailed connectivity status for a single peer
32#[derive(Debug, Clone)]
33pub struct PeerConnectivityStatus {
34    pub peer_id: String,
35    pub connections: Option<usize>,
36    pub error: Option<String>,
37}
38
39/// Detailed connectivity check result
40#[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
48/// A test network consisting of gateways and peer nodes
49pub 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    /// Docker backend for NAT simulation (if used)
55    pub(crate) docker_backend: Option<DockerNatBackend>,
56}
57
58impl TestNetwork {
59    /// Create a new network builder
60    pub fn builder() -> crate::builder::NetworkBuilder {
61        crate::builder::NetworkBuilder::new()
62    }
63
64    /// Get a gateway peer by index
65    pub fn gateway(&self, index: usize) -> &TestPeer {
66        &self.gateways[index]
67    }
68
69    /// Get a non-gateway peer by index
70    pub fn peer(&self, index: usize) -> &TestPeer {
71        &self.peers[index]
72    }
73
74    /// Get all gateway WebSocket URLs
75    pub fn gateway_ws_urls(&self) -> Vec<String> {
76        self.gateways.iter().map(|p| p.ws_url()).collect()
77    }
78
79    /// Get all peer WebSocket URLs
80    pub fn peer_ws_urls(&self) -> Vec<String> {
81        self.peers.iter().map(|p| p.ws_url()).collect()
82    }
83
84    /// Wait until the network is ready for use
85    ///
86    /// This checks that peers have formed connections and the network
87    /// is sufficiently connected for testing.
88    pub async fn wait_until_ready(&self) -> Result<()> {
89        self.wait_until_ready_with_timeout(Duration::from_secs(30))
90            .await
91    }
92
93    /// Wait until the network is ready with a custom timeout
94    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                // Log final detailed status on failure
108                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            // Check connectivity with detailed status
125            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            // Log progress periodically (every 10 seconds)
133            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    /// Check current network connectivity with detailed status
159    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    /// Check current network connectivity ratio (0.0 to 1.0)
212    async fn check_connectivity(&self) -> Result<f64> {
213        let status = self.check_connectivity_detailed().await;
214        Ok(status.ratio)
215    }
216
217    /// Format connectivity status for logging
218    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    /// Query a single peer for its connection count
233    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    /// Get the current network topology
273    pub async fn topology(&self) -> Result<NetworkTopology> {
274        // TODO: Query peers for their connections and build topology
275        Ok(NetworkTopology {
276            peers: vec![],
277            connections: vec![],
278        })
279    }
280
281    /// Export network information in JSON format for visualization tools
282    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    /// Collect diagnostics from every peer, returning a snapshot that can be serialized to JSON
304    /// for offline analysis.
305    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    /// Collect per-peer ring data (locations + adjacency) for visualization/debugging.
399    pub async fn ring_snapshot(&self) -> Result<Vec<RingPeerSnapshot>> {
400        self.collect_ring_snapshot(None).await
401    }
402
403    /// Collect per-peer ring data with optional contract-specific subscription info.
404    ///
405    /// When `instance_id` is provided, each `RingPeerSnapshot` will include
406    /// `PeerContractStatus` with subscriber information for that contract.
407    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    /// Generate an interactive HTML ring visualization for the current network.
420    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    /// Generate an interactive HTML ring visualization for a specific contract.
426    ///
427    /// Note: This function now takes a ContractKey directly instead of a string,
428    /// since freenet-stdlib 0.1.27 no longer supports ContractKey::from_id.
429    /// The contract_id string is used for display purposes.
430    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    /// Dump logs from all peers, optionally filtered by a pattern
571    ///
572    /// If `filter` is Some, only logs containing the pattern (case-insensitive) are printed.
573    /// Logs are printed in chronological order with peer ID prefix.
574    ///
575    /// This is useful for debugging test failures - call it before assertions or in error handlers.
576    pub fn dump_logs(&self, filter: Option<&str>) {
577        match self.read_logs() {
578            Ok(mut entries) => {
579                // Sort by timestamp
580                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    /// Dump logs related to connection establishment and NAT traversal
620    ///
621    /// Filters for: hole punch, NAT, connect, acceptor, joiner, handshake
622    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    /// Dump iptables counters from all NAT routers (Docker NAT only)
670    ///
671    /// Prints NAT table rules and FORWARD chain counters for debugging.
672    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    /// Dump conntrack table from all NAT routers (Docker NAT only)
693    ///
694    /// Shows active UDP connection tracking entries for debugging NAT issues.
695    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    /// Dump routing tables from all peer containers (Docker NAT only)
716    ///
717    /// Shows ip route output for debugging routing issues.
718    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    /// Directory containing all peer state/logs for this test network run.
772    pub fn run_root(&self) -> &std::path::Path {
773        &self.run_root
774    }
775}
776
777/// Snapshot describing diagnostics collected across the network at a moment in time.
778#[derive(Debug, Clone, Serialize, Deserialize)]
779pub struct NetworkDiagnosticsSnapshot {
780    pub collected_at: chrono::DateTime<Utc>,
781    pub peers: Vec<PeerDiagnosticsSnapshot>,
782}
783
784/// Diagnostic information for a single peer.
785#[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/// Snapshot describing a peer's ring metadata and adjacency.
817#[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
831/// Convert a diagnostics snapshot into the ring snapshot format used by the visualization.
832pub 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
863/// Render a ring visualization from a saved diagnostics snapshot (e.g. a large soak run).
864pub 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/// Contract-specific status for a peer when a contract key is provided.
891#[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/// Aggregated metrics that help reason about small-world properties.
930#[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/// Network topology information
959#[derive(Debug, Clone, Serialize, Deserialize)]
960pub struct NetworkTopology {
961    pub peers: Vec<PeerInfo>,
962    pub connections: Vec<Connection>,
963}
964
965/// Information about a peer in the network
966#[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/// A connection between two peers
974#[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        // Create a ContractKey with placeholder code hash for the diagnostics API.
1006        // The code hash is not used for contract lookup/filtering, only the instance ID matters.
1007        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        // Create placeholder key for contract_states lookup (keyed by ContractKey)
1094        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        // SubscriptionInfo.contract_key is now ContractInstanceId
1106        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(); // 0.1 buckets up to 0.5
1201    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 &lt;5% / &lt;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}