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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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")]