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