1use std::collections::{HashMap, HashSet, VecDeque};
4
5use super::host_state::ViewMode;
6use crate::containers::{ContainerInspect, ContainerRuntime};
7
8#[derive(Clone)]
11pub struct RefreshQueueItem {
12 pub alias: String,
13 pub askpass: Option<String>,
14 pub session_password: Option<String>,
17 pub cached_runtime: Option<ContainerRuntime>,
18 pub has_tunnel: bool,
19}
20
21impl std::fmt::Debug for RefreshQueueItem {
24 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25 f.debug_struct("RefreshQueueItem")
26 .field("alias", &self.alias)
27 .field("askpass", &self.askpass)
28 .field("session_password", &self.session_password.is_some())
29 .field("cached_runtime", &self.cached_runtime)
30 .field("has_tunnel", &self.has_tunnel)
31 .finish()
32 }
33}
34
35#[derive(Debug, Default)]
41pub struct RefreshBatch {
42 pub queue: VecDeque<RefreshQueueItem>,
43 pub in_flight: usize,
44 pub total: usize,
47 pub completed: usize,
49 pub in_flight_aliases: HashSet<String>,
56}
57
58pub const REFRESH_MAX_PARALLEL: usize = 4;
62
63#[derive(Debug, Clone)]
73pub struct ContainerExecRequest {
74 pub alias: String,
75 pub askpass: Option<String>,
76 pub runtime: ContainerRuntime,
77 pub container_id: String,
78 pub container_name: String,
83 pub command: Option<String>,
87}
88
89#[derive(Debug, Clone)]
94pub struct ContainerLogsRequest {
95 pub alias: String,
96 pub askpass: Option<String>,
97 pub runtime: ContainerRuntime,
98 pub container_id: String,
99 pub container_name: String,
100}
101
102#[derive(Debug, Clone)]
108pub struct ContainerActionRequest {
109 pub alias: String,
110 pub askpass: Option<String>,
111 pub runtime: ContainerRuntime,
112 pub container_id: String,
113 pub container_name: String,
114 pub action: crate::containers::ContainerAction,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
119pub enum ContainersSortMode {
120 #[default]
122 AlphaHost,
123 AlphaContainer,
125}
126
127impl ContainersSortMode {
128 pub fn next(self) -> Self {
129 match self {
130 ContainersSortMode::AlphaHost => ContainersSortMode::AlphaContainer,
131 ContainersSortMode::AlphaContainer => ContainersSortMode::AlphaHost,
132 }
133 }
134
135 pub fn label(self) -> &'static str {
136 match self {
137 ContainersSortMode::AlphaHost => "A-Z host",
138 ContainersSortMode::AlphaContainer => "A-Z container",
139 }
140 }
141
142 pub fn to_key(self) -> &'static str {
143 match self {
144 ContainersSortMode::AlphaHost => "alpha_host",
145 ContainersSortMode::AlphaContainer => "alpha_container",
146 }
147 }
148
149 pub fn from_key(s: &str) -> Self {
150 match s {
151 "alpha_container" => ContainersSortMode::AlphaContainer,
152 _ => ContainersSortMode::AlphaHost,
153 }
154 }
155}
156
157#[derive(Debug, Clone)]
161pub struct InspectCacheEntry {
162 pub timestamp: u64,
163 pub result: Result<ContainerInspect, String>,
164}
165
166#[derive(Debug, Default)]
171pub struct InspectCache {
172 pub entries: HashMap<String, InspectCacheEntry>,
173 pub in_flight: HashSet<String>,
176}
177
178#[derive(Debug, Clone)]
182pub struct LogsCacheEntry {
183 pub timestamp: u64,
184 pub result: Result<Vec<String>, String>,
185}
186
187#[derive(Debug, Default)]
190pub struct LogsCache {
191 pub entries: HashMap<String, LogsCacheEntry>,
192 pub in_flight: HashSet<String>,
193}
194
195pub const INSPECT_CACHE_TTL_SECS: u64 = 30;
199
200pub const LOGS_CACHE_TTL_SECS: u64 = 30;
204
205pub const LOGS_TAIL: usize = 50;
210
211pub const LIST_CACHE_TTL_SECS: u64 = 30;
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum BulkConfirmKind {
224 StackRestart,
227 HostRestartAll,
230 HostStopAll,
233}
234
235#[derive(Debug, Clone)]
239pub struct BulkConfirmContext {
240 pub kind: BulkConfirmKind,
241 pub alias: String,
242 pub project: Option<String>,
245 pub members: Vec<crate::app::StackMember>,
246}
247
248#[derive(Debug)]
249pub struct ContainersOverviewState {
250 pub(in crate::app) sort_mode: ContainersSortMode,
251 pub(in crate::app) inspect_cache: InspectCache,
252 pub(in crate::app) logs_cache: LogsCache,
253 pub(in crate::app) refresh_batch: Option<RefreshBatch>,
255 pub(in crate::app) auto_list_in_flight: HashSet<String>,
260 pub(in crate::app) view_mode: ViewMode,
264 pub(in crate::app) collapsed_hosts: HashSet<String>,
268 pub(in crate::app) view_cache:
279 std::cell::RefCell<Option<(u64, Vec<crate::ui::containers_overview::ContainerListItem>)>>,
280 pub(in crate::app) pending_bulk_confirm: Option<BulkConfirmContext>,
284}
285
286impl Default for ContainersOverviewState {
287 fn default() -> Self {
288 Self {
289 sort_mode: ContainersSortMode::default(),
290 inspect_cache: InspectCache::default(),
291 logs_cache: LogsCache::default(),
292 refresh_batch: None,
293 auto_list_in_flight: HashSet::new(),
294 view_mode: ViewMode::Detailed,
295 collapsed_hosts: HashSet::new(),
296 view_cache: std::cell::RefCell::new(None),
297 pending_bulk_confirm: None,
298 }
299 }
300}
301
302impl ContainersOverviewState {
303 pub fn start_refresh(&mut self, batch: RefreshBatch) {
307 self.refresh_batch = Some(batch);
308 }
309
310 pub fn clear_refresh(&mut self) {
313 self.refresh_batch = None;
314 }
315
316 pub fn sort_mode(&self) -> ContainersSortMode {
320 self.sort_mode
321 }
322
323 pub fn set_sort_mode_ephemeral(&mut self, mode: ContainersSortMode) {
326 self.sort_mode = mode;
327 }
328
329 pub fn view_mode(&self) -> ViewMode {
330 self.view_mode
331 }
332
333 pub fn set_view_mode_ephemeral(&mut self, mode: ViewMode) {
336 self.view_mode = mode;
337 }
338
339 pub fn collapsed_hosts(&self) -> &HashSet<String> {
340 &self.collapsed_hosts
341 }
342
343 pub fn pending_bulk_confirm(&self) -> Option<&BulkConfirmContext> {
346 self.pending_bulk_confirm.as_ref()
347 }
348
349 pub fn set_pending_bulk_confirm(&mut self, ctx: BulkConfirmContext) {
353 self.pending_bulk_confirm = Some(ctx);
354 }
355
356 pub fn take_pending_bulk_confirm(&mut self) -> Option<BulkConfirmContext> {
359 self.pending_bulk_confirm.take()
360 }
361
362 pub fn toggle_host_collapsed(&mut self, alias: &str) -> bool {
364 if self.collapsed_hosts.remove(alias) {
365 false
366 } else {
367 self.collapsed_hosts.insert(alias.to_string());
368 true
369 }
370 }
371
372 pub fn refresh_batch(&self) -> Option<&RefreshBatch> {
373 self.refresh_batch.as_ref()
374 }
375
376 pub fn refresh_batch_mut(&mut self) -> Option<&mut RefreshBatch> {
377 self.refresh_batch.as_mut()
378 }
379
380 pub fn auto_list_in_flight(&self) -> &HashSet<String> {
381 &self.auto_list_in_flight
382 }
383
384 pub fn auto_list_pending(&self, alias: &str) -> bool {
387 self.auto_list_in_flight.contains(alias)
388 }
389
390 pub fn mark_auto_list_pending(&mut self, alias: String) {
392 self.auto_list_in_flight.insert(alias);
393 }
394
395 pub fn clear_auto_list_pending(&mut self, alias: &str) {
397 self.auto_list_in_flight.remove(alias);
398 }
399
400 pub fn inspect_cache(&self) -> &InspectCache {
401 &self.inspect_cache
402 }
403
404 pub fn inspect_cache_mut(&mut self) -> &mut InspectCache {
405 &mut self.inspect_cache
406 }
407
408 pub fn logs_cache(&self) -> &LogsCache {
409 &self.logs_cache
410 }
411
412 pub fn logs_cache_mut(&mut self) -> &mut LogsCache {
413 &mut self.logs_cache
414 }
415
416 pub fn view_cache(
417 &self,
418 ) -> &std::cell::RefCell<Option<(u64, Vec<crate::ui::containers_overview::ContainerListItem>)>>
419 {
420 &self.view_cache
421 }
422
423 pub(crate) fn hydrate_from_prefs(&mut self, paths: Option<&crate::runtime::env::Paths>) {
427 self.view_mode = crate::preferences::load_containers_view_mode(paths);
428 self.sort_mode = crate::preferences::load_containers_sort_mode(paths);
429 self.collapsed_hosts = crate::preferences::load_containers_collapsed_hosts(paths);
430 }
431
432 pub fn set_view_mode(
437 &mut self,
438 paths: Option<&crate::runtime::env::Paths>,
439 mode: ViewMode,
440 ) -> std::io::Result<()> {
441 self.view_mode = mode;
442 crate::preferences::save_containers_view_mode(paths, mode).inspect_err(|e| {
443 log::warn!("[config] Failed to persist containers view mode: {e}");
444 })
445 }
446
447 pub fn set_sort_mode(
450 &mut self,
451 paths: Option<&crate::runtime::env::Paths>,
452 mode: ContainersSortMode,
453 ) -> std::io::Result<()> {
454 self.sort_mode = mode;
455 crate::preferences::save_containers_sort_mode(paths, mode).inspect_err(|e| {
456 log::warn!("[config] Failed to persist containers sort mode: {e}");
457 })
458 }
459
460 pub fn migrate_alias(&mut self, old: &str, new: &str) -> bool {
466 if old == new {
467 return false;
468 }
469 if self.auto_list_in_flight.remove(old) {
470 debug_assert!(
471 !self.auto_list_in_flight.contains(new),
472 "auto_list_in_flight collision on rename {old} -> {new}"
473 );
474 self.auto_list_in_flight.insert(new.to_string());
475 }
476 if let Some(batch) = self.refresh_batch.as_mut()
477 && batch.in_flight_aliases.remove(old)
478 {
479 debug_assert!(
480 !batch.in_flight_aliases.contains(new),
481 "refresh_batch.in_flight_aliases collision on rename {old} -> {new}"
482 );
483 batch.in_flight_aliases.insert(new.to_string());
484 }
485 if let Some(ctx) = self.pending_bulk_confirm.as_mut()
486 && ctx.alias == old
487 {
488 ctx.alias = new.to_string();
489 }
490 if self.collapsed_hosts.remove(old) {
491 debug_assert!(
492 !self.collapsed_hosts.contains(new),
493 "collapsed_hosts collision on rename {old} -> {new}"
494 );
495 self.collapsed_hosts.insert(new.to_string());
496 true
497 } else {
498 false
499 }
500 }
501
502 pub fn prune_by_container_ids(&mut self, valid_container_ids: &HashSet<String>) {
508 let pre_inspect = self.inspect_cache.entries.len();
509 self.inspect_cache
510 .entries
511 .retain(|id, _| valid_container_ids.contains(id));
512 self.inspect_cache
513 .in_flight
514 .retain(|id| valid_container_ids.contains(id));
515 self.logs_cache
516 .entries
517 .retain(|id, _| valid_container_ids.contains(id));
518 self.logs_cache
519 .in_flight
520 .retain(|id| valid_container_ids.contains(id));
521 let dropped = pre_inspect.saturating_sub(self.inspect_cache.entries.len());
522 if dropped > 0 {
523 log::debug!("[purple] reload_hosts: dropped {dropped} orphan inspect_cache entrie(s)");
524 }
525 }
526
527 pub fn prune_orphans(&mut self, valid_aliases: &HashSet<&str>) -> bool {
533 self.auto_list_in_flight
539 .retain(|alias| valid_aliases.contains(alias.as_str()));
540
541 if let Some(batch) = self.refresh_batch.as_mut() {
545 let pre = batch.in_flight_aliases.len();
546 batch
547 .in_flight_aliases
548 .retain(|alias| valid_aliases.contains(alias.as_str()));
549 let dropped = pre.saturating_sub(batch.in_flight_aliases.len());
550 if dropped > 0 {
551 log::debug!(
552 "[purple] reload_hosts: dropped {dropped} orphan refresh_batch in_flight alias(es)"
553 );
554 }
555 }
556
557 if let Some(ctx) = self.pending_bulk_confirm.as_ref()
561 && !valid_aliases.contains(ctx.alias.as_str())
562 {
563 self.pending_bulk_confirm = None;
564 }
565
566 let pre_collapsed = self.collapsed_hosts.len();
570 self.collapsed_hosts
571 .retain(|alias| valid_aliases.contains(alias.as_str()));
572 let dropped_collapsed = pre_collapsed.saturating_sub(self.collapsed_hosts.len());
573 if dropped_collapsed > 0 {
574 log::debug!(
575 "[purple] reload_hosts: dropped {dropped_collapsed} orphan collapsed_hosts entrie(s)"
576 );
577 true
578 } else {
579 false
580 }
581 }
582}
583
584impl InspectCache {
585 pub fn fresh(&self, container_id: &str, now: u64) -> Option<&InspectCacheEntry> {
589 self.entries
590 .get(container_id)
591 .filter(|e| now.saturating_sub(e.timestamp) < INSPECT_CACHE_TTL_SECS)
592 }
593}
594
595impl LogsCache {
596 pub fn fresh(&self, container_id: &str, now: u64) -> Option<&LogsCacheEntry> {
599 self.entries
600 .get(container_id)
601 .filter(|e| now.saturating_sub(e.timestamp) < LOGS_CACHE_TTL_SECS)
602 }
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608 use crate::runtime::env::Paths;
609
610 fn batch_with_aliases(aliases: &[&str]) -> RefreshBatch {
611 RefreshBatch {
612 queue: VecDeque::new(),
613 in_flight: aliases.len(),
614 total: aliases.len(),
615 completed: 0,
616 in_flight_aliases: aliases.iter().map(|a| a.to_string()).collect(),
617 }
618 }
619
620 #[test]
621 fn start_refresh_installs_batch() {
622 let mut state = ContainersOverviewState::default();
623 assert!(state.refresh_batch.is_none());
624 state.start_refresh(batch_with_aliases(&["host-a", "host-b"]));
625 let batch = state.refresh_batch.as_ref().unwrap();
626 assert_eq!(batch.total, 2);
627 assert_eq!(batch.in_flight, 2);
628 assert!(batch.in_flight_aliases.contains("host-a"));
629 }
630
631 #[test]
632 fn clear_refresh_drops_batch() {
633 let mut state = ContainersOverviewState::default();
634 state.start_refresh(batch_with_aliases(&["host-a"]));
635 state.clear_refresh();
636 assert!(state.refresh_batch.is_none());
637 }
638
639 #[test]
640 fn hydrate_from_prefs_reads_persisted_values() {
641 let dir = tempfile::tempdir().unwrap();
642 let paths = Paths::new(dir.path());
643 crate::preferences::save_containers_view_mode(Some(&paths), ViewMode::Compact).unwrap();
644 crate::preferences::save_containers_sort_mode(
645 Some(&paths),
646 ContainersSortMode::AlphaContainer,
647 )
648 .unwrap();
649 let mut collapsed = std::collections::HashSet::new();
650 collapsed.insert("folded-host".to_string());
651 crate::preferences::save_containers_collapsed_hosts(Some(&paths), &collapsed).unwrap();
652
653 let mut state = ContainersOverviewState::default();
654 state.hydrate_from_prefs(Some(&paths));
655 assert_eq!(state.view_mode, ViewMode::Compact);
656 assert_eq!(state.sort_mode, ContainersSortMode::AlphaContainer);
657 assert!(state.collapsed_hosts.contains("folded-host"));
658 }
659
660 #[test]
661 fn set_view_mode_updates_field_and_persists() {
662 let dir = tempfile::tempdir().unwrap();
663 let paths = Paths::new(dir.path());
664 let mut state = ContainersOverviewState::default();
665 state
666 .set_view_mode(Some(&paths), ViewMode::Compact)
667 .unwrap();
668 assert_eq!(state.view_mode, ViewMode::Compact);
669 assert_eq!(
670 crate::preferences::load_containers_view_mode(Some(&paths)),
671 ViewMode::Compact
672 );
673 }
674
675 #[test]
676 fn set_sort_mode_updates_field_and_persists() {
677 let dir = tempfile::tempdir().unwrap();
678 let paths = Paths::new(dir.path());
679 let mut state = ContainersOverviewState::default();
680 state
681 .set_sort_mode(Some(&paths), ContainersSortMode::AlphaContainer)
682 .unwrap();
683 assert_eq!(state.sort_mode, ContainersSortMode::AlphaContainer);
684 assert_eq!(
685 crate::preferences::load_containers_sort_mode(Some(&paths)),
686 ContainersSortMode::AlphaContainer
687 );
688 }
689
690 #[test]
691 fn migrate_alias_renames_auto_list_in_flight() {
692 let mut state = ContainersOverviewState::default();
693 state.auto_list_in_flight.insert("old".to_string());
694 state.migrate_alias("old", "new");
695 assert!(state.auto_list_in_flight.contains("new"));
696 assert!(!state.auto_list_in_flight.contains("old"));
697 }
698
699 #[test]
700 fn migrate_alias_renames_refresh_batch_in_flight() {
701 let mut state = ContainersOverviewState::default();
702 state.start_refresh(batch_with_aliases(&["old"]));
703 assert!(!state.migrate_alias("old", "new"));
708 let batch = state.refresh_batch.as_ref().unwrap();
709 assert!(batch.in_flight_aliases.contains("new"));
710 assert!(!batch.in_flight_aliases.contains("old"));
711 }
712
713 #[test]
714 fn migrate_alias_self_rename_is_noop() {
715 let mut state = ContainersOverviewState::default();
716 state.collapsed_hosts.insert("same".to_string());
717 state.auto_list_in_flight.insert("same".to_string());
718 assert!(!state.migrate_alias("same", "same"));
719 assert!(state.collapsed_hosts.contains("same"));
720 assert!(state.auto_list_in_flight.contains("same"));
721 }
722
723 #[test]
724 fn migrate_alias_renames_collapsed_hosts_and_returns_true() {
725 let mut state = ContainersOverviewState::default();
726 state.collapsed_hosts.insert("old".to_string());
727 assert!(state.migrate_alias("old", "new"));
728 assert!(state.collapsed_hosts.contains("new"));
729 assert!(!state.collapsed_hosts.contains("old"));
730 }
731
732 #[test]
733 fn migrate_alias_returns_false_when_collapsed_unchanged() {
734 let mut state = ContainersOverviewState::default();
735 state.auto_list_in_flight.insert("old".to_string());
736 assert!(!state.migrate_alias("old", "new"));
737 assert!(state.auto_list_in_flight.contains("new"));
738 }
739
740 #[test]
741 fn migrate_alias_is_noop_when_nothing_matches() {
742 let mut state = ContainersOverviewState::default();
743 assert!(!state.migrate_alias("missing", "new"));
744 }
745
746 #[test]
747 fn prune_by_container_ids_drops_unknown_id_from_in_flight_sets() {
748 let mut state = ContainersOverviewState::default();
749 state.inspect_cache.in_flight.insert("id-keep".to_string());
750 state.inspect_cache.in_flight.insert("id-drop".to_string());
751 state.logs_cache.in_flight.insert("id-keep".to_string());
752 state.logs_cache.in_flight.insert("id-drop".to_string());
753
754 let valid: HashSet<String> = ["id-keep".to_string()].into_iter().collect();
755 state.prune_by_container_ids(&valid);
756
757 assert!(state.inspect_cache.in_flight.contains("id-keep"));
758 assert!(!state.inspect_cache.in_flight.contains("id-drop"));
759 assert!(state.logs_cache.in_flight.contains("id-keep"));
760 assert!(!state.logs_cache.in_flight.contains("id-drop"));
761 }
762
763 #[test]
764 fn prune_by_container_ids_drops_unknown_id_from_entries_maps() {
765 let mut state = ContainersOverviewState::default();
769 state.inspect_cache.entries.insert(
770 "id-keep".to_string(),
771 InspectCacheEntry {
772 timestamp: 0,
773 result: Err("placeholder".to_string()),
774 },
775 );
776 state.inspect_cache.entries.insert(
777 "id-drop".to_string(),
778 InspectCacheEntry {
779 timestamp: 0,
780 result: Err("placeholder".to_string()),
781 },
782 );
783 state.logs_cache.entries.insert(
784 "id-keep".to_string(),
785 LogsCacheEntry {
786 timestamp: 0,
787 result: Ok(vec!["line".to_string()]),
788 },
789 );
790 state.logs_cache.entries.insert(
791 "id-drop".to_string(),
792 LogsCacheEntry {
793 timestamp: 0,
794 result: Ok(vec!["line".to_string()]),
795 },
796 );
797
798 let valid: HashSet<String> = ["id-keep".to_string()].into_iter().collect();
799 state.prune_by_container_ids(&valid);
800
801 assert!(state.inspect_cache.entries.contains_key("id-keep"));
802 assert!(!state.inspect_cache.entries.contains_key("id-drop"));
803 assert!(state.logs_cache.entries.contains_key("id-keep"));
804 assert!(!state.logs_cache.entries.contains_key("id-drop"));
805 }
806
807 #[test]
808 fn prune_orphans_drops_unknown_and_signals_collapsed_change() {
809 let mut state = ContainersOverviewState::default();
810 state.auto_list_in_flight.insert("keep".to_string());
811 state.auto_list_in_flight.insert("drop".to_string());
812 state.collapsed_hosts.insert("keep".to_string());
813 state.collapsed_hosts.insert("drop".to_string());
814
815 let valid: HashSet<&str> = ["keep"].into_iter().collect();
816 let collapsed_changed = state.prune_orphans(&valid);
817
818 assert!(
819 collapsed_changed,
820 "returns true so caller persists collapsed_hosts"
821 );
822 assert!(state.auto_list_in_flight.contains("keep"));
823 assert!(!state.auto_list_in_flight.contains("drop"));
824 assert!(state.collapsed_hosts.contains("keep"));
825 assert!(!state.collapsed_hosts.contains("drop"));
826 }
827
828 #[test]
829 fn prune_orphans_returns_false_when_collapsed_unchanged() {
830 let mut state = ContainersOverviewState::default();
831 state.auto_list_in_flight.insert("only".to_string());
832 let valid: HashSet<&str> = ["only"].into_iter().collect();
833 assert!(!state.prune_orphans(&valid));
834 }
835}