freenet_test_network/
network.rs

1use crate::{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/// A test network consisting of gateways and peer nodes
22pub struct TestNetwork {
23    pub(crate) gateways: Vec<TestPeer>,
24    pub(crate) peers: Vec<TestPeer>,
25    pub(crate) min_connectivity: f64,
26    pub(crate) run_root: PathBuf,
27}
28
29impl TestNetwork {
30    /// Create a new network builder
31    pub fn builder() -> crate::builder::NetworkBuilder {
32        crate::builder::NetworkBuilder::new()
33    }
34
35    /// Get a gateway peer by index
36    pub fn gateway(&self, index: usize) -> &TestPeer {
37        &self.gateways[index]
38    }
39
40    /// Get a non-gateway peer by index
41    pub fn peer(&self, index: usize) -> &TestPeer {
42        &self.peers[index]
43    }
44
45    /// Get all gateway WebSocket URLs
46    pub fn gateway_ws_urls(&self) -> Vec<String> {
47        self.gateways.iter().map(|p| p.ws_url()).collect()
48    }
49
50    /// Get all peer WebSocket URLs
51    pub fn peer_ws_urls(&self) -> Vec<String> {
52        self.peers.iter().map(|p| p.ws_url()).collect()
53    }
54
55    /// Wait until the network is ready for use
56    ///
57    /// This checks that peers have formed connections and the network
58    /// is sufficiently connected for testing.
59    pub async fn wait_until_ready(&self) -> Result<()> {
60        self.wait_until_ready_with_timeout(Duration::from_secs(30))
61            .await
62    }
63
64    /// Wait until the network is ready with a custom timeout
65    pub async fn wait_until_ready_with_timeout(&self, timeout: Duration) -> Result<()> {
66        let start = std::time::Instant::now();
67
68        tracing::info!(
69            "Waiting for network connectivity (timeout: {}s, required: {}%)",
70            timeout.as_secs(),
71            (self.min_connectivity * 100.0) as u8
72        );
73
74        loop {
75            if start.elapsed() > timeout {
76                return Err(Error::ConnectivityFailed(format!(
77                    "Network did not reach {}% connectivity within {}s",
78                    (self.min_connectivity * 100.0) as u8,
79                    timeout.as_secs()
80                )));
81            }
82
83            // Check connectivity by querying peers for their connections
84            match self.check_connectivity().await {
85                Ok(ratio) if ratio >= self.min_connectivity => {
86                    tracing::info!("Network ready: {:.1}% connectivity", ratio * 100.0);
87                    return Ok(());
88                }
89                Ok(ratio) => {
90                    tracing::debug!("Network connectivity: {:.1}%", ratio * 100.0);
91                }
92                Err(e) => {
93                    tracing::debug!("Connectivity check failed: {}", e);
94                }
95            }
96
97            tokio::time::sleep(Duration::from_millis(500)).await;
98        }
99    }
100
101    /// Check current network connectivity ratio (0.0 to 1.0)
102    async fn check_connectivity(&self) -> Result<f64> {
103        let all_peers: Vec<_> = self.gateways.iter().chain(self.peers.iter()).collect();
104        let total = all_peers.len();
105
106        if total == 0 {
107            return Ok(1.0);
108        }
109
110        let mut connected_count = 0;
111
112        for peer in &all_peers {
113            match self.query_peer_connections(peer).await {
114                Ok(0) => {
115                    tracing::trace!("{} has no connections (isolated)", peer.id());
116                }
117                Ok(connections) => {
118                    connected_count += 1;
119                    tracing::trace!("{} has {} connections", peer.id(), connections);
120                }
121                Err(e) => {
122                    tracing::debug!("Failed to query {}: {}", peer.id(), e);
123                }
124            }
125        }
126
127        let ratio = connected_count as f64 / total as f64;
128        Ok(ratio)
129    }
130
131    /// Query a single peer for its connection count
132    async fn query_peer_connections(&self, peer: &TestPeer) -> Result<usize> {
133        use tokio_tungstenite::connect_async;
134
135        let url = format!("{}?encodingProtocol=native", peer.ws_url());
136        let (ws_stream, _) =
137            tokio::time::timeout(std::time::Duration::from_secs(5), connect_async(&url))
138                .await
139                .map_err(|_| Error::ConnectivityFailed(format!("Timeout connecting to {}", url)))?
140                .map_err(|e| {
141                    Error::ConnectivityFailed(format!("Failed to connect to {}: {}", url, e))
142                })?;
143
144        let mut client = WebApi::start(ws_stream);
145
146        client
147            .send(ClientRequest::NodeQueries(NodeQuery::ConnectedPeers))
148            .await
149            .map_err(|e| Error::ConnectivityFailed(format!("Failed to send query: {}", e)))?;
150
151        let response = tokio::time::timeout(std::time::Duration::from_secs(5), client.recv())
152            .await
153            .map_err(|_| Error::ConnectivityFailed("Timeout waiting for response".into()))?;
154
155        let result = match response {
156            Ok(HostResponse::QueryResponse(QueryResponse::ConnectedPeers { peers })) => {
157                Ok(peers.len())
158            }
159            Ok(other) => Err(Error::ConnectivityFailed(format!(
160                "Unexpected response: {:?}",
161                other
162            ))),
163            Err(e) => Err(Error::ConnectivityFailed(format!("Query failed: {}", e))),
164        };
165
166        client.disconnect("connectivity probe").await;
167
168        result
169    }
170
171    /// Get the current network topology
172    pub async fn topology(&self) -> Result<NetworkTopology> {
173        // TODO: Query peers for their connections and build topology
174        Ok(NetworkTopology {
175            peers: vec![],
176            connections: vec![],
177        })
178    }
179
180    /// Export network information in JSON format for visualization tools
181    pub fn export_for_viz(&self) -> String {
182        let peers: Vec<_> = self
183            .gateways
184            .iter()
185            .chain(self.peers.iter())
186            .map(|p| {
187                serde_json::json!({
188                    "id": p.id(),
189                    "is_gateway": p.is_gateway(),
190                    "ws_port": p.ws_port,
191                    "network_port": p.network_port,
192                })
193            })
194            .collect();
195
196        serde_json::to_string_pretty(&serde_json::json!({
197            "peers": peers
198        }))
199        .unwrap_or_default()
200    }
201
202    /// Collect diagnostics from every peer, returning a snapshot that can be serialized to JSON
203    /// for offline analysis.
204    pub async fn collect_diagnostics(&self) -> Result<NetworkDiagnosticsSnapshot> {
205        let mut peers = Vec::with_capacity(self.gateways.len() + self.peers.len());
206        for peer in self.gateways.iter().chain(self.peers.iter()) {
207            peers.push(self.query_peer_diagnostics(peer).await);
208        }
209        peers.sort_by(|a, b| a.peer_id.cmp(&b.peer_id));
210        Ok(NetworkDiagnosticsSnapshot {
211            collected_at: Utc::now(),
212            peers,
213        })
214    }
215
216    async fn query_peer_diagnostics(&self, peer: &TestPeer) -> PeerDiagnosticsSnapshot {
217        use tokio_tungstenite::connect_async;
218
219        let mut snapshot = PeerDiagnosticsSnapshot::new(peer);
220        let url = format!("{}?encodingProtocol=native", peer.ws_url());
221        match tokio::time::timeout(std::time::Duration::from_secs(10), connect_async(&url)).await {
222            Ok(Ok((ws_stream, _))) => {
223                let mut client = WebApi::start(ws_stream);
224                let config = NodeDiagnosticsConfig {
225                    include_node_info: true,
226                    include_network_info: true,
227                    include_subscriptions: false,
228                    contract_keys: vec![],
229                    include_system_metrics: true,
230                    include_detailed_peer_info: true,
231                    include_subscriber_peer_ids: false,
232                };
233                if let Err(err) = client
234                    .send(ClientRequest::NodeQueries(NodeQuery::NodeDiagnostics {
235                        config,
236                    }))
237                    .await
238                {
239                    snapshot.error = Some(format!("failed to send diagnostics request: {err}"));
240                    return snapshot;
241                }
242                match tokio::time::timeout(std::time::Duration::from_secs(10), client.recv()).await
243                {
244                    Ok(Ok(HostResponse::QueryResponse(QueryResponse::NodeDiagnostics(
245                        response,
246                    )))) => {
247                        let node_info = response.node_info;
248                        let network_info = response.network_info;
249                        snapshot.peer_id = node_info
250                            .as_ref()
251                            .map(|info| info.peer_id.clone())
252                            .unwrap_or_else(|| peer.id().to_string());
253                        snapshot.is_gateway = node_info
254                            .as_ref()
255                            .map(|info| info.is_gateway)
256                            .unwrap_or_else(|| peer.is_gateway());
257                        snapshot.location =
258                            node_info.as_ref().and_then(|info| info.location.clone());
259                        snapshot.listening_address = node_info
260                            .as_ref()
261                            .and_then(|info| info.listening_address.clone());
262                        if let Some(info) = network_info {
263                            snapshot.active_connections = Some(info.active_connections);
264                            snapshot.connected_peer_ids = info
265                                .connected_peers
266                                .into_iter()
267                                .map(|(peer_id, _)| peer_id)
268                                .collect();
269                        }
270                        snapshot.connected_peers_detailed = response.connected_peers_detailed;
271                        snapshot.system_metrics = response.system_metrics;
272                    }
273                    Ok(Ok(other)) => {
274                        snapshot.error =
275                            Some(format!("unexpected diagnostics response: {:?}", other));
276                    }
277                    Ok(Err(err)) => {
278                        snapshot.error = Some(format!("diagnostics channel error: {err}"));
279                    }
280                    Err(_) => {
281                        snapshot.error = Some("timeout waiting for diagnostics response".into());
282                    }
283                }
284            }
285            Ok(Err(err)) => {
286                snapshot.error = Some(format!("failed to connect websocket: {err}"));
287            }
288            Err(_) => {
289                snapshot.error = Some("timeout establishing diagnostics websocket".into());
290            }
291        }
292        snapshot
293    }
294
295    /// Collect per-peer ring data (locations + adjacency) for visualization/debugging.
296    pub async fn ring_snapshot(&self) -> Result<Vec<RingPeerSnapshot>> {
297        self.collect_ring_snapshot(None).await
298    }
299
300    async fn collect_ring_snapshot(
301        &self,
302        contract_key: Option<&ContractKey>,
303    ) -> Result<Vec<RingPeerSnapshot>> {
304        let mut snapshots = Vec::with_capacity(self.gateways.len() + self.peers.len());
305        for peer in self.gateways.iter().chain(self.peers.iter()) {
306            snapshots.push(query_ring_snapshot(peer, contract_key).await?);
307        }
308        snapshots.sort_by(|a, b| a.id.cmp(&b.id));
309        Ok(snapshots)
310    }
311
312    /// Generate an interactive HTML ring visualization for the current network.
313    pub async fn write_ring_visualization<P: AsRef<Path>>(&self, output_path: P) -> Result<()> {
314        self.write_ring_visualization_internal(output_path, None)
315            .await
316    }
317
318    /// Generate an interactive HTML ring visualization for a specific contract.
319    pub async fn write_ring_visualization_for_contract<P: AsRef<Path>>(
320        &self,
321        output_path: P,
322        contract_id: &str,
323    ) -> Result<()> {
324        let contract_id_owned = contract_id.to_string();
325        let contract_key = ContractKey::from_id(contract_id_owned.clone()).map_err(|err| {
326            Error::Other(anyhow::anyhow!(
327                "invalid contract key {}: {}",
328                contract_id,
329                err
330            ))
331        })?;
332        self.write_ring_visualization_internal(
333            output_path,
334            Some((&contract_key, contract_id_owned.as_str())),
335        )
336        .await
337    }
338
339    async fn write_ring_visualization_internal<P: AsRef<Path>>(
340        &self,
341        output_path: P,
342        contract: Option<(&ContractKey, &str)>,
343    ) -> Result<()> {
344        let (snapshots, contract_viz) = if let Some((contract_key, contract_id)) = contract {
345            let snapshots = self.collect_ring_snapshot(Some(contract_key)).await?;
346            let caching_peers = snapshots
347                .iter()
348                .filter(|peer| {
349                    peer.contract
350                        .as_ref()
351                        .map(|state| state.stores_contract)
352                        .unwrap_or(false)
353                })
354                .map(|peer| peer.id.clone())
355                .collect::<Vec<_>>();
356            let contract_location = contract_location_from_key(contract_key);
357            let flow = self.collect_contract_flow(contract_id, &snapshots)?;
358            let viz = ContractVizData {
359                key: contract_id.to_string(),
360                location: contract_location,
361                caching_peers,
362                put: OperationPath {
363                    edges: flow.put_edges,
364                    completion_peer: flow.put_completion_peer,
365                },
366                update: OperationPath {
367                    edges: flow.update_edges,
368                    completion_peer: flow.update_completion_peer,
369                },
370                errors: flow.errors,
371            };
372            (snapshots, Some(viz))
373        } else {
374            (self.collect_ring_snapshot(None).await?, None)
375        };
376
377        let metrics = compute_ring_metrics(&snapshots);
378        let payload = json!({
379            "generated_at": Utc::now().to_rfc3339(),
380            "run_root": self.run_root.display().to_string(),
381            "nodes": snapshots,
382            "metrics": metrics,
383            "contract": contract_viz,
384        });
385        let data_json = serde_json::to_string(&payload).map_err(|e| Error::Other(e.into()))?;
386        let html = render_ring_template(&data_json);
387        let out_path = output_path.as_ref();
388        if let Some(parent) = out_path.parent() {
389            if !parent.exists() {
390                fs::create_dir_all(parent)?;
391            }
392        }
393        fs::write(out_path, html)?;
394        tracing::info!(path = %out_path.display(), "Wrote ring visualization");
395        Ok(())
396    }
397
398    fn collect_contract_flow(
399        &self,
400        contract_id: &str,
401        peers: &[RingPeerSnapshot],
402    ) -> Result<ContractFlowData> {
403        let mut data = ContractFlowData::default();
404        let logs = self.read_logs()?;
405        for entry in logs {
406            if !entry.message.contains(contract_id) {
407                continue;
408            }
409            if let Some(caps) = PUT_REQUEST_RE.captures(&entry.message) {
410                if &caps["key"] != contract_id {
411                    continue;
412                }
413                data.put_edges.push(ContractOperationEdge {
414                    from: caps["from"].to_string(),
415                    to: caps["to"].to_string(),
416                    timestamp: entry.timestamp.map(|ts| ts.to_rfc3339()),
417                    log_level: entry.level.clone(),
418                    log_source: Some(entry.peer_id.clone()),
419                    message: entry.message.clone(),
420                });
421                continue;
422            }
423            if let Some(caps) = PUT_COMPLETION_RE.captures(&entry.message) {
424                if &caps["key"] != contract_id {
425                    continue;
426                }
427                data.put_completion_peer = Some(caps["peer"].to_string());
428                continue;
429            }
430            if let Some(caps) = UPDATE_PROPAGATION_RE.captures(&entry.message) {
431                if &caps["contract"] != contract_id {
432                    continue;
433                }
434                let from = caps["from"].to_string();
435                let ts = entry.timestamp.map(|ts| ts.to_rfc3339());
436                let level = entry.level.clone();
437                let source = Some(entry.peer_id.clone());
438                let targets = caps["targets"].trim();
439                if !targets.is_empty() {
440                    for prefix in targets.split(',').filter(|s| !s.is_empty()) {
441                        if let Some(resolved) = resolve_peer_id(prefix, peers) {
442                            data.update_edges.push(ContractOperationEdge {
443                                from: from.clone(),
444                                to: resolved,
445                                timestamp: ts.clone(),
446                                log_level: level.clone(),
447                                log_source: source.clone(),
448                                message: entry.message.clone(),
449                            });
450                        }
451                    }
452                }
453                continue;
454            }
455            if let Some(caps) = UPDATE_NO_TARGETS_RE.captures(&entry.message) {
456                if &caps["contract"] != contract_id {
457                    continue;
458                }
459                data.errors.push(entry.message.clone());
460                continue;
461            }
462            if entry.message.contains("update will not propagate") {
463                data.errors.push(entry.message.clone());
464            }
465        }
466        Ok(data)
467    }
468}
469
470impl TestNetwork {
471    pub(crate) fn new(
472        gateways: Vec<TestPeer>,
473        peers: Vec<TestPeer>,
474        min_connectivity: f64,
475        run_root: PathBuf,
476    ) -> Self {
477        Self {
478            gateways,
479            peers,
480            min_connectivity,
481            run_root,
482        }
483    }
484
485    /// Directory containing all peer state/logs for this test network run.
486    pub fn run_root(&self) -> &std::path::Path {
487        &self.run_root
488    }
489}
490
491/// Snapshot describing diagnostics collected across the network at a moment in time.
492#[derive(Debug, Clone, Serialize)]
493pub struct NetworkDiagnosticsSnapshot {
494    pub collected_at: chrono::DateTime<Utc>,
495    pub peers: Vec<PeerDiagnosticsSnapshot>,
496}
497
498/// Diagnostic information for a single peer.
499#[derive(Debug, Clone, Serialize)]
500pub struct PeerDiagnosticsSnapshot {
501    pub peer_id: String,
502    pub is_gateway: bool,
503    pub ws_url: String,
504    pub location: Option<String>,
505    pub listening_address: Option<String>,
506    pub connected_peer_ids: Vec<String>,
507    pub connected_peers_detailed: Vec<ConnectedPeerInfo>,
508    pub active_connections: Option<usize>,
509    pub system_metrics: Option<SystemMetrics>,
510    pub error: Option<String>,
511}
512
513impl PeerDiagnosticsSnapshot {
514    fn new(peer: &TestPeer) -> Self {
515        Self {
516            peer_id: peer.id().to_string(),
517            is_gateway: peer.is_gateway(),
518            ws_url: peer.ws_url(),
519            location: None,
520            listening_address: None,
521            connected_peer_ids: Vec::new(),
522            connected_peers_detailed: Vec::new(),
523            active_connections: None,
524            system_metrics: None,
525            error: None,
526        }
527    }
528}
529
530/// Snapshot describing a peer's ring metadata and adjacency.
531#[derive(Debug, Clone, Serialize)]
532pub struct RingPeerSnapshot {
533    pub id: String,
534    pub is_gateway: bool,
535    pub ws_port: u16,
536    pub network_port: u16,
537    pub network_address: String,
538    #[serde(skip_serializing_if = "Option::is_none")]
539    pub location: Option<f64>,
540    pub connections: Vec<String>,
541    #[serde(skip_serializing_if = "Option::is_none")]
542    pub contract: Option<PeerContractStatus>,
543}
544
545/// Contract-specific status for a peer when a contract key is provided.
546#[derive(Debug, Clone, Serialize)]
547pub struct PeerContractStatus {
548    pub stores_contract: bool,
549    pub subscribed_locally: bool,
550    pub subscriber_peer_ids: Vec<String>,
551    pub subscriber_count: usize,
552}
553
554#[derive(Debug, Clone, Serialize)]
555pub struct ContractVizData {
556    pub key: String,
557    pub location: f64,
558    pub caching_peers: Vec<String>,
559    pub put: OperationPath,
560    pub update: OperationPath,
561    pub errors: Vec<String>,
562}
563
564#[derive(Debug, Clone, Serialize)]
565pub struct OperationPath {
566    pub edges: Vec<ContractOperationEdge>,
567    #[serde(skip_serializing_if = "Option::is_none")]
568    pub completion_peer: Option<String>,
569}
570
571#[derive(Debug, Clone, Serialize)]
572pub struct ContractOperationEdge {
573    pub from: String,
574    pub to: String,
575    #[serde(skip_serializing_if = "Option::is_none")]
576    pub timestamp: Option<String>,
577    #[serde(skip_serializing_if = "Option::is_none")]
578    pub log_level: Option<String>,
579    #[serde(skip_serializing_if = "Option::is_none")]
580    pub log_source: Option<String>,
581    pub message: String,
582}
583
584/// Aggregated metrics that help reason about small-world properties.
585#[derive(Debug, Clone, Serialize)]
586pub struct RingVizMetrics {
587    pub node_count: usize,
588    pub gateway_count: usize,
589    pub edge_count: usize,
590    pub average_degree: f64,
591    #[serde(skip_serializing_if = "Option::is_none")]
592    pub average_ring_distance: Option<f64>,
593    #[serde(skip_serializing_if = "Option::is_none")]
594    pub min_ring_distance: Option<f64>,
595    #[serde(skip_serializing_if = "Option::is_none")]
596    pub max_ring_distance: Option<f64>,
597    #[serde(skip_serializing_if = "Option::is_none")]
598    pub pct_edges_under_5pct: Option<f64>,
599    #[serde(skip_serializing_if = "Option::is_none")]
600    pub pct_edges_under_10pct: Option<f64>,
601    #[serde(skip_serializing_if = "Vec::is_empty")]
602    pub distance_histogram: Vec<RingDistanceBucket>,
603}
604
605#[derive(Debug, Clone, Serialize)]
606pub struct RingDistanceBucket {
607    pub upper_bound: f64,
608    pub count: usize,
609}
610
611/// Network topology information
612#[derive(Debug, Clone, Serialize, Deserialize)]
613pub struct NetworkTopology {
614    pub peers: Vec<PeerInfo>,
615    pub connections: Vec<Connection>,
616}
617
618/// Information about a peer in the network
619#[derive(Debug, Clone, Serialize, Deserialize)]
620pub struct PeerInfo {
621    pub id: String,
622    pub is_gateway: bool,
623    pub ws_port: u16,
624}
625
626/// A connection between two peers
627#[derive(Debug, Clone, Serialize, Deserialize)]
628pub struct Connection {
629    pub from: String,
630    pub to: String,
631}
632
633#[derive(Default)]
634struct ContractFlowData {
635    put_edges: Vec<ContractOperationEdge>,
636    put_completion_peer: Option<String>,
637    update_edges: Vec<ContractOperationEdge>,
638    update_completion_peer: Option<String>,
639    errors: Vec<String>,
640}
641
642async fn query_ring_snapshot(
643    peer: &TestPeer,
644    contract_key: Option<&ContractKey>,
645) -> Result<RingPeerSnapshot> {
646    use tokio_tungstenite::connect_async;
647
648    let url = format!("{}?encodingProtocol=native", peer.ws_url());
649    let (ws_stream, _) = tokio::time::timeout(Duration::from_secs(5), connect_async(&url))
650        .await
651        .map_err(|_| Error::ConnectivityFailed(format!("Timeout connecting to {}", peer.id())))?
652        .map_err(|e| {
653            Error::ConnectivityFailed(format!("Failed to connect to {}: {}", peer.id(), e))
654        })?;
655
656    let mut client = WebApi::start(ws_stream);
657    let diag_config = if let Some(key) = contract_key {
658        NodeDiagnosticsConfig {
659            include_node_info: true,
660            include_network_info: true,
661            include_subscriptions: true,
662            contract_keys: vec![key.clone()],
663            include_system_metrics: false,
664            include_detailed_peer_info: true,
665            include_subscriber_peer_ids: true,
666        }
667    } else {
668        NodeDiagnosticsConfig::basic_status()
669    };
670
671    client
672        .send(ClientRequest::NodeQueries(NodeQuery::NodeDiagnostics {
673            config: diag_config,
674        }))
675        .await
676        .map_err(|e| {
677            Error::ConnectivityFailed(format!(
678                "Failed to send diagnostics to {}: {}",
679                peer.id(),
680                e
681            ))
682        })?;
683
684    let response = tokio::time::timeout(Duration::from_secs(5), client.recv())
685        .await
686        .map_err(|_| {
687            Error::ConnectivityFailed(format!(
688                "Timeout waiting for diagnostics response from {}",
689                peer.id()
690            ))
691        })?;
692
693    let diag = match response {
694        Ok(HostResponse::QueryResponse(QueryResponse::NodeDiagnostics(diag))) => diag,
695        Ok(other) => {
696            client.disconnect("ring snapshot error").await;
697            return Err(Error::ConnectivityFailed(format!(
698                "Unexpected diagnostics response from {}: {:?}",
699                peer.id(),
700                other
701            )));
702        }
703        Err(e) => {
704            client.disconnect("ring snapshot error").await;
705            return Err(Error::ConnectivityFailed(format!(
706                "Diagnostics query failed for {}: {}",
707                peer.id(),
708                e
709            )));
710        }
711    };
712
713    let node_info = diag.node_info.ok_or_else(|| {
714        Error::ConnectivityFailed(format!("{} did not return node_info", peer.id()))
715    })?;
716
717    let location = node_info
718        .location
719        .as_deref()
720        .and_then(|value| value.parse::<f64>().ok());
721
722    let mut connections: Vec<String> = diag
723        .connected_peers_detailed
724        .into_iter()
725        .map(|info| info.peer_id)
726        .collect();
727
728    if connections.is_empty() {
729        if let Some(network_info) = diag.network_info {
730            connections = network_info
731                .connected_peers
732                .into_iter()
733                .map(|(peer_id, _)| peer_id)
734                .collect();
735        }
736    }
737
738    connections.retain(|conn| conn != &node_info.peer_id);
739    connections.sort();
740    connections.dedup();
741
742    let contract_status = contract_key.and_then(|key| {
743        let (stores_contract, subscriber_count, subscriber_peer_ids) =
744            if let Some(state) = diag.contract_states.get(key) {
745                (
746                    true,
747                    state.subscribers as usize,
748                    state.subscriber_peer_ids.clone(),
749                )
750            } else {
751                (false, 0, Vec::new())
752            };
753        let subscribed_locally = diag
754            .subscriptions
755            .iter()
756            .any(|sub| &sub.contract_key == key);
757        if stores_contract || subscribed_locally {
758            Some(PeerContractStatus {
759                stores_contract,
760                subscribed_locally,
761                subscriber_peer_ids,
762                subscriber_count,
763            })
764        } else {
765            None
766        }
767    });
768
769    client.disconnect("ring snapshot complete").await;
770
771    Ok(RingPeerSnapshot {
772        id: node_info.peer_id,
773        is_gateway: node_info.is_gateway,
774        ws_port: peer.ws_port,
775        network_port: peer.network_port,
776        network_address: peer.network_address.clone(),
777        location,
778        connections,
779        contract: contract_status,
780    })
781}
782
783fn compute_ring_metrics(nodes: &[RingPeerSnapshot]) -> RingVizMetrics {
784    let node_count = nodes.len();
785    let gateway_count = nodes.iter().filter(|peer| peer.is_gateway).count();
786    let mut total_degree = 0usize;
787    let mut unique_edges: HashSet<(String, String)> = HashSet::new();
788
789    for node in nodes {
790        total_degree += node.connections.len();
791        for neighbor in &node.connections {
792            if neighbor == &node.id {
793                continue;
794            }
795            let edge = if node.id < *neighbor {
796                (node.id.clone(), neighbor.clone())
797            } else {
798                (neighbor.clone(), node.id.clone())
799            };
800            unique_edges.insert(edge);
801        }
802    }
803
804    let average_degree = if node_count == 0 {
805        0.0
806    } else {
807        total_degree as f64 / node_count as f64
808    };
809
810    let mut location_lookup = HashMap::new();
811    for node in nodes {
812        if let Some(loc) = node.location {
813            location_lookup.insert(node.id.clone(), loc);
814        }
815    }
816
817    let mut distances = Vec::new();
818    for (a, b) in &unique_edges {
819        if let (Some(loc_a), Some(loc_b)) = (location_lookup.get(a), location_lookup.get(b)) {
820            let mut distance = (loc_a - loc_b).abs();
821            if distance > 0.5 {
822                distance = 1.0 - distance;
823            }
824            distances.push(distance);
825        }
826    }
827
828    let average_ring_distance = if distances.is_empty() {
829        None
830    } else {
831        Some(distances.iter().sum::<f64>() / distances.len() as f64)
832    };
833    let min_ring_distance = distances.iter().cloned().reduce(f64::min);
834    let max_ring_distance = distances.iter().cloned().reduce(f64::max);
835    let pct_edges_under_5pct = calculate_percentage(&distances, 0.05);
836    let pct_edges_under_10pct = calculate_percentage(&distances, 0.10);
837
838    let mut distance_histogram = Vec::new();
839    let bucket_bounds = [0.02, 0.05, 0.1, 0.2, 0.3, 0.5];
840    for bound in bucket_bounds {
841        let count = distances.iter().filter(|d| **d <= bound).count();
842        distance_histogram.push(RingDistanceBucket {
843            upper_bound: bound,
844            count,
845        });
846    }
847
848    RingVizMetrics {
849        node_count,
850        gateway_count,
851        edge_count: unique_edges.len(),
852        average_degree,
853        average_ring_distance,
854        min_ring_distance,
855        max_ring_distance,
856        pct_edges_under_5pct,
857        pct_edges_under_10pct,
858        distance_histogram,
859    }
860}
861
862fn calculate_percentage(distances: &[f64], threshold: f64) -> Option<f64> {
863    if distances.is_empty() {
864        return None;
865    }
866    let matching = distances.iter().filter(|value| **value < threshold).count();
867    Some((matching as f64 / distances.len() as f64) * 100.0)
868}
869
870fn resolve_peer_id(prefix: &str, peers: &[RingPeerSnapshot]) -> Option<String> {
871    let needle = prefix.trim().trim_matches(|c| c == '"' || c == '\'');
872    peers
873        .iter()
874        .find(|peer| peer.id.starts_with(needle))
875        .map(|peer| peer.id.clone())
876}
877
878fn contract_location_from_key(key: &ContractKey) -> f64 {
879    let mut value = 0.0;
880    let mut divisor = 256.0;
881    for byte in key.as_bytes() {
882        value += f64::from(*byte) / divisor;
883        divisor *= 256.0;
884    }
885    value.fract()
886}
887
888static PUT_REQUEST_RE: LazyLock<Regex> = LazyLock::new(|| {
889    Regex::new(r"Requesting put for contract (?P<key>\S+) from (?P<from>\S+) to (?P<to>\S+)")
890        .expect("valid regex")
891});
892static PUT_COMPLETION_RE: LazyLock<Regex> = LazyLock::new(|| {
893    Regex::new(r"Peer completed contract value put,.*key: (?P<key>\S+),.*this_peer: (?P<peer>\S+)")
894        .expect("valid regex")
895});
896static UPDATE_PROPAGATION_RE: LazyLock<Regex> = LazyLock::new(|| {
897    Regex::new(
898        r"UPDATE_PROPAGATION: contract=(?P<contract>\S+) from=(?P<from>\S+) targets=(?P<targets>[^ ]*)\s+count=",
899    )
900    .expect("valid regex")
901});
902static UPDATE_NO_TARGETS_RE: LazyLock<Regex> = LazyLock::new(|| {
903    Regex::new(r"UPDATE_PROPAGATION: contract=(?P<contract>\S+) from=(?P<from>\S+) NO_TARGETS")
904        .expect("valid regex")
905});
906
907const HTML_TEMPLATE: &str = r###"<!DOCTYPE html>
908<html lang="en">
909<head>
910  <meta charset="UTF-8" />
911  <title>Freenet Ring Topology</title>
912  <style>
913    :root {
914      color-scheme: dark;
915      font-family: "Inter", "Helvetica Neue", Arial, sans-serif;
916    }
917    body {
918      background: #020617;
919      color: #e2e8f0;
920      margin: 0;
921      padding: 32px;
922      display: flex;
923      justify-content: center;
924      min-height: 100vh;
925    }
926    #container {
927      max-width: 1000px;
928      width: 100%;
929      background: #0f172a;
930      border-radius: 18px;
931      padding: 28px 32px 40px;
932      box-shadow: 0 40px 120px rgba(2, 6, 23, 0.85);
933    }
934    h1 {
935      margin: 0 0 8px;
936      font-size: 26px;
937      letter-spacing: 0.4px;
938      color: #f8fafc;
939    }
940    #meta {
941      margin: 0 0 20px;
942      color: #94a3b8;
943      font-size: 14px;
944    }
945    canvas {
946      width: 100%;
947      max-width: 900px;
948      height: auto;
949      background: radial-gradient(circle, #020617 0%, #0f172a 70%, #020617 100%);
950      border-radius: 16px;
951      border: 1px solid rgba(148, 163, 184, 0.1);
952      display: block;
953      margin: 0 auto 24px;
954    }
955    #metrics {
956      display: grid;
957      grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
958      gap: 12px;
959      margin-bottom: 20px;
960      font-size: 14px;
961    }
962    #metrics div {
963      background: #1e293b;
964      padding: 12px 14px;
965      border-radius: 12px;
966      border: 1px solid rgba(148, 163, 184, 0.15);
967    }
968    .legend {
969      display: flex;
970      flex-wrap: wrap;
971      gap: 18px;
972      margin-bottom: 24px;
973      font-size: 14px;
974      color: #cbd5f5;
975    }
976    .legend span {
977      display: flex;
978      align-items: center;
979      gap: 6px;
980    }
981    .legend .line {
982      width: 30px;
983      height: 6px;
984      border-radius: 3px;
985      display: inline-block;
986    }
987    .legend .put-line {
988      background: #22c55e;
989    }
990    .legend .update-line {
991      background: #f59e0b;
992    }
993    .legend .contract-dot {
994      background: #a855f7;
995      box-shadow: 0 0 6px rgba(168, 85, 247, 0.7);
996    }
997    .legend .cached-dot {
998      background: #38bdf8;
999    }
1000    .legend .peer-dot {
1001      background: #64748b;
1002    }
1003    .legend .gateway-dot {
1004      background: #f97316;
1005    }
1006    .dot {
1007      width: 14px;
1008      height: 14px;
1009      border-radius: 50%;
1010      display: inline-block;
1011    }
1012    #contract-info {
1013      background: rgba(15, 23, 42, 0.7);
1014      border-radius: 12px;
1015      border: 1px solid rgba(148, 163, 184, 0.15);
1016      padding: 14px;
1017      margin-bottom: 20px;
1018      font-size: 13px;
1019      color: #cbd5f5;
1020    }
1021    #contract-info h2 {
1022      margin: 0 0 8px;
1023      font-size: 16px;
1024      color: #f8fafc;
1025    }
1026    #contract-info .op-block {
1027      margin-top: 10px;
1028    }
1029    #contract-info ol {
1030      padding-left: 20px;
1031      margin: 6px 0;
1032    }
1033    #contract-info .errors {
1034      margin-top: 10px;
1035      color: #f97316;
1036    }
1037    #peer-list {
1038      border-top: 1px solid rgba(148, 163, 184, 0.15);
1039      padding-top: 18px;
1040      display: grid;
1041      grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
1042      gap: 14px;
1043      font-size: 13px;
1044    }
1045    .peer {
1046      background: rgba(15, 23, 42, 0.75);
1047      padding: 12px 14px;
1048      border-radius: 12px;
1049      border: 1px solid rgba(148, 163, 184, 0.2);
1050    }
1051    .peer strong {
1052      display: block;
1053      font-size: 13px;
1054      color: #f1f5f9;
1055      margin-bottom: 6px;
1056      word-break: break-all;
1057    }
1058    .peer span {
1059      display: block;
1060      color: #94a3b8;
1061    }
1062  </style>
1063</head>
1064<body>
1065  <div id="container">
1066    <h1>Freenet Ring Topology</h1>
1067    <p id="meta"></p>
1068    <canvas id="ring" width="900" height="900"></canvas>
1069    <div id="metrics"></div>
1070    <div class="legend">
1071      <span><span class="dot peer-dot"></span>Peer</span>
1072      <span><span class="dot gateway-dot"></span>Gateway</span>
1073      <span><span class="dot cached-dot"></span>Cached peer</span>
1074      <span><span class="dot contract-dot"></span>Contract</span>
1075      <span><span class="line put-line"></span>PUT path</span>
1076      <span><span class="line update-line"></span>UPDATE path</span>
1077      <span><span class="dot" style="background:#475569"></span>Connection</span>
1078    </div>
1079    <div id="contract-info"></div>
1080    <div id="peer-list"></div>
1081  </div>
1082  <script>
1083    const vizData = __DATA__;
1084    const contractData = vizData.contract ?? null;
1085    const metaEl = document.getElementById("meta");
1086    metaEl.textContent = vizData.nodes.length
1087      ? `Captured ${vizData.nodes.length} nodes · ${vizData.metrics.edge_count} edges · Generated ${vizData.generated_at} · Run root: ${vizData.run_root}`
1088      : "No peers reported diagnostics data.";
1089
1090    const metricsEl = document.getElementById("metrics");
1091    const fmtNumber = (value, digits = 2) => (typeof value === "number" ? value.toFixed(digits) : "n/a");
1092    const fmtPercent = (value, digits = 1) => (typeof value === "number" ? `${value.toFixed(digits)}%` : "n/a");
1093
1094    const histogram = vizData.metrics.distance_histogram ?? [];
1095    const maxBucketCount = histogram.reduce((acc, b) => Math.max(acc, b.count), 0);
1096    const histoHtml = histogram.length
1097      ? `<div style="grid-column: 1 / -1; margin-top: 8px;">
1098            <strong>Ring distance histogram (cumulative)</strong>
1099            <div style="display:flex; flex-direction:column; gap:4px; margin-top:6px;">
1100              ${histogram
1101                .map((b) => {
1102                  const widthPct = maxBucketCount > 0 ? (b.count / maxBucketCount) * 100 : 0;
1103                  return `<div style="display:flex; align-items:center; gap:8px;">
1104                    <div style="width:180px; color:#94a3b8;">&le; ${(b.upper_bound * 100).toFixed(1)}% ring</div>
1105                    <div style="flex:1; height:8px; background:rgba(148,163,184,0.2); border-radius:4px; position:relative;">
1106                      <span style="display:block; height:8px; width:${widthPct}%; background:#38bdf8; border-radius:4px;"></span>
1107                    </div>
1108                    <span style="width:50px; text-align:right; color:#cbd5f5;">${b.count}</span>
1109                  </div>`;
1110                })
1111                .join("")}
1112            </div>
1113         </div>`
1114      : "";
1115
1116    metricsEl.innerHTML = `
1117      <div><strong>Total nodes</strong><br/>${vizData.metrics.node_count} (gateways: ${vizData.metrics.gateway_count})</div>
1118      <div><strong>Edges</strong><br/>${vizData.metrics.edge_count}</div>
1119      <div><strong>Average degree</strong><br/>${fmtNumber(vizData.metrics.average_degree)}</div>
1120      <div><strong>Average ring distance</strong><br/>${fmtNumber(vizData.metrics.average_ring_distance, 3)}</div>
1121      <div><strong>Min / Max ring distance</strong><br/>${fmtNumber(vizData.metrics.min_ring_distance, 3)} / ${fmtNumber(vizData.metrics.max_ring_distance, 3)}</div>
1122      <div><strong>Edges &lt;5% / &lt;10%</strong><br/>${fmtPercent(vizData.metrics.pct_edges_under_5pct)} / ${fmtPercent(vizData.metrics.pct_edges_under_10pct)}</div>
1123      ${histoHtml}
1124    `;
1125
1126    const peersEl = document.getElementById("peer-list");
1127    peersEl.innerHTML = vizData.nodes
1128      .map((node) => {
1129        const role = node.is_gateway ? "Gateway" : "Peer";
1130        const loc = typeof node.location === "number" ? node.location.toFixed(6) : "unknown";
1131        return `<div class="peer">
1132          <strong>${node.id}</strong>
1133          <span>${role}</span>
1134          <span>Location: ${loc}</span>
1135          <span>Degree: ${node.connections.length}</span>
1136        </div>`;
1137      })
1138      .join("");
1139
1140    const contractInfoEl = document.getElementById("contract-info");
1141    const canvas = document.getElementById("ring");
1142    const ctx = canvas.getContext("2d");
1143    const width = canvas.width;
1144    const height = canvas.height;
1145    const center = { x: width / 2, y: height / 2 };
1146    const radius = Math.min(width, height) * 0.37;
1147    const cachingPeers = new Set(contractData?.caching_peers ?? []);
1148    const putEdges = contractData?.put?.edges ?? [];
1149    const updateEdges = contractData?.update?.edges ?? [];
1150    const contractLocation = typeof (contractData?.location) === "number" ? contractData.location : null;
1151
1152    function shortId(id) {
1153      return id.slice(-6);
1154    }
1155
1156    function angleFromLocation(location) {
1157      return (location % 1) * Math.PI * 2;
1158    }
1159
1160    function polarToCartesian(angle, r = radius) {
1161      const theta = angle - Math.PI / 2;
1162      return {
1163        x: center.x + r * Math.cos(theta),
1164        y: center.y + r * Math.sin(theta),
1165      };
1166    }
1167
1168    const nodes = vizData.nodes.map((node, idx) => {
1169      const angle = typeof node.location === "number"
1170        ? angleFromLocation(node.location)
1171        : (idx / vizData.nodes.length) * Math.PI * 2;
1172      const coords = polarToCartesian(angle);
1173      return {
1174        ...node,
1175        angle,
1176        x: coords.x,
1177        y: coords.y,
1178      };
1179    });
1180    const nodeMap = new Map(nodes.map((node) => [node.id, node]));
1181
1182    function drawRingBase() {
1183      ctx.save();
1184      ctx.strokeStyle = "#475569";
1185      ctx.lineWidth = 2;
1186      ctx.beginPath();
1187      ctx.arc(center.x, center.y, radius, 0, Math.PI * 2);
1188      ctx.stroke();
1189      ctx.setLineDash([4, 8]);
1190      ctx.strokeStyle = "rgba(148,163,184,0.3)";
1191      [0, 0.25, 0.5, 0.75].forEach((loc) => {
1192        const angle = angleFromLocation(loc);
1193        const inner = polarToCartesian(angle, radius * 0.9);
1194        const outer = polarToCartesian(angle, radius * 1.02);
1195        ctx.beginPath();
1196        ctx.moveTo(inner.x, inner.y);
1197        ctx.lineTo(outer.x, outer.y);
1198        ctx.stroke();
1199        ctx.fillStyle = "#94a3b8";
1200        ctx.font = "11px 'Fira Code', monospace";
1201        ctx.textAlign = "center";
1202        ctx.fillText(loc.toFixed(2), outer.x, outer.y - 6);
1203      });
1204      ctx.restore();
1205    }
1206
1207    function drawConnections() {
1208      ctx.save();
1209      ctx.strokeStyle = "rgba(148, 163, 184, 0.35)";
1210      ctx.lineWidth = 1.2;
1211      const drawn = new Set();
1212      nodes.forEach((node) => {
1213        node.connections.forEach((neighborId) => {
1214          const neighbor = nodeMap.get(neighborId);
1215          if (!neighbor) return;
1216          const key = node.id < neighborId ? `${node.id}|${neighborId}` : `${neighborId}|${node.id}`;
1217          if (drawn.has(key)) return;
1218          drawn.add(key);
1219          ctx.beginPath();
1220          ctx.moveTo(node.x, node.y);
1221          ctx.lineTo(neighbor.x, neighbor.y);
1222          ctx.stroke();
1223        });
1224      });
1225      ctx.restore();
1226    }
1227
1228    function drawContractMarker() {
1229      if (typeof contractLocation !== "number") return;
1230      const pos = polarToCartesian(angleFromLocation(contractLocation));
1231      ctx.save();
1232      ctx.fillStyle = "#a855f7";
1233      ctx.beginPath();
1234      ctx.arc(pos.x, pos.y, 9, 0, Math.PI * 2);
1235      ctx.fill();
1236      ctx.lineWidth = 2;
1237      ctx.strokeStyle = "#f3e8ff";
1238      ctx.stroke();
1239      ctx.fillStyle = "#f3e8ff";
1240      ctx.font = "10px 'Fira Code', monospace";
1241      ctx.textAlign = "center";
1242      ctx.fillText("contract", pos.x, pos.y - 16);
1243      ctx.restore();
1244    }
1245
1246    function drawPeers() {
1247      nodes.forEach((node) => {
1248        const baseColor = node.is_gateway ? "#f97316" : "#64748b";
1249        const fill = cachingPeers.has(node.id) ? "#38bdf8" : baseColor;
1250        ctx.save();
1251        ctx.beginPath();
1252        ctx.fillStyle = fill;
1253        ctx.arc(node.x, node.y, 6.5, 0, Math.PI * 2);
1254        ctx.fill();
1255        ctx.lineWidth = 1.6;
1256        ctx.strokeStyle = "#0f172a";
1257        ctx.stroke();
1258        ctx.fillStyle = "#f8fafc";
1259        ctx.font = "12px 'Fira Code', monospace";
1260        ctx.textAlign = "center";
1261        ctx.fillText(shortId(node.id), node.x, node.y - 14);
1262        const locText = typeof node.location === "number" ? node.location.toFixed(3) : "n/a";
1263        ctx.fillStyle = "#94a3b8";
1264        ctx.font = "10px 'Fira Code', monospace";
1265        ctx.fillText(locText, node.x, node.y + 20);
1266        ctx.restore();
1267      });
1268    }
1269
1270    function drawOperationEdges(edges, color, dashed = false) {
1271      if (!edges.length) return;
1272      ctx.save();
1273      ctx.strokeStyle = color;
1274      ctx.lineWidth = 3;
1275      if (dashed) ctx.setLineDash([10, 8]);
1276      edges.forEach((edge, idx) => {
1277        const from = nodeMap.get(edge.from);
1278        const to = nodeMap.get(edge.to);
1279        if (!from || !to) return;
1280        ctx.beginPath();
1281        ctx.moveTo(from.x, from.y);
1282        ctx.lineTo(to.x, to.y);
1283        ctx.stroke();
1284        drawArrowhead(from, to, color);
1285        const midX = (from.x + to.x) / 2;
1286        const midY = (from.y + to.y) / 2;
1287        ctx.fillStyle = color;
1288        ctx.font = "11px 'Fira Code', monospace";
1289        ctx.fillText(`#${idx + 1}`, midX, midY - 4);
1290      });
1291      ctx.restore();
1292    }
1293
1294    function drawArrowhead(from, to, color) {
1295      const angle = Math.atan2(to.y - from.y, to.x - from.x);
1296      const length = 12;
1297      const spread = Math.PI / 6;
1298      ctx.save();
1299      ctx.fillStyle = color;
1300      ctx.beginPath();
1301      ctx.moveTo(to.x, to.y);
1302      ctx.lineTo(
1303        to.x - length * Math.cos(angle - spread),
1304        to.y - length * Math.sin(angle - spread)
1305      );
1306      ctx.lineTo(
1307        to.x - length * Math.cos(angle + spread),
1308        to.y - length * Math.sin(angle + spread)
1309      );
1310      ctx.closePath();
1311      ctx.fill();
1312      ctx.restore();
1313    }
1314
1315    function renderOperationList(title, edges) {
1316      if (!edges.length) {
1317        return `<div class="op-block"><strong>${title}:</strong> none recorded</div>`;
1318      }
1319      const items = edges
1320        .map((edge, idx) => {
1321          const ts = edge.timestamp
1322            ? new Date(edge.timestamp).toLocaleTimeString()
1323            : "no-ts";
1324          return `<li>#${idx + 1}: ${shortId(edge.from)} → ${shortId(edge.to)} (${ts})</li>`;
1325        })
1326        .join("");
1327      return `<div class="op-block"><strong>${title}:</strong><ol>${items}</ol></div>`;
1328    }
1329
1330    function renderContractInfo(data) {
1331      if (!data) {
1332        contractInfoEl.textContent = "No contract-specific data collected for this snapshot.";
1333        return;
1334      }
1335      const errors = data.errors?.length
1336        ? `<div class="errors"><strong>Notable events:</strong><ul>${data.errors
1337            .map((msg) => `<li>${msg}</li>`)
1338            .join("")}</ul></div>`
1339        : "";
1340      const putSummary = renderOperationList("PUT path", data.put.edges);
1341      const updateSummary = renderOperationList("UPDATE path", data.update.edges);
1342      contractInfoEl.innerHTML = `
1343        <h2>Contract ${data.key}</h2>
1344        <div><strong>Cached peers:</strong> ${data.caching_peers.length}</div>
1345        <div><strong>PUT completion:</strong> ${
1346          data.put.completion_peer ?? "pending"
1347        }</div>
1348        <div><strong>UPDATE completion:</strong> ${
1349          data.update.completion_peer ?? "pending"
1350        }</div>
1351        ${putSummary}
1352        ${updateSummary}
1353        ${errors}
1354      `;
1355    }
1356
1357    renderContractInfo(contractData);
1358
1359    if (nodes.length) {
1360      drawRingBase();
1361      drawConnections();
1362      drawContractMarker();
1363      drawOperationEdges(putEdges, "#22c55e", false);
1364      drawOperationEdges(updateEdges, "#f59e0b", true);
1365      drawPeers();
1366    } else {
1367      ctx.fillStyle = "#94a3b8";
1368      ctx.font = "16px Inter, sans-serif";
1369      ctx.fillText("No diagnostics available to render ring.", center.x - 140, center.y);
1370    }
1371  </script>
1372</body>
1373</html>
1374"###;
1375
1376fn render_ring_template(data_json: &str) -> String {
1377    HTML_TEMPLATE.replace("__DATA__", data_json)
1378}
1379
1380#[cfg(test)]
1381mod tests {
1382    use super::{compute_ring_metrics, RingPeerSnapshot};
1383
1384    #[test]
1385    fn ring_metrics_basic() {
1386        let nodes = vec![
1387            RingPeerSnapshot {
1388                id: "a".into(),
1389                is_gateway: false,
1390                ws_port: 0,
1391                network_port: 0,
1392                network_address: "127.1.0.1".into(),
1393                location: Some(0.1),
1394                connections: vec!["b".into(), "c".into()],
1395                contract: None,
1396            },
1397            RingPeerSnapshot {
1398                id: "b".into(),
1399                is_gateway: false,
1400                ws_port: 0,
1401                network_port: 0,
1402                network_address: "127.2.0.1".into(),
1403                location: Some(0.2),
1404                connections: vec!["a".into()],
1405                contract: None,
1406            },
1407            RingPeerSnapshot {
1408                id: "c".into(),
1409                is_gateway: true,
1410                ws_port: 0,
1411                network_port: 0,
1412                network_address: "127.3.0.1".into(),
1413                location: Some(0.8),
1414                connections: vec!["a".into()],
1415                contract: None,
1416            },
1417        ];
1418
1419        let metrics = compute_ring_metrics(&nodes);
1420        assert_eq!(metrics.node_count, 3);
1421        assert_eq!(metrics.gateway_count, 1);
1422        assert_eq!(metrics.edge_count, 2);
1423        assert!((metrics.average_degree - (4.0 / 3.0)).abs() < f64::EPSILON);
1424        assert!(metrics.average_ring_distance.is_some());
1425    }
1426}