diff --git a/README.md b/README.md index 8062233..2dd8655 100644 --- a/README.md +++ b/README.md @@ -104,15 +104,17 @@ failure behavior. The TUI consumes this registry in a single non-overlapping background poll, detects processes started or stopped by other invocations, and reads persistent -log files incrementally into a bounded 500-line view. Closing it leaves all -services running unless the quit dialog's explicit "stop template and quit" +log files incrementally into a bounded 500-line view. Its log window supports +scrolling, pauses live follow away from the bottom, and pages older retained +history directly from disk; `End` returns to the current tail. Closing it leaves +all services running unless the quit dialog's explicit "stop template and quit" choice is selected; the view also has a 4 MiB byte ceiling. Doctor runs outside the input/render loop and distinguishes managed listeners, foreign port owners, and stale registry entries. The details view includes PID/PGID, port and health state, command, cwd, persistent log paths, and the last exit code when the shell wrapper can observe it. An `exec` replacement or `SIGKILL` remains explicitly -unavailable because no resident hum daemon waits on detached services. Port polling uses -bounded TCP connections; `lsof` is reserved +unavailable because no resident hum daemon waits on detached services. Port +polling uses bounded TCP connections; `lsof` is reserved for explicit conflict diagnostics. Poll intervals, resource reuse, and the ten-service CPU/RSS budget are documented in [`docs/POLLING.md`](docs/POLLING.md). diff --git a/docs/LOGGING.md b/docs/LOGGING.md index fe16049..a294fc9 100644 --- a/docs/LOGGING.md +++ b/docs/LOGGING.md @@ -55,7 +55,11 @@ hum compri all-services logs api --lines 100 --follow ``` The CLI reads at most 512 KiB per stream for an initial tail and marks a tail -that had to be truncated. In the TUI, `l` opens a byte-bounded incremental view, -`/` searches the current view, and `c` clears only the view. Neither action -deletes or truncates persistent files; cleanup is exclusively controlled by the -rotation and retention policy above. +that had to be truncated. In the TUI, `l` opens a byte-bounded incremental view. +Use the arrows or `j`/`k` to scroll, Page Up/Page Down for larger steps, `Home` +to progressively load older retained pages from disk, and `End` to return to the +live tail. Scrolling up pauses live follow so incoming output cannot move the +viewport; returning to the bottom reloads the current tail. `/` searches the +current view, and `c` clears only the view. None of these actions deletes or +truncates persistent files; cleanup is exclusively controlled by the rotation +and retention policy above. diff --git a/src/runtime/logs.rs b/src/runtime/logs.rs index 786311e..98808e8 100644 --- a/src/runtime/logs.rs +++ b/src/runtime/logs.rs @@ -541,6 +541,235 @@ pub fn tail_history(path: &Path, count: usize, rotated_files: usize) -> Result, + source_index: usize, + reversed_line: Vec, + oversized_line: bool, + discard_newest_fragment: bool, + skip_trailing_separator: bool, + max_line_bytes: usize, + finished: bool, + saw_content: bool, +} + +struct HistorySource { + file: File, + position: u64, + start_verified: bool, +} + +impl HistoryPager { + pub fn open(path: &Path, rotated_files: usize, max_line_bytes: usize) -> Result { + let sources = open_history_sources(path, rotated_files, None)?; + Self::from_sources(sources, max_line_bytes) + } + + fn from_sources(sources: Vec, max_line_bytes: usize) -> Result { + let discard_newest_fragment = match sources.iter().find(|source| source.position > 0) { + Some(source) => { + let mut file = source.file.try_clone()?; + file.seek(SeekFrom::Start(source.position - 1))?; + let mut byte = [0]; + file.read_exact(&mut byte)?; + byte[0] != b'\n' + } + _ => false, + }; + Ok(Self { + sources, + source_index: 0, + reversed_line: Vec::new(), + oversized_line: false, + discard_newest_fragment, + skip_trailing_separator: true, + max_line_bytes, + finished: false, + saw_content: false, + }) + } + + /// Return the next older page in chronological order. + /// + /// Disk work per call is capped so a noisy or newline-free log cannot stall + /// the TUI. If a page is empty while `has_more` is true, another call keeps + /// advancing through that bounded input. + pub fn next_older(&mut self, count: usize) -> Result> { + if count == 0 || self.finished { + return Ok(Vec::new()); + } + let mut newest_first = Vec::new(); + let mut bytes_read = 0; + while newest_first.len() < count + && bytes_read < MAX_TAIL_BYTES + && self.source_index < self.sources.len() + { + if self.sources[self.source_index].position == 0 { + self.source_index += 1; + continue; + } + let amount = (self.sources[self.source_index].position as usize) + .min(TAIL_READ_CHUNK) + .min(MAX_TAIL_BYTES - bytes_read); + let end = self.sources[self.source_index].position; + let start = end - amount as u64; + let mut chunk = vec![0; amount]; + { + let source = &mut self.sources[self.source_index]; + source.file.seek(SeekFrom::Start(start))?; + source.file.read_exact(&mut chunk)?; + } + + let mut consumed = 0_u64; + for byte in chunk.into_iter().rev() { + consumed += 1; + bytes_read += 1; + self.consume_byte(byte, &mut newest_first); + if newest_first.len() == count || bytes_read == MAX_TAIL_BYTES { + break; + } + } + self.sources[self.source_index].position = end - consumed; + } + + if self.source_index == self.sources.len() && !self.finished { + self.finish_history(&mut newest_first); + } + newest_first.reverse(); + Ok(newest_first) + } + + pub fn has_more(&self) -> bool { + !self.finished + } + + fn consume_byte(&mut self, byte: u8, lines: &mut Vec) { + self.saw_content = true; + if self.discard_newest_fragment { + if byte == b'\n' { + self.discard_newest_fragment = false; + self.skip_trailing_separator = false; + lines.push("… [incomplete log line omitted]".to_string()); + } + return; + } + if byte == b'\n' { + if self.skip_trailing_separator { + self.skip_trailing_separator = false; + } else { + lines.push(self.take_line()); + } + return; + } + self.skip_trailing_separator = false; + if self.reversed_line.len() < self.max_line_bytes { + self.reversed_line.push(byte); + } else { + self.oversized_line = true; + } + } + + fn take_line(&mut self) -> String { + if self.oversized_line { + self.reversed_line.clear(); + self.oversized_line = false; + return "… [oversized log line omitted]".to_string(); + } + self.reversed_line.reverse(); + let line = String::from_utf8_lossy(&self.reversed_line).into_owned(); + self.reversed_line.clear(); + line + } + + fn finish_history(&mut self, lines: &mut Vec) { + let discarded_at_eof = self.discard_newest_fragment && self.saw_content; + if discarded_at_eof { + lines.push("… [incomplete log line omitted]".to_string()); + self.discard_newest_fragment = false; + } + let oldest_verified = self + .sources + .last() + .is_some_and(|source| source.start_verified); + if !self.discard_newest_fragment && (!self.reversed_line.is_empty() || self.oversized_line) + { + if oldest_verified { + lines.push(self.take_line()); + } else { + self.reversed_line.clear(); + self.oversized_line = false; + lines.push("… [history boundary]".to_string()); + } + } else if self.saw_content + && !oldest_verified + && !self.discard_newest_fragment + && !discarded_at_eof + { + lines.push("… [history boundary]".to_string()); + } + self.finished = true; + } +} + +fn open_history_sources( + path: &Path, + rotated_files: usize, + active: Option<(&File, u64, FileIdentity)>, +) -> Result> { + let mut sources = Vec::new(); + let mut identities = std::collections::HashSet::new(); + if let Some((file, length, identity)) = active { + identities.insert(identity); + sources.push(HistorySource { + file: file.try_clone()?, + position: length, + start_verified: read_boundary_marker(path, identity), + }); + } else { + match File::open(path) { + Ok(file) => { + let metadata = file.metadata()?; + let identity = file_identity(&metadata); + identities.insert(identity); + sources.push(HistorySource { + file, + position: metadata.len(), + start_verified: read_boundary_marker(path, identity), + }); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| format!("failed to open {}", path.display())); + } + } + } + for index in 1..=rotated_files { + let rotated = rotated_path(path, index); + let file = match File::open(&rotated) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(error).with_context(|| format!("failed to open {}", rotated.display())); + } + }; + let metadata = file.metadata()?; + let identity = file_identity(&metadata); + if identities.insert(identity) { + sources.push(HistorySource { + file, + position: metadata.len(), + start_verified: read_boundary_marker(&rotated, identity), + }); + } + } + Ok(sources) +} + fn tail_sources(sources_newest_first: &[PathBuf], count: usize) -> Result> { let mut sources = Vec::new(); for source in sources_newest_first { @@ -740,6 +969,19 @@ impl FileFollower { tail_open_sources(&mut sources, count) } + pub fn history_pager( + &self, + rotated_files: usize, + max_line_bytes: usize, + ) -> Result { + let sources = open_history_sources( + &self.active_path, + rotated_files, + Some((&self.file, self.offset, self.identity)), + )?; + HistoryPager::from_sources(sources, max_line_bytes) + } + fn open(path: &Path, from_end: bool, max_partial_line: usize) -> Result> { let mut file = match File::open(path) { Ok(file) => file, @@ -1214,6 +1456,82 @@ mod tests { } } + #[test] + fn history_pager_walks_backwards_in_bounded_pages() { + let path = temp_path("history-pager"); + let mut content = (0..300) + .map(|index| format!("line-{index}\n")) + .collect::(); + content.push_str("incomplete-secret"); + fs::write(&path, content).unwrap(); + + let mut pager = HistoryPager::open(&path, 0, 128).unwrap(); + let newest = pager.next_older(100).unwrap(); + let middle = pager.next_older(100).unwrap(); + let oldest = pager.next_older(100).unwrap(); + let boundary = pager.next_older(100).unwrap(); + + assert_eq!(newest.first().map(String::as_str), Some("line-201")); + assert_eq!(newest.get(98).map(String::as_str), Some("line-299")); + assert_eq!( + newest.last().map(String::as_str), + Some("… [incomplete log line omitted]") + ); + assert_eq!(middle.first().map(String::as_str), Some("line-101")); + assert_eq!(middle.last().map(String::as_str), Some("line-200")); + assert_eq!(oldest.first().map(String::as_str), Some("line-1")); + assert_eq!(oldest.last().map(String::as_str), Some("line-100")); + assert_eq!( + boundary.first().map(String::as_str), + Some("… [history boundary]") + ); + assert!(newest + .iter() + .chain(&middle) + .chain(&oldest) + .chain(&boundary) + .all(|line| !line.contains("incomplete-secret"))); + assert!(!pager.has_more()); + remove_if_exists(&path).unwrap(); + } + + #[test] + fn history_pager_omits_incomplete_newest_rotated_fragment_when_active_is_empty() { + let path = temp_path("history-pager-empty-active"); + fs::write(&path, b"").unwrap(); + fs::write(rotated_path(&path, 1), b"token=unredactable").unwrap(); + + let mut pager = HistoryPager::open(&path, 1, 128).unwrap(); + let history = pager.next_older(20).unwrap(); + + assert_eq!(history, ["… [incomplete log line omitted]"]); + assert!(history.iter().all(|line| !line.contains("unredactable"))); + remove_if_exists(&path).unwrap(); + remove_if_exists(&rotated_path(&path, 1)).unwrap(); + } + + #[test] + fn history_pager_reassembles_lines_split_across_rotations() { + let path = temp_path("history-pager-split"); + let mut writer = RotatingWriter::new(path.clone(), policy(6, 3)).unwrap(); + writer.write_bounded(b"abcdefgh\nnext\n").unwrap(); + drop(writer); + + let mut pager = HistoryPager::open(&path, 3, 128).unwrap(); + let history = pager.next_older(20).unwrap(); + + assert_eq!(history, ["abcdefgh", "next"]); + for index in 0..=3 { + let file = if index == 0 { + path.clone() + } else { + rotated_path(&path, index) + }; + remove_if_exists(&file).unwrap(); + remove_if_exists(&boundary_path(&file)).unwrap(); + } + } + #[test] fn tail_omits_an_incomplete_last_line() { let path = temp_path("incomplete-tail"); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 0aade79..2b60605 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -17,7 +17,7 @@ use tokio::sync::mpsc; use crate::core::state::HealthState; use crate::doctor; use crate::runtime::detached::{DetachedRuntime, DetachedServiceStatus}; -use crate::runtime::logs::{tail_history, FileFollower, Redactor}; +use crate::runtime::logs::{FileFollower, HistoryPager, Redactor}; mod ui; @@ -25,10 +25,13 @@ const EVENT_TICK: Duration = Duration::from_millis(250); const RUNTIME_POLL_INTERVAL: Duration = Duration::from_secs(1); const MAX_LOG_LINES: usize = 500; const MAX_LOG_VIEW_BYTES: usize = 4 * 1024 * 1024; +const INITIAL_LOG_LINES: usize = 200; +const LOG_HISTORY_PAGE_LINES: usize = 100; +const LOG_SCROLL_PAGE_LINES: usize = 20; const DETAIL_LINE_COUNT: u16 = 16; const DETAIL_HORIZONTAL_LIMIT: u16 = 512; -#[derive(Debug, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq)] enum Mode { Normal, TemplateSelect, @@ -47,6 +50,11 @@ pub struct App { pub template: Option, pub log_lines: VecDeque, log_bytes: usize, + pub log_scroll_from_bottom: usize, + pub log_follow: bool, + pub log_history_exhausted: bool, + pub log_visible_height: usize, + log_history_stale: bool, pub log_search: String, pub log_searching: bool, pub details_scroll: u16, @@ -60,6 +68,10 @@ pub struct App { health_due: HashMap, stdout_follower: Option, stderr_follower: Option, + stdout_history: Option, + stderr_history: Option, + stdout_history_skip: usize, + stderr_history_skip: usize, log_service: Option, redactor: Redactor, active_poll: Option, @@ -88,6 +100,11 @@ impl App { template, log_lines: VecDeque::with_capacity(MAX_LOG_LINES), log_bytes: 0, + log_scroll_from_bottom: 0, + log_follow: true, + log_history_exhausted: false, + log_visible_height: LOG_SCROLL_PAGE_LINES, + log_history_stale: false, log_search: String::new(), log_searching: false, details_scroll: 0, @@ -101,6 +118,10 @@ impl App { health_due: HashMap::new(), stdout_follower: None, stderr_follower: None, + stdout_history: None, + stderr_history: None, + stdout_history_skip: 0, + stderr_history_skip: 0, log_service: None, redactor, active_poll: None, @@ -256,7 +277,7 @@ async fn event_loop( schedule_poll(&mut app, &monitor_tx); loop { - terminal.draw(|f| ui::draw(f, &app))?; + terminal.draw(|f| ui::draw(f, &mut app))?; if app.should_quit { return Ok(()); @@ -540,7 +561,18 @@ fn handle_key( } match code { KeyCode::Esc | KeyCode::Char('q') => app.mode = Mode::Normal, - KeyCode::Char('c') => clear_log_view(app), + KeyCode::Up | KeyCode::Char('k') => scroll_logs_up(app, 1), + KeyCode::Down | KeyCode::Char('j') => scroll_logs_down(app, 1), + KeyCode::PageUp => scroll_logs_up(app, LOG_SCROLL_PAGE_LINES), + KeyCode::PageDown => scroll_logs_down(app, LOG_SCROLL_PAGE_LINES), + KeyCode::Home => scroll_logs_to_oldest(app), + KeyCode::End => return_to_live_logs(app), + KeyCode::Char('c') => { + clear_log_view(app); + app.log_scroll_from_bottom = 0; + app.log_follow = true; + app.log_history_stale = true; + } KeyCode::Char('/') => { app.log_search.clear(); app.log_searching = true; @@ -884,32 +916,71 @@ fn open_logs(app: &mut App) { clear_log_view(app); app.log_search.clear(); app.log_searching = false; + app.log_scroll_from_bottom = 0; + app.log_follow = true; + app.log_history_stale = false; let rotated_files = app.runtime.config().logs.rotated_files; let max_line_bytes = app.runtime.config().logs.max_line_bytes; - let mut stdout_follower = FileFollower::from_end_with_limit(&stdout_path, max_line_bytes) - .ok() - .flatten(); - let mut stderr_follower = FileFollower::from_end_with_limit(&stderr_path, max_line_bytes) - .ok() - .flatten(); - let stdout_tail = match stdout_follower.as_mut() { - Some(follower) => follower.initial_tail(200, rotated_files), - None => tail_history(&stdout_path, 200, rotated_files), + let stdout_follower = match FileFollower::from_end_with_limit(&stdout_path, max_line_bytes) { + Ok(follower) => follower, + Err(error) => { + app.status_line = format!("could not follow stdout log: {error}"); + None + } + }; + let stderr_follower = match FileFollower::from_end_with_limit(&stderr_path, max_line_bytes) { + Ok(follower) => follower, + Err(error) => { + app.status_line = format!("could not follow stderr log: {error}"); + None + } + }; + let stdout_history_result = match stdout_follower.as_ref() { + Some(follower) => follower.history_pager(rotated_files, max_line_bytes), + None => HistoryPager::open(&stdout_path, rotated_files, max_line_bytes), + }; + let stderr_history_result = match stderr_follower.as_ref() { + Some(follower) => follower.history_pager(rotated_files, max_line_bytes), + None => HistoryPager::open(&stderr_path, rotated_files, max_line_bytes), }; + let mut stdout_history = match stdout_history_result { + Ok(history) => Some(history), + Err(error) => { + app.status_line = format!("could not read stdout history: {error}"); + None + } + }; + let mut stderr_history = match stderr_history_result { + Ok(history) => Some(history), + Err(error) => { + app.status_line = format!("could not read stderr history: {error}"); + None + } + }; + let stdout_tail = stdout_history.as_mut().map_or_else( + || Ok(Vec::new()), + |history| history.next_older(INITIAL_LOG_LINES), + ); match stdout_tail { Ok(lines) => append_log_lines(app, "stdout", lines), Err(error) => app.status_line = error.to_string(), } - let stderr_tail = match stderr_follower.as_mut() { - Some(follower) => follower.initial_tail(200, rotated_files), - None => tail_history(&stderr_path, 200, rotated_files), - }; + let stderr_tail = stderr_history.as_mut().map_or_else( + || Ok(Vec::new()), + |history| history.next_older(INITIAL_LOG_LINES), + ); match stderr_tail { Ok(lines) => append_log_lines(app, "stderr", lines), Err(error) => app.status_line = error.to_string(), } app.stdout_follower = stdout_follower; app.stderr_follower = stderr_follower; + app.log_history_exhausted = !stdout_history.as_ref().is_some_and(HistoryPager::has_more) + && !stderr_history.as_ref().is_some_and(HistoryPager::has_more); + app.stdout_history = stdout_history; + app.stderr_history = stderr_history; + app.stdout_history_skip = 0; + app.stderr_history_skip = 0; app.log_service = Some(name); app.mode = Mode::Logs; } @@ -926,24 +997,34 @@ fn refresh_log_followers(app: &mut App) { }; let max_line_bytes = app.runtime.config().logs.max_line_bytes; if app.stdout_follower.is_none() { - app.stdout_follower = FileFollower::from_start_with_limit(&stdout_path, max_line_bytes) - .ok() - .flatten(); + match FileFollower::from_start_with_limit(&stdout_path, max_line_bytes) { + Ok(follower) => app.stdout_follower = follower, + Err(error) => app.status_line = format!("could not follow stdout log: {error}"), + } } if app.stderr_follower.is_none() { - app.stderr_follower = FileFollower::from_start_with_limit(&stderr_path, max_line_bytes) - .ok() - .flatten(); + match FileFollower::from_start_with_limit(&stderr_path, max_line_bytes) { + Ok(follower) => app.stderr_follower = follower, + Err(error) => app.status_line = format!("could not follow stderr log: {error}"), + } } if let Some(follower) = &mut app.stdout_follower { match follower.read_new_lines() { - Ok(lines) => append_log_lines(app, "stdout", lines), + Ok(lines) if app.log_follow => { + app.log_history_stale |= !lines.is_empty(); + append_log_lines(app, "stdout", lines); + } + Ok(_) => {} Err(error) => app.status_line = format!("log follow error: {error}"), } } if let Some(follower) = &mut app.stderr_follower { match follower.read_new_lines() { - Ok(lines) => append_log_lines(app, "stderr", lines), + Ok(lines) if app.log_follow => { + app.log_history_stale |= !lines.is_empty(); + append_log_lines(app, "stderr", lines); + } + Ok(_) => {} Err(error) => app.status_line = format!("log follow error: {error}"), } } @@ -951,19 +1032,7 @@ fn refresh_log_followers(app: &mut App) { fn append_log_lines(app: &mut App, stream: &str, lines: Vec) { for line in lines { - let mut rendered = format!( - "[{stream}] {}", - app.redactor - .redact_bounded(&line, app.runtime.config().logs.max_line_bytes) - ); - if rendered.len() > MAX_LOG_VIEW_BYTES { - let mut boundary = MAX_LOG_VIEW_BYTES.saturating_sub(16); - while !rendered.is_char_boundary(boundary) { - boundary -= 1; - } - rendered.truncate(boundary); - rendered.push_str("… [truncated]"); - } + let rendered = render_log_line(app, stream, &line); while app.log_lines.len() >= MAX_LOG_LINES || app.log_bytes + rendered.len() > MAX_LOG_VIEW_BYTES { @@ -977,6 +1046,169 @@ fn append_log_lines(app: &mut App, stream: &str, lines: Vec) { } } +fn prepend_log_lines(app: &mut App, stream: &str, lines: Vec) -> usize { + let rendered = lines + .into_iter() + .map(|line| render_log_line(app, stream, &line)) + .collect::>(); + let added = rendered.len(); + for line in rendered.into_iter().rev() { + while app.log_lines.len() >= MAX_LOG_LINES + || app.log_bytes + line.len() > MAX_LOG_VIEW_BYTES + { + let Some(removed) = app.log_lines.pop_back() else { + break; + }; + app.log_bytes = app.log_bytes.saturating_sub(removed.len()); + } + app.log_bytes += line.len(); + app.log_lines.push_front(line); + } + added +} + +fn render_log_line(app: &App, stream: &str, line: &str) -> String { + let mut rendered = format!( + "[{stream}] {}", + app.redactor + .redact_bounded(line, app.runtime.config().logs.max_line_bytes) + ); + if rendered.len() > MAX_LOG_VIEW_BYTES { + let mut boundary = MAX_LOG_VIEW_BYTES.saturating_sub(16); + while !rendered.is_char_boundary(boundary) { + boundary -= 1; + } + rendered.truncate(boundary); + rendered.push_str("… [truncated]"); + } + rendered +} + +fn scroll_logs_up(app: &mut App, amount: usize) { + if app.log_follow && app.log_history_stale { + refresh_log_history_snapshot(app); + } + app.log_follow = false; + app.log_scroll_from_bottom = app.log_scroll_from_bottom.saturating_add(amount); + let near_oldest_loaded = app.log_scroll_from_bottom >= max_log_scroll(app); + if near_oldest_loaded { + load_older_logs(app); + } +} + +fn scroll_logs_to_oldest(app: &mut App) { + if app.log_follow && app.log_history_stale { + refresh_log_history_snapshot(app); + } + app.log_follow = false; + load_older_logs(app); + app.log_scroll_from_bottom = max_log_scroll(app); +} + +fn scroll_logs_down(app: &mut App, amount: usize) { + if app.log_scroll_from_bottom <= amount { + return_to_live_logs(app); + } else { + app.log_scroll_from_bottom -= amount; + } +} + +fn load_older_logs(app: &mut App) { + if app.log_history_exhausted { + return; + } + let stdout = next_history_page(&mut app.stdout_history, &mut app.stdout_history_skip); + let stderr = next_history_page(&mut app.stderr_history, &mut app.stderr_history_skip); + let mut added = 0; + match stdout { + Ok(lines) => added += prepend_log_lines(app, "stdout", lines), + Err(error) => app.status_line = format!("log history error: {error}"), + } + match stderr { + Ok(lines) => added += prepend_log_lines(app, "stderr", lines), + Err(error) => app.status_line = format!("log history error: {error}"), + } + if added > 0 { + app.log_scroll_from_bottom = max_log_scroll(app); + } + app.log_history_exhausted = !app + .stdout_history + .as_ref() + .is_some_and(HistoryPager::has_more) + && !app + .stderr_history + .as_ref() + .is_some_and(HistoryPager::has_more); + if added == 0 && !app.log_history_exhausted { + app.status_line = "scanning older log data; scroll up again to continue".to_string(); + } +} + +fn max_log_scroll(app: &App) -> usize { + app.log_lines.len().saturating_sub(app.log_visible_height) +} + +fn next_history_page(history: &mut Option, skip: &mut usize) -> Result> { + let Some(history) = history else { + return Ok(Vec::new()); + }; + if *skip > 0 { + let skipped = history.next_older(*skip)?; + *skip = (*skip).saturating_sub(skipped.len()); + if *skip > 0 || (skipped.is_empty() && history.has_more()) { + return Ok(Vec::new()); + } + } + history.next_older(LOG_HISTORY_PAGE_LINES) +} + +fn refresh_log_history_snapshot(app: &mut App) { + let Some(service) = app.log_service.clone() else { + return; + }; + let Ok((stdout_path, stderr_path)) = app.runtime.log_paths(&service) else { + return; + }; + let rotated_files = app.runtime.config().logs.rotated_files; + let max_line_bytes = app.runtime.config().logs.max_line_bytes; + let stdout = match app.stdout_follower.as_ref() { + Some(follower) => follower.history_pager(rotated_files, max_line_bytes), + None => HistoryPager::open(&stdout_path, rotated_files, max_line_bytes), + }; + let stderr = match app.stderr_follower.as_ref() { + Some(follower) => follower.history_pager(rotated_files, max_line_bytes), + None => HistoryPager::open(&stderr_path, rotated_files, max_line_bytes), + }; + match (stdout, stderr) { + (Ok(stdout), Ok(stderr)) => { + app.stdout_history = Some(stdout); + app.stderr_history = Some(stderr); + app.stdout_history_skip = app + .log_lines + .iter() + .filter(|line| line.starts_with("[stdout] ")) + .count(); + app.stderr_history_skip = app + .log_lines + .iter() + .filter(|line| line.starts_with("[stderr] ")) + .count(); + app.log_history_exhausted = false; + app.log_history_stale = false; + } + (Err(error), _) => app.status_line = format!("could not refresh stdout history: {error}"), + (_, Err(error)) => app.status_line = format!("could not refresh stderr history: {error}"), + } +} + +fn return_to_live_logs(app: &mut App) { + let search = std::mem::take(&mut app.log_search); + let searching = app.log_searching; + open_logs(app); + app.log_search = search; + app.log_searching = searching; +} + fn clear_log_view(app: &mut App) { app.log_lines.clear(); app.log_bytes = 0; @@ -985,21 +1217,33 @@ fn clear_log_view(app: &mut App) { #[cfg(test)] mod tests { use std::fs; + use std::fs::OpenOptions; + use std::io::Write; use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; - use crate::config::{Config, Loaded, TemplateConfig}; + use crate::config::{Config, Loaded, ServiceConfig, TemplateConfig}; use crate::core::state::{PortState, ProcessState}; use super::*; - fn empty_app() -> (App, PathBuf) { + static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + + fn test_root() -> PathBuf { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); - let root = - std::env::temp_dir().join(format!("hum-tui-test-{}-{unique}", std::process::id())); + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "hum-tui-test-{}-{unique}-{sequence}", + std::process::id() + )) + } + + fn empty_app() -> (App, PathBuf) { + let root = test_root(); let loaded = Loaded { config: Config { version: 2, @@ -1029,6 +1273,35 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + fn logs_app() -> (App, PathBuf) { + let root = test_root(); + let loaded = Loaded { + config: Config { + version: 2, + project: Some("demo".to_string()), + services: HashMap::from([("api".to_string(), ServiceConfig::default())]), + templates: HashMap::from([( + "logs".to_string(), + TemplateConfig { + services: vec!["api".to_string()], + }, + )]), + ..Config::default() + }, + base_path: root.join("hum.yaml"), + local_path: None, + root_dir: root.clone(), + }; + let runtime = DetachedRuntime::with_state_root( + "demo".to_string(), + loaded, + HashMap::new(), + root.join("state"), + ) + .unwrap(); + (App::new(Arc::new(runtime), Some("logs".to_string())), root) + } + #[test] fn quit_requires_an_explicit_leave_choice() { let (mut app, root) = empty_app(); @@ -1134,4 +1407,77 @@ mod tests { assert_eq!(app.statuses["worker"].health, HealthState::Unchecked); cleanup(app, root); } + + #[test] + fn log_view_scrolls_into_disk_history_and_end_returns_live() { + let (mut app, root) = logs_app(); + let (stdout, _stderr) = app.runtime.log_paths("api").unwrap(); + fs::create_dir_all(stdout.parent().unwrap()).unwrap(); + let content = (0..300) + .map(|index| format!("line-{index}\n")) + .collect::(); + fs::write(&stdout, content).unwrap(); + let (action_tx, _action_rx) = mpsc::unbounded_channel(); + let (doctor_tx, _doctor_rx) = mpsc::unbounded_channel(); + + open_logs(&mut app); + assert!(app.log_follow); + assert_eq!(app.log_lines.len(), INITIAL_LOG_LINES); + + let mut stdout_file = OpenOptions::new().append(true).open(&stdout).unwrap(); + write!( + stdout_file, + "{}", + (300..700) + .map(|index| format!("line-{index}\n")) + .collect::() + ) + .unwrap(); + drop(stdout_file); + refresh_log_followers(&mut app); + assert_eq!(app.log_lines.len(), MAX_LOG_LINES); + assert!(app.log_lines.back().unwrap().contains("line-699")); + + handle_key(&mut app, KeyCode::Home, &action_tx, &doctor_tx); + assert!(!app.log_follow); + assert_eq!(app.log_scroll_from_bottom, max_log_scroll(&app)); + assert!(app.log_lines.iter().any(|line| line.contains("line-100"))); + let oldest_offset = app.log_scroll_from_bottom; + handle_key(&mut app, KeyCode::PageDown, &action_tx, &doctor_tx); + assert_eq!( + app.log_scroll_from_bottom, + oldest_offset - LOG_SCROLL_PAGE_LINES + ); + + let mut stdout_file = OpenOptions::new().append(true).open(&stdout).unwrap(); + writeln!(stdout_file, "line-700").unwrap(); + drop(stdout_file); + refresh_log_followers(&mut app); + assert!(app.log_lines.iter().all(|line| !line.contains("line-700"))); + + app.log_search = "line-29".to_string(); + handle_key(&mut app, KeyCode::End, &action_tx, &doctor_tx); + assert!(app.log_follow); + assert_eq!(app.log_scroll_from_bottom, 0); + assert_eq!(app.log_search, "line-29"); + assert!(app.log_lines.back().unwrap().contains("line-700")); + cleanup(app, root); + } + + #[test] + fn log_view_keeps_its_memory_bounds() { + let (mut app, root) = empty_app(); + append_log_lines( + &mut app, + "stdout", + (0..(MAX_LOG_LINES + 100)) + .map(|index| format!("line-{index}")) + .collect(), + ); + + assert_eq!(app.log_lines.len(), MAX_LOG_LINES); + assert!(app.log_bytes <= MAX_LOG_VIEW_BYTES); + assert!(app.log_lines.front().unwrap().contains("line-100")); + cleanup(app, root); + } } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 88255be..9e359ef 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -6,7 +6,7 @@ use ratatui::Frame; use super::{App, Mode}; -pub fn draw(f: &mut Frame, app: &App) { +pub fn draw(f: &mut Frame, app: &mut App) { let size = f.area(); let chunks = Layout::default() .direction(Direction::Vertical) @@ -315,10 +315,10 @@ fn draw_details(f: &mut Frame, app: &App, area: Rect) { ); } -fn draw_logs(f: &mut Frame, app: &App, area: Rect) { +fn draw_logs(f: &mut Frame, app: &mut App, area: Rect) { let popup = centered_rect(90, 80, area); f.render_widget(Clear, popup); - let Some(name) = app.selected_name() else { + let Some(name) = app.log_service.as_deref() else { return; }; let lines: Vec = app @@ -335,14 +335,22 @@ fn draw_logs(f: &mut Frame, app: &App, area: Rect) { format!(" filter: /{} ", app.log_search) }; let visible_height = usize::from(popup.height.saturating_sub(2)); - let scroll = lines - .len() - .saturating_sub(visible_height) + app.log_visible_height = visible_height; + let max_scroll = lines.len().saturating_sub(visible_height); + let from_bottom = app.log_scroll_from_bottom.min(max_scroll); + let scroll = max_scroll + .saturating_sub(from_bottom) .min(usize::from(u16::MAX)) as u16; + let state = if app.log_follow { "LIVE" } else { "PAUSED" }; + let history = if app.log_history_exhausted { + " oldest loaded" + } else { + " Home: older" + }; f.render_widget( Paragraph::new(lines).scroll((scroll, 0)).block( Block::default().borders(Borders::ALL).title(format!( - " {name} — logs (/: search, c: clear view, esc: close){search}" + " {name} — logs [{state}] (j/k, PgUp/PgDn, End: live,{history}, /: search, c: clear, esc: close){search}" )), ), popup,