use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub(crate) const OVERVIEW_PATH: &str = "/data/api/v1/overview";
pub(crate) const STATUS_PING_PATH: &str = "/StatusPing";
pub(crate) const MODULES_HEALTHY_PATH: &str = "/data/api/v1/modules/healthy";
pub(crate) const MODULES_QUARANTINED_PATH: &str = "/data/api/v1/modules/quarantined";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Overview {
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub redundancy: Option<RedundancyInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub java: Option<JavaInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub os: Option<OsInfo>,
pub uptime: i64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub memory: Vec<i64>,
pub cpu: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disk: Option<DiskInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<OverviewLicense>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RedundancyInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(
rename = "activityLevel",
default,
skip_serializing_if = "Option::is_none"
)]
pub activity_level: Option<String>,
#[serde(
rename = "projectState",
default,
skip_serializing_if = "Option::is_none"
)]
pub project_state: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JavaInfo {
#[serde(default)]
pub version: String,
#[serde(default)]
pub vendor: String,
#[serde(default)]
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OsInfo {
#[serde(default)]
pub name: String,
#[serde(default)]
pub arch: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DiskInfo {
#[serde(default)]
pub total: i64,
#[serde(default)]
pub used: i64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OverviewLicense {
#[serde(default)]
pub state: String,
#[serde(
rename = "trialRemaining",
default,
skip_serializing_if = "Option::is_none"
)]
pub trial_remaining_s: Option<i64>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatusPing {
pub state: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModuleInfo {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(
rename = "licenseState",
default,
skip_serializing_if = "Option::is_none"
)]
pub license_state: Option<String>,
#[serde(
rename = "vendorName",
default,
skip_serializing_if = "Option::is_none"
)]
pub vendor_name: Option<String>,
#[serde(
rename = "startupTime",
default,
skip_serializing_if = "Option::is_none"
)]
pub startup_time: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::{ModuleInfo, Overview, StatusPing};
#[test]
fn overview_parses_the_live_capture() {
let body = serde_json::json!({
"version": "8.3.6 (b2026042713)",
"redundancy": {"role": "Independent", "activityLevel": "ACTIVE", "projectState": "RUNNING"},
"java": {"version": "17.0.11", "vendor": "Azul Systems, Inc.", "name": "OpenJDK 64-Bit Server VM"},
"os": {"name": "Linux", "arch": "amd64", "version": "5.15.0"},
"cloudEnv": "unknown",
"uptime": 338137,
"timezone": "America/New_York",
"locale": "en-US",
"time": 1787346747022i64,
"memory": [338137088i64, 1073741824i64],
"cpu": 0.0031,
"disk": {"total": 62661259264i64, "used": 12272824320i64},
"license": {"state": "trial", "trialRemaining": 7017}
});
let overview: Overview =
serde_json::from_value(body).expect("the live overview shape must parse");
assert_eq!(overview.version, "8.3.6 (b2026042713)");
assert_eq!(overview.uptime, 338137, "epoch ms, verbatim");
assert!((overview.cpu - 0.0031).abs() < f64::EPSILON, "0–1 fraction");
assert_eq!(overview.memory, vec![338137088i64, 1073741824i64]);
assert_eq!(
overview
.license
.as_ref()
.expect("license block")
.trial_remaining_s,
Some(7017),
"trial countdown in seconds"
);
assert_eq!(
overview.java.as_ref().expect("java").vendor,
"Azul Systems, Inc."
);
assert_eq!(
overview.extra.get("cloudEnv"),
Some(&serde_json::json!("unknown")),
"unknown keys round-trip into extra"
);
let round = serde_json::to_value(&overview).expect("serialize");
assert_eq!(round["cpu"], 0.0031);
assert_eq!(round["license"]["trialRemaining"], 7017);
}
#[test]
fn status_ping_parses_observed_states() {
for state in ["RUNNING", "STARTING"] {
let ping: StatusPing = serde_json::from_value(serde_json::json!({ "state": state }))
.expect("observed state parses");
assert_eq!(ping.state, state);
}
}
#[test]
fn module_info_parses_healthy_and_quarantined_shapes() {
let healthy: ModuleInfo = serde_json::from_value(serde_json::json!({
"id": "com.inductiveautomation.perspective",
"name": "Perspective",
"version": "8.3.6",
"state": "ACTIVE",
"licenseState": "ACTIVATED",
"vendorName": "Inductive Automation",
"startupTime": "2026-08-21T22:03:29Z",
"onStartup": "ENABLE",
"shouldUpgrade": false
}))
.expect("healthy item parses");
assert_eq!(healthy.state.as_deref(), Some("ACTIVE"));
assert_eq!(healthy.license_state.as_deref(), Some("ACTIVATED"));
assert_eq!(
healthy.startup_time.as_deref(),
Some("2026-08-21T22:03:29Z")
);
assert_eq!(
healthy.extra.get("onStartup"),
Some(&serde_json::json!("ENABLE")),
"onStartup round-trips via extra"
);
let quarantined: ModuleInfo = serde_json::from_value(serde_json::json!({
"id": "com.example.broken",
"name": "Broken Module",
"version": "1.0.0",
"certAccepted": false,
"licenseAccepted": false,
"reason": "Certificate rejected"
}))
.expect("quarantined items carry a REDUCED shape (openapi) — must parse");
assert_eq!(quarantined.state, None);
assert_eq!(quarantined.license_state, None);
assert_eq!(
quarantined.extra.get("reason"),
Some(&serde_json::json!("Certificate rejected"))
);
}
}