use anyhow::Result; use crossterm::event::{Event, KeyCode}; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::Style, widgets::{Block, Paragraph}, Frame, }; use std::collections::HashMap; use std::time::Instant; use tracing::info; use wake_on_lan::MagicPacket; pub mod theme; pub mod widgets; use crate::config::DashboardConfig; use crate::metrics::MetricStore; use cm_dashboard_shared::Status; use theme::{Components, Layout as ThemeLayout, Theme}; use widgets::{HostsWidget, ServicesWidget, SystemWidget, Widget}; /// Panel types for focus management /// Widget states for a specific host #[derive(Clone)] pub struct HostWidgets { /// System widget state (includes CPU, Memory, NixOS info, Storage) pub system_widget: SystemWidget, /// Services widget state pub services_widget: ServicesWidget, /// Last update time for this host pub last_update: Option, } impl HostWidgets { pub fn new() -> Self { Self { system_widget: SystemWidget::new(), services_widget: ServicesWidget::new(), last_update: None, } } } /// Popup menu state #[derive(Clone)] pub struct PopupMenu { pub service_name: String, pub x: u16, pub y: u16, pub selected_index: usize, } /// Main TUI application pub struct TuiApp { /// Widget states per host (hostname -> HostWidgets) host_widgets: HashMap, /// Current active host pub current_host: Option, /// Available hosts available_hosts: Vec, /// Should quit application should_quit: bool, /// Track if user manually navigated away from localhost user_navigated_away: bool, /// Dashboard configuration config: DashboardConfig, /// Cached localhost hostname to avoid repeated system calls localhost: String, /// Active popup menu (if any) pub popup_menu: Option, /// Focus on hosts tab (false = Services, true = Hosts) pub focus_hosts: bool, /// Hosts widget for navigation and rendering pub hosts_widget: HostsWidget, } impl TuiApp { pub fn new(config: DashboardConfig) -> Self { let localhost = gethostname::gethostname().to_string_lossy().to_string(); let mut app = Self { host_widgets: HashMap::new(), current_host: None, available_hosts: config.hosts.keys().cloned().collect(), should_quit: false, user_navigated_away: false, config, localhost, popup_menu: None, focus_hosts: true, // Start with Hosts tab focused by default hosts_widget: HostsWidget::new(), }; // Sort predefined hosts app.available_hosts.sort(); // Initialize with first host if available if !app.available_hosts.is_empty() { app.current_host = Some(app.available_hosts[0].clone()); } app } /// Get or create host widgets for the given hostname pub fn get_or_create_host_widgets(&mut self, hostname: &str) -> &mut HostWidgets { self.host_widgets .entry(hostname.to_string()) .or_insert_with(HostWidgets::new) } /// Update widgets with structured data from store (only for current host) pub fn update_metrics(&mut self, metric_store: &mut MetricStore) { if let Some(hostname) = self.current_host.clone() { // Get structured data for this host if let Some(agent_data) = metric_store.get_agent_data(&hostname) { let host_widgets = self.get_or_create_host_widgets(&hostname); // Update all widgets with structured data directly host_widgets.system_widget.update_from_agent_data(agent_data); host_widgets.services_widget.update_from_agent_data(agent_data); host_widgets.last_update = Some(Instant::now()); } } } /// Update available hosts with localhost prioritization pub fn update_hosts(&mut self, discovered_hosts: Vec) { // Start with configured hosts (always visible) let mut all_hosts: Vec = self.config.hosts.keys().cloned().collect(); // Add any discovered hosts that aren't already configured for host in discovered_hosts { if !all_hosts.contains(&host) { all_hosts.push(host); } } all_hosts.sort(); self.available_hosts = all_hosts; // Track if we had a host before this update let had_host = self.current_host.is_some(); // Get the current hostname (localhost) for auto-selection if !self.available_hosts.is_empty() { if self.available_hosts.contains(&self.localhost) && !self.user_navigated_away { // Localhost is available and user hasn't navigated away - switch to it self.current_host = Some(self.localhost.clone()); // Initialize selector bar on first host selection if !had_host { let index = self.available_hosts.iter().position(|h| h == &self.localhost).unwrap_or(0); self.hosts_widget.set_selected_index(index, self.available_hosts.len()); } } else if self.current_host.is_none() { // No current host - select first available (which is localhost if available) self.current_host = Some(self.available_hosts[0].clone()); // Initialize selector bar self.hosts_widget.set_selected_index(0, self.available_hosts.len()); } else if let Some(ref current) = self.current_host { if !self.available_hosts.contains(current) { // Current host disconnected - FORCE switch to first available self.current_host = Some(self.available_hosts[0].clone()); // Reset selector bar since we're forcing a host change self.hosts_widget.set_selected_index(0, self.available_hosts.len()); self.user_navigated_away = false; // Reset since we're forced to switch } } } } /// Handle keyboard input pub fn handle_input(&mut self, event: Event) -> Result<()> { if let Event::Key(key) = event { // Close popup on Escape if matches!(key.code, KeyCode::Esc) { if self.popup_menu.is_some() { self.popup_menu = None; return Ok(()); } } match key.code { KeyCode::Char('q') => { self.should_quit = true; } KeyCode::Char('r') => { // System rebuild command - works on any panel for current host if let Some(hostname) = self.current_host.clone() { let connection_ip = self.get_connection_ip(&hostname); // Create command that shows logo, rebuilds, and waits for user input let logo_and_rebuild = format!( "echo 'Rebuilding system: {} ({})' && ssh -tt {}@{} \"bash -ic '{}'\"", hostname, connection_ip, self.config.ssh.rebuild_user, connection_ip, self.config.ssh.rebuild_cmd ); std::process::Command::new("tmux") .arg("split-window") .arg("-v") .arg("-p") .arg("30") .arg(&logo_and_rebuild) .spawn() .ok(); // Ignore errors, tmux will handle them } } KeyCode::Char('B') => { // Backup command - works on any panel for current host if let Some(hostname) = self.current_host.clone() { let connection_ip = self.get_connection_ip(&hostname); // Create command that shows logo, runs backup, and waits for user input let logo_and_backup = format!( "echo 'Running backup: {} ({})' && ssh -tt {}@{} \"bash -ic '{}'\"", hostname, connection_ip, self.config.ssh.rebuild_user, connection_ip, format!("{} start borgbackup", self.config.ssh.service_manage_cmd) ); std::process::Command::new("tmux") .arg("split-window") .arg("-v") .arg("-p") .arg("30") .arg(&logo_and_backup) .spawn() .ok(); // Ignore errors, tmux will handle them } } KeyCode::Char('s') => { // Service start command via SSH with progress display if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) { let connection_ip = self.get_connection_ip(&hostname); let service_start_command = format!( "echo 'Starting service: {} on {}' && ssh -tt {}@{} \"bash -ic '{} start {}'\"", service_name, hostname, self.config.ssh.rebuild_user, connection_ip, self.config.ssh.service_manage_cmd, service_name ); std::process::Command::new("tmux") .arg("split-window") .arg("-v") .arg("-p") .arg("30") .arg(&service_start_command) .spawn() .ok(); // Ignore errors, tmux will handle them } } KeyCode::Char('S') => { // Service stop command via SSH with progress display if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) { let connection_ip = self.get_connection_ip(&hostname); let service_stop_command = format!( "echo 'Stopping service: {} on {}' && ssh -tt {}@{} \"bash -ic '{} stop {}'\"", service_name, hostname, self.config.ssh.rebuild_user, connection_ip, self.config.ssh.service_manage_cmd, service_name ); std::process::Command::new("tmux") .arg("split-window") .arg("-v") .arg("-p") .arg("30") .arg(&service_stop_command) .spawn() .ok(); // Ignore errors, tmux will handle them } } KeyCode::Char('L') => { // Show service logs via service-manage script in tmux split window if let (Some(service_name), Some(hostname)) = (self.get_selected_service(), self.current_host.clone()) { let connection_ip = self.get_connection_ip(&hostname); let logs_command = format!( "ssh -tt {}@{} '{} logs {}'", self.config.ssh.rebuild_user, connection_ip, self.config.ssh.service_manage_cmd, service_name ); std::process::Command::new("tmux") .arg("split-window") .arg("-v") .arg("-p") .arg("30") .arg(&logs_command) .spawn() .ok(); // Ignore errors, tmux will handle them } } KeyCode::Char('w') => { // Wake on LAN for offline hosts if let Some(hostname) = self.current_host.clone() { // Check if host has MAC address configured if let Some(host_details) = self.config.hosts.get(&hostname) { if let Some(mac_address) = &host_details.mac_address { // Parse MAC address and send WoL packet let mac_bytes = Self::parse_mac_address(mac_address); match mac_bytes { Ok(mac) => { match MagicPacket::new(&mac).send() { Ok(_) => { info!("WakeOnLAN packet sent successfully to {} ({})", hostname, mac_address); } Err(e) => { tracing::error!("Failed to send WakeOnLAN packet to {}: {}", hostname, e); } } } Err(_) => { tracing::error!("Invalid MAC address format for {}: {}", hostname, mac_address); } } } } } } KeyCode::Char('t') => { // Open SSH terminal session in tmux window if let Some(hostname) = self.current_host.clone() { let connection_ip = self.get_connection_ip(&hostname); let ssh_command = format!( "echo 'Opening SSH terminal to: {}' && ssh -tt {}@{}", hostname, self.config.ssh.rebuild_user, connection_ip ); std::process::Command::new("tmux") .arg("split-window") .arg("-v") .arg("-p") .arg("30") // Use 30% like other commands .arg(&ssh_command) .spawn() .ok(); // Ignore errors, tmux will handle them } } KeyCode::Tab => { // Tab toggles between Services and Hosts tabs self.focus_hosts = !self.focus_hosts; } KeyCode::Up | KeyCode::Char('k') => { if self.focus_hosts { // Move blue selector bar up when in Hosts tab self.hosts_widget.select_previous(); } else { // Move service selection up when in Services tab if let Some(hostname) = self.current_host.clone() { let host_widgets = self.get_or_create_host_widgets(&hostname); host_widgets.services_widget.select_previous(); } } } KeyCode::Down | KeyCode::Char('j') => { if self.focus_hosts { // Move blue selector bar down when in Hosts tab let total_hosts = self.available_hosts.len(); self.hosts_widget.select_next(total_hosts); } else { // Move service selection down when in Services tab if let Some(hostname) = self.current_host.clone() { let total_services = { let host_widgets = self.get_or_create_host_widgets(&hostname); host_widgets.services_widget.get_total_services_count() }; let host_widgets = self.get_or_create_host_widgets(&hostname); host_widgets.services_widget.select_next(total_services); } } } KeyCode::Enter => { if self.focus_hosts { // Enter key switches to the selected host let selected_idx = self.hosts_widget.get_selected_index(); if selected_idx < self.available_hosts.len() { let selected_host = self.available_hosts[selected_idx].clone(); self.switch_to_host(&selected_host); } } } _ => {} } } Ok(()) } /// Switch to a specific host by name pub fn switch_to_host(&mut self, hostname: &str) { if let Some(index) = self.available_hosts.iter().position(|h| h == hostname) { // Update selector bar position self.hosts_widget.set_selected_index(index, self.available_hosts.len()); self.current_host = Some(hostname.to_string()); // Check if user navigated away from localhost if hostname != &self.localhost { self.user_navigated_away = true; } else { self.user_navigated_away = false; // User navigated back to localhost } info!("Switched to host: {}", hostname); } } /// Handle mouse click on tab title area pub fn handle_tab_click(&mut self, x: u16, area: &Rect) { // Tab title format: "hosts | services" // Calculate positions relative to area start let title_start_x = area.x + 1; // +1 for left border // "hosts | services" // 0123456789... let hosts_start = title_start_x; let hosts_end = hosts_start + 5; // "hosts" is 5 chars let services_start = hosts_end + 3; // After " | " let services_end = services_start + 8; // "services" is 8 chars if x >= hosts_start && x < hosts_end { // Clicked on "hosts" self.focus_hosts = true; } else if x >= services_start && x < services_end { // Clicked on "services" self.focus_hosts = false; } } /// Get the currently selected service name from the services widget fn get_selected_service(&self) -> Option { if let Some(hostname) = &self.current_host { if let Some(host_widgets) = self.host_widgets.get(hostname) { return host_widgets.services_widget.get_selected_service(); } } None } /// Get the list of available hosts pub fn get_available_hosts(&self) -> &Vec { &self.available_hosts } /// Should quit application pub fn should_quit(&self) -> bool { self.should_quit } /// Render the dashboard (real btop-style multi-panel layout) pub fn render(&mut self, frame: &mut Frame, metric_store: &MetricStore) -> (Rect, Rect, Rect) { let size = frame.size(); // Clear background to true black like btop frame.render_widget( Block::default().style(Style::default().bg(Theme::background())), size, ); // Create real btop-style layout: multi-panel with borders // Three-section layout: title bar, main content, statusbar let main_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), // Title bar Constraint::Min(0), // Main content area Constraint::Length(1), // Statusbar ]) .split(size); // New layout: left panels | right services (100% height) let content_chunks = ratatui::layout::Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage(ThemeLayout::LEFT_PANEL_WIDTH), // Left side: system, backup Constraint::Percentage(ThemeLayout::RIGHT_PANEL_WIDTH), // Right side: services (100% height) ]) .split(main_chunks[1]); // main_chunks[1] is now the content area (between title and statusbar) // Check if current host is offline let current_host_offline = if let Some(hostname) = self.current_host.clone() { self.calculate_host_status(&hostname, metric_store) == Status::Offline } else { true // No host selected is considered offline }; // Left side: system panel only (full height) let left_chunks = ratatui::layout::Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Percentage(100)]) // System section takes full height .split(content_chunks[0]); // Render title bar self.render_btop_title(frame, main_chunks[0], metric_store); // Render system panel or offline message in system panel area let system_area = left_chunks[0]; if current_host_offline { self.render_offline_host_message(frame, system_area); } else { self.render_system_panel(frame, system_area, metric_store); } // Render right panel with tabs (Services | Hosts) let services_area = content_chunks[1]; self.render_right_panel_with_tabs(frame, services_area, metric_store); // Render statusbar at the bottom self.render_statusbar(frame, main_chunks[2], metric_store); // Render popup menu on top of everything if active if let Some(ref popup) = self.popup_menu { self.render_popup_menu(frame, popup); } // Return all areas for mouse event handling (main_chunks[0], system_area, services_area) } /// Render btop-style minimal title with host status colors fn render_btop_title(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) { use ratatui::style::Modifier; use ratatui::text::{Line, Span}; if self.available_hosts.is_empty() { let title_text = "cm-dashboard • no hosts discovered"; let title = Paragraph::new(title_text) .style(Style::default().fg(Theme::background()).bg(Theme::status_color(Status::Unknown))); frame.render_widget(title, area); return; } // Calculate worst-case status across all hosts (excluding offline) let mut worst_status = Status::Ok; for host in &self.available_hosts { let host_status = self.calculate_host_status(host, metric_store); // Don't include offline hosts in status aggregation if host_status != Status::Offline { worst_status = Status::aggregate(&[worst_status, host_status]); } } // Use the worst status color as background let background_color = Theme::status_color(worst_status); // Single line title bar showing dashboard name (left) and dashboard IP (right) let left_text = format!(" cm-dashboard v{}", env!("CARGO_PKG_VERSION")); // Get dashboard local IP for right side let dashboard_ip = Self::get_local_ip(); let right_text = format!("{} ", dashboard_ip); // Calculate spacing to push right text to the right let total_text_len = left_text.len() + right_text.len(); let spacing = (area.width as usize).saturating_sub(total_text_len).max(1); let spacing_str = " ".repeat(spacing); let title = Paragraph::new(Line::from(vec![ Span::styled( left_text, Style::default().fg(Theme::background()).bg(background_color).add_modifier(Modifier::BOLD) ), Span::styled( spacing_str, Style::default().bg(background_color) ), Span::styled( right_text, Style::default().fg(Theme::background()).bg(background_color) ), ])) .style(Style::default().bg(background_color)); frame.render_widget(title, area); } /// Calculate overall status for a host based on its structured data fn calculate_host_status(&self, hostname: &str, metric_store: &MetricStore) -> Status { // Check if we have structured data for this host if let Some(_agent_data) = metric_store.get_agent_data(hostname) { // Return OK since we have data Status::Ok } else { Status::Offline } } /// Render popup menu for service actions fn render_popup_menu(&self, frame: &mut Frame, popup: &PopupMenu) { use ratatui::widgets::{Block, Borders, Clear, List, ListItem}; use ratatui::style::{Color, Modifier}; // Menu items let items = vec![ "Start Service", "Stop Service", "View Logs", ]; // Calculate popup size let width = 20; let height = items.len() as u16 + 2; // +2 for borders // Position popup near click location, but keep it on screen let screen_width = frame.size().width; let screen_height = frame.size().height; let x = if popup.x + width < screen_width { popup.x } else { screen_width.saturating_sub(width) }; let y = if popup.y + height < screen_height { popup.y } else { screen_height.saturating_sub(height) }; let popup_area = Rect { x, y, width, height, }; // Create menu items with selection highlight let menu_items: Vec = items .iter() .enumerate() .map(|(i, item)| { let style = if i == popup.selected_index { Style::default() .fg(Color::Black) .bg(Color::White) .add_modifier(Modifier::BOLD) } else { Style::default().fg(Theme::primary_text()) }; ListItem::new(*item).style(style) }) .collect(); let menu_list = List::new(menu_items) .block( Block::default() .borders(Borders::ALL) .style(Style::default().bg(Theme::background()).fg(Theme::primary_text())) ); // Clear the area and render menu frame.render_widget(Clear, popup_area); frame.render_widget(menu_list, popup_area); } /// Render statusbar with host and client IPs fn render_statusbar(&self, frame: &mut Frame, area: Rect, _metric_store: &MetricStore) { use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; // Get current host info let (hostname_str, host_ip, kernel_version, build_version, agent_version) = if let Some(hostname) = &self.current_host { // Get the connection IP (the IP dashboard uses to connect to the agent) let ip = if let Some(host_details) = self.config.hosts.get(hostname) { host_details.get_connection_ip(hostname) } else { hostname.clone() }; // Get kernel, build and agent versions from system widget let (kernel, build, agent) = if let Some(host_widgets) = self.host_widgets.get(hostname) { let kernel = host_widgets.system_widget.get_kernel_version().unwrap_or("N/A".to_string()); let build = host_widgets.system_widget.get_build_version().unwrap_or("N/A".to_string()); let agent = host_widgets.system_widget.get_agent_version().unwrap_or("N/A".to_string()); (kernel, build, agent) } else { ("N/A".to_string(), "N/A".to_string(), "N/A".to_string()) }; (hostname.clone(), ip, kernel, build, agent) } else { ("None".to_string(), "N/A".to_string(), "N/A".to_string(), "N/A".to_string(), "N/A".to_string()) }; let left_text = format!(" Host: {} | {} | {}", hostname_str, host_ip, kernel_version); let right_text = format!("Build:{} | Agent:{} ", build_version, agent_version); // Calculate spacing to push right text to the right let total_text_len = left_text.len() + right_text.len(); let spacing = (area.width as usize).saturating_sub(total_text_len).max(1); let spacing_str = " ".repeat(spacing); let line = Line::from(vec![ Span::styled(left_text, Style::default().fg(Theme::border())), Span::raw(spacing_str), Span::styled(right_text, Style::default().fg(Theme::border())), ]); let statusbar = Paragraph::new(line); frame.render_widget(statusbar, area); } /// Get local IP address of the dashboard fn get_local_ip() -> String { use std::net::UdpSocket; // Try to get local IP by creating a UDP socket // This doesn't actually send data, just determines routing if let Ok(socket) = UdpSocket::bind("0.0.0.0:0") { if socket.connect("8.8.8.8:80").is_ok() { if let Ok(addr) = socket.local_addr() { return addr.ip().to_string(); } } } "N/A".to_string() } fn render_system_panel(&mut self, frame: &mut Frame, area: Rect, _metric_store: &MetricStore) { let system_block = Components::widget_block("system"); let inner_area = system_block.inner(area); frame.render_widget(system_block, area); // Get current host widgets, create if none exist if let Some(hostname) = self.current_host.clone() { // Clone the config to avoid borrowing issues let config = self.config.clone(); let host_widgets = self.get_or_create_host_widgets(&hostname); host_widgets.system_widget.render(frame, inner_area, &hostname, Some(&config)); } } /// Render right panel with tabs (hosts | services) fn render_right_panel_with_tabs(&mut self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) { use ratatui::style::Modifier; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders}; // Build tab title with bold styling for active tab (like cm-player) let hosts_style = if self.focus_hosts { Style::default().fg(Theme::border_title()).add_modifier(Modifier::BOLD) } else { Style::default().fg(Theme::border_title()) }; let services_style = if !self.focus_hosts { Style::default().fg(Theme::border_title()).add_modifier(Modifier::BOLD) } else { Style::default().fg(Theme::border_title()) }; let title = Line::from(vec![ Span::styled("hosts", hosts_style), Span::raw(" | "), Span::styled("services", services_style), ]); // Create ONE block with tab title (like cm-player) let main_block = Block::default() .borders(Borders::ALL) .title(title.clone()) .style(Style::default().fg(Theme::border()).bg(Theme::background())); let inner_area = main_block.inner(area); frame.render_widget(main_block, area); // Render appropriate content based on active tab if self.focus_hosts { // Render hosts list (no additional borders) let localhost = self.localhost.clone(); let current_host = self.current_host.as_deref(); self.hosts_widget.render( frame, inner_area, &self.available_hosts, &localhost, current_host, metric_store, |hostname, store| { // Inline calculate_host_status logic if store.get_agent_data(hostname).is_some() { Status::Ok } else { Status::Offline } }, true, // Always focused when visible ); } else { // Render services for current host (no additional borders - just content!) if let Some(hostname) = self.current_host.clone() { let is_focused = true; let host_widgets = self.get_or_create_host_widgets(&hostname); host_widgets.services_widget.render_content(frame, inner_area, is_focused); } } } /// Render offline host message in system panel area fn render_offline_host_message(&self, frame: &mut Frame, area: Rect) { use ratatui::style::Modifier; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; // Get hostname for message let hostname = self.current_host.as_ref() .map(|h| h.as_str()) .unwrap_or("Unknown"); // Check if host has MAC address for wake-on-LAN let has_mac = self.current_host.as_ref() .and_then(|hostname| self.config.hosts.get(hostname)) .and_then(|details| details.mac_address.as_ref()) .is_some(); // Create message content let mut lines = vec![ Line::from(""), Line::from(Span::styled( format!(" Host '{}' is offline", hostname), Style::default().fg(Theme::muted_text()).add_modifier(Modifier::BOLD), )), Line::from(""), ]; if has_mac { lines.push(Line::from(Span::styled( " Press 'w' to wake up host", Style::default().fg(Theme::primary_text()), ))); } else { lines.push(Line::from(Span::styled( " No MAC address configured", Style::default().fg(Theme::muted_text()), ))); } // Render message in system panel with border let message = Paragraph::new(lines) .block(Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Theme::muted_text())) .title(" Offline ") .title_style(Style::default().fg(Theme::muted_text()).add_modifier(Modifier::BOLD))) .style(Style::default().bg(Theme::background()).fg(Theme::primary_text())); frame.render_widget(message, area); } /// Parse MAC address string (e.g., "AA:BB:CC:DD:EE:FF") to [u8; 6] /// Get the connection IP for a hostname based on host configuration fn get_connection_ip(&self, hostname: &str) -> String { if let Some(host_details) = self.config.hosts.get(hostname) { host_details.get_connection_ip(hostname) } else { hostname.to_string() } } fn parse_mac_address(mac_str: &str) -> Result<[u8; 6], &'static str> { let parts: Vec<&str> = mac_str.split(':').collect(); if parts.len() != 6 { return Err("MAC address must have 6 parts separated by colons"); } let mut mac = [0u8; 6]; for (i, part) in parts.iter().enumerate() { match u8::from_str_radix(part, 16) { Ok(byte) => mac[i] = byte, Err(_) => return Err("Invalid hexadecimal byte in MAC address"), } } Ok(mac) } }