use async_trait::async_trait;
use regex::{Regex, RegexBuilder};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::app_state::{AppState, ConnectionStatus};
use crate::common::config::EnvConfig;
use crate::network::NetworkClient;
use crate::storage::info::StorageInfo;
use super::aggregator::DataAggregator;
use super::strategy::{
CollectionConfig, CollectionData, CollectionError, CollectionResult, DataCollectionStrategy,
};
fn extract_hostname_from_url(url: &str) -> String {
if url.starts_with("http://") || url.starts_with("https://") {
if let Some(start) = url.find("://") {
let after_protocol = &url[start + 3..];
if let Some(end) = after_protocol.find('/') {
after_protocol[..end].to_string()
} else {
after_protocol.to_string()
}
} else {
url.to_string()
}
} else {
url.to_string()
}
}
fn extract_host_identifier(url: &str) -> String {
extract_hostname_from_url(url)
}
pub struct RemoteCollector {
network_client: NetworkClient,
semaphore: Arc<tokio::sync::Semaphore>,
regex: Regex,
aggregator: DataAggregator,
}
impl RemoteCollector {
pub fn new(max_connections: usize) -> Self {
let regex = RegexBuilder::new(r"^all_smi_([^\{]+)\{([^}]+)\} ([\d\.]+)$")
.size_limit(10_485_760) .dfa_size_limit(10_485_760) .build()
.expect("Failed to compile metrics regex");
Self {
network_client: NetworkClient::new(),
semaphore: Arc::new(tokio::sync::Semaphore::new(max_connections)),
regex,
aggregator: DataAggregator::new(),
}
}
#[allow(dead_code)]
pub fn with_hosts(hosts: Vec<String>) -> Self {
let max_connections = EnvConfig::max_concurrent_connections(hosts.len());
Self::new(max_connections)
}
fn deduplicate_storage_info(storage_info: Vec<StorageInfo>) -> Vec<StorageInfo> {
let mut deduplicated_storage: HashMap<String, StorageInfo> = HashMap::new();
for storage in storage_info {
let dedup_key = format!("{}:{}", storage.hostname, storage.mount_point);
deduplicated_storage.insert(dedup_key, storage);
}
let mut final_storage_info: Vec<StorageInfo> = deduplicated_storage.into_values().collect();
final_storage_info.sort_by(|a, b| match a.hostname.cmp(&b.hostname) {
std::cmp::Ordering::Equal => a.mount_point.cmp(&b.mount_point),
other => other,
});
final_storage_info
}
fn update_connection_status(
state: &mut AppState,
connection_statuses: Vec<ConnectionStatus>,
hosts: &[String],
) {
if state.known_hosts.is_empty() {
state.known_hosts = hosts.iter().map(|h| extract_host_identifier(h)).collect();
}
state.hostname_to_host_id.clear();
for mut status in connection_statuses {
if status.actual_hostname.is_none()
&& let Some(existing_status) = state.connection_status.get(&status.host_id)
&& let Some(existing_hostname) = &existing_status.actual_hostname
{
status.actual_hostname = Some(existing_hostname.clone());
}
if let Some(actual_hostname) = &status.actual_hostname {
state
.hostname_to_host_id
.insert(actual_hostname.clone(), status.host_id.clone());
}
state
.connection_status
.insert(status.host_id.clone(), status);
}
for host in hosts {
let host_id = extract_host_identifier(host);
state
.connection_status
.entry(host_id.clone())
.or_insert_with(|| {
let mut status = ConnectionStatus::new(host_id, host.clone());
status.mark_failure("No response received".to_string());
status
});
}
}
fn update_remote_tabs(state: &mut AppState) {
let mut tabs = vec![
"All".to_string(),
crate::ui::tabs::USERS_TAB_NAME.to_string(),
crate::ui::tabs::TOPOLOGY_TAB_NAME.to_string(),
];
tabs.extend(state.known_hosts.clone());
let previous_name = state.tabs.get(state.current_tab).cloned();
state.tabs = tabs;
if let Some(name) = previous_name
&& let Some(idx) = state.tabs.iter().position(|t| *t == name)
{
state.current_tab = idx;
} else if state.current_tab >= state.tabs.len() {
state.current_tab = 0;
}
if let Some(last) = state.topology_last_host_tab.as_ref()
&& !state.tabs.iter().any(|t| t == last)
{
state.topology_last_host_tab = None;
}
}
}
#[async_trait]
impl DataCollectionStrategy for RemoteCollector {
async fn collect(&self, config: &CollectionConfig) -> CollectionResult {
if config.hosts.is_empty() {
return Err(CollectionError::Other("No hosts configured".to_string()));
}
let (
gpu_info,
cpu_info,
memory_info,
storage_info,
vgpu_info,
mig_info,
remote_process_info,
connection_statuses,
) = self
.network_client
.fetch_remote_data(&config.hosts, &self.semaphore, &self.regex)
.await;
let deduplicated_storage = Self::deduplicate_storage_info(storage_info);
Ok(CollectionData {
gpu_info,
cpu_info,
memory_info,
process_info: Vec::new(),
storage_info: deduplicated_storage,
chassis_info: Vec::new(), vgpu_info,
mig_info,
connection_statuses,
remote_process_info,
})
}
async fn update_state(
&self,
app_state: Arc<Mutex<AppState>>,
data: CollectionData,
config: &CollectionConfig,
) {
let mut state = app_state.lock().await;
if !data.gpu_info.is_empty() && data.gpu_info.iter().any(|gpu| gpu.total_memory > 0) {
state.gpu_info = data.gpu_info;
} else if state.gpu_info.is_empty() {
state.gpu_info = data.gpu_info;
}
state.cpu_info = data.cpu_info;
state.memory_info = data.memory_info;
state.storage_info = data.storage_info;
state.vgpu_info = data.vgpu_info;
state.mig_info = data.mig_info;
state.remote_process_info = data.remote_process_info;
Self::update_connection_status(&mut state, data.connection_statuses, &config.hosts);
self.aggregator.update_utilization_history(&mut state);
self.aggregator.update_energy_counters(&mut state);
Self::update_remote_tabs(&mut state);
state.process_info = Vec::new(); state.loading = false;
state.mark_collector_data_changed();
}
fn strategy_type(&self) -> &str {
"remote"
}
}
pub struct RemoteCollectorBuilder {
hosts: Vec<String>,
max_connections: Option<usize>,
}
impl RemoteCollectorBuilder {
pub fn new() -> Self {
Self {
hosts: Vec::new(),
max_connections: None,
}
}
pub fn with_hosts(mut self, hosts: Vec<String>) -> Self {
self.hosts = hosts;
self
}
#[allow(dead_code)]
pub fn with_max_connections(mut self, max_connections: usize) -> Self {
self.max_connections = Some(max_connections);
self
}
pub fn load_hosts_from_file(mut self, file_path: &str) -> Result<Self, std::io::Error> {
use std::path::Path;
let expanded = crate::common::paths::expand_tilde(Path::new(file_path));
let canonical_path = expanded.canonicalize().map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Invalid hostfile path: {e}"),
)
})?;
if !canonical_path.is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Hostfile path is not a regular file",
));
}
let metadata = std::fs::metadata(&canonical_path)?;
const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; if metadata.len() > MAX_FILE_SIZE {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"Hostfile too large: {} bytes (max: {MAX_FILE_SIZE} bytes)",
metadata.len()
),
));
}
let content = std::fs::read_to_string(&canonical_path)?;
const MAX_HOSTS: usize = 1000;
let mut host_count = 0;
let file_hosts: Vec<String> = content
.lines()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.filter(|s| !s.starts_with('#'))
.take(MAX_HOSTS)
.filter_map(|s| {
host_count += 1;
if host_count > MAX_HOSTS {
eprintln!("Warning: Hostfile contains more than {MAX_HOSTS} hosts, truncating");
return None;
}
let host = if let Some(stripped) = s.strip_prefix("http://") {
stripped.to_string()
} else if let Some(stripped) = s.strip_prefix("https://") {
stripped.to_string()
} else {
s.to_string()
};
if host
.chars()
.all(|c| c.is_ascii() && (c.is_alphanumeric() || ".-:_".contains(c)))
{
Some(host)
} else {
eprintln!("Warning: Invalid host format skipped: {s}");
None
}
})
.collect();
self.hosts.extend(file_hosts);
Ok(self)
}
pub fn build(self) -> RemoteCollector {
let max_connections = self
.max_connections
.unwrap_or_else(|| EnvConfig::max_concurrent_connections(self.hosts.len()));
RemoteCollector::new(max_connections)
}
}
impl Default for RemoteCollectorBuilder {
fn default() -> Self {
Self::new()
}
}