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