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
This commit is contained in:
Claude 2026-02-13 08:55:07 +00:00
parent 5e3a779cba
commit 41930ef8f4
3 changed files with 157 additions and 20 deletions

View file

@ -187,7 +187,16 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
<div class="panel full-width">
<h2>All Actors <span id="actorCount" style="color:#555;font-weight:400;"></span></h2>
<div class="search-wrap">
<input type="text" id="actorSearch" class="search-input" placeholder="Filter by address or worker..." />
<input type="text" id="actorSearch" class="search-input" placeholder="Filter by address, type, or worker..." />
<select id="workerFilter" class="search-input" style="width:120px;">
<option value="">All Workers</option>
</select>
<select id="statusFilter" class="search-input" style="width:120px;">
<option value="">All Status</option>
<option value="healthy">Healthy</option>
<option value="poisoned">Poisoned</option>
</select>
<input type="number" id="minDepth" class="search-input" style="width:100px;" placeholder="Min depth" min="0" />
<span id="searchInfo" class="search-info"></span>
</div>
<div class="actor-list-wrap">
@ -449,13 +458,29 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
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##"<!DOCTYPE html>
});
}
// ── 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##"<!DOCTYPE html>
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 = '<option value="">All Workers</option>';
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); }

View file

@ -92,6 +92,9 @@ pub struct App {
pub prev_view_mode: ViewMode,
pub focused_worker: usize,
pub focused_actor: Option<ActorAddress>,
pub search_active: bool,
pub search_query: String,
pub search_locked: bool,
#[cfg(feature = "distribution")]
pub distribution: Option<distribution::snapshot::DistributionNodeSnapshot>,
@ -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;

View file

@ -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<Row> = app
.actor_rows
let visible = app.visible_actor_rows();
let rows: Vec<Row> = 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(