1use std::collections::BTreeMap;
21
22use serde::{Deserialize, Serialize};
23
24pub(crate) const OVERVIEW_PATH: &str = "/data/api/v1/overview";
27
28pub(crate) const STATUS_PING_PATH: &str = "/StatusPing";
31
32pub(crate) const MODULES_HEALTHY_PATH: &str = "/data/api/v1/modules/healthy";
35pub(crate) const MODULES_QUARANTINED_PATH: &str = "/data/api/v1/modules/quarantined";
36
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct Overview {
40 pub version: String,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub redundancy: Option<RedundancyInfo>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub java: Option<JavaInfo>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub os: Option<OsInfo>,
52 pub uptime: i64,
55 #[serde(default, skip_serializing_if = "Vec::is_empty")]
57 pub memory: Vec<i64>,
58 pub cpu: f64,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub disk: Option<DiskInfo>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub license: Option<OverviewLicense>,
68 #[serde(flatten)]
70 pub extra: BTreeMap<String, serde_json::Value>,
71}
72
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct RedundancyInfo {
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub role: Option<String>,
79 #[serde(
80 rename = "activityLevel",
81 default,
82 skip_serializing_if = "Option::is_none"
83 )]
84 pub activity_level: Option<String>,
85 #[serde(
86 rename = "projectState",
87 default,
88 skip_serializing_if = "Option::is_none"
89 )]
90 pub project_state: Option<String>,
91 #[serde(flatten)]
93 pub extra: BTreeMap<String, serde_json::Value>,
94}
95
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98pub struct JavaInfo {
99 #[serde(default)]
101 pub version: String,
102 #[serde(default)]
104 pub vendor: String,
105 #[serde(default)]
107 pub name: String,
108}
109
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112pub struct OsInfo {
113 #[serde(default)]
115 pub name: String,
116 #[serde(default)]
118 pub arch: String,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub version: Option<String>,
122}
123
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126pub struct DiskInfo {
127 #[serde(default)]
129 pub total: i64,
130 #[serde(default)]
132 pub used: i64,
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138pub struct OverviewLicense {
139 #[serde(default)]
141 pub state: String,
142 #[serde(
145 rename = "trialRemaining",
146 default,
147 skip_serializing_if = "Option::is_none"
148 )]
149 pub trial_remaining_s: Option<i64>,
150 #[serde(flatten)]
152 pub extra: BTreeMap<String, serde_json::Value>,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct StatusPing {
164 pub state: String,
166}
167
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176pub struct ModuleInfo {
177 pub id: String,
179 #[serde(default)]
181 pub name: String,
182 #[serde(default)]
184 pub version: String,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub state: Option<String>,
188 #[serde(
189 rename = "licenseState",
190 default,
191 skip_serializing_if = "Option::is_none"
192 )]
193 pub license_state: Option<String>,
194 #[serde(
195 rename = "vendorName",
196 default,
197 skip_serializing_if = "Option::is_none"
198 )]
199 pub vendor_name: Option<String>,
200 #[serde(
203 rename = "startupTime",
204 default,
205 skip_serializing_if = "Option::is_none"
206 )]
207 pub startup_time: Option<String>,
208 #[serde(flatten)]
211 pub extra: BTreeMap<String, serde_json::Value>,
212}
213
214#[cfg(test)]
215mod tests {
216 use super::{ModuleInfo, Overview, StatusPing};
217
218 #[test]
223 fn overview_parses_the_live_capture() {
224 let body = serde_json::json!({
225 "version": "8.3.6 (b2026042713)",
226 "redundancy": {"role": "Independent", "activityLevel": "ACTIVE", "projectState": "RUNNING"},
227 "java": {"version": "17.0.11", "vendor": "Azul Systems, Inc.", "name": "OpenJDK 64-Bit Server VM"},
228 "os": {"name": "Linux", "arch": "amd64", "version": "5.15.0"},
229 "cloudEnv": "unknown",
230 "uptime": 338137,
231 "timezone": "America/New_York",
232 "locale": "en-US",
233 "time": 1787346747022i64,
234 "memory": [338137088i64, 1073741824i64],
235 "cpu": 0.0031,
236 "disk": {"total": 62661259264i64, "used": 12272824320i64},
237 "license": {"state": "trial", "trialRemaining": 7017}
238 });
239 let overview: Overview =
240 serde_json::from_value(body).expect("the live overview shape must parse");
241 assert_eq!(overview.version, "8.3.6 (b2026042713)");
242 assert_eq!(overview.uptime, 338137, "epoch ms, verbatim");
243 assert!((overview.cpu - 0.0031).abs() < f64::EPSILON, "0–1 fraction");
244 assert_eq!(overview.memory, vec![338137088i64, 1073741824i64]);
245 assert_eq!(
246 overview
247 .license
248 .as_ref()
249 .expect("license block")
250 .trial_remaining_s,
251 Some(7017),
252 "trial countdown in seconds"
253 );
254 assert_eq!(
255 overview.java.as_ref().expect("java").vendor,
256 "Azul Systems, Inc."
257 );
258 assert_eq!(
259 overview.extra.get("cloudEnv"),
260 Some(&serde_json::json!("unknown")),
261 "unknown keys round-trip into extra"
262 );
263
264 let round = serde_json::to_value(&overview).expect("serialize");
266 assert_eq!(round["cpu"], 0.0031);
267 assert_eq!(round["license"]["trialRemaining"], 7017);
268 }
269
270 #[test]
272 fn status_ping_parses_observed_states() {
273 for state in ["RUNNING", "STARTING"] {
274 let ping: StatusPing = serde_json::from_value(serde_json::json!({ "state": state }))
275 .expect("observed state parses");
276 assert_eq!(ping.state, state);
277 }
278 }
279
280 #[test]
283 fn module_info_parses_healthy_and_quarantined_shapes() {
284 let healthy: ModuleInfo = serde_json::from_value(serde_json::json!({
285 "id": "com.inductiveautomation.perspective",
286 "name": "Perspective",
287 "version": "8.3.6",
288 "state": "ACTIVE",
289 "licenseState": "ACTIVATED",
290 "vendorName": "Inductive Automation",
291 "startupTime": "2026-08-21T22:03:29Z",
292 "onStartup": "ENABLE",
293 "shouldUpgrade": false
294 }))
295 .expect("healthy item parses");
296 assert_eq!(healthy.state.as_deref(), Some("ACTIVE"));
297 assert_eq!(healthy.license_state.as_deref(), Some("ACTIVATED"));
298 assert_eq!(
299 healthy.startup_time.as_deref(),
300 Some("2026-08-21T22:03:29Z")
301 );
302 assert_eq!(
303 healthy.extra.get("onStartup"),
304 Some(&serde_json::json!("ENABLE")),
305 "onStartup round-trips via extra"
306 );
307
308 let quarantined: ModuleInfo = serde_json::from_value(serde_json::json!({
309 "id": "com.example.broken",
310 "name": "Broken Module",
311 "version": "1.0.0",
312 "certAccepted": false,
313 "licenseAccepted": false,
314 "reason": "Certificate rejected"
315 }))
316 .expect("quarantined items carry a REDUCED shape (openapi) — must parse");
317 assert_eq!(quarantined.state, None);
318 assert_eq!(quarantined.license_state, None);
319 assert_eq!(
320 quarantined.extra.get("reason"),
321 Some(&serde_json::json!("Certificate rejected"))
322 );
323 }
324}