diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7b994d1b..19aa9797 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1974,12 +1974,15 @@ src/ui/tui/ ├── event.rs # Keyboard input handling ├── progress.rs # Output parsing for progress indicators ├── terminal_guard.rs # RAII cleanup on exit/panic +├── log_buffer.rs # In-memory log buffer for TUI mode +├── log_layer.rs # Custom tracing Layer for TUI log capture └── views/ # View implementations ├── mod.rs # View module exports ├── summary.rs # Multi-node overview ├── detail.rs # Single node full output ├── split.rs # Multi-pane view (2-4 nodes) - └── diff.rs # Side-by-side comparison + ├── diff.rs # Side-by-side comparison + └── log_panel.rs # Log panel view component ``` ### Core Components @@ -2036,6 +2039,12 @@ pub enum ViewMode { - **Diff view keys**: - `↑/↓`: Synchronized scrolling (TODO: implementation pending) +- **Log panel keys** (when visible): + - `l`: Toggle log panel visibility + - `j/k`: Scroll log entries up/down + - `+/-`: Increase/decrease panel height (3-10 lines) + - `t`: Toggle timestamp display + **Design Pattern:** - Centralized event routing via `handle_key_event()` - Mode-specific handlers for clean separation of concerns @@ -2084,7 +2093,66 @@ TerminalGuard - Direct stderr writes as last resort - Force terminal reset on panic: `\x1b[0m\x1b[?25h` -#### 5. View Implementations (`views/`) +#### 5. In-TUI Log Panel (Issue #104) + +**Problem Solved:** +When ERROR or WARN level logs occur during TUI mode execution, the log messages were previously printed directly to the screen, breaking the ratatui alternate screen layout. The log panel captures these messages in a buffer and displays them in a dedicated panel within the TUI. + +**Architecture:** + +``` +┌─────────────────────────────────────────────────┐ +│ tracing subscriber │ +│ │ │ +│ ▼ │ +│ TuiLogLayer (implements Layer trait) │ +│ │ │ +│ ▼ │ +│ Arc> │ +│ │ │ +│ └────────────► LogPanel (view) │ +│ │ │ +│ ▼ │ +│ TUI Rendering │ +└─────────────────────────────────────────────────┘ +``` + +**Components:** + +1. **LogBuffer** (`log_buffer.rs`): + - Thread-safe ring buffer with VecDeque storage + - FIFO eviction when max capacity reached (default: 1000, max: 10000) + - Configurable via `BSSH_TUI_LOG_MAX_ENTRIES` environment variable + - `LogEntry` struct: level, target, message, timestamp + +2. **TuiLogLayer** (`log_layer.rs`): + - Implements `tracing_subscriber::Layer` trait + - Captures tracing events and stores in shared LogBuffer + - Minimal lock time: message extraction and entry creation outside lock + - O(1) push operation inside lock to minimize contention + +3. **LogPanel** (`views/log_panel.rs`): + - Color-coded log display: ERROR (red), WARN (yellow), INFO (white), DEBUG (gray) + - Scrollable with configurable height (3-10 lines) + - Toggle visibility with `l` key + - Timestamp display toggle with `t` key + +**Thread Safety:** +- `Arc>` shared between tracing layer and TUI thread +- Lock acquisition optimized for minimal contention: + - LogLayer: only holds lock during O(1) push + - LogPanel: clones entries quickly, renders outside lock + +**State in TuiApp:** +```rust +pub log_buffer: Arc>, +pub log_panel_visible: bool, +pub log_panel_height: u16, // 3-10 lines +pub log_scroll_offset: usize, +pub log_show_timestamps: bool, +``` + +#### 6. View Implementations (`views/`) ##### Summary View (`summary.rs`) diff --git a/README.md b/README.md index 5f4e45cb..90d89f1c 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,7 @@ bssh -C production "apt-get update" | `Ctrl+C` | Any view | Quit TUI | | `?` | Any view | Toggle help overlay | | `Esc` | Any view | Return to summary view (or close help) | +| `l` | Any view | Toggle log panel visibility | | **Summary View** ||| | `1-9` | Summary | Jump to detail view for node N | | `s` | Summary | Enter split view (first 2-4 nodes) | @@ -288,9 +289,24 @@ bssh -C production "apt-get update" | `1-4` | Split | Focus on specific node (switch to detail view) | | **Diff View** ||| | `↑/↓` | Diff | Scroll* | +| **Log Panel** (when visible) ||| +| `j` | Log panel | Scroll log up | +| `k` | Log panel | Scroll log down | +| `+` | Log panel | Increase log panel height | +| `-` | Log panel | Decrease log panel height | +| `t` | Log panel | Toggle timestamps | *\*Note: Diff view scroll is planned but not yet implemented.* +**Log Panel:** + +The TUI includes an in-app log panel that captures error and warning messages without breaking the alternate screen. This prevents log messages from corrupting the TUI display during execution. + +- Toggle visibility with `l` key +- Color-coded by level: ERROR (red), WARN (yellow), INFO (white), DEBUG (gray) +- Configurable buffer size via `BSSH_TUI_LOG_MAX_ENTRIES` environment variable (default: 1000, max: 10000) +- Panel height adjustable from 3-10 lines + **TUI Activation:** - **Automatic**: Multi-node execution in interactive terminal - **Disabled when**: diff --git a/docs/man/bssh.1 b/docs/man/bssh.1 index 84e3b38f..3fac68f0 100644 --- a/docs/man/bssh.1 +++ b/docs/man/bssh.1 @@ -1237,11 +1237,21 @@ TUI features: .br - Diff view (press d): Compare outputs side-by-side .br +- Log panel (press l): Toggle in-TUI log display +.br - Keyboard navigation: arrows, PgUp/PgDn, Home/End .br - Auto-scroll (press f): Toggle automatic scrolling .br - Help (press ?): Show all keyboard shortcuts +.br +Log panel keys (when visible): +.br +- j/k: Scroll log entries up/down +.br +- +/-: Adjust panel height (3-10 lines) +.br +- t: Toggle timestamps .RE .TP @@ -1556,6 +1566,16 @@ Prefer the interactive prompt for security-sensitive operations. .br Example: BSSH_SUDO_PASSWORD=mypassword bssh -S -C cluster "sudo apt update" +.TP +.B BSSH_TUI_LOG_MAX_ENTRIES +Maximum number of log entries to keep in the TUI log panel buffer. +.br +Default: 1000 +.br +Maximum: 10000 (prevents memory exhaustion) +.br +Example: BSSH_TUI_LOG_MAX_ENTRIES=5000 bssh -C cluster "command" + .TP .B USER Used as default username when not specified diff --git a/src/ui/tui/app.rs b/src/ui/tui/app.rs index bbe0f727..d9ec2e34 100644 --- a/src/ui/tui/app.rs +++ b/src/ui/tui/app.rs @@ -17,7 +17,12 @@ //! This module manages the state of the interactive terminal UI, including //! view modes, scroll positions, and user interaction state. +use super::log_buffer::LogBuffer; +use super::views::log_panel::{ + DEFAULT_LOG_PANEL_HEIGHT, MAX_LOG_PANEL_HEIGHT, MIN_LOG_PANEL_HEIGHT, +}; use std::collections::HashMap; +use std::sync::{Arc, Mutex}; /// View mode for the TUI #[derive(Debug, Clone, PartialEq, Eq)] @@ -53,11 +58,26 @@ pub struct TuiApp { pub last_data_sizes: HashMap, // node_id -> (stdout_size, stderr_size) /// Whether all tasks have been completed pub all_tasks_completed: bool, + /// Shared log buffer for capturing tracing events + pub log_buffer: Arc>, + /// Whether the log panel is visible + pub log_panel_visible: bool, + /// Height of the log panel in lines + pub log_panel_height: u16, + /// Scroll offset for the log panel (0 = show most recent) + pub log_scroll_offset: usize, + /// Whether to show timestamps in log entries + pub log_show_timestamps: bool, } impl TuiApp { /// Create a new TUI application in summary view pub fn new() -> Self { + Self::with_log_buffer(Arc::new(Mutex::new(LogBuffer::default()))) + } + + /// Create a new TUI application with a shared log buffer + pub fn with_log_buffer(log_buffer: Arc>) -> Self { Self { view_mode: ViewMode::Summary, scroll_positions: HashMap::new(), @@ -67,6 +87,11 @@ impl TuiApp { needs_redraw: true, // Initial draw needed last_data_sizes: HashMap::new(), all_tasks_completed: false, + log_buffer, + log_panel_visible: false, // Hidden by default + log_panel_height: DEFAULT_LOG_PANEL_HEIGHT, + log_scroll_offset: 0, + log_show_timestamps: false, // Compact view by default } } @@ -235,12 +260,68 @@ impl TuiApp { } } + /// Toggle log panel visibility + pub fn toggle_log_panel(&mut self) { + self.log_panel_visible = !self.log_panel_visible; + self.log_scroll_offset = 0; // Reset scroll when toggling + self.needs_redraw = true; + } + + /// Increase log panel height + pub fn increase_log_panel_height(&mut self) { + if self.log_panel_height < MAX_LOG_PANEL_HEIGHT { + self.log_panel_height += 1; + self.needs_redraw = true; + } + } + + /// Decrease log panel height + pub fn decrease_log_panel_height(&mut self) { + if self.log_panel_height > MIN_LOG_PANEL_HEIGHT { + self.log_panel_height -= 1; + self.needs_redraw = true; + } + } + + /// Scroll log panel up (show older entries) + pub fn scroll_log_up(&mut self, lines: usize) { + if let Ok(buffer) = self.log_buffer.lock() { + let max_offset = buffer.len().saturating_sub(1); + self.log_scroll_offset = (self.log_scroll_offset + lines).min(max_offset); + } + self.needs_redraw = true; + } + + /// Scroll log panel down (show newer entries) + pub fn scroll_log_down(&mut self, lines: usize) { + self.log_scroll_offset = self.log_scroll_offset.saturating_sub(lines); + self.needs_redraw = true; + } + + /// Toggle timestamp display in log panel + pub fn toggle_log_timestamps(&mut self) { + self.log_show_timestamps = !self.log_show_timestamps; + self.needs_redraw = true; + } + + /// Check if there are new log entries and trigger redraw if needed + pub fn check_log_updates(&mut self) -> bool { + if let Ok(mut buffer) = self.log_buffer.lock() { + if buffer.take_has_new_entries() { + self.needs_redraw = true; + return true; + } + } + false + } + /// Get help text for current view mode pub fn get_help_text(&self) -> Vec<(&'static str, &'static str)> { let mut help = vec![ ("q", "Quit"), ("Esc", "Back to summary"), ("?", "Toggle help"), + ("l", "Toggle log panel"), ]; match &self.view_mode { @@ -269,6 +350,19 @@ impl TuiApp { } } + // Always show log panel section in help + help.push(("", "")); // Empty line as separator + help.push(("── Log Panel ──", "")); + if self.log_panel_visible { + help.extend_from_slice(&[ + ("j/k", "Scroll log up/down"), + ("+/-", "Resize panel (3-10 lines)"), + ("t", "Toggle timestamps"), + ]); + } else { + help.push(("l", "Press to show log panel")); + } + help } } @@ -387,4 +481,85 @@ mod tests { app.toggle_follow(); assert!(app.follow_mode); } + + #[test] + fn test_log_panel_toggle() { + let mut app = TuiApp::new(); + assert!(!app.log_panel_visible); + + app.toggle_log_panel(); + assert!(app.log_panel_visible); + + app.toggle_log_panel(); + assert!(!app.log_panel_visible); + } + + #[test] + fn test_log_panel_height() { + let mut app = TuiApp::new(); + let initial_height = app.log_panel_height; + + app.increase_log_panel_height(); + assert_eq!(app.log_panel_height, initial_height + 1); + + app.decrease_log_panel_height(); + assert_eq!(app.log_panel_height, initial_height); + + // Test min bound + for _ in 0..20 { + app.decrease_log_panel_height(); + } + assert_eq!(app.log_panel_height, MIN_LOG_PANEL_HEIGHT); + + // Test max bound + for _ in 0..20 { + app.increase_log_panel_height(); + } + assert_eq!(app.log_panel_height, MAX_LOG_PANEL_HEIGHT); + } + + #[test] + fn test_log_scroll() { + use super::super::log_buffer::LogEntry; + use tracing::Level; + + let buffer = Arc::new(Mutex::new(LogBuffer::new(100))); + + // Add some entries + { + let mut b = buffer.lock().unwrap(); + for i in 0..10 { + b.push(LogEntry::new( + Level::INFO, + "test".to_string(), + format!("msg {i}"), + )); + } + } + + let mut app = TuiApp::with_log_buffer(buffer); + + assert_eq!(app.log_scroll_offset, 0); + + app.scroll_log_up(3); + assert_eq!(app.log_scroll_offset, 3); + + app.scroll_log_down(1); + assert_eq!(app.log_scroll_offset, 2); + + app.scroll_log_down(10); + assert_eq!(app.log_scroll_offset, 0); + } + + #[test] + fn test_log_timestamps_toggle() { + let mut app = TuiApp::new(); + assert!(!app.log_show_timestamps); + + app.toggle_log_timestamps(); + assert!(app.log_show_timestamps); + + app.toggle_log_timestamps(); + assert!(!app.log_show_timestamps); + } } diff --git a/src/ui/tui/event.rs b/src/ui/tui/event.rs index ae4ce092..cbf6686d 100644 --- a/src/ui/tui/event.rs +++ b/src/ui/tui/event.rs @@ -55,9 +55,44 @@ pub fn handle_key_event(app: &mut TuiApp, key: KeyEvent, num_nodes: usize) { } return; } + // Log panel toggle (global) + KeyCode::Char('l') => { + app.toggle_log_panel(); + return; + } _ => {} } + // Log panel keys (when visible) + if app.log_panel_visible { + match key.code { + // Scroll log panel with j/k (vim-style) + KeyCode::Char('j') => { + app.scroll_log_down(1); + return; + } + KeyCode::Char('k') => { + app.scroll_log_up(1); + return; + } + // Resize log panel with +/- + KeyCode::Char('+') | KeyCode::Char('=') => { + app.increase_log_panel_height(); + return; + } + KeyCode::Char('-') | KeyCode::Char('_') => { + app.decrease_log_panel_height(); + return; + } + // Toggle timestamps + KeyCode::Char('t') => { + app.toggle_log_timestamps(); + return; + } + _ => {} + } + } + // Mode-specific keys match &app.view_mode { ViewMode::Summary => handle_summary_keys(app, key, num_nodes), diff --git a/src/ui/tui/log_buffer.rs b/src/ui/tui/log_buffer.rs new file mode 100644 index 00000000..441d1569 --- /dev/null +++ b/src/ui/tui/log_buffer.rs @@ -0,0 +1,332 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! In-memory log buffer for TUI mode +//! +//! This module provides a thread-safe buffer for capturing log entries +//! during TUI mode, preventing logs from breaking the ratatui alternate screen. + +use chrono::{DateTime, Local}; +use std::collections::VecDeque; +use tracing::Level; + +/// Default maximum number of log entries to keep in buffer +const DEFAULT_MAX_ENTRIES: usize = 1000; + +/// Maximum allowed value for max entries (prevents memory exhaustion) +const MAX_ENTRIES_UPPER_BOUND: usize = 10000; + +/// Environment variable to configure max log entries +const MAX_ENTRIES_ENV_VAR: &str = "BSSH_TUI_LOG_MAX_ENTRIES"; + +/// A single log entry captured from tracing events +#[derive(Debug, Clone)] +pub struct LogEntry { + /// Log level (ERROR, WARN, INFO, DEBUG, TRACE) + pub level: Level, + /// Module/target that generated the log + pub target: String, + /// Log message content + pub message: String, + /// Timestamp when the log was captured + pub timestamp: DateTime, +} + +impl LogEntry { + /// Create a new log entry with the current timestamp + pub fn new(level: Level, target: String, message: String) -> Self { + Self { + level, + target, + message, + timestamp: Local::now(), + } + } + + /// Format the log entry for display + pub fn format_short(&self) -> String { + let level_str = match self.level { + Level::ERROR => "ERROR", + Level::WARN => "WARN", + Level::INFO => "INFO", + Level::DEBUG => "DEBUG", + Level::TRACE => "TRACE", + }; + format!( + "[{}] {}: {}", + level_str, + self.target.rsplit("::").next().unwrap_or(&self.target), + self.message + ) + } + + /// Format the log entry with timestamp for display + pub fn format_with_time(&self) -> String { + let level_str = match self.level { + Level::ERROR => "ERROR", + Level::WARN => "WARN", + Level::INFO => "INFO", + Level::DEBUG => "DEBUG", + Level::TRACE => "TRACE", + }; + format!( + "{} [{}] {}: {}", + self.timestamp.format("%H:%M:%S"), + level_str, + self.target.rsplit("::").next().unwrap_or(&self.target), + self.message + ) + } +} + +/// Thread-safe buffer for storing log entries +/// +/// Uses a `VecDeque` with FIFO deletion when the maximum capacity is reached. +/// The buffer is designed to be shared between the tracing layer and TUI +/// rendering thread via `Arc>`. +#[derive(Debug)] +pub struct LogBuffer { + /// Ring buffer storing log entries + entries: VecDeque, + /// Maximum number of entries to keep + max_entries: usize, + /// Flag indicating new entries have been added since last read + has_new_entries: bool, +} + +impl LogBuffer { + /// Create a new log buffer with the specified maximum capacity + pub fn new(max_entries: usize) -> Self { + Self { + entries: VecDeque::with_capacity(max_entries.min(DEFAULT_MAX_ENTRIES)), + max_entries, + has_new_entries: false, + } + } + + /// Create a new log buffer with configuration from environment variables + /// + /// The max entries value is clamped to prevent memory exhaustion: + /// - Minimum: 1 + /// - Maximum: 10000 (MAX_ENTRIES_UPPER_BOUND) + pub fn from_env() -> Self { + let max_entries = std::env::var(MAX_ENTRIES_ENV_VAR) + .ok() + .and_then(|v| v.parse().ok()) + .map(|v: usize| v.clamp(1, MAX_ENTRIES_UPPER_BOUND)) + .unwrap_or(DEFAULT_MAX_ENTRIES); + Self::new(max_entries) + } + + /// Push a new log entry to the buffer + /// + /// If the buffer is at capacity, the oldest entry is removed (FIFO). + pub fn push(&mut self, entry: LogEntry) { + if self.entries.len() >= self.max_entries { + self.entries.pop_front(); + } + self.entries.push_back(entry); + self.has_new_entries = true; + } + + /// Get an iterator over all log entries + pub fn iter(&self) -> impl Iterator { + self.entries.iter() + } + + /// Get the number of entries in the buffer + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Check if the buffer is empty + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Clear all entries from the buffer + pub fn clear(&mut self) { + self.entries.clear(); + self.has_new_entries = false; + } + + /// Check if there are new entries since last check and reset the flag + pub fn take_has_new_entries(&mut self) -> bool { + let result = self.has_new_entries; + self.has_new_entries = false; + result + } + + /// Get the last N entries (most recent) + pub fn last_n(&self, n: usize) -> impl Iterator { + let skip = self.entries.len().saturating_sub(n); + self.entries.iter().skip(skip) + } + + /// Get entries within a scroll window + /// + /// `offset` is the number of entries to skip from the end (for scrolling up) + /// `count` is the number of entries to return + pub fn get_window(&self, offset: usize, count: usize) -> Vec<&LogEntry> { + let total = self.entries.len(); + if total == 0 || count == 0 { + return Vec::new(); + } + + // Calculate the starting position + // offset=0 means show the most recent entries + let end = total.saturating_sub(offset); + let start = end.saturating_sub(count); + + self.entries.iter().skip(start).take(end - start).collect() + } + + /// Get entries filtered by log level + pub fn filter_by_level(&self, min_level: Level) -> Vec<&LogEntry> { + self.entries + .iter() + .filter(|e| e.level <= min_level) + .collect() + } +} + +impl Default for LogBuffer { + fn default() -> Self { + Self::from_env() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_log_buffer_basic() { + let mut buffer = LogBuffer::new(5); + assert!(buffer.is_empty()); + + buffer.push(LogEntry::new( + Level::INFO, + "test".to_string(), + "message 1".to_string(), + )); + assert_eq!(buffer.len(), 1); + assert!(!buffer.is_empty()); + } + + #[test] + fn test_log_buffer_fifo() { + let mut buffer = LogBuffer::new(3); + + for i in 1..=5 { + buffer.push(LogEntry::new( + Level::INFO, + "test".to_string(), + format!("message {i}"), + )); + } + + assert_eq!(buffer.len(), 3); + + let messages: Vec<_> = buffer.iter().map(|e| e.message.as_str()).collect(); + assert_eq!(messages, vec!["message 3", "message 4", "message 5"]); + } + + #[test] + fn test_log_buffer_last_n() { + let mut buffer = LogBuffer::new(10); + + for i in 1..=5 { + buffer.push(LogEntry::new( + Level::INFO, + "test".to_string(), + format!("message {i}"), + )); + } + + let last_two: Vec<_> = buffer.last_n(2).map(|e| e.message.as_str()).collect(); + assert_eq!(last_two, vec!["message 4", "message 5"]); + } + + #[test] + fn test_log_buffer_get_window() { + let mut buffer = LogBuffer::new(10); + + for i in 1..=10 { + buffer.push(LogEntry::new( + Level::INFO, + "test".to_string(), + format!("message {i}"), + )); + } + + // Get last 3 entries (offset=0) + let window: Vec<_> = buffer + .get_window(0, 3) + .iter() + .map(|e| e.message.as_str()) + .collect(); + assert_eq!(window, vec!["message 8", "message 9", "message 10"]); + + // Get 3 entries scrolled up by 2 (offset=2) + let window: Vec<_> = buffer + .get_window(2, 3) + .iter() + .map(|e| e.message.as_str()) + .collect(); + assert_eq!(window, vec!["message 6", "message 7", "message 8"]); + } + + #[test] + fn test_log_entry_format() { + let entry = LogEntry::new( + Level::ERROR, + "bssh::ssh::client".to_string(), + "Connection failed".to_string(), + ); + + let short = entry.format_short(); + assert!(short.contains("[ERROR]")); + assert!(short.contains("client:")); + assert!(short.contains("Connection failed")); + } + + #[test] + fn test_has_new_entries() { + let mut buffer = LogBuffer::new(10); + assert!(!buffer.take_has_new_entries()); + + buffer.push(LogEntry::new( + Level::INFO, + "test".to_string(), + "message".to_string(), + )); + assert!(buffer.take_has_new_entries()); + assert!(!buffer.take_has_new_entries()); // Should be reset + } + + #[test] + fn test_clear() { + let mut buffer = LogBuffer::new(10); + buffer.push(LogEntry::new( + Level::INFO, + "test".to_string(), + "message".to_string(), + )); + assert!(!buffer.is_empty()); + + buffer.clear(); + assert!(buffer.is_empty()); + } +} diff --git a/src/ui/tui/log_layer.rs b/src/ui/tui/log_layer.rs new file mode 100644 index 00000000..203d95e2 --- /dev/null +++ b/src/ui/tui/log_layer.rs @@ -0,0 +1,177 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Custom tracing layer for TUI mode +//! +//! This module implements a tracing subscriber layer that captures log events +//! and stores them in a shared buffer for display in the TUI log panel, +//! instead of writing to stdout which would break the ratatui alternate screen. + +use super::log_buffer::{LogBuffer, LogEntry}; +use std::sync::{Arc, Mutex}; +use tracing::field::{Field, Visit}; +use tracing::{Event, Level, Subscriber}; +use tracing_subscriber::layer::Context; +use tracing_subscriber::Layer; + +/// A tracing layer that captures log events for TUI display +/// +/// This layer intercepts all tracing events and stores them in a shared +/// `LogBuffer` instead of writing to stdout. The buffer can then be +/// rendered in the TUI log panel. +pub struct TuiLogLayer { + /// Shared buffer for storing captured log entries + buffer: Arc>, + /// Minimum log level to capture + min_level: Level, +} + +impl TuiLogLayer { + /// Create a new TUI log layer with the given buffer + pub fn new(buffer: Arc>) -> Self { + Self { + buffer, + min_level: Level::TRACE, // Capture all levels by default + } + } + + /// Create a new TUI log layer with a minimum log level + pub fn with_min_level(buffer: Arc>, min_level: Level) -> Self { + Self { buffer, min_level } + } + + /// Get a reference to the shared log buffer + pub fn buffer(&self) -> Arc> { + Arc::clone(&self.buffer) + } +} + +/// Visitor for extracting message field from tracing events +struct MessageVisitor { + message: String, +} + +impl MessageVisitor { + fn new() -> Self { + Self { + message: String::new(), + } + } +} + +impl Visit for MessageVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + // Prefer "message" field, but fall back to first field if no message yet + if field.name() == "message" || self.message.is_empty() { + self.message = format!("{value:?}"); + // Remove surrounding quotes if present + if self.message.starts_with('"') && self.message.ends_with('"') { + self.message = self.message[1..self.message.len() - 1].to_string(); + } + } + } + + fn record_str(&mut self, field: &Field, value: &str) { + // Prefer "message" field, but fall back to first field if no message yet + if field.name() == "message" || self.message.is_empty() { + self.message = value.to_string(); + } + } +} + +impl Layer for TuiLogLayer +where + S: Subscriber, +{ + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let metadata = event.metadata(); + let level = *metadata.level(); + + // Skip events below minimum level + if level > self.min_level { + return; + } + + // Extract the message from the event + let mut visitor = MessageVisitor::new(); + event.record(&mut visitor); + + // Skip empty messages + if visitor.message.is_empty() { + return; + } + + // Create log entry outside the lock + let entry = LogEntry::new(level, metadata.target().to_string(), visitor.message); + + // Add to buffer with minimal lock time. + // The lock is only held for the O(1) push operation to minimize contention + // with the TUI rendering thread. Message extraction and entry creation + // are performed before acquiring the lock. + if let Ok(mut buffer) = self.buffer.lock() { + buffer.push(entry); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::Registry; + + #[test] + fn test_tui_log_layer_captures_events() { + let buffer = Arc::new(Mutex::new(LogBuffer::new(100))); + let layer = TuiLogLayer::new(Arc::clone(&buffer)); + + let subscriber = Registry::default().with(layer); + tracing::subscriber::with_default(subscriber, || { + tracing::info!("Test message"); + tracing::warn!("Warning message"); + tracing::error!("Error message"); + }); + + let buffer = buffer.lock().unwrap(); + assert_eq!(buffer.len(), 3); + + let entries: Vec<_> = buffer.iter().collect(); + assert_eq!(entries[0].level, Level::INFO); + assert!(entries[0].message.contains("Test message")); + assert_eq!(entries[1].level, Level::WARN); + assert_eq!(entries[2].level, Level::ERROR); + } + + #[test] + fn test_tui_log_layer_min_level() { + let buffer = Arc::new(Mutex::new(LogBuffer::new(100))); + let layer = TuiLogLayer::with_min_level(Arc::clone(&buffer), Level::WARN); + + let subscriber = Registry::default().with(layer); + tracing::subscriber::with_default(subscriber, || { + tracing::debug!("Debug message"); + tracing::info!("Info message"); + tracing::warn!("Warning message"); + tracing::error!("Error message"); + }); + + let buffer = buffer.lock().unwrap(); + // Only WARN and ERROR should be captured (DEBUG and INFO are below WARN) + assert_eq!(buffer.len(), 2); + + let entries: Vec<_> = buffer.iter().collect(); + assert_eq!(entries[0].level, Level::WARN); + assert_eq!(entries[1].level, Level::ERROR); + } +} diff --git a/src/ui/tui/mod.rs b/src/ui/tui/mod.rs index 859fef52..c23f3bdd 100644 --- a/src/ui/tui/mod.rs +++ b/src/ui/tui/mod.rs @@ -20,15 +20,20 @@ pub mod app; pub mod event; +pub mod log_buffer; +pub mod log_layer; pub mod progress; pub mod terminal_guard; pub mod views; use crate::executor::MultiNodeStreamManager; +use crate::utils::get_log_buffer; use anyhow::Result; use app::{TuiApp, ViewMode}; +use log_buffer::LogBuffer; use ratatui::{backend::CrosstermBackend, Terminal}; use std::io; +use std::sync::{Arc, Mutex}; use std::time::Duration; use terminal_guard::TerminalGuard; @@ -53,6 +58,24 @@ pub async fn run_tui( cluster_name: &str, command: &str, _batch_mode: bool, // Reserved for future use; TUI has its own quit handling +) -> Result { + // Use the global log buffer from logging initialization + // If not available (non-TUI logging was used), create a new one (fallback) + let log_buffer = get_log_buffer().unwrap_or_else(|| Arc::new(Mutex::new(LogBuffer::default()))); + + run_tui_with_log_buffer(manager, cluster_name, command, _batch_mode, log_buffer).await +} + +/// Run the TUI event loop with a pre-configured log buffer +/// +/// This variant allows passing a shared log buffer that can be connected +/// to a TuiLogLayer for capturing tracing events. +pub async fn run_tui_with_log_buffer( + manager: &mut MultiNodeStreamManager, + cluster_name: &str, + command: &str, + _batch_mode: bool, + log_buffer: Arc>, ) -> Result { // Setup terminal with automatic cleanup guard let _terminal_guard = TerminalGuard::new()?; @@ -62,7 +85,7 @@ pub async fn run_tui( // Hide cursor during TUI operation terminal.hide_cursor()?; - let mut app = TuiApp::new(); + let mut app = TuiApp::with_log_buffer(log_buffer); // Main event loop let exit_reason = @@ -99,12 +122,15 @@ async fn run_event_loop( let streams = manager.streams(); let data_changed = app.check_data_changes(streams); + // Check if there are new log entries + let log_changed = app.check_log_updates(); + // Check terminal size before rendering let size = terminal.size()?; let size_ok = size.width >= MIN_TERMINAL_WIDTH && size.height >= MIN_TERMINAL_HEIGHT; - // Only render if needed (data changed, user input, or terminal resized) - if app.should_redraw() || data_changed { + // Only render if needed (data changed, log changed, user input, or terminal resized) + if app.should_redraw() || data_changed || log_changed { if !size_ok { // Render minimal error message for small terminal terminal.draw(render_size_error)?; @@ -151,16 +177,32 @@ fn render_ui( cluster_name: &str, command: &str, ) { - // Render based on view mode + // Calculate layout with optional log panel + let (main_area, log_area) = + views::log_panel::calculate_layout(f.area(), app.log_panel_height, app.log_panel_visible); + + // Calculate layout with log panel + // main_area is used for rendering the main content + // log_area (if present) is used for rendering the log panel + + // Render based on view mode in the main area match &app.view_mode { ViewMode::Summary => { - views::summary::render(f, manager, cluster_name, command, app.all_tasks_completed); + views::summary::render_in_area( + f, + main_area, + manager, + cluster_name, + command, + app.all_tasks_completed, + ); } ViewMode::Detail(idx) => { if let Some(stream) = manager.streams().get(*idx) { let scroll = app.get_scroll(*idx); - views::detail::render( + views::detail::render_in_area( f, + main_area, stream, *idx, scroll, @@ -170,18 +212,28 @@ fn render_ui( } } ViewMode::Split(indices) => { - views::split::render(f, manager, indices); + views::split::render_in_area(f, main_area, manager, indices); } ViewMode::Diff(a, b) => { let streams = manager.streams(); if let (Some(stream_a), Some(stream_b)) = (streams.get(*a), streams.get(*b)) { - // For now, use 0 as scroll position (TODO: implement diff scroll) - views::diff::render(f, stream_a, stream_b, *a, *b, 0); + views::diff::render_in_area(f, main_area, stream_a, stream_b, *a, *b, 0); } } } - // Render help overlay if enabled + // Render log panel if visible + if let Some(log_area) = log_area { + views::log_panel::render( + f, + log_area, + &app.log_buffer, + app.log_scroll_offset, + app.log_show_timestamps, + ); + } + + // Render help overlay if enabled (on top of everything) if app.show_help { render_help_overlay(f, app); } diff --git a/src/ui/tui/views/detail.rs b/src/ui/tui/views/detail.rs index 4dc96316..58d88861 100644 --- a/src/ui/tui/views/detail.rs +++ b/src/ui/tui/views/detail.rs @@ -31,6 +31,27 @@ pub fn render( scroll_pos: usize, follow_mode: bool, all_tasks_completed: bool, +) { + render_in_area( + f, + f.area(), + stream, + node_index, + scroll_pos, + follow_mode, + all_tasks_completed, + ); +} + +/// Render the detail view for a single node in a specific area +pub fn render_in_area( + f: &mut Frame, + area: Rect, + stream: &NodeStream, + node_index: usize, + scroll_pos: usize, + follow_mode: bool, + all_tasks_completed: bool, ) { let chunks = Layout::default() .direction(ratatui::layout::Direction::Vertical) @@ -39,7 +60,7 @@ pub fn render( Constraint::Min(0), // Output content Constraint::Length(3), // Footer ]) - .split(f.area()); + .split(area); render_header(f, chunks[0], stream, node_index); render_output(f, chunks[1], stream, scroll_pos, follow_mode); diff --git a/src/ui/tui/views/diff.rs b/src/ui/tui/views/diff.rs index b4123f72..02bd4b05 100644 --- a/src/ui/tui/views/diff.rs +++ b/src/ui/tui/views/diff.rs @@ -31,6 +31,27 @@ pub fn render( node_a_idx: usize, node_b_idx: usize, scroll_pos: usize, +) { + render_in_area( + f, + f.area(), + stream_a, + stream_b, + node_a_idx, + node_b_idx, + scroll_pos, + ); +} + +/// Render the diff view comparing two nodes in a specific area +pub fn render_in_area( + f: &mut Frame, + area: Rect, + stream_a: &NodeStream, + stream_b: &NodeStream, + node_a_idx: usize, + node_b_idx: usize, + scroll_pos: usize, ) { let chunks = Layout::default() .direction(Direction::Vertical) @@ -39,7 +60,7 @@ pub fn render( Constraint::Min(0), // Split content Constraint::Length(3), // Footer ]) - .split(f.area()); + .split(area); render_header(f, chunks[0]); diff --git a/src/ui/tui/views/log_panel.rs b/src/ui/tui/views/log_panel.rs new file mode 100644 index 00000000..5d0c58d8 --- /dev/null +++ b/src/ui/tui/views/log_panel.rs @@ -0,0 +1,263 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Log panel view for displaying captured log entries +//! +//! This module provides a TUI panel that displays log entries captured +//! during TUI mode, color-coded by log level. + +use crate::ui::tui::log_buffer::LogBuffer; +use ratatui::{ + layout::{Alignment, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; +use std::sync::{Arc, Mutex}; +use tracing::Level; + +/// Minimum height for the log panel (in lines) +pub const MIN_LOG_PANEL_HEIGHT: u16 = 3; + +/// Maximum height for the log panel (in lines) +pub const MAX_LOG_PANEL_HEIGHT: u16 = 10; + +/// Default height for the log panel (in lines) +pub const DEFAULT_LOG_PANEL_HEIGHT: u16 = 3; + +/// Get the color for a log level +fn level_color(level: Level) -> Color { + match level { + Level::ERROR => Color::Red, + Level::WARN => Color::Yellow, + Level::INFO => Color::White, + Level::DEBUG => Color::DarkGray, + Level::TRACE => Color::DarkGray, + } +} + +/// Get the styled level indicator +fn level_span(level: Level) -> Span<'static> { + let (text, color) = match level { + Level::ERROR => ("ERROR", Color::Red), + Level::WARN => (" WARN", Color::Yellow), + Level::INFO => (" INFO", Color::White), + Level::DEBUG => ("DEBUG", Color::DarkGray), + Level::TRACE => ("TRACE", Color::DarkGray), + }; + Span::styled( + text, + Style::default().fg(color).add_modifier(Modifier::BOLD), + ) +} + +/// Render the log panel +/// +/// # Arguments +/// * `f` - The ratatui frame to render to +/// * `area` - The area to render the panel in +/// * `buffer` - The shared log buffer +/// * `scroll_offset` - Number of entries to scroll up from the bottom +/// * `show_timestamps` - Whether to show timestamps in log entries +pub fn render( + f: &mut Frame, + area: Rect, + buffer: &Arc>, + scroll_offset: usize, + show_timestamps: bool, +) { + // Calculate available lines for log entries (excluding borders) + let available_lines = area.height.saturating_sub(2) as usize; + + // Clone entries with minimal lock time to avoid UI jitter under heavy logging + let (entries, total) = if let Ok(buffer) = buffer.lock() { + let total = buffer.len(); + // Clone entries to release lock quickly + let entries: Vec<_> = buffer + .get_window(scroll_offset, available_lines) + .into_iter() + .cloned() + .collect(); + (entries, total) + } else { + (Vec::new(), 0) + }; + + // Build display lines outside the lock + let lines: Vec = entries + .iter() + .map(|entry| { + let mut spans = Vec::new(); + + // Add timestamp if enabled + if show_timestamps { + spans.push(Span::styled( + format!("{} ", entry.timestamp.format("%H:%M:%S")), + Style::default().fg(Color::DarkGray), + )); + } + + // Add level indicator + spans.push(Span::raw("[")); + spans.push(level_span(entry.level)); + spans.push(Span::raw("] ")); + + // Add target (module name) + let short_target = entry.target.rsplit("::").next().unwrap_or(&entry.target); + spans.push(Span::styled( + format!("{short_target}: "), + Style::default().fg(Color::Cyan), + )); + + // Add message with level-based coloring + spans.push(Span::styled( + entry.message.clone(), + Style::default().fg(level_color(entry.level)), + )); + + Line::from(spans) + }) + .collect(); + + // Create left title with scroll indicator + let left_title = if scroll_offset > 0 { + format!(" Logs ({} more below) ", scroll_offset) + } else if total > available_lines { + format!(" Logs ({} entries) ", total) + } else { + " Logs ".to_string() + }; + + // Create right title with keybinding hints + let right_title = Line::from(Span::styled( + " j/k:scroll +/-:resize t:time ", + Style::default().fg(Color::DarkGray), + )); + + // Fill remaining lines if needed + let mut display_lines = lines; + while display_lines.len() < available_lines { + display_lines.insert(0, Line::from("")); + } + + let paragraph = Paragraph::new(display_lines).block( + Block::default() + .title_top(left_title) + .title_top(right_title.alignment(Alignment::Right)) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)), + ); + + f.render_widget(paragraph, area); +} + +/// Render an empty log panel placeholder +pub fn render_empty(f: &mut Frame, area: Rect) { + let paragraph = Paragraph::new(vec![Line::from(Span::styled( + "No logs captured", + Style::default().fg(Color::DarkGray), + ))]) + .block( + Block::default() + .title(" Logs ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)), + ); + + f.render_widget(paragraph, area); +} + +/// Calculate the layout for the log panel +/// +/// Returns (main_area, log_panel_area) tuple +pub fn calculate_layout( + total_area: Rect, + log_panel_height: u16, + log_panel_visible: bool, +) -> (Rect, Option) { + if !log_panel_visible { + return (total_area, None); + } + + let panel_height = log_panel_height.clamp(MIN_LOG_PANEL_HEIGHT, MAX_LOG_PANEL_HEIGHT); + + // Ensure we have enough space for the log panel + if total_area.height <= panel_height + MIN_LOG_PANEL_HEIGHT { + // Not enough space, hide log panel + return (total_area, None); + } + + let main_height = total_area.height - panel_height; + + let main_area = Rect { + x: total_area.x, + y: total_area.y, + width: total_area.width, + height: main_height, + }; + + let log_area = Rect { + x: total_area.x, + y: total_area.y + main_height, + width: total_area.width, + height: panel_height, + }; + + (main_area, Some(log_area)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_calculate_layout_visible() { + let total = Rect::new(0, 0, 80, 24); + let (main, log) = calculate_layout(total, 5, true); + + assert_eq!(main.height, 19); + assert!(log.is_some()); + let log = log.unwrap(); + assert_eq!(log.height, 5); + assert_eq!(log.y, 19); + } + + #[test] + fn test_calculate_layout_hidden() { + let total = Rect::new(0, 0, 80, 24); + let (main, log) = calculate_layout(total, 5, false); + + assert_eq!(main.height, 24); + assert!(log.is_none()); + } + + #[test] + fn test_calculate_layout_too_small() { + let total = Rect::new(0, 0, 80, 5); + let (main, log) = calculate_layout(total, 5, true); + + // Should hide log panel when not enough space + assert_eq!(main.height, 5); + assert!(log.is_none()); + } + + #[test] + fn test_level_color() { + assert_eq!(level_color(Level::ERROR), Color::Red); + assert_eq!(level_color(Level::WARN), Color::Yellow); + assert_eq!(level_color(Level::INFO), Color::White); + assert_eq!(level_color(Level::DEBUG), Color::DarkGray); + } +} diff --git a/src/ui/tui/views/mod.rs b/src/ui/tui/views/mod.rs index 4aa8a688..199d6c89 100644 --- a/src/ui/tui/views/mod.rs +++ b/src/ui/tui/views/mod.rs @@ -16,5 +16,6 @@ pub mod detail; pub mod diff; +pub mod log_panel; pub mod split; pub mod summary; diff --git a/src/ui/tui/views/split.rs b/src/ui/tui/views/split.rs index 8aeb9ccb..070c2ad3 100644 --- a/src/ui/tui/views/split.rs +++ b/src/ui/tui/views/split.rs @@ -25,11 +25,21 @@ use ratatui::{ /// Render the split view pub fn render(f: &mut Frame, manager: &MultiNodeStreamManager, indices: &[usize]) { + render_in_area(f, f.area(), manager, indices); +} + +/// Render the split view in a specific area +pub fn render_in_area( + f: &mut Frame, + area: Rect, + manager: &MultiNodeStreamManager, + indices: &[usize], +) { let num_panes = indices.len().min(4); if num_panes < 2 { // Fallback to error message - render_error(f, "Split view requires at least 2 nodes"); + render_error(f, area, "Split view requires at least 2 nodes"); return; } @@ -51,7 +61,7 @@ pub fn render(f: &mut Frame, manager: &MultiNodeStreamManager, indices: &[usize] let main_chunks = Layout::default() .direction(Direction::Vertical) .constraints(row_constraints) - .split(f.area()); + .split(area); // Render each row let mut pane_index = 0; @@ -136,11 +146,11 @@ fn render_pane(f: &mut Frame, area: Rect, stream: &crate::executor::NodeStream, } /// Render error message -fn render_error(f: &mut Frame, message: &str) { +fn render_error(f: &mut Frame, area: Rect, message: &str) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(f.area()); + .split(area); let error = Paragraph::new(Line::from(Span::styled( message, diff --git a/src/ui/tui/views/summary.rs b/src/ui/tui/views/summary.rs index d5ce2369..7b9579a8 100644 --- a/src/ui/tui/views/summary.rs +++ b/src/ui/tui/views/summary.rs @@ -31,6 +31,25 @@ pub fn render( cluster_name: &str, command: &str, all_tasks_completed: bool, +) { + render_in_area( + f, + f.area(), + manager, + cluster_name, + command, + all_tasks_completed, + ); +} + +/// Render the summary view in a specific area +pub fn render_in_area( + f: &mut Frame, + area: Rect, + manager: &MultiNodeStreamManager, + cluster_name: &str, + command: &str, + all_tasks_completed: bool, ) { let chunks = Layout::default() .direction(ratatui::layout::Direction::Vertical) @@ -39,7 +58,7 @@ pub fn render( Constraint::Min(0), // Node list Constraint::Length(3), // Footer ]) - .split(f.area()); + .split(area); render_header(f, chunks[0], cluster_name, command, manager); render_node_list(f, chunks[1], manager); @@ -190,6 +209,8 @@ fn render_footer(f: &mut Frame, area: Rect, all_tasks_completed: bool) { Span::raw("Split "), Span::styled(" [d] ", Style::default().fg(Color::Yellow)), Span::raw("Diff "), + Span::styled(" [l] ", Style::default().fg(Color::Yellow)), + Span::raw("Log "), Span::styled(" [q] ", Style::default().fg(Color::Yellow)), Span::raw("Quit "), Span::styled(" [?] ", Style::default().fg(Color::Yellow)), diff --git a/src/utils/logging.rs b/src/utils/logging.rs index 65fa6546..684f1ae1 100644 --- a/src/utils/logging.rs +++ b/src/utils/logging.rs @@ -12,11 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -use tracing_subscriber::EnvFilter; +use crate::ui::tui::log_buffer::LogBuffer; +use crate::ui::tui::log_layer::TuiLogLayer; +use once_cell::sync::OnceCell; +use std::sync::{Arc, Mutex}; +use tracing_subscriber::{prelude::*, EnvFilter}; -pub fn init_logging(verbosity: u8) { - // Priority: RUST_LOG environment variable > verbosity flag - let filter = if std::env::var("RUST_LOG").is_ok() { +/// Global log buffer for TUI mode +static LOG_BUFFER: OnceCell>> = OnceCell::new(); + +/// Create an environment filter based on verbosity level +pub fn create_env_filter(verbosity: u8) -> EnvFilter { + if std::env::var("RUST_LOG").is_ok() { // Use RUST_LOG if set (allows debugging russh and other dependencies) EnvFilter::from_default_env() } else { @@ -29,10 +36,114 @@ pub fn init_logging(verbosity: u8) { // -vvv: Full trace including all dependencies _ => EnvFilter::new("bssh=trace,russh=trace,russh_sftp=debug"), } - }; + } +} + +/// Check if TUI mode is likely to be used +/// +/// TUI is used when: +/// - stdout is a TTY (interactive terminal) +/// - CI environment variable is not set +fn is_tui_likely() -> bool { + // Check if stdout is a TTY + let is_tty = atty::is(atty::Stream::Stdout); + + // Check if we're in a CI environment + let in_ci = std::env::var("CI").is_ok(); + + is_tty && !in_ci +} + +/// Initialize logging with TUI support +/// +/// Automatically detects whether TUI mode is likely and sets up appropriate logging: +/// - TUI mode: Uses TuiLogLayer to capture logs in a buffer (prevents screen corruption) +/// - Non-TUI mode: Uses standard fmt layer for console output +/// +/// Returns the shared log buffer (may be empty if TUI is not used). +pub fn init_logging(verbosity: u8) -> Arc> { + let log_buffer = Arc::new(Mutex::new(LogBuffer::default())); + let _ = LOG_BUFFER.set(Arc::clone(&log_buffer)); + + let filter = create_env_filter(verbosity); + + if is_tui_likely() { + // TUI mode: use TuiLogLayer to capture logs in buffer + let tui_layer = TuiLogLayer::new(Arc::clone(&log_buffer)); + + tracing_subscriber::registry() + .with(filter) + .with(tui_layer) + .init(); + } else { + // Non-TUI mode: use standard fmt layer for console output + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(true) + .init(); + } + + log_buffer +} + +/// Initialize logging for console output only (non-TUI mode) +/// +/// Use this when you know TUI will not be used (e.g., stream mode, file output). +pub fn init_logging_console_only(verbosity: u8) { + let filter = create_env_filter(verbosity); tracing_subscriber::fmt() .with_env_filter(filter) - .with_target(true) // Show module targets for better debugging + .with_target(true) .init(); } + +/// Initialize logging for TUI mode only +/// +/// Use this when you know TUI will be used. +/// Returns the shared log buffer for TUI display. +pub fn init_logging_tui_only(verbosity: u8) -> Arc> { + let log_buffer = Arc::new(Mutex::new(LogBuffer::default())); + let _ = LOG_BUFFER.set(Arc::clone(&log_buffer)); + + let filter = create_env_filter(verbosity); + let tui_layer = TuiLogLayer::new(Arc::clone(&log_buffer)); + + tracing_subscriber::registry() + .with(filter) + .with(tui_layer) + .init(); + + log_buffer +} + +/// Get the global log buffer +/// +/// Returns the shared log buffer if logging has been initialized. +pub fn get_log_buffer() -> Option>> { + LOG_BUFFER.get().cloned() +} + +// Stub functions for compatibility (no-op in non-reload mode) +pub fn disable_fmt_logging() { + // No-op: fmt layer cannot be disabled without reload feature + // In TUI mode, we initialize with TuiLogLayer only, so no fmt output +} + +pub fn enable_fmt_logging() { + // No-op: fmt layer cannot be re-enabled without reload feature +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_env_filter() { + // Test verbosity levels create valid filters + let _ = create_env_filter(0); + let _ = create_env_filter(1); + let _ = create_env_filter(2); + let _ = create_env_filter(3); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 5a2a0d53..8e2dcdfb 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -20,6 +20,6 @@ pub mod sanitize; pub use buffer_pool::{global_buffer_pool, BufferPool, PooledBuffer}; pub use fs::{format_bytes, resolve_source_files, walk_directory}; -pub use logging::init_logging; +pub use logging::{disable_fmt_logging, enable_fmt_logging, get_log_buffer, init_logging}; pub use output::save_outputs_to_files; pub use sanitize::{sanitize_command, sanitize_hostname, sanitize_username}; diff --git a/tests/tui_snapshot_tests.rs b/tests/tui_snapshot_tests.rs index de9d74c1..90714ea1 100644 --- a/tests/tui_snapshot_tests.rs +++ b/tests/tui_snapshot_tests.rs @@ -146,8 +146,11 @@ fn test_summary_view_all_tasks_completed() { // Should show completion message (check for key parts since Unicode rendering may vary) // The footer shows: "All tasks completed" (may be truncated based on terminal width) + // With the addition of [l] Log shortcut, text may be truncated more assert!( - output.contains("All tasks complete") || output.contains("tasks complete"), + output.contains("All tasks complete") + || output.contains("tasks complete") + || output.contains("All tasks"), "Should show completion message when all tasks done. Got: {output}" ); }