Skip to main content

elasticctl_api/
dashboards_ops.rs

1//! Dashboard selection and portable transfer orchestration.
2
3use crate::content_codec::{self, ContentFormat};
4use crate::dashboards::{self, Dashboard, DashboardSpec, DashboardSummary};
5use crate::data_views;
6use crate::ops::{DeleteOutcome, ExportOutcome, MutationPlan};
7use crate::saved_objects;
8use elasticctl_core::{Error, ErrorKind, Feature, Result, Transport};
9use serde::Serialize;
10use serde_json::{Map, Value, json};
11use std::collections::{BTreeMap, BTreeSet};
12use std::path::Path;
13
14mod bundle;
15mod delete;
16mod import;
17pub use bundle::{
18    BundleImportOutcome, BundleImportPlan, apply_bundle_import, export_bundle, plan_bundle_import,
19};
20pub use delete::{DashboardDeletePlan, apply_delete, plan_delete};
21pub use import::{DashboardImportPlan, DashboardImportReport, apply_import, plan_import};
22
23/// Dashboard list filters accepted by the portable command surface.
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
25pub struct DashboardFilter {
26    pub search: Option<String>,
27    pub tag: Option<String>,
28    pub limit: Option<usize>,
29}
30
31/// Dashboard list output after collection, stable-id sorting, and limiting.
32#[derive(Debug, Clone, PartialEq, Serialize)]
33pub struct DashboardList {
34    pub total: u64,
35    pub dashboards: Vec<DashboardSummary>,
36    pub truncated: bool,
37}
38
39/// Resolve a selector from dashboard summaries.
40///
41/// Stable ids win over titles. A title is only a convenience selector and must
42/// identify exactly one dashboard.
43pub fn resolve_from_summaries(
44    dashboards: &[DashboardSummary],
45    selector: &str,
46) -> Result<DashboardSummary> {
47    if let Some(dashboard) = dashboards.iter().find(|dashboard| dashboard.id == selector) {
48        return Ok(dashboard.clone());
49    }
50
51    let mut matches: Vec<_> = dashboards
52        .iter()
53        .filter(|dashboard| dashboard.title == selector)
54        .cloned()
55        .collect();
56    matches.sort_by(|left, right| left.id.cmp(&right.id));
57    match matches.as_slice() {
58        [] => Err(Error::new(
59            ErrorKind::NotFound,
60            format!("no dashboard with id or title '{selector}'"),
61        )),
62        [dashboard] => Ok(dashboard.clone()),
63        _ => Err(Error::new(
64            ErrorKind::Conflict,
65            format!(
66                "dashboard title '{selector}' is ambiguous: {}",
67                matches
68                    .iter()
69                    .map(|dashboard| dashboard.id.as_str())
70                    .collect::<Vec<_>>()
71                    .join(", ")
72            ),
73        )),
74    }
75}
76
77/// Resolve a dashboard by exact id, then by exact title only after an id miss.
78pub async fn resolve(transport: &Transport, selector: &str) -> Result<DashboardSummary> {
79    match dashboards::get(transport, selector).await {
80        Ok(dashboard) if dashboard.id == selector => summary_from_dashboard(dashboard),
81        Ok(dashboard) => Err(Error::new(
82            ErrorKind::Http,
83            format!(
84                "decoding dashboard get: expected id '{selector}', got '{}'",
85                dashboard.id
86            ),
87        )),
88        Err(error) if error.kind == ErrorKind::NotFound => {
89            let listed = list_op(transport, &DashboardFilter::default()).await?;
90            resolve_from_summaries(&listed.dashboards, selector)
91        }
92        Err(error) => Err(error),
93    }
94}
95
96/// Page through dashboard summaries, apply filters, and sort by stable id.
97pub async fn list_op(transport: &Transport, filter: &DashboardFilter) -> Result<DashboardList> {
98    let tags = filter.tag.iter().cloned().collect::<Vec<_>>();
99    let mut page_number = 1;
100    let mut total = None;
101    let mut dashboards = Vec::new();
102    let mut ids = BTreeSet::new();
103
104    loop {
105        let page =
106            dashboards::search(transport, page_number, filter.search.as_deref(), &tags).await?;
107        if page.page != page_number || page.per_page != 1000 {
108            return Err(Error::new(
109                ErrorKind::Http,
110                "decoding dashboard search: unexpected page metadata",
111            ));
112        }
113        if let Some(total) = total {
114            if page.total != total {
115                return Err(Error::new(
116                    ErrorKind::Http,
117                    "decoding dashboard search: total changed while paging",
118                ));
119            }
120        } else {
121            total = Some(page.total);
122        }
123        let page_len = page.data.len();
124        for dashboard in page.data {
125            if !ids.insert(dashboard.id.clone()) {
126                return Err(Error::new(
127                    ErrorKind::Http,
128                    format!(
129                        "decoding dashboard search: duplicate dashboard id '{}'",
130                        dashboard.id
131                    ),
132                ));
133            }
134            dashboards.push(dashboard);
135        }
136        let expected_total = total.expect("set from the first page");
137        if dashboards.len() as u64 >= expected_total {
138            break;
139        }
140        if page_len != 1000 {
141            return Err(Error::new(
142                ErrorKind::Http,
143                "decoding dashboard search: page was short before total",
144            ));
145        }
146        page_number += 1;
147    }
148
149    dashboards.sort_by(|left, right| left.id.cmp(&right.id));
150    let total = total.unwrap_or(0);
151    if dashboards.len() as u64 > total {
152        return Err(Error::new(
153            ErrorKind::Http,
154            "decoding dashboard search: returned more dashboards than total",
155        ));
156    }
157    let limit = filter.limit.unwrap_or(usize::MAX);
158    let truncated = dashboards.len() > limit;
159    dashboards.truncate(limit);
160    Ok(DashboardList {
161        total,
162        dashboards,
163        truncated,
164    })
165}
166
167/// Resolve one selector, then read its complete dashboard.
168pub async fn get_op(transport: &Transport, selector: &str) -> Result<Dashboard> {
169    let dashboard = resolve(transport, selector).await?;
170    dashboards::get(transport, &dashboard.id).await
171}
172
173/// Fully read and validate a portable dashboard artifact.
174pub fn validate(path: &Path) -> Result<Vec<DashboardSpec>> {
175    let body = std::fs::read_to_string(path).map_err(|error| {
176        Error::new(
177            ErrorKind::Error,
178            format!("reading {}: {error}", path.display()),
179        )
180    })?;
181    let mut specs = content_codec::decode_sequence::<DashboardSpec>(
182        &body,
183        ContentFormat::from_path(path),
184        "dashboard",
185    )?;
186    let mut seen = BTreeSet::new();
187    let mut duplicates = BTreeSet::new();
188    for spec in &specs {
189        dashboards::validate_spec(spec)?;
190        if !seen.insert(spec.id.as_str()) {
191            duplicates.insert(spec.id.as_str());
192        }
193    }
194    if !duplicates.is_empty() {
195        return Err(Error::new(
196            ErrorKind::Error,
197            format!(
198                "duplicate dashboard ids: {}",
199                duplicates.into_iter().collect::<Vec<_>>().join(", ")
200            ),
201        ));
202    }
203    specs.sort_by(|left, right| left.id.cmp(&right.id));
204    Ok(specs)
205}
206
207fn invalid_plan(message: impl Into<String>) -> Result<()> {
208    Err(Error::new(ErrorKind::Error, message))
209}
210
211/// Export selected dashboards as a portable JSON or YAML artifact.
212pub async fn export(
213    transport: &Transport,
214    selectors: &[String],
215    format: ContentFormat,
216) -> Result<ExportOutcome> {
217    let selected = if selectors.is_empty() {
218        list_op(transport, &DashboardFilter::default())
219            .await?
220            .dashboards
221    } else {
222        let mut selected = Vec::with_capacity(selectors.len());
223        for selector in selectors {
224            selected.push(resolve(transport, selector).await?);
225        }
226        selected
227    };
228    let selected: BTreeMap<_, _> = selected
229        .into_iter()
230        .map(|dashboard| (dashboard.id.clone(), dashboard))
231        .collect();
232    let mut specs = Vec::with_capacity(selected.len());
233    for (id, _) in selected {
234        let dashboard = dashboards::get(transport, &id).await?;
235        if dashboard.id != id {
236            return Err(Error::new(
237                ErrorKind::Http,
238                format!("dashboard export was short: expected id '{id}'"),
239            ));
240        }
241        if !dashboard.warnings.is_empty() {
242            return Err(Error::new(
243                ErrorKind::Unsupported,
244                format!(
245                    "dashboard '{id}' cannot be exported through the typed API without loss: {}; use `dashboards bundle export {id}`",
246                    dashboard
247                        .warnings
248                        .iter()
249                        .map(|warning| warning.message.as_str())
250                        .collect::<Vec<_>>()
251                        .join("; ")
252                ),
253            ));
254        }
255        let spec = DashboardSpec {
256            id: dashboard.id,
257            data: dashboard.data,
258        };
259        dashboards::validate_spec(&spec)?;
260        specs.push(spec);
261    }
262    specs.sort_by(|left, right| left.id.cmp(&right.id));
263    let body = content_codec::encode_sequence(&specs, format)?;
264    Ok(ExportOutcome {
265        body,
266        exported: specs.len() as u64,
267        missing: Vec::<Value>::new(),
268    })
269}
270
271fn summary_from_dashboard(dashboard: Dashboard) -> Result<DashboardSummary> {
272    let title = dashboard
273        .data
274        .get("title")
275        .and_then(Value::as_str)
276        .filter(|title| !title.trim().is_empty())
277        .ok_or_else(|| {
278            Error::new(
279                ErrorKind::Http,
280                "decoding dashboard get: data.title must be a non-empty string",
281            )
282        })?;
283    Ok(DashboardSummary {
284        id: dashboard.id,
285        title: title.to_string(),
286        description: dashboard
287            .data
288            .get("description")
289            .and_then(Value::as_str)
290            .map(str::to_string),
291        tags: dashboard
292            .data
293            .get("tags")
294            .and_then(Value::as_array)
295            .map(|tags| {
296                tags.iter()
297                    .filter_map(Value::as_str)
298                    .map(str::to_string)
299                    .collect()
300            }),
301    })
302}