1use std::{collections::HashMap, io, time::Duration};
2
3use anyhow::Result;
4use ratatui::{
5 Frame, Terminal,
6 crossterm::{
7 event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
8 execute,
9 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
10 },
11 layout::{Constraint, Direction, Layout},
12 prelude::CrosstermBackend,
13 style::{Color, Modifier, Style},
14 widgets::{Block, Borders, Cell, Paragraph, Row, Table},
15};
16use serde::Deserialize;
17use tokio::{
18 fs,
19 io::{AsyncBufReadExt, BufReader},
20};
21
22use crate::{
23 exec::metrics::{ExecMetrics, JobMetrics},
24 log::logger::Logger,
25};
26
27#[derive(Debug, Deserialize)]
28pub struct ProjectMetrics {
29 pub project_id: String,
30 pub project_name: String,
31 pub started_at: chrono::DateTime<chrono::Utc>,
32 pub finished_at: chrono::DateTime<chrono::Utc>,
33 pub duration_ms: u128,
34 pub cpu_usage: f32,
35 pub mem_usage_kb: u64,
36 pub mem_usage: f32,
37 pub max_cpu: f32,
38 pub max_mem: f32,
39 pub jobs: HashMap<String, JobMetrics>,
40}
41#[derive(Debug, Deserialize, PartialEq)]
42pub struct ProjectStats {
43 pub id: String,
44 pub name: String,
45 pub last_duration: String,
46 pub avg_cpu: f32,
47 pub avg_mem: f32,
48 pub max_cpu: f32,
49 pub max_mem: f32,
50 pub mem_kb: u64,
51 pub runs: usize,
52 pub last_logs: Vec<String>,
53}
54
55pub struct App {
56 pub project: Vec<ProjectStats>,
57 pub selected: usize,
58 pub scroll: usize,
59 pub table_height: usize,
60}
61
62pub fn ui(f: &mut Frame, app: &mut App) {
63 let chunks = Layout::default()
64 .direction(Direction::Vertical)
65 .constraints([Constraint::Length(7), Constraint::Min(0)].as_ref())
66 .split(f.area());
67
68 let table_height = chunks[0].height.saturating_sub(3) as usize;
69 app.table_height = table_height;
70 let table = render_table(app, table_height);
71 f.render_widget(table, chunks[0]);
72
73 if let Some(details) = render_project_details(app) {
74 f.render_widget(details, chunks[1]);
75 }
76}
77
78pub fn render_project_details(app: &App) -> Option<Paragraph<'_>> {
79 if let Some(proj) = app.project.get(app.selected) {
80 let logs = proj
81 .last_logs
82 .iter()
83 .map(|l| format!(" {l}"))
84 .collect::<Vec<_>>()
85 .join("\n");
86
87 let text = format!(
88 "Last run: {}\nAvg CPU: {:.1}%\nAvg MEM: {:.1}%\nTotal runs: {}\nLogs:\n{}",
89 proj.last_duration, proj.avg_cpu, proj.avg_mem, proj.runs, logs
90 );
91
92 let paragraph =
93 Paragraph::new(text).block(Block::default().borders(Borders::ALL).title("Details"));
94
95 return Some(paragraph);
96 }
97 None
98}
99
100pub fn render_table(app: &App, height: usize) -> Table<'_> {
101 let header = Row::new(vec![
103 Cell::from("ID"),
104 Cell::from("Name"),
105 Cell::from("Last Duration"),
106 Cell::from("CPU %"),
107 Cell::from("MEM %"),
108 Cell::from("Runs"),
109 ])
110 .style(Style::default().add_modifier(Modifier::BOLD));
111
112 let rows: Vec<Row> = app
113 .project
114 .iter()
115 .enumerate()
116 .skip(app.scroll)
117 .take(height)
118 .map(|(i, proj)| {
119 let mut row = Row::new(vec![
120 proj.id.clone(),
121 proj.name.clone(),
122 proj.last_duration.clone(),
123 format!("{:.1}", proj.avg_cpu),
124 format!("{:.1}", proj.avg_mem),
125 proj.runs.to_string(),
126 ]);
127
128 if i == app.selected {
129 row = row.style(Style::default().bg(Color::Blue).fg(Color::White));
130 }
131 row
132 })
133 .collect();
134
135 Table::new(
136 rows,
137 &[
138 Constraint::Length(13),
139 Constraint::Length(18),
140 Constraint::Length(14),
141 Constraint::Length(8),
142 Constraint::Length(8),
143 Constraint::Length(6),
144 ],
145 )
146 .header(header)
147 .block(Block::default().title("Projects").borders(Borders::ALL))
148}
149
150pub async fn load_all_stats() -> Result<Vec<ProjectStats>> {
151 let dir = ExecMetrics::ensure_metrics_dir().await?;
152 let mut entries = fs::read_dir(&dir).await?;
153 let mut projects: HashMap<String, Vec<ProjectMetrics>> = HashMap::new();
154
155 while let Some(entry) = entries.next_entry().await? {
156 let path = entry.path();
157 if path.extension().and_then(|e| e.to_str()) != Some("ndjson") {
158 continue;
159 }
160
161 let file = fs::File::open(&path).await?;
162 let reader = BufReader::new(file);
163 let mut lines = reader.lines();
164
165 while let Some(line) = lines.next_line().await? {
166 if line.trim().is_empty() {
167 continue;
168 }
169 match serde_json::from_str::<ProjectMetrics>(&line) {
170 Ok(pm) => {
171 projects.entry(pm.project_id.clone()).or_default().push(pm);
172 }
173 Err(e) => {
174 eprintln!("JSON Error in {path:?}: {e}");
175 }
176 }
177 }
178 }
179
180 let mut stats = Vec::new();
181 for (_id, runs) in projects {
182 if runs.is_empty() {
183 continue;
184 }
185
186 let id = runs[0].project_id.clone();
187 let name = runs[0].project_name.clone();
188
189 let runs_count = runs.len();
190 let avg_cpu = runs.iter().map(|r| r.cpu_usage).sum::<f32>() / runs_count as f32;
191 let avg_mem = runs.iter().map(|r| r.mem_usage).sum::<f32>() / runs_count as f32;
192
193 let last = runs.iter().max_by_key(|r| r.finished_at).unwrap();
194 let last_duration = format!("{} ms", last.duration_ms);
195 let last_logs = Logger::fetchn(&id, 5)
196 .await
197 .unwrap_or_else(|e| vec![format!("Error: {e}")]);
198 let avg_mem_kb = runs.iter().map(|r| r.mem_usage_kb).sum::<u64>() / runs_count as u64;
199
200 stats.push(ProjectStats {
201 id,
202 name,
203 last_duration,
204 avg_cpu,
205 avg_mem,
206 runs: runs_count,
207 last_logs,
208 max_cpu: last.max_cpu,
209 max_mem: last.max_mem,
210 mem_kb: avg_mem_kb,
211 });
212 }
213 stats.sort_by(|a, b| {
215 let a_last = a
216 .last_duration
217 .replace(" ms", "")
218 .parse::<u128>()
219 .unwrap_or(0);
220 let b_last = b
221 .last_duration
222 .replace(" ms", "")
223 .parse::<u128>()
224 .unwrap_or(0);
225 b_last.cmp(&a_last) });
227 Ok(stats)
228}
229
230pub async fn display_stats_interface() -> anyhow::Result<()> {
231 enable_raw_mode()?;
232 let mut stdout = io::stdout();
233 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
234 let backend = CrosstermBackend::new(stdout);
235 let mut terminal = Terminal::new(backend)?;
236
237 let mut app = App {
238 project: load_all_stats().await?,
239 selected: 0,
240 scroll: 0,
241 table_height: 0,
242 };
243
244 loop {
245 app.project = load_all_stats().await?;
246
247 terminal.draw(|f| {
248 ui(f, &mut app);
249 })?;
250
251 if event::poll(Duration::from_millis(200))?
252 && let Event::Key(key) = event::read()?
253 {
254 match key.code {
255 KeyCode::Char('q') | KeyCode::Char('Q') => break,
256 KeyCode::Down => {
257 if !app.project.is_empty() {
258 app.selected = (app.selected + 1).min(app.project.len() - 1);
259 if app.selected >= app.scroll + app.table_height {
260 app.scroll = app.selected - app.table_height + 1;
261 }
262 }
263 }
264 KeyCode::Up => {
265 if !app.project.is_empty() && app.selected > 0 {
266 app.selected -= 1;
267
268 if app.selected < app.scroll {
269 app.scroll = app.selected;
270 }
271 }
272 }
273 _ => {}
274 }
275 }
276 }
277
278 disable_raw_mode()?;
279 execute!(
280 terminal.backend_mut(),
281 LeaveAlternateScreen,
282 DisableMouseCapture
283 )?;
284 terminal.show_cursor()?;
285
286 Ok(())
287}