fix(fipstop): make the transports table fit an 80-column terminal

The table's fixed columns sum to 90, plus six single-column gaps: 96 against
the ~77 usable inside an 80-column terminal's border and scrollbar. Ratatui
resolves an over-subscribed layout by shrinking every column proportionally,
so the overflow does not clip the rightmost column — it clips all of them, and
`mesh0 (optional)` rendered as `mesh0 (optio`. The marker is the one thing on
that row worth reading, and the absence of it is what says `required`.

80x24 is the OpenWrt serial console and the xterm/tmux default, so this is the
deployment target rather than an edge case.

Below 100 columns the table drops Tx and Rx and sizes the rest down. Those two
are the only columns whose absence costs nothing an operator is scanning this
table to find — they are byte counters, and the detail pane carries them in
full — while Instance keeps 18 because `mesh0 (optional)` is 16, and Bound-to
keeps 17 because that is a full MAC. Both row shapes carry the same seven
cells in the same order, so the narrow variant is the same list with its tail
cut, keeping one place where the column count is decided rather than two that
have to agree.

The detail view stacks instead of splitting below 110 columns. A 40% split of
an 80-column terminal leaves the table 32 columns for a layout needing 65 even
narrow, and ratatui spends all of it on the trailing columns — rendering
Transport, Instance, Bound-to and State at width zero, so the operator gets
blank rows. Stacking keeps both panes readable instead of both unreadable.

The regression test renders at 80 and asserts the marker. That is precisely
the gap that let this through: the existing test asserting `mesh0 (optional)`
renders at 110, and the one rendering at 80 asserted only the netdev name, so
between them neither covered the width where the layout breaks. Verified
against the defect — with the narrow tier disabled, the new test fails and the
other two still pass.
This commit is contained in:
Arjen
2026-09-02 08:54:46 +01:00
parent 3384d93cdb
commit 73cd10699b
2 changed files with 148 additions and 35 deletions
+46
View File
@@ -1350,6 +1350,52 @@ fn transports_row_names_the_interface() {
assert!(!testkit::contains_row(&buf, "absent"));
}
/// The narrow layout keeps the columns an operator is scanning for.
///
/// 80x24 is the OpenWrt serial console and the xterm/tmux default. The full
/// layout's fixed columns sum to 96 against ~77 usable, and ratatui resolves
/// an over-subscribed layout by shrinking *every* column — so the overflow
/// does not clip the rightmost one, it clips all of them, and
/// `mesh0 (optional)` became `mesh0 (optio`. The marker is the one thing on
/// that row worth reading.
///
/// This is the gap that let the regression through: the test asserting the
/// marker rendered at width 110, and the one rendering at 80 asserted only the
/// netdev name.
#[test]
fn the_optional_marker_survives_an_80_column_terminal() {
let data = json!({
"transports": [{
"transport_id": 2,
"type": "ethernet",
"state": "up",
"mtu": 1499,
"name": "mesh0",
"interface": {
"name": "fips-mesh0",
"presence": "absent",
"carrier": false,
"policy": "optional",
"since_secs": 252,
"binds": 0,
"failed_attempts": 0
},
"stats": {}
}]
});
let mut app = app_with(Tab::Transports, data);
let buf = testkit::render(80, 20, |frame, area| {
super::transports::draw(frame, &mut app, area);
});
assert!(
testkit::contains_row(&buf, "mesh0 (optional)"),
"the policy marker must not be the thing that clips at 80 columns"
);
assert!(testkit::contains_row(&buf, "fips-mesh0"));
assert!(testkit::contains_row(&buf, "absent"));
}
/// An absent interface displaces the transport state in the State column, and
/// its severity follows the absence policy.
///
+102 -35
View File
@@ -23,6 +23,21 @@ enum TreeRow {
},
}
/// Below this width the table drops its byte counters and keeps its
/// identifying columns.
///
/// The full layout's fixed columns sum to 90, plus six single-column gaps —
/// 96 against the ~77 usable inside an 80-column terminal's border and
/// scrollbar. Ratatui resolves an over-subscribed layout by shrinking every
/// column, so the overflow does not clip the rightmost column, it clips *all*
/// of them: `mesh0 (optional)` becomes `mesh0 (optio`, losing the one marker
/// on the row worth reading.
const NARROW_TABLE_WIDTH: u16 = 100;
/// Below this width the detail view stacks above/below the table instead of
/// beside it.
const SIDE_BY_SIDE_MIN_WIDTH: u16 = 110;
pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
let transports = get_transports(app);
let links = get_links(app);
@@ -33,8 +48,18 @@ pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
update_selected_tree_item(app, &tree_rows);
if app.detail_view.is_some() {
let chunks = Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
// Side by side only where the table half can still show its
// identifying columns. At 80 columns — the OpenWrt serial console, and
// the xterm/tmux default — a 40% split leaves the table 32 columns for
// a layout that needs 65 even in its narrow form, and ratatui resolves
// that by giving the trailing columns everything and rendering
// Transport, Instance, Bound-to and State at width zero. Stacking
// keeps both panes readable instead of keeping both unreadable.
let chunks = if area.width < SIDE_BY_SIDE_MIN_WIDTH {
Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area)
} else {
Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)]).split(area)
};
draw_table(frame, app, chunks[0], &transports, &links, &tree_rows);
draw_detail(frame, app, chunks[1], &transports, &links, &tree_rows);
@@ -113,6 +138,19 @@ fn update_selected_tree_item(app: &mut App, tree_rows: &[TreeRow]) {
};
}
/// Build a row, dropping the trailing byte counters in the narrow layout.
///
/// Both row shapes carry the same seven cells in the same order, so the
/// narrow variant is the same list with its tail cut — keeping one place
/// where the column count is decided, rather than two that must agree.
fn table_row<'a>(cells: Vec<Cell<'a>>, narrow: bool) -> Row<'a> {
let mut cells = cells;
if narrow {
cells.truncate(5);
}
Row::new(cells)
}
fn draw_table(
frame: &mut Frame,
app: &mut App,
@@ -121,7 +159,12 @@ fn draw_table(
links: &[serde_json::Value],
tree_rows: &[TreeRow],
) {
let header = Row::new(vec![
// Tx/Rx are the first thing to go when width is short: they are the only
// columns whose absence costs nothing an operator is scanning this table
// to find, and the detail pane carries them in full.
let narrow = area.width < NARROW_TABLE_WIDTH;
let mut header_cells = vec![
Cell::from("Transport / Link"),
Cell::from("Instance"),
Cell::from("Bound to"),
@@ -129,8 +172,11 @@ fn draw_table(
Cell::from("Peer"),
Cell::from("Tx"),
Cell::from("Rx"),
])
.style(
];
if narrow {
header_cells.truncate(5);
}
let header = Row::new(header_cells).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
@@ -260,15 +306,18 @@ fn draw_table(
(false, false) => name.to_string(),
};
Row::new(vec![
Cell::from(label),
Cell::from(instance),
Cell::from(bound_to),
Cell::from(Line::from(state.to_string()).alignment(Alignment::Right)),
Cell::from(""),
Cell::from(tx),
Cell::from(rx),
])
table_row(
vec![
Cell::from(label),
Cell::from(instance),
Cell::from(bound_to),
Cell::from(Line::from(state.to_string()).alignment(Alignment::Right)),
Cell::from(""),
Cell::from(tx),
Cell::from(rx),
],
narrow,
)
.style(row_style)
}
TreeRow::Link { index, is_last } => {
@@ -301,25 +350,28 @@ fn draw_table(
.map(|p| helpers::str_field(&p, "display_name").to_string())
.unwrap_or_default();
Row::new(vec![
Cell::from(Span::styled(
label,
Style::default().fg(if dir == "Outbound" {
Color::Cyan
} else {
Color::Green
}),
)),
// A link has no instance name of its own — it inherits its
// parent transport's, shown one row up — and no absence
// policy, which is a property of an interface.
Cell::from(""),
Cell::from(addr),
Cell::from(Line::from(state.to_string()).alignment(Alignment::Right)),
Cell::from(peer_name),
Cell::from(""),
Cell::from(""),
])
table_row(
vec![
Cell::from(Span::styled(
label,
Style::default().fg(if dir == "Outbound" {
Color::Cyan
} else {
Color::Green
}),
)),
// A link has no instance name of its own — it inherits its
// parent transport's, shown one row up — and no absence
// policy, which is a property of an interface.
Cell::from(""),
Cell::from(addr),
Cell::from(Line::from(state.to_string()).alignment(Alignment::Right)),
Cell::from(peer_name),
Cell::from(""),
Cell::from(""),
],
narrow,
)
}
})
.collect();
@@ -344,7 +396,22 @@ fn draw_table(
// ` └─ Out` — rather than for a full MAC, because the address moved to
// `Bound to` where it belongs. `Bound to` is sized for a MAC (17), which
// also covers every netdev name and socket address that shares it.
let widths = [
// Narrow: the same columns, sized down to what still reads. Instance keeps
// 18 because `mesh0 (optional)` is 16 and the marker is the point; `Bound
// to` keeps 17 because that is a full MAC.
let widths: &[Constraint] = if narrow {
&[
Constraint::Length(12), // Transport / Link
Constraint::Length(18), // Instance, plus "(optional)"
Constraint::Length(17), // Bound to: a full MAC
Constraint::Length(9), // State, right-aligned
Constraint::Min(6), // Peer — takes the slack
]
} else {
&FULL_WIDTHS
};
const FULL_WIDTHS: [Constraint; 7] = [
Constraint::Length(18), // Transport / Link
Constraint::Length(20), // Instance, plus "(optional)" where it applies
Constraint::Length(18), // Bound to: netdev, socket addr, onion, MAC
@@ -357,7 +424,7 @@ fn draw_table(
Constraint::Length(7), // Rx
];
let table = Table::new(rows, widths)
let table = Table::new(rows, widths.to_vec())
.header(header)
.block(Block::default().borders(Borders::ALL).title(title))
.row_highlight_style(