feat: add PWA support with service worker, offline sync, and connectivity banners

- Added PWA icons for 192x192 and 512x512 resolutions.
- Implemented service worker (sw.js) for caching static assets and handling fetch requests.
- Created ConnectivityBanner component to notify users of online/offline status.
- Developed OfflineSyncPanel component to manage and display offline sync status.
- Introduced PwaInstallPrompt component to prompt users for PWA installation.
- Added PwaRegistration component to handle service worker registration and online event handling.
- Created AdminAiAuditPage for AI audit logs with filtering and review functionality.
- Developed AdminDataEthicsPage to display AI data ethics summary and recent events.
This commit is contained in:
2026-04-24 09:59:57 -04:00
parent 4148de5d66
commit e72f479639
32 changed files with 2332 additions and 74 deletions
@@ -0,0 +1,193 @@
use axum::{
Json,
extract::{Query, State},
http::StatusCode,
};
use common::auth::Claims;
use common::middleware::Org;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::{PgPool, Row};
use uuid::Uuid;
const DEFAULT_RETENTION_DAYS: i32 = 180;
#[derive(Debug, Deserialize)]
pub struct DataEthicsFilters {
pub days: Option<i32>,
pub limit: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct DataEthicsEventItem {
pub id: Uuid,
pub endpoint: String,
pub model: String,
pub request_type: String,
pub tokens_used: i32,
pub input_tokens: i32,
pub output_tokens: i32,
pub has_rag_context: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Serialize)]
pub struct DataEthicsSummary {
pub days_window: i32,
pub total_requests: i64,
pub total_tokens: i64,
pub total_input_tokens: i64,
pub total_output_tokens: i64,
pub average_tokens_per_request: i64,
pub model_count: i64,
pub request_type_count: i64,
pub retention_days: i32,
pub stored_fields: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct DataEthicsSummaryResponse {
pub summary: DataEthicsSummary,
pub events: Vec<DataEthicsEventItem>,
}
fn ensure_data_ethics_viewer_role(claims: &Claims) -> Result<(), (StatusCode, String)> {
if claims.role != "admin" && claims.role != "instructor" {
return Err((
StatusCode::FORBIDDEN,
"No autorizado para ver transparencia de datos IA".to_string(),
));
}
Ok(())
}
pub async fn get_data_ethics_summary(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Query(filters): Query<DataEthicsFilters>,
) -> Result<Json<DataEthicsSummaryResponse>, (StatusCode, String)> {
ensure_data_ethics_viewer_role(&claims)?;
let days_window = filters.days.unwrap_or(30).clamp(1, 365);
let limit = filters.limit.unwrap_or(40).clamp(1, 200);
let totals = sqlx::query(
r#"
SELECT
COUNT(*)::BIGINT AS total_requests,
COALESCE(SUM(tokens_used), 0)::BIGINT AS total_tokens,
COALESCE(SUM(input_tokens), 0)::BIGINT AS total_input_tokens,
COALESCE(SUM(output_tokens), 0)::BIGINT AS total_output_tokens,
COUNT(DISTINCT model)::BIGINT AS model_count,
COUNT(DISTINCT request_type)::BIGINT AS request_type_count
FROM ai_usage_logs
WHERE organization_id = $1
AND created_at >= NOW() - ($2 || ' days')::interval
"#,
)
.bind(org_ctx.id)
.bind(days_window)
.fetch_one(&pool)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error al obtener resumen de ética de datos: {}", e),
)
})?;
let total_requests: i64 = totals.get("total_requests");
let total_tokens: i64 = totals.get("total_tokens");
let total_input_tokens: i64 = totals.get("total_input_tokens");
let total_output_tokens: i64 = totals.get("total_output_tokens");
let model_count: i64 = totals.get("model_count");
let request_type_count: i64 = totals.get("request_type_count");
let average_tokens_per_request = if total_requests > 0 {
total_tokens / total_requests
} else {
0
};
let event_rows = sqlx::query(
r#"
SELECT
id,
endpoint,
model,
request_type,
tokens_used,
input_tokens,
output_tokens,
request_metadata,
created_at
FROM ai_usage_logs
WHERE organization_id = $1
AND created_at >= NOW() - ($2 || ' days')::interval
ORDER BY created_at DESC
LIMIT $3
"#,
)
.bind(org_ctx.id)
.bind(days_window)
.bind(limit)
.fetch_all(&pool)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error al obtener eventos de ética de datos: {}", e),
)
})?;
let mut events = Vec::with_capacity(event_rows.len());
for row in event_rows {
let metadata: Option<Value> = row.get("request_metadata");
let has_rag_context = metadata
.as_ref()
.and_then(|m| m.get("has_rag"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
events.push(DataEthicsEventItem {
id: row.get("id"),
endpoint: row.get("endpoint"),
model: row.get("model"),
request_type: row.get("request_type"),
tokens_used: row.get("tokens_used"),
input_tokens: row.get("input_tokens"),
output_tokens: row.get("output_tokens"),
has_rag_context,
created_at: row.get("created_at"),
});
}
Ok(Json(DataEthicsSummaryResponse {
summary: DataEthicsSummary {
days_window,
total_requests,
total_tokens,
total_input_tokens,
total_output_tokens,
average_tokens_per_request,
model_count,
request_type_count,
retention_days: DEFAULT_RETENTION_DAYS,
stored_fields: vec![
"prompt".to_string(),
"response".to_string(),
"tokens_used".to_string(),
"input_tokens".to_string(),
"output_tokens".to_string(),
"model".to_string(),
"endpoint".to_string(),
"request_type".to_string(),
"request_metadata.has_rag".to_string(),
"request_metadata.lesson_id".to_string(),
"request_metadata.session_id".to_string(),
"created_at".to_string(),
],
},
events,
}))
}