Add application read models and thin TUI command adapter

This commit is contained in:
2026-10-30 23:02:35 +02:00
parent 9a143e975e
commit 7123197ec4
12 changed files with 2669 additions and 703 deletions
+47 -395
View File
@@ -10,13 +10,10 @@ use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use crate::app::Application;
use crate::app::{
FirstIssueAttempt, FirstIssueDeliveryOutcome, FirstIssueRetry, RestartChoice, RestartReport,
RestorePreview,
};
use crate::app::{FirstIssueAttempt, FirstIssueRetry, RestartChoice, RestartReport};
use crate::backup::{ArchiveSummary, BackupReason, HistoricalApplyStatus};
use crate::config::SettingsFile;
use crate::delivery::files::{FileOutputError, PreparedDestination};
use crate::delivery::files::FileOutputError;
use crate::delivery::{QrCodeOutput, QrError};
use crate::domain::{ClientAddresses, PublicKey};
use crate::runtime::RuntimeObservation;
@@ -287,396 +284,10 @@ pub trait OperationExecutor: Send + 'static {
}
}
enum DeliveryMode {
FirstIssue,
Reissue,
}
struct PreparedDestinationContext {
identity: PublicKey,
include_qr: bool,
mode: DeliveryMode,
attempt: FirstIssueAttempt,
generation: u64,
destination: PreparedDestination,
}
struct RestorePreviewContext {
_archive_name: String,
generation: u64,
preview: RestorePreview,
}
#[derive(Default)]
pub struct ApplicationExecutor {
prepared_destination: Option<PreparedDestinationContext>,
restore_preview: Option<RestorePreviewContext>,
}
impl OperationExecutor for ApplicationExecutor {
fn execute(
&mut self,
app: &mut Application,
command: UiCommand,
) -> Result<CommandResult, OperationError> {
self.execute_at(app, command, 0)
}
fn execute_at(
&mut self,
app: &mut Application,
command: UiCommand,
generation: u64,
) -> Result<CommandResult, OperationError> {
let delivery_continuation = matches!(command, UiCommand::DeliverFirstIssue { .. });
let restore_continuation = matches!(
command,
UiCommand::ConfirmRestore { .. } | UiCommand::CancelRestorePreview
);
if !delivery_continuation && !matches!(&command, UiCommand::CancelPreparedDelivery) {
self.prepared_destination = None;
}
if !restore_continuation && !matches!(&command, UiCommand::PreviewRestore { .. }) {
self.restore_preview = None;
}
let result = match command {
UiCommand::CreateClient {
alias,
manual_addresses,
created_utc,
} => app
.prepare_new_client(&alias, manual_addresses, created_utc.as_deref())
.map(CommandResult::ClientCreated),
UiCommand::RenameClient { identity, alias } => app
.rename_client(identity, &alias)
.map(|()| CommandResult::ClientRenamed),
UiCommand::DeleteClient { identity } => app
.delete_client(identity)
.map(|record_removed| CommandResult::ClientDeleted { record_removed }),
UiCommand::DiscardDraft => app.discard_draft().map(|()| CommandResult::DraftDiscarded),
UiCommand::Commit => app.commit().map(CommandResult::Committed),
UiCommand::CreateBackup => app.create_manual_backup().map(CommandResult::BackupCreated),
UiCommand::ListBackups => app.list_backups().map(CommandResult::Backups),
UiCommand::ImportSelectedFile { file_name } => app
.import_selected_file(&file_name)
.map(CommandResult::SelectedFileImported),
UiCommand::PreviewRestart => app.preview_restart().map(|preview| {
CommandResult::RestartPreview(RestartPreviewSummary {
has_draft: preview.has_draft(),
interface: preview.interface().to_owned(),
systemd_unit: preview.systemd_unit().to_owned(),
})
}),
UiCommand::Restart(choice) => {
let result = app
.preview_restart()
.map(|preview| app.restart(preview, choice));
result.map(CommandResult::Restarted)
}
UiCommand::PrepareDelivery {
identity,
path,
include_qr,
reissue,
attempt,
} => app
.prepare_first_issue_destination(path)
.map_err(crate::app::AppError::FileOutput)
.map(|destination| {
let summary = DeliveryDestinationSummary {
identity,
path: destination.path().to_path_buf(),
overwrite_required: destination.overwrite_confirmation_required(),
include_qr,
reissue,
attempt,
prepared_generation: generation,
};
self.prepared_destination = Some(PreparedDestinationContext {
identity,
include_qr,
mode: if reissue {
DeliveryMode::Reissue
} else {
DeliveryMode::FirstIssue
},
attempt,
generation,
destination,
});
CommandResult::DeliveryDestination(summary)
}),
UiCommand::DeliverFirstIssue {
attempt,
overwrite_confirmed,
prepared_generation,
} => {
let prepared = self
.prepared_destination
.take()
.ok_or_else(|| OperationError {
message: "No prepared output destination is available.".to_owned(),
})?;
if !matches!(prepared.mode, DeliveryMode::FirstIssue)
|| prepared.attempt != attempt
|| !destination_generation_matches(
prepared.generation,
prepared_generation,
generation,
)
{
return Err(OperationError {
message: "Prepared output destination is stale; select it again."
.to_owned(),
});
}
let identity = prepared.identity;
let include_qr = prepared.include_qr;
let mut destination = prepared.destination;
if destination.overwrite_confirmation_required() {
if !overwrite_confirmed {
return Err(OperationError {
message: "Overwrite confirmation was not supplied.".to_owned(),
});
}
destination = destination.confirm_overwrite().map_err(operation_error)?;
}
if include_qr {
app.deliver_first_issue_file_and_qr(identity, attempt, destination)
.map(|outcome| match outcome {
FirstIssueDeliveryOutcome::Delivered(delivery) => {
let (qr, qr_output) = qr_result(delivery.qr);
CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::Delivered,
retry: None,
activation_warning: delivery.activation_warning,
qr,
qr_output,
})
}
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
value,
..
} => {
let (qr, qr_output) = qr_result(value.qr);
CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::PublishedButBookkeepingUncertain,
retry: None,
activation_warning: value.activation_warning,
qr,
qr_output,
})
}
FirstIssueDeliveryOutcome::Failed {
error,
retry,
activation_warning,
} => CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::Failed(error),
retry: Some(retry),
activation_warning,
qr: QrStatus::NotRequested,
qr_output: None,
}),
})
} else {
app.deliver_first_issue_file(identity, attempt, destination)
.map(|outcome| match outcome {
FirstIssueDeliveryOutcome::Delivered(delivery) => {
CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::Delivered,
retry: None,
activation_warning: delivery.activation_warning,
qr: QrStatus::NotRequested,
qr_output: None,
})
}
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
value,
..
} => CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::PublishedButBookkeepingUncertain,
retry: None,
activation_warning: value.activation_warning,
qr: QrStatus::NotRequested,
qr_output: None,
}),
FirstIssueDeliveryOutcome::Failed {
error,
retry,
activation_warning,
} => CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::Failed(error),
retry: Some(retry),
activation_warning,
qr: QrStatus::NotRequested,
qr_output: None,
}),
})
}
}
UiCommand::PreviewRestore { file_name } => {
app.preview_restore(&file_name).map(|preview| {
let changes = preview.change_summary(app.saved_bytes());
let summary = RestorePreviewSummary {
file_name: file_name.clone(),
created_at_utc: preview.created_at_utc().to_owned(),
reason: preview.reason(),
historical_apply: preview.historical_apply().clone(),
changes,
preview_generation: generation,
};
self.restore_preview = Some(RestorePreviewContext {
_archive_name: file_name,
generation,
preview,
});
CommandResult::RestorePreview(summary)
})
}
UiCommand::CancelPreparedDelivery => {
self.prepared_destination = None;
Ok(CommandResult::Cancelled)
}
UiCommand::CancelRestorePreview => {
self.restore_preview = None;
Ok(CommandResult::Cancelled)
}
UiCommand::ConfirmRestore { preview_generation } => {
let context = self.restore_preview.take().ok_or_else(|| OperationError {
message: "Restore preview is no longer available; select the archive again."
.to_owned(),
})?;
if !restore_preview_generation_matches(
context.generation,
preview_generation,
generation,
) {
return Err(OperationError {
message: "Restore preview is stale; select the archive again.".to_owned(),
});
}
app.confirm_restore(context.preview)
.map(CommandResult::Restored)
}
};
result.map_err(|error| OperationError {
message: error.to_string(),
})
}
fn execute_sensitive(
&mut self,
app: &mut Application,
command: SensitiveCommand,
) -> Result<CommandResult, OperationError> {
match command {
SensitiveCommand::PrepareInitialSetup(password) => app
.prepare_initial_setup_from_sources(password.expose())
.map(CommandResult::InitialSetupPrepared)
.map_err(|error| OperationError {
message: error.to_string(),
}),
SensitiveCommand::ReissueClient {
identity,
password,
overwrite_confirmed,
prepared_generation,
} => {
let prepared = self
.prepared_destination
.take()
.ok_or_else(|| OperationError {
message: "No prepared output destination is available.".to_owned(),
})?;
if prepared.identity != identity
|| !matches!(prepared.mode, DeliveryMode::Reissue)
|| prepared.generation != prepared_generation
{
return Err(OperationError {
message: "Prepared output destination is stale; select it again."
.to_owned(),
});
}
let include_qr = prepared.include_qr;
let mut destination = prepared.destination;
if destination.overwrite_confirmation_required() {
if !overwrite_confirmed {
return Err(OperationError {
message: "Overwrite confirmation was not supplied.".to_owned(),
});
}
destination = destination.confirm_overwrite().map_err(operation_error)?;
}
let delivery = app
.reissue_client_file(identity, password.expose(), destination, include_qr)
.map_err(|error| OperationError {
message: error.to_string(),
})?;
let (qr, qr_output) = match delivery.qr {
None => (QrStatus::NotRequested, None),
Some(result) => qr_result(result),
};
Ok(CommandResult::Reissued(ReissueSummary {
activation_warning: delivery.activation_warning,
qr,
qr_output,
}))
}
}
}
fn execute_sensitive_at(
&mut self,
app: &mut Application,
command: SensitiveCommand,
generation: u64,
) -> Result<CommandResult, OperationError> {
match &command {
SensitiveCommand::ReissueClient {
prepared_generation,
..
} if *prepared_generation != generation => {
return Err(OperationError {
message: "Prepared output destination is stale; select it again.".to_owned(),
});
}
_ => {}
}
if matches!(command, SensitiveCommand::PrepareInitialSetup(_)) {
self.prepared_destination = None;
self.restore_preview = None;
}
self.execute_sensitive(app, command)
}
}
fn operation_error(error: impl std::fmt::Display) -> OperationError {
OperationError {
message: error.to_string(),
}
}
fn destination_generation_matches(stored: u64, requested: u64, current: u64) -> bool {
stored == requested && stored == current
}
fn restore_preview_generation_matches(stored: u64, requested: u64, current: u64) -> bool {
stored == requested && current == requested.saturating_add(1)
}
fn qr_result(result: Result<QrCodeOutput, QrError>) -> (QrStatus, Option<QrCodeOutput>) {
match result {
Ok(output) => (QrStatus::Ready, Some(output)),
Err(error) => (QrStatus::Failed(error), None),
}
}
mod executor;
pub use executor::ApplicationExecutor;
#[cfg(test)]
use executor::{destination_generation_matches, restore_preview_generation_matches};
pub trait StatisticsReader: Send + 'static {
/// Implementations must return within `timeout`; unavailable runtime data
@@ -1151,6 +762,47 @@ mod tests {
);
}
#[test]
fn stale_delivery_confirmation_consumes_prepared_destination_without_delivery() {
let (root, mut app) = application();
let identity = PublicKey::from_bytes([45; 32]);
let path = root.path().join("prepared.conf");
let mut executor = ApplicationExecutor::default();
assert!(matches!(
executor.execute_at(
&mut app,
UiCommand::PrepareDelivery {
identity,
path,
include_qr: false,
reissue: false,
attempt: FirstIssueAttempt::Initial,
},
7,
),
Ok(CommandResult::DeliveryDestination(_))
));
let stale = UiCommand::DeliverFirstIssue {
attempt: FirstIssueAttempt::Initial,
overwrite_confirmed: false,
prepared_generation: 7,
};
assert_eq!(
executor
.execute_at(&mut app, stale.clone(), 8)
.unwrap_err()
.message,
"Prepared output destination is stale; select it again."
);
assert_eq!(
executor.execute_at(&mut app, stale, 7).unwrap_err().message,
"No prepared output destination is available."
);
assert!(!root.path().join("prepared.conf").exists());
}
struct FakeStatistics {
started: Option<mpsc::SyncSender<()>>,
release: Option<Receiver<()>>,
+404
View File
@@ -0,0 +1,404 @@
use super::{
CommandResult, DeliveryDestinationSummary, FirstIssueAttempt, FirstIssueStatus,
FirstIssueSummary, OperationError, OperationExecutor, QrStatus, ReissueSummary,
RestartPreviewSummary, RestorePreviewSummary, SensitiveCommand, UiCommand,
};
use crate::app::{Application, FirstIssueDeliveryOutcome, RestorePreview};
use crate::delivery::files::PreparedDestination;
use crate::delivery::{QrCodeOutput, QrError};
use crate::domain::PublicKey;
enum DeliveryMode {
FirstIssue,
Reissue,
}
struct PreparedDestinationContext {
identity: PublicKey,
include_qr: bool,
mode: DeliveryMode,
attempt: FirstIssueAttempt,
generation: u64,
destination: PreparedDestination,
}
struct RestorePreviewContext {
_archive_name: String,
generation: u64,
preview: RestorePreview,
}
#[derive(Default)]
pub struct ApplicationExecutor {
prepared_destination: Option<PreparedDestinationContext>,
restore_preview: Option<RestorePreviewContext>,
}
impl OperationExecutor for ApplicationExecutor {
fn execute(
&mut self,
app: &mut Application,
command: UiCommand,
) -> Result<CommandResult, OperationError> {
self.execute_at(app, command, 0)
}
fn execute_at(
&mut self,
app: &mut Application,
command: UiCommand,
generation: u64,
) -> Result<CommandResult, OperationError> {
let delivery_continuation = matches!(command, UiCommand::DeliverFirstIssue { .. });
let restore_continuation = matches!(
command,
UiCommand::ConfirmRestore { .. } | UiCommand::CancelRestorePreview
);
if !delivery_continuation && !matches!(&command, UiCommand::CancelPreparedDelivery) {
self.prepared_destination = None;
}
if !restore_continuation && !matches!(&command, UiCommand::PreviewRestore { .. }) {
self.restore_preview = None;
}
let result = match command {
UiCommand::CreateClient {
alias,
manual_addresses,
created_utc,
} => app
.prepare_new_client(&alias, manual_addresses, created_utc.as_deref())
.map(CommandResult::ClientCreated),
UiCommand::RenameClient { identity, alias } => app
.rename_client(identity, &alias)
.map(|()| CommandResult::ClientRenamed),
UiCommand::DeleteClient { identity } => app
.delete_client(identity)
.map(|record_removed| CommandResult::ClientDeleted { record_removed }),
UiCommand::DiscardDraft => app.discard_draft().map(|()| CommandResult::DraftDiscarded),
UiCommand::Commit => app.commit().map(CommandResult::Committed),
UiCommand::CreateBackup => app.create_manual_backup().map(CommandResult::BackupCreated),
UiCommand::ListBackups => app.list_backups().map(CommandResult::Backups),
UiCommand::ImportSelectedFile { file_name } => app
.import_selected_file(&file_name)
.map(CommandResult::SelectedFileImported),
UiCommand::PreviewRestart => app.preview_restart().map(|preview| {
CommandResult::RestartPreview(RestartPreviewSummary {
has_draft: preview.has_draft(),
interface: preview.interface().to_owned(),
systemd_unit: preview.systemd_unit().to_owned(),
})
}),
UiCommand::Restart(choice) => {
let result = app
.preview_restart()
.map(|preview| app.restart(preview, choice));
result.map(CommandResult::Restarted)
}
UiCommand::PrepareDelivery {
identity,
path,
include_qr,
reissue,
attempt,
} => app
.prepare_first_issue_destination(path)
.map_err(crate::app::AppError::FileOutput)
.map(|destination| {
let summary = DeliveryDestinationSummary {
identity,
path: destination.path().to_path_buf(),
overwrite_required: destination.overwrite_confirmation_required(),
include_qr,
reissue,
attempt,
prepared_generation: generation,
};
self.prepared_destination = Some(PreparedDestinationContext {
identity,
include_qr,
mode: if reissue {
DeliveryMode::Reissue
} else {
DeliveryMode::FirstIssue
},
attempt,
generation,
destination,
});
CommandResult::DeliveryDestination(summary)
}),
UiCommand::DeliverFirstIssue {
attempt,
overwrite_confirmed,
prepared_generation,
} => {
let prepared = self
.prepared_destination
.take()
.ok_or_else(|| OperationError {
message: "No prepared output destination is available.".to_owned(),
})?;
if !matches!(prepared.mode, DeliveryMode::FirstIssue)
|| prepared.attempt != attempt
|| !destination_generation_matches(
prepared.generation,
prepared_generation,
generation,
)
{
return Err(OperationError {
message: "Prepared output destination is stale; select it again."
.to_owned(),
});
}
let identity = prepared.identity;
let include_qr = prepared.include_qr;
let mut destination = prepared.destination;
if destination.overwrite_confirmation_required() {
if !overwrite_confirmed {
return Err(OperationError {
message: "Overwrite confirmation was not supplied.".to_owned(),
});
}
destination = destination.confirm_overwrite().map_err(operation_error)?;
}
if include_qr {
app.deliver_first_issue_file_and_qr(identity, attempt, destination)
.map(|outcome| match outcome {
FirstIssueDeliveryOutcome::Delivered(delivery) => {
let (qr, qr_output) = qr_result(delivery.qr);
CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::Delivered,
retry: None,
activation_warning: delivery.activation_warning,
qr,
qr_output,
})
}
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
value,
..
} => {
let (qr, qr_output) = qr_result(value.qr);
CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::PublishedButBookkeepingUncertain,
retry: None,
activation_warning: value.activation_warning,
qr,
qr_output,
})
}
FirstIssueDeliveryOutcome::Failed {
error,
retry,
activation_warning,
} => CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::Failed(error),
retry: Some(retry),
activation_warning,
qr: QrStatus::NotRequested,
qr_output: None,
}),
})
} else {
app.deliver_first_issue_file(identity, attempt, destination)
.map(|outcome| match outcome {
FirstIssueDeliveryOutcome::Delivered(delivery) => {
CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::Delivered,
retry: None,
activation_warning: delivery.activation_warning,
qr: QrStatus::NotRequested,
qr_output: None,
})
}
FirstIssueDeliveryOutcome::PublishedButBookkeepingUncertain {
value,
..
} => CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::PublishedButBookkeepingUncertain,
retry: None,
activation_warning: value.activation_warning,
qr: QrStatus::NotRequested,
qr_output: None,
}),
FirstIssueDeliveryOutcome::Failed {
error,
retry,
activation_warning,
} => CommandResult::FirstIssue(FirstIssueSummary {
identity,
status: FirstIssueStatus::Failed(error),
retry: Some(retry),
activation_warning,
qr: QrStatus::NotRequested,
qr_output: None,
}),
})
}
}
UiCommand::PreviewRestore { file_name } => {
app.preview_restore(&file_name).map(|preview| {
let changes = preview.change_summary(app.saved_bytes());
let summary = RestorePreviewSummary {
file_name: file_name.clone(),
created_at_utc: preview.created_at_utc().to_owned(),
reason: preview.reason(),
historical_apply: preview.historical_apply().clone(),
changes,
preview_generation: generation,
};
self.restore_preview = Some(RestorePreviewContext {
_archive_name: file_name,
generation,
preview,
});
CommandResult::RestorePreview(summary)
})
}
UiCommand::CancelPreparedDelivery => {
self.prepared_destination = None;
Ok(CommandResult::Cancelled)
}
UiCommand::CancelRestorePreview => {
self.restore_preview = None;
Ok(CommandResult::Cancelled)
}
UiCommand::ConfirmRestore { preview_generation } => {
let context = self.restore_preview.take().ok_or_else(|| OperationError {
message: "Restore preview is no longer available; select the archive again."
.to_owned(),
})?;
if !restore_preview_generation_matches(
context.generation,
preview_generation,
generation,
) {
return Err(OperationError {
message: "Restore preview is stale; select the archive again.".to_owned(),
});
}
app.confirm_restore(context.preview)
.map(CommandResult::Restored)
}
};
result.map_err(|error| OperationError {
message: error.to_string(),
})
}
fn execute_sensitive(
&mut self,
app: &mut Application,
command: SensitiveCommand,
) -> Result<CommandResult, OperationError> {
match command {
SensitiveCommand::PrepareInitialSetup(password) => app
.prepare_initial_setup_from_sources(password.expose())
.map(CommandResult::InitialSetupPrepared)
.map_err(|error| OperationError {
message: error.to_string(),
}),
SensitiveCommand::ReissueClient {
identity,
password,
overwrite_confirmed,
prepared_generation,
} => {
let prepared = self
.prepared_destination
.take()
.ok_or_else(|| OperationError {
message: "No prepared output destination is available.".to_owned(),
})?;
if prepared.identity != identity
|| !matches!(prepared.mode, DeliveryMode::Reissue)
|| prepared.generation != prepared_generation
{
return Err(OperationError {
message: "Prepared output destination is stale; select it again."
.to_owned(),
});
}
let include_qr = prepared.include_qr;
let mut destination = prepared.destination;
if destination.overwrite_confirmation_required() {
if !overwrite_confirmed {
return Err(OperationError {
message: "Overwrite confirmation was not supplied.".to_owned(),
});
}
destination = destination.confirm_overwrite().map_err(operation_error)?;
}
let delivery = app
.reissue_client_file(identity, password.expose(), destination, include_qr)
.map_err(|error| OperationError {
message: error.to_string(),
})?;
let (qr, qr_output) = match delivery.qr {
None => (QrStatus::NotRequested, None),
Some(result) => qr_result(result),
};
Ok(CommandResult::Reissued(ReissueSummary {
activation_warning: delivery.activation_warning,
qr,
qr_output,
}))
}
}
}
fn execute_sensitive_at(
&mut self,
app: &mut Application,
command: SensitiveCommand,
generation: u64,
) -> Result<CommandResult, OperationError> {
match &command {
SensitiveCommand::ReissueClient {
prepared_generation,
..
} if *prepared_generation != generation => {
return Err(OperationError {
message: "Prepared output destination is stale; select it again.".to_owned(),
});
}
_ => {}
}
if matches!(command, SensitiveCommand::PrepareInitialSetup(_)) {
self.prepared_destination = None;
self.restore_preview = None;
}
self.execute_sensitive(app, command)
}
}
fn operation_error(error: impl std::fmt::Display) -> OperationError {
OperationError {
message: error.to_string(),
}
}
pub(super) fn destination_generation_matches(stored: u64, requested: u64, current: u64) -> bool {
stored == requested && stored == current
}
pub(super) fn restore_preview_generation_matches(
stored: u64,
requested: u64,
current: u64,
) -> bool {
stored == requested && current == requested.saturating_add(1)
}
fn qr_result(result: Result<QrCodeOutput, QrError>) -> (QrStatus, Option<QrCodeOutput>) {
match result {
Ok(output) => (QrStatus::Ready, Some(output)),
Err(error) => (QrStatus::Failed(error), None),
}
}
+504 -148
View File
@@ -208,7 +208,9 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
add("p", "preview", HintKind::Action);
add("c", "Commit", HintKind::Action);
add("x", "discard", HintKind::Destructive);
add("R", "restart", HintKind::Destructive);
if restart_available(state) {
add("R", "restart", HintKind::Destructive);
}
add("b", "backups / restore", HintKind::Action);
add("B", "backup", HintKind::Action);
add("!", "issues", HintKind::Action);
@@ -219,22 +221,21 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
{
add("s", "setup", HintKind::Action);
}
if state.selected_client_mutable() {
if state.selected_rename_available() {
add("r", "rename", HintKind::Action);
}
if state.selected_delete_available() {
add("d", "delete", HintKind::Destructive);
let saved = state
.selected_identity
.and_then(|id| state.view.as_ref()?.client_states.get(&id))
.is_some_and(|row| row.saved.is_some());
if saved {
add("o", "first file", HintKind::Action);
add("O", "password reissue", HintKind::Action);
}
}
if state.selected_first_delivery_available() {
add("o", "first file", HintKind::Action);
}
if state.selected_reissue_available() {
add("O", "password reissue", HintKind::Action);
}
}
Screen::ClientList => {
let has_view = state.view.is_some();
let selected = state.selected_client_mutable();
add("↑/↓", "select", HintKind::Navigation);
if state
.view
@@ -246,23 +247,22 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
add("?", "Help", HintKind::Navigation);
if has_view {
add("n", "new", HintKind::Action);
if selected {
if state.selected_rename_available() {
add("r", "rename", HintKind::Action);
}
if state.selected_delete_available() {
add("d", "delete", HintKind::Destructive);
}
add("p", "preview", HintKind::Action);
add("c", "Commit", HintKind::Action);
add("x", "discard", HintKind::Destructive);
add("R", "restart", HintKind::Destructive);
if let Some(id) = state.selected_identity
&& state
.view
.as_ref()
.and_then(|view| view.client_states.get(&id))
.is_some_and(|row| row.saved.is_some())
&& selected
{
if restart_available(state) {
add("R", "restart", HintKind::Destructive);
}
if state.selected_first_delivery_available() {
add("o", "first file", HintKind::Action);
}
if state.selected_reissue_available() {
add("O", "password reissue", HintKind::Action);
}
add("b", "backups / restore", HintKind::Action);
@@ -286,6 +286,14 @@ fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
hints
}
fn restart_available(state: &UiState) -> bool {
state
.view
.as_ref()
.and_then(SessionView::restart_eligibility)
.is_none_or(|eligibility| eligibility.available)
}
fn pack_hints(hints: &[Hint], width: usize) -> Vec<Vec<Hint>> {
let mut rows = Vec::<Vec<Hint>>::new();
let mut used = 0;
@@ -649,10 +657,7 @@ fn qr_state_summary(state: &UiState, width: usize) -> Option<String> {
},
view.saved_peer_count
.map_or_else(|| "unknown".to_owned(), |count| count.to_string()),
view.client_states
.values()
.filter(|row| row.saved.is_some())
.count(),
view.saved_managed_client_count(),
match view.applied {
crate::domain::ApplyStatus::Verified => "verified",
crate::domain::ApplyStatus::Unknown => "not verified",
@@ -827,37 +832,83 @@ fn draft_review_lines(state: &UiState) -> Vec<String> {
if view.has_draft { "yes" } else { "no" }
),
];
for (identity, row) in &view.client_states {
match (&row.saved, &row.draft) {
(None, Some(draft)) => lines.push(format!(
"Added {identity}: {} (IPv4 {}, IPv6 {})",
safe_text(&draft.alias),
draft.addresses.ipv4,
draft
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
)),
(Some(saved), None) => lines.push(format!(
"Removed {identity}: {} (saved removal remains unapplied until restart)",
safe_text(&saved.alias)
)),
(Some(saved), Some(draft)) if saved != draft => lines.push(format!(
"Changed {identity}: alias {} → {}; addresses {} / {} → {} / {}",
safe_text(&saved.alias),
safe_text(&draft.alias),
saved.addresses.ipv4,
saved
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string()),
draft.addresses.ipv4,
draft
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
)),
_ => {}
if let Some(application) = &view.application {
for peer in &application.clients {
let identity = peer.identity;
match (
peer.state_change,
peer.saved_client.as_ref(),
peer.draft_client.as_ref(),
) {
(crate::app::ClientStateChange::Added, _, Some(draft)) => lines.push(format!(
"Added {}: {} (IPv4 {}, IPv6 {})",
identity.map_or_else(|| "unknown key".to_owned(), |key| key.to_string()),
safe_text(&draft.alias),
draft.addresses.ipv4,
draft
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
)),
(crate::app::ClientStateChange::Removed, _, _) => lines.push(format!(
"Removed {}: {} (saved removal remains unapplied until restart)",
identity.map_or_else(|| "unknown key".to_owned(), |key| key.to_string()),
safe_text(&peer.label)
)),
(crate::app::ClientStateChange::Changed, Some(saved), Some(draft)) => {
lines.push(format!(
"Changed {}: alias {} → {}; addresses {} / {} → {} / {}",
identity.map_or_else(|| "unknown key".to_owned(), |key| key.to_string()),
safe_text(&saved.alias),
safe_text(&draft.alias),
saved.addresses.ipv4,
saved
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string()),
draft.addresses.ipv4,
draft
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
))
}
_ => {}
}
}
} else {
for (identity, row) in &view.client_states {
match (&row.saved, &row.draft) {
(None, Some(draft)) => lines.push(format!(
"Added {identity}: {} (IPv4 {}, IPv6 {})",
safe_text(&draft.alias),
draft.addresses.ipv4,
draft
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
)),
(Some(saved), None) => lines.push(format!(
"Removed {identity}: {} (saved removal remains unapplied until restart)",
safe_text(&saved.alias)
)),
(Some(saved), Some(draft)) if saved != draft => lines.push(format!(
"Changed {identity}: alias {} → {}; addresses {} / {} → {} / {}",
safe_text(&saved.alias),
safe_text(&draft.alias),
saved.addresses.ipv4,
saved
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string()),
draft.addresses.ipv4,
draft
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
)),
_ => {}
}
}
}
if lines.len() == 2 && view.has_draft {
@@ -884,25 +935,38 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta
.clients
.iter()
.map(|client| {
let visible_issues: Vec<_> = client.presented_issues().collect();
let visible_issues = view.presented_issues(client);
let identity = client
.identity
.map(|key| short_key(&key))
.unwrap_or_else(|| format!("peer at line {}", client.line));
let (ipv4, ipv6, route_marker) = client_address_cells(client);
let row_state = client
.identity
.and_then(|identity| view.client_states.get(&identity))
.map_or_else(
|| "Saved ?".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(),
(Some(_), None) => "Draft removed".to_owned(),
(Some(_), Some(_)) => "Saved".to_owned(),
(None, None) => "State unavailable".to_owned(),
},
);
let row_state = view.client_state_change(client).map_or_else(
|| {
client
.identity
.and_then(|identity| view.client_states.get(&identity))
.map_or_else(
|| "Saved ?".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()
}
(Some(_), None) => "Draft removed".to_owned(),
(Some(_), Some(_)) => "Saved".to_owned(),
(None, None) => "State unavailable".to_owned(),
},
)
},
|change| match change {
crate::app::ClientStateChange::Added => "Draft added".to_owned(),
crate::app::ClientStateChange::Removed => "Draft removed".to_owned(),
crate::app::ClientStateChange::Changed => "Draft changed".to_owned(),
crate::app::ClientStateChange::Unchanged => "Saved".to_owned(),
crate::app::ClientStateChange::UnsupportedOrUnmanaged => "Saved ?".to_owned(),
},
);
let row_state = if layout.compact {
compact_state_label(&row_state).to_owned()
} else {
@@ -935,46 +999,94 @@ fn render_list(frame: &mut Frame<'_>, area: ratatui::layout::Rect, state: &UiSta
)
})
.collect();
rows.extend(view.client_states.iter().filter_map(|(identity, row)| {
let saved = row.saved.as_ref()?;
if row.draft.is_some()
|| view
.clients
.iter()
.any(|peer| peer.identity == Some(*identity))
{
return None;
}
let ipv6 = saved
.addresses
.ipv6
.map_or_else(|| "none".to_owned(), |address| address.to_string());
Some(client_table_row(
&layout.columns,
[
if layout.compact {
"Removed".to_owned()
} else {
"Draft removed".to_owned()
},
safe_text(&saved.alias),
identity.to_string(),
saved.addresses.ipv4.to_string(),
ipv6,
String::new(),
"Managed".to_owned(),
address_summary(
&saved.addresses.ipv4.to_string(),
&saved
let removed_clients = if let Some(application) = &view.application {
application
.clients
.iter()
.filter_map(|peer| {
if peer.state_change != crate::app::ClientStateChange::Removed {
return None;
}
let identity = peer.identity?;
let (ipv4, ipv6) = peer.saved_client.as_ref().map_or_else(
|| removed_peer_addresses(&peer.allowed_ips),
|saved| {
(
saved.addresses.ipv4.to_string(),
saved
.addresses
.ipv6
.map_or_else(String::new, |ip| ip.to_string()),
)
},
);
Some((
identity,
peer.label.clone(),
ipv4,
ipv6,
if peer.saved_client.is_some() {
"Managed"
} else {
"Inspect only"
},
))
})
.collect::<Vec<_>>()
} else {
view.client_states
.iter()
.filter_map(|(identity, row)| {
let saved = row.saved.as_ref()?;
if row.draft.is_some()
|| view
.clients
.iter()
.any(|peer| peer.identity == Some(*identity))
{
return None;
}
Some((
*identity,
saved.alias.clone(),
saved.addresses.ipv4.to_string(),
saved
.addresses
.ipv6
.map_or_else(String::new, |address| address.to_string()),
"",
),
],
false,
))
}));
.map_or_else(String::new, |ip| ip.to_string()),
"Managed",
))
})
.collect::<Vec<_>>()
};
rows.extend(
removed_clients
.iter()
.map(|(identity, label, ipv4, ipv6, access)| {
client_table_row(
&layout.columns,
[
if layout.compact {
"Removed".to_owned()
} else {
"Draft removed".to_owned()
},
safe_text(label),
identity.to_string(),
ipv4.clone(),
if ipv6.is_empty() {
"none".to_owned()
} else {
ipv6.clone()
},
String::new(),
(*access).to_owned(),
address_summary(ipv4, ipv6, ""),
],
false,
)
}),
);
rows.extend(view.document_issues.iter().map(|issue| {
client_table_row(
&layout.columns,
@@ -1110,23 +1222,60 @@ fn render_client_screen(frame: &mut Frame<'_>, area: Rect, state: &UiState) {
fn selected_display(state: &UiState) -> Option<ClientDisplay> {
match state.selected_record()? {
VisibleRecord::Peer(client) => Some(client.clone()),
VisibleRecord::DraftRemoved { identity, client } => {
let mut allowed_ips = vec![format!("{}/32", client.addresses.ipv4)];
if let Some(ipv6) = client.addresses.ipv6 {
allowed_ips.push(format!("{ipv6}/128"));
}
Some(ClientDisplay {
peer_index: usize::MAX,
line: 0,
identity: Some(identity),
label: client.alias.clone(),
allowed_ips,
has_subnet_routes: false,
issues: Vec::new(),
mutable_identity: None,
})
VisibleRecord::DraftRemoved {
identity,
peer: Some(peer),
..
} => Some(ClientDisplay {
peer_index: usize::MAX,
line: peer.line,
identity: Some(identity),
label: peer.label.clone(),
allowed_ips: peer.allowed_ips.clone(),
has_subnet_routes: peer.has_subnet_routes,
issues: peer.issues.clone(),
mutable_identity: None,
}),
VisibleRecord::DraftRemoved {
identity,
client: Some(client),
..
} => Some(ClientDisplay {
peer_index: usize::MAX,
line: 0,
identity: Some(identity),
label: client.alias.clone(),
allowed_ips: client_routes(client),
has_subnet_routes: false,
issues: Vec::new(),
mutable_identity: None,
}),
VisibleRecord::DraftRemoved { .. } => None,
}
}
fn client_routes(client: &crate::domain::Client) -> Vec<String> {
let mut routes = vec![format!("{}/32", client.addresses.ipv4)];
if let Some(ipv6) = client.addresses.ipv6 {
routes.push(format!("{ipv6}/128"));
}
routes
}
fn removed_peer_addresses(allowed_ips: &[String]) -> (String, String) {
let mut ipv4 = String::new();
let mut ipv6 = String::new();
for route in allowed_ips {
let Some((address, _)) = route.split_once('/') else {
continue;
};
match address.parse::<std::net::IpAddr>() {
Ok(std::net::IpAddr::V4(address)) if ipv4.is_empty() => ipv4 = address.to_string(),
Ok(std::net::IpAddr::V6(address)) if ipv6.is_empty() => ipv6 = address.to_string(),
_ => {}
}
}
(ipv4, ipv6)
}
fn persistent_detail_lines(
@@ -1204,11 +1353,7 @@ fn persistent_state_line(view: &SessionView) -> String {
let saved_peers = view
.saved_peer_count
.map_or_else(|| "unknown".to_owned(), |count| count.to_string());
let saved_managed = view
.client_states
.values()
.filter(|row| row.saved.is_some())
.count();
let saved_managed = view.saved_managed_client_count();
let applied = match view.applied {
crate::domain::ApplyStatus::Verified => "verified",
crate::domain::ApplyStatus::Unknown => "not verified",
@@ -1254,7 +1399,7 @@ fn inspection_summary(client: &ClientDisplay, view: &SessionView) -> String {
} else if client
.issues
.iter()
.any(|issue| client.is_capability_limitation_issue(issue))
.any(|issue| view.is_capability_limitation_issue(client, issue))
{
"Inspect: address shape; Enter for details".to_owned()
} else if !client.issues.is_empty() {
@@ -1629,7 +1774,7 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>
let has_capability_limitation = client
.issues
.iter()
.any(|issue| client.is_capability_limitation_issue(issue));
.any(|issue| view.is_capability_limitation_issue(client, issue));
if client.has_subnet_routes {
lines.push(Line::from(
"This peer contains subnet routes. Managing this configuration is not supported yet.",
@@ -1640,18 +1785,26 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>
));
}
if let Some(identity) = client.identity {
if let Some(row) = view.client_states.get(&identity) {
let managed_state = view
.application_record_for_client(client)
.map(|peer| (&peer.draft_client, &peer.saved_client))
.or_else(|| {
view.client_states
.get(&identity)
.map(|row| (&row.draft, &row.saved))
});
if let Some((draft, saved)) = managed_state {
lines.push(Line::from(format!(
"Draft managed record: {}",
safe_text(row.draft.as_ref().map_or("absent", |c| c.alias.as_str()))
safe_text(draft.as_ref().map_or("absent", |c| c.alias.as_str()))
)));
lines.push(Line::from(format!(
"Saved managed record: {}",
safe_text(row.saved.as_ref().map_or("absent", |c| c.alias.as_str()))
safe_text(saved.as_ref().map_or("absent", |c| c.alias.as_str()))
)));
if row.draft != row.saved {
if draft != saved {
lines.push(Line::from("Draft differs from saved state."));
if let Some(client) = &row.draft {
if let Some(client) = draft {
lines.push(Line::from(format!(
"Draft addresses: IPv4 {}, IPv6 {}",
client.addresses.ipv4,
@@ -1661,7 +1814,7 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>
.map_or_else(|| "none".to_owned(), |ip| ip.to_string())
)));
}
if let Some(client) = &row.saved {
if let Some(client) = saved {
lines.push(Line::from(format!(
"Saved addresses: IPv4 {}, IPv6 {}",
client.addresses.ipv4,
@@ -1703,7 +1856,7 @@ fn detail_lines(client: &ClientDisplay, view: &SessionView) -> Vec<Line<'static>
Line::from(issue_summary(view)),
Line::from("Handshake and counters are observations, not apply verification."),
]);
for issue in client.presented_issues() {
for issue in view.presented_issues(client) {
lines.push(Line::from(format!(
"Peer issue: {}",
safe_text(&format!("{issue:?}"))
@@ -1767,10 +1920,7 @@ fn state_summary(view: &SessionView) -> String {
}
fn managed_change_count(view: &SessionView) -> usize {
view.client_states
.values()
.filter(|row| row.draft != row.saved)
.count()
view.managed_change_count()
}
fn draft_status(view: &SessionView) -> String {
@@ -1784,11 +1934,7 @@ fn draft_status(view: &SessionView) -> String {
}
fn saved_status(view: &SessionView) -> String {
let managed = view
.client_states
.values()
.filter(|row| row.saved.is_some())
.count();
let managed = view.saved_managed_client_count();
match view.saved_peer_count {
Some(peers) => format!("Saved: {peers} peer sections; {managed} managed clients"),
None => format!("Saved: peer count unavailable; {managed} managed clients"),
@@ -1814,7 +1960,7 @@ fn issue_summary(view: &SessionView) -> String {
let peer_problems = view
.clients
.iter()
.filter(|peer| peer.presented_issues().next().is_some())
.filter(|peer| !view.presented_issues(peer).is_empty())
.count();
let capability_limits = view
.clients
@@ -1824,7 +1970,7 @@ fn issue_summary(view: &SessionView) -> String {
|| peer
.issues
.iter()
.any(|issue| peer.is_capability_limitation_issue(issue))
.any(|issue| view.is_capability_limitation_issue(peer, issue))
})
.count();
format!(
@@ -2334,9 +2480,145 @@ mod tests {
statistics: RuntimeObservation::unknown(),
has_draft: true,
vault_initialized: false,
application: None,
}
}
#[test]
fn draft_review_shows_removed_unmanaged_authoritative_peer() {
let identity = PublicKey::from_bytes([45; 32]);
let mut view = empty_view();
view.application = Some(crate::app::ApplicationView {
clients: vec![crate::app::PeerRecord {
peer_index: None,
draft_peer_index: None,
saved_peer_index: Some(2),
line: 8,
identity: Some(identity),
label: "legacy route device".to_owned(),
allowed_ips: vec!["10.0.0.45/32".to_owned(), "fd00::45/128".to_owned()],
has_subnet_routes: false,
issues: Vec::new(),
classified_issues: Vec::new(),
mutable_identity: None,
draft_client: None,
saved_client: None,
state_change: crate::app::ClientStateChange::Removed,
actions: crate::app::ClientActions {
rename: crate::app::ActionEligibility {
available: false,
reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft),
},
delete: crate::app::ActionEligibility {
available: false,
reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft),
},
first_delivery: crate::app::FirstDeliveryEligibility::Unavailable,
reissue: crate::app::ActionEligibility {
available: false,
reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft),
},
},
}],
document_issues: Vec::new(),
saved_peer_count: 1,
draft_client_count: 0,
saved_client_count: 0,
reservations: crate::app::ReservationFacts::default(),
applied: crate::domain::ApplyStatus::Unknown,
vault_initialized: false,
has_draft: true,
restart: crate::app::ActionEligibility {
available: true,
reason: None,
},
});
let state = UiState {
view: Some(view),
..UiState::default()
};
let lines = draft_review_lines(&state);
let removed = lines
.iter()
.find(|line| line.starts_with("Removed "))
.expect("removed peer appears in draft review");
assert!(removed.contains(&identity.to_string()));
assert!(removed.contains("legacy route device"));
assert!(removed.contains("unapplied until restart"));
}
#[test]
fn list_shows_removed_unprojectable_peer_as_inspection_only() {
let identity = PublicKey::from_bytes([46; 32]);
let mut view = empty_view();
view.application = Some(crate::app::ApplicationView {
clients: vec![crate::app::PeerRecord {
peer_index: None,
draft_peer_index: None,
saved_peer_index: Some(3),
line: 10,
identity: Some(identity),
label: "legacy route device".to_owned(),
allowed_ips: vec!["10.0.0.46/32".to_owned(), "fd00::46/128".to_owned()],
has_subnet_routes: false,
issues: Vec::new(),
classified_issues: Vec::new(),
mutable_identity: None,
draft_client: None,
saved_client: None,
state_change: crate::app::ClientStateChange::Removed,
actions: crate::app::ClientActions {
rename: crate::app::ActionEligibility {
available: false,
reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft),
},
delete: crate::app::ActionEligibility {
available: false,
reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft),
},
first_delivery: crate::app::FirstDeliveryEligibility::Unavailable,
reissue: crate::app::ActionEligibility {
available: false,
reason: Some(crate::app::ActionUnavailableReason::PeerNotInDraft),
},
},
}],
document_issues: Vec::new(),
saved_peer_count: 1,
draft_client_count: 0,
saved_client_count: 0,
reservations: crate::app::ReservationFacts::default(),
applied: crate::domain::ApplyStatus::Unknown,
vault_initialized: false,
has_draft: true,
restart: crate::app::ActionEligibility {
available: true,
reason: None,
},
});
let state = UiState {
view: Some(view),
..UiState::default()
};
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
terminal
.draw(|frame| draw(frame, &state, &FormController::default()))
.unwrap();
let text = rendered_text(&terminal);
for expected in [
"Draft removed",
"legacy route device",
"10.0.0.46",
"fd00::46",
"Inspect only",
] {
assert!(text.contains(expected), "missing {expected:?}:\n{text}");
}
assert!(text.contains(&identity.to_string()));
}
#[test]
fn help_document_covers_nine_sections_and_distinguishes_case_sensitive_actions() {
let rows = help_document(178);
@@ -2550,6 +2832,7 @@ mod tests {
statistics: RuntimeObservation::unknown(),
has_draft: false,
vault_initialized: true,
application: None,
}
}
@@ -2838,6 +3121,77 @@ mod tests {
);
}
#[test]
fn inspection_row_hints_follow_each_application_action_eligibility() {
let identity = PublicKey::from_bytes([44; 32]);
let mut view = empty_view();
let unavailable = crate::app::ActionEligibility {
available: false,
reason: Some(crate::app::ActionUnavailableReason::UnsafePeerOrDocument),
};
view.clients.push(ClientDisplay {
peer_index: 3,
line: 4,
identity: Some(identity),
label: "legacy peer".to_owned(),
allowed_ips: vec!["10.0.0.2/32".to_owned()],
has_subnet_routes: false,
issues: vec![crate::wireguard::PeerIssue::MissingIpv4HostRoute],
mutable_identity: None,
});
view.application = Some(crate::app::ApplicationView {
clients: vec![crate::app::PeerRecord {
peer_index: Some(3),
draft_peer_index: Some(3),
saved_peer_index: Some(3),
line: 4,
identity: Some(identity),
label: "legacy peer".to_owned(),
allowed_ips: vec!["10.0.0.2/32".to_owned()],
has_subnet_routes: false,
issues: vec![crate::wireguard::PeerIssue::MissingIpv4HostRoute],
classified_issues: vec![crate::app::ClassifiedPeerIssue {
issue: crate::wireguard::PeerIssue::MissingIpv4HostRoute,
class: crate::app::PeerIssueClass::UnsupportedCapability,
}],
mutable_identity: None,
draft_client: None,
saved_client: None,
state_change: crate::app::ClientStateChange::UnsupportedOrUnmanaged,
actions: crate::app::ClientActions {
rename: unavailable.clone(),
delete: crate::app::ActionEligibility {
available: true,
reason: None,
},
first_delivery: crate::app::FirstDeliveryEligibility::Unavailable,
reissue: unavailable.clone(),
},
}],
document_issues: Vec::new(),
saved_peer_count: 1,
draft_client_count: 0,
saved_client_count: 0,
reservations: Default::default(),
applied: crate::domain::ApplyStatus::Unknown,
vault_initialized: true,
has_draft: false,
restart: unavailable,
});
let state = UiState {
selected_identity: Some(identity),
selected_peer_index: Some(3),
view: Some(view),
..UiState::default()
};
let hints = keyboard_hints(&state, &FormController::default());
let available_keys = hints.iter().map(|hint| hint.key).collect::<Vec<_>>();
assert!(available_keys.contains(&"d"));
for unavailable_key in ["r", "o", "O", "R"] {
assert!(!available_keys.contains(&unavailable_key));
}
}
#[test]
fn standard_footer_is_four_rows_and_list_height_is_stable_across_states() {
let identity = PublicKey::from_bytes([4; 32]);
@@ -3937,6 +4291,7 @@ mod tests {
statistics: RuntimeObservation::unknown(),
has_draft: false,
vault_initialized: true,
application: None,
};
let state = UiState {
screen: Screen::ClientDetails,
@@ -4060,6 +4415,7 @@ mod tests {
statistics: RuntimeObservation::unknown(),
has_draft: true,
vault_initialized: true,
application: None,
};
let mut state = UiState {
view: Some(view),
+493 -54
View File
@@ -3,7 +3,10 @@
use std::collections::BTreeSet;
use std::net::IpAddr;
use crate::app::{Application, ClientDisplay};
use crate::app::{
ActionEligibility, Application, ApplicationView, ClientDisplay, FirstDeliveryEligibility,
PeerRecord,
};
use crate::domain::{ApplyStatus, Client, PublicKey};
use crate::runtime::RuntimeObservation;
@@ -56,45 +59,191 @@ pub struct SessionView {
pub statistics: RuntimeObservation,
pub has_draft: bool,
pub vault_initialized: bool,
/// Authoritative application facts used to present action availability.
/// The parallel fields above are the navigation/rendering projection.
pub application: Option<ApplicationView>,
}
impl SessionView {
pub fn from_application(app: &Application, generation: u64) -> Self {
let states = app.states();
let mut identities = BTreeSet::new();
identities.extend(states.saved().clients().map(|client| client.identity));
identities.extend(states.draft().clients().map(|client| client.identity));
let client_states = identities
.into_iter()
.map(|identity| {
(
identity,
ClientStateRow {
saved: states.saved().get(&identity).cloned(),
draft: states.draft().get(&identity).cloned(),
},
)
})
let application = app.read_model();
let mut client_states = std::collections::BTreeMap::new();
for row in &application.clients {
if let Some(identity) = row.identity {
if row.saved_client.is_none() && row.draft_client.is_none() {
continue;
}
let state = client_states
.entry(identity)
.or_insert_with(|| ClientStateRow {
saved: None,
draft: None,
});
if state.saved.is_none() {
state.saved.clone_from(&row.saved_client);
}
if state.draft.is_none() {
state.draft.clone_from(&row.draft_client);
}
}
}
let clients = application
.clients
.iter()
.filter_map(navigation_peer)
.collect();
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,
clients,
client_states,
saved_peer_count,
document_issues: projection.document_issues,
applied: *states.applied(),
reserved_addresses: states.reserved_addresses().clone(),
saved_peer_count: Some(application.saved_peer_count),
document_issues: application.document_issues.clone(),
applied: application.applied,
reserved_addresses: application.reservations.committed.iter().copied().collect(),
statistics: RuntimeObservation::unknown(),
has_draft: app.has_draft_changes(),
vault_initialized: app.vault_initialized(),
has_draft: application.has_draft,
vault_initialized: application.vault_initialized,
application: Some(application),
}
}
fn application_peer(
&self,
identity: PublicKey,
peer_index: Option<usize>,
) -> Option<&PeerRecord> {
let application = self.application.as_ref()?;
let mut matching = application
.clients
.iter()
.filter(|peer| peer.identity == Some(identity));
let peer = matching.next()?;
if matching.next().is_some() || peer.peer_index != peer_index {
return None;
}
Some(peer)
}
pub fn application_record_for_client(&self, client: &ClientDisplay) -> Option<&PeerRecord> {
let identity = client.identity?;
self.application_peer(identity, Some(client.peer_index))
}
pub fn selected_rename_eligibility(&self, identity: PublicKey, peer_index: usize) -> bool {
self.application_peer(identity, Some(peer_index))
.is_some_and(|peer| peer.actions.rename.available)
}
pub fn selected_delete_eligibility(&self, identity: PublicKey, peer_index: usize) -> bool {
self.application_peer(identity, Some(peer_index))
.is_some_and(|peer| peer.actions.delete.available)
}
pub fn selected_first_delivery_eligibility(
&self,
identity: PublicKey,
peer_index: usize,
) -> bool {
self.application_peer(identity, Some(peer_index))
.is_some_and(|peer| {
peer.actions.first_delivery != FirstDeliveryEligibility::Unavailable
})
}
pub fn selected_reissue_eligibility(&self, identity: PublicKey, peer_index: usize) -> bool {
self.application_peer(identity, Some(peer_index))
.is_some_and(|peer| peer.actions.reissue.available)
}
pub fn restart_eligibility(&self) -> Option<&ActionEligibility> {
self.application.as_ref().map(|view| &view.restart)
}
pub fn is_capability_limitation_issue(
&self,
client: &ClientDisplay,
issue: &crate::wireguard::PeerIssue,
) -> bool {
self.application
.as_ref()
.and_then(|application| {
application.clients.iter().find(|peer| {
peer.peer_index == Some(client.peer_index) && peer.identity == client.identity
})
})
.and_then(|peer| {
peer.classified_issues
.iter()
.find(|classified| &classified.issue == issue)
})
.map_or_else(
|| client.is_capability_limitation_issue(issue),
|classified| classified.class == crate::app::PeerIssueClass::UnsupportedCapability,
)
}
pub fn presented_issues<'a>(
&'a self,
client: &'a ClientDisplay,
) -> Vec<&'a crate::wireguard::PeerIssue> {
client
.issues
.iter()
.filter(|issue| !self.is_capability_limitation_issue(client, issue))
.collect()
}
pub fn client_state_change(
&self,
client: &ClientDisplay,
) -> Option<crate::app::ClientStateChange> {
self.application
.as_ref()?
.clients
.iter()
.find(|peer| {
peer.peer_index == Some(client.peer_index) && peer.identity == client.identity
})
.map(|peer| peer.state_change)
}
pub fn saved_managed_client_count(&self) -> usize {
self.application.as_ref().map_or_else(
|| {
self.client_states
.values()
.filter(|row| row.saved.is_some())
.count()
},
|application| application.saved_client_count,
)
}
pub fn managed_change_count(&self) -> usize {
self.application.as_ref().map_or_else(
|| {
self.client_states
.values()
.filter(|row| row.draft != row.saved)
.count()
},
|application| {
application
.clients
.iter()
.filter(|peer| {
matches!(
peer.state_change,
crate::app::ClientStateChange::Added
| crate::app::ClientStateChange::Removed
| crate::app::ClientStateChange::Changed
)
})
.count()
},
)
}
pub fn contains_identity(&self, identity: &PublicKey) -> bool {
self.clients
.iter()
@@ -109,31 +258,60 @@ impl SessionView {
.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,
})
}));
if let Some(application) = &self.application {
records.extend(application.clients.iter().filter_map(|peer| {
if peer.state_change != crate::app::ClientStateChange::Removed {
return None;
}
Some(VisibleRecord::DraftRemoved {
identity: peer.identity?,
peer: Some(peer),
client: peer.saved_client.as_ref(),
})
}));
} else {
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,
peer: None,
client: Some(client),
})
}));
}
records
}
}
fn navigation_peer(peer: &PeerRecord) -> Option<ClientDisplay> {
let peer_index = peer.peer_index?;
Some(ClientDisplay {
peer_index,
line: peer.line,
identity: peer.identity,
label: peer.label.clone(),
allowed_ips: peer.allowed_ips.clone(),
has_subnet_routes: peer.has_subnet_routes,
issues: peer.issues.clone(),
mutable_identity: peer.mutable_identity,
})
}
#[derive(Clone, Copy, Debug)]
pub enum VisibleRecord<'a> {
Peer(&'a ClientDisplay),
DraftRemoved {
identity: PublicKey,
client: &'a Client,
peer: Option<&'a PeerRecord>,
client: Option<&'a Client>,
},
}
@@ -248,13 +426,8 @@ impl UiState {
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))
let removed = view.visible_records().iter().any(|record| {
matches!(record, VisibleRecord::DraftRemoved { identity: removed, .. } if *removed == identity)
});
if removed {
self.selected_peer_index = None;
@@ -277,9 +450,9 @@ impl UiState {
}
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())
view.visible_records().iter().any(|record| {
matches!(record, VisibleRecord::DraftRemoved { identity: removed, .. } if *removed == identity)
})
}) {
return;
}
@@ -477,11 +650,75 @@ impl UiState {
&& client.mutable_identity == Some(identity)
})
}
pub fn selected_rename_available(&self) -> bool {
self.view.as_ref().is_some_and(|view| {
view.application.as_ref().map_or_else(
|| self.selected_client_mutable(),
|_| self.selected_peer_action(|peer| peer.actions.rename.available),
)
})
}
pub fn selected_delete_available(&self) -> bool {
self.view.as_ref().is_some_and(|view| {
view.application.as_ref().map_or_else(
|| self.selected_client_mutable(),
|_| self.selected_peer_action(|peer| peer.actions.delete.available),
)
})
}
pub fn selected_first_delivery_available(&self) -> bool {
self.view.as_ref().is_some_and(|view| {
view.application.as_ref().map_or_else(
|| self.selected_client_is_saved(),
|_| {
self.selected_peer_action(|peer| {
peer.actions.first_delivery != FirstDeliveryEligibility::Unavailable
})
},
)
})
}
pub fn selected_reissue_available(&self) -> bool {
self.view.as_ref().is_some_and(|view| {
view.application.as_ref().map_or_else(
|| self.selected_client_is_saved(),
|_| self.selected_peer_action(|peer| peer.actions.reissue.available),
)
})
}
fn selected_client_is_saved(&self) -> bool {
self.selected_identity.is_some_and(|identity| {
self.view
.as_ref()
.and_then(|view| view.client_states.get(&identity))
.is_some_and(|row| row.saved.is_some())
})
}
fn selected_peer_action(&self, action: impl FnOnce(&PeerRecord) -> bool) -> bool {
let (Some(identity), Some(peer_index)) = (self.selected_identity, self.selected_peer_index)
else {
return false;
};
self.view
.as_ref()
.and_then(|view| view.application_peer(identity, Some(peer_index)))
.is_some_and(action)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{
ActionEligibility, ActionUnavailableReason, ClientActions, ClientStateChange,
FirstDeliveryEligibility, PeerIssueClass, ReservationFacts,
};
use crate::domain::PublicKey;
use crate::wireguard::PeerIssue;
@@ -514,6 +751,72 @@ mod tests {
statistics: RuntimeObservation::unknown(),
has_draft: false,
vault_initialized: true,
application: None,
}
}
fn application_peer(
identity: PublicKey,
peer_index: usize,
actions: ClientActions,
) -> PeerRecord {
PeerRecord {
peer_index: Some(peer_index),
draft_peer_index: Some(peer_index),
saved_peer_index: Some(peer_index),
line: peer_index + 1,
identity: Some(identity),
label: "inspect-only peer".to_owned(),
allowed_ips: vec!["10.0.0.2/32".to_owned()],
has_subnet_routes: false,
issues: vec![PeerIssue::MissingIpv4HostRoute],
classified_issues: vec![crate::app::ClassifiedPeerIssue {
issue: PeerIssue::MissingIpv4HostRoute,
class: PeerIssueClass::MalformedOrAmbiguous,
}],
mutable_identity: None,
draft_client: None,
saved_client: None,
state_change: ClientStateChange::UnsupportedOrUnmanaged,
actions,
}
}
fn available_actions(delete: bool) -> ClientActions {
let unavailable = ActionEligibility {
available: false,
reason: Some(ActionUnavailableReason::UnsafePeerOrDocument),
};
ClientActions {
rename: unavailable.clone(),
delete: if delete {
ActionEligibility {
available: true,
reason: None,
}
} else {
unavailable.clone()
},
first_delivery: FirstDeliveryEligibility::Unavailable,
reissue: unavailable,
}
}
fn application_view(records: Vec<PeerRecord>) -> ApplicationView {
ApplicationView {
clients: records,
document_issues: Vec::new(),
saved_peer_count: 1,
draft_client_count: 0,
saved_client_count: 0,
reservations: ReservationFacts::default(),
applied: ApplyStatus::Unknown,
vault_initialized: false,
has_draft: false,
restart: ActionEligibility {
available: true,
reason: None,
},
}
}
@@ -598,6 +901,72 @@ mod tests {
assert!(!state.selected_client_mutable());
}
#[test]
fn action_eligibility_is_per_action_and_bound_to_the_selected_peer_position() {
let identity = PublicKey::from_bytes([40; 32]);
let mut peer = application_peer(identity, 6, available_actions(true));
let mut session = view(
0,
vec![ClientDisplay {
peer_index: 6,
line: 7,
identity: Some(identity),
label: peer.label.clone(),
allowed_ips: peer.allowed_ips.clone(),
has_subnet_routes: false,
issues: peer.issues.clone(),
mutable_identity: None,
}],
);
session.application = Some(application_view(vec![peer.clone()]));
let mut state = UiState {
view: Some(session),
..UiState::default()
};
assert!(state.select_peer_index(6));
assert!(!state.selected_client_mutable());
assert!(!state.selected_rename_available());
assert!(state.selected_delete_available());
assert!(!state.selected_first_delivery_available());
assert!(!state.selected_reissue_available());
state.selected_peer_index = Some(7);
assert!(!state.selected_delete_available());
peer.peer_index = Some(7);
if let Some(application) = state
.view
.as_mut()
.and_then(|view| view.application.as_mut())
{
application.clients.push(peer);
}
state.selected_peer_index = Some(6);
assert!(!state.selected_delete_available());
}
#[test]
fn malformed_peer_inspection_never_inherits_keyed_action_eligibility() {
let identity = PublicKey::from_bytes([41; 32]);
let keyed = application_peer(identity, 2, available_actions(true));
let mut malformed = keyed.clone();
malformed.peer_index = Some(9);
malformed.draft_peer_index = Some(9);
malformed.identity = None;
let mut inspection = row(identity, "peer", false);
inspection.peer_index = 9;
inspection.identity = None;
let mut state_view = view(0, vec![inspection]);
state_view.application = Some(application_view(vec![keyed.clone(), malformed]));
let mut state = UiState {
view: Some(state_view),
..UiState::default()
};
assert!(state.select_peer_index(9));
assert_eq!(state.selected_identity, None);
assert!(!state.selected_delete_available());
}
#[test]
fn removed_saved_client_is_visible_and_never_mutable() {
let key = PublicKey::from_bytes([13; 32]);
@@ -635,6 +1004,76 @@ mod tests {
assert_eq!(state.view.as_ref().unwrap().visible_records().len(), 1);
}
#[test]
fn read_model_removed_rows_remain_visible_without_state_map_reconstruction() {
let identity = PublicKey::from_bytes([42; 32]);
let removed = Client::new(
identity,
"removed by draft",
crate::domain::ClientAddresses {
ipv4: "10.0.0.42".parse().unwrap(),
ipv6: None,
},
)
.unwrap();
let mut peer = application_peer(identity, 0, available_actions(false));
peer.peer_index = None;
peer.draft_peer_index = None;
peer.saved_client = Some(removed.clone());
peer.state_change = ClientStateChange::Removed;
let mut session = view(0, Vec::new());
session.application = Some(application_view(vec![peer]));
let mut state = UiState::default();
assert!(state.apply_view(session));
assert_eq!(state.selected_identity, Some(identity));
assert!(matches!(
state.selected_record(),
Some(VisibleRecord::DraftRemoved {
identity: selected,
client: Some(client),
..
}) if selected == identity && client == &removed
));
assert!(state.view.as_ref().unwrap().client_states.is_empty());
assert!(!state.selected_delete_available());
}
#[test]
fn removed_unprojectable_authoritative_peer_stays_selectable_without_actions() {
let identity = PublicKey::from_bytes([43; 32]);
let mut peer = application_peer(identity, 0, available_actions(true));
peer.peer_index = None;
peer.draft_peer_index = None;
peer.saved_peer_index = Some(4);
peer.label = "legacy route peer".to_owned();
peer.allowed_ips = vec!["10.0.0.43/32".to_owned()];
peer.saved_client = None;
peer.state_change = ClientStateChange::Removed;
let mut session = view(0, Vec::new());
session.application = Some(application_view(vec![peer]));
let mut state = UiState::default();
assert!(state.apply_view(session));
assert_eq!(state.selected_identity, Some(identity));
assert_eq!(state.selected_peer_index, None);
assert!(matches!(
state.selected_record(),
Some(VisibleRecord::DraftRemoved {
identity: selected,
peer: Some(record),
client: None,
}) if selected == identity && record.label == "legacy route peer"
));
assert!(!state.selected_client_mutable());
assert!(!state.selected_rename_available());
assert!(!state.selected_delete_available());
assert!(!state.selected_first_delivery_available());
assert!(!state.selected_reissue_available());
assert!(state.open_selected_record());
assert_eq!(state.screen, Screen::ClientDetails);
}
#[test]
fn cancelling_a_dialog_does_not_claim_submitted_work_was_cancelled() {
let mut state = UiState {
+24 -11
View File
@@ -771,7 +771,7 @@ fn handle_key(
KeyCode::Char('n') if state.view.is_some() => forms.open_create(),
KeyCode::Char('i') => forms.open_import(),
KeyCode::Char('r') => {
if state.selected_client_mutable() {
if state.selected_rename_available() {
let selected = state.selected_identity.and_then(|id| {
state
.view
@@ -788,7 +788,7 @@ fn handle_key(
}
}
KeyCode::Char('d') => {
if state.selected_client_mutable() {
if state.selected_delete_available() {
if let (Some(id), Some(view)) = (state.selected_identity, state.view.as_ref()) {
let label = view
.clients
@@ -806,16 +806,25 @@ fn handle_key(
KeyCode::Char('p') => forms.open_draft_review(),
KeyCode::Char('c') => forms.open_commit(),
KeyCode::Char('x') => forms.open_discard(),
KeyCode::Char('R') => submit_command(worker, UiCommand::PreviewRestart, state),
KeyCode::Char('o') | KeyCode::Char('O') => {
if state.selected_client_mutable()
&& let Some(identity) = state.selected_identity
&& state.view.as_ref().is_some_and(|view| {
view.client_states
.get(&identity)
.is_some_and(|row| row.saved.is_some())
})
KeyCode::Char('R') => {
if state
.view
.as_ref()
.is_some_and(|view| view.restart_eligibility().is_none_or(|item| item.available))
{
submit_command(worker, UiCommand::PreviewRestart, state);
} else {
state.status_message =
Some("Restart preview is unavailable for the saved configuration.".to_owned());
}
}
KeyCode::Char('o') | KeyCode::Char('O') => {
let eligible = if code == KeyCode::Char('o') {
state.selected_first_delivery_available()
} else {
state.selected_reissue_available()
};
if eligible && let Some(identity) = state.selected_identity {
forms.open_delivery(identity, code == KeyCode::Char('O'));
} else {
state.status_message =
@@ -1038,6 +1047,7 @@ mod tests {
statistics: RuntimeObservation::unknown(),
has_draft: false,
vault_initialized: true,
application: None,
}
}
@@ -1319,6 +1329,7 @@ mod tests {
statistics: RuntimeObservation::unknown(),
has_draft: false,
vault_initialized: true,
application: None,
}),
..UiState::default()
};
@@ -1350,6 +1361,7 @@ mod tests {
statistics: RuntimeObservation::unknown(),
has_draft: false,
vault_initialized: true,
application: None,
}),
..UiState::default()
};
@@ -1584,6 +1596,7 @@ mod tests {
statistics: RuntimeObservation::unknown(),
has_draft: false,
vault_initialized: true,
application: None,
};
let mut worker = FakeWorker {
shutdown_calls: 0,