Skip to main content

purple_ssh/app/
container_state.rs

1//! Containers overlay state.
2
3use crate::app::{ContainerActionRequest, ContainerExecRequest, ContainerLogsRequest};
4
5/// Per-host overlay session state; only valid while the containers overlay is open.
6///
7/// No `Default` impl: construction always requires an alias and runtime
8/// metadata, so a default-constructed value would be meaningless.
9pub struct ContainerSession {
10    pub alias: String,
11    pub askpass: Option<String>,
12    pub runtime: Option<crate::containers::ContainerRuntime>,
13    pub containers: Vec<crate::containers::ContainerInfo>,
14    pub list_state: ratatui::widgets::ListState,
15    pub loading: bool,
16    pub error: Option<String>,
17    pub action_in_progress: Option<String>,
18    /// Pending confirmation for stop/restart actions: (action, container_name, container_id).
19    pub confirm_action: Option<(crate::containers::ContainerAction, String, String)>,
20}
21
22/// Open container logs viewer. The `Screen::ContainerLogs` variant is
23/// data-less; the alias/container identity and the streaming body live
24/// here so screen transitions never clone the body Vec.
25#[derive(Debug, Default)]
26pub struct LogsView {
27    pub alias: String,
28    pub container_id: String,
29    pub container_name: String,
30    /// Rendered lines fetched via SSH `docker logs --tail`. Empty while
31    /// the request is in flight, populated once the result lands.
32    pub body: Vec<String>,
33    pub fetched_at: u64,
34    pub error: Option<String>,
35    pub scroll: u16,
36    /// Written by the renderer each frame so `G` and the result-arrival
37    /// path can compute the tail-anchored scroll without guessing the
38    /// visible-area size.
39    pub last_render_height: u16,
40    /// `/` search state. `None` when no search is active.
41    pub search: Option<crate::app::ContainerLogsSearch>,
42}
43
44/// Always-present container-domain state: cache and cross-host pending operations.
45/// Separate from `ContainerSession`, which is the per-host overlay session state.
46#[derive(Debug, Default)]
47pub struct ContainerState {
48    pub(in crate::app) pending_exec: Option<ContainerExecRequest>,
49    pub(in crate::app) pending_logs: Option<ContainerLogsRequest>,
50    pub(in crate::app) pending_actions: std::collections::VecDeque<ContainerActionRequest>,
51    pub(in crate::app) pending_fetch_aliases: Vec<String>,
52    pub(in crate::app) cache:
53        std::collections::HashMap<String, crate::containers::ContainerCacheEntry>,
54    /// Open `Screen::ContainerLogs` overlay payload, `None` when no
55    /// logs overlay is open.
56    pub(in crate::app) logs_view: Option<LogsView>,
57}
58
59impl ContainerState {
60    pub fn cache(
61        &self,
62    ) -> &std::collections::HashMap<String, crate::containers::ContainerCacheEntry> {
63        &self.cache
64    }
65
66    pub fn set_cache(
67        &mut self,
68        cache: std::collections::HashMap<String, crate::containers::ContainerCacheEntry>,
69    ) {
70        self.cache = cache;
71    }
72
73    pub fn cache_entry(&self, alias: &str) -> Option<&crate::containers::ContainerCacheEntry> {
74        self.cache.get(alias)
75    }
76
77    pub fn cache_entry_mut(
78        &mut self,
79        alias: &str,
80    ) -> Option<&mut crate::containers::ContainerCacheEntry> {
81        self.cache.get_mut(alias)
82    }
83
84    pub fn cache_contains(&self, alias: &str) -> bool {
85        self.cache.contains_key(alias)
86    }
87
88    pub fn cache_len(&self) -> usize {
89        self.cache.len()
90    }
91
92    pub fn insert_cache_entry(
93        &mut self,
94        alias: String,
95        entry: crate::containers::ContainerCacheEntry,
96    ) {
97        self.cache.insert(alias, entry);
98    }
99
100    pub fn remove_cache_entry(&mut self, alias: &str) {
101        self.cache.remove(alias);
102    }
103
104    pub fn clear_cache(&mut self) {
105        self.cache.clear();
106    }
107
108    pub fn pending_exec_request(&self) -> Option<&ContainerExecRequest> {
109        self.pending_exec.as_ref()
110    }
111
112    pub fn pending_logs_request(&self) -> Option<&ContainerLogsRequest> {
113        self.pending_logs.as_ref()
114    }
115
116    pub fn has_pending_fetches(&self) -> bool {
117        !self.pending_fetch_aliases.is_empty()
118    }
119
120    pub fn pending_actions_len(&self) -> usize {
121        self.pending_actions.len()
122    }
123
124    pub fn take_pending_exec(&mut self) -> Option<ContainerExecRequest> {
125        self.pending_exec.take()
126    }
127
128    pub fn take_pending_logs(&mut self) -> Option<ContainerLogsRequest> {
129        self.pending_logs.take()
130    }
131
132    pub fn pop_next_action(&mut self) -> Option<ContainerActionRequest> {
133        self.pending_actions.pop_front()
134    }
135
136    pub fn pending_actions_iter(&self) -> impl Iterator<Item = &ContainerActionRequest> {
137        self.pending_actions.iter()
138    }
139
140    pub fn pending_actions_at(&self, idx: usize) -> Option<&ContainerActionRequest> {
141        self.pending_actions.get(idx)
142    }
143
144    pub fn pending_fetch_aliases(&self) -> &[String] {
145        &self.pending_fetch_aliases
146    }
147
148    pub fn extend_pending_fetches<I: IntoIterator<Item = String>>(&mut self, iter: I) {
149        self.pending_fetch_aliases.extend(iter);
150    }
151
152    /// Queue a logs request for the main loop to drain. Replaces any
153    /// previous pending logs request and logs the displaced alias so a
154    /// dropped request is traceable.
155    pub fn queue_logs(&mut self, req: ContainerLogsRequest) {
156        if let Some(prev) = self.pending_logs.as_ref() {
157            log::debug!(
158                "[purple] queue_logs replaced pending request for alias={} id={}",
159                prev.alias,
160                prev.container_id,
161            );
162        }
163        self.pending_logs = Some(req);
164    }
165
166    /// Queue an exec request for the main loop to drain. Same replace
167    /// and log semantics as `queue_logs`.
168    pub fn queue_exec(&mut self, req: ContainerExecRequest) {
169        if let Some(prev) = self.pending_exec.as_ref() {
170            log::debug!(
171                "[purple] queue_exec replaced pending request for alias={} id={}",
172                prev.alias,
173                prev.container_id,
174            );
175        }
176        self.pending_exec = Some(req);
177    }
178
179    /// Queue a non-interactive container action for the worker thread.
180    /// Actions are FIFO via `VecDeque::push_back`; multiple actions
181    /// against the same alias process in order.
182    pub fn queue_action(&mut self, req: ContainerActionRequest) {
183        self.pending_actions.push_back(req);
184    }
185
186    /// Enqueue an alias for the initial container-cache fetch. Drained by
187    /// the main loop on the next tick via `drain_pending_fetches`.
188    pub fn queue_fetch(&mut self, alias: String) {
189        self.pending_fetch_aliases.push(alias);
190    }
191
192    /// Take the full fetch queue, leaving it empty.
193    pub fn drain_pending_fetches(&mut self) -> Vec<String> {
194        std::mem::take(&mut self.pending_fetch_aliases)
195    }
196
197    /// Read the active container-logs overlay payload (`None` when no
198    /// logs overlay is open).
199    pub fn logs_view(&self) -> Option<&LogsView> {
200        self.logs_view.as_ref()
201    }
202
203    /// Mutable read for the active container-logs overlay payload.
204    pub fn logs_view_mut(&mut self) -> Option<&mut LogsView> {
205        self.logs_view.as_mut()
206    }
207
208    /// Install a fresh logs-view payload. Caller is responsible for
209    /// transitioning the screen.
210    pub fn set_logs_view(&mut self, view: LogsView) {
211        self.logs_view = Some(view);
212    }
213
214    /// Drop the logs-view payload. Called on overlay close.
215    pub fn clear_logs_view(&mut self) {
216        self.logs_view = None;
217    }
218
219    /// Drop cache entries whose host alias is no longer in
220    /// `valid_aliases`. Returns `true` when anything was dropped so the
221    /// caller can persist the trimmed cache via
222    /// `containers::save_container_cache`. The cache is also the source
223    /// of valid container IDs for downstream inspect/logs pruning.
224    pub fn prune_orphans(&mut self, valid_aliases: &std::collections::HashSet<&str>) -> bool {
225        let pre = self.cache.len();
226        self.cache
227            .retain(|alias, _| valid_aliases.contains(alias.as_str()));
228        let dropped = pre.saturating_sub(self.cache.len());
229        // Logs overlay targeting a deleted host is no longer valid;
230        // dropping the view also frees the body Vec. The matching
231        // handler restores the screen to HostList on the next key
232        // (`container_logs_key` checks for a missing view).
233        if let Some(view) = self.logs_view.as_ref() {
234            if !valid_aliases.contains(view.alias.as_str()) {
235                self.logs_view = None;
236            }
237        }
238        if dropped > 0 {
239            log::debug!("[purple] reload_hosts: dropped {dropped} orphan container_cache host(s)");
240            true
241        } else {
242            false
243        }
244    }
245
246    /// Move a cache entry from `old` to `new` on host rename. Returns
247    /// `true` when the cache changed so the caller can persist. No-op
248    /// (returns `false`) when `old == new` or no entry exists under `old`.
249    pub fn migrate_alias(&mut self, old: &str, new: &str) -> bool {
250        if old == new {
251            return false;
252        }
253        // Open logs overlay: rename its alias too so a refresh queued
254        // after the rename does not run against the stale name.
255        if let Some(view) = self.logs_view.as_mut() {
256            if view.alias == old {
257                view.alias = new.to_string();
258            }
259        }
260        if let Some(v) = self.cache.remove(old) {
261            debug_assert!(
262                !self.cache.contains_key(new),
263                "container_state.cache collision on rename {old} -> {new}"
264            );
265            self.cache.insert(new.to_string(), v);
266            true
267        } else {
268            false
269        }
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::containers::{ContainerCacheEntry, ContainerRuntime};
277
278    fn make_logs_request(alias: &str) -> ContainerLogsRequest {
279        ContainerLogsRequest {
280            alias: alias.to_string(),
281            askpass: None,
282            runtime: ContainerRuntime::Docker,
283            container_id: "abc123".to_string(),
284            container_name: "nginx".to_string(),
285        }
286    }
287
288    fn make_cache_entry() -> ContainerCacheEntry {
289        ContainerCacheEntry {
290            timestamp: 1700000000,
291            runtime: ContainerRuntime::Docker,
292            engine_version: Some("28.0.0".to_string()),
293            containers: vec![],
294        }
295    }
296
297    #[test]
298    fn queue_logs_sets_pending() {
299        let mut state = ContainerState::default();
300        assert!(state.pending_logs.is_none());
301        state.queue_logs(make_logs_request("host-a"));
302        assert!(state.pending_logs.is_some());
303        assert_eq!(state.pending_logs.as_ref().unwrap().alias, "host-a");
304    }
305
306    #[test]
307    fn queue_logs_replaces_previous() {
308        let mut state = ContainerState::default();
309        state.queue_logs(make_logs_request("host-a"));
310        state.queue_logs(make_logs_request("host-b"));
311        assert_eq!(state.pending_logs.as_ref().unwrap().alias, "host-b");
312    }
313
314    #[test]
315    fn queue_exec_sets_pending() {
316        let mut state = ContainerState::default();
317        assert!(state.pending_exec.is_none());
318        state.queue_exec(ContainerExecRequest {
319            alias: "host-a".to_string(),
320            askpass: None,
321            runtime: ContainerRuntime::Docker,
322            container_id: "abc".to_string(),
323            container_name: "nginx".to_string(),
324            command: Some("echo hi".to_string()),
325        });
326        assert!(state.pending_exec.is_some());
327        assert_eq!(state.pending_exec.as_ref().unwrap().alias, "host-a");
328    }
329
330    #[test]
331    fn queue_fetch_pushes_alias() {
332        let mut state = ContainerState::default();
333        state.queue_fetch("host-a".to_string());
334        state.queue_fetch("host-b".to_string());
335        assert_eq!(state.pending_fetch_aliases, vec!["host-a", "host-b"]);
336    }
337
338    #[test]
339    fn drain_pending_fetches_returns_and_clears() {
340        let mut state = ContainerState::default();
341        state.queue_fetch("host-a".to_string());
342        state.queue_fetch("host-b".to_string());
343        let drained = state.drain_pending_fetches();
344        assert_eq!(drained, vec!["host-a", "host-b"]);
345        assert!(state.pending_fetch_aliases.is_empty());
346    }
347
348    #[test]
349    fn drain_pending_fetches_empty_when_no_aliases() {
350        let mut state = ContainerState::default();
351        let drained = state.drain_pending_fetches();
352        assert!(drained.is_empty());
353        assert!(state.pending_fetch_aliases.is_empty());
354    }
355
356    #[test]
357    fn migrate_alias_renames_cache_entry() {
358        let mut state = ContainerState::default();
359        state.cache.insert("old".to_string(), make_cache_entry());
360        assert!(state.migrate_alias("old", "new"));
361        assert!(state.cache.contains_key("new"));
362        assert!(!state.cache.contains_key("old"));
363    }
364
365    #[test]
366    fn migrate_alias_returns_false_when_no_entry() {
367        let mut state = ContainerState::default();
368        assert!(!state.migrate_alias("missing", "new"));
369        assert!(state.cache.is_empty());
370    }
371
372    #[test]
373    fn migrate_alias_self_rename_is_noop() {
374        let mut state = ContainerState::default();
375        state.cache.insert("same".to_string(), make_cache_entry());
376        assert!(!state.migrate_alias("same", "same"));
377        assert!(state.cache.contains_key("same"));
378    }
379
380    #[test]
381    fn queue_action_pushes_back_in_order() {
382        let mut state = ContainerState::default();
383        for id in ["a", "b", "c"] {
384            state.queue_action(ContainerActionRequest {
385                alias: "host".to_string(),
386                askpass: None,
387                runtime: ContainerRuntime::Docker,
388                container_id: id.to_string(),
389                container_name: id.to_string(),
390                action: crate::containers::ContainerAction::Restart,
391            });
392        }
393        assert_eq!(state.pending_actions.len(), 3);
394        let ids: Vec<String> = state
395            .pending_actions
396            .iter()
397            .map(|r| r.container_id.clone())
398            .collect();
399        assert_eq!(ids, vec!["a", "b", "c"]);
400    }
401
402    #[test]
403    fn prune_orphans_drops_unknown_aliases_and_signals_persist() {
404        let mut state = ContainerState::default();
405        state.cache.insert("keep".to_string(), make_cache_entry());
406        state.cache.insert("drop".to_string(), make_cache_entry());
407
408        let valid: std::collections::HashSet<&str> = ["keep"].into_iter().collect();
409        let changed = state.prune_orphans(&valid);
410
411        assert!(changed, "returns true so caller persists the trimmed cache");
412        assert!(state.cache.contains_key("keep"));
413        assert!(!state.cache.contains_key("drop"));
414    }
415
416    #[test]
417    fn prune_orphans_returns_false_when_nothing_dropped() {
418        let mut state = ContainerState::default();
419        state.cache.insert("keep".to_string(), make_cache_entry());
420
421        let valid: std::collections::HashSet<&str> = ["keep"].into_iter().collect();
422        assert!(!state.prune_orphans(&valid));
423    }
424}