- 新增 silent_cmd() 辅助函数,统一添加 CREATE_NO_WINDOW 标志,全量替换 commands/ 中的裸 std::process::Command::new 调用 - 补充 project_tools / activity_log / project_env_tools 外键从 projects 迁移到 project_workspaces 的 migrate 逻辑 - CONVENTIONS.md v1.4.2:补充 files 回填规则和批量执行完整性检查规则
648 lines
26 KiB
Rust
648 lines
26 KiB
Rust
use crate::db::{log_event, pool};
|
||
use rusqlite::params;
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{Map, Value};
|
||
use std::collections::HashMap;
|
||
|
||
/// 全局 key(不按空间隔离,属于整个应用/机器级别配置)
|
||
const GLOBAL_KEYS: &[&str] = &[
|
||
"github_client_id", "github_client_secret",
|
||
"wsl_distro", // 机器级别,与空间无关
|
||
"wsl_open_mode", // WSL 编辑器启动方式
|
||
"editors", "active_editors", "active_editor",
|
||
"v2rayn_db_path", // v2rayN 数据库路径
|
||
"v2raya_username", // v2rayA 登录用户名
|
||
"v2raya_password", // v2rayA 登录密码
|
||
"mcp_port", // MCP Server 监听端口(全局,与空间无关)
|
||
];
|
||
|
||
/// 获取 settings:
|
||
/// - 有 space_id:返回该空间的 space_settings + 全局 key(github_client_id/secret)
|
||
/// - 无 space_id:返回全局 settings 表
|
||
#[tauri::command]
|
||
pub fn get_settings(space_id: Option<String>) -> Result<HashMap<String, String>, String> {
|
||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||
|
||
if let Some(ref sid) = space_id {
|
||
// 1. 只读当前空间的 space_settings
|
||
let mut map: HashMap<String, String> = {
|
||
let mut stmt = conn
|
||
.prepare("SELECT key, value FROM space_settings WHERE space_id = ?1")
|
||
.map_err(|e| e.to_string())?;
|
||
let x = stmt
|
||
.query_map(params![sid], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
|
||
.map_err(|e| e.to_string())?
|
||
.collect::<Result<HashMap<_, _>, _>>()
|
||
.map_err(|e| e.to_string())?; x
|
||
};
|
||
// 2. 补充全局 key(应用级,非空间相关)
|
||
for key in GLOBAL_KEYS {
|
||
if let Ok(val) = conn.query_row(
|
||
"SELECT value FROM settings WHERE key = ?1",
|
||
params![key],
|
||
|row| row.get::<_, String>(0),
|
||
) {
|
||
map.insert(key.to_string(), val);
|
||
}
|
||
}
|
||
Ok(map)
|
||
} else {
|
||
// 无空间上下文:返回全局表(应用设置弹窗使用)
|
||
let mut stmt = conn.prepare("SELECT key, value FROM settings").map_err(|e| e.to_string())?;
|
||
let map = stmt
|
||
.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
|
||
.map_err(|e| e.to_string())?
|
||
.collect::<Result<HashMap<_, _>, _>>()
|
||
.map_err(|e| e.to_string())?;
|
||
Ok(map)
|
||
}
|
||
}
|
||
|
||
/// 更新 settings:全局 key 写入 settings 表;其余写入 space_settings 表
|
||
#[tauri::command]
|
||
pub fn update_settings(settings: HashMap<String, String>, space_id: Option<String>) -> Result<(), String> {
|
||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||
let mut mcp_port_changed = false;
|
||
for (k, v) in &settings {
|
||
if k == "mcp_port" {
|
||
mcp_port_changed = true;
|
||
}
|
||
if GLOBAL_KEYS.contains(&k.as_str()) || space_id.is_none() {
|
||
conn.execute(
|
||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||
params![k, v],
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
} else {
|
||
conn.execute(
|
||
"INSERT OR REPLACE INTO space_settings (space_id, key, value) VALUES (?1, ?2, ?3)",
|
||
params![space_id, k, v],
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
}
|
||
}
|
||
// 端口变更后重新注入所有产品组成员的 MCP 配置
|
||
if mcp_port_changed {
|
||
let errors = crate::mcp_inject::reinject_all_mcp();
|
||
for e in &errors {
|
||
log_event("mcp_inject", "error", &format!("端口变更重注入失败: {e}"), None);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 检测系统中已安装的常见编辑器,返回 [{name, cmd, path, found}]
|
||
#[tauri::command]
|
||
pub fn detect_editors() -> Result<Vec<Value>, String> {
|
||
// (显示名, 命令名, 常见安装路径列表)
|
||
let candidates: &[(&str, &str, &[&str])] = &[
|
||
("Cursor", "cursor", &[
|
||
r"%LOCALAPPDATA%\Programs\cursor\Cursor.exe",
|
||
r"%USERPROFILE%\AppData\Local\Programs\cursor\Cursor.exe",
|
||
]),
|
||
("VS Code", "code", &[
|
||
r"%LOCALAPPDATA%\Programs\Microsoft VS Code\Code.exe",
|
||
r"C:\Program Files\Microsoft VS Code\Code.exe",
|
||
]),
|
||
("VS Code Insiders", "code-insiders", &[
|
||
r"%LOCALAPPDATA%\Programs\Microsoft VS Code Insiders\Code - Insiders.exe",
|
||
]),
|
||
("Windsurf", "windsurf", &[
|
||
r"%LOCALAPPDATA%\Programs\windsurf\Windsurf.exe",
|
||
]),
|
||
];
|
||
|
||
let mut results = Vec::new();
|
||
|
||
for (name, cmd, fallback_paths) in candidates {
|
||
// 先用 where 命令查 PATH
|
||
let where_out = crate::silent_cmd("cmd")
|
||
.args(["/c", &format!("where {cmd} 2>nul")])
|
||
.output()
|
||
.ok();
|
||
|
||
let path_from_where = where_out
|
||
.as_ref()
|
||
.filter(|o| o.status.success())
|
||
.and_then(|o| String::from_utf8(o.stdout.clone()).ok())
|
||
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()))
|
||
.filter(|s| !s.is_empty());
|
||
|
||
// 如果 where 找不到,尝试常见安装路径(展开 %LOCALAPPDATA% 等)
|
||
let resolved_path = path_from_where.or_else(|| {
|
||
for &p in *fallback_paths {
|
||
let expanded = expand_env(p);
|
||
if std::path::Path::new(&expanded).exists() {
|
||
return Some(expanded);
|
||
}
|
||
}
|
||
None
|
||
});
|
||
|
||
let found = resolved_path.is_some();
|
||
let mut obj = Map::new();
|
||
obj.insert("name".into(), Value::String(name.to_string()));
|
||
obj.insert("cmd".into(), Value::String(cmd.to_string()));
|
||
obj.insert("path".into(), match &resolved_path {
|
||
Some(p) => Value::String(p.clone()),
|
||
None => Value::Null,
|
||
});
|
||
obj.insert("found".into(), Value::Bool(found));
|
||
results.push(Value::Object(obj));
|
||
}
|
||
|
||
Ok(results)
|
||
}
|
||
|
||
/// 搜索指定命令名对应的可执行路径
|
||
#[tauri::command]
|
||
pub fn search_editor_path(cmd: String) -> Result<Option<String>, String> {
|
||
// 先 where
|
||
let where_out = crate::silent_cmd("cmd")
|
||
.args(["/c", &format!("where {cmd} 2>nul")])
|
||
.output()
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
if where_out.status.success() {
|
||
if let Ok(s) = String::from_utf8(where_out.stdout) {
|
||
if let Some(line) = s.lines().next() {
|
||
let p = line.trim().to_string();
|
||
if !p.is_empty() {
|
||
return Ok(Some(p));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 再搜 Program Files / LocalAppData
|
||
let search_dirs = [
|
||
std::env::var("LOCALAPPDATA").unwrap_or_default(),
|
||
std::env::var("PROGRAMFILES").unwrap_or_default(),
|
||
std::env::var("PROGRAMFILES(X86)").unwrap_or_default(),
|
||
];
|
||
|
||
let extensions = ["exe", "cmd"];
|
||
for dir in &search_dirs {
|
||
if dir.is_empty() { continue; }
|
||
for ext in &extensions {
|
||
let candidate = format!("{dir}\\{cmd}.{ext}");
|
||
if std::path::Path::new(&candidate).exists() {
|
||
return Ok(Some(candidate));
|
||
}
|
||
// 一层子目录
|
||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||
for entry in entries.flatten() {
|
||
let sub = entry.path().join(format!("{cmd}.{ext}"));
|
||
if sub.exists() {
|
||
return Ok(Some(sub.to_string_lossy().into_owned()));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(None)
|
||
}
|
||
|
||
/// 检测 MCP Server 是否真正在监听(实际 HTTP ping)
|
||
#[tauri::command]
|
||
pub fn check_mcp_health() -> Result<serde_json::Value, String> {
|
||
let port = crate::mcp_server::read_port();
|
||
let url = format!("http://localhost:{}/health", port);
|
||
let start = std::time::Instant::now();
|
||
match ureq::builder()
|
||
.timeout(std::time::Duration::from_secs(2))
|
||
.build()
|
||
.get(&url)
|
||
.call()
|
||
{
|
||
Ok(_) => {
|
||
let latency_ms = start.elapsed().as_millis() as u64;
|
||
Ok(serde_json::json!({ "ok": true, "latency_ms": latency_ms, "port": port }))
|
||
}
|
||
Err(e) => Ok(serde_json::json!({ "ok": false, "error": e.to_string(), "port": port })),
|
||
}
|
||
}
|
||
|
||
// ── 健康检查 ──────────────────────────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Serialize, Deserialize)]
|
||
pub struct HealthItem {
|
||
pub id: String,
|
||
pub name: String,
|
||
/// "ok" | "warn" | "error" | "skip"
|
||
pub status: String,
|
||
pub detail: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct HealthReport {
|
||
pub checked_at: String,
|
||
pub items: Vec<HealthItem>,
|
||
pub quality: QualityMetrics,
|
||
}
|
||
|
||
fn err_item(id: &str, name: &str, detail: &str) -> HealthItem {
|
||
HealthItem { id: id.into(), name: name.into(), status: "error".into(), detail: Some(detail.into()) }
|
||
}
|
||
|
||
fn skip_item(id: &str, name: &str, detail: &str) -> HealthItem {
|
||
HealthItem { id: id.into(), name: name.into(), status: "skip".into(), detail: Some(detail.into()) }
|
||
}
|
||
|
||
/// 检测 MCP Server 是否响应
|
||
fn check_mcp() -> HealthItem {
|
||
let port = crate::mcp_server::read_port();
|
||
let url = format!("http://localhost:{}/health", port);
|
||
let start = std::time::Instant::now();
|
||
match ureq::builder().timeout(std::time::Duration::from_secs(5)).build().get(&url).call() {
|
||
Ok(_) => HealthItem {
|
||
id: "mcp".into(), name: "MCP Server".into(), status: "ok".into(),
|
||
detail: Some(format!("端口 {} 响应正常,延迟 {}ms", port, start.elapsed().as_millis())),
|
||
},
|
||
Err(e) => HealthItem {
|
||
id: "mcp".into(), name: "MCP Server".into(), status: "error".into(),
|
||
detail: Some(format!("端口 {} 无响应: {}", port, e)),
|
||
},
|
||
}
|
||
}
|
||
|
||
/// 检测已注册仓库的本地 .git 目录是否存在(仅检测关联了 git_repos 的 workspace)
|
||
fn check_git_paths() -> HealthItem {
|
||
let result: Result<HealthItem, String> = (|| {
|
||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||
let mut stmt = conn.prepare(
|
||
"SELECT win_path FROM project_workspaces WHERE win_path IS NOT NULL AND win_path != '' AND repo_id IS NOT NULL"
|
||
).map_err(|e| e.to_string())?;
|
||
let paths: Vec<String> = stmt
|
||
.query_map([], |row| row.get::<_, String>(0))
|
||
.map_err(|e| e.to_string())?
|
||
.flatten().collect();
|
||
|
||
if paths.is_empty() {
|
||
return Ok(skip_item("git_paths", "Git 仓库路径", "暂无本地路径"));
|
||
}
|
||
let total = paths.len();
|
||
let invalid: Vec<&str> = paths.iter()
|
||
.filter(|p| !std::path::Path::new(p).join(".git").exists())
|
||
.map(|p| p.as_str()).collect();
|
||
if invalid.is_empty() {
|
||
Ok(HealthItem { id: "git_paths".into(), name: "Git 仓库路径".into(), status: "ok".into(),
|
||
detail: Some(format!("全部 {} 个仓库路径有效", total)) })
|
||
} else {
|
||
let status = if invalid.len() == total { "error" } else { "warn" };
|
||
Ok(HealthItem { id: "git_paths".into(), name: "Git 仓库路径".into(), status: status.into(),
|
||
detail: Some(format!("{}/{} 个 .git 目录不存在", invalid.len(), total)) })
|
||
}
|
||
})();
|
||
result.unwrap_or_else(|e| err_item("git_paths", "Git 仓库路径", &e))
|
||
}
|
||
|
||
/// 检测项目本地路径(win_path)是否存在于文件系统
|
||
fn check_project_paths() -> HealthItem {
|
||
let result: Result<HealthItem, String> = (|| {
|
||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||
let mut stmt = conn.prepare(
|
||
"SELECT win_path FROM project_workspaces WHERE win_path IS NOT NULL AND win_path != ''"
|
||
).map_err(|e| e.to_string())?;
|
||
let paths: Vec<String> = stmt
|
||
.query_map([], |row| row.get::<_, String>(0))
|
||
.map_err(|e| e.to_string())?
|
||
.flatten().collect();
|
||
|
||
if paths.is_empty() {
|
||
return Ok(skip_item("project_paths", "项目本地路径", "暂无配置路径的项目"));
|
||
}
|
||
let total = paths.len();
|
||
let missing = paths.iter().filter(|p| !std::path::Path::new(p).exists()).count();
|
||
if missing == 0 {
|
||
Ok(HealthItem { id: "project_paths".into(), name: "项目本地路径".into(), status: "ok".into(),
|
||
detail: Some(format!("全部 {} 个路径存在", total)) })
|
||
} else {
|
||
Ok(HealthItem { id: "project_paths".into(), name: "项目本地路径".into(), status: "warn".into(),
|
||
detail: Some(format!("{}/{} 个路径不存在(可能已移动或删除)", missing, total)) })
|
||
}
|
||
})();
|
||
result.unwrap_or_else(|e| err_item("project_paths", "项目本地路径", &e))
|
||
}
|
||
|
||
/// 检测 GitHub Token 是否有效(settings.github_token)
|
||
fn check_github_token() -> HealthItem {
|
||
let result: Result<HealthItem, String> = (|| {
|
||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||
let token: Option<String> = conn.query_row(
|
||
"SELECT value FROM settings WHERE key = 'github_token'",
|
||
[], |row| row.get(0),
|
||
).ok();
|
||
|
||
let token = match token {
|
||
Some(t) if !t.trim().is_empty() => t.trim().to_string(),
|
||
_ => return Ok(skip_item("github_token", "GitHub Token", "未登录 GitHub 账户")),
|
||
};
|
||
|
||
match ureq::builder().timeout(std::time::Duration::from_secs(8)).build()
|
||
.get("https://api.github.com/user")
|
||
.set("Authorization", &format!("Bearer {}", token))
|
||
.set("User-Agent", "dev-manager-tauri")
|
||
.call()
|
||
{
|
||
Ok(resp) => {
|
||
let username = resp.into_json::<Value>().ok()
|
||
.and_then(|v| v["login"].as_str().map(|s| s.to_string()))
|
||
.unwrap_or_else(|| "未知账户".into());
|
||
Ok(HealthItem { id: "github_token".into(), name: "GitHub Token".into(), status: "ok".into(),
|
||
detail: Some(format!("Token 有效,账户: {}", username)) })
|
||
}
|
||
Err(ureq::Error::Status(401, _)) | Err(ureq::Error::Status(403, _)) =>
|
||
Ok(HealthItem { id: "github_token".into(), name: "GitHub Token".into(), status: "error".into(),
|
||
detail: Some("Token 已失效或权限不足,请重新登录 GitHub 账户".into()) }),
|
||
Err(e) => Ok(HealthItem { id: "github_token".into(), name: "GitHub Token".into(), status: "warn".into(),
|
||
detail: Some(format!("Token 已存储但无法联通 GitHub API(可能需要代理): {}", e)) }),
|
||
}
|
||
})();
|
||
result.unwrap_or_else(|e| err_item("github_token", "GitHub Token", &e))
|
||
}
|
||
|
||
/// 检测活跃编辑器路径是否可用
|
||
/// 存储结构:editors = [{id,name,path}],active_editors = ["id1","id2"]
|
||
fn check_editor() -> HealthItem {
|
||
let result: Result<HealthItem, String> = (|| {
|
||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||
|
||
let get_val = |key: &str| -> Option<String> {
|
||
conn.query_row(
|
||
"SELECT value FROM settings WHERE key = ?1",
|
||
rusqlite::params![key], |row| row.get(0),
|
||
).ok()
|
||
};
|
||
|
||
// 读取编辑器列表
|
||
let editors_raw = match get_val("editors") {
|
||
Some(s) if !s.trim().is_empty() => s,
|
||
_ => return Ok(skip_item("editor", "编辑器路径", "未配置任何编辑器")),
|
||
};
|
||
let editors: Vec<Value> = serde_json::from_str(&editors_raw)
|
||
.unwrap_or_default();
|
||
if editors.is_empty() {
|
||
return Ok(skip_item("editor", "编辑器路径", "未配置任何编辑器"));
|
||
}
|
||
|
||
// 读取活跃编辑器 ID 列表(兼容旧单值字段)
|
||
let active_ids: Vec<String> = if let Some(raw) = get_val("active_editors") {
|
||
serde_json::from_str(&raw).unwrap_or_default()
|
||
} else if let Some(old_id) = get_val("active_editor") {
|
||
if old_id.is_empty() { vec![] } else { vec![old_id] }
|
||
} else {
|
||
vec![]
|
||
};
|
||
|
||
if active_ids.is_empty() {
|
||
return Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "warn".into(),
|
||
detail: Some("已添加编辑器但未勾选活跃编辑器,请前往设置页勾选至少一个".into()) });
|
||
}
|
||
|
||
// 取第一个活跃编辑器检测路径
|
||
let first_id = &active_ids[0];
|
||
let entry = editors.iter().find(|e| e["id"].as_str() == Some(first_id));
|
||
let (name, path) = match entry {
|
||
Some(e) => (
|
||
e["name"].as_str().unwrap_or("编辑器").to_string(),
|
||
e["path"].as_str().unwrap_or("").to_string(),
|
||
),
|
||
None => return Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "warn".into(),
|
||
detail: Some(format!("活跃编辑器 ID `{}` 在编辑器列表中未找到,请重新配置", first_id)) }),
|
||
};
|
||
|
||
if path.is_empty() {
|
||
return Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "warn".into(),
|
||
detail: Some(format!("{} 路径为空,请在设置页编辑该编辑器条目补全路径", name)) });
|
||
}
|
||
if std::path::Path::new(&path).exists() {
|
||
let extra = if active_ids.len() > 1 {
|
||
format!("(共 {} 个活跃编辑器)", active_ids.len())
|
||
} else {
|
||
String::new()
|
||
};
|
||
Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "ok".into(),
|
||
detail: Some(format!("{} 路径有效{}", name, extra)) })
|
||
} else {
|
||
Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "error".into(),
|
||
detail: Some(format!("{} 可执行文件不存在: {},请在设置页更新路径", name, path)) })
|
||
}
|
||
})();
|
||
result.unwrap_or_else(|e| err_item("editor", "编辑器路径", &e))
|
||
}
|
||
|
||
/// 检测 SQLite 读写延迟
|
||
fn check_db_latency() -> HealthItem {
|
||
let result: Result<HealthItem, String> = (|| {
|
||
let conn = pool().get().map_err(|e| e.to_string())?;
|
||
let start = std::time::Instant::now();
|
||
conn.execute_batch("
|
||
BEGIN;
|
||
INSERT OR REPLACE INTO settings (key, value) VALUES ('__health_probe__', '1');
|
||
DELETE FROM settings WHERE key = '__health_probe__';
|
||
COMMIT;
|
||
").map_err(|e| e.to_string())?;
|
||
let ms = start.elapsed().as_millis();
|
||
let status = if ms < 100 { "ok" } else { "warn" };
|
||
Ok(HealthItem { id: "db_latency".into(), name: "数据库延迟".into(), status: status.into(),
|
||
detail: Some(format!("读写延迟 {}ms{}", ms, if ms >= 100 { "(偏高)" } else { "" })) })
|
||
})();
|
||
result.unwrap_or_else(|e| err_item("db_latency", "数据库延迟", &e))
|
||
}
|
||
|
||
// ── 质量指标 ──────────────────────────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct DormantProject {
|
||
pub id: String,
|
||
pub name: String,
|
||
pub days_since_push: i64,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct OrphanedProject {
|
||
pub id: String,
|
||
pub name: String,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct QualityMetrics {
|
||
/// last_push_at 超过 30 天且有 repo_url 的项目
|
||
pub dormant_projects: Vec<DormantProject>,
|
||
/// 未加入任何产品组且无 repo_url 的项目
|
||
pub orphaned_projects: Vec<OrphanedProject>,
|
||
/// runtime_logs 近 7 天内有 error/warn 的产品组 id 列表
|
||
pub groups_with_inject_warn: Vec<String>,
|
||
/// win_path 和 wsl_path 均为空的项目数
|
||
pub projects_without_path: i64,
|
||
}
|
||
|
||
impl Default for QualityMetrics {
|
||
fn default() -> Self {
|
||
Self {
|
||
dormant_projects: vec![],
|
||
orphaned_projects: vec![],
|
||
groups_with_inject_warn: vec![],
|
||
projects_without_path: 0,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn collect_quality_metrics() -> QualityMetrics {
|
||
let conn = match pool().get() {
|
||
Ok(c) => c,
|
||
Err(_) => return QualityMetrics::default(),
|
||
};
|
||
|
||
// 1. 休眠项目:last_push_at 超 30 天 且 有 repo_url
|
||
let dormant_projects: Vec<DormantProject> = {
|
||
let stmt = conn.prepare(
|
||
"SELECT pw.id, pp.name,
|
||
CAST(julianday('now') - julianday(pw.last_push_at) AS INTEGER) as days
|
||
FROM project_workspaces pw
|
||
JOIN project_profiles pp ON pp.id = pw.profile_id
|
||
WHERE (pp.repo_url IS NOT NULL AND pp.repo_url != '')
|
||
AND pw.last_push_at IS NOT NULL AND pw.last_push_at != ''
|
||
AND julianday('now') - julianday(pw.last_push_at) > 30
|
||
ORDER BY days DESC"
|
||
);
|
||
match stmt {
|
||
Err(_) => vec![],
|
||
Ok(mut stmt) => stmt
|
||
.query_map([], |row| {
|
||
Ok(DormantProject {
|
||
id: row.get(0)?,
|
||
name: row.get(1)?,
|
||
days_since_push: row.get(2)?,
|
||
})
|
||
})
|
||
.map(|rows| rows.flatten().collect())
|
||
.unwrap_or_default(),
|
||
}
|
||
};
|
||
|
||
// 2. 孤立项目:无 repo_url 且不在任何产品组
|
||
let orphaned_projects: Vec<OrphanedProject> = {
|
||
let stmt = conn.prepare(
|
||
"SELECT pw.id, pp.name
|
||
FROM project_workspaces pw
|
||
JOIN project_profiles pp ON pp.id = pw.profile_id
|
||
WHERE (pp.repo_url IS NULL OR pp.repo_url = '')
|
||
AND pw.id NOT IN (SELECT project_id FROM project_group_map)"
|
||
);
|
||
match stmt {
|
||
Err(_) => vec![],
|
||
Ok(mut stmt) => stmt
|
||
.query_map([], |row| {
|
||
Ok(OrphanedProject { id: row.get(0)?, name: row.get(1)? })
|
||
})
|
||
.map(|rows| rows.flatten().collect())
|
||
.unwrap_or_default(),
|
||
}
|
||
};
|
||
|
||
// 3. 近 7 天有注入 error/warn 的产品组(解析 context JSON 取 group_id)
|
||
let groups_with_inject_warn: Vec<String> = {
|
||
let stmt = conn.prepare(
|
||
"SELECT DISTINCT context FROM runtime_logs
|
||
WHERE category = 'mcp_inject'
|
||
AND level IN ('error', 'warn')
|
||
AND ts > datetime('now', '-7 days')
|
||
AND context IS NOT NULL"
|
||
);
|
||
let contexts: Vec<String> = match stmt {
|
||
Err(_) => vec![],
|
||
Ok(mut stmt) => stmt
|
||
.query_map([], |row| row.get::<_, String>(0))
|
||
.map(|rows| rows.flatten().collect())
|
||
.unwrap_or_default(),
|
||
};
|
||
|
||
let mut group_ids: Vec<String> = contexts.iter()
|
||
.filter_map(|ctx| {
|
||
serde_json::from_str::<Value>(ctx).ok()
|
||
.and_then(|v| v["group_id"].as_str().map(|s| s.to_string()))
|
||
})
|
||
.collect();
|
||
group_ids.sort();
|
||
group_ids.dedup();
|
||
group_ids
|
||
};
|
||
|
||
// 4. 无路径项目数
|
||
let projects_without_path: i64 = conn.query_row(
|
||
"SELECT COUNT(*) FROM project_workspaces
|
||
WHERE (win_path IS NULL OR win_path = '')
|
||
AND (wsl_path IS NULL OR wsl_path = '')",
|
||
[], |row| row.get(0),
|
||
).unwrap_or(0);
|
||
|
||
QualityMetrics { dormant_projects, orphaned_projects, groups_with_inject_warn, projects_without_path }
|
||
}
|
||
|
||
/// 并行执行全部健康检查,返回结构化报告(内部复用入口,mcp_server 也可调用)
|
||
pub fn collect_health_report() -> HealthReport {
|
||
use std::thread;
|
||
let checked_at = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
|
||
|
||
let h_mcp = thread::spawn(check_mcp);
|
||
let h_git = thread::spawn(check_git_paths);
|
||
let h_proj = thread::spawn(check_project_paths);
|
||
let h_github = thread::spawn(check_github_token);
|
||
let h_editor = thread::spawn(check_editor);
|
||
let h_db = thread::spawn(check_db_latency);
|
||
let h_quality = thread::spawn(collect_quality_metrics);
|
||
|
||
let items = vec![
|
||
h_mcp .join().unwrap_or_else(|_| err_item("mcp", "MCP Server", "检测线程异常")),
|
||
h_git .join().unwrap_or_else(|_| err_item("git_paths", "Git 仓库路径", "检测线程异常")),
|
||
h_proj .join().unwrap_or_else(|_| err_item("project_paths", "项目本地路径", "检测线程异常")),
|
||
h_github.join().unwrap_or_else(|_| err_item("github_token", "GitHub Token", "检测线程异常")),
|
||
h_editor.join().unwrap_or_else(|_| err_item("editor", "编辑器路径", "检测线程异常")),
|
||
h_db .join().unwrap_or_else(|_| err_item("db_latency", "数据库延迟", "检测线程异常")),
|
||
];
|
||
let quality = h_quality.join().unwrap_or_default();
|
||
|
||
HealthReport { checked_at, items, quality }
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn run_health_check() -> Result<HealthReport, String> {
|
||
Ok(collect_health_report())
|
||
}
|
||
|
||
fn expand_env(s: &str) -> String {
|
||
let mut result = s.to_string();
|
||
for (key, val) in std::env::vars() {
|
||
result = result.replace(&format!("%{key}%"), &val);
|
||
}
|
||
result
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn global_keys_contains_auth_and_infra() {
|
||
assert!(GLOBAL_KEYS.contains(&"github_client_id"));
|
||
assert!(GLOBAL_KEYS.contains(&"github_client_secret"));
|
||
assert!(GLOBAL_KEYS.contains(&"mcp_port"));
|
||
assert!(GLOBAL_KEYS.contains(&"wsl_distro"));
|
||
}
|
||
|
||
#[test]
|
||
fn space_specific_keys_not_in_global() {
|
||
// editor / dev_mode 是空间维度的配置,不应进入全局表
|
||
assert!(!GLOBAL_KEYS.contains(&"editor"));
|
||
assert!(!GLOBAL_KEYS.contains(&"dev_mode"));
|
||
}
|
||
|
||
#[test]
|
||
fn settings_routing_via_db() {
|
||
crate::db::conn_with_schema(); // 仅验证 schema 可正常建立,不依赖全局 pool
|
||
}
|
||
}
|