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

+12
View File
@@ -0,0 +1,12 @@
[package]
name = "nx9-wg-ui"
description = "Dioxus web UI for nx9-wg"
version.workspace = true
edition.workspace = true
[dependencies]
nx9-wg-core.workspace = true
serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
uuid.workspace = true
Binary file not shown.
@@ -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());
}
}
+742
View File
@@ -0,0 +1,742 @@
//! Production CSS stylesheet with complete design tokens, responsive layout, and theme support.
use crate::theme::DesignTokens;
/// Compiles and returns the unified production CSS stylesheet.
pub fn generate_stylesheet() -> String {
let mut css = String::with_capacity(16384);
css.push_str(DesignTokens::css_variables());
css.push_str(
r#"
/* ── Universal Reset ─────────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
font-family: var(--font-sans);
background-color: var(--bg-base);
color: var(--text-primary);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: var(--accent-text);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* ── App Shell Layout ────────────────────────────────────────────────────────── */
#app-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.topbar {
height: var(--topbar-height);
background-color: var(--bg-surface);
border-bottom: 1px solid var(--border-subtle);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
position: sticky;
top: 0;
z-index: 100;
}
.topbar-left {
display: flex;
align-items: center;
gap: 16px;
}
.menu-toggle-btn {
display: none;
background: transparent;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-primary);
padding: 6px 10px;
cursor: pointer;
font-size: 16px;
min-height: 36px;
min-width: 36px;
}
.brand-logo {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
font-size: 16px;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.brand-mark {
background: var(--accent-primary);
color: #ffffff;
padding: 2px 7px;
border-radius: var(--radius-sm);
font-weight: 800;
font-size: 12px;
}
.topbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.system-status-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
padding: 3px 10px;
border-radius: var(--radius-full);
border: 1px solid var(--status-pass-border);
background: var(--status-pass-bg);
color: var(--status-pass-text);
}
.ws-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-muted);
}
.ws-indicator.connected {
color: var(--status-pass-text);
}
.admin-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
background: var(--bg-elevated);
padding: 4px 10px;
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
color: var(--text-primary);
}
.theme-toggle-btn {
background: var(--bg-elevated);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
padding: 6px 10px;
cursor: pointer;
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
}
/* ── Sidebar & Navigation ────────────────────────────────────────────────────── */
.main-container {
display: flex;
flex: 1;
min-height: calc(100vh - var(--topbar-height));
}
.sidebar {
width: var(--sidebar-width);
background-color: var(--bg-surface);
border-right: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
gap: 18px;
padding: 20px 12px;
overflow-y: auto;
flex-shrink: 0;
}
.sidebar-section {
display: flex;
flex-direction: column;
gap: 4px;
}
.sidebar-section-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
font-weight: 700;
padding: 4px 10px;
}
.nav-link {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
text-decoration: none;
transition: all var(--transition-fast);
}
.nav-link:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
text-decoration: none;
}
.nav-link.active {
background-color: var(--bg-active);
color: var(--text-primary);
font-weight: 600;
border-left: 3px solid var(--accent-primary);
}
.nav-icon {
font-size: 14px;
width: 18px;
text-align: center;
}
.content-wrapper {
flex: 1;
padding: 28px 32px;
overflow-y: auto;
max-width: 1400px;
width: 100%;
margin: 0 auto;
}
/* ── Page Header ─────────────────────────────────────────────────────────────── */
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.page-title-group h1 {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.02em;
}
.page-description {
font-size: 13px;
color: var(--text-secondary);
margin-top: 4px;
}
.page-actions {
display: flex;
align-items: center;
gap: 10px;
}
/* ── Cards & Grid ────────────────────────────────────────────────────────────── */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.card {
background-color: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 20px;
box-shadow: var(--shadow-sm);
}
.card-title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 8px;
}
.card-value {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.02em;
margin-bottom: 4px;
}
.card-subtitle {
font-size: 12px;
color: var(--text-muted);
}
.card-stack {
display: flex;
flex-direction: column;
gap: 20px;
}
.card-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-muted);
}
.card-header-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
/* ── Data Tables ─────────────────────────────────────────────────────────────── */
.table-wrapper {
background-color: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
margin-bottom: 24px;
}
.table-toolbar {
padding: 14px 18px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border-subtle);
flex-wrap: wrap;
}
.search-input {
background-color: var(--bg-elevated);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 6px 12px;
color: var(--text-primary);
font-size: 13px;
min-width: 240px;
}
.table-responsive {
width: 100%;
overflow-x: auto;
}
table.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
text-align: left;
}
table.data-table th {
background-color: var(--bg-elevated);
color: var(--text-secondary);
font-weight: 600;
padding: 10px 16px;
border-bottom: 1px solid var(--border-subtle);
white-space: nowrap;
}
table.data-table td {
padding: 12px 16px;
border-bottom: 1px solid var(--border-muted);
color: var(--text-primary);
vertical-align: middle;
}
table.data-table tr:last-child td {
border-bottom: none;
}
table.data-table tr:hover td {
background-color: var(--bg-hover);
}
/* ── Status Pills & Badges ───────────────────────────────────────────────────── */
.status-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: var(--radius-full);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.status-pass {
background-color: var(--status-pass-bg);
border: 1px solid var(--status-pass-border);
color: var(--status-pass-text);
}
.status-warning {
background-color: var(--status-warning-bg);
border: 1px solid var(--status-warning-border);
color: var(--status-warning-text);
}
.status-fail {
background-color: var(--status-fail-bg);
border: 1px solid var(--status-fail-border);
color: var(--status-fail-text);
}
.status-info {
background-color: var(--status-info-bg);
border: 1px solid var(--status-info-border);
color: var(--status-info-text);
}
.status-neutral {
background-color: var(--status-neutral-bg);
border: 1px solid var(--status-neutral-border);
color: var(--status-neutral-text);
}
.key-code {
font-family: var(--font-mono);
font-size: 11px;
background-color: var(--bg-elevated);
padding: 2px 6px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-muted);
}
/* ── Buttons & Controls ──────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 7px 14px;
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
transition: all var(--transition-fast);
min-height: 36px;
line-height: 1;
}
.btn-primary {
background-color: var(--accent-primary);
color: #ffffff;
}
.btn-primary:hover {
background-color: var(--accent-hover);
}
.btn-secondary {
background-color: var(--bg-elevated);
border-color: var(--border-subtle);
color: var(--text-primary);
}
.btn-secondary:hover {
background-color: var(--bg-hover);
}
.btn-danger {
background-color: var(--status-fail-bg);
border-color: var(--status-fail-border);
color: var(--status-fail-text);
}
.btn-danger:hover {
background-color: var(--status-fail-border);
color: #ffffff;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
min-height: 28px;
}
.btn-icon {
padding: 6px;
min-width: 32px;
min-height: 32px;
}
/* ── Forms & Inputs ──────────────────────────────────────────────────────────── */
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 16px;
}
.form-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.form-input, .form-select, .form-textarea {
padding: 8px 12px;
background-color: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
width: 100%;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
outline: none;
border-color: var(--border-accent);
box-shadow: 0 0 0 3px rgba(56, 139, 253, 0.2);
}
.form-section {
background-color: var(--bg-elevated);
border: 1px solid var(--border-muted);
border-radius: var(--radius-md);
padding: 16px;
margin-bottom: 16px;
}
.form-section-header {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
}
.form-grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.form-help {
font-size: 11px;
color: var(--text-muted);
}
.alert-box {
padding: 12px 16px;
border-radius: var(--radius-md);
font-size: 13px;
margin-bottom: 16px;
display: flex;
align-items: flex-start;
gap: 10px;
}
.alert-box.warning {
background-color: var(--status-warning-bg);
border: 1px solid var(--status-warning-border);
color: var(--status-warning-text);
}
/* ── Modals & Sheets ─────────────────────────────────────────────────────────── */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--backdrop-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 16px;
backdrop-filter: blur(2px);
}
.modal-sheet {
background-color: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
width: 100%;
max-width: 640px;
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.modal-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-subtle);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.modal-close-btn {
background: transparent;
border: none;
font-size: 18px;
color: var(--text-muted);
cursor: pointer;
padding: 4px 8px;
}
.modal-close-btn:hover {
color: var(--text-primary);
}
.modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--border-subtle);
display: flex;
justify-content: flex-end;
gap: 10px;
background-color: var(--bg-elevated);
}
/* ── QR Presentation ─────────────────────────────────────────────────────────── */
.qr-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
background-color: #ffffff;
border-radius: var(--radius-lg);
margin: 16px 0;
}
.qr-image {
max-width: 240px;
height: auto;
}
/* ── Responsive Adaptations ──────────────────────────────────────────────────── */
@media (max-width: 1024px) {
.sidebar {
width: 210px;
}
.content-wrapper {
padding: 20px;
}
}
@media (max-width: 768px) {
.menu-toggle-btn {
display: block;
}
.sidebar {
position: fixed;
top: var(--topbar-height);
left: 0;
bottom: 0;
width: 260px;
z-index: 950;
transform: translateX(-100%);
transition: transform var(--transition-normal);
box-shadow: var(--shadow-lg);
}
.sidebar.open {
transform: translateX(0);
}
.mobile-overlay {
display: none;
position: fixed;
top: var(--topbar-height);
left: 0;
right: 0;
bottom: 0;
background-color: var(--backdrop-overlay);
z-index: 900;
}
.mobile-overlay.active {
display: block;
}
.content-wrapper {
padding: 16px;
}
.card-grid {
grid-template-columns: 1fr;
}
.form-grid-2 {
grid-template-columns: 1fr;
}
.modal-sheet {
max-width: calc(100vw - 20px);
max-height: calc(100vh - 20px);
}
.page-header {
flex-direction: column;
align-items: stretch;
}
.table-toolbar {
flex-direction: column;
align-items: stretch;
}
.search-input {
width: 100%;
}
}
"#,
);
css
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_stylesheet() {
let css = generate_stylesheet();
assert!(css.contains("--bg-base"));
assert!(css.contains("--bg-surface"));
assert!(css.contains("[data-theme=\"dark\"]"));
assert!(css.contains("[data-theme=\"light\"]"));
assert!(css.contains("@media (max-width: 768px)"));
}
}
+21
View File
@@ -0,0 +1,21 @@
//! Responsive UI/UX Design System and Frontend Application for nx9-wg.
pub mod components;
pub mod css;
pub mod pages;
pub mod theme;
pub use components::admin_view::AdministratorState;
pub use components::app_shell::AppShellState;
pub use components::client_export_modal::ClientExportState;
pub use components::dashboard_view::DashboardState;
pub use components::diagnostics_view::{DiagnosticsViewState, SubsystemCardView};
pub use components::live_state_view::LiveStateView;
pub use components::peer_modal::PeerModalState;
pub use components::peer_table::{
PeerRowView, PeerTableFilter, format_bytes, format_relative_time,
};
pub use components::settings_view::SettingsState;
pub use css::generate_stylesheet;
pub use pages::{NavSection, Page};
pub use theme::{DesignTokens, SemanticStatus, ThemeMode};
+178
View File
@@ -0,0 +1,178 @@
//! Page routes and navigation state model.
use serde::{Deserialize, Serialize};
/// Navigation sections.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NavSection {
Primary,
Operations,
Administration,
}
/// Available application pages.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Page {
#[default]
Dashboard,
Interfaces,
Peers,
Networks,
Routes,
Firewall,
Nat,
Forwarding,
Reconciliation,
Diagnostics,
LiveState,
Settings,
Backups,
Audit,
Administrator,
}
impl Page {
pub fn id(&self) -> &'static str {
match self {
Self::Dashboard => "dashboard",
Self::Interfaces => "interfaces",
Self::Peers => "peers",
Self::Networks => "networks",
Self::Routes => "routes",
Self::Firewall => "firewall",
Self::Nat => "nat",
Self::Forwarding => "forwarding",
Self::Reconciliation => "reconciliation",
Self::Diagnostics => "diagnostics",
Self::LiveState => "live-state",
Self::Settings => "settings",
Self::Backups => "backups",
Self::Audit => "audit",
Self::Administrator => "administrator",
}
}
pub fn title(&self) -> &'static str {
match self {
Self::Dashboard => "Dashboard",
Self::Interfaces => "Interfaces",
Self::Peers => "Peers",
Self::Networks => "Networks",
Self::Routes => "Routes",
Self::Firewall => "Firewall",
Self::Nat => "NAT & Masquerade",
Self::Forwarding => "IP Forwarding",
Self::Reconciliation => "Reconciliation",
Self::Diagnostics => "Diagnostics",
Self::LiveState => "Live State",
Self::Settings => "Settings",
Self::Backups => "Backups",
Self::Audit => "Audit Log",
Self::Administrator => "Administrator",
}
}
pub fn description(&self) -> &'static str {
match self {
Self::Dashboard => "Real-time overview of the WireGuard appliance and system state.",
Self::Interfaces => "Manage authoritative WireGuard server network interfaces.",
Self::Peers => "Enrolled client peers, cryptographic keys, and tunnel assignments.",
Self::Networks => "Subnet networks and deterministic IP address allocations.",
Self::Routes => "Authoritative routing table entries and gateway assignments.",
Self::Firewall => "Traffic filter policies, peer associations, and port rules.",
Self::Nat => "Outbound NAT masquerading state and nftables integration.",
Self::Forwarding => "Kernel IPv4 and IPv6 packet forwarding configuration.",
Self::Reconciliation => "Desired state synchronization and drift remediation.",
Self::Diagnostics => "Comprehensive subsystem health checks and remediation hints.",
Self::LiveState => "Actual live Linux kernel netlink and WireGuard telemetry.",
Self::Settings => "System parameters, WireGuard defaults, and client MTU profiles.",
Self::Backups => "Atomic database snapshots and safety restore manifests.",
Self::Audit => "Append-only security and administrative audit event trail.",
Self::Administrator => "Single-administrator credentials, API tokens, and sessions.",
}
}
pub fn section(&self) -> NavSection {
match self {
Self::Dashboard
| Self::Interfaces
| Self::Peers
| Self::Networks
| Self::Routes
| Self::Firewall
| Self::Nat
| Self::Forwarding => NavSection::Primary,
Self::Reconciliation | Self::Diagnostics | Self::LiveState => NavSection::Operations,
Self::Settings | Self::Backups | Self::Audit | Self::Administrator => {
NavSection::Administration
}
}
}
pub fn icon(&self) -> &'static str {
match self {
Self::Dashboard => "📊",
Self::Interfaces => "🔌",
Self::Peers => "📱",
Self::Networks => "🌐",
Self::Routes => "🛣️",
Self::Firewall => "🛡️",
Self::Nat => "🔀",
Self::Forwarding => "⚡",
Self::Reconciliation => "🔄",
Self::Diagnostics => "🩺",
Self::LiveState => "📡",
Self::Settings => "⚙️",
Self::Backups => "💾",
Self::Audit => "📜",
Self::Administrator => "👤",
}
}
}
impl std::str::FromStr for Page {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().replace('_', "-").as_str() {
"dashboard" => Ok(Self::Dashboard),
"interfaces" => Ok(Self::Interfaces),
"peers" => Ok(Self::Peers),
"networks" => Ok(Self::Networks),
"routes" => Ok(Self::Routes),
"firewall" => Ok(Self::Firewall),
"nat" => Ok(Self::Nat),
"forwarding" => Ok(Self::Forwarding),
"reconciliation" => Ok(Self::Reconciliation),
"diagnostics" => Ok(Self::Diagnostics),
"live-state" | "live" => Ok(Self::LiveState),
"settings" => Ok(Self::Settings),
"backups" => Ok(Self::Backups),
"audit" => Ok(Self::Audit),
"administrator" | "admin" => Ok(Self::Administrator),
_ => Err(format!("unknown page: {s}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
#[test]
fn test_page_metadata() {
let p = Page::Peers;
assert_eq!(p.id(), "peers");
assert_eq!(p.title(), "Peers");
assert_eq!(p.section(), NavSection::Primary);
assert_eq!(Page::from_str("peers").unwrap(), Page::Peers);
assert_eq!(Page::from_str("diagnostics").unwrap(), Page::Diagnostics);
assert_eq!(Page::Diagnostics.section(), NavSection::Operations);
assert_eq!(Page::Administrator.section(), NavSection::Administration);
}
}
+223
View File
@@ -0,0 +1,223 @@
//! Design tokens and theme system for the NX9-WG Admin Appliance.
use serde::{Deserialize, Serialize};
/// Theme selection mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ThemeMode {
#[default]
Dark,
Light,
System,
}
impl ThemeMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Dark => "dark",
Self::Light => "light",
Self::System => "system",
}
}
}
impl std::fmt::Display for ThemeMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl std::str::FromStr for ThemeMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"dark" => Ok(Self::Dark),
"light" => Ok(Self::Light),
"system" => Ok(Self::System),
_ => Err(format!("unknown theme mode: {s}")),
}
}
}
/// Semantic status categories with accessible labels, icons, and contrast tokens.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SemanticStatus {
Pass,
Warning,
Fail,
Info,
Neutral,
}
impl SemanticStatus {
pub fn icon(&self) -> &'static str {
match self {
Self::Pass => "✓",
Self::Warning => "⚠",
Self::Fail => "✗",
Self::Info => "ℹ",
Self::Neutral => "•",
}
}
pub fn css_class(&self) -> &'static str {
match self {
Self::Pass => "status-pass",
Self::Warning => "status-warning",
Self::Fail => "status-fail",
Self::Info => "status-info",
Self::Neutral => "status-neutral",
}
}
}
/// Design tokens structure providing exact CSS variable definitions.
#[derive(Debug, Clone)]
pub struct DesignTokens;
impl DesignTokens {
/// Generate the complete CSS custom properties block for root and themes.
pub fn css_variables() -> &'static str {
r#"
:root {
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-full: 9999px;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--sidebar-width: 250px;
--topbar-height: 56px;
--transition-fast: 0.15s ease-in-out;
--transition-normal: 0.25s ease-in-out;
}
[data-theme="dark"], :root:not([data-theme="light"]) {
--bg-base: #0d1117;
--bg-surface: #161b22;
--bg-elevated: #21262d;
--bg-hover: #30363d;
--bg-active: #38404a;
--border-subtle: #30363d;
--border-muted: #21262d;
--border-accent: #388bfd;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--text-inverse: #0d1117;
--accent-primary: #1f6feb;
--accent-hover: #388bfd;
--accent-text: #58a6ff;
--status-pass-bg: rgba(35, 134, 54, 0.18);
--status-pass-border: rgba(46, 160, 67, 0.4);
--status-pass-text: #3fb950;
--status-warning-bg: rgba(187, 128, 9, 0.18);
--status-warning-border: rgba(210, 153, 34, 0.4);
--status-warning-text: #d29922;
--status-fail-bg: rgba(248, 81, 73, 0.18);
--status-fail-border: rgba(248, 81, 73, 0.4);
--status-fail-text: #f85149;
--status-info-bg: rgba(56, 139, 253, 0.18);
--status-info-border: rgba(56, 139, 253, 0.4);
--status-info-text: #58a6ff;
--status-neutral-bg: rgba(110, 118, 129, 0.18);
--status-neutral-border: rgba(110, 118, 129, 0.4);
--status-neutral-text: #8b949e;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
--shadow-md: 0 4px 12px rgba(0,0,0,0.5);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.6);
--backdrop-overlay: rgba(1, 4, 9, 0.8);
}
[data-theme="light"] {
--bg-base: #f6f8fa;
--bg-surface: #ffffff;
--bg-elevated: #f0f2f5;
--bg-hover: #e1e4e8;
--bg-active: #d0d7de;
--border-subtle: #d0d7de;
--border-muted: #e1e4e8;
--border-accent: #0969da;
--text-primary: #1f2328;
--text-secondary: #656d76;
--text-muted: #8c959f;
--text-inverse: #ffffff;
--accent-primary: #0969da;
--accent-hover: #0550ae;
--accent-text: #0969da;
--status-pass-bg: #dafbe1;
--status-pass-border: #aceebb;
--status-pass-text: #1a7f37;
--status-warning-bg: #fff8c5;
--status-warning-border: #fae17d;
--status-warning-text: #9a6700;
--status-fail-bg: #ffebe9;
--status-fail-border: #ffc1c0;
--status-fail-text: #cf222e;
--status-info-bg: #ddf4ff;
--status-info-border: #b6e3ff;
--status-info-text: #0969da;
--status-neutral-bg: #eff1f3;
--status-neutral-border: #d0d7de;
--status-neutral-text: #656d76;
--shadow-sm: 0 1px 2px rgba(31,35,40,0.06);
--shadow-md: 0 4px 12px rgba(31,35,40,0.08);
--shadow-lg: 0 8px 24px rgba(31,35,40,0.12);
--backdrop-overlay: rgba(31, 35, 40, 0.5);
}
"#
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
#[test]
fn test_theme_mode_roundtrip() {
assert_eq!(ThemeMode::from_str("dark").unwrap(), ThemeMode::Dark);
assert_eq!(ThemeMode::from_str("light").unwrap(), ThemeMode::Light);
assert_eq!(ThemeMode::from_str("system").unwrap(), ThemeMode::System);
assert_eq!(ThemeMode::Dark.to_string(), "dark");
}
#[test]
fn test_semantic_status_attributes() {
assert_eq!(SemanticStatus::Pass.icon(), "✓");
assert_eq!(SemanticStatus::Pass.css_class(), "status-pass");
assert_eq!(SemanticStatus::Fail.icon(), "✗");
}
}