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 InspectCache {
252 /// Returns `Some(entry)` only when the cache holds a *fresh* entry
253 /// for `container_id` (`now - timestamp < TTL`). Stale entries
254 /// behave like absent ones so the trigger code re-fetches.
255 pub fn fresh(&self, container_id: &str, now: u64) -> Option<&InspectCacheEntry> {
256 self.entries
257 .get(container_id)
258 .filter(|e| now.saturating_sub(e.timestamp) < INSPECT_CACHE_TTL_SECS)
259 }
260}
261
262impl LogsCache {
263 /// Same fresh-window contract as `InspectCache::fresh`, against
264 /// `LOGS_CACHE_TTL_SECS`.
265 pub fn fresh(&self, container_id: &str, now: u64) -> Option<&LogsCacheEntry> {
266 self.entries
267 .get(container_id)
268 .filter(|e| now.saturating_sub(e.timestamp) < LOGS_CACHE_TTL_SECS)
269 }
270}