1use std::path::PathBuf;
2use std::sync::Arc;
3
4use anyhow::Result;
5use axum::{
6 Json, Router,
7 extract::State,
8 http::header,
9 response::{Html, IntoResponse, Response},
10 routing::{get, post},
11};
12use chrono::Utc;
13use serde::Serialize;
14use tokio::{net::TcpListener, sync::RwLock};
15
16use crate::{
17 aggregate::build_summary,
18 models::UsageSummary,
19 provider::{LocalCodexProvider, UsageProvider},
20};
21
22#[derive(Clone)]
23struct AppState {
24 provider: Arc<LocalCodexProvider>,
25 summary: Arc<RwLock<UsageSummary>>,
26}
27
28#[derive(Debug, Serialize)]
29struct Health {
30 status: &'static str,
31}
32
33pub fn create_app(provider: LocalCodexProvider) -> Router {
34 let initial_summary = load_summary(&provider);
35 let state = AppState {
36 provider: Arc::new(provider),
37 summary: Arc::new(RwLock::new(initial_summary)),
38 };
39
40 Router::new()
41 .route("/", get(index))
42 .route("/number-format.js", get(number_format_js))
43 .route("/app.js", get(app_js))
44 .route("/styles.css", get(styles_css))
45 .route("/health", get(health))
46 .route("/api/summary", get(summary_route))
47 .route("/api/refresh", post(refresh))
48 .with_state(state)
49}
50
51pub async fn serve(codex_home: Option<PathBuf>, listen: String) -> Result<()> {
52 let provider = LocalCodexProvider::new(codex_home);
53 let app = create_app(provider);
54 let listener = TcpListener::bind(&listen).await?;
55 let address = listener.local_addr()?;
56 println!("Codex Cost dashboard: http://{address}");
57 axum::serve(listener, app).await?;
58 Ok(())
59}
60
61fn load_summary(provider: &LocalCodexProvider) -> UsageSummary {
62 let load = provider.load_events();
63 build_summary(load.events, load.warnings, load.session_count, Utc::now())
64}
65
66async fn index() -> Html<&'static str> {
67 Html(include_str!("../static/index.html"))
68}
69
70async fn app_js() -> Response {
71 (
72 [(
73 header::CONTENT_TYPE,
74 "application/javascript; charset=utf-8",
75 )],
76 include_str!("../static/app.js"),
77 )
78 .into_response()
79}
80
81async fn number_format_js() -> Response {
82 (
83 [(
84 header::CONTENT_TYPE,
85 "application/javascript; charset=utf-8",
86 )],
87 include_str!("../static/number-format.js"),
88 )
89 .into_response()
90}
91
92async fn styles_css() -> Response {
93 (
94 [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
95 include_str!("../static/styles.css"),
96 )
97 .into_response()
98}
99
100async fn health() -> Json<Health> {
101 Json(Health { status: "ok" })
102}
103
104async fn summary_route(State(state): State<AppState>) -> Json<UsageSummary> {
105 Json(state.summary.read().await.clone())
106}
107
108async fn refresh(State(state): State<AppState>) -> Json<UsageSummary> {
109 let summary = load_summary(&state.provider);
110 *state.summary.write().await = summary.clone();
111 Json(summary)
112}