diff --git a/ROADMAP.md b/ROADMAP.md index 3213d7c..cbfe8e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -194,8 +194,10 @@ Root problem: full object JSON kept for every entity. full k9s readonly surface vs krust (grounded in the k9s README keybindings + commands docs) and marks each ✅/🟡/❌/⛔. Mutations explicitly scoped out (readonly-first v1). Prioritized gaps to close next, highest value first: - - **Enter drill-down** (deploy/rs/sts/ds→pods, node→pods, svc→endpoints/pods) — biggest gap; the - owner→pod mapping already exists (log fan-in). + - [x] **Enter drill-down** — DONE (deploy/rs/sts/ds→pods via owner chain, node→pods via + scheduled-node). Live filter on the Pods view (`DrillFilter` in `ViewRequest`), `[DRILL]` title, + `esc` pops back to the owner list; cleared on any kind/namespace change. (svc→endpoints deferred + — needs the service selector, not currently extracted.) - **Previous logs** (`--previous` toggle) for crashloops. - **xray** relationship-tree view (replace stub). - Medium: jump-to-owner (`Shift-J`), UsedBy/dependents (`U`), log timestamps, pod metric-column diff --git a/docs/operator-guide.md b/docs/operator-guide.md index 347cf94..16d9138 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -52,7 +52,9 @@ krust --all-contexts - `tab` / `shift+tab`: switch context tabs - `j` / `k` (or arrows): move selection (table) / scroll (detail, logs) - `g` / `G`: top / bottom (in detail panes use `gg` for top) -- `Enter`: select namespace in namespace view; otherwise open describe +- `Enter`: drill down where it makes sense — namespace → its resources; Deployment/ReplicaSet/ + StatefulSet/DaemonSet → their pods; Node → pods scheduled on it. Other kinds open describe. + In a drill-down the title shows `[DRILL] → Pods`; `esc` pops back to the owner list. - `d`: describe selected resource (toggles back to table) - `v`: view YAML; `t`: back to table; `E`: events pane (this resource's events); `l`: logs - `n`: cycle namespace; `s`: cycle sort column; `r`: reverse sort order diff --git a/src/ui/app.rs b/src/ui/app.rs index f1dd94b..6c5de1a 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -70,7 +70,9 @@ use crate::{ StateDelta, }, state::StateStore, - view::{SimpleViewProjector, ViewModel, ViewProjector, ViewRequest, materialize_row}, + view::{ + DrillFilter, SimpleViewProjector, ViewModel, ViewProjector, ViewRequest, materialize_row, + }, }; mod bench; @@ -99,6 +101,9 @@ struct ContextTabState { descending: bool, /// Show Helm release secrets in the Secrets list (default false — they're hidden clutter). show_helm_secrets: bool, + /// Active Enter drill-down (Pods view scoped to an owner). Cleared on kind/namespace change + /// or Esc in the table. + drill: Option, pane: Pane, } @@ -622,6 +627,12 @@ impl App { sort: tab.sort, descending: tab.descending, show_helm_secrets: tab.show_helm_secrets, + // Drill-down only applies to the Pods view. + drill: if tab.kind() == ResourceKind::Pods { + tab.drill.clone() + } else { + None + }, } } @@ -904,6 +915,7 @@ impl App { sort: SortColumn::Name, descending: false, show_helm_secrets: false, + drill: None, pane: Pane::Table, }) .collect(); @@ -1275,6 +1287,11 @@ impl App { self.status_line = "Closed view".to_string(); } else if self.clear_active_filter_value() { self.status_line = self.filter_status_message(""); + } else if let Some(owner_kind) = self.current_tab().drill.as_ref().map(|d| d.owner_kind) + { + // Pop the drill-down back to the owner's list (krust has no view stack). + self.set_active_kind(owner_kind); + self.status_line = format!("Drill-down cleared → {owner_kind}"); } else { self.status_line = "Nothing to cancel".to_string(); } @@ -1531,6 +1548,7 @@ impl App { tab.selected = 0; tab.detail_scroll = 0; tab.detail_hscroll = 0; + tab.drill = None; tab.pane = Pane::Table; self.overlay = None; } @@ -1548,6 +1566,7 @@ impl App { tab.selected = 0; tab.detail_scroll = 0; tab.detail_hscroll = 0; + tab.drill = None; tab.pane = Pane::Table; self.overlay = None; } @@ -1561,6 +1580,7 @@ impl App { let tab = self.current_tab_mut(); tab.namespace = None; tab.selected = 0; + tab.drill = None; tab.pane = Pane::Table; self.status_line = "Namespace filter cleared".to_string(); return; @@ -1584,6 +1604,7 @@ impl App { } } tab.selected = 0; + tab.drill = None; tab.pane = Pane::Table; tab.namespace.clone() }; @@ -1672,6 +1693,7 @@ impl App { tab.selected = 0; tab.detail_scroll = 0; tab.detail_hscroll = 0; + tab.drill = None; tab.pane = Pane::Table; tab.kind().to_string() }; @@ -1723,6 +1745,18 @@ impl App { }); } + // A Deployment drill-down resolves pods transitively through their ReplicaSet, so the RS + // for the drilled namespace must be watched too (otherwise the chain finds nothing). + if tab.kind() == ResourceKind::Pods + && let Some(drill) = &tab.drill + && drill.owner_kind == ResourceKind::Deployments + { + set.insert(WatchTarget { + kind: ResourceKind::ReplicaSets, + namespace: tab.namespace.clone(), + }); + } + let mut out: Vec = set.into_iter().collect(); out.sort_by(|a, b| { a.kind @@ -3211,6 +3245,73 @@ mod tests { assert!(snap.contains("helm shown"), "title hint: {snap}"); } + #[test] + fn enter_on_deployment_drills_into_its_pods() { + let mut app = test_app(); + let dep_idx = ResourceKind::ORDERED + .iter() + .position(|k| *k == ResourceKind::Deployments) + .expect("deployments in ORDERED"); + app.current_tab_mut().kind_idx = dep_idx; + + // dep1 -> rs1 -> p1 ; unrelated p2 + app.store.apply(StateDelta::Upsert(mk_entity( + "ctx-dev", + ResourceKind::Deployments, + Some("default"), + "dep1", + "1/1 ready", + ))); + let mut rs = mk_entity( + "ctx-dev", + ResourceKind::ReplicaSets, + Some("default"), + "rs1", + "-", + ); + rs.extracted.owners = vec![crate::model::OwnerRef { + kind: "Deployment".to_string(), + name: "dep1".to_string(), + }]; + app.store.apply(StateDelta::Upsert(rs)); + let mut p1 = mk_entity( + "ctx-dev", + ResourceKind::Pods, + Some("default"), + "p1", + "Running", + ); + p1.extracted.owners = vec![crate::model::OwnerRef { + kind: "ReplicaSet".to_string(), + name: "rs1".to_string(), + }]; + app.store.apply(StateDelta::Upsert(p1)); + app.store.apply(StateDelta::Upsert(mk_entity( + "ctx-dev", + ResourceKind::Pods, + Some("default"), + "p2", + "Running", + ))); + + app.handle_enter_key(); + + // Switched to a Pods view scoped to dep1. + assert_eq!(app.current_tab().kind(), ResourceKind::Pods); + let drill = app.current_tab().drill.as_ref().expect("drill set"); + assert_eq!(drill.owner_kind, ResourceKind::Deployments); + assert_eq!(drill.owner_name, "dep1"); + + let snap = render_snapshot(&mut app, 140, 20); + assert!( + snap.contains("[DRILL] deploy/dep1"), + "drill indicator: {snap}" + ); + // p1 (owned by dep1 via rs1) shows; the unrelated p2 is filtered out. + assert!(snap.contains("default"), "owned pod row present: {snap}"); + assert!(!snap.contains("p2"), "unrelated pod hidden: {snap}"); + } + #[test] fn events_pane_shows_correlated_events_for_a_pod() { let mut app = test_app(); diff --git a/src/ui/app/command_mode.rs b/src/ui/app/command_mode.rs index 0a22c59..3a5f534 100644 --- a/src/ui/app/command_mode.rs +++ b/src/ui/app/command_mode.rs @@ -1,5 +1,19 @@ use super::*; +/// Kinds whose Enter drills into a filtered Pods view. Returns the owner kind itself when it +/// supports drill-down, else None (leaf kinds fall through to describe). +fn drill_owner_kind(kind: ResourceKind) -> Option { + matches!( + kind, + ResourceKind::Deployments + | ResourceKind::ReplicaSets + | ResourceKind::StatefulSets + | ResourceKind::DaemonSets + | ResourceKind::Nodes + ) + .then_some(kind) +} + impl App { pub(super) fn handle_enter_key(&mut self) { if self.current_tab().pane != Pane::Table { @@ -41,12 +55,47 @@ impl App { return; } + // Drill-down: Enter on a workload or node opens its pods (filtered Pods view). + if drill_owner_kind(row.key.kind).is_some() { + self.drill_into(&row); + return; + } + self.current_tab_mut().pane = Pane::Describe; self.current_tab_mut().detail_scroll = 0; self.current_tab_mut().detail_hscroll = 0; self.status_line = format!("Describe: {} {}", row.key.kind.short_name(), row.key.name); } + /// Switch to the Pods view scoped to the selected owner (Deployment/RS/STS/DS/Node). + fn drill_into(&mut self, row: &crate::view::ViewRow) { + let pods_idx = ResourceKind::ORDERED + .iter() + .position(|kind| *kind == ResourceKind::Pods) + .unwrap_or(0); + let owner_kind = row.key.kind; + let owner_name = row.key.name.clone(); + let label = format!("{}/{}", owner_kind.short_name(), owner_name); + let tab = self.current_tab_mut(); + // Node pods span namespaces; workload pods live in the owner's namespace. + if owner_kind != ResourceKind::Nodes { + tab.namespace = row.key.namespace.clone(); + } else { + tab.namespace = None; + } + tab.drill = Some(DrillFilter { + owner_kind, + owner_name, + }); + tab.kind_idx = pods_idx; + tab.last_non_namespace_kind_idx = pods_idx; + tab.selected = 0; + tab.table_offset = 0; + tab.pane = Pane::Table; + self.overlay = None; + self.status_line = format!("Drill-down: pods of {label} (esc to clear)"); + } + pub(super) async fn handle_command_key(&mut self, key: KeyEvent) -> anyhow::Result { match key.code { KeyCode::Esc => { diff --git a/src/ui/app/render_loop.rs b/src/ui/app/render_loop.rs index 07e12e0..d58116b 100644 --- a/src/ui/app/render_loop.rs +++ b/src/ui/app/render_loop.rs @@ -769,12 +769,22 @@ impl App { (header, widths) }; let title = if pods_view { - let legend = if self.pod_metrics.is_empty() { - "CPU/MEM: metrics-server n/a" + if let Some(drill) = &active.drill { + // Drill-down: lead with the owner so it's always visible (esc clears). + format!( + "[DRILL] {}/{} → Pods ({}) · esc clears", + drill.owner_kind.short_name(), + drill.owner_name, + vm.len() + ) } else { - "CPU/MEM used · CR/CL = cpu %req/%lim · MR/ML = mem %req/%lim" - }; - format!("[KIND] {} ({}) · {legend}", active.kind(), vm.len()) + let legend = if self.pod_metrics.is_empty() { + "CPU/MEM: metrics-server n/a" + } else { + "CPU/MEM used · CR/CL = cpu %req/%lim · MR/ML = mem %req/%lim" + }; + format!("[KIND] {} ({}) · {legend}", active.kind(), vm.len()) + } } else if active.kind() == ResourceKind::Secrets { let helm = if active.show_helm_secrets { format!( diff --git a/src/view/mod.rs b/src/view/mod.rs index ea7d806..2303cdd 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -1,5 +1,6 @@ mod projector; pub use projector::{ - SimpleViewProjector, ViewModel, ViewProjector, ViewRequest, ViewRow, materialize_row, + DrillFilter, SimpleViewProjector, ViewModel, ViewProjector, ViewRequest, ViewRow, + materialize_row, }; diff --git a/src/view/projector.rs b/src/view/projector.rs index ca3d2cf..3ad0d21 100644 --- a/src/view/projector.rs +++ b/src/view/projector.rs @@ -16,6 +16,64 @@ pub struct ViewRequest { /// When false (default), Helm release secrets (`type: helm.sh/release.v1`) are hidden from the /// Secrets list to cut clutter. No effect on other kinds. pub show_helm_secrets: bool, + /// When set (Pods view only), restrict the list to pods belonging to an owning resource — + /// a workload (Deployment/ReplicaSet/StatefulSet/DaemonSet) or a Node. Drives Enter drill-down. + pub drill: Option, +} + +/// Identifies an owner whose child pods a drill-down view should show. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DrillFilter { + pub owner_kind: ResourceKind, + pub owner_name: String, +} + +impl DrillFilter { + /// True if `pod` belongs to this owner. For Deployments the ownership is transitive through + /// the pod's ReplicaSet (resolved via `store`); other workloads and Nodes match directly. + fn matches( + &self, + store: &StateStore, + context: &str, + pod: &crate::model::ResourceEntity, + ) -> bool { + match self.owner_kind { + ResourceKind::ReplicaSets => pod.extracted.owned_by("ReplicaSet", &self.owner_name), + ResourceKind::StatefulSets => pod.extracted.owned_by("StatefulSet", &self.owner_name), + ResourceKind::DaemonSets => pod.extracted.owned_by("DaemonSet", &self.owner_name), + ResourceKind::Nodes => { + pod.extracted.node_name.as_deref() == Some(self.owner_name.as_str()) + } + ResourceKind::Deployments => { + let prefix = format!("{}-", self.owner_name); + pod.extracted + .owners + .iter() + .filter(|owner| owner.kind == "ReplicaSet") + .any(|owner| { + // Authoritative: the RS is in the store and owned by this deployment. + let rs_key = ResourceKey::new( + context, + ResourceKind::ReplicaSets, + pod.key.namespace.clone(), + owner.name.clone(), + ); + let by_store = store.get(&rs_key).is_some_and(|rs| { + rs.extracted.owned_by("Deployment", &self.owner_name) + }); + // Fallback when the RS isn't loaded yet (or RBAC-denied): a deployment's + // RS is always named `-`, hash having no `-`, + // which keeps `web` from matching `web-api`'s ReplicaSets. + let by_name = owner + .name + .strip_prefix(&prefix) + .is_some_and(|hash| !hash.is_empty() && !hash.contains('-')); + by_store || by_name + }) + } + _ => true, + } + } } #[derive(Debug, Clone)] @@ -71,6 +129,13 @@ impl ViewProjector for SimpleViewProjector { entities.retain(|entity| !entity.is_helm_release()); } + // Enter drill-down: restrict the Pods view to one owner's pods. + if request.kind == ResourceKind::Pods + && let Some(drill) = &request.drill + { + entities.retain(|entity| drill.matches(store, &request.context, entity)); + } + entities.sort_by(|a, b| { let ord = match request.sort { SortColumn::Name => a.key.name.cmp(&b.key.name), @@ -288,6 +353,7 @@ mod tests { sort: SortColumn::Name, descending: false, show_helm_secrets: false, + drill: None, }, ); @@ -296,6 +362,121 @@ mod tests { assert_eq!(row.name, "worker"); } + fn put_entity( + store: &mut StateStore, + kind: ResourceKind, + name: &str, + extracted: crate::model::Extracted, + ) { + store.apply(StateDelta::Upsert(ResourceEntity { + key: ResourceKey::new("ctx", kind, Some("ns".to_string()), name), + status: "-".to_string(), + age: Some(Utc::now()), + labels: vec![], + columns: vec![], + extracted, + })); + } + + fn owner(kind: &str, name: &str) -> crate::model::OwnerRef { + crate::model::OwnerRef { + kind: kind.to_string(), + name: name.to_string(), + } + } + + #[test] + fn drill_down_filters_pods_by_owner_chain_and_node() { + use crate::model::Extracted; + let mut store = StateStore::default(); + // dep1 -> rs1 -> p1 ; unrelated p2 ; p3 on node-a + put_entity( + &mut store, + ResourceKind::ReplicaSets, + "rs1", + Extracted { + owners: vec![owner("Deployment", "dep1")], + ..Default::default() + }, + ); + put_entity( + &mut store, + ResourceKind::Pods, + "p1", + Extracted { + owners: vec![owner("ReplicaSet", "rs1")], + ..Default::default() + }, + ); + put_entity(&mut store, ResourceKind::Pods, "p2", Extracted::default()); + put_entity( + &mut store, + ResourceKind::Pods, + "p3", + Extracted { + node_name: Some("node-a".to_string()), + ..Default::default() + }, + ); + // p4's ReplicaSet ("dep1-77c") isn't in the store — must still match dep1 by name convention. + put_entity( + &mut store, + ResourceKind::Pods, + "p4", + Extracted { + owners: vec![owner("ReplicaSet", "dep1-77c")], + ..Default::default() + }, + ); + + let projector = SimpleViewProjector; + let req = |drill: Option| ViewRequest { + context: "ctx".to_string(), + kind: ResourceKind::Pods, + namespace: None, + filter: String::new(), + sort: SortColumn::Name, + descending: false, + show_helm_secrets: false, + drill, + }; + let names = + |vm: ViewModel| -> Vec { vm.order.iter().map(|k| k.name.clone()).collect() }; + + // Deployment ownership: p1 (transitive via stored rs1) + p4 (RS not stored, name fallback). + let dep = projector.project( + &store, + &req(Some(DrillFilter { + owner_kind: ResourceKind::Deployments, + owner_name: "dep1".to_string(), + })), + ); + assert_eq!(names(dep), vec!["p1".to_string(), "p4".to_string()]); + + // Direct ReplicaSet ownership: only p1. + let rs = projector.project( + &store, + &req(Some(DrillFilter { + owner_kind: ResourceKind::ReplicaSets, + owner_name: "rs1".to_string(), + })), + ); + assert_eq!(names(rs), vec!["p1".to_string()]); + + // Node scheduling: only p3. + let node = projector.project( + &store, + &req(Some(DrillFilter { + owner_kind: ResourceKind::Nodes, + owner_name: "node-a".to_string(), + })), + ); + assert_eq!(names(node), vec!["p3".to_string()]); + + // No drill: all four pods. + assert_eq!(names(projector.project(&store, &req(None))).len(), 4); + } + fn put_secret(store: &mut StateStore, name: &str, secret_type: &str) { store.apply(StateDelta::Upsert(ResourceEntity { key: ResourceKey::new("ctx", ResourceKind::Secrets, Some("ns".to_string()), name), @@ -326,6 +507,7 @@ mod tests { sort: SortColumn::Name, descending: false, show_helm_secrets, + drill: None, }; // Default: the helm release secret is filtered out. @@ -364,6 +546,7 @@ mod tests { sort: SortColumn::Name, descending: false, show_helm_secrets: false, + drill: None, }, ) .order @@ -432,6 +615,7 @@ mod tests { sort: SortColumn::Name, descending: false, show_helm_secrets: false, + drill: None, }; let start = Instant::now();