cli: avoid data-dir initialization for version; create db parent dirs; redact generated passwords in CLI output

- 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>
This commit is contained in:
thakaresandCopilot committed 2026-08-16 16:26:24 +05:30
commit 2ac6c81dfe
140 files changed
+31342

No files matched your search

@@ -0,0 +1,111 @@
//! Single Administrator account, password, API tokens, and session management view model.
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
/// Active session view row.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionRowView {
pub id: String,
pub ip_address: String,
pub user_agent: String,
pub created_at: NaiveDateTime,
pub expires_at: NaiveDateTime,
pub is_current: bool,
}
/// Active API token view row.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiTokenRowView {
pub id: String,
pub name: String,
pub created_at: NaiveDateTime,
pub expires_at: Option<NaiveDateTime>,
pub last_used_at: Option<NaiveDateTime>,
pub is_revoked: bool,
}
/// Administrator management view state.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdministratorState {
// Card 1: Account
pub username: String,
pub last_login_at: Option<NaiveDateTime>,
pub last_login_ip: Option<String>,
pub totp_enabled: bool,
pub active_session_count: usize,
// Card 2: Password change
pub current_password: String,
pub new_password: String,
pub confirm_password: String,
// Card 3: API Tokens
pub new_token_name: String,
pub new_token_expires_days: Option<u32>,
pub created_token_plaintext: Option<String>,
pub tokens: Vec<ApiTokenRowView>,
// Card 4: Sessions
pub sessions: Vec<SessionRowView>,
// Feedback
pub success_message: Option<String>,
pub error_message: Option<String>,
}
impl Default for AdministratorState {
fn default() -> Self {
Self {
username: "admin".to_string(),
last_login_at: None,
last_login_ip: None,
totp_enabled: false,
active_session_count: 1,
current_password: String::new(),
new_password: String::new(),
confirm_password: String::new(),
new_token_name: String::new(),
new_token_expires_days: Some(30),
created_token_plaintext: None,
tokens: Vec::new(),
sessions: Vec::new(),
success_message: None,
error_message: None,
}
}
}
impl AdministratorState {
pub fn validate_password_change(&self) -> Result<(), &'static str> {
if self.current_password.is_empty() {
return Err("Current password is required");
}
if self.new_password.len() < 8 {
return Err("New password must be at least 8 characters long");
}
if self.new_password != self.confirm_password {
return Err("New password and confirmation do not match");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_admin_password_validation() {
let mut state = AdministratorState::default();
assert!(state.validate_password_change().is_err());
state.current_password = "OldPassword123!".to_string();
state.new_password = "NewPassword123!".to_string();
state.confirm_password = "MismatchPassword!".to_string();
assert!(state.validate_password_change().is_err());
state.confirm_password = "NewPassword123!".to_string();
assert!(state.validate_password_change().is_ok());
}
}
@@ -0,0 +1,108 @@
//! Application Shell component providing top app bar and responsive navigation sidebar.
use crate::pages::{NavSection, Page};
use crate::theme::ThemeMode;
use serde::{Deserialize, Serialize};
/// State of the Application Shell.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppShellState {
pub current_page: Page,
pub theme: ThemeMode,
pub is_mobile_sidebar_open: bool,
pub is_ws_connected: bool,
pub system_status: String,
pub is_system_healthy: bool,
pub admin_username: String,
}
impl Default for AppShellState {
fn default() -> Self {
Self {
current_page: Page::Dashboard,
theme: ThemeMode::Dark,
is_mobile_sidebar_open: false,
is_ws_connected: false,
system_status: "Operational".to_string(),
is_system_healthy: true,
admin_username: "admin".to_string(),
}
}
}
impl AppShellState {
pub fn new(current_page: Page) -> Self {
Self {
current_page,
..Default::default()
}
}
pub fn toggle_mobile_sidebar(&mut self) {
self.is_mobile_sidebar_open = !self.is_mobile_sidebar_open;
}
pub fn close_mobile_sidebar(&mut self) {
self.is_mobile_sidebar_open = false;
}
pub fn set_page(&mut self, page: Page) {
self.current_page = page;
self.is_mobile_sidebar_open = false;
}
pub fn toggle_theme(&mut self) {
self.theme = match self.theme {
ThemeMode::Dark => ThemeMode::Light,
ThemeMode::Light => ThemeMode::Dark,
ThemeMode::System => ThemeMode::Dark,
};
}
/// List pages for a specific navigation section.
pub fn pages_for_section(section: NavSection) -> Vec<Page> {
match section {
NavSection::Primary => vec![
Page::Dashboard,
Page::Interfaces,
Page::Peers,
Page::Networks,
Page::Routes,
Page::Firewall,
Page::Nat,
Page::Forwarding,
],
NavSection::Operations => {
vec![Page::Reconciliation, Page::Diagnostics, Page::LiveState]
}
NavSection::Administration => vec![
Page::Settings,
Page::Backups,
Page::Audit,
Page::Administrator,
],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_app_shell_state_navigation() {
let mut shell = AppShellState::default();
assert_eq!(shell.current_page, Page::Dashboard);
assert!(!shell.is_mobile_sidebar_open);
shell.toggle_mobile_sidebar();
assert!(shell.is_mobile_sidebar_open);
shell.set_page(Page::Peers);
assert_eq!(shell.current_page, Page::Peers);
assert!(!shell.is_mobile_sidebar_open);
shell.toggle_theme();
assert_eq!(shell.theme, ThemeMode::Light);
}
}
@@ -0,0 +1,135 @@
//! Client export modal presentation component.
use nx9_wg_core::types::client_profile::{
ConnectionType, DeviceCategory, NatType, ResolvedClientProfile,
};
use serde::{Deserialize, Serialize};
/// Client configuration export UI state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientExportState {
pub selected_provider: Option<String>,
pub selected_device: Option<DeviceCategory>,
pub selected_connection: ConnectionType,
pub selected_nat: NatType,
pub manual_mtu_override: Option<u16>,
pub resolved_profile: Option<ResolvedClientProfile>,
pub is_override_enabled: bool,
pub qr_view_active: bool,
}
impl Default for ClientExportState {
fn default() -> Self {
Self {
selected_provider: None,
selected_device: None,
selected_connection: ConnectionType::Web,
selected_nat: NatType::Unknown,
manual_mtu_override: None,
resolved_profile: None,
is_override_enabled: false,
qr_view_active: false,
}
}
}
impl ClientExportState {
/// Create a new initial export state.
pub fn new() -> Self {
Self::default()
}
/// Set connection type and reset manual override if disabled.
pub fn set_connection(&mut self, connection: ConnectionType) {
self.selected_connection = connection;
}
/// Set NAT type.
pub fn set_nat(&mut self, nat: NatType) {
self.selected_nat = nat;
}
/// Set device category.
pub fn set_device(&mut self, device: Option<DeviceCategory>) {
self.selected_device = device;
}
/// Set provider.
pub fn set_provider(&mut self, provider: Option<String>) {
self.selected_provider = provider;
}
/// Toggle or set manual MTU override.
pub fn set_manual_mtu(&mut self, mtu: Option<u16>) {
self.manual_mtu_override = mtu;
self.is_override_enabled = mtu.is_some();
}
/// Get current effective MTU for display.
pub fn display_mtu(&self) -> u16 {
if let Some(m) = self.manual_mtu_override {
m
} else if let Some(ref res) = self.resolved_profile {
res.mtu
} else {
1420
}
}
/// Determine if an active manual override warning should be displayed.
pub fn has_manual_override_warning(&self) -> bool {
self.is_override_enabled && self.manual_mtu_override.is_some()
}
/// Format export query parameters for REST API call.
pub fn to_query_string(&self, profile_id: Option<&str>) -> String {
let mut params = Vec::new();
if let Some(ref p) = self.selected_provider {
params.push(format!("provider={}", urlencoding(p)));
}
if let Some(ref d) = self.selected_device {
params.push(format!("device={}", d.as_str()));
}
params.push(format!("connection={}", self.selected_connection.as_str()));
params.push(format!("nat={}", self.selected_nat.as_str()));
if let Some(m) = self.manual_mtu_override {
params.push(format!("mtu={m}"));
}
if let Some(id) = profile_id {
params.push(format!("profile={}", urlencoding(id)));
}
params.join("&")
}
}
fn urlencoding(s: &str) -> String {
s.replace(' ', "%20")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_export_state_lifecycle() {
let mut state = ClientExportState::new();
assert_eq!(state.display_mtu(), 1420);
assert!(!state.has_manual_override_warning());
state.set_connection(ConnectionType::Mobile);
state.set_device(Some(DeviceCategory::Android));
state.set_nat(NatType::Cgnat);
state.set_provider(Some("tmobile".to_string()));
let query = state.to_query_string(None);
assert!(query.contains("connection=mobile"));
assert!(query.contains("device=android"));
assert!(query.contains("nat=cgnat"));
assert!(query.contains("provider=tmobile"));
state.set_manual_mtu(Some(1300));
assert_eq!(state.display_mtu(), 1300);
assert!(state.has_manual_override_warning());
assert!(state.to_query_string(None).contains("mtu=1300"));
}
}
@@ -0,0 +1,79 @@
//! Operational Dashboard view model for nx9-wg appliance.
use serde::{Deserialize, Serialize};
/// Dashboard summary state for system metrics, WireGuard, and network status.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardState {
// System status
pub hostname: String,
pub os_version: String,
pub uptime_formatted: String,
pub is_operational: bool,
pub load_average: String,
// WireGuard
pub total_interfaces: usize,
pub active_interfaces: usize,
pub total_peers: usize,
pub online_peers: usize,
pub latest_handshake_relative: String,
// Networking
pub wan_ip: Option<String>,
pub default_gateway: Option<String>,
pub ipv4_forwarding_enabled: bool,
pub ipv6_forwarding_enabled: bool,
pub nat_masquerade_active: bool,
// Traffic telemetry
pub aggregate_rx_bytes_formatted: String,
pub aggregate_tx_bytes_formatted: String,
// Diagnostics & Drift
pub diagnostics_pass_count: usize,
pub diagnostics_warning_count: usize,
pub diagnostics_fail_count: usize,
pub reconciliation_drift_detected: bool,
}
impl Default for DashboardState {
fn default() -> Self {
Self {
hostname: "nx9-wg-appliance".to_string(),
os_version: "Linux native (nx9-wg v0.1.0)".to_string(),
uptime_formatted: "3d 14h 22m".to_string(),
is_operational: true,
load_average: "0.15, 0.08, 0.03".to_string(),
total_interfaces: 1,
active_interfaces: 1,
total_peers: 0,
online_peers: 0,
latest_handshake_relative: "None".to_string(),
wan_ip: Some("198.51.100.1".to_string()),
default_gateway: Some("198.51.100.254".to_string()),
ipv4_forwarding_enabled: true,
ipv6_forwarding_enabled: false,
nat_masquerade_active: true,
aggregate_rx_bytes_formatted: "0 B".to_string(),
aggregate_tx_bytes_formatted: "0 B".to_string(),
diagnostics_pass_count: 10,
diagnostics_warning_count: 0,
diagnostics_fail_count: 0,
reconciliation_drift_detected: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dashboard_state_default() {
let state = DashboardState::default();
assert!(state.is_operational);
assert_eq!(state.total_interfaces, 1);
assert!(!state.reconciliation_drift_detected);
}
}
@@ -0,0 +1,100 @@
//! Subsystem diagnostics inspection and remediation view model.
use nx9_wg_core::types::diagnostics::{DiagnosticCheck, DiagnosticReport, DiagnosticStatus};
use serde::{Deserialize, Serialize};
/// Check view row for diagnostics display.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiagnosticCheckRowView {
pub check_name: String,
pub status: DiagnosticStatus,
pub status_icon: String,
pub status_class: String,
pub observed_value: String,
pub expected_value: Option<String>,
pub diagnostic_message: String,
pub remediation_hint: Option<String>,
}
impl From<&DiagnosticCheck> for DiagnosticCheckRowView {
fn from(c: &DiagnosticCheck) -> Self {
let (status_icon, status_class) = match c.status {
DiagnosticStatus::Pass => ("✓", "status-pass"),
DiagnosticStatus::Warning => ("⚠", "status-warning"),
DiagnosticStatus::Fail => ("✗", "status-fail"),
DiagnosticStatus::NotApplicable => ("•", "status-neutral"),
};
Self {
check_name: c.check_name.clone(),
status: c.status,
status_icon: status_icon.to_string(),
status_class: status_class.to_string(),
observed_value: c.observed_value.clone(),
expected_value: c.expected_value.clone(),
diagnostic_message: c.diagnostic_message.clone(),
remediation_hint: c.remediation_hint.clone(),
}
}
}
/// Subsystem diagnostics card view.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubsystemCardView {
pub subsystem: String,
pub title: String,
pub overall_status: DiagnosticStatus,
pub overall_status_class: String,
pub checks: Vec<DiagnosticCheckRowView>,
}
impl From<&DiagnosticReport> for SubsystemCardView {
fn from(r: &DiagnosticReport) -> Self {
let overall_status_class = match r.overall_status {
DiagnosticStatus::Pass => "status-pass",
DiagnosticStatus::Warning => "status-warning",
DiagnosticStatus::Fail => "status-fail",
DiagnosticStatus::NotApplicable => "status-neutral",
};
Self {
subsystem: r.subsystem.clone(),
title: r.subsystem.to_uppercase(),
overall_status: r.overall_status,
overall_status_class: overall_status_class.to_string(),
checks: r.checks.iter().map(DiagnosticCheckRowView::from).collect(),
}
}
}
/// Diagnostics page view state.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DiagnosticsViewState {
pub selected_subsystem_filter: Option<String>,
pub selected_peer_filter: Option<String>,
pub cards: Vec<SubsystemCardView>,
pub total_pass: usize,
pub total_warning: usize,
pub total_fail: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_diagnostics_view_conversion() {
let check = DiagnosticCheck {
check_name: "kernel_ip_forward".to_string(),
status: DiagnosticStatus::Pass,
observed_value: "1".to_string(),
expected_value: Some("1".to_string()),
diagnostic_message: "IPv4 forwarding is enabled".to_string(),
remediation_hint: None,
};
let row = DiagnosticCheckRowView::from(&check);
assert_eq!(row.status_icon, "✓");
assert_eq!(row.status_class, "status-pass");
}
}
@@ -0,0 +1,56 @@
//! Live kernel and WireGuard telemetry view model.
use serde::{Deserialize, Serialize};
/// Live telemetry for kernel state inspection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiveStateView {
pub active_tab: String, // "interfaces", "peers", "routes", "firewall", "nat", "forwarding"
pub interfaces: Vec<LiveInterfaceRow>,
pub peers: Vec<LivePeerTelemetryRow>,
pub routes: Vec<LiveRouteRow>,
pub nftables_ruleset: String,
pub ipv4_forwarding_kernel_val: String,
pub ipv6_forwarding_kernel_val: String,
pub last_refreshed: chrono::NaiveDateTime,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiveInterfaceRow {
pub name: String,
pub listen_port: u16,
pub public_key: String,
pub peer_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LivePeerTelemetryRow {
pub public_key: String,
pub endpoint: Option<String>,
pub rx_bytes_formatted: String,
pub tx_bytes_formatted: String,
pub last_handshake_relative: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiveRouteRow {
pub destination: String,
pub gateway: Option<String>,
pub interface_name: Option<String>,
pub metric: Option<u32>,
}
impl Default for LiveStateView {
fn default() -> Self {
Self {
active_tab: "interfaces".to_string(),
interfaces: Vec::new(),
peers: Vec::new(),
routes: Vec::new(),
nftables_ruleset: "table inet nx9_wg {\n chain postrouting {\n type nat hook postrouting priority srcnat; policy accept;\n masquerade\n }\n}".to_string(),
ipv4_forwarding_kernel_val: "1".to_string(),
ipv6_forwarding_kernel_val: "0".to_string(),
last_refreshed: chrono::Utc::now().naive_utc(),
}
}
}
+11
View File
@@ -0,0 +1,11 @@
//! UI Components module.
pub mod admin_view;
pub mod app_shell;
pub mod client_export_modal;
pub mod dashboard_view;
pub mod diagnostics_view;
pub mod live_state_view;
pub mod peer_modal;
pub mod peer_table;
pub mod settings_view;
@@ -0,0 +1,142 @@
//! Sectional modal component state for creating and editing WireGuard peers.
use nx9_wg_core::types::client_profile::{ConnectionType, DeviceCategory, NatType};
use nx9_wg_core::types::wireguard::{PeerProfile, PeerType};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// State for the Add/Edit Peer modal workflow.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeerModalState {
pub is_open: bool,
pub editing_peer_id: Option<Uuid>,
// Section 1: Identity
pub name: String,
pub description: String,
pub peer_type: PeerType,
pub profile: PeerProfile,
// Section 2: Client Environment
pub provider: Option<String>,
pub device: Option<DeviceCategory>,
pub connection: Option<ConnectionType>,
pub nat: Option<NatType>,
// Section 3: Configuration
pub resolved_mtu: u16,
pub manual_mtu: Option<u16>,
pub is_manual_mtu_enabled: bool,
pub persistent_keepalive: u16,
pub dns: String,
pub mtu_warning: Option<String>,
// Section 4: Network
pub interface_id: Option<Uuid>,
pub network_id: Option<Uuid>,
pub auto_allocate_ip: bool,
pub address_v4: String,
pub address_v6: String,
pub allowed_ips: String,
// UI state
pub is_submitting: bool,
pub error_message: Option<String>,
}
impl Default for PeerModalState {
fn default() -> Self {
Self {
is_open: false,
editing_peer_id: None,
name: String::new(),
description: String::new(),
peer_type: PeerType::RoadWarrior,
profile: PeerProfile::FullTunnel,
provider: None,
device: Some(DeviceCategory::Android),
connection: Some(ConnectionType::Mobile),
nat: Some(NatType::Unknown),
resolved_mtu: 1280,
manual_mtu: None,
is_manual_mtu_enabled: false,
persistent_keepalive: 25,
dns: "1.1.1.1, 1.0.0.1".to_string(),
mtu_warning: None,
interface_id: None,
network_id: None,
auto_allocate_ip: true,
address_v4: String::new(),
address_v6: String::new(),
allowed_ips: "0.0.0.0/0, ::/0".to_string(),
is_submitting: false,
error_message: None,
}
}
}
impl PeerModalState {
pub fn open_new(interface_id: Option<Uuid>) -> Self {
Self {
is_open: true,
interface_id,
..Default::default()
}
}
pub fn close(&mut self) {
self.is_open = false;
self.error_message = None;
self.is_submitting = false;
}
/// Returns the effective MTU to send to the backend.
pub fn effective_mtu(&self) -> u16 {
if self.is_manual_mtu_enabled {
self.manual_mtu.unwrap_or(self.resolved_mtu)
} else {
self.resolved_mtu
}
}
/// Validate the form before submitting.
pub fn validate(&self) -> Result<(), &'static str> {
if self.name.trim().is_empty() {
return Err("Peer name is required");
}
if self.interface_id.is_none() {
return Err("Target interface must be selected");
}
if !self.auto_allocate_ip && self.address_v4.trim().is_empty() {
return Err("IPv4 address is required when automatic allocation is disabled");
}
if self.is_manual_mtu_enabled
&& self.manual_mtu.is_some_and(|m| !(1280..=9000).contains(&m))
{
return Err("Manual MTU must be between 1280 and 9000 bytes");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_peer_modal_state_validation() {
let mut modal = PeerModalState::open_new(Some(Uuid::new_v4()));
assert!(modal.validate().is_err()); // empty name
modal.name = "work-phone".to_string();
assert!(modal.validate().is_ok());
modal.is_manual_mtu_enabled = true;
modal.manual_mtu = Some(1100);
assert!(modal.validate().is_err()); // MTU too low
modal.manual_mtu = Some(1350);
assert!(modal.validate().is_ok());
assert_eq!(modal.effective_mtu(), 1350);
}
}
@@ -0,0 +1,235 @@
//! 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));
}
}
@@ -0,0 +1,82 @@
//! Vertically stacked Settings cards view model.
use serde::{Deserialize, Serialize};
/// Settings page state with separated card groups.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SettingsState {
// Card 1: System
pub hostname: String,
pub listen_address: String,
pub log_level: String,
pub reconciliation_interval_secs: u64,
// Card 2: WireGuard
pub default_interface: String,
pub default_mtu: u16,
pub default_listen_port: u16,
// Card 3: Networking
pub nat_enabled: bool,
pub ipv4_forwarding: bool,
pub ipv6_forwarding: bool,
pub default_dns: String,
// Card 4: Backups
pub backup_directory: String,
pub max_backup_count: usize,
pub backup_schedule: String,
// Card 5: Danger Zone
pub is_reset_confirm_open: bool,
pub reset_confirm_input: String,
// Status feedback
pub success_message: Option<String>,
pub error_message: Option<String>,
}
impl Default for SettingsState {
fn default() -> Self {
Self {
hostname: "nx9-wg-node-1".to_string(),
listen_address: "0.0.0.0:8080".to_string(),
log_level: "info".to_string(),
reconciliation_interval_secs: 30,
default_interface: "wg0".to_string(),
default_mtu: 1420,
default_listen_port: 51820,
nat_enabled: true,
ipv4_forwarding: true,
ipv6_forwarding: false,
default_dns: "1.1.1.1, 1.0.0.1".to_string(),
backup_directory: "/var/lib/nx9-wg/backups".to_string(),
max_backup_count: 10,
backup_schedule: "0 2 * * *".to_string(),
is_reset_confirm_open: false,
reset_confirm_input: String::new(),
success_message: None,
error_message: None,
}
}
}
impl SettingsState {
pub fn can_perform_reset(&self) -> bool {
self.reset_confirm_input == "RESET-APPLIANCE"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_settings_state_danger_zone() {
let mut s = SettingsState::default();
assert!(!s.can_perform_reset());
s.reset_confirm_input = "RESET-APPLIANCE".to_string();
assert!(s.can_perform_reset());
}
}