Skip to content

Commit 433dcb8

Browse files
calliclesclaude
andcommitted
Fit the table in 80 columns, badge errors, coarsen the countdown
Three pieces of design feedback from issue #33, all about reading the table rather than what it says. Width: the columns are now sized in `app.rs` (`TableLayout`) rather than left to ratatui's constraint solver, because which column gives way first is a judgement call worth testing. At 80 columns everything the reporter asked for survives — resolver name, Loc, a full IPv4 address, Ping, TTL, Exp and the start of the answer — by shedding the spelled-out status first: the verdict moves to a glyph in the left margin, where a scan down the edge finds the failures. Numeric columns are right-aligned, and the units the header already implies ("ms", the "s" on a raw TTL) are gone. An IPv6 resolver's address is shown whole when there's room and clipped when there isn't; the alternative is cropping the columns the issue asked us to fit. Wide terminals are unchanged: the same status word, the same answer width, the same map/globe thresholds. Errors: `theme.error` is now a `Paint` — a foreground with an optional background, written "<fg> on <bg>" — defaulting to white on bright red, the reporter's `\033[101m`. Only the marker is filled: the row's glyph and the status word. Error messages, map dots, the propagation gauge and slow ping times take the hue alone, since a background behind a sentence turns the row into a red bar and behind a map dot paints a block over the coastline. Countdown: the Exp column uses `fmt_countdown` — two digits and a unit, 59s → 1m → 59m → 1h → 23h → 1d → 99d, truncating so the reading stays a lower bound. A whole column of seconds ticking out of unison was a distraction with no payoff. The advisory notes ("TTL ≈ 2h23m", "old answers expire in ≤ …") keep `fmt_secs`: those are single figures where the precision is the point, not a column that shimmers. Verified with `--once` and through a PTY at 80x24 and 160x24, plus a config of TEST-NET resolvers to draw real ERR rows (white-on-red badge confirmed in the rendered cell attributes). Demo GIF re-recorded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7347790 commit 433dcb8

8 files changed

Lines changed: 539 additions & 111 deletions

File tree

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,32 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

99
### Changed
1010

11+
- The resolver table now fits an 80-column terminal without cropping
12+
anything that matters: resolver IPs are shown in full, the numeric columns
13+
(Ping, TTL, Exp) are right-aligned so their digits line up, units that the
14+
header already implies are gone, and where there isn't room for the
15+
spelled-out status the verdict moves to a glyph in the left margin
16+
(`✓ ≠ ! ↻ ∅ ✗`) — one place to scan for failures. Wider terminals are
17+
unchanged: they keep the status word, the same answer column and the same
18+
map/globe thresholds.
19+
([#33](https://github.com/514-labs/dnsglobe/issues/33),
20+
[#XX](https://github.com/514-labs/dnsglobe/pull/XX))
21+
- The per-row expiry countdown is coarse: at most two digits and a unit
22+
(`59s`, `1m`, `59m`, `1h`, `23h`, `1d`, `99d`). A whole column of seconds
23+
ticking out of unison was a distraction, and above a minute the exact
24+
second never changed what you'd do. The TTL advisory notes still quote the
25+
precise figure (`TTL ≈ 2h23m`).
26+
([#33](https://github.com/514-labs/dnsglobe/issues/33),
27+
[#XX](https://github.com/514-labs/dnsglobe/pull/XX))
28+
- Failures now show as a white-on-red badge on the status glyph and word
29+
rather than red text, which went washed-out on terminal themes with a
30+
mid-toned background (macOS Terminal's "Ocean"). Only the marker is
31+
filled — error messages, map dots, the propagation gauge and slow ping
32+
times keep the plain red, so the table doesn't turn into a wall of red
33+
bars. `theme.error` accepts the new `"<fg> on <bg>"` form (for example
34+
`error = "black on 208"`); a plain color still works and means no badge.
35+
([#33](https://github.com/514-labs/dnsglobe/issues/33),
36+
[#XX](https://github.com/514-labs/dnsglobe/pull/XX))
1137
- Anycast site discovery now asks every resolver for its NSID (RFC 5001)
1238
first — a standard EDNS option servers answer with their own node name —
1339
and only falls back to the old operator-specific `id.server` probes when

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ consistent answer, not twenty conflicting ones. The propagation gauge shows
2727
how many resolvers are in the majority group; outliers are flagged
2828
`≠ DIFFERS` once all results are in.
2929

30+
The table fits an 80-column terminal: every resolver's full IPv4 address,
31+
round-trip time, TTL and expiry countdown stay intact, and the per-row
32+
verdict shrinks to the glyph in the left margin (`✓ ≠ ! ↻ ∅ ✗`) so the answer
33+
keeps the space. The countdown is coarse on purpose — `45s`, `4m`, `2h`, `1d`
34+
— since a column of seconds ticking out of unison is noise above a minute.
35+
3036
When the terminal is wide enough, a view of the world appears on the right
3137
with one dot per resolver, colored by status (green agrees, magenta differs,
3238
red error, yellow in flight). The view adapts to the width: terminals ≥157
@@ -141,7 +147,11 @@ ip = "198.51.100.53"
141147
accent = "lightcyan" # borders, titles, cursor, anycast sites
142148
agree = "lightgreen" # answers matching the majority; fast latency
143149
differ = "lightmagenta" # answers disagreeing with the majority
144-
error = "lightred" # ERR / SERVFAIL / NONE; slow latency
150+
error = "white on lightred"
151+
# ERR / SERVFAIL / NONE; slow latency. Written
152+
# "<fg> on <bg>", it becomes a filled badge on the
153+
# status glyph and word — legible on any background;
154+
# a plain color like "lightred" drops the badge
145155
pending = "lightyellow" # queries in flight; middling latency
146156
stale = "208" # caches serving an answer past its own TTL
147157
upstream = "lightblue" # refetched but upstream still has the old data

demo/demo.gif

-202 KB
Loading

src/app.rs

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,6 +1116,148 @@ impl App {
11161116
}
11171117
}
11181118

1119+
/// Column widths for the resolver table, in the order `ui.rs` renders them.
1120+
///
1121+
/// Sized here rather than left to ratatui's constraint solver because which
1122+
/// column gives way first is a judgement call worth testing: an 80-column
1123+
/// terminal has to show a full IPv4 address and undamaged numbers, so the
1124+
/// spelled-out status goes before a single digit does (issue #33).
1125+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1126+
pub struct TableLayout {
1127+
/// The one-glyph verdict at the left edge, so a scan down the margin
1128+
/// finds the failures.
1129+
pub mark: u16,
1130+
pub resolver: u16,
1131+
pub loc: u16,
1132+
pub ip: u16,
1133+
pub ping: u16,
1134+
pub ttl: u16,
1135+
pub exp: u16,
1136+
/// Zero when the spelled-out status had to go; the mark column still
1137+
/// carries the verdict.
1138+
pub status: u16,
1139+
pub answer: u16,
1140+
}
1141+
1142+
const COL_MARK: u16 = 1;
1143+
const COL_PING: u16 = 5; // four digits of milliseconds under a "Ping" header
1144+
const COL_TTL: u16 = 6; // a week in seconds, 604800
1145+
const COL_EXP: u16 = 3; // the coarse countdown's widest, "99d"
1146+
const COL_STATUS: u16 = 8; // "SERVFAIL" / "PAST TTL" / "UPSTREAM"
1147+
/// Fixed, not sized to the configured locations: site discovery replaces any
1148+
/// of them with a "→CODE" of its own, and `Site::code` caps at 7 characters.
1149+
const COL_LOC: u16 = 8;
1150+
const COL_IP_MIN: u16 = 15; // a full IPv4 literal — never cropped
1151+
const COL_IP_MAX: u16 = 39; // a full IPv6 literal
1152+
const COL_NAME_MIN: u16 = 10;
1153+
const COL_NAME_MAX: u16 = 20;
1154+
const COL_ANSWER_MIN: u16 = 16; // one full IPv4 literal, plus a space
1155+
/// What the Answer column is worth on a terminal wide enough for a map panel
1156+
/// too: a second address, or a long CNAME target. The table asks for this
1157+
/// much before the panel takes the rest, so freeing columns for narrow
1158+
/// terminals doesn't quietly hand the map a slice of the answers.
1159+
const COL_ANSWER_ROOMY: u16 = 27;
1160+
/// Everything whose width is fixed by the shape of its content.
1161+
const COL_FIXED: u16 = COL_MARK + COL_LOC + COL_PING + COL_TTL + COL_EXP;
1162+
/// The table's own borders.
1163+
const COL_BORDERS: u16 = 2;
1164+
1165+
impl TableLayout {
1166+
/// Widths that fit `width` columns of terminal, for this resolver list.
1167+
pub fn fit(width: u16, resolvers: &[Resolver]) -> Self {
1168+
let (mut resolver, mut ip) = content_widths(resolvers);
1169+
let mut status = COL_STATUS;
1170+
let inner = width.saturating_sub(COL_BORDERS);
1171+
let need = |resolver, ip, status| COL_FIXED + resolver + ip + status + spacing(status);
1172+
1173+
// Shed in the order that costs the least: first the spelled-out
1174+
// status (the mark glyph still names the verdict), then an IPv6
1175+
// resolver's full address, then the resolver name. The numbers, the
1176+
// 15 columns an IPv4 address needs, and the first answer are never
1177+
// touched — fitting those at 80 columns is the whole point.
1178+
if need(resolver, ip, status) + COL_ANSWER_MIN > inner {
1179+
status = 0;
1180+
}
1181+
if need(resolver, ip, status) + COL_ANSWER_MIN > inner {
1182+
ip = COL_IP_MIN;
1183+
}
1184+
let over = (need(resolver, ip, status) + COL_ANSWER_MIN).saturating_sub(inner);
1185+
resolver = resolver.saturating_sub(over).max(COL_NAME_MIN);
1186+
1187+
Self {
1188+
mark: COL_MARK,
1189+
resolver,
1190+
loc: COL_LOC,
1191+
ip,
1192+
ping: COL_PING,
1193+
ttl: COL_TTL,
1194+
exp: COL_EXP,
1195+
status,
1196+
// Whatever is left: the answer is the column that grows on a
1197+
// wide terminal, since it's the only one with unbounded content.
1198+
answer: inner
1199+
.saturating_sub(need(resolver, ip, status))
1200+
.max(COL_ANSWER_MIN),
1201+
}
1202+
}
1203+
1204+
/// Width `ui.rs` reserves for the table before handing what's left to the
1205+
/// map panel: every column at its full size, borders included.
1206+
pub fn reserved_width(resolvers: &[Resolver]) -> u16 {
1207+
let (resolver, ip) = content_widths(resolvers);
1208+
COL_FIXED
1209+
+ resolver
1210+
+ ip
1211+
+ COL_STATUS
1212+
+ COL_ANSWER_ROOMY
1213+
+ spacing(COL_STATUS)
1214+
+ COL_BORDERS
1215+
}
1216+
}
1217+
1218+
/// One space between each pair of rendered columns; the status column drops
1219+
/// out entirely when it has no width, taking its gap with it.
1220+
fn spacing(status: u16) -> u16 {
1221+
if status == 0 { 7 } else { 8 }
1222+
}
1223+
1224+
/// Name and IP widths the current list would like: enough for its widest
1225+
/// entry, clamped so one long custom name can't eat the answer.
1226+
fn content_widths(resolvers: &[Resolver]) -> (u16, u16) {
1227+
let widest = |f: fn(&Resolver) -> usize| -> u16 {
1228+
resolvers
1229+
.iter()
1230+
.map(f)
1231+
.max()
1232+
.unwrap_or(0)
1233+
.try_into()
1234+
.unwrap_or(u16::MAX)
1235+
};
1236+
(
1237+
widest(|r| r.name.chars().count()).clamp(COL_NAME_MIN, COL_NAME_MAX),
1238+
widest(|r| r.ip.to_string().len()).clamp(COL_IP_MIN, COL_IP_MAX),
1239+
)
1240+
}
1241+
1242+
/// Coarse countdown for the per-row Exp column: at most two digits and a
1243+
/// unit, `59s` → `1m` → `59m` → `1h` → `23h` → `1d` → `99d`.
1244+
///
1245+
/// The table shows one of these per resolver, and a whole column of seconds
1246+
/// ticking out of unison is a distraction with no payoff: above a minute the
1247+
/// exact second never changes what you'd do (issue #33). Truncating rather
1248+
/// than rounding keeps the reading a lower bound — `1m` means at least a
1249+
/// minute is left. Past 99 days it saturates: DNS TTLs that long are a
1250+
/// configuration accident, and the precise figure is in the TTL column and
1251+
/// the advisory note anyway.
1252+
pub fn fmt_countdown(total: u64) -> String {
1253+
match total {
1254+
s if s < 60 => format!("{s}s"),
1255+
s if s < 3_600 => format!("{}m", s / 60),
1256+
s if s < 86_400 => format!("{}h", s / 3_600),
1257+
s => format!("{}d", (s / 86_400).min(99)),
1258+
}
1259+
}
1260+
11191261
/// Compact human duration for countdowns and TTLs: `42s`, `4m10s`, `23h59m`,
11201262
/// `2d3h`. Two units max keeps it within a narrow table column.
11211263
pub fn fmt_secs(total: u64) -> String {
@@ -1402,6 +1544,97 @@ mod tests {
14021544
assert!(!app.globe.target());
14031545
}
14041546

1547+
#[test]
1548+
fn countdown_is_two_digits_and_a_unit() {
1549+
// Every step of the ladder the issue asked for.
1550+
for (secs, want) in [
1551+
(1, "1s"),
1552+
(59, "59s"),
1553+
(60, "1m"),
1554+
(3_599, "59m"),
1555+
(3_600, "1h"),
1556+
(86_399, "23h"),
1557+
(86_400, "1d"),
1558+
(99 * 86_400, "99d"),
1559+
] {
1560+
assert_eq!(fmt_countdown(secs), want, "{secs}s");
1561+
}
1562+
// Truncating, not rounding: "1m" means at least a minute is left.
1563+
assert_eq!(fmt_countdown(119), "1m");
1564+
assert_eq!(fmt_countdown(0), "0s");
1565+
// Saturates rather than widening the column for an absurd TTL.
1566+
assert_eq!(fmt_countdown(100 * 86_400), "99d");
1567+
assert_eq!(fmt_countdown(u64::MAX), "99d");
1568+
// Never wider than three cells, whatever it's handed.
1569+
for secs in [0, 59, 60, 3_599, 3_600, 86_399, 86_400, u64::MAX] {
1570+
assert!(fmt_countdown(secs).len() <= 3, "{secs}");
1571+
}
1572+
}
1573+
1574+
#[test]
1575+
fn table_fits_every_field_at_eighty_columns() {
1576+
let resolvers = resolvers::defaults();
1577+
let layout = TableLayout::fit(80, &resolvers);
1578+
// The numbers and a full IPv4 address survive; the spelled-out
1579+
// status is what gave way, and one whole answer still fits.
1580+
assert_eq!(layout.ip, COL_IP_MIN);
1581+
assert_eq!(layout.ping, COL_PING);
1582+
assert_eq!(layout.ttl, COL_TTL);
1583+
assert_eq!(layout.exp, COL_EXP);
1584+
assert_eq!(layout.status, 0);
1585+
assert!(layout.answer >= COL_ANSWER_MIN);
1586+
assert!(layout.resolver >= COL_NAME_MIN);
1587+
1588+
let total = layout.mark
1589+
+ layout.resolver
1590+
+ layout.loc
1591+
+ layout.ip
1592+
+ layout.ping
1593+
+ layout.ttl
1594+
+ layout.exp
1595+
+ layout.answer
1596+
+ spacing(layout.status)
1597+
+ COL_BORDERS;
1598+
assert_eq!(total, 80);
1599+
}
1600+
1601+
#[test]
1602+
fn table_spends_extra_width_on_the_answer() {
1603+
let resolvers = resolvers::defaults();
1604+
// The width reserved beside a map panel shows every column whole,
1605+
// with the roomy answer — no narrower than it was before issue #33.
1606+
let reserved = TableLayout::reserved_width(&resolvers);
1607+
let wide = TableLayout::fit(reserved, &resolvers);
1608+
assert_eq!(wide.status, COL_STATUS);
1609+
assert_eq!(wide.answer, COL_ANSWER_ROOMY);
1610+
assert_eq!(wide.resolver, COL_NAME_MAX);
1611+
1612+
// Past that, only the answer grows — nothing else moves.
1613+
let roomier = TableLayout::fit(reserved + 40, &resolvers);
1614+
assert_eq!(roomier.answer, COL_ANSWER_ROOMY + 40);
1615+
assert_eq!(roomier.resolver, wide.resolver);
1616+
assert_eq!(roomier.ip, wide.ip);
1617+
}
1618+
1619+
#[test]
1620+
fn ipv6_resolvers_get_their_full_address_only_when_it_fits() {
1621+
let mut resolvers = resolvers::defaults();
1622+
resolvers.push(Resolver {
1623+
name: "Custom v6".into(),
1624+
location: "EU".into(),
1625+
ip: "2606:4700:4700::1111".parse().unwrap(),
1626+
coords: None,
1627+
probe: None,
1628+
});
1629+
// Wide: the address is shown whole, so the table simply asks for
1630+
// more room and the map panel gets what's left.
1631+
let reserved = TableLayout::reserved_width(&resolvers);
1632+
assert_eq!(TableLayout::fit(reserved, &resolvers).ip, 20);
1633+
// Narrow: it falls back to IPv4 width and ratatui clips the tail —
1634+
// the alternative is cropping the columns the issue asked us to fit.
1635+
assert_eq!(TableLayout::fit(80, &resolvers).ip, COL_IP_MIN);
1636+
}
1637+
14051638
#[test]
14061639
fn fmt_secs_is_compact_two_units() {
14071640
assert_eq!(fmt_secs(42), "42s");

src/config.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,6 @@ fn build_theme(table: ThemeTable) -> Result<Theme> {
151151
("accent", table.accent, &mut out.accent),
152152
("agree", table.agree, &mut out.agree),
153153
("differ", table.differ, &mut out.differ),
154-
("error", table.error, &mut out.error),
155154
("pending", table.pending, &mut out.pending),
156155
("stale", table.stale, &mut out.stale),
157156
("upstream", table.upstream, &mut out.upstream),
@@ -162,6 +161,10 @@ fn build_theme(table: ThemeTable) -> Result<Theme> {
162161
*slot = theme::parse_color(&value).with_context(|| format!("theme.{key}"))?;
163162
}
164163
}
164+
// `error` renders as a badge, so it also accepts "<fg> on <bg>".
165+
if let Some(value) = table.error {
166+
out.error = theme::parse_paint(&value).context("theme.error")?;
167+
}
165168
if let Some(value) = table.muted {
166169
out.muted = theme::parse_muted(&value).context("theme.muted")?;
167170
}
@@ -372,6 +375,28 @@ mod tests {
372375
assert!(chain.contains("\"ornage\""), "{chain}");
373376
}
374377

378+
#[test]
379+
fn error_role_takes_a_background_and_reports_its_own_key() {
380+
let badge = theme("[theme]\nerror = \"black on yellow\"").unwrap();
381+
assert_eq!(
382+
badge.error,
383+
crate::theme::Paint::on(ratatui::style::Color::Black, ratatui::style::Color::Yellow)
384+
);
385+
// A bare color still works, and drops back to no background.
386+
let plain = theme("[theme]\nerror = \"lightred\"").unwrap();
387+
assert_eq!(
388+
plain.error,
389+
crate::theme::Paint::color(ratatui::style::Color::LightRed)
390+
);
391+
392+
let chain = format!(
393+
"{:#}",
394+
theme("[theme]\nerror = \"white on rd\"").unwrap_err()
395+
);
396+
assert!(chain.contains("theme.error"), "{chain}");
397+
assert!(chain.contains("\"rd\""), "{chain}");
398+
}
399+
375400
#[test]
376401
fn ecs_entries_parse_with_bare_ips_getting_full_prefixes() {
377402
let config: Config = toml::from_str(r#"ecs = ["203.0.113.77/24", "2001:db8::1"]"#).unwrap();

src/main.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -583,10 +583,11 @@ fn print_round(app: &App, summary: &app::Summary, multi: bool) {
583583
} else {
584584
"DIFFERS"
585585
};
586+
// Right-aligned like the TUI's numeric columns, so a
587+
// column of TTLs reads at a glance (issue #33).
586588
format!(
587-
"{status} {:>5}ms ttl={:<7} {}",
589+
"{status} {:>5}ms ttl={min_ttl:>6} {}",
588590
elapsed.as_millis(),
589-
min_ttl,
590591
values.join(", ")
591592
)
592593
}
@@ -613,8 +614,9 @@ fn print_round(app: &App, summary: &app::Summary, multi: bool) {
613614
Some(site) => format!("→{}", site.code),
614615
None => resolver.location.clone(),
615616
};
617+
// Same fixed widths the TUI table uses, so the two views line up.
616618
println!(
617-
"{:<22} {:<8} {:<16} {line}",
619+
"{:<20} {:<8} {:<15} {line}",
618620
resolver.name, location, resolver.ip
619621
);
620622
}

0 commit comments

Comments
 (0)