app

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

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

app: add logs filtering and view cap

- add level and query filters for log entries

- cap rendered entries to a fixed max count

- compute dump text from filtered entries

- add unit tests for filter helpers

Diffstat:
Mapp/src/logs.rs | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
1 file changed, 128 insertions(+), 13 deletions(-)

diff --git a/app/src/logs.rs b/app/src/logs.rs @@ -27,11 +27,16 @@ use crate::{ use js_sys::Array; const LOGS_AUTO_REFRESH_MS: u32 = 5000; +const LOGS_MAX_VISIBLE: usize = 500; fn logs_auto_refresh_ms() -> u32 { LOGS_AUTO_REFRESH_MS } +fn logs_max_visible() -> usize { + LOGS_MAX_VISIBLE +} + fn log_level_color(level: RadrootsAppLogLevel) -> &'static str { match level { RadrootsAppLogLevel::Debug => "#6b7280", @@ -41,6 +46,35 @@ fn log_level_color(level: RadrootsAppLogLevel) -> &'static str { } } +fn log_level_matches(level: RadrootsAppLogLevel, filter: &str) -> bool { + if filter.is_empty() || filter == "all" { + return true; + } + level.as_str() == filter +} + +fn log_query_matches(entry: &RadrootsAppLogEntry, query: &str) -> bool { + let trimmed = query.trim(); + if trimmed.is_empty() { + return true; + } + let needle = trimmed.to_lowercase(); + if entry.code.to_lowercase().contains(&needle) { + return true; + } + if entry.message.to_lowercase().contains(&needle) { + return true; + } + if let Some(context) = entry.context.as_ref() { + return context.to_lowercase().contains(&needle); + } + false +} + +fn log_entry_matches(entry: &RadrootsAppLogEntry, level_filter: &str, query: &str) -> bool { + log_level_matches(entry.level, level_filter) && log_query_matches(entry, query) +} + #[cfg(any(test, target_arch = "wasm32"))] fn log_dump_filename_from_ms(timestamp_ms: i64) -> String { format!("radroots-logs-{timestamp_ms}.jsonl") @@ -109,13 +143,36 @@ async fn log_dump_download(text: String) -> Result<(), String> { #[component] pub fn RadrootsAppLogsPage() -> impl IntoView { let entries = RwSignal::new_local(Vec::<RadrootsAppLogEntry>::new()); - let dump = RwSignal::new_local(String::new()); + let dump_error = RwSignal::new_local(None::<String>); 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 filter_query = RwSignal::new_local(String::new()); + let filter_level = RwSignal::new_local(String::from("all")); + let filter_limit = RwSignal::new_local(logs_max_visible()); let context = Rc::new(app_context()); + let filtered_entries = Memo::new(move |_| { + let level_filter = filter_level.get(); + let query = filter_query.get(); + let limit = filter_limit.get(); + entries.with(|items| { + items + .iter() + .filter(|entry| log_entry_matches(entry, &level_filter, &query)) + .take(limit) + .cloned() + .collect::<Vec<_>>() + }) + }); + let dump_text = Memo::new(move |_| { + if let Some(err) = dump_error.get() { + return err; + } + let items = filtered_entries.get(); + app_log_entries_dump(&items) + }); let resolve_backends = { let context = Rc::clone(&context); Rc::new(move || { @@ -135,12 +192,11 @@ pub fn RadrootsAppLogsPage() -> impl IntoView { Rc::new(move || { let Some((datastore, key_maps)) = resolve_backends() else { entries.set(Vec::new()); - dump.set(String::new()); + dump_error.set(None); return; }; loading.set(true); let entries_signal = entries; - let dump_signal = dump; let loading_signal = loading; spawn_local(async move { let _ = app_log_buffer_flush_no_prune(datastore.as_ref(), &key_maps).await; @@ -148,11 +204,11 @@ pub fn RadrootsAppLogsPage() -> impl IntoView { match result { Ok(mut items) => { items.sort_by(|a, b| b.timestamp_ms.cmp(&a.timestamp_ms)); - dump_signal.set(app_log_entries_dump(&items)); + dump_error.set(None); entries_signal.set(items); } Err(err) => { - dump_signal.set(format!("error: {err}")); + dump_error.set(Some(format!("error: {err}"))); entries_signal.set(Vec::new()); } } @@ -166,7 +222,7 @@ pub fn RadrootsAppLogsPage() -> impl IntoView { Rc::new(move || { let Some((datastore, key_maps)) = resolve_backends() else { entries.set(Vec::new()); - dump.set(String::new()); + dump_error.set(None); return; }; loading.set(true); @@ -178,9 +234,9 @@ pub fn RadrootsAppLogsPage() -> impl IntoView { }) }; let copy_dump = { - let dump_signal = dump; + let dump_text = dump_text.clone(); Rc::new(move || { - let text = dump_signal.get(); + let text = dump_text.get(); if text.is_empty() { dump_status.set(Some(String::from("dump_empty"))); return; @@ -197,9 +253,9 @@ pub fn RadrootsAppLogsPage() -> impl IntoView { }) }; let download_dump = { - let dump_signal = dump; + let dump_text = dump_text.clone(); Rc::new(move || { - let text = dump_signal.get(); + let text = dump_text.get(); if text.is_empty() { dump_status.set(Some(String::from("dump_empty"))); return; @@ -266,12 +322,44 @@ pub fn RadrootsAppLogsPage() -> impl IntoView { <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:12px;align-items:center;"> + <input + type="text" + placeholder="search code/message/context" + prop:value=move || filter_query.get() + on:input=move |ev| { + filter_query.set(event_target_value(&ev)); + } + style="flex:1 1 260px;border:1px solid #e5e7eb;border-radius:8px;padding:6px 8px;font-size:12px;" + /> + <select + prop:value=move || filter_level.get() + on:change=move |ev| { + filter_level.set(event_target_value(&ev)); + } + style="border:1px solid #e5e7eb;border-radius:8px;padding:6px 8px;font-size:12px;" + > + <option value="all">"all"</option> + <option value="debug">"debug"</option> + <option value="info">"info"</option> + <option value="warn">"warn"</option> + <option value="error">"error"</option> + </select> + <div style="font-size:12px;color:#6b7280;"> + {move || { + let total = entries.get().len(); + let visible = filtered_entries.get().len(); + let limit = filter_limit.get(); + format!("showing {visible} of {total} (limit {limit})") + }} + </div> + </div> <div style="margin-top:12px;display:flex;flex-wrap:wrap;gap:16px;"> <section style="flex:1 1 520px;min-width:280px;"> <div style="font-weight:600;font-size:14px;">"entries"</div> <div style="margin-top:8px;border:1px solid #e5e7eb;border-radius:8px;height:60vh;overflow:auto;padding:10px;display:flex;flex-direction:column;gap:10px;"> <For - each=move || entries.get() + each=move || filtered_entries.get() key=|entry| entry.id.clone() children=move |entry| { let level = entry.level; @@ -317,7 +405,7 @@ pub fn RadrootsAppLogsPage() -> impl IntoView { <div style="font-weight:600;font-size:14px;">"dump (jsonl)"</div> <textarea readonly - prop:value=move || dump.get() + prop:value=move || dump_text.get() style="margin-top:8px;width:100%;height:60vh;border:1px solid #e5e7eb;border-radius:8px;padding:8px;font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,\"Liberation Mono\",\"Courier New\",monospace;" ></textarea> </section> @@ -328,7 +416,13 @@ pub fn RadrootsAppLogsPage() -> impl IntoView { #[cfg(test)] mod tests { - use super::{log_dump_filename_from_ms, logs_auto_refresh_ms}; + use super::{ + log_dump_filename_from_ms, + log_entry_matches, + logs_auto_refresh_ms, + logs_max_visible, + }; + use crate::{RadrootsAppLogEntry, RadrootsAppLogLevel, RadrootsAppLogMetadata}; #[test] fn logs_auto_refresh_is_positive() { @@ -340,4 +434,25 @@ mod tests { let name = log_dump_filename_from_ms(123); assert_eq!(name, "radroots-logs-123.jsonl"); } + + #[test] + fn logs_max_visible_is_positive() { + assert!(logs_max_visible() > 0); + } + + #[test] + fn log_entry_matches_filters_level_and_query() { + let entry = RadrootsAppLogEntry { + id: String::from("a"), + timestamp_ms: 1, + level: RadrootsAppLogLevel::Info, + code: String::from("log.code.test"), + message: String::from("Hello World"), + context: Some(String::from("context")), + metadata: RadrootsAppLogMetadata::default(), + }; + assert!(log_entry_matches(&entry, "info", "hello")); + assert!(!log_entry_matches(&entry, "error", "hello")); + assert!(!log_entry_matches(&entry, "info", "missing")); + } }