//! Read-only health monitoring of the (remote) OpenFUT server. //! //! The launcher no longer *controls* the servers — they run elsewhere (e.g. in //! Docker on the server host). This module polls the configured server in a //! background thread and exposes a snapshot the UI can render. It never starts, //! stops, or assumes anything about how the server is hosted; it only asks //! "can the FIFA client reach it right now?". use parking_lot::Mutex; use std::{ net::{TcpStream, ToSocketAddrs}, sync::{ atomic::{AtomicBool, Ordering}, Arc, }, thread, time::{Duration, Instant}, }; const POLL_INTERVAL: Duration = Duration::from_secs(3); const CONNECT_TIMEOUT: Duration = Duration::from_secs(3); /// A snapshot of the last health probe, rendered by the dashboard. #[derive(Clone)] pub struct HealthState { /// None = not yet checked / no target; Some(true/false) = reachable or not. pub reachable: Option, pub detail: String, pub last_checked: Option, } impl Default for HealthState { fn default() -> Self { Self { reachable: None, detail: "No server configured.".into(), last_checked: None, } } } /// Background poller. Holds a shared target (host, port) the UI can update when /// the user changes the server address, and a shared state the UI reads. pub struct HealthMonitor { pub state: Arc>, target: Arc>>, running: Arc, } impl HealthMonitor { pub fn new() -> Self { let state = Arc::new(Mutex::new(HealthState::default())); let target: Arc>> = Arc::new(Mutex::new(None)); let running = Arc::new(AtomicBool::new(true)); let t_state = Arc::clone(&state); let t_target = Arc::clone(&target); let t_running = Arc::clone(&running); thread::spawn(move || { while t_running.load(Ordering::Relaxed) { let target = t_target.lock().clone(); match target { None => { *t_state.lock() = HealthState::default(); } Some((host, port)) => { let snapshot = probe(&host, port); *t_state.lock() = snapshot; } } thread::sleep(POLL_INTERVAL); } }); Self { state, target, running, } } /// Point the monitor at a new server address (host + bridge port). Passing /// None (e.g. no server configured) puts it back into the idle state. pub fn set_target(&self, target: Option<(String, u16)>) { *self.target.lock() = target; } pub fn snapshot(&self) -> HealthState { self.state.lock().clone() } } impl Drop for HealthMonitor { fn drop(&mut self) { self.running.store(false, Ordering::Relaxed); } } /// A single reachability probe: DNS-resolve host:port and attempt a bounded TCP /// connect. A successful connect proves the FIFA client can reach the bridge. fn probe(host: &str, port: u16) -> HealthState { let now = Some(Instant::now()); let addrs = match (host, port).to_socket_addrs() { Ok(a) => a.collect::>(), Err(e) => { return HealthState { reachable: Some(false), detail: format!("Cannot resolve {host}: {e}"), last_checked: now, }; } }; if addrs.is_empty() { return HealthState { reachable: Some(false), detail: format!("{host} resolved to no addresses"), last_checked: now, }; } for addr in &addrs { if TcpStream::connect_timeout(addr, CONNECT_TIMEOUT).is_ok() { return HealthState { reachable: Some(true), detail: format!("Reachable at {addr}"), last_checked: now, }; } } HealthState { reachable: Some(false), detail: format!("{host}:{port} not reachable"), last_checked: now, } }