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