Skip to main content

elasticctl_api/
dashboards_ops.rs

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