1use crate::compose::require_path_segment;
4use elasticctl_core::{Error, ErrorKind, Feature, Result, Transport, urlencode};
5use serde::{Deserialize, Deserializer, Serialize, de::DeserializeOwned};
6use serde_json::{Map, Value};
7use std::collections::BTreeSet;
8
9const BASE: &str = "/api/dashboards";
10
11#[derive(Debug, Clone, PartialEq, Serialize)]
16pub struct DashboardSpec {
17 pub id: String,
18 pub data: Map<String, Value>,
19}
20
21#[derive(Deserialize)]
22#[serde(deny_unknown_fields)]
23struct RawDashboardSpec {
24 id: String,
25 data: Map<String, Value>,
26}
27
28impl DashboardSpec {
29 fn validate_shape(&self) -> Result<()> {
30 if self.id.trim().is_empty() {
31 return Err(Error::new(
32 ErrorKind::Error,
33 "dashboard id must not be empty",
34 ));
35 }
36 match self.data.get("title") {
37 Some(Value::String(title)) if !title.trim().is_empty() => Ok(()),
38 _ => Err(Error::new(
39 ErrorKind::Error,
40 "dashboard data.title must be a non-empty string",
41 )),
42 }
43 }
44}
45
46impl TryFrom<Value> for DashboardSpec {
47 type Error = Error;
48
49 fn try_from(value: Value) -> Result<Self> {
50 serde_json::from_value(value)
51 .map_err(|error| Error::new(ErrorKind::Error, format!("decoding dashboard: {error}")))
52 }
53}
54
55impl<'de> Deserialize<'de> for DashboardSpec {
56 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
57 where
58 D: Deserializer<'de>,
59 {
60 let raw = RawDashboardSpec::deserialize(deserializer)?;
61 let spec = Self {
62 id: raw.id,
63 data: raw.data,
64 };
65 spec.validate_shape().map_err(serde::de::Error::custom)?;
66 Ok(spec)
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73pub struct DashboardSummary {
74 pub id: String,
75 pub title: String,
76 #[serde(default)]
77 pub description: Option<String>,
78 #[serde(default)]
79 pub tags: Option<Vec<String>>,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct DashboardWarning {
86 pub message: String,
87}
88
89#[derive(Debug, Clone, PartialEq)]
91pub struct Dashboard {
92 pub id: String,
93 pub data: Map<String, Value>,
94 pub meta: Map<String, Value>,
95 pub warnings: Vec<DashboardWarning>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct DashboardPage {
101 pub data: Vec<DashboardSummary>,
102 pub page: u64,
103 pub per_page: u64,
104 pub total: u64,
105}
106
107#[derive(Debug, Clone, PartialEq)]
109pub struct DashboardLoss {
110 pub path: String,
111 pub expected: Value,
112 pub actual: Option<Value>,
113}
114
115pub async fn search(
117 transport: &Transport,
118 page: u64,
119 query: Option<&str>,
120 tags: &[String],
121) -> Result<DashboardPage> {
122 transport.require_feature(Feature::Dashboards).await?;
123 let mut route = format!("{BASE}?page={page}&per_page=1000");
124 if let Some(query) = query {
125 route.push_str("&query=");
126 route.push_str(&urlencode(query));
127 }
128 for tag in tags {
129 route.push_str("&tags=");
130 route.push_str(&urlencode(tag));
131 }
132 decode_search(&transport.get(&route).await?)
133}
134
135pub async fn get(transport: &Transport, id: &str) -> Result<Dashboard> {
137 get_selector(transport, require_path_segment(id)?).await
138}
139
140pub(crate) async fn get_selector(transport: &Transport, selector: &str) -> Result<Dashboard> {
143 transport.require_feature(Feature::Dashboards).await?;
144 decode_dashboard(
145 &transport.get(&dashboard_path(selector)).await?,
146 "dashboard get",
147 )
148}
149
150pub async fn put(transport: &Transport, spec: &DashboardSpec) -> Result<Dashboard> {
152 validate_spec(spec)?;
153 let id = require_path_segment(&spec.id)?;
154 transport.require_feature(Feature::Dashboards).await?;
155 decode_dashboard(
156 &transport
157 .put(&dashboard_path(id), &Value::Object(spec.data.clone()))
158 .await?,
159 "dashboard put",
160 )
161}
162
163pub async fn delete(transport: &Transport, id: &str) -> Result<()> {
165 let id = require_path_segment(id)?;
166 transport.require_feature(Feature::Dashboards).await?;
167 match transport.delete(&dashboard_path(id)).await? {
168 Value::Null => Ok(()),
169 _ => Err(Error::new(
170 ErrorKind::Http,
171 "decoding dashboard delete: expected an empty response body",
172 )),
173 }
174}
175
176pub fn validate_spec(spec: &DashboardSpec) -> Result<()> {
178 spec.validate_shape()?;
179 if let Some(path) = time_range_mode_path(&Value::Object(spec.data.clone()), "$") {
180 return Err(Error::new(
181 ErrorKind::Unsupported,
182 format!("dashboard {path} is unsupported because Kibana does not persist it"),
183 ));
184 }
185 Ok(())
186}
187
188pub fn collect_data_view_refs(value: &Value) -> Vec<String> {
190 fn collect(value: &Value, ids: &mut BTreeSet<String>) {
191 match value {
192 Value::Object(map) => {
193 if map.get("type").and_then(Value::as_str) == Some("data_view_reference")
194 && let Some(id) = map.get("ref_id").and_then(Value::as_str)
195 {
196 ids.insert(id.to_string());
197 }
198 for value in map.values() {
199 collect(value, ids);
200 }
201 }
202 Value::Array(values) => {
203 for value in values {
204 collect(value, ids);
205 }
206 }
207 _ => {}
208 }
209 }
210
211 let mut ids = BTreeSet::new();
212 collect(value, &mut ids);
213 ids.into_iter().collect()
214}
215
216pub fn subset_losses(expected: &Value, actual: &Value) -> Vec<DashboardLoss> {
218 fn collect(
219 expected: &Value,
220 actual: Option<&Value>,
221 path: &str,
222 losses: &mut Vec<DashboardLoss>,
223 ) {
224 let Some(actual) = actual else {
225 losses.push(DashboardLoss {
226 path: path.to_string(),
227 expected: expected.clone(),
228 actual: None,
229 });
230 return;
231 };
232
233 match (expected, actual) {
234 (Value::Object(expected), Value::Object(actual)) => {
235 for (key, expected) in expected {
236 let path = format!("{path}.{}", json_path_key(key));
237 collect(expected, actual.get(key), &path, losses);
238 }
239 }
240 (Value::Array(expected), Value::Array(actual)) => {
241 for (index, expected) in expected.iter().enumerate() {
242 let path = format!("{path}[{index}]");
243 collect(expected, actual.get(index), &path, losses);
244 }
245 }
246 _ if expected == actual => {}
247 _ => losses.push(DashboardLoss {
248 path: path.to_string(),
249 expected: expected.clone(),
250 actual: Some(actual.clone()),
251 }),
252 }
253 }
254
255 let mut losses = Vec::new();
256 collect(expected, Some(actual), "$", &mut losses);
257 losses
258}
259
260fn dashboard_path(id: &str) -> String {
261 format!("{BASE}/{}", urlencode(id))
262}
263
264fn time_range_mode_path(value: &Value, path: &str) -> Option<String> {
265 match value {
266 Value::Object(map) => {
267 for (key, value) in map {
268 let child_path = format!("{path}.{}", json_path_key(key));
269 if key == "time_range" && value.get("mode").is_some() {
270 return Some(format!("{child_path}.mode"));
271 }
272 if let Some(path) = time_range_mode_path(value, &child_path) {
273 return Some(path);
274 }
275 }
276 None
277 }
278 Value::Array(values) => values
279 .iter()
280 .enumerate()
281 .find_map(|(index, value)| time_range_mode_path(value, &format!("{path}[{index}]"))),
282 _ => None,
283 }
284}
285
286fn json_path_key(key: &str) -> String {
287 if key.chars().enumerate().all(|(index, character)| {
288 character == '_'
289 || character.is_ascii_alphanumeric() && (index > 0 || !character.is_ascii_digit())
290 }) {
291 key.to_string()
292 } else {
293 format!("['{}']", key.replace('\\', "\\\\").replace('\'', "\\'"))
294 }
295}
296
297#[derive(Deserialize)]
298#[serde(deny_unknown_fields)]
299struct SearchEnvelope {
300 data: Vec<SearchDashboardRow>,
301 meta: SearchMeta,
302}
303
304#[derive(Deserialize)]
309#[serde(deny_unknown_fields)]
310struct SearchDashboardRow {
311 id: String,
312 data: SearchDashboardData,
313 #[serde(rename = "meta")]
317 _meta: Map<String, Value>,
318}
319
320#[derive(Deserialize)]
321struct SearchDashboardData {
322 title: String,
323 #[serde(default)]
324 description: Option<String>,
325 #[serde(default)]
326 tags: Option<Vec<String>>,
327 #[serde(flatten)]
328 _extra: Map<String, Value>,
329}
330
331#[derive(Deserialize)]
332#[serde(deny_unknown_fields)]
333struct SearchMeta {
334 page: u64,
335 per_page: u64,
336 total: u64,
337}
338
339#[derive(Deserialize)]
340#[serde(deny_unknown_fields)]
341struct DashboardEnvelope {
342 id: String,
343 data: Map<String, Value>,
344 meta: Map<String, Value>,
345 #[serde(default)]
346 warnings: Vec<DashboardWarning>,
347}
348
349fn decode_search(body: &Value) -> Result<DashboardPage> {
350 let response = decode_envelope::<SearchEnvelope>(body, "dashboard search")?;
351 let data = response
352 .data
353 .into_iter()
354 .map(|row| {
355 if row.id.trim().is_empty() {
356 return Err(Error::new(
357 ErrorKind::Http,
358 "decoding dashboard search: id must be a non-empty string",
359 ));
360 }
361 if row.data.title.trim().is_empty() {
362 return Err(Error::new(
363 ErrorKind::Http,
364 "decoding dashboard search: data.title must be a non-empty string",
365 ));
366 }
367 Ok(DashboardSummary {
368 id: row.id,
369 title: row.data.title,
370 description: row.data.description,
371 tags: row.data.tags,
372 })
373 })
374 .collect::<Result<Vec<_>>>()?;
375 Ok(DashboardPage {
376 data,
377 page: response.meta.page,
378 per_page: response.meta.per_page,
379 total: response.meta.total,
380 })
381}
382
383fn decode_dashboard(body: &Value, context: &str) -> Result<Dashboard> {
384 let response = decode_envelope::<DashboardEnvelope>(body, context)?;
385 if response.id.trim().is_empty() {
386 return Err(Error::new(
387 ErrorKind::Http,
388 format!("decoding {context}: id must be a non-empty string"),
389 ));
390 }
391 if !matches!(response.data.get("title"), Some(Value::String(title)) if !title.trim().is_empty())
392 {
393 return Err(Error::new(
394 ErrorKind::Http,
395 format!("decoding {context}: data.title must be a non-empty string"),
396 ));
397 }
398 Ok(Dashboard {
399 id: response.id,
400 data: response.data,
401 meta: response.meta,
402 warnings: response.warnings,
403 })
404}
405
406fn decode_envelope<T: DeserializeOwned>(body: &Value, context: &str) -> Result<T> {
407 serde_json::from_value(body.clone())
408 .map_err(|error| Error::new(ErrorKind::Http, format!("decoding {context}: {error}")))
409}