1use crate::app::{ContainerActionRequest, ContainerExecRequest, ContainerLogsRequest};
4
5pub 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 pub confirm_action: Option<(crate::containers::ContainerAction, String, String)>,
20}
21
22#[derive(Debug, Default)]
26pub struct LogsView {
27 pub alias: String,
28 pub container_id: String,
29 pub container_name: String,
30 pub body: Vec<String>,
33 pub fetched_at: u64,
34 pub error: Option<String>,
35 pub scroll: u16,
36 pub last_render_height: u16,
40 pub search: Option<crate::app::ContainerLogsSearch>,
42}
43
44#[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 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 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 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 pub fn queue_action(&mut self, req: ContainerActionRequest) {
183 self.pending_actions.push_back(req);
184 }
185
186 pub fn queue_fetch(&mut self, alias: String) {
189 self.pending_fetch_aliases.push(alias);
190 }
191
192 pub fn drain_pending_fetches(&mut self) -> Vec<String> {
194 std::mem::take(&mut self.pending_fetch_aliases)
195 }
196
197 pub fn logs_view(&self) -> Option<&LogsView> {
200 self.logs_view.as_ref()
201 }
202
203 pub fn logs_view_mut(&mut self) -> Option<&mut LogsView> {
205 self.logs_view.as_mut()
206 }
207
208 pub fn set_logs_view(&mut self, view: LogsView) {
211 self.logs_view = Some(view);
212 }
213
214 pub fn clear_logs_view(&mut self) {
216 self.logs_view = None;
217 }
218
219 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 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 pub fn migrate_alias(&mut self, old: &str, new: &str) -> bool {
250 if old == new {
251 return false;
252 }
253 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}