Add aligned users table

This commit is contained in:
2026-10-26 23:41:24 +02:00
parent ecc5c83d78
commit 6d8d6d572d
3 changed files with 608 additions and 61 deletions
+473 -60
View File
@@ -4,7 +4,7 @@ use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::widgets::{Block, Borders, Paragraph, Row, Table, TableState, Wrap};
use crate::app::ClientDisplay;
use crate::domain::PublicKey;
@@ -480,19 +480,11 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta
frame.render_widget(Paragraph::new("Loading client projection…"), area);
return;
};
if view.clients.is_empty() {
let text = if view.document_issues.is_empty() {
"No peers are configured."
} else {
"No peer rows. Document issues are listed below."
};
frame.render_widget(
Paragraph::new(text).block(Block::default().borders(Borders::ALL).title("Clients")),
area,
);
return;
}
let mut items: Vec<ListItem<'_>> = view
// At the supported minimum, the main panel is only three rows tall. Drop
// its border there so both the header and one client row remain visible.
let compact = area.height <= 3;
let columns = client_table_columns(area.width, compact);
let mut rows: Vec<Row<'static>> = view
.clients
.iter()
.map(|client| {
@@ -511,7 +503,7 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta
.identity
.and_then(|identity| view.client_states.get(&identity))
.map_or_else(
|| "state: unavailable".to_owned(),
|| "Inspection only".to_owned(),
|row| match (&row.saved, &row.draft) {
(None, Some(_)) => "Draft added".to_owned(),
(Some(saved), Some(draft)) if saved != draft => "Draft changed".to_owned(),
@@ -520,26 +512,25 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta
(None, None) => "State unavailable".to_owned(),
},
);
let anomaly_marker = if visible_issues.is_empty() {
String::new()
let row_state = if visible_issues.is_empty() {
row_state
} else {
" ! anomaly".to_owned()
format!("{row_state} !")
};
let anomaly_details = if visible_issues.is_empty() {
String::new()
} else {
format!(" {}", safe_text(&format!("{:?}", visible_issues)))
};
ListItem::new(Line::from(vec![
Span::raw(format!("{row_state}{anomaly_marker} ")),
Span::styled(safe_text(&client.label), Style::default().fg(Color::Cyan)),
Span::raw(format!(
" [{identity}] {supported} {addresses}{anomaly_details}"
)),
]))
client_table_row(
&columns,
[
row_state,
safe_text(&client.label),
identity,
supported.to_owned(),
addresses,
],
!visible_issues.is_empty(),
)
})
.collect();
items.extend(view.client_states.iter().filter_map(|(identity, row)| {
rows.extend(view.client_states.iter().filter_map(|(identity, row)| {
let saved = row.saved.as_ref()?;
if row.draft.is_some()
|| view
@@ -553,21 +544,44 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |address| address.to_string());
Some(ListItem::new(format!(
"Draft removed {} [{}] saved addresses: {}, {}",
safe_text(&saved.alias),
short_key(identity),
saved.addresses.ipv4,
ipv6
)))
}));
items.extend(view.document_issues.iter().map(|issue| {
ListItem::new(format!(
"Document issue: {}",
safe_text(&format!("{issue:?}"))
Some(client_table_row(
&columns,
[
"Draft removed".to_owned(),
safe_text(&saved.alias),
identity.to_string(),
"saved".to_owned(),
format!("{}, {ipv6}", saved.addresses.ipv4),
],
false,
))
.style(Style::default().fg(Color::Yellow))
}));
rows.extend(view.document_issues.iter().map(|issue| {
client_table_row(
&columns,
[
"Document issue".to_owned(),
safe_text(&format!("{issue:?}")),
String::new(),
String::new(),
String::new(),
],
true,
)
}));
if rows.is_empty() {
rows.push(client_table_row(
&columns,
[
"Empty".to_owned(),
"No peers".to_owned(),
String::new(),
String::new(),
String::new(),
],
false,
));
}
let selected = state
.selected_peer_index
.and_then(|index| view.clients.iter().position(|row| row.peer_index == index))
@@ -578,16 +592,129 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta
.position(|row| row.identity == Some(identity))
})
})
.unwrap_or(0);
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title("Clients and document issues"),
)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut list_state = ListState::default().with_selected(Some(selected));
frame.render_stateful_widget(list, area, &mut list_state);
.or_else(|| (!view.clients.is_empty()).then_some(0));
let header = Row::new(
columns
.iter()
.map(|column| column.title)
.collect::<Vec<_>>(),
)
.style(Style::default().add_modifier(Modifier::BOLD));
let widths = columns
.iter()
.map(|column| Constraint::Length(column.width));
let table = Table::new(rows, widths)
.header(header)
.column_spacing(1)
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.highlight_symbol("> ");
let table = if compact {
table
} else {
table.block(Block::default().borders(Borders::ALL).title("Clients"))
};
let mut table_state = TableState::default().with_selected(selected);
frame.render_stateful_widget(table, area, &mut table_state);
}
#[derive(Clone, Copy)]
struct ClientColumn {
title: &'static str,
index: usize,
width: u16,
}
fn client_table_columns(width: u16, borderless: bool) -> Vec<ClientColumn> {
let inner = width.saturating_sub(if borderless { 0 } else { 2 });
// Ratatui reserves two cells for the "> " selection marker in addition
// to inter-column spacing when a row is highlighted.
// The five headers need 77 cells: fixed columns, four gaps, the 2-cell
// highlight marker, and the 11-cell "Allowed IPs" heading.
if inner >= 77 {
let route_width = inner - 20 - 13 - 11 - 16 - 4 - 2;
return vec![
ClientColumn {
title: "State",
index: 0,
width: 18,
},
ClientColumn {
title: "Name",
index: 1,
width: 13,
},
ClientColumn {
title: "Public key",
index: 2,
width: 11,
},
ClientColumn {
title: "Mode",
index: 3,
width: 16,
},
ClientColumn {
title: "Allowed IPs",
index: 4,
width: route_width,
},
];
}
let mut columns = vec![ClientColumn {
title: "State",
index: 0,
width: 18,
}];
if inner >= 24 {
columns.push(ClientColumn {
title: "Name",
index: 1,
width: inner - 23,
});
}
columns
}
fn client_table_row(columns: &[ClientColumn], values: [String; 5], anomaly: bool) -> Row<'static> {
Row::new(
columns
.iter()
.map(|column| {
let text = truncate_cells(&values[column.index], column.width as usize);
let style = if anomaly && column.index == 0 {
Style::default().fg(Color::Yellow)
} else if column.index == 1 {
Style::default().fg(Color::Cyan)
} else {
Style::default()
};
Span::styled(text, style)
})
.collect::<Vec<_>>(),
)
}
fn truncate_cells(value: &str, width: usize) -> String {
let value = safe_text(value);
if Span::raw(value.as_str()).width() <= width {
return value;
}
if width == 0 {
return String::new();
}
let target = width.saturating_sub(1);
let mut result = String::new();
let mut used = 0;
for character in value.chars() {
let character_width = Span::raw(character.to_string()).width();
if used + character_width > target {
break;
}
result.push(character);
used += character_width;
}
result.push('…');
result
}
fn render_details(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiState) {
@@ -1010,6 +1137,295 @@ mod tests {
assert!(text.contains("R restart") && text.contains("r rename"));
}
#[test]
fn client_table_uses_aligned_columns_and_adapts_headers_by_width() {
let first = PublicKey::from_bytes([4; 32]);
let second = PublicKey::from_bytes([5; 32]);
let mut view = saved_client_view(first);
view.clients.push(ClientDisplay {
peer_index: 1,
line: 7,
identity: Some(second),
label: "宽い名 alias with a deliberately long suffix".to_owned(),
allowed_ips: vec!["10.0.0.5/32".to_owned(), "fd00::5/128".to_owned()],
has_subnet_routes: false,
issues: vec![crate::wireguard::PeerIssue::MissingIpv4HostRoute],
mutable_identity: None,
});
let state = UiState {
selected_peer_index: Some(1),
view: Some(view),
..UiState::default()
};
for (width, height) in [(120, 30), (80, 24), (40, 8), (34, 8)] {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| draw(frame, &state, &FormController::default()))
.unwrap();
let text = rendered_text(&terminal);
assert!(
text.contains("State") && text.contains("Name"),
"missing primary headers at {width}x{height}: {text}"
);
assert!(!text.contains('\t'));
if width >= 80 {
for header in ["Public key", "Mode", "Allowed IPs"] {
assert!(
text.contains(header),
"missing {header} at {width}x{height}: {text}"
);
}
}
if width >= 80 {
assert!(
text.contains("inspection"),
"missing inspection mode: {text}"
);
assert!(text.contains("!"), "missing anomaly marker: {text}");
}
}
}
#[test]
fn compact_supported_sizes_show_headers_selected_peer_and_footer() {
let identity = PublicKey::from_bytes([6; 32]);
let state = UiState {
selected_peer_index: Some(0),
view: Some(saved_client_view(identity)),
..UiState::default()
};
for width in [34, 40] {
let mut terminal = Terminal::new(TestBackend::new(width, 8)).unwrap();
terminal
.draw(|frame| draw(frame, &state, &FormController::default()))
.unwrap();
let buffer = terminal.backend().buffer();
let text = rendered_text(&terminal);
assert!(
text.contains("State") && text.contains("Name"),
"{width}x8: {text}"
);
assert!(
text.contains("synthetic"),
"peer hidden at {width}x8: {text}"
);
assert!(text.contains("Keys"), "footer hidden at {width}x8: {text}");
assert!(buffer[(0, 7)].symbol().contains('└'));
assert!(buffer[(width - 1, 7)].symbol().contains('┘'));
}
}
#[test]
fn ordinary_table_columns_fit_selection_marker_and_panel_boundary() {
let identity = PublicKey::from_bytes([7; 32]);
let state = UiState {
selected_peer_index: Some(0),
view: Some(saved_client_view(identity)),
..UiState::default()
};
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| draw(frame, &state, &FormController::default()))
.unwrap();
let buffer = terminal.backend().buffer();
let header_y = (0..buffer.area.height)
.find(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, *y)].symbol())
.collect::<String>()
.contains("State")
})
.unwrap();
let header_line = (0..buffer.area.width)
.map(|x| buffer[(x, header_y)].symbol())
.collect::<String>();
let positions = ["State", "Name", "Public key", "Mode", "Allowed IPs"].map(|header| {
header_line
.find(header)
.map(|position| position as u16)
.unwrap_or_else(|| panic!("missing header {header}: {header_line}"))
});
assert!(positions.windows(2).all(|pair| pair[0] < pair[1]));
assert!(positions[0] >= 3, "selection marker was not accounted for");
let allowed_end = positions[4] + "Allowed IPs".len() as u16;
assert!(
allowed_end < 79,
"last column reaches border: {allowed_end}; {header_line}"
);
let row_y = header_y + 1;
assert!(buffer[(1, row_y)].symbol().contains('>'));
assert!(buffer[(79, row_y)].symbol().contains('│'));
}
#[test]
fn five_column_headers_are_only_selected_when_allowed_ips_fits() {
let identity = PublicKey::from_bytes([10; 32]);
let state = UiState {
selected_peer_index: Some(0),
view: Some(saved_client_view(identity)),
..UiState::default()
};
let headers = ["State", "Name", "Public key", "Mode", "Allowed IPs"];
for width in [72, 78, 79, 80] {
let mut terminal = Terminal::new(TestBackend::new(width, 12)).unwrap();
terminal
.draw(|frame| render_list(frame, frame.area(), &state))
.unwrap();
let buffer = terminal.backend().buffer();
let header_y = (0..buffer.area.height)
.find(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, *y)].symbol())
.collect::<String>()
.contains("State")
})
.unwrap();
let header_line = (0..buffer.area.width)
.map(|x| buffer[(x, header_y)].symbol())
.collect::<String>();
let five_columns = width >= 79;
assert!(header_line.contains("State") && header_line.contains("Name"));
for header in &headers[2..] {
assert_eq!(
header_line.contains(header),
five_columns,
"unexpected {header:?} header at {width} columns: {header_line}"
);
}
if five_columns {
let starts = headers.map(|header| {
header_line
.find(header)
.unwrap_or_else(|| panic!("missing {header}: {header_line}"))
});
assert!(starts.windows(2).all(|pair| pair[0] < pair[1]));
let end = starts[4] + headers[4].len();
assert!(
end <= (width - 1) as usize,
"Allowed IPs reaches the right border at {width}: {header_line}"
);
}
}
}
#[test]
fn empty_and_document_only_tables_have_no_selected_peer_row() {
let mut cases = vec![empty_view()];
let mut document_only = empty_view();
document_only
.document_issues
.push(crate::wireguard::DocumentIssue::MalformedSectionHeader { line: 1 });
cases.push(document_only);
for view in cases {
let state = UiState {
selected_peer_index: None,
selected_identity: None,
view: Some(view),
..UiState::default()
};
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| draw(frame, &state, &FormController::default()))
.unwrap();
let buffer = terminal.backend().buffer();
assert!(!(0..buffer.area.height).any(|y| {
(0..buffer.area.width).any(|x| buffer[(x, y)].symbol().contains('>'))
}));
}
}
#[test]
fn client_table_truncates_by_display_cells_and_keeps_peer_details_full() {
let identity = PublicKey::from_bytes([9; 32]);
let mut view = saved_client_view(identity);
view.clients[0].label = "界界界界界界界界界界界界".to_owned();
view.clients[0].allowed_ips = vec!["10.0.0.2/32, 2001:db8:1234:5678::2/128".to_owned()];
let state = UiState {
selected_identity: Some(identity),
view: Some(view),
..UiState::default()
};
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| draw(frame, &state, &FormController::default()))
.unwrap();
let text = rendered_text(&terminal);
assert!(text.contains("界"));
// The details view keeps the full alias and routes even when list columns are narrow.
let mut details = state.clone();
details.screen = Screen::ClientDetails;
let mut detail_terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
detail_terminal
.draw(|frame| draw(frame, &details, &FormController::default()))
.unwrap();
let detail_text = rendered_text(&detail_terminal);
assert!(detail_text.contains("Allowed IPs"));
let complete_details = detail_lines(
&state.view.as_ref().unwrap().clients[0],
state.view.as_ref().unwrap(),
)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(complete_details.contains("界界界界界界界界界界界界"));
assert!(complete_details.contains("2001:db8:1234:5678::2/128"));
assert!(truncate_cells("界界界界", 5).ends_with('…'));
assert_eq!(Span::raw(truncate_cells("界界界界", 5)).width(), 5);
}
#[test]
fn client_table_keeps_empty_draft_removal_and_document_rows_aligned() {
let identity = PublicKey::from_bytes([8; 32]);
let mut view = empty_view();
let removed = crate::domain::Client::new(
identity,
"duplicate alias",
crate::domain::ClientAddresses {
ipv4: Ipv4Addr::new(10, 0, 0, 8),
ipv6: None,
},
)
.unwrap();
view.client_states.insert(
identity,
super::super::state::ClientStateRow {
saved: Some(removed),
draft: None,
},
);
view.document_issues
.push(crate::wireguard::DocumentIssue::MalformedSectionHeader { line: 17 });
let state = UiState {
view: Some(view),
..UiState::default()
};
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| draw(frame, &state, &FormController::default()))
.unwrap();
let text = rendered_text(&terminal);
assert!(text.contains("Draft removed"));
assert!(text.contains("duplicate"));
assert!(text.contains("Document issue"));
assert!(text.contains("Malformed"));
let mut empty_terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
empty_terminal
.draw(|frame| {
draw(
frame,
&UiState {
view: Some(empty_view()),
..UiState::default()
},
&FormController::default(),
)
})
.unwrap();
assert!(rendered_text(&empty_terminal).contains("No peers"));
}
#[test]
fn qr_disclosure_keeps_esc_clear_hint_and_never_renders_fixture_payload() {
let mut forms = FormController::default();
@@ -1265,12 +1681,9 @@ mod tests {
assert!(rendered.contains("inspection only"));
assert!(rendered.contains("removed alias"));
assert!(rendered.contains("Draft removed"));
assert!(rendered.contains("MalformedAllowedIp"));
assert!(rendered.contains("!"));
assert!(!rendered.contains("UnsupportedAllowedIpShape"));
assert!(
rendered.contains("! anomaly"),
"mixed genuine error stays visible"
);
assert!(rendered.contains("!"), "mixed genuine error stays visible");
let subnet_only = ClientDisplay {
has_subnet_routes: true,
issues: vec![crate::wireguard::PeerIssue::UnsupportedAllowedIpShape {
@@ -1309,7 +1722,7 @@ mod tests {
line.to_string()
.contains("Peer issue: UnsupportedAllowedIpShape")
}));
assert!(rendered.contains("odd\\u{1b}[31m peer"));
assert!(rendered.contains("odd\\u{1b}"));
assert!(rendered.contains("bad\\nroute"));
state.view.as_mut().unwrap().clients[1].issues =
vec![crate::wireguard::PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 1 }];