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, Deserialize)]
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, Deserialize)]
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/// Convert a diagnostics snapshot into the ring snapshot format used by the visualization.
546pub fn ring_nodes_from_diagnostics(snapshot: &NetworkDiagnosticsSnapshot) -> Vec<RingPeerSnapshot> {
547    snapshot
548        .peers
549        .iter()
550        .map(|peer| {
551            let location = peer
552                .location
553                .as_deref()
554                .and_then(|loc| loc.parse::<f64>().ok());
555            let (network_address, network_port) =
556                parse_listening_address(peer.listening_address.as_ref(), &peer.ws_url);
557            let ws_port = parse_ws_port(&peer.ws_url);
558            let mut connections = peer.connected_peer_ids.clone();
559            connections.retain(|id| id != &peer.peer_id);
560            connections.sort();
561            connections.dedup();
562
563            RingPeerSnapshot {
564                id: peer.peer_id.clone(),
565                is_gateway: peer.is_gateway,
566                ws_port,
567                network_port,
568                network_address,
569                location,
570                connections,
571                contract: None,
572            }
573        })
574        .collect()
575}
576
577/// Render a ring visualization from a saved diagnostics snapshot (e.g. a large soak run).
578pub fn write_ring_visualization_from_diagnostics<P: AsRef<Path>, Q: AsRef<Path>>(
579    snapshot: &NetworkDiagnosticsSnapshot,
580    run_root: P,
581    output_path: Q,
582) -> Result<()> {
583    let nodes = ring_nodes_from_diagnostics(snapshot);
584    let metrics = compute_ring_metrics(&nodes);
585    let payload = json!({
586        "generated_at": snapshot.collected_at.to_rfc3339(),
587        "run_root": run_root.as_ref().display().to_string(),
588        "nodes": nodes,
589        "metrics": metrics,
590    });
591    let data_json = serde_json::to_string(&payload).map_err(|e| Error::Other(e.into()))?;
592    let html = render_ring_template(&data_json);
593    let out_path = output_path.as_ref();
594    if let Some(parent) = out_path.parent() {
595        if !parent.exists() {
596            fs::create_dir_all(parent)?;
597        }
598    }
599    fs::write(out_path, html)?;
600    tracing::info!(path = %out_path.display(), "Wrote ring visualization from diagnostics");
601    Ok(())
602}
603
604/// Contract-specific status for a peer when a contract key is provided.
605#[derive(Debug, Clone, Serialize)]
606pub struct PeerContractStatus {
607    pub stores_contract: bool,
608    pub subscribed_locally: bool,
609    pub subscriber_peer_ids: Vec<String>,
610    pub subscriber_count: usize,
611}
612
613#[derive(Debug, Clone, Serialize)]
614pub struct ContractVizData {
615    pub key: String,
616    pub location: f64,
617    pub caching_peers: Vec<String>,
618    pub put: OperationPath,
619    pub update: OperationPath,
620    pub errors: Vec<String>,
621}
622
623#[derive(Debug, Clone, Serialize)]
624pub struct OperationPath {
625    pub edges: Vec<ContractOperationEdge>,
626    #[serde(skip_serializing_if = "Option::is_none")]
627    pub completion_peer: Option<String>,
628}
629
630#[derive(Debug, Clone, Serialize)]
631pub struct ContractOperationEdge {
632    pub from: String,
633    pub to: String,
634    #[serde(skip_serializing_if = "Option::is_none")]
635    pub timestamp: Option<String>,
636    #[serde(skip_serializing_if = "Option::is_none")]
637    pub log_level: Option<String>,
638    #[serde(skip_serializing_if = "Option::is_none")]
639    pub log_source: Option<String>,
640    pub message: String,
641}
642
643/// Aggregated metrics that help reason about small-world properties.
644#[derive(Debug, Clone, Serialize)]
645pub struct RingVizMetrics {
646    pub node_count: usize,
647    pub gateway_count: usize,
648    pub edge_count: usize,
649    pub average_degree: f64,
650    #[serde(skip_serializing_if = "Option::is_none")]
651    pub average_ring_distance: Option<f64>,
652    #[serde(skip_serializing_if = "Option::is_none")]
653    pub min_ring_distance: Option<f64>,
654    #[serde(skip_serializing_if = "Option::is_none")]
655    pub max_ring_distance: Option<f64>,
656    #[serde(skip_serializing_if = "Option::is_none")]
657    pub pct_edges_under_5pct: Option<f64>,
658    #[serde(skip_serializing_if = "Option::is_none")]
659    pub pct_edges_under_10pct: Option<f64>,
660    #[serde(skip_serializing_if = "Option::is_none")]
661    pub short_over_long_ratio: Option<f64>,
662    #[serde(skip_serializing_if = "Vec::is_empty")]
663    pub distance_histogram: Vec<RingDistanceBucket>,
664}
665
666#[derive(Debug, Clone, Serialize)]
667pub struct RingDistanceBucket {
668    pub upper_bound: f64,
669    pub count: usize,
670}
671
672/// Network topology information
673#[derive(Debug, Clone, Serialize, Deserialize)]
674pub struct NetworkTopology {
675    pub peers: Vec<PeerInfo>,
676    pub connections: Vec<Connection>,
677}
678
679/// Information about a peer in the network
680#[derive(Debug, Clone, Serialize, Deserialize)]
681pub struct PeerInfo {
682    pub id: String,
683    pub is_gateway: bool,
684    pub ws_port: u16,
685}
686
687/// A connection between two peers
688#[derive(Debug, Clone, Serialize, Deserialize)]
689pub struct Connection {
690    pub from: String,
691    pub to: String,
692}
693
694#[derive(Default)]
695struct ContractFlowData {
696    put_edges: Vec<ContractOperationEdge>,
697    put_completion_peer: Option<String>,
698    update_edges: Vec<ContractOperationEdge>,
699    update_completion_peer: Option<String>,
700    errors: Vec<String>,
701}
702
703async fn query_ring_snapshot(
704    peer: &TestPeer,
705    contract_key: Option<&ContractKey>,
706) -> Result<RingPeerSnapshot> {
707    use tokio_tungstenite::connect_async;
708
709    let url = format!("{}?encodingProtocol=native", peer.ws_url());
710    let (ws_stream, _) = tokio::time::timeout(Duration::from_secs(5), connect_async(&url))
711        .await
712        .map_err(|_| Error::ConnectivityFailed(format!("Timeout connecting to {}", peer.id())))?
713        .map_err(|e| {
714            Error::ConnectivityFailed(format!("Failed to connect to {}: {}", peer.id(), e))
715        })?;
716
717    let mut client = WebApi::start(ws_stream);
718    let diag_config = if let Some(key) = contract_key {
719        NodeDiagnosticsConfig {
720            include_node_info: true,
721            include_network_info: true,
722            include_subscriptions: true,
723            contract_keys: vec![key.clone()],
724            include_system_metrics: false,
725            include_detailed_peer_info: true,
726            include_subscriber_peer_ids: true,
727        }
728    } else {
729        NodeDiagnosticsConfig::basic_status()
730    };
731
732    client
733        .send(ClientRequest::NodeQueries(NodeQuery::NodeDiagnostics {
734            config: diag_config,
735        }))
736        .await
737        .map_err(|e| {
738            Error::ConnectivityFailed(format!(
739                "Failed to send diagnostics to {}: {}",
740                peer.id(),
741                e
742            ))
743        })?;
744
745    let response = tokio::time::timeout(Duration::from_secs(5), client.recv())
746        .await
747        .map_err(|_| {
748            Error::ConnectivityFailed(format!(
749                "Timeout waiting for diagnostics response from {}",
750                peer.id()
751            ))
752        })?;
753
754    let diag = match response {
755        Ok(HostResponse::QueryResponse(QueryResponse::NodeDiagnostics(diag))) => diag,
756        Ok(other) => {
757            client.disconnect("ring snapshot error").await;
758            return Err(Error::ConnectivityFailed(format!(
759                "Unexpected diagnostics response from {}: {:?}",
760                peer.id(),
761                other
762            )));
763        }
764        Err(e) => {
765            client.disconnect("ring snapshot error").await;
766            return Err(Error::ConnectivityFailed(format!(
767                "Diagnostics query failed for {}: {}",
768                peer.id(),
769                e
770            )));
771        }
772    };
773
774    let node_info = diag.node_info.ok_or_else(|| {
775        Error::ConnectivityFailed(format!("{} did not return node_info", peer.id()))
776    })?;
777
778    let location = node_info
779        .location
780        .as_deref()
781        .and_then(|value| value.parse::<f64>().ok());
782
783    let mut connections: Vec<String> = diag
784        .connected_peers_detailed
785        .into_iter()
786        .map(|info| info.peer_id)
787        .collect();
788
789    if connections.is_empty() {
790        if let Some(network_info) = diag.network_info {
791            connections = network_info
792                .connected_peers
793                .into_iter()
794                .map(|(peer_id, _)| peer_id)
795                .collect();
796        }
797    }
798
799    connections.retain(|conn| conn != &node_info.peer_id);
800    connections.sort();
801    connections.dedup();
802
803    let contract_status = contract_key.and_then(|key| {
804        let (stores_contract, subscriber_count, subscriber_peer_ids) =
805            if let Some(state) = diag.contract_states.get(key) {
806                (
807                    true,
808                    state.subscribers as usize,
809                    state.subscriber_peer_ids.clone(),
810                )
811            } else {
812                (false, 0, Vec::new())
813            };
814        let subscribed_locally = diag
815            .subscriptions
816            .iter()
817            .any(|sub| &sub.contract_key == key);
818        if stores_contract || subscribed_locally {
819            Some(PeerContractStatus {
820                stores_contract,
821                subscribed_locally,
822                subscriber_peer_ids,
823                subscriber_count,
824            })
825        } else {
826            None
827        }
828    });
829
830    client.disconnect("ring snapshot complete").await;
831
832    Ok(RingPeerSnapshot {
833        id: node_info.peer_id,
834        is_gateway: node_info.is_gateway,
835        ws_port: peer.ws_port,
836        network_port: peer.network_port,
837        network_address: peer.network_address.clone(),
838        location,
839        connections,
840        contract: contract_status,
841    })
842}
843
844fn compute_ring_metrics(nodes: &[RingPeerSnapshot]) -> RingVizMetrics {
845    let node_count = nodes.len();
846    let gateway_count = nodes.iter().filter(|peer| peer.is_gateway).count();
847    let mut total_degree = 0usize;
848    let mut unique_edges: HashSet<(String, String)> = HashSet::new();
849
850    for node in nodes {
851        total_degree += node.connections.len();
852        for neighbor in &node.connections {
853            if neighbor == &node.id {
854                continue;
855            }
856            let edge = if node.id < *neighbor {
857                (node.id.clone(), neighbor.clone())
858            } else {
859                (neighbor.clone(), node.id.clone())
860            };
861            unique_edges.insert(edge);
862        }
863    }
864
865    let average_degree = if node_count == 0 {
866        0.0
867    } else {
868        total_degree as f64 / node_count as f64
869    };
870
871    let mut location_lookup = HashMap::new();
872    for node in nodes {
873        if let Some(loc) = node.location {
874            location_lookup.insert(node.id.clone(), loc);
875        }
876    }
877
878    let mut distances = Vec::new();
879    for (a, b) in &unique_edges {
880        if let (Some(loc_a), Some(loc_b)) = (location_lookup.get(a), location_lookup.get(b)) {
881            let mut distance = (loc_a - loc_b).abs();
882            if distance > 0.5 {
883                distance = 1.0 - distance;
884            }
885            distances.push(distance);
886        }
887    }
888
889    let average_ring_distance = if distances.is_empty() {
890        None
891    } else {
892        Some(distances.iter().sum::<f64>() / distances.len() as f64)
893    };
894    let min_ring_distance = distances.iter().cloned().reduce(f64::min);
895    let max_ring_distance = distances.iter().cloned().reduce(f64::max);
896    let pct_edges_under_5pct = calculate_percentage(&distances, 0.05);
897    let pct_edges_under_10pct = calculate_percentage(&distances, 0.10);
898
899    let short_edges = distances.iter().filter(|d| **d <= 0.10).count();
900    let long_edges = distances
901        .iter()
902        .filter(|d| **d > 0.10 && **d <= 0.50)
903        .count();
904    let short_over_long_ratio = if short_edges == 0 || long_edges == 0 {
905        None
906    } else {
907        Some(short_edges as f64 / long_edges as f64)
908    };
909
910    let mut distance_histogram = Vec::new();
911    let bucket_bounds: Vec<f64> = (1..=5).map(|i| i as f64 * 0.10).collect(); // 0.1 buckets up to 0.5
912    let mut cumulative = 0usize;
913    for bound in bucket_bounds {
914        let up_to_bound = distances.iter().filter(|d| **d <= bound).count();
915        let count = up_to_bound.saturating_sub(cumulative);
916        cumulative = up_to_bound;
917        distance_histogram.push(RingDistanceBucket {
918            upper_bound: bound,
919            count,
920        });
921    }
922
923    RingVizMetrics {
924        node_count,
925        gateway_count,
926        edge_count: unique_edges.len(),
927        average_degree,
928        average_ring_distance,
929        min_ring_distance,
930        max_ring_distance,
931        pct_edges_under_5pct,
932        pct_edges_under_10pct,
933        short_over_long_ratio,
934        distance_histogram,
935    }
936}
937
938fn calculate_percentage(distances: &[f64], threshold: f64) -> Option<f64> {
939    if distances.is_empty() {
940        return None;
941    }
942    let matching = distances.iter().filter(|value| **value < threshold).count();
943    Some((matching as f64 / distances.len() as f64) * 100.0)
944}
945
946fn parse_ws_port(ws_url: &str) -> u16 {
947    ws_url
948        .split("://")
949        .nth(1)
950        .and_then(|rest| rest.split('/').next())
951        .and_then(|host_port| host_port.split(':').nth(1))
952        .and_then(|port| port.parse().ok())
953        .unwrap_or(0)
954}
955
956fn parse_listening_address(addr: Option<&String>, ws_url: &str) -> (String, u16) {
957    if let Some(addr) = addr {
958        let mut parts = addr.split(':');
959        let host = parts.next().unwrap_or("").to_string();
960        let port = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
961        (host, port)
962    } else {
963        let host = ws_url
964            .split("://")
965            .nth(1)
966            .and_then(|rest| rest.split('/').next())
967            .and_then(|host_port| host_port.split(':').next())
968            .unwrap_or_default()
969            .to_string();
970        (host, 0)
971    }
972}
973
974fn resolve_peer_id(prefix: &str, peers: &[RingPeerSnapshot]) -> Option<String> {
975    let needle = prefix.trim().trim_matches(|c| c == '"' || c == '\'');
976    peers
977        .iter()
978        .find(|peer| peer.id.starts_with(needle))
979        .map(|peer| peer.id.clone())
980}
981
982fn contract_location_from_key(key: &ContractKey) -> f64 {
983    let mut value = 0.0;
984    let mut divisor = 256.0;
985    for byte in key.as_bytes() {
986        value += f64::from(*byte) / divisor;
987        divisor *= 256.0;
988    }
989    value.fract()
990}
991
992static PUT_REQUEST_RE: LazyLock<Regex> = LazyLock::new(|| {
993    Regex::new(r"Requesting put for contract (?P<key>\S+) from (?P<from>\S+) to (?P<to>\S+)")
994        .expect("valid regex")
995});
996static PUT_COMPLETION_RE: LazyLock<Regex> = LazyLock::new(|| {
997    Regex::new(r"Peer completed contract value put,.*key: (?P<key>\S+),.*this_peer: (?P<peer>\S+)")
998        .expect("valid regex")
999});
1000static UPDATE_PROPAGATION_RE: LazyLock<Regex> = LazyLock::new(|| {
1001    Regex::new(
1002        r"UPDATE_PROPAGATION: contract=(?P<contract>\S+) from=(?P<from>\S+) targets=(?P<targets>[^ ]*)\s+count=",
1003    )
1004    .expect("valid regex")
1005});
1006static UPDATE_NO_TARGETS_RE: LazyLock<Regex> = LazyLock::new(|| {
1007    Regex::new(r"UPDATE_PROPAGATION: contract=(?P<contract>\S+) from=(?P<from>\S+) NO_TARGETS")
1008        .expect("valid regex")
1009});
1010
1011const HTML_TEMPLATE: &str = r###"<!DOCTYPE html>
1012<html lang="en">
1013<head>
1014  <meta charset="UTF-8" />
1015  <title>Freenet Ring Topology</title>
1016  <style>
1017    :root {
1018      color-scheme: dark;
1019      font-family: "Inter", "Helvetica Neue", Arial, sans-serif;
1020    }
1021    body {
1022      background: #020617;
1023      color: #e2e8f0;
1024      margin: 0;
1025      padding: 32px;
1026      display: flex;
1027      justify-content: center;
1028      min-height: 100vh;
1029    }
1030    #container {
1031      max-width: 1000px;
1032      width: 100%;
1033      background: #0f172a;
1034      border-radius: 18px;
1035      padding: 28px 32px 40px;
1036      box-shadow: 0 40px 120px rgba(2, 6, 23, 0.85);
1037    }
1038    h1 {
1039      margin: 0 0 8px;
1040      font-size: 26px;
1041      letter-spacing: 0.4px;
1042      color: #f8fafc;
1043    }
1044    #meta {
1045      margin: 0 0 20px;
1046      color: #94a3b8;
1047      font-size: 14px;
1048    }
1049    canvas {
1050      width: 100%;
1051      max-width: 900px;
1052      height: auto;
1053      background: radial-gradient(circle, #020617 0%, #0f172a 70%, #020617 100%);
1054      border-radius: 16px;
1055      border: 1px solid rgba(148, 163, 184, 0.1);
1056      display: block;
1057      margin: 0 auto 24px;
1058    }
1059    #metrics {
1060      display: grid;
1061      grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
1062      gap: 12px;
1063      margin-bottom: 20px;
1064      font-size: 14px;
1065    }
1066    #metrics div {
1067      background: #1e293b;
1068      padding: 12px 14px;
1069      border-radius: 12px;
1070      border: 1px solid rgba(148, 163, 184, 0.15);
1071    }
1072    .legend {
1073      display: flex;
1074      flex-wrap: wrap;
1075      gap: 18px;
1076      margin-bottom: 24px;
1077      font-size: 14px;
1078      color: #cbd5f5;
1079    }
1080    .legend span {
1081      display: flex;
1082      align-items: center;
1083      gap: 6px;
1084    }
1085    .legend .line {
1086      width: 30px;
1087      height: 6px;
1088      border-radius: 3px;
1089      display: inline-block;
1090    }
1091    .legend .put-line {
1092      background: #22c55e;
1093    }
1094    .legend .update-line {
1095      background: #f59e0b;
1096    }
1097    .legend .contract-dot {
1098      background: #a855f7;
1099      box-shadow: 0 0 6px rgba(168, 85, 247, 0.7);
1100    }
1101    .legend .cached-dot {
1102      background: #38bdf8;
1103    }
1104    .legend .peer-dot {
1105      background: #64748b;
1106    }
1107    .legend .gateway-dot {
1108      background: #f97316;
1109    }
1110    .dot {
1111      width: 14px;
1112      height: 14px;
1113      border-radius: 50%;
1114      display: inline-block;
1115    }
1116    #contract-info {
1117      background: rgba(15, 23, 42, 0.7);
1118      border-radius: 12px;
1119      border: 1px solid rgba(148, 163, 184, 0.15);
1120      padding: 14px;
1121      margin-bottom: 20px;
1122      font-size: 13px;
1123      color: #cbd5f5;
1124    }
1125    #contract-info h2 {
1126      margin: 0 0 8px;
1127      font-size: 16px;
1128      color: #f8fafc;
1129    }
1130    #contract-info .op-block {
1131      margin-top: 10px;
1132    }
1133    #contract-info ol {
1134      padding-left: 20px;
1135      margin: 6px 0;
1136    }
1137    #contract-info .errors {
1138      margin-top: 10px;
1139      color: #f97316;
1140    }
1141    #peer-list {
1142      border-top: 1px solid rgba(148, 163, 184, 0.15);
1143      padding-top: 18px;
1144      display: grid;
1145      grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
1146      gap: 14px;
1147      font-size: 13px;
1148    }
1149    .peer {
1150      background: rgba(15, 23, 42, 0.75);
1151      padding: 12px 14px;
1152      border-radius: 12px;
1153      border: 1px solid rgba(148, 163, 184, 0.2);
1154    }
1155    .peer strong {
1156      display: block;
1157      font-size: 13px;
1158      color: #f1f5f9;
1159      margin-bottom: 6px;
1160      word-break: break-all;
1161    }
1162    .peer span {
1163      display: block;
1164      color: #94a3b8;
1165    }
1166    .chart-card {
1167      background: rgba(15, 23, 42, 0.75);
1168      padding: 12px 14px;
1169      border-radius: 12px;
1170      border: 1px solid rgba(148, 163, 184, 0.2);
1171      margin-top: 12px;
1172    }
1173    .chart-card h3 {
1174      margin: 0 0 8px;
1175      font-size: 15px;
1176      color: #e2e8f0;
1177    }
1178  </style>
1179  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
1180</head>
1181<body>
1182  <div id="container">
1183    <h1>Freenet Ring Topology</h1>
1184    <p id="meta"></p>
1185    <canvas id="ring" width="900" height="900"></canvas>
1186    <div id="metrics"></div>
1187    <div class="chart-card" style="height: 280px;">
1188      <h3>Ring distance distribution</h3>
1189      <canvas id="histogram-chart" style="height: 220px;"></canvas>
1190    </div>
1191    <div class="legend">
1192      <span><span class="dot peer-dot"></span>Peer</span>
1193      <span><span class="dot gateway-dot"></span>Gateway</span>
1194      <span><span class="dot cached-dot"></span>Cached peer</span>
1195      <span><span class="dot contract-dot"></span>Contract</span>
1196      <span><span class="line put-line"></span>PUT path</span>
1197      <span><span class="line update-line"></span>UPDATE path</span>
1198      <span><span class="dot" style="background:#475569"></span>Connection</span>
1199    </div>
1200    <div id="contract-info"></div>
1201    <div id="peer-list"></div>
1202  </div>
1203  <script>
1204    const vizData = __DATA__;
1205    const contractData = vizData.contract ?? null;
1206    const metaEl = document.getElementById("meta");
1207    metaEl.textContent = vizData.nodes.length
1208      ? `Captured ${vizData.nodes.length} nodes · ${vizData.metrics.edge_count} edges · Generated ${vizData.generated_at} · Run root: ${vizData.run_root}`
1209      : "No peers reported diagnostics data.";
1210
1211    const metricsEl = document.getElementById("metrics");
1212    const fmtNumber = (value, digits = 2) => (typeof value === "number" ? value.toFixed(digits) : "n/a");
1213    const fmtPercent = (value, digits = 1) => (typeof value === "number" ? `${value.toFixed(digits)}%` : "n/a");
1214
1215    metricsEl.innerHTML = `
1216      <div><strong>Total nodes</strong><br/>${vizData.metrics.node_count} (gateways: ${vizData.metrics.gateway_count})</div>
1217      <div><strong>Edges</strong><br/>${vizData.metrics.edge_count}</div>
1218      <div><strong>Average degree</strong><br/>${fmtNumber(vizData.metrics.average_degree)}</div>
1219      <div><strong>Average ring distance</strong><br/>${fmtNumber(vizData.metrics.average_ring_distance, 3)}</div>
1220      <div><strong>Min / Max ring distance</strong><br/>${fmtNumber(vizData.metrics.min_ring_distance, 3)} / ${fmtNumber(vizData.metrics.max_ring_distance, 3)}</div>
1221      <div><strong>Edges &lt;5% / &lt;10%</strong><br/>${fmtPercent(vizData.metrics.pct_edges_under_5pct)} / ${fmtPercent(vizData.metrics.pct_edges_under_10pct)}</div>
1222      <div><strong>Short/long edge ratio (≤0.1 / 0.1–0.5)</strong><br/>${fmtNumber(vizData.metrics.short_over_long_ratio, 2)}</div>
1223    `;
1224
1225    const histogram = vizData.metrics.distance_histogram ?? [];
1226    const labels = histogram.map((b, idx) => {
1227      const lower = idx === 0 ? 0 : histogram[idx - 1].upper_bound;
1228      return `${lower.toFixed(1)}–${b.upper_bound.toFixed(1)}`;
1229    });
1230    const counts = histogram.map((b) => b.count);
1231
1232    if (histogram.length && window.Chart) {
1233      const ctx = document.getElementById("histogram-chart").getContext("2d");
1234      new Chart(ctx, {
1235        data: {
1236          labels,
1237          datasets: [
1238            {
1239              type: "bar",
1240              label: "Edges per bucket",
1241              data: counts,
1242              backgroundColor: "rgba(56, 189, 248, 0.75)",
1243              borderColor: "#38bdf8",
1244              borderWidth: 1,
1245            },
1246          ],
1247        },
1248        options: {
1249          responsive: true,
1250          maintainAspectRatio: false,
1251          animation: false,
1252          interaction: { mode: "index", intersect: false },
1253          plugins: {
1254            legend: { position: "bottom" },
1255            tooltip: {
1256              callbacks: {
1257                label: (ctx) => {
1258                  const label = ctx.dataset.label || "";
1259                  const value = ctx.parsed.y;
1260                  return ctx.dataset.type === "line"
1261                    ? `${label}: ${value.toFixed(1)}%`
1262                    : `${label}: ${value}`;
1263                },
1264              },
1265            },
1266          },
1267          scales: {
1268            x: { title: { display: true, text: "Ring distance (fraction of circumference)" } },
1269            y: {
1270              beginAtZero: true,
1271              title: { display: true, text: "Edge count" },
1272              grid: { color: "rgba(148,163,184,0.15)" },
1273            },
1274          },
1275        },
1276      });
1277    }
1278
1279    const peersEl = document.getElementById("peer-list");
1280    peersEl.innerHTML = vizData.nodes
1281      .map((node) => {
1282        const role = node.is_gateway ? "Gateway" : "Peer";
1283        const loc = typeof node.location === "number" ? node.location.toFixed(6) : "unknown";
1284        return `<div class="peer">
1285          <strong>${node.id}</strong>
1286          <span>${role}</span>
1287          <span>Location: ${loc}</span>
1288          <span>Degree: ${node.connections.length}</span>
1289        </div>`;
1290      })
1291      .join("");
1292
1293    const contractInfoEl = document.getElementById("contract-info");
1294    const canvas = document.getElementById("ring");
1295    const ctx = canvas.getContext("2d");
1296    const width = canvas.width;
1297    const height = canvas.height;
1298    const center = { x: width / 2, y: height / 2 };
1299    const radius = Math.min(width, height) * 0.37;
1300    const cachingPeers = new Set(contractData?.caching_peers ?? []);
1301    const putEdges = contractData?.put?.edges ?? [];
1302    const updateEdges = contractData?.update?.edges ?? [];
1303    const contractLocation = typeof (contractData?.location) === "number" ? contractData.location : null;
1304
1305    function shortId(id) {
1306      return id.slice(-6);
1307    }
1308
1309    function angleFromLocation(location) {
1310      return (location % 1) * Math.PI * 2;
1311    }
1312
1313    function polarToCartesian(angle, r = radius) {
1314      const theta = angle - Math.PI / 2;
1315      return {
1316        x: center.x + r * Math.cos(theta),
1317        y: center.y + r * Math.sin(theta),
1318      };
1319    }
1320
1321    const nodes = vizData.nodes.map((node, idx) => {
1322      const angle = typeof node.location === "number"
1323        ? angleFromLocation(node.location)
1324        : (idx / vizData.nodes.length) * Math.PI * 2;
1325      const coords = polarToCartesian(angle);
1326      return {
1327        ...node,
1328        angle,
1329        x: coords.x,
1330        y: coords.y,
1331      };
1332    });
1333    const nodeMap = new Map(nodes.map((node) => [node.id, node]));
1334
1335    function drawRingBase() {
1336      ctx.save();
1337      ctx.strokeStyle = "#475569";
1338      ctx.lineWidth = 2;
1339      ctx.beginPath();
1340      ctx.arc(center.x, center.y, radius, 0, Math.PI * 2);
1341      ctx.stroke();
1342      ctx.setLineDash([4, 8]);
1343      ctx.strokeStyle = "rgba(148,163,184,0.3)";
1344      [0, 0.25, 0.5, 0.75].forEach((loc) => {
1345        const angle = angleFromLocation(loc);
1346        const inner = polarToCartesian(angle, radius * 0.9);
1347        const outer = polarToCartesian(angle, radius * 1.02);
1348        ctx.beginPath();
1349        ctx.moveTo(inner.x, inner.y);
1350        ctx.lineTo(outer.x, outer.y);
1351        ctx.stroke();
1352        ctx.fillStyle = "#94a3b8";
1353        ctx.font = "11px 'Fira Code', monospace";
1354        ctx.textAlign = "center";
1355        ctx.fillText(loc.toFixed(2), outer.x, outer.y - 6);
1356      });
1357      ctx.restore();
1358    }
1359
1360    function drawConnections() {
1361      ctx.save();
1362      ctx.strokeStyle = "rgba(148, 163, 184, 0.35)";
1363      ctx.lineWidth = 1.2;
1364      const drawn = new Set();
1365      nodes.forEach((node) => {
1366        node.connections.forEach((neighborId) => {
1367          const neighbor = nodeMap.get(neighborId);
1368          if (!neighbor) return;
1369          const key = node.id < neighborId ? `${node.id}|${neighborId}` : `${neighborId}|${node.id}`;
1370          if (drawn.has(key)) return;
1371          drawn.add(key);
1372          ctx.beginPath();
1373          ctx.moveTo(node.x, node.y);
1374          ctx.lineTo(neighbor.x, neighbor.y);
1375          ctx.stroke();
1376        });
1377      });
1378      ctx.restore();
1379    }
1380
1381    function drawContractMarker() {
1382      if (typeof contractLocation !== "number") return;
1383      const pos = polarToCartesian(angleFromLocation(contractLocation));
1384      ctx.save();
1385      ctx.fillStyle = "#a855f7";
1386      ctx.beginPath();
1387      ctx.arc(pos.x, pos.y, 9, 0, Math.PI * 2);
1388      ctx.fill();
1389      ctx.lineWidth = 2;
1390      ctx.strokeStyle = "#f3e8ff";
1391      ctx.stroke();
1392      ctx.fillStyle = "#f3e8ff";
1393      ctx.font = "10px 'Fira Code', monospace";
1394      ctx.textAlign = "center";
1395      ctx.fillText("contract", pos.x, pos.y - 16);
1396      ctx.restore();
1397    }
1398
1399    function drawPeers() {
1400      nodes.forEach((node) => {
1401        const baseColor = node.is_gateway ? "#f97316" : "#64748b";
1402        const fill = cachingPeers.has(node.id) ? "#38bdf8" : baseColor;
1403        ctx.save();
1404        ctx.beginPath();
1405        ctx.fillStyle = fill;
1406        ctx.arc(node.x, node.y, 6.5, 0, Math.PI * 2);
1407        ctx.fill();
1408        ctx.lineWidth = 1.6;
1409        ctx.strokeStyle = "#0f172a";
1410        ctx.stroke();
1411        ctx.fillStyle = "#f8fafc";
1412        ctx.font = "12px 'Fira Code', monospace";
1413        ctx.textAlign = "center";
1414        ctx.fillText(shortId(node.id), node.x, node.y - 14);
1415        const locText = typeof node.location === "number" ? node.location.toFixed(3) : "n/a";
1416        ctx.fillStyle = "#94a3b8";
1417        ctx.font = "10px 'Fira Code', monospace";
1418        ctx.fillText(locText, node.x, node.y + 20);
1419        ctx.restore();
1420      });
1421    }
1422
1423    function drawOperationEdges(edges, color, dashed = false) {
1424      if (!edges.length) return;
1425      ctx.save();
1426      ctx.strokeStyle = color;
1427      ctx.lineWidth = 3;
1428      if (dashed) ctx.setLineDash([10, 8]);
1429      edges.forEach((edge, idx) => {
1430        const from = nodeMap.get(edge.from);
1431        const to = nodeMap.get(edge.to);
1432        if (!from || !to) return;
1433        ctx.beginPath();
1434        ctx.moveTo(from.x, from.y);
1435        ctx.lineTo(to.x, to.y);
1436        ctx.stroke();
1437        drawArrowhead(from, to, color);
1438        const midX = (from.x + to.x) / 2;
1439        const midY = (from.y + to.y) / 2;
1440        ctx.fillStyle = color;
1441        ctx.font = "11px 'Fira Code', monospace";
1442        ctx.fillText(`#${idx + 1}`, midX, midY - 4);
1443      });
1444      ctx.restore();
1445    }
1446
1447    function drawArrowhead(from, to, color) {
1448      const angle = Math.atan2(to.y - from.y, to.x - from.x);
1449      const length = 12;
1450      const spread = Math.PI / 6;
1451      ctx.save();
1452      ctx.fillStyle = color;
1453      ctx.beginPath();
1454      ctx.moveTo(to.x, to.y);
1455      ctx.lineTo(
1456        to.x - length * Math.cos(angle - spread),
1457        to.y - length * Math.sin(angle - spread)
1458      );
1459      ctx.lineTo(
1460        to.x - length * Math.cos(angle + spread),
1461        to.y - length * Math.sin(angle + spread)
1462      );
1463      ctx.closePath();
1464      ctx.fill();
1465      ctx.restore();
1466    }
1467
1468    function renderOperationList(title, edges) {
1469      if (!edges.length) {
1470        return `<div class="op-block"><strong>${title}:</strong> none recorded</div>`;
1471      }
1472      const items = edges
1473        .map((edge, idx) => {
1474          const ts = edge.timestamp
1475            ? new Date(edge.timestamp).toLocaleTimeString()
1476            : "no-ts";
1477          return `<li>#${idx + 1}: ${shortId(edge.from)} → ${shortId(edge.to)} (${ts})</li>`;
1478        })
1479        .join("");
1480      return `<div class="op-block"><strong>${title}:</strong><ol>${items}</ol></div>`;
1481    }
1482
1483    function renderContractInfo(data) {
1484      if (!data) {
1485        contractInfoEl.textContent = "No contract-specific data collected for this snapshot.";
1486        return;
1487      }
1488      const errors = data.errors?.length
1489        ? `<div class="errors"><strong>Notable events:</strong><ul>${data.errors
1490            .map((msg) => `<li>${msg}</li>`)
1491            .join("")}</ul></div>`
1492        : "";
1493      const putSummary = renderOperationList("PUT path", data.put.edges);
1494      const updateSummary = renderOperationList("UPDATE path", data.update.edges);
1495      contractInfoEl.innerHTML = `
1496        <h2>Contract ${data.key}</h2>
1497        <div><strong>Cached peers:</strong> ${data.caching_peers.length}</div>
1498        <div><strong>PUT completion:</strong> ${
1499          data.put.completion_peer ?? "pending"
1500        }</div>
1501        <div><strong>UPDATE completion:</strong> ${
1502          data.update.completion_peer ?? "pending"
1503        }</div>
1504        ${putSummary}
1505        ${updateSummary}
1506        ${errors}
1507      `;
1508    }
1509
1510    renderContractInfo(contractData);
1511
1512    if (nodes.length) {
1513      drawRingBase();
1514      drawConnections();
1515      drawContractMarker();
1516      drawOperationEdges(putEdges, "#22c55e", false);
1517      drawOperationEdges(updateEdges, "#f59e0b", true);
1518      drawPeers();
1519    } else {
1520      ctx.fillStyle = "#94a3b8";
1521      ctx.font = "16px Inter, sans-serif";
1522      ctx.fillText("No diagnostics available to render ring.", center.x - 140, center.y);
1523    }
1524  </script>
1525</body>
1526</html>
1527"###;
1528
1529fn render_ring_template(data_json: &str) -> String {
1530    HTML_TEMPLATE.replace("__DATA__", data_json)
1531}
1532
1533#[cfg(test)]
1534mod tests {
1535    use super::{compute_ring_metrics, RingPeerSnapshot};
1536
1537    #[test]
1538    fn ring_metrics_basic() {
1539        let nodes = vec![
1540            RingPeerSnapshot {
1541                id: "a".into(),
1542                is_gateway: false,
1543                ws_port: 0,
1544                network_port: 0,
1545                network_address: "127.1.0.1".into(),
1546                location: Some(0.1),
1547                connections: vec!["b".into(), "c".into()],
1548                contract: None,
1549            },
1550            RingPeerSnapshot {
1551                id: "b".into(),
1552                is_gateway: false,
1553                ws_port: 0,
1554                network_port: 0,
1555                network_address: "127.2.0.1".into(),
1556                location: Some(0.2),
1557                connections: vec!["a".into()],
1558                contract: None,
1559            },
1560            RingPeerSnapshot {
1561                id: "c".into(),
1562                is_gateway: true,
1563                ws_port: 0,
1564                network_port: 0,
1565                network_address: "127.3.0.1".into(),
1566                location: Some(0.8),
1567                connections: vec!["a".into()],
1568                contract: None,
1569            },
1570        ];
1571
1572        let metrics = compute_ring_metrics(&nodes);
1573        assert_eq!(metrics.node_count, 3);
1574        assert_eq!(metrics.gateway_count, 1);
1575        assert_eq!(metrics.edge_count, 2);
1576        assert!((metrics.average_degree - (4.0 / 3.0)).abs() < f64::EPSILON);
1577        assert!(metrics.average_ring_distance.is_some());
1578    }
1579}