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