use ratatui::layout::Constraint;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Row;
use seer_core::output::sanitize_line;
use crate::tui::app::SPIN;
use crate::tui::panes::FollowState;
use crate::tui::theme::Theme;
use crate::tui::widgets::page::{Block, Page, TableBlock};
use crate::tui::widgets::{dot, gauge, or_dash};
pub fn page(theme: &Theme, follow: &FollowState, spin: usize) -> Page {
let total = follow.run_total();
let interval = follow
.last_run
.map(|(_, secs)| secs)
.unwrap_or(follow.interval_secs);
let title = format!("Follow · {interval}s interval · {total} checks");
let ratio = if total > 0 {
(follow.log.len() as f64 / total as f64).min(1.0)
} else {
0.0
};
let gauge_label = format!("{}/{} done", follow.log.len(), total);
let gauge_line = gauge::line(theme, ratio, 30, theme.teal, Some(&gauge_label));
let spin_text = if follow.running {
format!(" {} polling…", SPIN[spin % SPIN.len()])
} else {
" idle".to_string()
};
let status_line = Line::from(vec![Span::styled(
spin_text,
Style::default().fg(if follow.running {
theme.teal
} else {
theme.overlay0
}),
)]);
let hints_line = Line::from(vec![Span::styled(
format!(
"s start · i interval ({}s) · n count ({}) · x stop",
follow.interval_secs, follow.count
),
Style::default().fg(theme.overlay0),
)]);
let log = Page::new("Change Log", theme.teal);
let log = if follow.log.is_empty() {
log.push(Block::Lines(vec![Line::from(vec![Span::styled(
"waiting for first check…",
Style::default()
.fg(theme.overlay0)
.add_modifier(Modifier::ITALIC),
)])]))
} else {
let rows = follow
.log
.iter()
.map(|it| {
let (a_record, delta) = if !it.success() {
let msg = it.error.as_deref().unwrap_or("error");
(
sanitize_line(msg).chars().take(40).collect::<String>(),
dot::line(theme, "fail", "ERROR"),
)
} else {
let a = sanitize_line(&or_dash(it.records.first().map(|r| r.format_short())));
let d = if it.changed {
dot::line(theme, "warn", "CHANGED")
} else {
dot::line(theme, "ok", "—")
};
(a, d)
};
Row::new(vec![
Line::from(it.iteration.to_string()),
Line::from(it.timestamp.format("%H:%M:%S").to_string()),
Line::from(a_record),
delta,
])
})
.collect();
log.push(Block::Table(
TableBlock::new(
vec!["#", "TIME", "A RECORD", "Δ"],
vec![
Constraint::Length(4),
Constraint::Length(10),
Constraint::Percentage(50),
Constraint::Min(10),
],
rows,
)
.unit("checks"),
))
};
Page::new(title, theme.teal)
.push(Block::Lines(vec![gauge_line, status_line, hints_line]))
.push(Block::Gap)
.push(Block::Panel(log))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::widgets::page;
fn render(
f: &mut ratatui::Frame,
area: ratatui::layout::Rect,
theme: &Theme,
follow: &FollowState,
spin: usize,
) {
page::draw(f, area, theme, page(theme, follow, spin), false);
}
use crate::tui::test_util::render_text;
use chrono::Utc;
use seer_core::dns::FollowIteration;
fn make_iteration(iteration: usize, record_value: &str, changed: bool) -> FollowIteration {
use seer_core::dns::{DnsRecord, RecordData};
use seer_core::RecordType;
let records = if record_value.is_empty() {
vec![]
} else {
vec![DnsRecord {
name: "example.com".into(),
record_type: RecordType::A,
ttl: 300,
data: RecordData::A {
address: record_value.into(),
},
}]
};
FollowIteration {
iteration,
total_iterations: 5,
timestamp: Utc::now(),
records,
changed,
added: if changed {
vec![record_value.into()]
} else {
vec![]
},
removed: vec![],
error: None,
}
}
#[test]
fn renders_a_record_in_log() {
let theme = Theme::frappe();
let mut follow = FollowState::default();
follow.push(make_iteration(1, "1.2.3.4", false));
follow.push(make_iteration(2, "1.2.3.4", true));
let text = render_text(80, 20, |f| render(f, f.area(), &theme, &follow, 0));
assert!(
text.contains("1.2.3.4"),
"rendered buffer should contain A record IP"
);
}
#[test]
fn renders_error_iteration_as_failure_not_healthy() {
let theme = Theme::frappe();
let mut follow = FollowState::default();
follow.push(FollowIteration {
iteration: 1,
total_iterations: 5,
timestamp: Utc::now(),
records: vec![],
changed: false,
added: vec![],
removed: vec![],
error: Some("NXDOMAIN".into()),
});
let text = render_text(80, 20, |f| render(f, f.area(), &theme, &follow, 0));
assert!(
text.contains("ERROR"),
"errored iteration should render an ERROR marker"
);
assert!(
text.contains("NXDOMAIN"),
"errored iteration should surface the error message"
);
}
#[test]
fn spinner_animates_with_app_spin_index() {
let theme = Theme::frappe();
let follow = FollowState {
running: true,
..Default::default()
};
let text = render_text(80, 20, |f| render(f, f.area(), &theme, &follow, 3));
assert!(
text.contains(SPIN[3]),
"spinner should render the current animation frame"
);
}
#[test]
fn gauge_uses_the_runs_total_not_the_edited_count() {
let theme = Theme::frappe();
let mut follow = FollowState {
last_run: Some((20, 30)),
..Default::default()
};
for i in 1..=20 {
let mut it = make_iteration(i, "1.2.3.4", false);
it.total_iterations = 20;
follow.push(it);
}
follow.count = 5;
let text = render_text(80, 20, |f| render(f, f.area(), &theme, &follow, 0));
assert!(text.contains("20/20 done"), "got: {text}");
assert!(!text.contains("20/5"), "got: {text}");
assert!(text.contains("20 checks"), "title shows the run's count");
assert!(
text.contains("n count (5)"),
"hints show the next-run setting"
);
}
#[test]
fn renders_waiting_when_log_empty() {
let theme = Theme::frappe();
let follow = FollowState::default();
let text = render_text(80, 20, |f| render(f, f.area(), &theme, &follow, 0));
assert!(
text.contains("waiting"),
"empty log should show 'waiting' placeholder"
);
}
}