codex-cost 0.1.0

Local web dashboard for inspecting Codex token usage from session logs.
Documentation
use std::path::PathBuf;
use std::sync::Arc;

use anyhow::Result;
use axum::{
    Json, Router,
    extract::State,
    http::header,
    response::{Html, IntoResponse, Response},
    routing::{get, post},
};
use chrono::Utc;
use serde::Serialize;
use tokio::{net::TcpListener, sync::RwLock};

use crate::{
    aggregate::build_summary,
    models::UsageSummary,
    provider::{LocalCodexProvider, UsageProvider},
};

#[derive(Clone)]
struct AppState {
    provider: Arc<LocalCodexProvider>,
    summary: Arc<RwLock<UsageSummary>>,
}

#[derive(Debug, Serialize)]
struct Health {
    status: &'static str,
}

pub fn create_app(provider: LocalCodexProvider) -> Router {
    let initial_summary = load_summary(&provider);
    let state = AppState {
        provider: Arc::new(provider),
        summary: Arc::new(RwLock::new(initial_summary)),
    };

    Router::new()
        .route("/", get(index))
        .route("/number-format.js", get(number_format_js))
        .route("/app.js", get(app_js))
        .route("/styles.css", get(styles_css))
        .route("/health", get(health))
        .route("/api/summary", get(summary_route))
        .route("/api/refresh", post(refresh))
        .with_state(state)
}

pub async fn serve(codex_home: Option<PathBuf>, listen: String) -> Result<()> {
    let provider = LocalCodexProvider::new(codex_home);
    let app = create_app(provider);
    let listener = TcpListener::bind(&listen).await?;
    let address = listener.local_addr()?;
    println!("Codex Cost dashboard: http://{address}");
    axum::serve(listener, app).await?;
    Ok(())
}

fn load_summary(provider: &LocalCodexProvider) -> UsageSummary {
    let load = provider.load_events();
    build_summary(load.events, load.warnings, load.session_count, Utc::now())
}

async fn index() -> Html<&'static str> {
    Html(include_str!("../static/index.html"))
}

async fn app_js() -> Response {
    (
        [(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )],
        include_str!("../static/app.js"),
    )
        .into_response()
}

async fn number_format_js() -> Response {
    (
        [(
            header::CONTENT_TYPE,
            "application/javascript; charset=utf-8",
        )],
        include_str!("../static/number-format.js"),
    )
        .into_response()
}

async fn styles_css() -> Response {
    (
        [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
        include_str!("../static/styles.css"),
    )
        .into_response()
}

async fn health() -> Json<Health> {
    Json(Health { status: "ok" })
}

async fn summary_route(State(state): State<AppState>) -> Json<UsageSummary> {
    Json(state.summary.read().await.clone())
}

async fn refresh(State(state): State<AppState>) -> Json<UsageSummary> {
    let summary = load_summary(&state.provider);
    *state.summary.write().await = summary.clone();
    Json(summary)
}