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(in crate::app) status: HashMap<String, PingStatus>,
12    pub(in crate::app) last_checked: HashMap<String, Instant>,
13    pub(in crate::app) has_pinged: bool,
14    pub(in crate::app) generation: u64,
15    pub(in crate::app) slow_threshold_ms: u16,
16    pub(in crate::app) auto_ping: bool,
17    pub(in crate::app) filter_down_only: bool,
18    pub(in crate::app) 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    pub fn status_map(&self) -> &HashMap<String, PingStatus> {
38        &self.status
39    }
40
41    pub fn status_map_mut(&mut self) -> &mut HashMap<String, PingStatus> {
42        &mut self.status
43    }
44
45    pub fn status_of(&self, alias: &str) -> Option<&PingStatus> {
46        self.status.get(alias)
47    }
48
49    pub fn status_contains(&self, alias: &str) -> bool {
50        self.status.contains_key(alias)
51    }
52
53    pub fn status_len(&self) -> usize {
54        self.status.len()
55    }
56
57    pub fn status_is_empty(&self) -> bool {
58        self.status.is_empty()
59    }
60
61    pub fn insert_status(&mut self, alias: String, status: PingStatus) {
62        self.status.insert(alias, status);
63    }
64
65    pub fn remove_status(&mut self, alias: &str) {
66        self.status.remove(alias);
67    }
68
69    pub fn last_checked(&self) -> &HashMap<String, Instant> {
70        &self.last_checked
71    }
72
73    pub fn last_checked_at(&self, alias: &str) -> Option<&Instant> {
74        self.last_checked.get(alias)
75    }
76
77    pub fn record_check(&mut self, alias: String, at: Instant) {
78        self.last_checked.insert(alias, at);
79    }
80
81    pub fn has_pinged(&self) -> bool {
82        self.has_pinged
83    }
84
85    pub fn set_has_pinged(&mut self, value: bool) {
86        self.has_pinged = value;
87    }
88
89    pub fn generation(&self) -> u64 {
90        self.generation
91    }
92
93    pub fn set_generation(&mut self, value: u64) {
94        self.generation = value;
95    }
96
97    pub fn slow_threshold_ms(&self) -> u16 {
98        self.slow_threshold_ms
99    }
100
101    pub fn set_slow_threshold_ms(&mut self, value: u16) {
102        self.slow_threshold_ms = value;
103    }
104
105    pub fn auto_ping(&self) -> bool {
106        self.auto_ping
107    }
108
109    pub fn set_auto_ping(&mut self, value: bool) {
110        self.auto_ping = value;
111    }
112
113    pub fn filter_down_only(&self) -> bool {
114        self.filter_down_only
115    }
116
117    pub fn set_filter_down_only(&mut self, value: bool) {
118        self.filter_down_only = value;
119    }
120
121    pub fn checked_at(&self) -> Option<Instant> {
122        self.checked_at
123    }
124
125    pub fn set_checked_at(&mut self, value: Option<Instant>) {
126        self.checked_at = value;
127    }
128
129    /// Construct with slow threshold + auto-ping loaded from preferences.
130    pub fn from_preferences(paths: Option<&crate::runtime::env::Paths>) -> Self {
131        Self {
132            slow_threshold_ms: crate::preferences::load_slow_threshold(paths),
133            auto_ping: crate::preferences::load_auto_ping(paths),
134            ..Self::default()
135        }
136    }
137
138    /// Clear all ping results and reset the dynamic filter/timestamp state.
139    /// Preserves config (slow_threshold_ms, auto_ping) and `has_pinged`.
140    /// Bumps `generation` so in-flight ping responses can be discarded.
141    pub fn clear_results(&mut self) {
142        self.status.clear();
143        self.last_checked.clear();
144        self.filter_down_only = false;
145        self.checked_at = None;
146        self.generation += 1;
147    }
148
149    /// True when no ping result exists for `alias`, or the result is older
150    /// than `STALE_REFRESH_AFTER`. Used to decide whether selecting a host
151    /// should trigger a background refresh.
152    pub fn is_stale(&self, alias: &str) -> bool {
153        match self.last_checked.get(alias) {
154            Some(t) => t.elapsed() >= STALE_REFRESH_AFTER,
155            None => !self.status.contains_key(alias),
156        }
157    }
158
159    /// Drop `status` and `last_checked` entries whose alias is no longer
160    /// in `valid_aliases`. Called from `App::reload_hosts` after the new
161    /// host list lands so stale ping entries cannot outlive their host.
162    pub fn prune_orphans(&mut self, valid_aliases: &std::collections::HashSet<&str>) {
163        let pre_status = self.status.len();
164        let pre_checked = self.last_checked.len();
165        self.status
166            .retain(|alias, _| valid_aliases.contains(alias.as_str()));
167        self.last_checked
168            .retain(|alias, _| valid_aliases.contains(alias.as_str()));
169        let dropped = pre_status.saturating_sub(self.status.len())
170            + pre_checked.saturating_sub(self.last_checked.len());
171        if dropped > 0 {
172            log::debug!(
173                "[purple] reload_hosts: pruned {} orphan ping entrie(s); {} aliases remain",
174                dropped,
175                valid_aliases.len()
176            );
177        }
178    }
179
180    /// Move `status` and `last_checked` entries from `old` to `new`.
181    /// Called from `App::migrate_alias_keyed_caches` before
182    /// `reload_hosts`, whose prune step would otherwise drop entries
183    /// still under the old alias. No-op when `old == new`.
184    pub fn migrate_alias(&mut self, old: &str, new: &str) {
185        if old == new {
186            return;
187        }
188        if let Some(v) = self.status.remove(old) {
189            self.status.insert(new.to_string(), v);
190        }
191        if let Some(v) = self.last_checked.remove(old) {
192            self.last_checked.insert(new.to_string(), v);
193        }
194    }
195}
196
197/// Ping status for a host.
198#[derive(Debug, Clone, PartialEq)]
199pub enum PingStatus {
200    Checking,
201    Reachable { rtt_ms: u32 },
202    Slow { rtt_ms: u32 },
203    Unreachable,
204    Skipped,
205}
206
207/// Classify a ping result into a PingStatus based on RTT and threshold.
208pub fn classify_ping(rtt_ms: Option<u32>, slow_threshold_ms: u16) -> PingStatus {
209    match rtt_ms {
210        Some(ms) if ms >= slow_threshold_ms as u32 => PingStatus::Slow { rtt_ms: ms },
211        Some(ms) => PingStatus::Reachable { rtt_ms: ms },
212        None => PingStatus::Unreachable,
213    }
214}
215
216/// Propagate a ping result to all hosts that use the given alias as ProxyJump bastion.
217pub fn propagate_ping_to_dependents(
218    hosts: &[HostEntry],
219    ping_status: &mut HashMap<String, PingStatus>,
220    bastion_alias: &str,
221    status: &PingStatus,
222) {
223    for h in hosts {
224        if h.proxy_jump == bastion_alias {
225            ping_status.insert(h.alias.clone(), status.clone());
226        }
227    }
228}
229
230/// Sort key for ping status: unreachable first, slow, reachable, unchecked last.
231pub fn ping_sort_key(status: Option<&PingStatus>) -> u8 {
232    match status {
233        Some(PingStatus::Unreachable) => 0,
234        Some(PingStatus::Slow { .. }) => 1,
235        Some(PingStatus::Reachable { .. }) => 2,
236        Some(PingStatus::Checking) => 3,
237        Some(PingStatus::Skipped) | None => 4,
238    }
239}
240
241/// Status glyph for dual encoding (color + shape).
242/// ● online, ▲ slow, ✖ down. Checking uses animated spinner via tick.
243pub fn status_glyph(status: Option<&PingStatus>, tick: u64) -> &'static str {
244    match status {
245        Some(PingStatus::Reachable { .. }) => "\u{25CF}", // ●
246        Some(PingStatus::Slow { .. }) => "\u{25B2}",      // ▲
247        Some(PingStatus::Unreachable) => "\u{2716}",      // ✖
248        Some(PingStatus::Checking) => {
249            crate::animation::SPINNER_FRAMES
250                [(tick as usize) % crate::animation::SPINNER_FRAMES.len()]
251        }
252        Some(PingStatus::Skipped) => "",
253        None => "\u{25CB}", // ○
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn is_stale_when_no_status_and_no_timestamp() {
263        let state = PingState::default();
264        assert!(state.is_stale("web1"));
265    }
266
267    #[test]
268    fn is_fresh_when_just_checked() {
269        let mut state = PingState::default();
270        state
271            .status
272            .insert("web1".into(), PingStatus::Reachable { rtt_ms: 5 });
273        state.last_checked.insert("web1".into(), Instant::now());
274        assert!(!state.is_stale("web1"));
275    }
276
277    #[test]
278    fn is_stale_after_refresh_window() {
279        let mut state = PingState::default();
280        state
281            .status
282            .insert("web1".into(), PingStatus::Reachable { rtt_ms: 5 });
283        let past = Instant::now() - STALE_REFRESH_AFTER - Duration::from_secs(1);
284        state.last_checked.insert("web1".into(), past);
285        assert!(state.is_stale("web1"));
286    }
287
288    #[test]
289    fn is_not_stale_when_status_present_without_timestamp() {
290        // Demo seeds `status` but the timestamp branch handles the freshness
291        // decision. Without a timestamp and with a status, we treat the host
292        // as fresh so demo mode does not get a refresh storm.
293        let mut state = PingState::default();
294        state
295            .status
296            .insert("web1".into(), PingStatus::Reachable { rtt_ms: 5 });
297        assert!(!state.is_stale("web1"));
298    }
299
300    #[test]
301    fn clear_results_empties_status_and_last_checked_and_resets_filter() {
302        let mut state = PingState::default();
303        state
304            .status
305            .insert("web1".into(), PingStatus::Reachable { rtt_ms: 5 });
306        state.last_checked.insert("web1".into(), Instant::now());
307        state.filter_down_only = true;
308        state.checked_at = Some(Instant::now());
309
310        state.clear_results();
311
312        assert!(state.status.is_empty());
313        assert!(state.last_checked.is_empty());
314        assert!(!state.filter_down_only);
315        assert!(state.checked_at.is_none());
316    }
317
318    #[test]
319    fn clear_results_increments_generation() {
320        let mut state = PingState {
321            generation: 7,
322            ..Default::default()
323        };
324        state.clear_results();
325        assert_eq!(state.generation, 8);
326    }
327
328    #[test]
329    fn clear_results_preserves_config_and_has_pinged() {
330        let mut state = PingState {
331            slow_threshold_ms: 750,
332            auto_ping: true,
333            has_pinged: true,
334            ..Default::default()
335        };
336
337        state.clear_results();
338
339        assert_eq!(state.slow_threshold_ms, 750);
340        assert!(state.auto_ping);
341        assert!(state.has_pinged);
342    }
343
344    #[test]
345    fn clear_results_is_idempotent_on_empty_state() {
346        let mut state = PingState::default();
347        state.clear_results();
348        state.clear_results();
349        assert!(state.status.is_empty());
350        assert!(state.last_checked.is_empty());
351        assert!(!state.filter_down_only);
352        assert!(state.checked_at.is_none());
353        assert_eq!(state.generation, 2);
354    }
355
356    #[test]
357    fn prune_orphans_drops_only_unknown_aliases() {
358        let mut s = PingState::default();
359        s.status
360            .insert("keep".to_string(), PingStatus::Reachable { rtt_ms: 12 });
361        s.status.insert("drop".to_string(), PingStatus::Unreachable);
362        s.last_checked.insert("keep".to_string(), Instant::now());
363        s.last_checked.insert("drop".to_string(), Instant::now());
364
365        let valid: std::collections::HashSet<&str> = ["keep"].into_iter().collect();
366        s.prune_orphans(&valid);
367
368        assert!(s.status.contains_key("keep"));
369        assert!(!s.status.contains_key("drop"));
370        assert!(s.last_checked.contains_key("keep"));
371        assert!(!s.last_checked.contains_key("drop"));
372    }
373
374    #[test]
375    fn migrate_alias_moves_status_and_last_checked() {
376        let mut s = PingState::default();
377        let now = Instant::now();
378        s.status
379            .insert("old".to_string(), PingStatus::Reachable { rtt_ms: 7 });
380        s.last_checked.insert("old".to_string(), now);
381
382        s.migrate_alias("old", "new");
383
384        assert!(!s.status.contains_key("old"));
385        assert!(!s.last_checked.contains_key("old"));
386        assert!(matches!(
387            s.status.get("new"),
388            Some(PingStatus::Reachable { rtt_ms: 7 })
389        ));
390        assert_eq!(s.last_checked.get("new"), Some(&now));
391    }
392}