use net_sdk::deck::{LogLevel, LogRecord};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::theme;
pub struct LogsView<'a> {
pub min_level: LogLevel,
pub paused: bool,
pub search: &'a str,
pub search_editing: bool,
}
pub fn render(
frame: &mut Frame<'_>,
area: Rect,
tick: u64,
records: &[LogRecord],
view: LogsView<'_>,
) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(2),
])
.split(area);
render_filter_bar(
frame,
rows[0],
view.min_level,
view.paused,
view.search,
view.search_editing,
);
render_log_grid(frame, rows[1], tick, records, view.min_level, view.search);
let status_row = Rect {
height: 1,
..rows[2]
};
render_status(
frame,
status_row,
records,
view.min_level,
view.paused,
view.search,
);
}
fn render_filter_bar(
frame: &mut Frame<'_>,
area: Rect,
min_level: LogLevel,
paused: bool,
search: &str,
search_editing: bool,
) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme::rule())
.title(Line::from(vec![
Span::styled(format!("{} ", theme::SECTION_PREFIX), theme::green()),
Span::styled("LOG.MATRIX", theme::green_hi()),
]));
let inner = block.inner(area);
frame.render_widget(block, area);
if search_editing {
let line = Line::from(vec![
Span::styled("/ ", theme::amber()),
Span::styled(search.to_string(), theme::green_hi()),
Span::styled("_", theme::amber()),
Span::styled(" [Enter] commit [Esc] cancel", theme::dim()),
]);
frame.render_widget(Paragraph::new(line), inner);
return;
}
let (level_text, level_style) = level_chip(min_level);
let (follow_text, follow_style) = if paused {
("PAUSED", theme::amber())
} else {
("ON", theme::green_hi())
};
let mut spans = vec![
Span::styled("level ", theme::chrome()),
Span::styled("[", theme::chrome()),
Span::styled(level_text, level_style),
Span::styled("] ", theme::chrome()),
Span::styled("match ", theme::chrome()),
Span::styled("[", theme::chrome()),
];
if search.is_empty() {
spans.push(Span::styled("*", theme::green_hi()));
} else {
spans.push(Span::styled(format!("/{search}/"), theme::amber()));
}
spans.extend([
Span::styled("] ", theme::chrome()),
Span::styled("kind ", theme::chrome()),
Span::styled("[", theme::chrome()),
Span::styled("*", theme::green_hi()),
Span::styled("] ", theme::chrome()),
Span::styled("follow ", theme::chrome()),
Span::styled("[", theme::chrome()),
Span::styled(follow_text, follow_style),
Span::styled("]", theme::chrome()),
]);
frame.render_widget(Paragraph::new(Line::from(spans)), inner);
}
fn level_chip(min_level: LogLevel) -> (&'static str, ratatui::style::Style) {
match min_level {
LogLevel::Debug => ("DEBUG+", theme::green_hi()),
LogLevel::Info => ("INFO+", theme::green_hi()),
LogLevel::Warn => ("WARN+", theme::amber()),
LogLevel::Error => ("ERR", theme::red()),
_ => ("?", theme::chrome()),
}
}
pub(crate) fn level_rank(l: LogLevel) -> u8 {
match l {
LogLevel::Debug => 0,
LogLevel::Info => 1,
LogLevel::Warn => 2,
LogLevel::Error => 3,
_ => 0,
}
}
fn render_log_grid(
frame: &mut Frame<'_>,
area: Rect,
tick: u64,
records: &[LogRecord],
min_level: LogLevel,
search: &str,
) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(theme::rule());
let inner = block.inner(area);
frame.render_widget(block, area);
let total_rows = inner.height as usize;
let _ = tick;
let lines: Vec<Line> = if records.is_empty() {
Vec::new()
} else {
project_log_records(records, total_rows, min_level, search)
};
if lines.is_empty() {
let (head, hint) = if records.is_empty() {
(
"no log lines yet",
"publish_log() on any registered daemon will appear here",
)
} else if !search.is_empty() {
(
"no log lines match the current filters",
"press [/] to edit the search or [f] to lower the level",
)
} else {
(
"no log lines at this threshold",
"press [f] to lower the level filter",
)
};
crate::widgets::empty::render(frame, inner, head, hint);
return;
}
let start = lines.len().saturating_sub(total_rows);
let visible = &lines[start..];
frame.render_widget(Paragraph::new(visible.to_vec()), inner);
}
fn render_status(
frame: &mut Frame<'_>,
area: Rect,
records: &[LogRecord],
min_level: LogLevel,
paused: bool,
search: &str,
) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(36)])
.split(area);
let total = records.len();
let needle = search.to_ascii_lowercase();
let shown = records
.iter()
.filter(|r| level_rank(r.level) >= level_rank(min_level))
.filter(|r| needle.is_empty() || record_matches(r, &needle))
.count();
let (source_text, source_style) = if paused {
("frozen snapshot", theme::amber())
} else {
("live snapshot", theme::dim())
};
let left = Line::from(vec![
Span::styled(format!("{shown}/{total} lines · "), theme::chrome()),
Span::styled(source_text, source_style),
Span::styled(" · ", theme::chrome()),
Span::styled("0 dropped", theme::green_hi()),
]);
frame.render_widget(Paragraph::new(left), cols[0]);
let right = Line::from(vec![
Span::styled("[/] ", theme::green_hi()),
Span::styled("search ", theme::dim()),
Span::styled("[f] ", theme::green_hi()),
Span::styled("filter ", theme::dim()),
Span::styled("[p] ", theme::green_hi()),
Span::styled("pause", theme::dim()),
]);
frame.render_widget(Paragraph::new(right).alignment(Alignment::Right), cols[1]);
}
fn project_log_records(
records: &[LogRecord],
capacity: usize,
min_level: LogLevel,
search: &str,
) -> Vec<Line<'static>> {
let mut out: Vec<Line> = Vec::with_capacity(records.len().min(capacity));
let min = level_rank(min_level);
let needle = search.to_ascii_lowercase();
for rec in records
.iter()
.filter(|r| level_rank(r.level) >= min)
.filter(|r| needle.is_empty() || record_matches(r, &needle))
{
let (level_text, level_style) = match rec.level {
LogLevel::Debug => ("DEBUG", theme::chrome()),
LogLevel::Info => ("INFO ", theme::dim()),
LogLevel::Warn => ("WARN ", theme::amber()),
LogLevel::Error => ("ERROR", theme::red()),
_ => ("? ", theme::text()),
};
let (icon, icon_style) = super::event_icon(rec);
let source = super::event_source(rec);
const SOURCE_PAD: usize = 19;
out.push(Line::from(vec![
Span::styled(
format!(" {} ", super::fmt_ts_hms_ms(rec.ts_ms)),
theme::chrome(),
),
Span::styled(format!("{level_text} "), level_style),
Span::styled(format!("{icon} "), icon_style),
Span::styled(format!("{source:<SOURCE_PAD$} "), icon_style),
Span::styled(rec.message.clone(), theme::text()),
]));
}
out
}
pub(crate) fn record_matches(rec: &LogRecord, needle_lower: &str) -> bool {
if needle_lower.is_empty() {
return true;
}
if super::audit::ascii_icontains(&rec.message, needle_lower) {
return true;
}
use std::fmt::Write;
let mut buf = String::with_capacity(18);
if let Some(d) = rec.daemon_id {
buf.clear();
let _ = write!(&mut buf, "0x{d:x}");
if super::audit::ascii_icontains(&buf, needle_lower) {
return true;
}
}
if let Some(n) = rec.node_id {
buf.clear();
let _ = write!(&mut buf, "0x{n:x}");
if super::audit::ascii_icontains(&buf, needle_lower) {
return true;
}
}
false
}