Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/aetheris-app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions crates/aetheris-app/src/app/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions crates/aetheris-app/src/app/methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
56 changes: 56 additions & 0 deletions crates/aetheris-app/src/app/object_detail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(&section_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(&section_title(&tr("Port Forward")));
Expand Down Expand Up @@ -372,6 +385,49 @@ pub(super) fn rebuild_related_pods(
stack.set_visible_child_name("table");
}

pub(super) fn rebuild_related_pod_states(
section: &gtk::Box,
states: &gtk::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: &gtk::ListBox, detail: &ObjectDetail) {
while let Some(child) = list.first_child() {
list.remove(&child);
Expand Down
1 change: 1 addition & 0 deletions crates/aetheris-app/src/app/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
4 changes: 2 additions & 2 deletions crates/aetheris-kube/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
99 changes: 88 additions & 11 deletions crates/aetheris-kube/src/objects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand All @@ -267,12 +268,12 @@ impl KubeSession {
resource: &ResourceKind,
namespace: &str,
object: &DynamicObject,
) -> Result<Vec<ObjectSummary>> {
) -> Result<DeploymentPods> {
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<Pod> = Api::namespaced(self.client.clone(), namespace);
Expand All @@ -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(|| {
Expand All @@ -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)
Expand All @@ -319,10 +322,46 @@ impl KubeSession {
.collect::<Vec<_>>();
summaries.sort_by(|left, right| left.name.cmp(&right.name));

Ok(summaries)
Ok(DeploymentPods { summaries, states })
}
}

#[derive(Default)]
struct DeploymentPods {
summaries: Vec<ObjectSummary>,
states: Vec<PodStateCount>,
}

fn pod_state_counts(pods: &[Pod]) -> Vec<PodStateCount> {
const ORDER: [&str; 5] = ["Running", "Pending", "Succeeded", "Failed", "Unknown"];

let mut counts = BTreeMap::<String, u32>::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::<Vec<_>>();
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
Expand Down Expand Up @@ -651,8 +690,12 @@ fn deployment_label_selector(object: &DynamicObject) -> Option<String> {

#[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;

Expand Down Expand Up @@ -782,4 +825,38 @@ mod tests {
]
);
}

#[test]
fn pod_state_counts_groups_phases_in_dashboard_order() {
let pods: Vec<Pod> = 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
},
]
);
}
}
9 changes: 9 additions & 0 deletions crates/aetheris-kube/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,22 @@ pub struct ObjectDetail {
pub yaml: String,
pub containers: Vec<String>,
pub related_pods: Vec<ObjectSummary>,
/// Counts of Pods selected by a Deployment, grouped by their Kubernetes
/// lifecycle phase (Running, Pending, Succeeded, Failed, or Unknown).
pub related_pod_states: Vec<PodStateCount>,
pub replicas: Option<i32>,
pub node_unschedulable: Option<bool>,
pub conditions: Vec<ObjectCondition>,
pub events: Vec<ObjectEvent>,
pub events_error: Option<String>,
}

#[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,
Expand Down
42 changes: 42 additions & 0 deletions data/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading