diff --git a/crates/runtime-dashboard/src/actor_detail_html.rs b/crates/runtime-dashboard/src/actor_detail_html.rs new file mode 100644 index 0000000..1c7398b --- /dev/null +++ b/crates/runtime-dashboard/src/actor_detail_html.rs @@ -0,0 +1,230 @@ +pub const ACTOR_DETAIL_HTML: &str = r##" + + + + +Actor Detail — Swactor Dashboard + + + +
+
+

+ Swactor Runtime Dashboard + +

+ +
+
+ +
+ + +
+
+
Address
—
+
Worker
—
+
Status
—
+
+
+
Last Message Type
—
+
+
+ +
+
0
Messages
+
0
Mailbox
+
0
Msg/s
+
—
Worker Load
+
+ +
+

Message Rate

+ +
+ +
+

Mailbox Depth

+ +
+
+ + + + +"##; diff --git a/crates/runtime-dashboard/src/actors_html.rs b/crates/runtime-dashboard/src/actors_html.rs index 7fb6100..969ba35 100644 --- a/crates/runtime-dashboard/src/actors_html.rs +++ b/crates/runtime-dashboard/src/actors_html.rs @@ -558,8 +558,8 @@ pub const ACTORS_HTML: &str = r##" return; } - document.getElementById('detailAddr').textContent = addrToHex(actor.address); - document.getElementById('detailFullAddr').textContent = focusedAddrHex; + document.getElementById('detailAddr').innerHTML = '' + escapeHtml(addrToHex(actor.address)) + ''; + document.getElementById('detailFullAddr').innerHTML = '' + escapeHtml(focusedAddrHex) + ''; document.getElementById('detailWorker').textContent = 'W' + actor.worker_id; document.getElementById('detailMailbox').textContent = actor.mailbox_depth; document.getElementById('detailMsgCount').textContent = (actor.messages_processed || 0).toLocaleString(); diff --git a/crates/runtime-dashboard/src/dashboard_html.rs b/crates/runtime-dashboard/src/dashboard_html.rs index 06efb72..8004023 100644 --- a/crates/runtime-dashboard/src/dashboard_html.rs +++ b/crates/runtime-dashboard/src/dashboard_html.rs @@ -319,7 +319,7 @@ pub const DASHBOARD_HTML: &str = r##" hex += '\u2026'; } var tr = document.createElement('tr'); - tr.innerHTML = '' + hex + 'W' + wid + ''; + tr.innerHTML = '' + hex + 'W' + wid + ''; tbody.appendChild(tr); }); if (data.actors.length > 200) { @@ -440,7 +440,7 @@ pub const DASHBOARD_HTML: &str = r##" var msgShort = hasMsg ? shortTypeName(a.last_msg_type) : 'none'; var msgClass = hasMsg ? 'msg-type' : 'msg-type none'; var title = hasMsg ? ' title="' + escapeHtml(a.last_msg_type) + '"' : ''; - rows += '' + hex + '' + + rows += '' + hex + '' + '' + a.mailbox_depth + '' + '' + escapeHtml(msgShort) + ''; }); diff --git a/crates/runtime-dashboard/src/lib.rs b/crates/runtime-dashboard/src/lib.rs index 38b1e41..654419b 100644 --- a/crates/runtime-dashboard/src/lib.rs +++ b/crates/runtime-dashboard/src/lib.rs @@ -3,6 +3,7 @@ pub mod history; pub mod investigate; pub mod layer; pub mod trace; +mod actor_detail_html; mod actors_html; mod dashboard_html; mod server; diff --git a/crates/runtime-dashboard/src/server.rs b/crates/runtime-dashboard/src/server.rs index d67ef5a..d3e7bbd 100644 --- a/crates/runtime-dashboard/src/server.rs +++ b/crates/runtime-dashboard/src/server.rs @@ -8,6 +8,7 @@ use std::collections::HashMap; use swactor::runtime::Runtime; +use crate::actor_detail_html::ACTOR_DETAIL_HTML; use crate::actors_html::ACTORS_HTML; use crate::collector::StatsCollector; use crate::dashboard_html::DASHBOARD_HTML; @@ -192,6 +193,10 @@ pub(crate) fn spawn_http_server( Arc::clone(&distribution), ); } + _ if path.starts_with("/actor/") => { + let hex = &path[7..]; // strip "/actor/" + respond_actor_detail(request, hex); + } _ => respond_404(request), } } @@ -199,6 +204,18 @@ pub(crate) fn spawn_http_server( } } +fn respond_actor_detail(request: tiny_http::Request, hex_addr: &str) { + let html = ACTOR_DETAIL_HTML + .replace("__DASHBOARD_MODE__", "live") + .replace("__ACTOR_ADDR__", hex_addr); + let response = tiny_http::Response::from_string(html).with_header( + "Content-Type: text/html; charset=utf-8" + .parse::() + .unwrap(), + ); + let _ = request.respond(response); +} + fn handle_live_sse( request: tiny_http::Request, store: Arc, diff --git a/crates/runtime-dashboard/src/tui/app.rs b/crates/runtime-dashboard/src/tui/app.rs index c90c8fd..fb5a0d5 100644 --- a/crates/runtime-dashboard/src/tui/app.rs +++ b/crates/runtime-dashboard/src/tui/app.rs @@ -1,4 +1,4 @@ -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::time::Instant; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -30,6 +30,10 @@ pub struct ActorRow { pub last_msg_type: Option, pub messages_processed: u64, pub poisoned: bool, + /// Per-actor mailbox sparkline (from local ring buffer). + pub sparkline_mailbox: Vec, + /// Per-actor message rate sparkline. + pub sparkline_rates: Vec, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -67,6 +71,7 @@ impl SortColumn { pub enum ViewMode { Overview, WorkerDetail, + ActorDetail, #[cfg(feature = "distribution")] Distribution, } @@ -84,7 +89,9 @@ pub struct App { pub total_panics: u64, pub num_workers: usize, pub view_mode: ViewMode, + pub prev_view_mode: ViewMode, pub focused_worker: usize, + pub focused_actor: Option, #[cfg(feature = "distribution")] pub distribution: Option, @@ -99,6 +106,8 @@ pub struct App { sparkline_rates: Vec>, /// Per-worker sparkline history (mailbox depths). sparkline_mailbox: Vec>, + /// Per-actor sparkline history: address → (prev_msgs, rates, mailbox_depths). + actor_sparklines: HashMap, VecDeque)>, } impl App { @@ -116,7 +125,9 @@ impl App { total_panics: 0, num_workers: 0, view_mode: ViewMode::Overview, + prev_view_mode: ViewMode::Overview, focused_worker: 0, + focused_actor: None, #[cfg(feature = "distribution")] distribution: None, #[cfg(feature = "distribution")] @@ -126,6 +137,7 @@ impl App { msg_rates: Vec::new(), sparkline_rates: Vec::new(), sparkline_mailbox: Vec::new(), + actor_sparklines: HashMap::new(), } } @@ -136,6 +148,13 @@ impl App { self.distribution = Some(snapshot); } + /// 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| { + self.actor_rows.iter().find(|r| r.address == *addr) + }) + } + /// Actor rows filtered to the focused worker (for worker detail view). pub fn focused_actor_rows(&self) -> Vec<&ActorRow> { self.actor_rows @@ -213,9 +232,20 @@ impl App { self.total_mailbox = stats.workers.iter().map(|w| w.mailbox_depth).sum(); self.total_panics = stats.workers.iter().map(|w| w.panics).sum(); - // Build actor table + // Build actor table with sparkline history self.actor_rows.clear(); for a in &stats.actor_details { + let (prev, rates_buf, mbox_buf) = self.actor_sparklines + .entry(a.address) + .or_insert_with(|| (0, VecDeque::new(), VecDeque::new())); + + let rate_delta = a.messages_processed.saturating_sub(*prev); + *prev = a.messages_processed; + if rates_buf.len() >= 60 { rates_buf.pop_front(); } + rates_buf.push_back(rate_delta); + if mbox_buf.len() >= 60 { mbox_buf.pop_front(); } + mbox_buf.push_back(a.mailbox_depth as u64); + self.actor_rows.push(ActorRow { address: a.address, worker_id: a.worker_id, @@ -223,6 +253,8 @@ impl App { last_msg_type: a.last_msg_type.clone(), messages_processed: a.messages_processed, poisoned: a.poisoned, + sparkline_rates: rates_buf.iter().copied().collect(), + sparkline_mailbox: mbox_buf.iter().copied().collect(), }); } self.sort_actors(); @@ -291,6 +323,7 @@ impl App { KeyCode::Tab => { self.view_mode = match self.view_mode { ViewMode::Overview => ViewMode::WorkerDetail, + ViewMode::ActorDetail => ViewMode::Overview, #[cfg(feature = "distribution")] ViewMode::WorkerDetail => ViewMode::Distribution, #[cfg(not(feature = "distribution"))] @@ -306,6 +339,7 @@ impl App { match self.view_mode { ViewMode::Overview => self.handle_key_overview(key), ViewMode::WorkerDetail => self.handle_key_worker_detail(key), + ViewMode::ActorDetail => self.handle_key_actor_detail(key), #[cfg(feature = "distribution")] ViewMode::Distribution => self.handle_key_distribution(key), } @@ -329,11 +363,12 @@ impl App { KeyCode::Home => { self.selected = 0; } KeyCode::End => { self.selected = max; } KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => { - // Enter worker detail for the selected actor's worker + // Enter actor detail for the selected actor if let Some(row) = self.actor_rows.get(self.selected) { - self.focused_worker = row.worker_id; + self.focused_actor = Some(row.address); + self.prev_view_mode = ViewMode::Overview; + self.view_mode = ViewMode::ActorDetail; } - self.view_mode = ViewMode::WorkerDetail; } _ => {} } @@ -363,6 +398,16 @@ impl App { } } + fn handle_key_actor_detail(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => { + self.view_mode = self.prev_view_mode; + self.focused_actor = None; + } + _ => {} + } + } + #[cfg(feature = "distribution")] fn handle_key_distribution(&mut self, key: KeyEvent) { let max = self diff --git a/crates/runtime-dashboard/src/tui/ui.rs b/crates/runtime-dashboard/src/tui/ui.rs index c7d9d70..21987ae 100644 --- a/crates/runtime-dashboard/src/tui/ui.rs +++ b/crates/runtime-dashboard/src/tui/ui.rs @@ -11,6 +11,7 @@ pub fn draw(f: &mut Frame, app: &App, table_state: &mut TableState) { match app.view_mode { ViewMode::Overview => draw_overview(f, app, table_state), ViewMode::WorkerDetail => draw_worker_detail(f, app, table_state), + ViewMode::ActorDetail => draw_actor_detail(f, app), #[cfg(feature = "distribution")] ViewMode::Distribution => draw_distribution(f, app, table_state), } @@ -479,6 +480,99 @@ fn draw_focused_actor_table(f: &mut Frame, app: &App, table_state: &mut TableSta f.render_stateful_widget(table, area, table_state); } +// ─── Actor Detail View ────────────────────────────────────────────────────── + +fn draw_actor_detail(f: &mut Frame, app: &App) { + let actor = match app.focused_actor_row() { + Some(a) => a, + None => { + let msg = Paragraph::new(" No actor selected. Press Esc to go back.") + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(msg, f.area()); + return; + } + }; + + let chunks = Layout::vertical([ + Constraint::Length(5), // Info card + Constraint::Length(5), // Rate sparkline + Constraint::Length(5), // Mailbox sparkline + Constraint::Length(1), // Help bar + Constraint::Fill(1), // Spacer + ]) + .split(f.area()); + + // Info card + let addr_str = format!("{}", actor.address); + let status = if actor.poisoned { "POISONED" } else { "Healthy" }; + let status_color = if actor.poisoned { Color::Red } else { Color::Green }; + let msg_type = actor.last_msg_type.as_deref() + .map(|s| short_type_name(Some(s))) + .unwrap_or_else(|| "\u{2014}".to_string()); + + let info_lines = vec![ + Line::from(vec![ + Span::styled(" Address: ", Style::default().fg(Color::DarkGray)), + Span::styled(addr_str, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + ]), + Line::from(vec![ + Span::styled(" Worker: ", Style::default().fg(Color::DarkGray)), + Span::styled(format!("W{}", actor.worker_id), Style::default().fg(Color::Cyan)), + Span::styled(" Status: ", Style::default().fg(Color::DarkGray)), + Span::styled(status, Style::default().fg(status_color).add_modifier(Modifier::BOLD)), + ]), + Line::from(vec![ + Span::styled(" Messages: ", Style::default().fg(Color::DarkGray)), + Span::styled( + format_num(actor.messages_processed), + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ), + Span::styled(" Mailbox: ", Style::default().fg(Color::DarkGray)), + Span::styled( + format!("{}", actor.mailbox_depth), + Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD), + ), + Span::styled(" Last Msg: ", Style::default().fg(Color::DarkGray)), + Span::styled(msg_type, Style::default().fg(Color::Green)), + ]), + ]; + + let info_block = Block::default() + .borders(Borders::ALL) + .title(" Actor Detail "); + let info = Paragraph::new(info_lines).block(info_block); + f.render_widget(info, chunks[0]); + + // Rate sparkline + let rate_block = Block::default() + .borders(Borders::ALL) + .title(Span::styled(" Msg Rate ", Style::default().fg(Color::Green))); + let rate_sparkline = Sparkline::default() + .block(rate_block) + .data(&actor.sparkline_rates) + .style(Style::default().fg(Color::Green)); + f.render_widget(rate_sparkline, chunks[1]); + + // Mailbox sparkline + let mbox_block = Block::default() + .borders(Borders::ALL) + .title(Span::styled(" Mailbox Depth ", Style::default().fg(Color::Blue))); + let mbox_sparkline = Sparkline::default() + .block(mbox_block) + .data(&actor.sparkline_mailbox) + .style(Style::default().fg(Color::Blue)); + f.render_widget(mbox_sparkline, chunks[2]); + + // Help bar + let help = Line::from(vec![ + Span::styled( + " Esc/\u{2190}: back Tab: overview q: quit", + Style::default().fg(Color::DarkGray), + ), + ]); + f.render_widget(Paragraph::new(help), chunks[3]); +} + // ─── Distribution View ────────────────────────────────────────────────────── #[cfg(feature = "distribution")]