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:
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(
¬ifications,
&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 {
¬ifications,
&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,
+ ¬ifications,
+ &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 {
¬ifications,
&tangle,
&key_maps,
+ false,
));
assert_eq!(report.key_maps.status, RadrootsAppHealthCheckStatus::Ok);
assert!(datastore.entry_len() > 0);