Skip to main content

ic_query/ic/
text.rs

1//! Module: ic::text
2//!
3//! Responsibility: render IC Dashboard reports as human-facing text.
4//! Does not own: REST calls, report construction, JSON serialization, or command output.
5//! Boundary: keeps raw values intact in JSON while making nullable text fields readable.
6
7use crate::{
8    human_quantity::decimal_cycle_rate_text,
9    ic::{
10        IcBoundaryNodeDataCentersReport, IcCanisterCountReport, IcCanisterFilters,
11        IcCanisterPageReport, IcCanisterReport, IcDailyStatsReport, IcDashboardReportProvenance,
12        IcMetricKind, IcMetricReport,
13    },
14    text_value::{sanitize_text, yes_no},
15};
16
17/// Render one official boundary-node data-center report as human-facing text.
18#[must_use]
19pub fn ic_boundary_node_data_centers_report_text(
20    report: &IcBoundaryNodeDataCentersReport,
21) -> String {
22    let mut lines = report_header(&report.provenance);
23    lines.extend([
24        format!("data_center_count: {}", report.data_center_count),
25        format!("total_node_count: {}", report.total_node_count),
26    ]);
27    append_report_footer(&mut lines, &report.provenance);
28
29    if !report.rows.is_empty() {
30        lines.push(String::new());
31        lines.push("boundary_node_data_centers:".to_string());
32        lines.extend(report.rows.iter().map(|row| {
33            format!(
34                "  {}  name={}  owner={}  region={}  latitude={}  longitude={}  nodes={}",
35                sanitize_text(&row.dc_id),
36                sanitize_text(&row.name),
37                sanitize_text(&row.owner),
38                sanitize_text(&row.region),
39                sanitize_text(&row.latitude),
40                sanitize_text(&row.longitude),
41                sanitize_text(&row.total_nodes),
42            )
43        }));
44    }
45    lines.join("\n")
46}
47
48/// Render one bounded official Dashboard daily-statistics report as human-facing text.
49#[must_use]
50pub fn ic_daily_stats_report_text(report: &IcDailyStatsReport) -> String {
51    let mut lines = report_header(&report.provenance);
52    lines.extend([
53        format!("start_unix_secs: {}", report.query.start_unix_secs),
54        format!("end_unix_secs: {}", report.query.end_unix_secs),
55        format!("returned_day_count: {}", report.returned_day_count),
56    ]);
57    append_report_footer(&mut lines, &report.provenance);
58
59    if !report.rows.is_empty() {
60        lines.push(String::new());
61        lines.push("daily_stats:".to_string());
62        lines.extend(report.rows.iter().map(|row| {
63            format!(
64                "  {}  timestamp={}  avg_total={}  avg_update={}  avg_query={}  max_total={}  max_update={}  max_query={}  blocks_avg={}",
65                sanitize_text(&row.day),
66                row.timestamp_unix_secs,
67                sanitize_text(&row.average_transactions_per_second),
68                sanitize_text(&row.average_update_transactions_per_second),
69                sanitize_text(&row.average_query_transactions_per_second),
70                sanitize_text(&row.max_total_transactions_per_second),
71                sanitize_text(&row.max_update_transactions_per_second),
72                sanitize_text(&row.max_query_transactions_per_second),
73                sanitize_text(&row.blocks_per_second_average),
74            )
75        }));
76    }
77    lines.join("\n")
78}
79
80/// Render one bounded official Dashboard metric report as human-facing text.
81#[must_use]
82pub fn ic_metric_report_text(report: &IcMetricReport) -> String {
83    let mut lines = report_header(&report.provenance);
84    lines.extend([
85        format!("metric: {}", report.query.metric),
86        format!("start_unix_secs: {}", report.query.start_unix_secs),
87        format!("end_unix_secs: {}", report.query.end_unix_secs),
88        format!("step_secs: {}", report.query.step_secs),
89        format!("returned_series_count: {}", report.returned_series_count),
90        format!(
91            "returned_observation_count: {}",
92            report.returned_observation_count
93        ),
94    ]);
95    append_report_footer(&mut lines, &report.provenance);
96
97    for series in &report.series {
98        lines.push(String::new());
99        lines.push(format!("{}:", sanitize_text(&series.name)));
100        lines.extend(series.observations.iter().map(|observation| {
101            format!(
102                "  {}  {}",
103                observation.timestamp_unix_secs,
104                metric_observation_text(report.query.metric, &observation.value)
105            )
106        }));
107    }
108    lines.join("\n")
109}
110
111fn metric_observation_text(metric: IcMetricKind, value: &str) -> String {
112    if metric == IcMetricKind::CycleBurnRate {
113        decimal_cycle_rate_text(value)
114    } else {
115        sanitize_text(value)
116    }
117}
118
119/// Render one official Dashboard canister report as human-facing text.
120#[must_use]
121pub fn ic_canister_report_text(report: &IcCanisterReport) -> String {
122    let mut lines = report_header(&report.provenance);
123    lines.extend([
124        format!("canister_id: {}", report.canister_id),
125        format!("dashboard_id: {}", report.dashboard_id),
126        format!("name: {}", text_or_dash(&report.name)),
127        format!(
128            "canister_type: {}",
129            report
130                .canister_type
131                .as_deref()
132                .map_or_else(|| "-".to_string(), text_or_dash)
133        ),
134        format!("subnet_id: {}", report.subnet_id),
135        format!("controller_count: {}", report.controllers.len()),
136        format!("language: {}", text_or_dash(&report.language)),
137        format!("module_hash: {}", text_or_dash(&report.module_hash)),
138        format!(
139            "dashboard_updated_at: {}",
140            sanitize_text(&report.dashboard_updated_at)
141        ),
142        format!(
143            "upgrade_history_available: {}",
144            yes_no(report.upgrades.is_some())
145        ),
146        format!(
147            "upgrade_count: {}",
148            report
149                .upgrade_count
150                .map_or_else(|| "-".to_string(), |count| count.to_string())
151        ),
152    ]);
153    append_report_footer(&mut lines, &report.provenance);
154
155    if !report.controllers.is_empty() {
156        lines.push(String::new());
157        lines.push("controllers:".to_string());
158        lines.extend(
159            report
160                .controllers
161                .iter()
162                .map(|controller| format!("  {controller}")),
163        );
164    }
165
166    if let Some(latest) = report
167        .upgrades
168        .as_ref()
169        .and_then(|upgrades| upgrades.first())
170    {
171        lines.push(String::new());
172        lines.push("latest_upgrade:".to_string());
173        lines.push(format!("  proposal_id: {}", latest.proposal_id));
174        lines.push(format!(
175            "  executed_timestamp_seconds: {}",
176            latest.executed_timestamp_seconds
177        ));
178        lines.push(format!("  module_hash: {}", latest.module_hash));
179    }
180
181    lines.join("\n")
182}
183
184/// Render one official Dashboard canister-count report as human-facing text.
185#[must_use]
186pub fn ic_canister_count_report_text(report: &IcCanisterCountReport) -> String {
187    let mut lines = report_header(&report.provenance);
188    lines.push(format!("total: {}", report.total));
189    append_filters(&mut lines, &report.filters);
190    append_report_footer(&mut lines, &report.provenance);
191    lines.join("\n")
192}
193
194/// Render one bounded official Dashboard canister page as human-facing text.
195#[must_use]
196pub fn ic_canister_page_report_text(report: &IcCanisterPageReport) -> String {
197    let mut lines = report_header(&report.provenance);
198    lines.extend([
199        format!("returned_count: {}", report.returned_count),
200        format!("requested_limit: {}", report.requested_limit),
201        format!(
202            "after: {}",
203            report.after.as_deref().map_or("-", |value| value)
204        ),
205        format!(
206            "before: {}",
207            report.before.as_deref().map_or("-", |value| value)
208        ),
209        format!(
210            "previous_cursor: {}",
211            report.previous_cursor.as_deref().map_or("-", |value| value)
212        ),
213        format!(
214            "next_cursor: {}",
215            report.next_cursor.as_deref().map_or("-", |value| value)
216        ),
217    ]);
218    append_filters(&mut lines, &report.filters);
219    append_report_footer(&mut lines, &report.provenance);
220
221    if !report.rows.is_empty() {
222        lines.push(String::new());
223        lines.push("canisters:".to_string());
224        lines.extend(report.rows.iter().map(|row| {
225            format!(
226                "  {}  name={}  type={}  subnet={}  controllers={}  language={}  updated={}",
227                row.canister_id,
228                text_or_dash(&row.name),
229                row.canister_type
230                    .as_deref()
231                    .map_or_else(|| "-".to_string(), text_or_dash),
232                row.subnet_id,
233                row.controllers.len(),
234                text_or_dash(&row.language),
235                sanitize_text(&row.dashboard_updated_at),
236            )
237        }));
238    }
239    lines.join("\n")
240}
241
242fn report_header(provenance: &IcDashboardReportProvenance) -> Vec<String> {
243    vec![
244        format!("network: {}", sanitize_text(&provenance.network)),
245        format!("authority: {}", sanitize_text(&provenance.authority)),
246    ]
247}
248
249fn append_report_footer(lines: &mut Vec<String>, provenance: &IcDashboardReportProvenance) {
250    lines.extend([
251        format!("certified: {}", yes_no(provenance.certified)),
252        format!(
253            "point_in_time_guaranteed: {}",
254            yes_no(provenance.point_in_time_guaranteed)
255        ),
256        format!("fetched_at: {}", sanitize_text(&provenance.fetched_at)),
257        format!(
258            "source_endpoint: {}",
259            sanitize_text(&provenance.source_endpoint)
260        ),
261    ]);
262}
263
264fn append_filters(lines: &mut Vec<String>, filters: &IcCanisterFilters) {
265    if let Some(has_name) = filters.has_name {
266        lines.push(format!("filter.has_name: {}", yes_no(has_name)));
267    }
268    if let Some(subnet_id) = filters.subnet_id.as_deref() {
269        lines.push(format!("filter.subnet_id: {subnet_id}"));
270    }
271    if let Some(controller_id) = filters.controller_id.as_deref() {
272        lines.push(format!("filter.controller_id: {controller_id}"));
273    }
274    if !filters.languages.is_empty() {
275        lines.push(format!(
276            "filter.languages: {}",
277            filters
278                .languages
279                .iter()
280                .map(|value| sanitize_text(value))
281                .collect::<Vec<_>>()
282                .join(",")
283        ));
284    }
285    if !filters.canister_types.is_empty() {
286        lines.push(format!(
287            "filter.canister_types: {}",
288            filters
289                .canister_types
290                .iter()
291                .map(|value| sanitize_text(value))
292                .collect::<Vec<_>>()
293                .join(",")
294        ));
295    }
296    if let Some(query) = filters.query.as_deref() {
297        lines.push(format!("filter.query: {}", sanitize_text(query)));
298    }
299}
300
301fn text_or_dash(value: &str) -> String {
302    if value.is_empty() {
303        "-".to_string()
304    } else {
305        sanitize_text(value)
306    }
307}