Skip to main content

ignition_core/client/
status.rs

1//! Status/info capability models (02-02, HLTH-01/02) — field names match
2//! the **live 8.3.6 gateway** captures (02-RESEARCH §Status/info,
3//! §Modules) and the gateway's own openapi schema.
4//!
5//! Every model carries `#[serde(flatten)] extra` passthrough so `--json`
6//! stays complete as gateway responses evolve (unknown keys round-trip
7//! instead of being dropped).
8//!
9//! Two unit gotchas are pinned by naming/comments, never silently
10//! converted (02-RESEARCH §Metrics: "normalize in the model, not in
11//! users' eyes" — the normalization is HONEST NAMING):
12//! - [`Overview::uptime`] is epoch **milliseconds**;
13//! - [`Overview::cpu`] is a 0–1 **fraction** — the
14//!   `systemPerformance/currentGauges` cpu is **percent**. Same concept,
15//!   two scales: the field comments say so at both homes.
16//!
17//! `/data/api/v1/overview` is live-verified (02-RESEARCH §Status/info)
18//! and present in the openapi extract — the single best status call.
19
20use std::collections::BTreeMap;
21
22use serde::{Deserialize, Serialize};
23
24/// GET path of the overview capability (live-verified, 02-RESEARCH
25/// §Status/info).
26pub(crate) const OVERVIEW_PATH: &str = "/data/api/v1/overview";
27
28/// GET path of the unauthenticated readiness probe (NOTE: root-level, not
29/// under `/data` — it answers even while the gateway restarts).
30pub(crate) const STATUS_PING_PATH: &str = "/StatusPing";
31
32/// GET paths of the two module lists (healthy = fully loaded modules;
33/// quarantined = modules withheld at startup).
34pub(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/// GET `/data/api/v1/overview` — the status call (platform + runtime).
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct Overview {
40    /// Version + build revision in one string, e.g.
41    /// `"8.3.6 (b2026042713)"`.
42    pub version: String,
43    /// Redundancy block, when reported.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub redundancy: Option<RedundancyInfo>,
46    /// JVM block `{version, vendor, name}`, when reported.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub java: Option<JavaInfo>,
49    /// OS block `{name, arch, version}`, when reported.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub os: Option<OsInfo>,
52    /// Gateway uptime in epoch **MILLISECONDS** (live capture: `338137`
53    /// ≈ 5½ minutes — a seconds interpretation would be off by 1000×).
54    pub uptime: i64,
55    /// `[used, max]` heap bytes (live capture: `[338137088i64, 1073741824i64]`).
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub memory: Vec<i64>,
58    /// CPU utilization as a 0–1 **FRACTION** (live capture: `0.0031`).
59    /// NOT percent — `systemPerformance/currentGauges` reports percent
60    /// (4.88). No silent conversion between the two, ever.
61    pub cpu: f64,
62    /// Disk block `{total, used}` bytes, when reported.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub disk: Option<DiskInfo>,
65    /// License state block, when reported.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub license: Option<OverviewLicense>,
68    /// Unknown keys round-trip (passthrough-shaped `--json`).
69    #[serde(flatten)]
70    pub extra: BTreeMap<String, serde_json::Value>,
71}
72
73/// `overview.redundancy` — `{role, activityLevel, projectState, …}`.
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct RedundancyInfo {
76    /// `"Independent"` / `"Backup"` / `"Primary"`, when reported.
77    #[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    /// Unknown keys round-trip.
92    #[serde(flatten)]
93    pub extra: BTreeMap<String, serde_json::Value>,
94}
95
96/// `overview.java` — `{version, vendor, name}`.
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98pub struct JavaInfo {
99    /// e.g. `"17.0.11"`.
100    #[serde(default)]
101    pub version: String,
102    /// e.g. `"Azul Systems, Inc."`.
103    #[serde(default)]
104    pub vendor: String,
105    /// e.g. `"OpenJDK 64-Bit Server VM"`.
106    #[serde(default)]
107    pub name: String,
108}
109
110/// `overview.os` — `{name, arch, version}`.
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112pub struct OsInfo {
113    /// e.g. `"Linux"`.
114    #[serde(default)]
115    pub name: String,
116    /// e.g. `"amd64"`.
117    #[serde(default)]
118    pub arch: String,
119    /// e.g. `"5.15.0-91-generic"`, when reported.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub version: Option<String>,
122}
123
124/// `overview.disk` — `{total, used}` bytes.
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126pub struct DiskInfo {
127    /// Total bytes.
128    #[serde(default)]
129    pub total: i64,
130    /// Used bytes.
131    #[serde(default)]
132    pub used: i64,
133}
134
135/// `overview.license` — `{state, trialRemaining}` (suffixed `_s` in Rust
136/// to make the unit explicit; serialized under the gateway-native key).
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138pub struct OverviewLicense {
139    /// `"trial"` / `"licensed"` / …
140    #[serde(default)]
141    pub state: String,
142    /// Trial countdown in epoch **SECONDS** (live capture: `7017` ≈
143    /// 1h57m), when reported (absent on licensed gateways).
144    #[serde(
145        rename = "trialRemaining",
146        default,
147        skip_serializing_if = "Option::is_none"
148    )]
149    pub trial_remaining_s: Option<i64>,
150    /// Unknown keys round-trip.
151    #[serde(flatten)]
152    pub extra: BTreeMap<String, serde_json::Value>,
153}
154
155/// GET `/StatusPing` — the UNAUTHENTICATED readiness anchor (works
156/// mid-restart and with broken credentials; 02-02 fetches it header-less
157/// via `auth = false`).
158///
159/// States observed live: `RUNNING`, `STARTING`. Commissioning-era states
160/// are unenumerated — unknown states surface as-is and are treated as
161/// not-ready by 02-05's wait loops (02-RESEARCH Open Question 3).
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct StatusPing {
164    /// `RUNNING` / `STARTING` / …, verbatim from the gateway.
165    pub state: String,
166}
167
168/// One item of `/data/api/v1/modules/healthy` (or `/modules/quarantined`).
169///
170/// Quarantined items answer a REDUCED shape (openapi + live): only
171/// `id`/`name`/version-family fields are guaranteed — `state`,
172/// `licenseState`, `vendorName`, `startupTime` exist only on fully
173/// loaded modules, so they are `Option` here or the `--quarantined`
174/// list would fail to parse.
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176pub struct ModuleInfo {
177    /// Module id, e.g. `"com.inductiveautomation.perspective"`.
178    pub id: String,
179    /// Human-readable module name.
180    #[serde(default)]
181    pub name: String,
182    /// Module version string.
183    #[serde(default)]
184    pub version: String,
185    /// `"ACTIVE"` / … — fully-loaded modules only.
186    #[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    /// The time the module started — a STRING on the wire (openapi),
201    /// not epoch ms.
202    #[serde(
203        rename = "startupTime",
204        default,
205        skip_serializing_if = "Option::is_none"
206    )]
207    pub startup_time: Option<String>,
208    /// Unknown keys (`onStartup`, `shouldUpgrade`, `description`,
209    /// `vendorId`, `selfSigned`, `certAccepted`, `reason`, …) round-trip.
210    #[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    /// THE live-capture regression (02-RESEARCH §Status/info): the exact
219    /// overview body a commissioned 8.3.6 gateway answers with — uptime
220    /// in ms, cpu a 0–1 fraction, trialRemaining in seconds, unknown
221    /// keys (`cloudEnv`, `timezone`, …) preserved in `extra`.
222    #[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        // Fraction vs percent honesty: serializing keeps the wire value.
265        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    /// StatusPing parses the two observed states verbatim.
271    #[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    /// A healthy-module item parses with the fully-loaded fields AND a
281    /// quarantined item (reduced shape, `reason` passthrough) parses too.
282    #[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}