feat: time-series history infrastructure with sparklines (Stage 1)

Add in-process ring buffer history for per-worker and per-actor stats,
with sparkline rendering on both web (SVG) and TUI (ratatui) surfaces.

- history.rs: DashboardHistory with bounded VecDeque ring buffers, LRU
  actor eviction, thread-safe RwLock, 4 unit tests
- SSE producer records stats every tick, sends initial history snapshot
- /api/history REST endpoint for on-demand history JSON
- Web: inline SVG sparklines in worker detail headers (rates + mailbox)
- TUI: per-worker Sparkline widgets in overview layout

Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
This commit is contained in:
Claude 2026-02-13 08:49:19 +00:00
parent a1f5f581fe
commit 144b4ca5ef
6 changed files with 504 additions and 4 deletions

View file

@ -113,6 +113,8 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
.worker-group-header .wid { font-weight: 700; } .worker-group-header .wid { font-weight: 700; }
.worker-group-header .summary { color: #888; font-size: 11px; } .worker-group-header .summary { color: #888; font-size: 11px; }
.worker-group-header .toggle { color: #555; font-size: 14px; } .worker-group-header .toggle { color: #555; font-size: 14px; }
.worker-group-header .sparkline-wrap { display: inline-flex; gap: 8px; margin-left: 12px; }
.worker-group-header .sparkline-wrap svg { vertical-align: middle; }
.worker-group-body { display: none; } .worker-group-body { display: none; }
.worker-group.open .worker-group-body { display: block; } .worker-group.open .worker-group-body { display: block; }
.worker-group-body table { width: 100%; border-collapse: collapse; } .worker-group-body table { width: 100%; border-collapse: collapse; }
@ -203,6 +205,7 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
var isReplay = (DASHBOARD_MODE === 'replay'); var isReplay = (DASHBOARD_MODE === 'replay');
var lastUptimeMs = null; var lastUptimeMs = null;
var lastStatsTime = null; var lastStatsTime = null;
var workerHistory = {}; // { id: { message_rates: [], mailbox_depths: [] } }
var dot = document.getElementById('statusDot'); var dot = document.getElementById('statusDot');
var uptimeLabel = document.getElementById('uptimeLabel'); var uptimeLabel = document.getElementById('uptimeLabel');
@ -347,6 +350,34 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
return parts[parts.length - 1]; return parts[parts.length - 1];
} }
function renderSparklineSvg(data, w, h, color) {
if (!data || data.length < 2) return '';
var max = Math.max.apply(null, data);
if (max === 0) max = 1;
var step = w / (data.length - 1);
var points = data.map(function(v, i) {
return (i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2) - 1).toFixed(1);
}).join(' ');
return '<svg width="' + w + '" height="' + h + '" style="vertical-align:middle">' +
'<polyline fill="none" stroke="' + color + '" stroke-width="1.5" points="' + points + '"/></svg>';
}
function pushHistorySample(stats) {
if (!stats.workers) return;
stats.workers.forEach(function(w) {
if (!workerHistory[w.id]) {
workerHistory[w.id] = { message_rates: [], mailbox_depths: [], prev_msgs: w.messages_processed };
}
var wh = workerHistory[w.id];
var rate = w.messages_processed - wh.prev_msgs;
if (rate < 0) rate = 0;
wh.prev_msgs = w.messages_processed;
wh.message_rates.push(rate);
wh.mailbox_depths.push(w.mailbox_depth);
if (wh.message_rates.length > 300) { wh.message_rates.shift(); wh.mailbox_depths.shift(); }
});
}
function updateWorkerDetails(data) { function updateWorkerDetails(data) {
var container = document.getElementById('workerDetailContainer'); var container = document.getElementById('workerDetailContainer');
if (!data.workers) return; if (!data.workers) return;
@ -377,8 +408,17 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
var hdr = document.createElement('div'); var hdr = document.createElement('div');
hdr.className = 'worker-group-header'; hdr.className = 'worker-group-header';
var panicHtml = g.info.panics > 0 ? ', <span style="color:#f44336">' + g.info.panics + ' panics</span>' : ''; var panicHtml = g.info.panics > 0 ? ', <span style="color:#f44336">' + g.info.panics + ' panics</span>' : '';
var wh = workerHistory[wid];
var sparkHtml = '';
if (wh) {
sparkHtml = '<span class="sparkline-wrap">' +
renderSparklineSvg(wh.message_rates, 80, 16, '#4caf50') +
renderSparklineSvg(wh.mailbox_depths, 80, 16, '#2196f3') +
'</span>';
}
hdr.innerHTML = hdr.innerHTML =
'<span class="wid" style="color:' + colors[wid % colors.length] + '">W' + wid + '</span>' + '<span class="wid" style="color:' + colors[wid % colors.length] + '">W' + wid + '</span>' +
sparkHtml +
'<span class="summary">' + g.actors.length + ' actors, ' + '<span class="summary">' + g.actors.length + ' actors, ' +
g.info.messages_processed.toLocaleString() + ' msgs, mbox ' + g.info.mailbox_depth + panicHtml + '</span>' + g.info.messages_processed.toLocaleString() + ' msgs, mbox ' + g.info.mailbox_depth + panicHtml + '</span>' +
'<span class="toggle">' + (isOpen ? '\u25BC' : '\u25B6') + '</span>'; '<span class="toggle">' + (isOpen ? '\u25BC' : '\u25B6') + '</span>';
@ -467,7 +507,26 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
var es = new EventSource('/events'); var es = new EventSource('/events');
es.addEventListener('stats', function(e) { es.addEventListener('stats', function(e) {
try { updateStats(JSON.parse(e.data)); } catch(err) { console.error('stats parse error', err); } try {
var data = JSON.parse(e.data);
pushHistorySample(data);
updateStats(data);
} catch(err) { console.error('stats parse error', err); }
});
es.addEventListener('history', function(e) {
try {
var data = JSON.parse(e.data);
if (data.workers) {
data.workers.forEach(function(w) {
workerHistory[w.id] = {
message_rates: w.message_rates || [],
mailbox_depths: w.mailbox_depths || [],
prev_msgs: 0
};
});
}
} catch(err) { console.error('history parse error', err); }
}); });
es.addEventListener('activity', function(e) { es.addEventListener('activity', function(e) {

View file

@ -0,0 +1,343 @@
//! In-process time-series history for dashboard sparklines and trend detection.
//!
//! Stores bounded ring buffers of per-worker and per-actor stats, sampled at
//! a configurable interval. All data is kept in memory with automatic eviction
//! of the oldest samples when capacity is reached.
use std::collections::{HashMap, VecDeque};
use std::sync::RwLock;
use swactor::actor::ActorAddress;
use swactor::stats::{ActorInfo, RuntimeStats};
/// Configuration for history collection.
#[derive(Debug, Clone)]
pub struct HistoryConfig {
/// Maximum samples per worker (default: 300 = 5 min at 1/sec).
pub max_worker_samples: usize,
/// Maximum samples per actor (default: 300).
pub max_actor_samples: usize,
/// Maximum number of actors tracked (LRU eviction). Default: 1000.
pub max_tracked_actors: usize,
}
impl Default for HistoryConfig {
fn default() -> Self {
Self {
max_worker_samples: 300,
max_actor_samples: 300,
max_tracked_actors: 1000,
}
}
}
/// Time-series data for a single worker.
#[derive(Debug, Clone)]
pub struct WorkerHistory {
pub message_rates: VecDeque<f64>,
pub mailbox_depths: VecDeque<u64>,
pub actor_counts: VecDeque<u32>,
prev_messages: u64,
}
impl WorkerHistory {
fn new() -> Self {
Self {
message_rates: VecDeque::new(),
mailbox_depths: VecDeque::new(),
actor_counts: VecDeque::new(),
prev_messages: 0,
}
}
fn push(&mut self, messages_processed: u64, mailbox_depth: usize, num_actors: usize, cap: usize) {
let rate = messages_processed.saturating_sub(self.prev_messages) as f64;
self.prev_messages = messages_processed;
push_bounded(&mut self.message_rates, rate, cap);
push_bounded(&mut self.mailbox_depths, mailbox_depth as u64, cap);
push_bounded(&mut self.actor_counts, num_actors as u32, cap);
}
}
/// Time-series data for a single actor.
#[derive(Debug, Clone)]
pub struct ActorHistory {
pub mailbox_depths: VecDeque<u64>,
pub message_rates: VecDeque<f64>,
prev_messages: u64,
last_seen_sample: u64,
}
impl ActorHistory {
fn new(sample_counter: u64) -> Self {
Self {
mailbox_depths: VecDeque::new(),
message_rates: VecDeque::new(),
prev_messages: 0,
last_seen_sample: sample_counter,
}
}
fn push(&mut self, info: &ActorInfo, cap: usize, sample_counter: u64) {
let rate = info.messages_processed.saturating_sub(self.prev_messages) as f64;
self.prev_messages = info.messages_processed;
self.last_seen_sample = sample_counter;
push_bounded(&mut self.mailbox_depths, info.mailbox_depth as u64, cap);
push_bounded(&mut self.message_rates, rate, cap);
}
}
fn push_bounded<T>(buf: &mut VecDeque<T>, val: T, cap: usize) {
if buf.len() >= cap {
buf.pop_front();
}
buf.push_back(val);
}
/// Thread-safe history store. Written by the sampler, read by SSE/TUI.
pub struct DashboardHistory {
inner: RwLock<HistoryInner>,
config: HistoryConfig,
}
struct HistoryInner {
workers: Vec<WorkerHistory>,
actors: HashMap<ActorAddress, ActorHistory>,
sample_counter: u64,
}
impl DashboardHistory {
pub fn new(config: HistoryConfig) -> Self {
Self {
inner: RwLock::new(HistoryInner {
workers: Vec::new(),
actors: HashMap::new(),
sample_counter: 0,
}),
config,
}
}
/// Record a stats snapshot. Called by the sampler thread.
pub fn record(&self, stats: &RuntimeStats) {
let mut inner = self.inner.write().unwrap();
inner.sample_counter += 1;
let counter = inner.sample_counter;
// Resize workers vec if needed
while inner.workers.len() < stats.workers.len() {
inner.workers.push(WorkerHistory::new());
}
// Record per-worker data
for w in &stats.workers {
if let Some(wh) = inner.workers.get_mut(w.id) {
wh.push(
w.messages_processed,
w.mailbox_depth,
w.num_actors,
self.config.max_worker_samples,
);
}
}
// Record per-actor data
for a in &stats.actor_details {
let ah = inner.actors.entry(a.address).or_insert_with(|| ActorHistory::new(counter));
ah.push(a, self.config.max_actor_samples, counter);
}
// LRU eviction: remove actors not seen recently if over capacity
if inner.actors.len() > self.config.max_tracked_actors {
let mut entries: Vec<(ActorAddress, u64)> = inner
.actors
.iter()
.map(|(addr, ah)| (*addr, ah.last_seen_sample))
.collect();
entries.sort_by_key(|&(_, seen)| seen);
let to_remove = inner.actors.len() - self.config.max_tracked_actors;
for (addr, _) in entries.into_iter().take(to_remove) {
inner.actors.remove(&addr);
}
}
}
/// Get a snapshot of worker history for rendering sparklines.
/// Returns Vec indexed by worker_id, each containing recent message rates.
pub fn worker_sparklines(&self) -> Vec<Vec<u64>> {
let inner = self.inner.read().unwrap();
inner
.workers
.iter()
.map(|wh| wh.message_rates.iter().map(|r| *r as u64).collect())
.collect()
}
/// Get worker mailbox depth history.
pub fn worker_mailbox_sparklines(&self) -> Vec<Vec<u64>> {
let inner = self.inner.read().unwrap();
inner
.workers
.iter()
.map(|wh| wh.mailbox_depths.iter().copied().collect())
.collect()
}
/// Get sparkline data for a specific actor.
pub fn actor_sparkline(&self, addr: &ActorAddress) -> Option<(Vec<u64>, Vec<u64>)> {
let inner = self.inner.read().unwrap();
inner.actors.get(addr).map(|ah| {
let mailbox: Vec<u64> = ah.mailbox_depths.iter().copied().collect();
let rates: Vec<u64> = ah.message_rates.iter().map(|r| *r as u64).collect();
(mailbox, rates)
})
}
/// Get total sample count (useful for knowing if history is available).
pub fn sample_count(&self) -> u64 {
self.inner.read().unwrap().sample_counter
}
/// Serialize worker history as JSON for the SSE initial payload.
pub fn worker_history_json(&self) -> String {
let sparklines = self.worker_sparklines();
let mailbox = self.worker_mailbox_sparklines();
serde_json::json!({
"workers": sparklines.iter().enumerate().map(|(i, rates)| {
serde_json::json!({
"id": i,
"message_rates": rates,
"mailbox_depths": mailbox.get(i).unwrap_or(&Vec::new()),
})
}).collect::<Vec<_>>(),
})
.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use swactor::stats::{ActorInfo, WorkerInfo};
fn make_stats(workers: Vec<(u64, usize, usize)>, actors: Vec<ActorInfo>) -> RuntimeStats {
RuntimeStats {
num_workers: workers.len(),
uptime_ms: 0,
actors: actors.iter().map(|a| (a.address, a.worker_id)).collect(),
workers: workers
.into_iter()
.enumerate()
.map(|(id, (msgs, depth, n_actors))| WorkerInfo {
id,
num_actors: n_actors,
mailbox_depth: depth,
messages_processed: msgs,
local_sends: 0,
cross_sends: 0,
inbox_sends: 0,
type_mismatches: 0,
panics: 0,
messages_dropped: 0,
restarts: 0,
stops: 0,
})
.collect(),
actor_details: actors,
tick_timings: Vec::new(),
}
}
fn make_actor(id: u8, worker: usize, depth: usize, msgs: u64) -> ActorInfo {
ActorInfo {
address: ActorAddress([id; 32]),
worker_id: worker,
mailbox_depth: depth,
last_msg_type: None,
messages_processed: msgs,
poisoned: false,
}
}
#[test]
fn worker_rates_accumulate_over_samples() {
let history = DashboardHistory::new(HistoryConfig::default());
// First sample: establishes baseline (rate will be the raw value since prev=0)
let stats1 = make_stats(vec![(100, 5, 2)], vec![]);
history.record(&stats1);
// Second sample: delta = 150 - 100 = 50
let stats2 = make_stats(vec![(150, 3, 2)], vec![]);
history.record(&stats2);
let sparklines = history.worker_sparklines();
assert_eq!(sparklines.len(), 1);
assert_eq!(sparklines[0].len(), 2);
assert_eq!(sparklines[0][0], 100); // first sample: 100 - 0
assert_eq!(sparklines[0][1], 50); // second sample: 150 - 100
}
#[test]
fn bounded_eviction_drops_oldest() {
let config = HistoryConfig {
max_worker_samples: 3,
..Default::default()
};
let history = DashboardHistory::new(config);
for i in 0..5u64 {
let stats = make_stats(vec![(i * 10, 0, 0)], vec![]);
history.record(&stats);
}
let sparklines = history.worker_sparklines();
assert_eq!(sparklines[0].len(), 3); // capped at 3
}
#[test]
fn actor_lru_eviction_keeps_most_recent() {
let config = HistoryConfig {
max_tracked_actors: 2,
..Default::default()
};
let history = DashboardHistory::new(config);
// Sample 1: actors A and B
let stats1 = make_stats(
vec![(0, 0, 2)],
vec![make_actor(1, 0, 0, 0), make_actor(2, 0, 0, 0)],
);
history.record(&stats1);
// Sample 2: actors B and C (A not seen)
let stats2 = make_stats(
vec![(0, 0, 2)],
vec![make_actor(2, 0, 0, 0), make_actor(3, 0, 0, 0)],
);
history.record(&stats2);
// A should be evicted (LRU), B and C kept
assert!(history.actor_sparkline(&ActorAddress([1; 32])).is_none());
assert!(history.actor_sparkline(&ActorAddress([2; 32])).is_some());
assert!(history.actor_sparkline(&ActorAddress([3; 32])).is_some());
}
#[test]
fn actor_rates_track_deltas() {
let history = DashboardHistory::new(HistoryConfig::default());
let stats1 = make_stats(vec![(0, 0, 1)], vec![make_actor(1, 0, 5, 100)]);
history.record(&stats1);
let stats2 = make_stats(vec![(0, 0, 1)], vec![make_actor(1, 0, 3, 175)]);
history.record(&stats2);
let (mailbox, rates) = history.actor_sparkline(&ActorAddress([1; 32])).unwrap();
assert_eq!(mailbox, vec![5, 3]);
assert_eq!(rates[0], 100); // first: 100 - 0
assert_eq!(rates[1], 75); // second: 175 - 100
}
}

View file

@ -1,4 +1,5 @@
pub mod collector; pub mod collector;
pub mod history;
pub mod investigate; pub mod investigate;
pub mod layer; pub mod layer;
pub mod trace; pub mod trace;
@ -27,6 +28,7 @@ use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::util::SubscriberInitExt;
use crate::collector::StatsCollector; use crate::collector::StatsCollector;
use crate::history::{DashboardHistory, HistoryConfig};
use crate::layer::{now_ms, DashboardLayer, EventStore}; use crate::layer::{now_ms, DashboardLayer, EventStore};
use crate::trace::{RuntimeTrace, TimestampedStats}; use crate::trace::{RuntimeTrace, TimestampedStats};
@ -80,6 +82,7 @@ pub struct DashboardHandle {
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>, collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
shutdown: Arc<AtomicBool>, shutdown: Arc<AtomicBool>,
stats_timeline: Arc<ArrayQueue<TimestampedStats>>, stats_timeline: Arc<ArrayQueue<TimestampedStats>>,
history: Arc<DashboardHistory>,
recording: bool, recording: bool,
#[cfg(feature = "distribution")] #[cfg(feature = "distribution")]
distribution: Arc<Mutex<Option<Arc<dyn distribution_collector::DistributionStatsProvider>>>>, distribution: Arc<Mutex<Option<Arc<dyn distribution_collector::DistributionStatsProvider>>>>,
@ -115,6 +118,11 @@ impl DashboardHandle {
*self.distribution.lock().unwrap() = Some(provider); *self.distribution.lock().unwrap() = Some(provider);
} }
/// Access the time-series history store (for TUI sparklines, etc.).
pub fn history(&self) -> &Arc<DashboardHistory> {
&self.history
}
/// Signal the dashboard to shut down (SSE clients receive "done"). /// Signal the dashboard to shut down (SSE clients receive "done").
pub fn shutdown(&self) { pub fn shutdown(&self) {
self.shutdown.store(true, Ordering::Release); self.shutdown.store(true, Ordering::Release);
@ -159,6 +167,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
let collector: Arc<Mutex<Option<Arc<StatsCollector>>>> = Arc::new(Mutex::new(None)); let collector: Arc<Mutex<Option<Arc<StatsCollector>>>> = Arc::new(Mutex::new(None));
let shutdown = Arc::new(AtomicBool::new(false)); let shutdown = Arc::new(AtomicBool::new(false));
let stats_timeline = Arc::new(ArrayQueue::new(config.record_stats_capacity.max(1))); let stats_timeline = Arc::new(ArrayQueue::new(config.record_stats_capacity.max(1)));
let history = Arc::new(DashboardHistory::new(HistoryConfig::default()));
#[cfg(feature = "distribution")] #[cfg(feature = "distribution")]
let distribution: Arc<Mutex<Option<Arc<dyn distribution_collector::DistributionStatsProvider>>>> = let distribution: Arc<Mutex<Option<Arc<dyn distribution_collector::DistributionStatsProvider>>>> =
@ -169,6 +178,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
Arc::clone(&runtime), Arc::clone(&runtime),
Arc::clone(&collector), Arc::clone(&collector),
Arc::clone(&shutdown), Arc::clone(&shutdown),
Arc::clone(&history),
config.port, config.port,
#[cfg(feature = "distribution")] #[cfg(feature = "distribution")]
Arc::clone(&distribution), Arc::clone(&distribution),
@ -210,6 +220,7 @@ pub fn start_dashboard(config: DashboardConfig) -> DashboardHandle {
collector, collector,
shutdown, shutdown,
stats_timeline, stats_timeline,
history,
recording: config.record, recording: config.record,
#[cfg(feature = "distribution")] #[cfg(feature = "distribution")]
distribution, distribution,

View file

@ -11,6 +11,7 @@ use swactor::runtime::Runtime;
use crate::actors_html::ACTORS_HTML; use crate::actors_html::ACTORS_HTML;
use crate::collector::StatsCollector; use crate::collector::StatsCollector;
use crate::dashboard_html::DASHBOARD_HTML; use crate::dashboard_html::DASHBOARD_HTML;
use crate::history::DashboardHistory;
use crate::layer::EventStore; use crate::layer::EventStore;
use crate::trace::RuntimeTrace; use crate::trace::RuntimeTrace;
@ -119,6 +120,7 @@ pub(crate) fn spawn_http_server(
runtime: Arc<Mutex<Option<Arc<Runtime>>>>, runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>, collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
shutdown: Arc<AtomicBool>, shutdown: Arc<AtomicBool>,
history: Arc<DashboardHistory>,
port: u16, port: u16,
#[cfg(feature = "distribution")] #[cfg(feature = "distribution")]
distribution: Arc<Mutex<Option<Arc<dyn DistributionStatsProvider>>>>, distribution: Arc<Mutex<Option<Arc<dyn DistributionStatsProvider>>>>,
@ -134,6 +136,7 @@ pub(crate) fn spawn_http_server(
let runtime = Arc::clone(&runtime); let runtime = Arc::clone(&runtime);
let collector = Arc::clone(&collector); let collector = Arc::clone(&collector);
let shutdown = Arc::clone(&shutdown); let shutdown = Arc::clone(&shutdown);
let history = Arc::clone(&history);
let cmd_router = Arc::clone(&cmd_router); let cmd_router = Arc::clone(&cmd_router);
#[cfg(feature = "distribution")] #[cfg(feature = "distribution")]
let distribution = Arc::clone(&distribution); let distribution = Arc::clone(&distribution);
@ -158,6 +161,7 @@ pub(crate) fn spawn_http_server(
Arc::clone(&runtime), Arc::clone(&runtime),
Arc::clone(&collector), Arc::clone(&collector),
Arc::clone(&shutdown), Arc::clone(&shutdown),
Arc::clone(&history),
#[cfg(feature = "distribution")] #[cfg(feature = "distribution")]
Arc::clone(&distribution), Arc::clone(&distribution),
); );
@ -169,6 +173,9 @@ pub(crate) fn spawn_http_server(
Arc::clone(&collector), Arc::clone(&collector),
); );
} }
"/api/history" => {
handle_history_api(request, Arc::clone(&history));
}
"/api/investigate" => { "/api/investigate" => {
handle_investigate_api( handle_investigate_api(
request, request,
@ -198,6 +205,7 @@ fn handle_live_sse(
runtime: Arc<Mutex<Option<Arc<Runtime>>>>, runtime: Arc<Mutex<Option<Arc<Runtime>>>>,
collector: Arc<Mutex<Option<Arc<StatsCollector>>>>, collector: Arc<Mutex<Option<Arc<StatsCollector>>>>,
shutdown: Arc<AtomicBool>, shutdown: Arc<AtomicBool>,
history: Arc<DashboardHistory>,
#[cfg(feature = "distribution")] #[cfg(feature = "distribution")]
distribution: Arc<Mutex<Option<Arc<dyn DistributionStatsProvider>>>>, distribution: Arc<Mutex<Option<Arc<dyn DistributionStatsProvider>>>>,
) { ) {
@ -208,6 +216,12 @@ fn handle_live_sse(
thread::spawn(move || { thread::spawn(move || {
let mut cursor: u64 = 0; let mut cursor: u64 = 0;
// Send initial history snapshot so sparklines render immediately
if history.sample_count() > 0 {
let json = history.worker_history_json();
let _ = tx.send(format_sse("history", &json));
}
loop { loop {
// Send stats if runtime is available // Send stats if runtime is available
{ {
@ -217,6 +231,7 @@ fn handle_live_sse(
if let Some(col) = collector.lock().unwrap().as_ref() { if let Some(col) = collector.lock().unwrap().as_ref() {
col.enrich(&mut stats); col.enrich(&mut stats);
} }
history.record(&stats);
let json = serde_json::to_string(&stats).unwrap(); let json = serde_json::to_string(&stats).unwrap();
if tx.send(format_sse("stats", &json)).is_err() { if tx.send(format_sse("stats", &json)).is_err() {
return; return;
@ -353,6 +368,16 @@ fn handle_distribution_api(
let _ = request.respond(response); 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(
"Content-Type: application/json"
.parse::<tiny_http::Header>()
.unwrap(),
);
let _ = request.respond(response);
}
fn parse_query_string(url: &str) -> HashMap<String, String> { fn parse_query_string(url: &str) -> HashMap<String, String> {
let mut params = HashMap::new(); let mut params = HashMap::new();
if let Some(qs) = url.split('?').nth(1) { if let Some(qs) = url.split('?').nth(1) {

View file

@ -1,3 +1,4 @@
use std::collections::VecDeque;
use std::time::Instant; use std::time::Instant;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
@ -15,6 +16,10 @@ pub struct WorkerView {
/// Fraction of bar for each phase group: [processing, delivery, spawns, overhead] /// Fraction of bar for each phase group: [processing, delivery, spawns, overhead]
pub phase_fractions: [f64; 4], pub phase_fractions: [f64; 4],
pub panics: u64, pub panics: u64,
/// Recent message rates for sparkline rendering.
pub sparkline_rates: Vec<u64>,
/// Recent mailbox depths for sparkline rendering.
pub sparkline_mailbox: Vec<u64>,
} }
/// One row in the actor table. /// One row in the actor table.
@ -90,6 +95,10 @@ pub struct App {
prev_time: Instant, prev_time: Instant,
/// Rolling msg rates (smoothed) /// Rolling msg rates (smoothed)
msg_rates: Vec<f64>, msg_rates: Vec<f64>,
/// Per-worker sparkline history (message rate deltas).
sparkline_rates: Vec<VecDeque<u64>>,
/// Per-worker sparkline history (mailbox depths).
sparkline_mailbox: Vec<VecDeque<u64>>,
} }
impl App { impl App {
@ -115,6 +124,8 @@ impl App {
prev_messages: Vec::new(), prev_messages: Vec::new(),
prev_time: Instant::now(), prev_time: Instant::now(),
msg_rates: Vec::new(), msg_rates: Vec::new(),
sparkline_rates: Vec::new(),
sparkline_mailbox: Vec::new(),
} }
} }
@ -144,6 +155,8 @@ impl App {
if self.prev_messages.len() != stats.workers.len() { if self.prev_messages.len() != stats.workers.len() {
self.prev_messages = stats.workers.iter().map(|w| w.messages_processed).collect(); self.prev_messages = stats.workers.iter().map(|w| w.messages_processed).collect();
self.msg_rates = vec![0.0; stats.workers.len()]; self.msg_rates = vec![0.0; stats.workers.len()];
self.sparkline_rates.resize_with(stats.workers.len(), VecDeque::new);
self.sparkline_mailbox.resize_with(stats.workers.len(), VecDeque::new);
} }
// Compute per-worker views // Compute per-worker views
@ -163,8 +176,19 @@ impl App {
self.msg_rates[i] * 0.6 + rate * 0.4 self.msg_rates[i] * 0.6 + rate * 0.4
}; };
self.msg_rates[i] = smoothed; self.msg_rates[i] = smoothed;
// Track sparkline history
let delta = w.messages_processed.saturating_sub(self.prev_messages[i]);
self.prev_messages[i] = w.messages_processed; self.prev_messages[i] = w.messages_processed;
let spark_rates = &mut self.sparkline_rates[i];
if spark_rates.len() >= 60 { spark_rates.pop_front(); }
spark_rates.push_back(delta);
let spark_mbox = &mut self.sparkline_mailbox[i];
if spark_mbox.len() >= 60 { spark_mbox.pop_front(); }
spark_mbox.push_back(w.mailbox_depth as u64);
// Load % and phase fractions from tick timings // Load % and phase fractions from tick timings
let timings = stats.tick_timings.get(i).map(|v| v.as_slice()).unwrap_or(&[]); let timings = stats.tick_timings.get(i).map(|v| v.as_slice()).unwrap_or(&[]);
let (load_pct, phase_fractions) = compute_load_and_phases(timings); let (load_pct, phase_fractions) = compute_load_and_phases(timings);
@ -178,6 +202,8 @@ impl App {
load_pct, load_pct,
phase_fractions, phase_fractions,
panics: w.panics, panics: w.panics,
sparkline_rates: spark_rates.iter().copied().collect(),
sparkline_mailbox: spark_mbox.iter().copied().collect(),
}); });
} }

View file

@ -2,7 +2,7 @@ use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState}; use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Sparkline, Table, TableState};
use super::app::{App, SortColumn, ViewMode}; use super::app::{App, SortColumn, ViewMode};
@ -20,17 +20,23 @@ pub fn draw(f: &mut Frame, app: &App, table_state: &mut TableState) {
fn draw_overview(f: &mut Frame, app: &App, table_state: &mut TableState) { fn draw_overview(f: &mut Frame, app: &App, table_state: &mut TableState) {
let num_workers = app.workers.len().max(1); let num_workers = app.workers.len().max(1);
let has_sparkline_data = app.workers.iter().any(|w| w.sparkline_rates.len() > 1);
let sparkline_height = if has_sparkline_data { 4u16 } else { 0u16 };
let chunks = Layout::vertical([ let chunks = Layout::vertical([
Constraint::Length(num_workers as u16), Constraint::Length(num_workers as u16),
Constraint::Length(sparkline_height),
Constraint::Length(1), Constraint::Length(1),
Constraint::Fill(1), Constraint::Fill(1),
]) ])
.split(f.area()); .split(f.area());
draw_worker_bars(f, app, chunks[0]); draw_worker_bars(f, app, chunks[0]);
draw_summary(f, app, chunks[1]); if has_sparkline_data {
draw_actor_table(f, app, table_state, chunks[2]); draw_worker_sparklines(f, app, chunks[1]);
}
draw_summary(f, app, chunks[2]);
draw_actor_table(f, app, table_state, chunks[3]);
} }
/// Render htop-style worker bars. /// Render htop-style worker bars.
@ -45,6 +51,36 @@ fn draw_worker_bars(f: &mut Frame, app: &App, area: Rect) {
f.render_widget(paragraph, area); f.render_widget(paragraph, area);
} }
/// Render per-worker sparklines showing message rate trends.
fn draw_worker_sparklines(f: &mut Frame, app: &App, area: Rect) {
if app.workers.is_empty() {
return;
}
// Split area horizontally: one sparkline per worker
let constraints: Vec<Constraint> = app
.workers
.iter()
.map(|_| Constraint::Ratio(1, app.workers.len() as u32))
.collect();
let cols = Layout::horizontal(constraints).split(area);
for (i, w) in app.workers.iter().enumerate() {
if let Some(&col_area) = cols.get(i) {
let block = Block::default()
.borders(Borders::NONE)
.title(Span::styled(
format!(" W{} ", w.id),
Style::default().fg(Color::DarkGray),
));
let sparkline = Sparkline::default()
.block(block)
.data(&w.sparkline_rates)
.style(Style::default().fg(Color::Green));
f.render_widget(sparkline, col_area);
}
}
}
fn build_worker_line(w: &super::app::WorkerView, total_width: usize) -> Line<'static> { fn build_worker_line(w: &super::app::WorkerView, total_width: usize) -> Line<'static> {
let id_str = format!("{:>3}", w.id); let id_str = format!("{:>3}", w.id);
let suffix = format!( let suffix = format!(