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 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 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 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 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#[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
78pub 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
87pub 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
101pub 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
112pub fn status_glyph(status: Option<&PingStatus>, tick: u64) -> &'static str {
115 match status {
116 Some(PingStatus::Reachable { .. }) => "\u{25CF}", Some(PingStatus::Slow { .. }) => "\u{25B2}", Some(PingStatus::Unreachable) => "\u{2716}", 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}", }
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 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}