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() -> Self {
131        Self {
132            slow_threshold_ms: crate::preferences::load_slow_threshold(),
133            auto_ping: crate::preferences::load_auto_ping(),
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
160/// Ping status for a host.
161#[derive(Debug, Clone, PartialEq)]
162pub enum PingStatus {
163    Checking,
164    Reachable { rtt_ms: u32 },
165    Slow { rtt_ms: u32 },
166    Unreachable,
167    Skipped,
168}
169
170/// Classify a ping result into a PingStatus based on RTT and threshold.
171pub fn classify_ping(rtt_ms: Option<u32>, slow_threshold_ms: u16) -> PingStatus {
172    match rtt_ms {
173        Some(ms) if ms >= slow_threshold_ms as u32 => PingStatus::Slow { rtt_ms: ms },
174        Some(ms) => PingStatus::Reachable { rtt_ms: ms },
175        None => PingStatus::Unreachable,
176    }
177}
178
179/// Propagate a ping result to all hosts that use the given alias as ProxyJump bastion.
180pub fn propagate_ping_to_dependents(
181    hosts: &[HostEntry],
182    ping_status: &mut HashMap<String, PingStatus>,
183    bastion_alias: &str,
184    status: &PingStatus,
185) {
186    for h in hosts {
187        if h.proxy_jump == bastion_alias {
188            ping_status.insert(h.alias.clone(), status.clone());
189        }
190    }
191}
192
193/// Sort key for ping status: unreachable first, slow, reachable, unchecked last.
194pub fn ping_sort_key(status: Option<&PingStatus>) -> u8 {
195    match status {
196        Some(PingStatus::Unreachable) => 0,
197        Some(PingStatus::Slow { .. }) => 1,
198        Some(PingStatus::Reachable { .. }) => 2,
199        Some(PingStatus::Checking) => 3,
200        Some(PingStatus::Skipped) | None => 4,
201    }
202}
203
204/// Status glyph for dual encoding (color + shape).
205/// ● online, ▲ slow, ✖ down. Checking uses animated spinner via tick.
206pub fn status_glyph(status: Option<&PingStatus>, tick: u64) -> &'static str {
207    match status {
208        Some(PingStatus::Reachable { .. }) => "\u{25CF}", // ●
209        Some(PingStatus::Slow { .. }) => "\u{25B2}",      // ▲
210        Some(PingStatus::Unreachable) => "\u{2716}",      // ✖
211        Some(PingStatus::Checking) => {
212            crate::animation::SPINNER_FRAMES
213                [(tick as usize) % crate::animation::SPINNER_FRAMES.len()]
214        }
215        Some(PingStatus::Skipped) => "",
216        None => "\u{25CB}", // ○
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn is_stale_when_no_status_and_no_timestamp() {
226        let state = PingState::default();
227        assert!(state.is_stale("web1"));
228    }
229
230    #[test]
231    fn is_fresh_when_just_checked() {
232        let mut state = PingState::default();
233        state
234            .status
235            .insert("web1".into(), PingStatus::Reachable { rtt_ms: 5 });
236        state.last_checked.insert("web1".into(), Instant::now());
237        assert!(!state.is_stale("web1"));
238    }
239
240    #[test]
241    fn is_stale_after_refresh_window() {
242        let mut state = PingState::default();
243        state
244            .status
245            .insert("web1".into(), PingStatus::Reachable { rtt_ms: 5 });
246        let past = Instant::now() - STALE_REFRESH_AFTER - Duration::from_secs(1);
247        state.last_checked.insert("web1".into(), past);
248        assert!(state.is_stale("web1"));
249    }
250
251    #[test]
252    fn is_not_stale_when_status_present_without_timestamp() {
253        // Demo seeds `status` but the timestamp branch handles the freshness
254        // decision. Without a timestamp and with a status, we treat the host
255        // as fresh so demo mode does not get a refresh storm.
256        let mut state = PingState::default();
257        state
258            .status
259            .insert("web1".into(), PingStatus::Reachable { rtt_ms: 5 });
260        assert!(!state.is_stale("web1"));
261    }
262
263    #[test]
264    fn clear_results_empties_status_and_last_checked_and_resets_filter() {
265        let mut state = PingState::default();
266        state
267            .status
268            .insert("web1".into(), PingStatus::Reachable { rtt_ms: 5 });
269        state.last_checked.insert("web1".into(), Instant::now());
270        state.filter_down_only = true;
271        state.checked_at = Some(Instant::now());
272
273        state.clear_results();
274
275        assert!(state.status.is_empty());
276        assert!(state.last_checked.is_empty());
277        assert!(!state.filter_down_only);
278        assert!(state.checked_at.is_none());
279    }
280
281    #[test]
282    fn clear_results_increments_generation() {
283        let mut state = PingState {
284            generation: 7,
285            ..Default::default()
286        };
287        state.clear_results();
288        assert_eq!(state.generation, 8);
289    }
290
291    #[test]
292    fn clear_results_preserves_config_and_has_pinged() {
293        let mut state = PingState {
294            slow_threshold_ms: 750,
295            auto_ping: true,
296            has_pinged: true,
297            ..Default::default()
298        };
299
300        state.clear_results();
301
302        assert_eq!(state.slow_threshold_ms, 750);
303        assert!(state.auto_ping);
304        assert!(state.has_pinged);
305    }
306
307    #[test]
308    fn clear_results_is_idempotent_on_empty_state() {
309        let mut state = PingState::default();
310        state.clear_results();
311        state.clear_results();
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        assert_eq!(state.generation, 2);
317    }
318}