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:
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user