diff --git a/crates/aetheris-app/src/app.rs b/crates/aetheris-app/src/app.rs index 64f570d..95b2ac8 100644 --- a/crates/aetheris-app/src/app.rs +++ b/crates/aetheris-app/src/app.rs @@ -92,6 +92,8 @@ pub(super) struct DetailPane { conditions_list: gtk::ListBox, related_pods_store: gtk::gio::ListStore, related_pods_sorted: gtk::SortListModel, + related_pod_states_section: gtk::Box, + related_pod_states: gtk::FlowBox, related_pods_stack: gtk::Stack, related_pods_message: adw::StatusPage, log_container_dropdown: gtk::DropDown, diff --git a/crates/aetheris-app/src/app/component.rs b/crates/aetheris-app/src/app/component.rs index 785a277..ff8b516 100644 --- a/crates/aetheris-app/src/app/component.rs +++ b/crates/aetheris-app/src/app/component.rs @@ -374,6 +374,17 @@ impl Component for App { detail_conditions_list.set_selection_mode(gtk::SelectionMode::None); let (detail_related_pods_view, detail_related_pods_store, detail_related_pods_sorted) = super::widgets::related_pods_column_view(); + let detail_related_pod_states = gtk::FlowBox::builder() + .selection_mode(gtk::SelectionMode::None) + .column_spacing(8) + .row_spacing(8) + .max_children_per_line(4) + .min_children_per_line(1) + .homogeneous(true) + .hexpand(true) + .build(); + detail_related_pod_states.add_css_class("deployment-pod-state-grid"); + let detail_related_pod_states_section = gtk::Box::new(gtk::Orientation::Vertical, 8); let detail_related_pods_stack = gtk::Stack::builder() .hhomogeneous(false) .vhomogeneous(false) @@ -479,6 +490,8 @@ impl Component for App { events_list: &detail_events_list, conditions_list: &detail_conditions_list, related_pods_view: &detail_related_pods_view, + related_pod_states_section: &detail_related_pod_states_section, + related_pod_states: &detail_related_pod_states, related_pods_stack: &detail_related_pods_stack, related_pods_message: &detail_related_pods_message, log_container_dropdown: &detail_log_container_dropdown, @@ -971,6 +984,8 @@ impl Component for App { conditions_list: detail_conditions_list, related_pods_store: detail_related_pods_store, related_pods_sorted: detail_related_pods_sorted, + related_pod_states_section: detail_related_pod_states_section, + related_pod_states: detail_related_pod_states, related_pods_stack: detail_related_pods_stack, related_pods_message: detail_related_pods_message, log_container_dropdown: detail_log_container_dropdown, diff --git a/crates/aetheris-app/src/app/methods.rs b/crates/aetheris-app/src/app/methods.rs index 16f1399..fbdb854 100644 --- a/crates/aetheris-app/src/app/methods.rs +++ b/crates/aetheris-app/src/app/methods.rs @@ -930,6 +930,11 @@ impl App { &self.detail.related_pods_message, detail, ); + rebuild_related_pod_states( + &self.detail.related_pod_states_section, + &self.detail.related_pod_states, + detail, + ); rebuild_container_metrics(&self.detail.container_metrics_list, detail); self.sync_detail_tabs(detail); self.sync_terminal_controls(); diff --git a/crates/aetheris-app/src/app/object_detail.rs b/crates/aetheris-app/src/app/object_detail.rs index 167bc99..58578f6 100644 --- a/crates/aetheris-app/src/app/object_detail.rs +++ b/crates/aetheris-app/src/app/object_detail.rs @@ -25,6 +25,8 @@ pub(super) struct ObjectDetailWidgets<'a> { pub(super) events_list: &'a gtk::ListBox, pub(super) conditions_list: &'a gtk::ListBox, pub(super) related_pods_view: &'a gtk::ColumnView, + pub(super) related_pod_states_section: &'a gtk::Box, + pub(super) related_pod_states: &'a gtk::FlowBox, pub(super) related_pods_stack: &'a gtk::Stack, pub(super) related_pods_message: &'a adw::StatusPage, pub(super) log_container_dropdown: &'a gtk::DropDown, @@ -71,6 +73,17 @@ pub(super) fn build_object_detail_page(widgets: ObjectDetailWidgets<'_>) -> gtk: actions.append(widgets.drain_button); overview.append(&actions); + widgets + .related_pod_states_section + .add_css_class("deployment-pod-states"); + widgets + .related_pod_states_section + .append(§ion_title(&tr("Pods by State"))); + widgets + .related_pod_states_section + .append(widgets.related_pod_states); + overview.append(widgets.related_pod_states_section); + widgets .port_forward_group .append(§ion_title(&tr("Port Forward"))); @@ -372,6 +385,49 @@ pub(super) fn rebuild_related_pods( stack.set_visible_child_name("table"); } +pub(super) fn rebuild_related_pod_states( + section: >k::Box, + states: >k::FlowBox, + detail: &ObjectDetail, +) { + while let Some(child) = states.first_child() { + states.remove(&child); + } + + section.set_visible(detail.kind == "Deployment" && !detail.related_pod_states.is_empty()); + for state in &detail.related_pod_states { + let card = gtk::Box::new(gtk::Orientation::Vertical, 2); + card.add_css_class("pod-state-card"); + card.add_css_class(pod_state_css_class(&state.state)); + card.set_hexpand(true); + card.set_size_request(170, -1); + + let count = gtk::Label::builder() + .label(state.count.to_string()) + .xalign(0.0) + .css_classes(["title-1"]) + .build(); + let label = gtk::Label::builder() + .label(&state.state) + .xalign(0.0) + .css_classes(["caption"]) + .build(); + card.append(&count); + card.append(&label); + states.insert(&card, -1); + } +} + +fn pod_state_css_class(state: &str) -> &'static str { + match state { + "Running" => "pod-state-running", + "Pending" => "pod-state-pending", + "Succeeded" => "pod-state-succeeded", + "Failed" => "pod-state-failed", + _ => "pod-state-unknown", + } +} + pub(super) fn rebuild_container_metrics(list: >k::ListBox, detail: &ObjectDetail) { while let Some(child) = list.first_child() { list.remove(&child); diff --git a/crates/aetheris-app/src/app/utils.rs b/crates/aetheris-app/src/app/utils.rs index a620a1e..8229939 100644 --- a/crates/aetheris-app/src/app/utils.rs +++ b/crates/aetheris-app/src/app/utils.rs @@ -296,6 +296,7 @@ pub(super) fn unavailable_object_detail(target: &DetailTarget) -> ObjectDetail { yaml: String::new(), containers: Vec::new(), related_pods: Vec::new(), + related_pod_states: Vec::new(), replicas: None, node_unschedulable: None, conditions: Vec::new(), diff --git a/crates/aetheris-kube/src/lib.rs b/crates/aetheris-kube/src/lib.rs index c40ae83..229690a 100644 --- a/crates/aetheris-kube/src/lib.rs +++ b/crates/aetheris-kube/src/lib.rs @@ -20,6 +20,6 @@ pub use session::KubeSession; pub use types::{ AddClusterRequest, ClusterSummary, ContainerResources, ContainerUsage, ContextInfo, ObjectCondition, ObjectDetail, ObjectEvent, ObjectSummary, ObjectWatchEvent, PodExecEvent, - PodExecRequest, PodLogRequest, PodPortForwardEvent, PodPortForwardRequest, PodSummary, - ResourceKind, ResourceRatio, ResourceScope, ResourceUsage, + PodExecRequest, PodLogRequest, PodPortForwardEvent, PodPortForwardRequest, PodStateCount, + PodSummary, ResourceKind, ResourceRatio, ResourceScope, ResourceUsage, }; diff --git a/crates/aetheris-kube/src/objects.rs b/crates/aetheris-kube/src/objects.rs index 6dd0811..1e32596 100644 --- a/crates/aetheris-kube/src/objects.rs +++ b/crates/aetheris-kube/src/objects.rs @@ -11,8 +11,8 @@ use serde_json::Value; use crate::status::{age_label, status_label}; use crate::{ ContainerResources, KubeSession, ObjectCondition, ObjectDetail, ObjectSummary, - ObjectWatchEvent, PodSummary, ResourceKind, ResourceRatio, ResourceScope, ResourceUsage, - api_resource, namespace_scope, resource_scope, + ObjectWatchEvent, PodStateCount, PodSummary, ResourceKind, ResourceRatio, ResourceScope, + ResourceUsage, api_resource, namespace_scope, resource_scope, }; impl KubeSession { @@ -228,7 +228,7 @@ impl KubeSession { }; let summary = object_summary(object.clone(), resource, &metrics); let yaml = serde_yaml::to_string(&object).context("failed to serialize object YAML")?; - let related_pods = self + let deployment_pods = self .deployment_pods(resource, &summary.namespace, &object) .await .unwrap_or_default(); @@ -253,7 +253,8 @@ impl KubeSession { container_resources, yaml, containers, - related_pods, + related_pods: deployment_pods.summaries, + related_pod_states: deployment_pods.states, replicas, node_unschedulable, conditions, @@ -267,12 +268,12 @@ impl KubeSession { resource: &ResourceKind, namespace: &str, object: &DynamicObject, - ) -> Result> { + ) -> Result { if resource.kind != "Deployment" || resource.group != "apps" || namespace == "-" { - return Ok(Vec::new()); + return Ok(DeploymentPods::default()); } let Some(selector) = deployment_label_selector(object) else { - return Ok(Vec::new()); + return Ok(DeploymentPods::default()); }; let pods: Api = Api::namespaced(self.client.clone(), namespace); @@ -288,7 +289,7 @@ impl KubeSession { .resource_metrics(&pod_resource, Some(namespace)) .await .unwrap_or_default(); - let mut summaries = pods + let pods = pods .list(&ListParams::default().labels(&selector)) .await .with_context(|| { @@ -297,7 +298,9 @@ impl KubeSession { object.name_any() ) })? - .items + .items; + let states = pod_state_counts(&pods); + let mut summaries = pods .into_iter() .map(|pod| { let object = serde_json::to_value(&pod) @@ -319,10 +322,46 @@ impl KubeSession { .collect::>(); summaries.sort_by(|left, right| left.name.cmp(&right.name)); - Ok(summaries) + Ok(DeploymentPods { summaries, states }) } } +#[derive(Default)] +struct DeploymentPods { + summaries: Vec, + states: Vec, +} + +fn pod_state_counts(pods: &[Pod]) -> Vec { + const ORDER: [&str; 5] = ["Running", "Pending", "Succeeded", "Failed", "Unknown"]; + + let mut counts = BTreeMap::::new(); + for pod in pods { + let state = pod + .status + .as_ref() + .and_then(|status| status.phase.as_deref()) + .unwrap_or("Unknown"); + *counts.entry(state.to_owned()).or_default() += 1; + } + + let mut states = ORDER + .into_iter() + .filter_map(|state| { + counts.remove(state).map(|count| PodStateCount { + state: state.to_owned(), + count, + }) + }) + .collect::>(); + states.extend( + counts + .into_iter() + .map(|(state, count)| PodStateCount { state, count }), + ); + states +} + fn sort_object_summaries(objects: &mut [ObjectSummary]) { objects.sort_by(|left, right| { left.namespace @@ -651,8 +690,12 @@ fn deployment_label_selector(object: &DynamicObject) -> Option { #[cfg(test)] mod tests { - use super::{object_container_resources, object_images, quantity_as_f64, resource_ratio}; + use super::{ + object_container_resources, object_images, pod_state_counts, quantity_as_f64, + resource_ratio, + }; use crate::{ContainerResources, ResourceKind, ResourceScope}; + use k8s_openapi::api::core::v1::Pod; use kube::api::DynamicObject; use serde_json::json; @@ -782,4 +825,38 @@ mod tests { ] ); } + + #[test] + fn pod_state_counts_groups_phases_in_dashboard_order() { + let pods: Vec = serde_json::from_value(json!([ + {"apiVersion": "v1", "kind": "Pod", "status": {"phase": "Pending"}}, + {"apiVersion": "v1", "kind": "Pod", "status": {"phase": "Running"}}, + {"apiVersion": "v1", "kind": "Pod", "status": {"phase": "Running"}}, + {"apiVersion": "v1", "kind": "Pod", "status": {"phase": "Failed"}}, + {"apiVersion": "v1", "kind": "Pod", "status": {}} + ])) + .expect("pods should deserialize"); + + assert_eq!( + pod_state_counts(&pods), + vec![ + crate::PodStateCount { + state: String::from("Running"), + count: 2 + }, + crate::PodStateCount { + state: String::from("Pending"), + count: 1 + }, + crate::PodStateCount { + state: String::from("Failed"), + count: 1 + }, + crate::PodStateCount { + state: String::from("Unknown"), + count: 1 + }, + ] + ); + } } diff --git a/crates/aetheris-kube/src/types.rs b/crates/aetheris-kube/src/types.rs index b43e7dd..a4ffb03 100644 --- a/crates/aetheris-kube/src/types.rs +++ b/crates/aetheris-kube/src/types.rs @@ -81,6 +81,9 @@ pub struct ObjectDetail { pub yaml: String, pub containers: Vec, pub related_pods: Vec, + /// Counts of Pods selected by a Deployment, grouped by their Kubernetes + /// lifecycle phase (Running, Pending, Succeeded, Failed, or Unknown). + pub related_pod_states: Vec, pub replicas: Option, pub node_unschedulable: Option, pub conditions: Vec, @@ -88,6 +91,12 @@ pub struct ObjectDetail { pub events_error: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PodStateCount { + pub state: String, + pub count: u32, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ObjectCondition { pub type_: String, diff --git a/data/style.css b/data/style.css index de549d7..87df460 100644 --- a/data/style.css +++ b/data/style.css @@ -42,6 +42,48 @@ background-color: alpha(currentColor, 0.10); } +.deployment-pod-states { + margin-top: 8px; +} + +.pod-state-card { + border-radius: 10px; + padding: 10px 14px; +} + +flowbox.deployment-pod-state-grid > flowboxchild, +flowbox.deployment-pod-state-grid > flowboxchild:hover, +flowbox.deployment-pod-state-grid > flowboxchild:selected, +flowbox.deployment-pod-state-grid > flowboxchild:selected:hover { + background: transparent; + border-radius: 10px; + box-shadow: none; +} + +.pod-state-running { + background-color: alpha(@success_color, 0.16); + color: @success_color; +} + +.pod-state-pending { + background-color: alpha(@warning_color, 0.18); + color: @warning_color; +} + +.pod-state-succeeded { + background-color: alpha(@accent_color, 0.15); + color: @accent_color; +} + +.pod-state-failed { + background-color: alpha(@error_color, 0.16); + color: @error_color; +} + +.pod-state-unknown { + background-color: alpha(currentColor, 0.10); +} + .status-icon-good { color: @success_color; }