commit 79c6b924ba35694a489faa97f356591832062e08
parent 2b78c36a6ece0b88632d9e91ea0bda1bd377845d
Author: triesap <triesap@radroots.dev>
Date: Wed, 21 Jan 2026 14:14:43 +0000
app: add logs pagination helpers
- add default page size and paging helpers
- slice filtered entries by page state
- show page count in logs status
- add unit tests for paging logic
Diffstat:
| M | app/src/logs.rs | | | 76 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- |
1 file changed, 74 insertions(+), 2 deletions(-)
diff --git a/app/src/logs.rs b/app/src/logs.rs
@@ -29,6 +29,7 @@ use js_sys::Array;
const LOGS_AUTO_REFRESH_MS: u32 = 5000;
const LOGS_MAX_VISIBLE: usize = 500;
+const LOGS_PAGE_SIZE: usize = 100;
fn logs_auto_refresh_ms() -> u32 {
LOGS_AUTO_REFRESH_MS
@@ -38,6 +39,10 @@ fn logs_max_visible() -> usize {
LOGS_MAX_VISIBLE
}
+fn logs_page_size_default() -> usize {
+ LOGS_PAGE_SIZE
+}
+
fn log_level_color(level: RadrootsAppLogLevel) -> &'static str {
match level {
RadrootsAppLogLevel::Debug => "#6b7280",
@@ -113,6 +118,29 @@ fn log_dump_with_header(entries: &[RadrootsAppLogEntry]) -> String {
format!("{}\n{}", app_log_dump_header(), app_log_entries_dump(entries))
}
+fn log_page_count(total: usize, page_size: usize) -> usize {
+ if page_size == 0 {
+ return 0;
+ }
+ (total + page_size - 1) / page_size
+}
+
+fn log_entries_page(
+ entries: &[RadrootsAppLogEntry],
+ page_index: usize,
+ page_size: usize,
+) -> Vec<RadrootsAppLogEntry> {
+ if page_size == 0 {
+ return Vec::new();
+ }
+ let start = page_index.saturating_mul(page_size);
+ if start >= entries.len() {
+ return Vec::new();
+ }
+ let end = (start + page_size).min(entries.len());
+ entries[start..end].to_vec()
+}
+
#[cfg(any(test, target_arch = "wasm32"))]
fn log_dump_filename_from_ms(timestamp_ms: i64) -> String {
format!("radroots-logs-{timestamp_ms}.jsonl")
@@ -190,6 +218,8 @@ pub fn RadrootsAppLogsPage() -> impl IntoView {
let filter_from = RwSignal::new_local(String::new());
let filter_to = RwSignal::new_local(String::new());
let filter_limit = RwSignal::new_local(logs_max_visible());
+ let page_size = RwSignal::new_local(logs_page_size_default());
+ let page_index = RwSignal::new_local(0usize);
let context = Rc::new(app_context());
let filtered_entries = Memo::new(move |_| {
let level_filter = filter_level.get();
@@ -206,6 +236,13 @@ pub fn RadrootsAppLogsPage() -> impl IntoView {
.collect::<Vec<_>>()
})
});
+ let paged_entries = Memo::new(move |_| {
+ let items = filtered_entries.get();
+ log_entries_page(&items, page_index.get(), page_size.get())
+ });
+ let page_total = Memo::new(move |_| {
+ log_page_count(filtered_entries.get().len(), page_size.get())
+ });
let dump_text = Memo::new(move |_| {
if let Some(err) = dump_error.get() {
return err;
@@ -408,7 +445,9 @@ pub fn RadrootsAppLogsPage() -> impl IntoView {
let total = entries.get().len();
let visible = filtered_entries.get().len();
let limit = filter_limit.get();
- format!("showing {visible} of {total} (limit {limit})")
+ let page = page_index.get() + 1;
+ let pages = page_total.get();
+ format!("showing {visible} of {total} (limit {limit}) page {page}/{pages}")
}}
</div>
</div>
@@ -417,7 +456,7 @@ pub fn RadrootsAppLogsPage() -> impl IntoView {
<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 || filtered_entries.get()
+ each=move || paged_entries.get()
key=|entry| entry.id.clone()
children=move |entry| {
let level = entry.level;
@@ -478,10 +517,13 @@ mod tests {
log_dump_filename_from_ms,
log_dump_with_header,
log_entry_matches,
+ log_entries_page,
+ log_page_count,
log_timestamp_matches,
parse_log_timestamp,
logs_auto_refresh_ms,
logs_max_visible,
+ logs_page_size_default,
};
use crate::{RadrootsAppLogEntry, RadrootsAppLogLevel, RadrootsAppLogMetadata};
@@ -502,6 +544,11 @@ mod tests {
}
#[test]
+ fn logs_page_size_default_is_positive() {
+ assert!(logs_page_size_default() > 0);
+ }
+
+ #[test]
fn log_entry_matches_filters_level_and_query() {
let entry = RadrootsAppLogEntry {
id: String::from("a"),
@@ -551,4 +598,29 @@ mod tests {
assert!(!log_timestamp_matches(100, Some(120), None));
assert!(!log_timestamp_matches(100, None, Some(80)));
}
+
+ #[test]
+ fn log_page_count_rounds_up() {
+ assert_eq!(log_page_count(0, 10), 0);
+ assert_eq!(log_page_count(1, 10), 1);
+ assert_eq!(log_page_count(11, 10), 2);
+ }
+
+ #[test]
+ fn log_entries_page_slices() {
+ let entries = (0..5)
+ .map(|idx| RadrootsAppLogEntry {
+ id: format!("id-{idx}"),
+ timestamp_ms: idx,
+ level: RadrootsAppLogLevel::Info,
+ code: String::from("log.code.test"),
+ message: String::from("Hello"),
+ context: None,
+ metadata: RadrootsAppLogMetadata::default(),
+ })
+ .collect::<Vec<_>>();
+ let page = log_entries_page(&entries, 1, 2);
+ assert_eq!(page.len(), 2);
+ assert_eq!(page[0].id, "id-2");
+ }
}