From e0508def67adf8098cdd2c8696a1fa7a0624787d Mon Sep 17 00:00:00 2001 From: Erfan Yousefi Date: Thu, 25 Jun 2026 17:57:30 +0200 Subject: [PATCH] feat(ui): previous-instance logs toggle (P) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the logs pane, `P` (shift+p) toggles between the current and the previous (terminated) container instance's logs — `kubectl logs -p`, for debugging crashlooping containers. Closes gap #2 from the k9s parity audit (4.7). - One-shot fetch: previous logs don't follow and don't auto-reconnect (the container is gone), so the reconnect loop is skipped while previous is active. - Refetches on toggle (clears the buffer); a fresh log view always starts on the current instance. - Status line shows `instance:current|previous`. The PodLogRequest.previous plumbing already existed; this wires the UI to it. Test: toggle flips the flag, resets the buffer, and the title reflects the instance. Co-Authored-By: Claude Opus 4.8 (1M context) --- ROADMAP.md | 4 +++- docs/operator-guide.md | 4 ++++ src/ui/app.rs | 53 ++++++++++++++++++++++++++++++++++++++++++ src/ui/app/logs.rs | 39 +++++++++++++++++++++++++++---- 4 files changed, 94 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index cbfe8e0..cb1acf6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -198,7 +198,9 @@ Root problem: full object JSON kept for every entity. 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. + - [x] **Previous logs** — DONE. `P` in the logs pane toggles the previous (terminated) container + instance (`kubectl logs -p`) as a one-shot fetch (no follow/reconnect); status line shows + `instance:current|previous`. Plumbing (`PodLogRequest.previous`) already existed. - **xray** relationship-tree view (replace stub). - Medium: jump-to-owner (`Shift-J`), UsedBy/dependents (`U`), log timestamps, pod metric-column sort, inline `:pod /term`/`@ctx`. diff --git a/docs/operator-guide.md b/docs/operator-guide.md index 16d9138..ef7b94b 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -122,9 +122,13 @@ Without a metrics-server these degrade gracefully (columns show `-`, the table t - `l`: open logs pane - `s`: tail toggle - `p`: pause/resume stream +- `P`: toggle previous container instance (`kubectl logs -p`) — a one-shot fetch for crashloops - `S`: source selector - `c`: container selector +The logs status line shows `instance:current` or `instance:previous`. Previous-instance logs don't +follow or auto-reconnect (the container is gone); press `P` again to return to the live stream. + Pod behavior: - multi-container pods can stream from all containers diff --git a/src/ui/app.rs b/src/ui/app.rs index 6c5de1a..f20354f 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -539,6 +539,9 @@ struct LogViewState { reconnect_after: Option, reconnect_blocked: bool, paused: bool, + /// Stream the previous (terminated) container instance's logs (`kubectl logs -p`) — a one-shot + /// fetch (no follow/reconnect), used for crashloop debugging. + previous: bool, paused_skipped_lines: u64, container_override_pod: Option, container_override: Option, @@ -576,6 +579,7 @@ impl Default for LogViewState { reconnect_after: None, reconnect_blocked: false, paused: false, + previous: false, paused_skipped_lines: 0, container_override_pod: None, container_override: None, @@ -1151,6 +1155,14 @@ impl App { self.ensure_active_watch().await; return Ok(false); } + if self.current_tab().pane == Pane::Logs + && ((key.code == KeyCode::Char('P')) + || (key.code == KeyCode::Char('p') && key.modifiers.contains(KeyModifiers::SHIFT))) + { + self.toggle_previous_logs().await; + self.ensure_active_watch().await; + return Ok(false); + } if self.current_tab().pane == Pane::Logs && ((key.code == KeyCode::Char('L')) || (key.code == KeyCode::Char('l') && key.modifiers.contains(KeyModifiers::SHIFT))) @@ -3548,6 +3560,47 @@ mod tests { assert!(snap.contains("[LG]")); assert!(snap.contains("Logs | target:pod default/pod-a")); assert!(snap.contains("[default/pod-a/main] line-1")); + // Defaults to the current container instance. + assert!(snap.contains("instance:current"), "instance: {snap}"); + } + + #[tokio::test] + async fn toggle_previous_logs_flips_instance_and_resets() { + let mut app = test_app(); + app.current_tab_mut().pane = Pane::Logs; + app.logs.selection = Some(super::LogSelection { + scope: "pod default/pod-a".to_string(), + targets: vec![super::LogTarget { + context: "ctx-dev".to_string(), + namespace: "default".to_string(), + pod: "pod-a".to_string(), + container: Some("main".to_string()), + }], + }); + app.push_log_line("[default/pod-a/main] old".to_string()); + + assert!(!app.logs.previous); + app.toggle_previous_logs().await; + assert!(app.logs.previous, "toggled to previous"); + // Switching instances refetches: the prior buffer is cleared. + assert!( + !app.logs.lines.iter().any(|l| l.contains("old")), + "buffer reset on toggle: {:?}", + app.logs.lines + ); + assert!( + app.logs_title().contains("instance:previous"), + "{}", + app.logs_title() + ); + + app.toggle_previous_logs().await; + assert!(!app.logs.previous, "toggled back to current"); + assert!( + app.logs_title().contains("instance:current"), + "{}", + app.logs_title() + ); } #[test] diff --git a/src/ui/app/logs.rs b/src/ui/app/logs.rs index b8d3edd..8b5b25b 100644 --- a/src/ui/app/logs.rs +++ b/src/ui/app/logs.rs @@ -187,20 +187,22 @@ impl App { let mut tasks = Vec::new(); let mut opened = 0usize; + let previous = self.logs.previous; for target in &selection.targets { let request = PodLogRequest { context: target.context.clone(), namespace: target.namespace.clone(), pod: target.pod.clone(), container: target.container.clone(), - follow: true, - tail_lines: if initial { + // The previous container instance is gone, so there's nothing to follow. + follow: !previous, + tail_lines: if previous || initial { Some(LOG_DEFAULT_TAIL_LINES) } else { None }, - since_seconds: if initial { None } else { Some(15) }, - previous: false, + since_seconds: if previous || initial { None } else { Some(15) }, + previous, timestamps: true, }; match self.resource_provider.stream_pod_logs(request).await { @@ -296,6 +298,8 @@ impl App { if desired != self.logs.selection { self.stop_log_session(); self.reset_log_buffer(); + // A fresh log view starts on the current (followed) instance, not previous. + self.logs.previous = false; self.logs.selection = desired.clone(); let Some(selection) = desired else { return true; @@ -306,6 +310,8 @@ impl App { if self.logs.selection.is_none() || self.logs.session.is_some() || self.logs.reconnect_blocked + // Previous-instance logs are a one-shot fetch — don't auto-reconnect after close. + || self.logs.previous { return false; } @@ -478,6 +484,11 @@ impl App { } else { "idle" }; + let instance = if self.logs.previous { + "previous" + } else { + "current" + }; let target = self .logs .selection @@ -503,8 +514,9 @@ impl App { }; format!( - "Logs | target:{} | streams:{} | state:{} | src:{} | lines:{} | dropped:{} | paused-drop:{}{} | wrap:{}", + "Logs | target:{} | instance:{} | streams:{} | state:{} | src:{} | lines:{} | dropped:{} | paused-drop:{}{} | wrap:{}", target, + instance, streams, state, source_filters, @@ -530,6 +542,23 @@ impl App { } } + /// Toggle between the current and previous (terminated) container instance's logs and refetch. + pub(super) async fn toggle_previous_logs(&mut self) { + let Some(selection) = self.logs.selection.clone() else { + self.status_line = "No active log stream".to_string(); + return; + }; + self.logs.previous = !self.logs.previous; + self.stop_log_session(); + self.reset_log_buffer(); + self.start_log_session(selection, true).await; + self.status_line = if self.logs.previous { + "Logs: previous container instance (-p) — one-shot".to_string() + } else { + "Logs: current container instance".to_string() + }; + } + pub(super) fn jump_logs_to_latest(&mut self) { self.logs.paused = false; self.logs.auto_scroll = true;