diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee2aa65..5600f88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,7 +105,10 @@ jobs: uses: dtolnay/rust-toolchain@1.95.0 - name: Install cargo-audit - run: cargo install cargo-audit + # `--locked` uses cargo-audit's own pinned lockfile so a freshly + # published transitive dep can't raise the tool's MSRV above the + # toolchain pinned above (e.g. kstring 2.0.4 requiring rustc 1.96). + run: cargo install cargo-audit --locked - name: Run cargo-audit run: cargo audit @@ -120,7 +123,9 @@ jobs: uses: dtolnay/rust-toolchain@1.95.0 - name: Install cargo-deny - run: cargo install cargo-deny + # See the cargo-audit note above: pin to the tool's own lockfile so a + # transitive MSRV bump can't break the install on the pinned toolchain. + run: cargo install cargo-deny --locked - name: Run cargo-deny run: cargo deny check diff --git a/Cargo.toml b/Cargo.toml index 0868761..c9b69c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,8 +40,11 @@ server = ["tokens", "dep:axum", "dep:tower-http"] cli = ["server", "dep:clap", "dep:tracing-subscriber"] # Real-time stats dashboard (`llmsim serve --tui`); only reachable via the -# binary, so it implies `cli`. -tui = ["cli", "dep:crossterm", "dep:ratatui", "tokio/io-util"] +# binary, so it implies `cli`. The dashboard is composed with `tuika` (flexbox +# layout, alt-screen host, focus, live redraw) and renders its data panels +# through `tuika`'s safe ratatui interop, so `ratatui` stays a direct dep for +# the widget code while `tuika` owns the layout/host/event loop. +tui = ["cli", "dep:tuika", "tuika/async", "dep:ratatui", "tokio/io-util"] [dependencies] # Async runtime @@ -75,9 +78,15 @@ futures-util = { version = "0.3", default-features = false, features = ["sink", async-stream = "0.3" thiserror = "2.0" -# TUI +# TUI. `tuika` is a composable terminal UI toolkit over ratatui (flexbox +# layout, overlays, focus, alt-screen host, live redraw) that pins ratatui +# 0.30.2; keeping `ratatui = "0.30"` here unifies onto that same version so the +# `Buffer`/`Rect` types line up across the `RatatuiView` interop boundary. The +# host machinery (crossterm, alt-screen, async event loop) now lives inside +# `tuika`; its `async` feature (enabled via the `tui` feature) is what brings in +# the `AsyncRunner`. ratatui = { version = "0.30", optional = true } -crossterm = { version = "0.29", optional = true } +tuika = { version = "0.4", optional = true } [dev-dependencies] tokio-test = "0.4" diff --git a/README.md b/README.md index 21095bd..478cd39 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ what they use. The defaults (`["cli"]`) give the full binary, so `cargo build`, | `tokens` | `tokens` module (token counting) | `tiktoken-rs` | | `server` | `cli` module (axum router, handlers, websockets); implies `tokens` | `axum`, `tower-http` | | `cli` | the `llmsim` binary; implies `server` | `clap`, `tracing-subscriber` | -| `tui` | `serve --tui` dashboard; implies `cli` | `ratatui`, `crossterm` | +| `tui` | `serve --tui` dashboard; implies `cli` | `tuika`, `ratatui` | To embed only the core library modules (types, generators, latency, streaming, stats, scripts) and shed `axum`, `tower-http`, `tiktoken-rs`, diff --git a/specs/architecture.md b/specs/architecture.md index ab6c8c7..7ffd58c 100644 --- a/specs/architecture.md +++ b/specs/architecture.md @@ -19,8 +19,8 @@ llmsim/ │ │ └── state.rs # Application state (config + stats) │ ├── tui/ # Terminal UI dashboard │ │ ├── mod.rs # TUI module entry point -│ │ ├── app.rs # Application state and event loop -│ │ └── ui.rs # Ratatui widget rendering +│ │ ├── app.rs # Application state and tuika host loop +│ │ └── ui.rs # tuika view tree (ratatui widgets via RatatuiView) │ ├── openai/ # OpenAI API types │ │ ├── mod.rs │ │ ├── types.rs @@ -129,7 +129,7 @@ them. The features form a chain, each enabling the previous one: | `tokens` | `tokens` | `tiktoken-rs` | | `server` | `cli` (router/handlers) + `tokens` | `axum`, `tower-http` (+ axum `ws`) | | `cli` | binary + `server` | `clap`, `tracing-subscriber` | -| `tui` | `tui` dashboard + `cli` | `ratatui`, `crossterm` | +| `tui` | `tui` dashboard + `cli` | `tuika`, `ratatui` | `server` implies `tokens` because the handlers count tokens for usage accounting. `cli` implies `server` because the binary's job is to run the @@ -204,7 +204,7 @@ See `specs/anthropic-api.md` for detailed Anthropic API specification. - **Public modules** (`openai`, `openresponses`, `anthropic`, `generator`, `latency`, `stream`, `responses_stream`, `imagegen`, `image_stream`, `errors`, `stats`): Core library functionality, re-exported from `lib.rs`, always available - **Token module** (`tokens`): Token counting behind the `tokens` feature (tiktoken-rs) - **CLI modules** (`cli/*`): Server-specific code, HTTP handlers and configuration, behind the `server` feature -- **TUI modules** (`tui/*`): Optional terminal dashboard behind the `tui` feature, built with Ratatui +- **TUI modules** (`tui/*`): Optional terminal dashboard behind the `tui` feature, built with tuika (flexbox layout, alt-screen host, live redraw) over Ratatui widgets ### API Support @@ -245,10 +245,16 @@ Stats are exposed via `/llmsim/stats` endpoint and consumed by the optional TUI ### TUI Module -The `tui` feature enables a real-time terminal dashboard built with [Ratatui](https://ratatui.rs/): +The `tui` feature enables a real-time terminal dashboard built with +[tuika](https://crates.io/crates/tuika) — a composable toolkit over +[Ratatui](https://ratatui.rs/) providing flexbox layout, an alternate-screen +host, focus, and live redraw: -- **app.rs**: Event loop, state management, HTTP polling -- **ui.rs**: Widget layout and rendering (tables, sparklines, bar charts) +- **app.rs**: tuika `AsyncRunner` host loop on the caller's Tokio runtime; the + dashboard state is a plain local value the loop owns, with stats polled inline + on each tick (and on manual `r` refresh) +- **ui.rs**: tuika `Flex` view tree; data panels (tables, sparklines, bar + charts) are Ratatui widgets rendered through `RatatuiView` interop ## Model Profiles diff --git a/src/tui/app.rs b/src/tui/app.rs index c32ce23..8a6ed91 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,17 +1,23 @@ -//! TUI Application logic and event handling. +//! TUI application logic: state, stats fetching, and the tuika host loop. +//! +//! Design note: the dashboard is driven by `tuika`'s [`AsyncRunner`], which ties +//! the alternate-screen lifecycle, crossterm's async event stream, and a tick +//! timer into one `tokio::select!` loop on the caller's runtime. The dashboard +//! state is a plain local [`DashboardData`] the loop owns: `view` reads it to +//! build each frame and `update` mutates it (and may `.await` a stats fetch) in +//! response to a [`Signal`] — a tick or a key. No `spawn_blocking`, shared +//! `RwLock`, `Notify`, or stop flag; `run_dashboard` stays an `async fn` the +//! caller races against the server with `tokio::select!`. use super::ui; use crate::stats::StatsSnapshot; -use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, - execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, -}; -use ratatui::{backend::CrosstermBackend, Terminal}; +use ratatui::style::Color; use std::io; +use std::ops::ControlFlow; use std::time::{Duration, Instant}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; +use tuika::{AsyncRunner, Event, KeyCode, RunnerConfig, Signal, Theme}; /// Configuration for the dashboard #[derive(Debug, Clone)] @@ -31,70 +37,65 @@ impl Default for DashboardConfig { } } -/// Application state for the dashboard -pub struct App { +/// Live dashboard state, shared between the stats poller and the renderer. +/// +/// The renderer reads it each frame ([`ui::dashboard`]); the poller mutates it +/// through [`Live::update`], which requests a redraw from the runner. +pub struct DashboardData { /// Current stats snapshot pub stats: Option, /// Last error message pub error: Option, - /// Historical RPS values for sparkline (last 60 values) + /// Historical RPS values for the sparkline (last 60 values) pub rps_history: Vec, - /// Historical token rate values for sparkline + /// Historical token-rate values for the sparkline pub tokens_history: Vec, - /// Last fetch time - pub last_fetch: Instant, - /// Whether to exit - pub should_quit: bool, - /// Server URL - pub server_url: String, - /// Total tokens from last snapshot (for rate calculation) - pub last_total_tokens: u64, + /// Last fetch time, used to derive the token rate + last_fetch: Instant, + /// Total tokens from the last snapshot (for rate calculation) + last_total_tokens: u64, } -impl App { - pub fn new(server_url: String) -> Self { +impl DashboardData { + fn new() -> Self { Self { stats: None, error: None, rps_history: Vec::with_capacity(60), tokens_history: Vec::with_capacity(60), last_fetch: Instant::now(), - should_quit: false, - server_url, last_total_tokens: 0, } } - /// Update the stats by fetching from the server - pub async fn update_stats(&mut self) { - match fetch_stats(&self.server_url).await { - Ok(snapshot) => { - // Calculate token rate - let elapsed = self.last_fetch.elapsed().as_secs_f64(); - if elapsed > 0.0 && self.last_total_tokens > 0 { - let token_diff = snapshot.total_tokens.saturating_sub(self.last_total_tokens); - let token_rate = token_diff as f64 / elapsed; - self.tokens_history.push(token_rate); - if self.tokens_history.len() > 60 { - self.tokens_history.remove(0); - } - } - self.last_total_tokens = snapshot.total_tokens; - - // Update RPS history - self.rps_history.push(snapshot.requests_per_second); - if self.rps_history.len() > 60 { - self.rps_history.remove(0); - } - - self.stats = Some(snapshot); - self.error = None; - self.last_fetch = Instant::now(); - } - Err(e) => { - self.error = Some(e); + /// Fold a freshly fetched snapshot into the rolling history and clear any + /// previous error. + fn ingest(&mut self, snapshot: StatsSnapshot) { + // Token rate over the interval since the previous successful fetch. + let elapsed = self.last_fetch.elapsed().as_secs_f64(); + if elapsed > 0.0 && self.last_total_tokens > 0 { + let token_diff = snapshot.total_tokens.saturating_sub(self.last_total_tokens); + let token_rate = token_diff as f64 / elapsed; + self.tokens_history.push(token_rate); + if self.tokens_history.len() > 60 { + self.tokens_history.remove(0); } } + self.last_total_tokens = snapshot.total_tokens; + + self.rps_history.push(snapshot.requests_per_second); + if self.rps_history.len() > 60 { + self.rps_history.remove(0); + } + + self.stats = Some(snapshot); + self.error = None; + self.last_fetch = Instant::now(); + } + + /// Record a failed fetch; the header flips to DISCONNECTED. + fn set_error(&mut self, error: String) { + self.error = Some(error); } } @@ -202,73 +203,62 @@ fn contains_invalid_request_chars(value: &str) -> bool { .any(|c| c.is_ascii_control() || c == ' ' || !c.is_ascii()) } -/// Run the TUI dashboard -pub async fn run_dashboard(config: DashboardConfig) -> io::Result<()> { - // Setup terminal - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - // Create app state - let mut app = App::new(config.server_url); - let tick_rate = Duration::from_millis(config.refresh_ms); - let mut last_tick = Instant::now(); - - // Initial fetch - app.update_stats().await; - - loop { - // Draw UI - terminal.draw(|f| ui::draw(f, &app))?; - - // Handle events with timeout - let timeout = tick_rate.saturating_sub(last_tick.elapsed()); - if crossterm::event::poll(timeout)? { - if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press { - match key.code { - KeyCode::Char('q') | KeyCode::Esc => { - app.should_quit = true; - } - KeyCode::Char('r') => { - // Force refresh - app.update_stats().await; - } - _ => {} - } - } - } - } - - // Check if we should quit - if app.should_quit { - break; - } - - // Update stats on tick - if last_tick.elapsed() >= tick_rate { - app.update_stats().await; - last_tick = Instant::now(); - } +/// Fetch a fresh snapshot and fold it into `data`, recording any failure as the +/// disconnected state. Shared by the periodic tick and the manual `r` refresh. +async fn poll(url: &str, data: &mut DashboardData) { + match fetch_stats(url).await { + Ok(snapshot) => data.ingest(snapshot), + Err(error) => data.set_error(error), } +} - // Restore terminal - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - terminal.show_cursor()?; - - Ok(()) +/// Run the TUI dashboard until the user quits with `q`/`Esc`. +pub async fn run_dashboard(config: DashboardConfig) -> io::Result<()> { + let refresh = Duration::from_millis(config.refresh_ms.max(1)); + let runner = AsyncRunner::new(RunnerConfig { tick_rate: refresh }); + + // Match the previous look: keep the terminal's own background instead of + // tuika's themed fill, so only the widgets paint color. + let theme = Theme { + background: Color::Reset, + ..Theme::default() + }; + + // The runner owns this state for the duration of the run. `view` reads it + // each frame; `update` mutates it on every signal. The first tick fires + // right after the initial paint, so the dashboard loads without a special + // startup fetch. + let url = config.server_url; + let mut data = DashboardData::new(); + + runner + .run( + &theme, + &mut data, + |data, _frame| ui::dashboard(data), + async |data, signal| match signal { + Signal::Tick => { + poll(&url, data).await; + ControlFlow::Continue(()) + } + Signal::Event(Event::Key(key)) if key.plain() => match key.code { + KeyCode::Char('q') | KeyCode::Esc => ControlFlow::Break(()), + KeyCode::Char('r') => { + // Force an immediate refresh out of the tick cadence. + poll(&url, data).await; + ControlFlow::Continue(()) + } + _ => ControlFlow::Continue(()), + }, + _ => ControlFlow::Continue(()), + }, + ) + .await } #[cfg(test)] mod tests { - use super::StatsEndpoint; + use super::{DashboardData, StatsEndpoint, StatsSnapshot}; #[test] fn parse_rejects_crlf_in_host() { @@ -287,4 +277,79 @@ mod tests { }; assert!(err.contains("invalid path characters")); } + + /// A snapshot with the two fields `ingest` reads (`total_tokens`, + /// `requests_per_second`) set and everything else zeroed. + fn snapshot(total_tokens: u64, requests_per_second: f64) -> StatsSnapshot { + StatsSnapshot { + uptime_secs: 0, + total_requests: 0, + active_requests: 0, + streaming_requests: 0, + non_streaming_requests: 0, + completions_requests: 0, + responses_requests: 0, + websocket_requests: 0, + messages_requests: 0, + image_requests: 0, + active_websocket_connections: 0, + prompt_tokens: 0, + completion_tokens: 0, + total_tokens, + total_errors: 0, + rate_limit_errors: 0, + server_errors: 0, + timeout_errors: 0, + requests_per_second, + avg_latency_ms: 0.0, + min_latency_ms: None, + max_latency_ms: None, + model_requests: Default::default(), + } + } + + #[test] + fn ingest_derives_token_rate_after_the_first_snapshot() { + let mut data = DashboardData::new(); + + // First snapshot seeds the token baseline; no rate can be derived yet. + data.ingest(snapshot(100, 1.0)); + assert_eq!(data.rps_history, vec![1.0]); + assert!( + data.tokens_history.is_empty(), + "no token rate on the first sample" + ); + + // Second snapshot has a baseline to diff against, so a rate is recorded. + data.ingest(snapshot(300, 2.0)); + assert_eq!(data.rps_history, vec![1.0, 2.0]); + assert_eq!(data.tokens_history.len(), 1); + assert!( + data.tokens_history[0] > 0.0, + "200 new tokens over a positive interval is a positive rate" + ); + assert_eq!(data.stats.as_ref().map(|s| s.total_tokens), Some(300)); + } + + #[test] + fn ingest_caps_rolling_history_at_60_samples() { + let mut data = DashboardData::new(); + for i in 0..70 { + data.ingest(snapshot((i + 1) * 10, i as f64)); + } + assert_eq!(data.rps_history.len(), 60, "rps history is bounded"); + assert_eq!(data.tokens_history.len(), 60, "token history is bounded"); + } + + #[test] + fn set_error_marks_disconnected_then_ingest_clears_it() { + let mut data = DashboardData::new(); + + data.set_error("connection refused".to_string()); + assert_eq!(data.error.as_deref(), Some("connection refused")); + + // A successful fetch clears the disconnected state. + data.ingest(snapshot(10, 1.0)); + assert!(data.error.is_none()); + } } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 9d09990..cd6aa71 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1,80 +1,88 @@ -//! TUI rendering logic using Ratatui. - -use super::app::App; +//! TUI rendering: the dashboard as a tree of `tuika` views. +//! +//! Design note: composition (the margin, the row/column splits, the 60/40 and +//! 50/50 panels) is expressed with `tuika`'s flexbox `Flex`/`Dimension` instead +//! of ratatui's `Layout`. The data panels themselves are still drawn with +//! ratatui widgets (`Table`, `Sparkline`, `BarChart`, `Paragraph`) because +//! `tuika` has no charting widgets of its own; each panel is wrapped in a +//! `RatatuiView` so the widget code renders through `tuika`'s clipped surface +//! rather than touching the frame buffer directly. Keeping the widgets means +//! the rendered dashboard is pixel-identical to the previous ratatui version. +//! +//! Every `RatatuiView` closure must be `'static`, so each panel takes owned +//! copies of the values it draws; `dashboard` pulls them out of the snapshot +//! once and hands each panel exactly what it needs. + +use super::app::DashboardData; use ratatui::{ - layout::{Constraint, Direction, Layout, Rect}, + layout::Constraint, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Bar, BarChart, BarGroup, Block, Borders, Paragraph, Row, Sparkline, Table}, - Frame, + widgets::{Bar, BarChart, BarGroup, Block, Borders, Paragraph, Row, Sparkline, Table, Widget}, }; +use tuika::{element, Dimension, Element, Flex, Padding, RatatuiView}; -/// Main draw function -pub fn draw(f: &mut Frame, app: &App) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints([ - Constraint::Length(3), // Header - Constraint::Length(9), // Request stats + Token stats - Constraint::Length(8), // Latency + Errors - Constraint::Min(8), // Charts - Constraint::Length(1), // Footer - ]) - .split(f.area()); - - draw_header(f, app, chunks[0]); - draw_stats_row(f, app, chunks[1]); - draw_latency_errors_row(f, app, chunks[2]); - draw_charts(f, app, chunks[3]); - draw_footer(f, chunks[4]); -} +/// Build the whole dashboard tree for the current snapshot. +/// +/// Layout mirrors the original: a 1-cell margin around a vertical stack of a +/// header (3), the request/token row (9), the latency/error row (8), the charts +/// (flex, min 8), and a one-line footer. +pub fn dashboard(data: &DashboardData) -> Element { + let stats = data.stats.as_ref(); -fn draw_header(f: &mut Frame, app: &App, area: Rect) { - let uptime = app - .stats - .as_ref() + let uptime = stats .map(|s| format_uptime(s.uptime_secs)) .unwrap_or_else(|| "N/A".to_string()); - - let status = if app.error.is_some() { - Span::styled("● DISCONNECTED", Style::default().fg(Color::Red).bold()) - } else { - Span::styled("● CONNECTED", Style::default().fg(Color::Green).bold()) - }; - - let header = Paragraph::new(Line::from(vec![ - Span::styled( - " LLMSim Stats Dashboard ", - Style::default().fg(Color::Cyan).bold(), - ), - Span::raw(" │ "), - status, - Span::raw(" │ Uptime: "), - Span::styled(uptime, Style::default().fg(Color::Yellow)), - ])) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), - ); - - f.render_widget(header, area); + let connected = data.error.is_none(); + + element( + Flex::column() + .padding(Padding::all(1)) + .child(Dimension::Fixed(3), header(uptime, connected)) + .child(Dimension::Fixed(9), stats_row(data)) + .child(Dimension::Fixed(8), latency_errors_row(data)) + .grow(1, charts(data)) + .child(Dimension::Fixed(1), footer()), + ) } -fn draw_stats_row(f: &mut Frame, app: &App, area: Rect) { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(area); +fn header(uptime: String, connected: bool) -> Element { + element(RatatuiView::fill(move |area, buf| { + let status = if connected { + Span::styled("● CONNECTED", Style::default().fg(Color::Green).bold()) + } else { + Span::styled("● DISCONNECTED", Style::default().fg(Color::Red).bold()) + }; - draw_request_stats(f, app, chunks[0]); - draw_token_stats(f, app, chunks[1]); + Paragraph::new(Line::from(vec![ + Span::styled( + " LLMSim Stats Dashboard ", + Style::default().fg(Color::Cyan).bold(), + ), + Span::raw(" │ "), + status, + Span::raw(" │ Uptime: "), + Span::styled(uptime.clone(), Style::default().fg(Color::Yellow)), + ])) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .render(area, buf); + })) } -fn draw_request_stats(f: &mut Frame, app: &App, area: Rect) { - let stats = app.stats.as_ref(); +fn stats_row(data: &DashboardData) -> Element { + element( + Flex::row() + .grow(1, request_stats(data)) + .grow(1, token_stats(data)), + ) +} +fn request_stats(data: &DashboardData) -> Element { + let stats = data.stats.as_ref(); let total = stats.map(|s| s.total_requests).unwrap_or(0); let active = stats.map(|s| s.active_requests).unwrap_or(0); let completions = stats.map(|s| s.completions_requests).unwrap_or(0); @@ -83,191 +91,182 @@ fn draw_request_stats(f: &mut Frame, app: &App, area: Rect) { let streaming = stats.map(|s| s.streaming_requests).unwrap_or(0); let rps = stats.map(|s| s.requests_per_second).unwrap_or(0.0); - let rows = vec![ - Row::new(vec![ - Span::raw("Total Requests"), - Span::styled( - format_number(total), - Style::default().fg(Color::Green).bold(), - ), - ]), - Row::new(vec![ - Span::raw("Active Requests"), - Span::styled( - format!("{}", active), - if active > 0 { - Style::default().fg(Color::Yellow).bold() - } else { - Style::default().fg(Color::Gray) - }, - ), - ]), - Row::new(vec![ - Span::raw("Completions API"), - Span::styled(format_number(completions), Style::default().fg(Color::Cyan)), - ]), - Row::new(vec![ - Span::raw("Responses API"), - Span::styled( - format_number(responses), - Style::default().fg(Color::Magenta), - ), - ]), - Row::new(vec![ - Span::raw("Messages API"), - Span::styled( - format_number(messages), - Style::default().fg(Color::LightRed), - ), - ]), - Row::new(vec![ - Span::raw("Streaming"), - Span::styled(format_number(streaming), Style::default().fg(Color::Blue)), - ]), - Row::new(vec![ - Span::raw("Requests/sec"), - Span::styled( - format!("{:.2}", rps), - Style::default().fg(Color::Green).bold(), - ), - ]), - ]; - - let table = Table::new( - rows, - [Constraint::Percentage(60), Constraint::Percentage(40)], - ) - .block( - Block::default() - .title(" Requests ") - .title_style(Style::default().fg(Color::Green).bold()) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Green)), - ); - - f.render_widget(table, area); + element(RatatuiView::fill(move |area, buf| { + let rows = vec![ + Row::new(vec![ + Span::raw("Total Requests"), + Span::styled( + format_number(total), + Style::default().fg(Color::Green).bold(), + ), + ]), + Row::new(vec![ + Span::raw("Active Requests"), + Span::styled( + format!("{}", active), + if active > 0 { + Style::default().fg(Color::Yellow).bold() + } else { + Style::default().fg(Color::Gray) + }, + ), + ]), + Row::new(vec![ + Span::raw("Completions API"), + Span::styled(format_number(completions), Style::default().fg(Color::Cyan)), + ]), + Row::new(vec![ + Span::raw("Responses API"), + Span::styled( + format_number(responses), + Style::default().fg(Color::Magenta), + ), + ]), + Row::new(vec![ + Span::raw("Messages API"), + Span::styled( + format_number(messages), + Style::default().fg(Color::LightRed), + ), + ]), + Row::new(vec![ + Span::raw("Streaming"), + Span::styled(format_number(streaming), Style::default().fg(Color::Blue)), + ]), + Row::new(vec![ + Span::raw("Requests/sec"), + Span::styled( + format!("{:.2}", rps), + Style::default().fg(Color::Green).bold(), + ), + ]), + ]; + + Table::new( + rows, + [Constraint::Percentage(60), Constraint::Percentage(40)], + ) + .block( + Block::default() + .title(" Requests ") + .title_style(Style::default().fg(Color::Green).bold()) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)), + ) + .render(area, buf); + })) } -fn draw_token_stats(f: &mut Frame, app: &App, area: Rect) { - let stats = app.stats.as_ref(); - +fn token_stats(data: &DashboardData) -> Element { + let stats = data.stats.as_ref(); let prompt = stats.map(|s| s.prompt_tokens).unwrap_or(0); let completion = stats.map(|s| s.completion_tokens).unwrap_or(0); let total = stats.map(|s| s.total_tokens).unwrap_or(0); - - // Calculate token rate - let token_rate = if !app.tokens_history.is_empty() { - *app.tokens_history.last().unwrap_or(&0.0) - } else { - 0.0 - }; - - let rows = vec![ - Row::new(vec![ - Span::raw("Prompt Tokens"), - Span::styled(format_number(prompt), Style::default().fg(Color::Blue)), - ]), - Row::new(vec![ - Span::raw("Completion Tokens"), - Span::styled( - format_number(completion), - Style::default().fg(Color::Magenta), - ), - ]), - Row::new(vec![ - Span::raw("Total Tokens"), - Span::styled( - format_number(total), - Style::default().fg(Color::Cyan).bold(), - ), - ]), - Row::new(vec![ - Span::raw("Tokens/sec"), - Span::styled( - format!("{:.1}", token_rate), - Style::default().fg(Color::Green), - ), - ]), - ]; - - let table = Table::new( - rows, - [Constraint::Percentage(60), Constraint::Percentage(40)], - ) - .block( - Block::default() - .title(" Tokens ") - .title_style(Style::default().fg(Color::Cyan).bold()) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), - ); - - f.render_widget(table, area); + let token_rate = data.tokens_history.last().copied().unwrap_or(0.0); + + element(RatatuiView::fill(move |area, buf| { + let rows = vec![ + Row::new(vec![ + Span::raw("Prompt Tokens"), + Span::styled(format_number(prompt), Style::default().fg(Color::Blue)), + ]), + Row::new(vec![ + Span::raw("Completion Tokens"), + Span::styled( + format_number(completion), + Style::default().fg(Color::Magenta), + ), + ]), + Row::new(vec![ + Span::raw("Total Tokens"), + Span::styled( + format_number(total), + Style::default().fg(Color::Cyan).bold(), + ), + ]), + Row::new(vec![ + Span::raw("Tokens/sec"), + Span::styled( + format!("{:.1}", token_rate), + Style::default().fg(Color::Green), + ), + ]), + ]; + + Table::new( + rows, + [Constraint::Percentage(60), Constraint::Percentage(40)], + ) + .block( + Block::default() + .title(" Tokens ") + .title_style(Style::default().fg(Color::Cyan).bold()) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .render(area, buf); + })) } -fn draw_latency_errors_row(f: &mut Frame, app: &App, area: Rect) { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(area); - - draw_latency_stats(f, app, chunks[0]); - draw_error_stats(f, app, chunks[1]); +fn latency_errors_row(data: &DashboardData) -> Element { + element( + Flex::row() + .grow(1, latency_stats(data)) + .grow(1, error_stats(data)), + ) } -fn draw_latency_stats(f: &mut Frame, app: &App, area: Rect) { - let stats = app.stats.as_ref(); - +fn latency_stats(data: &DashboardData) -> Element { + let stats = data.stats.as_ref(); let avg = stats.map(|s| s.avg_latency_ms).unwrap_or(0.0); let min = stats.and_then(|s| s.min_latency_ms).unwrap_or(0.0); let max = stats.and_then(|s| s.max_latency_ms).unwrap_or(0.0); - let rows = vec![ - Row::new(vec![ - Span::raw("Average"), - Span::styled(format!("{:.2} ms", avg), Style::default().fg(Color::Yellow)), - ]), - Row::new(vec![ - Span::raw("Minimum"), - Span::styled(format!("{:.2} ms", min), Style::default().fg(Color::Green)), - ]), - Row::new(vec![ - Span::raw("Maximum"), - Span::styled( - format!("{:.2} ms", max), - if max > 1000.0 { - Style::default().fg(Color::Red) - } else { - Style::default().fg(Color::Yellow) - }, - ), - ]), - ]; - - let table = Table::new( - rows, - [Constraint::Percentage(50), Constraint::Percentage(50)], - ) - .block( - Block::default() - .title(" Latency ") - .title_style(Style::default().fg(Color::Yellow).bold()) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Yellow)), - ); - - f.render_widget(table, area); + element(RatatuiView::fill(move |area, buf| { + let rows = vec![ + Row::new(vec![ + Span::raw("Average"), + Span::styled(format!("{:.2} ms", avg), Style::default().fg(Color::Yellow)), + ]), + Row::new(vec![ + Span::raw("Minimum"), + Span::styled(format!("{:.2} ms", min), Style::default().fg(Color::Green)), + ]), + Row::new(vec![ + Span::raw("Maximum"), + Span::styled( + format!("{:.2} ms", max), + if max > 1000.0 { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::Yellow) + }, + ), + ]), + ]; + + Table::new( + rows, + [Constraint::Percentage(50), Constraint::Percentage(50)], + ) + .block( + Block::default() + .title(" Latency ") + .title_style(Style::default().fg(Color::Yellow).bold()) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)), + ) + .render(area, buf); + })) } -fn draw_error_stats(f: &mut Frame, app: &App, area: Rect) { - let stats = app.stats.as_ref(); - +fn error_stats(data: &DashboardData) -> Element { + let stats = data.stats.as_ref(); let total = stats.map(|s| s.total_errors).unwrap_or(0); let rate_limit = stats.map(|s| s.rate_limit_errors).unwrap_or(0); let server = stats.map(|s| s.server_errors).unwrap_or(0); let timeout = stats.map(|s| s.timeout_errors).unwrap_or(0); - // Calculate error rate let total_requests = stats.map(|s| s.total_requests).unwrap_or(0); let error_rate = if total_requests > 0 { (total as f64 / total_requests as f64) * 100.0 @@ -275,183 +274,191 @@ fn draw_error_stats(f: &mut Frame, app: &App, area: Rect) { 0.0 }; - let error_style = if total > 0 { - Style::default().fg(Color::Red).bold() - } else { - Style::default().fg(Color::Gray) - }; - - let rows = vec![ - Row::new(vec![ - Span::raw("Total Errors"), - Span::styled(format!("{} ({:.1}%)", total, error_rate), error_style), - ]), - Row::new(vec![ - Span::raw("Rate Limit (429)"), - Span::styled( - format!("{}", rate_limit), - Style::default().fg(Color::Yellow), - ), - ]), - Row::new(vec![ - Span::raw("Server (5xx)"), - Span::styled(format!("{}", server), Style::default().fg(Color::Red)), - ]), - Row::new(vec![ - Span::raw("Timeout (504)"), - Span::styled(format!("{}", timeout), Style::default().fg(Color::Magenta)), - ]), - ]; - - let table = Table::new( - rows, - [Constraint::Percentage(60), Constraint::Percentage(40)], - ) - .block( - Block::default() - .title(" Errors ") - .title_style(Style::default().fg(Color::Red).bold()) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Red)), - ); - - f.render_widget(table, area); -} - -fn draw_charts(f: &mut Frame, app: &App, area: Rect) { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) - .split(area); - - draw_sparklines(f, app, chunks[0]); - draw_model_chart(f, app, chunks[1]); -} - -fn draw_sparklines(f: &mut Frame, app: &App, area: Rect) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(area); - - // RPS Sparkline - let rps_data: Vec = app.rps_history.iter().map(|v| (*v * 10.0) as u64).collect(); - let current_rps = app.rps_history.last().copied().unwrap_or(0.0); - let max_rps = app.rps_history.iter().copied().fold(0.0_f64, f64::max); - - let rps_sparkline = Sparkline::default() + element(RatatuiView::fill(move |area, buf| { + let error_style = if total > 0 { + Style::default().fg(Color::Red).bold() + } else { + Style::default().fg(Color::Gray) + }; + + let rows = vec![ + Row::new(vec![ + Span::raw("Total Errors"), + Span::styled(format!("{} ({:.1}%)", total, error_rate), error_style), + ]), + Row::new(vec![ + Span::raw("Rate Limit (429)"), + Span::styled( + format!("{}", rate_limit), + Style::default().fg(Color::Yellow), + ), + ]), + Row::new(vec![ + Span::raw("Server (5xx)"), + Span::styled(format!("{}", server), Style::default().fg(Color::Red)), + ]), + Row::new(vec![ + Span::raw("Timeout (504)"), + Span::styled(format!("{}", timeout), Style::default().fg(Color::Magenta)), + ]), + ]; + + Table::new( + rows, + [Constraint::Percentage(60), Constraint::Percentage(40)], + ) .block( Block::default() - .title(format!( - " RPS (current: {:.2}, max: {:.2}) ", - current_rps, max_rps - )) - .title_style(Style::default().fg(Color::Green)) + .title(" Errors ") + .title_style(Style::default().fg(Color::Red).bold()) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Green)), + .border_style(Style::default().fg(Color::Red)), ) - .data(&rps_data) - .style(Style::default().fg(Color::Green)); + .render(area, buf); + })) +} - f.render_widget(rps_sparkline, chunks[0]); +fn charts(data: &DashboardData) -> Element { + element( + Flex::row() + .grow(3, sparklines(data)) + .grow(2, model_chart(data)), + ) +} - // Token rate Sparkline - let token_data: Vec = app.tokens_history.iter().map(|v| *v as u64).collect(); - let current_tokens = app.tokens_history.last().copied().unwrap_or(0.0); - let max_tokens = app.tokens_history.iter().copied().fold(0.0_f64, f64::max); +fn sparklines(data: &DashboardData) -> Element { + element( + Flex::column() + .grow(1, rps_sparkline(data)) + .grow(1, tokens_sparkline(data)), + ) +} - let token_sparkline = Sparkline::default() - .block( - Block::default() - .title(format!( - " Tokens/sec (current: {:.0}, max: {:.0}) ", - current_tokens, max_tokens - )) - .title_style(Style::default().fg(Color::Cyan)) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), - ) - .data(&token_data) - .style(Style::default().fg(Color::Cyan)); +fn rps_sparkline(data: &DashboardData) -> Element { + let rps_data: Vec = data + .rps_history + .iter() + .map(|v| (*v * 10.0) as u64) + .collect(); + let current = data.rps_history.last().copied().unwrap_or(0.0); + let max = data.rps_history.iter().copied().fold(0.0_f64, f64::max); - f.render_widget(token_sparkline, chunks[1]); + element(RatatuiView::fill(move |area, buf| { + Sparkline::default() + .block( + Block::default() + .title(format!(" RPS (current: {:.2}, max: {:.2}) ", current, max)) + .title_style(Style::default().fg(Color::Green)) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)), + ) + .data(&rps_data) + .style(Style::default().fg(Color::Green)) + .render(area, buf); + })) } -fn draw_model_chart(f: &mut Frame, app: &App, area: Rect) { - let stats = app.stats.as_ref(); +fn tokens_sparkline(data: &DashboardData) -> Element { + let token_data: Vec = data.tokens_history.iter().map(|v| *v as u64).collect(); + let current = data.tokens_history.last().copied().unwrap_or(0.0); + let max = data.tokens_history.iter().copied().fold(0.0_f64, f64::max); - let model_requests = stats.map(|s| s.model_requests.clone()).unwrap_or_default(); + element(RatatuiView::fill(move |area, buf| { + Sparkline::default() + .block( + Block::default() + .title(format!( + " Tokens/sec (current: {:.0}, max: {:.0}) ", + current, max + )) + .title_style(Style::default().fg(Color::Cyan)) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .data(&token_data) + .style(Style::default().fg(Color::Cyan)) + .render(area, buf); + })) +} - if model_requests.is_empty() { - let empty = Paragraph::new("No requests yet") - .style(Style::default().fg(Color::Gray)) +fn model_chart(data: &DashboardData) -> Element { + let model_requests = data + .stats + .as_ref() + .map(|s| s.model_requests.clone()) + .unwrap_or_default(); + + element(RatatuiView::fill(move |area, buf| { + if model_requests.is_empty() { + Paragraph::new("No requests yet") + .style(Style::default().fg(Color::Gray)) + .block( + Block::default() + .title(" Models ") + .title_style(Style::default().fg(Color::Magenta).bold()) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)), + ) + .render(area, buf); + return; + } + + // Sort by count and take the top models. + let mut model_vec: Vec<_> = model_requests.clone().into_iter().collect(); + model_vec.sort_by_key(|m| std::cmp::Reverse(m.1)); + model_vec.truncate(8); + + let bars: Vec = model_vec + .iter() + .map(|(model, count)| { + // Shorten model name if too long. + let short_name = if model.len() > 12 { + format!("{}...", &model[..9]) + } else { + model.clone() + }; + Bar::default() + .value(*count) + .label(Line::from(short_name)) + .style(Style::default().fg(Color::Magenta)) + }) + .collect(); + + BarChart::default() .block( Block::default() - .title(" Models ") + .title(" Models (top 8) ") .title_style(Style::default().fg(Color::Magenta).bold()) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Magenta)), - ); - f.render_widget(empty, area); - return; - } - - // Sort by count and take top models - let mut model_vec: Vec<_> = model_requests.into_iter().collect(); - model_vec.sort_by_key(|m| std::cmp::Reverse(m.1)); - model_vec.truncate(8); - - // Create bars - let bars: Vec = model_vec - .iter() - .map(|(model, count)| { - // Shorten model name if too long - let short_name = if model.len() > 12 { - format!("{}...", &model[..9]) - } else { - model.clone() - }; - Bar::default() - .value(*count) - .label(Line::from(short_name)) - .style(Style::default().fg(Color::Magenta)) - }) - .collect(); - - let bar_chart = BarChart::default() - .block( - Block::default() - .title(" Models (top 8) ") - .title_style(Style::default().fg(Color::Magenta).bold()) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Magenta)), - ) - .data(BarGroup::default().bars(&bars)) - .bar_width(3) - .bar_gap(1) - .bar_style(Style::default().fg(Color::Magenta)) - .value_style( - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ); - - f.render_widget(bar_chart, area); + ) + .data(BarGroup::default().bars(&bars)) + .bar_width(3) + .bar_gap(1) + .bar_style(Style::default().fg(Color::Magenta)) + .value_style( + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + .render(area, buf); + })) } -fn draw_footer(f: &mut Frame, area: Rect) { - let footer = Paragraph::new(Line::from(vec![ - Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)), - Span::raw(" Quit "), - Span::styled(" r ", Style::default().fg(Color::Black).bg(Color::White)), - Span::raw(" Refresh "), - ])) - .style(Style::default().fg(Color::Gray)); - - f.render_widget(footer, area); +fn footer() -> Element { + element(RatatuiView::fill(move |area, buf| { + Paragraph::new(Line::from(vec![ + Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)), + Span::raw(" Quit "), + Span::styled(" r ", Style::default().fg(Color::Black).bg(Color::White)), + Span::raw(" Refresh "), + ])) + .style(Style::default().fg(Color::Gray)) + .render(area, buf); + })) } -/// Format uptime in human-readable format +/// Format uptime in human-readable format. fn format_uptime(secs: u64) -> String { let days = secs / 86400; let hours = (secs % 86400) / 3600; @@ -469,7 +476,7 @@ fn format_uptime(secs: u64) -> String { } } -/// Format large numbers with K/M/B suffixes +/// Format large numbers with K/M/B suffixes. fn format_number(n: u64) -> String { if n >= 1_000_000_000 { format!("{:.2}B", n as f64 / 1_000_000_000.0)