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}