llmtop 0.1.0

Realtime TUI monitor for local LLM servers (ollama, llama.cpp). The only GPU monitor that knows what model is running and how much each token costs you in energy and dollar-equivalent.
use ratatui::{
    Frame,
    layout::{Constraint, Direction, Layout, Rect},
    style::{Color, Style},
    symbols::Marker,
    text::Span,
    widgets::{Axis, Block, Borders, Chart, Dataset, GraphType},
};

const ACCENT: Color = Color::Green;
const DANGER: Color = Color::Red;

use crate::app::App;

pub fn draw(f: &mut Frame, area: Rect, app: &App) {
    let cols = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
        .split(area);

    draw_util(f, cols[0], app);
    draw_power(f, cols[1], app);
}

fn draw_util(f: &mut Frame, area: Rect, app: &App) {
    let cur = app.hardware.as_ref().map(|h| h.gpu_util_pct).unwrap_or(0);
    let color = accent_for(cur as f64, 100.0);

    let points = smoothed(&app.util_history, 0.35);

    let title_spans = vec![Span::raw(format!(" GPU util {:>3}% ", cur))];

    let datasets = vec![
        Dataset::default()
            .marker(Marker::Braille)
            .graph_type(GraphType::Line)
            .style(Style::default().fg(color))
            .data(&points),
    ];

    let xmax = points.last().map(|(x, _)| *x).unwrap_or(0.0).max(1.0);
    let xmin = (xmax - 120.0).max(0.0);

    let chart = Chart::new(datasets)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::DarkGray))
                .title(title_spans),
        )
        .x_axis(Axis::default().bounds([xmin, xmax]))
        .y_axis(
            Axis::default()
                .style(Style::default().fg(Color::DarkGray))
                .bounds([0.0, 100.0])
                .labels(vec![
                    Span::styled("0", Style::default().fg(Color::DarkGray)),
                    Span::styled("50", Style::default().fg(Color::DarkGray)),
                    Span::styled("100", Style::default().fg(Color::DarkGray)),
                ]),
        );
    f.render_widget(chart, area);
}

fn draw_power(f: &mut Frame, area: Rect, app: &App) {
    let cur = app.hardware.as_ref().map(|h| h.power_w).unwrap_or(0.0);
    let limit = app
        .hardware
        .as_ref()
        .map(|h| h.power_limit_w.max(1.0))
        .unwrap_or(500.0);

    let points = smoothed(&app.power_history, 0.35);

    let color = accent_for(cur, limit);
    let title_spans = vec![Span::raw(format!(" Power {:>3.0}W / {:.0}W ", cur, limit))];

    let datasets = vec![
        Dataset::default()
            .marker(Marker::Braille)
            .graph_type(GraphType::Line)
            .style(Style::default().fg(color))
            .data(&points),
    ];

    let xmax = points.last().map(|(x, _)| *x).unwrap_or(0.0).max(1.0);
    let xmin = (xmax - 120.0).max(0.0);
    let half = limit / 2.0;

    let chart = Chart::new(datasets)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::DarkGray))
                .title(title_spans),
        )
        .x_axis(Axis::default().bounds([xmin, xmax]))
        .y_axis(
            Axis::default()
                .style(Style::default().fg(Color::DarkGray))
                .bounds([0.0, limit])
                .labels(vec![
                    Span::styled("0", Style::default().fg(Color::DarkGray)),
                    Span::styled(format!("{:.0}", half), Style::default().fg(Color::DarkGray)),
                    Span::styled(format!("{:.0}", limit), Style::default().fg(Color::DarkGray)),
                ]),
        );
    f.render_widget(chart, area);
}

fn smoothed(hist: &[f64], alpha: f64) -> Vec<(f64, f64)> {
    let mut out = Vec::with_capacity(hist.len());
    let mut ema = 0.0;
    for (i, v) in hist.iter().enumerate() {
        ema = if i == 0 { *v } else { alpha * v + (1.0 - alpha) * ema };
        out.push((i as f64, ema));
    }
    out
}

fn accent_for(v: f64, max: f64) -> Color {
    let r = if max > 0.0 { v / max } else { 0.0 };
    if r >= 0.9 { DANGER } else { ACCENT }
}