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