use std::collections::{HashMap, HashSet, VecDeque};
use super::host_state::ViewMode;
use crate::containers::{ContainerInspect, ContainerRuntime};
#[derive(Debug, Clone)]
pub struct RefreshQueueItem {
pub alias: String,
pub askpass: Option<String>,
pub cached_runtime: Option<ContainerRuntime>,
pub has_tunnel: bool,
}
#[derive(Debug, Default)]
pub struct RefreshBatch {
pub queue: VecDeque<RefreshQueueItem>,
pub in_flight: usize,
pub total: usize,
pub completed: usize,
pub in_flight_aliases: HashSet<String>,
}
pub const REFRESH_MAX_PARALLEL: usize = 4;
#[derive(Debug, Clone)]
pub struct ContainerExecRequest {
pub alias: String,
pub askpass: Option<String>,
pub runtime: ContainerRuntime,
pub container_id: String,
pub container_name: String,
pub command: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ContainerLogsRequest {
pub alias: String,
pub askpass: Option<String>,
pub runtime: ContainerRuntime,
pub container_id: String,
pub container_name: String,
}
#[derive(Debug, Clone)]
pub struct ContainerActionRequest {
pub alias: String,
pub askpass: Option<String>,
pub runtime: ContainerRuntime,
pub container_id: String,
pub container_name: String,
pub action: crate::containers::ContainerAction,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ContainersSortMode {
#[default]
AlphaHost,
AlphaContainer,
}
impl ContainersSortMode {
pub fn next(self) -> Self {
match self {
ContainersSortMode::AlphaHost => ContainersSortMode::AlphaContainer,
ContainersSortMode::AlphaContainer => ContainersSortMode::AlphaHost,
}
}
pub fn label(self) -> &'static str {
match self {
ContainersSortMode::AlphaHost => "A-Z host",
ContainersSortMode::AlphaContainer => "A-Z container",
}
}
pub fn to_key(self) -> &'static str {
match self {
ContainersSortMode::AlphaHost => "alpha_host",
ContainersSortMode::AlphaContainer => "alpha_container",
}
}
pub fn from_key(s: &str) -> Self {
match s {
"alpha_container" => ContainersSortMode::AlphaContainer,
_ => ContainersSortMode::AlphaHost,
}
}
}
#[derive(Debug, Clone)]
pub struct InspectCacheEntry {
pub timestamp: u64,
pub result: Result<ContainerInspect, String>,
}
#[derive(Debug, Default)]
pub struct InspectCache {
pub entries: HashMap<String, InspectCacheEntry>,
pub in_flight: HashSet<String>,
}
#[derive(Debug, Clone)]
pub struct LogsCacheEntry {
pub timestamp: u64,
pub result: Result<Vec<String>, String>,
}
#[derive(Debug, Default)]
pub struct LogsCache {
pub entries: HashMap<String, LogsCacheEntry>,
pub in_flight: HashSet<String>,
}
pub const INSPECT_CACHE_TTL_SECS: u64 = 30;
pub const LOGS_CACHE_TTL_SECS: u64 = 30;
pub const LOGS_TAIL: usize = 50;
pub const LIST_CACHE_TTL_SECS: u64 = 30;
#[derive(Debug)]
pub struct ContainersOverviewState {
pub(in crate::app) sort_mode: ContainersSortMode,
pub(in crate::app) inspect_cache: InspectCache,
pub(in crate::app) logs_cache: LogsCache,
pub(in crate::app) refresh_batch: Option<RefreshBatch>,
pub(in crate::app) auto_list_in_flight: HashSet<String>,
pub(in crate::app) view_mode: ViewMode,
pub(in crate::app) collapsed_hosts: HashSet<String>,
pub(in crate::app) view_cache:
std::cell::RefCell<Option<(u64, Vec<crate::ui::containers_overview::ContainerListItem>)>>,
}
impl Default for ContainersOverviewState {
fn default() -> Self {
Self {
sort_mode: ContainersSortMode::default(),
inspect_cache: InspectCache::default(),
logs_cache: LogsCache::default(),
refresh_batch: None,
auto_list_in_flight: HashSet::new(),
view_mode: ViewMode::Detailed,
collapsed_hosts: HashSet::new(),
view_cache: std::cell::RefCell::new(None),
}
}
}
impl ContainersOverviewState {
pub fn start_refresh(&mut self, batch: RefreshBatch) {
self.refresh_batch = Some(batch);
}
pub fn clear_refresh(&mut self) {
self.refresh_batch = None;
}
pub fn sort_mode(&self) -> ContainersSortMode {
self.sort_mode
}
pub fn set_sort_mode_ephemeral(&mut self, mode: ContainersSortMode) {
self.sort_mode = mode;
}
pub fn view_mode(&self) -> ViewMode {
self.view_mode
}
pub fn set_view_mode_ephemeral(&mut self, mode: ViewMode) {
self.view_mode = mode;
}
pub fn collapsed_hosts(&self) -> &HashSet<String> {
&self.collapsed_hosts
}
pub fn toggle_host_collapsed(&mut self, alias: &str) -> bool {
if self.collapsed_hosts.remove(alias) {
false
} else {
self.collapsed_hosts.insert(alias.to_string());
true
}
}
pub fn refresh_batch(&self) -> Option<&RefreshBatch> {
self.refresh_batch.as_ref()
}
pub fn refresh_batch_mut(&mut self) -> Option<&mut RefreshBatch> {
self.refresh_batch.as_mut()
}
pub fn auto_list_in_flight(&self) -> &HashSet<String> {
&self.auto_list_in_flight
}
pub fn auto_list_pending(&self, alias: &str) -> bool {
self.auto_list_in_flight.contains(alias)
}
pub fn mark_auto_list_pending(&mut self, alias: String) {
self.auto_list_in_flight.insert(alias);
}
pub fn clear_auto_list_pending(&mut self, alias: &str) {
self.auto_list_in_flight.remove(alias);
}
pub fn inspect_cache(&self) -> &InspectCache {
&self.inspect_cache
}
pub fn inspect_cache_mut(&mut self) -> &mut InspectCache {
&mut self.inspect_cache
}
pub fn logs_cache(&self) -> &LogsCache {
&self.logs_cache
}
pub fn logs_cache_mut(&mut self) -> &mut LogsCache {
&mut self.logs_cache
}
pub fn view_cache(
&self,
) -> &std::cell::RefCell<Option<(u64, Vec<crate::ui::containers_overview::ContainerListItem>)>>
{
&self.view_cache
}
pub(crate) fn hydrate_from_prefs(&mut self, paths: Option<&crate::runtime::env::Paths>) {
self.view_mode = crate::preferences::load_containers_view_mode(paths);
self.sort_mode = crate::preferences::load_containers_sort_mode(paths);
self.collapsed_hosts = crate::preferences::load_containers_collapsed_hosts(paths);
}
pub fn set_view_mode(
&mut self,
paths: Option<&crate::runtime::env::Paths>,
mode: ViewMode,
) -> std::io::Result<()> {
self.view_mode = mode;
crate::preferences::save_containers_view_mode(paths, mode).inspect_err(|e| {
log::warn!("[config] Failed to persist containers view mode: {e}");
})
}
pub fn set_sort_mode(
&mut self,
paths: Option<&crate::runtime::env::Paths>,
mode: ContainersSortMode,
) -> std::io::Result<()> {
self.sort_mode = mode;
crate::preferences::save_containers_sort_mode(paths, mode).inspect_err(|e| {
log::warn!("[config] Failed to persist containers sort mode: {e}");
})
}
pub fn migrate_alias(&mut self, old: &str, new: &str) -> bool {
if old == new {
return false;
}
if self.auto_list_in_flight.remove(old) {
debug_assert!(
!self.auto_list_in_flight.contains(new),
"auto_list_in_flight collision on rename {old} -> {new}"
);
self.auto_list_in_flight.insert(new.to_string());
}
if let Some(batch) = self.refresh_batch.as_mut() {
if batch.in_flight_aliases.remove(old) {
debug_assert!(
!batch.in_flight_aliases.contains(new),
"refresh_batch.in_flight_aliases collision on rename {old} -> {new}"
);
batch.in_flight_aliases.insert(new.to_string());
}
}
if self.collapsed_hosts.remove(old) {
debug_assert!(
!self.collapsed_hosts.contains(new),
"collapsed_hosts collision on rename {old} -> {new}"
);
self.collapsed_hosts.insert(new.to_string());
true
} else {
false
}
}
pub fn prune_by_container_ids(&mut self, valid_container_ids: &HashSet<String>) {
let pre_inspect = self.inspect_cache.entries.len();
self.inspect_cache
.entries
.retain(|id, _| valid_container_ids.contains(id));
self.inspect_cache
.in_flight
.retain(|id| valid_container_ids.contains(id));
self.logs_cache
.entries
.retain(|id, _| valid_container_ids.contains(id));
self.logs_cache
.in_flight
.retain(|id| valid_container_ids.contains(id));
let dropped = pre_inspect.saturating_sub(self.inspect_cache.entries.len());
if dropped > 0 {
log::debug!("[purple] reload_hosts: dropped {dropped} orphan inspect_cache entrie(s)");
}
}
pub fn prune_orphans(&mut self, valid_aliases: &HashSet<&str>) -> bool {
self.auto_list_in_flight
.retain(|alias| valid_aliases.contains(alias.as_str()));
if let Some(batch) = self.refresh_batch.as_mut() {
let pre = batch.in_flight_aliases.len();
batch
.in_flight_aliases
.retain(|alias| valid_aliases.contains(alias.as_str()));
let dropped = pre.saturating_sub(batch.in_flight_aliases.len());
if dropped > 0 {
log::debug!(
"[purple] reload_hosts: dropped {dropped} orphan refresh_batch in_flight alias(es)"
);
}
}
let pre_collapsed = self.collapsed_hosts.len();
self.collapsed_hosts
.retain(|alias| valid_aliases.contains(alias.as_str()));
let dropped_collapsed = pre_collapsed.saturating_sub(self.collapsed_hosts.len());
if dropped_collapsed > 0 {
log::debug!(
"[purple] reload_hosts: dropped {dropped_collapsed} orphan collapsed_hosts entrie(s)"
);
true
} else {
false
}
}
}
impl InspectCache {
pub fn fresh(&self, container_id: &str, now: u64) -> Option<&InspectCacheEntry> {
self.entries
.get(container_id)
.filter(|e| now.saturating_sub(e.timestamp) < INSPECT_CACHE_TTL_SECS)
}
}
impl LogsCache {
pub fn fresh(&self, container_id: &str, now: u64) -> Option<&LogsCacheEntry> {
self.entries
.get(container_id)
.filter(|e| now.saturating_sub(e.timestamp) < LOGS_CACHE_TTL_SECS)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::env::Paths;
fn batch_with_aliases(aliases: &[&str]) -> RefreshBatch {
RefreshBatch {
queue: VecDeque::new(),
in_flight: aliases.len(),
total: aliases.len(),
completed: 0,
in_flight_aliases: aliases.iter().map(|a| a.to_string()).collect(),
}
}
#[test]
fn start_refresh_installs_batch() {
let mut state = ContainersOverviewState::default();
assert!(state.refresh_batch.is_none());
state.start_refresh(batch_with_aliases(&["host-a", "host-b"]));
let batch = state.refresh_batch.as_ref().unwrap();
assert_eq!(batch.total, 2);
assert_eq!(batch.in_flight, 2);
assert!(batch.in_flight_aliases.contains("host-a"));
}
#[test]
fn clear_refresh_drops_batch() {
let mut state = ContainersOverviewState::default();
state.start_refresh(batch_with_aliases(&["host-a"]));
state.clear_refresh();
assert!(state.refresh_batch.is_none());
}
#[test]
fn hydrate_from_prefs_reads_persisted_values() {
let dir = tempfile::tempdir().unwrap();
let paths = Paths::new(dir.path());
crate::preferences::save_containers_view_mode(Some(&paths), ViewMode::Compact).unwrap();
crate::preferences::save_containers_sort_mode(
Some(&paths),
ContainersSortMode::AlphaContainer,
)
.unwrap();
let mut collapsed = std::collections::HashSet::new();
collapsed.insert("folded-host".to_string());
crate::preferences::save_containers_collapsed_hosts(Some(&paths), &collapsed).unwrap();
let mut state = ContainersOverviewState::default();
state.hydrate_from_prefs(Some(&paths));
assert_eq!(state.view_mode, ViewMode::Compact);
assert_eq!(state.sort_mode, ContainersSortMode::AlphaContainer);
assert!(state.collapsed_hosts.contains("folded-host"));
}
#[test]
fn set_view_mode_updates_field_and_persists() {
let dir = tempfile::tempdir().unwrap();
let paths = Paths::new(dir.path());
let mut state = ContainersOverviewState::default();
state
.set_view_mode(Some(&paths), ViewMode::Compact)
.unwrap();
assert_eq!(state.view_mode, ViewMode::Compact);
assert_eq!(
crate::preferences::load_containers_view_mode(Some(&paths)),
ViewMode::Compact
);
}
#[test]
fn set_sort_mode_updates_field_and_persists() {
let dir = tempfile::tempdir().unwrap();
let paths = Paths::new(dir.path());
let mut state = ContainersOverviewState::default();
state
.set_sort_mode(Some(&paths), ContainersSortMode::AlphaContainer)
.unwrap();
assert_eq!(state.sort_mode, ContainersSortMode::AlphaContainer);
assert_eq!(
crate::preferences::load_containers_sort_mode(Some(&paths)),
ContainersSortMode::AlphaContainer
);
}
#[test]
fn migrate_alias_renames_auto_list_in_flight() {
let mut state = ContainersOverviewState::default();
state.auto_list_in_flight.insert("old".to_string());
state.migrate_alias("old", "new");
assert!(state.auto_list_in_flight.contains("new"));
assert!(!state.auto_list_in_flight.contains("old"));
}
#[test]
fn migrate_alias_renames_refresh_batch_in_flight() {
let mut state = ContainersOverviewState::default();
state.start_refresh(batch_with_aliases(&["old"]));
assert!(!state.migrate_alias("old", "new"));
let batch = state.refresh_batch.as_ref().unwrap();
assert!(batch.in_flight_aliases.contains("new"));
assert!(!batch.in_flight_aliases.contains("old"));
}
#[test]
fn migrate_alias_self_rename_is_noop() {
let mut state = ContainersOverviewState::default();
state.collapsed_hosts.insert("same".to_string());
state.auto_list_in_flight.insert("same".to_string());
assert!(!state.migrate_alias("same", "same"));
assert!(state.collapsed_hosts.contains("same"));
assert!(state.auto_list_in_flight.contains("same"));
}
#[test]
fn migrate_alias_renames_collapsed_hosts_and_returns_true() {
let mut state = ContainersOverviewState::default();
state.collapsed_hosts.insert("old".to_string());
assert!(state.migrate_alias("old", "new"));
assert!(state.collapsed_hosts.contains("new"));
assert!(!state.collapsed_hosts.contains("old"));
}
#[test]
fn migrate_alias_returns_false_when_collapsed_unchanged() {
let mut state = ContainersOverviewState::default();
state.auto_list_in_flight.insert("old".to_string());
assert!(!state.migrate_alias("old", "new"));
assert!(state.auto_list_in_flight.contains("new"));
}
#[test]
fn migrate_alias_is_noop_when_nothing_matches() {
let mut state = ContainersOverviewState::default();
assert!(!state.migrate_alias("missing", "new"));
}
#[test]
fn prune_by_container_ids_drops_unknown_id_from_in_flight_sets() {
let mut state = ContainersOverviewState::default();
state.inspect_cache.in_flight.insert("id-keep".to_string());
state.inspect_cache.in_flight.insert("id-drop".to_string());
state.logs_cache.in_flight.insert("id-keep".to_string());
state.logs_cache.in_flight.insert("id-drop".to_string());
let valid: HashSet<String> = ["id-keep".to_string()].into_iter().collect();
state.prune_by_container_ids(&valid);
assert!(state.inspect_cache.in_flight.contains("id-keep"));
assert!(!state.inspect_cache.in_flight.contains("id-drop"));
assert!(state.logs_cache.in_flight.contains("id-keep"));
assert!(!state.logs_cache.in_flight.contains("id-drop"));
}
#[test]
fn prune_by_container_ids_drops_unknown_id_from_entries_maps() {
let mut state = ContainersOverviewState::default();
state.inspect_cache.entries.insert(
"id-keep".to_string(),
InspectCacheEntry {
timestamp: 0,
result: Err("placeholder".to_string()),
},
);
state.inspect_cache.entries.insert(
"id-drop".to_string(),
InspectCacheEntry {
timestamp: 0,
result: Err("placeholder".to_string()),
},
);
state.logs_cache.entries.insert(
"id-keep".to_string(),
LogsCacheEntry {
timestamp: 0,
result: Ok(vec!["line".to_string()]),
},
);
state.logs_cache.entries.insert(
"id-drop".to_string(),
LogsCacheEntry {
timestamp: 0,
result: Ok(vec!["line".to_string()]),
},
);
let valid: HashSet<String> = ["id-keep".to_string()].into_iter().collect();
state.prune_by_container_ids(&valid);
assert!(state.inspect_cache.entries.contains_key("id-keep"));
assert!(!state.inspect_cache.entries.contains_key("id-drop"));
assert!(state.logs_cache.entries.contains_key("id-keep"));
assert!(!state.logs_cache.entries.contains_key("id-drop"));
}
#[test]
fn prune_orphans_drops_unknown_and_signals_collapsed_change() {
let mut state = ContainersOverviewState::default();
state.auto_list_in_flight.insert("keep".to_string());
state.auto_list_in_flight.insert("drop".to_string());
state.collapsed_hosts.insert("keep".to_string());
state.collapsed_hosts.insert("drop".to_string());
let valid: HashSet<&str> = ["keep"].into_iter().collect();
let collapsed_changed = state.prune_orphans(&valid);
assert!(
collapsed_changed,
"returns true so caller persists collapsed_hosts"
);
assert!(state.auto_list_in_flight.contains("keep"));
assert!(!state.auto_list_in_flight.contains("drop"));
assert!(state.collapsed_hosts.contains("keep"));
assert!(!state.collapsed_hosts.contains("drop"));
}
#[test]
fn prune_orphans_returns_false_when_collapsed_unchanged() {
let mut state = ContainersOverviewState::default();
state.auto_list_in_flight.insert("only".to_string());
let valid: HashSet<&str> = ["only"].into_iter().collect();
assert!(!state.prune_orphans(&valid));
}
}