use std::collections::VecDeque;
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use time::{OffsetDateTime, UtcOffset};
use crate::domain::system_log::{
SYSTEM_LOG_LIMIT, SystemLogBuffer, SystemLogEntry, SystemLogLevel,
};
use crate::ui::state::help_action;
use crate::ui::text_util::inline_text;
use crate::ui::{Page, layout, style};
pub struct SystemLogPage<'a> {
logs: &'a SystemLogBuffer,
tail_offset: u16,
}
impl<'a> SystemLogPage<'a> {
pub fn new(logs: &'a SystemLogBuffer, tail_offset: u16) -> Self {
Self { logs, tail_offset }
}
}
impl Page for SystemLogPage<'_> {
fn render(&mut self, f: &mut Frame, area: Rect) {
let areas = layout::tab_page_areas(area);
let block = system_log_block(self.logs.len());
let inner_height = block.inner(areas.main_area).height;
let lines = system_log_lines(self.logs.entries());
let scroll_offset = top_scroll_offset(lines.len(), inner_height, self.tail_offset);
let paragraph = Paragraph::new(lines)
.block(block)
.scroll((scroll_offset, 0))
.style(Style::default().fg(style::palette::text()));
f.render_widget(paragraph, areas.main_area);
f.render_widget(Paragraph::new(system_log_footer_line()), areas.footer_area);
}
}
fn system_log_block(log_count: usize) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.title(format!("Logs ({log_count}/{SYSTEM_LOG_LIMIT})"))
.border_style(style::border_style())
}
fn system_log_lines(entries: &VecDeque<SystemLogEntry>) -> Vec<Line<'static>> {
if entries.is_empty() {
return vec![Line::styled(
"No system logs yet.",
Style::default().fg(style::palette::text_muted()),
)];
}
entries.iter().map(system_log_line).collect()
}
fn system_log_line(entry: &SystemLogEntry) -> Line<'static> {
let mut spans = vec![
Span::styled(
format!("{} ", timestamp_label(entry.timestamp_unix_seconds)),
Style::default().fg(style::palette::text_subtle()),
),
Span::styled(
format!("{:<4} ", entry.level.label()),
level_style(entry.level).add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{:<7} ", entry.category.label()),
Style::default().fg(style::palette::accent_soft()),
),
Span::styled(
inline_text(&entry.message),
Style::default().fg(style::palette::text()),
),
];
if let Some(detail) = entry.detail.as_ref().filter(|detail| !detail.is_empty()) {
spans.push(Span::styled(
" ",
Style::default().fg(style::palette::text_subtle()),
));
spans.push(Span::styled(
inline_text(detail),
Style::default().fg(style::palette::text_muted()),
));
}
Line::from(spans)
}
fn level_style(level: SystemLogLevel) -> Style {
match level {
SystemLogLevel::Info => Style::default().fg(style::palette::info()),
SystemLogLevel::Success => Style::default().fg(style::palette::success()),
SystemLogLevel::Warning => Style::default().fg(style::palette::warning()),
SystemLogLevel::Error => Style::default().fg(style::palette::danger()),
}
}
fn timestamp_label(timestamp_unix_seconds: i64) -> String {
let Ok(utc_time) = OffsetDateTime::from_unix_timestamp(timestamp_unix_seconds) else {
return "--:--:--".to_string();
};
let display_time =
UtcOffset::local_offset_at(utc_time).map_or(utc_time, |offset| utc_time.to_offset(offset));
format!(
"{:02}:{:02}:{:02}",
display_time.hour(),
display_time.minute(),
display_time.second()
)
}
fn top_scroll_offset(line_count: usize, viewport_height: u16, tail_offset: u16) -> u16 {
let hidden_line_count = line_count.saturating_sub(usize::from(viewport_height));
let tail_offset = usize::from(tail_offset).min(hidden_line_count);
let top_offset = hidden_line_count.saturating_sub(tail_offset);
u16::try_from(top_offset).unwrap_or(u16::MAX)
}
fn system_log_footer_line() -> Line<'static> {
let mut line = help_action::footer_line(&help_action::system_log_footer_actions());
line.spans.push(help_action::footer_separator_span());
line.spans.push(help_action::footer_muted_span(format!(
"keeps last {SYSTEM_LOG_LIMIT} entries in memory"
)));
line
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::system_log::{SystemLogCategory, SystemLogEvent};
#[test]
fn top_scroll_offset_tails_newest_lines_by_default() {
let line_count = 20;
let viewport_height = 5;
let tail_offset = 0;
let scroll_offset = top_scroll_offset(line_count, viewport_height, tail_offset);
assert_eq!(scroll_offset, 15);
}
#[test]
fn top_scroll_offset_clamps_to_oldest_lines() {
let line_count = 20;
let viewport_height = 5;
let tail_offset = u16::MAX;
let scroll_offset = top_scroll_offset(line_count, viewport_height, tail_offset);
assert_eq!(scroll_offset, 0);
}
#[test]
fn system_log_line_highlights_warning_level() {
let mut buffer = SystemLogBuffer::default();
buffer.push(
0,
SystemLogEvent::new(
SystemLogLevel::Warning,
SystemLogCategory::Forge,
"Review sync failed",
)
.with_detail("network unavailable"),
);
let entry = buffer
.entries()
.front()
.expect("expected log entry for render test");
let line = system_log_line(entry);
assert_eq!(line.spans[1].content.as_ref(), "WARN ");
assert_eq!(line.spans[1].style.fg, Some(style::palette::warning()));
assert!(line.to_string().contains("network unavailable"));
}
#[test]
fn timestamp_label_returns_unknown_for_invalid_timestamp() {
let timestamp = i64::MAX;
let label = timestamp_label(timestamp);
assert_eq!(label, "--:--:--");
}
}