Skip to main content

purple_ssh/app/
containers_overview.rs

1//! State for the global Containers tab (top_page = Containers).
2
3use std::collections::{HashMap, HashSet, VecDeque};
4
5use super::host_state::ViewMode;
6use crate::containers::{ContainerInspect, ContainerRuntime};
7
8/// One queued host in a `R` batch refresh: everything the listing
9/// thread needs to spawn an SSH `docker ps` for that alias.
10#[derive(Clone)]
11pub struct RefreshQueueItem {
12    pub alias: String,
13    pub askpass: Option<String>,
14    /// Password typed in the TUI for this host this session, resolved when
15    /// the item was queued so the worker needs no App access.
16    pub session_password: Option<String>,
17    pub cached_runtime: Option<ContainerRuntime>,
18    pub has_tunnel: bool,
19}
20
21// Hand-written so a stray `{:?}`, here or on the batch that holds a queue of
22// these, can never print the password. Shows whether one is present.
23impl 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/// State of a `R` batch refresh. None when no batch is active.
36/// Drives a windowed concurrency: at most `MAX_PARALLEL` listings are
37/// in flight at any time. Each `ContainerListing` event decrements
38/// `in_flight` and pops the next queued item; the batch ends when
39/// `queue` is empty and `in_flight` drops to zero.
40#[derive(Debug, Default)]
41pub struct RefreshBatch {
42    pub queue: VecDeque<RefreshQueueItem>,
43    pub in_flight: usize,
44    /// Total host count when the batch started. used for the
45    /// `Refreshing N/M` progress readout in the status footer.
46    pub total: usize,
47    /// Hosts whose listing has already returned (success or error).
48    pub completed: usize,
49    /// Aliases that the batch has spawned but not yet seen complete.
50    /// Listings whose alias is NOT in this set are non-batch traffic
51    /// (host-list `C`, action-complete refresh, `a`-add) and must not
52    /// touch the counters. Without this guard the in-flight counter
53    /// gets corrupted whenever a parallel non-batch fetch completes
54    /// during a `R` run.
55    pub in_flight_aliases: HashSet<String>,
56}
57
58/// Cap on parallel SSH connections during a `R` batch refresh. Picked
59/// to keep load on the local SSH agent and remote sshd reasonable
60/// while still amortising connection setup.
61pub const REFRESH_MAX_PARALLEL: usize = 4;
62
63/// Pending request to drop the user into a remote container shell. Set
64/// by the handler when Enter is pressed on a running container; drained
65/// by `handle_pending_container_exec` in the main loop, which suspends
66/// the TUI, runs `ssh -t <alias> <runtime> exec -it <id> sh -c
67/// 'bash || sh'`, then restores the TUI on exit.
68///
69/// `command` is the full remote command. `None` runs the default
70/// shell (`sh -c 'bash || sh'`); `Some` runs the user-typed exec
71/// prompt verbatim (validated upstream. must not contain newlines).
72#[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    /// Human-readable container name (for the log line and the toast
79    /// the user sees when the session ends). Not used to build the
80    /// command. the validated `container_id` is what addresses the
81    /// container.
82    pub container_name: String,
83    /// User-supplied command. `None` falls back to the interactive
84    /// shell (`sh -c 'bash || sh'`) used by the default Enter binding.
85    /// `Some` is the verbatim payload from the `e` exec prompt.
86    pub command: Option<String>,
87}
88
89/// Pending request to fetch container logs from a remote host. Drained
90/// by the main loop into a background SSH thread; the result returns
91/// via `AppEvent::ContainerLogsComplete` and lands on the
92/// `Screen::ContainerLogs` overlay the user opened with `l`.
93#[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/// Pending non-interactive container action (restart, stop). Same shape
103/// as `ContainerExecRequest` but plus an action tag and minus the askpass
104/// handling for an interactive shell. these run in a worker thread, not
105/// the foreground TUI. Reuses `containers::ContainerAction` so the
106/// command formatter (`container_action_command`) is shared.
107#[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/// Sort order for the containers overview screen. Cycled with `s`.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
119pub enum ContainersSortMode {
120    /// Alphabetical by host alias, then container name.
121    #[default]
122    AlphaHost,
123    /// Alphabetical by container name, then host alias.
124    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/// One cached `docker inspect` result, paired with the wall-clock seconds
158/// at which it was fetched. The detail panel treats entries older than
159/// `INSPECT_CACHE_TTL_SECS` as stale and re-fires the SSH call.
160#[derive(Debug, Clone)]
161pub struct InspectCacheEntry {
162    pub timestamp: u64,
163    pub result: Result<ContainerInspect, String>,
164}
165
166/// Detail-panel inspect cache and in-flight tracking. Keyed on the full
167/// container ID rather than `(alias, container_id)` because `docker`
168/// container IDs are globally unique 64-char hex strings. collisions
169/// across hosts are practically impossible. Keeps the key shape simple.
170#[derive(Debug, Default)]
171pub struct InspectCache {
172    pub entries: HashMap<String, InspectCacheEntry>,
173    /// Container IDs with a pending background `inspect` thread. Prevents
174    /// re-firing while a previous fetch is still in flight.
175    pub in_flight: HashSet<String>,
176}
177
178/// One cached `docker logs --tail N` result. Same TTL semantics as
179/// `InspectCacheEntry`: the LOGS card on the detail panel re-fires the
180/// SSH call once the entry is older than `LOGS_CACHE_TTL_SECS`.
181#[derive(Debug, Clone)]
182pub struct LogsCacheEntry {
183    pub timestamp: u64,
184    pub result: Result<Vec<String>, String>,
185}
186
187/// Detail-panel logs cache and in-flight tracking. Mirrors `InspectCache`
188/// so the LOGS card and the inspect cards refresh on the same rhythm.
189#[derive(Debug, Default)]
190pub struct LogsCache {
191    pub entries: HashMap<String, LogsCacheEntry>,
192    pub in_flight: HashSet<String>,
193}
194
195/// Cache TTL in seconds. Kept short so resource-y fields like
196/// `RestartCount` and `Health.Status` do not lag too far behind reality
197/// while still avoiding an SSH storm when the user scrolls a long list.
198pub const INSPECT_CACHE_TTL_SECS: u64 = 30;
199
200/// TTL for the per-container `docker logs --tail` cache feeding the
201/// LOGS card. Same value as the inspect cache so a single user-driven
202/// refresh re-fires both streams in lockstep.
203pub const LOGS_CACHE_TTL_SECS: u64 = 30;
204
205/// How many log lines the LOGS card requests via `--tail`. Sized for
206/// the worst-case panel height we expect (a tall terminal can fit
207/// dozens of lines inside the card); the renderer slices the trailing
208/// `inner_capacity` rows so a short panel only paints what fits.
209pub const LOGS_TAIL: usize = 50;
210
211/// TTL for the per-host `docker ps` cache used by the auto-list-refresh
212/// helper. When the user scrolls to a row whose host has a stale (or
213/// missing) entry in `container_cache`, we re-fire the listing so the
214/// running/exited counts and uptime in the visible row reflect reality.
215/// Same value as the inspect TTL so the two refresh streams stay
216/// loosely in lockstep.
217pub const LIST_CACHE_TTL_SECS: u64 = 30;
218
219/// Which bulk-action confirm dialog is open. Mirrored by the
220/// corresponding `Screen::Confirm*` variant (now data-less) so the
221/// renderer knows which header to draw.
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum BulkConfirmKind {
224    /// `Ctrl-K` on a container row: restart every running member of
225    /// the selected container's compose stack.
226    StackRestart,
227    /// `K` on a host-divider row: restart every running container on
228    /// that host, ignoring compose-project boundaries.
229    HostRestartAll,
230    /// `S` on a host-divider row: stop every running container on
231    /// that host.
232    HostStopAll,
233}
234
235/// Payload of the three bulk container confirm dialogs. The Screen
236/// variant carries only the kind tag; the alias/project/members live
237/// here so screen transitions never clone the member vec.
238#[derive(Debug, Clone)]
239pub struct BulkConfirmContext {
240    pub kind: BulkConfirmKind,
241    pub alias: String,
242    /// Compose project name. Always `Some` for `StackRestart`, `None`
243    /// for the host-wide variants.
244    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    /// Currently-running `R` batch, if any. `None` when idle.
254    pub(in crate::app) refresh_batch: Option<RefreshBatch>,
255    /// Aliases whose `docker ps` listing was kicked off by the
256    /// scroll-driven auto-refresh helper and has not yet returned.
257    /// Lets the helper skip a re-spawn while one is already pending.
258    /// Cleared by `handle_container_listing` on arrival.
259    pub(in crate::app) auto_list_in_flight: HashSet<String>,
260    /// Toggle for the per-row detail panel on the right. Mirrors the
261    /// host-list `v` toggle. Default `Detailed` so the panel is visible
262    /// whenever the terminal is wide enough.
263    pub(in crate::app) view_mode: ViewMode,
264    /// Aliases whose container group is currently collapsed in the
265    /// AlphaHost rendering. Persisted across sessions via preferences
266    /// so a folded group stays folded after restart.
267    pub(in crate::app) collapsed_hosts: HashSet<String>,
268    /// Memoized render list. The render and handler paths call
269    /// `visible_items` repeatedly (24 call sites, several per key
270    /// event) and each call cloned 6 String fields per container. The
271    /// cache stores the built `Vec<ContainerListItem>` keyed on a
272    /// content fingerprint over the inputs (sort_mode, search query,
273    /// collapsed_hosts, per-host (timestamp, container_count)). On a
274    /// hit we skip the collect/sort/intersperse step entirely and
275    /// return a clone of the cached vec. The fingerprint walks all
276    /// hosts but only reads a few fields each, so it is dramatically
277    /// cheaper than rebuilding the row set.
278    pub(in crate::app) view_cache:
279        std::cell::RefCell<Option<(u64, Vec<crate::ui::containers_overview::ContainerListItem>)>>,
280    /// Payload of the currently-open bulk container confirm dialog
281    /// (`ConfirmStackRestart` / `ConfirmHostRestartAll` /
282    /// `ConfirmHostStopAll`). `None` when no such dialog is open.
283    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    /// Install a fresh refresh batch. Caller is responsible for spawning
304    /// the initial parallel listings after this call; this method only
305    /// owns the state slot.
306    pub fn start_refresh(&mut self, batch: RefreshBatch) {
307        self.refresh_batch = Some(batch);
308    }
309
310    /// Drop the active refresh batch. Called when the queue drains and
311    /// in-flight count returns to zero.
312    pub fn clear_refresh(&mut self) {
313        self.refresh_batch = None;
314    }
315
316    // Sealed-field accessors. Fields are `pub(in crate::app)`; callers
317    // outside the app module reach state through these.
318
319    pub fn sort_mode(&self) -> ContainersSortMode {
320        self.sort_mode
321    }
322
323    /// Set the sort mode without persisting to preferences. Demo mode and
324    /// tests only; the persisting path is `set_sort_mode`.
325    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    /// Set the view mode without persisting to preferences. Demo mode and
334    /// tests only; the persisting path is `set_view_mode`.
335    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    /// Read the active bulk confirm payload (`None` when no bulk
344    /// container confirm dialog is open).
345    pub fn pending_bulk_confirm(&self) -> Option<&BulkConfirmContext> {
346        self.pending_bulk_confirm.as_ref()
347    }
348
349    /// Install a fresh bulk confirm payload. Caller is responsible for
350    /// transitioning the screen to the matching `Screen::Confirm*`
351    /// variant.
352    pub fn set_pending_bulk_confirm(&mut self, ctx: BulkConfirmContext) {
353        self.pending_bulk_confirm = Some(ctx);
354    }
355
356    /// Drop the active bulk confirm payload, returning it for use by
357    /// the confirm-yes handler.
358    pub fn take_pending_bulk_confirm(&mut self) -> Option<BulkConfirmContext> {
359        self.pending_bulk_confirm.take()
360    }
361
362    /// Fold or unfold a host group; returns the new collapsed state.
363    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    /// True when a scroll-driven auto-listing is already in flight for
385    /// `alias`, so the helper skips a re-spawn.
386    pub fn auto_list_pending(&self, alias: &str) -> bool {
387        self.auto_list_in_flight.contains(alias)
388    }
389
390    /// Mark a scroll-driven auto-listing as in flight for `alias`.
391    pub fn mark_auto_list_pending(&mut self, alias: String) {
392        self.auto_list_in_flight.insert(alias);
393    }
394
395    /// Clear the in-flight marker once an auto-listing arrives.
396    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    /// Load the persisted overview fields from `preferences`. Startup only:
424    /// clobbers any in-memory state with whatever is on disk. `pub(crate)`
425    /// so a stray mid-session caller cannot quietly revert user edits.
426    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    /// Update `view_mode` and persist. Returns the persist error so the
433    /// caller can surface it (current call site discards it intentionally
434    /// to match the pre-encapsulation behavior where view-mode persist
435    /// failures only logged).
436    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    /// Update `sort_mode` and persist. Same contract as `set_view_mode`;
448    /// the call site does surface the error via a toast.
449    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    /// Rename an alias across every alias-keyed set in this state.
461    /// Returns `true` when `collapsed_hosts` changed so the caller can
462    /// persist; `auto_list_in_flight` and `refresh_batch.in_flight_aliases`
463    /// are also migrated but are not persistent so they do not affect
464    /// the return value. No-op (returns `false`) when `old == new`.
465    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    /// Drop inspect-cache and logs-cache entries whose container ID is
503    /// not in `valid_container_ids`. Called from `App::reload_hosts`
504    /// after the per-host container cache has been pruned: container
505    /// IDs come from the just-pruned cache, so this keeps inspect/logs
506    /// aligned with the surviving hosts.
507    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    /// Drop per-alias overview state (auto-list in-flight, refresh batch
528    /// in-flight aliases, collapsed-hosts) whose alias is no longer in
529    /// `valid_aliases`. Returns `true` when `collapsed_hosts` shrank, so
530    /// the caller can persist the trimmed set via
531    /// `preferences::save_containers_collapsed_hosts`.
532    pub fn prune_orphans(&mut self, valid_aliases: &HashSet<&str>) -> bool {
533        // Auto-list in-flight markers for deleted hosts. The listing
534        // thread still posts a result and the race guard in
535        // `handle_container_listing` removes it; pruning here keeps
536        // debug state clean and avoids a false-positive dedup hit if
537        // the same alias is re-added before the stray listing returns.
538        self.auto_list_in_flight
539            .retain(|alias| valid_aliases.contains(alias.as_str()));
540
541        // Container-overview refresh batch (R). Tracks in-flight aliases
542        // to gate counter updates against non-batch listings; prune so a
543        // host removed mid-batch cannot linger.
544        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        // Bulk-confirm payload aimed at a deleted host: clear it; the
558        // matching Screen::Confirm* variant would render an empty body
559        // and a y press would no-op.
560        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        // Containers-overview collapsed groups are persisted to disk via
567        // preferences, so leftover aliases survive restart. Rename is
568        // already handled by `apply_alias_renames`; this covers delete.
569        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    /// Returns `Some(entry)` only when the cache holds a *fresh* entry
586    /// for `container_id` (`now - timestamp < TTL`). Stale entries
587    /// behave like absent ones so the trigger code re-fetches.
588    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    /// Same fresh-window contract as `InspectCache::fresh`, against
597    /// `LOGS_CACHE_TTL_SECS`.
598    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        // Non-persistent change: collapsed_hosts is untouched so the
704        // return value must be false even though the in-flight set
705        // was migrated. Pins the contract that drives the persist
706        // call in app::hosts::migrate_alias_keyed_caches.
707        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        // Distinct from the in_flight test: this one populates the
766        // `.entries` HashMaps so the retain() calls covering them
767        // cannot be removed without a test failure.
768        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}