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