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    let headers = [
115        "NODE PROVIDER",
116        "NAME",
117        "NODES",
118        "UP",
119        "DEGRADED",
120        "DOWN",
121        "DISABLED",
122        "UNKNOWN",
123        "ASSIGNED UP/NON-UP",
124        "UNASSIGNED UP/NON-UP",
125        "API BN UP/NON-UP",
126        "UNKNOWN ASN UP/NON-UP",
127    ];
128    let rows = report
129        .providers
130        .iter()
131        .map(|row| {
132            [
133                compact(&row.node_provider_id),
134                sanitize_text(&row.node_provider_name),
135                row.counts.statuses.total.to_string(),
136                row.counts.statuses.up.to_string(),
137                row.counts.statuses.degraded.to_string(),
138                row.counts.statuses.down.to_string(),
139                row.counts.statuses.disabled.to_string(),
140                row.counts.statuses.unknown.to_string(),
141                up_non_up(&row.counts.assignment_statuses.assigned),
142                up_non_up(&row.counts.assignment_statuses.unassigned),
143                up_non_up(&row.counts.assignment_statuses.api_boundary),
144                up_non_up(&row.counts.assignment_statuses.unknown),
145            ]
146        })
147        .collect::<Vec<_>>();
148    push_table_section(
149        &mut sections,
150        render_table(
151            &headers,
152            &rows,
153            &[
154                ColumnAlign::Left,
155                ColumnAlign::Left,
156                ColumnAlign::Right,
157                ColumnAlign::Right,
158                ColumnAlign::Right,
159                ColumnAlign::Right,
160                ColumnAlign::Right,
161                ColumnAlign::Right,
162                ColumnAlign::Right,
163                ColumnAlign::Right,
164                ColumnAlign::Right,
165                ColumnAlign::Right,
166            ],
167        ),
168    );
169    sections.join("\n")
170}
171
172fn push_table_section(sections: &mut Vec<String>, table: String) {
173    sections.push(String::new());
174    sections.push(table);
175}
176
177/// Render a human-readable forced node-status cache refresh report.
178#[cfg(feature = "host")]
179#[must_use]
180pub fn ic_node_status_refresh_report_text(report: &IcNodeStatusRefreshReport) -> String {
181    [
182        format!("network: {}", sanitize_text(&report.network)),
183        format!("fetched_at: {}", sanitize_text(&report.fetched_at)),
184        format!(
185            "source_endpoint: {}",
186            sanitize_text(&report.source_endpoint)
187        ),
188        counts_line("nodes", &report.counts.statuses),
189        assignment_status_line(&report.counts.assignment_statuses),
190        format!(
191            "replaced_existing_cache: {}",
192            yes_no(report.replaced_existing_cache)
193        ),
194        format!("cache_path: {}", sanitize_text(&report.cache_path)),
195        format!(
196            "refresh_lock_path: {}",
197            sanitize_text(&report.refresh_lock_path)
198        ),
199    ]
200    .join("\n")
201}
202
203fn observation_lines(observation: &IcNodeStatusObservation) -> Vec<String> {
204    let mut lines = vec![format!(
205        "observed node status: network={} fetched_at={} source={} certified={} point_in_time={}",
206        sanitize_text(&observation.source.network),
207        sanitize_text(&observation.source.fetched_at),
208        sanitize_text(&observation.source.source_endpoint),
209        yes_no(observation.source.certified),
210        yes_no(observation.source.point_in_time_guaranteed)
211    )];
212    lines.push(format!(
213        "scope: {} cloud_engine_nodes_included={}",
214        observation.scope.as_str(),
215        yes_no(observation.cloud_engine_nodes_included)
216    ));
217    if let Some(cache) = &observation.cache {
218        lines.push(format!(
219            "cache: fresh={} age={}s stale_after={}s path={}",
220            yes_no(cache.cache_fresh),
221            cache.age_seconds,
222            cache.stale_after_seconds,
223            sanitize_text(&cache.cache_path)
224        ));
225    }
226    lines
227}
228
229fn counts_line(label: &str, counts: &IcNodeStatusCounts) -> String {
230    format!(
231        "{label}: total={} up={} degraded={} down={} disabled={} unknown={} non_up={}",
232        counts.total,
233        counts.up,
234        counts.degraded,
235        counts.down,
236        counts.disabled,
237        counts.unknown,
238        counts.non_up()
239    )
240}
241
242fn assignment_status_line(counts: &IcNodeAssignmentStatusCounts) -> String {
243    format!(
244        "assignments (total/up/non_up): assigned={}/{}/{} unassigned={}/{}/{} api_boundary={}/{}/{} unknown={}/{}/{}",
245        counts.assigned.total,
246        counts.assigned.up,
247        counts.assigned.non_up(),
248        counts.unassigned.total,
249        counts.unassigned.up,
250        counts.unassigned.non_up(),
251        counts.api_boundary.total,
252        counts.api_boundary.up,
253        counts.api_boundary.non_up(),
254        counts.unknown.total,
255        counts.unknown.up,
256        counts.unknown.non_up()
257    )
258}
259
260fn up_non_up(counts: &IcNodeStatusCounts) -> String {
261    format!("{}/{}", counts.up, counts.non_up())
262}
263
264fn render_node_table(nodes: &[IcNodeStatusRow]) -> String {
265    let headers = ["NODE", "STATUS", "TYPE", "SUBNET", "PROVIDER", "ALERT"];
266    let rows = nodes
267        .iter()
268        .map(|node| {
269            [
270                compact(&node.node_id),
271                sanitize_text(&node.status),
272                sanitize_text(&node.node_type),
273                node.subnet_id
274                    .as_deref()
275                    .map_or_else(|| "-".to_string(), compact),
276                compact(&node.node_provider_id),
277                node.alert_name
278                    .as_deref()
279                    .map_or_else(|| "-".to_string(), sanitize_text),
280            ]
281        })
282        .collect::<Vec<_>>();
283    render_table(
284        &headers,
285        &rows,
286        &[
287            ColumnAlign::Left,
288            ColumnAlign::Left,
289            ColumnAlign::Left,
290            ColumnAlign::Left,
291            ColumnAlign::Left,
292            ColumnAlign::Left,
293        ],
294    )
295}
296
297fn compact(value: &str) -> String {
298    sanitize_text(value)
299        .chars()
300        .take(COMPACT_PRINCIPAL_CHARS)
301        .collect()
302}