feat(dashboard): per-actor logging with tracing integration (Stage 7)
Add actor-level tracing span in worker tick_all to propagate actor_addr to user log events. DashboardLayer now extracts actor_addr from both span extensions and event fields. EventStore gains read_for_actor() for filtered log queries. Web actor detail page gets a live-updating log panel with per-level filter buttons (ERR/WARN/INFO/DBG/TRC) and auto-scroll. TUI actor detail view adds scrollable log panel with j/k navigation and 1-5 keys for level toggling. REST endpoint at /api/logs?actor=<hex>&limit=N&level=L. Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
This commit is contained in:
parent
991b212191
commit
c7a0bd2523
7 changed files with 372 additions and 13 deletions
|
|
@ -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 h3 { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
|
||||||
.sparkline-panel svg { width: 100%; height: 50px; }
|
.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 { width: 6px; }
|
||||||
::-webkit-scrollbar-track { background: #0f1117; }
|
::-webkit-scrollbar-track { background: #0f1117; }
|
||||||
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
|
::-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>
|
<h3>Mailbox Depth</h3>
|
||||||
<svg id="mboxSpark" viewBox="0 0 400 50" preserveAspectRatio="none"></svg>
|
<svg id="mboxSpark" viewBox="0 0 400 50" preserveAspectRatio="none"></svg>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
@ -210,12 +259,87 @@ pub const ACTOR_DETAIL_HTML: &str = r##"<!DOCTYPE html>
|
||||||
updateSparklineSvg(document.getElementById('mboxSpark'), history.mailbox, '#2196f3');
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
var es = new EventSource('/events');
|
||||||
|
|
||||||
es.addEventListener('stats', function(e) {
|
es.addEventListener('stats', function(e) {
|
||||||
try { updateDetail(JSON.parse(e.data)); } catch(err) { console.error(err); }
|
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() {
|
es.addEventListener('done', function() {
|
||||||
dot.className = 'status-dot disconnected';
|
dot.className = 'status-dot disconnected';
|
||||||
es.close();
|
es.close();
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ pub struct DashboardEvent {
|
||||||
pub level: String,
|
pub level: String,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
pub worker_id: Option<usize>,
|
pub worker_id: Option<usize>,
|
||||||
|
pub actor_addr: Option<String>,
|
||||||
pub fields: serde_json::Map<String, serde_json::Value>,
|
pub fields: serde_json::Map<String, serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,6 +84,28 @@ impl EventStore {
|
||||||
(batch, new_cursor)
|
(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.
|
/// Drains the full recording log. Only available when recording is enabled.
|
||||||
/// This is destructive — events are consumed. Intended for `save_trace()`.
|
/// This is destructive — events are consumed. Intended for `save_trace()`.
|
||||||
pub fn all_events(&self) -> Option<Vec<DashboardEvent>> {
|
pub fn all_events(&self) -> Option<Vec<DashboardEvent>> {
|
||||||
|
|
@ -183,24 +206,39 @@ where
|
||||||
let mut visitor = FieldVisitor::new();
|
let mut visitor = FieldVisitor::new();
|
||||||
event.record(&mut visitor);
|
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 worker_id = None;
|
||||||
|
let mut actor_addr = None;
|
||||||
if let Some(scope) = ctx.event_scope(event) {
|
if let Some(scope) = ctx.event_scope(event) {
|
||||||
for span in scope {
|
for span in scope {
|
||||||
let exts = span.extensions();
|
let exts = span.extensions();
|
||||||
|
if worker_id.is_none() {
|
||||||
if let Some(wid) = exts.get::<WorkerIdField>() {
|
if let Some(wid) = exts.get::<WorkerIdField>() {
|
||||||
worker_id = Some(wid.0);
|
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;
|
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 worker_id.is_none() {
|
||||||
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") {
|
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") {
|
||||||
worker_id = n.as_u64().map(|v| v as usize);
|
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 {
|
let dashboard_event = DashboardEvent {
|
||||||
seq: 0, // filled by push()
|
seq: 0, // filled by push()
|
||||||
|
|
@ -208,6 +246,7 @@ where
|
||||||
level: event.metadata().level().to_string(),
|
level: event.metadata().level().to_string(),
|
||||||
message: visitor.message,
|
message: visitor.message,
|
||||||
worker_id,
|
worker_id,
|
||||||
|
actor_addr,
|
||||||
fields: visitor.fields,
|
fields: visitor.fields,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -215,19 +254,25 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_new_span(&self, attrs: &span::Attributes<'_>, id: &span::Id, ctx: Context<'_, S>) {
|
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();
|
let mut visitor = FieldVisitor::new();
|
||||||
attrs.record(&mut visitor);
|
attrs.record(&mut visitor);
|
||||||
|
|
||||||
|
if let Some(span) = ctx.span(id) {
|
||||||
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") {
|
if let Some(serde_json::Value::Number(n)) = visitor.fields.get("worker_id") {
|
||||||
if let Some(wid) = n.as_u64() {
|
if let Some(wid) = n.as_u64() {
|
||||||
if let Some(span) = ctx.span(id) {
|
|
||||||
span.extensions_mut().insert(WorkerIdField(wid as usize));
|
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.
|
/// Stored in span extensions to propagate worker_id to child events.
|
||||||
struct WorkerIdField(usize);
|
struct WorkerIdField(usize);
|
||||||
|
|
||||||
|
/// Stored in span extensions to propagate actor_addr to child events.
|
||||||
|
struct ActorAddrField(String);
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,9 @@ pub(crate) fn spawn_http_server(
|
||||||
Arc::clone(&distribution),
|
Arc::clone(&distribution),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
"/api/logs" => {
|
||||||
|
handle_logs_api(request, &url, Arc::clone(&store));
|
||||||
|
}
|
||||||
_ if path.starts_with("/actor/") => {
|
_ if path.starts_with("/actor/") => {
|
||||||
let hex = &path[7..]; // strip "/actor/"
|
let hex = &path[7..]; // strip "/actor/"
|
||||||
respond_actor_detail(request, hex);
|
respond_actor_detail(request, hex);
|
||||||
|
|
@ -445,6 +448,32 @@ fn handle_topology_api(
|
||||||
let _ = request.respond(response);
|
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>) {
|
fn handle_history_api(request: tiny_http::Request, history: Arc<DashboardHistory>) {
|
||||||
let json = history.worker_history_json();
|
let json = history.worker_history_json();
|
||||||
let response = tiny_http::Response::from_string(json).with_header(
|
let response = tiny_http::Response::from_string(json).with_header(
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use swactor::actor::ActorAddress;
|
use swactor::actor::ActorAddress;
|
||||||
use swactor::stats::{RuntimeStats, TickTiming};
|
use swactor::stats::{RuntimeStats, TickTiming};
|
||||||
|
|
||||||
|
use crate::layer::{DashboardEvent, EventStore};
|
||||||
use crate::warnings::{Warning, WarningConfig, WarningDetector};
|
use crate::warnings::{Warning, WarningConfig, WarningDetector};
|
||||||
|
|
||||||
/// Per-worker data prepared for rendering.
|
/// Per-worker data prepared for rendering.
|
||||||
|
|
@ -115,6 +117,14 @@ pub struct App {
|
||||||
/// Per-actor sparkline history: address → (prev_msgs, rates, mailbox_depths).
|
/// Per-actor sparkline history: address → (prev_msgs, rates, mailbox_depths).
|
||||||
actor_sparklines: HashMap<ActorAddress, (u64, VecDeque<u64>, VecDeque<u64>)>,
|
actor_sparklines: HashMap<ActorAddress, (u64, VecDeque<u64>, VecDeque<u64>)>,
|
||||||
warning_detector: WarningDetector,
|
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 {
|
impl App {
|
||||||
|
|
@ -150,9 +160,18 @@ impl App {
|
||||||
sparkline_mailbox: Vec::new(),
|
sparkline_mailbox: Vec::new(),
|
||||||
actor_sparklines: HashMap::new(),
|
actor_sparklines: HashMap::new(),
|
||||||
warning_detector: WarningDetector::new(WarningConfig::default()),
|
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")]
|
#[cfg(feature = "distribution")]
|
||||||
pub fn update_distribution(&mut self, snapshot: distribution::snapshot::DistributionNodeSnapshot) {
|
pub fn update_distribution(&mut self, snapshot: distribution::snapshot::DistributionNodeSnapshot) {
|
||||||
let max = if snapshot.members.is_empty() { 0 } else { snapshot.members.len() - 1 };
|
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).
|
/// Actor rows filtered to the focused worker (for worker detail view).
|
||||||
pub fn focused_actor_rows(&self) -> Vec<&ActorRow> {
|
pub fn focused_actor_rows(&self) -> Vec<&ActorRow> {
|
||||||
self.actor_rows
|
self.actor_rows
|
||||||
|
|
@ -289,6 +342,11 @@ impl App {
|
||||||
// Run warning detection
|
// Run warning detection
|
||||||
self.warnings = self.warning_detector.check(&stats);
|
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
|
// Clamp selection
|
||||||
if !self.actor_rows.is_empty() {
|
if !self.actor_rows.is_empty() {
|
||||||
self.selected = self.selected.min(self.actor_rows.len() - 1);
|
self.selected = self.selected.min(self.actor_rows.len() - 1);
|
||||||
|
|
@ -432,6 +490,8 @@ impl App {
|
||||||
self.focused_actor = Some(row.address);
|
self.focused_actor = Some(row.address);
|
||||||
self.prev_view_mode = ViewMode::Overview;
|
self.prev_view_mode = ViewMode::Overview;
|
||||||
self.view_mode = ViewMode::ActorDetail;
|
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) {
|
fn handle_key_actor_detail(&mut self, key: KeyEvent) {
|
||||||
|
let max_scroll = self.visible_logs().len().saturating_sub(1);
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => {
|
KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => {
|
||||||
self.view_mode = self.prev_view_mode;
|
self.view_mode = self.prev_view_mode;
|
||||||
self.focused_actor = None;
|
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),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ use swactor::runtime::Runtime;
|
||||||
use crate::collector::StatsCollector;
|
use crate::collector::StatsCollector;
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
use crate::distribution_collector::DistributionStatsProvider;
|
use crate::distribution_collector::DistributionStatsProvider;
|
||||||
|
use crate::layer::EventStore;
|
||||||
use self::app::App;
|
use self::app::App;
|
||||||
use self::event::{AppEvent, EventLoop};
|
use self::event::{AppEvent, EventLoop};
|
||||||
use self::types::RuntimeEndpoint;
|
use self::types::RuntimeEndpoint;
|
||||||
|
|
@ -47,6 +48,7 @@ pub fn start_tui(
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
distribution: Option<Arc<dyn DistributionStatsProvider>>,
|
distribution: Option<Arc<dyn DistributionStatsProvider>>,
|
||||||
config: TuiConfig,
|
config: TuiConfig,
|
||||||
|
event_store: Option<Arc<EventStore>>,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
// Set up terminal
|
// Set up terminal
|
||||||
crossterm::terminal::enable_raw_mode()?;
|
crossterm::terminal::enable_raw_mode()?;
|
||||||
|
|
@ -72,6 +74,7 @@ pub fn start_tui(
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
distribution,
|
distribution,
|
||||||
config,
|
config,
|
||||||
|
event_store,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Restore terminal
|
// Restore terminal
|
||||||
|
|
@ -92,8 +95,12 @@ fn run_loop(
|
||||||
#[cfg(feature = "distribution")]
|
#[cfg(feature = "distribution")]
|
||||||
distribution: Option<Arc<dyn DistributionStatsProvider>>,
|
distribution: Option<Arc<dyn DistributionStatsProvider>>,
|
||||||
config: TuiConfig,
|
config: TuiConfig,
|
||||||
|
event_store: Option<Arc<EventStore>>,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
if let Some(store) = event_store {
|
||||||
|
app.set_event_store(store);
|
||||||
|
}
|
||||||
let mut table_state = TableState::default();
|
let mut table_state = TableState::default();
|
||||||
let events = EventLoop::new(config.poll_interval_ms);
|
let events = EventLoop::new(config.poll_interval_ms);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -543,7 +543,7 @@ fn draw_actor_detail(f: &mut Frame, app: &App) {
|
||||||
Constraint::Length(5), // Rate sparkline
|
Constraint::Length(5), // Rate sparkline
|
||||||
Constraint::Length(5), // Mailbox sparkline
|
Constraint::Length(5), // Mailbox sparkline
|
||||||
Constraint::Length(1), // Help bar
|
Constraint::Length(1), // Help bar
|
||||||
Constraint::Fill(1), // Spacer
|
Constraint::Fill(1), // Logs panel
|
||||||
])
|
])
|
||||||
.split(f.area());
|
.split(f.area());
|
||||||
|
|
||||||
|
|
@ -609,13 +609,82 @@ fn draw_actor_detail(f: &mut Frame, app: &App) {
|
||||||
f.render_widget(mbox_sparkline, chunks[2]);
|
f.render_widget(mbox_sparkline, chunks[2]);
|
||||||
|
|
||||||
// Help bar
|
// 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(
|
Span::styled(
|
||||||
" Esc/\u{2190}: back Tab: overview q: quit",
|
" Esc: back \u{2191}\u{2193}: scroll logs ",
|
||||||
Style::default().fg(Color::DarkGray),
|
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 ──────────────────────────────────────────────────────
|
// ─── Distribution View ──────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -698,6 +698,9 @@ impl ActorPool {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "tracing")]
|
||||||
|
let _actor_span = tracing::trace_span!("actor.tick", actor_addr = %addr).entered();
|
||||||
|
|
||||||
let ctx = Ctx::new(inner, addr);
|
let ctx = Ctx::new(inner, addr);
|
||||||
|
|
||||||
// Call on_start once, before first message
|
// Call on_start once, before first message
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue