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"` / `"Backup"` / `"Primary"`, when reported.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub role: Option<String>,
126    #[serde(
127        rename = "activityLevel",
128        default,
129        skip_serializing_if = "Option::is_none"
130    )]
131    pub activity_level: Option<String>,
132    #[serde(
133        rename = "projectState",
134        default,
135        skip_serializing_if = "Option::is_none"
136    )]
137    pub project_state: Option<String>,
138    /// Unknown keys round-trip.
139    #[serde(flatten)]
140    pub extra: BTreeMap<String, serde_json::Value>,
141}
142
143/// `overview.java` — `{version, vendor, name}`.
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145pub struct JavaInfo {
146    /// e.g. `"17.0.11"`.
147    #[serde(default)]
148    pub version: String,
149    /// e.g. `"Azul Systems, Inc."`.
150    #[serde(default)]
151    pub vendor: String,
152    /// e.g. `"OpenJDK 64-Bit Server VM"`.
153    #[serde(default)]
154    pub name: String,
155}
156
157/// `overview.os` — `{name, arch, version}`.
158#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
159pub struct OsInfo {
160    /// e.g. `"Linux"`.
161    #[serde(default)]
162    pub name: String,
163    /// e.g. `"amd64"`.
164    #[serde(default)]
165    pub arch: String,
166    /// e.g. `"5.15.0-91-generic"`, when reported.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub version: Option<String>,
169}
170
171/// `overview.disk` — `{total, used}` bytes.
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173pub struct DiskInfo {
174    /// Total bytes.
175    #[serde(default)]
176    pub total: i64,
177    /// Used bytes.
178    #[serde(default)]
179    pub used: i64,
180}
181
182/// `overview.license` — `{state, trialRemaining}` (suffixed `_s` in Rust
183/// to make the unit explicit; serialized under the gateway-native key).
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185pub struct OverviewLicense {
186    /// `"trial"` / `"licensed"` / …
187    #[serde(default)]
188    pub state: String,
189    /// Trial countdown in epoch **SECONDS** (live capture: `7017` ≈
190    /// 1h57m), when reported (absent on licensed gateways).
191    #[serde(
192        rename = "trialRemaining",
193        default,
194        skip_serializing_if = "Option::is_none"
195    )]
196    pub trial_remaining_s: Option<i64>,
197    /// Unknown keys round-trip.
198    #[serde(flatten)]
199    pub extra: BTreeMap<String, serde_json::Value>,
200}
201
202/// GET `/StatusPing` — the UNAUTHENTICATED readiness anchor (works
203/// mid-restart and with broken credentials; 02-02 fetches it header-less
204/// via `auth = false`).
205///
206/// States observed live: `RUNNING`, `STARTING`. Commissioning-era states
207/// are unenumerated — unknown states surface as-is and are treated as
208/// not-ready by 02-05's wait loops (02-RESEARCH Open Question 3).
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210pub struct StatusPing {
211    /// `RUNNING` / `STARTING` / …, verbatim from the gateway.
212    pub state: String,
213}
214
215/// One item of `/data/api/v1/modules/healthy` (or `/modules/quarantined`).
216///
217/// Quarantined items answer a REDUCED shape (openapi + live): only
218/// `id`/`name`/version-family fields are guaranteed — `state`,
219/// `licenseState`, `vendorName`, `startupTime` exist only on fully
220/// loaded modules, so they are `Option` here or the `--quarantined`
221/// list would fail to parse.
222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
223pub struct ModuleInfo {
224    /// Module id, e.g. `"com.inductiveautomation.perspective"`.
225    pub id: String,
226    /// Human-readable module name.
227    #[serde(default)]
228    pub name: String,
229    /// Module version string.
230    #[serde(default)]
231    pub version: String,
232    /// `"ACTIVE"` / … — fully-loaded modules only.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub state: Option<String>,
235    #[serde(
236        rename = "licenseState",
237        default,
238        skip_serializing_if = "Option::is_none"
239    )]
240    pub license_state: Option<String>,
241    #[serde(
242        rename = "vendorName",
243        default,
244        skip_serializing_if = "Option::is_none"
245    )]
246    pub vendor_name: Option<String>,
247    /// The time the module started — a STRING on the wire (openapi),
248    /// not epoch ms.
249    #[serde(
250        rename = "startupTime",
251        default,
252        skip_serializing_if = "Option::is_none"
253    )]
254    pub startup_time: Option<String>,
255    /// Unknown keys (`onStartup`, `shouldUpgrade`, `description`,
256    /// `vendorId`, `selfSigned`, `certAccepted`, `reason`, …) round-trip.
257    #[serde(flatten)]
258    pub extra: BTreeMap<String, serde_json::Value>,
259}
260
261#[cfg(test)]
262mod tests {
263    use super::{ModuleInfo, Overview, StatusPing};
264
265    /// THE live-capture regression (02-RESEARCH §Status/info): the exact
266    /// overview body a commissioned 8.3.6 gateway answers with — uptime
267    /// in ms, cpu a 0–1 fraction, trialRemaining in seconds, unknown
268    /// keys (`cloudEnv`, `timezone`, …) preserved in `extra`.
269    #[test]
270    fn overview_parses_the_live_capture() {
271        let body = serde_json::json!({
272            "version": "8.3.6 (b2026042713)",
273            "redundancy": {"role": "Independent", "activityLevel": "ACTIVE", "projectState": "RUNNING"},
274            "java": {"version": "17.0.11", "vendor": "Azul Systems, Inc.", "name": "OpenJDK 64-Bit Server VM"},
275            "os": {"name": "Linux", "arch": "amd64", "version": "5.15.0"},
276            "cloudEnv": "unknown",
277            "uptime": 338137,
278            "timezone": "America/New_York",
279            "locale": "en-US",
280            "time": 1787346747022i64,
281            "memory": [338137088i64, 1073741824i64],
282            "cpu": 0.0031,
283            "disk": {"total": 62661259264i64, "used": 12272824320i64},
284            "license": {"state": "trial", "trialRemaining": 7017}
285        });
286        let overview: Overview =
287            serde_json::from_value(body).expect("the live overview shape must parse");
288        assert_eq!(overview.version, "8.3.6 (b2026042713)");
289        assert_eq!(overview.uptime, 338137, "epoch ms, verbatim");
290        assert!((overview.cpu - 0.0031).abs() < f64::EPSILON, "0–1 fraction");
291        assert_eq!(overview.memory, vec![338137088i64, 1073741824i64]);
292        assert_eq!(
293            overview
294                .license
295                .as_ref()
296                .expect("license block")
297                .trial_remaining_s,
298            Some(7017),
299            "trial countdown in seconds"
300        );
301        assert_eq!(
302            overview.java.as_ref().expect("java").vendor,
303            "Azul Systems, Inc."
304        );
305        assert_eq!(
306            overview.extra.get("cloudEnv"),
307            Some(&serde_json::json!("unknown")),
308            "unknown keys round-trip into extra"
309        );
310
311        // Fraction vs percent honesty: serializing keeps the wire value.
312        let round = serde_json::to_value(&overview).expect("serialize");
313        assert_eq!(round["cpu"], 0.0031);
314        assert_eq!(round["license"]["trialRemaining"], 7017);
315    }
316
317    /// StatusPing parses the two observed states verbatim.
318    #[test]
319    fn status_ping_parses_observed_states() {
320        for state in ["RUNNING", "STARTING"] {
321            let ping: StatusPing = serde_json::from_value(serde_json::json!({ "state": state }))
322                .expect("observed state parses");
323            assert_eq!(ping.state, state);
324        }
325    }
326
327    /// A healthy-module item parses with the fully-loaded fields AND a
328    /// quarantined item (reduced shape, `reason` passthrough) parses too.
329    #[test]
330    fn module_info_parses_healthy_and_quarantined_shapes() {
331        let healthy: ModuleInfo = serde_json::from_value(serde_json::json!({
332            "id": "com.inductiveautomation.perspective",
333            "name": "Perspective",
334            "version": "8.3.6",
335            "state": "ACTIVE",
336            "licenseState": "ACTIVATED",
337            "vendorName": "Inductive Automation",
338            "startupTime": "2026-08-21T22:03:29Z",
339            "onStartup": "ENABLE",
340            "shouldUpgrade": false
341        }))
342        .expect("healthy item parses");
343        assert_eq!(healthy.state.as_deref(), Some("ACTIVE"));
344        assert_eq!(healthy.license_state.as_deref(), Some("ACTIVATED"));
345        assert_eq!(
346            healthy.startup_time.as_deref(),
347            Some("2026-08-21T22:03:29Z")
348        );
349        assert_eq!(
350            healthy.extra.get("onStartup"),
351            Some(&serde_json::json!("ENABLE")),
352            "onStartup round-trips via extra"
353        );
354
355        let quarantined: ModuleInfo = serde_json::from_value(serde_json::json!({
356            "id": "com.example.broken",
357            "name": "Broken Module",
358            "version": "1.0.0",
359            "certAccepted": false,
360            "licenseAccepted": false,
361            "reason": "Certificate rejected"
362        }))
363        .expect("quarantined items carry a REDUCED shape (openapi) — must parse");
364        assert_eq!(quarantined.state, None);
365        assert_eq!(quarantined.license_state, None);
366        assert_eq!(
367            quarantined.extra.get("reason"),
368            Some(&serde_json::json!("Certificate rejected"))
369        );
370    }
371}