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:
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,
- ¬ifications,
- &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,
+ ¬ifications,
+ &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);
}