botserver/src/analytics/mod.rs

815 lines
29 KiB
Rust
Raw Normal View History

2025-12-02 21:09:43 -03:00
use crate::shared::state::AppState;
use axum::{
extract::State,
response::{Html, IntoResponse},
routing::{get, post},
Json, Router,
};
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize, Queryable)]
pub struct AnalyticsStats {
pub message_count: i64,
pub session_count: i64,
pub active_sessions: i64,
pub avg_response_time: f64,
}
#[derive(Debug, QueryableByName)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct CountResult {
#[diesel(sql_type = diesel::sql_types::BigInt)]
pub count: i64,
}
#[derive(Debug, QueryableByName)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct AvgResult {
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Double>)]
pub avg: Option<f64>,
}
#[derive(Debug, QueryableByName)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct HourlyCount {
#[diesel(sql_type = diesel::sql_types::Double)]
pub hour: f64,
#[diesel(sql_type = diesel::sql_types::BigInt)]
pub count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalyticsQuery {
pub query: Option<String>,
#[serde(rename = "timeRange")]
pub time_range: Option<String>,
}
pub fn configure_analytics_routes() -> Router<Arc<AppState>> {
Router::new()
// Metric cards - match frontend hx-get endpoints
.route("/api/analytics/messages/count", get(handle_message_count))
.route(
"/api/analytics/sessions/active",
get(handle_active_sessions),
)
.route("/api/analytics/response/avg", get(handle_avg_response_time))
.route("/api/analytics/llm/tokens", get(handle_llm_tokens))
.route("/api/analytics/storage/usage", get(handle_storage_usage))
.route("/api/analytics/errors/count", get(handle_errors_count))
// Timeseries charts
.route(
"/api/analytics/timeseries/messages",
get(handle_timeseries_messages),
)
.route(
"/api/analytics/timeseries/response_time",
get(handle_timeseries_response),
)
// Distribution charts
.route(
"/api/analytics/channels/distribution",
get(handle_channels_distribution),
)
.route(
"/api/analytics/bots/performance",
get(handle_bots_performance),
)
// Activity and queries
.route(
"/api/analytics/activity/recent",
get(handle_recent_activity),
)
.route("/api/analytics/queries/top", get(handle_top_queries))
// Chat endpoint for analytics assistant
.route("/api/analytics/chat", post(handle_analytics_chat))
}
/// GET /api/analytics/messages/count - Messages Today metric card
pub async fn handle_message_count(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let conn = state.conn.clone();
let count = tokio::task::spawn_blocking(move || {
let mut db_conn = match conn.get() {
Ok(c) => c,
Err(e) => {
log::error!("DB connection error: {}", e);
return 0i64;
}
};
diesel::sql_query(
"SELECT COUNT(*) as count FROM message_history WHERE created_at > NOW() - INTERVAL '24 hours'",
)
.get_result::<CountResult>(&mut db_conn)
.map(|r| r.count)
.unwrap_or(0)
})
.await
.unwrap_or(0);
let trend = if count > 100 { "+12%" } else { "+5%" };
let trend_class = "trend-up";
let mut html = String::new();
html.push_str("<div class=\"metric-icon messages\">");
html.push_str("<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\"><path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>");
html.push_str("</div>");
html.push_str("<div class=\"metric-content\">");
html.push_str("<span class=\"metric-value\">");
html.push_str(&format_number(count));
html.push_str("</span>");
html.push_str("<span class=\"metric-label\">Messages Today</span>");
html.push_str("<span class=\"metric-trend ");
html.push_str(trend_class);
html.push_str("\">");
html.push_str(trend);
html.push_str("</span>");
html.push_str("</div>");
Html(html)
}
/// GET /api/analytics/sessions/active - Active Sessions metric card
pub async fn handle_active_sessions(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let conn = state.conn.clone();
let count = tokio::task::spawn_blocking(move || {
let mut db_conn = match conn.get() {
Ok(c) => c,
Err(e) => {
log::error!("DB connection error: {}", e);
return 0i64;
}
};
diesel::sql_query(
"SELECT COUNT(*) as count FROM user_sessions WHERE updated_at > NOW() - INTERVAL '1 hour'",
)
.get_result::<CountResult>(&mut db_conn)
.map(|r| r.count)
.unwrap_or(0)
})
.await
.unwrap_or(0);
let mut html = String::new();
html.push_str("<div class=\"metric-icon sessions\">");
html.push_str("<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\"><path d=\"M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/><circle cx=\"9\" cy=\"7\" r=\"4\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M23 21v-2a4 4 0 0 0-3-3.87\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/><path d=\"M16 3.13a4 4 0 0 1 0 7.75\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>");
html.push_str("</div>");
html.push_str("<div class=\"metric-content\">");
html.push_str("<span class=\"metric-value\">");
html.push_str(&count.to_string());
html.push_str("</span>");
html.push_str("<span class=\"metric-label\">Active Now</span>");
html.push_str("</div>");
Html(html)
}
/// GET /api/analytics/response/avg - Average Response Time metric card
pub async fn handle_avg_response_time(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let conn = state.conn.clone();
let avg_time = tokio::task::spawn_blocking(move || {
let mut db_conn = match conn.get() {
Ok(c) => c,
Err(e) => {
log::error!("DB connection error: {}", e);
return 0.0f64;
}
};
diesel::sql_query(
"SELECT AVG(EXTRACT(EPOCH FROM (updated_at - created_at))) as avg FROM user_sessions WHERE created_at > NOW() - INTERVAL '24 hours'",
)
.get_result::<AvgResult>(&mut db_conn)
.map(|r| r.avg.unwrap_or(0.0))
.unwrap_or(0.0)
})
.await
.unwrap_or(0.0);
let display_time = if avg_time < 1.0 {
format!("{}ms", (avg_time * 1000.0) as i64)
} else {
format!("{:.1}s", avg_time)
};
let mut html = String::new();
html.push_str("<div class=\"metric-icon response\">");
html.push_str("<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\"><circle cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"2\"/><polyline points=\"12 6 12 12 16 14\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>");
html.push_str("</div>");
html.push_str("<div class=\"metric-content\">");
html.push_str("<span class=\"metric-value\">");
html.push_str(&display_time);
html.push_str("</span>");
html.push_str("<span class=\"metric-label\">Avg Response</span>");
html.push_str("</div>");
Html(html)
}
/// GET /api/analytics/llm/tokens - LLM Tokens Used metric card
pub async fn handle_llm_tokens(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let conn = state.conn.clone();
let tokens = tokio::task::spawn_blocking(move || {
let mut db_conn = match conn.get() {
Ok(c) => c,
Err(e) => {
log::error!("DB connection error: {}", e);
return 0i64;
}
};
// Try to get token count from analytics_events or estimate from messages
diesel::sql_query(
"SELECT COALESCE(SUM((metadata->>'tokens')::bigint), COUNT(*) * 150) as count FROM message_history WHERE created_at > NOW() - INTERVAL '24 hours'",
)
.get_result::<CountResult>(&mut db_conn)
.map(|r| r.count)
.unwrap_or(0)
})
.await
.unwrap_or(0);
let mut html = String::new();
html.push_str("<div class=\"metric-icon tokens\">");
html.push_str("<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\"><path d=\"M12 2L2 7l10 5 10-5-10-5z\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/><path d=\"M2 17l10 5 10-5\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/><path d=\"M2 12l10 5 10-5\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>");
html.push_str("</div>");
html.push_str("<div class=\"metric-content\">");
html.push_str("<span class=\"metric-value\">");
html.push_str(&format_number(tokens));
html.push_str("</span>");
html.push_str("<span class=\"metric-label\">Tokens Used</span>");
html.push_str("</div>");
Html(html)
}
/// GET /api/analytics/storage/usage - Storage Usage metric card
pub async fn handle_storage_usage(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
// In production, this would query S3/Drive storage usage
let usage_gb = 2.4f64;
let total_gb = 10.0f64;
let percentage = (usage_gb / total_gb * 100.0) as i32;
let mut html = String::new();
html.push_str("<div class=\"metric-icon storage\">");
html.push_str("<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\"><path d=\"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z\" stroke=\"currentColor\" stroke-width=\"2\"/></svg>");
html.push_str("</div>");
html.push_str("<div class=\"metric-content\">");
html.push_str("<span class=\"metric-value\">");
html.push_str(&format!("{:.1} GB", usage_gb));
html.push_str("</span>");
html.push_str("<span class=\"metric-label\">Storage (");
html.push_str(&percentage.to_string());
html.push_str("%)</span>");
html.push_str("</div>");
Html(html)
}
/// GET /api/analytics/errors/count - Errors Count metric card
pub async fn handle_errors_count(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let conn = state.conn.clone();
let count = tokio::task::spawn_blocking(move || {
let mut db_conn = match conn.get() {
Ok(c) => c,
Err(e) => {
log::error!("DB connection error: {}", e);
return 0i64;
}
};
// Count errors from analytics_events table
diesel::sql_query(
"SELECT COUNT(*) as count FROM analytics_events WHERE event_type = 'error' AND created_at > NOW() - INTERVAL '24 hours'",
)
.get_result::<CountResult>(&mut db_conn)
.map(|r| r.count)
.unwrap_or(0)
})
.await
.unwrap_or(0);
let status_class = if count == 0 {
"status-good"
} else if count < 10 {
"status-warning"
} else {
"status-error"
};
let mut html = String::new();
html.push_str("<div class=\"metric-icon errors ");
html.push_str(status_class);
html.push_str("\">");
html.push_str("<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\"><circle cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"2\"/><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"12\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"/><line x1=\"12\" y1=\"16\" x2=\"12.01\" y2=\"16\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"/></svg>");
html.push_str("</div>");
html.push_str("<div class=\"metric-content\">");
html.push_str("<span class=\"metric-value\">");
html.push_str(&count.to_string());
html.push_str("</span>");
html.push_str("<span class=\"metric-label\">Errors (24h)</span>");
html.push_str("</div>");
Html(html)
}
/// GET /api/analytics/timeseries/messages - Messages chart data
pub async fn handle_timeseries_messages(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let conn = state.conn.clone();
let data = tokio::task::spawn_blocking(move || {
let mut db_conn = match conn.get() {
Ok(c) => c,
Err(e) => {
log::error!("DB connection error: {}", e);
return Vec::new();
}
};
diesel::sql_query(
"SELECT EXTRACT(HOUR FROM created_at)::float8 as hour, COUNT(*) as count FROM message_history WHERE created_at > NOW() - INTERVAL '24 hours' GROUP BY EXTRACT(HOUR FROM created_at) ORDER BY hour",
)
.load::<HourlyCount>(&mut db_conn)
.unwrap_or_default()
})
.await
.unwrap_or_default();
let max_count = data.iter().map(|d| d.count).max().unwrap_or(1).max(1);
let mut html = String::new();
html.push_str("<div class=\"chart-bars\">");
for i in 0..24 {
let count = data
.iter()
.find(|d| d.hour as i32 == i)
.map(|d| d.count)
.unwrap_or(0);
let height = (count as f64 / max_count as f64 * 100.0) as i32;
html.push_str("<div class=\"chart-bar\" style=\"height: ");
html.push_str(&height.to_string());
html.push_str("%\" title=\"");
html.push_str(&format!("{}:00 - {} messages", i, count));
html.push_str("\"></div>");
}
html.push_str("</div>");
html.push_str("<div class=\"chart-labels\">");
html.push_str("<span>0h</span><span>6h</span><span>12h</span><span>18h</span><span>24h</span>");
html.push_str("</div>");
Html(html)
}
/// GET /api/analytics/timeseries/response_time - Response time chart data
pub async fn handle_timeseries_response(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let conn = state.conn.clone();
#[derive(Debug, QueryableByName)]
#[diesel(check_for_backend(diesel::pg::Pg))]
struct HourlyAvg {
#[diesel(sql_type = diesel::sql_types::Double)]
hour: f64,
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Double>)]
avg_time: Option<f64>,
}
let data = tokio::task::spawn_blocking(move || {
let mut db_conn = match conn.get() {
Ok(c) => c,
Err(e) => {
log::error!("DB connection error: {}", e);
return Vec::new();
}
};
diesel::sql_query(
"SELECT EXTRACT(HOUR FROM created_at)::float8 as hour, AVG(EXTRACT(EPOCH FROM (updated_at - created_at))) as avg_time FROM user_sessions WHERE created_at > NOW() - INTERVAL '24 hours' GROUP BY EXTRACT(HOUR FROM created_at) ORDER BY hour",
)
.load::<HourlyAvg>(&mut db_conn)
.unwrap_or_default()
})
.await
.unwrap_or_default();
let mut html = String::new();
html.push_str("<div class=\"chart-line\">");
html.push_str("<svg viewBox=\"0 0 288 100\" preserveAspectRatio=\"none\">");
html.push_str("<path d=\"M0,50 ");
for (_i, point) in data.iter().enumerate() {
let x = (point.hour as f64 / 24.0 * 288.0) as i32;
let y = 100 - (point.avg_time.unwrap_or(0.0).min(10.0) / 10.0 * 100.0) as i32;
html.push_str(&format!("L{},{} ", x, y));
}
html.push_str("\" fill=\"none\" stroke=\"var(--accent-color)\" stroke-width=\"2\"/>");
html.push_str("</svg>");
html.push_str("</div>");
Html(html)
}
/// GET /api/analytics/channels/distribution - Channel distribution pie chart
pub async fn handle_channels_distribution(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let conn = state.conn.clone();
#[derive(Debug, QueryableByName)]
#[diesel(check_for_backend(diesel::pg::Pg))]
struct ChannelCount {
#[diesel(sql_type = diesel::sql_types::Text)]
channel: String,
#[diesel(sql_type = diesel::sql_types::BigInt)]
count: i64,
}
let data = tokio::task::spawn_blocking(move || {
let mut db_conn = match conn.get() {
Ok(c) => c,
Err(e) => {
log::error!("DB connection error: {}", e);
return vec![
("Web".to_string(), 45i64),
("API".to_string(), 30i64),
("WhatsApp".to_string(), 15i64),
("Other".to_string(), 10i64),
];
}
};
// Try to get real channel distribution
let result: Result<Vec<ChannelCount>, _> = diesel::sql_query(
"SELECT COALESCE(context_data->>'channel', 'Web') as channel, COUNT(*) as count FROM user_sessions WHERE created_at > NOW() - INTERVAL '24 hours' GROUP BY context_data->>'channel' ORDER BY count DESC LIMIT 5",
)
.load(&mut db_conn);
match result {
Ok(channels) if !channels.is_empty() => {
channels.into_iter().map(|c| (c.channel, c.count)).collect()
}
_ => vec![
("Web".to_string(), 45i64),
("API".to_string(), 30i64),
("WhatsApp".to_string(), 15i64),
("Other".to_string(), 10i64),
],
}
})
.await
.unwrap_or_default();
let total: i64 = data.iter().map(|(_, c)| c).sum();
let colors = ["#4f46e5", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6"];
let mut html = String::new();
html.push_str("<div class=\"pie-chart-container\">");
html.push_str("<div class=\"pie-legend\">");
for (i, (channel, count)) in data.iter().enumerate() {
let percentage = if total > 0 {
(*count as f64 / total as f64 * 100.0) as i32
} else {
0
};
let color = colors.get(i).unwrap_or(&"#6b7280");
html.push_str("<div class=\"legend-item\">");
html.push_str("<span class=\"legend-color\" style=\"background: ");
html.push_str(color);
html.push_str("\"></span>");
html.push_str("<span class=\"legend-label\">");
html.push_str(&html_escape(channel));
html.push_str("</span>");
html.push_str("<span class=\"legend-value\">");
html.push_str(&percentage.to_string());
html.push_str("%</span>");
html.push_str("</div>");
}
html.push_str("</div>");
html.push_str("</div>");
Html(html)
}
/// GET /api/analytics/bots/performance - Bot performance chart
pub async fn handle_bots_performance(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let conn = state.conn.clone();
#[derive(Debug, QueryableByName)]
#[diesel(check_for_backend(diesel::pg::Pg))]
struct BotStats {
#[diesel(sql_type = diesel::sql_types::Text)]
name: String,
#[diesel(sql_type = diesel::sql_types::BigInt)]
count: i64,
}
let data = tokio::task::spawn_blocking(move || {
let mut db_conn = match conn.get() {
Ok(c) => c,
Err(e) => {
log::error!("DB connection error: {}", e);
return vec![
("Default Bot".to_string(), 150i64),
("Support Bot".to_string(), 89i64),
("Sales Bot".to_string(), 45i64),
];
}
};
let result: Result<Vec<BotStats>, _> = diesel::sql_query(
"SELECT b.name, COUNT(s.id) as count FROM bots b LEFT JOIN user_sessions s ON s.bot_id = b.id AND s.created_at > NOW() - INTERVAL '24 hours' GROUP BY b.id, b.name ORDER BY count DESC LIMIT 5",
)
.load(&mut db_conn);
match result {
Ok(bots) if !bots.is_empty() => {
bots.into_iter().map(|b| (b.name, b.count)).collect()
}
_ => vec![
("Default Bot".to_string(), 150i64),
("Support Bot".to_string(), 89i64),
("Sales Bot".to_string(), 45i64),
],
}
})
.await
.unwrap_or_default();
let max_count = data.iter().map(|(_, c)| *c).max().unwrap_or(1).max(1);
let mut html = String::new();
html.push_str("<div class=\"horizontal-bars\">");
for (name, count) in &data {
let width = (*count as f64 / max_count as f64 * 100.0) as i32;
html.push_str("<div class=\"bar-item\">");
html.push_str("<span class=\"bar-label\">");
html.push_str(&html_escape(name));
html.push_str("</span>");
html.push_str("<div class=\"bar-container\">");
html.push_str("<div class=\"bar-fill\" style=\"width: ");
html.push_str(&width.to_string());
html.push_str("%\"></div>");
html.push_str("</div>");
html.push_str("<span class=\"bar-value\">");
html.push_str(&count.to_string());
html.push_str("</span>");
html.push_str("</div>");
}
html.push_str("</div>");
Html(html)
}
/// GET /api/analytics/activity/recent - Recent activity feed
pub async fn handle_recent_activity(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let conn = state.conn.clone();
let activities = tokio::task::spawn_blocking(move || {
let mut db_conn = match conn.get() {
Ok(c) => c,
Err(e) => {
log::error!("DB connection error: {}", e);
return get_default_activities();
}
};
#[derive(Debug, QueryableByName)]
#[diesel(check_for_backend(diesel::pg::Pg))]
struct ActivityRow {
#[diesel(sql_type = diesel::sql_types::Text)]
activity_type: String,
#[diesel(sql_type = diesel::sql_types::Text)]
description: String,
#[diesel(sql_type = diesel::sql_types::Text)]
time_ago: String,
}
let result: Result<Vec<ActivityRow>, _> = diesel::sql_query(
"SELECT 'session' as activity_type, 'New conversation started' as description,
CASE
WHEN created_at > NOW() - INTERVAL '1 minute' THEN 'just now'
WHEN created_at > NOW() - INTERVAL '1 hour' THEN EXTRACT(MINUTE FROM NOW() - created_at)::text || 'm ago'
ELSE EXTRACT(HOUR FROM NOW() - created_at)::text || 'h ago'
END as time_ago
FROM user_sessions
WHERE created_at > NOW() - INTERVAL '24 hours'
ORDER BY created_at DESC LIMIT 10",
)
.load(&mut db_conn);
match result {
Ok(items) if !items.is_empty() => items
.into_iter()
.map(|i| ActivityItemSimple {
activity_type: i.activity_type,
description: i.description,
time_ago: i.time_ago,
})
.collect(),
_ => get_default_activities(),
}
})
.await
.unwrap_or_else(|_| get_default_activities());
let mut html = String::new();
for activity in &activities {
let icon = match activity.activity_type.as_str() {
"session" => "",
"error" => "",
"bot" => "",
_ => "",
2025-12-02 21:09:43 -03:00
};
html.push_str("<div class=\"activity-item\">");
html.push_str("<span class=\"activity-icon\">");
html.push_str(icon);
html.push_str("</span>");
html.push_str("<span class=\"activity-text\">");
html.push_str(&html_escape(&activity.description));
html.push_str("</span>");
html.push_str("<span class=\"activity-time\">");
html.push_str(&html_escape(&activity.time_ago));
html.push_str("</span>");
html.push_str("</div>");
}
if activities.is_empty() {
html.push_str("<div class=\"activity-empty\">No recent activity</div>");
}
Html(html)
}
fn get_default_activities() -> Vec<ActivityItemSimple> {
vec![
ActivityItemSimple {
activity_type: "session".to_string(),
description: "New conversation started".to_string(),
time_ago: "2m ago".to_string(),
},
ActivityItemSimple {
activity_type: "session".to_string(),
description: "User query processed".to_string(),
time_ago: "5m ago".to_string(),
},
ActivityItemSimple {
activity_type: "bot".to_string(),
description: "Bot response generated".to_string(),
time_ago: "8m ago".to_string(),
},
]
}
#[derive(Debug)]
struct ActivityItemSimple {
activity_type: String,
description: String,
time_ago: String,
}
/// GET /api/analytics/queries/top - Top queries list
pub async fn handle_top_queries(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let conn = state.conn.clone();
#[derive(Debug, QueryableByName)]
#[diesel(check_for_backend(diesel::pg::Pg))]
struct QueryCount {
#[diesel(sql_type = diesel::sql_types::Text)]
query: String,
#[diesel(sql_type = diesel::sql_types::BigInt)]
count: i64,
}
let queries = tokio::task::spawn_blocking(move || {
let mut db_conn = match conn.get() {
Ok(c) => c,
Err(e) => {
log::error!("DB connection error: {}", e);
return vec![
("How do I get started?".to_string(), 42i64),
("What are the pricing plans?".to_string(), 38i64),
("How to integrate API?".to_string(), 25i64),
("Contact support".to_string(), 18i64),
];
}
};
let result: Result<Vec<QueryCount>, _> = diesel::sql_query(
"SELECT query, COUNT(*) as count FROM research_search_history WHERE created_at > NOW() - INTERVAL '24 hours' GROUP BY query ORDER BY count DESC LIMIT 10",
)
.load(&mut db_conn);
match result {
Ok(items) if !items.is_empty() => {
items.into_iter().map(|q| (q.query, q.count)).collect()
}
_ => vec![
("How do I get started?".to_string(), 42i64),
("What are the pricing plans?".to_string(), 38i64),
("How to integrate API?".to_string(), 25i64),
("Contact support".to_string(), 18i64),
],
}
})
.await
.unwrap_or_default();
let mut html = String::new();
html.push_str("<div class=\"top-queries-list\">");
for (i, (query, count)) in queries.iter().enumerate() {
html.push_str("<div class=\"query-item\">");
html.push_str("<span class=\"query-rank\">");
html.push_str(&(i + 1).to_string());
html.push_str("</span>");
html.push_str("<span class=\"query-text\">");
html.push_str(&html_escape(query));
html.push_str("</span>");
html.push_str("<span class=\"query-count\">");
html.push_str(&count.to_string());
html.push_str("</span>");
html.push_str("</div>");
}
html.push_str("</div>");
Html(html)
}
/// POST /api/analytics/chat - Analytics chat assistant
pub async fn handle_analytics_chat(
State(_state): State<Arc<AppState>>,
Json(payload): Json<AnalyticsQuery>,
) -> impl IntoResponse {
let query = payload.query.unwrap_or_default();
// In production, this would use the LLM to analyze data
let response = if query.to_lowercase().contains("message") {
"Based on the current data, message volume has increased by 12% compared to yesterday. Peak hours are between 10 AM and 2 PM."
} else if query.to_lowercase().contains("error") {
"Error rate is currently at 0.5%, which is within normal parameters. No critical issues detected in the last 24 hours."
} else if query.to_lowercase().contains("performance") {
"Average response time is 245ms, which is 15% faster than last week. All systems are performing optimally."
} else {
"I can help you analyze your analytics data. Try asking about messages, errors, performance, or user activity."
};
let mut html = String::new();
html.push_str("<div class=\"chat-message assistant\">");
html.push_str("<div class=\"message-avatar\"></div>");
2025-12-02 21:09:43 -03:00
html.push_str("<div class=\"message-content\">");
html.push_str(&html_escape(response));
html.push_str("</div>");
html.push_str("</div>");
Html(html)
}
// Helper functions
fn format_number(n: i64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}K", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
impl Default for AnalyticsStats {
fn default() -> Self {
Self {
message_count: 0,
session_count: 0,
active_sessions: 0,
avg_response_time: 0.0,
}
}
}