1use std::collections::{HashMap, HashSet, VecDeque};
4
5use super::host_state::ViewMode;
6use crate::containers::{ContainerInspect, ContainerRuntime};
7
8#[derive(Debug, Clone)]
11pub struct RefreshQueueItem {
12 pub alias: String,
13 pub askpass: Option<String>,
14 pub cached_runtime: Option<ContainerRuntime>,
15 pub has_tunnel: bool,
16}
17
18#[derive(Debug, Default)]
24pub struct RefreshBatch {
25 pub queue: VecDeque<RefreshQueueItem>,
26 pub in_flight: usize,
27 pub total: usize,
30 pub completed: usize,
32 pub in_flight_aliases: HashSet<String>,
39}
40
41pub const REFRESH_MAX_PARALLEL: usize = 4;
45
46#[derive(Debug, Clone)]
56pub struct ContainerExecRequest {
57 pub alias: String,
58 pub askpass: Option<String>,
59 pub runtime: ContainerRuntime,
60 pub container_id: String,
61 pub container_name: String,
66 pub command: Option<String>,
70}
71
72#[derive(Debug, Clone)]
77pub struct ContainerLogsRequest {
78 pub alias: String,
79 pub askpass: Option<String>,
80 pub runtime: ContainerRuntime,
81 pub container_id: String,
82 pub container_name: String,
83}
84
85#[derive(Debug, Clone)]
91pub struct ContainerActionRequest {
92 pub alias: String,
93 pub askpass: Option<String>,
94 pub runtime: ContainerRuntime,
95 pub container_id: String,
96 pub container_name: String,
97 pub action: crate::containers::ContainerAction,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
102pub enum ContainersSortMode {
103 #[default]
105 AlphaHost,
106 AlphaContainer,
108}
109
110impl ContainersSortMode {
111 pub fn next(self) -> Self {
112 match self {
113 ContainersSortMode::AlphaHost => ContainersSortMode::AlphaContainer,
114 ContainersSortMode::AlphaContainer => ContainersSortMode::AlphaHost,
115 }
116 }
117
118 pub fn label(self) -> &'static str {
119 match self {
120 ContainersSortMode::AlphaHost => "A-Z host",
121 ContainersSortMode::AlphaContainer => "A-Z container",
122 }
123 }
124
125 pub fn to_key(self) -> &'static str {
126 match self {
127 ContainersSortMode::AlphaHost => "alpha_host",
128 ContainersSortMode::AlphaContainer => "alpha_container",
129 }
130 }
131
132 pub fn from_key(s: &str) -> Self {
133 match s {
134 "alpha_container" => ContainersSortMode::AlphaContainer,
135 _ => ContainersSortMode::AlphaHost,
136 }
137 }
138}
139
140#[derive(Debug, Clone)]
144pub struct InspectCacheEntry {
145 pub timestamp: u64,
146 pub result: Result<ContainerInspect, String>,
147}
148
149#[derive(Debug, Default)]
154pub struct InspectCache {
155 pub entries: HashMap<String, InspectCacheEntry>,
156 pub in_flight: HashSet<String>,
159}
160
161#[derive(Debug, Clone)]
165pub struct LogsCacheEntry {
166 pub timestamp: u64,
167 pub result: Result<Vec<String>, String>,
168}
169
170#[derive(Debug, Default)]
173pub struct LogsCache {
174 pub entries: HashMap<String, LogsCacheEntry>,
175 pub in_flight: HashSet<String>,
176}
177
178pub const INSPECT_CACHE_TTL_SECS: u64 = 30;
182
183pub const LOGS_CACHE_TTL_SECS: u64 = 30;
187
188pub const LOGS_TAIL: usize = 50;
193
194pub const LIST_CACHE_TTL_SECS: u64 = 30;
201
202#[derive(Debug)]
203pub struct ContainersOverviewState {
204 pub sort_mode: ContainersSortMode,
205 pub inspect_cache: InspectCache,
206 pub logs_cache: LogsCache,
207 pub refresh_batch: Option<RefreshBatch>,
209 pub auto_list_in_flight: HashSet<String>,
214 pub view_mode: ViewMode,
218 pub collapsed_hosts: HashSet<String>,
222 pub view_cache:
233 std::cell::RefCell<Option<(u64, Vec<crate::ui::containers_overview::ContainerListItem>)>>,
234}
235
236impl Default for ContainersOverviewState {
237 fn default() -> Self {
238 Self {
239 sort_mode: ContainersSortMode::default(),
240 inspect_cache: InspectCache::default(),
241 logs_cache: LogsCache::default(),
242 refresh_batch: None,
243 auto_list_in_flight: HashSet::new(),
244 view_mode: ViewMode::Detailed,
245 collapsed_hosts: HashSet::new(),
246 view_cache: std::cell::RefCell::new(None),
247 }
248 }
249}
250
251impl ContainersOverviewState {
252 pub fn start_refresh(&mut self, batch: RefreshBatch) {
256 self.refresh_batch = Some(batch);
257 }
258
259 pub fn clear_refresh(&mut self) {
262 self.refresh_batch = None;
263 }
264
265 pub(crate) fn hydrate_from_prefs(&mut self) {
269 self.view_mode = crate::preferences::load_containers_view_mode();
270 self.sort_mode = crate::preferences::load_containers_sort_mode();
271 self.collapsed_hosts = crate::preferences::load_containers_collapsed_hosts();
272 }
273
274 pub fn set_view_mode(&mut self, mode: ViewMode) -> std::io::Result<()> {
279 self.view_mode = mode;
280 crate::preferences::save_containers_view_mode(mode).inspect_err(|e| {
281 log::warn!("[config] Failed to persist containers view mode: {e}");
282 })
283 }
284
285 pub fn set_sort_mode(&mut self, mode: ContainersSortMode) -> std::io::Result<()> {
288 self.sort_mode = mode;
289 crate::preferences::save_containers_sort_mode(mode).inspect_err(|e| {
290 log::warn!("[config] Failed to persist containers sort mode: {e}");
291 })
292 }
293
294 pub fn migrate_alias(&mut self, old: &str, new: &str) -> bool {
300 if old == new {
301 return false;
302 }
303 if self.auto_list_in_flight.remove(old) {
304 debug_assert!(
305 !self.auto_list_in_flight.contains(new),
306 "auto_list_in_flight collision on rename {old} -> {new}"
307 );
308 self.auto_list_in_flight.insert(new.to_string());
309 }
310 if let Some(batch) = self.refresh_batch.as_mut() {
311 if batch.in_flight_aliases.remove(old) {
312 debug_assert!(
313 !batch.in_flight_aliases.contains(new),
314 "refresh_batch.in_flight_aliases collision on rename {old} -> {new}"
315 );
316 batch.in_flight_aliases.insert(new.to_string());
317 }
318 }
319 if self.collapsed_hosts.remove(old) {
320 debug_assert!(
321 !self.collapsed_hosts.contains(new),
322 "collapsed_hosts collision on rename {old} -> {new}"
323 );
324 self.collapsed_hosts.insert(new.to_string());
325 true
326 } else {
327 false
328 }
329 }
330}
331
332impl InspectCache {
333 pub fn fresh(&self, container_id: &str, now: u64) -> Option<&InspectCacheEntry> {
337 self.entries
338 .get(container_id)
339 .filter(|e| now.saturating_sub(e.timestamp) < INSPECT_CACHE_TTL_SECS)
340 }
341}
342
343impl LogsCache {
344 pub fn fresh(&self, container_id: &str, now: u64) -> Option<&LogsCacheEntry> {
347 self.entries
348 .get(container_id)
349 .filter(|e| now.saturating_sub(e.timestamp) < LOGS_CACHE_TTL_SECS)
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356 use crate::preferences::tests_helpers::with_temp_prefs;
357
358 fn batch_with_aliases(aliases: &[&str]) -> RefreshBatch {
359 RefreshBatch {
360 queue: VecDeque::new(),
361 in_flight: aliases.len(),
362 total: aliases.len(),
363 completed: 0,
364 in_flight_aliases: aliases.iter().map(|a| a.to_string()).collect(),
365 }
366 }
367
368 #[test]
369 fn start_refresh_installs_batch() {
370 let mut state = ContainersOverviewState::default();
371 assert!(state.refresh_batch.is_none());
372 state.start_refresh(batch_with_aliases(&["host-a", "host-b"]));
373 let batch = state.refresh_batch.as_ref().unwrap();
374 assert_eq!(batch.total, 2);
375 assert_eq!(batch.in_flight, 2);
376 assert!(batch.in_flight_aliases.contains("host-a"));
377 }
378
379 #[test]
380 fn clear_refresh_drops_batch() {
381 let mut state = ContainersOverviewState::default();
382 state.start_refresh(batch_with_aliases(&["host-a"]));
383 state.clear_refresh();
384 assert!(state.refresh_batch.is_none());
385 }
386
387 #[test]
388 fn hydrate_from_prefs_reads_persisted_values() {
389 with_temp_prefs("hydrate_from_prefs", |_path| {
390 crate::preferences::save_containers_view_mode(ViewMode::Compact).unwrap();
391 crate::preferences::save_containers_sort_mode(ContainersSortMode::AlphaContainer)
392 .unwrap();
393 let mut collapsed = std::collections::HashSet::new();
394 collapsed.insert("folded-host".to_string());
395 crate::preferences::save_containers_collapsed_hosts(&collapsed).unwrap();
396
397 let mut state = ContainersOverviewState::default();
398 state.hydrate_from_prefs();
399 assert_eq!(state.view_mode, ViewMode::Compact);
400 assert_eq!(state.sort_mode, ContainersSortMode::AlphaContainer);
401 assert!(state.collapsed_hosts.contains("folded-host"));
402 });
403 }
404
405 #[test]
406 fn set_view_mode_updates_field_and_persists() {
407 with_temp_prefs("set_view_mode", |_path| {
408 let mut state = ContainersOverviewState::default();
409 state.set_view_mode(ViewMode::Compact).unwrap();
410 assert_eq!(state.view_mode, ViewMode::Compact);
411 assert_eq!(
412 crate::preferences::load_containers_view_mode(),
413 ViewMode::Compact
414 );
415 });
416 }
417
418 #[test]
419 fn set_sort_mode_updates_field_and_persists() {
420 with_temp_prefs("set_sort_mode", |_path| {
421 let mut state = ContainersOverviewState::default();
422 state
423 .set_sort_mode(ContainersSortMode::AlphaContainer)
424 .unwrap();
425 assert_eq!(state.sort_mode, ContainersSortMode::AlphaContainer);
426 assert_eq!(
427 crate::preferences::load_containers_sort_mode(),
428 ContainersSortMode::AlphaContainer
429 );
430 });
431 }
432
433 #[test]
434 fn migrate_alias_renames_auto_list_in_flight() {
435 let mut state = ContainersOverviewState::default();
436 state.auto_list_in_flight.insert("old".to_string());
437 state.migrate_alias("old", "new");
438 assert!(state.auto_list_in_flight.contains("new"));
439 assert!(!state.auto_list_in_flight.contains("old"));
440 }
441
442 #[test]
443 fn migrate_alias_renames_refresh_batch_in_flight() {
444 let mut state = ContainersOverviewState::default();
445 state.start_refresh(batch_with_aliases(&["old"]));
446 assert!(!state.migrate_alias("old", "new"));
451 let batch = state.refresh_batch.as_ref().unwrap();
452 assert!(batch.in_flight_aliases.contains("new"));
453 assert!(!batch.in_flight_aliases.contains("old"));
454 }
455
456 #[test]
457 fn migrate_alias_self_rename_is_noop() {
458 let mut state = ContainersOverviewState::default();
459 state.collapsed_hosts.insert("same".to_string());
460 state.auto_list_in_flight.insert("same".to_string());
461 assert!(!state.migrate_alias("same", "same"));
462 assert!(state.collapsed_hosts.contains("same"));
463 assert!(state.auto_list_in_flight.contains("same"));
464 }
465
466 #[test]
467 fn migrate_alias_renames_collapsed_hosts_and_returns_true() {
468 let mut state = ContainersOverviewState::default();
469 state.collapsed_hosts.insert("old".to_string());
470 assert!(state.migrate_alias("old", "new"));
471 assert!(state.collapsed_hosts.contains("new"));
472 assert!(!state.collapsed_hosts.contains("old"));
473 }
474
475 #[test]
476 fn migrate_alias_returns_false_when_collapsed_unchanged() {
477 let mut state = ContainersOverviewState::default();
478 state.auto_list_in_flight.insert("old".to_string());
479 assert!(!state.migrate_alias("old", "new"));
480 assert!(state.auto_list_in_flight.contains("new"));
481 }
482
483 #[test]
484 fn migrate_alias_is_noop_when_nothing_matches() {
485 let mut state = ContainersOverviewState::default();
486 assert!(!state.migrate_alias("missing", "new"));
487 }
488}