Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,32 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Changed

- The resolver table now fits an 80-column terminal without cropping
anything that matters: resolver IPs are shown in full, the numeric columns
(Ping, TTL, Exp) are right-aligned so their digits line up, units that the
header already implies are gone, and where there isn't room for the
spelled-out status the verdict moves to a glyph in the left margin
(`✓ ≠ ! ↻ ∅ ✗`) — one place to scan for failures. Wider terminals are
unchanged: they keep the status word, the same answer column and the same
map/globe thresholds.
([#33](https://github.com/514-labs/dnsglobe/issues/33),
[#40](https://github.com/514-labs/dnsglobe/pull/40))
- The per-row expiry countdown is coarse: at most two digits and a unit
(`59s`, `1m`, `59m`, `1h`, `23h`, `1d`, `99d`). A whole column of seconds
ticking out of unison was a distraction, and above a minute the exact
second never changed what you'd do. The TTL advisory notes still quote the
precise figure (`TTL ≈ 2h23m`).
([#33](https://github.com/514-labs/dnsglobe/issues/33),
[#40](https://github.com/514-labs/dnsglobe/pull/40))
- Failures now show as a white-on-red badge on the status glyph and word
rather than red text, which went washed-out on terminal themes with a
mid-toned background (macOS Terminal's "Ocean"). Only the marker is
filled — error messages, map dots, the propagation gauge and slow ping
times keep the plain red, so the table doesn't turn into a wall of red
bars. `theme.error` accepts the new `"<fg> on <bg>"` form (for example
`error = "black on 208"`); a plain color still works and means no badge.
([#33](https://github.com/514-labs/dnsglobe/issues/33),
[#40](https://github.com/514-labs/dnsglobe/pull/40))
- Anycast site discovery now asks every resolver for its NSID (RFC 5001)
first — a standard EDNS option servers answer with their own node name —
and only falls back to the old operator-specific `id.server` probes when
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ consistent answer, not twenty conflicting ones. The propagation gauge shows
how many resolvers are in the majority group; outliers are flagged
`≠ DIFFERS` once all results are in.

The table fits an 80-column terminal: every resolver's full IPv4 address,
round-trip time, TTL and expiry countdown stay intact, and the per-row
verdict shrinks to the glyph in the left margin (`✓ ≠ ! ↻ ∅ ✗`) so the answer
keeps the space. The countdown is coarse on purpose — `45s`, `4m`, `2h`, `1d`
— since a column of seconds ticking out of unison is noise above a minute.

When the terminal is wide enough, a view of the world appears on the right
with one dot per resolver, colored by status (green agrees, magenta differs,
red error, yellow in flight). The view adapts to the width: terminals ≥157
Expand Down Expand Up @@ -141,7 +147,11 @@ ip = "198.51.100.53"
accent = "lightcyan" # borders, titles, cursor, anycast sites
agree = "lightgreen" # answers matching the majority; fast latency
differ = "lightmagenta" # answers disagreeing with the majority
error = "lightred" # ERR / SERVFAIL / NONE; slow latency
error = "white on lightred"
# ERR / SERVFAIL / NONE; slow latency. Written
# "<fg> on <bg>", it becomes a filled badge on the
# status glyph and word — legible on any background;
# a plain color like "lightred" drops the badge
pending = "lightyellow" # queries in flight; middling latency
stale = "208" # caches serving an answer past its own TTL
upstream = "lightblue" # refetched but upstream still has the old data
Expand Down
Binary file modified demo/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
233 changes: 233 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1116,6 +1116,148 @@ impl App {
}
}

/// Column widths for the resolver table, in the order `ui.rs` renders them.
///
/// Sized here rather than left to ratatui's constraint solver because which
/// column gives way first is a judgement call worth testing: an 80-column
/// terminal has to show a full IPv4 address and undamaged numbers, so the
/// spelled-out status goes before a single digit does (issue #33).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TableLayout {
/// The one-glyph verdict at the left edge, so a scan down the margin
/// finds the failures.
pub mark: u16,
pub resolver: u16,
pub loc: u16,
pub ip: u16,
pub ping: u16,
pub ttl: u16,
pub exp: u16,
/// Zero when the spelled-out status had to go; the mark column still
/// carries the verdict.
pub status: u16,
pub answer: u16,
}

const COL_MARK: u16 = 1;
const COL_PING: u16 = 5; // four digits of milliseconds under a "Ping" header
const COL_TTL: u16 = 6; // a week in seconds, 604800
const COL_EXP: u16 = 3; // the coarse countdown's widest, "99d"
const COL_STATUS: u16 = 8; // "SERVFAIL" / "PAST TTL" / "UPSTREAM"
/// Fixed, not sized to the configured locations: site discovery replaces any
/// of them with a "→CODE" of its own, and `Site::code` caps at 7 characters.
const COL_LOC: u16 = 8;
const COL_IP_MIN: u16 = 15; // a full IPv4 literal — never cropped
const COL_IP_MAX: u16 = 39; // a full IPv6 literal
const COL_NAME_MIN: u16 = 10;
const COL_NAME_MAX: u16 = 20;
const COL_ANSWER_MIN: u16 = 16; // one full IPv4 literal, plus a space
/// What the Answer column is worth on a terminal wide enough for a map panel
/// too: a second address, or a long CNAME target. The table asks for this
/// much before the panel takes the rest, so freeing columns for narrow
/// terminals doesn't quietly hand the map a slice of the answers.
const COL_ANSWER_ROOMY: u16 = 27;
/// Everything whose width is fixed by the shape of its content.
const COL_FIXED: u16 = COL_MARK + COL_LOC + COL_PING + COL_TTL + COL_EXP;
/// The table's own borders.
const COL_BORDERS: u16 = 2;

impl TableLayout {
/// Widths that fit `width` columns of terminal, for this resolver list.
pub fn fit(width: u16, resolvers: &[Resolver]) -> Self {
let (mut resolver, mut ip) = content_widths(resolvers);
let mut status = COL_STATUS;
let inner = width.saturating_sub(COL_BORDERS);
let need = |resolver, ip, status| COL_FIXED + resolver + ip + status + spacing(status);

// Shed in the order that costs the least: first the spelled-out
// status (the mark glyph still names the verdict), then an IPv6
// resolver's full address, then the resolver name. The numbers, the
// 15 columns an IPv4 address needs, and the first answer are never
// touched — fitting those at 80 columns is the whole point.
if need(resolver, ip, status) + COL_ANSWER_MIN > inner {
status = 0;
}
if need(resolver, ip, status) + COL_ANSWER_MIN > inner {
ip = COL_IP_MIN;
}
let over = (need(resolver, ip, status) + COL_ANSWER_MIN).saturating_sub(inner);
resolver = resolver.saturating_sub(over).max(COL_NAME_MIN);

Self {
mark: COL_MARK,
resolver,
loc: COL_LOC,
ip,
ping: COL_PING,
ttl: COL_TTL,
exp: COL_EXP,
status,
// Whatever is left: the answer is the column that grows on a
// wide terminal, since it's the only one with unbounded content.
answer: inner
.saturating_sub(need(resolver, ip, status))
.max(COL_ANSWER_MIN),
}
}

/// Width `ui.rs` reserves for the table before handing what's left to the
/// map panel: every column at its full size, borders included.
pub fn reserved_width(resolvers: &[Resolver]) -> u16 {
let (resolver, ip) = content_widths(resolvers);
COL_FIXED
+ resolver
+ ip
+ COL_STATUS
+ COL_ANSWER_ROOMY
+ spacing(COL_STATUS)
+ COL_BORDERS
}
}

/// One space between each pair of rendered columns; the status column drops
/// out entirely when it has no width, taking its gap with it.
fn spacing(status: u16) -> u16 {
if status == 0 { 7 } else { 8 }
}

/// Name and IP widths the current list would like: enough for its widest
/// entry, clamped so one long custom name can't eat the answer.
fn content_widths(resolvers: &[Resolver]) -> (u16, u16) {
let widest = |f: fn(&Resolver) -> usize| -> u16 {
resolvers
.iter()
.map(f)
.max()
.unwrap_or(0)
.try_into()
.unwrap_or(u16::MAX)
};
(
widest(|r| r.name.chars().count()).clamp(COL_NAME_MIN, COL_NAME_MAX),
widest(|r| r.ip.to_string().len()).clamp(COL_IP_MIN, COL_IP_MAX),
)
}

/// Coarse countdown for the per-row Exp column: at most two digits and a
/// unit, `59s` → `1m` → `59m` → `1h` → `23h` → `1d` → `99d`.
///
/// The table shows one of these per resolver, and a whole column of seconds
/// ticking out of unison is a distraction with no payoff: above a minute the
/// exact second never changes what you'd do (issue #33). Truncating rather
/// than rounding keeps the reading a lower bound — `1m` means at least a
/// minute is left. Past 99 days it saturates: DNS TTLs that long are a
/// configuration accident, and the precise figure is in the TTL column and
/// the advisory note anyway.
pub fn fmt_countdown(total: u64) -> String {
match total {
s if s < 60 => format!("{s}s"),
s if s < 3_600 => format!("{}m", s / 60),
s if s < 86_400 => format!("{}h", s / 3_600),
s => format!("{}d", (s / 86_400).min(99)),
}
}

/// Compact human duration for countdowns and TTLs: `42s`, `4m10s`, `23h59m`,
/// `2d3h`. Two units max keeps it within a narrow table column.
pub fn fmt_secs(total: u64) -> String {
Expand Down Expand Up @@ -1402,6 +1544,97 @@ mod tests {
assert!(!app.globe.target());
}

#[test]
fn countdown_is_two_digits_and_a_unit() {
// Every step of the ladder the issue asked for.
for (secs, want) in [
(1, "1s"),
(59, "59s"),
(60, "1m"),
(3_599, "59m"),
(3_600, "1h"),
(86_399, "23h"),
(86_400, "1d"),
(99 * 86_400, "99d"),
] {
assert_eq!(fmt_countdown(secs), want, "{secs}s");
}
// Truncating, not rounding: "1m" means at least a minute is left.
assert_eq!(fmt_countdown(119), "1m");
assert_eq!(fmt_countdown(0), "0s");
// Saturates rather than widening the column for an absurd TTL.
assert_eq!(fmt_countdown(100 * 86_400), "99d");
assert_eq!(fmt_countdown(u64::MAX), "99d");
// Never wider than three cells, whatever it's handed.
for secs in [0, 59, 60, 3_599, 3_600, 86_399, 86_400, u64::MAX] {
assert!(fmt_countdown(secs).len() <= 3, "{secs}");
}
}

#[test]
fn table_fits_every_field_at_eighty_columns() {
let resolvers = resolvers::defaults();
let layout = TableLayout::fit(80, &resolvers);
// The numbers and a full IPv4 address survive; the spelled-out
// status is what gave way, and one whole answer still fits.
assert_eq!(layout.ip, COL_IP_MIN);
assert_eq!(layout.ping, COL_PING);
assert_eq!(layout.ttl, COL_TTL);
assert_eq!(layout.exp, COL_EXP);
assert_eq!(layout.status, 0);
assert!(layout.answer >= COL_ANSWER_MIN);
assert!(layout.resolver >= COL_NAME_MIN);

let total = layout.mark
+ layout.resolver
+ layout.loc
+ layout.ip
+ layout.ping
+ layout.ttl
+ layout.exp
+ layout.answer
+ spacing(layout.status)
+ COL_BORDERS;
assert_eq!(total, 80);
}

#[test]
fn table_spends_extra_width_on_the_answer() {
let resolvers = resolvers::defaults();
// The width reserved beside a map panel shows every column whole,
// with the roomy answer — no narrower than it was before issue #33.
let reserved = TableLayout::reserved_width(&resolvers);
let wide = TableLayout::fit(reserved, &resolvers);
assert_eq!(wide.status, COL_STATUS);
assert_eq!(wide.answer, COL_ANSWER_ROOMY);
assert_eq!(wide.resolver, COL_NAME_MAX);

// Past that, only the answer grows — nothing else moves.
let roomier = TableLayout::fit(reserved + 40, &resolvers);
assert_eq!(roomier.answer, COL_ANSWER_ROOMY + 40);
assert_eq!(roomier.resolver, wide.resolver);
assert_eq!(roomier.ip, wide.ip);
}

#[test]
fn ipv6_resolvers_get_their_full_address_only_when_it_fits() {
let mut resolvers = resolvers::defaults();
resolvers.push(Resolver {
name: "Custom v6".into(),
location: "EU".into(),
ip: "2606:4700:4700::1111".parse().unwrap(),
coords: None,
probe: None,
});
// Wide: the address is shown whole, so the table simply asks for
// more room and the map panel gets what's left.
let reserved = TableLayout::reserved_width(&resolvers);
assert_eq!(TableLayout::fit(reserved, &resolvers).ip, 20);
// Narrow: it falls back to IPv4 width and ratatui clips the tail —
// the alternative is cropping the columns the issue asked us to fit.
assert_eq!(TableLayout::fit(80, &resolvers).ip, COL_IP_MIN);
}

#[test]
fn fmt_secs_is_compact_two_units() {
assert_eq!(fmt_secs(42), "42s");
Expand Down
27 changes: 26 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,6 @@ fn build_theme(table: ThemeTable) -> Result<Theme> {
("accent", table.accent, &mut out.accent),
("agree", table.agree, &mut out.agree),
("differ", table.differ, &mut out.differ),
("error", table.error, &mut out.error),
("pending", table.pending, &mut out.pending),
("stale", table.stale, &mut out.stale),
("upstream", table.upstream, &mut out.upstream),
Expand All @@ -162,6 +161,10 @@ fn build_theme(table: ThemeTable) -> Result<Theme> {
*slot = theme::parse_color(&value).with_context(|| format!("theme.{key}"))?;
}
}
// `error` renders as a badge, so it also accepts "<fg> on <bg>".
if let Some(value) = table.error {
out.error = theme::parse_paint(&value).context("theme.error")?;
}
if let Some(value) = table.muted {
out.muted = theme::parse_muted(&value).context("theme.muted")?;
}
Expand Down Expand Up @@ -372,6 +375,28 @@ mod tests {
assert!(chain.contains("\"ornage\""), "{chain}");
}

#[test]
fn error_role_takes_a_background_and_reports_its_own_key() {
let badge = theme("[theme]\nerror = \"black on yellow\"").unwrap();
assert_eq!(
badge.error,
crate::theme::Paint::on(ratatui::style::Color::Black, ratatui::style::Color::Yellow)
);
// A bare color still works, and drops back to no background.
let plain = theme("[theme]\nerror = \"lightred\"").unwrap();
assert_eq!(
plain.error,
crate::theme::Paint::color(ratatui::style::Color::LightRed)
);

let chain = format!(
"{:#}",
theme("[theme]\nerror = \"white on rd\"").unwrap_err()
);
assert!(chain.contains("theme.error"), "{chain}");
assert!(chain.contains("\"rd\""), "{chain}");
}

#[test]
fn ecs_entries_parse_with_bare_ips_getting_full_prefixes() {
let config: Config = toml::from_str(r#"ecs = ["203.0.113.77/24", "2001:db8::1"]"#).unwrap();
Expand Down
8 changes: 5 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -583,10 +583,11 @@ fn print_round(app: &App, summary: &app::Summary, multi: bool) {
} else {
"DIFFERS"
};
// Right-aligned like the TUI's numeric columns, so a
// column of TTLs reads at a glance (issue #33).
format!(
"{status} {:>5}ms ttl={:<7} {}",
"{status} {:>5}ms ttl={min_ttl:>6} {}",
elapsed.as_millis(),
min_ttl,
values.join(", ")
)
}
Expand All @@ -613,8 +614,9 @@ fn print_round(app: &App, summary: &app::Summary, multi: bool) {
Some(site) => format!("→{}", site.code),
None => resolver.location.clone(),
};
// Same fixed widths the TUI table uses, so the two views line up.
println!(
"{:<22} {:<8} {:<16} {line}",
"{:<20} {:<8} {:<15} {line}",
resolver.name, location, resolver.ip
);
}
Expand Down
Loading