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
4 changes: 3 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
4 changes: 4 additions & 0 deletions docs/operator-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
53 changes: 53 additions & 0 deletions src/ui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,9 @@ struct LogViewState {
reconnect_after: Option<Instant>,
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<ResourceKey>,
container_override: Option<String>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)))
Expand Down Expand Up @@ -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]
Expand Down
39 changes: 34 additions & 5 deletions src/ui/app/logs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -478,6 +484,11 @@ impl App {
} else {
"idle"
};
let instance = if self.logs.previous {
"previous"
} else {
"current"
};
let target = self
.logs
.selection
Expand All @@ -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,
Expand All @@ -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;
Expand Down
Loading