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