use std::fmt;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::OnvifError;
use crate::soap::SoapError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Category {
Connectivity,
Time,
Services,
Media,
Imaging,
Ptz,
Events,
Network,
Users,
Coverage,
Write,
}
impl Category {
pub fn label(self) -> &'static str {
match self {
Category::Connectivity => "Connectivity",
Category::Time => "Time",
Category::Services => "Services",
Category::Media => "Media",
Category::Imaging => "Imaging",
Category::Ptz => "PTZ",
Category::Events => "Events",
Category::Network => "Network",
Category::Users => "Users",
Category::Coverage => "Parse coverage",
Category::Write => "Write round-trip",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "reason")]
pub enum CheckStatus {
Pass,
Warn(String),
Fail(String),
Skip(String),
}
impl CheckStatus {
pub fn tag(&self) -> &'static str {
match self {
CheckStatus::Pass => "PASS",
CheckStatus::Warn(_) => "WARN",
CheckStatus::Fail(_) => "FAIL",
CheckStatus::Skip(_) => "SKIP",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorClass {
SoapFault,
Precondition,
Parse,
Http,
InvalidArgument,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckError {
pub class: ErrorClass,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub fault_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub subcode: Option<String>,
pub reason: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub detail: Option<String>,
}
impl From<&OnvifError> for CheckError {
fn from(e: &OnvifError) -> Self {
match e {
OnvifError::Soap(SoapError::Fault {
code,
reason,
subcode,
detail,
}) => CheckError {
class: ErrorClass::SoapFault,
fault_code: (!code.is_empty()).then(|| code.clone()),
subcode: subcode.clone(),
reason: if reason.is_empty() {
e.to_string()
} else {
reason.clone()
},
detail: detail.clone(),
},
OnvifError::Soap(SoapError::MissingField(_)) => CheckError {
class: ErrorClass::Precondition,
fault_code: None,
subcode: None,
reason: e.to_string(),
detail: None,
},
OnvifError::Soap(
SoapError::XmlParse(_)
| SoapError::MissingBody
| SoapError::UnexpectedResponse(_)
| SoapError::InvalidValue { .. },
) => CheckError {
class: ErrorClass::Parse,
fault_code: None,
subcode: None,
reason: e.to_string(),
detail: None,
},
OnvifError::Transport(_) => CheckError {
class: ErrorClass::Http,
fault_code: None,
subcode: None,
reason: e.to_string(),
detail: None,
},
OnvifError::InvalidArgument(_) => CheckError {
class: ErrorClass::InvalidArgument,
fault_code: None,
subcode: None,
reason: e.to_string(),
detail: None,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckResult {
pub id: String,
pub category: Category,
pub status: CheckStatus,
pub detail: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub error: Option<CheckError>,
#[serde(with = "duration_ms", rename = "elapsed_ms")]
pub elapsed: Duration,
}
mod duration_ms {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
s.serialize_u128(d.as_millis())
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
let ms = u64::deserialize(d)?;
Ok(Duration::from_millis(ms))
}
}
impl CheckResult {
pub(crate) fn pass(
id: impl Into<String>,
category: Category,
detail: impl Into<String>,
) -> Self {
Self {
id: id.into(),
category,
status: CheckStatus::Pass,
detail: detail.into(),
error: None,
elapsed: Duration::ZERO,
}
}
pub(crate) fn warn(
id: impl Into<String>,
category: Category,
reason: impl Into<String>,
detail: impl Into<String>,
) -> Self {
Self {
id: id.into(),
category,
status: CheckStatus::Warn(reason.into()),
detail: detail.into(),
error: None,
elapsed: Duration::ZERO,
}
}
pub(crate) fn fail(
id: impl Into<String>,
category: Category,
reason: impl Into<String>,
) -> Self {
Self {
id: id.into(),
category,
status: CheckStatus::Fail(reason.into()),
detail: String::new(),
error: None,
elapsed: Duration::ZERO,
}
}
pub(crate) fn skip(
id: impl Into<String>,
category: Category,
reason: impl Into<String>,
) -> Self {
Self {
id: id.into(),
category,
status: CheckStatus::Skip(reason.into()),
detail: String::new(),
error: None,
elapsed: Duration::ZERO,
}
}
pub(crate) fn fail_from(id: impl Into<String>, category: Category, err: &OnvifError) -> Self {
Self {
id: id.into(),
category,
status: CheckStatus::Fail(err.to_string()),
detail: String::new(),
error: Some(CheckError::from(err)),
elapsed: Duration::ZERO,
}
}
pub(crate) fn with_error(mut self, err: &OnvifError) -> Self {
self.error = Some(CheckError::from(err));
self
}
pub(crate) fn with_elapsed(mut self, elapsed: Duration) -> Self {
self.elapsed = elapsed;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProfileVerdict {
Conformant,
Partial,
Unsupported,
}
impl ProfileVerdict {
fn label(self) -> &'static str {
match self {
ProfileVerdict::Conformant => "conformant",
ProfileVerdict::Partial => "partial",
ProfileVerdict::Unsupported => "unsupported",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileAssessment {
pub profile_s: (ProfileVerdict, Vec<String>),
pub profile_t: (ProfileVerdict, Vec<String>),
pub profile_g: (ProfileVerdict, Vec<String>),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HealthReport {
pub target: String,
#[serde(with = "duration_ms", rename = "total_elapsed_ms")]
pub total_elapsed: Duration,
pub checks: Vec<CheckResult>,
pub profiles: ProfileAssessment,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub clock_skew_s: Option<i64>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub declared_profiles: Vec<String>,
}
impl HealthReport {
pub fn count(&self, want: fn(&CheckStatus) -> bool) -> usize {
self.checks.iter().filter(|c| want(&c.status)).count()
}
pub fn ok(&self) -> bool {
!self
.checks
.iter()
.any(|c| matches!(c.status, CheckStatus::Fail(_)))
}
pub fn to_json(&self) -> String {
serde_json::to_string(self).expect("HealthReport is fully serializable")
}
pub fn to_json_pretty(&self) -> String {
serde_json::to_string_pretty(self).expect("HealthReport is fully serializable")
}
pub fn diff(&self, prev: &HealthReport) -> ReportDiff {
ReportDiff::compute(prev, self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReportDiff {
pub flipped_to_fail: Vec<String>,
pub flipped_to_pass: Vec<String>,
pub new_checks: Vec<String>,
pub removed_checks: Vec<String>,
pub slowed: Vec<SlowedCheck>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SlowedCheck {
pub id: String,
pub prev_ms: u128,
pub now_ms: u128,
}
impl ReportDiff {
pub fn is_empty(&self) -> bool {
self.flipped_to_fail.is_empty()
&& self.flipped_to_pass.is_empty()
&& self.new_checks.is_empty()
&& self.removed_checks.is_empty()
&& self.slowed.is_empty()
}
fn compute(prev: &HealthReport, now: &HealthReport) -> Self {
use std::collections::HashMap;
let prev_by_id: HashMap<&str, &CheckResult> =
prev.checks.iter().map(|c| (c.id.as_str(), c)).collect();
let now_by_id: HashMap<&str, &CheckResult> =
now.checks.iter().map(|c| (c.id.as_str(), c)).collect();
let mut flipped_to_fail = Vec::new();
let mut flipped_to_pass = Vec::new();
let mut new_checks = Vec::new();
let mut slowed = Vec::new();
for c in &now.checks {
match prev_by_id.get(c.id.as_str()) {
None => new_checks.push(c.id.clone()),
Some(p) => {
let was_fail = matches!(p.status, CheckStatus::Fail(_));
let is_fail = matches!(c.status, CheckStatus::Fail(_));
match (was_fail, is_fail) {
(false, true) => flipped_to_fail.push(c.id.clone()),
(true, false) => flipped_to_pass.push(c.id.clone()),
_ => {}
}
let prev_ms = p.elapsed.as_millis();
let now_ms = c.elapsed.as_millis();
if now_ms > prev_ms.saturating_mul(2) && now_ms.saturating_sub(prev_ms) > 100 {
slowed.push(SlowedCheck {
id: c.id.clone(),
prev_ms,
now_ms,
});
}
}
}
}
let removed_checks: Vec<String> = prev
.checks
.iter()
.filter(|c| !now_by_id.contains_key(c.id.as_str()))
.map(|c| c.id.clone())
.collect();
Self {
flipped_to_fail,
flipped_to_pass,
new_checks,
removed_checks,
slowed,
}
}
}
impl fmt::Display for ReportDiff {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "Diff vs baseline:")?;
if self.is_empty() {
writeln!(f, " no changes")?;
return Ok(());
}
if !self.flipped_to_fail.is_empty() {
writeln!(f, " flipped → FAIL: {}", self.flipped_to_fail.join(", "))?;
}
if !self.flipped_to_pass.is_empty() {
writeln!(f, " recovered : {}", self.flipped_to_pass.join(", "))?;
}
if !self.new_checks.is_empty() {
writeln!(f, " new checks : {}", self.new_checks.join(", "))?;
}
if !self.removed_checks.is_empty() {
writeln!(f, " removed : {}", self.removed_checks.join(", "))?;
}
if !self.slowed.is_empty() {
writeln!(f, " slowed:")?;
for s in &self.slowed {
writeln!(f, " {:<28} {:>5}ms → {:>5}ms", s.id, s.prev_ms, s.now_ms)?;
}
}
Ok(())
}
}
impl fmt::Display for HealthReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "ONVIF health check — {}", self.target)?;
writeln!(
f,
" {} pass · {} warn · {} fail · {} skip ({} ms total)",
self.count(|s| matches!(s, CheckStatus::Pass)),
self.count(|s| matches!(s, CheckStatus::Warn(_))),
self.count(|s| matches!(s, CheckStatus::Fail(_))),
self.count(|s| matches!(s, CheckStatus::Skip(_))),
self.total_elapsed.as_millis(),
)?;
let mut last: Option<Category> = None;
for c in &self.checks {
if last != Some(c.category) {
writeln!(f, "\n [{}]", c.category.label())?;
last = Some(c.category);
}
let note = match &c.status {
CheckStatus::Pass => c.detail.clone(),
CheckStatus::Warn(r) | CheckStatus::Fail(r) | CheckStatus::Skip(r) => r.clone(),
};
writeln!(
f,
" {:<4} {:<28} {:>5}ms {}",
c.status.tag(),
c.id,
c.elapsed.as_millis(),
note,
)?;
}
writeln!(f, "\n Profiles:")?;
for (name, (verdict, missing)) in [
("S", &self.profiles.profile_s),
("T", &self.profiles.profile_t),
("G", &self.profiles.profile_g),
] {
let miss = if missing.is_empty() {
String::new()
} else {
format!(" (missing: {})", missing.join(", "))
};
writeln!(f, " Profile {name}: {}{miss}", verdict.label())?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn check(id: &str, status: CheckStatus, elapsed_ms: u64) -> CheckResult {
let mut c = CheckResult::pass(id, Category::Media, "");
c.status = status;
c.elapsed = Duration::from_millis(elapsed_ms);
c
}
#[test]
fn check_error_classifies_variants() {
let e = OnvifError::Soap(SoapError::Fault {
code: "SOAP-ENV:Sender".into(),
reason: "Sender not Authorized".into(),
subcode: Some("ter:NotAuthorized".into()),
detail: None,
});
let ce = CheckError::from(&e);
assert_eq!(ce.class, ErrorClass::SoapFault);
assert_eq!(ce.fault_code.as_deref(), Some("SOAP-ENV:Sender"));
assert_eq!(ce.subcode.as_deref(), Some("ter:NotAuthorized"));
assert_eq!(ce.reason, "Sender not Authorized");
let e = OnvifError::Soap(SoapError::missing("Search service URL"));
assert_eq!(CheckError::from(&e).class, ErrorClass::Precondition);
}
fn assess() -> ProfileAssessment {
ProfileAssessment {
profile_s: (ProfileVerdict::Conformant, vec![]),
profile_t: (ProfileVerdict::Conformant, vec![]),
profile_g: (ProfileVerdict::Unsupported, vec!["recording".into()]),
}
}
fn report(checks: Vec<CheckResult>) -> HealthReport {
HealthReport {
target: "http://test/onvif/device_service".into(),
total_elapsed: Duration::from_millis(123),
checks,
profiles: assess(),
clock_skew_s: None,
declared_profiles: vec![],
}
}
#[test]
fn diff_flags_pass_to_fail() {
let prev = report(vec![check("a", CheckStatus::Pass, 10)]);
let now = report(vec![check("a", CheckStatus::Fail("boom".into()), 10)]);
let d = now.diff(&prev);
assert_eq!(d.flipped_to_fail, vec!["a".to_string()]);
assert!(d.flipped_to_pass.is_empty());
}
#[test]
fn diff_flags_fail_to_pass() {
let prev = report(vec![check("a", CheckStatus::Fail("x".into()), 10)]);
let now = report(vec![check("a", CheckStatus::Pass, 10)]);
let d = now.diff(&prev);
assert_eq!(d.flipped_to_pass, vec!["a".to_string()]);
assert!(d.flipped_to_fail.is_empty());
}
#[test]
fn diff_flags_warn_is_not_flipped() {
let prev = report(vec![check("a", CheckStatus::Warn("slow".into()), 10)]);
let now = report(vec![check("a", CheckStatus::Pass, 10)]);
let d = now.diff(&prev);
assert!(d.flipped_to_pass.is_empty());
assert!(d.flipped_to_fail.is_empty());
}
#[test]
fn diff_lists_added_and_removed_checks() {
let prev = report(vec![
check("a", CheckStatus::Pass, 1),
check("b", CheckStatus::Pass, 1),
]);
let now = report(vec![
check("a", CheckStatus::Pass, 1),
check("c", CheckStatus::Pass, 1),
]);
let d = now.diff(&prev);
assert_eq!(d.new_checks, vec!["c".to_string()]);
assert_eq!(d.removed_checks, vec!["b".to_string()]);
}
#[test]
fn diff_slowed_requires_doubled_and_delta_over_100ms() {
let prev = report(vec![check("a", CheckStatus::Pass, 10)]);
let now = report(vec![check("a", CheckStatus::Pass, 30)]);
assert!(now.diff(&prev).slowed.is_empty());
let prev = report(vec![check("b", CheckStatus::Pass, 200)]);
let now = report(vec![check("b", CheckStatus::Pass, 350)]);
assert!(now.diff(&prev).slowed.is_empty());
let prev = report(vec![check("c", CheckStatus::Pass, 100)]);
let now = report(vec![check("c", CheckStatus::Pass, 250)]);
let d = now.diff(&prev);
assert_eq!(d.slowed.len(), 1);
assert_eq!(d.slowed[0].id, "c");
assert_eq!(d.slowed[0].prev_ms, 100);
assert_eq!(d.slowed[0].now_ms, 250);
}
#[test]
fn diff_is_empty_when_nothing_changed() {
let r = report(vec![check("a", CheckStatus::Pass, 5)]);
assert!(r.diff(&r).is_empty());
}
#[test]
fn report_round_trips_through_json() {
let r = report(vec![
check("a", CheckStatus::Pass, 12),
check("b", CheckStatus::Fail("nope".into()), 7),
]);
let json = r.to_json();
let re: HealthReport = serde_json::from_str(&json).expect("json deserialises");
assert_eq!(re.target, r.target);
assert_eq!(re.checks.len(), r.checks.len());
assert_eq!(re.checks[1].id, "b");
assert!(matches!(re.checks[1].status, CheckStatus::Fail(_)));
assert_eq!(re.checks[0].elapsed, Duration::from_millis(12));
assert_eq!(re.total_elapsed, Duration::from_millis(123));
}
}