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