380 lines
13 KiB
Rust
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)
|
|
}
|