Implement adaptive client list
This commit is contained in:
+1159
-163
File diff suppressed because it is too large
Load Diff
+297
-32
@@ -54,6 +54,9 @@ pub struct SessionView {
|
||||
pub generation: u64,
|
||||
pub clients: Vec<ClientDisplay>,
|
||||
pub client_states: std::collections::BTreeMap<PublicKey, ClientStateRow>,
|
||||
/// Number of peer sections in the installed server document, when its
|
||||
/// saved bytes can be projected. This includes nonmanaged peers.
|
||||
pub saved_peer_count: Option<usize>,
|
||||
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
|
||||
pub applied: AppliedState,
|
||||
pub reserved_addresses: BTreeSet<IpAddr>,
|
||||
@@ -86,10 +89,15 @@ impl SessionView {
|
||||
RuntimeObservation::Verified => AppliedState::Verified,
|
||||
};
|
||||
let projection = app.display_projection();
|
||||
let saved_peer_count =
|
||||
crate::wireguard::ConfigDocument::parse(&app.saved_bytes().server_config)
|
||||
.ok()
|
||||
.map(|document| document.peers().len());
|
||||
Self {
|
||||
generation,
|
||||
clients: projection.clients,
|
||||
client_states,
|
||||
saved_peer_count,
|
||||
document_issues: projection.document_issues,
|
||||
applied,
|
||||
reserved_addresses: states.reserved_addresses().clone(),
|
||||
@@ -104,6 +112,57 @@ impl SessionView {
|
||||
.iter()
|
||||
.any(|client| client.identity == Some(*identity))
|
||||
}
|
||||
|
||||
/// Rows shown in the client table. Document issues are deliberately
|
||||
/// excluded because they are summaries, not client action targets.
|
||||
pub fn visible_records(&self) -> Vec<VisibleRecord<'_>> {
|
||||
let mut records = self
|
||||
.clients
|
||||
.iter()
|
||||
.map(VisibleRecord::Peer)
|
||||
.collect::<Vec<_>>();
|
||||
records.extend(self.client_states.iter().filter_map(|(identity, row)| {
|
||||
let client = row.saved.as_ref()?;
|
||||
if row.draft.is_some()
|
||||
|| self
|
||||
.clients
|
||||
.iter()
|
||||
.any(|peer| peer.identity == Some(*identity))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(VisibleRecord::DraftRemoved {
|
||||
identity: *identity,
|
||||
client,
|
||||
})
|
||||
}));
|
||||
records
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum VisibleRecord<'a> {
|
||||
Peer(&'a ClientDisplay),
|
||||
DraftRemoved {
|
||||
identity: PublicKey,
|
||||
client: &'a Client,
|
||||
},
|
||||
}
|
||||
|
||||
impl VisibleRecord<'_> {
|
||||
pub fn identity(self) -> Option<PublicKey> {
|
||||
match self {
|
||||
Self::Peer(client) => client.identity,
|
||||
Self::DraftRemoved { identity, .. } => Some(identity),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn peer_index(self) -> Option<usize> {
|
||||
match self {
|
||||
Self::Peer(client) => Some(client.peer_index),
|
||||
Self::DraftRemoved { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -171,28 +230,92 @@ impl UiState {
|
||||
if view.generation != self.generation {
|
||||
return false;
|
||||
}
|
||||
if self
|
||||
.selected_identity
|
||||
.is_some_and(|identity| !view.contains_identity(&identity))
|
||||
{
|
||||
self.selected_identity = None;
|
||||
}
|
||||
if let Some(identity) = self.selected_identity {
|
||||
self.selected_peer_index = view
|
||||
.clients
|
||||
.iter()
|
||||
.find(|client| client.identity == Some(identity))
|
||||
.map(|client| client.peer_index);
|
||||
} else if self
|
||||
.selected_peer_index
|
||||
.is_some_and(|index| !view.clients.iter().any(|client| client.peer_index == index))
|
||||
{
|
||||
self.selected_peer_index = None;
|
||||
}
|
||||
self.reconcile_selection(&view);
|
||||
self.view = Some(view);
|
||||
true
|
||||
}
|
||||
|
||||
fn reconcile_selection(&mut self, view: &SessionView) {
|
||||
if let Some(identity) = self.selected_identity {
|
||||
let matching = view
|
||||
.clients
|
||||
.iter()
|
||||
.filter(|client| client.identity == Some(identity))
|
||||
.collect::<Vec<_>>();
|
||||
if matching.len() == 1 {
|
||||
self.selected_peer_index = Some(matching[0].peer_index);
|
||||
return;
|
||||
}
|
||||
if matching.len() > 1 {
|
||||
if self
|
||||
.selected_peer_index
|
||||
.is_some_and(|index| matching.iter().any(|client| client.peer_index == index))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// The old row disappeared and the key is now ambiguous. Do
|
||||
// not guess which peer the administrator meant.
|
||||
self.selected_identity = None;
|
||||
self.selected_peer_index = None;
|
||||
self.select_first_record(view);
|
||||
return;
|
||||
}
|
||||
let removed = view.client_states.get(&identity).is_some_and(|row| {
|
||||
row.saved.is_some()
|
||||
&& row.draft.is_none()
|
||||
&& !view
|
||||
.clients
|
||||
.iter()
|
||||
.any(|client| client.identity == Some(identity))
|
||||
});
|
||||
if removed {
|
||||
self.selected_peer_index = None;
|
||||
return;
|
||||
}
|
||||
self.selected_identity = None;
|
||||
self.selected_peer_index = None;
|
||||
}
|
||||
|
||||
if let Some(peer_index) = self.selected_peer_index {
|
||||
if let Some(client) = view
|
||||
.clients
|
||||
.iter()
|
||||
.find(|client| client.peer_index == peer_index)
|
||||
{
|
||||
self.selected_identity = client.identity;
|
||||
return;
|
||||
}
|
||||
self.selected_peer_index = None;
|
||||
}
|
||||
|
||||
if self.selected_identity.is_some_and(|identity| {
|
||||
view.client_states
|
||||
.get(&identity)
|
||||
.is_some_and(|row| row.saved.is_some() && row.draft.is_none())
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.select_first_record(view);
|
||||
}
|
||||
|
||||
fn select_first_record(&mut self, view: &SessionView) {
|
||||
match view.visible_records().first().copied() {
|
||||
Some(VisibleRecord::Peer(client)) => {
|
||||
self.selected_peer_index = Some(client.peer_index);
|
||||
self.selected_identity = client.identity;
|
||||
}
|
||||
Some(VisibleRecord::DraftRemoved { identity, .. }) => {
|
||||
self.selected_peer_index = None;
|
||||
self.selected_identity = Some(identity);
|
||||
}
|
||||
None => {
|
||||
self.selected_peer_index = None;
|
||||
self.selected_identity = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply only statistics sampled from the still-current saved/draft view.
|
||||
/// This never changes the applied-state marker.
|
||||
pub fn apply_statistics(&mut self, generation: u64, statistics: Statistics) -> bool {
|
||||
@@ -250,20 +373,19 @@ impl UiState {
|
||||
}
|
||||
|
||||
pub fn select(&mut self, identity: PublicKey) -> bool {
|
||||
if !self
|
||||
.view
|
||||
.as_ref()
|
||||
.is_some_and(|view| view.contains_identity(&identity))
|
||||
{
|
||||
let Some(view) = &self.view else { return false };
|
||||
let mut matching = view
|
||||
.clients
|
||||
.iter()
|
||||
.filter(|row| row.identity == Some(identity));
|
||||
let Some(client) = matching.next() else {
|
||||
return false;
|
||||
};
|
||||
if matching.next().is_some() {
|
||||
return false;
|
||||
}
|
||||
self.selected_identity = Some(identity);
|
||||
self.selected_peer_index = self.view.as_ref().and_then(|view| {
|
||||
view.clients
|
||||
.iter()
|
||||
.find(|client| client.identity == Some(identity))
|
||||
.map(|client| client.peer_index)
|
||||
});
|
||||
self.selected_peer_index = Some(client.peer_index);
|
||||
self.screen = Screen::ClientDetails;
|
||||
true
|
||||
}
|
||||
@@ -290,14 +412,81 @@ impl UiState {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn selected_record(&self) -> Option<VisibleRecord<'_>> {
|
||||
let view = self.view.as_ref()?;
|
||||
let records = view.visible_records();
|
||||
self.selected_visible_index(&records)
|
||||
.and_then(|index| records.get(index).copied())
|
||||
}
|
||||
|
||||
pub fn selected_visible_index(&self, records: &[VisibleRecord<'_>]) -> Option<usize> {
|
||||
self.selected_peer_index
|
||||
.and_then(|peer_index| {
|
||||
records
|
||||
.iter()
|
||||
.position(|record| record.peer_index() == Some(peer_index))
|
||||
})
|
||||
.or_else(|| {
|
||||
self.selected_identity.and_then(|identity| {
|
||||
records
|
||||
.iter()
|
||||
.position(|record| record.identity() == Some(identity))
|
||||
})
|
||||
})
|
||||
.or_else(|| (!records.is_empty()).then_some(0))
|
||||
}
|
||||
|
||||
pub fn select_visible_index(&mut self, selected: usize) -> bool {
|
||||
let Some(record) = self
|
||||
.view
|
||||
.as_ref()
|
||||
.and_then(|view| view.visible_records().get(selected).copied())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
match record {
|
||||
VisibleRecord::Peer(client) => {
|
||||
self.selected_peer_index = Some(client.peer_index);
|
||||
self.selected_identity = client.identity;
|
||||
}
|
||||
VisibleRecord::DraftRemoved { identity, .. } => {
|
||||
self.selected_peer_index = None;
|
||||
self.selected_identity = Some(identity);
|
||||
}
|
||||
}
|
||||
self.scroll_offset = 0;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn open_selected_record(&mut self) -> bool {
|
||||
let Some(view) = &self.view else { return false };
|
||||
let records = view.visible_records();
|
||||
let Some(index) = self.selected_visible_index(&records) else {
|
||||
return false;
|
||||
};
|
||||
self.select_visible_index(index);
|
||||
self.screen = Screen::ClientDetails;
|
||||
self.scroll_offset = 0;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn selected_client_mutable(&self) -> bool {
|
||||
let Some(identity) = self.selected_identity else {
|
||||
return false;
|
||||
};
|
||||
self.view.as_ref().is_some_and(|view| {
|
||||
view.clients.iter().any(|client| {
|
||||
client.identity == Some(identity) && client.mutable_identity == Some(identity)
|
||||
})
|
||||
let mut matching = view
|
||||
.clients
|
||||
.iter()
|
||||
.filter(|client| client.identity == Some(identity));
|
||||
let Some(client) = matching.next() else {
|
||||
return false;
|
||||
};
|
||||
matching.next().is_none()
|
||||
&& self
|
||||
.selected_peer_index
|
||||
.is_none_or(|peer_index| peer_index == client.peer_index)
|
||||
&& client.mutable_identity == Some(identity)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -330,6 +519,7 @@ mod tests {
|
||||
generation,
|
||||
clients,
|
||||
client_states: Default::default(),
|
||||
saved_peer_count: None,
|
||||
document_issues: Vec::new(),
|
||||
applied: AppliedState::Unknown,
|
||||
reserved_addresses: BTreeSet::new(),
|
||||
@@ -382,6 +572,81 @@ mod tests {
|
||||
assert!(!state.selected_client_mutable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_uses_peer_position_for_duplicate_keys_and_disables_mutation() {
|
||||
let key = PublicKey::from_bytes([12; 32]);
|
||||
let mut first = row(key, "first duplicate", true);
|
||||
first.peer_index = 3;
|
||||
let mut second = row(key, "second duplicate", true);
|
||||
second.peer_index = 8;
|
||||
let mut state = UiState::default();
|
||||
assert!(state.apply_view(view(0, vec![first, second])));
|
||||
assert_eq!(state.selected_peer_index, Some(3));
|
||||
assert!(!state.selected_client_mutable());
|
||||
assert!(state.highlight_peer_index(8));
|
||||
assert_eq!(state.selected_record().unwrap().peer_index(), Some(8));
|
||||
assert_eq!(
|
||||
match state.selected_record().unwrap() {
|
||||
VisibleRecord::Peer(client) => client.label.as_str(),
|
||||
VisibleRecord::DraftRemoved { .. } => panic!("duplicate resolved to removal"),
|
||||
},
|
||||
"second duplicate"
|
||||
);
|
||||
assert!(!state.selected_client_mutable());
|
||||
|
||||
let mut refreshed_first = row(key, "refreshed first", true);
|
||||
refreshed_first.peer_index = 3;
|
||||
let mut refreshed_second = row(key, "refreshed second", true);
|
||||
refreshed_second.peer_index = 8;
|
||||
assert!(state.apply_view(view(0, vec![refreshed_second, refreshed_first])));
|
||||
assert_eq!(state.selected_peer_index, Some(8));
|
||||
assert_eq!(
|
||||
match state.selected_record().unwrap() {
|
||||
VisibleRecord::Peer(client) => client.label.as_str(),
|
||||
VisibleRecord::DraftRemoved { .. } => panic!("duplicate resolved to removal"),
|
||||
},
|
||||
"refreshed second"
|
||||
);
|
||||
assert!(!state.selected_client_mutable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_saved_client_is_visible_and_never_mutable() {
|
||||
let key = PublicKey::from_bytes([13; 32]);
|
||||
let removed = Client::new(
|
||||
key,
|
||||
"removed alias",
|
||||
crate::domain::ClientAddresses {
|
||||
ipv4: "10.0.0.9".parse().unwrap(),
|
||||
ipv6: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut view = view(0, Vec::new());
|
||||
view.client_states.insert(
|
||||
key,
|
||||
ClientStateRow {
|
||||
saved: Some(removed),
|
||||
draft: None,
|
||||
},
|
||||
);
|
||||
view.document_issues
|
||||
.push(crate::wireguard::DocumentIssue::MalformedSectionHeader { line: 5 });
|
||||
let mut state = UiState::default();
|
||||
assert!(state.apply_view(view));
|
||||
assert_eq!(state.selected_identity, Some(key));
|
||||
assert_eq!(
|
||||
state.selected_visible_index(&state.view.as_ref().unwrap().visible_records()),
|
||||
Some(0)
|
||||
);
|
||||
assert!(matches!(
|
||||
state.selected_record(),
|
||||
Some(VisibleRecord::DraftRemoved { identity, .. }) if identity == key
|
||||
));
|
||||
assert!(!state.selected_client_mutable());
|
||||
assert_eq!(state.view.as_ref().unwrap().visible_records().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_a_dialog_does_not_claim_submitted_work_was_cancelled() {
|
||||
let mut state = UiState {
|
||||
|
||||
+112
-29
@@ -869,15 +869,7 @@ fn handle_navigation_key(code: KeyCode, state: &mut UiState) -> bool {
|
||||
true
|
||||
}
|
||||
KeyCode::Enter if state.screen == Screen::ClientList => {
|
||||
let peer_index = state.selected_peer_index.or_else(|| {
|
||||
state
|
||||
.view
|
||||
.as_ref()
|
||||
.and_then(|view| view.clients.first().map(|client| client.peer_index))
|
||||
});
|
||||
if let Some(peer_index) = peer_index {
|
||||
let _ = state.select_peer_index(peer_index);
|
||||
}
|
||||
let _ = state.open_selected_record();
|
||||
state.scroll_offset = 0;
|
||||
true
|
||||
}
|
||||
@@ -891,31 +883,17 @@ fn handle_navigation_key(code: KeyCode, state: &mut UiState) -> bool {
|
||||
|
||||
fn move_selection(state: &mut UiState, down: bool) {
|
||||
let Some(view) = &state.view else { return };
|
||||
if view.clients.is_empty() {
|
||||
let records = view.visible_records();
|
||||
if records.is_empty() {
|
||||
return;
|
||||
}
|
||||
let current = state
|
||||
.selected_peer_index
|
||||
.and_then(|selected| {
|
||||
view.clients
|
||||
.iter()
|
||||
.position(|row| row.peer_index == selected)
|
||||
})
|
||||
.or_else(|| {
|
||||
state.selected_identity.and_then(|identity| {
|
||||
view.clients
|
||||
.iter()
|
||||
.position(|row| row.identity == Some(identity))
|
||||
})
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let current = state.selected_visible_index(&records).unwrap_or(0);
|
||||
let next = if down {
|
||||
(current + 1) % view.clients.len()
|
||||
(current + 1) % records.len()
|
||||
} else {
|
||||
current.checked_sub(1).unwrap_or(view.clients.len() - 1)
|
||||
current.checked_sub(1).unwrap_or(records.len() - 1)
|
||||
};
|
||||
let peer_index = view.clients[next].peer_index;
|
||||
let _ = state.highlight_peer_index(peer_index);
|
||||
let _ = state.select_visible_index(next);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -996,6 +974,7 @@ mod tests {
|
||||
unsupported,
|
||||
],
|
||||
client_states: Default::default(),
|
||||
saved_peer_count: Some(0),
|
||||
document_issues: Vec::new(),
|
||||
applied: AppliedState::Unknown,
|
||||
reserved_addresses: Default::default(),
|
||||
@@ -1064,6 +1043,109 @@ mod tests {
|
||||
assert!(!state.selected_client_mutable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyboard_navigation_reaches_draft_removed_rows_without_authorizing_them() {
|
||||
let removed_key = crate::domain::PublicKey::from_bytes([19; 32]);
|
||||
let mut view = navigation_view();
|
||||
view.client_states.insert(
|
||||
removed_key,
|
||||
crate::tui::state::ClientStateRow {
|
||||
saved: Some(
|
||||
crate::domain::Client::new(
|
||||
removed_key,
|
||||
"removed",
|
||||
crate::domain::ClientAddresses {
|
||||
ipv4: "10.0.0.9".parse().unwrap(),
|
||||
ipv6: None,
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
draft: None,
|
||||
},
|
||||
);
|
||||
let mut state = UiState {
|
||||
view: Some(view),
|
||||
..UiState::default()
|
||||
};
|
||||
assert!(handle_navigation_key(KeyCode::Down, &mut state));
|
||||
assert_eq!(state.selected_peer_index, Some(9));
|
||||
assert!(handle_navigation_key(KeyCode::Down, &mut state));
|
||||
assert_eq!(state.selected_peer_index, None);
|
||||
assert_eq!(state.selected_identity, Some(removed_key));
|
||||
assert!(!state.selected_client_mutable());
|
||||
assert!(handle_navigation_key(KeyCode::Enter, &mut state));
|
||||
assert_eq!(state.screen, Screen::ClientDetails);
|
||||
assert!(matches!(
|
||||
state.selected_record(),
|
||||
Some(crate::tui::state::VisibleRecord::DraftRemoved { identity, .. })
|
||||
if identity == removed_key
|
||||
));
|
||||
assert!(handle_navigation_key(KeyCode::Esc, &mut state));
|
||||
assert_eq!(state.screen, Screen::ClientList);
|
||||
assert_eq!(state.selected_identity, Some(removed_key));
|
||||
|
||||
let mut empty = UiState {
|
||||
view: Some(SessionView {
|
||||
generation: 0,
|
||||
clients: Vec::new(),
|
||||
client_states: Default::default(),
|
||||
saved_peer_count: Some(0),
|
||||
document_issues: vec![crate::wireguard::DocumentIssue::MalformedSectionHeader {
|
||||
line: 1,
|
||||
}],
|
||||
applied: AppliedState::Unknown,
|
||||
reserved_addresses: Default::default(),
|
||||
statistics: RuntimeObservation::unknown(),
|
||||
has_draft: false,
|
||||
vault_initialized: true,
|
||||
}),
|
||||
..UiState::default()
|
||||
};
|
||||
assert!(handle_navigation_key(KeyCode::Enter, &mut empty));
|
||||
assert_eq!(empty.screen, Screen::ClientList);
|
||||
assert!(empty.selected_record().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_public_keys_remain_inspectable_by_peer_position_but_not_mutable() {
|
||||
let key = crate::domain::PublicKey::from_bytes([20; 32]);
|
||||
let mut first = navigation_view().clients[0].clone();
|
||||
first.peer_index = 3;
|
||||
first.identity = Some(key);
|
||||
first.mutable_identity = Some(key);
|
||||
first.label = "first duplicate".to_owned();
|
||||
let mut second = first.clone();
|
||||
second.peer_index = 7;
|
||||
second.label = "second duplicate".to_owned();
|
||||
let mut state = UiState {
|
||||
view: Some(SessionView {
|
||||
generation: 0,
|
||||
clients: vec![first, second],
|
||||
client_states: Default::default(),
|
||||
saved_peer_count: Some(2),
|
||||
document_issues: Vec::new(),
|
||||
applied: AppliedState::Unknown,
|
||||
reserved_addresses: Default::default(),
|
||||
statistics: RuntimeObservation::unknown(),
|
||||
has_draft: false,
|
||||
vault_initialized: true,
|
||||
}),
|
||||
..UiState::default()
|
||||
};
|
||||
assert!(handle_navigation_key(KeyCode::Down, &mut state));
|
||||
assert_eq!(state.selected_peer_index, Some(7));
|
||||
assert_eq!(state.selected_identity, Some(key));
|
||||
assert!(!state.selected_client_mutable());
|
||||
assert!(handle_navigation_key(KeyCode::Enter, &mut state));
|
||||
assert!(matches!(
|
||||
state.selected_record(),
|
||||
Some(crate::tui::state::VisibleRecord::Peer(client))
|
||||
if client.peer_index == 7 && client.label == "second duplicate"
|
||||
));
|
||||
assert!(!state.selected_client_mutable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issues_screen_hides_valid_address_shape_limits_but_keeps_genuine_errors() {
|
||||
let mut view = navigation_view();
|
||||
@@ -1275,6 +1357,7 @@ mod tests {
|
||||
generation: 1,
|
||||
clients: Vec::new(),
|
||||
client_states: Default::default(),
|
||||
saved_peer_count: Some(0),
|
||||
document_issues: Vec::new(),
|
||||
applied: AppliedState::Unknown,
|
||||
reserved_addresses: Default::default(),
|
||||
|
||||
Reference in New Issue
Block a user