From 41930ef8f4400b083616f42296a0e5f460cea021 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Feb 2026 08:55:07 +0000 Subject: [PATCH] feat: search and filter for actor tables (Stage 3) TUI: - Press / to enter search mode (vim-style), type to filter actors in real-time by address, message type, or worker ID - Enter locks the filter, Esc clears it - Navigation bounds respect filtered results Web (actors page): - Worker dropdown filter (dynamically populated from live data) - Status filter (All / Healthy / Poisoned) - Mailbox depth threshold filter (min depth) - Text search now also matches message type names Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski --- crates/runtime-dashboard/src/actors_html.rs | 62 ++++++++++++++++++--- crates/runtime-dashboard/src/tui/app.rs | 61 +++++++++++++++++++- crates/runtime-dashboard/src/tui/ui.rs | 54 +++++++++++++++--- 3 files changed, 157 insertions(+), 20 deletions(-) diff --git a/crates/runtime-dashboard/src/actors_html.rs b/crates/runtime-dashboard/src/actors_html.rs index 969ba35..6f2063a 100644 --- a/crates/runtime-dashboard/src/actors_html.rs +++ b/crates/runtime-dashboard/src/actors_html.rs @@ -187,7 +187,16 @@ pub const ACTORS_HTML: &str = r##"

All Actors

- + + + +
@@ -449,13 +458,29 @@ pub const ACTORS_HTML: &str = r##" function renderActorTable() { var filter = document.getElementById('actorSearch').value.toLowerCase(); - var filtered = currentActors; - if (filter) { - filtered = currentActors.filter(function(a) { + var workerFilter = document.getElementById('workerFilter').value; + var statusFilter = document.getElementById('statusFilter').value; + var minDepthVal = document.getElementById('minDepth').value; + var minDepth = minDepthVal ? parseInt(minDepthVal, 10) : 0; + + var filtered = currentActors.filter(function(a) { + // Text search + if (filter) { var hex = addrToHex(a.address).toLowerCase(); - return hex.indexOf(filter) >= 0 || ('w' + a.worker_id).indexOf(filter) >= 0; - }); - } + var msgType = (a.last_msg_type || '').toLowerCase(); + if (hex.indexOf(filter) < 0 && ('w' + a.worker_id).indexOf(filter) < 0 && msgType.indexOf(filter) < 0) { + return false; + } + } + // Worker filter + if (workerFilter && a.worker_id !== parseInt(workerFilter, 10)) return false; + // Status filter + if (statusFilter === 'healthy' && a.poisoned) return false; + if (statusFilter === 'poisoned' && !a.poisoned) return false; + // Min depth + if (minDepth > 0 && a.mailbox_depth < minDepth) return false; + return true; + }); // Sort filtered.sort(function(a, b) { @@ -647,11 +672,17 @@ pub const ACTORS_HTML: &str = r##" }); } - // ── Search handler ───────────────────────────────────── + // ── Search/filter handlers ───────────────────────────── document.getElementById('actorSearch').addEventListener('keyup', function() { clearTimeout(searchTimer); searchTimer = setTimeout(renderActorTable, 150); }); + document.getElementById('workerFilter').addEventListener('change', renderActorTable); + document.getElementById('statusFilter').addEventListener('change', renderActorTable); + document.getElementById('minDepth').addEventListener('input', function() { + clearTimeout(searchTimer); + searchTimer = setTimeout(renderActorTable, 150); + }); // ── Status helpers ───────────────────────────────────── function setStatus(s) { @@ -697,6 +728,21 @@ pub const ACTORS_HTML: &str = r##" if (!liveAddrs[key]) delete depthHistory[key]; } + // Update worker filter dropdown + var wSelect = document.getElementById('workerFilter'); + var curVal = wSelect.value; + var workerIds = {}; + for (var i = 0; i < currentActors.length; i++) workerIds[currentActors[i].worker_id] = true; + var wids = Object.keys(workerIds).sort(function(a,b) { return +a - +b; }); + wSelect.innerHTML = ''; + wids.forEach(function(wid) { + var opt = document.createElement('option'); + opt.value = wid; + opt.textContent = 'W' + wid; + wSelect.appendChild(opt); + }); + wSelect.value = curVal; + renderActorTable(); if (focusedAddrHex) updateDetailPanel(); } catch(err) { console.error('stats parse error', err); } diff --git a/crates/runtime-dashboard/src/tui/app.rs b/crates/runtime-dashboard/src/tui/app.rs index fb5a0d5..539826e 100644 --- a/crates/runtime-dashboard/src/tui/app.rs +++ b/crates/runtime-dashboard/src/tui/app.rs @@ -92,6 +92,9 @@ pub struct App { pub prev_view_mode: ViewMode, pub focused_worker: usize, pub focused_actor: Option, + pub search_active: bool, + pub search_query: String, + pub search_locked: bool, #[cfg(feature = "distribution")] pub distribution: Option, @@ -128,6 +131,9 @@ impl App { prev_view_mode: ViewMode::Overview, focused_worker: 0, focused_actor: None, + search_active: false, + search_query: String::new(), + search_locked: false, #[cfg(feature = "distribution")] distribution: None, #[cfg(feature = "distribution")] @@ -148,6 +154,21 @@ impl App { self.distribution = Some(snapshot); } + /// Get visible actor rows (filtered by search query if active). + pub fn visible_actor_rows(&self) -> Vec<&ActorRow> { + if self.search_query.is_empty() { + self.actor_rows.iter().collect() + } else { + let q = self.search_query.to_lowercase(); + self.actor_rows.iter().filter(|r| { + let addr = format!("{}", r.address).to_lowercase(); + let msg = r.last_msg_type.as_deref().unwrap_or("").to_lowercase(); + let worker = format!("w{}", r.worker_id); + addr.contains(&q) || msg.contains(&q) || worker.contains(&q) + }).collect() + } + } + /// Get the focused actor's data (for actor detail view). pub fn focused_actor_row(&self) -> Option<&ActorRow> { self.focused_actor.as_ref().and_then(|addr| { @@ -304,12 +325,45 @@ impl App { } pub fn handle_key(&mut self, key: KeyEvent) { + // Search mode input handling + if self.search_active { + match key.code { + KeyCode::Esc => { + self.search_active = false; + if !self.search_locked { + self.search_query.clear(); + } + return; + } + KeyCode::Enter => { + self.search_active = false; + self.search_locked = !self.search_query.is_empty(); + return; + } + KeyCode::Backspace => { + self.search_query.pop(); + return; + } + KeyCode::Char(c) => { + self.search_query.push(c); + return; + } + _ => return, + } + } + // Global keys match key.code { KeyCode::Char('q') => { self.should_quit = true; return; } KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.should_quit = true; return; } + KeyCode::Char('/') => { + self.search_active = true; + self.search_locked = false; + self.search_query.clear(); + return; + } KeyCode::Char('s') => { self.sort_column = self.sort_column.next(); self.sort_actors(); @@ -346,7 +400,8 @@ impl App { } fn handle_key_overview(&mut self, key: KeyEvent) { - let max = if self.actor_rows.is_empty() { 0 } else { self.actor_rows.len() - 1 }; + let visible = self.visible_actor_rows(); + let max = if visible.is_empty() { 0 } else { visible.len() - 1 }; match key.code { KeyCode::Up | KeyCode::Char('k') => { self.selected = self.selected.saturating_sub(1); @@ -363,8 +418,8 @@ impl App { KeyCode::Home => { self.selected = 0; } KeyCode::End => { self.selected = max; } KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => { - // Enter actor detail for the selected actor - if let Some(row) = self.actor_rows.get(self.selected) { + // Enter actor detail for the selected visible actor + if let Some(&row) = visible.get(self.selected) { self.focused_actor = Some(row.address); self.prev_view_mode = ViewMode::Overview; self.view_mode = ViewMode::ActorDetail; diff --git a/crates/runtime-dashboard/src/tui/ui.rs b/crates/runtime-dashboard/src/tui/ui.rs index 21987ae..13b906d 100644 --- a/crates/runtime-dashboard/src/tui/ui.rs +++ b/crates/runtime-dashboard/src/tui/ui.rs @@ -194,8 +194,33 @@ fn draw_summary(f: &mut Frame, app: &App, area: Rect) { f.render_widget(Paragraph::new(line), area); } -/// Render the actor table with scrolling and selection. +/// Render the actor table with scrolling, selection, and search. fn draw_actor_table(f: &mut Frame, app: &App, table_state: &mut TableState, area: Rect) { + // Split area: optional search bar + table + let has_search = app.search_active || !app.search_query.is_empty(); + let search_height = if has_search { 1u16 } else { 0 }; + let chunks = Layout::vertical([ + Constraint::Length(search_height), + Constraint::Fill(1), + ]) + .split(area); + + // Draw search bar + if has_search { + let search_style = if app.search_active { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::DarkGray) + }; + let cursor = if app.search_active { "\u{2588}" } else { "" }; + let prefix = if app.search_locked { " [locked] /" } else { " /" }; + let line = Line::from(vec![ + Span::styled(prefix, Style::default().fg(Color::DarkGray)), + Span::styled(format!("{}{}", app.search_query, cursor), search_style), + ]); + f.render_widget(Paragraph::new(line), chunks[0]); + } + let sort_arrow = if app.sort_desc { " \u{25bc}" } else { " \u{25b2}" }; let columns = [ @@ -218,8 +243,8 @@ fn draw_actor_table(f: &mut Frame, app: &App, table_state: &mut TableState, area }); let header = Row::new(header_cells).height(1); - let rows: Vec = app - .actor_rows + let visible = app.visible_actor_rows(); + let rows: Vec = visible .iter() .map(|a| actor_row_cells(a)) .collect(); @@ -227,7 +252,22 @@ fn draw_actor_table(f: &mut Frame, app: &App, table_state: &mut TableState, area table_state.select(Some(app.selected)); let help_text = - " q: quit \u{2191}\u{2193}: scroll s: sort column r: reverse Tab: worker view Enter: drill in"; + " q: quit /: search \u{2191}\u{2193}: scroll s: sort r: reverse Tab: worker view Enter: detail"; + + let title = if !app.search_query.is_empty() { + format!( + " Actors ({} of {} matching \"{}\") ", + visible.len(), + app.actor_rows.len(), + app.search_query, + ) + } else { + format!( + " Actors (sorted by {}{}) ", + app.sort_column.label(), + sort_arrow + ) + }; let table = Table::new( rows, @@ -243,11 +283,7 @@ fn draw_actor_table(f: &mut Frame, app: &App, table_state: &mut TableState, area .block( Block::default() .borders(Borders::ALL) - .title(format!( - " Actors (sorted by {}{}) ", - app.sort_column.label(), - sort_arrow - )) + .title(title) .title_bottom(Line::from(help_text).centered()), ) .row_highlight_style(