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 session_password: Option<String>,
17 pub cached_runtime: Option<ContainerRuntime>,
18 pub has_tunnel: bool,
19}
20
21#[derive(Debug, Default)]
27pub struct RefreshBatch {
28 pub queue: VecDeque<RefreshQueueItem>,
29 pub in_flight: usize,
30 pub total: usize,
33 pub completed: usize,
35 pub in_flight_aliases: HashSet<String>,
42}
43
44pub const REFRESH_MAX_PARALLEL: usize = 4;
48
49#[derive(Debug, Clone)]
59pub struct ContainerExecRequest {
60 pub alias: String,
61 pub askpass: Option<String>,
62 pub runtime: ContainerRuntime,
63 pub container_id: String,
64 pub container_name: String,
69 pub command: Option<String>,
73}
74
75#[derive(Debug, Clone)]
80pub struct ContainerLogsRequest {
81 pub alias: String,
82 pub askpass: Option<String>,
83 pub runtime: ContainerRuntime,
84 pub container_id: String,
85 pub container_name: String,
86}
87
88#[derive(Debug, Clone)]
94pub struct ContainerActionRequest {
95 pub alias: String,
96 pub askpass: Option<String>,
97 pub runtime: ContainerRuntime,
98 pub container_id: String,
99 pub container_name: String,
100 pub action: crate::containers::ContainerAction,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
105pub enum ContainersSortMode {
106 #[default]
108 AlphaHost,
109 AlphaContainer,
111}
112
113impl ContainersSortMode {
114 pub fn next(self) -> Self {
115 match self {
116 ContainersSortMode::AlphaHost => ContainersSortMode::AlphaContainer,
117 ContainersSortMode::AlphaContainer => ContainersSortMode::AlphaHost,
118 }
119 }
120
121 pub fn label(self) -> &'static str {
122 match self {
123 ContainersSortMode::AlphaHost => "A-Z host",
124 ContainersSortMode::AlphaContainer => "A-Z container",
125 }
126 }
127
128 pub fn to_key(self) -> &'static str {
129 match self {
130 ContainersSortMode::AlphaHost => "alpha_host",
131 ContainersSortMode::AlphaContainer => "alpha_container",
132 }
133 }
134
135 pub fn from_key(s: &str) -> Self {
136 match s {
137 "alpha_container" => ContainersSortMode::AlphaContainer,
138 _ => ContainersSortMode::AlphaHost,
139 }
140 }
141}
142
143#[derive(Debug, Clone)]
147pub struct InspectCacheEntry {
148 pub timestamp: u64,
149 pub result: Result<ContainerInspect, String>,
150}
151
152#[derive(Debug, Default)]
157pub struct InspectCache {
158 pub entries: HashMap<String, InspectCacheEntry>,
159 pub in_flight: HashSet<String>,
162}
163
164#[derive(Debug, Clone)]
168pub struct LogsCacheEntry {
169 pub timestamp: u64,
170 pub result: Result<Vec<String>, String>,
171}
172
173#[derive(Debug, Default)]
176pub struct LogsCache {
177 pub entries: HashMap<String, LogsCacheEntry>,
178 pub in_flight: HashSet<String>,
179}
180
181pub const INSPECT_CACHE_TTL_SECS: u64 = 30;
185
186pub const LOGS_CACHE_TTL_SECS: u64 = 30;
190
191pub const LOGS_TAIL: usize = 50;
196
197pub const LIST_CACHE_TTL_SECS: u64 = 30;
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum BulkConfirmKind {
210 StackRestart,
213 HostRestartAll,
216 HostStopAll,
219}
220
221#[derive(Debug, Clone)]
225pub struct BulkConfirmContext {
226 pub kind: BulkConfirmKind,
227 pub alias: String,
228 pub project: Option<String>,
231 pub members: Vec<crate::app::StackMember>,
232}
233
234#[derive(Debug)]
235pub struct ContainersOverviewState {
236 pub(in crate::app) sort_mode: ContainersSortMode,
237 pub(in crate::app) inspect_cache: InspectCache,
238 pub(in crate::app) logs_cache: LogsCache,
239 pub(in crate::app) refresh_batch: Option<RefreshBatch>,
241 pub(in crate::app) auto_list_in_flight: HashSet<String>,
246 pub(in crate::app) view_mode: ViewMode,
250 pub(in crate::app) collapsed_hosts: HashSet<String>,
254 pub(in crate::app) view_cache:
265 std::cell::RefCell<Option<(u64, Vec<crate::ui::containers_overview::ContainerListItem>)>>,
266 pub(in crate::app) pending_bulk_confirm: Option<BulkConfirmContext>,
270}
271
272impl Default for ContainersOverviewState {
273 fn default() -> Self {
274 Self {
275 sort_mode: ContainersSortMode::default(),
276 inspect_cache: InspectCache::default(),
277 logs_cache: LogsCache::default(),
278 refresh_batch: None,
279 auto_list_in_flight: HashSet::new(),
280 view_mode: ViewMode::Detailed,
281 collapsed_hosts: HashSet::new(),
282 view_cache: std::cell::RefCell::new(None),
283 pending_bulk_confirm: None,
284 }
285 }
286}
287
288impl ContainersOverviewState {
289 pub fn start_refresh(&mut self, batch: RefreshBatch) {
293 self.refresh_batch = Some(batch);
294 }
295
296 pub fn clear_refresh(&mut self) {
299 self.refresh_batch = None;
300 }
301
302 pub fn sort_mode(&self) -> ContainersSortMode {
306 self.sort_mode
307 }
308
309 pub fn set_sort_mode_ephemeral(&mut self, mode: ContainersSortMode) {
312 self.sort_mode = mode;
313 }
314
315 pub fn view_mode(&self) -> ViewMode {
316 self.view_mode
317 }
318
319 pub fn set_view_mode_ephemeral(&mut self, mode: ViewMode) {
322 self.view_mode = mode;
323 }
324
325 pub fn collapsed_hosts(&self) -> &HashSet<String> {
326 &self.collapsed_hosts
327 }
328
329 pub fn pending_bulk_confirm(&self) -> Option<&BulkConfirmContext> {
332 self.pending_bulk_confirm.as_ref()
333 }
334
335 pub fn set_pending_bulk_confirm(&mut self, ctx: BulkConfirmContext) {
339 self.pending_bulk_confirm = Some(ctx);
340 }
341
342 pub fn take_pending_bulk_confirm(&mut self) -> Option<BulkConfirmContext> {
345 self.pending_bulk_confirm.take()
346 }
347
348 pub fn toggle_host_collapsed(&mut self, alias: &str) -> bool {
350 if self.collapsed_hosts.remove(alias) {
351 false
352 } else {
353 self.collapsed_hosts.insert(alias.to_string());
354 true
355 }
356 }
357
358 pub fn refresh_batch(&self) -> Option<&RefreshBatch> {
359 self.refresh_batch.as_ref()
360 }
361
362 pub fn refresh_batch_mut(&mut self) -> Option<&mut RefreshBatch> {
363 self.refresh_batch.as_mut()
364 }
365
366 pub fn auto_list_in_flight(&self) -> &HashSet<String> {
367 &self.auto_list_in_flight
368 }
369
370 pub fn auto_list_pending(&self, alias: &str) -> bool {
373 self.auto_list_in_flight.contains(alias)
374 }
375
376 pub fn mark_auto_list_pending(&mut self, alias: String) {
378 self.auto_list_in_flight.insert(alias);
379 }
380
381 pub fn clear_auto_list_pending(&mut self, alias: &str) {
383 self.auto_list_in_flight.remove(alias);
384 }
385
386 pub fn inspect_cache(&self) -> &InspectCache {
387 &self.inspect_cache
388 }
389
390 pub fn inspect_cache_mut(&mut self) -> &mut InspectCache {
391 &mut self.inspect_cache
392 }
393
394 pub fn logs_cache(&self) -> &LogsCache {
395 &self.logs_cache
396 }
397
398 pub fn logs_cache_mut(&mut self) -> &mut LogsCache {
399 &mut self.logs_cache
400 }
401
402 pub fn view_cache(
403 &self,
404 ) -> &std::cell::RefCell<Option<(u64, Vec<crate::ui::containers_overview::ContainerListItem>)>>
405 {
406 &self.view_cache
407 }
408
409 pub(crate) fn hydrate_from_prefs(&mut self, paths: Option<&crate::runtime::env::Paths>) {
413 self.view_mode = crate::preferences::load_containers_view_mode(paths);
414 self.sort_mode = crate::preferences::load_containers_sort_mode(paths);
415 self.collapsed_hosts = crate::preferences::load_containers_collapsed_hosts(paths);
416 }
417
418 pub fn set_view_mode(
423 &mut self,
424 paths: Option<&crate::runtime::env::Paths>,
425 mode: ViewMode,
426 ) -> std::io::Result<()> {
427 self.view_mode = mode;
428 crate::preferences::save_containers_view_mode(paths, mode).inspect_err(|e| {
429 log::warn!("[config] Failed to persist containers view mode: {e}");
430 })
431 }
432
433 pub fn set_sort_mode(
436 &mut self,
437 paths: Option<&crate::runtime::env::Paths>,
438 mode: ContainersSortMode,
439 ) -> std::io::Result<()> {
440 self.sort_mode = mode;
441 crate::preferences::save_containers_sort_mode(paths, mode).inspect_err(|e| {
442 log::warn!("[config] Failed to persist containers sort mode: {e}");
443 })
444 }
445
446 pub fn migrate_alias(&mut self, old: &str, new: &str) -> bool {
452 if old == new {
453 return false;
454 }
455 if self.auto_list_in_flight.remove(old) {
456 debug_assert!(
457 !self.auto_list_in_flight.contains(new),
458 "auto_list_in_flight collision on rename {old} -> {new}"
459 );
460 self.auto_list_in_flight.insert(new.to_string());
461 }
462 if let Some(batch) = self.refresh_batch.as_mut()
463 && batch.in_flight_aliases.remove(old)
464 {
465 debug_assert!(
466 !batch.in_flight_aliases.contains(new),
467 "refresh_batch.in_flight_aliases collision on rename {old} -> {new}"
468 );
469 batch.in_flight_aliases.insert(new.to_string());
470 }
471 if let Some(ctx) = self.pending_bulk_confirm.as_mut()
472 && ctx.alias == old
473 {
474 ctx.alias = new.to_string();
475 }
476 if self.collapsed_hosts.remove(old) {
477 debug_assert!(
478 !self.collapsed_hosts.contains(new),
479 "collapsed_hosts collision on rename {old} -> {new}"
480 );
481 self.collapsed_hosts.insert(new.to_string());
482 true
483 } else {
484 false
485 }
486 }
487
488 pub fn prune_by_container_ids(&mut self, valid_container_ids: &HashSet<String>) {
494 let pre_inspect = self.inspect_cache.entries.len();
495 self.inspect_cache
496 .entries
497 .retain(|id, _| valid_container_ids.contains(id));
498 self.inspect_cache
499 .in_flight
500 .retain(|id| valid_container_ids.contains(id));
501 self.logs_cache
502 .entries
503 .retain(|id, _| valid_container_ids.contains(id));
504 self.logs_cache
505 .in_flight
506 .retain(|id| valid_container_ids.contains(id));
507 let dropped = pre_inspect.saturating_sub(self.inspect_cache.entries.len());
508 if dropped > 0 {
509 log::debug!("[purple] reload_hosts: dropped {dropped} orphan inspect_cache entrie(s)");
510 }
511 }
512
513 pub fn prune_orphans(&mut self, valid_aliases: &HashSet<&str>) -> bool {
519 self.auto_list_in_flight
525 .retain(|alias| valid_aliases.contains(alias.as_str()));
526
527 if let Some(batch) = self.refresh_batch.as_mut() {
531 let pre = batch.in_flight_aliases.len();
532 batch
533 .in_flight_aliases
534 .retain(|alias| valid_aliases.contains(alias.as_str()));
535 let dropped = pre.saturating_sub(batch.in_flight_aliases.len());
536 if dropped > 0 {
537 log::debug!(
538 "[purple] reload_hosts: dropped {dropped} orphan refresh_batch in_flight alias(es)"
539 );
540 }
541 }
542
543 if let Some(ctx) = self.pending_bulk_confirm.as_ref()
547 && !valid_aliases.contains(ctx.alias.as_str())
548 {
549 self.pending_bulk_confirm = None;
550 }
551
552 let pre_collapsed = self.collapsed_hosts.len();
556 self.collapsed_hosts
557 .retain(|alias| valid_aliases.contains(alias.as_str()));
558 let dropped_collapsed = pre_collapsed.saturating_sub(self.collapsed_hosts.len());
559 if dropped_collapsed > 0 {
560 log::debug!(
561 "[purple] reload_hosts: dropped {dropped_collapsed} orphan collapsed_hosts entrie(s)"
562 );
563 true
564 } else {
565 false
566 }
567 }
568}
569
570impl InspectCache {
571 pub fn fresh(&self, container_id: &str, now: u64) -> Option<&InspectCacheEntry> {
575 self.entries
576 .get(container_id)
577 .filter(|e| now.saturating_sub(e.timestamp) < INSPECT_CACHE_TTL_SECS)
578 }
579}
580
581impl LogsCache {
582 pub fn fresh(&self, container_id: &str, now: u64) -> Option<&LogsCacheEntry> {
585 self.entries
586 .get(container_id)
587 .filter(|e| now.saturating_sub(e.timestamp) < LOGS_CACHE_TTL_SECS)
588 }
589}
590
591#[cfg(test)]
592mod tests {
593 use super::*;
594 use crate::runtime::env::Paths;
595
596 fn batch_with_aliases(aliases: &[&str]) -> RefreshBatch {
597 RefreshBatch {
598 queue: VecDeque::new(),
599 in_flight: aliases.len(),
600 total: aliases.len(),
601 completed: 0,
602 in_flight_aliases: aliases.iter().map(|a| a.to_string()).collect(),
603 }
604 }
605
606 #[test]
607 fn start_refresh_installs_batch() {
608 let mut state = ContainersOverviewState::default();
609 assert!(state.refresh_batch.is_none());
610 state.start_refresh(batch_with_aliases(&["host-a", "host-b"]));
611 let batch = state.refresh_batch.as_ref().unwrap();
612 assert_eq!(batch.total, 2);
613 assert_eq!(batch.in_flight, 2);
614 assert!(batch.in_flight_aliases.contains("host-a"));
615 }
616
617 #[test]
618 fn clear_refresh_drops_batch() {
619 let mut state = ContainersOverviewState::default();
620 state.start_refresh(batch_with_aliases(&["host-a"]));
621 state.clear_refresh();
622 assert!(state.refresh_batch.is_none());
623 }
624
625 #[test]
626 fn hydrate_from_prefs_reads_persisted_values() {
627 let dir = tempfile::tempdir().unwrap();
628 let paths = Paths::new(dir.path());
629 crate::preferences::save_containers_view_mode(Some(&paths), ViewMode::Compact).unwrap();
630 crate::preferences::save_containers_sort_mode(
631 Some(&paths),
632 ContainersSortMode::AlphaContainer,
633 )
634 .unwrap();
635 let mut collapsed = std::collections::HashSet::new();
636 collapsed.insert("folded-host".to_string());
637 crate::preferences::save_containers_collapsed_hosts(Some(&paths), &collapsed).unwrap();
638
639 let mut state = ContainersOverviewState::default();
640 state.hydrate_from_prefs(Some(&paths));
641 assert_eq!(state.view_mode, ViewMode::Compact);
642 assert_eq!(state.sort_mode, ContainersSortMode::AlphaContainer);
643 assert!(state.collapsed_hosts.contains("folded-host"));
644 }
645
646 #[test]
647 fn set_view_mode_updates_field_and_persists() {
648 let dir = tempfile::tempdir().unwrap();
649 let paths = Paths::new(dir.path());
650 let mut state = ContainersOverviewState::default();
651 state
652 .set_view_mode(Some(&paths), ViewMode::Compact)
653 .unwrap();
654 assert_eq!(state.view_mode, ViewMode::Compact);
655 assert_eq!(
656 crate::preferences::load_containers_view_mode(Some(&paths)),
657 ViewMode::Compact
658 );
659 }
660
661 #[test]
662 fn set_sort_mode_updates_field_and_persists() {
663 let dir = tempfile::tempdir().unwrap();
664 let paths = Paths::new(dir.path());
665 let mut state = ContainersOverviewState::default();
666 state
667 .set_sort_mode(Some(&paths), ContainersSortMode::AlphaContainer)
668 .unwrap();
669 assert_eq!(state.sort_mode, ContainersSortMode::AlphaContainer);
670 assert_eq!(
671 crate::preferences::load_containers_sort_mode(Some(&paths)),
672 ContainersSortMode::AlphaContainer
673 );
674 }
675
676 #[test]
677 fn migrate_alias_renames_auto_list_in_flight() {
678 let mut state = ContainersOverviewState::default();
679 state.auto_list_in_flight.insert("old".to_string());
680 state.migrate_alias("old", "new");
681 assert!(state.auto_list_in_flight.contains("new"));
682 assert!(!state.auto_list_in_flight.contains("old"));
683 }
684
685 #[test]
686 fn migrate_alias_renames_refresh_batch_in_flight() {
687 let mut state = ContainersOverviewState::default();
688 state.start_refresh(batch_with_aliases(&["old"]));
689 assert!(!state.migrate_alias("old", "new"));
694 let batch = state.refresh_batch.as_ref().unwrap();
695 assert!(batch.in_flight_aliases.contains("new"));
696 assert!(!batch.in_flight_aliases.contains("old"));
697 }
698
699 #[test]
700 fn migrate_alias_self_rename_is_noop() {
701 let mut state = ContainersOverviewState::default();
702 state.collapsed_hosts.insert("same".to_string());
703 state.auto_list_in_flight.insert("same".to_string());
704 assert!(!state.migrate_alias("same", "same"));
705 assert!(state.collapsed_hosts.contains("same"));
706 assert!(state.auto_list_in_flight.contains("same"));
707 }
708
709 #[test]
710 fn migrate_alias_renames_collapsed_hosts_and_returns_true() {
711 let mut state = ContainersOverviewState::default();
712 state.collapsed_hosts.insert("old".to_string());
713 assert!(state.migrate_alias("old", "new"));
714 assert!(state.collapsed_hosts.contains("new"));
715 assert!(!state.collapsed_hosts.contains("old"));
716 }
717
718 #[test]
719 fn migrate_alias_returns_false_when_collapsed_unchanged() {
720 let mut state = ContainersOverviewState::default();
721 state.auto_list_in_flight.insert("old".to_string());
722 assert!(!state.migrate_alias("old", "new"));
723 assert!(state.auto_list_in_flight.contains("new"));
724 }
725
726 #[test]
727 fn migrate_alias_is_noop_when_nothing_matches() {
728 let mut state = ContainersOverviewState::default();
729 assert!(!state.migrate_alias("missing", "new"));
730 }
731
732 #[test]
733 fn prune_by_container_ids_drops_unknown_id_from_in_flight_sets() {
734 let mut state = ContainersOverviewState::default();
735 state.inspect_cache.in_flight.insert("id-keep".to_string());
736 state.inspect_cache.in_flight.insert("id-drop".to_string());
737 state.logs_cache.in_flight.insert("id-keep".to_string());
738 state.logs_cache.in_flight.insert("id-drop".to_string());
739
740 let valid: HashSet<String> = ["id-keep".to_string()].into_iter().collect();
741 state.prune_by_container_ids(&valid);
742
743 assert!(state.inspect_cache.in_flight.contains("id-keep"));
744 assert!(!state.inspect_cache.in_flight.contains("id-drop"));
745 assert!(state.logs_cache.in_flight.contains("id-keep"));
746 assert!(!state.logs_cache.in_flight.contains("id-drop"));
747 }
748
749 #[test]
750 fn prune_by_container_ids_drops_unknown_id_from_entries_maps() {
751 let mut state = ContainersOverviewState::default();
755 state.inspect_cache.entries.insert(
756 "id-keep".to_string(),
757 InspectCacheEntry {
758 timestamp: 0,
759 result: Err("placeholder".to_string()),
760 },
761 );
762 state.inspect_cache.entries.insert(
763 "id-drop".to_string(),
764 InspectCacheEntry {
765 timestamp: 0,
766 result: Err("placeholder".to_string()),
767 },
768 );
769 state.logs_cache.entries.insert(
770 "id-keep".to_string(),
771 LogsCacheEntry {
772 timestamp: 0,
773 result: Ok(vec!["line".to_string()]),
774 },
775 );
776 state.logs_cache.entries.insert(
777 "id-drop".to_string(),
778 LogsCacheEntry {
779 timestamp: 0,
780 result: Ok(vec!["line".to_string()]),
781 },
782 );
783
784 let valid: HashSet<String> = ["id-keep".to_string()].into_iter().collect();
785 state.prune_by_container_ids(&valid);
786
787 assert!(state.inspect_cache.entries.contains_key("id-keep"));
788 assert!(!state.inspect_cache.entries.contains_key("id-drop"));
789 assert!(state.logs_cache.entries.contains_key("id-keep"));
790 assert!(!state.logs_cache.entries.contains_key("id-drop"));
791 }
792
793 #[test]
794 fn prune_orphans_drops_unknown_and_signals_collapsed_change() {
795 let mut state = ContainersOverviewState::default();
796 state.auto_list_in_flight.insert("keep".to_string());
797 state.auto_list_in_flight.insert("drop".to_string());
798 state.collapsed_hosts.insert("keep".to_string());
799 state.collapsed_hosts.insert("drop".to_string());
800
801 let valid: HashSet<&str> = ["keep"].into_iter().collect();
802 let collapsed_changed = state.prune_orphans(&valid);
803
804 assert!(
805 collapsed_changed,
806 "returns true so caller persists collapsed_hosts"
807 );
808 assert!(state.auto_list_in_flight.contains("keep"));
809 assert!(!state.auto_list_in_flight.contains("drop"));
810 assert!(state.collapsed_hosts.contains("keep"));
811 assert!(!state.collapsed_hosts.contains("drop"));
812 }
813
814 #[test]
815 fn prune_orphans_returns_false_when_collapsed_unchanged() {
816 let mut state = ContainersOverviewState::default();
817 state.auto_list_in_flight.insert("only".to_string());
818 let valid: HashSet<&str> = ["only"].into_iter().collect();
819 assert!(!state.prune_orphans(&valid));
820 }
821}