1use 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#[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#[derive(Debug, Clone, PartialEq, Serialize)]
33pub struct DashboardList {
34 pub total: u64,
35 pub dashboards: Vec<DashboardSummary>,
36 pub truncated: bool,
37}
38
39pub 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
77pub 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
96pub 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
167pub 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
173pub 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
211pub 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}