Skip to main content

offline_protocol/
visualization.rs

1//! Network visualization and topology export.
2//!
3//! This module provides tools for visualizing the mesh network topology
4//! and exporting metrics for debugging and monitoring.
5
6use crate::constants::{HISTORY_CLEANUP_BATCH_SIZE, MAX_MESSAGE_HISTORY};
7use crate::{Error, Result};
8use offline_protocol_transport::TransportType;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::time::SystemTime;
12
13/// Node in the network topology.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct NetworkNode {
16    /// Node user ID
17    pub user_id: String,
18    /// Node role (Normal or Relay)
19    pub role: NodeRole,
20    /// Connection count
21    pub connection_count: usize,
22    /// Battery level (0-100), if known
23    pub battery_level: Option<u8>,
24    /// Last seen timestamp
25    pub last_seen: i64,
26    /// Transport types available
27    pub transports: Vec<TransportType>,
28}
29
30/// Node role in the network.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum NodeRole {
34    /// Normal node
35    Normal,
36    /// Relay node (forwarding messages)
37    Relay,
38}
39
40/// Edge/link between two nodes.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct NetworkLink {
43    /// Source node
44    pub from: String,
45    /// Destination node
46    pub to: String,
47    /// Link quality (0.0 - 1.0)
48    pub quality: f32,
49    /// Transport type used for this link
50    pub transport: TransportType,
51    /// RSSI (signal strength) if available
52    pub rssi: Option<i16>,
53}
54
55/// Complete network topology snapshot.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct NetworkTopology {
58    /// Timestamp of this snapshot
59    pub timestamp: i64,
60    /// Local device user ID
61    pub local_user_id: String,
62    /// All nodes in the network
63    pub nodes: Vec<NetworkNode>,
64    /// All links between nodes
65    pub links: Vec<NetworkLink>,
66    /// Network-wide statistics
67    pub stats: NetworkStats,
68}
69
70/// Network-wide statistics.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct NetworkStats {
73    /// Total nodes in network
74    pub total_nodes: usize,
75    /// Total relay nodes
76    pub relay_nodes: usize,
77    /// Total active connections
78    pub total_connections: usize,
79    /// Average link quality
80    pub avg_link_quality: f32,
81    /// Network diameter (max hops between any two nodes)
82    pub network_diameter: Option<u8>,
83}
84
85/// Message delivery statistics.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct MessageStats {
88    /// Message ID
89    pub message_id: String,
90    /// Sender
91    pub sender: String,
92    /// Recipient
93    pub recipient: String,
94    /// Timestamp sent
95    pub sent_at: i64,
96    /// Timestamp delivered (if delivered)
97    pub delivered_at: Option<i64>,
98    /// Number of hops
99    pub hop_count: u8,
100    /// Transport used for final delivery
101    pub transport: Option<TransportType>,
102    /// Retry count
103    pub retry_count: u32,
104    /// Delivery latency in milliseconds (if delivered)
105    pub latency_ms: Option<u64>,
106}
107
108/// Network metrics collector for visualization.
109pub struct NetworkVisualizer {
110    /// Discovered nodes
111    nodes: HashMap<String, NetworkNode>,
112    /// Active links
113    links: Vec<NetworkLink>,
114    /// Message delivery history
115    message_history: Vec<MessageStats>,
116    /// Local user ID
117    local_user_id: String,
118}
119
120impl NetworkVisualizer {
121    /// Creates a new network visualizer.
122    pub fn new(local_user_id: impl Into<String>) -> Self {
123        Self {
124            nodes: HashMap::new(),
125            links: Vec::new(),
126            message_history: Vec::new(),
127            local_user_id: local_user_id.into(),
128        }
129    }
130
131    /// Replaces the local user id reported in the topology.
132    ///
133    /// Exists because the visualizer is constructed before this device has an
134    /// identity: the address is derived during `initialize_mls`, so the
135    /// visualizer starts on the pre-identity placeholder and has to be told
136    /// the real value once it exists. Without this the topology reports the
137    /// local `profile` — a storage-namespace selector that is never supposed
138    /// to leave the device — as though it were this device's peer id.
139    pub fn set_local_user_id(&mut self, local_user_id: impl Into<String>) {
140        self.local_user_id = local_user_id.into();
141    }
142
143    /// Registers or updates a node in the topology.
144    pub fn update_node(&mut self, node: NetworkNode) {
145        self.nodes.insert(node.user_id.clone(), node);
146    }
147
148    /// Adds a link between two nodes.
149    pub fn add_link(&mut self, link: NetworkLink) {
150        // Remove existing link if present
151        self.links
152            .retain(|l| !(l.from == link.from && l.to == link.to));
153        self.links.push(link);
154    }
155
156    /// Removes a link between two nodes.
157    pub fn remove_link(&mut self, from: &str, to: &str) {
158        self.links.retain(|l| !(l.from == from && l.to == to));
159    }
160
161    /// Records a message delivery attempt.
162    pub fn record_message(&mut self, stats: MessageStats) {
163        self.message_history.push(stats);
164
165        if self.message_history.len() > MAX_MESSAGE_HISTORY {
166            self.message_history.drain(0..HISTORY_CLEANUP_BATCH_SIZE);
167        }
168    }
169
170    /// Generates a network topology snapshot.
171    pub fn get_topology(&self) -> NetworkTopology {
172        let stats = self.calculate_stats();
173
174        NetworkTopology {
175            timestamp: SystemTime::now()
176                .duration_since(SystemTime::UNIX_EPOCH)
177                .map(|d| d.as_secs() as i64)
178                .unwrap_or(0),
179            local_user_id: self.local_user_id.clone(),
180            nodes: self.nodes.values().cloned().collect(),
181            links: self.links.clone(),
182            stats,
183        }
184    }
185
186    /// Calculates network-wide statistics.
187    fn calculate_stats(&self) -> NetworkStats {
188        let total_nodes = self.nodes.len();
189        let relay_nodes = self
190            .nodes
191            .values()
192            .filter(|n| n.role == NodeRole::Relay)
193            .count();
194        let total_connections = self.links.len();
195
196        let avg_link_quality = if total_connections > 0 {
197            self.links.iter().map(|l| l.quality).sum::<f32>() / total_connections as f32
198        } else {
199            0.0
200        };
201
202        NetworkStats {
203            total_nodes,
204            relay_nodes,
205            total_connections,
206            avg_link_quality,
207            network_diameter: self.calculate_diameter(),
208        }
209    }
210
211    /// Calculates network diameter (max hops between any two nodes).
212    ///
213    /// Uses Floyd-Warshall algorithm for simplicity.
214    fn calculate_diameter(&self) -> Option<u8> {
215        if self.nodes.is_empty() {
216            return None;
217        }
218
219        // Build adjacency map
220        let nodes: Vec<_> = self.nodes.keys().cloned().collect();
221        let n = nodes.len();
222
223        if n == 1 {
224            return Some(0);
225        }
226
227        // Initialize distance matrix
228        let mut dist = vec![vec![u8::MAX; n]; n];
229
230        // Set diagonal to 0
231        for (i, row) in dist.iter_mut().enumerate() {
232            row[i] = 0;
233        }
234
235        // Set direct connections to 1
236        for link in &self.links {
237            if let (Some(from_idx), Some(to_idx)) = (
238                nodes.iter().position(|id| id == &link.from),
239                nodes.iter().position(|id| id == &link.to),
240            ) {
241                dist[from_idx][to_idx] = 1;
242                dist[to_idx][from_idx] = 1; // Assume bidirectional
243            }
244        }
245
246        // Floyd-Warshall
247        for k in 0..n {
248            for i in 0..n {
249                for j in 0..n {
250                    if dist[i][k] != u8::MAX && dist[k][j] != u8::MAX {
251                        dist[i][j] = dist[i][j].min(dist[i][k].saturating_add(dist[k][j]));
252                    }
253                }
254            }
255        }
256
257        // Find maximum distance
258        let max_dist = dist
259            .iter()
260            .flat_map(|row| row.iter())
261            .filter(|&&d| d != u8::MAX)
262            .max()
263            .copied();
264
265        max_dist
266    }
267
268    /// Exports topology as JSON.
269    pub fn export_json(&self) -> Result<String> {
270        let topology = self.get_topology();
271        serde_json::to_string_pretty(&topology)
272            .map_err(|e| Error::Other(format!("Failed to serialize topology: {}", e)))
273    }
274
275    /// Gets message delivery statistics.
276    pub fn get_message_stats(&self) -> Vec<MessageStats> {
277        self.message_history.clone()
278    }
279
280    /// Calculates delivery success rate.
281    pub fn delivery_success_rate(&self) -> f32 {
282        if self.message_history.is_empty() {
283            return 0.0;
284        }
285
286        let delivered = self
287            .message_history
288            .iter()
289            .filter(|m| m.delivered_at.is_some())
290            .count();
291
292        delivered as f32 / self.message_history.len() as f32
293    }
294
295    /// Calculates median delivery latency.
296    pub fn median_latency(&self) -> Option<u64> {
297        let mut latencies: Vec<_> = self
298            .message_history
299            .iter()
300            .filter_map(|m| m.latency_ms)
301            .collect();
302
303        if latencies.is_empty() {
304            return None;
305        }
306
307        latencies.sort_unstable();
308        Some(latencies[latencies.len() / 2])
309    }
310
311    /// Calculates median hop count.
312    pub fn median_hops(&self) -> Option<u8> {
313        let mut hops: Vec<_> = self
314            .message_history
315            .iter()
316            .filter(|m| m.delivered_at.is_some())
317            .map(|m| m.hop_count)
318            .collect();
319
320        if hops.is_empty() {
321            return None;
322        }
323
324        hops.sort_unstable();
325        Some(hops[hops.len() / 2])
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn test_visualizer_creation() {
335        let viz = NetworkVisualizer::new("alice");
336        let topology = viz.get_topology();
337        assert_eq!(topology.local_user_id, "alice");
338        assert_eq!(topology.nodes.len(), 0);
339    }
340
341    #[test]
342    fn test_add_node() {
343        let mut viz = NetworkVisualizer::new("alice");
344
345        let node = NetworkNode {
346            user_id: "bob".to_string(),
347            role: NodeRole::Normal,
348            connection_count: 1,
349            battery_level: Some(80),
350            last_seen: 0,
351            transports: vec![TransportType::BLE],
352        };
353
354        viz.update_node(node);
355
356        let topology = viz.get_topology();
357        assert_eq!(topology.nodes.len(), 1);
358        assert_eq!(topology.nodes[0].user_id, "bob");
359    }
360
361    #[test]
362    fn test_add_link() {
363        let mut viz = NetworkVisualizer::new("alice");
364
365        let link = NetworkLink {
366            from: "alice".to_string(),
367            to: "bob".to_string(),
368            quality: 0.9,
369            transport: TransportType::BLE,
370            rssi: Some(-50),
371        };
372
373        viz.add_link(link);
374
375        let topology = viz.get_topology();
376        assert_eq!(topology.links.len(), 1);
377    }
378
379    #[test]
380    fn test_network_stats() {
381        let mut viz = NetworkVisualizer::new("alice");
382
383        // Add nodes
384        for i in 0..5 {
385            viz.update_node(NetworkNode {
386                user_id: format!("node{}", i),
387                role: if i < 2 {
388                    NodeRole::Relay
389                } else {
390                    NodeRole::Normal
391                },
392                connection_count: 2,
393                battery_level: Some(75),
394                last_seen: 0,
395                transports: vec![TransportType::BLE],
396            });
397        }
398
399        // Add links
400        viz.add_link(NetworkLink {
401            from: "node0".to_string(),
402            to: "node1".to_string(),
403            quality: 0.8,
404            transport: TransportType::BLE,
405            rssi: Some(-60),
406        });
407
408        let topology = viz.get_topology();
409        assert_eq!(topology.stats.total_nodes, 5);
410        assert_eq!(topology.stats.relay_nodes, 2);
411        assert_eq!(topology.stats.total_connections, 1);
412    }
413
414    #[test]
415    fn test_message_stats() {
416        let mut viz = NetworkVisualizer::new("alice");
417
418        viz.record_message(MessageStats {
419            message_id: "msg1".to_string(),
420            sender: "alice".to_string(),
421            recipient: "bob".to_string(),
422            sent_at: 0,
423            delivered_at: Some(100),
424            hop_count: 3,
425            transport: Some(TransportType::BLE),
426            retry_count: 0,
427            latency_ms: Some(100),
428        });
429
430        viz.record_message(MessageStats {
431            message_id: "msg2".to_string(),
432            sender: "alice".to_string(),
433            recipient: "charlie".to_string(),
434            sent_at: 100,
435            delivered_at: None,
436            hop_count: 0,
437            transport: None,
438            retry_count: 3,
439            latency_ms: None,
440        });
441
442        assert_eq!(viz.delivery_success_rate(), 0.5);
443        assert_eq!(viz.median_latency(), Some(100));
444        assert_eq!(viz.median_hops(), Some(3));
445    }
446
447    #[test]
448    fn test_export_json() {
449        let mut viz = NetworkVisualizer::new("alice");
450
451        viz.update_node(NetworkNode {
452            user_id: "bob".to_string(),
453            role: NodeRole::Relay,
454            connection_count: 3,
455            battery_level: Some(90),
456            last_seen: 0,
457            transports: vec![TransportType::BLE, TransportType::WiFiDirect],
458        });
459
460        let json = viz.export_json().unwrap();
461        assert!(json.contains("bob"));
462        assert!(json.contains("relay"));
463    }
464}