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!("--- 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    /// Dump logs related to connection establishment and NAT traversal
616    ///
617    /// Filters for: hole punch, NAT, connect, acceptor, joiner, handshake
618    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    /// Dump iptables counters from all NAT routers (Docker NAT only)
657    ///
658    /// Prints NAT table rules and FORWARD chain counters for debugging.
659    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    /// Dump conntrack table from all NAT routers (Docker NAT only)
680    ///
681    /// Shows active UDP connection tracking entries for debugging NAT issues.
682    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    /// Dump routing tables from all peer containers (Docker NAT only)
703    ///
704    /// Shows ip route output for debugging routing issues.
705    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    /// Directory containing all peer state/logs for this test network run.
759    pub fn run_root(&self) -> &std::path::Path {
760        &self.run_root
761    }
762}
763
764/// Snapshot describing diagnostics collected across the network at a moment in time.
765#[derive(Debug, Clone, Serialize, Deserialize)]
766pub struct NetworkDiagnosticsSnapshot {
767    pub collected_at: chrono::DateTime<Utc>,
768    pub peers: Vec<PeerDiagnosticsSnapshot>,
769}
770
771/// Diagnostic information for a single peer.
772#[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/// Snapshot describing a peer's ring metadata and adjacency.
804#[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
818/// Convert a diagnostics snapshot into the ring snapshot format used by the visualization.
819pub 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
850/// Render a ring visualization from a saved diagnostics snapshot (e.g. a large soak run).
851pub 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/// Contract-specific status for a peer when a contract key is provided.
878#[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/// Aggregated metrics that help reason about small-world properties.
917#[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/// Network topology information
946#[derive(Debug, Clone, Serialize, Deserialize)]
947pub struct NetworkTopology {
948    pub peers: Vec<PeerInfo>,
949    pub connections: Vec<Connection>,
950}
951
952/// Information about a peer in the network
953#[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/// A connection between two peers
961#[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        // Create a ContractKey with placeholder code hash for the diagnostics API.
993        // The code hash is not used for contract lookup/filtering, only the instance ID matters.
994        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        // Create placeholder key for contract_states lookup (keyed by ContractKey)
1082        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        // SubscriptionInfo.contract_key is now ContractInstanceId
1094        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(); // 0.1 buckets up to 0.5
1192    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 &lt;5% / &lt;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}