Add aligned users table
This commit is contained in:
@@ -3465,12 +3465,90 @@ HEAD to `18f28ee` while M11.1 was underway; the review covered committed and
|
|||||||
uncommitted M11 inputs. This team created no commit. No Alpine, VM or
|
uncommitted M11 inputs. This team created no commit. No Alpine, VM or
|
||||||
production behavior is claimed. Stop after this milestone.
|
production behavior is claimed. Stop after this milestone.
|
||||||
|
|
||||||
|
### Milestone 12 — Aligned, responsive client table
|
||||||
|
|
||||||
|
Status: accepted (2026-10-26T21:39:45Z). M11 is accepted; M9 remains
|
||||||
|
retired and unaccepted. The user requested this scoped presentation change.
|
||||||
|
|
||||||
|
#### Outcome and scope
|
||||||
|
|
||||||
|
Render client records with Ratatui's native `Table` and explicit column
|
||||||
|
constraints. Show `State`, `Name`, `Public key`, `Mode`, and `Allowed IPs`
|
||||||
|
headers when width permits. Keep columns aligned across short/long and Unicode
|
||||||
|
aliases, supported and inspection-only peers, and all draft states. Truncate
|
||||||
|
predictably by terminal cell width. At narrow widths prioritize State and Name,
|
||||||
|
then omit or shrink secondary columns while preserving Enter access to full
|
||||||
|
peer details. Retain diagnostics, document-level issue rows, draft-removal
|
||||||
|
information, selected-row highlight, keyboard navigation, and the M10 bottom
|
||||||
|
shortcut panel. This milestone changes rendering only; parser, projection,
|
||||||
|
validation, persistence, allocation, restart and VPN behavior are unchanged.
|
||||||
|
|
||||||
|
#### Context and steps
|
||||||
|
|
||||||
|
`src/tui/render.rs::render_list` currently builds variable-length `ListItem`
|
||||||
|
strings, so columns drift with alias/state length. `UiState` and
|
||||||
|
`src/tui/terminal.rs` select real peers by `peer_index`; draft-removal and
|
||||||
|
document-issue rows are appended after peer rows and are not peer selections.
|
||||||
|
Keep that row order and mapping when replacing the widget with `Table`.
|
||||||
|
`detail_lines` already retains full peer public key, alias, routes and issues.
|
||||||
|
The list must continue escaping untrusted display text. Use the existing
|
||||||
|
approved `ratatui` dependency and its terminal-width APIs; do not add a direct
|
||||||
|
Unicode-width crate or literal tab delimiters.
|
||||||
|
|
||||||
|
| Task | Owner | Scope and expected result | State |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| M12.1 | implementer | Replace list rendering with responsive Ratatui Table, preserve peer selection mapping, state/diagnostic information and footer; add focused mixed-width/Unicode/long-route/empty/narrow tests. | complete: render 20/20, terminal 8/8, TUI workflow 6/6 on final integrated source |
|
||||||
|
| M12.2 | reviewer / repairer | Fresh independent review of layout, truncation, details access, draft/document rows and navigation; repair and re-review actionable findings. | complete: R1–R4 repaired and independently re-reviewed closed; no remaining actionable finding |
|
||||||
|
| M12.3 | coordinator | Verify integrated files, run focused and Rust completion gates, record evidence and accept only if criteria pass. | complete: all Rust completion gates passed; source fingerprints/results in [M12 evidence](docs/evidence/milestone-12.md) |
|
||||||
|
|
||||||
|
#### Acceptance and validation
|
||||||
|
|
||||||
|
- [x] M12-A1: native Table has the five requested headers at ordinary width;
|
||||||
|
row columns align across mixed alias lengths, states, duplicate aliases,
|
||||||
|
supported and inspection-only peers, with no literal tabs.
|
||||||
|
- [x] M12-A2: Unicode aliases and long values truncate at display-cell
|
||||||
|
boundaries without corrupting adjacent columns; full peer alias, public key,
|
||||||
|
routes and diagnostic explanations remain in details.
|
||||||
|
- [x] M12-A3: 120×30, 80×24, 40×8, 34×8 and below-minimum layouts retain
|
||||||
|
visible State/Name at supported sizes, adapt or omit secondary columns,
|
||||||
|
preserve the M10 footer and readable resize fallback.
|
||||||
|
- [x] M12-A4: Up/Down highlight and Enter-to-details stay tied to the selected
|
||||||
|
peer (including unsupported/malformed peers), without enabling mutation;
|
||||||
|
empty lists, draft additions/removals and document issues remain readable
|
||||||
|
and do not disturb selection alignment.
|
||||||
|
- [x] M12-A5: focused synthetic render/navigation tests and applicable Rust
|
||||||
|
completion gates pass; independent review closes actionable findings.
|
||||||
|
|
||||||
|
Planned checks: `cargo test --locked tui::render`,
|
||||||
|
`cargo test --locked tui::terminal`, `cargo test --locked --test tui_workflow`,
|
||||||
|
`cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D
|
||||||
|
warnings`, `cargo test --locked`, `cargo build --locked --release`, and
|
||||||
|
`git diff --check`. Synthetic Ratatui/PTY evidence only; no Alpine, VM,
|
||||||
|
production, or live VPN checks are required for this presentation change.
|
||||||
|
|
||||||
|
#### Outcome
|
||||||
|
|
||||||
|
Accepted for scoped TUI presentation. **M12-R1 (P2, closed):** compact bordered table hid all peers;
|
||||||
|
repair uses a borderless compact table with a visible selected row and footer.
|
||||||
|
**M12-R2 (P2, closed):** empty/document-only views falsely highlighted row
|
||||||
|
zero; selection is now `None`. **M12-R3 (P2, closed):** five columns omitted
|
||||||
|
the two-cell marker budget; repair and buffer assertions cover the right edge.
|
||||||
|
Independent re-review closed R1–R3. **M12-R4 (P2, closed):** at widths 72–78 the
|
||||||
|
five-column threshold activated before the `Allowed IPs` header fit. Repair
|
||||||
|
uses the full-header minimum width and buffer tests at 72/78/79/80;
|
||||||
|
independent re-review closed R4 with no remaining finding. Final focused tests,
|
||||||
|
format, Clippy, locked full suite, release build and diff check passed on the
|
||||||
|
source in [M12 evidence](docs/evidence/milestone-12.md). Synthetic UI/PTY
|
||||||
|
evidence does not establish physical terminal, Alpine or systemd behavior.
|
||||||
|
Stop after this milestone; do not resume VM work, commit or deploy.
|
||||||
|
|
||||||
## Current progress and resume point
|
## Current progress and resume point
|
||||||
|
|
||||||
- Bounded TUI maintenance (2026-10-26): repaired list arrows opening client details. Up/Down now only move the highlight; Enter opens details and Esc returns. Initial navigation follows the renderer's first-row highlight. Independent review closed NAV-001 with no remaining scoped findings; validation is recorded in [navigation repair evidence](docs/evidence/tui-navigation.md). This does not advance M9 or supply VM/Alpine acceptance evidence; prior artifact identities remain historical after this Rust change.
|
- Bounded TUI maintenance (2026-10-26): repaired list arrows opening client details. Up/Down now only move the highlight; Enter opens details and Esc returns. Initial navigation follows the renderer's first-row highlight. Independent review closed NAV-001 with no remaining scoped findings; validation is recorded in [navigation repair evidence](docs/evidence/tui-navigation.md). This does not advance M9 or supply VM/Alpine acceptance evidence; prior artifact identities remain historical after this Rust change.
|
||||||
- Milestones 1–8 are accepted; milestone 9 is retired by user direction and unaccepted. Its real systemd/D-Bus checks remain unverified and are not a prerequisite to M10.
|
- Milestones 1–8 are accepted; milestone 9 is retired by user direction and unaccepted. Its real systemd/D-Bus checks remain unverified and are not a prerequisite to M10.
|
||||||
- Milestone 10 is accepted: the styled keyboard quick reference passed independent review, focused synthetic/PTY checks and Rust completion gates.
|
- Milestone 10 is accepted: the styled keyboard quick reference passed independent review, focused synthetic/PTY checks and Rust completion gates.
|
||||||
- Milestone 11 is accepted for valid but unsupported AllowedIPs route presentation; focused and full Rust gates and independent re-review passed. Real systemd/D-Bus and IPv6 behavior remain unverified.
|
- Milestone 11 is accepted for valid but unsupported AllowedIPs route presentation; focused and full Rust gates and independent re-review passed. Real systemd/D-Bus and IPv6 behavior remain unverified.
|
||||||
|
- Milestone 12 is accepted: native responsive client table, independent R4 closure and all scoped Rust gates passed; source and synthetic UI/PTY evidence are linked above.
|
||||||
- The approved dependency list is in [DEPENDENCIES.md](docs/DEPENDENCIES.md); container testing methodology is in [TESTING.md](docs/TESTING.md).
|
- The approved dependency list is in [DEPENDENCIES.md](docs/DEPENDENCIES.md); container testing methodology is in [TESTING.md](docs/TESTING.md).
|
||||||
- Milestone 1 is expanded and the Rust environment is pinned.
|
- Milestone 1 is expanded and the Rust environment is pinned.
|
||||||
- Milestone 2 delivered a lossless in-memory WireGuard configuration editor, versioned metadata, and behavioral preservation tests.
|
- Milestone 2 delivered a lossless in-memory WireGuard configuration editor, versioned metadata, and behavioral preservation tests.
|
||||||
@@ -3483,7 +3561,7 @@ production behavior is claimed. Stop after this milestone.
|
|||||||
|
|
||||||
- Milestone 8 delivered reviewed opt-in Alpine Testcontainers coverage for real IPv4 WireGuard peers, application lifecycle, saved/runtime separation, restoration, import/reissue, backup failures and the real Rustix reader. Host gates and three fresh real-container runs passed on one stable source fingerprint; M8-A1–A10 accepted. Real IPv6 behavior is unverified by user-authorized scope.
|
- Milestone 8 delivered reviewed opt-in Alpine Testcontainers coverage for real IPv4 WireGuard peers, application lifecycle, saved/runtime separation, restoration, import/reissue, backup failures and the real Rustix reader. Host gates and three fresh real-container runs passed on one stable source fingerprint; M8-A1–A10 accepted. Real IPv6 behavior is unverified by user-authorized scope.
|
||||||
- Milestone 9 is retired and unaccepted: the reviewed disposable VM preparation and guarded first start did not establish Debian/systemd acceptance. The exact VM was powered off after the user stopped work. M9.2–M9.3 were not started; M9-A1–A9 are unchecked.
|
- Milestone 9 is retired and unaccepted: the reviewed disposable VM preparation and guarded first start did not establish Debian/systemd acceptance. The exact VM was powered off after the user stopped work. M9.2–M9.3 were not started; M9-A1–A9 are unchecked.
|
||||||
- Next action: stop after M11 and await user direction. Do not restart the VM, access production, commit, or deploy.
|
- Next action: stop after M12 and await user direction. Do not restart the VM, access production, commit, or deploy.
|
||||||
|
|
||||||
## Plan maintenance
|
## Plan maintenance
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Milestone 12 acceptance evidence
|
||||||
|
|
||||||
|
Run: 2026-10-26T21:39:45Z, local development workspace. This report covers
|
||||||
|
the responsive client table presentation. It does not establish real terminal
|
||||||
|
emulator, Alpine WireGuard, systemd/VM or production behavior.
|
||||||
|
|
||||||
|
## Source and environment
|
||||||
|
|
||||||
|
- HEAD `ecc5c83d78a4428b8fa2c1826fe74ead144cf72b`; the working tree at
|
||||||
|
validation was dirty with `PLANS.md` and `src/tui/render.rs`. Relevant
|
||||||
|
integrated SHA-256 inputs: `render.rs`
|
||||||
|
`78be59f8550f94c573db9eab6b43968a9e0621bcabd7dd268b5ba7f9b9aba3ad`;
|
||||||
|
`state.rs` `7e893758b623b42f955358dcaba8100fdb6b54ca48c9dd9a1dea15090b84f371`;
|
||||||
|
`terminal.rs` `ed9a99932dddbaf508667c5f17886704bda749264c53cc60c7e29b889a56e074`;
|
||||||
|
`Cargo.lock` `b8549df27f971dc550218cfc28e788f6630a1e13e7df538aded38e969ab713b6`.
|
||||||
|
- Pinned `rustc`/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97; native
|
||||||
|
`x86_64-unknown-linux-gnu` host. No dependency or lockfile change.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
| Exact command | Actual result |
|
||||||
|
| --- | --- |
|
||||||
|
| `cargo test --locked tui::render` | Exit 0; 20 render tests passed. |
|
||||||
|
| `cargo test --locked tui::terminal` | Exit 0; 8 terminal tests passed. |
|
||||||
|
| `cargo test --locked --test tui_workflow` | Exit 0; 6 workflow tests passed, including isolated PTY smoke. |
|
||||||
|
| `cargo fmt --all -- --check` | Exit 0. |
|
||||||
|
| `cargo clippy --locked --all-targets -- -D warnings` | Exit 0. |
|
||||||
|
| `cargo test --locked` | Exit 0; 279 library unit tests passed, 2 opt-in drivers ignored. All integration suites and 2 compile-fail doctests passed. Testcontainers target reported 25 passed and 1 ignored without starting Alpine. |
|
||||||
|
| `cargo build --locked --release` | Exit 0. |
|
||||||
|
| `git diff --check` | Exit 0. |
|
||||||
|
|
||||||
|
Ratatui TestBackend coverage checks five full headers and column boundaries at
|
||||||
|
ordinary widths, State/Name priority across 72/78/79/80 widths, display-cell
|
||||||
|
Unicode truncation, long routes, mixed client modes, empty/draft/document rows,
|
||||||
|
no false non-peer selection, and visible selected peers with the Keys footer at
|
||||||
|
40×8 and 34×8. Below-minimum resize fallback remains covered. Existing
|
||||||
|
terminal tests cover Up/Down highlight and Enter-to-details by peer index.
|
||||||
|
These tests use synthetic buffers and fixtures; physical terminal display
|
||||||
|
differences were not tested.
|
||||||
|
|
||||||
|
## Independent review
|
||||||
|
|
||||||
|
Fresh read-only review found M12-R1 (compact table hid all peer rows), R2
|
||||||
|
(empty/document-only views falsely selected row zero), and R3 (selector width
|
||||||
|
clipped the last column). A targeted repair added compact body visibility,
|
||||||
|
unselected non-peer views and marker-aware width budgeting; independent
|
||||||
|
re-review closed R1–R3. That re-review found R4: five-column mode began before
|
||||||
|
the `Allowed IPs` header fit at widths 72–78. A second targeted repair moved
|
||||||
|
the threshold to the full-header minimum and added boundary-buffer checks;
|
||||||
|
independent re-review closed R4 with no remaining actionable finding.
|
||||||
|
|
||||||
|
M12-A1–A4 are supported by the inspected implementation and synthetic
|
||||||
|
render/navigation evidence. The integrated gates above satisfy M12-A5.
|
||||||
|
Configuration parsing, validation, persistence, allocation and VPN behavior
|
||||||
|
were outside scope and unchanged. Milestone 9 remains retired and unaccepted;
|
||||||
|
real systemd/D-Bus and IPv6 behavior remain unverified.
|
||||||
+473
-60
@@ -4,7 +4,7 @@ use ratatui::Frame;
|
|||||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span, Text};
|
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::app::ClientDisplay;
|
||||||
use crate::domain::PublicKey;
|
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);
|
frame.render_widget(Paragraph::new("Loading client projection…"), area);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if view.clients.is_empty() {
|
// At the supported minimum, the main panel is only three rows tall. Drop
|
||||||
let text = if view.document_issues.is_empty() {
|
// its border there so both the header and one client row remain visible.
|
||||||
"No peers are configured."
|
let compact = area.height <= 3;
|
||||||
} else {
|
let columns = client_table_columns(area.width, compact);
|
||||||
"No peer rows. Document issues are listed below."
|
let mut rows: Vec<Row<'static>> = view
|
||||||
};
|
|
||||||
frame.render_widget(
|
|
||||||
Paragraph::new(text).block(Block::default().borders(Borders::ALL).title("Clients")),
|
|
||||||
area,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut items: Vec<ListItem<'_>> = view
|
|
||||||
.clients
|
.clients
|
||||||
.iter()
|
.iter()
|
||||||
.map(|client| {
|
.map(|client| {
|
||||||
@@ -511,7 +503,7 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta
|
|||||||
.identity
|
.identity
|
||||||
.and_then(|identity| view.client_states.get(&identity))
|
.and_then(|identity| view.client_states.get(&identity))
|
||||||
.map_or_else(
|
.map_or_else(
|
||||||
|| "state: unavailable".to_owned(),
|
|| "Inspection only".to_owned(),
|
||||||
|row| match (&row.saved, &row.draft) {
|
|row| match (&row.saved, &row.draft) {
|
||||||
(None, Some(_)) => "Draft added".to_owned(),
|
(None, Some(_)) => "Draft added".to_owned(),
|
||||||
(Some(saved), Some(draft)) if saved != draft => "Draft changed".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(),
|
(None, None) => "State unavailable".to_owned(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let anomaly_marker = if visible_issues.is_empty() {
|
let row_state = if visible_issues.is_empty() {
|
||||||
String::new()
|
row_state
|
||||||
} else {
|
} else {
|
||||||
" ! anomaly".to_owned()
|
format!("{row_state} !")
|
||||||
};
|
};
|
||||||
let anomaly_details = if visible_issues.is_empty() {
|
client_table_row(
|
||||||
String::new()
|
&columns,
|
||||||
} else {
|
[
|
||||||
format!(" {}", safe_text(&format!("{:?}", visible_issues)))
|
row_state,
|
||||||
};
|
safe_text(&client.label),
|
||||||
ListItem::new(Line::from(vec![
|
identity,
|
||||||
Span::raw(format!("{row_state}{anomaly_marker} ")),
|
supported.to_owned(),
|
||||||
Span::styled(safe_text(&client.label), Style::default().fg(Color::Cyan)),
|
addresses,
|
||||||
Span::raw(format!(
|
],
|
||||||
" [{identity}] {supported} {addresses}{anomaly_details}"
|
!visible_issues.is_empty(),
|
||||||
)),
|
)
|
||||||
]))
|
|
||||||
})
|
})
|
||||||
.collect();
|
.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()?;
|
let saved = row.saved.as_ref()?;
|
||||||
if row.draft.is_some()
|
if row.draft.is_some()
|
||||||
|| view
|
|| view
|
||||||
@@ -553,21 +544,44 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta
|
|||||||
.addresses
|
.addresses
|
||||||
.ipv6
|
.ipv6
|
||||||
.map_or_else(|| "none".to_owned(), |address| address.to_string());
|
.map_or_else(|| "none".to_owned(), |address| address.to_string());
|
||||||
Some(ListItem::new(format!(
|
Some(client_table_row(
|
||||||
"Draft removed {} [{}] saved addresses: {}, {}",
|
&columns,
|
||||||
safe_text(&saved.alias),
|
[
|
||||||
short_key(identity),
|
"Draft removed".to_owned(),
|
||||||
saved.addresses.ipv4,
|
safe_text(&saved.alias),
|
||||||
ipv6
|
identity.to_string(),
|
||||||
)))
|
"saved".to_owned(),
|
||||||
}));
|
format!("{}, {ipv6}", saved.addresses.ipv4),
|
||||||
items.extend(view.document_issues.iter().map(|issue| {
|
],
|
||||||
ListItem::new(format!(
|
false,
|
||||||
"Document issue: {}",
|
|
||||||
safe_text(&format!("{issue:?}"))
|
|
||||||
))
|
))
|
||||||
.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
|
let selected = state
|
||||||
.selected_peer_index
|
.selected_peer_index
|
||||||
.and_then(|index| view.clients.iter().position(|row| row.peer_index == 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))
|
.position(|row| row.identity == Some(identity))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.unwrap_or(0);
|
.or_else(|| (!view.clients.is_empty()).then_some(0));
|
||||||
let list = List::new(items)
|
let header = Row::new(
|
||||||
.block(
|
columns
|
||||||
Block::default()
|
.iter()
|
||||||
.borders(Borders::ALL)
|
.map(|column| column.title)
|
||||||
.title("Clients and document issues"),
|
.collect::<Vec<_>>(),
|
||||||
)
|
)
|
||||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
.style(Style::default().add_modifier(Modifier::BOLD));
|
||||||
let mut list_state = ListState::default().with_selected(Some(selected));
|
let widths = columns
|
||||||
frame.render_stateful_widget(list, area, &mut list_state);
|
.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) {
|
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"));
|
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]
|
#[test]
|
||||||
fn qr_disclosure_keeps_esc_clear_hint_and_never_renders_fixture_payload() {
|
fn qr_disclosure_keeps_esc_clear_hint_and_never_renders_fixture_payload() {
|
||||||
let mut forms = FormController::default();
|
let mut forms = FormController::default();
|
||||||
@@ -1265,12 +1681,9 @@ mod tests {
|
|||||||
assert!(rendered.contains("inspection only"));
|
assert!(rendered.contains("inspection only"));
|
||||||
assert!(rendered.contains("removed alias"));
|
assert!(rendered.contains("removed alias"));
|
||||||
assert!(rendered.contains("Draft removed"));
|
assert!(rendered.contains("Draft removed"));
|
||||||
assert!(rendered.contains("MalformedAllowedIp"));
|
assert!(rendered.contains("!"));
|
||||||
assert!(!rendered.contains("UnsupportedAllowedIpShape"));
|
assert!(!rendered.contains("UnsupportedAllowedIpShape"));
|
||||||
assert!(
|
assert!(rendered.contains("!"), "mixed genuine error stays visible");
|
||||||
rendered.contains("! anomaly"),
|
|
||||||
"mixed genuine error stays visible"
|
|
||||||
);
|
|
||||||
let subnet_only = ClientDisplay {
|
let subnet_only = ClientDisplay {
|
||||||
has_subnet_routes: true,
|
has_subnet_routes: true,
|
||||||
issues: vec![crate::wireguard::PeerIssue::UnsupportedAllowedIpShape {
|
issues: vec![crate::wireguard::PeerIssue::UnsupportedAllowedIpShape {
|
||||||
@@ -1309,7 +1722,7 @@ mod tests {
|
|||||||
line.to_string()
|
line.to_string()
|
||||||
.contains("Peer issue: UnsupportedAllowedIpShape")
|
.contains("Peer issue: UnsupportedAllowedIpShape")
|
||||||
}));
|
}));
|
||||||
assert!(rendered.contains("odd\\u{1b}[31m peer"));
|
assert!(rendered.contains("odd\\u{1b}"));
|
||||||
assert!(rendered.contains("bad\\nroute"));
|
assert!(rendered.contains("bad\\nroute"));
|
||||||
state.view.as_mut().unwrap().clients[1].issues =
|
state.view.as_mut().unwrap().clients[1].issues =
|
||||||
vec![crate::wireguard::PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 1 }];
|
vec![crate::wireguard::PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 1 }];
|
||||||
|
|||||||
Reference in New Issue
Block a user