agent-orchestrator/agor-pro/src/analytics.rs
Hibryda 738574b9f0 fix(security): resolve all HIGH/MEDIUM/LOW audit findings
Rust fixes (HIGH):
- symbols.rs: path validation (reject near-root, 50K file limit, symlink filter)
- memory.rs: FTS5 query quoting (prevent operator injection), 1000 fragment cap, content length limit, transaction wrapping
- budget.rs: atomic check-and-reserve via transaction, input validation, index on budget_log
- export.rs: safe UTF-8 truncation via chars().take()
- git_context.rs: reject paths starting with '-' (flag injection)
- branch_policy.rs: action validation (block|warn only), path validation

Rust fixes (MEDIUM):
- export.rs: named column access (positional→named)
- budget.rs: named column access, negative value guards

Svelte fixes:
- AccountSwitcher: 2-step confirmation before account switch
- ProjectMemory: expand/collapse content, 2-step delete confirm, tags split fix
- CodeIntelligence: min 2-char symbol query, CodeSymbol rename, aria-labels
- BudgetManager: 10M upper bound, aria-label on input, named constants
- SessionExporter: timeout cleanup on destroy, aria-live feedback
- AnalyticsDashboard: SVG aria-label, removed unused import, named constant
2026-03-17 03:56:44 +01:00

181 lines
6.1 KiB
Rust

// 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(*) AS cnt, COALESCE(SUM(cost_usd), 0) AS total_cost,
COALESCE(SUM(peak_tokens), 0) AS total_tokens,
COALESCE(SUM(turn_count), 0) AS total_turns,
COALESCE(SUM(tool_call_count), 0) AS total_tools
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("cnt")?;
let cost: f64 = row.get("total_cost")?;
let tokens: i64 = row.get("total_tokens")?;
let turns: i64 = row.get("total_turns")?;
let tools: i64 = row.get("total_tools")?;
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(*) AS cnt, COALESCE(SUM(cost_usd), 0) AS total_cost,
COALESCE(SUM(peak_tokens), 0) AS total_tokens,
COALESCE(SUM(turn_count), 0) AS total_turns,
COALESCE(SUM(tool_call_count), 0) AS total_tools
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("day")?,
session_count: row.get("cnt")?,
cost_usd: row.get("total_cost")?,
tokens: row.get("total_tokens")?,
turns: row.get("total_turns")?,
tool_calls: row.get("total_tools")?,
})
}).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') AS model_name, COUNT(*) AS cnt,
COALESCE(SUM(cost_usd), 0) AS total_cost,
COALESCE(SUM(peak_tokens), 0) AS total_tokens
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("cnt")?;
let cost: f64 = row.get("total_cost")?;
Ok(ModelBreakdown {
model: row.get("model_name")?,
session_count: count,
total_cost_usd: cost,
total_tokens: row.get("total_tokens")?,
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"));
}
}