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