1use std::collections::HashMap;
14use std::time::{Duration, Instant};
15
16use tokio::sync::mpsc;
17use tokio::task::JoinHandle;
18
19use crate::event::{CoreEvent, Metrics, ProcessInfo};
20use crate::ssh::client::{ConnectionStatus, Host};
21use crate::ssh::metrics::{
22 parse_cpu_proc_stat, parse_cpu_top, parse_cpu_top_macos, parse_disk_df, parse_loadavg,
23 parse_ram_free, parse_ram_vmstat, parse_top_processes, parse_uptime,
24};
25use crate::ssh::session::SshSession;
26
27const BACKOFF_SECS: [u64; 4] = [30, 60, 120, 300];
32
33struct BackoffState {
34 step: usize,
35}
36
37impl BackoffState {
38 fn new() -> Self {
39 Self { step: 0 }
40 }
41
42 fn next_delay(&mut self) -> Duration {
43 let secs = BACKOFF_SECS[self.step];
44 self.step = (self.step + 1).min(BACKOFF_SECS.len() - 1);
45 Duration::from_secs(secs)
46 }
47
48 fn reset(&mut self) {
49 self.step = 0;
50 }
51}
52
53pub struct PollManager {
61 task_handles: Vec<JoinHandle<()>>,
62 refresh_txs: HashMap<String, mpsc::Sender<()>>,
64}
65
66impl PollManager {
67 pub fn start(hosts: Vec<Host>, tx: mpsc::Sender<CoreEvent>, poll_interval: Duration) -> Self {
69 let mut task_handles = Vec::with_capacity(hosts.len());
70 let mut refresh_txs = HashMap::with_capacity(hosts.len());
71
72 for host in hosts {
73 let (refresh_tx, refresh_rx) = mpsc::channel::<()>(4);
74 refresh_txs.insert(host.name.clone(), refresh_tx);
75
76 let event_tx = tx.clone();
77 let interval = poll_interval;
78 let handle = tokio::spawn(run_host_poller(host, event_tx, interval, refresh_rx));
79 task_handles.push(handle);
80 }
81
82 Self {
83 task_handles,
84 refresh_txs,
85 }
86 }
87
88 pub fn refresh_all(&self) {
90 for (name, tx) in &self.refresh_txs {
91 if tx.try_send(()).is_err() {
92 tracing::debug!(host = %name, "refresh signal dropped — channel full or closed");
93 }
94 }
95 }
96
97 pub fn shutdown(self) {
101 for handle in &self.task_handles {
102 handle.abort();
103 }
104 }
105}
106
107async fn run_host_poller(
112 host: Host,
113 tx: mpsc::Sender<CoreEvent>,
114 poll_interval: Duration,
115 mut refresh_rx: mpsc::Receiver<()>,
116) {
117 let mut backoff = BackoffState::new();
118 let mut session: Option<SshSession> = None;
119 let mut discovery_done = false; loop {
122 if session.is_none() {
124 send_status(&tx, &host.name, ConnectionStatus::Connecting).await;
125 match SshSession::connect(&host).await {
126 Ok(s) => {
127 backoff.reset();
128 send_status(&tx, &host.name, ConnectionStatus::Connected).await;
129 session = Some(s);
130 discovery_done = false; }
132 Err(e) => {
133 tracing::debug!(host = %host.name, error = %e, "connection failed");
134 send_status(&tx, &host.name, ConnectionStatus::Failed(e.to_string())).await;
135 let delay = backoff.next_delay();
137 wait_or_refresh(delay, &mut refresh_rx).await;
138 continue;
139 }
140 }
141 }
142
143 if !discovery_done {
146 if let Some(sess) = &session {
147 let sess_clone = sess.clone();
150 let host_name = host.name.clone();
151 let tx_clone = tx.clone();
152 tokio::spawn(async move {
153 match crate::ssh::discovery::quick_scan(
154 &sess_clone,
155 host_name.clone(),
156 tx_clone.clone(),
157 )
158 .await
159 {
160 Ok(()) => {
161 tracing::debug!(host = %host_name, "quick scan completed successfully");
162 }
163 Err(e) => {
164 tracing::warn!(host = %host_name, error = %e, "quick scan failed");
165 let _ = tx_clone
166 .send(CoreEvent::DiscoveryFailed(host_name, e.to_string()))
167 .await;
168 }
169 }
170 });
171 discovery_done = true;
172 }
173 }
174
175 let sess = session.as_ref().expect("session is Some here");
181 match collect_metrics(sess, &host.name).await {
182 Ok(metrics) => {
183 if tx
184 .send(CoreEvent::MetricsUpdate(host.name.clone(), metrics))
185 .await
186 .is_err()
187 {
188 break; }
190 }
191 Err(e) => {
192 tracing::debug!(host = %host.name, error = %e, "metric collection failed");
193 session.take();
195 send_status(&tx, &host.name, ConnectionStatus::Failed(e.to_string())).await;
196 let delay = backoff.next_delay();
197 wait_or_refresh(delay, &mut refresh_rx).await;
198 continue;
199 }
200 }
201
202 wait_or_refresh(poll_interval, &mut refresh_rx).await;
204 }
205}
206
207async fn wait_or_refresh(delay: Duration, refresh_rx: &mut mpsc::Receiver<()>) {
209 tokio::select! {
210 _ = tokio::time::sleep(delay) => {}
211 _ = refresh_rx.recv() => {}
212 }
213}
214
215async fn send_status(tx: &mpsc::Sender<CoreEvent>, name: &str, status: ConnectionStatus) {
216 let _ = tx
217 .send(CoreEvent::HostStatusChanged(name.to_string(), status))
218 .await;
219}
220
221async fn collect_metrics(session: &SshSession, host_name: &str) -> anyhow::Result<Metrics> {
234 let (cpu_out, mem_out, disk_out, uptime_out, loadavg_out) = tokio::join!(
236 session.run_command("top -bn1 2>/dev/null | head -5"),
237 session.run_command("free -b 2>/dev/null || vm_stat 2>/dev/null"),
238 session.run_command("df -k / 2>/dev/null"),
239 session.run_command("uptime 2>/dev/null"),
240 session.run_command("cat /proc/loadavg 2>/dev/null"),
241 );
242
243 if cpu_out.is_err()
246 && mem_out.is_err()
247 && disk_out.is_err()
248 && uptime_out.is_err()
249 && loadavg_out.is_err()
250 {
251 let err = cpu_out
252 .err()
253 .unwrap_or_else(|| anyhow::anyhow!("all metric commands failed"));
254 return Err(anyhow::anyhow!(
255 "all metric commands failed (session may be dead): {}",
256 err
257 ));
258 }
259
260 let cpu_str = cpu_out
263 .inspect_err(|e| tracing::debug!(host = %host_name, error = %e, "cpu command failed"))
264 .unwrap_or_default();
265 let mem_str = mem_out
266 .inspect_err(|e| tracing::debug!(host = %host_name, error = %e, "mem command failed"))
267 .unwrap_or_default();
268 let disk_str = disk_out
269 .inspect_err(|e| tracing::debug!(host = %host_name, error = %e, "disk command failed"))
270 .unwrap_or_default();
271 let uptime_str = uptime_out
272 .inspect_err(|e| tracing::debug!(host = %host_name, error = %e, "uptime command failed"))
273 .unwrap_or_default();
274 let loadavg_str = loadavg_out
275 .inspect_err(|e| tracing::debug!(host = %host_name, error = %e, "loadavg command failed"))
276 .unwrap_or_default();
277
278 let cpu_percent = parse_cpu_combined(&cpu_str, session).await;
279
280 let ram_percent = parse_ram_combined(&mem_str, session).await;
281
282 let disk_percent = parse_disk_df(&disk_str).or_else(|| {
283 if !disk_str.is_empty() {
284 tracing::debug!(host = %host_name, "disk output present but parse failed");
285 }
286 None
287 });
288
289 let uptime = parse_uptime(&uptime_str);
290
291 let load_avg = parse_loadavg(&loadavg_str);
292
293 let top_processes = collect_top_processes(session).await;
294
295 Ok(Metrics {
296 cpu_percent,
297 ram_percent,
298 disk_percent,
299 uptime,
300 load_avg,
301 os_info: None, top_processes,
303 last_updated: Instant::now(),
304 })
305}
306
307async fn collect_top_processes(session: &SshSession) -> Option<Vec<ProcessInfo>> {
312 let linux_out = session
314 .run_command(&top_processes_command(
315 "-eo pid=,ppid=,pcpu=,pmem=,comm= --sort=-pcpu",
316 ))
317 .await
318 .unwrap_or_default();
319 if let Some(procs) = parse_top_processes(&linux_out) {
320 return Some(procs);
321 }
322 let macos_out = session
324 .run_command(&top_processes_command(
325 "-Aceo pid=,ppid=,pcpu=,pmem=,comm= -r",
326 ))
327 .await
328 .unwrap_or_default();
329 parse_top_processes(&macos_out)
330}
331
332fn top_processes_command(ps_args: &str) -> String {
349 format!(
350 "g=$(ps -o ppid= -p $PPID 2>/dev/null | tr -d ' '); \
351 ps {ps_args} 2>/dev/null | \
352 awk -v s=$$ -v p=$PPID -v g=\"$g\" \
353 '$1!=s && $1!=p && $1!=g && $2!=s && $2!=p \
354 {{$1=\"\";$2=\"\";sub(/^[ \\t]+/,\"\");print}}' | \
355 head -n 3"
356 )
357}
358
359async fn parse_cpu_combined(top_out: &str, session: &SshSession) -> Option<f64> {
360 if let Some(v) = parse_cpu_top(top_out) {
362 return Some(v);
363 }
364 let macos_out = session
366 .run_command("top -l 1 -n 0 2>/dev/null | grep 'CPU usage'")
367 .await
368 .unwrap_or_default();
369 if let Some(v) = parse_cpu_top_macos(&macos_out) {
370 return Some(v);
371 }
372 let stat_out = session
374 .run_command("head -1 /proc/stat 2>/dev/null")
375 .await
376 .unwrap_or_default();
377 parse_cpu_proc_stat(&stat_out)
378}
379
380async fn parse_ram_combined(mem_out: &str, session: &SshSession) -> Option<f64> {
381 if let Some(v) = parse_ram_free(mem_out) {
383 return Some(v);
384 }
385 if mem_out.contains("Mach Virtual Memory") {
387 let memsize_out = session
388 .run_command("sysctl hw.memsize 2>/dev/null")
389 .await
390 .unwrap_or_default();
391 return parse_ram_vmstat(mem_out, &memsize_out);
392 }
393 None
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
401 fn top_processes_command_excludes_monitor_pid_chain() {
402 let cmd = top_processes_command("-eo pid=,ppid=,pcpu=,pmem=,comm= --sort=-pcpu");
403
404 assert!(cmd.contains("g=$(ps -o ppid= -p $PPID"));
406 assert!(cmd.contains("-v s=$$"));
408 assert!(cmd.contains("-v p=$PPID"));
409 assert!(cmd.contains("-v g=\"$g\""));
410 assert!(cmd.contains("$1!=s && $1!=p && $1!=g && $2!=s && $2!=p"));
412 assert!(!cmd.contains("sshd"));
414 assert!(cmd.trim_end().ends_with("head -n 3"));
416 }
417
418 #[test]
419 fn top_processes_command_splices_ps_args_verbatim() {
420 let linux = top_processes_command("-eo pid=,ppid=,pcpu=,pmem=,comm= --sort=-pcpu");
421 assert!(linux.contains("ps -eo pid=,ppid=,pcpu=,pmem=,comm= --sort=-pcpu 2>/dev/null"));
422
423 let macos = top_processes_command("-Aceo pid=,ppid=,pcpu=,pmem=,comm= -r");
424 assert!(macos.contains("ps -Aceo pid=,ppid=,pcpu=,pmem=,comm= -r 2>/dev/null"));
425 }
426}