diff --git a/docs/operator-guide.md b/docs/operator-guide.md index 856096c..10c246d 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -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 diff --git a/src/ui/app.rs b/src/ui/app.rs index a43b771..ee3f917 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -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, container_override: Option, @@ -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, @@ -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))) @@ -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(); diff --git a/src/ui/app/logs.rs b/src/ui/app/logs.rs index dbcfeec..7330551 100644 --- a/src/ui/app/logs.rs +++ b/src/ui/app/logs.rs @@ -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, @@ -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 { @@ -592,7 +608,8 @@ 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; @@ -600,25 +617,32 @@ impl App { 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::>() .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 { let needle = query.trim().to_ascii_lowercase(); if needle.is_empty() { @@ -652,6 +676,38 @@ impl App { } } +/// A stored log line is `[source] `. 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( @@ -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" + ); + } } diff --git a/src/ui/app/render_loop.rs b/src/ui/app/render_loop.rs index e14a2de..f34c268 100644 --- a/src/ui/app/render_loop.rs +++ b/src/ui/app/render_loop.rs @@ -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() @@ -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() @@ -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() {