Remove TUI feature and all related code

Drop ratatui, crossterm, hostname dependencies and the tui feature flag.
Delete src/tui/ directory, remove event bus plumbing from websocket client,
and simplify connect/init_logger to single implementations.

-1,979 lines of conditional compilation removed.
This commit is contained in:
Graham McIntire 2026-02-10 09:31:39 -06:00
parent dd29a783db
commit 58d53cab1e
No known key found for this signature in database
9 changed files with 49 additions and 1979 deletions

941
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -22,22 +22,13 @@ russh = "0.57"
async-trait = "0.1"
home = "=0.5.12"
zeroize = { version = "1", features = ["derive"] }
hostname = "0.4"
anyhow = "1.0"
surge-ping = "0.8"
rand = "0.8"
# TUI dependencies (optional feature)
ratatui = { version = "0.30", optional = true }
crossterm = { version = "0.29", optional = true }
[dev-dependencies]
regex = "1"
[features]
default = ["tui"]
tui = ["ratatui", "crossterm"]
[build-dependencies]
prost-build = "0.14"
cc = "1.0"

View file

@ -4,8 +4,6 @@ mod proto;
pub mod secret;
mod snmp;
mod ssh;
#[cfg(feature = "tui")]
mod tui;
mod version;
mod websocket_client;
@ -19,45 +17,16 @@ use tokio::time::sleep;
use tracing_subscriber::EnvFilter;
use websocket_client::AgentClient;
fn init_logger(suppress_stdout: bool) {
fn init_logger() {
// Use LOG_LEVEL env var (fall back to RUST_LOG for backwards compatibility)
let filter = env::var("LOG_LEVEL")
.or_else(|_| env::var("RUST_LOG"))
.unwrap_or_else(|_| "info".to_string());
let builder = tracing_subscriber::fmt()
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::new(&filter))
.with_target(false);
if suppress_stdout {
// Route to sink when TUI is active (using closure to create MakeWriter)
builder.with_writer(std::io::sink).init();
} else {
builder.init();
}
}
/// Determine if TUI should be enabled based on CLI flags and TTY detection
#[cfg(feature = "tui")]
fn should_enable_tui(args: &Args) -> bool {
// If explicitly disabled, never enable
if args.no_tui {
return false;
}
// If explicitly enabled, always enable
if let Some(explicit) = args.tui {
return explicit;
}
// Auto-detect: check if stdout is a TTY
use std::io::IsTerminal;
std::io::stdout().is_terminal()
}
#[cfg(not(feature = "tui"))]
fn should_enable_tui(_args: &Args) -> bool {
false
.with_target(false)
.init();
}
/// Convert HTTP(S) URL to WebSocket URL
@ -145,14 +114,6 @@ struct Args {
/// SNMPv3 priv password (for --snmpv3-test)
#[arg(long, default_value = "")]
snmpv3_priv_pass: String,
/// Enable TUI mode (auto-detects TTY by default)
#[arg(long, env = "TOWEROPS_TUI")]
tui: Option<bool>,
/// Force disable TUI even if TTY is available
#[arg(long, env = "TOWEROPS_NO_TUI", default_value_t = false)]
no_tui: bool,
}
fn main() {
@ -169,11 +130,8 @@ fn main() {
async fn async_main() {
let args = Args::parse();
// Determine if TUI should be enabled (before initializing logger)
let tui_enabled = should_enable_tui(&args);
// Initialize logging (suppress stdout if TUI is enabled)
init_logger(tui_enabled);
// Initialize logging
init_logger();
// Handle MikroTik test mode
if args.mikrotik_test {
@ -224,58 +182,12 @@ async fn async_main() {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
// Spawn signal handler for graceful shutdown
let shutdown_tx_clone = shutdown_tx.clone();
tokio::spawn(async move {
wait_for_shutdown_signal().await;
tracing::info!("Shutdown signal received, initiating graceful shutdown...");
let _ = shutdown_tx_clone.send(true);
let _ = shutdown_tx.send(true);
});
// Setup TUI if enabled
#[cfg(feature = "tui")]
let (event_bus, _tui_handle) = if tui_enabled {
use std::sync::Mutex;
// Get hostname for TUI state
let hostname = hostname::get()
.ok()
.and_then(|h| h.into_string().ok())
.unwrap_or_else(|| "unknown".to_string());
// Create event bus and shared state
let event_bus = tui::EventBus::new(100);
let agent_state = Arc::new(Mutex::new(tui::AgentState::new(
hostname,
version::current_version().to_string(),
)));
// Spawn TUI task
match tui::TuiApp::new(agent_state.clone(), event_bus.subscribe()) {
Ok(mut tui_app) => {
let shutdown_rx_clone = shutdown_rx.clone();
let tui_handle = tokio::spawn(async move {
if let Err(e) = tui_app.run(shutdown_rx_clone).await {
eprintln!("TUI task failed: {}", e);
}
// When TUI exits (user presses 'q'), trigger shutdown
let _ = shutdown_tx.send(true);
});
(Some(event_bus), Some(tui_handle))
}
Err(e) => {
eprintln!("Failed to initialize TUI: {}", e);
eprintln!("Falling back to log mode");
(None, None)
}
}
} else {
(None, None)
};
#[cfg(not(feature = "tui"))]
let event_bus: Option<()> = None;
// Retry loop with exponential backoff
let mut retry_delay = Duration::from_secs(1);
let max_retry_delay = Duration::from_secs(60);
@ -305,26 +217,6 @@ async fn async_main() {
}
// Connect to Towerops server via WebSocket
#[cfg(feature = "tui")]
let mut client = match AgentClient::connect(&ws_url, &token, event_bus.clone()).await {
Ok(client) => {
tracing::info!("Successfully connected to server");
// Mark as connected for health check
connected.store(true, Ordering::Relaxed);
// Reset retry delay on successful connection
retry_delay = Duration::from_secs(1);
attempt = 0;
client
}
Err(e) => {
tracing::error!("Failed to connect to server: {}", e);
// Mark as disconnected for health check
connected.store(false, Ordering::Relaxed);
continue;
}
};
#[cfg(not(feature = "tui"))]
let mut client = match AgentClient::connect(&ws_url, &token).await {
Ok(client) => {
tracing::info!("Successfully connected to server");

View file

@ -1,150 +0,0 @@
use std::io::{self, Stdout};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crossterm::{
event::{self, Event, KeyCode, KeyEvent},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use tokio::sync::{broadcast, watch};
use tokio::time::interval;
use super::events::AgentEvent;
use super::state::AgentState;
use super::ui;
pub struct TuiApp {
state: Arc<Mutex<AgentState>>,
event_rx: broadcast::Receiver<AgentEvent>,
terminal: Terminal<CrosstermBackend<Stdout>>,
scroll_offset: usize,
}
impl TuiApp {
pub fn new(
state: Arc<Mutex<AgentState>>,
event_rx: broadcast::Receiver<AgentEvent>,
) -> anyhow::Result<Self> {
// Setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(Self {
state,
event_rx,
terminal,
scroll_offset: 0,
})
}
pub async fn run(&mut self, mut shutdown_rx: watch::Receiver<bool>) -> anyhow::Result<()> {
let mut tick_interval = interval(Duration::from_millis(100)); // 10 FPS
loop {
tokio::select! {
// Check for shutdown signal
_ = shutdown_rx.changed() => {
break;
}
// Handle agent events
result = self.event_rx.recv() => {
match result {
Ok(event) => {
let mut state = self.state.lock().unwrap();
state.apply_event(&event);
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
tracing::warn!("TUI lagged behind, skipped {} events", skipped);
}
Err(broadcast::error::RecvError::Closed) => {
break;
}
}
}
// Render tick
_ = tick_interval.tick() => {
self.render()?;
}
// Handle keyboard input (non-blocking)
_ = tokio::time::sleep(Duration::from_millis(50)) => {
if event::poll(Duration::from_millis(0))? {
if let Event::Key(key) = event::read()? {
if self.handle_key(key) {
break; // User pressed 'q' to quit
}
}
}
}
}
}
self.cleanup()?;
Ok(())
}
fn render(&mut self) -> anyhow::Result<()> {
let state = self.state.lock().unwrap().clone();
let scroll_offset = self.scroll_offset;
self.terminal.draw(|f| {
ui::render(f, &state, scroll_offset);
})?;
Ok(())
}
fn handle_key(&mut self, key: KeyEvent) -> bool {
match key.code {
KeyCode::Char('q') => {
return true; // Signal to quit
}
KeyCode::Up => {
self.scroll_offset = self.scroll_offset.saturating_add(1);
}
KeyCode::Down => {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
KeyCode::PageUp => {
self.scroll_offset = self.scroll_offset.saturating_add(10);
}
KeyCode::PageDown => {
self.scroll_offset = self.scroll_offset.saturating_sub(10);
}
KeyCode::Home => {
// Go to end (most recent events)
self.scroll_offset = 0;
}
KeyCode::End => {
// Go to beginning (oldest events)
let state = self.state.lock().unwrap();
self.scroll_offset = state.recent_events.len().saturating_sub(1);
}
_ => {}
}
false
}
fn cleanup(&mut self) -> anyhow::Result<()> {
disable_raw_mode()?;
execute!(self.terminal.backend_mut(), LeaveAlternateScreen,)?;
self.terminal.show_cursor()?;
Ok(())
}
}
impl Drop for TuiApp {
fn drop(&mut self) {
// Ensure terminal is restored even on panic
let _ = disable_raw_mode();
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen,);
let _ = self.terminal.show_cursor();
}
}

View file

@ -1,72 +0,0 @@
use tokio::sync::broadcast;
#[derive(Debug, Clone)]
pub enum AgentEvent {
Connected {
agent_id: String,
},
Disconnected,
JobReceived {
job_id: String,
device_id: String,
job_type: String,
},
JobCompleted {
job_id: String,
device_id: String,
duration_ms: u64,
},
JobFailed {
job_id: String,
device_id: String,
error: String,
},
SnmpResultSent {
device_id: String,
oid_count: usize,
},
MikrotikResultSent {
device_id: String,
sentence_count: usize,
},
MonitoringCheckSent {
device_id: String,
status: String,
},
PollerCreated {
device_ip: String,
total_count: usize,
},
PollerRemoved {
device_ip: String,
total_count: usize,
},
HeartbeatSent,
PhxHeartbeatSent,
Error {
message: String,
},
}
#[derive(Clone)]
pub struct EventBus {
tx: broadcast::Sender<AgentEvent>,
}
impl EventBus {
pub fn new(capacity: usize) -> Self {
let (tx, _) = broadcast::channel(capacity);
Self { tx }
}
pub fn subscribe(&self) -> broadcast::Receiver<AgentEvent> {
self.tx.subscribe()
}
pub fn send(
&self,
event: AgentEvent,
) -> Result<usize, broadcast::error::SendError<AgentEvent>> {
self.tx.send(event)
}
}

View file

@ -1,8 +0,0 @@
mod app;
mod events;
mod state;
mod ui;
pub use app::TuiApp;
pub use events::{AgentEvent, EventBus};
pub use state::AgentState;

View file

@ -1,172 +0,0 @@
use std::collections::VecDeque;
use std::time::Instant;
use super::events::AgentEvent;
#[derive(Clone)]
pub struct AgentState {
// Connection
pub connected: bool,
pub agent_id: String,
// System info
pub hostname: String,
pub version: String,
pub started_at: Instant,
// Active jobs
pub active_pollers: usize,
pub active_devices: Vec<String>,
// Event log (ring buffer, last 100 events)
pub recent_events: VecDeque<(Instant, String)>,
// Statistics
pub stats: Stats,
}
#[derive(Clone, Default)]
pub struct Stats {
pub jobs_received: u64,
pub snmp_results_sent: u64,
pub mikrotik_results_sent: u64,
pub heartbeats_sent: u64,
pub errors: u64,
pub last_heartbeat_at: Option<Instant>,
}
impl AgentState {
pub fn new(hostname: String, version: String) -> Self {
Self {
connected: false,
agent_id: String::new(),
hostname,
version,
started_at: Instant::now(),
active_pollers: 0,
active_devices: Vec::new(),
recent_events: VecDeque::with_capacity(100),
stats: Stats::default(),
}
}
pub fn apply_event(&mut self, event: &AgentEvent) {
let now = Instant::now();
let event_msg = match event {
AgentEvent::Connected { agent_id } => {
self.connected = true;
self.agent_id = agent_id.clone();
format!("Connected to server (agent_id: {})", agent_id)
}
AgentEvent::Disconnected => {
self.connected = false;
"Disconnected from server".to_string()
}
AgentEvent::JobReceived {
job_id,
device_id,
job_type,
} => {
self.stats.jobs_received += 1;
format!("Job received: {} ({} - {})", job_id, device_id, job_type)
}
AgentEvent::JobCompleted {
job_id,
device_id,
duration_ms,
} => {
format!(
"Job completed: {} ({}) in {}ms",
job_id, device_id, duration_ms
)
}
AgentEvent::JobFailed {
job_id,
device_id,
error,
} => {
self.stats.errors += 1;
format!("Job failed: {} ({}) - {}", job_id, device_id, error)
}
AgentEvent::SnmpResultSent {
device_id,
oid_count,
} => {
self.stats.snmp_results_sent += 1;
format!("SNMP result sent for {} ({} OIDs)", device_id, oid_count)
}
AgentEvent::MikrotikResultSent {
device_id,
sentence_count,
} => {
self.stats.mikrotik_results_sent += 1;
format!(
"MikroTik result sent for {} ({} sentences)",
device_id, sentence_count
)
}
AgentEvent::MonitoringCheckSent { device_id, status } => {
format!(
"Monitoring check sent for {} (status: {})",
device_id, status
)
}
AgentEvent::PollerCreated {
device_ip,
total_count,
} => {
self.active_pollers = *total_count;
if !self.active_devices.contains(device_ip) {
self.active_devices.push(device_ip.clone());
}
format!(
"Poller created for {} ({} total pollers)",
device_ip, total_count
)
}
AgentEvent::PollerRemoved {
device_ip,
total_count,
} => {
self.active_pollers = *total_count;
self.active_devices.retain(|d| d != device_ip);
format!(
"Poller removed for {} ({} total pollers)",
device_ip, total_count
)
}
AgentEvent::HeartbeatSent => {
self.stats.heartbeats_sent += 1;
self.stats.last_heartbeat_at = Some(now);
"Heartbeat sent".to_string()
}
AgentEvent::PhxHeartbeatSent => "Phoenix heartbeat sent".to_string(),
AgentEvent::Error { message } => {
self.stats.errors += 1;
format!("Error: {}", message)
}
};
// Add event to ring buffer (keep last 100)
if self.recent_events.len() >= 100 {
self.recent_events.pop_front();
}
self.recent_events.push_back((now, event_msg));
}
pub fn uptime_seconds(&self) -> u64 {
self.started_at.elapsed().as_secs()
}
pub fn format_uptime(&self) -> String {
let seconds = self.uptime_seconds();
let hours = seconds / 3600;
let minutes = (seconds % 3600) / 60;
let secs = seconds % 60;
format!("{}h {}m {}s", hours, minutes, secs)
}
pub fn last_heartbeat_ago(&self) -> Option<u64> {
self.stats.last_heartbeat_at.map(|t| t.elapsed().as_secs())
}
}

View file

@ -1,194 +0,0 @@
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
use super::state::AgentState;
pub fn render(f: &mut Frame, state: &AgentState, scroll_offset: usize) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Length(3), // Stats
Constraint::Length(8), // Devices
Constraint::Min(0), // Events log
])
.split(f.area());
render_header(f, chunks[0], state);
render_stats(f, chunks[1], state);
render_devices(f, chunks[2], state);
render_events(f, chunks[3], state, scroll_offset);
}
fn render_header(f: &mut Frame, area: Rect, state: &AgentState) {
let status_color = if state.connected {
Color::Green
} else {
Color::Red
};
let status_text = if state.connected {
"Connected"
} else {
"Disconnected"
};
let version_str = format!(" | v{}", state.version);
let uptime_str = format!(" | Uptime: {}", state.format_uptime());
let header_lines = vec![
Line::from(vec![
Span::raw("Status: "),
Span::styled(
status_text,
Style::default()
.fg(status_color)
.add_modifier(Modifier::BOLD),
),
Span::raw(" | Agent ID: "),
Span::styled(&state.agent_id, Style::default().fg(Color::Cyan)),
Span::raw(version_str),
]),
Line::from(vec![
Span::raw("Hostname: "),
Span::styled(&state.hostname, Style::default().fg(Color::Yellow)),
Span::raw(uptime_str),
]),
];
let header = Paragraph::new(header_lines).block(
Block::default()
.title("Towerops Agent")
.borders(Borders::ALL),
);
f.render_widget(header, area);
}
fn render_stats(f: &mut Frame, area: Rect, state: &AgentState) {
let heartbeat_ago = state
.last_heartbeat_ago()
.map(|s| format!("{}s ago", s))
.unwrap_or_else(|| "never".to_string());
let heartbeat_str = format!(" | Last Heartbeat: {}", heartbeat_ago);
let stats_lines = vec![
Line::from(vec![
Span::raw("Jobs: "),
Span::styled(
format!("{}", state.stats.jobs_received),
Style::default().fg(Color::Cyan),
),
Span::raw(" | SNMP: "),
Span::styled(
format!("{}", state.stats.snmp_results_sent),
Style::default().fg(Color::Green),
),
Span::raw(" | MikroTik: "),
Span::styled(
format!("{}", state.stats.mikrotik_results_sent),
Style::default().fg(Color::Green),
),
Span::raw(" | Errors: "),
Span::styled(
format!("{}", state.stats.errors),
Style::default().fg(if state.stats.errors > 0 {
Color::Red
} else {
Color::White
}),
),
]),
Line::from(vec![
Span::raw("Active Pollers: "),
Span::styled(
format!("{}", state.active_pollers),
Style::default().fg(Color::Yellow),
),
Span::raw(heartbeat_str),
]),
];
let stats = Paragraph::new(stats_lines)
.block(Block::default().title("Statistics").borders(Borders::ALL));
f.render_widget(stats, area);
}
fn render_devices(f: &mut Frame, area: Rect, state: &AgentState) {
let device_items: Vec<ListItem> = state
.active_devices
.iter()
.take(10)
.map(|device| {
ListItem::new(Line::from(vec![
Span::raw(""),
Span::styled(device, Style::default().fg(Color::Cyan)),
]))
})
.collect();
let more_count = state.active_devices.len().saturating_sub(10);
let title = if more_count > 0 {
format!("Active Devices ({} more)", more_count)
} else {
"Active Devices".to_string()
};
let devices =
List::new(device_items).block(Block::default().title(title).borders(Borders::ALL));
f.render_widget(devices, area);
}
fn render_events(f: &mut Frame, area: Rect, state: &AgentState, scroll_offset: usize) {
let event_items: Vec<ListItem> = state
.recent_events
.iter()
.rev()
.skip(scroll_offset)
.take(area.height.saturating_sub(2) as usize)
.map(|(timestamp, message)| {
let elapsed = timestamp.elapsed().as_secs();
let time_str = if elapsed < 60 {
format!("{}s ago", elapsed)
} else if elapsed < 3600 {
format!("{}m ago", elapsed / 60)
} else {
format!("{}h ago", elapsed / 3600)
};
let color = if message.contains("Error") || message.contains("failed") {
Color::Red
} else if message.contains("sent") || message.contains("completed") {
Color::Green
} else if message.contains("received") {
Color::Yellow
} else {
Color::White
};
ListItem::new(Line::from(vec![
Span::styled(
format!("[{}] ", time_str),
Style::default().fg(Color::DarkGray),
),
Span::styled(message, Style::default().fg(color)),
]))
})
.collect();
let events = List::new(event_items).block(
Block::default()
.title("Recent Events (Press 'q' to quit, ↑/↓ to scroll)")
.borders(Borders::ALL),
);
f.render_widget(events, area);
}

View file

@ -69,9 +69,6 @@ pub struct AgentClient {
phx_heartbeat_ref: u64,
/// Cached hostname (computed once at startup, avoids blocking /proc reads)
cached_hostname: String,
/// Optional event bus for TUI updates
#[cfg(feature = "tui")]
event_bus: Option<crate::tui::EventBus>,
}
impl AgentClient {
@ -80,124 +77,7 @@ impl AgentClient {
/// # Arguments
/// * `url` - Server URL (e.g., "wss://towerops.net")
/// * `token` - Agent authentication token
/// * `event_bus` - Optional event bus for TUI updates
///
/// # Example
/// ```no_run
/// let client = AgentClient::connect("wss://towerops.net", "token123", None).await?;
/// ```
#[cfg(feature = "tui")]
pub async fn connect(
url: &str,
token: &SecretString,
event_bus: Option<crate::tui::EventBus>,
) -> Result<Self> {
Self::connect_internal(url, token, event_bus).await
}
#[cfg(not(feature = "tui"))]
pub async fn connect(url: &str, token: &SecretString) -> Result<Self> {
Self::connect_internal(url, token).await
}
#[cfg(feature = "tui")]
async fn connect_internal(
url: &str,
token: &SecretString,
event_bus: Option<crate::tui::EventBus>,
) -> Result<Self> {
// Strip trailing slash from base URL to avoid double slashes
let base_url = url.trim_end_matches('/');
let ws_url = format!("{}/socket/agent/websocket", base_url);
tracing::info!(
"Connecting to WebSocket: {} (timeout: {}s)",
ws_url,
CONNECTION_TIMEOUT.as_secs()
);
// Wrap connection in timeout to avoid hanging indefinitely on bad network
let (ws_stream, _) = match timeout(CONNECTION_TIMEOUT, connect_async(&ws_url)).await {
Ok(Ok(result)) => result,
Ok(Err(e)) => {
tracing::error!("WebSocket connection failed: {}", e);
return Err(format!("Failed to connect to WebSocket: {}", e).into());
}
Err(_) => {
tracing::error!(
"WebSocket connection timed out after {}s",
CONNECTION_TIMEOUT.as_secs()
);
return Err(format!(
"Connection timed out after {}s",
CONNECTION_TIMEOUT.as_secs()
)
.into());
}
};
tracing::info!("Connected to Towerops server at {}", url);
let agent_id = generate_agent_id();
let (result_tx, result_rx) = mpsc::channel(RESULT_CHANNEL_CAPACITY);
let (mikrotik_result_tx, mikrotik_result_rx) = mpsc::channel(RESULT_CHANNEL_CAPACITY);
let (credential_test_tx, credential_test_rx) = mpsc::channel(RESULT_CHANNEL_CAPACITY);
let (monitoring_check_tx, monitoring_check_rx) = mpsc::channel(RESULT_CHANNEL_CAPACITY);
// Split the WebSocket stream so reads and writes can proceed concurrently.
// The write half is owned by a dedicated writer task.
let (ws_write, ws_read) = ws_stream.split();
let (ws_write_tx, ws_write_rx) = mpsc::channel::<WsMessage>(WS_WRITE_CHANNEL_CAPACITY);
tokio::spawn(ws_writer_task(ws_write, ws_write_rx));
// Join Phoenix channel with token in payload
let join_msg = PhoenixMessage {
topic: format!("agent:{}", agent_id),
event: "phx_join".to_string(),
payload: serde_json::json!({"token": token.expose()}),
reference: Some("1".to_string()),
};
let join_text = serde_json::to_string(&join_msg)?;
ws_write_tx
.send(WsMessage::Text(join_text.into()))
.await
.map_err(|e| format!("Failed to send join message: {}", e))?;
tracing::info!(
"Sent channel join request with token for agent:{}",
agent_id
);
let client = Self {
ws_read,
ws_write_tx,
agent_id: agent_id.clone(),
result_tx,
result_rx,
mikrotik_result_tx,
mikrotik_result_rx,
credential_test_tx,
credential_test_rx,
monitoring_check_tx,
monitoring_check_rx,
poller_registry: PollerRegistry::new(),
phx_heartbeat_ref: 0,
cached_hostname: get_hostname(),
#[cfg(feature = "tui")]
event_bus: event_bus.clone(),
};
// Publish connected event
#[cfg(feature = "tui")]
client.publish_event(crate::tui::AgentEvent::Connected {
agent_id: agent_id.clone(),
});
Ok(client)
}
#[cfg(not(feature = "tui"))]
async fn connect_internal(url: &str, token: &SecretString) -> Result<Self> {
// Strip trailing slash from base URL to avoid double slashes
let base_url = url.trim_end_matches('/');
let ws_url = format!("{}/socket/agent/websocket", base_url);
@ -278,19 +158,6 @@ impl AgentClient {
})
}
/// Publish an event to the TUI event bus (if enabled).
#[cfg(feature = "tui")]
fn publish_event(&self, event: crate::tui::AgentEvent) {
if let Some(bus) = &self.event_bus {
let _ = bus.send(event); // Fire-and-forget
}
}
#[cfg(not(feature = "tui"))]
fn publish_event(&self, _event: ()) {
// No-op when TUI is disabled
}
/// Main event loop for agent operation.
///
/// Handles:
@ -330,22 +197,16 @@ impl AgentClient {
}
Some(Ok(WsMessage::Close(_))) => {
tracing::info!("Server closed connection");
#[cfg(feature = "tui")]
self.publish_event(crate::tui::AgentEvent::Disconnected);
self.poller_registry.shutdown_all();
break Ok(());
}
Some(Err(e)) => {
tracing::error!("WebSocket error: {}", e);
#[cfg(feature = "tui")]
self.publish_event(crate::tui::AgentEvent::Disconnected);
self.poller_registry.shutdown_all();
break Err(e.into());
}
None => {
tracing::info!("Connection closed");
#[cfg(feature = "tui")]
self.publish_event(crate::tui::AgentEvent::Disconnected);
self.poller_registry.shutdown_all();
break Ok(());
}
@ -355,29 +216,15 @@ impl AgentClient {
// Receive SNMP results from job tasks
Some(snmp_result) = self.result_rx.recv() => {
#[cfg(feature = "tui")]
let device_id = snmp_result.device_id.clone();
if let Err(e) = self.send_snmp_result(snmp_result).await {
tracing::error!("Error sending SNMP result: {}", e);
#[cfg(feature = "tui")]
self.publish_event(crate::tui::AgentEvent::Error {
message: format!("SNMP result send failed for {}: {}", device_id, e),
});
}
}
// Receive MikroTik results from job tasks
Some(mikrotik_result) = self.mikrotik_result_rx.recv() => {
#[cfg(feature = "tui")]
let device_id = mikrotik_result.device_id.clone();
if let Err(e) = self.send_mikrotik_result(mikrotik_result).await {
tracing::error!("Error sending MikroTik result: {}", e);
#[cfg(feature = "tui")]
self.publish_event(crate::tui::AgentEvent::Error {
message: format!("MikroTik result send failed for {}: {}", device_id, e),
});
}
}
@ -390,15 +237,8 @@ impl AgentClient {
// Receive monitoring check results from job tasks
Some(monitoring_check) = self.monitoring_check_rx.recv() => {
#[cfg(feature = "tui")]
let device_id = monitoring_check.device_id.clone();
if let Err(e) = self.send_monitoring_check(monitoring_check).await {
tracing::error!("Error sending monitoring check: {}", e);
#[cfg(feature = "tui")]
self.publish_event(crate::tui::AgentEvent::Error {
message: format!("Monitoring check send failed for {}: {}", device_id, e),
});
}
}
@ -406,13 +246,6 @@ impl AgentClient {
_ = heartbeat_interval.tick() => {
if let Err(e) = self.send_heartbeat().await {
tracing::error!("Error sending heartbeat: {}", e);
#[cfg(feature = "tui")]
self.publish_event(crate::tui::AgentEvent::Error {
message: format!("Heartbeat failed: {}", e),
});
} else {
#[cfg(feature = "tui")]
self.publish_event(crate::tui::AgentEvent::HeartbeatSent);
}
// Log active poller count
let count = self.poller_registry.count();
@ -425,9 +258,6 @@ impl AgentClient {
_ = phx_heartbeat_interval.tick() => {
if let Err(e) = self.send_phx_heartbeat().await {
tracing::error!("Error sending Phoenix heartbeat: {}", e);
} else {
#[cfg(feature = "tui")]
self.publish_event(crate::tui::AgentEvent::PhxHeartbeatSent);
}
}
}
@ -493,17 +323,7 @@ impl AgentClient {
"Removing poller for device no longer in job list: {}",
device_id
);
let device_ip = self.poller_registry.remove(&device_id);
#[cfg(feature = "tui")]
if let Some(ref bus) = self.event_bus {
if let Some(ip) = device_ip {
let _ = bus.send(crate::tui::AgentEvent::PollerRemoved {
device_ip: ip,
total_count: self.poller_registry.count(),
});
}
}
self.poller_registry.remove(&device_id);
}
}
@ -511,123 +331,34 @@ impl AgentClient {
let job_type = JobType::try_from(job.job_type).unwrap_or(JobType::Poll);
tracing::info!("Executing job: {} (type: {:?})", job.job_id, job_type);
#[cfg(feature = "tui")]
{
if let Some(ref bus) = self.event_bus {
let _ = bus.send(crate::tui::AgentEvent::JobReceived {
job_id: job.job_id.clone(),
device_id: job.device_id.clone(),
job_type: format!("{:?}", job_type),
});
}
}
match job_type {
JobType::Mikrotik => {
// Execute MikroTik API job
let mikrotik_result_tx = self.mikrotik_result_tx.clone();
#[cfg(feature = "tui")]
let event_bus = self.event_bus.clone();
let job_id = job.job_id.clone();
let device_id = job.device_id.clone();
tokio::spawn(async move {
let start_time = std::time::Instant::now();
match execute_mikrotik_job(job, mikrotik_result_tx).await {
Ok(_) =>
{
#[cfg(feature = "tui")]
if let Some(ref bus) = event_bus {
let _ = bus.send(crate::tui::AgentEvent::JobCompleted {
job_id,
device_id,
duration_ms: start_time.elapsed().as_millis() as u64,
});
}
}
Err(e) => {
tracing::error!("MikroTik job execution failed: {}", e);
#[cfg(feature = "tui")]
if let Some(ref bus) = event_bus {
let _ = bus.send(crate::tui::AgentEvent::JobFailed {
job_id,
device_id,
error: e.to_string(),
});
}
}
if let Err(e) = execute_mikrotik_job(job, mikrotik_result_tx).await {
tracing::error!("MikroTik job execution failed: {}", e);
}
});
}
JobType::TestCredentials => {
// Execute credential test
let credential_test_tx = self.credential_test_tx.clone();
#[cfg(feature = "tui")]
let event_bus = self.event_bus.clone();
let job_id = job.job_id.clone();
let device_id = job.device_id.clone();
tokio::spawn(async move {
let start_time = std::time::Instant::now();
match execute_credential_test(job, credential_test_tx).await {
Ok(_) =>
{
#[cfg(feature = "tui")]
if let Some(ref bus) = event_bus {
let _ = bus.send(crate::tui::AgentEvent::JobCompleted {
job_id,
device_id,
duration_ms: start_time.elapsed().as_millis() as u64,
});
}
}
Err(e) => {
tracing::error!("Credential test execution failed: {}", e);
#[cfg(feature = "tui")]
if let Some(ref bus) = event_bus {
let _ = bus.send(crate::tui::AgentEvent::JobFailed {
job_id,
device_id,
error: e.to_string(),
});
}
}
if let Err(e) = execute_credential_test(job, credential_test_tx).await {
tracing::error!("Credential test execution failed: {}", e);
}
});
}
JobType::Ping => {
// Execute ICMP ping health check
let monitoring_check_tx = self.monitoring_check_tx.clone();
#[cfg(feature = "tui")]
let event_bus = self.event_bus.clone();
let job_id = job.job_id.clone();
let device_id = job.device_id.clone();
tokio::spawn(async move {
let start_time = std::time::Instant::now();
match execute_ping_job(job, monitoring_check_tx).await {
Ok(_) =>
{
#[cfg(feature = "tui")]
if let Some(ref bus) = event_bus {
let _ = bus.send(crate::tui::AgentEvent::JobCompleted {
job_id,
device_id,
duration_ms: start_time.elapsed().as_millis() as u64,
});
}
}
Err(e) => {
tracing::error!("Ping job execution failed: {}", e);
#[cfg(feature = "tui")]
if let Some(ref bus) = event_bus {
let _ = bus.send(crate::tui::AgentEvent::JobFailed {
job_id,
device_id,
error: e.to_string(),
});
}
}
if let Err(e) = execute_ping_job(job, monitoring_check_tx).await {
tracing::error!("Ping job execution failed: {}", e);
}
});
}
@ -635,45 +366,10 @@ impl AgentClient {
// Execute SNMP job (discovery or polling)
let result_tx = self.result_tx.clone();
let poller_registry = self.poller_registry.clone();
#[cfg(feature = "tui")]
let event_bus = self.event_bus.clone();
#[cfg(feature = "tui")]
let event_bus_for_task = event_bus.clone();
let job_id = job.job_id.clone();
let device_id = job.device_id.clone();
tokio::spawn(async move {
let start_time = std::time::Instant::now();
#[cfg(feature = "tui")]
let result =
execute_snmp_job(job, result_tx, poller_registry, event_bus_for_task)
.await;
#[cfg(not(feature = "tui"))]
let result = execute_snmp_job(job, result_tx, poller_registry).await;
match result {
Ok(_) =>
{
#[cfg(feature = "tui")]
if let Some(ref bus) = event_bus {
let _ = bus.send(crate::tui::AgentEvent::JobCompleted {
job_id,
device_id,
duration_ms: start_time.elapsed().as_millis() as u64,
});
}
}
Err(e) => {
tracing::error!("SNMP job execution failed: {}", e);
#[cfg(feature = "tui")]
if let Some(ref bus) = event_bus {
let _ = bus.send(crate::tui::AgentEvent::JobFailed {
job_id,
device_id,
error: e.to_string(),
});
}
}
if let Err(e) = execute_snmp_job(job, result_tx, poller_registry).await {
tracing::error!("SNMP job execution failed: {}", e);
}
});
}
@ -757,13 +453,6 @@ impl AgentClient {
.map_err(|e| format!("Writer task closed: {}", e))?;
tracing::debug!("Sent SNMP result for device {}", result.device_id);
#[cfg(feature = "tui")]
self.publish_event(crate::tui::AgentEvent::SnmpResultSent {
device_id: result.device_id.clone(),
oid_count: result.oid_values.len(),
});
Ok(())
}
@ -789,13 +478,6 @@ impl AgentClient {
result.device_id,
result.job_id
);
#[cfg(feature = "tui")]
self.publish_event(crate::tui::AgentEvent::MikrotikResultSent {
device_id: result.device_id.clone(),
sentence_count: result.sentences.len(),
});
Ok(())
}
@ -846,13 +528,6 @@ impl AgentClient {
result.device_id,
result.status
);
#[cfg(feature = "tui")]
self.publish_event(crate::tui::AgentEvent::MonitoringCheckSent {
device_id: result.device_id.clone(),
status: result.status.clone(),
});
Ok(())
}
}
@ -892,7 +567,6 @@ async fn execute_snmp_job(
job: AgentJob,
result_tx: mpsc::Sender<SnmpResult>,
poller_registry: PollerRegistry,
#[cfg(feature = "tui")] event_bus: Option<crate::tui::EventBus>,
) -> Result<()> {
let mut snmp_device = job.snmp_device.ok_or("Job missing SNMP device info")?;
@ -963,24 +637,8 @@ async fn execute_snmp_job(
snmp_device.v3_auth_password.zeroize();
snmp_device.v3_priv_password.zeroize();
#[cfg(feature = "tui")]
let count_before = poller_registry.count();
let poller = poller_registry.get_or_create(job.device_id.clone(), device_config);
#[cfg(feature = "tui")]
{
let count_after = poller_registry.count();
if count_after > count_before {
if let Some(ref bus) = event_bus {
let _ = bus.send(crate::tui::AgentEvent::PollerCreated {
device_ip: snmp_device.ip.clone(),
total_count: count_after,
});
}
}
}
let mut oid_values: HashMap<String, String> = HashMap::new();
for query in job.queries {