Skip to main content

ic_query/ic/node_status/
text.rs

1//! Module: ic::node_status::text
2//!
3//! Responsibility: human-readable observed node, Subnet, provider, and refresh reports.
4//! Does not own: source collection, cache policy, projection, or JSON output.
5//! Boundary: keeps compact operational display separate from raw report fields.
6
7#[cfg(feature = "host")]
8use super::IcNodeStatusRefreshReport;
9use super::{
10    IcNodeAssignmentStatusCounts, IcNodeProviderStatusReport, IcNodeStatusCounts,
11    IcNodeStatusObservation, IcNodeStatusReport, IcNodeStatusRow, IcSubnetStatusReport,
12};
13use crate::{
14    table::{ColumnAlign, render_table},
15    text_value::{sanitize_text, yes_no},
16};
17
18const COMPACT_PRINCIPAL_CHARS: usize = 8;
19
20/// Render a human-readable node-level operational status report.
21#[must_use]
22pub fn ic_node_status_report_text(report: &IcNodeStatusReport) -> String {
23    let mut sections = observation_lines(&report.observation);
24    sections.push(counts_line("nodes", &report.counts.statuses));
25    sections.push(assignment_status_line(&report.counts.assignment_statuses));
26    push_table_section(&mut sections, render_node_table(&report.nodes));
27    sections.join("\n")
28}
29
30/// Render a human-readable Subnet operational status report.
31#[must_use]
32pub fn ic_subnet_status_report_text(report: &IcSubnetStatusReport) -> String {
33    let mut sections = observation_lines(&report.observation);
34    sections.push(format!(
35        "subnets: total={} attention={} returned={} assigned_nodes={}",
36        report.subnet_count,
37        report.attention_subnet_count,
38        report.returned_subnet_count,
39        report.assigned_node_count
40    ));
41    let headers = [
42        "SUBNET",
43        "NODES",
44        "UP",
45        "DEGRADED",
46        "DOWN",
47        "DISABLED",
48        "UNKNOWN",
49        "F",
50        "+DOWN >F",
51        "+NON-UP >F",
52    ];
53    let rows = report
54        .subnets
55        .iter()
56        .map(|row| {
57            [
58                compact(&row.subnet_id),
59                row.statuses.total.to_string(),
60                row.statuses.up.to_string(),
61                row.statuses.degraded.to_string(),
62                row.statuses.down.to_string(),
63                row.statuses.disabled.to_string(),
64                row.statuses.unknown.to_string(),
65                row.fault_tolerance_node_count.to_string(),
66                row.additional_down_nodes_to_exceed_fault_tolerance
67                    .to_string(),
68                row.additional_non_up_nodes_to_exceed_fault_tolerance
69                    .to_string(),
70            ]
71        })
72        .collect::<Vec<_>>();
73    push_table_section(
74        &mut sections,
75        render_table(
76            &headers,
77            &rows,
78            &[
79                ColumnAlign::Left,
80                ColumnAlign::Right,
81                ColumnAlign::Right,
82                ColumnAlign::Right,
83                ColumnAlign::Right,
84                ColumnAlign::Right,
85                ColumnAlign::Right,
86                ColumnAlign::Right,
87                ColumnAlign::Right,
88                ColumnAlign::Right,
89            ],
90        ),
91    );
92    let non_up = report
93        .subnets
94        .iter()
95        .flat_map(|subnet| subnet.non_up_nodes.iter())
96        .cloned()
97        .collect::<Vec<_>>();
98    if !non_up.is_empty() {
99        sections.push(String::new());
100        sections.push("non-up node evidence:".to_string());
101        sections.push(render_node_table(&non_up));
102    }
103    sections.join("\n")
104}
105
106/// Render a human-readable node-provider operational status report.
107#[must_use]
108pub fn ic_node_provider_status_report_text(report: &IcNodeProviderStatusReport) -> String {
109    let mut sections = observation_lines(&report.observation);
110    sections.push(format!(
111        "node_providers: total={} attention={} returned={}",
112        report.provider_count, report.attention_provider_count, report.returned_provider_count
113    ));
114    sections.push(format!(
115        "unassigned_vs_assigned providers (less/equal/greater): up={}/{}/{} non_up={}/{}/{}",
116        report.unassigned_up_vs_assigned_up_provider_counts.less,
117        report.unassigned_up_vs_assigned_up_provider_counts.equal,
118        report.unassigned_up_vs_assigned_up_provider_counts.greater,
119        report
120            .unassigned_non_up_vs_assigned_non_up_provider_counts
121            .less,
122        report
123            .unassigned_non_up_vs_assigned_non_up_provider_counts
124            .equal,
125        report
126            .unassigned_non_up_vs_assigned_non_up_provider_counts
127            .greater,
128    ));
129    let headers = [
130        "NODE PROVIDER",
131        "NAME",
132        "NODES",
133        "UP",
134        "DEGRADED",
135        "DOWN",
136        "DISABLED",
137        "UNKNOWN",
138        "ASSIGNED UP/NON-UP",
139        "UNASSIGNED UP/NON-UP",
140        "API BN UP/NON-UP",
141        "UNKNOWN ASN UP/NON-UP",
142        "UNASN VS ASN UP/NON-UP",
143    ];
144    let rows = report
145        .providers
146        .iter()
147        .map(|row| {
148            [
149                compact(&row.node_provider_id),
150                sanitize_text(&row.node_provider_name),
151                row.counts.statuses.total.to_string(),
152                row.counts.statuses.up.to_string(),
153                row.counts.statuses.degraded.to_string(),
154                row.counts.statuses.down.to_string(),
155                row.counts.statuses.disabled.to_string(),
156                row.counts.statuses.unknown.to_string(),
157                up_non_up(&row.counts.assignment_statuses.assigned),
158                up_non_up(&row.counts.assignment_statuses.unassigned),
159                up_non_up(&row.counts.assignment_statuses.api_boundary),
160                up_non_up(&row.counts.assignment_statuses.unknown),
161                format!(
162                    "{}/{}",
163                    row.unassigned_up_vs_assigned_up.as_str(),
164                    row.unassigned_non_up_vs_assigned_non_up.as_str()
165                ),
166            ]
167        })
168        .collect::<Vec<_>>();
169    push_table_section(
170        &mut sections,
171        render_table(
172            &headers,
173            &rows,
174            &[
175                ColumnAlign::Left,
176                ColumnAlign::Left,
177                ColumnAlign::Right,
178                ColumnAlign::Right,
179                ColumnAlign::Right,
180                ColumnAlign::Right,
181                ColumnAlign::Right,
182                ColumnAlign::Right,
183                ColumnAlign::Right,
184                ColumnAlign::Right,
185                ColumnAlign::Right,
186                ColumnAlign::Right,
187                ColumnAlign::Left,
188            ],
189        ),
190    );
191    sections.join("\n")
192}
193
194fn push_table_section(sections: &mut Vec<String>, table: String) {
195    sections.push(String::new());
196    sections.push(table);
197}
198
199/// Render a human-readable forced node-status cache refresh report.
200#[cfg(feature = "host")]
201#[must_use]
202pub fn ic_node_status_refresh_report_text(report: &IcNodeStatusRefreshReport) -> String {
203    [
204        format!("network: {}", sanitize_text(&report.network)),
205        format!("fetched_at: {}", sanitize_text(&report.fetched_at)),
206        format!(
207            "source_endpoint: {}",
208            sanitize_text(&report.source_endpoint)
209        ),
210        counts_line("nodes", &report.counts.statuses),
211        assignment_status_line(&report.counts.assignment_statuses),
212        format!(
213            "replaced_existing_cache: {}",
214            yes_no(report.replaced_existing_cache)
215        ),
216        format!("cache_path: {}", sanitize_text(&report.cache_path)),
217        format!(
218            "refresh_lock_path: {}",
219            sanitize_text(&report.refresh_lock_path)
220        ),
221    ]
222    .join("\n")
223}
224
225fn observation_lines(observation: &IcNodeStatusObservation) -> Vec<String> {
226    let mut lines = vec![format!(
227        "observed node status: network={} fetched_at={} source={} certified={} point_in_time={}",
228        sanitize_text(&observation.source.network),
229        sanitize_text(&observation.source.fetched_at),
230        sanitize_text(&observation.source.source_endpoint),
231        yes_no(observation.source.certified),
232        yes_no(observation.source.point_in_time_guaranteed)
233    )];
234    lines.push(format!(
235        "scope: {} cloud_engine_nodes_included={}",
236        observation.scope.as_str(),
237        yes_no(observation.cloud_engine_nodes_included)
238    ));
239    if let Some(cache) = &observation.cache {
240        lines.push(format!(
241            "cache: fresh={} age={}s stale_after={}s path={}",
242            yes_no(cache.cache_fresh),
243            cache.age_seconds,
244            cache.stale_after_seconds,
245            sanitize_text(&cache.cache_path)
246        ));
247    }
248    lines
249}
250
251fn counts_line(label: &str, counts: &IcNodeStatusCounts) -> String {
252    format!(
253        "{label}: total={} up={} degraded={} down={} disabled={} unknown={} non_up={}",
254        counts.total,
255        counts.up,
256        counts.degraded,
257        counts.down,
258        counts.disabled,
259        counts.unknown,
260        counts.non_up()
261    )
262}
263
264fn assignment_status_line(counts: &IcNodeAssignmentStatusCounts) -> String {
265    format!(
266        "assignments (total/up/non_up): assigned={}/{}/{} unassigned={}/{}/{} api_boundary={}/{}/{} unknown={}/{}/{}",
267        counts.assigned.total,
268        counts.assigned.up,
269        counts.assigned.non_up(),
270        counts.unassigned.total,
271        counts.unassigned.up,
272        counts.unassigned.non_up(),
273        counts.api_boundary.total,
274        counts.api_boundary.up,
275        counts.api_boundary.non_up(),
276        counts.unknown.total,
277        counts.unknown.up,
278        counts.unknown.non_up()
279    )
280}
281
282fn up_non_up(counts: &IcNodeStatusCounts) -> String {
283    format!("{}/{}", counts.up, counts.non_up())
284}
285
286fn render_node_table(nodes: &[IcNodeStatusRow]) -> String {
287    let headers = ["NODE", "STATUS", "TYPE", "SUBNET", "PROVIDER", "ALERT"];
288    let rows = nodes
289        .iter()
290        .map(|node| {
291            [
292                compact(&node.node_id),
293                sanitize_text(&node.status),
294                sanitize_text(&node.node_type),
295                node.subnet_id
296                    .as_deref()
297                    .map_or_else(|| "-".to_string(), compact),
298                compact(&node.node_provider_id),
299                node.alert_name
300                    .as_deref()
301                    .map_or_else(|| "-".to_string(), sanitize_text),
302            ]
303        })
304        .collect::<Vec<_>>();
305    render_table(
306        &headers,
307        &rows,
308        &[
309            ColumnAlign::Left,
310            ColumnAlign::Left,
311            ColumnAlign::Left,
312            ColumnAlign::Left,
313            ColumnAlign::Left,
314            ColumnAlign::Left,
315        ],
316    )
317}
318
319fn compact(value: &str) -> String {
320    sanitize_text(value)
321        .chars()
322        .take(COMPACT_PRINCIPAL_CHARS)
323        .collect()
324}