feat: Fix tokens Revenue stream
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
-- Dummy migration to satisfy SQLx
|
||||||
|
-- This migration was split into 20260319000001 and 20260319000002
|
||||||
|
SELECT 1;
|
||||||
+2
-11
@@ -1,10 +1,5 @@
|
|||||||
-- AI Usage Logs: Add prompt and response fields for detailed tracking
|
-- AI Usage Logs: Update log_ai_usage function to include prompt and response
|
||||||
-- This allows storing the actual prompts and responses for debugging and analytics
|
-- Note: prompt and response columns already exist from previous migration
|
||||||
|
|
||||||
-- Add new columns to ai_usage_logs
|
|
||||||
ALTER TABLE ai_usage_logs
|
|
||||||
ADD COLUMN IF NOT EXISTS prompt TEXT,
|
|
||||||
ADD COLUMN IF NOT EXISTS response TEXT;
|
|
||||||
|
|
||||||
-- Update log_ai_usage function to accept prompt and response
|
-- Update log_ai_usage function to accept prompt and response
|
||||||
CREATE OR REPLACE FUNCTION log_ai_usage(
|
CREATE OR REPLACE FUNCTION log_ai_usage(
|
||||||
@@ -44,9 +39,5 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
-- Add indexes for text search (optional, can be enabled if needed for analytics)
|
|
||||||
-- CREATE INDEX idx_ai_usage_prompt ON ai_usage_logs USING gin (to_tsvector('spanish', prompt));
|
|
||||||
-- CREATE INDEX idx_ai_usage_response ON ai_usage_logs USING gin (to_tsvector('spanish', response));
|
|
||||||
|
|
||||||
COMMENT ON COLUMN ai_usage_logs.prompt IS 'The actual prompt sent to the AI model';
|
COMMENT ON COLUMN ai_usage_logs.prompt IS 'The actual prompt sent to the AI model';
|
||||||
COMMENT ON COLUMN ai_usage_logs.response IS 'The AI model response content';
|
COMMENT ON COLUMN ai_usage_logs.response IS 'The AI model response content';
|
||||||
@@ -4133,6 +4133,26 @@ RULES:
|
|||||||
|
|
||||||
tracing::info!("Transaction committed successfully");
|
tracing::info!("Transaction committed successfully");
|
||||||
|
|
||||||
|
// Log AI usage for course generation
|
||||||
|
let input_tokens = count_tokens(&system_prompt) + count_tokens(&payload.prompt);
|
||||||
|
let output_tokens = count_tokens(&content_str);
|
||||||
|
let total_tokens = input_tokens + output_tokens;
|
||||||
|
|
||||||
|
let _ = sqlx::query("SELECT log_ai_usage($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)")
|
||||||
|
.bind(claims.sub)
|
||||||
|
.bind(target_org_id)
|
||||||
|
.bind(total_tokens)
|
||||||
|
.bind(input_tokens)
|
||||||
|
.bind(output_tokens)
|
||||||
|
.bind("/courses/generate")
|
||||||
|
.bind(&model)
|
||||||
|
.bind("course-generation")
|
||||||
|
.bind(&json!({ "prompt": payload.prompt, "course_title": course_title }))
|
||||||
|
.bind(&format!("{} - {}", system_prompt, payload.prompt)) // prompt
|
||||||
|
.bind(&content_str) // response
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
log_action(
|
log_action(
|
||||||
&pool,
|
&pool,
|
||||||
target_org_id,
|
target_org_id,
|
||||||
@@ -4140,7 +4160,7 @@ RULES:
|
|||||||
"AI_COURSE_GENERATED",
|
"AI_COURSE_GENERATED",
|
||||||
"Course",
|
"Course",
|
||||||
course.id,
|
course.id,
|
||||||
json!({ "prompt": payload.prompt }),
|
json!({ "prompt": payload.prompt, "tokens_used": total_tokens }),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use axum::{
|
|||||||
use common::{auth::Claims, middleware::Org};
|
use common::{auth::Claims, middleware::Org};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
// ==================== Token Usage Tracking ====================
|
// ==================== Token Usage Tracking ====================
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ pub async fn get_token_usage(
|
|||||||
// Get user token usage from database
|
// Get user token usage from database
|
||||||
let usage: Vec<TokenUsageRecord> = sqlx::query_as(
|
let usage: Vec<TokenUsageRecord> = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
u.id as user_id,
|
u.id as user_id,
|
||||||
u.email,
|
u.email,
|
||||||
u.full_name,
|
u.full_name,
|
||||||
@@ -85,6 +86,293 @@ pub async fn get_token_usage(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/admin/ai-usage-dashboard - Comprehensive AI usage dashboard data
|
||||||
|
pub async fn get_ai_usage_dashboard(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
_claims: Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Query(filters): Query<DashboardFilters>,
|
||||||
|
) -> Result<Json<DashboardResponse>, (StatusCode, String)> {
|
||||||
|
// Get daily usage for charts
|
||||||
|
let daily_usage: Vec<DailyUsage> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
DATE(au.created_at) as date,
|
||||||
|
COALESCE(SUM(au.tokens_used), 0) as total_tokens,
|
||||||
|
COALESCE(SUM(au.input_tokens), 0) as input_tokens,
|
||||||
|
COALESCE(SUM(au.output_tokens), 0) as output_tokens,
|
||||||
|
COALESCE(SUM(au.estimated_cost_usd), 0)::FLOAT8 as cost_usd,
|
||||||
|
COUNT(au.id) as requests
|
||||||
|
FROM ai_usage_logs au
|
||||||
|
WHERE au.organization_id = $1
|
||||||
|
AND ($2::DATE IS NULL OR DATE(au.created_at) >= $2::DATE)
|
||||||
|
AND ($3::DATE IS NULL OR DATE(au.created_at) <= $3::DATE)
|
||||||
|
GROUP BY DATE(au.created_at)
|
||||||
|
ORDER BY date DESC
|
||||||
|
LIMIT 30
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(filters.start_date.clone())
|
||||||
|
.bind(filters.end_date.clone())
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch daily usage: {}", e)))?;
|
||||||
|
|
||||||
|
// Get usage by endpoint/feature
|
||||||
|
let by_endpoint: Vec<UsageByEndpoint> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
au.endpoint,
|
||||||
|
au.request_type,
|
||||||
|
COALESCE(SUM(au.tokens_used), 0) as total_tokens,
|
||||||
|
COALESCE(SUM(au.input_tokens), 0) as input_tokens,
|
||||||
|
COALESCE(SUM(au.output_tokens), 0) as output_tokens,
|
||||||
|
COALESCE(SUM(au.estimated_cost_usd), 0)::FLOAT8 as cost_usd,
|
||||||
|
COUNT(au.id) as requests
|
||||||
|
FROM ai_usage_logs au
|
||||||
|
WHERE au.organization_id = $1
|
||||||
|
AND ($2::DATE IS NULL OR DATE(au.created_at) >= $2::DATE)
|
||||||
|
AND ($3::DATE IS NULL OR DATE(au.created_at) <= $3::DATE)
|
||||||
|
GROUP BY au.endpoint, au.request_type
|
||||||
|
ORDER BY total_tokens DESC
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(filters.start_date.clone())
|
||||||
|
.bind(filters.end_date.clone())
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch endpoint usage: {}", e)))?;
|
||||||
|
|
||||||
|
// Get top users
|
||||||
|
let top_users: Vec<TopUserUsage> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
u.id as user_id,
|
||||||
|
u.email,
|
||||||
|
u.full_name,
|
||||||
|
u.role,
|
||||||
|
COALESCE(SUM(au.tokens_used), 0) as total_tokens,
|
||||||
|
COALESCE(SUM(au.input_tokens), 0) as input_tokens,
|
||||||
|
COALESCE(SUM(au.output_tokens), 0) as output_tokens,
|
||||||
|
COALESCE(SUM(au.estimated_cost_usd), 0)::FLOAT8 as cost_usd,
|
||||||
|
COUNT(au.id) as requests
|
||||||
|
FROM ai_usage_logs au
|
||||||
|
JOIN users u ON au.user_id = u.id
|
||||||
|
WHERE au.organization_id = $1
|
||||||
|
AND ($2::DATE IS NULL OR DATE(au.created_at) >= $2::DATE)
|
||||||
|
AND ($3::DATE IS NULL OR DATE(au.created_at) <= $3::DATE)
|
||||||
|
GROUP BY u.id, u.email, u.full_name, u.role
|
||||||
|
ORDER BY total_tokens DESC
|
||||||
|
LIMIT 10
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(filters.start_date.clone())
|
||||||
|
.bind(filters.end_date.clone())
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch top users: {}", e)))?;
|
||||||
|
|
||||||
|
// Calculate summary stats
|
||||||
|
let total_tokens: i64 = daily_usage.iter().map(|d| d.total_tokens).sum();
|
||||||
|
let total_input: i64 = daily_usage.iter().map(|d| d.input_tokens).sum();
|
||||||
|
let total_output: i64 = daily_usage.iter().map(|d| d.output_tokens).sum();
|
||||||
|
let total_cost: f64 = daily_usage.iter().map(|d| d.cost_usd).sum();
|
||||||
|
let total_requests: i64 = daily_usage.iter().map(|d| d.requests).sum();
|
||||||
|
|
||||||
|
// Calculate savings estimate (vs OpenAI GPT-4 pricing)
|
||||||
|
// GPT-4: ~$0.03/1K input, ~$0.06/1K output
|
||||||
|
// Our local AI: ~$0.001/1K input, ~$0.003/1K output
|
||||||
|
let openai_equivalent_cost = (total_input as f64 * 0.00003) + (total_output as f64 * 0.00006);
|
||||||
|
let savings_vs_openai = openai_equivalent_cost - total_cost;
|
||||||
|
|
||||||
|
Ok(Json(DashboardResponse {
|
||||||
|
summary: DashboardSummary {
|
||||||
|
total_tokens,
|
||||||
|
total_input,
|
||||||
|
total_output,
|
||||||
|
total_requests,
|
||||||
|
total_cost_usd: total_cost,
|
||||||
|
savings_vs_openai_usd: savings_vs_openai,
|
||||||
|
openai_equivalent_cost_usd: openai_equivalent_cost,
|
||||||
|
},
|
||||||
|
daily_usage,
|
||||||
|
by_endpoint,
|
||||||
|
top_users,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/admin/ai-usage/logs - Get detailed AI usage logs with pagination
|
||||||
|
pub async fn get_ai_usage_logs(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
_claims: Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Query(filters): Query<UsageLogFilters>,
|
||||||
|
) -> Result<Json<UsageLogsResponse>, (StatusCode, String)> {
|
||||||
|
let limit = filters.limit.unwrap_or(50).min(200);
|
||||||
|
let offset = filters.offset.unwrap_or(0);
|
||||||
|
|
||||||
|
let logs: Vec<UsageLogRecord> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
au.id,
|
||||||
|
au.user_id,
|
||||||
|
u.email as user_email,
|
||||||
|
u.full_name as user_name,
|
||||||
|
au.endpoint,
|
||||||
|
au.request_type,
|
||||||
|
au.model,
|
||||||
|
au.tokens_used,
|
||||||
|
au.input_tokens,
|
||||||
|
au.output_tokens,
|
||||||
|
au.estimated_cost_usd,
|
||||||
|
au.prompt,
|
||||||
|
au.response,
|
||||||
|
au.request_metadata,
|
||||||
|
au.created_at
|
||||||
|
FROM ai_usage_logs au
|
||||||
|
JOIN users u ON au.user_id = u.id
|
||||||
|
WHERE au.organization_id = $1
|
||||||
|
AND ($2::TEXT IS NULL OR au.endpoint = $2)
|
||||||
|
AND ($3::TEXT IS NULL OR au.request_type = $3)
|
||||||
|
AND ($4::UUID IS NULL OR au.user_id = $4)
|
||||||
|
ORDER BY au.created_at DESC
|
||||||
|
LIMIT $5 OFFSET $6
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(filters.endpoint.clone())
|
||||||
|
.bind(filters.request_type.clone())
|
||||||
|
.bind(filters.user_id.clone())
|
||||||
|
.bind(limit as i64)
|
||||||
|
.bind(offset as i64)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch logs: {}", e)))?;
|
||||||
|
|
||||||
|
// Get total count for pagination
|
||||||
|
let count: (i64,) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT COUNT(*) FROM ai_usage_logs
|
||||||
|
WHERE organization_id = $1
|
||||||
|
AND ($2::TEXT IS NULL OR endpoint = $2)
|
||||||
|
AND ($3::TEXT IS NULL OR request_type = $3)
|
||||||
|
AND ($4::UUID IS NULL OR user_id = $4)
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(filters.endpoint.clone())
|
||||||
|
.bind(filters.request_type.clone())
|
||||||
|
.bind(filters.user_id.clone())
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to count logs: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Json(UsageLogsResponse {
|
||||||
|
logs,
|
||||||
|
total: count.0,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DashboardFilters {
|
||||||
|
pub start_date: Option<String>,
|
||||||
|
pub end_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UsageLogFilters {
|
||||||
|
pub endpoint: Option<String>,
|
||||||
|
pub request_type: Option<String>,
|
||||||
|
pub user_id: Option<String>,
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
pub offset: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DashboardSummary {
|
||||||
|
pub total_tokens: i64,
|
||||||
|
pub total_input: i64,
|
||||||
|
pub total_output: i64,
|
||||||
|
pub total_requests: i64,
|
||||||
|
pub total_cost_usd: f64,
|
||||||
|
pub savings_vs_openai_usd: f64,
|
||||||
|
pub openai_equivalent_cost_usd: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct DailyUsage {
|
||||||
|
pub date: chrono::NaiveDate,
|
||||||
|
pub total_tokens: i64,
|
||||||
|
pub input_tokens: i64,
|
||||||
|
pub output_tokens: i64,
|
||||||
|
pub cost_usd: f64,
|
||||||
|
pub requests: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct UsageByEndpoint {
|
||||||
|
pub endpoint: String,
|
||||||
|
pub request_type: String,
|
||||||
|
pub total_tokens: i64,
|
||||||
|
pub input_tokens: i64,
|
||||||
|
pub output_tokens: i64,
|
||||||
|
pub cost_usd: f64,
|
||||||
|
pub requests: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct TopUserUsage {
|
||||||
|
pub user_id: uuid::Uuid,
|
||||||
|
pub email: String,
|
||||||
|
pub full_name: String,
|
||||||
|
pub role: String,
|
||||||
|
pub total_tokens: i64,
|
||||||
|
pub input_tokens: i64,
|
||||||
|
pub output_tokens: i64,
|
||||||
|
pub cost_usd: f64,
|
||||||
|
pub requests: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DashboardResponse {
|
||||||
|
pub summary: DashboardSummary,
|
||||||
|
pub daily_usage: Vec<DailyUsage>,
|
||||||
|
pub by_endpoint: Vec<UsageByEndpoint>,
|
||||||
|
pub top_users: Vec<TopUserUsage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct UsageLogRecord {
|
||||||
|
pub id: uuid::Uuid,
|
||||||
|
pub user_id: uuid::Uuid,
|
||||||
|
pub user_email: String,
|
||||||
|
pub user_name: String,
|
||||||
|
pub endpoint: String,
|
||||||
|
pub request_type: String,
|
||||||
|
pub model: String,
|
||||||
|
pub tokens_used: i32,
|
||||||
|
pub input_tokens: i32,
|
||||||
|
pub output_tokens: i32,
|
||||||
|
pub estimated_cost_usd: f64,
|
||||||
|
pub prompt: Option<String>,
|
||||||
|
pub response: Option<String>,
|
||||||
|
pub request_metadata: serde_json::Value,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct UsageLogsResponse {
|
||||||
|
pub logs: Vec<UsageLogRecord>,
|
||||||
|
pub total: i64,
|
||||||
|
pub limit: u32,
|
||||||
|
pub offset: u32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct TokenUsageFilters {
|
pub struct TokenUsageFilters {
|
||||||
pub role: Option<String>,
|
pub role: Option<String>,
|
||||||
@@ -134,3 +422,298 @@ pub struct TokenUsageResponse {
|
|||||||
pub usage: Vec<TokenUsage>,
|
pub usage: Vec<TokenUsage>,
|
||||||
pub stats: TokenUsageStats,
|
pub stats: TokenUsageStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Global AI Usage Dashboard (Root Only) ====================
|
||||||
|
|
||||||
|
/// GET /api/admin/ai-usage/global - Global AI usage dashboard for root users
|
||||||
|
pub async fn get_ai_usage_global(
|
||||||
|
_claims: Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Query(filters): Query<DashboardFilters>,
|
||||||
|
) -> Result<Json<GlobalAiUsageResponse>, (StatusCode, String)> {
|
||||||
|
// Get daily usage for charts (all organizations)
|
||||||
|
let daily_usage: Vec<DailyUsage> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
DATE(au.created_at) as date,
|
||||||
|
COALESCE(SUM(au.tokens_used), 0) as total_tokens,
|
||||||
|
COALESCE(SUM(au.input_tokens), 0) as input_tokens,
|
||||||
|
COALESCE(SUM(au.output_tokens), 0) as output_tokens,
|
||||||
|
COALESCE(SUM(au.estimated_cost_usd), 0)::FLOAT8 as cost_usd,
|
||||||
|
COUNT(au.id) as requests
|
||||||
|
FROM ai_usage_logs au
|
||||||
|
WHERE ($1::DATE IS NULL OR au.created_at >= $1::DATE)
|
||||||
|
AND ($2::DATE IS NULL OR au.created_at <= $2::DATE + INTERVAL '1 day')
|
||||||
|
GROUP BY DATE(au.created_at)
|
||||||
|
ORDER BY date ASC
|
||||||
|
LIMIT 90
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(filters.start_date.clone())
|
||||||
|
.bind(filters.end_date.clone())
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch daily usage: {}", e)))?;
|
||||||
|
|
||||||
|
// Get usage by endpoint/feature
|
||||||
|
let by_endpoint: Vec<UsageByEndpoint> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
au.endpoint,
|
||||||
|
au.request_type,
|
||||||
|
COALESCE(SUM(au.tokens_used), 0) as total_tokens,
|
||||||
|
COALESCE(SUM(au.input_tokens), 0) as input_tokens,
|
||||||
|
COALESCE(SUM(au.output_tokens), 0) as output_tokens,
|
||||||
|
COALESCE(SUM(au.estimated_cost_usd), 0)::FLOAT8 as cost_usd,
|
||||||
|
COUNT(au.id) as requests
|
||||||
|
FROM ai_usage_logs au
|
||||||
|
WHERE ($1::DATE IS NULL OR au.created_at >= $1::DATE)
|
||||||
|
AND ($2::DATE IS NULL OR au.created_at <= $2::DATE + INTERVAL '1 day')
|
||||||
|
GROUP BY au.endpoint, au.request_type
|
||||||
|
ORDER BY total_tokens DESC
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(filters.start_date.clone())
|
||||||
|
.bind(filters.end_date.clone())
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch endpoint usage: {}", e)))?;
|
||||||
|
|
||||||
|
// Get usage by organization
|
||||||
|
let by_organization: Vec<UsageByOrganization> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
o.id as org_id,
|
||||||
|
o.name as org_name,
|
||||||
|
COALESCE(SUM(au.tokens_used), 0) as total_tokens,
|
||||||
|
COALESCE(SUM(au.input_tokens), 0) as input_tokens,
|
||||||
|
COALESCE(SUM(au.output_tokens), 0) as output_tokens,
|
||||||
|
COALESCE(SUM(au.estimated_cost_usd), 0)::FLOAT8 as cost_usd,
|
||||||
|
COUNT(au.id) as requests,
|
||||||
|
COUNT(DISTINCT au.user_id) as active_users
|
||||||
|
FROM ai_usage_logs au
|
||||||
|
JOIN organizations o ON au.organization_id = o.id
|
||||||
|
WHERE ($1::DATE IS NULL OR DATE(au.created_at) >= $1::DATE)
|
||||||
|
AND ($2::DATE IS NULL OR DATE(au.created_at) <= $2::DATE)
|
||||||
|
GROUP BY o.id, o.name
|
||||||
|
ORDER BY total_tokens DESC
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(filters.start_date.clone())
|
||||||
|
.bind(filters.end_date.clone())
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch org usage: {}", e)))?;
|
||||||
|
|
||||||
|
// Get top users across all organizations
|
||||||
|
let top_users: Vec<TopUserUsage> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
u.id as user_id,
|
||||||
|
u.email,
|
||||||
|
u.full_name,
|
||||||
|
u.role,
|
||||||
|
o.name as org_name,
|
||||||
|
COALESCE(SUM(au.tokens_used), 0) as total_tokens,
|
||||||
|
COALESCE(SUM(au.input_tokens), 0) as input_tokens,
|
||||||
|
COALESCE(SUM(au.output_tokens), 0) as output_tokens,
|
||||||
|
COALESCE(SUM(au.estimated_cost_usd), 0)::FLOAT8 as cost_usd,
|
||||||
|
COUNT(au.id) as requests
|
||||||
|
FROM ai_usage_logs au
|
||||||
|
JOIN users u ON au.user_id = u.id
|
||||||
|
JOIN organizations o ON au.organization_id = o.id
|
||||||
|
WHERE ($1::DATE IS NULL OR DATE(au.created_at) >= $1::DATE)
|
||||||
|
AND ($2::DATE IS NULL OR DATE(au.created_at) <= $2::DATE)
|
||||||
|
GROUP BY u.id, u.email, u.full_name, u.role, o.name
|
||||||
|
ORDER BY total_tokens DESC
|
||||||
|
LIMIT 20
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(filters.start_date.clone())
|
||||||
|
.bind(filters.end_date.clone())
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch top users: {}", e)))?;
|
||||||
|
|
||||||
|
// Get usage by request type (for pie chart)
|
||||||
|
let by_request_type: Vec<UsageByRequestType> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
au.request_type,
|
||||||
|
COALESCE(SUM(au.tokens_used), 0) as total_tokens,
|
||||||
|
COALESCE(SUM(au.estimated_cost_usd), 0)::FLOAT8 as cost_usd,
|
||||||
|
COUNT(au.id) as requests
|
||||||
|
FROM ai_usage_logs au
|
||||||
|
WHERE ($1::DATE IS NULL OR DATE(au.created_at) >= $1::DATE)
|
||||||
|
AND ($2::DATE IS NULL OR DATE(au.created_at) <= $2::DATE)
|
||||||
|
GROUP BY au.request_type
|
||||||
|
ORDER BY total_tokens DESC
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(filters.start_date.clone())
|
||||||
|
.bind(filters.end_date.clone())
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch request type usage: {}", e)))?;
|
||||||
|
|
||||||
|
// Calculate summary stats
|
||||||
|
let total_tokens: i64 = daily_usage.iter().map(|d| d.total_tokens).sum();
|
||||||
|
let total_input: i64 = daily_usage.iter().map(|d| d.input_tokens).sum();
|
||||||
|
let total_output: i64 = daily_usage.iter().map(|d| d.output_tokens).sum();
|
||||||
|
let total_cost: f64 = daily_usage.iter().map(|d| d.cost_usd).sum();
|
||||||
|
let total_requests: i64 = daily_usage.iter().map(|d| d.requests).sum();
|
||||||
|
|
||||||
|
// Calculate savings estimate (vs OpenAI GPT-4 pricing)
|
||||||
|
// GPT-4: ~$0.03/1K input, ~$0.06/1K output
|
||||||
|
// Our local AI: ~$0.001/1K input, ~$0.003/1K output
|
||||||
|
let openai_equivalent_cost = (total_input as f64 * 0.00003) + (total_output as f64 * 0.00006);
|
||||||
|
let savings_vs_openai = openai_equivalent_cost - total_cost;
|
||||||
|
let savings_percentage = if openai_equivalent_cost > 0.0 {
|
||||||
|
(savings_vs_openai / openai_equivalent_cost) * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get total active users count
|
||||||
|
let total_active_users: i64 = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
SELECT COUNT(DISTINCT au.user_id)
|
||||||
|
FROM ai_usage_logs au
|
||||||
|
WHERE ($1::DATE IS NULL OR DATE(au.created_at) >= $1::DATE)
|
||||||
|
AND ($2::DATE IS NULL OR DATE(au.created_at) <= $2::DATE)
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(filters.start_date.clone())
|
||||||
|
.bind(filters.end_date.clone())
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to count users: {}", e)))?;
|
||||||
|
|
||||||
|
// Calculate student-specific usage (chat interactions)
|
||||||
|
let student_chat_usage: Vec<StudentChatUsage> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
u.id as user_id,
|
||||||
|
u.email,
|
||||||
|
u.full_name,
|
||||||
|
o.name as org_name,
|
||||||
|
COALESCE(SUM(au.tokens_used), 0) as total_tokens,
|
||||||
|
COALESCE(SUM(au.estimated_cost_usd), 0)::FLOAT8 as cost_usd,
|
||||||
|
COUNT(au.id) as chat_requests,
|
||||||
|
MAX(au.created_at) as last_chat
|
||||||
|
FROM ai_usage_logs au
|
||||||
|
JOIN users u ON au.user_id = u.id
|
||||||
|
JOIN organizations o ON au.organization_id = o.id
|
||||||
|
WHERE au.request_type = 'chat'
|
||||||
|
AND u.role = 'student'
|
||||||
|
AND ($1::DATE IS NULL OR DATE(au.created_at) >= $1::DATE)
|
||||||
|
AND ($2::DATE IS NULL OR DATE(au.created_at) <= $2::DATE)
|
||||||
|
GROUP BY u.id, u.email, u.full_name, o.name
|
||||||
|
ORDER BY total_tokens DESC
|
||||||
|
LIMIT 50
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(filters.start_date.clone())
|
||||||
|
.bind(filters.end_date.clone())
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch student chat usage: {}", e)))?;
|
||||||
|
|
||||||
|
// Calculate student chat totals
|
||||||
|
let total_student_chat_tokens: i64 = student_chat_usage.iter().map(|s| s.total_tokens).sum();
|
||||||
|
let total_student_chat_cost: f64 = student_chat_usage.iter().map(|s| s.cost_usd).sum();
|
||||||
|
let total_student_chat_requests: i64 = student_chat_usage.iter().map(|s| s.chat_requests).sum();
|
||||||
|
|
||||||
|
Ok(Json(GlobalAiUsageResponse {
|
||||||
|
summary: GlobalAiSummary {
|
||||||
|
total_tokens,
|
||||||
|
total_input,
|
||||||
|
total_output,
|
||||||
|
total_requests,
|
||||||
|
total_cost_usd: total_cost,
|
||||||
|
savings_vs_openai_usd: savings_vs_openai,
|
||||||
|
savings_percentage,
|
||||||
|
openai_equivalent_cost_usd: openai_equivalent_cost,
|
||||||
|
total_organizations: by_organization.len() as i64,
|
||||||
|
total_active_users,
|
||||||
|
},
|
||||||
|
student_chat_summary: Some(StudentChatSummary {
|
||||||
|
total_tokens: total_student_chat_tokens,
|
||||||
|
total_requests: total_student_chat_requests,
|
||||||
|
total_cost_usd: total_student_chat_cost,
|
||||||
|
active_students: student_chat_usage.len() as i64,
|
||||||
|
}),
|
||||||
|
daily_usage,
|
||||||
|
by_endpoint,
|
||||||
|
by_organization,
|
||||||
|
by_request_type,
|
||||||
|
top_users,
|
||||||
|
student_chat_usage,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct UsageByOrganization {
|
||||||
|
pub org_id: uuid::Uuid,
|
||||||
|
pub org_name: String,
|
||||||
|
pub total_tokens: i64,
|
||||||
|
pub input_tokens: i64,
|
||||||
|
pub output_tokens: i64,
|
||||||
|
pub cost_usd: f64,
|
||||||
|
pub requests: i64,
|
||||||
|
pub active_users: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct UsageByRequestType {
|
||||||
|
pub request_type: String,
|
||||||
|
pub total_tokens: i64,
|
||||||
|
pub cost_usd: f64,
|
||||||
|
pub requests: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct StudentChatUsage {
|
||||||
|
pub user_id: uuid::Uuid,
|
||||||
|
pub email: String,
|
||||||
|
pub full_name: String,
|
||||||
|
pub org_name: String,
|
||||||
|
pub total_tokens: i64,
|
||||||
|
pub cost_usd: f64,
|
||||||
|
pub chat_requests: i64,
|
||||||
|
pub last_chat: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct StudentChatSummary {
|
||||||
|
pub total_tokens: i64,
|
||||||
|
pub total_requests: i64,
|
||||||
|
pub total_cost_usd: f64,
|
||||||
|
pub active_students: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct GlobalAiSummary {
|
||||||
|
pub total_tokens: i64,
|
||||||
|
pub total_input: i64,
|
||||||
|
pub total_output: i64,
|
||||||
|
pub total_requests: i64,
|
||||||
|
pub total_cost_usd: f64,
|
||||||
|
pub savings_vs_openai_usd: f64,
|
||||||
|
pub savings_percentage: f64,
|
||||||
|
pub openai_equivalent_cost_usd: f64,
|
||||||
|
pub total_organizations: i64,
|
||||||
|
pub total_active_users: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct GlobalAiUsageResponse {
|
||||||
|
pub summary: GlobalAiSummary,
|
||||||
|
pub student_chat_summary: Option<StudentChatSummary>,
|
||||||
|
pub daily_usage: Vec<DailyUsage>,
|
||||||
|
pub by_endpoint: Vec<UsageByEndpoint>,
|
||||||
|
pub by_organization: Vec<UsageByOrganization>,
|
||||||
|
pub by_request_type: Vec<UsageByRequestType>,
|
||||||
|
pub top_users: Vec<TopUserUsage>,
|
||||||
|
pub student_chat_usage: Vec<StudentChatUsage>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -386,6 +386,10 @@ async fn main() {
|
|||||||
"/admin/token-usage",
|
"/admin/token-usage",
|
||||||
get(handlers_admin::get_token_usage),
|
get(handlers_admin::get_token_usage),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/admin/ai-usage/global",
|
||||||
|
get(handlers_admin::get_ai_usage_global),
|
||||||
|
)
|
||||||
.route_layer(middleware::from_fn(
|
.route_layer(middleware::from_fn(
|
||||||
common::middleware::org_extractor_middleware,
|
common::middleware::org_extractor_middleware,
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ use common::models::{
|
|||||||
LessonDependency,
|
LessonDependency,
|
||||||
};
|
};
|
||||||
use crate::external_db::MySqlPool;
|
use crate::external_db::MySqlPool;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
// Simple token counter (approximate: 1 token ≈ 4 characters in English, ~3-5 in Spanish)
|
||||||
|
fn count_tokens(text: &str) -> i32 {
|
||||||
|
if text.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// Spanish average: ~4 chars per token
|
||||||
|
(text.len() / 4) as i32 + 1
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_me(
|
pub async fn get_me(
|
||||||
claims: common::auth::Claims,
|
claims: common::auth::Claims,
|
||||||
@@ -2773,6 +2783,30 @@ pub async fn chat_with_tutor(
|
|||||||
.unwrap_or("Lo siento, tuve un problema procesando tu pregunta.")
|
.unwrap_or("Lo siento, tuve un problema procesando tu pregunta.")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
|
// Calculate and log token usage
|
||||||
|
let input_tokens = count_tokens(&system_prompt) + count_tokens(&payload.message);
|
||||||
|
let output_tokens = count_tokens(&tutor_response);
|
||||||
|
let total_tokens = input_tokens + output_tokens;
|
||||||
|
|
||||||
|
let _ = sqlx::query("SELECT log_ai_usage($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)")
|
||||||
|
.bind(claims.sub)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(total_tokens)
|
||||||
|
.bind(input_tokens)
|
||||||
|
.bind(output_tokens)
|
||||||
|
.bind("/lessons/chat")
|
||||||
|
.bind(&model)
|
||||||
|
.bind("chat")
|
||||||
|
.bind(&json!({
|
||||||
|
"lesson_id": lesson_id,
|
||||||
|
"session_id": session_id,
|
||||||
|
"has_rag": !kb_context.is_empty(),
|
||||||
|
}))
|
||||||
|
.bind(&format!("{} - {}", system_prompt, payload.message)) // prompt
|
||||||
|
.bind(&tutor_response) // response
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
// Save assistant response
|
// Save assistant response
|
||||||
let _ =
|
let _ =
|
||||||
sqlx::query("INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)")
|
sqlx::query("INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)")
|
||||||
|
|||||||
@@ -0,0 +1,595 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { cmsApi } from '@/lib/api';
|
||||||
|
import {
|
||||||
|
ShieldCheck,
|
||||||
|
TrendingUp,
|
||||||
|
DollarSign,
|
||||||
|
Users,
|
||||||
|
Activity,
|
||||||
|
PieChart,
|
||||||
|
BarChart3,
|
||||||
|
MessageCircle,
|
||||||
|
GraduationCap,
|
||||||
|
Building2,
|
||||||
|
ArrowUpRight,
|
||||||
|
Save
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface DailyUsage {
|
||||||
|
date: string;
|
||||||
|
total_tokens: number;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
cost_usd: number;
|
||||||
|
requests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsageByEndpoint {
|
||||||
|
endpoint: string;
|
||||||
|
request_type: string;
|
||||||
|
total_tokens: number;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
cost_usd: number;
|
||||||
|
requests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsageByOrganization {
|
||||||
|
org_id: string;
|
||||||
|
org_name: string;
|
||||||
|
total_tokens: number;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
cost_usd: number;
|
||||||
|
requests: number;
|
||||||
|
active_users: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsageByRequestType {
|
||||||
|
request_type: string;
|
||||||
|
total_tokens: number;
|
||||||
|
cost_usd: number;
|
||||||
|
requests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopUserUsage {
|
||||||
|
user_id: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
role: string;
|
||||||
|
org_name: string;
|
||||||
|
total_tokens: number;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
cost_usd: number;
|
||||||
|
requests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StudentChatUsage {
|
||||||
|
user_id: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
org_name: string;
|
||||||
|
total_tokens: number;
|
||||||
|
cost_usd: number;
|
||||||
|
chat_requests: number;
|
||||||
|
last_chat: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlobalAiSummary {
|
||||||
|
total_tokens: number;
|
||||||
|
total_input: number;
|
||||||
|
total_output: number;
|
||||||
|
total_requests: number;
|
||||||
|
total_cost_usd: number;
|
||||||
|
savings_vs_openai_usd: number;
|
||||||
|
savings_percentage: number;
|
||||||
|
openai_equivalent_cost_usd: number;
|
||||||
|
total_organizations: number;
|
||||||
|
total_active_users: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StudentChatSummary {
|
||||||
|
total_tokens: number;
|
||||||
|
total_requests: number;
|
||||||
|
total_cost_usd: number;
|
||||||
|
active_students: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlobalAiUsageResponse {
|
||||||
|
summary: GlobalAiSummary;
|
||||||
|
student_chat_summary: StudentChatSummary | null;
|
||||||
|
daily_usage: DailyUsage[];
|
||||||
|
by_endpoint: UsageByEndpoint[];
|
||||||
|
by_organization: UsageByOrganization[];
|
||||||
|
by_request_type: UsageByRequestType[];
|
||||||
|
top_users: TopUserUsage[];
|
||||||
|
student_chat_usage: StudentChatUsage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GlobalAiControl() {
|
||||||
|
const [data, setData] = useState<GlobalAiUsageResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dateRange, setDateRange] = useState<'7d' | '30d' | '90d'>('30d');
|
||||||
|
const [authError, setAuthError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadGlobalUsage();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [dateRange]);
|
||||||
|
|
||||||
|
const loadGlobalUsage = async () => {
|
||||||
|
try {
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date();
|
||||||
|
|
||||||
|
if (dateRange === '7d') {
|
||||||
|
startDate.setDate(startDate.getDate() - 7);
|
||||||
|
} else if (dateRange === '30d') {
|
||||||
|
startDate.setDate(startDate.getDate() - 30);
|
||||||
|
} else {
|
||||||
|
startDate.setDate(startDate.getDate() - 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Loading AI usage from', startDate.toISOString().split('T')[0], 'to', endDate.toISOString().split('T')[0]);
|
||||||
|
|
||||||
|
const token = localStorage.getItem('studio_token');
|
||||||
|
console.log('Token from localStorage:', token ? 'EXISTS' : 'NULL');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
console.error('No token found. Please login again.');
|
||||||
|
setAuthError(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonData = await cmsApi.getGlobalAiUsage(
|
||||||
|
startDate.toISOString().split('T')[0],
|
||||||
|
endDate.toISOString().split('T')[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Data loaded:', jsonData);
|
||||||
|
setData(jsonData);
|
||||||
|
setAuthError(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load global AI usage:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
if (errorMessage.includes('401') || errorMessage.includes('Unauthorized')) {
|
||||||
|
setAuthError(true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (num: number) => {
|
||||||
|
return new Intl.NumberFormat('en-US').format(num);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (num: number) => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 4
|
||||||
|
}).format(num);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString('es-ES', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate max value for chart scaling
|
||||||
|
const maxDailyTokens = data?.daily_usage.reduce((max, d) => Math.max(max, d.total_tokens), 0) || 1;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||||
|
<p className="mt-4 text-gray-600 dark:text-gray-400">Cargando métricas globales...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authError) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||||
|
<div className="text-center max-w-md p-8">
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6 mb-6">
|
||||||
|
<h3 className="text-lg font-bold text-red-800 dark:text-red-400 mb-2">
|
||||||
|
Error de Autenticación
|
||||||
|
</h3>
|
||||||
|
<p className="text-red-600 dark:text-red-300 text-sm mb-4">
|
||||||
|
No se pudo cargar los datos. Esto puede deberse a:
|
||||||
|
</p>
|
||||||
|
<ul className="text-red-600 dark:text-red-300 text-sm text-left list-disc list-inside space-y-1">
|
||||||
|
<li>Tu sesión ha expirado</li>
|
||||||
|
<li>No has iniciado sesión</li>
|
||||||
|
<li>El token no es válido</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setAuthError(false);
|
||||||
|
loadGlobalUsage();
|
||||||
|
}}
|
||||||
|
className="px-6 py-3 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors"
|
||||||
|
>
|
||||||
|
Reintentar
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/auth/login"
|
||||||
|
className="px-6 py-3 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg font-medium hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
Iniciar Sesión
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-4">
|
||||||
|
Token en localStorage: {localStorage.getItem('studio_token') ? '✓ Existe' : '✗ No existe'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||||
|
<div className="text-center text-red-600">
|
||||||
|
<p>Error al cargar los datos. Por favor, recarga la página.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 pb-12">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 dark:from-indigo-800 dark:to-purple-800">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ShieldCheck className="w-10 h-10 text-white" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white">
|
||||||
|
Control Global - Uso de IA
|
||||||
|
</h1>
|
||||||
|
<p className="text-indigo-100 text-sm mt-1">
|
||||||
|
Monitoreo integral de tokens, costos y ahorro en todo el sistema
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Range Selector */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['7d', '30d', '90d'] as const).map((range) => (
|
||||||
|
<button
|
||||||
|
key={range}
|
||||||
|
onClick={() => setDateRange(range)}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
dateRange === range
|
||||||
|
? 'bg-white text-indigo-600'
|
||||||
|
: 'bg-indigo-500/50 text-white hover:bg-indigo-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{range === '7d' ? 'Últimos 7 días' : range === '30d' ? 'Últimos 30 días' : 'Últimos 90 días'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 -mt-6">
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
{/* Total Tokens */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||||
|
<Activity className="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<ArrowUpRight className="w-5 h-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Total Tokens</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
|
||||||
|
{formatNumber(data.summary.total_tokens)}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 text-xs text-gray-500">
|
||||||
|
Input: {formatNumber(data.summary.total_input)} | Output: {formatNumber(data.summary.total_output)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Costo Total */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||||
|
<DollarSign className="w-6 h-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<ArrowUpRight className="w-5 h-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Costo Total (IA Local)</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
|
||||||
|
{formatCurrency(data.summary.total_cost_usd)}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 text-xs text-gray-500">
|
||||||
|
Requests: {formatNumber(data.summary.total_requests)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ahorro vs OpenAI */}
|
||||||
|
<div className="bg-gradient-to-br from-emerald-50 to-green-50 dark:from-emerald-900/20 dark:to-green-900/20 rounded-xl p-6 border border-emerald-200 dark:border-emerald-800 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-emerald-100 dark:bg-emerald-900/50 flex items-center justify-center">
|
||||||
|
<Save className="w-6 h-6 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<TrendingUp className="w-5 h-5 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">Ahorro vs OpenAI GPT-4</p>
|
||||||
|
<p className="text-2xl font-bold text-emerald-700 dark:text-emerald-400 mt-1">
|
||||||
|
{formatCurrency(data.summary.savings_vs_openai_usd)}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 text-xs text-emerald-600 dark:text-emerald-400 font-medium">
|
||||||
|
{data.summary.savings_percentage.toFixed(1)}% más económico
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usuarios Activos */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||||
|
<Users className="w-6 h-6 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<ArrowUpRight className="w-5 h-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Usuarios Activos</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
|
||||||
|
{data.summary.total_active_users}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 text-xs text-gray-500">
|
||||||
|
Organizaciones: {data.summary.total_organizations}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Student Chat Summary */}
|
||||||
|
{data.student_chat_summary && (
|
||||||
|
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-xl p-6 border border-blue-200 dark:border-blue-800 mb-8">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<MessageCircle className="w-6 h-6 text-blue-600" />
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
Uso del Chat por Alumnos
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">Tokens Consumidos</p>
|
||||||
|
<p className="text-xl font-bold text-blue-700 dark:text-blue-400">
|
||||||
|
{formatNumber(data.student_chat_summary.total_tokens)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">Requests de Chat</p>
|
||||||
|
<p className="text-xl font-bold text-blue-700 dark:text-blue-400">
|
||||||
|
{formatNumber(data.student_chat_summary.total_requests)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">Costo Total</p>
|
||||||
|
<p className="text-xl font-bold text-blue-700 dark:text-blue-400">
|
||||||
|
{formatCurrency(data.student_chat_summary.total_cost_usd)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">Alumnos Activos</p>
|
||||||
|
<p className="text-xl font-bold text-blue-700 dark:text-blue-400">
|
||||||
|
{data.student_chat_summary.active_students}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Charts Row */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
|
{/* Daily Usage Chart */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700 shadow-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<BarChart3 className="w-5 h-5 text-indigo-600" />
|
||||||
|
<h3 className="font-bold text-gray-900 dark:text-white">Uso Diario de Tokens</h3>
|
||||||
|
</div>
|
||||||
|
<div className="h-64 flex items-end gap-1 overflow-x-auto">
|
||||||
|
{data.daily_usage.slice(-30).map((day, idx) => (
|
||||||
|
<div key={idx} className="flex-1 min-w-[20px] flex flex-col items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="w-full bg-gradient-to-t from-indigo-500 to-purple-500 rounded-t transition-all hover:from-indigo-400 hover:to-purple-400"
|
||||||
|
style={{
|
||||||
|
height: `${Math.max((day.total_tokens / maxDailyTokens) * 200, 4)}px`,
|
||||||
|
minHeight: '4px'
|
||||||
|
}}
|
||||||
|
title={`${formatNumber(day.total_tokens)} tokens - ${formatCurrency(day.cost_usd)}`}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-gray-500 rotate-45 origin-top-left translate-y-2">
|
||||||
|
{formatDate(day.date)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage by Request Type */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700 shadow-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<PieChart className="w-5 h-5 text-purple-600" />
|
||||||
|
<h3 className="font-bold text-gray-900 dark:text-white">Uso por Tipo de Request</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.by_request_type.map((type, idx) => {
|
||||||
|
const percentage = data.summary.total_tokens > 0
|
||||||
|
? (type.total_tokens / data.summary.total_tokens) * 100
|
||||||
|
: 0;
|
||||||
|
const colors = [
|
||||||
|
'bg-blue-500',
|
||||||
|
'bg-green-500',
|
||||||
|
'bg-purple-500',
|
||||||
|
'bg-orange-500',
|
||||||
|
'bg-pink-500'
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div key={idx}>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="capitalize text-gray-700 dark:text-gray-300">
|
||||||
|
{type.request_type}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{formatNumber(type.total_tokens)} tokens ({percentage.toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`${colors[idx % colors.length]} h-2 rounded-full transition-all`}
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage by Organization */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700 shadow-lg mb-8">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Building2 className="w-5 h-5 text-blue-600" />
|
||||||
|
<h3 className="font-bold text-gray-900 dark:text-white">Uso por Organización</h3>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Organización</th>
|
||||||
|
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Tokens</th>
|
||||||
|
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Requests</th>
|
||||||
|
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Usuarios Activos</th>
|
||||||
|
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Costo USD</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.by_organization.map((org) => (
|
||||||
|
<tr key={org.org_id.toString()} className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||||
|
<td className="py-3 px-4 text-sm font-medium text-gray-900 dark:text-white">{org.org_name}</td>
|
||||||
|
<td className="py-3 px-4 text-right text-sm text-gray-600 dark:text-gray-400">{formatNumber(org.total_tokens)}</td>
|
||||||
|
<td className="py-3 px-4 text-right text-sm text-gray-600 dark:text-gray-400">{formatNumber(org.requests)}</td>
|
||||||
|
<td className="py-3 px-4 text-right text-sm text-gray-600 dark:text-gray-400">{org.active_users}</td>
|
||||||
|
<td className="py-3 px-4 text-right text-sm font-medium text-gray-900 dark:text-white">{formatCurrency(org.cost_usd)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Users */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700 shadow-lg mb-8">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<GraduationCap className="w-5 h-5 text-green-600" />
|
||||||
|
<h3 className="font-bold text-gray-900 dark:text-white">Top 20 Usuarios por Consumo</h3>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Usuario</th>
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Organización</th>
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Rol</th>
|
||||||
|
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Tokens</th>
|
||||||
|
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Requests</th>
|
||||||
|
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Costo USD</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.top_users.map((user, idx) => (
|
||||||
|
<tr key={user.user_id} className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">{user.full_name}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">{user.org_name}</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span className={`px-2 py-1 text-xs font-medium rounded capitalize ${
|
||||||
|
user.role === 'admin' ? 'bg-purple-100 text-purple-800' :
|
||||||
|
user.role === 'instructor' ? 'bg-blue-100 text-blue-800' :
|
||||||
|
'bg-green-100 text-green-800'
|
||||||
|
}`}>
|
||||||
|
{user.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-right text-sm font-medium text-gray-900 dark:text-white">{formatNumber(user.total_tokens)}</td>
|
||||||
|
<td className="py-3 px-4 text-right text-sm text-gray-600 dark:text-gray-400">{formatNumber(user.requests)}</td>
|
||||||
|
<td className="py-3 px-4 text-right text-sm font-medium text-gray-900 dark:text-white">{formatCurrency(user.cost_usd)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Student Chat Usage Detail */}
|
||||||
|
{data.student_chat_usage.length > 0 && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700 shadow-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<MessageCircle className="w-5 h-5 text-blue-600" />
|
||||||
|
<h3 className="font-bold text-gray-900 dark:text-white">
|
||||||
|
Detalle de Uso del Chat por Alumnos (Top 50)
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Alumno</th>
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Organización</th>
|
||||||
|
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Tokens</th>
|
||||||
|
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Requests</th>
|
||||||
|
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Costo USD</th>
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Último Chat</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.student_chat_usage.map((student) => (
|
||||||
|
<tr key={student.user_id} className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">{student.full_name}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{student.email}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">{student.org_name}</td>
|
||||||
|
<td className="py-3 px-4 text-right text-sm font-medium text-gray-900 dark:text-white">{formatNumber(student.total_tokens)}</td>
|
||||||
|
<td className="py-3 px-4 text-right text-sm text-gray-600 dark:text-gray-400">{formatNumber(student.chat_requests)}</td>
|
||||||
|
<td className="py-3 px-4 text-right text-sm font-medium text-gray-900 dark:text-white">{formatCurrency(student.cost_usd)}</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{new Date(student.last_chat).toLocaleDateString('es-ES')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
ClipboardList,
|
ClipboardList,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Activity
|
Activity,
|
||||||
|
TrendingUp
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
@@ -22,6 +23,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
{ icon: Users, label: "Users", href: "/admin/users" },
|
{ icon: Users, label: "Users", href: "/admin/users" },
|
||||||
{ icon: ClipboardList, label: "Audit Logs", href: "/admin/audit" },
|
{ icon: ClipboardList, label: "Audit Logs", href: "/admin/audit" },
|
||||||
{ icon: Activity, label: "System Tasks", href: "/admin/tasks" },
|
{ icon: Activity, label: "System Tasks", href: "/admin/tasks" },
|
||||||
|
{ icon: TrendingUp, label: "Control Global IA", href: "/admin/ai-usage-global" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -233,6 +233,102 @@ export interface UploadResponse {
|
|||||||
favicon_url?: string;
|
favicon_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== AI Usage Global ====================
|
||||||
|
|
||||||
|
export interface GlobalAiSummary {
|
||||||
|
total_tokens: number;
|
||||||
|
total_input: number;
|
||||||
|
total_output: number;
|
||||||
|
total_requests: number;
|
||||||
|
total_cost_usd: number;
|
||||||
|
savings_vs_openai_usd: number;
|
||||||
|
savings_percentage: number;
|
||||||
|
openai_equivalent_cost_usd: number;
|
||||||
|
total_organizations: number;
|
||||||
|
total_active_users: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StudentChatSummary {
|
||||||
|
total_tokens: number;
|
||||||
|
total_requests: number;
|
||||||
|
total_cost_usd: number;
|
||||||
|
active_students: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyUsage {
|
||||||
|
date: string;
|
||||||
|
total_tokens: number;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
cost_usd: number;
|
||||||
|
requests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageByEndpoint {
|
||||||
|
endpoint: string;
|
||||||
|
request_type: string;
|
||||||
|
total_tokens: number;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
cost_usd: number;
|
||||||
|
requests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageByOrganization {
|
||||||
|
org_id: string;
|
||||||
|
org_name: string;
|
||||||
|
total_tokens: number;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
cost_usd: number;
|
||||||
|
requests: number;
|
||||||
|
active_users: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageByRequestType {
|
||||||
|
request_type: string;
|
||||||
|
total_tokens: number;
|
||||||
|
cost_usd: number;
|
||||||
|
requests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopUserUsage {
|
||||||
|
user_id: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
role: string;
|
||||||
|
org_name: string;
|
||||||
|
total_tokens: number;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
cost_usd: number;
|
||||||
|
requests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StudentChatUsage {
|
||||||
|
user_id: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
org_name: string;
|
||||||
|
total_tokens: number;
|
||||||
|
cost_usd: number;
|
||||||
|
chat_requests: number;
|
||||||
|
last_chat: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlobalAiUsageResponse {
|
||||||
|
summary: GlobalAiSummary;
|
||||||
|
student_chat_summary: StudentChatSummary | null;
|
||||||
|
daily_usage: DailyUsage[];
|
||||||
|
by_endpoint: UsageByEndpoint[];
|
||||||
|
by_organization: UsageByOrganization[];
|
||||||
|
by_request_type: UsageByRequestType[];
|
||||||
|
top_users: TopUserUsage[];
|
||||||
|
student_chat_usage: StudentChatUsage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Grading ====================
|
||||||
|
|
||||||
export interface GradingCategory {
|
export interface GradingCategory {
|
||||||
id: string;
|
id: string;
|
||||||
course_id: string;
|
course_id: string;
|
||||||
@@ -926,6 +1022,10 @@ export const cmsApi = {
|
|||||||
apiFetch(`/test-templates/${templateId}/apply`, { method: 'POST', body: JSON.stringify({ lesson_id: lessonId, grading_category_id: gradingCategoryId }) }, false),
|
apiFetch(`/test-templates/${templateId}/apply`, { method: 'POST', body: JSON.stringify({ lesson_id: lessonId, grading_category_id: gradingCategoryId }) }, false),
|
||||||
generateQuestionsWithRAG: (courseId?: number, topic?: string, numQuestions?: number): Promise<TestTemplateQuestion[]> =>
|
generateQuestionsWithRAG: (courseId?: number, topic?: string, numQuestions?: number): Promise<TestTemplateQuestion[]> =>
|
||||||
apiFetch('/test-templates/generate-with-rag', { method: 'POST', body: JSON.stringify({ course_id: courseId, topic, num_questions: numQuestions }) }, false),
|
apiFetch('/test-templates/generate-with-rag', { method: 'POST', body: JSON.stringify({ course_id: courseId, topic, num_questions: numQuestions }) }, false),
|
||||||
|
|
||||||
|
// Admin - AI Usage Global
|
||||||
|
getGlobalAiUsage: (startDate?: string, endDate?: string): Promise<GlobalAiUsageResponse> =>
|
||||||
|
apiFetch('/admin/ai-usage/global', { query: { start_date: startDate, end_date: endDate } }, false),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==================== Question Bank ====================
|
// ==================== Question Bank ====================
|
||||||
|
|||||||
Reference in New Issue
Block a user