use axum::{extract::{State, Path, Query}, Json}; use serde::{Deserialize, Serialize}; use sqlx::Row; use crate::AppState; use super::{ApiResponse, Pagination}; /// GET /api/devices/:uid/health-score pub async fn get_health_score( State(state): State, Path(uid): Path, ) -> Json> { match crate::health::get_device_score(&state.db, &uid).await { Ok(Some(score)) => Json(ApiResponse::ok(score)), Ok(None) => Json(ApiResponse::error("No health score available")), Err(e) => Json(ApiResponse::internal_error("health score", e)), } } /// GET /api/dashboard/health-overview pub async fn health_overview( State(state): State, ) -> Json> { match crate::health::get_health_overview(&state.db).await { Ok(overview) => Json(ApiResponse::ok(overview)), Err(e) => Json(ApiResponse::internal_error("health overview", e)), } } #[derive(Debug, Deserialize)] pub struct DeviceListParams { pub status: Option, pub group: Option, pub search: Option, pub page: Option, pub page_size: Option, } #[derive(Debug, Serialize, sqlx::FromRow)] pub struct DeviceRow { pub id: i64, pub device_uid: String, pub hostname: String, pub ip_address: String, pub mac_address: Option, pub os_version: Option, pub client_version: Option, pub status: String, pub last_heartbeat: Option, pub registered_at: Option, pub group_name: Option, #[sqlx(default)] pub health_score: Option, #[sqlx(default)] pub health_level: Option, } pub async fn list( State(state): State, Query(params): Query, ) -> Json> { let limit = params.page_size.unwrap_or(20).min(100); let offset = params.page.unwrap_or(1).saturating_sub(1) * limit; // Normalize empty strings to None (Axum deserializes `status=` as Some("")) let status = params.status.as_deref().filter(|s| !s.is_empty()).map(String::from); let group = params.group.as_deref().filter(|s| !s.is_empty()).map(String::from); let search = params.search.as_deref().filter(|s| !s.is_empty()).map(String::from); let devices = sqlx::query_as::<_, DeviceRow>( "SELECT d.id, d.device_uid, d.hostname, d.ip_address, d.mac_address, d.os_version, d.client_version, d.status, d.last_heartbeat, d.registered_at, d.group_name, h.score as health_score, h.level as health_level FROM devices d LEFT JOIN device_health_scores h ON h.device_uid = d.device_uid WHERE 1=1 AND (? IS NULL OR d.status = ?) AND (? IS NULL OR d.group_name = ?) AND (? IS NULL OR d.hostname LIKE '%' || ? || '%' OR d.ip_address LIKE '%' || ? || '%') ORDER BY d.registered_at DESC LIMIT ? OFFSET ?" ) .bind(&status).bind(&status) .bind(&group).bind(&group) .bind(&search).bind(&search).bind(&search) .bind(limit).bind(offset) .fetch_all(&state.db) .await; let total: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM devices WHERE 1=1 AND (? IS NULL OR status = ?) AND (? IS NULL OR group_name = ?) AND (? IS NULL OR hostname LIKE '%' || ? || '%' OR ip_address LIKE '%' || ? || '%')" ) .bind(&status).bind(&status) .bind(&group).bind(&group) .bind(&search).bind(&search).bind(&search) .fetch_one(&state.db) .await .unwrap_or(0); match devices { Ok(rows) => Json(ApiResponse::ok(serde_json::json!({ "devices": rows, "total": total, "page": params.page.unwrap_or(1), "page_size": limit, }))), Err(e) => Json(ApiResponse::internal_error("query devices", e)), } } pub async fn get_detail( State(state): State, Path(uid): Path, ) -> Json> { let device = sqlx::query_as::<_, DeviceRow>( "SELECT id, device_uid, hostname, ip_address, mac_address, os_version, client_version, status, last_heartbeat, registered_at, group_name FROM devices WHERE device_uid = ?" ) .bind(&uid) .fetch_optional(&state.db) .await; match device { Ok(Some(d)) => Json(ApiResponse::ok(serde_json::to_value(d).unwrap_or_default())), Ok(None) => Json(ApiResponse::error("Device not found")), Err(e) => Json(ApiResponse::internal_error("query devices", e)), } } #[derive(Debug, Serialize, sqlx::FromRow)] struct StatusRow { pub cpu_usage: f64, pub memory_usage: f64, pub memory_total_mb: i64, pub disk_usage: f64, pub disk_total_mb: i64, pub network_rx_rate: i64, pub network_tx_rate: i64, pub running_procs: i32, pub top_processes: Option, pub reported_at: String, } pub async fn get_status( State(state): State, Path(uid): Path, ) -> Json> { let status = sqlx::query_as::<_, StatusRow>( "SELECT cpu_usage, memory_usage, memory_total_mb, disk_usage, disk_total_mb, network_rx_rate, network_tx_rate, running_procs, top_processes, reported_at FROM device_status WHERE device_uid = ?" ) .bind(&uid) .fetch_optional(&state.db) .await; match status { Ok(Some(s)) => { let mut val = serde_json::to_value(&s).unwrap_or_default(); // Parse top_processes JSON string back to array if let Some(tp_str) = &s.top_processes { if let Ok(tp) = serde_json::from_str::(tp_str) { val["top_processes"] = tp; } } Json(ApiResponse::ok(val)) } Ok(None) => Json(ApiResponse::error("No status data found")), Err(e) => Json(ApiResponse::internal_error("query devices", e)), } } pub async fn get_history( State(state): State, Path(uid): Path, Query(page): Query, ) -> Json> { let offset = page.offset(); let limit = page.limit(); let rows = sqlx::query( "SELECT cpu_usage, memory_usage, disk_usage, running_procs, reported_at FROM device_status_history WHERE device_uid = ? ORDER BY reported_at DESC LIMIT ? OFFSET ?" ) .bind(&uid) .bind(limit) .bind(offset) .fetch_all(&state.db) .await; match rows { Ok(records) => { let items: Vec = records.iter().map(|r| { serde_json::json!({ "cpu_usage": r.get::("cpu_usage"), "memory_usage": r.get::("memory_usage"), "disk_usage": r.get::("disk_usage"), "running_procs": r.get::("running_procs"), "reported_at": r.get::("reported_at"), }) }).collect(); Json(ApiResponse::ok(serde_json::json!({ "history": items, "page": page.page.unwrap_or(1), "page_size": limit, }))) } Err(e) => Json(ApiResponse::internal_error("query devices", e)), } } pub async fn remove( State(state): State, Path(uid): Path, ) -> Json> { // Delete device and all associated data in a transaction let mut tx = match state.db.begin().await { Ok(tx) => tx, Err(e) => return Json(ApiResponse::internal_error("begin transaction", e)), }; // Delete status history if let Err(e) = sqlx::query("DELETE FROM device_status_history WHERE device_uid = ?") .bind(&uid) .execute(&mut *tx) .await { return Json(ApiResponse::internal_error("remove device history", e)); } // Delete current status if let Err(e) = sqlx::query("DELETE FROM device_status WHERE device_uid = ?") .bind(&uid) .execute(&mut *tx) .await { return Json(ApiResponse::internal_error("remove device status", e)); } // Delete plugin-related data let cleanup_tables = [ "hardware_assets", "software_assets", "asset_changes", "usb_events", "usb_file_operations", "usage_daily", "app_usage_daily", "software_violations", "web_access_log", "popup_block_stats", "disk_encryption_status", "disk_encryption_alerts", "print_events", "clipboard_violations", "behavior_metrics", "anomaly_alerts", "device_health_scores", "patch_status", ]; for table in &cleanup_tables { // Safety: table names are hardcoded constants above, not user input. // Parameterized ? is used for device_uid. debug_assert!(table.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'), "BUG: table name contains unexpected characters: {}", table); if let Err(e) = sqlx::query(&format!("DELETE FROM {} WHERE device_uid = ?", table)) .bind(&uid) .execute(&mut *tx) .await { tracing::warn!("Failed to clean {} for device {}: {}", table, uid, e); } } // Finally delete the device itself let delete_result = sqlx::query("DELETE FROM devices WHERE device_uid = ?") .bind(&uid) .execute(&mut *tx) .await; match delete_result { Ok(r) if r.rows_affected() > 0 => { if let Err(e) = tx.commit().await { return Json(ApiResponse::internal_error("commit device deletion", e)); } // Send self-destruct command AFTER successful commit let frame = csm_protocol::Frame::new_json( csm_protocol::MessageType::ConfigUpdate, &serde_json::json!({"type": "SelfDestruct"}), ).ok(); if let Some(frame) = frame { state.clients.send_to(&uid, frame.encode()).await; } state.clients.unregister(&uid).await; tracing::info!(device_uid = %uid, "Device and all associated data deleted"); Json(ApiResponse::ok(())) } Ok(_) => Json(ApiResponse::error("Device not found")), Err(e) => Json(ApiResponse::internal_error("remove device", e)), } }