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(in crate::app) sort_mode: ContainersSortMode,
205 pub(in crate::app) inspect_cache: InspectCache,
206 pub(in crate::app) logs_cache: LogsCache,
207 pub(in crate::app) refresh_batch: Option<RefreshBatch>,
209 pub(in crate::app) auto_list_in_flight: HashSet<String>,
214 pub(in crate::app) view_mode: ViewMode,
218 pub(in crate::app) collapsed_hosts: HashSet<String>,
222 pub(in crate::app) 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 fn sort_mode(&self) -> ContainersSortMode {
269 self.sort_mode
270 }
271
272 pub fn set_sort_mode_ephemeral(&mut self, mode: ContainersSortMode) {
275 self.sort_mode = mode;
276 }
277
278 pub fn view_mode(&self) -> ViewMode {
279 self.view_mode
280 }
281
282 pub fn set_view_mode_ephemeral(&mut self, mode: ViewMode) {
285 self.view_mode = mode;
286 }
287
288 pub fn collapsed_hosts(&self) -> &HashSet<String> {
289 &self.collapsed_hosts
290 }
291
292 pub fn toggle_host_collapsed(&mut self, alias: &str) -> bool {
294 if self.collapsed_hosts.remove(alias) {
295 false
296 } else {
297 self.collapsed_hosts.insert(alias.to_string());
298 true
299 }
300 }
301
302 pub fn refresh_batch(&self) -> Option<&RefreshBatch> {
303 self.refresh_batch.as_ref()
304 }
305
306 pub fn refresh_batch_mut(&mut self) -> Option<&mut RefreshBatch> {
307 self.refresh_batch.as_mut()
308 }
309
310 pub fn auto_list_in_flight(&self) -> &HashSet<String> {
311 &self.auto_list_in_flight
312 }
313
314 pub fn auto_list_pending(&self, alias: &str) -> bool {
317 self.auto_list_in_flight.contains(alias)
318 }
319
320 pub fn mark_auto_list_pending(&mut self, alias: String) {
322 self.auto_list_in_flight.insert(alias);
323 }
324
325 pub fn clear_auto_list_pending(&mut self, alias: &str) {
327 self.auto_list_in_flight.remove(alias);
328 }
329
330 pub fn inspect_cache(&self) -> &InspectCache {
331 &self.inspect_cache
332 }
333
334 pub fn inspect_cache_mut(&mut self) -> &mut InspectCache {
335 &mut self.inspect_cache
336 }
337
338 pub fn logs_cache(&self) -> &LogsCache {
339 &self.logs_cache
340 }
341
342 pub fn logs_cache_mut(&mut self) -> &mut LogsCache {
343 &mut self.logs_cache
344 }
345
346 pub fn view_cache(
347 &self,
348 ) -> &std::cell::RefCell<Option<(u64, Vec<crate::ui::containers_overview::ContainerListItem>)>>
349 {
350 &self.view_cache
351 }
352
353 pub(crate) fn hydrate_from_prefs(&mut self, paths: Option<&crate::runtime::env::Paths>) {
357 self.view_mode = crate::preferences::load_containers_view_mode(paths);
358 self.sort_mode = crate::preferences::load_containers_sort_mode(paths);
359 self.collapsed_hosts = crate::preferences::load_containers_collapsed_hosts(paths);
360 }
361
362 pub fn set_view_mode(
367 &mut self,
368 paths: Option<&crate::runtime::env::Paths>,
369 mode: ViewMode,
370 ) -> std::io::Result<()> {
371 self.view_mode = mode;
372 crate::preferences::save_containers_view_mode(paths, mode).inspect_err(|e| {
373 log::warn!("[config] Failed to persist containers view mode: {e}");
374 })
375 }
376
377 pub fn set_sort_mode(
380 &mut self,
381 paths: Option<&crate::runtime::env::Paths>,
382 mode: ContainersSortMode,
383 ) -> std::io::Result<()> {
384 self.sort_mode = mode;
385 crate::preferences::save_containers_sort_mode(paths, mode).inspect_err(|e| {
386 log::warn!("[config] Failed to persist containers sort mode: {e}");
387 })
388 }
389
390 pub fn migrate_alias(&mut self, old: &str, new: &str) -> bool {
396 if old == new {
397 return false;
398 }
399 if self.auto_list_in_flight.remove(old) {
400 debug_assert!(
401 !self.auto_list_in_flight.contains(new),
402 "auto_list_in_flight collision on rename {old} -> {new}"
403 );
404 self.auto_list_in_flight.insert(new.to_string());
405 }
406 if let Some(batch) = self.refresh_batch.as_mut() {
407 if batch.in_flight_aliases.remove(old) {
408 debug_assert!(
409 !batch.in_flight_aliases.contains(new),
410 "refresh_batch.in_flight_aliases collision on rename {old} -> {new}"
411 );
412 batch.in_flight_aliases.insert(new.to_string());
413 }
414 }
415 if self.collapsed_hosts.remove(old) {
416 debug_assert!(
417 !self.collapsed_hosts.contains(new),
418 "collapsed_hosts collision on rename {old} -> {new}"
419 );
420 self.collapsed_hosts.insert(new.to_string());
421 true
422 } else {
423 false
424 }
425 }
426
427 pub fn prune_by_container_ids(&mut self, valid_container_ids: &HashSet<String>) {
433 let pre_inspect = self.inspect_cache.entries.len();
434 self.inspect_cache
435 .entries
436 .retain(|id, _| valid_container_ids.contains(id));
437 self.inspect_cache
438 .in_flight
439 .retain(|id| valid_container_ids.contains(id));
440 self.logs_cache
441 .entries
442 .retain(|id, _| valid_container_ids.contains(id));
443 self.logs_cache
444 .in_flight
445 .retain(|id| valid_container_ids.contains(id));
446 let dropped = pre_inspect.saturating_sub(self.inspect_cache.entries.len());
447 if dropped > 0 {
448 log::debug!("[purple] reload_hosts: dropped {dropped} orphan inspect_cache entrie(s)");
449 }
450 }
451
452 pub fn prune_orphans(&mut self, valid_aliases: &HashSet<&str>) -> bool {
458 self.auto_list_in_flight
464 .retain(|alias| valid_aliases.contains(alias.as_str()));
465
466 if let Some(batch) = self.refresh_batch.as_mut() {
470 let pre = batch.in_flight_aliases.len();
471 batch
472 .in_flight_aliases
473 .retain(|alias| valid_aliases.contains(alias.as_str()));
474 let dropped = pre.saturating_sub(batch.in_flight_aliases.len());
475 if dropped > 0 {
476 log::debug!(
477 "[purple] reload_hosts: dropped {dropped} orphan refresh_batch in_flight alias(es)"
478 );
479 }
480 }
481
482 let pre_collapsed = self.collapsed_hosts.len();
486 self.collapsed_hosts
487 .retain(|alias| valid_aliases.contains(alias.as_str()));
488 let dropped_collapsed = pre_collapsed.saturating_sub(self.collapsed_hosts.len());
489 if dropped_collapsed > 0 {
490 log::debug!(
491 "[purple] reload_hosts: dropped {dropped_collapsed} orphan collapsed_hosts entrie(s)"
492 );
493 true
494 } else {
495 false
496 }
497 }
498}
499
500impl InspectCache {
501 pub fn fresh(&self, container_id: &str, now: u64) -> Option<&InspectCacheEntry> {
505 self.entries
506 .get(container_id)
507 .filter(|e| now.saturating_sub(e.timestamp) < INSPECT_CACHE_TTL_SECS)
508 }
509}
510
511impl LogsCache {
512 pub fn fresh(&self, container_id: &str, now: u64) -> Option<&LogsCacheEntry> {
515 self.entries
516 .get(container_id)
517 .filter(|e| now.saturating_sub(e.timestamp) < LOGS_CACHE_TTL_SECS)
518 }
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524 use crate::runtime::env::Paths;
525
526 fn batch_with_aliases(aliases: &[&str]) -> RefreshBatch {
527 RefreshBatch {
528 queue: VecDeque::new(),
529 in_flight: aliases.len(),
530 total: aliases.len(),
531 completed: 0,
532 in_flight_aliases: aliases.iter().map(|a| a.to_string()).collect(),
533 }
534 }
535
536 #[test]
537 fn start_refresh_installs_batch() {
538 let mut state = ContainersOverviewState::default();
539 assert!(state.refresh_batch.is_none());
540 state.start_refresh(batch_with_aliases(&["host-a", "host-b"]));
541 let batch = state.refresh_batch.as_ref().unwrap();
542 assert_eq!(batch.total, 2);
543 assert_eq!(batch.in_flight, 2);
544 assert!(batch.in_flight_aliases.contains("host-a"));
545 }
546
547 #[test]
548 fn clear_refresh_drops_batch() {
549 let mut state = ContainersOverviewState::default();
550 state.start_refresh(batch_with_aliases(&["host-a"]));
551 state.clear_refresh();
552 assert!(state.refresh_batch.is_none());
553 }
554
555 #[test]
556 fn hydrate_from_prefs_reads_persisted_values() {
557 let dir = tempfile::tempdir().unwrap();
558 let paths = Paths::new(dir.path());
559 crate::preferences::save_containers_view_mode(Some(&paths), ViewMode::Compact).unwrap();
560 crate::preferences::save_containers_sort_mode(
561 Some(&paths),
562 ContainersSortMode::AlphaContainer,
563 )
564 .unwrap();
565 let mut collapsed = std::collections::HashSet::new();
566 collapsed.insert("folded-host".to_string());
567 crate::preferences::save_containers_collapsed_hosts(Some(&paths), &collapsed).unwrap();
568
569 let mut state = ContainersOverviewState::default();
570 state.hydrate_from_prefs(Some(&paths));
571 assert_eq!(state.view_mode, ViewMode::Compact);
572 assert_eq!(state.sort_mode, ContainersSortMode::AlphaContainer);
573 assert!(state.collapsed_hosts.contains("folded-host"));
574 }
575
576 #[test]
577 fn set_view_mode_updates_field_and_persists() {
578 let dir = tempfile::tempdir().unwrap();
579 let paths = Paths::new(dir.path());
580 let mut state = ContainersOverviewState::default();
581 state
582 .set_view_mode(Some(&paths), ViewMode::Compact)
583 .unwrap();
584 assert_eq!(state.view_mode, ViewMode::Compact);
585 assert_eq!(
586 crate::preferences::load_containers_view_mode(Some(&paths)),
587 ViewMode::Compact
588 );
589 }
590
591 #[test]
592 fn set_sort_mode_updates_field_and_persists() {
593 let dir = tempfile::tempdir().unwrap();
594 let paths = Paths::new(dir.path());
595 let mut state = ContainersOverviewState::default();
596 state
597 .set_sort_mode(Some(&paths), ContainersSortMode::AlphaContainer)
598 .unwrap();
599 assert_eq!(state.sort_mode, ContainersSortMode::AlphaContainer);
600 assert_eq!(
601 crate::preferences::load_containers_sort_mode(Some(&paths)),
602 ContainersSortMode::AlphaContainer
603 );
604 }
605
606 #[test]
607 fn migrate_alias_renames_auto_list_in_flight() {
608 let mut state = ContainersOverviewState::default();
609 state.auto_list_in_flight.insert("old".to_string());
610 state.migrate_alias("old", "new");
611 assert!(state.auto_list_in_flight.contains("new"));
612 assert!(!state.auto_list_in_flight.contains("old"));
613 }
614
615 #[test]
616 fn migrate_alias_renames_refresh_batch_in_flight() {
617 let mut state = ContainersOverviewState::default();
618 state.start_refresh(batch_with_aliases(&["old"]));
619 assert!(!state.migrate_alias("old", "new"));
624 let batch = state.refresh_batch.as_ref().unwrap();
625 assert!(batch.in_flight_aliases.contains("new"));
626 assert!(!batch.in_flight_aliases.contains("old"));
627 }
628
629 #[test]
630 fn migrate_alias_self_rename_is_noop() {
631 let mut state = ContainersOverviewState::default();
632 state.collapsed_hosts.insert("same".to_string());
633 state.auto_list_in_flight.insert("same".to_string());
634 assert!(!state.migrate_alias("same", "same"));
635 assert!(state.collapsed_hosts.contains("same"));
636 assert!(state.auto_list_in_flight.contains("same"));
637 }
638
639 #[test]
640 fn migrate_alias_renames_collapsed_hosts_and_returns_true() {
641 let mut state = ContainersOverviewState::default();
642 state.collapsed_hosts.insert("old".to_string());
643 assert!(state.migrate_alias("old", "new"));
644 assert!(state.collapsed_hosts.contains("new"));
645 assert!(!state.collapsed_hosts.contains("old"));
646 }
647
648 #[test]
649 fn migrate_alias_returns_false_when_collapsed_unchanged() {
650 let mut state = ContainersOverviewState::default();
651 state.auto_list_in_flight.insert("old".to_string());
652 assert!(!state.migrate_alias("old", "new"));
653 assert!(state.auto_list_in_flight.contains("new"));
654 }
655
656 #[test]
657 fn migrate_alias_is_noop_when_nothing_matches() {
658 let mut state = ContainersOverviewState::default();
659 assert!(!state.migrate_alias("missing", "new"));
660 }
661
662 #[test]
663 fn prune_by_container_ids_drops_unknown_id_from_in_flight_sets() {
664 let mut state = ContainersOverviewState::default();
665 state.inspect_cache.in_flight.insert("id-keep".to_string());
666 state.inspect_cache.in_flight.insert("id-drop".to_string());
667 state.logs_cache.in_flight.insert("id-keep".to_string());
668 state.logs_cache.in_flight.insert("id-drop".to_string());
669
670 let valid: HashSet<String> = ["id-keep".to_string()].into_iter().collect();
671 state.prune_by_container_ids(&valid);
672
673 assert!(state.inspect_cache.in_flight.contains("id-keep"));
674 assert!(!state.inspect_cache.in_flight.contains("id-drop"));
675 assert!(state.logs_cache.in_flight.contains("id-keep"));
676 assert!(!state.logs_cache.in_flight.contains("id-drop"));
677 }
678
679 #[test]
680 fn prune_by_container_ids_drops_unknown_id_from_entries_maps() {
681 let mut state = ContainersOverviewState::default();
685 state.inspect_cache.entries.insert(
686 "id-keep".to_string(),
687 InspectCacheEntry {
688 timestamp: 0,
689 result: Err("placeholder".to_string()),
690 },
691 );
692 state.inspect_cache.entries.insert(
693 "id-drop".to_string(),
694 InspectCacheEntry {
695 timestamp: 0,
696 result: Err("placeholder".to_string()),
697 },
698 );
699 state.logs_cache.entries.insert(
700 "id-keep".to_string(),
701 LogsCacheEntry {
702 timestamp: 0,
703 result: Ok(vec!["line".to_string()]),
704 },
705 );
706 state.logs_cache.entries.insert(
707 "id-drop".to_string(),
708 LogsCacheEntry {
709 timestamp: 0,
710 result: Ok(vec!["line".to_string()]),
711 },
712 );
713
714 let valid: HashSet<String> = ["id-keep".to_string()].into_iter().collect();
715 state.prune_by_container_ids(&valid);
716
717 assert!(state.inspect_cache.entries.contains_key("id-keep"));
718 assert!(!state.inspect_cache.entries.contains_key("id-drop"));
719 assert!(state.logs_cache.entries.contains_key("id-keep"));
720 assert!(!state.logs_cache.entries.contains_key("id-drop"));
721 }
722
723 #[test]
724 fn prune_orphans_drops_unknown_and_signals_collapsed_change() {
725 let mut state = ContainersOverviewState::default();
726 state.auto_list_in_flight.insert("keep".to_string());
727 state.auto_list_in_flight.insert("drop".to_string());
728 state.collapsed_hosts.insert("keep".to_string());
729 state.collapsed_hosts.insert("drop".to_string());
730
731 let valid: HashSet<&str> = ["keep"].into_iter().collect();
732 let collapsed_changed = state.prune_orphans(&valid);
733
734 assert!(
735 collapsed_changed,
736 "returns true so caller persists collapsed_hosts"
737 );
738 assert!(state.auto_list_in_flight.contains("keep"));
739 assert!(!state.auto_list_in_flight.contains("drop"));
740 assert!(state.collapsed_hosts.contains("keep"));
741 assert!(!state.collapsed_hosts.contains("drop"));
742 }
743
744 #[test]
745 fn prune_orphans_returns_false_when_collapsed_unchanged() {
746 let mut state = ContainersOverviewState::default();
747 state.auto_list_in_flight.insert("only".to_string());
748 let valid: HashSet<&str> = ["only"].into_iter().collect();
749 assert!(!state.prune_orphans(&valid));
750 }
751}