Skip to main content

ignition_core/actions/
inspect.rs

1//! Inspection actions (02-02, HLTH-01/02/07): `status`, `modules`,
2//! `metrics` — serde models OUT, no printing (ARCHITECTURE.md layering:
3//! the Phase-6 TUI rides this same layer).
4//!
5//! Error contract: `status` is a read of a HEALTHY gateway — a failed
6//! sub-call is an error (exit per taxonomy), never silently degraded.
7//! Fields the gateway omits serialize as absent (`skip_serializing_if`)
8//! — the envelope never carries nulls for unknown state.
9
10use serde::Serialize;
11
12use crate::client::GatewayApi;
13use crate::client::metrics::{CurrentGauges, PerformanceCharts, ThreadCounts};
14use crate::client::query::ListQuery;
15use crate::client::status::{DiskInfo, JavaInfo, ModuleInfo, OsInfo, OverviewLicense};
16use crate::client::version::LicenseInfo;
17use crate::error::CoreError;
18
19/// `ign status` output model — gateway_info + overview + status_ping
20/// merged. Declaration order = golden field order. Key names are the
21/// documented contract: `gateway {name, ignition_version, edition,
22/// license}`, `state`, `overview {java, os, uptime_ms, memory,
23/// cpu_fraction, disk, license {state, trial_remaining_s}}` — honest
24/// units (`_ms`, `_s`, `_fraction`) instead of the gateway's bare
25/// `uptime`/`cpu`/`trialRemaining`.
26#[derive(Debug, Serialize)]
27pub struct StatusResult {
28    /// Identity block from `/data/api/v1/gateway-info`.
29    pub gateway: StatusGateway,
30    /// Running state from the unauthenticated `/StatusPing`
31    /// (`"RUNNING"` / `"STARTING"` / …).
32    pub state: String,
33    /// Runtime block from `/data/api/v1/overview`.
34    pub overview: StatusOverview,
35}
36
37/// Identity half of [`StatusResult`].
38#[derive(Debug, Serialize)]
39pub struct StatusGateway {
40    /// Gateway display name, when reported.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub name: Option<String>,
43    /// Version + build revision, e.g. `"8.3.6 (b2026042713)"`.
44    pub ignition_version: String,
45    /// Edition, when reported.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub edition: Option<String>,
48    /// License summary from gateway-info (`{mode, …}`), when reported.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub license: Option<LicenseInfo>,
51}
52
53/// Runtime half of [`StatusResult`] — the overview fields agents and
54/// humans actually read, under unit-explicit names.
55#[derive(Debug, Serialize)]
56pub struct StatusOverview {
57    /// JVM block `{version, vendor, name}`, when reported.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub java: Option<JavaInfo>,
60    /// OS block `{name, arch, version}`, when reported.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub os: Option<OsInfo>,
63    /// Uptime in epoch MILLISECONDS (gateway key: `uptime`).
64    pub uptime_ms: i64,
65    /// `[used, max]` heap bytes.
66    #[serde(skip_serializing_if = "Vec::is_empty")]
67    pub memory: Vec<i64>,
68    /// CPU utilization as a 0–1 FRACTION (gateway key: `cpu`; the
69    /// `systemPerformance/currentGauges` endpoint reports PERCENT —
70    /// `ign metrics` is that one's home).
71    pub cpu_fraction: f64,
72    /// Disk block `{total, used}` bytes, when reported.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub disk: Option<DiskInfo>,
75    /// License state incl. the trial countdown, when reported.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub license: Option<StatusLicense>,
78}
79
80/// License block of [`StatusOverview`] — `trial_remaining_s` keeps the
81/// seconds unit in the KEY itself.
82#[derive(Debug, Serialize)]
83pub struct StatusLicense {
84    /// `"trial"` / `"licensed"` / …
85    pub state: String,
86    /// Trial countdown in SECONDS, when reported.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub trial_remaining_s: Option<i64>,
89}
90
91/// `ign modules` output model.
92#[derive(Debug, Serialize)]
93pub struct ModulesResult {
94    /// The module rows (healthy or quarantined per the flag).
95    pub items: Vec<ModuleInfo>,
96    /// Whether the quarantined list was requested.
97    pub quarantined: bool,
98}
99
100/// `ign metrics` output model — current gauges + thread counts always;
101/// historic charts only under `--history`.
102#[derive(Debug, Serialize)]
103pub struct MetricsResult {
104    /// Current CPU (percent) / heap / max-heap gauges.
105    pub current: CurrentGauges,
106    /// Thread execution counts.
107    pub threads: ThreadCounts,
108    /// Historic chart datapoints (`--history` only).
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub history: Option<PerformanceCharts>,
111}
112
113/// Merge gateway_info + overview + status_ping into one payload. A
114/// failed sub-call IS an error (exit per taxonomy) — status reads a
115/// healthy gateway, it does not guess at a sick one.
116pub async fn status(api: &dyn GatewayApi) -> Result<StatusResult, CoreError> {
117    let info = api.gateway_info().await?;
118    let overview = api.overview().await?;
119    let ping = api.status_ping().await?;
120    Ok(StatusResult {
121        gateway: StatusGateway {
122            name: info.name,
123            ignition_version: info.ignition_version,
124            edition: info.edition,
125            license: info.license,
126        },
127        state: ping.state,
128        overview: StatusOverview {
129            java: overview.java,
130            os: overview.os,
131            uptime_ms: overview.uptime,
132            memory: overview.memory,
133            cpu_fraction: overview.cpu,
134            disk: overview.disk,
135            license: overview.license.map(
136                |OverviewLicense {
137                     state,
138                     trial_remaining_s,
139                     ..
140                 }| {
141                    StatusLicense {
142                        state,
143                        trial_remaining_s,
144                    }
145                },
146            ),
147        },
148    })
149}
150
151/// List healthy (default) or quarantined modules — `limit = -1` (the
152/// UI's "everything" convention).
153pub async fn modules(api: &dyn GatewayApi, quarantined: bool) -> Result<ModulesResult, CoreError> {
154    let page = api.modules(quarantined, &ListQuery::default()).await?;
155    Ok(ModulesResult {
156        items: page.items,
157        quarantined,
158    })
159}
160
161/// Current gauges + thread counts; historic charts only when asked (the
162/// charts body is the heaviest of the three — default output stays lean).
163pub async fn metrics(
164    api: &dyn GatewayApi,
165    include_history: bool,
166) -> Result<MetricsResult, CoreError> {
167    let current = api.metrics_current().await?;
168    let threads = api.metrics_threads().await?;
169    let history = if include_history {
170        Some(api.metrics_historic().await?)
171    } else {
172        None
173    };
174    Ok(MetricsResult {
175        current,
176        threads,
177        history,
178    })
179}
180
181#[cfg(test)]
182mod tests {
183    use super::{MetricsResult, ModulesResult, StatusResult, metrics, modules, status};
184    use crate::client::GatewayApi;
185    use crate::client::metrics::{CurrentGauges, Datapoint, PerformanceCharts, ThreadCounts};
186    use crate::client::query::{ListEnvelope, ListMetadata};
187    use crate::client::status::{ModuleInfo, Overview, StatusPing};
188    use crate::client::version::{GatewayInfo, LicenseInfo};
189    use crate::error::CoreError;
190
191    use std::sync::Mutex;
192
193    /// Captured-shaped healthy-rig double (01-04 pattern: outcomes are
194    /// values; `CoreError`s that need a `reqwest::Error` are constructed
195    /// lazily). Records every `modules(quarantined)` flag it serves.
196    struct HealthyRig {
197        modules_flags: Mutex<Vec<bool>>,
198    }
199
200    fn overview_fixture() -> Overview {
201        serde_json::from_value(serde_json::json!({
202            "version": "8.3.6 (b2026042713)",
203            "java": {"version": "17.0.11", "vendor": "Azul Systems, Inc.", "name": "OpenJDK 64-Bit Server VM"},
204            "os": {"name": "Linux", "arch": "amd64", "version": "5.15.0"},
205            "uptime": 338137,
206            "memory": [338137088i64, 1073741824i64],
207            "cpu": 0.0031,
208            "disk": {"total": 62661259264i64, "used": 12272824320i64},
209            "license": {"state": "trial", "trialRemaining": 7017}
210        }))
211        .expect("fixture overview parses")
212    }
213
214    fn gauges_fixture() -> CurrentGauges {
215        serde_json::from_value(serde_json::json!({
216            "cpu": 4.88, "heapMemory": 240000000i64, "maxMemory": 1073741824i64
217        }))
218        .expect("fixture gauges parse")
219    }
220
221    fn charts_fixture() -> PerformanceCharts {
222        PerformanceCharts {
223            cpu_datapoints: vec![Datapoint {
224                hist_id: 1,
225                timestamp: 1787346747022,
226                value: 4.88,
227            }],
228            heap_memory_datapoints: vec![Datapoint {
229                hist_id: 2,
230                timestamp: 1787346747022,
231                value: 240000000.0,
232            }],
233            non_heap_memory_datapoints: Vec::new(),
234        }
235    }
236
237    #[async_trait::async_trait]
238    impl GatewayApi for HealthyRig {
239        async fn thread_dump(
240            &self,
241        ) -> Result<crate::client::threads::FormattedThreadDump, CoreError> {
242            unreachable!("not part of this action")
243        }
244        async fn thread_deadlocks(
245            &self,
246        ) -> Result<crate::client::threads::DeadlocksWire, CoreError> {
247            unreachable!("not part of this action")
248        }
249        async fn uninstall_module(
250            &self,
251            _gateway_module_id: &str,
252            _module_id: &str,
253        ) -> Result<(), CoreError> {
254            unreachable!("not part of this action")
255        }
256        async fn bundle_generate(
257            &self,
258        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
259            unreachable!("not part of this action")
260        }
261        async fn bundle_status(
262            &self,
263        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
264            unreachable!("not part of this action")
265        }
266        async fn bundle_download(
267            &self,
268            _out: &std::path::Path,
269        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
270            unreachable!("not part of this action")
271        }
272        async fn tag_provider_list(
273            &self,
274            _query: &crate::client::query::ListQuery,
275        ) -> Result<
276            crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
277            CoreError,
278        > {
279            unreachable!("not part of this action")
280        }
281        async fn tag_provider_find(
282            &self,
283            _name: &str,
284        ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
285            unreachable!("not part of this action")
286        }
287        async fn tag_provider_create(
288            &self,
289            _body: &[crate::client::tags::TagProviderCreate],
290        ) -> Result<(), CoreError> {
291            unreachable!("not part of this action")
292        }
293        async fn tag_provider_delete(
294            &self,
295            _name: &str,
296            _signature: &str,
297        ) -> Result<(), CoreError> {
298            unreachable!("not part of this action")
299        }
300        async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
301            unreachable!("not part of this action")
302        }
303        async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
304            unreachable!("not part of this action")
305        }
306        async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
307            unreachable!("not part of this action")
308        }
309        async fn backup_download(
310            &self,
311            _out: &std::path::Path,
312            _backup_type: crate::client::backup::BackupType,
313        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
314            unreachable!("not part of this action")
315        }
316        async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
317            unreachable!("not part of this action")
318        }
319        async fn eam_task_history(
320            &self,
321            _limit: Option<u32>,
322            _search: Option<&str>,
323        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
324        {
325            unreachable!("not part of this action")
326        }
327        async fn eam_task_definitions(
328            &self,
329        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
330        {
331            unreachable!("not part of this action")
332        }
333        async fn eam_task_find(
334            &self,
335            _name: &str,
336        ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
337            unreachable!("not part of this action")
338        }
339        async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
340            unreachable!("not part of this action")
341        }
342        async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
343            unreachable!("not part of this action")
344        }
345        async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
346            unreachable!("not part of this action")
347        }
348        async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
349            unreachable!("not part of this action")
350        }
351        async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
352            unreachable!("not part of this action")
353        }
354        async fn eam_tasks_scheduled(
355            &self,
356            _running: bool,
357        ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
358            unreachable!("not part of this action")
359        }
360        async fn eam_task_modify(
361            &self,
362            _definition: &serde_json::Value,
363        ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
364            unreachable!("not part of this action")
365        }
366        async fn eam_task_delete(
367            &self,
368            _name: &str,
369            _signature: &str,
370            _confirm: bool,
371        ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
372            unreachable!("not part of this action")
373        }
374        async fn api_call(
375            &self,
376            _call: &crate::client::apicall::ApiCallRequest,
377        ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
378            unreachable!("not part of this action")
379        }
380        async fn license_status(
381            &self,
382        ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
383            unreachable!("not part of this action")
384        }
385        async fn redundancy_status(
386            &self,
387        ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
388            unreachable!("not part of this action")
389        }
390        async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
391            unreachable!("not part of this action")
392        }
393        async fn gateway_info(&self) -> Result<GatewayInfo, CoreError> {
394            Ok(GatewayInfo {
395                name: Some("ign-mock".into()),
396                redundancy_role: Some("Independent".into()),
397                edition: Some("standard".into()),
398                ignition_version: "8.3.6 (b2026042713)".into(),
399                jvm_version: Some("17.0.11".into()),
400                license: Some(LicenseInfo {
401                    mode: "Trial".into(),
402                    expiration_date: Some("2026-08-24T19:00:00Z".into()),
403                }),
404                endpoint: None,
405            })
406        }
407        async fn overview(&self) -> Result<Overview, CoreError> {
408            Ok(overview_fixture())
409        }
410        async fn status_ping(&self) -> Result<StatusPing, CoreError> {
411            Ok(StatusPing {
412                state: "RUNNING".into(),
413            })
414        }
415        async fn modules(
416            &self,
417            quarantined: bool,
418            _query: &crate::client::query::ListQuery,
419        ) -> Result<ListEnvelope<ModuleInfo>, CoreError> {
420            self.modules_flags
421                .lock()
422                .expect("flags lock")
423                .push(quarantined);
424            let item = ModuleInfo {
425                id: "com.inductiveautomation.perspective".into(),
426                name: "Perspective".into(),
427                version: "8.3.6".into(),
428                state: Some("ACTIVE".into()),
429                license_state: Some("ACTIVATED".into()),
430                vendor_name: Some("Inductive Automation".into()),
431                startup_time: Some("2026-08-21 22:03:29".into()),
432                extra: Default::default(),
433            };
434            Ok(ListEnvelope {
435                items: vec![item],
436                metadata: ListMetadata {
437                    total: 1,
438                    matching: 1,
439                    limit: -1,
440                    offset: 0,
441                },
442            })
443        }
444        async fn metrics_current(&self) -> Result<CurrentGauges, CoreError> {
445            Ok(gauges_fixture())
446        }
447        async fn metrics_historic(&self) -> Result<PerformanceCharts, CoreError> {
448            Ok(charts_fixture())
449        }
450        async fn metrics_threads(&self) -> Result<ThreadCounts, CoreError> {
451            Ok(ThreadCounts {
452                running: 32,
453                waiting: 39,
454                timed_waiting: 51,
455                blocked: 0,
456                extra: Default::default(),
457            })
458        }
459        async fn designers(
460            &self,
461            _query: &crate::client::query::ListQuery,
462        ) -> Result<ListEnvelope<crate::client::sessions::DesignerInfo>, CoreError> {
463            unreachable!("not part of this double's actions")
464        }
465        async fn perspective_sessions(
466            &self,
467            _query: &crate::client::query::ListQuery,
468        ) -> Result<ListEnvelope<crate::client::sessions::PerspectiveSession>, CoreError> {
469            unreachable!("not part of this double's actions")
470        }
471        async fn vision_clients(
472            &self,
473            _query: &crate::client::query::ListQuery,
474        ) -> Result<ListEnvelope<crate::client::sessions::VisionClient>, CoreError> {
475            unreachable!("not part of this double's actions")
476        }
477        async fn terminate_perspective_session(
478            &self,
479            _id: &str,
480            _message: Option<&str>,
481        ) -> Result<(), CoreError> {
482            unreachable!("not part of this double's actions")
483        }
484        async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
485            unreachable!("not part of this double's actions")
486        }
487        async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
488            unreachable!("not part of this double's actions")
489        }
490        async fn database_connections(
491            &self,
492        ) -> Result<
493            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
494            CoreError,
495        > {
496            unreachable!("not part of this double's actions")
497        }
498        async fn opc_connections(
499            &self,
500        ) -> Result<
501            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
502            CoreError,
503        > {
504            unreachable!("not part of this double's actions")
505        }
506
507        async fn logs(
508            &self,
509            _filter: &crate::client::logs::LogQuery,
510        ) -> Result<ListEnvelope<crate::client::logs::LogEntry>, CoreError> {
511            unreachable!("not part of this double's actions")
512        }
513        async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
514            unreachable!("not part of this double's actions")
515        }
516        async fn loggers(
517            &self,
518            _query: &crate::client::query::ListQuery,
519        ) -> Result<ListEnvelope<crate::client::logs::LoggerInfo>, CoreError> {
520            unreachable!("not part of this double's actions")
521        }
522        async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
523            unreachable!("not part of this double's actions")
524        }
525        async fn reset_logger_levels(&self) -> Result<(), CoreError> {
526            unreachable!("not part of this double's actions")
527        }
528        async fn restart(&self) -> Result<(), CoreError> {
529            unreachable!("not part of this action")
530        }
531        async fn scan_projects(&self) -> Result<(), CoreError> {
532            unreachable!("not part of this action")
533        }
534        async fn security_properties(
535            &self,
536        ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
537            unreachable!("not part of this action")
538        }
539        async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
540            unreachable!("not part of this action")
541        }
542        async fn webdev_route_call(
543            &self,
544            _project: &str,
545            _route: &str,
546            _body: &serde_json::Value,
547            _extra_headers: &[(&str, &str)],
548        ) -> Result<serde_json::Value, CoreError> {
549            unreachable!("not part of this action")
550        }
551        async fn webdev_route_probe(
552            &self,
553            _project: &str,
554            _route: &str,
555            _extra_headers: &[(&str, &str)],
556        ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
557            unreachable!("not part of this action")
558        }
559        async fn projects(
560            &self,
561            _query: &crate::client::query::ListQuery,
562        ) -> Result<
563            crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
564            CoreError,
565        > {
566            unreachable!("not part of this action")
567        }
568        async fn project_find(
569            &self,
570            _name: &str,
571        ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
572            unreachable!("not part of this action")
573        }
574        async fn project_create(
575            &self,
576            _body: &crate::client::projects::ProjectCreate,
577        ) -> Result<(), CoreError> {
578            unreachable!("not part of this action")
579        }
580        async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
581            unreachable!("not part of this action")
582        }
583        async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
584            unreachable!("not part of this action")
585        }
586        async fn project_modify(
587            &self,
588            _name: &str,
589            _body: &crate::client::projects::ProjectModify,
590        ) -> Result<(), CoreError> {
591            unreachable!("not part of this action")
592        }
593        async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
594            unreachable!("not part of this action")
595        }
596        async fn project_export_to_file(
597            &self,
598            _name: &str,
599            _out: &std::path::Path,
600        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
601            unreachable!("not part of this action")
602        }
603        async fn project_import(
604            &self,
605            _name: &str,
606            _zip: Vec<u8>,
607            _overwrite: bool,
608        ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
609            unreachable!("not part of this action")
610        }
611    }
612
613    /// status merges all three sources under the documented keys —
614    /// `uptime_ms`/`cpu_fraction`/`trial_remaining_s` honest names.
615    #[tokio::test]
616    async fn status_merges_gateway_overview_and_ping() {
617        let rig = HealthyRig {
618            modules_flags: Mutex::new(Vec::new()),
619        };
620        let result: StatusResult = status(&rig).await.expect("healthy rig merges");
621
622        // The documented data keys serialize exactly (string-level, like
623        // the error-envelope golden — key order is contract). Serialized
624        // FIRST: the field assertions below move out of `result`.
625        let json = serde_json::to_string(&result).expect("serialize");
626        assert_eq!(
627            json,
628            concat!(
629                r#"{"gateway":{"name":"ign-mock","ignition_version":"8.3.6 (b2026042713)","edition":"standard","#,
630                r#""license":{"mode":"Trial","expirationDate":"2026-08-24T19:00:00Z"}},"state":"RUNNING","#,
631                r#""overview":{"java":{"version":"17.0.11","vendor":"Azul Systems, Inc.","name":"OpenJDK 64-Bit Server VM"},"#,
632                r#""os":{"name":"Linux","arch":"amd64","version":"5.15.0"},"uptime_ms":338137,"memory":[338137088,1073741824],"#,
633                r#""cpu_fraction":0.0031,"disk":{"total":62661259264,"used":12272824320},"#,
634                r#""license":{"state":"trial","trial_remaining_s":7017}}}"#
635            ),
636            "data keys are the documented contract"
637        );
638
639        assert_eq!(result.gateway.ignition_version, "8.3.6 (b2026042713)");
640        assert_eq!(result.gateway.name.as_deref(), Some("ign-mock"));
641        assert_eq!(result.state, "RUNNING");
642        assert_eq!(result.overview.uptime_ms, 338137);
643        assert!((result.overview.cpu_fraction - 0.0031).abs() < f64::EPSILON);
644        let license = result.overview.license.expect("license block");
645        assert_eq!(license.state, "trial");
646        assert_eq!(license.trial_remaining_s, Some(7017));
647    }
648
649    /// A failed sub-call is an error (exit per taxonomy) — never a
650    /// degraded payload. BrokenOverview: gateway_info OK, overview 401.
651    struct BrokenOverview;
652
653    #[async_trait::async_trait]
654    impl GatewayApi for BrokenOverview {
655        async fn thread_dump(
656            &self,
657        ) -> Result<crate::client::threads::FormattedThreadDump, CoreError> {
658            unreachable!("not part of this action")
659        }
660        async fn thread_deadlocks(
661            &self,
662        ) -> Result<crate::client::threads::DeadlocksWire, CoreError> {
663            unreachable!("not part of this action")
664        }
665        async fn uninstall_module(
666            &self,
667            _gateway_module_id: &str,
668            _module_id: &str,
669        ) -> Result<(), CoreError> {
670            unreachable!("not part of this action")
671        }
672        async fn bundle_generate(
673            &self,
674        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
675            unreachable!("not part of this action")
676        }
677        async fn bundle_status(
678            &self,
679        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
680            unreachable!("not part of this action")
681        }
682        async fn bundle_download(
683            &self,
684            _out: &std::path::Path,
685        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
686            unreachable!("not part of this action")
687        }
688        async fn tag_provider_list(
689            &self,
690            _query: &crate::client::query::ListQuery,
691        ) -> Result<
692            crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
693            CoreError,
694        > {
695            unreachable!("not part of this action")
696        }
697        async fn tag_provider_find(
698            &self,
699            _name: &str,
700        ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
701            unreachable!("not part of this action")
702        }
703        async fn tag_provider_create(
704            &self,
705            _body: &[crate::client::tags::TagProviderCreate],
706        ) -> Result<(), CoreError> {
707            unreachable!("not part of this action")
708        }
709        async fn tag_provider_delete(
710            &self,
711            _name: &str,
712            _signature: &str,
713        ) -> Result<(), CoreError> {
714            unreachable!("not part of this action")
715        }
716        async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
717            unreachable!("not part of this action")
718        }
719        async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
720            unreachable!("not part of this action")
721        }
722        async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
723            unreachable!("not part of this action")
724        }
725        async fn backup_download(
726            &self,
727            _out: &std::path::Path,
728            _backup_type: crate::client::backup::BackupType,
729        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
730            unreachable!("not part of this action")
731        }
732        async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
733            unreachable!("not part of this action")
734        }
735        async fn eam_task_history(
736            &self,
737            _limit: Option<u32>,
738            _search: Option<&str>,
739        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
740        {
741            unreachable!("not part of this action")
742        }
743        async fn eam_task_definitions(
744            &self,
745        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
746        {
747            unreachable!("not part of this action")
748        }
749        async fn eam_task_find(
750            &self,
751            _name: &str,
752        ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
753            unreachable!("not part of this action")
754        }
755        async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
756            unreachable!("not part of this action")
757        }
758        async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
759            unreachable!("not part of this action")
760        }
761        async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
762            unreachable!("not part of this action")
763        }
764        async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
765            unreachable!("not part of this action")
766        }
767        async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
768            unreachable!("not part of this action")
769        }
770        async fn eam_tasks_scheduled(
771            &self,
772            _running: bool,
773        ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
774            unreachable!("not part of this action")
775        }
776        async fn eam_task_modify(
777            &self,
778            _definition: &serde_json::Value,
779        ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
780            unreachable!("not part of this action")
781        }
782        async fn eam_task_delete(
783            &self,
784            _name: &str,
785            _signature: &str,
786            _confirm: bool,
787        ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
788            unreachable!("not part of this action")
789        }
790        async fn api_call(
791            &self,
792            _call: &crate::client::apicall::ApiCallRequest,
793        ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
794            unreachable!("not part of this action")
795        }
796        async fn license_status(
797            &self,
798        ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
799            unreachable!("not part of this action")
800        }
801        async fn redundancy_status(
802            &self,
803        ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
804            unreachable!("not part of this action")
805        }
806        async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
807            unreachable!("not part of this action")
808        }
809        async fn gateway_info(&self) -> Result<GatewayInfo, CoreError> {
810            Ok(GatewayInfo {
811                name: None,
812                redundancy_role: None,
813                edition: None,
814                ignition_version: "8.3.6 (b2026042713)".into(),
815                jvm_version: None,
816                license: None,
817                endpoint: None,
818            })
819        }
820        async fn overview(&self) -> Result<Overview, CoreError> {
821            Err(CoreError::Auth {
822                status: 401,
823                endpoint: Some("http://gw.example.com/data/api/v1/overview".into()),
824            })
825        }
826        async fn status_ping(&self) -> Result<StatusPing, CoreError> {
827            unreachable!("status() must fail at overview() before pinging")
828        }
829        async fn modules(
830            &self,
831            _quarantined: bool,
832            _query: &crate::client::query::ListQuery,
833        ) -> Result<ListEnvelope<ModuleInfo>, CoreError> {
834            unreachable!("not part of this action")
835        }
836        async fn metrics_current(&self) -> Result<CurrentGauges, CoreError> {
837            unreachable!("not part of this action")
838        }
839        async fn metrics_historic(&self) -> Result<PerformanceCharts, CoreError> {
840            unreachable!("not part of this action")
841        }
842        async fn metrics_threads(&self) -> Result<ThreadCounts, CoreError> {
843            unreachable!("not part of this action")
844        }
845        async fn designers(
846            &self,
847            _query: &crate::client::query::ListQuery,
848        ) -> Result<ListEnvelope<crate::client::sessions::DesignerInfo>, CoreError> {
849            unreachable!("not part of this action")
850        }
851        async fn perspective_sessions(
852            &self,
853            _query: &crate::client::query::ListQuery,
854        ) -> Result<ListEnvelope<crate::client::sessions::PerspectiveSession>, CoreError> {
855            unreachable!("not part of this action")
856        }
857        async fn vision_clients(
858            &self,
859            _query: &crate::client::query::ListQuery,
860        ) -> Result<ListEnvelope<crate::client::sessions::VisionClient>, CoreError> {
861            unreachable!("not part of this action")
862        }
863        async fn terminate_perspective_session(
864            &self,
865            _id: &str,
866            _message: Option<&str>,
867        ) -> Result<(), CoreError> {
868            unreachable!("not part of this action")
869        }
870        async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
871            unreachable!("not part of this action")
872        }
873        async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
874            unreachable!("not part of this action")
875        }
876        async fn database_connections(
877            &self,
878        ) -> Result<
879            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
880            CoreError,
881        > {
882            unreachable!("not part of this action")
883        }
884        async fn opc_connections(
885            &self,
886        ) -> Result<
887            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
888            CoreError,
889        > {
890            unreachable!("not part of this action")
891        }
892
893        async fn logs(
894            &self,
895            _filter: &crate::client::logs::LogQuery,
896        ) -> Result<ListEnvelope<crate::client::logs::LogEntry>, CoreError> {
897            unreachable!("not part of this double's actions")
898        }
899        async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
900            unreachable!("not part of this double's actions")
901        }
902        async fn loggers(
903            &self,
904            _query: &crate::client::query::ListQuery,
905        ) -> Result<ListEnvelope<crate::client::logs::LoggerInfo>, CoreError> {
906            unreachable!("not part of this double's actions")
907        }
908        async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
909            unreachable!("not part of this double's actions")
910        }
911        async fn reset_logger_levels(&self) -> Result<(), CoreError> {
912            unreachable!("not part of this double's actions")
913        }
914        async fn restart(&self) -> Result<(), CoreError> {
915            unreachable!("not part of this action")
916        }
917        async fn scan_projects(&self) -> Result<(), CoreError> {
918            unreachable!("not part of this action")
919        }
920        async fn security_properties(
921            &self,
922        ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
923            unreachable!("not part of this action")
924        }
925        async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
926            unreachable!("not part of this action")
927        }
928        async fn webdev_route_call(
929            &self,
930            _project: &str,
931            _route: &str,
932            _body: &serde_json::Value,
933            _extra_headers: &[(&str, &str)],
934        ) -> Result<serde_json::Value, CoreError> {
935            unreachable!("not part of this action")
936        }
937        async fn webdev_route_probe(
938            &self,
939            _project: &str,
940            _route: &str,
941            _extra_headers: &[(&str, &str)],
942        ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
943            unreachable!("not part of this action")
944        }
945        async fn projects(
946            &self,
947            _query: &crate::client::query::ListQuery,
948        ) -> Result<
949            crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
950            CoreError,
951        > {
952            unreachable!("not part of this action")
953        }
954        async fn project_find(
955            &self,
956            _name: &str,
957        ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
958            unreachable!("not part of this action")
959        }
960        async fn project_create(
961            &self,
962            _body: &crate::client::projects::ProjectCreate,
963        ) -> Result<(), CoreError> {
964            unreachable!("not part of this action")
965        }
966        async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
967            unreachable!("not part of this action")
968        }
969        async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
970            unreachable!("not part of this action")
971        }
972        async fn project_modify(
973            &self,
974            _name: &str,
975            _body: &crate::client::projects::ProjectModify,
976        ) -> Result<(), CoreError> {
977            unreachable!("not part of this action")
978        }
979        async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
980            unreachable!("not part of this action")
981        }
982        async fn project_export_to_file(
983            &self,
984            _name: &str,
985            _out: &std::path::Path,
986        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
987            unreachable!("not part of this action")
988        }
989        async fn project_import(
990            &self,
991            _name: &str,
992            _zip: Vec<u8>,
993            _overwrite: bool,
994        ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
995            unreachable!("not part of this action")
996        }
997    }
998
999    #[tokio::test]
1000    async fn status_propagates_subcall_errors() {
1001        let err = status(&BrokenOverview).await.expect_err("401 propagates");
1002        assert!(matches!(&err, CoreError::Auth { status: 401, .. }));
1003        assert_eq!(err.exit_code(), 5);
1004    }
1005
1006    /// modules() forwards the quarantined flag and wraps the envelope.
1007    #[tokio::test]
1008    async fn modules_forwards_the_quarantined_flag() {
1009        let rig = HealthyRig {
1010            modules_flags: Mutex::new(Vec::new()),
1011        };
1012        let healthy: ModulesResult = modules(&rig, false).await.expect("healthy list");
1013        let quarantined: ModulesResult = modules(&rig, true).await.expect("quarantined list");
1014        assert!(!healthy.quarantined && healthy.items.len() == 1);
1015        assert!(quarantined.quarantined);
1016        assert_eq!(
1017            *rig.modules_flags.lock().expect("flags lock"),
1018            vec![false, true],
1019            "the flag reached the client seam in call order"
1020        );
1021    }
1022
1023    /// metrics() defaults to current+threads (no charts call); with
1024    /// history the charts ride along.
1025    #[tokio::test]
1026    async fn metrics_history_is_opt_in() {
1027        let rig = HealthyRig {
1028            modules_flags: Mutex::new(Vec::new()),
1029        };
1030        let lean: MetricsResult = metrics(&rig, false).await.expect("lean metrics");
1031        assert!(lean.history.is_none());
1032        assert_eq!(lean.threads.running, 32);
1033        assert!((lean.current.cpu - 4.88).abs() < f64::EPSILON);
1034
1035        let full: MetricsResult = metrics(&rig, true).await.expect("full metrics");
1036        let charts = full.history.expect("charts included");
1037        assert_eq!(charts.cpu_datapoints.len(), 1);
1038    }
1039}