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:
parent
5e3a779cba
commit
41930ef8f4
3 changed files with 157 additions and 20 deletions
|
|
@ -187,7 +187,16 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
||||||
<div class="panel full-width">
|
<div class="panel full-width">
|
||||||
<h2>All Actors <span id="actorCount" style="color:#555;font-weight:400;"></span></h2>
|
<h2>All Actors <span id="actorCount" style="color:#555;font-weight:400;"></span></h2>
|
||||||
<div class="search-wrap">
|
<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>
|
<span id="searchInfo" class="search-info"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="actor-list-wrap">
|
<div class="actor-list-wrap">
|
||||||
|
|
@ -449,13 +458,29 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
||||||
|
|
||||||
function renderActorTable() {
|
function renderActorTable() {
|
||||||
var filter = document.getElementById('actorSearch').value.toLowerCase();
|
var filter = document.getElementById('actorSearch').value.toLowerCase();
|
||||||
var filtered = currentActors;
|
var workerFilter = document.getElementById('workerFilter').value;
|
||||||
if (filter) {
|
var statusFilter = document.getElementById('statusFilter').value;
|
||||||
filtered = currentActors.filter(function(a) {
|
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();
|
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
|
// Sort
|
||||||
filtered.sort(function(a, b) {
|
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() {
|
document.getElementById('actorSearch').addEventListener('keyup', function() {
|
||||||
clearTimeout(searchTimer);
|
clearTimeout(searchTimer);
|
||||||
searchTimer = setTimeout(renderActorTable, 150);
|
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 ─────────────────────────────────────
|
// ── Status helpers ─────────────────────────────────────
|
||||||
function setStatus(s) {
|
function setStatus(s) {
|
||||||
|
|
@ -697,6 +728,21 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
||||||
if (!liveAddrs[key]) delete depthHistory[key];
|
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();
|
renderActorTable();
|
||||||
if (focusedAddrHex) updateDetailPanel();
|
if (focusedAddrHex) updateDetailPanel();
|
||||||
} catch(err) { console.error('stats parse error', err); }
|
} catch(err) { console.error('stats parse error', err); }
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,9 @@ pub struct App {
|
||||||
pub prev_view_mode: ViewMode,
|
pub prev_view_mode: ViewMode,
|
||||||
pub focused_worker: usize,
|
pub focused_worker: usize,
|
||||||
pub focused_actor: Option<ActorAddress>,
|
pub focused_actor: Option<ActorAddress>,
|
||||||
|
pub search_active: bool,
|
||||||
|
pub search_query: String,
|
||||||
|
pub search_locked: bool,
|
||||||
|
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
pub distribution: Option<distribution::snapshot::DistributionNodeSnapshot>,
|
pub distribution: Option<distribution::snapshot::DistributionNodeSnapshot>,
|
||||||
|
|
@ -128,6 +131,9 @@ impl App {
|
||||||
prev_view_mode: ViewMode::Overview,
|
prev_view_mode: ViewMode::Overview,
|
||||||
focused_worker: 0,
|
focused_worker: 0,
|
||||||
focused_actor: None,
|
focused_actor: None,
|
||||||
|
search_active: false,
|
||||||
|
search_query: String::new(),
|
||||||
|
search_locked: false,
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
distribution: None,
|
distribution: None,
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
|
|
@ -148,6 +154,21 @@ impl App {
|
||||||
self.distribution = Some(snapshot);
|
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).
|
/// Get the focused actor's data (for actor detail view).
|
||||||
pub fn focused_actor_row(&self) -> Option<&ActorRow> {
|
pub fn focused_actor_row(&self) -> Option<&ActorRow> {
|
||||||
self.focused_actor.as_ref().and_then(|addr| {
|
self.focused_actor.as_ref().and_then(|addr| {
|
||||||
|
|
@ -304,12 +325,45 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_key(&mut self, key: KeyEvent) {
|
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
|
// Global keys
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('q') => { self.should_quit = true; return; }
|
KeyCode::Char('q') => { self.should_quit = true; return; }
|
||||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
self.should_quit = true; return;
|
self.should_quit = true; return;
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('/') => {
|
||||||
|
self.search_active = true;
|
||||||
|
self.search_locked = false;
|
||||||
|
self.search_query.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
KeyCode::Char('s') => {
|
KeyCode::Char('s') => {
|
||||||
self.sort_column = self.sort_column.next();
|
self.sort_column = self.sort_column.next();
|
||||||
self.sort_actors();
|
self.sort_actors();
|
||||||
|
|
@ -346,7 +400,8 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_key_overview(&mut self, key: KeyEvent) {
|
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 {
|
match key.code {
|
||||||
KeyCode::Up | KeyCode::Char('k') => {
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
self.selected = self.selected.saturating_sub(1);
|
self.selected = self.selected.saturating_sub(1);
|
||||||
|
|
@ -363,8 +418,8 @@ impl App {
|
||||||
KeyCode::Home => { self.selected = 0; }
|
KeyCode::Home => { self.selected = 0; }
|
||||||
KeyCode::End => { self.selected = max; }
|
KeyCode::End => { self.selected = max; }
|
||||||
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => {
|
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => {
|
||||||
// Enter actor detail for the selected actor
|
// Enter actor detail for the selected visible actor
|
||||||
if let Some(row) = self.actor_rows.get(self.selected) {
|
if let Some(&row) = visible.get(self.selected) {
|
||||||
self.focused_actor = Some(row.address);
|
self.focused_actor = Some(row.address);
|
||||||
self.prev_view_mode = ViewMode::Overview;
|
self.prev_view_mode = ViewMode::Overview;
|
||||||
self.view_mode = ViewMode::ActorDetail;
|
self.view_mode = ViewMode::ActorDetail;
|
||||||
|
|
|
||||||
|
|
@ -194,8 +194,33 @@ fn draw_summary(f: &mut Frame, app: &App, area: Rect) {
|
||||||
f.render_widget(Paragraph::new(line), area);
|
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) {
|
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 sort_arrow = if app.sort_desc { " \u{25bc}" } else { " \u{25b2}" };
|
||||||
|
|
||||||
let columns = [
|
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 header = Row::new(header_cells).height(1);
|
||||||
|
|
||||||
let rows: Vec<Row> = app
|
let visible = app.visible_actor_rows();
|
||||||
.actor_rows
|
let rows: Vec<Row> = visible
|
||||||
.iter()
|
.iter()
|
||||||
.map(|a| actor_row_cells(a))
|
.map(|a| actor_row_cells(a))
|
||||||
.collect();
|
.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));
|
table_state.select(Some(app.selected));
|
||||||
|
|
||||||
let help_text =
|
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(
|
let table = Table::new(
|
||||||
rows,
|
rows,
|
||||||
|
|
@ -243,11 +283,7 @@ fn draw_actor_table(f: &mut Frame, app: &App, table_state: &mut TableState, area
|
||||||
.block(
|
.block(
|
||||||
Block::default()
|
Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.title(format!(
|
.title(title)
|
||||||
" Actors (sorted by {}{}) ",
|
|
||||||
app.sort_column.label(),
|
|
||||||
sort_arrow
|
|
||||||
))
|
|
||||||
.title_bottom(Line::from(help_text).centered()),
|
.title_bottom(Line::from(help_text).centered()),
|
||||||
)
|
)
|
||||||
.row_highlight_style(
|
.row_highlight_style(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue