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}