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