1use std::collections::HashMap;
2use std::time::{Duration, Instant};
3
4use crate::ssh_config::model::HostEntry;
5
6pub const STALE_REFRESH_AFTER: Duration = Duration::from_secs(120);
8
9pub 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 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 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 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 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 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#[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
207pub 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
216pub 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
230pub 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
241pub fn status_glyph(status: Option<&PingStatus>, tick: u64) -> &'static str {
244 match status {
245 Some(PingStatus::Reachable { .. }) => "\u{25CF}", Some(PingStatus::Slow { .. }) => "\u{25B2}", Some(PingStatus::Unreachable) => "\u{2716}", 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}", }
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 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}