diff --git a/crates/aetheris-app/src/app.rs b/crates/aetheris-app/src/app.rs index 95b2ac8..d1990a4 100644 --- a/crates/aetheris-app/src/app.rs +++ b/crates/aetheris-app/src/app.rs @@ -6,9 +6,9 @@ use std::{ use aetheris_kube::{ AddClusterRequest, ClusterSummary, ContainerResources, ContainerUsage, ContextInfo, - KubeManager, ObjectCondition, ObjectDetail, ObjectEvent, ObjectSummary, ObjectWatchEvent, - PodExecEvent, PodExecRequest, PodLogRequest, PodPortForwardEvent, PodPortForwardRequest, - ResourceKind, ResourceUsage, + IngressRule, KubeManager, ObjectCondition, ObjectDetail, ObjectEvent, ObjectSummary, + ObjectWatchEvent, PodExecEvent, PodExecRequest, PodLogRequest, PodPortForwardEvent, + PodPortForwardRequest, ResourceKind, ResourceUsage, ServicePort, }; use futures::FutureExt; use futures::future::{AbortHandle, Abortable}; @@ -90,6 +90,9 @@ pub(super) struct DetailPane { yaml_buffer: sourceview5::Buffer, events_list: gtk::ListBox, conditions_list: gtk::ListBox, + service_ports_list: gtk::ListBox, + service_selectors_list: gtk::ListBox, + ingress_rules_list: gtk::ListBox, related_pods_store: gtk::gio::ListStore, related_pods_sorted: gtk::SortListModel, related_pod_states_section: gtk::Box, diff --git a/crates/aetheris-app/src/app/component.rs b/crates/aetheris-app/src/app/component.rs index ff8b516..e331dc0 100644 --- a/crates/aetheris-app/src/app/component.rs +++ b/crates/aetheris-app/src/app/component.rs @@ -372,6 +372,15 @@ impl Component for App { let detail_conditions_list = gtk::ListBox::new(); detail_conditions_list.add_css_class("boxed-list"); detail_conditions_list.set_selection_mode(gtk::SelectionMode::None); + let detail_service_ports_list = gtk::ListBox::new(); + detail_service_ports_list.add_css_class("boxed-list"); + detail_service_ports_list.set_selection_mode(gtk::SelectionMode::None); + let detail_service_selectors_list = gtk::ListBox::new(); + detail_service_selectors_list.add_css_class("boxed-list"); + detail_service_selectors_list.set_selection_mode(gtk::SelectionMode::None); + let detail_ingress_rules_list = gtk::ListBox::new(); + detail_ingress_rules_list.add_css_class("boxed-list"); + detail_ingress_rules_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() @@ -489,6 +498,9 @@ impl Component for App { yaml_error_label: &detail_yaml_error_label, events_list: &detail_events_list, conditions_list: &detail_conditions_list, + service_ports_list: &detail_service_ports_list, + service_selectors_list: &detail_service_selectors_list, + ingress_rules_list: &detail_ingress_rules_list, related_pods_view: &detail_related_pods_view, related_pod_states_section: &detail_related_pod_states_section, related_pod_states: &detail_related_pod_states, @@ -982,6 +994,9 @@ impl Component for App { yaml_buffer: detail_yaml_buffer, events_list: detail_events_list, conditions_list: detail_conditions_list, + service_ports_list: detail_service_ports_list, + service_selectors_list: detail_service_selectors_list, + ingress_rules_list: detail_ingress_rules_list, related_pods_store: detail_related_pods_store, related_pods_sorted: detail_related_pods_sorted, related_pod_states_section: detail_related_pod_states_section, diff --git a/crates/aetheris-app/src/app/methods.rs b/crates/aetheris-app/src/app/methods.rs index fbdb854..9a2c4c5 100644 --- a/crates/aetheris-app/src/app/methods.rs +++ b/crates/aetheris-app/src/app/methods.rs @@ -924,6 +924,9 @@ impl App { } rebuild_detail_events(&self.detail.events_list, detail); rebuild_detail_conditions(&self.detail.conditions_list, detail); + rebuild_service_ports(&self.detail.service_ports_list, detail); + rebuild_service_selectors(&self.detail.service_selectors_list, detail); + rebuild_ingress_rules(&self.detail.ingress_rules_list, detail); rebuild_related_pods( &self.detail.related_pods_store, &self.detail.related_pods_stack, @@ -949,6 +952,8 @@ impl App { let show_pods = detail.kind == "Deployment"; let show_conditions = !detail.conditions.is_empty(); let show_containers = detail.kind == "Pod"; + let show_service_tabs = detail.kind == "Service"; + let show_ingress_rules = detail.kind == "Ingress"; set_stack_page( &self.detail.stack, @@ -977,6 +982,33 @@ impl App { &[("{count}", detail.containers.len().to_string())], ), ); + set_stack_page( + &self.detail.stack, + "service-ports", + show_service_tabs, + &tr_format( + "Ports ({count})", + &[("{count}", detail.service_ports.len().to_string())], + ), + ); + set_stack_page( + &self.detail.stack, + "service-selectors", + show_service_tabs, + &tr_format( + "Selectors ({count})", + &[("{count}", detail.service_selectors.len().to_string())], + ), + ); + set_stack_page( + &self.detail.stack, + "ingress-rules", + show_ingress_rules, + &tr_format( + "Rules ({count})", + &[("{count}", detail.ingress_rules.len().to_string())], + ), + ); set_stack_page( &self.detail.stack, "events", diff --git a/crates/aetheris-app/src/app/object_detail.rs b/crates/aetheris-app/src/app/object_detail.rs index 58578f6..2d6e453 100644 --- a/crates/aetheris-app/src/app/object_detail.rs +++ b/crates/aetheris-app/src/app/object_detail.rs @@ -24,6 +24,9 @@ pub(super) struct ObjectDetailWidgets<'a> { pub(super) yaml_error_label: &'a gtk::Label, pub(super) events_list: &'a gtk::ListBox, pub(super) conditions_list: &'a gtk::ListBox, + pub(super) service_ports_list: &'a gtk::ListBox, + pub(super) service_selectors_list: &'a gtk::ListBox, + pub(super) ingress_rules_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, @@ -177,6 +180,44 @@ pub(super) fn build_object_detail_page(widgets: ObjectDetailWidgets<'_>) -> gtk: .stack .add_titled(&containers_page, Some("containers"), &tr("Containers")); + let service_ports_page = gtk::Box::new(gtk::Orientation::Vertical, 0); + service_ports_page.set_margin_all(12); + let service_ports_scrolled = gtk::ScrolledWindow::builder() + .vexpand(true) + .hexpand(true) + .build(); + service_ports_scrolled.set_child(Some(widgets.service_ports_list)); + service_ports_page.append(&service_ports_scrolled); + widgets + .stack + .add_titled(&service_ports_page, Some("service-ports"), &tr("Ports")); + + let service_selectors_page = gtk::Box::new(gtk::Orientation::Vertical, 0); + service_selectors_page.set_margin_all(12); + let service_selectors_scrolled = gtk::ScrolledWindow::builder() + .vexpand(true) + .hexpand(true) + .build(); + service_selectors_scrolled.set_child(Some(widgets.service_selectors_list)); + service_selectors_page.append(&service_selectors_scrolled); + widgets.stack.add_titled( + &service_selectors_page, + Some("service-selectors"), + &tr("Selectors"), + ); + + let ingress_rules_page = gtk::Box::new(gtk::Orientation::Vertical, 0); + ingress_rules_page.set_margin_all(12); + let ingress_rules_scrolled = gtk::ScrolledWindow::builder() + .vexpand(true) + .hexpand(true) + .build(); + ingress_rules_scrolled.set_child(Some(widgets.ingress_rules_list)); + ingress_rules_page.append(&ingress_rules_scrolled); + widgets + .stack + .add_titled(&ingress_rules_page, Some("ingress-rules"), &tr("Rules")); + let pods_page = gtk::Box::new(gtk::Orientation::Vertical, 8); pods_page.set_margin_all(12); let pods_scrolled = gtk::ScrolledWindow::builder() @@ -357,6 +398,120 @@ pub(super) fn rebuild_detail_conditions(list: >k::ListBox, detail: &ObjectDeta } } +pub(super) fn rebuild_service_ports(list: >k::ListBox, detail: &ObjectDetail) { + while let Some(child) = list.first_child() { + list.remove(&child); + } + + if detail.service_ports.is_empty() { + list.append(&detail_message_row( + &tr("No ports"), + &tr("This Service does not expose any ports."), + "dialog-information-symbolic", + )); + return; + } + + for port in &detail.service_ports { + list.append(&service_port_row(port)); + } +} + +pub(super) fn rebuild_service_selectors(list: >k::ListBox, detail: &ObjectDetail) { + while let Some(child) = list.first_child() { + list.remove(&child); + } + + if detail.service_selectors.is_empty() { + list.append(&detail_message_row( + &tr("No selectors"), + &tr("This Service does not select Pods."), + "dialog-information-symbolic", + )); + return; + } + + for selector in &detail.service_selectors { + let row = adw::ActionRow::builder() + .title(&selector.key) + .subtitle(&selector.value) + .build(); + row.add_prefix(>k::Image::from_icon_name("lucide-waypoints-symbolic")); + list.append(&row); + } +} + +pub(super) fn rebuild_ingress_rules(list: >k::ListBox, detail: &ObjectDetail) { + while let Some(child) = list.first_child() { + list.remove(&child); + } + + if detail.ingress_rules.is_empty() { + list.append(&detail_message_row( + &tr("No rules"), + &tr("This Ingress does not route traffic to a Service."), + "dialog-information-symbolic", + )); + return; + } + + for rule in &detail.ingress_rules { + list.append(&ingress_rule_row(rule)); + } +} + +fn ingress_rule_row(rule: &IngressRule) -> adw::ActionRow { + let target = tr_format( + "{service}:{port}", + &[ + ("{service}", rule.service.clone()), + ("{port}", rule.port.clone()), + ], + ); + let subtitle = tr_format( + "{path_type} → {target}", + &[ + ("{path_type}", rule.path_type.clone()), + ("{target}", target), + ], + ); + let row = adw::ActionRow::builder() + .title(format!("{}{}", rule.host, rule.path)) + .subtitle(subtitle) + .build(); + row.add_prefix(>k::Image::from_icon_name("lucide-radio-tower-symbolic")); + row +} + +fn service_port_row(port: &ServicePort) -> adw::ActionRow { + let title = tr_format( + "Port {port} → Target {target}", + &[ + ("{port}", port.port.clone()), + ("{target}", port.target_port.clone()), + ], + ); + let mut details = vec![port.protocol.clone()]; + if !port.name.is_empty() { + details.push(port.name.clone()); + } + if let Some(node_port) = &port.node_port { + details.push(tr_format( + "NodePort {port}", + &[("{port}", node_port.clone())], + )); + } + + let row = adw::ActionRow::builder() + .title(title) + .subtitle(details.join(" · ")) + .build(); + row.add_prefix(>k::Image::from_icon_name( + "network-transmit-receive-symbolic", + )); + row +} + pub(super) fn rebuild_related_pods( store: >k::gio::ListStore, stack: >k::Stack, diff --git a/crates/aetheris-app/src/app/projects.rs b/crates/aetheris-app/src/app/projects.rs index dcbf4a7..2199c9f 100644 --- a/crates/aetheris-app/src/app/projects.rs +++ b/crates/aetheris-app/src/app/projects.rs @@ -80,6 +80,9 @@ pub(super) enum StatusFilter { pub(crate) enum ObjectColumn { Image, Namespace, + Target, + Selector, + IngressClass, Status, Cpu, Memory, @@ -158,9 +161,12 @@ impl ObjectFavorite { } impl ObjectColumn { - pub(super) const ALL: [Self; 7] = [ + pub(super) const ALL: [Self; 10] = [ Self::Image, Self::Namespace, + Self::Target, + Self::Selector, + Self::IngressClass, Self::Status, Self::Cpu, Self::Memory, @@ -172,6 +178,9 @@ impl ObjectColumn { match self { Self::Image => tr("Image"), Self::Namespace => tr("Namespace"), + Self::Target => tr("Target"), + Self::Selector => tr("Selector"), + Self::IngressClass => tr("Ingress Class"), Self::Status => tr("Status"), Self::Cpu => tr("CPU"), Self::Memory => tr("Memory"), @@ -184,6 +193,9 @@ impl ObjectColumn { match self { Self::Image => OBJECT_IMAGE_WIDTH, Self::Namespace => OBJECT_NAMESPACE_WIDTH, + Self::Target => 156, + Self::Selector => 180, + Self::IngressClass => 144, Self::Status => OBJECT_STATUS_WIDTH, Self::Cpu | Self::Memory => OBJECT_METRIC_WIDTH, Self::Api => OBJECT_API_WIDTH, @@ -196,7 +208,7 @@ pub(super) fn default_object_columns() -> Vec { ObjectColumn::ALL.to_vec() } -const OBJECT_COLUMN_SCHEMA_VERSION: u32 = 1; +const OBJECT_COLUMN_SCHEMA_VERSION: u32 = 3; impl StatusFilter { pub(super) const ALL: [Self; 6] = [ @@ -553,6 +565,13 @@ impl ProjectStore { { self.visible_object_columns.push(ObjectColumn::Image); } + if self.object_column_schema_version < 2 { + self.visible_object_columns + .extend([ObjectColumn::Target, ObjectColumn::Selector]); + } + if self.object_column_schema_version < 3 { + self.visible_object_columns.push(ObjectColumn::IngressClass); + } self.object_column_schema_version = OBJECT_COLUMN_SCHEMA_VERSION; } diff --git a/crates/aetheris-app/src/app/utils.rs b/crates/aetheris-app/src/app/utils.rs index 8229939..0db98e9 100644 --- a/crates/aetheris-app/src/app/utils.rs +++ b/crates/aetheris-app/src/app/utils.rs @@ -125,6 +125,14 @@ pub(super) fn supports_metrics(resource: &ResourceKind) -> bool { resource.group.is_empty() && matches!(resource.kind.as_str(), "Pod" | "Node") } +fn is_service_resource(resource: &ResourceKind) -> bool { + resource.group.is_empty() && resource.kind == "Service" +} + +fn is_ingress_resource(resource: &ResourceKind) -> bool { + resource.group == "networking.k8s.io" && resource.kind == "Ingress" +} + /// Columns that make sense to render for `resource` (e.g. "Status" only for /// kinds with a ready/desired ratio; "CPU"/"Memory" only for Pods and /// Nodes). Shared by the main object list and the related-Pods table in the @@ -140,6 +148,11 @@ pub(super) fn offerable_columns_for(resource: Option<&ResourceKind>) -> Vec resource.map(ResourceKind::is_namespaced).unwrap_or(true), + ObjectColumn::Target => resource.is_some_and(|resource| { + is_service_resource(resource) || is_ingress_resource(resource) + }), + ObjectColumn::Selector => resource.is_some_and(is_service_resource), + ObjectColumn::IngressClass => resource.is_some_and(is_ingress_resource), ObjectColumn::Status => has_status_ratio, ObjectColumn::Cpu | ObjectColumn::Memory => has_metrics, _ => true, @@ -297,6 +310,9 @@ pub(super) fn unavailable_object_detail(target: &DetailTarget) -> ObjectDetail { containers: Vec::new(), related_pods: Vec::new(), related_pod_states: Vec::new(), + service_ports: Vec::new(), + service_selectors: Vec::new(), + ingress_rules: Vec::new(), replicas: None, node_unschedulable: None, conditions: Vec::new(), @@ -409,4 +425,57 @@ mod tests { assert!(offerable_columns_for(Some(&pod)).contains(&ObjectColumn::Image)); assert!(!offerable_columns_for(Some(&deployment)).contains(&ObjectColumn::Image)); } + + #[test] + fn offerable_columns_show_target_and_selector_only_for_services() { + let service = ResourceKind { + group: String::new(), + version: String::from("v1"), + api_version: String::from("v1"), + kind: String::from("Service"), + plural: String::from("services"), + scope: ResourceScope::Namespaced, + }; + let pod = ResourceKind { + group: String::new(), + version: String::from("v1"), + api_version: String::from("v1"), + kind: String::from("Pod"), + plural: String::from("pods"), + scope: ResourceScope::Namespaced, + }; + + let service_columns = offerable_columns_for(Some(&service)); + assert!(service_columns.contains(&ObjectColumn::Target)); + assert!(service_columns.contains(&ObjectColumn::Selector)); + let pod_columns = offerable_columns_for(Some(&pod)); + assert!(!pod_columns.contains(&ObjectColumn::Target)); + assert!(!pod_columns.contains(&ObjectColumn::Selector)); + } + + #[test] + fn offerable_columns_show_target_and_class_only_for_ingresses() { + let ingress = ResourceKind { + group: String::from("networking.k8s.io"), + version: String::from("v1"), + api_version: String::from("networking.k8s.io/v1"), + kind: String::from("Ingress"), + plural: String::from("ingresses"), + scope: ResourceScope::Namespaced, + }; + let service = ResourceKind { + group: String::new(), + version: String::from("v1"), + api_version: String::from("v1"), + kind: String::from("Service"), + plural: String::from("services"), + scope: ResourceScope::Namespaced, + }; + + let ingress_columns = offerable_columns_for(Some(&ingress)); + assert!(ingress_columns.contains(&ObjectColumn::Target)); + assert!(ingress_columns.contains(&ObjectColumn::IngressClass)); + assert!(!ingress_columns.contains(&ObjectColumn::Selector)); + assert!(!offerable_columns_for(Some(&service)).contains(&ObjectColumn::IngressClass)); + } } diff --git a/crates/aetheris-app/src/app/widgets.rs b/crates/aetheris-app/src/app/widgets.rs index fba7b14..659fe32 100644 --- a/crates/aetheris-app/src/app/widgets.rs +++ b/crates/aetheris-app/src/app/widgets.rs @@ -806,6 +806,21 @@ pub(super) fn object_column_sorter(column: ObjectColumn) -> Option Some(summary_sorter(|a, b| { + a.service_target + .cmp(&b.service_target) + .then_with(|| a.name.cmp(&b.name)) + })), + ObjectColumn::Selector => Some(summary_sorter(|a, b| { + a.service_selector + .cmp(&b.service_selector) + .then_with(|| a.name.cmp(&b.name)) + })), + ObjectColumn::IngressClass => Some(summary_sorter(|a, b| { + a.ingress_class + .cmp(&b.ingress_class) + .then_with(|| a.name.cmp(&b.name)) + })), ObjectColumn::Cpu | ObjectColumn::Memory => Some(summary_sorter(move |a, b| { // Usage percentage is the primary key; raw quantity only breaks // ties among objects with no percentage (no requests set), and @@ -976,6 +991,22 @@ pub(super) fn object_data_column_factory(column: ObjectColumn) -> gtk::SignalLis let object = boxed.borrow::(); let (text, tooltip) = match column { ObjectColumn::Namespace => (object.namespace.clone(), None), + ObjectColumn::Target => { + let target = object_target(&object); + ( + target.to_owned(), + (!target.is_empty()).then(|| target.to_owned()), + ) + } + ObjectColumn::Selector => ( + object.service_selector.clone(), + (!object.service_selector.is_empty()) + .then(|| object.service_selector.clone()), + ), + ObjectColumn::IngressClass => ( + object.ingress_class.clone(), + (!object.ingress_class.is_empty()).then(|| object.ingress_class.clone()), + ), ObjectColumn::Image => { let Some(main_image) = super::utils::pod_main_image(&object.images) else { return; @@ -1016,6 +1047,14 @@ pub(super) fn object_data_column_factory(column: ObjectColumn) -> gtk::SignalLis factory } +fn object_target(object: &ObjectSummary) -> &str { + if object.service_target.is_empty() { + &object.ingress_target + } else { + &object.service_target + } +} + /// Whether a status string is actual information rather than the "no /// status data" placeholder (e.g. ControllerRevision, which has no status /// subresource at all). diff --git a/crates/aetheris-kube/src/lib.rs b/crates/aetheris-kube/src/lib.rs index 229690a..e707994 100644 --- a/crates/aetheris-kube/src/lib.rs +++ b/crates/aetheris-kube/src/lib.rs @@ -19,7 +19,8 @@ pub use manager::KubeManager; pub use session::KubeSession; pub use types::{ AddClusterRequest, ClusterSummary, ContainerResources, ContainerUsage, ContextInfo, - ObjectCondition, ObjectDetail, ObjectEvent, ObjectSummary, ObjectWatchEvent, PodExecEvent, - PodExecRequest, PodLogRequest, PodPortForwardEvent, PodPortForwardRequest, PodStateCount, - PodSummary, ResourceKind, ResourceRatio, ResourceScope, ResourceUsage, + IngressRule, ObjectCondition, ObjectDetail, ObjectEvent, ObjectSummary, ObjectWatchEvent, + PodExecEvent, PodExecRequest, PodLogRequest, PodPortForwardEvent, PodPortForwardRequest, + PodStateCount, PodSummary, ResourceKind, ResourceRatio, ResourceScope, ResourceUsage, + ServicePort, ServiceSelector, }; diff --git a/crates/aetheris-kube/src/objects.rs b/crates/aetheris-kube/src/objects.rs index 1e32596..5120cc1 100644 --- a/crates/aetheris-kube/src/objects.rs +++ b/crates/aetheris-kube/src/objects.rs @@ -10,9 +10,9 @@ use serde_json::Value; use crate::status::{age_label, status_label}; use crate::{ - ContainerResources, KubeSession, ObjectCondition, ObjectDetail, ObjectSummary, + ContainerResources, IngressRule, KubeSession, ObjectCondition, ObjectDetail, ObjectSummary, ObjectWatchEvent, PodStateCount, PodSummary, ResourceKind, ResourceRatio, ResourceScope, - ResourceUsage, api_resource, namespace_scope, resource_scope, + ResourceUsage, ServicePort, ServiceSelector, api_resource, namespace_scope, resource_scope, }; impl KubeSession { @@ -232,6 +232,8 @@ impl KubeSession { .deployment_pods(resource, &summary.namespace, &object) .await .unwrap_or_default(); + let (service_ports, service_selectors) = service_details(&object, resource); + let ingress_rules = ingress_rules(&object, resource); let events = self .object_events(resource, &summary.namespace, name) .await @@ -255,6 +257,9 @@ impl KubeSession { containers, related_pods: deployment_pods.summaries, related_pod_states: deployment_pods.states, + service_ports, + service_selectors, + ingress_rules, replicas, node_unschedulable, conditions, @@ -316,6 +321,10 @@ impl KubeSession { api_version: String::from("v1"), age: String::from("-"), images: Vec::new(), + service_target: String::new(), + service_selector: String::new(), + ingress_target: String::new(), + ingress_class: String::new(), metrics: None, }) }) @@ -452,9 +461,195 @@ fn object_summary( api_version: resource.api_version.clone(), age, images: object_images(&object, resource), + service_target: service_target(&object, resource), + service_selector: service_selector(&object, resource), + ingress_target: ingress_target(&object, resource), + ingress_class: ingress_class(&object, resource), } } +fn is_ingress(resource: &ResourceKind) -> bool { + resource.group == "networking.k8s.io" && resource.kind == "Ingress" +} + +fn ingress_target(object: &DynamicObject, resource: &ResourceKind) -> String { + ingress_rules(object, resource) + .into_iter() + .map(|rule| { + format!( + "{}{} → {}:{}", + rule.host, rule.path, rule.service, rule.port + ) + }) + .collect::>() + .join(", ") +} + +fn ingress_class(object: &DynamicObject, resource: &ResourceKind) -> String { + if !is_ingress(resource) { + return String::new(); + } + + object + .data + .get("spec") + .and_then(|spec| spec.get("ingressClassName")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned() +} + +fn ingress_rules(object: &DynamicObject, resource: &ResourceKind) -> Vec { + if !is_ingress(resource) { + return Vec::new(); + } + + let Some(spec) = object.data.get("spec") else { + return Vec::new(); + }; + let mut rules = Vec::new(); + if let Some(backend) = spec.get("defaultBackend") + && let Some(rule) = ingress_rule_from_backend(backend, "*", "/", "Default") + { + rules.push(rule); + } + if let Some(ingress_rules) = spec.get("rules").and_then(Value::as_array) { + for rule in ingress_rules { + let host = rule.get("host").and_then(Value::as_str).unwrap_or("*"); + let Some(paths) = rule + .get("http") + .and_then(|http| http.get("paths")) + .and_then(Value::as_array) + else { + continue; + }; + for path in paths { + let path_value = path.get("path").and_then(Value::as_str).unwrap_or("/"); + let path_type = path + .get("pathType") + .and_then(Value::as_str) + .unwrap_or("ImplementationSpecific"); + if let Some(rule) = path.get("backend").and_then(|backend| { + ingress_rule_from_backend(backend, host, path_value, path_type) + }) { + rules.push(rule); + } + } + } + } + rules +} + +fn ingress_rule_from_backend( + backend: &Value, + host: &str, + path: &str, + path_type: &str, +) -> Option { + let service = backend.get("service")?; + let service_name = service.get("name").and_then(Value::as_str)?; + let port = service.get("port")?; + let port = value_string(port.get("name")).or_else(|| value_string(port.get("number")))?; + Some(IngressRule { + host: host.to_owned(), + path: path.to_owned(), + path_type: path_type.to_owned(), + service: service_name.to_owned(), + port, + }) +} + +fn service_target(object: &DynamicObject, resource: &ResourceKind) -> String { + service_ports(object, resource) + .into_iter() + .map(|port| format!("{}:{}/{}", port.port, port.target_port, port.protocol)) + .collect::>() + .join(", ") +} + +fn service_selector(object: &DynamicObject, resource: &ResourceKind) -> String { + service_selectors(object, resource) + .into_iter() + .map(|selector| format!("{}={}", selector.key, selector.value)) + .collect::>() + .join(", ") +} + +fn service_details( + object: &DynamicObject, + resource: &ResourceKind, +) -> (Vec, Vec) { + ( + service_ports(object, resource), + service_selectors(object, resource), + ) +} + +fn service_ports(object: &DynamicObject, resource: &ResourceKind) -> Vec { + if resource.kind != "Service" || !resource.group.is_empty() { + return Vec::new(); + } + + object + .data + .get("spec") + .and_then(|spec| spec.get("ports")) + .and_then(Value::as_array) + .map(|ports| { + ports + .iter() + .filter_map(|port| { + let port_number = value_string(port.get("port"))?; + let target_port = + value_string(port.get("targetPort")).unwrap_or_else(|| port_number.clone()); + Some(ServicePort { + name: value_string(port.get("name")).unwrap_or_default(), + protocol: value_string(port.get("protocol")) + .unwrap_or_else(|| String::from("TCP")), + port: port_number, + target_port, + node_port: value_string(port.get("nodePort")), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn service_selectors(object: &DynamicObject, resource: &ResourceKind) -> Vec { + if resource.kind != "Service" || !resource.group.is_empty() { + return Vec::new(); + } + + let mut selectors = object + .data + .get("spec") + .and_then(|spec| spec.get("selector")) + .and_then(Value::as_object) + .map(|selectors| { + selectors + .iter() + .filter_map(|(key, value)| { + value_string(Some(value)).map(|value| ServiceSelector { + key: key.clone(), + value, + }) + }) + .collect::>() + }) + .unwrap_or_default(); + selectors.sort_by(|left, right| left.key.cmp(&right.key)); + selectors +} + +fn value_string(value: Option<&Value>) -> Option { + value.and_then(|value| match value { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + _ => None, + }) +} + fn attach_resource_ratios( usage: &mut ResourceUsage, object: &DynamicObject, @@ -691,10 +886,13 @@ fn deployment_label_selector(object: &DynamicObject) -> Option { #[cfg(test)] mod tests { use super::{ - object_container_resources, object_images, pod_state_counts, quantity_as_f64, - resource_ratio, + ingress_class, ingress_rules, ingress_target, object_container_resources, object_images, + pod_state_counts, quantity_as_f64, resource_ratio, service_details, service_selector, + service_target, + }; + use crate::{ + ContainerResources, IngressRule, ResourceKind, ResourceScope, ServicePort, ServiceSelector, }; - use crate::{ContainerResources, ResourceKind, ResourceScope}; use k8s_openapi::api::core::v1::Pod; use kube::api::DynamicObject; use serde_json::json; @@ -859,4 +1057,137 @@ mod tests { ] ); } + + #[test] + fn service_details_extract_ports_targets_and_sorted_selectors() { + let service: DynamicObject = serde_json::from_value(json!({ + "apiVersion": "v1", + "kind": "Service", + "spec": { + "selector": {"tier": "frontend", "app": "web"}, + "ports": [ + {"name": "http", "port": 80, "targetPort": 8080, "protocol": "TCP"}, + {"name": "metrics", "port": 9090, "targetPort": "metrics", "protocol": "TCP", "nodePort": 32090} + ] + } + })) + .expect("service should deserialize"); + let resource = ResourceKind { + group: String::new(), + version: String::from("v1"), + api_version: String::from("v1"), + kind: String::from("Service"), + plural: String::from("services"), + scope: ResourceScope::Namespaced, + }; + + let (ports, selectors) = service_details(&service, &resource); + assert_eq!( + ports, + vec![ + ServicePort { + name: String::from("http"), + protocol: String::from("TCP"), + port: String::from("80"), + target_port: String::from("8080"), + node_port: None, + }, + ServicePort { + name: String::from("metrics"), + protocol: String::from("TCP"), + port: String::from("9090"), + target_port: String::from("metrics"), + node_port: Some(String::from("32090")), + }, + ] + ); + assert_eq!( + selectors, + vec![ + ServiceSelector { + key: String::from("app"), + value: String::from("web") + }, + ServiceSelector { + key: String::from("tier"), + value: String::from("frontend") + }, + ] + ); + assert_eq!( + service_target(&service, &resource), + "80:8080/TCP, 9090:metrics/TCP" + ); + assert_eq!( + service_selector(&service, &resource), + "app=web, tier=frontend" + ); + } + + #[test] + fn ingress_rules_extract_targets_and_ingress_class() { + let ingress: DynamicObject = serde_json::from_value(json!({ + "apiVersion": "networking.k8s.io/v1", + "kind": "Ingress", + "spec": { + "ingressClassName": "nginx", + "defaultBackend": { + "service": {"name": "fallback", "port": {"number": 80}} + }, + "rules": [{ + "host": "app.example.com", + "http": {"paths": [{ + "path": "/api", + "pathType": "Prefix", + "backend": {"service": {"name": "api", "port": {"number": 8080}}} + }, { + "path": "/metrics", + "pathType": "Exact", + "backend": {"service": {"name": "metrics", "port": {"name": "http"}}} + }]} + }] + } + })) + .expect("ingress should deserialize"); + let resource = ResourceKind { + group: String::from("networking.k8s.io"), + version: String::from("v1"), + api_version: String::from("networking.k8s.io/v1"), + kind: String::from("Ingress"), + plural: String::from("ingresses"), + scope: ResourceScope::Namespaced, + }; + + assert_eq!(ingress_class(&ingress, &resource), "nginx"); + assert_eq!( + ingress_rules(&ingress, &resource), + vec![ + IngressRule { + host: String::from("*"), + path: String::from("/"), + path_type: String::from("Default"), + service: String::from("fallback"), + port: String::from("80"), + }, + IngressRule { + host: String::from("app.example.com"), + path: String::from("/api"), + path_type: String::from("Prefix"), + service: String::from("api"), + port: String::from("8080"), + }, + IngressRule { + host: String::from("app.example.com"), + path: String::from("/metrics"), + path_type: String::from("Exact"), + service: String::from("metrics"), + port: String::from("http"), + }, + ] + ); + assert_eq!( + ingress_target(&ingress, &resource), + "*/ → fallback:80, app.example.com/api → api:8080, app.example.com/metrics → metrics:http" + ); + } } diff --git a/crates/aetheris-kube/src/types.rs b/crates/aetheris-kube/src/types.rs index a4ffb03..bf235c3 100644 --- a/crates/aetheris-kube/src/types.rs +++ b/crates/aetheris-kube/src/types.rs @@ -63,6 +63,16 @@ pub struct ObjectSummary { pub age: String, #[serde(default)] pub images: Vec, + /// Service-only summary values. Empty for other resource kinds. + #[serde(default)] + pub service_target: String, + #[serde(default)] + pub service_selector: String, + /// Ingress-only summary values. Empty for other resource kinds. + #[serde(default)] + pub ingress_target: String, + #[serde(default)] + pub ingress_class: String, pub metrics: Option, } @@ -84,6 +94,12 @@ pub struct ObjectDetail { /// Counts of Pods selected by a Deployment, grouped by their Kubernetes /// lifecycle phase (Running, Pending, Succeeded, Failed, or Unknown). pub related_pod_states: Vec, + #[serde(default)] + pub service_ports: Vec, + #[serde(default)] + pub service_selectors: Vec, + #[serde(default)] + pub ingress_rules: Vec, pub replicas: Option, pub node_unschedulable: Option, pub conditions: Vec, @@ -97,6 +113,30 @@ pub struct PodStateCount { pub count: u32, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServicePort { + pub name: String, + pub protocol: String, + pub port: String, + pub target_port: String, + pub node_port: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServiceSelector { + pub key: String, + pub value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IngressRule { + pub host: String, + pub path: String, + pub path_type: String, + pub service: String, + pub port: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ObjectCondition { pub type_: String,