feat(pro): add analytics, export, and multi-account commercial features
3 new agor-pro modules: analytics.rs (usage dashboard queries), export.rs (session/project Markdown report generation), profiles.rs (multi-account switching via accounts.json). 9 Tauri plugin commands. Frontend IPC bridge (pro-bridge.ts). 168 cargo tests, 14 commercial vitest tests.
This commit is contained in:
parent
6973c70c5a
commit
03fe2e2237
8 changed files with 805 additions and 3 deletions
176
agor-pro/src/analytics.rs
Normal file
176
agor-pro/src/analytics.rs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
// SPDX-License-Identifier: LicenseRef-Commercial
|
||||
// Analytics Dashboard — historical cost tracking, model usage, token trends.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnalyticsSummary {
|
||||
pub total_sessions: i64,
|
||||
pub total_cost_usd: f64,
|
||||
pub total_tokens: i64,
|
||||
pub total_turns: i64,
|
||||
pub total_tool_calls: i64,
|
||||
pub avg_cost_per_session: f64,
|
||||
pub avg_tokens_per_session: f64,
|
||||
pub period_days: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DailyStats {
|
||||
pub date: String,
|
||||
pub session_count: i64,
|
||||
pub cost_usd: f64,
|
||||
pub tokens: i64,
|
||||
pub turns: i64,
|
||||
pub tool_calls: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelBreakdown {
|
||||
pub model: String,
|
||||
pub session_count: i64,
|
||||
pub total_cost_usd: f64,
|
||||
pub total_tokens: i64,
|
||||
pub avg_cost_per_session: f64,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pro_analytics_summary(project_id: String, days: Option<i64>) -> Result<AnalyticsSummary, String> {
|
||||
let conn = super::open_sessions_db()?;
|
||||
let period = days.unwrap_or(30);
|
||||
let cutoff = now_epoch() - (period * 86400);
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT COUNT(*), COALESCE(SUM(cost_usd), 0), COALESCE(SUM(peak_tokens), 0),
|
||||
COALESCE(SUM(turn_count), 0), COALESCE(SUM(tool_call_count), 0)
|
||||
FROM session_metrics WHERE project_id = ?1 AND end_time >= ?2"
|
||||
).map_err(|e| format!("Query failed: {e}"))?;
|
||||
|
||||
let result = stmt.query_row(rusqlite::params![project_id, cutoff], |row| {
|
||||
let count: i64 = row.get(0)?;
|
||||
let cost: f64 = row.get(1)?;
|
||||
let tokens: i64 = row.get(2)?;
|
||||
let turns: i64 = row.get(3)?;
|
||||
let tools: i64 = row.get(4)?;
|
||||
Ok(AnalyticsSummary {
|
||||
total_sessions: count,
|
||||
total_cost_usd: cost,
|
||||
total_tokens: tokens,
|
||||
total_turns: turns,
|
||||
total_tool_calls: tools,
|
||||
avg_cost_per_session: if count > 0 { cost / count as f64 } else { 0.0 },
|
||||
avg_tokens_per_session: if count > 0 { tokens as f64 / count as f64 } else { 0.0 },
|
||||
period_days: period,
|
||||
})
|
||||
}).map_err(|e| format!("Query failed: {e}"))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pro_analytics_daily(project_id: String, days: Option<i64>) -> Result<Vec<DailyStats>, String> {
|
||||
let conn = super::open_sessions_db()?;
|
||||
let period = days.unwrap_or(30);
|
||||
let cutoff = now_epoch() - (period * 86400);
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT date(end_time, 'unixepoch') as day,
|
||||
COUNT(*), COALESCE(SUM(cost_usd), 0), COALESCE(SUM(peak_tokens), 0),
|
||||
COALESCE(SUM(turn_count), 0), COALESCE(SUM(tool_call_count), 0)
|
||||
FROM session_metrics
|
||||
WHERE project_id = ?1 AND end_time >= ?2
|
||||
GROUP BY day ORDER BY day ASC"
|
||||
).map_err(|e| format!("Query failed: {e}"))?;
|
||||
|
||||
let rows = stmt.query_map(rusqlite::params![project_id, cutoff], |row| {
|
||||
Ok(DailyStats {
|
||||
date: row.get(0)?,
|
||||
session_count: row.get(1)?,
|
||||
cost_usd: row.get(2)?,
|
||||
tokens: row.get(3)?,
|
||||
turns: row.get(4)?,
|
||||
tool_calls: row.get(5)?,
|
||||
})
|
||||
}).map_err(|e| format!("Query failed: {e}"))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("Row read failed: {e}"))?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pro_analytics_model_breakdown(project_id: String, days: Option<i64>) -> Result<Vec<ModelBreakdown>, String> {
|
||||
let conn = super::open_sessions_db()?;
|
||||
let period = days.unwrap_or(30);
|
||||
let cutoff = now_epoch() - (period * 86400);
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT COALESCE(model, 'unknown'), COUNT(*), COALESCE(SUM(cost_usd), 0),
|
||||
COALESCE(SUM(peak_tokens), 0)
|
||||
FROM session_metrics
|
||||
WHERE project_id = ?1 AND end_time >= ?2
|
||||
GROUP BY model ORDER BY SUM(cost_usd) DESC"
|
||||
).map_err(|e| format!("Query failed: {e}"))?;
|
||||
|
||||
let rows = stmt.query_map(rusqlite::params![project_id, cutoff], |row| {
|
||||
let count: i64 = row.get(1)?;
|
||||
let cost: f64 = row.get(2)?;
|
||||
Ok(ModelBreakdown {
|
||||
model: row.get(0)?,
|
||||
session_count: count,
|
||||
total_cost_usd: cost,
|
||||
total_tokens: row.get(3)?,
|
||||
avg_cost_per_session: if count > 0 { cost / count as f64 } else { 0.0 },
|
||||
})
|
||||
}).map_err(|e| format!("Query failed: {e}"))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("Row read failed: {e}"))?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub(crate) fn now_epoch() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_analytics_summary_struct() {
|
||||
let s = AnalyticsSummary {
|
||||
total_sessions: 5,
|
||||
total_cost_usd: 1.25,
|
||||
total_tokens: 50000,
|
||||
total_turns: 30,
|
||||
total_tool_calls: 100,
|
||||
avg_cost_per_session: 0.25,
|
||||
avg_tokens_per_session: 10000.0,
|
||||
period_days: 30,
|
||||
};
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
assert!(json.contains("totalSessions"));
|
||||
assert!(json.contains("avgCostPerSession"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_breakdown_serializes_camel_case() {
|
||||
let m = ModelBreakdown {
|
||||
model: "opus".into(),
|
||||
session_count: 3,
|
||||
total_cost_usd: 0.75,
|
||||
total_tokens: 30000,
|
||||
avg_cost_per_session: 0.25,
|
||||
};
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
assert!(json.contains("sessionCount"));
|
||||
assert!(json.contains("totalCostUsd"));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue