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 uninstall_module(
240            &self,
241            _gateway_module_id: &str,
242            _module_id: &str,
243        ) -> Result<(), CoreError> {
244            unreachable!("not part of this action")
245        }
246        async fn bundle_generate(
247            &self,
248        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
249            unreachable!("not part of this action")
250        }
251        async fn bundle_status(
252            &self,
253        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
254            unreachable!("not part of this action")
255        }
256        async fn bundle_download(
257            &self,
258            _out: &std::path::Path,
259        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
260            unreachable!("not part of this action")
261        }
262        async fn tag_provider_list(
263            &self,
264            _query: &crate::client::query::ListQuery,
265        ) -> Result<
266            crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
267            CoreError,
268        > {
269            unreachable!("not part of this action")
270        }
271        async fn tag_provider_find(
272            &self,
273            _name: &str,
274        ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
275            unreachable!("not part of this action")
276        }
277        async fn tag_provider_create(
278            &self,
279            _body: &[crate::client::tags::TagProviderCreate],
280        ) -> Result<(), CoreError> {
281            unreachable!("not part of this action")
282        }
283        async fn tag_provider_delete(
284            &self,
285            _name: &str,
286            _signature: &str,
287        ) -> Result<(), CoreError> {
288            unreachable!("not part of this action")
289        }
290        async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
291            unreachable!("not part of this action")
292        }
293        async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
294            unreachable!("not part of this action")
295        }
296        async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
297            unreachable!("not part of this action")
298        }
299        async fn backup_download(
300            &self,
301            _out: &std::path::Path,
302            _backup_type: crate::client::backup::BackupType,
303        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
304            unreachable!("not part of this action")
305        }
306        async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
307            unreachable!("not part of this action")
308        }
309        async fn eam_task_history(
310            &self,
311            _limit: Option<u32>,
312            _search: Option<&str>,
313        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
314        {
315            unreachable!("not part of this action")
316        }
317        async fn eam_task_definitions(
318            &self,
319        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
320        {
321            unreachable!("not part of this action")
322        }
323        async fn eam_task_find(
324            &self,
325            _name: &str,
326        ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
327            unreachable!("not part of this action")
328        }
329        async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
330            unreachable!("not part of this action")
331        }
332        async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
333            unreachable!("not part of this action")
334        }
335        async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
336            unreachable!("not part of this action")
337        }
338        async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
339            unreachable!("not part of this action")
340        }
341        async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
342            unreachable!("not part of this action")
343        }
344        async fn eam_tasks_scheduled(
345            &self,
346            _running: bool,
347        ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
348            unreachable!("not part of this action")
349        }
350        async fn eam_task_modify(
351            &self,
352            _definition: &serde_json::Value,
353        ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
354            unreachable!("not part of this action")
355        }
356        async fn eam_task_delete(
357            &self,
358            _name: &str,
359            _signature: &str,
360            _confirm: bool,
361        ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
362            unreachable!("not part of this action")
363        }
364        async fn api_call(
365            &self,
366            _call: &crate::client::apicall::ApiCallRequest,
367        ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
368            unreachable!("not part of this action")
369        }
370        async fn license_status(
371            &self,
372        ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
373            unreachable!("not part of this action")
374        }
375        async fn redundancy_status(
376            &self,
377        ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
378            unreachable!("not part of this action")
379        }
380        async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
381            unreachable!("not part of this action")
382        }
383        async fn gateway_info(&self) -> Result<GatewayInfo, CoreError> {
384            Ok(GatewayInfo {
385                name: Some("ign-mock".into()),
386                redundancy_role: Some("Independent".into()),
387                edition: Some("standard".into()),
388                ignition_version: "8.3.6 (b2026042713)".into(),
389                jvm_version: Some("17.0.11".into()),
390                license: Some(LicenseInfo {
391                    mode: "Trial".into(),
392                    expiration_date: Some("2026-08-24T19:00:00Z".into()),
393                }),
394                endpoint: None,
395            })
396        }
397        async fn overview(&self) -> Result<Overview, CoreError> {
398            Ok(overview_fixture())
399        }
400        async fn status_ping(&self) -> Result<StatusPing, CoreError> {
401            Ok(StatusPing {
402                state: "RUNNING".into(),
403            })
404        }
405        async fn modules(
406            &self,
407            quarantined: bool,
408            _query: &crate::client::query::ListQuery,
409        ) -> Result<ListEnvelope<ModuleInfo>, CoreError> {
410            self.modules_flags
411                .lock()
412                .expect("flags lock")
413                .push(quarantined);
414            let item = ModuleInfo {
415                id: "com.inductiveautomation.perspective".into(),
416                name: "Perspective".into(),
417                version: "8.3.6".into(),
418                state: Some("ACTIVE".into()),
419                license_state: Some("ACTIVATED".into()),
420                vendor_name: Some("Inductive Automation".into()),
421                startup_time: Some("2026-08-21 22:03:29".into()),
422                extra: Default::default(),
423            };
424            Ok(ListEnvelope {
425                items: vec![item],
426                metadata: ListMetadata {
427                    total: 1,
428                    matching: 1,
429                    limit: -1,
430                    offset: 0,
431                },
432            })
433        }
434        async fn metrics_current(&self) -> Result<CurrentGauges, CoreError> {
435            Ok(gauges_fixture())
436        }
437        async fn metrics_historic(&self) -> Result<PerformanceCharts, CoreError> {
438            Ok(charts_fixture())
439        }
440        async fn metrics_threads(&self) -> Result<ThreadCounts, CoreError> {
441            Ok(ThreadCounts {
442                running: 32,
443                waiting: 39,
444                timed_waiting: 51,
445                blocked: 0,
446                extra: Default::default(),
447            })
448        }
449        async fn designers(
450            &self,
451            _query: &crate::client::query::ListQuery,
452        ) -> Result<ListEnvelope<crate::client::sessions::DesignerInfo>, CoreError> {
453            unreachable!("not part of this double's actions")
454        }
455        async fn perspective_sessions(
456            &self,
457            _query: &crate::client::query::ListQuery,
458        ) -> Result<ListEnvelope<crate::client::sessions::PerspectiveSession>, CoreError> {
459            unreachable!("not part of this double's actions")
460        }
461        async fn vision_clients(
462            &self,
463            _query: &crate::client::query::ListQuery,
464        ) -> Result<ListEnvelope<crate::client::sessions::VisionClient>, CoreError> {
465            unreachable!("not part of this double's actions")
466        }
467        async fn terminate_perspective_session(
468            &self,
469            _id: &str,
470            _message: Option<&str>,
471        ) -> Result<(), CoreError> {
472            unreachable!("not part of this double's actions")
473        }
474        async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
475            unreachable!("not part of this double's actions")
476        }
477        async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
478            unreachable!("not part of this double's actions")
479        }
480        async fn database_connections(
481            &self,
482        ) -> Result<
483            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
484            CoreError,
485        > {
486            unreachable!("not part of this double's actions")
487        }
488        async fn opc_connections(
489            &self,
490        ) -> Result<
491            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
492            CoreError,
493        > {
494            unreachable!("not part of this double's actions")
495        }
496
497        async fn logs(
498            &self,
499            _filter: &crate::client::logs::LogQuery,
500        ) -> Result<ListEnvelope<crate::client::logs::LogEntry>, CoreError> {
501            unreachable!("not part of this double's actions")
502        }
503        async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
504            unreachable!("not part of this double's actions")
505        }
506        async fn loggers(
507            &self,
508            _query: &crate::client::query::ListQuery,
509        ) -> Result<ListEnvelope<crate::client::logs::LoggerInfo>, CoreError> {
510            unreachable!("not part of this double's actions")
511        }
512        async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
513            unreachable!("not part of this double's actions")
514        }
515        async fn reset_logger_levels(&self) -> Result<(), CoreError> {
516            unreachable!("not part of this double's actions")
517        }
518        async fn restart(&self) -> Result<(), CoreError> {
519            unreachable!("not part of this action")
520        }
521        async fn scan_projects(&self) -> Result<(), CoreError> {
522            unreachable!("not part of this action")
523        }
524        async fn security_properties(
525            &self,
526        ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
527            unreachable!("not part of this action")
528        }
529        async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
530            unreachable!("not part of this action")
531        }
532        async fn webdev_route_call(
533            &self,
534            _project: &str,
535            _route: &str,
536            _body: &serde_json::Value,
537            _extra_headers: &[(&str, &str)],
538        ) -> Result<serde_json::Value, CoreError> {
539            unreachable!("not part of this action")
540        }
541        async fn webdev_route_probe(
542            &self,
543            _project: &str,
544            _route: &str,
545            _extra_headers: &[(&str, &str)],
546        ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
547            unreachable!("not part of this action")
548        }
549        async fn projects(
550            &self,
551            _query: &crate::client::query::ListQuery,
552        ) -> Result<
553            crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
554            CoreError,
555        > {
556            unreachable!("not part of this action")
557        }
558        async fn project_find(
559            &self,
560            _name: &str,
561        ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
562            unreachable!("not part of this action")
563        }
564        async fn project_create(
565            &self,
566            _body: &crate::client::projects::ProjectCreate,
567        ) -> Result<(), CoreError> {
568            unreachable!("not part of this action")
569        }
570        async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
571            unreachable!("not part of this action")
572        }
573        async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
574            unreachable!("not part of this action")
575        }
576        async fn project_modify(
577            &self,
578            _name: &str,
579            _body: &crate::client::projects::ProjectModify,
580        ) -> Result<(), CoreError> {
581            unreachable!("not part of this action")
582        }
583        async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
584            unreachable!("not part of this action")
585        }
586        async fn project_export_to_file(
587            &self,
588            _name: &str,
589            _out: &std::path::Path,
590        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
591            unreachable!("not part of this action")
592        }
593        async fn project_import(
594            &self,
595            _name: &str,
596            _zip: Vec<u8>,
597            _overwrite: bool,
598        ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
599            unreachable!("not part of this action")
600        }
601    }
602
603    /// status merges all three sources under the documented keys —
604    /// `uptime_ms`/`cpu_fraction`/`trial_remaining_s` honest names.
605    #[tokio::test]
606    async fn status_merges_gateway_overview_and_ping() {
607        let rig = HealthyRig {
608            modules_flags: Mutex::new(Vec::new()),
609        };
610        let result: StatusResult = status(&rig).await.expect("healthy rig merges");
611
612        // The documented data keys serialize exactly (string-level, like
613        // the error-envelope golden — key order is contract). Serialized
614        // FIRST: the field assertions below move out of `result`.
615        let json = serde_json::to_string(&result).expect("serialize");
616        assert_eq!(
617            json,
618            concat!(
619                r#"{"gateway":{"name":"ign-mock","ignition_version":"8.3.6 (b2026042713)","edition":"standard","#,
620                r#""license":{"mode":"Trial","expirationDate":"2026-08-24T19:00:00Z"}},"state":"RUNNING","#,
621                r#""overview":{"java":{"version":"17.0.11","vendor":"Azul Systems, Inc.","name":"OpenJDK 64-Bit Server VM"},"#,
622                r#""os":{"name":"Linux","arch":"amd64","version":"5.15.0"},"uptime_ms":338137,"memory":[338137088,1073741824],"#,
623                r#""cpu_fraction":0.0031,"disk":{"total":62661259264,"used":12272824320},"#,
624                r#""license":{"state":"trial","trial_remaining_s":7017}}}"#
625            ),
626            "data keys are the documented contract"
627        );
628
629        assert_eq!(result.gateway.ignition_version, "8.3.6 (b2026042713)");
630        assert_eq!(result.gateway.name.as_deref(), Some("ign-mock"));
631        assert_eq!(result.state, "RUNNING");
632        assert_eq!(result.overview.uptime_ms, 338137);
633        assert!((result.overview.cpu_fraction - 0.0031).abs() < f64::EPSILON);
634        let license = result.overview.license.expect("license block");
635        assert_eq!(license.state, "trial");
636        assert_eq!(license.trial_remaining_s, Some(7017));
637    }
638
639    /// A failed sub-call is an error (exit per taxonomy) — never a
640    /// degraded payload. BrokenOverview: gateway_info OK, overview 401.
641    struct BrokenOverview;
642
643    #[async_trait::async_trait]
644    impl GatewayApi for BrokenOverview {
645        async fn uninstall_module(
646            &self,
647            _gateway_module_id: &str,
648            _module_id: &str,
649        ) -> Result<(), CoreError> {
650            unreachable!("not part of this action")
651        }
652        async fn bundle_generate(
653            &self,
654        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
655            unreachable!("not part of this action")
656        }
657        async fn bundle_status(
658            &self,
659        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
660            unreachable!("not part of this action")
661        }
662        async fn bundle_download(
663            &self,
664            _out: &std::path::Path,
665        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
666            unreachable!("not part of this action")
667        }
668        async fn tag_provider_list(
669            &self,
670            _query: &crate::client::query::ListQuery,
671        ) -> Result<
672            crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
673            CoreError,
674        > {
675            unreachable!("not part of this action")
676        }
677        async fn tag_provider_find(
678            &self,
679            _name: &str,
680        ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
681            unreachable!("not part of this action")
682        }
683        async fn tag_provider_create(
684            &self,
685            _body: &[crate::client::tags::TagProviderCreate],
686        ) -> Result<(), CoreError> {
687            unreachable!("not part of this action")
688        }
689        async fn tag_provider_delete(
690            &self,
691            _name: &str,
692            _signature: &str,
693        ) -> Result<(), CoreError> {
694            unreachable!("not part of this action")
695        }
696        async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
697            unreachable!("not part of this action")
698        }
699        async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
700            unreachable!("not part of this action")
701        }
702        async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
703            unreachable!("not part of this action")
704        }
705        async fn backup_download(
706            &self,
707            _out: &std::path::Path,
708            _backup_type: crate::client::backup::BackupType,
709        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
710            unreachable!("not part of this action")
711        }
712        async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
713            unreachable!("not part of this action")
714        }
715        async fn eam_task_history(
716            &self,
717            _limit: Option<u32>,
718            _search: Option<&str>,
719        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
720        {
721            unreachable!("not part of this action")
722        }
723        async fn eam_task_definitions(
724            &self,
725        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
726        {
727            unreachable!("not part of this action")
728        }
729        async fn eam_task_find(
730            &self,
731            _name: &str,
732        ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
733            unreachable!("not part of this action")
734        }
735        async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
736            unreachable!("not part of this action")
737        }
738        async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
739            unreachable!("not part of this action")
740        }
741        async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
742            unreachable!("not part of this action")
743        }
744        async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
745            unreachable!("not part of this action")
746        }
747        async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
748            unreachable!("not part of this action")
749        }
750        async fn eam_tasks_scheduled(
751            &self,
752            _running: bool,
753        ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
754            unreachable!("not part of this action")
755        }
756        async fn eam_task_modify(
757            &self,
758            _definition: &serde_json::Value,
759        ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
760            unreachable!("not part of this action")
761        }
762        async fn eam_task_delete(
763            &self,
764            _name: &str,
765            _signature: &str,
766            _confirm: bool,
767        ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
768            unreachable!("not part of this action")
769        }
770        async fn api_call(
771            &self,
772            _call: &crate::client::apicall::ApiCallRequest,
773        ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
774            unreachable!("not part of this action")
775        }
776        async fn license_status(
777            &self,
778        ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
779            unreachable!("not part of this action")
780        }
781        async fn redundancy_status(
782            &self,
783        ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
784            unreachable!("not part of this action")
785        }
786        async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
787            unreachable!("not part of this action")
788        }
789        async fn gateway_info(&self) -> Result<GatewayInfo, CoreError> {
790            Ok(GatewayInfo {
791                name: None,
792                redundancy_role: None,
793                edition: None,
794                ignition_version: "8.3.6 (b2026042713)".into(),
795                jvm_version: None,
796                license: None,
797                endpoint: None,
798            })
799        }
800        async fn overview(&self) -> Result<Overview, CoreError> {
801            Err(CoreError::Auth {
802                status: 401,
803                endpoint: Some("http://gw.example.com/data/api/v1/overview".into()),
804            })
805        }
806        async fn status_ping(&self) -> Result<StatusPing, CoreError> {
807            unreachable!("status() must fail at overview() before pinging")
808        }
809        async fn modules(
810            &self,
811            _quarantined: bool,
812            _query: &crate::client::query::ListQuery,
813        ) -> Result<ListEnvelope<ModuleInfo>, CoreError> {
814            unreachable!("not part of this action")
815        }
816        async fn metrics_current(&self) -> Result<CurrentGauges, CoreError> {
817            unreachable!("not part of this action")
818        }
819        async fn metrics_historic(&self) -> Result<PerformanceCharts, CoreError> {
820            unreachable!("not part of this action")
821        }
822        async fn metrics_threads(&self) -> Result<ThreadCounts, CoreError> {
823            unreachable!("not part of this action")
824        }
825        async fn designers(
826            &self,
827            _query: &crate::client::query::ListQuery,
828        ) -> Result<ListEnvelope<crate::client::sessions::DesignerInfo>, CoreError> {
829            unreachable!("not part of this action")
830        }
831        async fn perspective_sessions(
832            &self,
833            _query: &crate::client::query::ListQuery,
834        ) -> Result<ListEnvelope<crate::client::sessions::PerspectiveSession>, CoreError> {
835            unreachable!("not part of this action")
836        }
837        async fn vision_clients(
838            &self,
839            _query: &crate::client::query::ListQuery,
840        ) -> Result<ListEnvelope<crate::client::sessions::VisionClient>, CoreError> {
841            unreachable!("not part of this action")
842        }
843        async fn terminate_perspective_session(
844            &self,
845            _id: &str,
846            _message: Option<&str>,
847        ) -> Result<(), CoreError> {
848            unreachable!("not part of this action")
849        }
850        async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
851            unreachable!("not part of this action")
852        }
853        async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
854            unreachable!("not part of this action")
855        }
856        async fn database_connections(
857            &self,
858        ) -> Result<
859            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
860            CoreError,
861        > {
862            unreachable!("not part of this action")
863        }
864        async fn opc_connections(
865            &self,
866        ) -> Result<
867            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
868            CoreError,
869        > {
870            unreachable!("not part of this action")
871        }
872
873        async fn logs(
874            &self,
875            _filter: &crate::client::logs::LogQuery,
876        ) -> Result<ListEnvelope<crate::client::logs::LogEntry>, CoreError> {
877            unreachable!("not part of this double's actions")
878        }
879        async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
880            unreachable!("not part of this double's actions")
881        }
882        async fn loggers(
883            &self,
884            _query: &crate::client::query::ListQuery,
885        ) -> Result<ListEnvelope<crate::client::logs::LoggerInfo>, CoreError> {
886            unreachable!("not part of this double's actions")
887        }
888        async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
889            unreachable!("not part of this double's actions")
890        }
891        async fn reset_logger_levels(&self) -> Result<(), CoreError> {
892            unreachable!("not part of this double's actions")
893        }
894        async fn restart(&self) -> Result<(), CoreError> {
895            unreachable!("not part of this action")
896        }
897        async fn scan_projects(&self) -> Result<(), CoreError> {
898            unreachable!("not part of this action")
899        }
900        async fn security_properties(
901            &self,
902        ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
903            unreachable!("not part of this action")
904        }
905        async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
906            unreachable!("not part of this action")
907        }
908        async fn webdev_route_call(
909            &self,
910            _project: &str,
911            _route: &str,
912            _body: &serde_json::Value,
913            _extra_headers: &[(&str, &str)],
914        ) -> Result<serde_json::Value, CoreError> {
915            unreachable!("not part of this action")
916        }
917        async fn webdev_route_probe(
918            &self,
919            _project: &str,
920            _route: &str,
921            _extra_headers: &[(&str, &str)],
922        ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
923            unreachable!("not part of this action")
924        }
925        async fn projects(
926            &self,
927            _query: &crate::client::query::ListQuery,
928        ) -> Result<
929            crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
930            CoreError,
931        > {
932            unreachable!("not part of this action")
933        }
934        async fn project_find(
935            &self,
936            _name: &str,
937        ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
938            unreachable!("not part of this action")
939        }
940        async fn project_create(
941            &self,
942            _body: &crate::client::projects::ProjectCreate,
943        ) -> Result<(), CoreError> {
944            unreachable!("not part of this action")
945        }
946        async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
947            unreachable!("not part of this action")
948        }
949        async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
950            unreachable!("not part of this action")
951        }
952        async fn project_modify(
953            &self,
954            _name: &str,
955            _body: &crate::client::projects::ProjectModify,
956        ) -> Result<(), CoreError> {
957            unreachable!("not part of this action")
958        }
959        async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
960            unreachable!("not part of this action")
961        }
962        async fn project_export_to_file(
963            &self,
964            _name: &str,
965            _out: &std::path::Path,
966        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
967            unreachable!("not part of this action")
968        }
969        async fn project_import(
970            &self,
971            _name: &str,
972            _zip: Vec<u8>,
973            _overwrite: bool,
974        ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
975            unreachable!("not part of this action")
976        }
977    }
978
979    #[tokio::test]
980    async fn status_propagates_subcall_errors() {
981        let err = status(&BrokenOverview).await.expect_err("401 propagates");
982        assert!(matches!(&err, CoreError::Auth { status: 401, .. }));
983        assert_eq!(err.exit_code(), 5);
984    }
985
986    /// modules() forwards the quarantined flag and wraps the envelope.
987    #[tokio::test]
988    async fn modules_forwards_the_quarantined_flag() {
989        let rig = HealthyRig {
990            modules_flags: Mutex::new(Vec::new()),
991        };
992        let healthy: ModulesResult = modules(&rig, false).await.expect("healthy list");
993        let quarantined: ModulesResult = modules(&rig, true).await.expect("quarantined list");
994        assert!(!healthy.quarantined && healthy.items.len() == 1);
995        assert!(quarantined.quarantined);
996        assert_eq!(
997            *rig.modules_flags.lock().expect("flags lock"),
998            vec![false, true],
999            "the flag reached the client seam in call order"
1000        );
1001    }
1002
1003    /// metrics() defaults to current+threads (no charts call); with
1004    /// history the charts ride along.
1005    #[tokio::test]
1006    async fn metrics_history_is_opt_in() {
1007        let rig = HealthyRig {
1008            modules_flags: Mutex::new(Vec::new()),
1009        };
1010        let lean: MetricsResult = metrics(&rig, false).await.expect("lean metrics");
1011        assert!(lean.history.is_none());
1012        assert_eq!(lean.threads.running, 32);
1013        assert!((lean.current.cpu - 4.88).abs() < f64::EPSILON);
1014
1015        let full: MetricsResult = metrics(&rig, true).await.expect("full metrics");
1016        let charts = full.history.expect("charts included");
1017        assert_eq!(charts.cpu_datapoints.len(), 1);
1018    }
1019}