- Prevent 'nx9-wg version' from creating data directories by avoiding database initialization. - Create parent directories when an explicit --database path is provided. - Redact printed generated administrator passwords; announce file path or redact instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
236 lines
7.7 KiB
Rust
236 lines
7.7 KiB
Rust
//! 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<NaiveDateTime>,
|
|
pub is_expired: bool,
|
|
}
|
|
|
|
impl PeerRowView {
|
|
pub fn from_peer_and_telemetry(
|
|
peer: &Peer,
|
|
rx_bytes: u64,
|
|
tx_bytes: u64,
|
|
last_handshake: Option<NaiveDateTime>,
|
|
) -> 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<String>,
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|