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(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/// State of a `R` batch refresh. None when no batch is active.
19/// Drives a windowed concurrency: at most `MAX_PARALLEL` listings are
20/// in flight at any time. Each `ContainerListing` event decrements
21/// `in_flight` and pops the next queued item; the batch ends when
22/// `queue` is empty and `in_flight` drops to zero.
23#[derive(Debug, Default)]
24pub struct RefreshBatch {
25    pub queue: VecDeque<RefreshQueueItem>,
26    pub in_flight: usize,
27    /// Total host count when the batch started. used for the
28    /// `Refreshing N/M` progress readout in the status footer.
29    pub total: usize,
30    /// Hosts whose listing has already returned (success or error).
31    pub completed: usize,
32    /// Aliases that the batch has spawned but not yet seen complete.
33    /// Listings whose alias is NOT in this set are non-batch traffic
34    /// (host-list `C`, action-complete refresh, `a`-add) and must not
35    /// touch the counters. Without this guard the in-flight counter
36    /// gets corrupted whenever a parallel non-batch fetch completes
37    /// during a `R` run.
38    pub in_flight_aliases: HashSet<String>,
39}
40
41/// Cap on parallel SSH connections during a `R` batch refresh. Picked
42/// to keep load on the local SSH agent and remote sshd reasonable
43/// while still amortising connection setup.
44pub const REFRESH_MAX_PARALLEL: usize = 4;
45
46/// Pending request to drop the user into a remote container shell. Set
47/// by the handler when Enter is pressed on a running container; drained
48/// by `handle_pending_container_exec` in the main loop, which suspends
49/// the TUI, runs `ssh -t <alias> <runtime> exec -it <id> sh -c
50/// 'bash || sh'`, then restores the TUI on exit.
51///
52/// `command` is the full remote command. `None` runs the default
53/// shell (`sh -c 'bash || sh'`); `Some` runs the user-typed exec
54/// prompt verbatim (validated upstream. must not contain newlines).
55#[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    /// Human-readable container name (for the log line and the toast
62    /// the user sees when the session ends). Not used to build the
63    /// command. the validated `container_id` is what addresses the
64    /// container.
65    pub container_name: String,
66    /// User-supplied command. `None` falls back to the interactive
67    /// shell (`sh -c 'bash || sh'`) used by the default Enter binding.
68    /// `Some` is the verbatim payload from the `e` exec prompt.
69    pub command: Option<String>,
70}
71
72/// Pending request to fetch container logs from a remote host. Drained
73/// by the main loop into a background SSH thread; the result returns
74/// via `AppEvent::ContainerLogsComplete` and lands on the
75/// `Screen::ContainerLogs` overlay the user opened with `l`.
76#[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/// Pending non-interactive container action (restart, stop). Same shape
86/// as `ContainerExecRequest` but plus an action tag and minus the askpass
87/// handling for an interactive shell. these run in a worker thread, not
88/// the foreground TUI. Reuses `containers::ContainerAction` so the
89/// command formatter (`container_action_command`) is shared.
90#[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/// Sort order for the containers overview screen. Cycled with `s`.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
102pub enum ContainersSortMode {
103    /// Alphabetical by host alias, then container name.
104    #[default]
105    AlphaHost,
106    /// Alphabetical by container name, then host alias.
107    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/// One cached `docker inspect` result, paired with the wall-clock seconds
141/// at which it was fetched. The detail panel treats entries older than
142/// `INSPECT_CACHE_TTL_SECS` as stale and re-fires the SSH call.
143#[derive(Debug, Clone)]
144pub struct InspectCacheEntry {
145    pub timestamp: u64,
146    pub result: Result<ContainerInspect, String>,
147}
148
149/// Detail-panel inspect cache and in-flight tracking. Keyed on the full
150/// container ID rather than `(alias, container_id)` because `docker`
151/// container IDs are globally unique 64-char hex strings. collisions
152/// across hosts are practically impossible. Keeps the key shape simple.
153#[derive(Debug, Default)]
154pub struct InspectCache {
155    pub entries: HashMap<String, InspectCacheEntry>,
156    /// Container IDs with a pending background `inspect` thread. Prevents
157    /// re-firing while a previous fetch is still in flight.
158    pub in_flight: HashSet<String>,
159}
160
161/// One cached `docker logs --tail N` result. Same TTL semantics as
162/// `InspectCacheEntry`: the LOGS card on the detail panel re-fires the
163/// SSH call once the entry is older than `LOGS_CACHE_TTL_SECS`.
164#[derive(Debug, Clone)]
165pub struct LogsCacheEntry {
166    pub timestamp: u64,
167    pub result: Result<Vec<String>, String>,
168}
169
170/// Detail-panel logs cache and in-flight tracking. Mirrors `InspectCache`
171/// so the LOGS card and the inspect cards refresh on the same rhythm.
172#[derive(Debug, Default)]
173pub struct LogsCache {
174    pub entries: HashMap<String, LogsCacheEntry>,
175    pub in_flight: HashSet<String>,
176}
177
178/// Cache TTL in seconds. Kept short so resource-y fields like
179/// `RestartCount` and `Health.Status` do not lag too far behind reality
180/// while still avoiding an SSH storm when the user scrolls a long list.
181pub const INSPECT_CACHE_TTL_SECS: u64 = 30;
182
183/// TTL for the per-container `docker logs --tail` cache feeding the
184/// LOGS card. Same value as the inspect cache so a single user-driven
185/// refresh re-fires both streams in lockstep.
186pub const LOGS_CACHE_TTL_SECS: u64 = 30;
187
188/// How many log lines the LOGS card requests via `--tail`. Sized for
189/// the worst-case panel height we expect (a tall terminal can fit
190/// dozens of lines inside the card); the renderer slices the trailing
191/// `inner_capacity` rows so a short panel only paints what fits.
192pub const LOGS_TAIL: usize = 50;
193
194/// TTL for the per-host `docker ps` cache used by the auto-list-refresh
195/// helper. When the user scrolls to a row whose host has a stale (or
196/// missing) entry in `container_cache`, we re-fire the listing so the
197/// running/exited counts and uptime in the visible row reflect reality.
198/// Same value as the inspect TTL so the two refresh streams stay
199/// loosely in lockstep.
200pub const LIST_CACHE_TTL_SECS: u64 = 30;
201
202#[derive(Debug)]
203pub struct ContainersOverviewState {
204    pub sort_mode: ContainersSortMode,
205    pub inspect_cache: InspectCache,
206    pub logs_cache: LogsCache,
207    /// Currently-running `R` batch, if any. `None` when idle.
208    pub refresh_batch: Option<RefreshBatch>,
209    /// Aliases whose `docker ps` listing was kicked off by the
210    /// scroll-driven auto-refresh helper and has not yet returned.
211    /// Lets the helper skip a re-spawn while one is already pending.
212    /// Cleared by `handle_container_listing` on arrival.
213    pub auto_list_in_flight: HashSet<String>,
214    /// Toggle for the per-row detail panel on the right. Mirrors the
215    /// host-list `v` toggle. Default `Detailed` so the panel is visible
216    /// whenever the terminal is wide enough.
217    pub view_mode: ViewMode,
218    /// Aliases whose container group is currently collapsed in the
219    /// AlphaHost rendering. Persisted across sessions via preferences
220    /// so a folded group stays folded after restart.
221    pub collapsed_hosts: HashSet<String>,
222    /// Memoized render list. The render and handler paths call
223    /// `visible_items` repeatedly (24 call sites, several per key
224    /// event) and each call cloned 6 String fields per container. The
225    /// cache stores the built `Vec<ContainerListItem>` keyed on a
226    /// content fingerprint over the inputs (sort_mode, search query,
227    /// collapsed_hosts, per-host (timestamp, container_count)). On a
228    /// hit we skip the collect/sort/intersperse step entirely and
229    /// return a clone of the cached vec. The fingerprint walks all
230    /// hosts but only reads a few fields each, so it is dramatically
231    /// cheaper than rebuilding the row set.
232    pub view_cache:
233        std::cell::RefCell<Option<(u64, Vec<crate::ui::containers_overview::ContainerListItem>)>>,
234}
235
236impl Default for ContainersOverviewState {
237    fn default() -> Self {
238        Self {
239            sort_mode: ContainersSortMode::default(),
240            inspect_cache: InspectCache::default(),
241            logs_cache: LogsCache::default(),
242            refresh_batch: None,
243            auto_list_in_flight: HashSet::new(),
244            view_mode: ViewMode::Detailed,
245            collapsed_hosts: HashSet::new(),
246            view_cache: std::cell::RefCell::new(None),
247        }
248    }
249}
250
251impl ContainersOverviewState {
252    /// Install a fresh refresh batch. Caller is responsible for spawning
253    /// the initial parallel listings after this call; this method only
254    /// owns the state slot.
255    pub fn start_refresh(&mut self, batch: RefreshBatch) {
256        self.refresh_batch = Some(batch);
257    }
258
259    /// Drop the active refresh batch. Called when the queue drains and
260    /// in-flight count returns to zero.
261    pub fn clear_refresh(&mut self) {
262        self.refresh_batch = None;
263    }
264
265    /// Load the persisted overview fields from `preferences`. Startup only:
266    /// clobbers any in-memory state with whatever is on disk. `pub(crate)`
267    /// so a stray mid-session caller cannot quietly revert user edits.
268    pub(crate) fn hydrate_from_prefs(&mut self) {
269        self.view_mode = crate::preferences::load_containers_view_mode();
270        self.sort_mode = crate::preferences::load_containers_sort_mode();
271        self.collapsed_hosts = crate::preferences::load_containers_collapsed_hosts();
272    }
273
274    /// Update `view_mode` and persist. Returns the persist error so the
275    /// caller can surface it (current call site discards it intentionally
276    /// to match the pre-encapsulation behavior where view-mode persist
277    /// failures only logged).
278    pub fn set_view_mode(&mut self, mode: ViewMode) -> std::io::Result<()> {
279        self.view_mode = mode;
280        crate::preferences::save_containers_view_mode(mode).inspect_err(|e| {
281            log::warn!("[config] Failed to persist containers view mode: {e}");
282        })
283    }
284
285    /// Update `sort_mode` and persist. Same contract as `set_view_mode`;
286    /// the call site does surface the error via a toast.
287    pub fn set_sort_mode(&mut self, mode: ContainersSortMode) -> std::io::Result<()> {
288        self.sort_mode = mode;
289        crate::preferences::save_containers_sort_mode(mode).inspect_err(|e| {
290            log::warn!("[config] Failed to persist containers sort mode: {e}");
291        })
292    }
293
294    /// Rename an alias across every alias-keyed set in this state.
295    /// Returns `true` when `collapsed_hosts` changed so the caller can
296    /// persist; `auto_list_in_flight` and `refresh_batch.in_flight_aliases`
297    /// are also migrated but are not persistent so they do not affect
298    /// the return value. No-op (returns `false`) when `old == new`.
299    pub fn migrate_alias(&mut self, old: &str, new: &str) -> bool {
300        if old == new {
301            return false;
302        }
303        if self.auto_list_in_flight.remove(old) {
304            debug_assert!(
305                !self.auto_list_in_flight.contains(new),
306                "auto_list_in_flight collision on rename {old} -> {new}"
307            );
308            self.auto_list_in_flight.insert(new.to_string());
309        }
310        if let Some(batch) = self.refresh_batch.as_mut() {
311            if batch.in_flight_aliases.remove(old) {
312                debug_assert!(
313                    !batch.in_flight_aliases.contains(new),
314                    "refresh_batch.in_flight_aliases collision on rename {old} -> {new}"
315                );
316                batch.in_flight_aliases.insert(new.to_string());
317            }
318        }
319        if self.collapsed_hosts.remove(old) {
320            debug_assert!(
321                !self.collapsed_hosts.contains(new),
322                "collapsed_hosts collision on rename {old} -> {new}"
323            );
324            self.collapsed_hosts.insert(new.to_string());
325            true
326        } else {
327            false
328        }
329    }
330}
331
332impl InspectCache {
333    /// Returns `Some(entry)` only when the cache holds a *fresh* entry
334    /// for `container_id` (`now - timestamp < TTL`). Stale entries
335    /// behave like absent ones so the trigger code re-fetches.
336    pub fn fresh(&self, container_id: &str, now: u64) -> Option<&InspectCacheEntry> {
337        self.entries
338            .get(container_id)
339            .filter(|e| now.saturating_sub(e.timestamp) < INSPECT_CACHE_TTL_SECS)
340    }
341}
342
343impl LogsCache {
344    /// Same fresh-window contract as `InspectCache::fresh`, against
345    /// `LOGS_CACHE_TTL_SECS`.
346    pub fn fresh(&self, container_id: &str, now: u64) -> Option<&LogsCacheEntry> {
347        self.entries
348            .get(container_id)
349            .filter(|e| now.saturating_sub(e.timestamp) < LOGS_CACHE_TTL_SECS)
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use crate::preferences::tests_helpers::with_temp_prefs;
357
358    fn batch_with_aliases(aliases: &[&str]) -> RefreshBatch {
359        RefreshBatch {
360            queue: VecDeque::new(),
361            in_flight: aliases.len(),
362            total: aliases.len(),
363            completed: 0,
364            in_flight_aliases: aliases.iter().map(|a| a.to_string()).collect(),
365        }
366    }
367
368    #[test]
369    fn start_refresh_installs_batch() {
370        let mut state = ContainersOverviewState::default();
371        assert!(state.refresh_batch.is_none());
372        state.start_refresh(batch_with_aliases(&["host-a", "host-b"]));
373        let batch = state.refresh_batch.as_ref().unwrap();
374        assert_eq!(batch.total, 2);
375        assert_eq!(batch.in_flight, 2);
376        assert!(batch.in_flight_aliases.contains("host-a"));
377    }
378
379    #[test]
380    fn clear_refresh_drops_batch() {
381        let mut state = ContainersOverviewState::default();
382        state.start_refresh(batch_with_aliases(&["host-a"]));
383        state.clear_refresh();
384        assert!(state.refresh_batch.is_none());
385    }
386
387    #[test]
388    fn hydrate_from_prefs_reads_persisted_values() {
389        with_temp_prefs("hydrate_from_prefs", |_path| {
390            crate::preferences::save_containers_view_mode(ViewMode::Compact).unwrap();
391            crate::preferences::save_containers_sort_mode(ContainersSortMode::AlphaContainer)
392                .unwrap();
393            let mut collapsed = std::collections::HashSet::new();
394            collapsed.insert("folded-host".to_string());
395            crate::preferences::save_containers_collapsed_hosts(&collapsed).unwrap();
396
397            let mut state = ContainersOverviewState::default();
398            state.hydrate_from_prefs();
399            assert_eq!(state.view_mode, ViewMode::Compact);
400            assert_eq!(state.sort_mode, ContainersSortMode::AlphaContainer);
401            assert!(state.collapsed_hosts.contains("folded-host"));
402        });
403    }
404
405    #[test]
406    fn set_view_mode_updates_field_and_persists() {
407        with_temp_prefs("set_view_mode", |_path| {
408            let mut state = ContainersOverviewState::default();
409            state.set_view_mode(ViewMode::Compact).unwrap();
410            assert_eq!(state.view_mode, ViewMode::Compact);
411            assert_eq!(
412                crate::preferences::load_containers_view_mode(),
413                ViewMode::Compact
414            );
415        });
416    }
417
418    #[test]
419    fn set_sort_mode_updates_field_and_persists() {
420        with_temp_prefs("set_sort_mode", |_path| {
421            let mut state = ContainersOverviewState::default();
422            state
423                .set_sort_mode(ContainersSortMode::AlphaContainer)
424                .unwrap();
425            assert_eq!(state.sort_mode, ContainersSortMode::AlphaContainer);
426            assert_eq!(
427                crate::preferences::load_containers_sort_mode(),
428                ContainersSortMode::AlphaContainer
429            );
430        });
431    }
432
433    #[test]
434    fn migrate_alias_renames_auto_list_in_flight() {
435        let mut state = ContainersOverviewState::default();
436        state.auto_list_in_flight.insert("old".to_string());
437        state.migrate_alias("old", "new");
438        assert!(state.auto_list_in_flight.contains("new"));
439        assert!(!state.auto_list_in_flight.contains("old"));
440    }
441
442    #[test]
443    fn migrate_alias_renames_refresh_batch_in_flight() {
444        let mut state = ContainersOverviewState::default();
445        state.start_refresh(batch_with_aliases(&["old"]));
446        // Non-persistent change: collapsed_hosts is untouched so the
447        // return value must be false even though the in-flight set
448        // was migrated. Pins the contract that drives the persist
449        // call in app::hosts::migrate_alias_keyed_caches.
450        assert!(!state.migrate_alias("old", "new"));
451        let batch = state.refresh_batch.as_ref().unwrap();
452        assert!(batch.in_flight_aliases.contains("new"));
453        assert!(!batch.in_flight_aliases.contains("old"));
454    }
455
456    #[test]
457    fn migrate_alias_self_rename_is_noop() {
458        let mut state = ContainersOverviewState::default();
459        state.collapsed_hosts.insert("same".to_string());
460        state.auto_list_in_flight.insert("same".to_string());
461        assert!(!state.migrate_alias("same", "same"));
462        assert!(state.collapsed_hosts.contains("same"));
463        assert!(state.auto_list_in_flight.contains("same"));
464    }
465
466    #[test]
467    fn migrate_alias_renames_collapsed_hosts_and_returns_true() {
468        let mut state = ContainersOverviewState::default();
469        state.collapsed_hosts.insert("old".to_string());
470        assert!(state.migrate_alias("old", "new"));
471        assert!(state.collapsed_hosts.contains("new"));
472        assert!(!state.collapsed_hosts.contains("old"));
473    }
474
475    #[test]
476    fn migrate_alias_returns_false_when_collapsed_unchanged() {
477        let mut state = ContainersOverviewState::default();
478        state.auto_list_in_flight.insert("old".to_string());
479        assert!(!state.migrate_alias("old", "new"));
480        assert!(state.auto_list_in_flight.contains("new"));
481    }
482
483    #[test]
484    fn migrate_alias_is_noop_when_nothing_matches() {
485        let mut state = ContainersOverviewState::default();
486        assert!(!state.migrate_alias("missing", "new"));
487    }
488}