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