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, Clone, Copy, PartialEq, Eq)]
206pub enum BulkConfirmKind {
207 StackRestart,
210 HostRestartAll,
213 HostStopAll,
216}
217
218#[derive(Debug, Clone)]
222pub struct BulkConfirmContext {
223 pub kind: BulkConfirmKind,
224 pub alias: String,
225 pub project: Option<String>,
228 pub members: Vec<crate::app::StackMember>,
229}
230
231#[derive(Debug)]
232pub struct ContainersOverviewState {
233 pub(in crate::app) sort_mode: ContainersSortMode,
234 pub(in crate::app) inspect_cache: InspectCache,
235 pub(in crate::app) logs_cache: LogsCache,
236 pub(in crate::app) refresh_batch: Option<RefreshBatch>,
238 pub(in crate::app) auto_list_in_flight: HashSet<String>,
243 pub(in crate::app) view_mode: ViewMode,
247 pub(in crate::app) collapsed_hosts: HashSet<String>,
251 pub(in crate::app) view_cache:
262 std::cell::RefCell<Option<(u64, Vec<crate::ui::containers_overview::ContainerListItem>)>>,
263 pub(in crate::app) pending_bulk_confirm: Option<BulkConfirmContext>,
267}
268
269impl Default for ContainersOverviewState {
270 fn default() -> Self {
271 Self {
272 sort_mode: ContainersSortMode::default(),
273 inspect_cache: InspectCache::default(),
274 logs_cache: LogsCache::default(),
275 refresh_batch: None,
276 auto_list_in_flight: HashSet::new(),
277 view_mode: ViewMode::Detailed,
278 collapsed_hosts: HashSet::new(),
279 view_cache: std::cell::RefCell::new(None),
280 pending_bulk_confirm: None,
281 }
282 }
283}
284
285impl ContainersOverviewState {
286 pub fn start_refresh(&mut self, batch: RefreshBatch) {
290 self.refresh_batch = Some(batch);
291 }
292
293 pub fn clear_refresh(&mut self) {
296 self.refresh_batch = None;
297 }
298
299 pub fn sort_mode(&self) -> ContainersSortMode {
303 self.sort_mode
304 }
305
306 pub fn set_sort_mode_ephemeral(&mut self, mode: ContainersSortMode) {
309 self.sort_mode = mode;
310 }
311
312 pub fn view_mode(&self) -> ViewMode {
313 self.view_mode
314 }
315
316 pub fn set_view_mode_ephemeral(&mut self, mode: ViewMode) {
319 self.view_mode = mode;
320 }
321
322 pub fn collapsed_hosts(&self) -> &HashSet<String> {
323 &self.collapsed_hosts
324 }
325
326 pub fn pending_bulk_confirm(&self) -> Option<&BulkConfirmContext> {
329 self.pending_bulk_confirm.as_ref()
330 }
331
332 pub fn set_pending_bulk_confirm(&mut self, ctx: BulkConfirmContext) {
336 self.pending_bulk_confirm = Some(ctx);
337 }
338
339 pub fn take_pending_bulk_confirm(&mut self) -> Option<BulkConfirmContext> {
342 self.pending_bulk_confirm.take()
343 }
344
345 pub fn toggle_host_collapsed(&mut self, alias: &str) -> bool {
347 if self.collapsed_hosts.remove(alias) {
348 false
349 } else {
350 self.collapsed_hosts.insert(alias.to_string());
351 true
352 }
353 }
354
355 pub fn refresh_batch(&self) -> Option<&RefreshBatch> {
356 self.refresh_batch.as_ref()
357 }
358
359 pub fn refresh_batch_mut(&mut self) -> Option<&mut RefreshBatch> {
360 self.refresh_batch.as_mut()
361 }
362
363 pub fn auto_list_in_flight(&self) -> &HashSet<String> {
364 &self.auto_list_in_flight
365 }
366
367 pub fn auto_list_pending(&self, alias: &str) -> bool {
370 self.auto_list_in_flight.contains(alias)
371 }
372
373 pub fn mark_auto_list_pending(&mut self, alias: String) {
375 self.auto_list_in_flight.insert(alias);
376 }
377
378 pub fn clear_auto_list_pending(&mut self, alias: &str) {
380 self.auto_list_in_flight.remove(alias);
381 }
382
383 pub fn inspect_cache(&self) -> &InspectCache {
384 &self.inspect_cache
385 }
386
387 pub fn inspect_cache_mut(&mut self) -> &mut InspectCache {
388 &mut self.inspect_cache
389 }
390
391 pub fn logs_cache(&self) -> &LogsCache {
392 &self.logs_cache
393 }
394
395 pub fn logs_cache_mut(&mut self) -> &mut LogsCache {
396 &mut self.logs_cache
397 }
398
399 pub fn view_cache(
400 &self,
401 ) -> &std::cell::RefCell<Option<(u64, Vec<crate::ui::containers_overview::ContainerListItem>)>>
402 {
403 &self.view_cache
404 }
405
406 pub(crate) fn hydrate_from_prefs(&mut self, paths: Option<&crate::runtime::env::Paths>) {
410 self.view_mode = crate::preferences::load_containers_view_mode(paths);
411 self.sort_mode = crate::preferences::load_containers_sort_mode(paths);
412 self.collapsed_hosts = crate::preferences::load_containers_collapsed_hosts(paths);
413 }
414
415 pub fn set_view_mode(
420 &mut self,
421 paths: Option<&crate::runtime::env::Paths>,
422 mode: ViewMode,
423 ) -> std::io::Result<()> {
424 self.view_mode = mode;
425 crate::preferences::save_containers_view_mode(paths, mode).inspect_err(|e| {
426 log::warn!("[config] Failed to persist containers view mode: {e}");
427 })
428 }
429
430 pub fn set_sort_mode(
433 &mut self,
434 paths: Option<&crate::runtime::env::Paths>,
435 mode: ContainersSortMode,
436 ) -> std::io::Result<()> {
437 self.sort_mode = mode;
438 crate::preferences::save_containers_sort_mode(paths, mode).inspect_err(|e| {
439 log::warn!("[config] Failed to persist containers sort mode: {e}");
440 })
441 }
442
443 pub fn migrate_alias(&mut self, old: &str, new: &str) -> bool {
449 if old == new {
450 return false;
451 }
452 if self.auto_list_in_flight.remove(old) {
453 debug_assert!(
454 !self.auto_list_in_flight.contains(new),
455 "auto_list_in_flight collision on rename {old} -> {new}"
456 );
457 self.auto_list_in_flight.insert(new.to_string());
458 }
459 if let Some(batch) = self.refresh_batch.as_mut() {
460 if batch.in_flight_aliases.remove(old) {
461 debug_assert!(
462 !batch.in_flight_aliases.contains(new),
463 "refresh_batch.in_flight_aliases collision on rename {old} -> {new}"
464 );
465 batch.in_flight_aliases.insert(new.to_string());
466 }
467 }
468 if let Some(ctx) = self.pending_bulk_confirm.as_mut() {
469 if ctx.alias == old {
470 ctx.alias = new.to_string();
471 }
472 }
473 if self.collapsed_hosts.remove(old) {
474 debug_assert!(
475 !self.collapsed_hosts.contains(new),
476 "collapsed_hosts collision on rename {old} -> {new}"
477 );
478 self.collapsed_hosts.insert(new.to_string());
479 true
480 } else {
481 false
482 }
483 }
484
485 pub fn prune_by_container_ids(&mut self, valid_container_ids: &HashSet<String>) {
491 let pre_inspect = self.inspect_cache.entries.len();
492 self.inspect_cache
493 .entries
494 .retain(|id, _| valid_container_ids.contains(id));
495 self.inspect_cache
496 .in_flight
497 .retain(|id| valid_container_ids.contains(id));
498 self.logs_cache
499 .entries
500 .retain(|id, _| valid_container_ids.contains(id));
501 self.logs_cache
502 .in_flight
503 .retain(|id| valid_container_ids.contains(id));
504 let dropped = pre_inspect.saturating_sub(self.inspect_cache.entries.len());
505 if dropped > 0 {
506 log::debug!("[purple] reload_hosts: dropped {dropped} orphan inspect_cache entrie(s)");
507 }
508 }
509
510 pub fn prune_orphans(&mut self, valid_aliases: &HashSet<&str>) -> bool {
516 self.auto_list_in_flight
522 .retain(|alias| valid_aliases.contains(alias.as_str()));
523
524 if let Some(batch) = self.refresh_batch.as_mut() {
528 let pre = batch.in_flight_aliases.len();
529 batch
530 .in_flight_aliases
531 .retain(|alias| valid_aliases.contains(alias.as_str()));
532 let dropped = pre.saturating_sub(batch.in_flight_aliases.len());
533 if dropped > 0 {
534 log::debug!(
535 "[purple] reload_hosts: dropped {dropped} orphan refresh_batch in_flight alias(es)"
536 );
537 }
538 }
539
540 if let Some(ctx) = self.pending_bulk_confirm.as_ref() {
544 if !valid_aliases.contains(ctx.alias.as_str()) {
545 self.pending_bulk_confirm = None;
546 }
547 }
548
549 let pre_collapsed = self.collapsed_hosts.len();
553 self.collapsed_hosts
554 .retain(|alias| valid_aliases.contains(alias.as_str()));
555 let dropped_collapsed = pre_collapsed.saturating_sub(self.collapsed_hosts.len());
556 if dropped_collapsed > 0 {
557 log::debug!(
558 "[purple] reload_hosts: dropped {dropped_collapsed} orphan collapsed_hosts entrie(s)"
559 );
560 true
561 } else {
562 false
563 }
564 }
565}
566
567impl InspectCache {
568 pub fn fresh(&self, container_id: &str, now: u64) -> Option<&InspectCacheEntry> {
572 self.entries
573 .get(container_id)
574 .filter(|e| now.saturating_sub(e.timestamp) < INSPECT_CACHE_TTL_SECS)
575 }
576}
577
578impl LogsCache {
579 pub fn fresh(&self, container_id: &str, now: u64) -> Option<&LogsCacheEntry> {
582 self.entries
583 .get(container_id)
584 .filter(|e| now.saturating_sub(e.timestamp) < LOGS_CACHE_TTL_SECS)
585 }
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591 use crate::runtime::env::Paths;
592
593 fn batch_with_aliases(aliases: &[&str]) -> RefreshBatch {
594 RefreshBatch {
595 queue: VecDeque::new(),
596 in_flight: aliases.len(),
597 total: aliases.len(),
598 completed: 0,
599 in_flight_aliases: aliases.iter().map(|a| a.to_string()).collect(),
600 }
601 }
602
603 #[test]
604 fn start_refresh_installs_batch() {
605 let mut state = ContainersOverviewState::default();
606 assert!(state.refresh_batch.is_none());
607 state.start_refresh(batch_with_aliases(&["host-a", "host-b"]));
608 let batch = state.refresh_batch.as_ref().unwrap();
609 assert_eq!(batch.total, 2);
610 assert_eq!(batch.in_flight, 2);
611 assert!(batch.in_flight_aliases.contains("host-a"));
612 }
613
614 #[test]
615 fn clear_refresh_drops_batch() {
616 let mut state = ContainersOverviewState::default();
617 state.start_refresh(batch_with_aliases(&["host-a"]));
618 state.clear_refresh();
619 assert!(state.refresh_batch.is_none());
620 }
621
622 #[test]
623 fn hydrate_from_prefs_reads_persisted_values() {
624 let dir = tempfile::tempdir().unwrap();
625 let paths = Paths::new(dir.path());
626 crate::preferences::save_containers_view_mode(Some(&paths), ViewMode::Compact).unwrap();
627 crate::preferences::save_containers_sort_mode(
628 Some(&paths),
629 ContainersSortMode::AlphaContainer,
630 )
631 .unwrap();
632 let mut collapsed = std::collections::HashSet::new();
633 collapsed.insert("folded-host".to_string());
634 crate::preferences::save_containers_collapsed_hosts(Some(&paths), &collapsed).unwrap();
635
636 let mut state = ContainersOverviewState::default();
637 state.hydrate_from_prefs(Some(&paths));
638 assert_eq!(state.view_mode, ViewMode::Compact);
639 assert_eq!(state.sort_mode, ContainersSortMode::AlphaContainer);
640 assert!(state.collapsed_hosts.contains("folded-host"));
641 }
642
643 #[test]
644 fn set_view_mode_updates_field_and_persists() {
645 let dir = tempfile::tempdir().unwrap();
646 let paths = Paths::new(dir.path());
647 let mut state = ContainersOverviewState::default();
648 state
649 .set_view_mode(Some(&paths), ViewMode::Compact)
650 .unwrap();
651 assert_eq!(state.view_mode, ViewMode::Compact);
652 assert_eq!(
653 crate::preferences::load_containers_view_mode(Some(&paths)),
654 ViewMode::Compact
655 );
656 }
657
658 #[test]
659 fn set_sort_mode_updates_field_and_persists() {
660 let dir = tempfile::tempdir().unwrap();
661 let paths = Paths::new(dir.path());
662 let mut state = ContainersOverviewState::default();
663 state
664 .set_sort_mode(Some(&paths), ContainersSortMode::AlphaContainer)
665 .unwrap();
666 assert_eq!(state.sort_mode, ContainersSortMode::AlphaContainer);
667 assert_eq!(
668 crate::preferences::load_containers_sort_mode(Some(&paths)),
669 ContainersSortMode::AlphaContainer
670 );
671 }
672
673 #[test]
674 fn migrate_alias_renames_auto_list_in_flight() {
675 let mut state = ContainersOverviewState::default();
676 state.auto_list_in_flight.insert("old".to_string());
677 state.migrate_alias("old", "new");
678 assert!(state.auto_list_in_flight.contains("new"));
679 assert!(!state.auto_list_in_flight.contains("old"));
680 }
681
682 #[test]
683 fn migrate_alias_renames_refresh_batch_in_flight() {
684 let mut state = ContainersOverviewState::default();
685 state.start_refresh(batch_with_aliases(&["old"]));
686 assert!(!state.migrate_alias("old", "new"));
691 let batch = state.refresh_batch.as_ref().unwrap();
692 assert!(batch.in_flight_aliases.contains("new"));
693 assert!(!batch.in_flight_aliases.contains("old"));
694 }
695
696 #[test]
697 fn migrate_alias_self_rename_is_noop() {
698 let mut state = ContainersOverviewState::default();
699 state.collapsed_hosts.insert("same".to_string());
700 state.auto_list_in_flight.insert("same".to_string());
701 assert!(!state.migrate_alias("same", "same"));
702 assert!(state.collapsed_hosts.contains("same"));
703 assert!(state.auto_list_in_flight.contains("same"));
704 }
705
706 #[test]
707 fn migrate_alias_renames_collapsed_hosts_and_returns_true() {
708 let mut state = ContainersOverviewState::default();
709 state.collapsed_hosts.insert("old".to_string());
710 assert!(state.migrate_alias("old", "new"));
711 assert!(state.collapsed_hosts.contains("new"));
712 assert!(!state.collapsed_hosts.contains("old"));
713 }
714
715 #[test]
716 fn migrate_alias_returns_false_when_collapsed_unchanged() {
717 let mut state = ContainersOverviewState::default();
718 state.auto_list_in_flight.insert("old".to_string());
719 assert!(!state.migrate_alias("old", "new"));
720 assert!(state.auto_list_in_flight.contains("new"));
721 }
722
723 #[test]
724 fn migrate_alias_is_noop_when_nothing_matches() {
725 let mut state = ContainersOverviewState::default();
726 assert!(!state.migrate_alias("missing", "new"));
727 }
728
729 #[test]
730 fn prune_by_container_ids_drops_unknown_id_from_in_flight_sets() {
731 let mut state = ContainersOverviewState::default();
732 state.inspect_cache.in_flight.insert("id-keep".to_string());
733 state.inspect_cache.in_flight.insert("id-drop".to_string());
734 state.logs_cache.in_flight.insert("id-keep".to_string());
735 state.logs_cache.in_flight.insert("id-drop".to_string());
736
737 let valid: HashSet<String> = ["id-keep".to_string()].into_iter().collect();
738 state.prune_by_container_ids(&valid);
739
740 assert!(state.inspect_cache.in_flight.contains("id-keep"));
741 assert!(!state.inspect_cache.in_flight.contains("id-drop"));
742 assert!(state.logs_cache.in_flight.contains("id-keep"));
743 assert!(!state.logs_cache.in_flight.contains("id-drop"));
744 }
745
746 #[test]
747 fn prune_by_container_ids_drops_unknown_id_from_entries_maps() {
748 let mut state = ContainersOverviewState::default();
752 state.inspect_cache.entries.insert(
753 "id-keep".to_string(),
754 InspectCacheEntry {
755 timestamp: 0,
756 result: Err("placeholder".to_string()),
757 },
758 );
759 state.inspect_cache.entries.insert(
760 "id-drop".to_string(),
761 InspectCacheEntry {
762 timestamp: 0,
763 result: Err("placeholder".to_string()),
764 },
765 );
766 state.logs_cache.entries.insert(
767 "id-keep".to_string(),
768 LogsCacheEntry {
769 timestamp: 0,
770 result: Ok(vec!["line".to_string()]),
771 },
772 );
773 state.logs_cache.entries.insert(
774 "id-drop".to_string(),
775 LogsCacheEntry {
776 timestamp: 0,
777 result: Ok(vec!["line".to_string()]),
778 },
779 );
780
781 let valid: HashSet<String> = ["id-keep".to_string()].into_iter().collect();
782 state.prune_by_container_ids(&valid);
783
784 assert!(state.inspect_cache.entries.contains_key("id-keep"));
785 assert!(!state.inspect_cache.entries.contains_key("id-drop"));
786 assert!(state.logs_cache.entries.contains_key("id-keep"));
787 assert!(!state.logs_cache.entries.contains_key("id-drop"));
788 }
789
790 #[test]
791 fn prune_orphans_drops_unknown_and_signals_collapsed_change() {
792 let mut state = ContainersOverviewState::default();
793 state.auto_list_in_flight.insert("keep".to_string());
794 state.auto_list_in_flight.insert("drop".to_string());
795 state.collapsed_hosts.insert("keep".to_string());
796 state.collapsed_hosts.insert("drop".to_string());
797
798 let valid: HashSet<&str> = ["keep"].into_iter().collect();
799 let collapsed_changed = state.prune_orphans(&valid);
800
801 assert!(
802 collapsed_changed,
803 "returns true so caller persists collapsed_hosts"
804 );
805 assert!(state.auto_list_in_flight.contains("keep"));
806 assert!(!state.auto_list_in_flight.contains("drop"));
807 assert!(state.collapsed_hosts.contains("keep"));
808 assert!(!state.collapsed_hosts.contains("drop"));
809 }
810
811 #[test]
812 fn prune_orphans_returns_false_when_collapsed_unchanged() {
813 let mut state = ContainersOverviewState::default();
814 state.auto_list_in_flight.insert("only".to_string());
815 let valid: HashSet<&str> = ["only"].into_iter().collect();
816 assert!(!state.prune_orphans(&valid));
817 }
818}