use crate::api::models::LogEntry;
use crate::state::LogsState;
use crate::ui::render_tab_bar;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState, Wrap},
Frame,
};
#[allow(clippy::too_many_arguments)]
pub fn render_logs_view(
frame: &mut Frame,
area: Rect,
state: &LogsState,
filter_input_active: bool,
filter_input_buffer: &str,
api_error: Option<&str>,
) {
let content_area = if let Some(err) = api_error {
let splits = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area);
render_api_error_banner(frame, splits[0], err);
splits[1]
} else {
area
};
let constraints = if filter_input_active {
vec![
Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), Constraint::Length(1), ]
} else {
vec![
Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), ]
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(content_area);
render_tab_bar(frame, chunks[0], "Logs");
if state.show_detail {
render_logs_with_detail(frame, chunks[1], state);
} else {
render_logs_table(frame, chunks[1], state);
}
if filter_input_active {
render_filter_input_bar(frame, chunks[2], filter_input_buffer);
render_status_bar(frame, chunks[3], state, api_error);
} else {
render_status_bar(frame, chunks[2], state, api_error);
}
}
fn render_logs_table(frame: &mut Frame, area: Rect, state: &LogsState) {
let filtered_logs = state.filtered_logs();
if filtered_logs.is_empty() && state.error.is_none() {
let block = Block::default().borders(Borders::ALL).title(" Logs (0) ");
let inner = block.inner(area);
frame.render_widget(block, area);
let paragraph =
Paragraph::new("No logs yet — send OTLP data to :4317 (gRPC) or :4318 (HTTP)")
.alignment(Alignment::Center)
.style(Style::default().fg(Color::DarkGray));
if inner.height > 2 {
let v_splits = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(inner.height / 2),
Constraint::Length(1),
Constraint::Min(0),
])
.split(inner);
frame.render_widget(paragraph, v_splits[1]);
} else {
frame.render_widget(paragraph, inner);
}
return;
}
let rows: Vec<Row> = filtered_logs
.iter()
.map(|log| {
Row::new(vec![
Cell::from(format_timestamp(log.timestamp)),
Cell::from(log.severity.clone()).style(get_severity_style(&log.severity)),
Cell::from(truncate_string(&log.body, 80))
.style(Style::default().fg(get_severity_body_color(&log.severity))),
])
.height(1)
})
.collect();
let header = Row::new(vec!["Timestamp", "Severity", "Message"])
.style(Style::default().add_modifier(Modifier::BOLD))
.bottom_margin(1);
let table = Table::new(
rows,
[
Constraint::Length(16), Constraint::Length(10), Constraint::Min(50), ],
)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" Logs ({}) ", filtered_logs.len()))
.border_style(Style::default().fg(Color::Blue)),
)
.row_highlight_style(
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let mut table_state = TableState::default();
table_state.select(Some(state.selected_index));
frame.render_stateful_widget(table, area, &mut table_state);
}
fn render_logs_with_detail(frame: &mut Frame, area: Rect, state: &LogsState) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(60), Constraint::Percentage(40), ])
.split(area);
render_logs_table(frame, chunks[0], state);
render_detail_panel(frame, chunks[1], state);
}
fn render_detail_panel(frame: &mut Frame, area: Rect, state: &LogsState) {
let content = if let Some(log) = state.selected_log_detail() {
format_log_detail(log)
} else {
Text::from("No log selected")
};
let paragraph = Paragraph::new(content)
.block(Block::default().borders(Borders::ALL).title(" Log Detail "))
.wrap(Wrap { trim: true })
.scroll((state.detail_scroll, 0));
frame.render_widget(paragraph, area);
}
fn format_log_detail(log: &LogEntry) -> Text<'static> {
let mut lines = vec![
Line::from(vec![
Span::styled("Timestamp: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(format_timestamp_full(log.timestamp)),
]),
Line::from(vec![
Span::styled("Severity: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(log.severity.clone(), get_severity_style(&log.severity)),
]),
Line::from(""),
Line::from(vec![Span::styled(
"Message:",
Style::default().add_modifier(Modifier::BOLD),
)]),
Line::from(log.body.clone()),
Line::from(""),
];
if !log.attributes.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"Attributes:",
Style::default().add_modifier(Modifier::BOLD),
)]));
for (key, value) in &log.attributes {
append_formatted_key_value_lines(&mut lines, key, value, 60);
}
}
if let Some(resource) = &log.resource {
if !resource.attributes.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"Resource:",
Style::default().add_modifier(Modifier::BOLD),
)]));
for (key, value) in &resource.attributes {
append_formatted_key_value_lines(&mut lines, key, value, 60);
}
}
}
Text::from(lines)
}
fn append_formatted_key_value_lines(
lines: &mut Vec<Line<'static>>,
key: &str,
value: &str,
preview_width: usize,
) {
let preview = otelite_core::telemetry::format_attribute_preview(value, preview_width);
lines.push(Line::from(format!(" {key}: {preview}")));
let formatted = otelite_core::telemetry::format_attribute_value(value);
if formatted != value {
for line in formatted.lines() {
lines.push(Line::from(format!(" {line}")));
}
}
}
fn render_status_bar(frame: &mut Frame, area: Rect, state: &LogsState, api_error: Option<&str>) {
let mut status_parts = vec![];
status_parts.push(Span::styled(
" LOGS ",
Style::default()
.fg(Color::Black)
.bg(Color::Blue)
.add_modifier(Modifier::BOLD),
));
status_parts.push(Span::raw(" "));
if api_error.is_some() {
status_parts.push(Span::styled(
"Disconnected",
Style::default().fg(Color::Red),
));
} else {
status_parts.push(Span::styled("Connected", Style::default().fg(Color::Green)));
}
status_parts.push(Span::styled(
format!(" | Logs: {} ", state.filtered_logs().len()),
Style::default().fg(Color::Gray),
));
if !state.search_query.is_empty() {
status_parts.push(Span::raw(" "));
status_parts.push(Span::styled(
format!(" 🔍 {} ", state.search_query),
Style::default().fg(Color::Yellow),
));
}
if !state.filters.is_empty() {
status_parts.push(Span::raw(" "));
let filter_text = state
.filters
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join(", ");
status_parts.push(Span::styled(
format!(" 🔧 {} ", filter_text),
Style::default().fg(Color::Cyan),
));
}
if state.auto_scroll {
status_parts.push(Span::raw(" "));
status_parts.push(Span::styled(" ⬇ AUTO ", Style::default().fg(Color::Green)));
}
if let Some(error) = &state.error {
status_parts.push(Span::raw(" "));
status_parts.push(Span::styled(
format!(" ⚠ {} ", error),
Style::default().fg(Color::Red),
));
}
status_parts.push(Span::raw(" | "));
status_parts.push(Span::styled(
"↑↓/jk:Navigate Enter:Detail /:Search f:Filter a:AutoScroll r:Refresh",
Style::default().fg(Color::Gray),
));
let status_line = Line::from(status_parts);
let paragraph = Paragraph::new(status_line);
frame.render_widget(paragraph, area);
}
pub(crate) fn render_api_error_banner(frame: &mut Frame, area: Rect, err: &str) {
let paragraph = Paragraph::new(format!("⚠ Cannot connect to API: {}", err))
.style(Style::default().fg(Color::Red).bg(Color::Black));
frame.render_widget(paragraph, area);
}
fn render_filter_input_bar(frame: &mut Frame, area: Rect, buffer: &str) {
let line = Line::from(vec![
Span::styled(
"Filter: ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(buffer.to_string()),
Span::styled("█", Style::default().fg(Color::Yellow)),
Span::styled(
" (Enter to apply, Esc to cancel, key=value or text)",
Style::default().fg(Color::DarkGray),
),
]);
frame.render_widget(Paragraph::new(line), area);
}
fn get_severity_body_color(severity: &str) -> Color {
match severity.to_uppercase().as_str() {
"ERROR" | "FATAL" | "CRITICAL" => Color::LightRed,
"WARN" | "WARNING" => Color::LightYellow,
"INFO" => Color::White,
"DEBUG" => Color::Gray,
"TRACE" => Color::DarkGray,
_ => Color::White,
}
}
fn get_severity_style(severity: &str) -> Style {
match severity.to_uppercase().as_str() {
"TRACE" => Style::default().fg(Color::DarkGray),
"DEBUG" => Style::default().fg(Color::Blue),
"INFO" => Style::default().fg(Color::Green),
"WARN" | "WARNING" => Style::default().fg(Color::Yellow),
"ERROR" => Style::default().fg(Color::Red),
"FATAL" | "CRITICAL" => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
_ => Style::default(),
}
}
fn format_timestamp(timestamp_ns: i64) -> String {
use chrono::{DateTime, Local, Utc};
DateTime::<Utc>::from_timestamp_millis(timestamp_ns / 1_000_000)
.map(|dt| {
dt.with_timezone(&Local)
.format("%Y-%m-%d %H:%M")
.to_string()
})
.unwrap_or_else(|| "?".to_string())
}
pub(crate) fn format_timestamp_full(timestamp_ns: i64) -> String {
use chrono::{DateTime, Local, Utc};
DateTime::<Utc>::from_timestamp_millis(timestamp_ns / 1_000_000)
.map(|dt| {
dt.with_timezone(&Local)
.format("%Y-%m-%d %H:%M:%S %z")
.to_string()
})
.unwrap_or_else(|| "?".to_string())
}
fn truncate_string(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len - 3])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_severity_style() {
let trace_style = get_severity_style("TRACE");
assert_eq!(trace_style.fg, Some(Color::DarkGray));
let error_style = get_severity_style("ERROR");
assert_eq!(error_style.fg, Some(Color::Red));
}
#[test]
fn test_format_timestamp() {
let timestamp_ns: i64 = 1713360896789 * 1_000_000;
let formatted = format_timestamp(timestamp_ns);
assert_eq!(formatted.len(), 16);
assert!(formatted.starts_with("20")); assert!(formatted.contains('-'));
assert!(formatted.contains(':'));
}
#[test]
fn test_format_timestamp_full() {
let timestamp_ns: i64 = 1713360896789 * 1_000_000;
let formatted = format_timestamp_full(timestamp_ns);
assert!(formatted.len() >= 24);
assert!(formatted.starts_with("20"));
}
#[test]
fn test_truncate_string() {
let short = "Hello";
assert_eq!(truncate_string(short, 10), "Hello");
let long = "This is a very long string that needs truncation";
let truncated = truncate_string(long, 20);
assert_eq!(truncated.len(), 20);
assert!(truncated.ends_with("..."));
}
}