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