app

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

commit decdfc8e17535e79ef2cca50f08bb6310f1cb234
parent 56f9416428c652c0c6eb2f5938d0932da61ec0b6
Author: triesap <tyson@radroots.org>
Date:   Fri,  6 Feb 2026 03:17:21 +0000

app: add settings status page

- add settings status route with health list
- move home health to summary link
- add semantic status tokens and list styles
- update i18n strings and build outputs

Diffstat:
Mapp/i18n/build/i18n.catalog.json | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/i18n/build/id_map.json | 5+++++
Mapp/i18n/build/id_map_hash | 2+-
Mapp/i18n/build/manifest.json | 4++--
Mapp/i18n/build/packs/en.mf2pack | 0
Mapp/i18n/locales/en/messages.mf2 | 10++++++++++
Mapp/src/app.rs | 260+++++++++++--------------------------------------------------------------------
Aapp/src/health_ui.rs | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/src/lib.rs | 13+++++++++++++
Mapp/src/settings.rs | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Aapp/src/settings_status.rs | 249+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/ui-components/assets/list.css | 30++++++++++++++++++++++++++++++
Mcrates/ui-tokens/assets/themes/semantic.css | 5+++++
13 files changed, 635 insertions(+), 230 deletions(-)

diff --git a/app/i18n/build/i18n.catalog.json b/app/i18n/build/i18n.catalog.json @@ -1270,6 +1270,61 @@ } }, { + "key": "app.settings.status.title", + "id": 3557645539, + "args": [], + "features": { + "select": false, + "plural_cardinal": false, + "plural_ordinal": false, + "formatters": [] + } + }, + { + "key": "app.settings.status.updated", + "id": 4045760753, + "args": [], + "features": { + "select": false, + "plural_cardinal": false, + "plural_ordinal": false, + "formatters": [] + } + }, + { + "key": "app.settings.status.view", + "id": 1254024536, + "args": [], + "features": { + "select": false, + "plural_cardinal": false, + "plural_ordinal": false, + "formatters": [] + } + }, + { + "key": "app.settings.system.status", + "id": 809595327, + "args": [], + "features": { + "select": false, + "plural_cardinal": false, + "plural_ordinal": false, + "formatters": [] + } + }, + { + "key": "app.settings.system.title", + "id": 797085259, + "args": [], + "features": { + "select": false, + "plural_cardinal": false, + "plural_ordinal": false, + "formatters": [] + } + }, + { "key": "app.settings.title", "id": 2068161917, "args": [], diff --git a/app/i18n/build/id_map.json b/app/i18n/build/id_map.json @@ -114,6 +114,11 @@ "app.settings.appearance.color_mode.option.light": 2964032561, "app.settings.appearance.color_mode.option.system": 116103587, "app.settings.appearance.title": 410103833, + "app.settings.status.title": 3557645539, + "app.settings.status.updated": 4045760753, + "app.settings.status.view": 1254024536, + "app.settings.system.status": 809595327, + "app.settings.system.title": 797085259, "app.settings.title": 2068161917, "app.setup.business.title": 1801429027, "app.setup.eula.acceptance.body": 2382792662, diff --git a/app/i18n/build/id_map_hash b/app/i18n/build/id_map_hash @@ -1 +1 @@ -sha256:e5e758fa6183b4c5bf523e75c87909d48d14d832aa89802c1fc270495e2c80e3 +sha256:74c93a0a2572e6ddee22ba8121685c275e5d3b98dad0c713394bf3d72fbc78ad diff --git a/app/i18n/build/manifest.json b/app/i18n/build/manifest.json @@ -1 +1 @@ -{"schema":1,"release_id":"dev","generated_at":"2026-02-02T00:00:00Z","default_locale":"en","supported_locales":["en"],"id_map_hash":"sha256:e5e758fa6183b4c5bf523e75c87909d48d14d832aa89802c1fc270495e2c80e3","mf2_packs":{"en":{"kind":"base","url":"packs/en.mf2pack","hash":"sha256:887081ef60f00d9602e23b05c62845e5c7952b5ed3b2aaa96604ddfbe224ace5","size":11393,"content_encoding":"identity","pack_schema":0}}} -\ No newline at end of file +{"schema":1,"release_id":"dev","generated_at":"2026-02-02T00:00:00Z","default_locale":"en","supported_locales":["en"],"id_map_hash":"sha256:74c93a0a2572e6ddee22ba8121685c275e5d3b98dad0c713394bf3d72fbc78ad","mf2_packs":{"en":{"kind":"base","url":"packs/en.mf2pack","hash":"sha256:1e5b94cb8e1e51cece51482becf25da1b42425165934d34fbb3fd61f691c6db7","size":11611,"content_encoding":"identity","pack_schema":0}}} +\ No newline at end of file diff --git a/app/i18n/build/packs/en.mf2pack b/app/i18n/build/packs/en.mf2pack Binary files differ. diff --git a/app/i18n/locales/en/messages.mf2 b/app/i18n/locales/en/messages.mf2 @@ -332,6 +332,16 @@ app.settings.actions.export_db = export database app.settings.actions.logout = logout +app.settings.system.title = system + +app.settings.system.status = system status + +app.settings.status.title = system status + +app.settings.status.updated = last updated + +app.settings.status.view = view status + # ui demo app.ui_demo.title = ui demo diff --git a/app/src/app.rs b/app/src/app.rs @@ -43,7 +43,6 @@ use crate::{ app_log_error_store, app_config_default, app_datastore_clear_setup_draft, - app_datastore_read_state, app_datastore_write_profile_seed, app_datastore_write_setup_draft, app_keystore_nostr_ensure_key, @@ -53,18 +52,18 @@ use crate::{ app_setup_lock_enabled, app_setup_lock_release, app_setup_lock_ttl_ms, - app_state_notifications_permission_value, app_state_set_notifications_permission_value, app_state_timestamp_ms, app_setup_eula_date, app_setup_finalize_with_key, app_setup_gate_from_status, app_setup_step_default, - app_health_check_all, + app_health_check_delay_ms, + health_report_summary, + health_result_label, + health_status_class, RadrootsAppBackends, - RadrootsAppConfig, RadrootsAppHealthCheckResult, - RadrootsAppHealthCheckStatus, RadrootsAppHealthReport, RadrootsAppInitError, RadrootsAppInitStage, @@ -84,17 +83,10 @@ use crate::{ RadrootsAppSetupStatus, RadrootsAppUiDemoPage, RadrootsAppSetupStep, - RadrootsAppTangleClientStub, + RadrootsAppSettingsStatusPage, + spawn_health_checks, }; -fn health_status_color(status: RadrootsAppHealthCheckStatus) -> &'static str { - match status { - RadrootsAppHealthCheckStatus::Ok => "green", - RadrootsAppHealthCheckStatus::Error => "red", - RadrootsAppHealthCheckStatus::Skipped => "gray", - } -} - fn init_stage_label(stage: RadrootsAppInitStage) -> String { match stage { RadrootsAppInitStage::Idle => t!("app.init.stage.idle"), @@ -108,32 +100,6 @@ fn init_stage_label(stage: RadrootsAppInitStage) -> String { } } -fn health_status_label(status: RadrootsAppHealthCheckStatus) -> String { - match status { - RadrootsAppHealthCheckStatus::Ok => t!("app.home.health.status.ok"), - RadrootsAppHealthCheckStatus::Error => t!("app.home.health.status.error"), - RadrootsAppHealthCheckStatus::Skipped => t!("app.home.health.status.skipped"), - } -} - -fn health_message_label(message: &str) -> String { - match message { - "missing" => t!("app.home.health.message.missing"), - "mismatch" => t!("app.home.health.message.mismatch"), - "uninitialized" => t!("app.home.health.message.uninitialized"), - "unavailable" => t!("app.home.health.message.unavailable"), - _ => message.to_string(), - } -} - -fn health_result_label(result: &RadrootsAppHealthCheckResult) -> String { - let status = health_status_label(result.status); - match result.message.as_deref() { - Some(message) => format!("{}: {}", status, health_message_label(message)), - None => status, - } -} - fn error_label(key: &str) -> Option<String> { let label = match key { "error.app.init.idb" => t!("error.app.init.idb"), @@ -179,18 +145,6 @@ fn setup_touch_callback(action: &'static str) -> Callback<MouseEvent> { }) } -fn active_key_label(value: Option<String>) -> String { - let Some(value) = value else { - return t!("app.common.missing"); - }; - if value.len() <= 12 { - return value; - } - let head = &value[..8]; - let tail = &value[value.len() - 4..]; - format!("{head}...{tail}") -} - fn log_init_stage(stage: RadrootsAppInitStage) { let _ = app_log_debug_emit("log.app.init.stage", stage.as_str(), None); } @@ -199,68 +153,6 @@ fn logs_datastore() -> radroots_app_core::datastore::RadrootsClientWebDatastore radroots_app_core::datastore::RadrootsClientWebDatastore::new(Some(IDB_CONFIG_LOGS)) } -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>, - notifications_status: RwSignal<Option<String>, LocalStorage>, -) { - health_running.set(true); - spawn_local(async move { - let datastore = radroots_app_core::datastore::RadrootsClientWebDatastore::new( - Some(config.datastore.idb_config), - ); - let keystore = radroots_app_core::keystore::RadrootsClientWebKeystoreNostr::new( - Some(config.keystore.nostr_store), - ); - let notifications = RadrootsAppNotifications::new(None); - let tangle = RadrootsAppTangleClientStub::new(); - let report = app_health_check_all( - &datastore, - &keystore, - &notifications, - &tangle, - &config.datastore.key_maps, - setup_required, - ) - .await; - 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); - health_running.set(false); - let key_maps = config.datastore.key_maps.clone(); - spawn_local(async move { - let log_datastore = logs_datastore(); - let _ = app_log_buffer_flush_deferred(&log_datastore, &key_maps, true).await; - }); - }); -} - -const APP_HEALTH_CHECK_DELAY_MS: u32 = 300; -fn app_health_check_delay_ms() -> u32 { - APP_HEALTH_CHECK_DELAY_MS -} - #[component] fn SplashPage() -> impl IntoView { view! { @@ -1582,6 +1474,7 @@ fn HomePage() -> impl IntoView { let active_key = RwSignal::new_local(None::<String>); let notifications_status = RwSignal::new_local(None::<String>); let notifications_requesting = RwSignal::new_local(false); + let last_run = RwSignal::new_local(None::<i64>); Effect::new(move || { if init_state.get().stage != RadrootsAppInitStage::Ready { return; @@ -1609,6 +1502,7 @@ fn HomePage() -> impl IntoView { health_running, active_key, notifications_status, + last_run, ); }); }); @@ -1630,11 +1524,6 @@ fn HomePage() -> impl IntoView { .map(reset_status_label) .unwrap_or_else(|| t!("app.home.reset.status.idle")) }; - let health_disabled = move || { - backends.with(|value| value.is_none()) - || health_running.get() - || matches!(setup_status.get(), RadrootsAppSetupStatus::Unknown) - }; let notifications_disabled = move || { backends.with(|value| value.is_none()) || notifications_requesting.get() }; @@ -1712,6 +1601,7 @@ fn HomePage() -> impl IntoView { health_running, active_key, notifications_status, + last_run, ); } Err(err) => { @@ -1769,6 +1659,7 @@ fn HomePage() -> impl IntoView { health_running, active_key, notifications_status, + last_run, ); } Err(err) => { @@ -1793,114 +1684,31 @@ fn HomePage() -> impl IntoView { </div> </section> <section id="app-home-health" aria-label=t!("app.home.health.aria") style="margin-top: 16px;"> - <header id="app-home-health-header"> - <h2 id="app-home-health-title" style="font-weight: 600;">{t!("app.home.health.title")}</h2> + <header id="app-home-health-header" style="display:flex;align-items:center;gap:12px;"> + <h2 id="app-home-health-title" style="font-weight: 600;"> + {t!("app.settings.status.title")} + </h2> + <A href="/settings/status" attr:id="app-home-health-link"> + {t!("app.settings.status.view")} + </A> </header> - <div id="app-home-health-actions" style="margin-top: 8px; display: flex; align-items: center; gap: 8px;"> - <button - on:click=move |_| { - let config = backends.with_untracked(|value| value.as_ref().map(|backends| backends.config.clone())); - let Some(config) = config else { - return; - }; - let setup_required_value = - !matches!(setup_status.get(), RadrootsAppSetupStatus::Configured); - spawn_health_checks( - config, - setup_required_value, - health_report, - health_running, - active_key, - notifications_status, - ); + <div id="app-home-health-summary" style="margin-top: 8px; display: flex; align-items: center; gap: 8px;"> + <span + class=move || { + let summary = health_report_summary(&health_report.get()); + format!("status-dot {}", health_status_class(summary)) } - disabled=health_disabled > - {move || { - if health_running.get() { - t!("app.home.health.button.checking") - } else { - t!("app.home.health.button.run") - } - }} - </button> + {"●"} + </span> + <span>{move || { + let summary = health_report_summary(&health_report.get()); + health_result_label(&RadrootsAppHealthCheckResult { + status: summary, + message: None, + }) + }}</span> </div> - <ul id="app-home-health-list" style="margin-top: 8px; display: grid; gap: 6px;"> - <li id="app-home-health-key-maps" style="display: flex; align-items: center; gap: 8px;"> - <span - style=move || format!( - "display:inline-block;width:10px;height:10px;border-radius:50%;background:{};", - health_status_color(health_report.get().key_maps.status) - ) - ></span> - <span>{t!("app.home.health.item.key_maps")}</span> - <span>{move || health_result_label(&health_report.get().key_maps)}</span> - </li> - <li id="app-home-health-bootstrap-state" style="display: flex; align-items: center; gap: 8px;"> - <span - style=move || format!( - "display:inline-block;width:10px;height:10px;border-radius:50%;background:{};", - health_status_color(health_report.get().bootstrap_state.status) - ) - ></span> - <span>{t!("app.home.health.item.bootstrap_state")}</span> - <span>{move || health_result_label(&health_report.get().bootstrap_state)}</span> - </li> - <li id="app-home-health-active-key-state" style="display: flex; align-items: center; gap: 8px;"> - <span - style=move || format!( - "display:inline-block;width:10px;height:10px;border-radius:50%;background:{};", - health_status_color(health_report.get().state_active_key.status) - ) - ></span> - <span>{t!("app.home.health.item.state_active_key")}</span> - <span>{move || health_result_label(&health_report.get().state_active_key)}</span> - </li> - <li id="app-home-health-notifications" style="display: flex; align-items: center; gap: 8px;"> - <span - style=move || format!( - "display:inline-block;width:10px;height:10px;border-radius:50%;background:{};", - health_status_color(health_report.get().notifications.status) - ) - ></span> - <span>{t!("app.home.health.item.notifications")}</span> - <span>{move || health_result_label(&health_report.get().notifications)}</span> - </li> - <li id="app-home-health-tangle" style="display: flex; align-items: center; gap: 8px;"> - <span - style=move || format!( - "display:inline-block;width:10px;height:10px;border-radius:50%;background:{};", - health_status_color(health_report.get().tangle.status) - ) - ></span> - <span>{t!("app.home.health.item.tangle")}</span> - <span>{move || health_result_label(&health_report.get().tangle)}</span> - </li> - <li id="app-home-health-datastore" style="display: flex; align-items: center; gap: 8px;"> - <span - style=move || format!( - "display:inline-block;width:10px;height:10px;border-radius:50%;background:{};", - health_status_color(health_report.get().datastore_roundtrip.status) - ) - ></span> - <span>{t!("app.home.health.item.datastore_roundtrip")}</span> - <span>{move || health_result_label(&health_report.get().datastore_roundtrip)}</span> - </li> - <li id="app-home-health-keystore" style="display: flex; align-items: center; gap: 8px;"> - <span - style=move || format!( - "display:inline-block;width:10px;height:10px;border-radius:50%;background:{};", - health_status_color(health_report.get().keystore.status) - ) - ></span> - <span>{t!("app.home.health.item.keystore")}</span> - <span>{move || health_result_label(&health_report.get().keystore)}</span> - </li> - <li id="app-home-health-active-key" style="display: flex; align-items: center; gap: 8px;"> - <span>{t!("app.home.health.item.active_key")}</span> - <span>{move || active_key_label(active_key.get())}</span> - </li> - </ul> </section> </main> } @@ -2065,6 +1873,10 @@ fn AppShell() -> impl IntoView { <Route path=path!("") view=HomePage /> <Route path=path!("logs") view=RadrootsAppLogsPage /> <Route path=path!("ui") view=RadrootsAppUiDemoPage /> + <Route + path=path!("settings/status") + view=RadrootsAppSettingsStatusPage + /> <Route path=path!("settings") view=RadrootsAppSettingsPage /> </Routes> </div> @@ -2079,7 +1891,7 @@ fn AppShell() -> impl IntoView { #[cfg(test)] mod tests { - use super::app_health_check_delay_ms; + use crate::app_health_check_delay_ms; #[test] fn health_check_delay_is_positive() { diff --git a/app/src/health_ui.rs b/app/src/health_ui.rs @@ -0,0 +1,160 @@ +#![forbid(unsafe_code)] + +use leptos::prelude::{LocalStorage, RwSignal, Set}; +use leptos::task::spawn_local; + +use crate::{ + app_datastore_read_state, + app_health_check_all, + app_log_buffer_flush_deferred, + app_state_notifications_permission_value, + app_state_timestamp_ms, + t, + RadrootsAppConfig, + RadrootsAppHealthCheckResult, + RadrootsAppHealthCheckStatus, + RadrootsAppHealthReport, + RadrootsAppNotifications, + RadrootsAppTangleClientStub, +}; +use radroots_app_core::idb::IDB_CONFIG_LOGS; + +const APP_HEALTH_CHECK_DELAY_MS: u32 = 300; + +pub fn app_health_check_delay_ms() -> u32 { + APP_HEALTH_CHECK_DELAY_MS +} + +pub fn health_status_class(status: RadrootsAppHealthCheckStatus) -> &'static str { + match status { + RadrootsAppHealthCheckStatus::Ok => "status-ok", + RadrootsAppHealthCheckStatus::Error => "status-error", + RadrootsAppHealthCheckStatus::Skipped => "status-warn", + } +} + +pub fn health_status_label(status: RadrootsAppHealthCheckStatus) -> String { + match status { + RadrootsAppHealthCheckStatus::Ok => t!("app.home.health.status.ok"), + RadrootsAppHealthCheckStatus::Error => t!("app.home.health.status.error"), + RadrootsAppHealthCheckStatus::Skipped => t!("app.home.health.status.skipped"), + } +} + +pub fn health_message_label(message: &str) -> String { + match message { + "missing" => t!("app.home.health.message.missing"), + "mismatch" => t!("app.home.health.message.mismatch"), + "uninitialized" => t!("app.home.health.message.uninitialized"), + "unavailable" => t!("app.home.health.message.unavailable"), + _ => message.to_string(), + } +} + +pub fn health_result_label(result: &RadrootsAppHealthCheckResult) -> String { + let status = health_status_label(result.status); + match result.message.as_deref() { + Some(message) => format!("{}: {}", status, health_message_label(message)), + None => status, + } +} + +pub fn health_report_summary(report: &RadrootsAppHealthReport) -> RadrootsAppHealthCheckStatus { + let statuses = [ + report.key_maps.status, + report.bootstrap_state.status, + report.state_active_key.status, + report.notifications.status, + report.tangle.status, + report.datastore_roundtrip.status, + report.keystore.status, + ]; + if statuses + .iter() + .any(|status| matches!(status, RadrootsAppHealthCheckStatus::Error)) + { + return RadrootsAppHealthCheckStatus::Error; + } + if statuses + .iter() + .any(|status| matches!(status, RadrootsAppHealthCheckStatus::Skipped)) + { + return RadrootsAppHealthCheckStatus::Skipped; + } + RadrootsAppHealthCheckStatus::Ok +} + +pub fn active_key_label(value: Option<String>) -> String { + let Some(value) = value else { + return t!("app.common.missing"); + }; + if value.len() <= 12 { + return value; + } + let head = &value[..8]; + let tail = &value[value.len() - 4..]; + format!("{head}...{tail}") +} + +fn logs_datastore() -> radroots_app_core::datastore::RadrootsClientWebDatastore { + radroots_app_core::datastore::RadrootsClientWebDatastore::new(Some(IDB_CONFIG_LOGS)) +} + +pub 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>, + notifications_status: RwSignal<Option<String>, LocalStorage>, + last_run: RwSignal<Option<i64>, LocalStorage>, +) { + health_running.set(true); + spawn_local(async move { + let datastore = radroots_app_core::datastore::RadrootsClientWebDatastore::new( + Some(config.datastore.idb_config), + ); + let keystore = radroots_app_core::keystore::RadrootsClientWebKeystoreNostr::new( + Some(config.keystore.nostr_store), + ); + let notifications = RadrootsAppNotifications::new(None); + let tangle = RadrootsAppTangleClientStub::new(); + let report = app_health_check_all( + &datastore, + &keystore, + &notifications, + &tangle, + &config.datastore.key_maps, + setup_required, + ) + .await; + 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); + last_run.set(Some(app_state_timestamp_ms())); + health_running.set(false); + let key_maps = config.datastore.key_maps.clone(); + spawn_local(async move { + let log_datastore = logs_datastore(); + let _ = app_log_buffer_flush_deferred(&log_datastore, &key_maps, true).await; + }); + }); +} diff --git a/app/src/lib.rs b/app/src/lib.rs @@ -6,6 +6,7 @@ mod context; mod config; mod data; mod health; +mod health_ui; mod init; mod i18n; mod keystore; @@ -13,6 +14,7 @@ mod logging; mod logs; mod notifications; mod settings; +mod settings_status; mod setup; mod setup_flow; mod setup_lock; @@ -68,6 +70,16 @@ pub use health::{ RadrootsAppHealthCheckStatus, RadrootsAppHealthReport, }; +pub use health_ui::{ + active_key_label, + app_health_check_delay_ms, + health_message_label, + health_report_summary, + health_result_label, + health_status_class, + health_status_label, + spawn_health_checks, +}; pub use keystore::{ app_keystore_nostr_ensure_key, app_keystore_nostr_keys, @@ -78,6 +90,7 @@ pub use keystore::{ }; pub use logs::RadrootsAppLogsPage; pub use settings::RadrootsAppSettingsPage; +pub use settings_status::RadrootsAppSettingsStatusPage; pub use setup_status::{ app_setup_gate_from_status, RadrootsAppSetupGate, diff --git a/app/src/settings.rs b/app/src/settings.rs @@ -2,6 +2,7 @@ use leptos::ev::MouseEvent; use leptos::prelude::*; +use leptos_router::hooks::use_navigate; use crate::{ app_theme_apply_mode, @@ -45,6 +46,17 @@ fn settings_touch_callback(action: &'static str) -> Callback<MouseEvent> { Callback::new(move |_| log_settings_action(action)) } +fn settings_capitalize(value: &str) -> String { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + let mut out = String::new(); + out.extend(first.to_uppercase()); + out.push_str(chars.as_str()); + out +} + fn settings_label(value: String, classes: Option<&str>) -> RadrootsAppUiListLabelValue { RadrootsAppUiListLabelValue { classes_wrap: None, @@ -58,6 +70,7 @@ fn settings_label(value: String, classes: Option<&str>) -> RadrootsAppUiListLabe #[component] pub fn RadrootsAppSettingsPage() -> impl IntoView { + let navigate = use_navigate(); let initial_mode = app_theme_read_mode().unwrap_or(RadrootsAppThemeMode::System); let color_mode_value = initial_mode.as_str().to_string(); let color_mode_callback = Callback::new(move |value: String| { @@ -86,17 +99,23 @@ pub fn RadrootsAppSettingsPage() -> impl IntoView { value: color_mode_value, options: vec![ RadrootsAppUiListSelectOption { - label: t!("app.settings.appearance.color_mode.option.system"), + label: settings_capitalize( + &t!("app.settings.appearance.color_mode.option.system"), + ), value: "system".to_string(), classes: None, }, RadrootsAppUiListSelectOption { - label: t!("app.settings.appearance.color_mode.option.light"), + label: settings_capitalize( + &t!("app.settings.appearance.color_mode.option.light"), + ), value: "light".to_string(), classes: None, }, RadrootsAppUiListSelectOption { - label: t!("app.settings.appearance.color_mode.option.dark"), + label: settings_capitalize( + &t!("app.settings.appearance.color_mode.option.dark"), + ), value: "dark".to_string(), classes: None, }, @@ -194,6 +213,52 @@ pub fn RadrootsAppSettingsPage() -> impl IntoView { hide_offset: false, styles: None, }; + let system_status_action = { + let navigate = navigate.clone(); + Callback::new(move |_| { + navigate("/settings/status", Default::default()); + }) + }; + let system_list = RadrootsAppUiList { + id: Some("settings-system".to_string()), + view: Some("settings".to_string()), + classes: None, + title: Some(RadrootsAppUiListTitle { + value: RadrootsAppUiListTitleValue::Text(t!("app.settings.system.title")), + classes: None, + mod_value: None, + link: None, + on_click: None, + }), + default_state: None, + list: Some(vec![Some(RadrootsAppUiListItem { + kind: RadrootsAppUiListItemKind::Touch(RadrootsAppUiListTouch { + label: RadrootsAppUiListLabel { + left: vec![settings_label( + t!("app.settings.system.status"), + Some("capitalize"), + )], + right: Vec::new(), + }, + display: None, + end: Some(RadrootsAppUiListTouchEnd { + icon: RadrootsAppUiListIcon { + key: "chevron-right".to_string(), + class: None, + }, + on_click: None, + }), + on_click: Some(system_status_action), + }), + loading: false, + hide_active: false, + hide_field: false, + full_rounded: false, + offset: None, + })]), + hide_offset: false, + styles: None, + }; view! { <main id="app-settings" class="app-page app-page-scroll" style="padding: 16px;"> <header id="app-settings-header" style="font: var(--type-title2); margin-bottom: 12px;"> @@ -202,6 +267,7 @@ pub fn RadrootsAppSettingsPage() -> impl IntoView { <section id="app-settings-content" style="display:flex;flex-direction:column;gap:16px;"> <RadrootsAppUiListView basis=appearance_list /> <RadrootsAppUiListView basis=actions_list /> + <RadrootsAppUiListView basis=system_list /> </section> </main> } diff --git a/app/src/settings_status.rs b/app/src/settings_status.rs @@ -0,0 +1,249 @@ +#![forbid(unsafe_code)] + +use gloo_timers::future::TimeoutFuture; +use leptos::prelude::*; +use leptos::task::spawn_local; + +use crate::{ + active_key_label, + app_context, + app_health_check_delay_ms, + health_result_label, + health_status_class, + spawn_health_checks, + t, + RadrootsAppBackends, + RadrootsAppHealthCheckResult, + RadrootsAppHealthReport, + RadrootsAppSetupStatus, +}; +use radroots_app_ui_components::{ + RadrootsAppUiList, + RadrootsAppUiListItem, + RadrootsAppUiListItemKind, + RadrootsAppUiListLabel, + RadrootsAppUiListLabelText, + RadrootsAppUiListLabelValue, + RadrootsAppUiListLabelValueKind, + RadrootsAppUiListTitle, + RadrootsAppUiListTitleValue, + RadrootsAppUiListTouch, + RadrootsAppUiListView, +}; + +fn status_dot(status_class: &str) -> RadrootsAppUiListLabelValue { + RadrootsAppUiListLabelValue { + classes_wrap: Some("pr-1".to_string()), + hide_truncate: true, + value: RadrootsAppUiListLabelValueKind::Text(RadrootsAppUiListLabelText { + value: "●".to_string(), + classes: Some(format!("status-dot {}", status_class)), + }), + } +} + +fn status_text(value: String) -> RadrootsAppUiListLabelValue { + RadrootsAppUiListLabelValue { + classes_wrap: None, + hide_truncate: false, + value: RadrootsAppUiListLabelValueKind::Text(RadrootsAppUiListLabelText { + value, + classes: None, + }), + } +} + +fn status_row(label: String, result: RadrootsAppHealthCheckResult) -> RadrootsAppUiListItem { + let status_label = health_result_label(&result); + let status_class = health_status_class(result.status); + RadrootsAppUiListItem { + kind: RadrootsAppUiListItemKind::Touch(RadrootsAppUiListTouch { + label: RadrootsAppUiListLabel { + left: vec![status_text(label)], + right: vec![status_text(status_label), status_dot(status_class)], + }, + display: None, + end: None, + on_click: None, + }), + loading: false, + hide_active: true, + hide_field: false, + full_rounded: false, + offset: None, + } +} + +fn format_timestamp(ms: i64) -> String { + #[cfg(target_arch = "wasm32")] + { + use leptos::wasm_bindgen::JsValue; + + let date = js_sys::Date::new(&JsValue::from_f64(ms as f64)); + return date + .to_locale_string("en-US", &JsValue::UNDEFINED) + .as_string() + .unwrap_or_else(|| ms.to_string()); + } + #[cfg(not(target_arch = "wasm32"))] + { + ms.to_string() + } +} + +#[component] +pub fn RadrootsAppSettingsStatusPage() -> impl IntoView { + let context = app_context(); + let fallback_backends = RwSignal::new_local(None::<RadrootsAppBackends>); + let fallback_setup_status = RwSignal::new_local(RadrootsAppSetupStatus::Unknown); + let backends = context + .as_ref() + .map(|value| value.backends) + .unwrap_or(fallback_backends); + let setup_status = context + .as_ref() + .map(|value| value.setup_status) + .unwrap_or(fallback_setup_status); + let health_report = RwSignal::new_local(RadrootsAppHealthReport::empty()); + let health_running = RwSignal::new_local(false); + let health_autorun = RwSignal::new_local(false); + let active_key = RwSignal::new_local(None::<String>); + let notifications_status = RwSignal::new_local(None::<String>); + let last_run = RwSignal::new_local(None::<i64>); + Effect::new(move || { + if health_autorun.get() { + return; + } + let setup_status = setup_status.get(); + if matches!(setup_status, RadrootsAppSetupStatus::Unknown) { + return; + } + let setup_required_value = !matches!(setup_status, RadrootsAppSetupStatus::Configured); + let config = backends.with_untracked(|value| value.as_ref().map(|backends| backends.config.clone())); + let Some(config) = config else { + return; + }; + health_autorun.set(true); + let delay_ms = app_health_check_delay_ms(); + spawn_local(async move { + TimeoutFuture::new(delay_ms).await; + spawn_health_checks( + config, + setup_required_value, + health_report, + health_running, + active_key, + notifications_status, + last_run, + ); + }); + }); + let health_disabled = move || { + backends.with(|value| value.is_none()) + || health_running.get() + || matches!(setup_status.get(), RadrootsAppSetupStatus::Unknown) + }; + let last_updated_label = move || { + let value = last_run.get().map(format_timestamp); + value.unwrap_or_else(|| t!("app.common.unknown")) + }; + view! { + <main id="app-settings-status" class="app-page app-page-scroll" style="padding: 16px;"> + <header id="app-settings-status-header" style="display:flex;align-items:center;gap:12px;"> + <h1 id="app-settings-status-title" style="font: var(--type-title2);"> + {t!("app.settings.status.title")} + </h1> + <button + on:click=move |_| { + let config = backends.with_untracked(|value| value.as_ref().map(|backends| backends.config.clone())); + let Some(config) = config else { + return; + }; + let setup_required_value = + !matches!(setup_status.get(), RadrootsAppSetupStatus::Configured); + spawn_health_checks( + config, + setup_required_value, + health_report, + health_running, + active_key, + notifications_status, + last_run, + ); + } + disabled=health_disabled + > + {move || { + if health_running.get() { + t!("app.home.health.button.checking") + } else { + t!("app.home.health.button.run") + } + }} + </button> + <div id="app-settings-status-updated" style="font-size:12px;color:var(--text-secondary);"> + {move || format!("{}: {}", t!("app.settings.status.updated"), last_updated_label())} + </div> + </header> + <section id="app-settings-status-content" style="display:flex;flex-direction:column;gap:16px;margin-top:12px;"> + {move || { + let report = health_report.get(); + let active = active_key_label(active_key.get()); + let list = RadrootsAppUiList { + id: Some("settings-status-list".to_string()), + view: Some("settings-status".to_string()), + classes: None, + title: Some(RadrootsAppUiListTitle { + value: RadrootsAppUiListTitleValue::Text(t!("app.home.health.title")), + classes: None, + mod_value: None, + link: None, + on_click: None, + }), + default_state: None, + list: Some(vec![ + Some(status_row(t!("app.home.health.item.key_maps"), report.key_maps)), + Some(status_row( + t!("app.home.health.item.bootstrap_state"), + report.bootstrap_state, + )), + Some(status_row( + t!("app.home.health.item.state_active_key"), + report.state_active_key, + )), + Some(status_row( + t!("app.home.health.item.notifications"), + report.notifications, + )), + Some(status_row(t!("app.home.health.item.tangle"), report.tangle)), + Some(status_row( + t!("app.home.health.item.datastore_roundtrip"), + report.datastore_roundtrip, + )), + Some(status_row(t!("app.home.health.item.keystore"), report.keystore)), + Some(RadrootsAppUiListItem { + kind: RadrootsAppUiListItemKind::Touch(RadrootsAppUiListTouch { + label: RadrootsAppUiListLabel { + left: vec![status_text(t!("app.home.health.item.active_key"))], + right: vec![status_text(active), status_dot("status-neutral")], + }, + display: None, + end: None, + on_click: None, + }), + loading: false, + hide_active: true, + hide_field: false, + full_rounded: false, + offset: None, + }), + ]), + hide_offset: false, + styles: None, + }; + view! { <RadrootsAppUiListView basis=list /> }.into_any() + }} + </section> + </main> + } +} diff --git a/crates/ui-components/assets/list.css b/crates/ui-components/assets/list.css @@ -158,4 +158,34 @@ -moz-appearance: none; z-index: 30; } + + .status-ok { + color: var(--status-ok); + } + + .status-warn { + color: var(--status-warn); + } + + .status-error { + color: var(--status-error); + } + + .status-neutral { + color: var(--status-neutral); + } + + .status-dot { + font-size: 0.6rem; + line-height: 1; + } + + .status-pill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font: var(--type-caption1); + background: color-mix(in srgb, currentColor 12%, transparent); + } } diff --git a/crates/ui-tokens/assets/themes/semantic.css b/crates/ui-tokens/assets/themes/semantic.css @@ -17,4 +17,9 @@ --material-regular: color-mix(in srgb, hsl(var(--ly1) / 1) 72%, transparent); --material-thick: color-mix(in srgb, hsl(var(--ly1) / 1) 85%, transparent); --material-chrome: color-mix(in srgb, hsl(var(--ly0) / 1) 90%, transparent); + + --status-ok: var(--success); + --status-warn: var(--warning); + --status-error: var(--destructive); + --status-neutral: var(--text-secondary); }