Skip to main content

elasticctl_api/
dashboards.rs

1//! Typed dashboard models and public Kibana route wrappers.
2
3use 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/// The portable, author-controlled dashboard representation.
11///
12/// `data` is an ordered, open JSON map so new public API fields survive a
13/// portable artifact round trip.
14#[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/// A dashboard list row returned by Kibana.
70#[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/// A warning Kibana returned while transforming a dashboard.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct DashboardWarning {
85    pub message: String,
86}
87
88/// A full dashboard returned by Kibana.
89#[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/// One page returned by Kibana's dashboard list route.
98#[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/// One submitted value that Kibana did not persist.
107#[derive(Debug, Clone, PartialEq)]
108pub struct DashboardLoss {
109    pub path: String,
110    pub expected: Value,
111    pub actual: Option<Value>,
112}
113
114/// List one page of dashboards through the public Dashboards API.
115pub 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
134/// Get one dashboard by its stable id.
135pub 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
140/// Upsert one dashboard using only its portable body.
141pub 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
152/// Delete one dashboard by id.
153pub 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
164/// Reject locally known dashboard values that Kibana accepts but drops.
165pub 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
176/// Return sorted, unique data-view reference ids found anywhere in `value`.
177pub 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
204/// Find submitted values that are absent or changed in an accepted response.
205pub 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/// The wire row is intentionally separate from the flat rendering summary.
293/// Kibana nests dashboard fields under `data`; new nested fields do not affect
294/// list selection and are not rejected merely because this CLI does not render
295/// them yet.
296#[derive(Deserialize)]
297#[serde(deny_unknown_fields)]
298struct SearchDashboardRow {
299    id: String,
300    data: SearchDashboardData,
301    // The measured row carries its own open metadata object. It is not part
302    // of the flat rendering summary, but requiring its object shape keeps a
303    // malformed successful response from being silently accepted.
304    #[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}