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}