Skip to main content

elasticctl_api/
dashboards.rs

1//! Typed dashboard models and public Kibana route wrappers.
2
3use 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/// The portable, author-controlled dashboard representation.
12///
13/// `data` is an ordered, open JSON map so new public API fields survive a
14/// portable artifact round trip.
15#[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/// A dashboard list row returned by Kibana.
71#[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/// A warning Kibana returned while transforming a dashboard.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct DashboardWarning {
86    pub message: String,
87}
88
89/// A full dashboard returned by Kibana.
90#[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/// One page returned by Kibana's dashboard list route.
99#[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/// One submitted value that Kibana did not persist.
108#[derive(Debug, Clone, PartialEq)]
109pub struct DashboardLoss {
110    pub path: String,
111    pub expected: Value,
112    pub actual: Option<Value>,
113}
114
115/// List one page of dashboards through the public Dashboards API.
116pub 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
135/// Get one dashboard by a stable id read from a response or a local file.
136pub async fn get(transport: &Transport, id: &str) -> Result<Dashboard> {
137    get_selector(transport, require_path_segment(id)?).await
138}
139
140/// Get one dashboard by the caller's own selector, which keeps the caller's
141/// validation.
142pub(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
150/// Upsert one dashboard using only its portable body.
151pub 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
163/// Delete one dashboard by id.
164pub 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
176/// Reject locally known dashboard values that Kibana accepts but drops.
177pub 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
188/// Return sorted, unique data-view reference ids found anywhere in `value`.
189pub 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
216/// Find submitted values that are absent or changed in an accepted response.
217pub 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/// The wire row is intentionally separate from the flat rendering summary.
305/// Kibana nests dashboard fields under `data`; new nested fields do not affect
306/// list selection and are not rejected merely because this CLI does not render
307/// them yet.
308#[derive(Deserialize)]
309#[serde(deny_unknown_fields)]
310struct SearchDashboardRow {
311    id: String,
312    data: SearchDashboardData,
313    // The measured row carries its own open metadata object. It is not part
314    // of the flat rendering summary, but requiring its object shape keeps a
315    // malformed successful response from being silently accepted.
316    #[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}