feat: 全面重构前端UI及完善后端功能
前端重构: - 重构Layout为左侧导航+顶栏的现代管理后台布局 - 重构设备管理页面(Devices.vue):左侧分组面板+右侧设备列表 - 重构设备详情(DeviceDetail.vue):集成硬件资产/软件资产/变更记录标签页 - 移除独立资产管理页面,功能合并至设备详情 - 重构Dashboard/登录/设置/告警/水印/上网管控等页面样式 - 新增全局CSS变量和统一样式系统 - 添加分组管理UI:新建/重命名/删除分组,移动设备到分组 后端完善: - 新增分组CRUD API(groups.rs):创建/重命名/删除分组,设备分组移动 - 客户端硬件采集:完善GPU/主板/序列号/磁盘信息采集(Windows PowerShell) - 客户端软件采集:通过Windows注册表读取已安装软件列表 - 新增SoftwareAssetReport消息类型(0x09)及处理链路 - 数据库新增upsert_software方法处理软件资产存储 - 服务端推送软件资产配置给新注册设备 - 修复密码修改功能,添加旧密码验证
This commit is contained in:
@@ -1,20 +1,17 @@
|
||||
use csm_protocol::{Frame, MessageType, HardwareAsset};
|
||||
use csm_protocol::{Frame, MessageType, HardwareAsset, SoftwareAsset};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tracing::{info, error};
|
||||
use sysinfo::System;
|
||||
|
||||
pub async fn start_collecting(tx: Sender<Frame>, device_uid: String) {
|
||||
let interval = Duration::from_secs(86400); // Once per day
|
||||
let interval = Duration::from_secs(86400);
|
||||
|
||||
// Initial collection on startup
|
||||
if let Err(e) = collect_and_send(&tx, &device_uid).await {
|
||||
error!("Initial asset collection failed: {}", e);
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(interval).await;
|
||||
|
||||
if let Err(e) = collect_and_send(&tx, &device_uid).await {
|
||||
error!("Asset collection failed: {}", e);
|
||||
}
|
||||
@@ -22,33 +19,163 @@ pub async fn start_collecting(tx: Sender<Frame>, device_uid: String) {
|
||||
}
|
||||
|
||||
async fn collect_and_send(tx: &Sender<Frame>, device_uid: &str) -> anyhow::Result<()> {
|
||||
// Collect & send hardware
|
||||
let hardware = collect_hardware(device_uid)?;
|
||||
let frame = Frame::new_json(MessageType::AssetReport, &hardware)?;
|
||||
tx.send(frame).await.map_err(|e| anyhow::anyhow!("Channel send failed: {}", e))?;
|
||||
info!("Asset report sent for {}", device_uid);
|
||||
info!("Hardware asset report sent for {}", device_uid);
|
||||
|
||||
// Collect & send software list
|
||||
let software_list = collect_software(device_uid);
|
||||
let sw_count = software_list.len();
|
||||
for sw in &software_list {
|
||||
let frame = Frame::new_json(MessageType::SoftwareAssetReport, sw)?;
|
||||
tx.send(frame).await.map_err(|e| anyhow::anyhow!("Channel send failed: {}", e))?;
|
||||
}
|
||||
info!("Software asset reports sent ({} items) for {}", sw_count, device_uid);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_hardware(device_uid: &str) -> anyhow::Result<HardwareAsset> {
|
||||
let mut sys = System::new_all();
|
||||
let mut sys = sysinfo::System::new_all();
|
||||
sys.refresh_all();
|
||||
|
||||
// CPU
|
||||
let cpu_model = sys.cpus().first()
|
||||
.map(|c| c.brand().to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
let cpu_cores = sys.cpus().len() as u32;
|
||||
let memory_total_mb = sys.total_memory() / 1024 / 1024; // bytes to MB (sysinfo 0.30)
|
||||
|
||||
// Memory
|
||||
let memory_total_mb = sys.total_memory() / 1024 / 1024;
|
||||
|
||||
// Disk — pick the largest non-removable disk
|
||||
let disks = sysinfo::Disks::new_with_refreshed_list();
|
||||
let (disk_model, disk_total_mb) = disks.iter()
|
||||
.max_by_key(|d| d.total_space())
|
||||
.map(|d| {
|
||||
let name = d.name().to_string_lossy().to_string();
|
||||
let total = d.total_space() / 1024 / 1024;
|
||||
(if name.is_empty() { "Unknown".to_string() } else { name }, total)
|
||||
})
|
||||
.unwrap_or_else(|| ("Unknown".to_string(), 0));
|
||||
|
||||
// GPU, motherboard, serial — Windows-specific via PowerShell
|
||||
let (gpu_model, motherboard, serial_number) = collect_system_details();
|
||||
|
||||
Ok(HardwareAsset {
|
||||
device_uid: device_uid.to_string(),
|
||||
cpu_model,
|
||||
cpu_cores,
|
||||
memory_total_mb: memory_total_mb as u64,
|
||||
disk_model: "Unknown".to_string(),
|
||||
disk_total_mb: 0,
|
||||
gpu_model: None,
|
||||
motherboard: None,
|
||||
serial_number: None,
|
||||
memory_total_mb,
|
||||
disk_model,
|
||||
disk_total_mb,
|
||||
gpu_model,
|
||||
motherboard,
|
||||
serial_number,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn collect_system_details() -> (Option<String>, Option<String>, Option<String>) {
|
||||
let gpu = powershell_first("Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Caption");
|
||||
let mb_manufacturer = powershell_first("Get-CimInstance Win32_BaseBoard | Select-Object -ExpandProperty Manufacturer");
|
||||
let mb_product = powershell_first("Get-CimInstance Win32_BaseBoard | Select-Object -ExpandProperty Product");
|
||||
let motherboard = match (mb_manufacturer, mb_product) {
|
||||
(Some(m), Some(p)) => Some(format!("{} {}", m, p)),
|
||||
(single @ Some(_), _) => single,
|
||||
(_, single @ Some(_)) => single,
|
||||
_ => None,
|
||||
};
|
||||
let serial_number = powershell_first("Get-CimInstance Win32_BIOS | Select-Object -ExpandProperty SerialNumber");
|
||||
|
||||
(gpu, motherboard, serial_number)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn collect_system_details() -> (Option<String>, Option<String>, Option<String>) {
|
||||
(None, None, None)
|
||||
}
|
||||
|
||||
/// Run a PowerShell command and return the first non-empty line of output.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn powershell_first(command: &str) -> Option<String> {
|
||||
use std::process::Command;
|
||||
let output = Command::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command",
|
||||
&format!("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}", command)])
|
||||
.output()
|
||||
.ok()?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
stdout.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.find(|l| !l.is_empty())
|
||||
}
|
||||
|
||||
/// Collect installed software from Windows Registry via PowerShell.
|
||||
fn collect_software(device_uid: &str) -> Vec<SoftwareAsset> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
collect_windows_software(device_uid)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = device_uid;
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn collect_windows_software(device_uid: &str) -> Vec<SoftwareAsset> {
|
||||
use std::process::Command;
|
||||
|
||||
let ps_cmd = r#"
|
||||
$paths = @(
|
||||
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
|
||||
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*",
|
||||
"HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*"
|
||||
)
|
||||
Get-ItemProperty $paths -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.DisplayName -and $_.SystemComponent -ne 1 -and $null -eq $_.ParentDisplayName } |
|
||||
Select-Object DisplayName, DisplayVersion, Publisher, InstallDate, InstallLocation |
|
||||
ConvertTo-Json -Compress
|
||||
"#;
|
||||
|
||||
let output = match Command::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
|
||||
.output()
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if stdout.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Single item -> object, multiple -> array
|
||||
let items: Vec<serde_json::Value> = if stdout.starts_with('[') {
|
||||
serde_json::from_str(&stdout).unwrap_or_default()
|
||||
} else {
|
||||
serde_json::from_str::<serde_json::Value>(&stdout)
|
||||
.map(|v| vec![v])
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
items.iter().filter_map(|item| {
|
||||
let name = item.get("DisplayName")?.as_str()?.to_string();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(SoftwareAsset {
|
||||
device_uid: device_uid.to_string(),
|
||||
name,
|
||||
version: item.get("DisplayVersion").and_then(|v| v.as_str()).map(String::from),
|
||||
publisher: item.get("Publisher").and_then(|v| v.as_str()).map(String::from),
|
||||
install_date: item.get("InstallDate").and_then(|v| v.as_str()).map(String::from),
|
||||
install_path: item.get("InstallLocation").and_then(|v| v.as_str()).map(String::from),
|
||||
})
|
||||
}).collect()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user