use std::collections::HashMap;
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tracing::warn;
use super::AppState;
use crate::audit;
use crate::audit::Caller;
#[derive(Serialize, sqlx::FromRow)]
pub struct CheckRow {
pub pc_id: String,
pub check_name: String,
pub label: Option<String>,
pub status: String,
pub detail: Option<String>,
pub recorded_at: DateTime<Utc>,
#[serde(default)]
#[sqlx(default)]
pub stale: bool,
}
#[derive(Serialize, Default, Clone)]
pub struct CheckCounts {
pub check_name: String,
pub label: Option<String>,
pub ok: i64,
pub warn: i64,
pub fail: i64,
pub unknown: i64,
}
#[derive(Serialize)]
pub struct ChecksResponse {
pub counts: Vec<CheckCounts>,
pub rows: Vec<CheckRow>,
pub stale_days: u32,
pub stale_attention: usize,
}
#[derive(Debug, Default, Deserialize)]
pub struct ChecksParams {
pub check: Option<String>,
pub include_ok: Option<bool>,
pub include_stale: Option<bool>,
}
const ROWS_SQL: &str = "SELECT pc_id, check_name, label, status, detail, recorded_at
FROM check_status
WHERE (?1 IS NULL OR check_name = ?1)
AND (?2 OR status != 'ok')
AND (?3 OR ?4 IS NULL OR recorded_at >= ?4)
ORDER BY check_name, pc_id";
const CLEAR_SQL: &str = "DELETE FROM check_status
WHERE check_name = ?1
AND (?2 IS NULL OR pc_id = ?2)";
pub async fn list_all(
State(state): State<AppState>,
Query(params): Query<ChecksParams>,
) -> Result<Json<ChecksResponse>, (StatusCode, String)> {
let include_ok = params.include_ok.unwrap_or(false);
let include_stale = params.include_stale.unwrap_or(false);
let check = params
.check
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let stale_days = match crate::api::server_settings::load(&state).await {
Ok(s) => s.effective_check_status_stale_days(),
Err(e) => {
warn!(error = %e, "checks: server_settings load failed; staleness disabled for this response");
0
}
};
let cutoff: Option<DateTime<Utc>> =
(stale_days > 0).then(|| Utc::now() - chrono::Duration::days(stale_days as i64));
#[derive(sqlx::FromRow)]
struct CountRow {
check_name: String,
label: Option<String>,
status: String,
n: i64,
}
let count_rows: Vec<CountRow> = sqlx::query_as(
"SELECT check_name, MAX(label) AS label, status, COUNT(*) AS n
FROM check_status
WHERE (?1 IS NULL OR check_name = ?1)
AND (?2 OR ?3 IS NULL OR recorded_at >= ?3)
GROUP BY check_name, status",
)
.bind(check)
.bind(include_stale)
.bind(cutoff)
.fetch_all(&state.pool)
.await
.map_err(|e| {
warn!(error = %e, "check_status count query");
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
})?;
let mut by_check: HashMap<String, CheckCounts> = HashMap::new();
for r in count_rows {
let entry = by_check
.entry(r.check_name.clone())
.or_insert_with(|| CheckCounts {
check_name: r.check_name,
..CheckCounts::default()
});
if entry.label.is_none() {
entry.label = r.label;
}
match r.status.as_str() {
"ok" => entry.ok = r.n,
"warn" => entry.warn = r.n,
"fail" => entry.fail = r.n,
_ => entry.unknown += r.n,
}
}
let mut counts: Vec<CheckCounts> = by_check.into_values().collect();
counts.sort_by(|a, b| a.check_name.cmp(&b.check_name));
let mut rows: Vec<CheckRow> = sqlx::query_as(ROWS_SQL)
.bind(check)
.bind(include_ok)
.bind(include_stale)
.bind(cutoff)
.fetch_all(&state.pool)
.await
.map_err(|e| {
warn!(error = %e, "check_status query");
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
})?;
if let Some(c) = cutoff {
for r in &mut rows {
r.stale = r.recorded_at < c;
}
}
let stale_attention: i64 = if let Some(c) = cutoff {
sqlx::query_scalar(
"SELECT COUNT(*) FROM check_status
WHERE (?1 IS NULL OR check_name = ?1)
AND status != 'ok'
AND recorded_at < ?2",
)
.bind(check)
.bind(c)
.fetch_one(&state.pool)
.await
.map_err(|e| {
warn!(error = %e, "check_status stale-count query");
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
})?
} else {
0
};
Ok(Json(ChecksResponse {
counts,
rows,
stale_days,
stale_attention: stale_attention as usize,
}))
}
#[derive(Debug, Default, Deserialize)]
pub struct ClearParams {
pub pc_id: Option<String>,
}
#[derive(Serialize)]
pub struct ClearResponse {
pub deleted: u64,
}
pub async fn clear(
State(state): State<AppState>,
Path(check_name): Path<String>,
Query(params): Query<ClearParams>,
caller: Caller,
) -> Result<Json<ClearResponse>, (StatusCode, String)> {
let check_name = check_name.trim();
if check_name.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"check_name must be non-empty".into(),
));
}
let pc_id = params
.pc_id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let deleted = sqlx::query(CLEAR_SQL)
.bind(check_name)
.bind(pc_id)
.execute(&state.pool)
.await
.map_err(|e| {
warn!(error = %e, check_name, "check_status delete");
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
})?
.rows_affected();
audit::record(
&state.nats,
"operator",
"check_clear",
Some(check_name),
Some(&caller),
serde_json::json!({ "check_name": check_name, "pc_id": pc_id, "deleted": deleted }),
)
.await;
Ok(Json(ClearResponse { deleted }))
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::SqlitePool;
use sqlx::sqlite::SqlitePoolOptions;
async fn seeded_pool() -> SqlitePool {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
for (pc, check, label, status) in [
("pc-1", "bitlocker", Some("BitLocker 暗号化"), "ok"),
("pc-2", "bitlocker", Some("BitLocker 暗号化"), "fail"),
("pc-3", "bitlocker", Some("BitLocker 暗号化"), "ok"),
("pc-1", "av", None, "warn"),
("pc-2", "av", None, "ok"),
] {
sqlx::query(
"INSERT INTO check_status (pc_id, check_name, label, status, recorded_at)
VALUES (?, ?, ?, ?, ?)",
)
.bind(pc)
.bind(check)
.bind(label)
.bind(status)
.bind(chrono::Utc::now())
.execute(&pool)
.await
.unwrap();
}
pool
}
async fn rows_for(pool: &SqlitePool, check: Option<&str>, include_ok: bool) -> Vec<CheckRow> {
rows_with_cutoff(pool, check, include_ok, true, None).await
}
async fn rows_with_cutoff(
pool: &SqlitePool,
check: Option<&str>,
include_ok: bool,
include_stale: bool,
cutoff: Option<DateTime<Utc>>,
) -> Vec<CheckRow> {
sqlx::query_as(ROWS_SQL)
.bind(check)
.bind(include_ok)
.bind(include_stale)
.bind(cutoff)
.fetch_all(pool)
.await
.unwrap()
}
#[tokio::test]
async fn default_rows_exclude_ok() {
let pool = seeded_pool().await;
let rows = rows_for(&pool, None, false).await;
assert_eq!(rows.len(), 2, "only warn+fail rows by default");
assert!(rows.iter().all(|r| r.status != "ok"));
}
#[tokio::test]
async fn rows_carry_label_and_fall_back_to_none() {
let pool = seeded_pool().await;
let rows = rows_for(&pool, None, true).await;
let bl = rows.iter().find(|r| r.check_name == "bitlocker").unwrap();
assert_eq!(bl.label.as_deref(), Some("BitLocker 暗号化"));
let av = rows.iter().find(|r| r.check_name == "av").unwrap();
assert_eq!(av.label, None, "unlabeled check leaves label NULL");
}
#[tokio::test]
async fn check_filter_with_include_ok_returns_full_check() {
let pool = seeded_pool().await;
let rows = rows_for(&pool, Some("bitlocker"), true).await;
assert_eq!(rows.len(), 3, "all bitlocker rows incl. ok");
assert!(rows.iter().all(|r| r.check_name == "bitlocker"));
}
#[tokio::test]
async fn stale_rows_excluded_by_default_shown_on_demand_and_when_disabled() {
let pool = seeded_pool().await;
let old = Utc::now() - chrono::Duration::days(90);
sqlx::query(
"INSERT INTO check_status (pc_id, check_name, label, status, recorded_at)
VALUES (?, ?, ?, ?, ?)",
)
.bind("pc-9")
.bind("bitlocker")
.bind(Some("BitLocker 暗号化"))
.bind("fail")
.bind(old)
.execute(&pool)
.await
.unwrap();
let cutoff = Some(Utc::now() - chrono::Duration::days(30));
let def = rows_with_cutoff(&pool, Some("bitlocker"), true, false, cutoff).await;
assert!(def.iter().all(|r| r.pc_id != "pc-9"), "stale row hidden");
assert!(def.iter().any(|r| r.pc_id == "pc-2"), "in-scope fail kept");
let all = rows_with_cutoff(&pool, Some("bitlocker"), true, true, cutoff).await;
assert!(
all.iter().any(|r| r.pc_id == "pc-9"),
"stale row shown on demand"
);
let disabled = rows_with_cutoff(&pool, Some("bitlocker"), true, false, None).await;
assert!(
disabled.iter().any(|r| r.pc_id == "pc-9"),
"disabled staleness shows all rows"
);
}
#[tokio::test]
async fn clear_removes_every_pc_for_a_check() {
let pool = seeded_pool().await;
let deleted = sqlx::query(CLEAR_SQL)
.bind("bitlocker")
.bind(Option::<&str>::None)
.execute(&pool)
.await
.unwrap()
.rows_affected();
assert_eq!(deleted, 3, "all three bitlocker rows cleared");
assert!(rows_for(&pool, Some("bitlocker"), true).await.is_empty());
assert_eq!(rows_for(&pool, Some("av"), true).await.len(), 2);
}
#[tokio::test]
async fn clear_scoped_to_one_pc() {
let pool = seeded_pool().await;
let deleted = sqlx::query(CLEAR_SQL)
.bind("bitlocker")
.bind(Some("pc-2"))
.execute(&pool)
.await
.unwrap()
.rows_affected();
assert_eq!(deleted, 1, "only pc-2's bitlocker row cleared");
let left = rows_for(&pool, Some("bitlocker"), true).await;
assert_eq!(left.len(), 2);
assert!(left.iter().all(|r| r.pc_id != "pc-2"));
}
}