feat: 初始化项目基础架构和核心功能

- 添加项目基础结构:Cargo.toml、.gitignore、设备UID和密钥文件
- 实现前端Vue3项目结构:路由、登录页面、设备管理页面
- 添加核心协议定义(crates/protocol):设备状态、资产、USB事件等
- 实现客户端监控模块:系统状态收集、资产收集
- 实现服务端基础API和插件系统
- 添加数据库迁移脚本:设备管理、资产跟踪、告警系统等
- 实现前端设备状态展示和基本交互
- 添加使用时长统计和水印功能插件
This commit is contained in:
iven
2026-04-05 00:57:51 +08:00
commit fd6fb5cca0
87 changed files with 19576 additions and 0 deletions

View File

@@ -0,0 +1,263 @@
use axum::{extract::{State, Path, Query}, Json};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use crate::AppState;
use super::{ApiResponse, Pagination};
#[derive(Debug, Deserialize)]
pub struct DeviceListParams {
pub status: Option<String>,
pub group: Option<String>,
pub search: Option<String>,
pub page: Option<u32>,
pub page_size: Option<u32>,
}
#[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<String>,
pub os_version: Option<String>,
pub client_version: Option<String>,
pub status: String,
pub last_heartbeat: Option<String>,
pub registered_at: Option<String>,
pub group_name: Option<String>,
}
pub async fn list(
State(state): State<AppState>,
Query(params): Query<DeviceListParams>,
) -> Json<ApiResponse<serde_json::Value>> {
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 id, device_uid, hostname, ip_address, mac_address, os_version, client_version,
status, last_heartbeat, registered_at, group_name
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 '%' || ? || '%')
ORDER BY 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<AppState>,
Path(uid): Path<String>,
) -> Json<ApiResponse<serde_json::Value>> {
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<String>,
pub reported_at: String,
}
pub async fn get_status(
State(state): State<AppState>,
Path(uid): Path<String>,
) -> Json<ApiResponse<serde_json::Value>> {
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::<serde_json::Value>(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<AppState>,
Path(uid): Path<String>,
Query(page): Query<Pagination>,
) -> Json<ApiResponse<serde_json::Value>> {
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<serde_json::Value> = records.iter().map(|r| {
serde_json::json!({
"cpu_usage": r.get::<f64, _>("cpu_usage"),
"memory_usage": r.get::<f64, _>("memory_usage"),
"disk_usage": r.get::<f64, _>("disk_usage"),
"running_procs": r.get::<i32, _>("running_procs"),
"reported_at": r.get::<String, _>("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<AppState>,
Path(uid): Path<String>,
) -> Json<ApiResponse<()>> {
// If client is connected, send self-destruct command
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;
}
// 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",
"usb_events",
"usb_file_operations",
"usage_daily",
"app_usage_daily",
"software_violations",
"web_access_log",
"popup_block_stats",
];
for table in &cleanup_tables {
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));
}
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)),
}
}