Skip to main content

ic_query/ic/node_status/
model.rs

1//! Module: ic::node_status::model
2//!
3//! Responsibility: public observed node-status requests, evidence, reports, and errors.
4//! Does not own: source calls, cache IO, projection, or rendering.
5//! Boundary: preserves raw Dashboard status data and explicit off-chain/cache provenance.
6
7use crate::ic::IcDashboardReportProvenance;
8use serde::{Deserialize as SerdeDeserialize, Serialize};
9use std::fmt;
10#[cfg(feature = "dashboard-host")]
11use std::path::PathBuf;
12use thiserror::Error as ThisError;
13
14///
15/// IcNodeStatusScope
16///
17/// Dashboard collection scope retained by an observed node-status snapshot.
18///
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
21#[serde(rename_all = "snake_case")]
22pub enum IcNodeStatusScope {
23    /// Dashboard's default public-mainnet node set, excluding cloud-engine Type4 nodes.
24    DashboardMainnetDefault,
25}
26
27impl IcNodeStatusScope {
28    /// Return the stable serialized scope label.
29    #[must_use]
30    pub const fn as_str(self) -> &'static str {
31        match self {
32            Self::DashboardMainnetDefault => super::IC_NODE_STATUS_SCOPE,
33        }
34    }
35}
36
37///
38/// IcNodeOperationalStatus
39///
40/// Known Dashboard node-status classification used for filtering and counts.
41///
42
43#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
44#[serde(rename_all = "snake_case")]
45pub enum IcNodeOperationalStatus {
46    /// The Dashboard reports the node as up.
47    Up,
48    /// The Dashboard reports the node as down.
49    Down,
50    /// The Dashboard reports the node as administratively disabled.
51    Disabled,
52    /// The Dashboard reports degraded operation.
53    Degraded,
54    /// The raw Dashboard value is not recognized by this release.
55    Unknown,
56}
57
58impl IcNodeOperationalStatus {
59    /// Classify raw Dashboard status text without discarding unknown future values.
60    #[must_use]
61    pub fn from_raw(raw: &str) -> Self {
62        match raw {
63            "UP" => Self::Up,
64            "DOWN" => Self::Down,
65            "DISABLED" => Self::Disabled,
66            "DEGRADED" => Self::Degraded,
67            _ => Self::Unknown,
68        }
69    }
70
71    /// Return the stable lowercase display label.
72    #[must_use]
73    pub const fn as_str(self) -> &'static str {
74        match self {
75            Self::Up => "up",
76            Self::Down => "down",
77            Self::Disabled => "disabled",
78            Self::Degraded => "degraded",
79            Self::Unknown => "unknown",
80        }
81    }
82}
83
84impl fmt::Display for IcNodeOperationalStatus {
85    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
86        formatter.write_str(self.as_str())
87    }
88}
89
90///
91/// IcNodeStatusCounts
92///
93/// Exact status totals derived from raw observed node rows.
94///
95
96#[derive(Clone, Debug, Default, Eq, PartialEq, SerdeDeserialize, Serialize)]
97pub struct IcNodeStatusCounts {
98    /// Total observed rows in the group.
99    pub total: usize,
100    /// Rows classified as `UP`.
101    pub up: usize,
102    /// Rows classified as `DOWN`.
103    pub down: usize,
104    /// Rows classified as `DISABLED`.
105    pub disabled: usize,
106    /// Rows classified as `DEGRADED`.
107    pub degraded: usize,
108    /// Rows whose raw status is not recognized.
109    pub unknown: usize,
110}
111
112impl IcNodeStatusCounts {
113    /// Return every row not classified as raw Dashboard `UP`.
114    #[must_use]
115    pub const fn non_up(&self) -> usize {
116        self.total.saturating_sub(self.up)
117    }
118}
119
120///
121/// IcNodeAssignmentStatusCounts
122///
123/// Operational-status totals partitioned by mutually exclusive assignment class.
124///
125
126#[derive(Clone, Debug, Default, Eq, PartialEq, SerdeDeserialize, Serialize)]
127pub struct IcNodeAssignmentStatusCounts {
128    /// Status totals for nodes with an observed assigned Subnet.
129    pub assigned: IcNodeStatusCounts,
130    /// Status totals for rows whose raw node type is `UNASSIGNED`.
131    pub unassigned: IcNodeStatusCounts,
132    /// Status totals for rows whose raw node type is `API_BOUNDARY`.
133    pub api_boundary: IcNodeStatusCounts,
134    /// Status totals for rows without a known assignment class.
135    pub unknown: IcNodeStatusCounts,
136}
137
138///
139/// IcNodeStatusGroupCounts
140///
141/// Overall and assignment-partitioned status totals for one aggregate group.
142///
143
144#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
145pub struct IcNodeStatusGroupCounts {
146    /// Raw operational-status totals for the group.
147    pub statuses: IcNodeStatusCounts,
148    /// Raw operational-status totals partitioned by assignment class.
149    pub assignment_statuses: IcNodeAssignmentStatusCounts,
150}
151
152///
153/// IcNodeCountComparison
154///
155/// Ordering of one observed node count relative to another.
156///
157
158#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
159#[serde(rename_all = "snake_case")]
160pub enum IcNodeCountComparison {
161    /// The left-hand count is smaller.
162    Less,
163    /// The two counts are equal.
164    Equal,
165    /// The left-hand count is larger.
166    Greater,
167}
168
169impl IcNodeCountComparison {
170    /// Compare a left-hand count with a right-hand count.
171    #[must_use]
172    pub const fn from_counts(left: usize, right: usize) -> Self {
173        if left < right {
174            Self::Less
175        } else if left > right {
176            Self::Greater
177        } else {
178            Self::Equal
179        }
180    }
181
182    /// Return the stable lowercase label.
183    #[must_use]
184    pub const fn as_str(self) -> &'static str {
185        match self {
186            Self::Less => "less",
187            Self::Equal => "equal",
188            Self::Greater => "greater",
189        }
190    }
191}
192
193///
194/// IcNodeCountComparisonCounts
195///
196/// Number of provider groups in each node-count comparison outcome.
197///
198
199#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
200pub struct IcNodeCountComparisonCounts {
201    /// Provider groups whose left-hand count is smaller.
202    pub less: usize,
203    /// Provider groups whose counts are equal.
204    pub equal: usize,
205    /// Provider groups whose left-hand count is larger.
206    pub greater: usize,
207}
208
209///
210/// IcNodeStatusRow
211///
212/// One raw node observation retained from the official Dashboard node resource.
213///
214
215#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
216pub struct IcNodeStatusRow {
217    /// Canonical node principal.
218    pub node_id: String,
219    /// Canonical node-operator principal.
220    pub node_operator_id: String,
221    /// Canonical node-provider principal.
222    pub node_provider_id: String,
223    /// Off-chain Dashboard node-provider name.
224    pub node_provider_name: String,
225    /// Raw Dashboard node type.
226    pub node_type: String,
227    /// Raw Registry reward type reported by the Dashboard.
228    pub node_reward_type: String,
229    /// Raw Dashboard operational status.
230    pub status: String,
231    /// Raw Dashboard alert name when present.
232    pub alert_name: Option<String>,
233    /// Assigned Subnet principal when present.
234    pub subnet_id: Option<String>,
235    /// CloudEngine Subnet principal when reported by the selected Dashboard scope.
236    pub cloud_engine_subnet_id: Option<String>,
237    /// Dashboard data-center identifier.
238    pub data_center_id: String,
239    /// Dashboard data-center name.
240    pub data_center_name: String,
241    /// Dashboard infrastructure owner label.
242    pub owner: String,
243    /// Raw Dashboard geographic region label.
244    pub region: String,
245    /// Observed GuestOS version when reported.
246    pub guestos_version: Option<String>,
247    /// Observed GuestOS trusted-execution status when reported.
248    pub guestos_tee_active: Option<bool>,
249    /// Observed node IP address when reported.
250    pub ip_address: Option<String>,
251    /// Observed IPv4-connectivity state when reported.
252    pub ipv4_connectivity_status: Option<bool>,
253    /// Dashboard hardware-generation label when reported.
254    pub node_hardware_generation: Option<String>,
255}
256
257impl IcNodeStatusRow {
258    /// Return the known classification of this row's raw status.
259    #[must_use]
260    pub fn operational_status(&self) -> IcNodeOperationalStatus {
261        IcNodeOperationalStatus::from_raw(&self.status)
262    }
263
264    /// Return whether the raw row is anything other than known `UP`.
265    #[must_use]
266    pub fn is_non_up(&self) -> bool {
267        self.operational_status() != IcNodeOperationalStatus::Up
268    }
269}
270
271///
272/// IcNodeStatusCacheEvidence
273///
274/// Caller-relative local cache evidence attached to a projected status report.
275///
276
277#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
278pub struct IcNodeStatusCacheEvidence {
279    /// Canonical local cache path used by the report.
280    pub cache_path: String,
281    /// Whether the cache is within the default age policy for this caller's time.
282    pub cache_fresh: bool,
283    /// Cache age at report construction.
284    pub age_seconds: u64,
285    /// Age threshold used to classify freshness.
286    pub stale_after_seconds: u64,
287}
288
289///
290/// IcNodeStatusObservation
291///
292/// Shared source, scope, and optional cache evidence for status reports.
293///
294
295#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
296pub struct IcNodeStatusObservation {
297    #[serde(flatten)]
298    /// Official Dashboard source provenance.
299    pub source: IcDashboardReportProvenance,
300    /// Explicit node collection scope.
301    pub scope: IcNodeStatusScope,
302    /// Whether cloud-engine nodes were included in the source collection.
303    pub cloud_engine_nodes_included: bool,
304    /// Local cache evidence, absent for a pure live snapshot.
305    pub cache: Option<IcNodeStatusCacheEvidence>,
306}
307
308///
309/// IcNodeStatusSnapshot
310///
311/// Complete canonical observed node snapshot used by every status projection.
312///
313
314#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
315pub struct IcNodeStatusSnapshot {
316    #[serde(flatten)]
317    /// Shared source and scope evidence.
318    pub observation: IcNodeStatusObservation,
319    /// Exact number of raw node rows.
320    pub node_count: usize,
321    /// Snapshot-wide overall and assignment-partitioned totals.
322    pub counts: IcNodeStatusGroupCounts,
323    /// Canonically ordered raw node rows.
324    pub nodes: Vec<IcNodeStatusRow>,
325}
326
327///
328/// IcNodeStatusSourceData
329///
330/// Untrusted raw node rows and provenance returned by a Dashboard source capability.
331///
332
333#[cfg(feature = "dashboard-host")]
334#[derive(Clone, Debug, Eq, PartialEq)]
335pub struct IcNodeStatusSourceData {
336    /// Source-call provenance echoed by the source.
337    pub source: crate::ic::IcSourceRequest,
338    /// Explicit Dashboard collection scope.
339    pub scope: IcNodeStatusScope,
340    /// Whether the source claims cloud-engine node inclusion.
341    pub cloud_engine_nodes_included: bool,
342    /// Raw observed node rows.
343    pub nodes: Vec<IcNodeStatusRow>,
344}
345
346///
347/// IcNodeStatusView
348///
349/// Target and attention selection shared by node, Subnet, and provider views.
350///
351
352#[derive(Clone, Debug, Default, Eq, PartialEq)]
353pub struct IcNodeStatusView {
354    /// Optional exact identifier or unique principal prefix.
355    pub target: Option<String>,
356    /// Whether fully-up rows or groups are included without a target.
357    pub include_all: bool,
358}
359
360impl IcNodeStatusView {
361    /// Construct the default attention-only view.
362    #[must_use]
363    pub const fn attention() -> Self {
364        Self {
365            target: None,
366            include_all: false,
367        }
368    }
369
370    /// Select one exact identifier or unique principal prefix.
371    #[must_use]
372    pub fn with_target(mut self, target: impl Into<String>) -> Self {
373        self.target = Some(target.into());
374        self
375    }
376
377    /// Include fully-up rows as well as attention rows.
378    #[must_use]
379    pub const fn with_all(mut self, include_all: bool) -> Self {
380        self.include_all = include_all;
381        self
382    }
383}
384
385///
386/// IcNodeStatusReport
387///
388/// Node-level view over one complete observed status snapshot.
389///
390
391#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
392pub struct IcNodeStatusReport {
393    #[serde(flatten)]
394    /// Shared source, scope, and cache evidence.
395    pub observation: IcNodeStatusObservation,
396    /// Total rows in the complete source snapshot.
397    pub snapshot_node_count: usize,
398    /// Snapshot-wide overall and assignment-partitioned totals.
399    pub counts: IcNodeStatusGroupCounts,
400    /// Whether the view includes fully-up rows.
401    pub include_all: bool,
402    /// Target text supplied by the caller.
403    pub requested_target: Option<String>,
404    /// Canonical target resolved by exact or unique-prefix matching.
405    pub resolved_target: Option<String>,
406    /// Stable label describing how the target resolved.
407    pub resolved_from: Option<String>,
408    /// Number of returned node rows.
409    pub returned_node_count: usize,
410    /// Selected canonical node rows.
411    pub nodes: Vec<IcNodeStatusRow>,
412}
413
414///
415/// IcSubnetStatusRow
416///
417/// Observed operational counts and conservative threshold evidence for one Subnet.
418///
419
420#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
421pub struct IcSubnetStatusRow {
422    /// Canonical Subnet principal.
423    pub subnet_id: String,
424    /// Operational-status totals for observed assigned nodes.
425    pub statuses: IcNodeStatusCounts,
426    /// Byzantine fault count `floor((n - 1) / 3)` for the observed membership size.
427    pub fault_tolerance_node_count: usize,
428    /// Additional down nodes required for the down count to exceed the derived threshold.
429    pub additional_down_nodes_to_exceed_fault_tolerance: usize,
430    /// Additional non-up nodes required for the conservative count to exceed the threshold.
431    pub additional_non_up_nodes_to_exceed_fault_tolerance: usize,
432    /// Whether the observed down count already exceeds the derived threshold.
433    pub down_fault_tolerance_exceeded: bool,
434    /// Whether the observed conservative non-up count already exceeds the threshold.
435    pub conservative_non_up_fault_tolerance_exceeded: bool,
436    /// Raw non-up node evidence in canonical node order.
437    pub non_up_nodes: Vec<IcNodeStatusRow>,
438}
439
440///
441/// IcSubnetStatusReport
442///
443/// Subnet-level operational view over one complete observed node snapshot.
444///
445
446#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
447pub struct IcSubnetStatusReport {
448    #[serde(flatten)]
449    /// Shared source, scope, and cache evidence.
450    pub observation: IcNodeStatusObservation,
451    /// Total rows in the complete source snapshot.
452    pub snapshot_node_count: usize,
453    /// Snapshot rows with an assigned Subnet.
454    pub assigned_node_count: usize,
455    /// Number of observed Subnets before view filtering.
456    pub subnet_count: usize,
457    /// Number of observed Subnets containing a non-up node.
458    pub attention_subnet_count: usize,
459    /// Whether fully-up Subnets are included without a target.
460    pub include_all: bool,
461    /// Target text supplied by the caller.
462    pub requested_target: Option<String>,
463    /// Canonical Subnet target resolved by exact or unique-prefix matching.
464    pub resolved_target: Option<String>,
465    /// Stable label describing how the target resolved.
466    pub resolved_from: Option<String>,
467    /// Number of returned Subnet rows.
468    pub returned_subnet_count: usize,
469    /// Canonically ordered selected Subnet rows.
470    pub subnets: Vec<IcSubnetStatusRow>,
471}
472
473///
474/// IcNodeProviderStatusRow
475///
476/// Assignment and raw operational counts for one observed node provider.
477///
478
479#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
480pub struct IcNodeProviderStatusRow {
481    /// Canonical node-provider principal.
482    pub node_provider_id: String,
483    /// Off-chain Dashboard provider name.
484    pub node_provider_name: String,
485    /// Operational-status and assignment totals for the provider.
486    pub counts: IcNodeStatusGroupCounts,
487    /// Unassigned up-node count compared with assigned up-node count.
488    pub unassigned_up_vs_assigned_up: IcNodeCountComparison,
489    /// Unassigned non-up-node count compared with assigned non-up-node count.
490    pub unassigned_non_up_vs_assigned_non_up: IcNodeCountComparison,
491    /// Raw non-up node evidence in canonical node order.
492    pub non_up_nodes: Vec<IcNodeStatusRow>,
493}
494
495///
496/// IcNodeProviderStatusReport
497///
498/// Node-provider operational view over one complete observed node snapshot.
499///
500
501#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
502pub struct IcNodeProviderStatusReport {
503    #[serde(flatten)]
504    /// Shared source, scope, and cache evidence.
505    pub observation: IcNodeStatusObservation,
506    /// Total rows in the complete source snapshot.
507    pub snapshot_node_count: usize,
508    /// Number of observed provider principals before view filtering.
509    pub provider_count: usize,
510    /// Number of provider groups containing a non-up node.
511    pub attention_provider_count: usize,
512    /// All-provider outcomes for unassigned up nodes compared with assigned up nodes.
513    pub unassigned_up_vs_assigned_up_provider_counts: IcNodeCountComparisonCounts,
514    /// All-provider outcomes for unassigned non-up nodes compared with assigned non-up nodes.
515    pub unassigned_non_up_vs_assigned_non_up_provider_counts: IcNodeCountComparisonCounts,
516    /// Whether fully-up providers are included without a target.
517    pub include_all: bool,
518    /// Target text supplied by the caller.
519    pub requested_target: Option<String>,
520    /// Canonical provider target resolved by exact or unique-prefix matching.
521    pub resolved_target: Option<String>,
522    /// Stable label describing how the target resolved.
523    pub resolved_from: Option<String>,
524    /// Number of returned provider rows.
525    pub returned_provider_count: usize,
526    /// Canonically ordered selected provider rows.
527    pub providers: Vec<IcNodeProviderStatusRow>,
528}
529
530///
531/// IcNodeStatusProjectionError
532///
533/// Failure to resolve or project a requested observed status view.
534///
535
536#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
537pub enum IcNodeStatusProjectionError {
538    /// Observation provenance, raw rows, canonical ordering, or counts are invalid.
539    #[error("invalid observed node-status snapshot: {reason}")]
540    InvalidSnapshot {
541        /// Deterministic validation failure.
542        reason: String,
543    },
544    /// The supplied target is empty after trimming.
545    #[error("{kind} target must not be empty")]
546    EmptyTarget {
547        /// Projection kind being selected.
548        kind: &'static str,
549    },
550    /// No observed identifier matches the supplied target.
551    #[error("{kind} target {target:?} did not match the observed snapshot")]
552    UnknownTarget {
553        /// Projection kind being selected.
554        kind: &'static str,
555        /// Unmatched target text.
556        target: String,
557    },
558    /// More than one observed identifier matches the supplied prefix.
559    #[error("{kind} prefix {prefix:?} is ambiguous; matches: {matches:?}")]
560    AmbiguousTarget {
561        /// Projection kind being selected.
562        kind: &'static str,
563        /// Ambiguous principal prefix.
564        prefix: String,
565        /// Canonically ordered matching principals.
566        matches: Vec<String>,
567    },
568}
569
570///
571/// IcNodeStatusCacheRequest
572///
573/// Stable identity of one network-level observed node-status cache.
574///
575
576#[cfg(feature = "dashboard-host")]
577#[derive(Clone, Debug, Eq, PartialEq)]
578pub struct IcNodeStatusCacheRequest {
579    /// Root directory containing all `ic-query` caches.
580    pub cache_root: PathBuf,
581    /// Requested network name.
582    pub network: String,
583}
584
585#[cfg(feature = "dashboard-host")]
586impl IcNodeStatusCacheRequest {
587    /// Construct one observed node-status cache identity.
588    #[must_use]
589    pub fn new(cache_root: impl Into<PathBuf>, network: impl Into<String>) -> Self {
590        Self {
591            cache_root: cache_root.into(),
592            network: network.into(),
593        }
594    }
595}
596
597///
598/// IcNodeStatusRefreshRequest
599///
600/// Settings for one forced observed node-status snapshot refresh.
601///
602
603#[cfg(feature = "dashboard-host")]
604#[derive(Clone, Debug, Eq, PartialEq)]
605pub struct IcNodeStatusRefreshRequest {
606    /// Stable cache identity.
607    pub cache: IcNodeStatusCacheRequest,
608    /// Explicit Dashboard API base endpoint.
609    pub source_endpoint: String,
610    /// Caller-supplied observation time as Unix seconds.
611    pub now_unix_secs: u64,
612    /// Age after which an abandoned refresh lock can be reclaimed.
613    pub lock_stale_after_seconds: u64,
614}
615
616#[cfg(feature = "dashboard-host")]
617impl IcNodeStatusRefreshRequest {
618    /// Construct one forced node-status snapshot refresh request.
619    #[must_use]
620    pub fn new(
621        cache_root: impl Into<PathBuf>,
622        network: impl Into<String>,
623        source_endpoint: impl Into<String>,
624        now_unix_secs: u64,
625        lock_stale_after_seconds: u64,
626    ) -> Self {
627        Self {
628            cache: IcNodeStatusCacheRequest::new(cache_root, network),
629            source_endpoint: source_endpoint.into(),
630            now_unix_secs,
631            lock_stale_after_seconds,
632        }
633    }
634}
635
636///
637/// IcNodeStatusReadRequest
638///
639/// Cache-backed status-view request shared by node, Subnet, and provider reports.
640///
641
642#[cfg(feature = "dashboard-host")]
643#[derive(Clone, Debug, Eq, PartialEq)]
644pub struct IcNodeStatusReadRequest {
645    /// Live and cache settings used when refresh is required.
646    pub refresh: IcNodeStatusRefreshRequest,
647    /// Projection target and attention selection.
648    pub view: IcNodeStatusView,
649    /// Whether to force a live replacement before reading.
650    pub force_refresh: bool,
651}
652
653#[cfg(feature = "dashboard-host")]
654impl IcNodeStatusReadRequest {
655    /// Construct one stale-refresh status-view request.
656    #[must_use]
657    pub fn new(
658        cache_root: impl Into<PathBuf>,
659        network: impl Into<String>,
660        source_endpoint: impl Into<String>,
661        now_unix_secs: u64,
662    ) -> Self {
663        Self {
664            refresh: IcNodeStatusRefreshRequest::new(
665                cache_root,
666                network,
667                source_endpoint,
668                now_unix_secs,
669                super::DEFAULT_IC_NODE_STATUS_REFRESH_LOCK_STALE_SECONDS,
670            ),
671            view: IcNodeStatusView::attention(),
672            force_refresh: false,
673        }
674    }
675
676    /// Apply one target and attention selection.
677    #[must_use]
678    pub fn with_view(mut self, view: IcNodeStatusView) -> Self {
679        self.view = view;
680        self
681    }
682
683    /// Select a forced refresh before projecting the report.
684    #[must_use]
685    pub const fn with_force_refresh(mut self, force_refresh: bool) -> Self {
686        self.force_refresh = force_refresh;
687        self
688    }
689}
690
691///
692/// IcNodeStatusRefreshReport
693///
694/// Result of atomically replacing the complete observed node-status cache.
695///
696
697#[cfg(feature = "dashboard-host")]
698#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
699pub struct IcNodeStatusRefreshReport {
700    /// Cache/report schema version.
701    pub schema_version: u32,
702    /// Network identity attached to the replacement.
703    pub network: String,
704    /// Explicit Dashboard endpoint queried.
705    pub source_endpoint: String,
706    /// Canonical UTC collection timestamp.
707    pub fetched_at: String,
708    /// Collector identity.
709    pub fetched_by: String,
710    /// Published cache path.
711    pub cache_path: String,
712    /// Sibling refresh-lock path.
713    pub refresh_lock_path: String,
714    /// Whether an existing complete snapshot was replaced.
715    pub replaced_existing_cache: bool,
716    /// Number of published node rows.
717    pub node_count: usize,
718    /// Published overall and assignment-partitioned status totals.
719    pub counts: IcNodeStatusGroupCounts,
720}
721
722///
723/// IcNodeStatusHostError
724///
725/// Host source, cache, and projection failures for observed node-status reports.
726///
727
728#[cfg(feature = "dashboard-host")]
729#[derive(Debug, ThisError)]
730pub enum IcNodeStatusHostError {
731    /// The observed Dashboard source supports only public mainnet identity.
732    #[error("observed IC node status supports only the mainnet `ic` network, not {network:?}")]
733    UnsupportedNetwork {
734        /// Unsupported requested network.
735        network: String,
736    },
737    /// Live-source or source-validation failure.
738    #[error(transparent)]
739    Source(#[from] crate::ic::IcHostError),
740    /// Pure projection failure.
741    #[error(transparent)]
742    Projection(#[from] IcNodeStatusProjectionError),
743    /// Strict cache load found no complete snapshot.
744    #[error("observed node-status cache is missing at {}", path.display())]
745    MissingCache {
746        /// Expected cache path.
747        path: PathBuf,
748    },
749    /// Existing cache snapshot-key identity does not match its path.
750    #[error(
751        "observed node-status cache identity mismatch at {}: {field} is {actual:?}, expected {expected:?}",
752        path.display()
753    )]
754    CacheIdentityMismatch {
755        /// Cache path that failed validation.
756        path: PathBuf,
757        /// Identity field that differs.
758        field: &'static str,
759        /// Required identity value.
760        expected: String,
761        /// Stored identity value.
762        actual: String,
763    },
764    /// Existing cache is structurally readable but semantically invalid.
765    #[error("invalid observed node-status cache at {}: {reason}", path.display())]
766    InvalidCache {
767        /// Cache path that failed validation.
768        path: PathBuf,
769        /// Deterministic validation failure.
770        reason: String,
771    },
772    /// Shared generic JSON, filesystem, or refresh-lock cache failure.
773    #[error(transparent)]
774    Cache(#[from] crate::cache_file::HostCacheError),
775}