use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::canvas::{Canvas, Map, MapResolution};
use ratatui::widgets::{Block, Borders, Cell, LineGauge, Paragraph, Row, Table, TableState};
use ratatui::Frame;
use crate::app::{App, RowState, Summary, RECORD_TYPES, SPINNER};
use crate::dns::QueryResult;
use crate::resolvers::RESOLVERS;
const ACCENT: Color = Color::Cyan;
const MIN_WIDTH_FOR_MAP: u16 = 150;
const TABLE_WIDTH: u16 = 96;
const MAP_MAX_WIDTH: u16 = 170;
const MAP_LON_SPAN: f64 = 350.0;
const MAP_LAT_SPAN: f64 = 127.0;
const MAP_ASPECT: f64 = MAP_LAT_SPAN / MAP_LON_SPAN * 2.0 / 4.0;
pub fn draw(frame: &mut Frame, app: &mut App) {
let summary = app.summary();
let complete = summary.done > 0 && !app.in_flight();
let [header, body, footer] = Layout::vertical([
Constraint::Length(4),
Constraint::Min(6),
Constraint::Length(2),
])
.areas(frame.area());
draw_header(frame, app, header);
let (left, right) = if body.width >= MIN_WIDTH_FOR_MAP {
let map_width = (body.width - TABLE_WIDTH).min(MAP_MAX_WIDTH);
let [left, right] =
Layout::horizontal([Constraint::Fill(1), Constraint::Length(map_width)]).areas(body);
(left, Some(right))
} else {
(body, None)
};
let [gauge, table] =
Layout::vertical([Constraint::Length(1), Constraint::Min(5)]).areas(left);
draw_gauge(frame, app, &summary, gauge);
let visible = table.height.saturating_sub(3) as usize;
app.scroll = app.scroll.min(RESOLVERS.len().saturating_sub(visible));
draw_table(frame, app, &summary, complete, table);
if let Some(right) = right {
let map_height = ((f64::from(right.width.saturating_sub(2)) * MAP_ASPECT).round()
as u16)
.saturating_add(2)
.min(right.height);
let [map_area, info_area] =
Layout::vertical([Constraint::Length(map_height), Constraint::Fill(1)]).areas(right);
draw_map(frame, app, &summary, complete, map_area);
draw_map_info(frame, app, &summary, complete, info_area);
}
draw_footer(frame, app, &summary, footer);
}
fn draw_header(frame: &mut Frame, app: &App, area: Rect) {
let (before, after) = app.domain.split_at(app.cursor.min(app.domain.len()));
let input = Line::from(vec![
Span::styled(" Domain: ", Style::new().fg(Color::DarkGray)),
Span::styled(before, Style::new().bold()),
Span::styled("▏", Style::new().fg(ACCENT)),
Span::styled(after, Style::new().bold()),
]);
let mut types = vec![Span::styled(" Type: ", Style::new().fg(Color::DarkGray))];
for (i, rtype) in RECORD_TYPES.iter().enumerate() {
let label = format!(" {rtype} ");
types.push(if i == app.rtype_idx {
Span::styled(label, Style::new().fg(Color::Black).bg(ACCENT).bold())
} else {
Span::styled(label, Style::new().fg(Color::DarkGray))
});
types.push(Span::raw(" "));
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(ACCENT))
.title(" 🌍 DNS Propagation Checker ")
.title_style(Style::new().bold());
frame.render_widget(
Paragraph::new(vec![input, Line::from(types)]).block(block),
area,
);
}
fn draw_gauge(frame: &mut Frame, app: &App, summary: &Summary, area: Rect) {
let total = RESOLVERS.len();
if app.queried.is_none() {
let hint = Paragraph::new(Line::from(Span::styled(
" type a domain and press Enter",
Style::new().fg(Color::DarkGray).italic(),
)));
frame.render_widget(hint, area);
return;
}
let (ratio, color, label) = if app.in_flight() {
(
summary.done as f64 / total as f64,
ACCENT,
format!(
"{} checking… {}/{} ",
SPINNER[app.spinner_frame % SPINNER.len()],
summary.done,
total
),
)
} else {
let responding = summary.responding.max(1);
let ratio = summary.agree as f64 / responding as f64;
let color = if ratio >= 0.9 {
Color::Green
} else if ratio >= 0.5 {
Color::Yellow
} else {
Color::Red
};
let mut label = format!(
" propagation {}/{} ({:.0}%)",
summary.agree,
summary.responding,
ratio * 100.0
);
if summary.errors > 0 {
label.push_str(&format!(" · {} unreachable", summary.errors));
}
if summary.responding > 0 && summary.agree == summary.responding {
label.push_str(" · complete ");
} else if let Some(at) = app.next_poll {
let secs = at.saturating_duration_since(std::time::Instant::now()).as_secs();
label.push_str(&format!(" · next poll in {secs}s (Ctrl+R stops) "));
} else {
label.push_str(" · watch off (Ctrl+R resumes) ");
}
(ratio, color, label)
};
let gauge = LineGauge::default()
.ratio(ratio)
.label(label)
.filled_style(Style::new().fg(color).add_modifier(Modifier::BOLD))
.unfilled_style(Style::new().fg(Color::DarkGray));
frame.render_widget(gauge, area);
}
fn draw_table(frame: &mut Frame, app: &App, summary: &Summary, complete: bool, area: Rect) {
let header = Row::new(["Resolver", "Loc", "IP", "Time", "TTL", "Status", "Answer"])
.style(Style::new().fg(ACCENT).bold());
let rows = RESOLVERS
.iter()
.zip(&app.rows)
.enumerate()
.map(|(i, (resolver, state))| {
let (time_cell, ttl_cell, status_cell, answer_cell) = match state {
RowState::Idle => (
Cell::from("—"),
Cell::from(""),
Cell::from(Span::styled("idle", Style::new().fg(Color::DarkGray))),
Cell::from(""),
),
RowState::Pending => (
Cell::from("…"),
Cell::from(""),
Cell::from(Span::styled(
format!("{} query", SPINNER[app.spinner_frame % SPINNER.len()]),
Style::new().fg(Color::Yellow),
)),
Cell::from(""),
),
RowState::Done { result, elapsed } => {
let ms = elapsed.as_millis();
let time_style = if ms < 100 {
Style::new().fg(Color::Green)
} else if ms < 400 {
Style::new().fg(Color::Yellow)
} else {
Style::new().fg(Color::Red)
};
let time = Cell::from(Span::styled(format!("{ms}ms"), time_style));
match result {
QueryResult::Records { values, min_ttl } => {
let matches_majority = !complete || summary.majority_rows[i];
let (status, style) = if matches_majority {
("✓ OK", Style::new().fg(Color::Green).bold())
} else {
("≠ DIFFERS", Style::new().fg(Color::Magenta).bold())
};
(
time,
Cell::from(format!("{min_ttl}")),
Cell::from(Span::styled(status, style)),
Cell::from(Span::styled(
values.join(", "),
if matches_majority {
Style::new()
} else {
Style::new().fg(Color::Magenta)
},
)),
)
}
QueryResult::NoRecords(code) => (
time,
Cell::from(""),
Cell::from(Span::styled("∅ NONE", Style::new().fg(Color::Red).bold())),
Cell::from(Span::styled(code.clone(), Style::new().fg(Color::Red))),
),
QueryResult::Error(message) => (
time,
Cell::from(""),
Cell::from(Span::styled("✗ ERR", Style::new().fg(Color::Red).bold())),
Cell::from(Span::styled(
message.clone(),
Style::new().fg(Color::Red).italic(),
)),
),
}
}
};
Row::new(vec![
Cell::from(resolver.name),
Cell::from(Span::styled(
resolver.location,
Style::new().fg(Color::DarkGray),
)),
Cell::from(Span::styled(resolver.ip, Style::new().fg(Color::DarkGray))),
time_cell,
ttl_cell,
status_cell,
answer_cell,
])
});
let table = Table::new(
rows,
[
Constraint::Length(21),
Constraint::Length(7),
Constraint::Length(15),
Constraint::Length(7),
Constraint::Length(6),
Constraint::Length(9),
Constraint::Min(20),
],
)
.header(header)
.column_spacing(1)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::DarkGray))
.title_bottom(
Line::from(format!(" {} resolvers (↑/↓ scroll) ", RESOLVERS.len()))
.right_aligned()
.style(Style::new().fg(Color::DarkGray)),
),
);
let mut state = TableState::default().with_offset(app.scroll);
frame.render_stateful_widget(table, area, &mut state);
}
fn draw_map(frame: &mut Frame, app: &App, summary: &Summary, complete: bool, area: Rect) {
let canvas = Canvas::default()
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::DarkGray))
.title(" Resolver Map ")
.title_style(Style::new().fg(ACCENT).bold()),
)
.x_bounds([-170.0, 180.0])
.y_bounds([-55.0, 72.0])
.paint(|ctx| {
ctx.draw(&Map {
color: Color::DarkGray,
resolution: MapResolution::High,
});
for (i, (resolver, state)) in RESOLVERS.iter().zip(&app.rows).enumerate() {
let color = match state {
RowState::Idle => Color::DarkGray,
RowState::Pending => Color::Yellow,
RowState::Done { result, .. } => match result {
QueryResult::Records { .. } => {
if !complete || summary.majority_rows[i] {
Color::Green
} else {
Color::Magenta
}
}
QueryResult::NoRecords(_) | QueryResult::Error(_) => Color::Red,
},
};
ctx.print(
resolver.lon,
resolver.lat,
Span::styled("●", Style::new().fg(color).bold()),
);
}
});
frame.render_widget(canvas, area);
}
fn draw_map_info(frame: &mut Frame, app: &App, summary: &Summary, complete: bool, area: Rect) {
if area.height < 3 {
return;
}
let mut lines = vec![Line::from(vec![
Span::styled("● agrees ", Style::new().fg(Color::Green)),
Span::styled("● differs ", Style::new().fg(Color::Magenta)),
Span::styled("● error ", Style::new().fg(Color::Red)),
Span::styled("● pending", Style::new().fg(Color::Yellow)),
])];
if complete && !summary.majority_values.is_empty() {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
format!(
"Majority answer ({}/{} resolvers):",
summary.agree,
RESOLVERS.len()
),
Style::new().fg(ACCENT).bold(),
)));
for value in &summary.majority_values {
lines.push(Line::from(vec![
Span::styled(" • ", Style::new().fg(Color::DarkGray)),
Span::raw(value.as_str()),
]));
}
} else if app.queried.is_some() && app.in_flight() {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
"waiting for all resolvers…",
Style::new().fg(Color::DarkGray).italic(),
)));
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::DarkGray));
frame.render_widget(
Paragraph::new(lines)
.wrap(ratatui::widgets::Wrap { trim: false })
.block(block),
area,
);
}
fn draw_footer(frame: &mut Frame, app: &App, summary: &Summary, area: Rect) {
let mut status = Line::default();
if let Some((domain, rtype)) = &app.queried {
status.push_span(Span::styled(
format!(" {domain} {rtype}: "),
Style::new().bold(),
));
status.push_span(Span::styled(
format!("{} ok", summary.ok),
Style::new().fg(Color::Green),
));
status.push_span(Span::raw(" · "));
status.push_span(Span::styled(
format!("{} none", summary.no_records),
Style::new().fg(Color::Red),
));
status.push_span(Span::raw(" · "));
status.push_span(Span::styled(
format!("{} err", summary.errors),
Style::new().fg(Color::Red),
));
status.push_span(Span::raw(" · "));
status.push_span(Span::styled(
format!("{} answer group(s)", summary.groups),
if summary.groups > 1 {
Style::new().fg(Color::Magenta)
} else {
Style::new().fg(Color::DarkGray)
},
));
}
let keys = Line::from(Span::styled(
" type to edit · ←/→ move cursor · Enter query+watch · Ctrl+R watch on/off · Tab record type · ↑/↓ scroll · Esc quit",
Style::new().fg(Color::DarkGray),
));
let [status_area, keys_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
frame.render_widget(Paragraph::new(status), status_area);
frame.render_widget(Paragraph::new(keys), keys_area);
}