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) -> Result, 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 = { 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::, _>>() .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::, _>>() .map_err(|e| e.to_string())?; Ok(map) } } /// 更新 settings:全局 key 写入 settings 表;其余写入 space_settings 表 #[tauri::command] pub fn update_settings(settings: HashMap, space_id: Option) -> 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, 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, 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 { 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, } #[derive(Debug, Serialize)] pub struct HealthReport { pub checked_at: String, pub items: Vec, 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 = (|| { 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 = 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 = (|| { 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 = 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 = (|| { let conn = pool().get().map_err(|e| e.to_string())?; let token: Option = 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::().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 = (|| { let conn = pool().get().map_err(|e| e.to_string())?; let get_val = |key: &str| -> Option { 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 = serde_json::from_str(&editors_raw) .unwrap_or_default(); if editors.is_empty() { return Ok(skip_item("editor", "编辑器路径", "未配置任何编辑器")); } // 读取活跃编辑器 ID 列表(兼容旧单值字段) let active_ids: Vec = 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 = (|| { 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, /// 未加入任何产品组且无 repo_url 的项目 pub orphaned_projects: Vec, /// runtime_logs 近 7 天内有 error/warn 的产品组 id 列表 pub groups_with_inject_warn: Vec, /// 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 = { 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 = { 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 = { 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 = 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 = contexts.iter() .filter_map(|ctx| { serde_json::from_str::(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 { 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 } }