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
8 changes: 6 additions & 2 deletions docs/operator-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,15 @@ Without a metrics-server these degrade gracefully (columns show `-`, the table t
- `s`: tail toggle
- `p`: pause/resume stream
- `P`: toggle previous container instance (`kubectl logs -p`) — a one-shot fetch for crashloops
- `m`: message-only — hide the `[source]` prefix and timestamp, keeping just the log message
(great for multi-pod streams where the pod name eats half the width)
- `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.
The logs status line shows `instance:current`/`previous` and `view: full`/`message-only`.
Previous-instance logs don't follow or auto-reconnect (the container is gone); press `P` again to
return to the live stream. `m` toggles message-only when source prefixes/timestamps crowd out the
message (still searchable by source/time — only the display is trimmed).

Pod behavior:
- multi-container pods can stream from all containers
Expand Down
46 changes: 46 additions & 0 deletions src/ui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,8 @@ struct LogViewState {
/// Stream the previous (terminated) container instance's logs (`kubectl logs -p`) — a one-shot
/// fetch (no follow/reconnect), used for crashloop debugging.
previous: bool,
/// Show only the log message, hiding the `[source]` prefix and the timestamp (toggle `m`).
messages_only: bool,
paused_skipped_lines: u64,
container_override_pod: Option<ResourceKey>,
container_override: Option<String>,
Expand Down Expand Up @@ -606,6 +608,7 @@ impl Default for LogViewState {
reconnect_blocked: false,
paused: false,
previous: false,
messages_only: false,
paused_skipped_lines: 0,
container_override_pod: None,
container_override: None,
Expand Down Expand Up @@ -1243,6 +1246,14 @@ impl App {
self.ensure_active_watch().await;
return Ok(false);
}
if self.current_tab().pane == Pane::Logs
&& key.modifiers.is_empty()
&& key.code == KeyCode::Char('m')
{
self.toggle_log_messages_only();
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 @@ -4023,6 +4034,41 @@ mod tests {
assert!(snap.contains("instance: current"), "instance: {snap}");
}

#[test]
fn logs_message_only_hides_source_and_timestamp() {
let mut app = test_app();
app.current_tab_mut().pane = Pane::Logs;
app.current_tab_mut().detail_wrap = false;
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] 2026-05-26T22:41:01.812Z hello from the app".to_string(),
);

// Full view shows the source prefix and timestamp.
let snap = render_snapshot(&mut app, 120, 16);
assert!(snap.contains("[default/pod-a/main]"), "full prefix: {snap}");
assert!(snap.contains("2026-05-26T22:41:01"), "full ts: {snap}");

// Message-only hides both, keeps the message; title notes the mode.
app.toggle_log_messages_only();
let snap = render_snapshot(&mut app, 120, 16);
assert!(snap.contains("hello from the app"), "message kept: {snap}");
assert!(
!snap.contains("[default/pod-a/main]"),
"prefix hidden: {snap}"
);
assert!(!snap.contains("2026-05-26T22:41:01"), "ts hidden: {snap}");
assert!(snap.contains("view: message-only"), "title flag: {snap}");
}

#[tokio::test]
async fn toggle_previous_logs_flips_instance_and_resets() {
let mut app = test_app();
Expand Down
86 changes: 81 additions & 5 deletions src/ui/app/logs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -514,12 +514,17 @@ impl App {
};

format!(
"[LOGS] {} · instance: {} · streams: {} · state: {} · src: {} · lines: {} · dropped: {} · paused-drop: {}{} · wrap: {}",
"[LOGS] {} · instance: {} · streams: {} · state: {} · src: {} · view: {} · lines: {} · dropped: {} · paused-drop: {}{} · wrap: {}",
target,
instance,
streams,
state,
source_filters,
if self.logs.messages_only {
"message-only"
} else {
"full"
},
self.logs.lines.len(),
self.logs.dropped_lines,
self.logs.paused_skipped_lines,
Expand All @@ -532,6 +537,17 @@ impl App {
)
}

pub(super) fn toggle_log_messages_only(&mut self) {
self.logs.messages_only = !self.logs.messages_only;
// Width changes, so reset horizontal scroll to avoid a stranded offset.
self.current_tab_mut().detail_hscroll = 0;
self.status_line = if self.logs.messages_only {
"Logs: message-only (source + timestamp hidden)".to_string()
} else {
"Logs: full lines (source + timestamp shown)".to_string()
};
}

pub(super) fn set_log_paused(&mut self, paused: bool) {
self.logs.paused = paused;
if paused {
Expand Down Expand Up @@ -592,33 +608,41 @@ impl App {
}

pub(super) fn filtered_log_line_count_and_width(&self) -> (usize, usize) {
if self.logs.hidden_sources.is_empty() {
// Fast path: no source filter and full lines → use the cached width.
if self.logs.hidden_sources.is_empty() && !self.logs.messages_only {
return (self.logs.lines.len(), self.logs.max_line_width);
}
let mut count = 0usize;
let mut max_width = 0usize;
for line in &self.logs.lines {
if is_visible_log_line(line, &self.logs.hidden_sources) {
count = count.saturating_add(1);
max_width = max_width.max(line.chars().count());
max_width = max_width.max(self.display_log_line(line).chars().count());
}
}
(count, max_width)
}

pub(super) fn filtered_log_body_text(&mut self) -> String {
if self.logs.hidden_sources.is_empty() {
// Fast path only when nothing transforms the lines.
if self.logs.hidden_sources.is_empty() && !self.logs.messages_only {
return self.log_joined_text().to_string();
}
let messages_only = self.logs.messages_only;
self.logs
.lines
.iter()
.filter(|line| is_visible_log_line(line, &self.logs.hidden_sources))
.cloned()
.map(|line| display_log_line(line, messages_only))
.collect::<Vec<_>>()
.join("\n")
}

/// The line as displayed: full, or message-only (source prefix + timestamp stripped).
pub(super) fn display_log_line<'a>(&self, line: &'a str) -> &'a str {
display_log_line(line, self.logs.messages_only)
}

pub(super) fn log_search_match_lines(&mut self, query: &str) -> Vec<usize> {
let needle = query.trim().to_ascii_lowercase();
if needle.is_empty() {
Expand Down Expand Up @@ -652,6 +676,38 @@ impl App {
}
}

/// A stored log line is `[source] <rfc3339-timestamp> <message>`. In message-only mode, strip the
/// `[source]` prefix and the leading timestamp so only the message shows. Returns a borrowed slice
/// (the message is a contiguous tail), so this stays allocation-free on the render hot path.
fn display_log_line(line: &str, messages_only: bool) -> &str {
if !messages_only {
return line;
}
let mut rest = line;
// Strip a leading "[...] " source prefix.
if rest.starts_with('[')
&& let Some(idx) = rest.find("] ")
{
rest = &rest[idx + 2..];
}
// Strip a leading RFC3339 timestamp token followed by a space.
if let Some(sp) = rest.find(' ')
&& looks_like_timestamp(&rest[..sp])
{
rest = &rest[sp + 1..];
}
rest
}

/// Heuristic RFC3339 check (e.g. `2026-05-26T22:41:01.812Z`) — enough to recognize the kubelet
/// timestamp prefix without a full parse.
fn looks_like_timestamp(token: &str) -> bool {
token.len() >= 20
&& token.as_bytes()[0].is_ascii_digit()
&& token.contains('T')
&& (token.ends_with('Z') || token.contains('+') || token.contains(':'))
}

/// Build a multi-pod log selection, capping the number of concurrent streams to LOG_MAX_STREAMS.
/// Beyond the cap the scope label notes "N of M streams, capped" so the operator knows it's partial.
fn capped_multi_pod_selection(
Expand Down Expand Up @@ -715,4 +771,24 @@ mod tests {
fn empty_targets_yield_no_selection() {
assert!(capped_multi_pod_selection("rs", "ns", "app", Vec::new()).is_none());
}

#[test]
fn message_only_strips_source_prefix_and_timestamp() {
let line = "[orbit-production/backend-65495400b-4n3q/backend] 2026-05-26T22:41:01.812Z Resolved spring controller";
// full mode returns the line untouched
assert_eq!(display_log_line(line, false), line);
// message-only drops the [source] prefix and the RFC3339 timestamp
assert_eq!(display_log_line(line, true), "Resolved spring controller");

// No source prefix, just a timestamp
assert_eq!(
display_log_line("2026-05-26T22:41:01Z hello world", true),
"hello world"
);
// A message that merely starts with a non-timestamp token is left intact
assert_eq!(
display_log_line("[ns/pod/c] INFO starting up", true),
"INFO starting up"
);
}
}
10 changes: 7 additions & 3 deletions src/ui/app/render_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1065,7 +1065,7 @@ impl App {
let detail_wrap = active.detail_wrap;
let (max_v, max_h) = if detail_wrap {
let body = if logs_line_count > 0 {
if self.logs.hidden_sources.is_empty() {
if self.logs.hidden_sources.is_empty() && !self.logs.messages_only {
self.log_joined_text().to_string()
} else {
self.filtered_log_body_text()
Expand Down Expand Up @@ -1136,7 +1136,7 @@ impl App {
);
if detail_wrap {
let body = if logs_line_count > 0 {
if self.logs.hidden_sources.is_empty() {
if self.logs.hidden_sources.is_empty() && !self.logs.messages_only {
self.log_joined_text().to_string()
} else {
self.filtered_log_body_text()
Expand Down Expand Up @@ -1175,7 +1175,11 @@ impl App {
if visible.len() >= viewport_h {
break;
}
visible.push(slice_chars(line, detail_hscroll as usize, viewport_w));
visible.push(slice_chars(
self.display_log_line(line),
detail_hscroll as usize,
viewport_w,
));
visible_idx = visible_idx.saturating_add(1);
}
if visible.is_empty() {
Expand Down
Loading