//! Peer management table and responsive card component. use chrono::{NaiveDateTime, Utc}; use nx9_wg_core::types::wireguard::{Peer, PeerProfile, PeerState, PeerType}; use serde::{Deserialize, Serialize}; use uuid::Uuid; /// Formatted peer row for table and mobile card representation. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PeerRowView { pub id: Uuid, pub name: String, pub peer_type: PeerType, pub profile: PeerProfile, pub state: PeerState, pub is_online: bool, pub status_label: String, pub status_class: String, pub ipv4: String, pub ipv6: String, pub public_key_full: String, pub public_key_truncated: String, pub rx_bytes_formatted: String, pub tx_bytes_formatted: String, pub last_handshake_relative: String, pub expires_at: Option, pub is_expired: bool, } impl PeerRowView { pub fn from_peer_and_telemetry( peer: &Peer, rx_bytes: u64, tx_bytes: u64, last_handshake: Option, ) -> Self { let now = Utc::now().naive_utc(); let is_expired = peer.state == PeerState::Expired || peer.expires_at.is_some_and(|exp| exp <= now); let is_online = if is_expired || peer.state != PeerState::Active { false } else if let Some(hs) = last_handshake.or(peer.last_handshake_at) { let diff = now.signed_duration_since(hs); diff.num_seconds() >= 0 && diff.num_seconds() < 180 } else { false }; let (status_label, status_class) = if is_expired { ("Expired".to_string(), "status-fail".to_string()) } else { match peer.state { PeerState::Active => { if is_online { ("Online".to_string(), "status-pass".to_string()) } else { ("Offline".to_string(), "status-neutral".to_string()) } } PeerState::Disabled => ("Disabled".to_string(), "status-warning".to_string()), PeerState::Revoked => ("Revoked".to_string(), "status-fail".to_string()), PeerState::Expired => ("Expired".to_string(), "status-fail".to_string()), } }; let pub_key_str = peer.public_key.as_str(); let pub_key_truncated = if pub_key_str.len() > 12 { format!( "{}...{}", &pub_key_str[..6], &pub_key_str[pub_key_str.len() - 6..] ) } else { pub_key_str.to_string() }; let last_handshake_relative = match last_handshake.or(peer.last_handshake_at) { Some(hs) => format_relative_time(hs, now), None => "Never".to_string(), }; Self { id: peer.id, name: peer.name.clone(), peer_type: peer.peer_type, profile: peer.profile, state: peer.state, is_online, status_label, status_class, ipv4: peer .address_v4 .map(|ip| ip.to_string()) .unwrap_or_else(|| "—".to_string()), ipv6: peer .address_v6 .map(|ip| ip.to_string()) .unwrap_or_else(|| "—".to_string()), public_key_full: pub_key_str.to_string(), public_key_truncated: pub_key_truncated, rx_bytes_formatted: format_bytes(rx_bytes), tx_bytes_formatted: format_bytes(tx_bytes), last_handshake_relative, expires_at: peer.expires_at, is_expired, } } } /// Filter state for peer table. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct PeerTableFilter { pub search_query: String, pub status_filter: Option, } impl PeerTableFilter { pub fn matches(&self, row: &PeerRowView) -> bool { if let Some(s) = self.status_filter.as_ref().filter(|s| !s.is_empty()) && !row.status_label.eq_ignore_ascii_case(s) { return false; } if !self.search_query.trim().is_empty() { let q = self.search_query.to_lowercase(); let name_match = row.name.to_lowercase().contains(&q); let ip_match = row.ipv4.contains(&q) || row.ipv6.contains(&q); let key_match = row.public_key_full.to_lowercase().contains(&q); if !name_match && !ip_match && !key_match { return false; } } true } } /// Format bytes into human readable binary units (B, KB, MB, GB, TB). pub fn format_bytes(bytes: u64) -> String { const KB: u64 = 1024; const MB: u64 = KB * 1024; const GB: u64 = MB * 1024; const TB: u64 = GB * 1024; if bytes >= TB { format!("{:.2} TB", bytes as f64 / TB as f64) } else if bytes >= GB { format!("{:.2} GB", bytes as f64 / GB as f64) } else if bytes >= MB { format!("{:.2} MB", bytes as f64 / MB as f64) } else if bytes >= KB { format!("{:.1} KB", bytes as f64 / KB as f64) } else { format!("{bytes} B") } } /// Format relative time between a past timestamp and reference now. pub fn format_relative_time(past: NaiveDateTime, now: NaiveDateTime) -> String { let diff = now.signed_duration_since(past); let seconds = diff.num_seconds(); if seconds < 0 { "Just now".to_string() } else if seconds < 60 { format!("{seconds}s ago") } else if seconds < 3600 { format!("{}m ago", seconds / 60) } else if seconds < 86400 { format!("{}h ago", seconds / 3600) } else { format!("{}d ago", seconds / 86400) } } #[cfg(test)] mod tests { use super::*; use nx9_wg_core::types::wireguard::WireGuardPublicKey; #[test] fn test_format_bytes() { assert_eq!(format_bytes(500), "500 B"); assert_eq!(format_bytes(1024), "1.0 KB"); assert_eq!(format_bytes(1048576), "1.00 MB"); assert_eq!(format_bytes(1073741824), "1.00 GB"); } #[test] fn test_peer_row_view_and_filtering() { let peer = Peer { id: Uuid::new_v4(), interface_id: Uuid::new_v4(), name: "alice-laptop".to_string(), peer_type: PeerType::RoadWarrior, state: PeerState::Active, public_key: WireGuardPublicKey::new( "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_string(), ), private_key: None, preshared_key: None, endpoint: None, allowed_ips: "10.0.0.2/32".to_string(), server_allowed_ips: None, address_v4: Some("10.0.0.2/32".parse().unwrap()), address_v6: None, dns: None, mtu: Some(1420), persistent_keepalive: Some(25), profile: PeerProfile::FullTunnel, expires_at: None, last_handshake_at: None, created_at: Utc::now().naive_utc(), updated_at: Utc::now().naive_utc(), }; let row = PeerRowView::from_peer_and_telemetry(&peer, 1024, 2048, None); assert_eq!(row.name, "alice-laptop"); assert_eq!(row.status_label, "Offline"); assert_eq!(row.rx_bytes_formatted, "1.0 KB"); assert_eq!(row.tx_bytes_formatted, "2.0 KB"); let filter = PeerTableFilter { search_query: "alice".to_string(), status_filter: None, }; assert!(filter.matches(&row)); let non_matching = PeerTableFilter { search_query: "bob".to_string(), status_filter: None, }; assert!(!non_matching.matches(&row)); } }