app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 6414d9b64336ce814150a43b3964299c59473e7b
parent 864d0aabb13229fe00a712e97aeb100ba754d30d
Author: triesap <triesap@radroots.dev>
Date:   Wed, 21 Jan 2026 17:07:33 +0000

app: skip health errors when setup required

- track setup-required state in app context

- gate health checks until setup status resolves

- skip uninitialized checks instead of erroring

- add setup-required health report coverage

Diffstat:
Mapp/src/app.rs | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mapp/src/context.rs | 5+++++
Mapp/src/health.rs | 58+++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 141 insertions(+), 39 deletions(-)

diff --git a/app/src/app.rs b/app/src/app.rs @@ -72,6 +72,7 @@ fn log_init_stage(stage: RadrootsAppInitStage) { fn spawn_health_checks( config: RadrootsAppConfig, + setup_required: bool, health_report: RwSignal<RadrootsAppHealthReport, LocalStorage>, health_running: RwSignal<bool, LocalStorage>, active_key: RwSignal<Option<String>, LocalStorage>, @@ -93,22 +94,27 @@ fn spawn_health_checks( &notifications, &tangle, &config.datastore.key_maps, + setup_required, ) .await; - let app_data = app_datastore_read_state(&datastore, &config.datastore.key_maps) - .await - .ok(); - let active_key_value = app_data.as_ref().and_then(|data| { - if data.active_key.is_empty() { - None - } else { - Some(data.active_key.clone()) - } - }); - let notifications_value = app_data - .as_ref() - .and_then(app_state_notifications_permission_value) - .map(|permission| permission.as_str().to_string()); + let mut active_key_value = None; + let mut notifications_value = None; + if !setup_required { + let app_data = app_datastore_read_state(&datastore, &config.datastore.key_maps) + .await + .ok(); + active_key_value = app_data.as_ref().and_then(|data| { + if data.active_key.is_empty() { + None + } else { + Some(data.active_key.clone()) + } + }); + notifications_value = app_data + .as_ref() + .and_then(app_state_notifications_permission_value) + .map(|permission| permission.as_str().to_string()); + } health_report.set(report); active_key.set(active_key_value); notifications_status.set(notifications_value); @@ -141,6 +147,7 @@ fn HomePage() -> impl IntoView { let fallback_backends = RwSignal::new_local(None::<RadrootsAppBackends>); let fallback_init_error = RwSignal::new_local(None::<RadrootsAppInitError>); let fallback_init_state = RwSignal::new_local(app_init_state_default()); + let fallback_setup_required = RwSignal::new_local(None::<bool>); let backends = context .as_ref() .map(|value| value.backends) @@ -149,6 +156,10 @@ fn HomePage() -> impl IntoView { .as_ref() .map(|value| value.init_state) .unwrap_or(fallback_init_state); + let setup_required = context + .as_ref() + .map(|value| value.setup_required) + .unwrap_or(fallback_setup_required); let _init_error = context .as_ref() .map(|value| value.init_error) @@ -167,6 +178,10 @@ fn HomePage() -> impl IntoView { if health_autorun.get() { return; } + let setup_required = setup_required.get(); + let Some(setup_required_value) = setup_required else { + return; + }; let config = backends.with_untracked(|value| value.as_ref().map(|backends| backends.config.clone())); let Some(config) = config else { return; @@ -175,14 +190,15 @@ fn HomePage() -> impl IntoView { let delay_ms = app_health_check_delay_ms(); spawn_local(async move { TimeoutFuture::new(delay_ms).await; - spawn_health_checks( - config, - health_report, - health_running, - active_key, - notifications_status, - ); - }); + spawn_health_checks( + config, + setup_required_value, + health_report, + health_running, + active_key, + notifications_status, + ); + }); }); let status_color = move || match init_state.get().stage { RadrootsAppInitStage::Ready => "green", @@ -200,8 +216,11 @@ fn HomePage() -> impl IntoView { .get() .unwrap_or_else(|| "reset_idle".to_string()) }; - let health_disabled = - move || backends.with(|value| value.is_none()) || health_running.get(); + let health_disabled = move || { + backends.with(|value| value.is_none()) + || health_running.get() + || setup_required.get().is_none() + }; let notifications_disabled = move || { backends.with(|value| value.is_none()) || notifications_requesting.get() }; @@ -237,6 +256,7 @@ fn HomePage() -> impl IntoView { health_report.set(RadrootsAppHealthReport::empty()); active_key.set(None); notifications_status.set(None); + setup_required.set(Some(true)); spawn_local(async move { let Some(config) = config else { reset_status.set(Some("reset_missing_backends".to_string())); @@ -259,6 +279,7 @@ fn HomePage() -> impl IntoView { reset_status.set(Some("reset_done".to_string())); spawn_health_checks( config, + true, health_report, health_running, active_key, @@ -306,6 +327,7 @@ fn HomePage() -> impl IntoView { notifications_status.set(Some(value)); spawn_health_checks( config, + false, health_report, health_running, active_key, @@ -341,8 +363,12 @@ fn HomePage() -> impl IntoView { let Some(config) = config else { return; }; + let setup_required_value = setup_required + .get() + .unwrap_or(false); spawn_health_checks( config, + setup_required_value, health_report, health_running, active_key, @@ -437,13 +463,24 @@ fn HomePage() -> impl IntoView { #[component] pub fn RadrootsApp() -> impl IntoView { + view! { + <Router> + <AppShell /> + </Router> + } +} + +#[component] +fn AppShell() -> impl IntoView { let backends = RwSignal::new_local(None::<RadrootsAppBackends>); let init_error = RwSignal::new_local(None::<RadrootsAppInitError>); let init_state = RwSignal::new_local(app_init_state_default()); + let setup_required = RwSignal::new_local(None::<bool>); let navigate = use_navigate(); provide_context(backends); provide_context(init_error); provide_context(init_state); + provide_context(setup_required); Effect::new(move || { let navigate = navigate.clone(); spawn_local(async move { @@ -497,15 +534,21 @@ pub fn RadrootsApp() -> impl IntoView { init_state.update(|state| app_init_stage_set(state, stage)); log_init_stage(stage); let navigate = navigate.clone(); + let setup_required = setup_required.clone(); spawn_local(async move { let keystore = radroots_app_core::keystore::RadrootsClientWebKeystoreNostr::new( Some(keystore_config), ); match app_init_needs_setup(datastore.as_ref(), &keystore, &key_maps).await { - Ok(true) => navigate("/setup", Default::default()), - Ok(false) => {} + Ok(needs_setup) => { + setup_required.set(Some(needs_setup)); + if needs_setup { + navigate("/setup", Default::default()); + } + } Err(err) => { let _ = app_log_error_emit(&err); + setup_required.set(Some(true)); navigate("/setup", Default::default()); } } @@ -540,18 +583,16 @@ pub fn RadrootsApp() -> impl IntoView { }) }); view! { - <Router> - <nav style="display:flex;gap:12px;margin-bottom:12px;"> - <A href="/" exact=true>"home"</A> - <A href="/logs">"logs"</A> - <A href="/setup">"setup"</A> - </nav> - <Routes fallback=|| view! { <div>"not_found"</div> }> - <Route path=path!("") view=HomePage /> - <Route path=path!("logs") view=RadrootsAppLogsPage /> - <Route path=path!("setup") view=SetupPage /> - </Routes> - </Router> + <nav style="display:flex;gap:12px;margin-bottom:12px;"> + <A href="/" exact=true>"home"</A> + <A href="/logs">"logs"</A> + <A href="/setup">"setup"</A> + </nav> + <Routes fallback=|| view! { <div>"not_found"</div> }> + <Route path=path!("") view=HomePage /> + <Route path=path!("logs") view=RadrootsAppLogsPage /> + <Route path=path!("setup") view=SetupPage /> + </Routes> } } diff --git a/app/src/context.rs b/app/src/context.rs @@ -9,6 +9,7 @@ pub struct RadrootsAppContext { pub backends: RwSignal<Option<RadrootsAppBackends>, LocalStorage>, pub init_error: RwSignal<Option<RadrootsAppInitError>, LocalStorage>, pub init_state: RwSignal<RadrootsAppInitState, LocalStorage>, + pub setup_required: RwSignal<Option<bool>, LocalStorage>, } pub fn app_context() -> Option<RadrootsAppContext> { @@ -16,6 +17,7 @@ pub fn app_context() -> Option<RadrootsAppContext> { backends: use_context::<RwSignal<Option<RadrootsAppBackends>, LocalStorage>>()?, init_error: use_context::<RwSignal<Option<RadrootsAppInitError>, LocalStorage>>()?, init_state: use_context::<RwSignal<RadrootsAppInitState, LocalStorage>>()?, + setup_required: use_context::<RwSignal<Option<bool>, LocalStorage>>()?, }) } @@ -39,9 +41,11 @@ mod tests { let backends = RwSignal::new_local(None::<RadrootsAppBackends>); let init_error = RwSignal::new_local(None::<RadrootsAppInitError>); let init_state = RwSignal::new_local(app_init_state_default()); + let setup_required = RwSignal::new_local(None::<bool>); provide_context(backends); provide_context(init_error); provide_context(init_state); + provide_context(setup_required); let context = app_context().expect("context"); assert!(context.backends.with_untracked(|value| value.is_none())); assert!(context.init_error.with_untracked(|value| value.is_none())); @@ -49,5 +53,6 @@ mod tests { context.init_state.with_untracked(|state| state.stage), RadrootsAppInitStage::Idle ); + assert!(context.setup_required.with_untracked(|value| value.is_none())); } } diff --git a/app/src/health.rs b/app/src/health.rs @@ -44,6 +44,13 @@ impl RadrootsAppHealthCheckResult { message: None, } } + + pub fn skipped_with_message(message: impl Into<String>) -> Self { + Self { + status: RadrootsAppHealthCheckStatus::Skipped, + message: Some(message.into()), + } + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -269,6 +276,7 @@ pub async fn app_health_check_all<T: RadrootsClientDatastore, K: RadrootsClientK notifications: &RadrootsAppNotifications, tangle: &G, key_maps: &RadrootsAppKeyMapConfig, + setup_required: bool, ) -> RadrootsAppHealthReport { log_health_start("key_maps"); let key_maps_result = app_health_check_key_maps(key_maps); @@ -279,6 +287,25 @@ pub async fn app_health_check_all<T: RadrootsClientDatastore, K: RadrootsClientK log_health_start("tangle"); log_health_start("datastore_roundtrip"); log_health_start("keystore"); + if setup_required { + let uninitialized = + RadrootsAppHealthCheckResult::skipped_with_message("uninitialized"); + log_health_end("bootstrap_state", &uninitialized); + log_health_end("state_active_key", &uninitialized); + log_health_end("notifications", &uninitialized); + log_health_end("tangle", &uninitialized); + log_health_end("datastore_roundtrip", &uninitialized); + log_health_end("keystore", &uninitialized); + return RadrootsAppHealthReport { + key_maps: key_maps_result, + bootstrap_state: uninitialized.clone(), + state_active_key: uninitialized.clone(), + notifications: uninitialized.clone(), + tangle: uninitialized.clone(), + datastore_roundtrip: uninitialized.clone(), + keystore: uninitialized, + }; + } let stored_state = app_datastore_read_state(datastore, key_maps).await.ok(); let stored_permission = stored_state .as_ref() @@ -321,8 +348,11 @@ pub async fn app_health_check_all_logged<T: RadrootsClientDatastore, K: Radroots notifications: &RadrootsAppNotifications, tangle: &G, key_maps: &RadrootsAppKeyMapConfig, + setup_required: bool, ) -> RadrootsAppHealthReport { - let report = app_health_check_all(datastore, keystore, notifications, tangle, key_maps).await; + let report = + app_health_check_all(datastore, keystore, notifications, tangle, key_maps, setup_required) + .await; let _ = app_log_buffer_flush_critical(datastore, key_maps).await; report } @@ -735,6 +765,7 @@ mod tests { &notifications, &tangle, &key_maps, + false, )); assert_eq!(report.key_maps.status, RadrootsAppHealthCheckStatus::Ok); assert_eq!(report.bootstrap_state.status, RadrootsAppHealthCheckStatus::Error); @@ -746,6 +777,30 @@ mod tests { } #[test] + fn health_check_all_skips_when_setup_required() { + let datastore = RadrootsClientWebDatastore::new(None); + let keystore = RadrootsClientWebKeystoreNostr::new(None); + let notifications = crate::RadrootsAppNotifications::new(None); + let tangle = crate::RadrootsAppTangleClientStub::new(); + let key_maps = crate::app_key_maps_default(); + let report = futures::executor::block_on(app_health_check_all( + &datastore, + &keystore, + &notifications, + &tangle, + &key_maps, + true, + )); + assert_eq!(report.key_maps.status, RadrootsAppHealthCheckStatus::Ok); + assert_eq!(report.bootstrap_state.status, RadrootsAppHealthCheckStatus::Skipped); + assert_eq!(report.state_active_key.status, RadrootsAppHealthCheckStatus::Skipped); + assert_eq!(report.notifications.status, RadrootsAppHealthCheckStatus::Skipped); + assert_eq!(report.tangle.status, RadrootsAppHealthCheckStatus::Skipped); + assert_eq!(report.datastore_roundtrip.status, RadrootsAppHealthCheckStatus::Skipped); + assert_eq!(report.keystore.status, RadrootsAppHealthCheckStatus::Skipped); + } + + #[test] fn health_check_notifications_reports_unavailable() { let notifications = crate::RadrootsAppNotifications::new(None); let result = @@ -934,6 +989,7 @@ mod tests { &notifications, &tangle, &key_maps, + false, )); assert_eq!(report.key_maps.status, RadrootsAppHealthCheckStatus::Ok); assert!(datastore.entry_len() > 0);