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/// DELETE path to uninstall a module (Phase 19, D-19-03/D-19-04).
38///
39/// **HTTP 200 is NOT the verdict.** Live-verified against a real
40/// gateway: a stock module, a bogus id, and a still-mounted module ALL
41/// answer 200 — success/failure rides the body's `success` +
42/// `failedUninstalls` fields instead. See [`ModuleUninstallResponse`].
43pub(crate) const MODULES_UNINSTALL_PATH: &str = "/data/api/v1/modules/uninstall";
44
45/// DELETE `/data/api/v1/modules/uninstall` request body — ALWAYS a
46/// single-element array (D-19-04: a mixed success+failure batch's
47/// atomicity was never observed live, so `ign` sidesteps the question
48/// entirely — one call per module id, never a batch).
49#[derive(Debug, Clone, Serialize)]
50pub(crate) struct ModuleUninstallRequest {
51    /// Exactly one element: the target's
52    /// [`crate::module::ModuleSpec::gateway_module_id`].
53    pub uninstall: Vec<String>,
54}
55
56/// DELETE `/data/api/v1/modules/uninstall` response body.
57///
58/// **HTTP 200 is NOT the verdict** (live-verified, Phase 19 research): a
59/// stock module, a bogus id, and a still-mounted module all answered
60/// 200 with `success:false`. `success` defaults to `false` when absent
61/// and `failed_uninstalls` defaults to `None` — both defaults keep the
62/// SAFE direction: a body this shape can't fully parse never degrades
63/// into a reported success.
64#[derive(Debug, Clone, Deserialize, Default)]
65pub(crate) struct ModuleUninstallResponse {
66    #[serde(default)]
67    pub success: bool,
68    #[serde(rename = "failedUninstalls", default)]
69    pub failed_uninstalls: Option<FailedUninstalls>,
70}
71
72/// `ModuleUninstallResponse.failedUninstalls` — the gateway's detail on
73/// which ids the uninstall refused.
74#[derive(Debug, Clone, Deserialize, Default)]
75pub(crate) struct FailedUninstalls {
76    /// The failed gateway module ids, verbatim. `#[serde(default)]` so
77    /// an absent key deserializes to empty rather than failing the
78    /// whole response (a present-but-empty `failedUninstalls` object is
79    /// a real shape the gateway can answer with).
80    #[serde(default)]
81    pub uninstall: Vec<String>,
82}
83
84/// GET `/data/api/v1/overview` — the status call (platform + runtime).
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct Overview {
87    /// Version + build revision in one string, e.g.
88    /// `"8.3.6 (b2026042713)"`.
89    pub version: String,
90    /// Redundancy block, when reported.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub redundancy: Option<RedundancyInfo>,
93    /// JVM block `{version, vendor, name}`, when reported.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub java: Option<JavaInfo>,
96    /// OS block `{name, arch, version}`, when reported.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub os: Option<OsInfo>,
99    /// Gateway uptime in epoch **MILLISECONDS** (live capture: `338137`
100    /// ≈ 5½ minutes — a seconds interpretation would be off by 1000×).
101    pub uptime: i64,
102    /// `[used, max]` heap bytes (live capture: `[338137088i64, 1073741824i64]`).
103    #[serde(default, skip_serializing_if = "Vec::is_empty")]
104    pub memory: Vec<i64>,
105    /// CPU utilization as a 0–1 **FRACTION** (live capture: `0.0031`).
106    /// NOT percent — `systemPerformance/currentGauges` reports percent
107    /// (4.88). No silent conversion between the two, ever.
108    pub cpu: f64,
109    /// Disk block `{total, used}` bytes, when reported.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub disk: Option<DiskInfo>,
112    /// License state block, when reported.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub license: Option<OverviewLicense>,
115    /// Unknown keys round-trip (passthrough-shaped `--json`).
116    #[serde(flatten)]
117    pub extra: BTreeMap<String, serde_json::Value>,
118}
119
120/// `overview.redundancy` — `{role, activityLevel, projectState, …}`.
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122pub struct RedundancyInfo {
123    /// `"Independent"` / `"Master"` / `"Backup"`, when reported. The
124    /// active node is decided by `activityLevel`
125    /// (`client::redundancy::active_node`), not by role.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub role: Option<String>,
128    #[serde(
129        rename = "activityLevel",
130        default,
131        skip_serializing_if = "Option::is_none"
132    )]
133    pub activity_level: Option<String>,
134    #[serde(
135        rename = "projectState",
136        default,
137        skip_serializing_if = "Option::is_none"
138    )]
139    pub project_state: Option<String>,
140    /// Unknown keys round-trip.
141    #[serde(flatten)]
142    pub extra: BTreeMap<String, serde_json::Value>,
143}
144
145/// `overview.java` — `{version, vendor, name}`.
146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147pub struct JavaInfo {
148    /// e.g. `"17.0.11"`.
149    #[serde(default)]
150    pub version: String,
151    /// e.g. `"Azul Systems, Inc."`.
152    #[serde(default)]
153    pub vendor: String,
154    /// e.g. `"OpenJDK 64-Bit Server VM"`.
155    #[serde(default)]
156    pub name: String,
157}
158
159/// `overview.os` — `{name, arch, version}`.
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161pub struct OsInfo {
162    /// e.g. `"Linux"`.
163    #[serde(default)]
164    pub name: String,
165    /// e.g. `"amd64"`.
166    #[serde(default)]
167    pub arch: String,
168    /// e.g. `"5.15.0-91-generic"`, when reported.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub version: Option<String>,
171}
172
173/// `overview.disk` — `{total, used}` bytes.
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub struct DiskInfo {
176    /// Total bytes.
177    #[serde(default)]
178    pub total: i64,
179    /// Used bytes.
180    #[serde(default)]
181    pub used: i64,
182}
183
184/// `overview.license` — `{state, trialRemaining}` (suffixed `_s` in Rust
185/// to make the unit explicit; serialized under the gateway-native key).
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub struct OverviewLicense {
188    /// `"trial"` / `"licensed"` / …
189    #[serde(default)]
190    pub state: String,
191    /// Trial countdown in epoch **SECONDS** (live capture: `7017` ≈
192    /// 1h57m), when reported (absent on licensed gateways).
193    #[serde(
194        rename = "trialRemaining",
195        default,
196        skip_serializing_if = "Option::is_none"
197    )]
198    pub trial_remaining_s: Option<i64>,
199    /// Unknown keys round-trip.
200    #[serde(flatten)]
201    pub extra: BTreeMap<String, serde_json::Value>,
202}
203
204/// GET `/StatusPing` — the UNAUTHENTICATED readiness anchor (works
205/// mid-restart and with broken credentials; 02-02 fetches it header-less
206/// via `auth = false`).
207///
208/// States observed live: `RUNNING`, `STARTING`. Commissioning-era states
209/// are unenumerated — unknown states surface as-is and are treated as
210/// not-ready by 02-05's wait loops (02-RESEARCH Open Question 3).
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub struct StatusPing {
213    /// `RUNNING` / `STARTING` / …, verbatim from the gateway.
214    pub state: String,
215}
216
217/// One item of `/data/api/v1/modules/healthy` (or `/modules/quarantined`).
218///
219/// Quarantined items answer a REDUCED shape (openapi + live): only
220/// `id`/`name`/version-family fields are guaranteed — `state`,
221/// `licenseState`, `vendorName`, `startupTime` exist only on fully
222/// loaded modules, so they are `Option` here or the `--quarantined`
223/// list would fail to parse.
224#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
225pub struct ModuleInfo {
226    /// Module id, e.g. `"com.inductiveautomation.perspective"`.
227    pub id: String,
228    /// Human-readable module name.
229    #[serde(default)]
230    pub name: String,
231    /// Module version string.
232    #[serde(default)]
233    pub version: String,
234    /// `"ACTIVE"` / … — fully-loaded modules only.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub state: Option<String>,
237    #[serde(
238        rename = "licenseState",
239        default,
240        skip_serializing_if = "Option::is_none"
241    )]
242    pub license_state: Option<String>,
243    #[serde(
244        rename = "vendorName",
245        default,
246        skip_serializing_if = "Option::is_none"
247    )]
248    pub vendor_name: Option<String>,
249    /// The time the module started — a STRING on the wire (openapi),
250    /// not epoch ms.
251    #[serde(
252        rename = "startupTime",
253        default,
254        skip_serializing_if = "Option::is_none"
255    )]
256    pub startup_time: Option<String>,
257    /// Unknown keys (`onStartup`, `shouldUpgrade`, `description`,
258    /// `vendorId`, `selfSigned`, `certAccepted`, `reason`, …) round-trip.
259    #[serde(flatten)]
260    pub extra: BTreeMap<String, serde_json::Value>,
261}
262
263#[cfg(test)]
264mod tests {
265    use super::{ModuleInfo, Overview, StatusPing};
266
267    /// THE live-capture regression (02-RESEARCH §Status/info): the exact
268    /// overview body a commissioned 8.3.6 gateway answers with — uptime
269    /// in ms, cpu a 0–1 fraction, trialRemaining in seconds, unknown
270    /// keys (`cloudEnv`, `timezone`, …) preserved in `extra`.
271    #[test]
272    fn overview_parses_the_live_capture() {
273        let body = serde_json::json!({
274            "version": "8.3.6 (b2026042713)",
275            "redundancy": {"role": "Independent", "activityLevel": "ACTIVE", "projectState": "RUNNING"},
276            "java": {"version": "17.0.11", "vendor": "Azul Systems, Inc.", "name": "OpenJDK 64-Bit Server VM"},
277            "os": {"name": "Linux", "arch": "amd64", "version": "5.15.0"},
278            "cloudEnv": "unknown",
279            "uptime": 338137,
280            "timezone": "America/New_York",
281            "locale": "en-US",
282            "time": 1787346747022i64,
283            "memory": [338137088i64, 1073741824i64],
284            "cpu": 0.0031,
285            "disk": {"total": 62661259264i64, "used": 12272824320i64},
286            "license": {"state": "trial", "trialRemaining": 7017}
287        });
288        let overview: Overview =
289            serde_json::from_value(body).expect("the live overview shape must parse");
290        assert_eq!(overview.version, "8.3.6 (b2026042713)");
291        assert_eq!(overview.uptime, 338137, "epoch ms, verbatim");
292        assert!((overview.cpu - 0.0031).abs() < f64::EPSILON, "0–1 fraction");
293        assert_eq!(overview.memory, vec![338137088i64, 1073741824i64]);
294        assert_eq!(
295            overview
296                .license
297                .as_ref()
298                .expect("license block")
299                .trial_remaining_s,
300            Some(7017),
301            "trial countdown in seconds"
302        );
303        assert_eq!(
304            overview.java.as_ref().expect("java").vendor,
305            "Azul Systems, Inc."
306        );
307        assert_eq!(
308            overview.extra.get("cloudEnv"),
309            Some(&serde_json::json!("unknown")),
310            "unknown keys round-trip into extra"
311        );
312
313        // Fraction vs percent honesty: serializing keeps the wire value.
314        let round = serde_json::to_value(&overview).expect("serialize");
315        assert_eq!(round["cpu"], 0.0031);
316        assert_eq!(round["license"]["trialRemaining"], 7017);
317    }
318
319    /// StatusPing parses the two observed states verbatim.
320    #[test]
321    fn status_ping_parses_observed_states() {
322        for state in ["RUNNING", "STARTING"] {
323            let ping: StatusPing = serde_json::from_value(serde_json::json!({ "state": state }))
324                .expect("observed state parses");
325            assert_eq!(ping.state, state);
326        }
327    }
328
329    /// A healthy-module item parses with the fully-loaded fields AND a
330    /// quarantined item (reduced shape, `reason` passthrough) parses too.
331    #[test]
332    fn module_info_parses_healthy_and_quarantined_shapes() {
333        let healthy: ModuleInfo = serde_json::from_value(serde_json::json!({
334            "id": "com.inductiveautomation.perspective",
335            "name": "Perspective",
336            "version": "8.3.6",
337            "state": "ACTIVE",
338            "licenseState": "ACTIVATED",
339            "vendorName": "Inductive Automation",
340            "startupTime": "2026-08-21T22:03:29Z",
341            "onStartup": "ENABLE",
342            "shouldUpgrade": false
343        }))
344        .expect("healthy item parses");
345        assert_eq!(healthy.state.as_deref(), Some("ACTIVE"));
346        assert_eq!(healthy.license_state.as_deref(), Some("ACTIVATED"));
347        assert_eq!(
348            healthy.startup_time.as_deref(),
349            Some("2026-08-21T22:03:29Z")
350        );
351        assert_eq!(
352            healthy.extra.get("onStartup"),
353            Some(&serde_json::json!("ENABLE")),
354            "onStartup round-trips via extra"
355        );
356
357        let quarantined: ModuleInfo = serde_json::from_value(serde_json::json!({
358            "id": "com.example.broken",
359            "name": "Broken Module",
360            "version": "1.0.0",
361            "certAccepted": false,
362            "licenseAccepted": false,
363            "reason": "Certificate rejected"
364        }))
365        .expect("quarantined items carry a REDUCED shape (openapi) — must parse");
366        assert_eq!(quarantined.state, None);
367        assert_eq!(quarantined.license_state, None);
368        assert_eq!(
369            quarantined.extra.get("reason"),
370            Some(&serde_json::json!("Certificate rejected"))
371        );
372    }
373}