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