Implement adaptive client list
This commit is contained in:
+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 {
|
||||
|
||||
Reference in New Issue
Block a user