app

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

commit fa8a10698d3ab3b08f53f3784ed9b255f6a1f59c
parent 59c1533a0e76cda0392d1bb7702fd27648a39cde
Author: triesap <triesap@radroots.dev>
Date:   Tue, 20 Jan 2026 23:56:23 +0000

app: add log dump copy and download

- enable web clipboard support in workspace

- add log dump filename helpers and tests

- wire copy and download actions for log dump

- surface action status in logs toolbar

Diffstat:
MCargo.toml | 1+
Mapp/src/logs.rs | 128++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 128 insertions(+), 1 deletion(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -57,6 +57,7 @@ web-sys = { version = "0.3.77", features = [ "Coordinates", "Cache", "CacheStorage", + "Clipboard", "Headers", "Geolocation", "MediaQueryList", diff --git a/app/src/logs.rs b/app/src/logs.rs @@ -5,6 +5,12 @@ use futures::StreamExt; use gloo_timers::future::IntervalStream; use leptos::prelude::*; use leptos::task::spawn_local; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::JsCast; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_futures::JsFuture; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::JsValue; use std::rc::Rc; use crate::{ @@ -17,6 +23,9 @@ use crate::{ RadrootsAppLogLevel, }; +#[cfg(target_arch = "wasm32")] +use js_sys::Array; + const LOGS_AUTO_REFRESH_MS: u32 = 5000; fn logs_auto_refresh_ms() -> u32 { @@ -32,11 +41,78 @@ fn log_level_color(level: RadrootsAppLogLevel) -> &'static str { } } +#[cfg(any(test, target_arch = "wasm32"))] +fn log_dump_filename_from_ms(timestamp_ms: i64) -> String { + format!("radroots-logs-{timestamp_ms}.jsonl") +} + +#[cfg(target_arch = "wasm32")] +fn log_dump_filename() -> String { + log_dump_filename_from_ms(crate::app_log_timestamp_ms()) +} + +async fn log_dump_copy(text: String) -> Result<(), String> { + #[cfg(not(target_arch = "wasm32"))] + { + let _ = text; + return Err(String::from("copy_unavailable")); + } + #[cfg(target_arch = "wasm32")] + { + let Some(window) = web_sys::window() else { + return Err(String::from("window_unavailable")); + }; + let Some(clipboard) = window.navigator().clipboard() else { + return Err(String::from("clipboard_unavailable")); + }; + let promise = clipboard.write_text(&text); + JsFuture::from(promise) + .await + .map_err(|_| String::from("copy_failed"))?; + Ok(()) + } +} + +async fn log_dump_download(text: String) -> Result<(), String> { + #[cfg(not(target_arch = "wasm32"))] + { + let _ = text; + return Err(String::from("download_unavailable")); + } + #[cfg(target_arch = "wasm32")] + { + let Some(window) = web_sys::window() else { + return Err(String::from("window_unavailable")); + }; + let Some(document) = window.document() else { + return Err(String::from("document_unavailable")); + }; + let parts = Array::new(); + parts.push(&JsValue::from_str(&text)); + let blob = web_sys::Blob::new_with_str_sequence(&parts) + .map_err(|_| String::from("blob_failed"))?; + let url = web_sys::Url::create_object_url_with_blob(&blob) + .map_err(|_| String::from("url_failed"))?; + let anchor = document + .create_element("a") + .map_err(|_| String::from("anchor_failed"))? + .dyn_into::<web_sys::HtmlAnchorElement>() + .map_err(|_| String::from("anchor_cast_failed"))?; + anchor.set_href(&url); + anchor.set_download(&log_dump_filename()); + anchor.click(); + let _ = web_sys::Url::revoke_object_url(&url); + Ok(()) + } +} + #[component] pub fn RadrootsAppLogsPage() -> impl IntoView { let entries = RwSignal::new_local(Vec::<RadrootsAppLogEntry>::new()); let dump = RwSignal::new_local(String::new()); let loading = RwSignal::new_local(false); + let dump_status = RwSignal::new_local(None::<String>); + let dump_action_running = RwSignal::new_local(false); let did_load = RwSignal::new_local(false); let interval_started = RwSignal::new_local(false); let context = Rc::new(app_context()); @@ -101,6 +177,44 @@ pub fn RadrootsAppLogsPage() -> impl IntoView { }); }) }; + let copy_dump = { + let dump_signal = dump; + Rc::new(move || { + let text = dump_signal.get(); + if text.is_empty() { + dump_status.set(Some(String::from("dump_empty"))); + return; + } + dump_action_running.set(true); + spawn_local(async move { + let status = match log_dump_copy(text).await { + Ok(()) => String::from("copy_ok"), + Err(err) => err, + }; + dump_status.set(Some(status)); + dump_action_running.set(false); + }); + }) + }; + let download_dump = { + let dump_signal = dump; + Rc::new(move || { + let text = dump_signal.get(); + if text.is_empty() { + dump_status.set(Some(String::from("dump_empty"))); + return; + } + dump_action_running.set(true); + spawn_local(async move { + let status = match log_dump_download(text).await { + Ok(()) => String::from("download_ok"), + Err(err) => err, + }; + dump_status.set(Some(status)); + dump_action_running.set(false); + }); + }) + }; let refresh_effect = Rc::clone(&refresh); let context_effect = Rc::clone(&context); Effect::new(move || { @@ -138,13 +252,19 @@ pub fn RadrootsAppLogsPage() -> impl IntoView { on_cleanup(move || abort_handle_cleanup.abort()); }); let status_label = move || if loading.get() { "loading" } else { "idle" }; + let dump_action_label = + move || dump_status.get().unwrap_or_else(|| "idle".to_string()); + let dump_action_disabled = move || dump_action_running.get(); view! { <main> <div style="display:flex;align-items:center;gap:12px;"> <div style="font-size:18px;font-weight:600;">"logs"</div> <button on:click=move |_| refresh()>"refresh"</button> <button on:click=move |_| clear()>"clear"</button> + <button on:click=move |_| copy_dump() disabled=dump_action_disabled>"copy dump"</button> + <button on:click=move |_| download_dump() disabled=dump_action_disabled>"download dump"</button> <div style="font-size:12px;color:#6b7280;">{status_label}</div> + <div style="font-size:12px;color:#6b7280;">{dump_action_label}</div> </div> <div style="margin-top:12px;display:flex;flex-wrap:wrap;gap:16px;"> <section style="flex:1 1 520px;min-width:280px;"> @@ -208,10 +328,16 @@ pub fn RadrootsAppLogsPage() -> impl IntoView { #[cfg(test)] mod tests { - use super::logs_auto_refresh_ms; + use super::{log_dump_filename_from_ms, logs_auto_refresh_ms}; #[test] fn logs_auto_refresh_is_positive() { assert!(logs_auto_refresh_ms() > 0); } + + #[test] + fn log_dump_filename_uses_timestamp() { + let name = log_dump_filename_from_ms(123); + assert_eq!(name, "radroots-logs-123.jsonl"); + } }