use serde::Serialize;
use std::collections::BTreeMap;
use std::path::Path;
use std::sync::Mutex;
use std::time::Instant;
use reqwest::Client;
use crate::cm_config::LlmHttpAuthMode;
#[derive(Debug, Clone, Serialize)]
pub struct HealthCheckItem {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct HealthReport {
pub status: String,
pub checks: BTreeMap<String, HealthCheckItem>,
}
#[derive(Clone)]
pub struct CachedLlmModelsHealthProbe {
pub checked_at: Instant,
pub item: HealthCheckItem,
}
pub struct LlmModelsEndpointProbeParams<'a> {
pub enabled: bool,
pub cache_secs: u64,
pub cache_cell: &'a Mutex<Option<CachedLlmModelsHealthProbe>>,
pub client: &'a Client,
pub api_base: &'a str,
pub api_key: &'a str,
pub auth_mode: LlmHttpAuthMode,
}
fn health_report_status(checks: &BTreeMap<String, HealthCheckItem>) -> String {
let required_ok = checks
.get("workspace_writable")
.map(|c| c.ok)
.unwrap_or(false);
if required_ok && checks.values().all(|c| c.ok) {
"ok".to_string()
} else {
"degraded".to_string()
}
}
pub async fn append_llm_models_endpoint_probe(
report: &mut HealthReport,
p: LlmModelsEndpointProbeParams<'_>,
) {
if !p.enabled {
return;
}
let item = if p.auth_mode == LlmHttpAuthMode::Bearer && p.api_key.trim().is_empty() {
HealthCheckItem {
ok: true,
detail: Some("跳过(bearer 且无 API_KEY)".to_string()),
}
} else {
let cache_ttl = std::time::Duration::from_secs(p.cache_secs.max(1));
let from_cache = p.cache_cell.lock().ok().and_then(|guard| {
guard.as_ref().and_then(|c| {
if c.checked_at.elapsed() < cache_ttl {
Some(c.item.clone())
} else {
None
}
})
});
if let Some(cached) = from_cache {
cached
} else {
let fresh =
probe_llm_models_endpoint(p.client, p.api_base, p.api_key, p.auth_mode).await;
if let Ok(mut guard) = p.cache_cell.lock() {
*guard = Some(CachedLlmModelsHealthProbe {
checked_at: Instant::now(),
item: fresh.clone(),
});
}
fresh
}
};
report
.checks
.insert("llm_models_endpoint".to_string(), item);
report.status = health_report_status(&report.checks);
}
async fn probe_llm_models_endpoint(
client: &Client,
api_base: &str,
api_key: &str,
auth_mode: LlmHttpAuthMode,
) -> HealthCheckItem {
match crate::cm_llm::fetch_models_report(client, api_base, api_key, auth_mode).await {
Ok(rep) => {
let ok = (200..300).contains(&rep.http_status)
&& rep.note.is_none()
&& !rep.model_ids.is_empty();
let detail = if ok {
Some(format!(
"HTTP {} · {}ms · {} 个模型 id(仅列表,无 completion)",
rep.http_status,
rep.elapsed_ms,
rep.model_ids.len()
))
} else {
Some(
rep.note
.unwrap_or_else(|| format!("HTTP {}(无可用模型 id)", rep.http_status)),
)
};
HealthCheckItem { ok, detail }
}
Err(e) => HealthCheckItem {
ok: false,
detail: Some(format!("请求失败: {e}")),
},
}
}
pub async fn build_health_report(
workspace_dir: &Path,
include_frontend_static: bool,
) -> HealthReport {
let mut checks: BTreeMap<String, HealthCheckItem> = BTreeMap::new();
if include_frontend_static {
let static_dir = crate::cm_internal::web_static_dir::resolve_web_static_dir();
let static_ok = static_dir.is_dir();
checks.insert(
"frontend_static_dir".to_string(),
HealthCheckItem {
ok: static_ok,
detail: if static_ok {
None
} else {
Some(format!("目录不存在:{}", static_dir.display()))
},
},
);
}
let work_dir = workspace_dir.to_path_buf();
let writable = tokio::task::spawn_blocking({
let work_dir = work_dir.clone();
move || {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let pid = std::process::id();
let p = work_dir.join(format!(".crabmate_healthcheck_{}_{}.tmp", pid, ts));
match std::fs::write(&p, b"") {
Ok(()) => {
let _ = std::fs::remove_file(&p);
Ok(())
}
Err(e) => Err(e),
}
}
})
.await
.ok()
.and_then(|r| r.err())
.map(|e| format!("不可写:{}({})", work_dir.display(), e));
checks.insert(
"workspace_writable".to_string(),
HealthCheckItem {
ok: writable.is_none(),
detail: writable,
},
);
let deps = tokio::task::spawn_blocking(|| {
fn check_cmd(cmd: &str, args: &[&str]) -> Result<String, String> {
match std::process::Command::new(cmd).args(args).output() {
Ok(out) => {
let status = out.status.code().unwrap_or(-1);
if status == 0 {
let s = if !out.stdout.is_empty() {
String::from_utf8_lossy(&out.stdout).trim().to_string()
} else {
String::from_utf8_lossy(&out.stderr).trim().to_string()
};
Ok(if s.is_empty() { "ok".to_string() } else { s })
} else {
Err(format!("exit={}", status))
}
}
Err(e) => Err(e.to_string()),
}
}
let mut m = BTreeMap::new();
m.insert("rustc", check_cmd("rustc", &["-V"]));
m.insert("cargo", check_cmd("cargo", &["-V"]));
let bc = check_cmd("bc", &["--version"])
.or_else(|_| check_cmd("bc", &["-v"]))
.or_else(|_| check_cmd("bc", &["-V"]));
m.insert("bc", bc);
m.insert("rustfmt", check_cmd("rustfmt", &["--version"]));
m.insert("clang_format", check_cmd("clang-format", &["--version"]));
m.insert("cmake", check_cmd("cmake", &["--version"]));
m.insert("ctest", check_cmd("ctest", &["--version"]));
m.insert("cxxfilt", check_cmd("c++filt", &["--version"]));
m.insert("objdump", check_cmd("objdump", &["--version"]));
m.insert("nm", check_cmd("nm", &["--version"]));
m.insert("readelf", check_cmd("readelf", &["--version"]));
m.insert("strings_binutils", check_cmd("strings", &["--version"]));
m.insert("size", check_cmd("size", &["--version"]));
m.insert("ar", check_cmd("ar", &["--version"]));
m.insert("npm", check_cmd("npm", &["--version"]));
m.insert("python3", check_cmd("python3", &["--version"]));
m.insert("mvn", check_cmd("mvn", &["--version"]));
m.insert("gradle", check_cmd("gradle", &["--version"]));
m.insert("docker", check_cmd("docker", &["--version"]));
m.insert("podman", check_cmd("podman", &["--version"]));
m.insert("gh", check_cmd("gh", &["version"]));
m.insert("typos", check_cmd("typos", &["--version"]));
m.insert("codespell", check_cmd("codespell", &["--version"]));
m.insert("ast_grep", check_cmd("ast-grep", &["--version"]));
m.insert(
"cargo_machete",
check_cmd("cargo", &["machete", "--version"]),
);
m.insert("cargo_udeps", check_cmd("cargo", &["udeps", "--version"]));
m.insert("shellcheck", check_cmd("shellcheck", &["--version"]));
m.insert("cppcheck", check_cmd("cppcheck", &["--version"]));
m.insert("semgrep", check_cmd("semgrep", &["--version"]));
m.insert("hadolint", check_cmd("hadolint", &["--version"]));
m.insert("bandit", check_cmd("bandit", &["--version"]));
m.insert("lizard", check_cmd("lizard", &["--version"]));
m
})
.await
.ok()
.unwrap_or_default();
for (k, v) in deps {
let key = match k {
"rustc" => "dep_toolchain_rustc",
"cargo" => "dep_toolchain_cargo",
"bc" => "dep_bc",
"rustfmt" => "dep_rustfmt",
"clang_format" => "dep_clang_format",
"cmake" => "dep_cmake",
"ctest" => "dep_ctest",
"cxxfilt" => "dep_cxxfilt",
"objdump" => "dep_objdump",
"nm" => "dep_nm",
"readelf" => "dep_readelf",
"strings_binutils" => "dep_strings_binutils",
"size" => "dep_size",
"ar" => "dep_ar",
"npm" => "dep_npm",
"python3" => "dep_python3",
"mvn" => "dep_mvn",
"gradle" => "dep_gradle",
"docker" => "dep_docker_cli",
"podman" => "dep_podman",
"gh" => "dep_gh",
"typos" => "dep_typos",
"codespell" => "dep_codespell",
"ast_grep" => "dep_ast_grep",
"cargo_machete" => "dep_cargo_machete",
"cargo_udeps" => "dep_cargo_udeps",
"shellcheck" => "dep_shellcheck",
"cppcheck" => "dep_cppcheck",
"semgrep" => "dep_semgrep",
"hadolint" => "dep_hadolint",
"bandit" => "dep_bandit",
"lizard" => "dep_lizard",
_ => continue,
};
checks.insert(key.to_string(), {
let raw = crate::cm_internal::health_dep_compat::health_item_from_cmd_result(k, v);
HealthCheckItem {
ok: raw.ok,
detail: raw.detail,
}
});
}
HealthReport {
status: health_report_status(&checks),
checks,
}
}
fn dep_check_label(check_key: &str) -> String {
let name = check_key.strip_prefix("dep_").unwrap_or(check_key);
match name {
"toolchain_rustc" => "rustc".to_string(),
"toolchain_cargo" => "cargo".to_string(),
"clang_format" => "clang-format".to_string(),
"strings_binutils" => "strings".to_string(),
"docker_cli" => "docker".to_string(),
"ast_grep" => "ast-grep".to_string(),
"cargo_machete" => "cargo machete".to_string(),
"cargo_udeps" => "cargo udeps".to_string(),
other => other.replace('_', "-"),
}
}
fn is_missing_cli_detail(detail: &str) -> bool {
detail.contains("No such file") || detail.contains("not found") || detail.starts_with("exit=")
}
fn is_version_incompat_detail(detail: &str) -> bool {
detail.contains("低于建议最低")
}
struct StartupHealthBuckets {
config: Vec<String>,
workspace: Vec<String>,
toolchain: Vec<String>,
optional_missing: Vec<String>,
other: Vec<String>,
}
fn classify_startup_health_failures(report: &HealthReport) -> StartupHealthBuckets {
let mut b = StartupHealthBuckets {
config: Vec::new(),
workspace: Vec::new(),
toolchain: Vec::new(),
optional_missing: Vec::new(),
other: Vec::new(),
};
for (check_key, item) in &report.checks {
if item.ok {
continue;
}
let detail = item.detail.as_deref().unwrap_or("未通过");
if check_key == "frontend_static_dir" {
b.config.push(format!("{check_key}: {detail}"));
} else if check_key == "workspace_writable" {
b.workspace.push(format!("{check_key}: {detail}"));
} else if check_key.starts_with("dep_toolchain_") {
b.toolchain
.push(format!("{} — {detail}", dep_check_label(check_key)));
} else if check_key.starts_with("dep_") {
if is_version_incompat_detail(detail) {
b.toolchain
.push(format!("{} — {detail}", dep_check_label(check_key)));
} else if is_missing_cli_detail(detail) {
b.optional_missing.push(dep_check_label(check_key));
} else {
b.other
.push(format!("{} — {detail}", dep_check_label(check_key)));
}
} else {
b.other.push(format!("{check_key}: {detail}"));
}
}
b.optional_missing.sort_unstable();
b.optional_missing.dedup();
b
}
pub fn format_startup_health_summary(report: &HealthReport) -> String {
let b = classify_startup_health_failures(report);
let mut sections: Vec<String> = Vec::new();
if !b.config.is_empty() {
sections.push(format!("【配置】{}", b.config.join(";")));
}
if !b.workspace.is_empty() {
sections.push(format!("【工作区】{}", b.workspace.join(";")));
}
if !b.toolchain.is_empty() {
sections.push(format!("【工具链】{}", b.toolchain.join(";")));
}
if !b.optional_missing.is_empty() {
sections.push(format!(
"【可选 CLI 未安装 {} 项】{}",
b.optional_missing.len(),
b.optional_missing.join("、")
));
}
if !b.other.is_empty() {
sections.push(format!("【其他】{}", b.other.join(";")));
}
if sections.is_empty() {
"存在未通过的检查项(详情见 GET /health)".to_string()
} else {
sections.join(";")
}
}
pub fn log_startup_dep_compat_summary(report: &HealthReport) {
if report.status == "ok" {
tracing::info!(target: "crabmate", "启动健康检查: 全部通过 (status=ok)");
return;
}
let summary = format_startup_health_summary(report);
tracing::warn!(
target: "crabmate",
status = %report.status,
"启动健康检查未通过: {summary}(完整项见 GET /health)"
);
}
#[cfg(test)]
mod health_status_tests {
use super::{
HealthCheckItem, HealthReport, format_startup_health_summary, health_report_status,
};
use std::collections::BTreeMap;
#[test]
fn status_ok_when_required_and_all_checks_ok() {
let mut checks = BTreeMap::new();
checks.insert(
"workspace_writable".into(),
HealthCheckItem {
ok: true,
detail: None,
},
);
checks.insert(
"dep_bc".into(),
HealthCheckItem {
ok: true,
detail: Some("ok".into()),
},
);
assert_eq!(health_report_status(&checks), "ok");
}
#[test]
fn status_degraded_when_optional_dep_fails() {
let mut checks = BTreeMap::new();
checks.insert(
"workspace_writable".into(),
HealthCheckItem {
ok: true,
detail: None,
},
);
checks.insert(
"dep_bc".into(),
HealthCheckItem {
ok: false,
detail: Some("missing".into()),
},
);
assert_eq!(health_report_status(&checks), "degraded");
}
#[test]
fn startup_summary_groups_optional_missing_and_config() {
let mut checks = BTreeMap::new();
checks.insert(
"frontend_static_dir".into(),
HealthCheckItem {
ok: false,
detail: Some("目录不存在:/tmp/missing-frontend-dist".into()),
},
);
checks.insert(
"workspace_writable".into(),
HealthCheckItem {
ok: true,
detail: None,
},
);
checks.insert(
"dep_bandit".into(),
HealthCheckItem {
ok: false,
detail: Some("No such file or directory (os error 2)".into()),
},
);
checks.insert(
"dep_shellcheck".into(),
HealthCheckItem {
ok: false,
detail: Some("No such file or directory (os error 2)".into()),
},
);
let summary = format_startup_health_summary(&HealthReport {
status: "degraded".into(),
checks,
});
assert!(summary.contains("【配置】"));
assert!(summary.contains("frontend_static_dir"));
assert!(summary.contains("【可选 CLI 未安装 2 项】"));
assert!(summary.contains("bandit"));
assert!(summary.contains("shellcheck"));
}
#[test]
fn startup_summary_shows_toolchain_version_reason() {
let mut checks = BTreeMap::new();
checks.insert(
"workspace_writable".into(),
HealthCheckItem {
ok: true,
detail: None,
},
);
checks.insert(
"dep_toolchain_rustc".into(),
HealthCheckItem {
ok: false,
detail: Some("rustc 1.84.0 (abc)(版本 1.84.0 低于建议最低 1.85.0)".into()),
},
);
let summary = format_startup_health_summary(&HealthReport {
status: "degraded".into(),
checks,
});
assert!(summary.contains("【工具链】"));
assert!(summary.contains("rustc"));
assert!(summary.contains("1.85.0"));
}
}