Skip to main content

ccboard_web/
api.rs

1//! API client utilities and shared types for frontend
2
3use gloo_net::http::Request;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// API base URL - points to Axum backend
8/// Backend and frontend are served from the same origin (port 3333 by default)
9/// Use empty string for relative URLs (works in both dev and prod)
10const API_BASE_URL: &str = "";
11
12/// Stats data structure matching backend API response
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct StatsData {
16    #[serde(default)]
17    pub version: u32,
18    #[serde(default)]
19    pub last_computed_date: Option<String>,
20    #[serde(default)]
21    pub total_sessions: u64,
22    #[serde(default)]
23    pub total_messages: u64,
24    #[serde(default)]
25    pub daily_activity: Vec<DailyActivityEntry>,
26    #[serde(default)]
27    pub model_usage: HashMap<String, ModelUsage>,
28    // Analytics extension
29    #[serde(default)]
30    pub daily_tokens_30d: Vec<u64>,
31    #[serde(default)]
32    pub forecast_tokens_30d: Vec<u64>,
33    #[serde(default)]
34    pub forecast_confidence: f64,
35    #[serde(default)]
36    pub forecast_cost_30d: f64,
37    #[serde(default)]
38    pub projects_by_cost: Vec<ProjectCost>,
39    #[serde(default)]
40    pub most_used_model: Option<MostUsedModel>,
41    #[serde(default)]
42    pub this_month_cost: f64,
43    #[serde(default)]
44    pub avg_session_cost: f64,
45    #[serde(default)]
46    pub cache_hit_ratio: f64,
47    #[serde(default)]
48    pub mcp_servers_count: usize,
49}
50
51#[derive(Debug, Clone, Default, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase")]
53pub struct ProjectCost {
54    pub project: String,
55    #[serde(default)]
56    pub cost: f64,
57    #[serde(default)]
58    pub percentage: f64,
59}
60
61#[derive(Debug, Clone, Default, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63pub struct MostUsedModel {
64    pub name: String,
65    #[serde(default)]
66    pub count: u64,
67}
68
69#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct DailyActivityEntry {
72    pub date: String,
73    #[serde(default)]
74    pub message_count: u64,
75    #[serde(default)]
76    pub session_count: u64,
77    #[serde(default)]
78    pub tool_call_count: u64,
79}
80
81#[derive(Debug, Clone, Default, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct ModelUsage {
84    #[serde(default)]
85    pub input_tokens: u64,
86    #[serde(default)]
87    pub output_tokens: u64,
88    #[serde(default)]
89    pub cache_read_input_tokens: u64,
90    #[serde(default)]
91    pub cache_creation_input_tokens: u64,
92    #[serde(default)]
93    pub cost_usd: f64,
94}
95
96impl ModelUsage {
97    pub fn total_tokens(&self) -> u64 {
98        self.input_tokens + self.output_tokens
99    }
100}
101
102impl StatsData {
103    /// Calculate total tokens across all models
104    pub fn total_tokens(&self) -> u64 {
105        self.model_usage.values().map(|m| m.total_tokens()).sum()
106    }
107
108    /// Calculate total cost across all models
109    pub fn total_cost(&self) -> f64 {
110        self.model_usage.values().map(|m| m.cost_usd).sum()
111    }
112
113    /// Average cost per session
114    pub fn avg_session_cost(&self) -> f64 {
115        if self.total_sessions == 0 {
116            return 0.0;
117        }
118        self.total_cost() / self.total_sessions as f64
119    }
120
121    /// Sessions count for current month (simplified for WASM)
122    pub fn this_month_sessions(&self) -> u64 {
123        // Get last 30 days of activity as a proxy for "this month"
124        let len = self.daily_activity.len();
125        let start = if len > 30 { len - 30 } else { 0 };
126
127        self.daily_activity[start..]
128            .iter()
129            .map(|entry| entry.session_count)
130            .sum()
131    }
132
133    /// Token count for current week (simplified for WASM)
134    pub fn this_week_tokens(&self) -> u64 {
135        // Get last 7 days of activity
136        let len = self.daily_activity.len();
137        let start = if len > 7 { len - 7 } else { 0 };
138
139        self.daily_activity[start..]
140            .iter()
141            .map(|entry| {
142                // Estimate tokens from message count (rough approximation)
143                entry.message_count * 500
144            })
145            .sum()
146    }
147
148    /// Get last 30 days of token activity for sparkline
149    pub fn daily_tokens_30d(&self) -> Vec<u64> {
150        let mut result = Vec::new();
151        let len = self.daily_activity.len();
152        let start = if len > 30 { len - 30 } else { 0 };
153
154        for entry in &self.daily_activity[start..] {
155            // Estimate tokens from message count
156            result.push(entry.message_count * 500);
157        }
158
159        // Pad with zeros if less than 30 days
160        while result.len() < 30 {
161            result.insert(0, 0);
162        }
163
164        result
165    }
166}
167
168/// Session data structure (complete version)
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
170pub struct SessionData {
171    pub id: String,
172    pub date: Option<String>,
173    pub project: String,
174    pub model: String,
175    pub messages: u64,
176    pub tokens: u64,
177    pub input_tokens: u64,
178    pub output_tokens: u64,
179    pub cache_creation_tokens: u64,
180    pub cache_read_tokens: u64,
181    pub cost: f64,
182    pub status: String,
183    pub first_timestamp: Option<String>,
184    pub duration_seconds: Option<u64>,
185    pub preview: Option<String>,
186}
187
188/// Recent sessions response from API
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct RecentSessionsResponse {
191    pub sessions: Vec<SessionData>,
192    pub total: u64,
193}
194
195/// Fetch stats from API
196pub async fn fetch_stats() -> Result<StatsData, String> {
197    let url = format!("{}/api/stats", API_BASE_URL);
198    let response = Request::get(&url)
199        .send()
200        .await
201        .map_err(|e| format!("Network error: {}", e))?;
202
203    if !response.ok() {
204        return Err(format!("HTTP error: {}", response.status()));
205    }
206
207    let stats = response
208        .json::<StatsData>()
209        .await
210        .map_err(|e| format!("Parse error: {}", e))?;
211
212    Ok(stats)
213}
214
215/// Fetch recent sessions from API (for dashboard)
216pub async fn fetch_recent_sessions(limit: u32) -> Result<RecentSessionsResponse, String> {
217    let url = format!("{}/api/sessions/recent?limit={}", API_BASE_URL, limit);
218    let response = Request::get(&url)
219        .send()
220        .await
221        .map_err(|e| format!("Network error: {}", e))?;
222
223    if !response.ok() {
224        return Err(format!("HTTP error: {}", response.status()));
225    }
226
227    let sessions = response
228        .json::<RecentSessionsResponse>()
229        .await
230        .map_err(|e| format!("Parse error: {}", e))?;
231
232    Ok(sessions)
233}
234
235/// Format large numbers (K, M, B)
236pub fn format_number(n: u64) -> String {
237    if n >= 1_000_000_000 {
238        format!("{:.1}B", n as f64 / 1_000_000_000.0)
239    } else if n >= 1_000_000 {
240        format!("{:.1}M", n as f64 / 1_000_000.0)
241    } else if n >= 1_000 {
242        format!("{:.1}K", n as f64 / 1_000.0)
243    } else {
244        n.to_string()
245    }
246}
247
248/// Format cost as USD
249pub fn format_cost(cost: f64) -> String {
250    format!("${:.2}", cost)
251}