Add complete multi-user support with role-based access control: Backend: - Add users CRUD endpoints (GET/POST/PUT/DELETE /api/users) with admin-only guards - Add display_name column to users table with ALTER TABLE migration - Fix auth to use session-based user identity (not hardcoded 'admin') - Add POST /api/auth/logout to revoke server-side sessions - Add require_admin() and extract_session() helpers for clean RBAC - Guard all mutating endpoints (clients, providers, models, settings, backup) Frontend: - Add Users management page with create/edit/reset-password/delete modals - Add role gating: hide edit/delete buttons for viewers on clients, providers, models - Settings page hides auth tokens and admin actions for viewers - Logout now revokes server session before clearing localStorage - Sidebar shows real display_name and formatted role (Administrator/Viewer) - Fix sidebar header: single logo with onerror fallback, renamed to 'LLM Proxy' - Add badge and btn-action CSS classes for role pills and action buttons - Bump cache-bust to v=7
366 lines
13 KiB
Rust
366 lines
13 KiB
Rust
use axum::{extract::State, response::Json};
|
|
use chrono;
|
|
use serde_json;
|
|
use sqlx::Row;
|
|
use std::collections::HashMap;
|
|
use tracing::warn;
|
|
|
|
use super::{ApiResponse, DashboardState};
|
|
|
|
/// Read a value from /proc files, returning None on any failure.
|
|
fn read_proc_file(path: &str) -> Option<String> {
|
|
std::fs::read_to_string(path).ok()
|
|
}
|
|
|
|
pub(super) async fn handle_system_health(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
|
let mut components = HashMap::new();
|
|
components.insert("api_server".to_string(), "online".to_string());
|
|
components.insert("database".to_string(), "online".to_string());
|
|
|
|
// Check provider health via circuit breakers
|
|
let provider_ids: Vec<String> = state
|
|
.app_state
|
|
.provider_manager
|
|
.get_all_providers()
|
|
.await
|
|
.iter()
|
|
.map(|p| p.name().to_string())
|
|
.collect();
|
|
|
|
for p_id in provider_ids {
|
|
if state
|
|
.app_state
|
|
.rate_limit_manager
|
|
.check_provider_request(&p_id)
|
|
.await
|
|
.unwrap_or(true)
|
|
{
|
|
components.insert(p_id, "online".to_string());
|
|
} else {
|
|
components.insert(p_id, "degraded".to_string());
|
|
}
|
|
}
|
|
|
|
// Read real memory usage from /proc/self/status
|
|
let memory_mb = read_proc_file("/proc/self/status")
|
|
.and_then(|s| s.lines().find(|l| l.starts_with("VmRSS:")).map(|l| l.to_string()))
|
|
.and_then(|l| l.split_whitespace().nth(1).and_then(|v| v.parse::<f64>().ok()))
|
|
.map(|kb| kb / 1024.0)
|
|
.unwrap_or(0.0);
|
|
|
|
// Get real database pool stats
|
|
let db_pool_size = state.app_state.db_pool.size();
|
|
let db_pool_idle = state.app_state.db_pool.num_idle();
|
|
|
|
Json(ApiResponse::success(serde_json::json!({
|
|
"status": "healthy",
|
|
"timestamp": chrono::Utc::now().to_rfc3339(),
|
|
"components": components,
|
|
"metrics": {
|
|
"memory_usage_mb": (memory_mb * 10.0).round() / 10.0,
|
|
"db_connections_active": db_pool_size - db_pool_idle as u32,
|
|
"db_connections_idle": db_pool_idle,
|
|
}
|
|
})))
|
|
}
|
|
|
|
/// Real system metrics from /proc (Linux only).
|
|
pub(super) async fn handle_system_metrics(
|
|
State(state): State<DashboardState>,
|
|
) -> Json<ApiResponse<serde_json::Value>> {
|
|
// --- CPU usage (aggregate across all cores) ---
|
|
// /proc/stat first line: cpu user nice system idle iowait irq softirq steal guest guest_nice
|
|
let cpu_percent = read_proc_file("/proc/stat")
|
|
.and_then(|s| {
|
|
let line = s.lines().find(|l| l.starts_with("cpu "))?.to_string();
|
|
let fields: Vec<u64> = line
|
|
.split_whitespace()
|
|
.skip(1)
|
|
.filter_map(|v| v.parse().ok())
|
|
.collect();
|
|
if fields.len() >= 4 {
|
|
let idle = fields[3];
|
|
let total: u64 = fields.iter().sum();
|
|
if total > 0 {
|
|
Some(((total - idle) as f64 / total as f64 * 100.0 * 10.0).round() / 10.0)
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.unwrap_or(0.0);
|
|
|
|
// --- Memory (system-wide from /proc/meminfo) ---
|
|
let meminfo = read_proc_file("/proc/meminfo").unwrap_or_default();
|
|
let parse_meminfo = |key: &str| -> u64 {
|
|
meminfo
|
|
.lines()
|
|
.find(|l| l.starts_with(key))
|
|
.and_then(|l| l.split_whitespace().nth(1))
|
|
.and_then(|v| v.parse::<u64>().ok())
|
|
.unwrap_or(0)
|
|
};
|
|
let mem_total_kb = parse_meminfo("MemTotal:");
|
|
let mem_available_kb = parse_meminfo("MemAvailable:");
|
|
let mem_used_kb = mem_total_kb.saturating_sub(mem_available_kb);
|
|
let mem_total_mb = mem_total_kb as f64 / 1024.0;
|
|
let mem_used_mb = mem_used_kb as f64 / 1024.0;
|
|
let mem_percent = if mem_total_kb > 0 {
|
|
(mem_used_kb as f64 / mem_total_kb as f64 * 100.0 * 10.0).round() / 10.0
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
// --- Process-specific memory (VmRSS) ---
|
|
let process_rss_mb = read_proc_file("/proc/self/status")
|
|
.and_then(|s| s.lines().find(|l| l.starts_with("VmRSS:")).map(|l| l.to_string()))
|
|
.and_then(|l| l.split_whitespace().nth(1).and_then(|v| v.parse::<f64>().ok()))
|
|
.map(|kb| (kb / 1024.0 * 10.0).round() / 10.0)
|
|
.unwrap_or(0.0);
|
|
|
|
// --- Disk usage of the data directory ---
|
|
let (disk_total_gb, disk_used_gb, disk_percent) = {
|
|
// statvfs via libc would be ideal; use df as a simple fallback
|
|
std::process::Command::new("df")
|
|
.args(["-BM", "--output=size,used,pcent", "."])
|
|
.output()
|
|
.ok()
|
|
.and_then(|o| {
|
|
let out = String::from_utf8_lossy(&o.stdout);
|
|
let line = out.lines().nth(1)?.to_string();
|
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
|
if parts.len() >= 3 {
|
|
let total = parts[0].trim_end_matches('M').parse::<f64>().unwrap_or(0.0) / 1024.0;
|
|
let used = parts[1].trim_end_matches('M').parse::<f64>().unwrap_or(0.0) / 1024.0;
|
|
let pct = parts[2].trim_end_matches('%').parse::<f64>().unwrap_or(0.0);
|
|
Some(((total * 10.0).round() / 10.0, (used * 10.0).round() / 10.0, pct))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.unwrap_or((0.0, 0.0, 0.0))
|
|
};
|
|
|
|
// --- Uptime ---
|
|
let uptime_seconds = read_proc_file("/proc/uptime")
|
|
.and_then(|s| s.split_whitespace().next().and_then(|v| v.parse::<f64>().ok()))
|
|
.unwrap_or(0.0) as u64;
|
|
|
|
// --- Load average ---
|
|
let (load_1, load_5, load_15) = read_proc_file("/proc/loadavg")
|
|
.and_then(|s| {
|
|
let parts: Vec<&str> = s.split_whitespace().collect();
|
|
if parts.len() >= 3 {
|
|
Some((
|
|
parts[0].parse::<f64>().unwrap_or(0.0),
|
|
parts[1].parse::<f64>().unwrap_or(0.0),
|
|
parts[2].parse::<f64>().unwrap_or(0.0),
|
|
))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.unwrap_or((0.0, 0.0, 0.0));
|
|
|
|
// --- Network (from /proc/net/dev, aggregate non-lo interfaces) ---
|
|
let (net_rx_bytes, net_tx_bytes) = read_proc_file("/proc/net/dev")
|
|
.map(|s| {
|
|
s.lines()
|
|
.skip(2) // skip header lines
|
|
.filter(|l| !l.trim().starts_with("lo:"))
|
|
.fold((0u64, 0u64), |(rx, tx), line| {
|
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
|
if parts.len() >= 10 {
|
|
let r = parts[1].parse::<u64>().unwrap_or(0);
|
|
let t = parts[9].parse::<u64>().unwrap_or(0);
|
|
(rx + r, tx + t)
|
|
} else {
|
|
(rx, tx)
|
|
}
|
|
})
|
|
})
|
|
.unwrap_or((0, 0));
|
|
|
|
// --- Database pool ---
|
|
let db_pool_size = state.app_state.db_pool.size();
|
|
let db_pool_idle = state.app_state.db_pool.num_idle();
|
|
|
|
// --- Active WebSocket listeners ---
|
|
let ws_listeners = state.app_state.dashboard_tx.receiver_count();
|
|
|
|
Json(ApiResponse::success(serde_json::json!({
|
|
"cpu": {
|
|
"usage_percent": cpu_percent,
|
|
"load_average": [load_1, load_5, load_15],
|
|
},
|
|
"memory": {
|
|
"total_mb": (mem_total_mb * 10.0).round() / 10.0,
|
|
"used_mb": (mem_used_mb * 10.0).round() / 10.0,
|
|
"usage_percent": mem_percent,
|
|
"process_rss_mb": process_rss_mb,
|
|
},
|
|
"disk": {
|
|
"total_gb": disk_total_gb,
|
|
"used_gb": disk_used_gb,
|
|
"usage_percent": disk_percent,
|
|
},
|
|
"network": {
|
|
"rx_bytes": net_rx_bytes,
|
|
"tx_bytes": net_tx_bytes,
|
|
},
|
|
"uptime_seconds": uptime_seconds,
|
|
"connections": {
|
|
"db_active": db_pool_size - db_pool_idle as u32,
|
|
"db_idle": db_pool_idle,
|
|
"websocket_listeners": ws_listeners,
|
|
},
|
|
"timestamp": chrono::Utc::now().to_rfc3339(),
|
|
})))
|
|
}
|
|
|
|
pub(super) async fn handle_system_logs(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
|
let pool = &state.app_state.db_pool;
|
|
|
|
let result = sqlx::query(
|
|
r#"
|
|
SELECT
|
|
id,
|
|
timestamp,
|
|
client_id,
|
|
provider,
|
|
model,
|
|
prompt_tokens,
|
|
completion_tokens,
|
|
total_tokens,
|
|
cost,
|
|
status,
|
|
error_message,
|
|
duration_ms
|
|
FROM llm_requests
|
|
ORDER BY timestamp DESC
|
|
LIMIT 100
|
|
"#,
|
|
)
|
|
.fetch_all(pool)
|
|
.await;
|
|
|
|
match result {
|
|
Ok(rows) => {
|
|
let logs: Vec<serde_json::Value> = rows
|
|
.into_iter()
|
|
.map(|row| {
|
|
serde_json::json!({
|
|
"id": row.get::<i64, _>("id"),
|
|
"timestamp": row.get::<chrono::DateTime<chrono::Utc>, _>("timestamp"),
|
|
"client_id": row.get::<String, _>("client_id"),
|
|
"provider": row.get::<String, _>("provider"),
|
|
"model": row.get::<String, _>("model"),
|
|
"tokens": row.get::<i64, _>("total_tokens"),
|
|
"cost": row.get::<f64, _>("cost"),
|
|
"status": row.get::<String, _>("status"),
|
|
"error": row.get::<Option<String>, _>("error_message"),
|
|
"duration": row.get::<i64, _>("duration_ms"),
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Json(ApiResponse::success(serde_json::json!(logs)))
|
|
}
|
|
Err(e) => {
|
|
warn!("Failed to fetch system logs: {}", e);
|
|
Json(ApiResponse::error("Failed to fetch system logs".to_string()))
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn handle_system_backup(
|
|
State(state): State<DashboardState>,
|
|
headers: axum::http::HeaderMap,
|
|
) -> Json<ApiResponse<serde_json::Value>> {
|
|
if let Err(e) = super::auth::require_admin(&state, &headers).await {
|
|
return e;
|
|
}
|
|
|
|
let pool = &state.app_state.db_pool;
|
|
let backup_id = format!("backup-{}", chrono::Utc::now().timestamp());
|
|
let backup_path = format!("data/{}.db", backup_id);
|
|
|
|
// Ensure the data directory exists
|
|
if let Err(e) = std::fs::create_dir_all("data") {
|
|
return Json(ApiResponse::error(format!("Failed to create backup directory: {}", e)));
|
|
}
|
|
|
|
// Use SQLite VACUUM INTO for a consistent backup
|
|
let result = sqlx::query(&format!("VACUUM INTO '{}'", backup_path))
|
|
.execute(pool)
|
|
.await;
|
|
|
|
match result {
|
|
Ok(_) => {
|
|
// Get backup file size
|
|
let size_bytes = std::fs::metadata(&backup_path).map(|m| m.len()).unwrap_or(0);
|
|
|
|
Json(ApiResponse::success(serde_json::json!({
|
|
"success": true,
|
|
"message": "Backup completed successfully",
|
|
"backup_id": backup_id,
|
|
"backup_path": backup_path,
|
|
"size_bytes": size_bytes,
|
|
})))
|
|
}
|
|
Err(e) => {
|
|
warn!("Database backup failed: {}", e);
|
|
Json(ApiResponse::error(format!("Backup failed: {}", e)))
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn handle_get_settings(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
|
let registry = &state.app_state.model_registry;
|
|
let provider_count = registry.providers.len();
|
|
let model_count: usize = registry.providers.values().map(|p| p.models.len()).sum();
|
|
|
|
Json(ApiResponse::success(serde_json::json!({
|
|
"server": {
|
|
"auth_tokens": state.app_state.auth_tokens.iter().map(|t| mask_token(t)).collect::<Vec<_>>(),
|
|
"version": env!("CARGO_PKG_VERSION"),
|
|
},
|
|
"registry": {
|
|
"provider_count": provider_count,
|
|
"model_count": model_count,
|
|
},
|
|
"database": {
|
|
"type": "SQLite",
|
|
}
|
|
})))
|
|
}
|
|
|
|
pub(super) async fn handle_update_settings(
|
|
State(state): State<DashboardState>,
|
|
headers: axum::http::HeaderMap,
|
|
) -> Json<ApiResponse<serde_json::Value>> {
|
|
if let Err(e) = super::auth::require_admin(&state, &headers).await {
|
|
return e;
|
|
}
|
|
|
|
Json(ApiResponse::error(
|
|
"Changing settings at runtime is not yet supported. Please update your config file and restart the server."
|
|
.to_string(),
|
|
))
|
|
}
|
|
|
|
// Helper functions
|
|
fn mask_token(token: &str) -> String {
|
|
if token.len() <= 8 {
|
|
return "*****".to_string();
|
|
}
|
|
|
|
let masked_len = token.len().min(12);
|
|
let visible_len = 4;
|
|
let mask_len = masked_len - visible_len;
|
|
|
|
format!("{}{}", "*".repeat(mask_len), &token[token.len() - visible_len..])
|
|
}
|