codex-helper-tui 0.15.0

Terminal UI crate for codex-helper.
Documentation
use std::collections::HashMap;

use super::Language;
use super::i18n;
use super::model::tokens_short;
use super::state::UiState;
use super::types::StatsFocus;
use crate::state::UsageBucket;
use crate::usage::UsageMetrics;
use crate::usage_balance::UsageBalanceView;

#[derive(Debug, Clone)]
pub(in crate::tui) enum StatsTarget {
    Station(String),
    Provider(String),
}

fn sum_buckets(buckets: &[(i32, UsageBucket)]) -> UsageBucket {
    let mut out = UsageBucket::default();
    for (_, b) in buckets {
        out.add_assign(b);
    }
    out
}

#[derive(Debug, Clone, Default)]
struct RecentBreakdown {
    total: u64,
    err: u64,
    class_2xx: u64,
    class_3xx: u64,
    class_4xx: u64,
    class_5xx: u64,
    top_status: Vec<(u16, u64)>,
    top_models_by_tokens: Vec<(String, (u64, i64))>,
    top_paths_by_tokens: Vec<(String, (u64, u64, i64))>,
}

fn compute_recent_breakdown(
    ui: &UiState,
    snapshot: &super::model::Snapshot,
    target: &StatsTarget,
) -> RecentBreakdown {
    let mut by_model: HashMap<String, (u64, i64)> = HashMap::new();
    let mut by_path: HashMap<String, (u64, u64, i64)> = HashMap::new();
    let mut by_status: HashMap<u16, u64> = HashMap::new();
    let mut out = RecentBreakdown::default();

    for r in &snapshot.recent {
        let matches = match target {
            StatsTarget::Station(name) => r.station_name.as_deref() == Some(name.as_str()),
            StatsTarget::Provider(name) => r.provider_id.as_deref() == Some(name.as_str()),
        };
        if !matches {
            continue;
        }
        if ui.stats_errors_only && r.status_code < 400 {
            continue;
        }

        out.total += 1;
        if r.status_code >= 400 {
            out.err += 1;
        }
        match r.status_code {
            200..=299 => out.class_2xx += 1,
            300..=399 => out.class_3xx += 1,
            400..=499 => out.class_4xx += 1,
            _ => out.class_5xx += 1,
        }
        *by_status.entry(r.status_code).or_insert(0) += 1;

        let model = r.model.as_deref().unwrap_or("-");
        let tokens = r.usage.as_ref().map(|u| u.total_tokens).unwrap_or(0);
        by_model
            .entry(model.to_string())
            .and_modify(|(c, t)| {
                *c = c.saturating_add(1);
                *t = t.saturating_add(tokens);
            })
            .or_insert((1, tokens));

        by_path
            .entry(r.path.clone())
            .and_modify(|(c, e, t)| {
                *c = c.saturating_add(1);
                if r.status_code >= 400 {
                    *e = e.saturating_add(1);
                }
                *t = t.saturating_add(tokens);
            })
            .or_insert((1, if r.status_code >= 400 { 1 } else { 0 }, tokens));
    }

    let mut status_items = by_status.into_iter().collect::<Vec<_>>();
    status_items.sort_by_key(|(_, c)| std::cmp::Reverse(*c));
    status_items.truncate(10);
    out.top_status = status_items;

    let mut model_items = by_model.into_iter().collect::<Vec<_>>();
    model_items.sort_by_key(|(_, (_, tok))| std::cmp::Reverse(*tok));
    model_items.truncate(10);
    out.top_models_by_tokens = model_items;

    let mut path_items = by_path.into_iter().collect::<Vec<_>>();
    path_items.sort_by_key(|(_, (_, _, tok))| std::cmp::Reverse(*tok));
    path_items.truncate(10);
    out.top_paths_by_tokens = path_items;

    out
}

fn fmt_pct(num: u64, den: u64) -> String {
    if den == 0 {
        return "-".to_string();
    }
    format!("{:.1}%", (num as f64) * 100.0 / (den as f64))
}

fn fmt_avg_ms(total_ms: u64, n: u64) -> String {
    if n == 0 {
        return "-".to_string();
    }
    format!("{}ms", total_ms / n)
}

fn fmt_usage_line(u: &UsageMetrics, lang: Language) -> String {
    let mut line = format!(
        "{}: {}/{}/{}/{}",
        i18n::label(lang, "tok in/out/rsn/ttl"),
        tokens_short(u.input_tokens),
        tokens_short(u.output_tokens),
        tokens_short(u.reasoning_output_tokens_total()),
        tokens_short(u.total_tokens)
    );
    if u.has_cache_tokens() {
        line.push_str(&format!(
            " {}: {}/{}",
            i18n::label(lang, "cache read/create"),
            tokens_short(u.cache_read_tokens_total()),
            tokens_short(u.cache_creation_tokens_total())
        ));
    }
    line
}

fn selected_stats_target_from_view(
    ui: &UiState,
    snapshot: &super::model::Snapshot,
    usage_balance: &UsageBalanceView,
) -> Option<StatsTarget> {
    match ui.stats_focus {
        StatsFocus::Stations => snapshot
            .usage_rollup
            .by_config
            .get(ui.selected_stats_station_idx)
            .map(|(k, _)| StatsTarget::Station(k.clone())),
        StatsFocus::Providers => ui
            .selected_usage_balance_provider_row(usage_balance)
            .map(|row| StatsTarget::Provider(row.provider_id.clone())),
    }
}

pub(in crate::tui) fn build_stats_report(
    ui: &UiState,
    snapshot: &super::model::Snapshot,
    now_ms: u64,
) -> Option<String> {
    let usage_balance = ui.usage_balance_view_for_report(snapshot, now_ms);
    let target = selected_stats_target_from_view(ui, snapshot, &usage_balance)?;

    let window_series = match &target {
        StatsTarget::Station(name) => snapshot
            .usage_rollup
            .by_config_day
            .get(name)
            .cloned()
            .unwrap_or_default(),
        StatsTarget::Provider(name) => snapshot
            .usage_rollup
            .by_provider_day
            .get(name)
            .cloned()
            .unwrap_or_default(),
    };

    let window_bucket = sum_buckets(&window_series);
    let recent = compute_recent_breakdown(ui, snapshot, &target);

    let (kind, name) = match &target {
        StatsTarget::Station(n) => (i18n::label(ui.language, "station"), n.as_str()),
        StatsTarget::Provider(n) => (i18n::label(ui.language, "provider"), n.as_str()),
    };
    let l = |text| i18n::label(ui.language, text);

    let mut out = String::new();
    out.push_str(match ui.language {
        Language::Zh => "codex-helper TUI 统计报告\n",
        Language::En => "codex-helper TUI Stats report\n",
    });
    out.push_str(&format!("generated_at_ms: {now_ms}\n"));
    out.push_str(&format!("service: {}\n", ui.service_name));
    out.push_str(&format!("{}: {kind} {name}\n", l("target")));
    let window_label = match ui.stats_days {
        0 => l("loaded").to_string(),
        1 => l("today").to_string(),
        n => format!("{n}d"),
    };
    out.push_str(&format!("{}: {window_label}\n", l("window")));
    out.push_str(&format!(
        "{}: {}  {}: {}\n",
        l("loaded total req"),
        snapshot.usage_rollup.coverage.loaded_requests,
        l("loaded days with data"),
        snapshot.usage_rollup.coverage.loaded_days_with_data
    ));
    if snapshot.usage_rollup.coverage.window_exceeds_loaded_start {
        out.push_str(&format!(
            "{}: {}\n",
            l("coverage warning"),
            l("selected window starts before loaded log data")
        ));
    }
    out.push_str(&format!(
        "{}: {}={}\n",
        l("recent filter"),
        l("errors_only"),
        ui.stats_errors_only
    ));
    out.push('\n');

    out.push_str(match ui.language {
        Language::Zh => "[窗口汇总]\n",
        Language::En => "[window rollup]\n",
    });
    out.push_str(&format!(
        "{}: {} ({} {} / {})  {} {}\n",
        l("requests"),
        window_bucket.requests_total,
        l("errors"),
        window_bucket.requests_error,
        fmt_pct(window_bucket.requests_error, window_bucket.requests_total),
        l("avg"),
        fmt_avg_ms(
            window_bucket.duration_ms_total,
            window_bucket.requests_total
        ),
    ));
    out.push_str(&format!(
        "{}\n",
        fmt_usage_line(&window_bucket.usage, ui.language)
    ));
    if let StatsTarget::Provider(provider_id) = &target
        && let Some(row) = usage_balance
            .provider_rows
            .iter()
            .find(|row| row.provider_id == *provider_id)
    {
        out.push_str(&format!(
            "{}: {}  balance_status={}  route={}  endpoints={}  latest_error={}\n",
            i18n::label(ui.language, "Usage / Balance"),
            row.provider_id,
            row.balance_status.as_str(),
            if row.routing.selected {
                row.routing
                    .selected_endpoint_id
                    .as_deref()
                    .unwrap_or("selected")
                    .to_string()
            } else if row.routing.skip_reasons.is_empty() {
                "-".to_string()
            } else {
                row.routing.skip_reasons.join(",")
            },
            row.endpoint_count,
            row.latest_balance_error.as_deref().unwrap_or("-")
        ));
    }
    out.push('\n');

    out.push_str(match ui.language {
        Language::Zh => "[Usage / Balance providers]\n",
        Language::En => "[Usage / Balance providers]\n",
    });
    for row in usage_balance.provider_rows.iter().take(30) {
        let route = if row.routing.selected {
            row.routing
                .selected_endpoint_id
                .as_deref()
                .unwrap_or("selected")
                .to_string()
        } else if row.routing.skip_reasons.is_empty() {
            "-".to_string()
        } else {
            row.routing.skip_reasons.join(",")
        };
        out.push_str(&format!(
            "  - {}: req={} err={} tok={} cost={} balance={} endpoints={} route={} latest_error={}\n",
            row.provider_id,
            row.usage.requests_total,
            row.usage.requests_error,
            tokens_short(row.usage.usage.total_tokens),
            row.cost_display,
            row.balance_status.as_str(),
            row.endpoint_count,
            route,
            row.latest_balance_error.as_deref().unwrap_or("-")
        ));
    }
    if let StatsTarget::Provider(provider_id) = &target {
        let endpoint_rows = usage_balance
            .endpoint_rows
            .iter()
            .filter(|row| row.provider_id == *provider_id)
            .take(30)
            .collect::<Vec<_>>();
        if !endpoint_rows.is_empty() {
            out.push_str(match ui.language {
                Language::Zh => "[Usage / Balance endpoints]\n",
                Language::En => "[Usage / Balance endpoints]\n",
            });
            for row in endpoint_rows {
                let route = if row.route_selected {
                    "selected".to_string()
                } else if row.route_skip_reasons.is_empty() {
                    "-".to_string()
                } else {
                    row.route_skip_reasons.join(",")
                };
                out.push_str(&format!(
                    "  - {}: req={} err={} tok={} balance={} route={}\n",
                    row.endpoint_id,
                    row.usage.requests_total,
                    row.usage.requests_error,
                    tokens_short(row.usage.usage.total_tokens),
                    row.balance_status.as_str(),
                    route
                ));
            }
        }
    }
    out.push('\n');

    out.push_str(match ui.language {
        Language::Zh => "[最近样本]\n",
        Language::En => "[recent sample]\n",
    });
    out.push_str(&format!(
        "{}: {}  {}: {}  2xx/3xx/4xx/5xx: {}/{}/{}/{}\n",
        l("requests"),
        recent.total,
        l("errors"),
        recent.err,
        recent.class_2xx,
        recent.class_3xx,
        recent.class_4xx,
        recent.class_5xx
    ));
    if !recent.top_status.is_empty() {
        out.push_str(&format!("{}:\n", l("top status")));
        for (s, c) in &recent.top_status {
            out.push_str(&format!("  - {s}: {c}\n"));
        }
    }
    if !recent.top_models_by_tokens.is_empty() {
        out.push_str(&format!("{}:\n", l("top models by tokens")));
        for (m, (c, tok)) in &recent.top_models_by_tokens {
            out.push_str(&format!(
                "  - {}: {} {} / {}\n",
                m,
                c,
                l("req"),
                tokens_short(*tok)
            ));
        }
    }
    if !recent.top_paths_by_tokens.is_empty() {
        out.push_str(&format!("{}:\n", l("top paths by tokens")));
        for (path, (c, e, tok)) in &recent.top_paths_by_tokens {
            out.push_str(&format!(
                "  - {}: {} {} ({} {}) / {}\n",
                path,
                c,
                l("req"),
                l("err"),
                e,
                tokens_short(*tok)
            ));
        }
    }

    Some(out)
}