Add application read models and thin TUI command adapter
This commit is contained in:
+47
-395
@@ -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<()>>,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user