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">
|
||||
<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;
|
||||
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) {
|
||||
filtered = currentActors.filter(function(a) {
|
||||
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); }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue