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
6 changes: 4 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/operator-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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] <owner> → 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
Expand Down
103 changes: 102 additions & 1 deletion src/ui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<DrillFilter>,
pane: Pane,
}

Expand Down Expand Up @@ -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
},
}
}

Expand Down Expand Up @@ -904,6 +915,7 @@ impl App {
sort: SortColumn::Name,
descending: false,
show_helm_secrets: false,
drill: None,
pane: Pane::Table,
})
.collect();
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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;
Expand All @@ -1584,6 +1604,7 @@ impl App {
}
}
tab.selected = 0;
tab.drill = None;
tab.pane = Pane::Table;
tab.namespace.clone()
};
Expand Down Expand Up @@ -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()
};
Expand Down Expand Up @@ -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<WatchTarget> = set.into_iter().collect();
out.sort_by(|a, b| {
a.kind
Expand Down Expand Up @@ -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();
Expand Down
49 changes: 49 additions & 0 deletions src/ui/app/command_mode.rs
Original file line number Diff line number Diff line change
@@ -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<ResourceKind> {
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 {
Expand Down Expand Up @@ -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<bool> {
match key.code {
KeyCode::Esc => {
Expand Down
20 changes: 15 additions & 5 deletions src/ui/app/render_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
3 changes: 2 additions & 1 deletion src/view/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod projector;

pub use projector::{
SimpleViewProjector, ViewModel, ViewProjector, ViewRequest, ViewRow, materialize_row,
DrillFilter, SimpleViewProjector, ViewModel, ViewProjector, ViewRequest, ViewRow,
materialize_row,
};
Loading
Loading