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() -> 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 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
160#[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
170pub 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
179pub 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
193pub 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
204pub fn status_glyph(status: Option<&PingStatus>, tick: u64) -> &'static str {
207 match status {
208 Some(PingStatus::Reachable { .. }) => "\u{25CF}", Some(PingStatus::Slow { .. }) => "\u{25B2}", Some(PingStatus::Unreachable) => "\u{2716}", 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}", }
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 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}