diff --git a/crates/aetheris-app/src/app.rs b/crates/aetheris-app/src/app.rs index c260fab..64f570d 100644 --- a/crates/aetheris-app/src/app.rs +++ b/crates/aetheris-app/src/app.rs @@ -1,5 +1,5 @@ use std::{ - collections::{BTreeSet, HashMap}, + collections::{BTreeSet, HashMap, VecDeque}, fs, path::PathBuf, }; @@ -56,6 +56,7 @@ const OBJECT_API_WIDTH: i32 = 96; const OBJECT_AGE_WIDTH: i32 = 56; const OBJECT_COLUMN_MIN_WIDTH: i32 = 48; const OBJECT_NAME_MIN_WIDTH: i32 = 160; +const OBJECT_CACHE_LIMIT: usize = 20; #[derive(Debug, Clone)] pub(super) enum ClusterSummaryState { @@ -180,6 +181,8 @@ pub struct App { object_sorted: gtk::SortListModel, object_columns: Vec<(ObjectTableColumn, gtk::ColumnViewColumn)>, object_list_stack: gtk::Stack, + object_cache: HashMap>, + object_cache_order: VecDeque, detail: DetailPane, object_load_token: u64, object_watch_token: u64, @@ -221,6 +224,16 @@ pub struct App { renaming_namespace: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ObjectCacheKey { + context: String, + group: String, + version: String, + kind: String, + plural: String, + namespace: Option, +} + #[cfg(not(target_os = "windows"))] struct TerminalSession { window: adw::Window, diff --git a/crates/aetheris-app/src/app/commands.rs b/crates/aetheris-app/src/app/commands.rs index c426289..39977c9 100644 --- a/crates/aetheris-app/src/app/commands.rs +++ b/crates/aetheris-app/src/app/commands.rs @@ -4,6 +4,7 @@ use super::utils::*; use super::*; const CLUSTER_LOAD_TIMEOUT: Duration = Duration::from_secs(30); +const OBJECT_LIST_TIMEOUT: Duration = Duration::from_secs(30); pub(super) async fn load_state() -> AppMsg { let result = async { @@ -89,7 +90,17 @@ pub(super) async fn list_objects( resource: ResourceKind, namespace: Option, ) -> AppMsg { - let result = list_objects_snapshot(context, resource, namespace).await; + let result = match tokio::time::timeout( + OBJECT_LIST_TIMEOUT, + list_objects_snapshot(context, resource, namespace), + ) + .await + { + Ok(result) => result, + Err(_) => Err(String::from( + "Timed out while listing objects for the selected resource. Check that the API server is reachable and try again.", + )), + }; AppMsg::ObjectsLoaded(token, result) } diff --git a/crates/aetheris-app/src/app/component.rs b/crates/aetheris-app/src/app/component.rs index 1d33804..785a277 100644 --- a/crates/aetheris-app/src/app/component.rs +++ b/crates/aetheris-app/src/app/component.rs @@ -942,6 +942,8 @@ impl Component for App { object_sorted, object_columns, object_list_stack, + object_cache: HashMap::new(), + object_cache_order: VecDeque::new(), detail: DetailPane { back_button: detail_back_button, stack: detail_stack, diff --git a/crates/aetheris-app/src/app/handler.rs b/crates/aetheris-app/src/app/handler.rs index 32e95e1..5d6e6ac 100644 --- a/crates/aetheris-app/src/app/handler.rs +++ b/crates/aetheris-app/src/app/handler.rs @@ -686,6 +686,7 @@ impl App { } AppMsg::ObjectCreated(Ok(name)) => { self.loading = false; + self.clear_object_cache(); self.create_yaml_dialog.close(); self.create_yaml_buffer.set_text(""); self.toaster.add_toast(adw::Toast::new(&tr_format( @@ -722,6 +723,7 @@ impl App { return; } self.loading = false; + self.clear_object_cache(); self.status = tr_format("Scaled {name}", &[("{name}", detail.name.clone())]); self.populate_detail_dialog(&detail); self.update_log_target_containers(&detail); @@ -761,6 +763,7 @@ impl App { return; } self.loading = false; + self.clear_object_cache(); self.status = tr_format("Updated {name}", &[("{name}", detail.name.clone())]); self.populate_detail_dialog(&detail); self.update_log_target_containers(&detail); @@ -822,6 +825,7 @@ impl App { return; } self.loading = false; + self.clear_object_cache(); self.status = tr_format("Drained {name}", &[("{name}", detail.name.clone())]); self.populate_detail_dialog(&detail); self.update_log_target_containers(&detail); @@ -861,6 +865,7 @@ impl App { return; } self.loading = false; + self.clear_object_cache(); self.status = tr_format("Applied {name}", &[("{name}", detail.name.clone())]); self.populate_detail_dialog(&detail); self.update_log_target_containers(&detail); @@ -1014,6 +1019,7 @@ impl App { return; } self.loading = false; + self.clear_object_cache(); self.detail.target = None; self.detail.log_target = None; self.detail.exec_target = None; @@ -1162,6 +1168,7 @@ impl App { self.loading = false; let count = objects.len(); self.objects = objects; + self.cache_current_objects(); self.set_object_status(count); self.sync_status(); self.rebuild_object_list(); @@ -1173,7 +1180,14 @@ impl App { } self.loading = false; self.stop_object_watch(); - self.objects.clear(); + if let Some(objects) = self + .current_object_cache_key() + .and_then(|key| self.cached_objects(&key)) + { + self.objects = objects; + } else { + self.objects.clear(); + } self.status = tr("Unable to list selected resource."); self.sync_status(); self.rebuild_object_list(); @@ -1186,14 +1200,17 @@ impl App { match event { ObjectWatchEvent::Restarted(objects) => { self.objects = objects; + self.cache_current_objects(); self.schedule_object_list_refresh(&sender); } ObjectWatchEvent::Applied(object) => { self.upsert_object(object); + self.cache_current_objects(); self.schedule_object_list_refresh(&sender); } ObjectWatchEvent::Deleted(object) => { self.remove_object(&object); + self.cache_current_objects(); self.schedule_object_list_refresh(&sender); } ObjectWatchEvent::Error(error) => { diff --git a/crates/aetheris-app/src/app/methods.rs b/crates/aetheris-app/src/app/methods.rs index 9cf197d..16f1399 100644 --- a/crates/aetheris-app/src/app/methods.rs +++ b/crates/aetheris-app/src/app/methods.rs @@ -232,6 +232,8 @@ impl App { self.stop_object_watch(); self.stop_log_stream(); self.stop_port_forward(); + self.object_cache.clear(); + self.object_cache_order.clear(); self.detail.exec_target = None; self.detail.port_forward_target = None; self.loading = true; @@ -275,9 +277,20 @@ impl App { self.stop_object_watch(); self.object_load_token = self.object_load_token.saturating_add(1); let token = self.object_load_token; + let cache_key = object_cache_key(&context, &resource, namespace.clone()); self.loading = true; - self.objects.clear(); - self.status = tr_format("Loading {resource}...", &[("{resource}", resource.label())]); + if let Some(objects) = self.cached_objects(&cache_key) { + let count = objects.len(); + self.objects = objects; + self.set_object_status(count); + self.status = tr_format( + "Refreshing {resource}...", + &[("{resource}", resource.label())], + ); + } else { + self.objects.clear(); + self.status = tr_format("Loading {resource}...", &[("{resource}", resource.label())]); + } self.sync_status(); self.rebuild_object_list(); sender.oneshot_command( @@ -682,6 +695,47 @@ impl App { .splice(0, self.object_store.n_items(), &items); } + pub(super) fn current_object_cache_key(&self) -> Option { + let context = self.selected_context.as_ref()?; + let resource = self.selected_resource_kind()?; + let namespace = resource + .is_namespaced() + .then(|| self.selected_namespace.clone()); + Some(object_cache_key(context, resource, namespace)) + } + + pub(super) fn cached_objects(&mut self, key: &ObjectCacheKey) -> Option> { + let objects = self.object_cache.get(key)?.clone(); + self.touch_object_cache_key(key.clone()); + Some(objects) + } + + pub(super) fn cache_current_objects(&mut self) { + if let Some(key) = self.current_object_cache_key() { + self.cache_objects(key, self.objects.clone()); + } + } + + pub(super) fn cache_objects(&mut self, key: ObjectCacheKey, objects: Vec) { + self.object_cache.insert(key.clone(), objects); + self.touch_object_cache_key(key); + while self.object_cache_order.len() > OBJECT_CACHE_LIMIT { + if let Some(oldest) = self.object_cache_order.pop_front() { + self.object_cache.remove(&oldest); + } + } + } + + pub(super) fn touch_object_cache_key(&mut self, key: ObjectCacheKey) { + self.object_cache_order.retain(|existing| existing != &key); + self.object_cache_order.push_back(key); + } + + pub(super) fn clear_object_cache(&mut self) { + self.object_cache.clear(); + self.object_cache_order.clear(); + } + /// Merges one watch event into `objects` without sorting or repainting; /// both are deferred to `flush_object_list_refresh` so an event burst /// costs one refresh instead of thousands. @@ -941,6 +995,21 @@ impl App { } } +fn object_cache_key( + context: &str, + resource: &ResourceKind, + namespace: Option, +) -> ObjectCacheKey { + ObjectCacheKey { + context: context.to_owned(), + group: resource.group.clone(), + version: resource.version.clone(), + kind: resource.kind.clone(), + plural: resource.plural.clone(), + namespace, + } +} + /// The object at a view position, resolved against the sorted model the /// `ColumnView` actually displays (positions differ from the backing store /// whenever a header-click sort is active). diff --git a/crates/aetheris-app/src/app/projects.rs b/crates/aetheris-app/src/app/projects.rs index 095b422..dcbf4a7 100644 --- a/crates/aetheris-app/src/app/projects.rs +++ b/crates/aetheris-app/src/app/projects.rs @@ -68,6 +68,7 @@ pub(crate) enum ResourceSection { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(super) enum StatusFilter { Ready, + Available, Unavailable, Running, Pending, @@ -198,8 +199,9 @@ pub(super) fn default_object_columns() -> Vec { const OBJECT_COLUMN_SCHEMA_VERSION: u32 = 1; impl StatusFilter { - pub(super) const ALL: [Self; 5] = [ + pub(super) const ALL: [Self; 6] = [ Self::Ready, + Self::Available, Self::Unavailable, Self::Running, Self::Pending, @@ -209,6 +211,7 @@ impl StatusFilter { pub(super) fn label(self) -> String { match self { Self::Ready => tr("Ready"), + Self::Available => tr("Available"), Self::Unavailable => tr("Unavailable"), Self::Running => tr("Running"), Self::Pending => tr("Pending"), @@ -237,6 +240,7 @@ impl StatusFilter { pub(super) fn keyword(self) -> &'static str { match self { Self::Ready => "Ready", + Self::Available => "Available", Self::Unavailable => "Unavailable", Self::Running => "Running", Self::Pending => "Pending", diff --git a/crates/aetheris-app/src/app/widgets.rs b/crates/aetheris-app/src/app/widgets.rs index 62170f2..f393b47 100644 --- a/crates/aetheris-app/src/app/widgets.rs +++ b/crates/aetheris-app/src/app/widgets.rs @@ -295,7 +295,7 @@ fn filter_chip( fn status_filter_tone(filter: StatusFilter) -> StatusTone { match filter { - StatusFilter::Ready | StatusFilter::Running => StatusTone::Good, + StatusFilter::Ready | StatusFilter::Available | StatusFilter::Running => StatusTone::Good, StatusFilter::Pending | StatusFilter::Unavailable => StatusTone::Warning, StatusFilter::Failed => StatusTone::Bad, }