Skip to main content

elasticctl_api/
dashboards_ops.rs

1//! Dashboard selection and portable transfer orchestration.
2
3use crate::content_codec::{self, ContentFormat};
4use crate::dashboards::{self, Dashboard, DashboardSpec, DashboardSummary};
5use crate::data_views;
6use crate::ops::{DeleteOutcome, ExportOutcome, MutationPlan};
7use crate::saved_objects;
8use elasticctl_core::{Error, ErrorKind, Feature, Result, Transport};
9use serde::Serialize;
10use serde_json::{Map, Value, json};
11use std::collections::{BTreeMap, BTreeSet};
12use std::path::Path;
13
14/// Dashboard list filters accepted by the portable command surface.
15#[derive(Debug, Clone, Default, PartialEq, Eq)]
16pub struct DashboardFilter {
17    pub search: Option<String>,
18    pub tag: Option<String>,
19    pub limit: Option<usize>,
20}
21
22/// Dashboard list output after collection, stable-id sorting, and limiting.
23#[derive(Debug, Clone, PartialEq, Serialize)]
24pub struct DashboardList {
25    pub total: u64,
26    pub dashboards: Vec<DashboardSummary>,
27    pub truncated: bool,
28}
29
30/// The immutable, guard-ready import work computed from one portable
31/// dashboard artifact.
32#[derive(Debug, Clone, PartialEq)]
33pub struct DashboardImportPlan {
34    pub preview: crate::ops::MutationPlan,
35    pub specs: Vec<DashboardSpec>,
36    pub before: BTreeMap<String, Option<DashboardSpec>>,
37    pub skipped: Vec<Value>,
38    pub total: usize,
39    pub overwrite: bool,
40}
41
42/// The per-object result of applying a guarded dashboard import.
43///
44/// Field order is rendered JSON order and is contractual.
45#[derive(Debug, Clone, PartialEq, Serialize)]
46pub struct DashboardImportReport {
47    pub applied: bool,
48    pub succeeded: Vec<Value>,
49    pub skipped: Vec<Value>,
50    pub failed: Vec<Value>,
51    pub lossy: Vec<Value>,
52    pub total: usize,
53}
54
55/// The immutable, guard-ready work computed from one opaque Saved Objects
56/// dashboard bundle.
57#[derive(Debug, Clone, PartialEq)]
58pub struct BundleImportPlan {
59    pub preview: MutationPlan,
60    pub ndjson: String,
61    pub scan: saved_objects::BundleScan,
62    pub overwrite: bool,
63}
64
65/// The per-object report from applying an opaque Saved Objects bundle import.
66#[derive(Debug, Clone, PartialEq, Serialize)]
67pub struct BundleImportOutcome {
68    pub applied: bool,
69    pub succeeded: Vec<Value>,
70    pub failed: Vec<Value>,
71    pub total: usize,
72}
73
74/// The immutable, guard-ready targets for dashboard deletion.
75#[derive(Debug, Clone, PartialEq)]
76pub struct DashboardDeletePlan {
77    pub preview: MutationPlan,
78    pub targets: Vec<DashboardSummary>,
79}
80
81/// Resolve a selector from dashboard summaries.
82///
83/// Stable ids win over titles. A title is only a convenience selector and must
84/// identify exactly one dashboard.
85pub fn resolve_from_summaries(
86    dashboards: &[DashboardSummary],
87    selector: &str,
88) -> Result<DashboardSummary> {
89    if let Some(dashboard) = dashboards.iter().find(|dashboard| dashboard.id == selector) {
90        return Ok(dashboard.clone());
91    }
92
93    let mut matches: Vec<_> = dashboards
94        .iter()
95        .filter(|dashboard| dashboard.title == selector)
96        .cloned()
97        .collect();
98    matches.sort_by(|left, right| left.id.cmp(&right.id));
99    match matches.as_slice() {
100        [] => Err(Error::new(
101            ErrorKind::NotFound,
102            format!("no dashboard with id or title '{selector}'"),
103        )),
104        [dashboard] => Ok(dashboard.clone()),
105        _ => Err(Error::new(
106            ErrorKind::Conflict,
107            format!(
108                "dashboard title '{selector}' is ambiguous: {}",
109                matches
110                    .iter()
111                    .map(|dashboard| dashboard.id.as_str())
112                    .collect::<Vec<_>>()
113                    .join(", ")
114            ),
115        )),
116    }
117}
118
119/// Resolve a dashboard by exact id, then by exact title only after an id miss.
120pub async fn resolve(transport: &Transport, selector: &str) -> Result<DashboardSummary> {
121    match dashboards::get(transport, selector).await {
122        Ok(dashboard) if dashboard.id == selector => summary_from_dashboard(dashboard),
123        Ok(dashboard) => Err(Error::new(
124            ErrorKind::Http,
125            format!(
126                "decoding dashboard get: expected id '{selector}', got '{}'",
127                dashboard.id
128            ),
129        )),
130        Err(error) if error.kind == ErrorKind::NotFound => {
131            let listed = list_op(transport, &DashboardFilter::default()).await?;
132            resolve_from_summaries(&listed.dashboards, selector)
133        }
134        Err(error) => Err(error),
135    }
136}
137
138/// Page through dashboard summaries, apply filters, and sort by stable id.
139pub async fn list_op(transport: &Transport, filter: &DashboardFilter) -> Result<DashboardList> {
140    let tags = filter.tag.iter().cloned().collect::<Vec<_>>();
141    let mut page_number = 1;
142    let mut total = None;
143    let mut dashboards = Vec::new();
144    let mut ids = BTreeSet::new();
145
146    loop {
147        let page =
148            dashboards::search(transport, page_number, filter.search.as_deref(), &tags).await?;
149        if page.page != page_number || page.per_page != 1000 {
150            return Err(Error::new(
151                ErrorKind::Http,
152                "decoding dashboard search: unexpected page metadata",
153            ));
154        }
155        if let Some(total) = total {
156            if page.total != total {
157                return Err(Error::new(
158                    ErrorKind::Http,
159                    "decoding dashboard search: total changed while paging",
160                ));
161            }
162        } else {
163            total = Some(page.total);
164        }
165        let page_len = page.data.len();
166        for dashboard in page.data {
167            if !ids.insert(dashboard.id.clone()) {
168                return Err(Error::new(
169                    ErrorKind::Http,
170                    format!(
171                        "decoding dashboard search: duplicate dashboard id '{}'",
172                        dashboard.id
173                    ),
174                ));
175            }
176            dashboards.push(dashboard);
177        }
178        let expected_total = total.expect("set from the first page");
179        if dashboards.len() as u64 >= expected_total {
180            break;
181        }
182        if page_len != 1000 {
183            return Err(Error::new(
184                ErrorKind::Http,
185                "decoding dashboard search: page was short before total",
186            ));
187        }
188        page_number += 1;
189    }
190
191    dashboards.sort_by(|left, right| left.id.cmp(&right.id));
192    let total = total.unwrap_or(0);
193    if dashboards.len() as u64 > total {
194        return Err(Error::new(
195            ErrorKind::Http,
196            "decoding dashboard search: returned more dashboards than total",
197        ));
198    }
199    let limit = filter.limit.unwrap_or(usize::MAX);
200    let truncated = dashboards.len() > limit;
201    dashboards.truncate(limit);
202    Ok(DashboardList {
203        total,
204        dashboards,
205        truncated,
206    })
207}
208
209/// Resolve one selector, then read its complete dashboard.
210pub async fn get_op(transport: &Transport, selector: &str) -> Result<Dashboard> {
211    let dashboard = resolve(transport, selector).await?;
212    dashboards::get(transport, &dashboard.id).await
213}
214
215/// Fully read and validate a portable dashboard artifact.
216pub fn validate(path: &Path) -> Result<Vec<DashboardSpec>> {
217    let body = std::fs::read_to_string(path).map_err(|error| {
218        Error::new(
219            ErrorKind::Error,
220            format!("reading {}: {error}", path.display()),
221        )
222    })?;
223    let mut specs = content_codec::decode_sequence::<DashboardSpec>(
224        &body,
225        ContentFormat::from_path(path),
226        "dashboard",
227    )?;
228    let mut seen = BTreeSet::new();
229    let mut duplicates = BTreeSet::new();
230    for spec in &specs {
231        dashboards::validate_spec(spec)?;
232        if !seen.insert(spec.id.as_str()) {
233            duplicates.insert(spec.id.as_str());
234        }
235    }
236    if !duplicates.is_empty() {
237        return Err(Error::new(
238            ErrorKind::Error,
239            format!(
240                "duplicate dashboard ids: {}",
241                duplicates.into_iter().collect::<Vec<_>>().join(", ")
242            ),
243        ));
244    }
245    specs.sort_by(|left, right| left.id.cmp(&right.id));
246    Ok(specs)
247}
248
249/// Fully validate an artifact and collect the precise import work to show in
250/// the mutation guard. The supplied transport is optional only for a local
251/// no-conflict plan with no data-view references.
252pub async fn plan_import(
253    transport: Option<&Transport>,
254    path: &Path,
255    overwrite: bool,
256    skip_existing: bool,
257) -> Result<DashboardImportPlan> {
258    let mut specs = validate(path)?;
259    if specs.is_empty() {
260        return Err(Error::new(
261            ErrorKind::Error,
262            "dashboard import needs at least one dashboard",
263        ));
264    }
265    if overwrite && skip_existing {
266        return Err(Error::new(
267            ErrorKind::Error,
268            "--overwrite and --skip-existing cannot be used together",
269        ));
270    }
271
272    let references: BTreeSet<_> = specs
273        .iter()
274        .flat_map(|spec| dashboards::collect_data_view_refs(&Value::Object(spec.data.clone())))
275        .collect();
276    let requires_server =
277        overwrite || skip_existing || transport.is_some() || !references.is_empty();
278    let transport = if requires_server { transport } else { None };
279    if (overwrite || skip_existing || !references.is_empty()) && transport.is_none() {
280        return Err(Error::new(
281            ErrorKind::Error,
282            "dashboard import preflight needs a transport",
283        ));
284    }
285
286    let total = specs.len();
287    let mut before = BTreeMap::new();
288    let mut conflicts = Vec::new();
289    if let Some(transport) = transport {
290        for spec in &specs {
291            match read_spec(transport, &spec.id).await {
292                Ok(current) => {
293                    if !overwrite && !skip_existing {
294                        conflicts.push(spec.id.clone());
295                    }
296                    before.insert(spec.id.clone(), Some(current));
297                }
298                Err(error) if error.kind == ErrorKind::NotFound => {
299                    before.insert(spec.id.clone(), None);
300                }
301                Err(error) => return Err(error),
302            }
303        }
304
305        let mut missing = Vec::new();
306        for id in references {
307            match data_views::get(transport, &id).await {
308                Ok(data_view) => match data_view.data_view.get("id").and_then(Value::as_str) {
309                    Some(actual) if actual == id => {}
310                    Some(actual) => {
311                        return Err(Error::new(
312                            ErrorKind::Http,
313                            format!("decoding data view: expected id '{id}', got '{actual}'"),
314                        ));
315                    }
316                    None => {
317                        return Err(Error::new(
318                            ErrorKind::Http,
319                            format!(
320                                "decoding data view: expected id '{id}', got missing or non-string id"
321                            ),
322                        ));
323                    }
324                },
325                Err(error) if error.kind == ErrorKind::NotFound => missing.push(id),
326                Err(error) => return Err(error),
327            }
328        }
329        if !missing.is_empty() {
330            return Err(Error::new(
331                ErrorKind::NotFound,
332                format!("referenced data views do not exist: {}", missing.join(", ")),
333            ));
334        }
335    } else {
336        before.extend(specs.iter().map(|spec| (spec.id.clone(), None)));
337    }
338
339    if !conflicts.is_empty() {
340        return Err(Error::new(
341            ErrorKind::Conflict,
342            format!("dashboards already exist: {}", conflicts.join(", ")),
343        ));
344    }
345
346    let mut skipped = Vec::new();
347    if skip_existing {
348        specs.retain(|spec| match before.get(&spec.id) {
349            Some(Some(_)) => {
350                skipped.push(serde_json::json!({"id": spec.id, "reason": "exists"}));
351                false
352            }
353            _ => true,
354        });
355        before.retain(|id, _| specs.iter().any(|spec| spec.id == *id));
356    }
357
358    let preview = crate::ops::MutationPlan {
359        preview_action: format!("Import {} dashboard(s)", specs.len()),
360        preview_details: import_preview_details(&specs, &before),
361        targets: specs.iter().map(|spec| spec.id.clone()).collect(),
362    };
363    Ok(DashboardImportPlan {
364        preview,
365        specs,
366        before,
367        skipped,
368        total,
369        overwrite,
370    })
371}
372
373/// Apply a guard-approved dashboard import without rereading its source file.
374///
375/// The final dashboard GET immediately precedes each possible PUT. Kibana has
376/// no conditional-write token, so the interval after that read is the smallest
377/// unavoidable race window. Independent objects continue after failures, and
378/// successful earlier writes are never rolled back.
379pub async fn apply_import(
380    transport: &Transport,
381    plan: &DashboardImportPlan,
382) -> Result<DashboardImportReport> {
383    validate_import_plan(plan)?;
384    let mut succeeded = Vec::new();
385    let mut failed = Vec::new();
386    let mut lossy = Vec::new();
387
388    for desired in &plan.specs {
389        let Some(before) = plan.before.get(&desired.id) else {
390            failed.push(failed_row(&desired.id, false, "missing preflight snapshot"));
391            continue;
392        };
393        let current = match read_spec(transport, &desired.id).await {
394            Ok(current) => Some(current),
395            Err(error) if error.kind == ErrorKind::NotFound => None,
396            Err(error) => {
397                failed.push(failed_row(&desired.id, false, error.message));
398                continue;
399            }
400        };
401
402        match (before, current) {
403            (None, Some(_)) => {
404                failed.push(failed_row(
405                    &desired.id,
406                    false,
407                    "dashboard appeared since preview",
408                ));
409            }
410            (Some(_), None) => {
411                failed.push(failed_row(
412                    &desired.id,
413                    false,
414                    "dashboard disappeared since preview",
415                ));
416            }
417            (Some(before), Some(current)) if before != &current => {
418                failed.push(failed_row(
419                    &desired.id,
420                    false,
421                    "dashboard changed since preview",
422                ));
423            }
424            (Some(_), Some(current)) if current == *desired => {
425                succeeded.push(serde_json::json!({"id": desired.id, "action": "unchanged"}));
426            }
427            (None, None) => {
428                apply_put(
429                    transport,
430                    desired,
431                    "created",
432                    &mut succeeded,
433                    &mut failed,
434                    &mut lossy,
435                )
436                .await;
437            }
438            (Some(_), Some(_)) => {
439                apply_put(
440                    transport,
441                    desired,
442                    "replaced",
443                    &mut succeeded,
444                    &mut failed,
445                    &mut lossy,
446                )
447                .await;
448            }
449        }
450    }
451
452    Ok(DashboardImportReport {
453        applied: true,
454        succeeded,
455        skipped: plan.skipped.clone(),
456        failed,
457        lossy,
458        total: plan.total,
459    })
460}
461
462/// Resolve every dashboard selector and build the exact delete guard preview.
463pub async fn plan_delete(
464    transport: &Transport,
465    selectors: &[String],
466) -> Result<DashboardDeletePlan> {
467    if selectors.is_empty() {
468        return Err(Error::new(
469            ErrorKind::Error,
470            "dashboard delete needs at least one dashboard",
471        ));
472    }
473    let mut seen = BTreeSet::new();
474    let mut targets = Vec::new();
475    for selector in selectors {
476        let dashboard = resolve(transport, selector).await?;
477        if seen.insert(dashboard.id.clone()) {
478            targets.push(dashboard);
479        }
480    }
481    Ok(DashboardDeletePlan {
482        preview: delete_preview(&targets),
483        targets,
484    })
485}
486
487/// Apply a guard-approved dashboard deletion without resolving selectors again.
488pub async fn apply_delete(
489    transport: &Transport,
490    plan: &DashboardDeletePlan,
491) -> Result<DeleteOutcome> {
492    validate_delete_plan(plan)?;
493    let mut deleted = Vec::new();
494    let mut failed = Vec::new();
495    for target in &plan.targets {
496        match dashboards::delete(transport, &target.id).await {
497            Ok(()) => deleted.push(serde_json::json!({"id": target.id})),
498            Err(error) => failed.push(serde_json::json!({
499                "id": target.id,
500                "error": error.message,
501            })),
502        }
503    }
504    Ok(DeleteOutcome {
505        applied: true,
506        deleted,
507        failed,
508        total: plan.targets.len(),
509    })
510}
511
512fn delete_preview(targets: &[DashboardSummary]) -> MutationPlan {
513    MutationPlan {
514        preview_action: format!("Delete {} dashboard(s)", targets.len()),
515        preview_details: targets
516            .iter()
517            .map(|dashboard| format!("{}  {}", dashboard.id, dashboard.title))
518            .collect(),
519        targets: targets
520            .iter()
521            .map(|dashboard| dashboard.id.clone())
522            .collect(),
523    }
524}
525
526fn validate_delete_plan(plan: &DashboardDeletePlan) -> Result<()> {
527    if plan.targets.is_empty() {
528        return invalid_plan("dashboard delete plan needs at least one target");
529    }
530    let mut ids = BTreeSet::new();
531    for target in &plan.targets {
532        if target.id.trim().is_empty() || target.title.trim().is_empty() {
533            return invalid_plan("dashboard delete target identity must not be empty");
534        }
535        if !ids.insert(target.id.clone()) {
536            return invalid_plan("dashboard delete targets must be unique by id");
537        }
538    }
539    if plan.preview != delete_preview(&plan.targets) {
540        return invalid_plan("dashboard delete preview does not match guarded targets");
541    }
542    Ok(())
543}
544
545async fn apply_put(
546    transport: &Transport,
547    desired: &DashboardSpec,
548    action: &str,
549    succeeded: &mut Vec<Value>,
550    failed: &mut Vec<Value>,
551    lossy: &mut Vec<Value>,
552) {
553    let response = match dashboards::put(transport, desired).await {
554        Ok(response) => response,
555        Err(error) => {
556            failed.push(failed_row(&desired.id, false, error.message));
557            return;
558        }
559    };
560    if response.id != desired.id {
561        failed.push(failed_row(
562            &desired.id,
563            true,
564            format!(
565                "dashboard PUT returned id '{}' instead of '{}'",
566                response.id, desired.id
567            ),
568        ));
569        return;
570    }
571
572    let mut paths: Vec<_> = dashboards::subset_losses(
573        &Value::Object(desired.data.clone()),
574        &Value::Object(response.data),
575    )
576    .into_iter()
577    .map(|loss| loss.path)
578    .collect();
579    if paths.is_empty() {
580        succeeded.push(serde_json::json!({"id": desired.id, "action": action}));
581        return;
582    }
583    paths.sort();
584    let warnings = match dashboards::get(transport, &desired.id).await {
585        Ok(dashboard) if dashboard.id == desired.id => dashboard
586            .warnings
587            .into_iter()
588            .map(|warning| warning.message)
589            .collect(),
590        Ok(dashboard) => {
591            failed.push(failed_row(
592                &desired.id,
593                true,
594                format!(
595                    "dashboard loss audit returned id '{}' instead of '{}'",
596                    dashboard.id, desired.id
597                ),
598            ));
599            Vec::new()
600        }
601        Err(error) => {
602            failed.push(failed_row(
603                &desired.id,
604                true,
605                format!("dashboard loss audit failed: {}", error.message),
606            ));
607            Vec::new()
608        }
609    };
610    lossy.push(serde_json::json!({
611        "id": desired.id,
612        "applied": true,
613        "paths": paths,
614        "warnings": warnings,
615    }));
616}
617
618fn validate_import_plan(plan: &DashboardImportPlan) -> Result<()> {
619    if plan.total == 0 {
620        return invalid_plan("total must be greater than zero");
621    }
622    if plan.total != plan.specs.len() + plan.skipped.len() {
623        return invalid_plan("total does not equal pending and skipped dashboards");
624    }
625    if !plan.skipped.is_empty() && plan.overwrite {
626        return invalid_plan("skipped dashboards require skip-existing mode");
627    }
628
629    let mut ids = Vec::with_capacity(plan.specs.len());
630    for spec in &plan.specs {
631        dashboards::validate_spec(spec)?;
632        if ids
633            .last()
634            .is_some_and(|previous: &String| previous >= &spec.id)
635        {
636            return invalid_plan("pending dashboards must be unique and sorted by id");
637        }
638        ids.push(spec.id.clone());
639    }
640    let pending: BTreeSet<_> = ids.iter().cloned().collect();
641    if plan.preview.targets != ids {
642        return invalid_plan("preview targets do not match pending dashboards");
643    }
644    if plan.preview.preview_action != format!("Import {} dashboard(s)", plan.specs.len()) {
645        return invalid_plan("preview action does not match pending dashboards");
646    }
647    if plan.preview.preview_details != import_preview_details(&plan.specs, &plan.before) {
648        return invalid_plan("preview details do not match pending dashboards");
649    }
650
651    if plan.before.keys().cloned().collect::<BTreeSet<_>>() != pending {
652        return invalid_plan("preflight snapshots do not match pending dashboards");
653    }
654    for spec in &plan.specs {
655        match plan.before.get(&spec.id).expect("checked key set") {
656            None => {}
657            Some(snapshot) => {
658                dashboards::validate_spec(snapshot)?;
659                if snapshot.id != spec.id {
660                    return invalid_plan("preflight snapshot id does not match its target");
661                }
662                if !plan.overwrite {
663                    return invalid_plan("replacement plan requires overwrite");
664                }
665            }
666        }
667    }
668
669    let mut skipped_ids = BTreeSet::new();
670    let mut previous = None;
671    for row in &plan.skipped {
672        let object = row
673            .as_object()
674            .filter(|object| object.len() == 2)
675            .ok_or_else(|| Error::new(ErrorKind::Error, "invalid dashboard import skipped row"))?;
676        if !object.keys().map(String::as_str).eq(["id", "reason"]) {
677            return invalid_plan("invalid dashboard import skipped row order");
678        }
679        let id = object
680            .get("id")
681            .and_then(Value::as_str)
682            .filter(|id| !id.trim().is_empty())
683            .ok_or_else(|| Error::new(ErrorKind::Error, "invalid dashboard import skipped row"))?;
684        if object.get("reason").and_then(Value::as_str) != Some("exists")
685            || previous.is_some_and(|previous: &str| previous >= id)
686            || !skipped_ids.insert(id.to_owned())
687            || pending.contains(id)
688        {
689            return invalid_plan("invalid dashboard import skipped rows");
690        }
691        previous = Some(id);
692    }
693    Ok(())
694}
695
696fn failed_row(id: &str, applied: bool, error: impl Into<String>) -> Value {
697    serde_json::json!({"id": id, "applied": applied, "error": error.into()})
698}
699
700fn invalid_plan(message: impl Into<String>) -> Result<()> {
701    Err(Error::new(ErrorKind::Error, message))
702}
703
704/// Export selected dashboards as a portable JSON or YAML artifact.
705pub async fn export(
706    transport: &Transport,
707    selectors: &[String],
708    format: ContentFormat,
709) -> Result<ExportOutcome> {
710    let selected = if selectors.is_empty() {
711        list_op(transport, &DashboardFilter::default())
712            .await?
713            .dashboards
714    } else {
715        let mut selected = Vec::with_capacity(selectors.len());
716        for selector in selectors {
717            selected.push(resolve(transport, selector).await?);
718        }
719        selected
720    };
721    let selected: BTreeMap<_, _> = selected
722        .into_iter()
723        .map(|dashboard| (dashboard.id.clone(), dashboard))
724        .collect();
725    let mut specs = Vec::with_capacity(selected.len());
726    for (id, _) in selected {
727        let dashboard = dashboards::get(transport, &id).await?;
728        if dashboard.id != id {
729            return Err(Error::new(
730                ErrorKind::Http,
731                format!("dashboard export was short: expected id '{id}'"),
732            ));
733        }
734        if !dashboard.warnings.is_empty() {
735            return Err(Error::new(
736                ErrorKind::Unsupported,
737                format!(
738                    "dashboard '{id}' cannot be exported through the typed API without loss: {}; use `dashboards bundle export {id}`",
739                    dashboard
740                        .warnings
741                        .iter()
742                        .map(|warning| warning.message.as_str())
743                        .collect::<Vec<_>>()
744                        .join("; ")
745                ),
746            ));
747        }
748        let spec = DashboardSpec {
749            id: dashboard.id,
750            data: dashboard.data,
751        };
752        dashboards::validate_spec(&spec)?;
753        specs.push(spec);
754    }
755    specs.sort_by(|left, right| left.id.cmp(&right.id));
756    let body = content_codec::encode_sequence(&specs, format)?;
757    Ok(ExportOutcome {
758        body,
759        exported: specs.len() as u64,
760        missing: Vec::<Value>::new(),
761    })
762}
763
764/// Export selected dashboards and their deep Saved Objects references as
765/// opaque NDJSON.
766pub async fn export_bundle(transport: &Transport, selectors: &[String]) -> Result<ExportOutcome> {
767    let selected = if selectors.is_empty() {
768        list_op(transport, &DashboardFilter::default())
769            .await?
770            .dashboards
771    } else {
772        let mut selected = Vec::with_capacity(selectors.len());
773        for selector in selectors {
774            selected.push(resolve(transport, selector).await?);
775        }
776        selected
777    };
778    let ids: Vec<_> = selected
779        .into_iter()
780        .map(|dashboard| dashboard.id)
781        .collect::<BTreeSet<_>>()
782        .into_iter()
783        .collect();
784    let body = saved_objects::export(transport, &ids).await?;
785    if !ids.is_empty() {
786        let scan = saved_objects::scan_bundle(&body).map_err(|error| {
787            Error::new(
788                ErrorKind::Http,
789                format!("decoding dashboard bundle export: {}", error.message),
790            )
791        })?;
792        let exported: BTreeSet<_> = scan.dashboards.into_iter().collect();
793        let missing: Vec<_> = ids
794            .iter()
795            .filter(|id| !exported.contains(*id))
796            .cloned()
797            .collect();
798        if !missing.is_empty() {
799            return Err(Error::new(
800                ErrorKind::Http,
801                format!(
802                    "dashboard bundle export was short: missing {}",
803                    missing.join(", ")
804                ),
805            ));
806        }
807    }
808    Ok(ExportOutcome {
809        body,
810        exported: ids.len() as u64,
811        missing: Vec::new(),
812    })
813}
814
815/// Fully read and scan an opaque bundle before presenting its mutation guard.
816pub fn plan_bundle_import(path: &Path, overwrite: bool) -> Result<BundleImportPlan> {
817    let bytes = std::fs::read(path).map_err(|error| {
818        Error::new(
819            ErrorKind::Error,
820            format!("reading {}: {error}", path.display()),
821        )
822    })?;
823    let ndjson = String::from_utf8(bytes).map_err(|error| {
824        Error::new(
825            ErrorKind::Error,
826            format!(
827                "reading {}: bundle is not valid UTF-8: {error}",
828                path.display()
829            ),
830        )
831    })?;
832    let scan = saved_objects::scan_bundle(&ndjson)?;
833    let preview = bundle_import_preview(&scan, overwrite);
834    Ok(BundleImportPlan {
835        preview,
836        ndjson,
837        scan,
838        overwrite,
839    })
840}
841
842/// Apply a guard-approved opaque bundle without rereading its source file.
843pub async fn apply_bundle_import(
844    transport: &Transport,
845    plan: &BundleImportPlan,
846) -> Result<BundleImportOutcome> {
847    validate_bundle_import_plan(plan)?;
848    transport.require_feature(Feature::Dashboards).await?;
849    let report = saved_objects::import(transport, &plan.ndjson, plan.overwrite).await?;
850    Ok(BundleImportOutcome {
851        applied: true,
852        succeeded: sanitize_bundle_success_rows(&report.success_results)?,
853        failed: sanitize_bundle_failure_rows(&report.errors)?,
854        total: plan.scan.total,
855    })
856}
857
858fn sanitize_bundle_success_rows(rows: &[Value]) -> Result<Vec<Value>> {
859    rows.iter()
860        .map(|row| {
861            let object = bundle_report_object(row, "success result")?;
862            let object_type = bundle_report_string(object, "type", "success result")?;
863            let id = bundle_report_string(object, "id", "success result")?;
864            Ok(json!({"type": object_type, "id": id}))
865        })
866        .collect()
867}
868
869fn sanitize_bundle_failure_rows(rows: &[Value]) -> Result<Vec<Value>> {
870    rows.iter()
871        .map(|row| {
872            let object = bundle_report_object(row, "error result")?;
873            let object_type = bundle_report_string(object, "type", "error result")?;
874            let id = bundle_report_string(object, "id", "error result")?;
875            let error = object
876                .get("error")
877                .and_then(Value::as_object)
878                .ok_or_else(|| {
879                    bundle_import_response_error("error result error must be an object")
880                })?;
881            let error_type = bundle_report_string(error, "type", "error result error")?;
882            Ok(json!({"type": object_type, "id": id, "error": error_type}))
883        })
884        .collect()
885}
886
887fn bundle_report_object<'a>(row: &'a Value, kind: &str) -> Result<&'a Map<String, Value>> {
888    row.as_object()
889        .ok_or_else(|| bundle_import_response_error(&format!("{kind} must be an object")))
890}
891
892fn bundle_report_string<'a>(
893    object: &'a Map<String, Value>,
894    field: &str,
895    kind: &str,
896) -> Result<&'a str> {
897    object
898        .get(field)
899        .and_then(Value::as_str)
900        .filter(|value| !value.trim().is_empty())
901        .ok_or_else(|| {
902            bundle_import_response_error(&format!("{kind} {field} must be a non-empty string"))
903        })
904}
905
906fn bundle_import_response_error(message: &str) -> Error {
907    Error::new(
908        ErrorKind::Http,
909        format!("decoding dashboard bundle import response: {message}"),
910    )
911}
912
913fn summary_from_dashboard(dashboard: Dashboard) -> Result<DashboardSummary> {
914    let title = dashboard
915        .data
916        .get("title")
917        .and_then(Value::as_str)
918        .filter(|title| !title.trim().is_empty())
919        .ok_or_else(|| {
920            Error::new(
921                ErrorKind::Http,
922                "decoding dashboard get: data.title must be a non-empty string",
923            )
924        })?;
925    Ok(DashboardSummary {
926        id: dashboard.id,
927        title: title.to_string(),
928        description: dashboard
929            .data
930            .get("description")
931            .and_then(Value::as_str)
932            .map(str::to_string),
933        tags: dashboard
934            .data
935            .get("tags")
936            .and_then(Value::as_array)
937            .map(|tags| {
938                tags.iter()
939                    .filter_map(Value::as_str)
940                    .map(str::to_string)
941                    .collect()
942            }),
943    })
944}
945
946/// Read one live dashboard and rebuild the portable spec used for snapshot
947/// comparisons. The response id is checked separately from its request path.
948async fn read_spec(transport: &Transport, id: &str) -> Result<DashboardSpec> {
949    let dashboard = dashboards::get(transport, id).await?;
950    if dashboard.id != id {
951        return Err(Error::new(
952            ErrorKind::Http,
953            format!(
954                "decoding dashboard: expected id '{id}', got '{}'",
955                dashboard.id
956            ),
957        ));
958    }
959    let spec = DashboardSpec {
960        id: dashboard.id,
961        data: dashboard.data,
962    };
963    dashboards::validate_spec(&spec)?;
964    Ok(spec)
965}
966
967fn import_preview_details(
968    specs: &[DashboardSpec],
969    before: &BTreeMap<String, Option<DashboardSpec>>,
970) -> Vec<String> {
971    specs
972        .iter()
973        .filter_map(|spec| match before.get(&spec.id) {
974            Some(None) => Some(format!("{}  create  {}", spec.id, dashboard_title(spec))),
975            Some(Some(current)) if current == spec => {
976                Some(format!("{}  no-op  {}", spec.id, dashboard_title(spec)))
977            }
978            Some(Some(current)) => Some(format!(
979                "{}  replace  {} -> {}",
980                spec.id,
981                dashboard_title(current),
982                dashboard_title(spec)
983            )),
984            None => None,
985        })
986        .collect()
987}
988
989fn dashboard_title(spec: &DashboardSpec) -> &str {
990    spec.data
991        .get("title")
992        .and_then(Value::as_str)
993        .expect("validated dashboard specs have a title")
994}
995
996fn bundle_import_preview(scan: &saved_objects::BundleScan, overwrite: bool) -> MutationPlan {
997    let mut dashboards = scan.dashboards.clone();
998    dashboards.sort();
999    let mut preview_details = dashboards
1000        .iter()
1001        .map(|id| format!("dashboard/{id}"))
1002        .collect::<Vec<_>>();
1003    preview_details.extend(
1004        scan.counts
1005            .iter()
1006            .filter(|(object_type, _)| object_type.as_str() != "dashboard")
1007            .map(|(object_type, count)| format!("{object_type}  {count}")),
1008    );
1009    let dashboard_count = scan.dashboards.len();
1010    let related_count = scan.total.saturating_sub(dashboard_count);
1011    MutationPlan {
1012        preview_action: format!(
1013            "{} {} dashboard(s) and {related_count} related saved object(s)",
1014            if overwrite {
1015                "Import or replace"
1016            } else {
1017                "Import"
1018            },
1019            dashboard_count,
1020        ),
1021        preview_details,
1022        targets: scan.dashboards.clone(),
1023    }
1024}
1025
1026fn validate_bundle_import_plan(plan: &BundleImportPlan) -> Result<()> {
1027    let scan = saved_objects::scan_bundle(&plan.ndjson)?;
1028    if scan != plan.scan {
1029        return invalid_plan("bundle scan does not match the planned NDJSON");
1030    }
1031    if plan.preview != bundle_import_preview(&scan, plan.overwrite) {
1032        return invalid_plan("bundle import preview does not match the planned NDJSON");
1033    }
1034    Ok(())
1035}