dashboard #38

Merged
zacheryasc merged 10 commits from dashboard into master 2026-02-13 14:42:11 +00:00
7 changed files with 372 additions and 13 deletions
Showing only changes of commit c7a0bd2523 - Show all commits

View file

@ -62,6 +62,40 @@ pub const ACTOR_DETAIL_HTML: &str = r##"<!DOCTYPE html>
.sparkline-panel h3 { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
.sparkline-panel svg { width: 100%; height: 50px; }
.logs-panel {
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
padding: 14px; margin-bottom: 12px;
}
.logs-panel h3 {
font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px;
margin-bottom: 8px; display: flex; align-items: center; gap: 12px;
}
.level-filter { display: flex; gap: 4px; }
.level-btn {
background: #1e2030; border: 1px solid #2a2d3e; border-radius: 3px;
color: #888; font-size: 10px; padding: 1px 6px; cursor: pointer;
font-family: inherit;
}
.level-btn.active { border-color: #555; color: #fff; }
.level-btn.error { color: #f44336; }
.level-btn.warn { color: #ff9800; }
.level-btn.info { color: #2196f3; }
.level-btn.debug { color: #888; }
.level-btn.trace { color: #555; }
.log-list {
max-height: 400px; overflow-y: auto; font-size: 11px; line-height: 1.6;
}
.log-entry { display: flex; gap: 8px; padding: 1px 0; border-bottom: 1px solid #1a1c2e; }
.log-time { color: #555; white-space: nowrap; min-width: 80px; }
.log-level { font-weight: 700; min-width: 50px; }
.log-level.ERROR { color: #f44336; }
.log-level.WARN { color: #ff9800; }
.log-level.INFO { color: #2196f3; }
.log-level.DEBUG { color: #888; }
.log-level.TRACE { color: #555; }
.log-msg { color: #e0e0e0; word-break: break-all; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0f1117; }
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
@ -113,6 +147,21 @@ pub const ACTOR_DETAIL_HTML: &str = r##"<!DOCTYPE html>
<h3>Mailbox Depth</h3>
<svg id="mboxSpark" viewBox="0 0 400 50" preserveAspectRatio="none"></svg>
</div>
<div class="logs-panel">
<h3>
<span>Logs</span>
<span id="logCount" style="color:#555;">(0)</span>
<div class="level-filter">
<button class="level-btn error active" data-level="ERROR" onclick="toggleLevel(this)">ERR</button>
<button class="level-btn warn active" data-level="WARN" onclick="toggleLevel(this)">WARN</button>
<button class="level-btn info active" data-level="INFO" onclick="toggleLevel(this)">INFO</button>
<button class="level-btn debug active" data-level="DEBUG" onclick="toggleLevel(this)">DBG</button>
<button class="level-btn trace active" data-level="TRACE" onclick="toggleLevel(this)">TRC</button>
</div>
</h3>
<div class="log-list" id="logList"></div>
</div>
</div>
<script>
@ -210,12 +259,87 @@ pub const ACTOR_DETAIL_HTML: &str = r##"<!DOCTYPE html>
updateSparklineSvg(document.getElementById('mboxSpark'), history.mailbox, '#2196f3');
}
// ─── Logging ─────────────────────────────────────────────────
var activeLevels = { ERROR: true, WARN: true, INFO: true, DEBUG: true, TRACE: true };
var allLogs = [];
var logList = document.getElementById('logList');
var logCount = document.getElementById('logCount');
var autoScroll = true;
window.toggleLevel = function(btn) {
var lvl = btn.getAttribute('data-level');
activeLevels[lvl] = !activeLevels[lvl];
btn.classList.toggle('active');
renderLogs();
};
function formatLogTime(ms) {
var d = new Date(ms);
return ('0' + d.getHours()).slice(-2) + ':' +
('0' + d.getMinutes()).slice(-2) + ':' +
('0' + d.getSeconds()).slice(-2) + '.' +
('00' + d.getMilliseconds()).slice(-3);
}
function renderLogs() {
var visible = allLogs.filter(function(e) { return activeLevels[e.level]; });
logCount.textContent = '(' + visible.length + ')';
var html = '';
for (var i = 0; i < visible.length; i++) {
var e = visible[i];
html += '<div class="log-entry">' +
'<span class="log-time">' + formatLogTime(e.timestamp_ms) + '</span>' +
'<span class="log-level ' + e.level + '">' + e.level + '</span>' +
'<span class="log-msg">' + escapeHtml(e.message) + '</span>' +
'</div>';
}
logList.innerHTML = html;
if (autoScroll) {
logList.scrollTop = logList.scrollHeight;
}
}
function escapeHtml(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function addLogEntries(events) {
for (var i = 0; i < events.length; i++) {
var e = events[i];
if (!e.actor_addr) continue;
// Match if event's actor_addr starts with or contains target
var a = e.actor_addr.toLowerCase();
if (a.indexOf(targetAddr.toLowerCase()) === 0 || a === targetAddr.toLowerCase()) {
allLogs.push(e);
}
}
// Keep bounded
while (allLogs.length > 500) allLogs.shift();
renderLogs();
}
// Fetch initial logs
fetch('/api/logs?actor=' + targetAddr + '&limit=200')
.then(function(r) { return r.json(); })
.then(function(data) { if (Array.isArray(data)) addLogEntries(data); })
.catch(function() {});
logList.addEventListener('scroll', function() {
autoScroll = (logList.scrollTop + logList.clientHeight >= logList.scrollHeight - 20);
});
// ─── SSE ────────────────────────────────────────────────────
var es = new EventSource('/events');
es.addEventListener('stats', function(e) {
try { updateDetail(JSON.parse(e.data)); } catch(err) { console.error(err); }
});
es.addEventListener('activity', function(e) {
try { addLogEntries(JSON.parse(e.data)); } catch(err) { console.error(err); }
});
es.addEventListener('done', function() {
dot.className = 'status-dot disconnected';
es.close();

View file

@ -20,6 +20,7 @@ pub struct DashboardEvent {
pub level: String,
pub message: String,
pub worker_id: Option<usize>,
pub actor_addr: Option<String>,
pub fields: serde_json::Map<String, serde_json::Value>,
}
@ -83,6 +84,28 @@ impl EventStore {
(batch, new_cursor)
}
/// Read recent events for a specific actor address (hex prefix match).
/// Returns up to `limit` most recent matching events.
pub fn read_for_actor(&self, actor_hex: &str, limit: usize) -> Vec<DashboardEvent> {
let events = self.events.lock().unwrap();
let lower = actor_hex.to_lowercase();
events
.iter()
.rev()
.filter(|e| {
e.actor_addr
.as_ref()
.map(|a| a.to_lowercase().starts_with(&lower) || a.to_lowercase().contains(&lower))
.unwrap_or(false)
})
.take(limit)
.cloned()
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect()
}
/// Drains the full recording log. Only available when recording is enabled.
/// This is destructive — events are consumed. Intended for `save_trace()`.
pub fn all_events(&self) -> Option<Vec<DashboardEvent>> {
@ -183,24 +206,39 @@ where
let mut visitor = FieldVisitor::new();
event.record(&mut visitor);
// Walk span context to find worker_id
// Walk span context to find worker_id and actor_addr
let mut worker_id = None;
let mut actor_addr = None;
if let Some(scope) = ctx.event_scope(event) {
for span in scope {
let exts = span.extensions();
if let Some(wid) = exts.get::<WorkerIdField>() {
worker_id = Some(wid.0);
if worker_id.is_none() {
if let Some(wid) = exts.get::<WorkerIdField>() {
worker_id = Some(wid.0);
}
}
if actor_addr.is_none() {
if let Some(aa) = exts.get::<ActorAddrField>() {
actor_addr = Some(aa.0.clone());
}
}
if worker_id.is_some() && actor_addr.is_some() {
break;
}
}
}
// Also check if worker_id was a field on the event itself
// Also check if worker_id or actor_addr was a field on the event itself
if worker_id.is_none() {
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") {
worker_id = n.as_u64().map(|v| v as usize);
}
}
if actor_addr.is_none() {
if let Some(serde_json::Value::String(s)) = visitor.fields.get("actor_addr") {
actor_addr = Some(s.clone());
}
}
let dashboard_event = DashboardEvent {
seq: 0, // filled by push()
@ -208,6 +246,7 @@ where
level: event.metadata().level().to_string(),
message: visitor.message,
worker_id,
actor_addr,
fields: visitor.fields,
};
@ -215,19 +254,25 @@ where
}
fn on_new_span(&self, attrs: &span::Attributes<'_>, id: &span::Id, ctx: Context<'_, S>) {
// Extract worker_id from span fields and store in extensions
// Extract worker_id and actor_addr from span fields and store in extensions
let mut visitor = FieldVisitor::new();
attrs.record(&mut visitor);
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") {
if let Some(wid) = n.as_u64() {
if let Some(span) = ctx.span(id) {
if let Some(span) = ctx.span(id) {
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") {
if let Some(wid) = n.as_u64() {
span.extensions_mut().insert(WorkerIdField(wid as usize));
}
}
if let Some(serde_json::Value::String(s)) = visitor.fields.get("actor_addr") {
span.extensions_mut().insert(ActorAddrField(s.clone()));
}
}
}
}
/// Stored in span extensions to propagate worker_id to child events.
struct WorkerIdField(usize);
/// Stored in span extensions to propagate actor_addr to child events.
struct ActorAddrField(String);

View file

@ -204,6 +204,9 @@ pub(crate) fn spawn_http_server(
Arc::clone(&distribution),
);
}
"/api/logs" => {
handle_logs_api(request, &url, Arc::clone(&store));
}
_ if path.starts_with("/actor/") => {
let hex = &path[7..]; // strip "/actor/"
respond_actor_detail(request, hex);
@ -445,6 +448,32 @@ fn handle_topology_api(
let _ = request.respond(response);
}
fn handle_logs_api(request: tiny_http::Request, url: &str, store: Arc<EventStore>) {
let params = parse_query_string(url);
let actor = params.get("actor").cloned().unwrap_or_default();
let limit: usize = params
.get("limit")
.and_then(|s| s.parse().ok())
.unwrap_or(200);
let level = params.get("level").cloned();
let mut events = store.read_for_actor(&actor, limit);
// Filter by level if specified
if let Some(ref lvl) = level {
let lvl_upper = lvl.to_uppercase();
events.retain(|e| e.level == lvl_upper);
}
let json = serde_json::to_string(&events).unwrap_or_else(|_| "[]".into());
let response = tiny_http::Response::from_string(json).with_header(
"Content-Type: application/json"
.parse::<tiny_http::Header>()
.unwrap(),
);
let _ = request.respond(response);
}
fn handle_history_api(request: tiny_http::Request, history: Arc<DashboardHistory>) {
let json = history.worker_history_json();
let response = tiny_http::Response::from_string(json).with_header(

View file

@ -1,10 +1,12 @@
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::time::Instant;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use swactor::actor::ActorAddress;
use swactor::stats::{RuntimeStats, TickTiming};
use crate::layer::{DashboardEvent, EventStore};
use crate::warnings::{Warning, WarningConfig, WarningDetector};
/// Per-worker data prepared for rendering.
@ -115,6 +117,14 @@ pub struct App {
/// Per-actor sparkline history: address → (prev_msgs, rates, mailbox_depths).
actor_sparklines: HashMap<ActorAddress, (u64, VecDeque<u64>, VecDeque<u64>)>,
warning_detector: WarningDetector,
/// Event store for fetching per-actor logs.
event_store: Option<Arc<EventStore>>,
/// Cached log entries for the focused actor.
pub actor_logs: Vec<DashboardEvent>,
/// Scroll offset for actor log view.
pub log_scroll: usize,
/// Active log level filter (all enabled by default).
pub log_levels: [bool; 5], // ERROR, WARN, INFO, DEBUG, TRACE
}
impl App {
@ -150,9 +160,18 @@ impl App {
sparkline_mailbox: Vec::new(),
actor_sparklines: HashMap::new(),
warning_detector: WarningDetector::new(WarningConfig::default()),
event_store: None,
actor_logs: Vec::new(),
log_scroll: 0,
log_levels: [true; 5],
}
}
/// Set the event store for per-actor log retrieval.
pub fn set_event_store(&mut self, store: Arc<EventStore>) {
self.event_store = Some(store);
}
#[cfg(feature = "distribution")]
pub fn update_distribution(&mut self, snapshot: distribution::snapshot::DistributionNodeSnapshot) {
let max = if snapshot.members.is_empty() { 0 } else { snapshot.members.len() - 1 };
@ -182,6 +201,40 @@ impl App {
})
}
/// Refresh the actor log cache from the event store.
pub fn refresh_actor_logs(&mut self) {
if let (Some(addr), Some(store)) = (&self.focused_actor, &self.event_store) {
let hex = format!("{}", addr);
self.actor_logs = store.read_for_actor(&hex, 200);
} else {
self.actor_logs.clear();
}
}
/// Get visible log entries (filtered by level).
pub fn visible_logs(&self) -> Vec<&DashboardEvent> {
self.actor_logs
.iter()
.filter(|e| {
match e.level.as_str() {
"ERROR" => self.log_levels[0],
"WARN" => self.log_levels[1],
"INFO" => self.log_levels[2],
"DEBUG" => self.log_levels[3],
"TRACE" => self.log_levels[4],
_ => true,
}
})
.collect()
}
/// Toggle a log level filter (0=ERROR, 1=WARN, 2=INFO, 3=DEBUG, 4=TRACE).
pub fn toggle_log_level(&mut self, idx: usize) {
if idx < 5 {
self.log_levels[idx] = !self.log_levels[idx];
}
}
/// Actor rows filtered to the focused worker (for worker detail view).
pub fn focused_actor_rows(&self) -> Vec<&ActorRow> {
self.actor_rows
@ -289,6 +342,11 @@ impl App {
// Run warning detection
self.warnings = self.warning_detector.check(&stats);
// Refresh actor logs if in detail view
if self.view_mode == ViewMode::ActorDetail {
self.refresh_actor_logs();
}
// Clamp selection
if !self.actor_rows.is_empty() {
self.selected = self.selected.min(self.actor_rows.len() - 1);
@ -432,6 +490,8 @@ impl App {
self.focused_actor = Some(row.address);
self.prev_view_mode = ViewMode::Overview;
self.view_mode = ViewMode::ActorDetail;
self.log_scroll = 0;
self.refresh_actor_logs();
}
}
_ => {}
@ -463,11 +523,33 @@ impl App {
}
fn handle_key_actor_detail(&mut self, key: KeyEvent) {
let max_scroll = self.visible_logs().len().saturating_sub(1);
match key.code {
KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => {
self.view_mode = self.prev_view_mode;
self.focused_actor = None;
self.actor_logs.clear();
self.log_scroll = 0;
}
KeyCode::Down | KeyCode::Char('j') => {
self.log_scroll = (self.log_scroll + 1).min(max_scroll);
}
KeyCode::Up | KeyCode::Char('k') => {
self.log_scroll = self.log_scroll.saturating_sub(1);
}
KeyCode::PageDown => {
self.log_scroll = (self.log_scroll + 20).min(max_scroll);
}
KeyCode::PageUp => {
self.log_scroll = self.log_scroll.saturating_sub(20);
}
KeyCode::Home => { self.log_scroll = 0; }
KeyCode::End => { self.log_scroll = max_scroll; }
KeyCode::Char('1') => self.toggle_log_level(0),
KeyCode::Char('2') => self.toggle_log_level(1),
KeyCode::Char('3') => self.toggle_log_level(2),
KeyCode::Char('4') => self.toggle_log_level(3),
KeyCode::Char('5') => self.toggle_log_level(4),
_ => {}
}
}

View file

@ -18,6 +18,7 @@ use swactor::runtime::Runtime;
use crate::collector::StatsCollector;
#[cfg(feature = "distribution")]
use crate::distribution_collector::DistributionStatsProvider;
use crate::layer::EventStore;
use self::app::App;
use self::event::{AppEvent, EventLoop};
use self::types::RuntimeEndpoint;
@ -47,6 +48,7 @@ pub fn start_tui(
#[cfg(feature = "distribution")]
distribution: Option<Arc<dyn DistributionStatsProvider>>,
config: TuiConfig,
event_store: Option<Arc<EventStore>>,
) -> io::Result<()> {
// Set up terminal
crossterm::terminal::enable_raw_mode()?;
@ -72,6 +74,7 @@ pub fn start_tui(
#[cfg(feature = "distribution")]
distribution,
config,
event_store,
);
// Restore terminal
@ -92,8 +95,12 @@ fn run_loop(
#[cfg(feature = "distribution")]
distribution: Option<Arc<dyn DistributionStatsProvider>>,
config: TuiConfig,
event_store: Option<Arc<EventStore>>,
) -> io::Result<()> {
let mut app = App::new();
if let Some(store) = event_store {
app.set_event_store(store);
}
let mut table_state = TableState::default();
let events = EventLoop::new(config.poll_interval_ms);

View file

@ -543,7 +543,7 @@ fn draw_actor_detail(f: &mut Frame, app: &App) {
Constraint::Length(5), // Rate sparkline
Constraint::Length(5), // Mailbox sparkline
Constraint::Length(1), // Help bar
Constraint::Fill(1), // Spacer
Constraint::Fill(1), // Logs panel
])
.split(f.area());
@ -609,13 +609,82 @@ fn draw_actor_detail(f: &mut Frame, app: &App) {
f.render_widget(mbox_sparkline, chunks[2]);
// Help bar
let help = Line::from(vec![
let level_names = ["ERR", "WARN", "INFO", "DBG", "TRC"];
let level_colors = [Color::Red, Color::Yellow, Color::Blue, Color::DarkGray, Color::DarkGray];
let mut help_spans: Vec<Span> = vec![
Span::styled(
" Esc/\u{2190}: back Tab: overview q: quit",
" Esc: back \u{2191}\u{2193}: scroll logs ",
Style::default().fg(Color::DarkGray),
),
]);
f.render_widget(Paragraph::new(help), chunks[3]);
];
for (i, &name) in level_names.iter().enumerate() {
let active = app.log_levels[i];
let color = if active { level_colors[i] } else { Color::DarkGray };
let style = if active {
Style::default().fg(color).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(color)
};
help_spans.push(Span::styled(format!("{}:{} ", i + 1, name), style));
}
f.render_widget(Paragraph::new(Line::from(help_spans)), chunks[3]);
// Logs panel
draw_actor_logs(f, app, chunks[4]);
}
fn draw_actor_logs(f: &mut Frame, app: &App, area: Rect) {
let visible = app.visible_logs();
let log_count = visible.len();
let inner_height = area.height.saturating_sub(2) as usize; // borders
// Compute scroll window
let scroll = app.log_scroll.min(log_count.saturating_sub(inner_height));
let lines: Vec<Line> = visible
.iter()
.skip(scroll)
.take(inner_height)
.map(|e| {
let level_color = match e.level.as_str() {
"ERROR" => Color::Red,
"WARN" => Color::Yellow,
"INFO" => Color::Blue,
"DEBUG" => Color::DarkGray,
"TRACE" => Color::DarkGray,
_ => Color::White,
};
let ts = {
let secs = e.timestamp_ms / 1000;
let ms = e.timestamp_ms % 1000;
let h = (secs / 3600) % 24;
let m = (secs / 60) % 60;
let s = secs % 60;
format!("{:02}:{:02}:{:02}.{:03}", h, m, s, ms)
};
Line::from(vec![
Span::styled(
format!(" {} ", ts),
Style::default().fg(Color::DarkGray),
),
Span::styled(
format!("{:<5} ", e.level),
Style::default().fg(level_color).add_modifier(Modifier::BOLD),
),
Span::styled(
e.message.clone(),
Style::default().fg(Color::White),
),
])
})
.collect();
let title = format!(" Logs ({}) ", log_count);
let block = Block::default()
.borders(Borders::ALL)
.title(title);
let paragraph = Paragraph::new(lines).block(block);
f.render_widget(paragraph, area);
}
// ─── Distribution View ──────────────────────────────────────────────────────

View file

@ -698,6 +698,9 @@ impl ActorPool {
continue;
}
#[cfg(feature = "tracing")]
let _actor_span = tracing::trace_span!("actor.tick", actor_addr = %addr).entered();
let ctx = Ctx::new(inner, addr);
// Call on_start once, before first message