Skip to main content

purple_ssh/app/
ping.rs

1use std::collections::HashMap;
2use std::time::{Duration, Instant};
3
4use crate::ssh_config::model::HostEntry;
5
6/// Re-ping a focused host whose last result is older than this.
7pub const STALE_REFRESH_AFTER: Duration = Duration::from_secs(120);
8
9/// Ping/health-check state for all hosts.
10pub struct PingState {
11    pub status: HashMap<String, PingStatus>,
12    pub last_checked: HashMap<String, Instant>,
13    pub has_pinged: bool,
14    pub generation: u64,
15    pub slow_threshold_ms: u16,
16    pub auto_ping: bool,
17    pub filter_down_only: bool,
18    pub checked_at: Option<Instant>,
19}
20
21impl Default for PingState {
22    fn default() -> Self {
23        Self {
24            status: HashMap::new(),
25            last_checked: HashMap::new(),
26            has_pinged: false,
27            generation: 0,
28            slow_threshold_ms: 500,
29            auto_ping: false,
30            filter_down_only: false,
31            checked_at: None,
32        }
33    }
34}
35
36impl PingState {
37    /// Construct with slow threshold + auto-ping loaded from preferences.
38    pub fn from_preferences() -> Self {
39        Self {
40            slow_threshold_ms: crate::preferences::load_slow_threshold(),
41            auto_ping: crate::preferences::load_auto_ping(),
42            ..Self::default()
43        }
44    }
45
46    /// Clear all ping results and reset the dynamic filter/timestamp state.
47    /// Preserves config (slow_threshold_ms, auto_ping) and `has_pinged`.
48    /// Bumps `generation` so in-flight ping responses can be discarded.
49    pub fn clear_results(&mut self) {
50        self.status.clear();
51        self.last_checked.clear();
52        self.filter_down_only = false;
53        self.checked_at = None;
54        self.generation += 1;
55    }
56
57    /// True when no ping result exists for `alias`, or the result is older
58    /// than `STALE_REFRESH_AFTER`. Used to decide whether selecting a host
59    /// should trigger a background refresh.
60    pub fn is_stale(&self, alias: &str) -> bool {
61        match self.last_checked.get(alias) {
62            Some(t) => t.elapsed() >= STALE_REFRESH_AFTER,
63            None => !self.status.contains_key(alias),
64        }
65    }
66}
67
68/// Ping status for a host.
69#[derive(Debug, Clone, PartialEq)]
70pub enum PingStatus {
71    Checking,
72    Reachable { rtt_ms: u32 },
73    Slow { rtt_ms: u32 },
74    Unreachable,
75    Skipped,
76}
77
78/// Classify a ping result into a PingStatus based on RTT and threshold.
79pub fn classify_ping(rtt_ms: Option<u32>, slow_threshold_ms: u16) -> PingStatus {
80    match rtt_ms {
81        Some(ms) if ms >= slow_threshold_ms as u32 => PingStatus::Slow { rtt_ms: ms },
82        Some(ms) => PingStatus::Reachable { rtt_ms: ms },
83        None => PingStatus::Unreachable,
84    }
85}
86
87/// Propagate a ping result to all hosts that use the given alias as ProxyJump bastion.
88pub fn propagate_ping_to_dependents(
89    hosts: &[HostEntry],
90    ping_status: &mut HashMap<String, PingStatus>,
91    bastion_alias: &str,
92    status: &PingStatus,
93) {
94    for h in hosts {
95        if h.proxy_jump == bastion_alias {
96            ping_status.insert(h.alias.clone(), status.clone());
97        }
98    }
99}
100
101/// Sort key for ping status: unreachable first, slow, reachable, unchecked last.
102pub fn ping_sort_key(status: Option<&PingStatus>) -> u8 {
103    match status {
104        Some(PingStatus::Unreachable) => 0,
105        Some(PingStatus::Slow { .. }) => 1,
106        Some(PingStatus::Reachable { .. }) => 2,
107        Some(PingStatus::Checking) => 3,
108        Some(PingStatus::Skipped) | None => 4,
109    }
110}
111
112/// Status glyph for dual encoding (color + shape).
113/// ● online, ▲ slow, ✖ down. Checking uses animated spinner via tick.
114pub fn status_glyph(status: Option<&PingStatus>, tick: u64) -> &'static str {
115    match status {
116        Some(PingStatus::Reachable { .. }) => "\u{25CF}", // ●
117        Some(PingStatus::Slow { .. }) => "\u{25B2}",      // ▲
118        Some(PingStatus::Unreachable) => "\u{2716}",      // ✖
119        Some(PingStatus::Checking) => {
120            crate::animation::SPINNER_FRAMES
121                [(tick as usize) % crate::animation::SPINNER_FRAMES.len()]
122        }
123        Some(PingStatus::Skipped) => "",
124        None => "\u{25CB}", // ○
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn is_stale_when_no_status_and_no_timestamp() {
134        let state = PingState::default();
135        assert!(state.is_stale("web1"));
136    }
137
138    #[test]
139    fn is_fresh_when_just_checked() {
140        let mut state = PingState::default();
141        state
142            .status
143            .insert("web1".into(), PingStatus::Reachable { rtt_ms: 5 });
144        state.last_checked.insert("web1".into(), Instant::now());
145        assert!(!state.is_stale("web1"));
146    }
147
148    #[test]
149    fn is_stale_after_refresh_window() {
150        let mut state = PingState::default();
151        state
152            .status
153            .insert("web1".into(), PingStatus::Reachable { rtt_ms: 5 });
154        let past = Instant::now() - STALE_REFRESH_AFTER - Duration::from_secs(1);
155        state.last_checked.insert("web1".into(), past);
156        assert!(state.is_stale("web1"));
157    }
158
159    #[test]
160    fn is_not_stale_when_status_present_without_timestamp() {
161        // Demo seeds `status` but the timestamp branch handles the freshness
162        // decision. Without a timestamp and with a status, we treat the host
163        // as fresh so demo mode does not get a refresh storm.
164        let mut state = PingState::default();
165        state
166            .status
167            .insert("web1".into(), PingStatus::Reachable { rtt_ms: 5 });
168        assert!(!state.is_stale("web1"));
169    }
170
171    #[test]
172    fn clear_results_empties_status_and_last_checked_and_resets_filter() {
173        let mut state = PingState::default();
174        state
175            .status
176            .insert("web1".into(), PingStatus::Reachable { rtt_ms: 5 });
177        state.last_checked.insert("web1".into(), Instant::now());
178        state.filter_down_only = true;
179        state.checked_at = Some(Instant::now());
180
181        state.clear_results();
182
183        assert!(state.status.is_empty());
184        assert!(state.last_checked.is_empty());
185        assert!(!state.filter_down_only);
186        assert!(state.checked_at.is_none());
187    }
188
189    #[test]
190    fn clear_results_increments_generation() {
191        let mut state = PingState {
192            generation: 7,
193            ..Default::default()
194        };
195        state.clear_results();
196        assert_eq!(state.generation, 8);
197    }
198
199    #[test]
200    fn clear_results_preserves_config_and_has_pinged() {
201        let mut state = PingState {
202            slow_threshold_ms: 750,
203            auto_ping: true,
204            has_pinged: true,
205            ..Default::default()
206        };
207
208        state.clear_results();
209
210        assert_eq!(state.slow_threshold_ms, 750);
211        assert!(state.auto_ping);
212        assert!(state.has_pinged);
213    }
214
215    #[test]
216    fn clear_results_is_idempotent_on_empty_state() {
217        let mut state = PingState::default();
218        state.clear_results();
219        state.clear_results();
220        assert!(state.status.is_empty());
221        assert!(state.last_checked.is_empty());
222        assert!(!state.filter_down_only);
223        assert!(state.checked_at.is_none());
224        assert_eq!(state.generation, 2);
225    }
226}