870 lines
30 KiB
Rust
870 lines
30 KiB
Rust
#[cfg(feature = "goals")]
|
|
pub mod goals;
|
|
#[cfg(feature = "goals")]
|
|
pub mod goals_ui;
|
|
pub mod insights;
|
|
|
|
use crate::core::urls::ApiUrls;
|
|
#[cfg(feature = "llm")]
|
|
use crate::llm::observability::{ObservabilityConfig, ObservabilityManager, QuickStats};
|
|
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::fmt::Write as FmtWrite;
|
|
use std::sync::Arc;
|
|
#[cfg(feature = "llm")]
|
|
use tokio::sync::RwLock;
|
|
|
|
#[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>,
|
|
}
|
|
|
|
#[cfg(feature = "llm")]
|
|
#[derive(Debug)]
|
|
pub struct AnalyticsService {
|
|
observability: Arc<RwLock<ObservabilityManager>>,
|
|
}
|
|
|
|
#[cfg(feature = "llm")]
|
|
impl AnalyticsService {
|
|
pub fn new() -> Self {
|
|
let config = ObservabilityConfig::default();
|
|
Self {
|
|
observability: Arc::new(RwLock::new(ObservabilityManager::new(config))),
|
|
}
|
|
}
|
|
|
|
pub fn with_config(config: ObservabilityConfig) -> Self {
|
|
Self {
|
|
observability: Arc::new(RwLock::new(ObservabilityManager::new(config))),
|
|
}
|
|
}
|
|
|
|
pub async fn get_quick_stats(&self) -> QuickStats {
|
|
let manager = self.observability.read().await;
|
|
manager.get_quick_stats()
|
|
}
|
|
|
|
pub async fn get_observability_manager(
|
|
&self,
|
|
) -> tokio::sync::RwLockReadGuard<'_, ObservabilityManager> {
|
|
self.observability.read().await
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "llm")]
|
|
impl Default for AnalyticsService {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
pub fn configure_analytics_routes() -> Router<Arc<AppState>> {
|
|
let router = Router::new()
|
|
.route(ApiUrls::ANALYTICS_MESSAGES_COUNT, get(handle_message_count))
|
|
.route(
|
|
ApiUrls::ANALYTICS_SESSIONS_ACTIVE,
|
|
get(handle_active_sessions),
|
|
)
|
|
.route(
|
|
ApiUrls::ANALYTICS_RESPONSE_AVG,
|
|
get(handle_avg_response_time),
|
|
)
|
|
.route(ApiUrls::ANALYTICS_LLM_TOKENS, get(handle_llm_tokens))
|
|
.route(ApiUrls::ANALYTICS_STORAGE_USAGE, get(handle_storage_usage))
|
|
.route(ApiUrls::ANALYTICS_ERRORS_COUNT, get(handle_errors_count))
|
|
.route(
|
|
ApiUrls::ANALYTICS_TIMESERIES_MESSAGES,
|
|
get(handle_timeseries_messages),
|
|
)
|
|
.route(
|
|
ApiUrls::ANALYTICS_TIMESERIES_RESPONSE,
|
|
get(handle_timeseries_response),
|
|
)
|
|
.route(
|
|
ApiUrls::ANALYTICS_CHANNELS_DISTRIBUTION,
|
|
get(handle_channels_distribution),
|
|
)
|
|
.route(
|
|
ApiUrls::ANALYTICS_BOTS_PERFORMANCE,
|
|
get(handle_bots_performance),
|
|
)
|
|
.route(
|
|
ApiUrls::ANALYTICS_ACTIVITY_RECENT,
|
|
get(handle_recent_activity),
|
|
)
|
|
.route(ApiUrls::ANALYTICS_QUERIES_TOP, get(handle_top_queries))
|
|
.route(ApiUrls::ANALYTICS_CHAT, post(handle_analytics_chat));
|
|
|
|
#[cfg(feature = "llm")]
|
|
let router = router
|
|
.route(ApiUrls::ANALYTICS_LLM_STATS, get(handle_llm_stats))
|
|
.route(ApiUrls::ANALYTICS_BUDGET_STATUS, get(handle_budget_status));
|
|
|
|
router
|
|
}
|
|
|
|
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 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("</div>");
|
|
|
|
Html(html)
|
|
}
|
|
|
|
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(DISTINCT session_id) as count FROM message_history WHERE created_at > NOW() - INTERVAL '30 minutes'",
|
|
)
|
|
.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 Sessions</span>");
|
|
html.push_str("</div>");
|
|
|
|
Html(html)
|
|
}
|
|
|
|
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 message_history WHERE role = 1 AND 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 i32)
|
|
} 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)
|
|
}
|
|
|
|
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;
|
|
}
|
|
};
|
|
|
|
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)
|
|
}
|
|
|
|
pub async fn handle_storage_usage(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
|
|
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\">");
|
|
let _ = write!(html, "{:.1} GB", usage_gb);
|
|
html.push_str("</span>");
|
|
html.push_str("<span class=\"metric-label\">Storage (");
|
|
let _ = write!(html, "{percentage}");
|
|
html.push_str("%)</span>");
|
|
html.push_str("</div>");
|
|
|
|
Html(html)
|
|
}
|
|
|
|
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;
|
|
}
|
|
};
|
|
|
|
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 > 10 {
|
|
"error"
|
|
} else if count > 0 {
|
|
"warning"
|
|
} else {
|
|
"success"
|
|
};
|
|
|
|
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)
|
|
}
|
|
|
|
pub async fn handle_timeseries_messages(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
|
let conn = state.conn.clone();
|
|
|
|
let hourly_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) 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 hours: Vec<i32> = (0..24).collect();
|
|
let mut counts: Vec<i64> = vec![0; 24];
|
|
|
|
for data in hourly_data {
|
|
let hour_idx = data.hour as usize;
|
|
if hour_idx < 24 {
|
|
counts[hour_idx] = data.count;
|
|
}
|
|
}
|
|
|
|
let labels: Vec<String> = hours.iter().map(|h| format!("{}:00", h)).collect();
|
|
let max_count = counts.iter().max().copied().unwrap_or(1).max(1);
|
|
|
|
let mut html = String::new();
|
|
html.push_str("<div class=\"chart-container\">");
|
|
html.push_str("<div class=\"chart-bars\">");
|
|
|
|
for (i, count) in counts.iter().enumerate() {
|
|
let height_pct = (*count as f64 / max_count as f64) * 100.0;
|
|
let _ = write!(
|
|
html,
|
|
"<div class=\"chart-bar\" style=\"height: {}%\" title=\"{}: {} messages\"></div>",
|
|
height_pct, labels[i], count
|
|
);
|
|
}
|
|
|
|
html.push_str("</div>");
|
|
html.push_str("<div class=\"chart-labels\">");
|
|
for (i, label) in labels.iter().enumerate() {
|
|
if i % 4 == 0 {
|
|
let _ = write!(html, "<span>{label}</span>");
|
|
}
|
|
}
|
|
html.push_str("</div>");
|
|
html.push_str("</div>");
|
|
|
|
Html(html)
|
|
}
|
|
|
|
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 hourly_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) as hour,
|
|
AVG(EXTRACT(EPOCH FROM (updated_at - created_at))) as avg_time
|
|
FROM message_history
|
|
WHERE role = 1 AND 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 avgs: Vec<f64> = vec![0.0; 24];
|
|
|
|
for data in hourly_data {
|
|
let hour_idx = data.hour as usize;
|
|
if hour_idx < 24 {
|
|
avgs[hour_idx] = data.avg_time.unwrap_or(0.0);
|
|
}
|
|
}
|
|
|
|
let labels: Vec<String> = (0..24).map(|h| format!("{}:00", h)).collect();
|
|
let max_avg = avgs.iter().copied().fold(0.0f64, f64::max).max(0.1);
|
|
|
|
let mut html = String::new();
|
|
html.push_str("<div class=\"chart-container line-chart\">");
|
|
html.push_str("<svg viewBox=\"0 0 480 200\" preserveAspectRatio=\"none\">");
|
|
html.push_str("<polyline fill=\"none\" stroke=\"var(--primary)\" stroke-width=\"2\" points=\"");
|
|
|
|
for (i, avg) in avgs.iter().enumerate() {
|
|
let x = (i as f64 / 23.0) * 480.0;
|
|
let y = 180.0f64.mul_add(-(*avg / max_avg), 200.0);
|
|
let _ = write!(html, "{x},{y} ");
|
|
}
|
|
|
|
html.push_str("\"/></svg>");
|
|
html.push_str("<div class=\"chart-labels\">");
|
|
for (i, label) in labels.iter().enumerate() {
|
|
if i % 4 == 0 {
|
|
let _ = write!(html, "<span>{label}</span>");
|
|
}
|
|
}
|
|
html.push_str("</div>");
|
|
html.push_str("</div>");
|
|
|
|
Html(html)
|
|
}
|
|
|
|
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 channel_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 COALESCE(channel, 'web') as channel, COUNT(*) as count
|
|
FROM sessions
|
|
WHERE created_at > NOW() - INTERVAL '7 days'
|
|
GROUP BY channel
|
|
ORDER BY count DESC
|
|
LIMIT 5",
|
|
)
|
|
.load::<ChannelCount>(&mut db_conn)
|
|
.unwrap_or_default()
|
|
})
|
|
.await
|
|
.unwrap_or_default();
|
|
|
|
let total: i64 = channel_data.iter().map(|c| c.count).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-chart\">");
|
|
|
|
let mut offset = 0.0f64;
|
|
for (i, data) in channel_data.iter().enumerate() {
|
|
let pct = if total > 0 {
|
|
(data.count as f64 / total as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
let color = colors[i % colors.len()];
|
|
let _ = write!(
|
|
html,
|
|
"<div class=\"pie-segment\" style=\"--offset: {}; --value: {}; --color: {};\"></div>",
|
|
offset, pct, color
|
|
);
|
|
offset += pct;
|
|
}
|
|
|
|
html.push_str("</div>");
|
|
html.push_str("<div class=\"pie-legend\">");
|
|
|
|
for (i, data) in channel_data.iter().enumerate() {
|
|
let pct = if total > 0 {
|
|
(data.count as f64 / total as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
let color = colors[i % colors.len()];
|
|
let _ = write!(
|
|
html,
|
|
"<div class=\"legend-item\"><span class=\"legend-color\" style=\"background: {};\"></span>{} ({:.0}%)</div>",
|
|
color, html_escape(&data.channel), pct
|
|
);
|
|
}
|
|
|
|
html.push_str("</div>");
|
|
html.push_str("</div>");
|
|
|
|
Html(html)
|
|
}
|
|
|
|
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 bot_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 b.name, COUNT(mh.id) as count
|
|
FROM bots b
|
|
LEFT JOIN sessions s ON s.bot_id = b.id
|
|
LEFT JOIN message_history mh ON mh.session_id = s.id
|
|
WHERE mh.created_at > NOW() - INTERVAL '24 hours' OR mh.created_at IS NULL
|
|
GROUP BY b.id, b.name
|
|
ORDER BY count DESC
|
|
LIMIT 5",
|
|
)
|
|
.load::<BotStats>(&mut db_conn)
|
|
.unwrap_or_default()
|
|
})
|
|
.await
|
|
.unwrap_or_default();
|
|
|
|
let max_count = bot_data.iter().map(|b| b.count).max().unwrap_or(1).max(1);
|
|
|
|
let mut html = String::new();
|
|
html.push_str("<div class=\"horizontal-bars\">");
|
|
|
|
for data in bot_data.iter() {
|
|
let pct = (data.count as f64 / max_count as f64) * 100.0;
|
|
html.push_str("<div class=\"bar-row\">");
|
|
let _ = write!(
|
|
html,
|
|
"<span class=\"bar-label\">{}</span>",
|
|
html_escape(&data.name)
|
|
);
|
|
let _ = write!(
|
|
html,
|
|
"<div class=\"bar-track\"><div class=\"bar-fill\" style=\"width: {pct}%;\"></div></div>"
|
|
);
|
|
let _ = write!(html, "<span class=\"bar-value\">{}</span>", data.count);
|
|
html.push_str("</div>");
|
|
}
|
|
|
|
html.push_str("</div>");
|
|
|
|
Html(html)
|
|
}
|
|
|
|
#[derive(Debug, QueryableByName, Clone)]
|
|
#[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,
|
|
}
|
|
|
|
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 db_conn = match conn.get() {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
log::error!("DB connection error: {}", e);
|
|
return get_default_activities();
|
|
}
|
|
};
|
|
|
|
diesel::sql_query(
|
|
"SELECT
|
|
CASE
|
|
WHEN role = 0 THEN 'message'
|
|
WHEN role = 1 THEN 'response'
|
|
ELSE 'system'
|
|
END as activity_type,
|
|
SUBSTRING(content FROM 1 FOR 50) as description,
|
|
CASE
|
|
WHEN created_at > NOW() - INTERVAL '1 minute' THEN 'just now'
|
|
WHEN created_at > NOW() - INTERVAL '1 hour' THEN CONCAT(EXTRACT(MINUTE FROM NOW() - created_at)::int, 'm ago')
|
|
ELSE CONCAT(EXTRACT(HOUR FROM NOW() - created_at)::int, 'h ago')
|
|
END as time_ago
|
|
FROM message_history
|
|
ORDER BY created_at DESC
|
|
LIMIT 10",
|
|
)
|
|
.load::<ActivityRow>(&mut { db_conn })
|
|
.unwrap_or_else(|_| get_default_activities())
|
|
})
|
|
.await
|
|
.unwrap_or_else(|_| get_default_activities());
|
|
|
|
let mut html = String::new();
|
|
html.push_str("<div class=\"activity-list\">");
|
|
|
|
for activity in activities.iter() {
|
|
let icon = match activity.activity_type.as_str() {
|
|
"message" => "💬",
|
|
"response" => "🤖",
|
|
"error" => "⚠️",
|
|
_ => "📋",
|
|
};
|
|
|
|
html.push_str("<div class=\"activity-item\">");
|
|
let _ = write!(html, "<span class=\"activity-icon\">{icon}</span>");
|
|
html.push_str("<div class=\"activity-content\">");
|
|
let _ = write!(
|
|
html,
|
|
"<span class=\"activity-desc\">{}</span>",
|
|
html_escape(&activity.description)
|
|
);
|
|
let _ = write!(
|
|
html,
|
|
"<span class=\"activity-time\">{}</span>",
|
|
html_escape(&activity.time_ago)
|
|
);
|
|
html.push_str("</div>");
|
|
html.push_str("</div>");
|
|
}
|
|
|
|
html.push_str("</div>");
|
|
|
|
Html(html)
|
|
}
|
|
|
|
fn get_default_activities() -> Vec<ActivityRow> {
|
|
vec![]
|
|
}
|
|
|
|
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::new();
|
|
}
|
|
};
|
|
|
|
diesel::sql_query(
|
|
"SELECT SUBSTRING(content FROM 1 FOR 100) as query, COUNT(*) as count
|
|
FROM message_history
|
|
WHERE role = 0 AND created_at > NOW() - INTERVAL '24 hours'
|
|
GROUP BY SUBSTRING(content FROM 1 FOR 100)
|
|
ORDER BY count DESC
|
|
LIMIT 10",
|
|
)
|
|
.load::<QueryCount>(&mut db_conn)
|
|
.unwrap_or_default()
|
|
})
|
|
.await
|
|
.unwrap_or_default();
|
|
|
|
let mut html = String::new();
|
|
html.push_str("<div class=\"top-queries-list\">");
|
|
|
|
for (i, q) in queries.iter().enumerate() {
|
|
html.push_str("<div class=\"query-item\">");
|
|
let _ = write!(html, "<span class=\"query-rank\">{}</span>", i + 1);
|
|
let _ = write!(
|
|
html,
|
|
"<span class=\"query-text\">{}</span>",
|
|
html_escape(&q.query)
|
|
);
|
|
let _ = write!(html, "<span class=\"query-count\">{}</span>", q.count);
|
|
html.push_str("</div>");
|
|
}
|
|
|
|
html.push_str("</div>");
|
|
|
|
Html(html)
|
|
}
|
|
|
|
pub async fn handle_analytics_chat(
|
|
State(_state): State<Arc<AppState>>,
|
|
Json(payload): Json<AnalyticsQuery>,
|
|
) -> impl IntoResponse {
|
|
let query = payload.query.unwrap_or_default();
|
|
|
|
let response = if query.to_lowercase().contains("message") {
|
|
"Based on current data, message volume trends are being analyzed."
|
|
} else if query.to_lowercase().contains("error") {
|
|
"Error rate analysis is available in the errors dashboard."
|
|
} else if query.to_lowercase().contains("performance") {
|
|
"Performance metrics show average response times within normal parameters."
|
|
} else {
|
|
"I can help analyze your data. Ask about messages, errors, or performance."
|
|
};
|
|
|
|
let mut html = String::new();
|
|
html.push_str("<div class=\"chat-message assistant\">");
|
|
html.push_str("<div class=\"message-content\">");
|
|
html.push_str(&html_escape(response));
|
|
html.push_str("</div>");
|
|
html.push_str("</div>");
|
|
|
|
Html(html)
|
|
}
|
|
|
|
#[cfg(feature = "llm")]
|
|
pub async fn handle_llm_stats(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
|
|
let service = AnalyticsService::new();
|
|
let stats = service.get_quick_stats().await;
|
|
|
|
let mut html = String::new();
|
|
html.push_str("<div class=\"llm-stats\">");
|
|
let _ = write!(html, "<div class=\"stat\"><span class=\"label\">Total Requests</span><span class=\"value\">{}</span></div>", stats.total_requests);
|
|
let _ = write!(html, "<div class=\"stat\"><span class=\"label\">Total Tokens</span><span class=\"value\">{}</span></div>", stats.total_tokens);
|
|
let _ = write!(html, "<div class=\"stat\"><span class=\"label\">Cache Hits</span><span class=\"value\">{}</span></div>", stats.cache_hits);
|
|
let _ = write!(html, "<div class=\"stat\"><span class=\"label\">Cache Hit Rate</span><span class=\"value\">{:.1}%</span></div>", stats.cache_hit_rate * 100.0);
|
|
let _ = write!(html, "<div class=\"stat\"><span class=\"label\">Error Rate</span><span class=\"value\">{:.1}%</span></div>", stats.error_rate * 100.0);
|
|
html.push_str("</div>");
|
|
|
|
Html(html)
|
|
}
|
|
|
|
#[cfg(feature = "llm")]
|
|
pub async fn handle_budget_status(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
|
|
let status = {
|
|
let service = AnalyticsService::new();
|
|
let manager = service.get_observability_manager().await;
|
|
manager.get_budget_status().await
|
|
};
|
|
|
|
let mut html = String::new();
|
|
html.push_str("<div class=\"budget-status\">");
|
|
let _ = write!(html, "<div class=\"budget-item\"><span class=\"label\">Daily Spend</span><span class=\"value\">${:.2} / ${:.2}</span></div>", status.daily_spend, status.daily_limit);
|
|
let _ = write!(html, "<div class=\"budget-item\"><span class=\"label\">Monthly Spend</span><span class=\"value\">${:.2} / ${:.2}</span></div>", status.monthly_spend, status.monthly_limit);
|
|
let _ = write!(html, "<div class=\"budget-item\"><span class=\"label\">Daily Remaining</span><span class=\"value\">${:.2} ({:.0}%)</span></div>", status.daily_remaining, status.daily_percentage * 100.0);
|
|
let _ = write!(html, "<div class=\"budget-item\"><span class=\"label\">Monthly Remaining</span><span class=\"value\">${:.2} ({:.0}%)</span></div>", status.monthly_remaining, status.monthly_percentage * 100.0);
|
|
html.push_str("</div>");
|
|
|
|
Html(html)
|
|
}
|
|
|
|
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('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
.replace('\'', "'")
|
|
}
|
|
|
|
impl Default for AnalyticsStats {
|
|
fn default() -> Self {
|
|
Self {
|
|
message_count: 0,
|
|
session_count: 0,
|
|
active_sessions: 0,
|
|
avg_response_time: 0.0,
|
|
}
|
|
}
|
|
}
|