Files
agent-manager/src/commands/dashboard_cmd.rs
T

380 lines
13 KiB
Rust

//! dashboard: live TUI over the whole fleet (issue #33) — five tabs
//! (overview, activity, stats, sessions, projects), 1 s refresh, keyboard
//! navigation, clean exit. When stdout is not a terminal (or --json), a
//! plain snapshot is printed instead of entering the TUI.
use super::*;
use crate::dashboard::{collect, ActivityRow, DashData, OverviewRow, SessionRow, StatsRow};
use crate::projects::ProjectSummary;
use crate::output::print_json;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Tabs};
use ratatui::{DefaultTerminal, Frame};
use std::io::IsTerminal;
use std::time::Duration;
pub const TABS: [&str; 5] = ["1 overview", "2 activity", "3 stats", "4 sessions", "5 projects"];
struct Ui {
tab: usize,
scroll: [usize; 5],
}
pub fn run(app: &App) -> Result<i32> {
if app.json() {
print_json(&collect(app));
return Ok(0);
}
if !std::io::stdout().is_terminal() {
print_snapshot(app)?;
return Ok(0);
}
let mut terminal = ratatui::init();
let result = run_loop(app, &mut terminal);
ratatui::restore();
// Leave the prompt on a fresh line when launched from the REPL.
println!();
result
}
fn run_loop(app: &App, terminal: &mut DefaultTerminal) -> Result<i32> {
let mut ui = Ui { tab: 0, scroll: [0; 5] };
let mut data = collect(app);
loop {
terminal.draw(|f| draw(f, &data, &ui))?;
if !event::poll(Duration::from_secs(1))? {
// 1 s refresh: repaint with fresh data.
data = collect(app);
continue;
}
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(0),
KeyCode::Char('h') | KeyCode::Left => ui.tab = ui.tab.saturating_sub(1),
KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => {
ui.tab = (ui.tab + 1) % TABS.len()
}
KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => {
ui.tab = (c as usize - '1' as usize).min(TABS.len() - 1);
}
KeyCode::Char('k') | KeyCode::Up => {
ui.scroll[ui.tab] = ui.scroll[ui.tab].saturating_sub(1)
}
KeyCode::Char('j') | KeyCode::Down => ui.scroll[ui.tab] += 1,
KeyCode::PageUp => ui.scroll[ui.tab] = ui.scroll[ui.tab].saturating_sub(10),
KeyCode::PageDown => ui.scroll[ui.tab] += 10,
KeyCode::Home => ui.scroll[ui.tab] = 0,
KeyCode::Char('r') => data = collect(app),
_ => {}
},
Event::Resize(_, _) => data = collect(app),
_ => {}
}
}
}
fn draw(f: &mut Frame, data: &DashData, ui: &Ui) {
let [header, body] =
Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).areas(f.area());
let counts = &data.counts;
let title = Line::from(vec![
Span::styled(
" agent-manager ",
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(format!(
" {} installed · {} running · {} sessions{}",
counts.installed,
counts.running,
counts.sessions_count,
counts
.last_used
.as_deref()
.map(|t| format!(" · last used {t}"))
.unwrap_or_default(),
)),
]);
let tabs = Tabs::new(TABS)
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
.title_alignment(Alignment::Left),
)
.select(ui.tab)
.highlight_style(
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
f.render_widget(tabs, header);
match ui.tab {
0 => draw_overview(f, data, ui, body),
1 => draw_activity(f, data, ui, body),
2 => draw_stats(f, data, ui, body),
3 => draw_sessions(f, data, ui, body),
_ => draw_projects(f, data, ui, body),
}
}
fn status_style(status: &str) -> Style {
match status {
"running" => Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
"installed" | "external" => Style::default().fg(Color::Cyan),
"failed" | "interrupted" => Style::default().fg(Color::Red),
"not-installed" | "not-installable" => Style::default().fg(Color::DarkGray),
_ => Style::default().fg(Color::Yellow),
}
}
fn block<'a>(title: &str, hint: &str, scroll: usize) -> Block<'a> {
Block::default()
.borders(Borders::ALL)
.title(format!(" {title} "))
.title_bottom(format!(" {hint} · scroll {scroll} "))
.title_alignment(Alignment::Left)
}
fn fmt_duration(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m{:02}s", secs / 60, secs % 60)
} else if secs < 86400 {
format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
} else {
format!("{}d{:02}h", secs / 86400, (secs % 86400) / 3600)
}
}
fn overview_line(r: &OverviewRow) -> Line<'static> {
let star = if r.favorite { "★ " } else { " " };
let status = r.status.as_str();
let version = r.version.as_deref().unwrap_or("—");
let uptime = r
.uptime_s
.map(|s| format!("up {}", fmt_duration(s)))
.unwrap_or_default();
let pid = r
.pid
.map(|p| format!("pid {p}"))
.unwrap_or_default();
Line::from(vec![
Span::styled(format!("{star}{:<22}", r.name), Style::default().add_modifier(Modifier::BOLD)),
Span::styled(format!("{:<16}", status), status_style(status)),
Span::raw(format!("{:<12} ", version)),
Span::raw(format!("{pid:<10} {uptime}")),
])
}
fn draw_overview(f: &mut Frame, data: &DashData, ui: &Ui, area: Rect) {
let mut lines: Vec<Line> = data
.overview
.iter()
.map(overview_line)
.collect();
if lines.is_empty() {
lines.push(Line::from("no agents in the catalog"));
}
let p = Paragraph::new(lines)
.block(block("overview", "j/k scroll · q quit", ui.scroll[0]))
.scroll((ui.scroll[0] as u16, 0));
f.render_widget(p, area);
}
fn activity_line(r: &ActivityRow) -> Line<'static> {
Line::from(vec![
Span::styled(format!("{:<21} ", r.ts), Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{:<10} ", r.kind),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::styled(format!("{:<22} ", r.agent.as_deref().unwrap_or("-")), Style::default().fg(Color::Yellow)),
Span::raw(r.detail.clone()),
])
}
fn draw_activity(f: &mut Frame, data: &DashData, ui: &Ui, area: Rect) {
let lines: Vec<Line> = data.activity.iter().map(activity_line).collect();
let p = Paragraph::new(if lines.is_empty() {
vec![Line::from("the event journal is empty — every am action will appear here")]
} else {
lines
})
.block(block("activity — event journal", "j/k scroll · q quit", ui.scroll[1]))
.scroll((ui.scroll[1] as u16, 0));
f.render_widget(p, area);
}
fn stats_line(r: &StatsRow) -> Line<'static> {
let rate = if r.launches > 0 {
format!("{:>6.0}% ok", 100.0 * (r.launches.saturating_sub(r.failures)) as f64 / r.launches as f64)
} else {
" —".to_string()
};
Line::from(vec![
Span::styled(format!("{:<22}", r.agent), Style::default().add_modifier(Modifier::BOLD)),
Span::raw(format!("{:>8} launches ", r.launches)),
Span::raw(format!("{:>8} failures ", r.failures)),
Span::raw(format!("{:>10} ", fmt_duration(r.total_duration_s))),
Span::styled(rate, status_style(if r.failures > 0 { "failed" } else { "running" })),
])
}
fn draw_stats(f: &mut Frame, data: &DashData, ui: &Ui, area: Rect) {
let lines: Vec<Line> = data.stats.iter().map(stats_line).collect();
let p = Paragraph::new(if lines.is_empty() {
vec![Line::from("no usage data yet — start an agent to fill the journal")]
} else {
lines
})
.block(block("stats — per agent", "j/k scroll · q quit", ui.scroll[2]))
.scroll((ui.scroll[2] as u16, 0));
f.render_widget(p, area);
}
fn session_line(r: &SessionRow) -> Line<'static> {
let dur = r
.duration_s
.map(fmt_duration)
.unwrap_or_else(|| "running…".to_string());
Line::from(vec![
Span::styled(format!("{:<18} ", r.id), Style::default().fg(Color::Yellow)),
Span::styled(format!("{:<11} ", r.status), status_style(&r.status)),
Span::styled(
format!("{:<22} ", r.agent.as_deref().unwrap_or("repl")),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(format!(
"{:<26} ",
r.project.as_deref().unwrap_or("-")
)),
Span::styled(format!("{:<10} ", r.started_at), Style::default().fg(Color::DarkGray)),
Span::raw(dur),
])
}
fn draw_sessions(f: &mut Frame, data: &DashData, ui: &Ui, area: Rect) {
let lines: Vec<Line> = data.sessions.iter().map(session_line).collect();
let p = Paragraph::new(if lines.is_empty() {
vec![Line::from("no sessions recorded yet")]
} else {
lines
})
.block(block("sessions", "j/k scroll · q quit", ui.scroll[3]))
.scroll((ui.scroll[3] as u16, 0));
f.render_widget(p, area);
}
fn project_line(r: &ProjectSummary) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::styled(
format!("{:<28} ", r.root),
Style::default().add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
format!("{:<22} ", r.dominant.as_deref().unwrap_or("-")),
Style::default().fg(Color::Cyan),
));
spans.push(Span::raw(format!(
"{:>4} sessions {:>5} launches {:>10} ",
r.sessions, r.launches, fmt_duration(r.total_duration_s)
)));
spans.push(Span::styled(
r.last_activity.as_deref().unwrap_or("-").to_string(),
Style::default().fg(Color::DarkGray),
));
Line::from(spans)
}
fn draw_projects(f: &mut Frame, data: &DashData, ui: &Ui, area: Rect) {
let lines: Vec<Line> = data.projects.iter().map(project_line).collect();
let p = Paragraph::new(if lines.is_empty() {
vec![Line::from("no project activity recorded yet")]
} else {
lines
})
.block(block("projects", "j/k scroll · q quit", ui.scroll[4]))
.scroll((ui.scroll[4] as u16, 0));
f.render_widget(p, area);
}
/// Plain-text snapshot (piped stdout): same data, no TUI.
fn print_snapshot(app: &App) -> Result<i32> {
let data = collect(app);
let c = &data.counts;
println!(
"agent-manager dashboard — {} installed · {} running · {} sessions",
c.installed, c.running, c.sessions_count
);
println!();
println!("overview");
for r in &data.overview {
let star = if r.favorite { "★ " } else { " " };
let uptime = r
.uptime_s
.map(|s| format!(" (up {})", fmt_duration(s)))
.unwrap_or_default();
println!(
" {star}{:<22} {:<16} {:<12} {}{}",
r.name,
r.status,
r.version.as_deref().unwrap_or("—"),
r.pid.map(|p| format!("pid {p}")).unwrap_or_default(),
uptime,
);
}
println!();
println!("activity (last {})", data.activity.len());
for r in data.activity.iter().take(20) {
println!(
" {:<21} {:<10} {:<22} {}",
r.ts,
r.kind,
r.agent.as_deref().unwrap_or("-"),
r.detail
);
}
println!();
println!("stats");
for r in &data.stats {
println!(
" {:<22} {:>8} launches {:>8} failures {:>10}",
r.agent,
r.launches,
r.failures,
fmt_duration(r.total_duration_s),
);
}
println!();
println!("sessions");
for r in data.sessions.iter().take(20) {
println!(
" {:<18} {:<11} {:<22} {}",
r.id,
r.status,
r.agent.as_deref().unwrap_or("repl"),
r.duration_s.map(fmt_duration).unwrap_or_else(|| "running…".to_string()),
);
}
println!();
println!("projects");
for r in &data.projects {
println!(
" {:<28} {:<22} {} sessions {} launches",
r.root,
r.dominant.as_deref().unwrap_or("-"),
r.sessions,
r.launches,
);
}
Ok(0)
}