core_lib/cli/stats/
interface.rs

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    // En-tête
102    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    // dbg!(&stats);
214    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) // du plus récent au plus ancien
226    });
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}