1use super::{
2 BTreeMap, BTreeSet, DashboardSpec, Error, ErrorKind, Path, Result, Serialize, Transport, Value,
3 dashboards, data_views, invalid_plan, validate,
4};
5
6#[derive(Debug, Clone, PartialEq)]
7pub struct DashboardImportPlan {
8 pub preview: crate::ops::MutationPlan,
9 pub specs: Vec<DashboardSpec>,
10 pub before: BTreeMap<String, Option<DashboardSpec>>,
11 pub skipped: Vec<Value>,
12 pub total: usize,
13 pub overwrite: bool,
14}
15
16#[derive(Debug, Clone, PartialEq, Serialize)]
17pub struct DashboardImportReport {
18 pub applied: bool,
19 pub succeeded: Vec<Value>,
20 pub skipped: Vec<Value>,
21 pub failed: Vec<Value>,
22 pub lossy: Vec<Value>,
23 pub total: usize,
24}
25pub async fn plan_import(
26 transport: Option<&Transport>,
27 path: &Path,
28 overwrite: bool,
29 skip_existing: bool,
30) -> Result<DashboardImportPlan> {
31 let mut specs = validate(path)?;
32 if specs.is_empty() {
33 return Err(Error::new(
34 ErrorKind::Error,
35 "dashboard import needs at least one dashboard",
36 ));
37 }
38 if overwrite && skip_existing {
39 return Err(Error::new(
40 ErrorKind::Error,
41 "--overwrite and --skip-existing cannot be used together",
42 ));
43 }
44
45 let references: BTreeSet<_> = specs
46 .iter()
47 .flat_map(|spec| dashboards::collect_data_view_refs(&Value::Object(spec.data.clone())))
48 .collect();
49 let requires_server =
50 overwrite || skip_existing || transport.is_some() || !references.is_empty();
51 let transport = if requires_server { transport } else { None };
52 if (overwrite || skip_existing || !references.is_empty()) && transport.is_none() {
53 return Err(Error::new(
54 ErrorKind::Error,
55 "dashboard import preflight needs a transport",
56 ));
57 }
58
59 let total = specs.len();
60 let mut before = BTreeMap::new();
61 let mut conflicts = Vec::new();
62 if let Some(transport) = transport {
63 for spec in &specs {
64 match read_spec(transport, &spec.id).await {
65 Ok(current) => {
66 if !overwrite && !skip_existing {
67 conflicts.push(spec.id.clone());
68 }
69 before.insert(spec.id.clone(), Some(current));
70 }
71 Err(error) if error.kind == ErrorKind::NotFound => {
72 before.insert(spec.id.clone(), None);
73 }
74 Err(error) => return Err(error),
75 }
76 }
77
78 let mut missing = Vec::new();
79 for id in references {
80 match data_views::get(transport, &id).await {
81 Ok(data_view) => match data_view.data_view.get("id").and_then(Value::as_str) {
82 Some(actual) if actual == id => {}
83 Some(actual) => {
84 return Err(Error::new(
85 ErrorKind::Http,
86 format!("decoding data view: expected id '{id}', got '{actual}'"),
87 ));
88 }
89 None => {
90 return Err(Error::new(
91 ErrorKind::Http,
92 format!(
93 "decoding data view: expected id '{id}', got missing or non-string id"
94 ),
95 ));
96 }
97 },
98 Err(error) if error.kind == ErrorKind::NotFound => missing.push(id),
99 Err(error) => return Err(error),
100 }
101 }
102 if !missing.is_empty() {
103 return Err(Error::new(
104 ErrorKind::NotFound,
105 format!("referenced data views do not exist: {}", missing.join(", ")),
106 ));
107 }
108 } else {
109 before.extend(specs.iter().map(|spec| (spec.id.clone(), None)));
110 }
111
112 if !conflicts.is_empty() {
113 return Err(Error::new(
114 ErrorKind::Conflict,
115 format!("dashboards already exist: {}", conflicts.join(", ")),
116 ));
117 }
118
119 let mut skipped = Vec::new();
120 if skip_existing {
121 specs.retain(|spec| match before.get(&spec.id) {
122 Some(Some(_)) => {
123 skipped.push(serde_json::json!({"id": spec.id, "reason": "exists"}));
124 false
125 }
126 _ => true,
127 });
128 before.retain(|id, _| specs.iter().any(|spec| spec.id == *id));
129 }
130
131 let preview = crate::ops::MutationPlan {
132 preview_action: format!("Import {} dashboard(s)", specs.len()),
133 preview_details: import_preview_details(&specs, &before),
134 targets: specs.iter().map(|spec| spec.id.clone()).collect(),
135 };
136 Ok(DashboardImportPlan {
137 preview,
138 specs,
139 before,
140 skipped,
141 total,
142 overwrite,
143 })
144}
145
146pub async fn apply_import(
147 transport: &Transport,
148 plan: &DashboardImportPlan,
149) -> Result<DashboardImportReport> {
150 validate_import_plan(plan)?;
151 let mut succeeded = Vec::new();
152 let mut failed = Vec::new();
153 let mut lossy = Vec::new();
154
155 for desired in &plan.specs {
156 let Some(before) = plan.before.get(&desired.id) else {
157 failed.push(failed_row(&desired.id, false, "missing preflight snapshot"));
158 continue;
159 };
160 let current = match read_spec(transport, &desired.id).await {
161 Ok(current) => Some(current),
162 Err(error) if error.kind == ErrorKind::NotFound => None,
163 Err(error) => {
164 failed.push(failed_row(&desired.id, false, error.message));
165 continue;
166 }
167 };
168
169 match (before, current) {
170 (None, Some(_)) => {
171 failed.push(failed_row(
172 &desired.id,
173 false,
174 "dashboard appeared since preview",
175 ));
176 }
177 (Some(_), None) => {
178 failed.push(failed_row(
179 &desired.id,
180 false,
181 "dashboard disappeared since preview",
182 ));
183 }
184 (Some(before), Some(current)) if before != ¤t => {
185 failed.push(failed_row(
186 &desired.id,
187 false,
188 "dashboard changed since preview",
189 ));
190 }
191 (Some(_), Some(current)) if current == *desired => {
192 succeeded.push(serde_json::json!({"id": desired.id, "action": "unchanged"}));
193 }
194 (None, None) => {
195 apply_put(
196 transport,
197 desired,
198 "created",
199 &mut succeeded,
200 &mut failed,
201 &mut lossy,
202 )
203 .await;
204 }
205 (Some(_), Some(_)) => {
206 apply_put(
207 transport,
208 desired,
209 "replaced",
210 &mut succeeded,
211 &mut failed,
212 &mut lossy,
213 )
214 .await;
215 }
216 }
217 }
218
219 Ok(DashboardImportReport {
220 applied: true,
221 succeeded,
222 skipped: plan.skipped.clone(),
223 failed,
224 lossy,
225 total: plan.total,
226 })
227}
228async fn apply_put(
229 transport: &Transport,
230 desired: &DashboardSpec,
231 action: &str,
232 succeeded: &mut Vec<Value>,
233 failed: &mut Vec<Value>,
234 lossy: &mut Vec<Value>,
235) {
236 let response = match dashboards::put(transport, desired).await {
237 Ok(response) => response,
238 Err(error) => {
239 failed.push(failed_row(&desired.id, false, error.message));
240 return;
241 }
242 };
243 if response.id != desired.id {
244 failed.push(failed_row(
245 &desired.id,
246 true,
247 format!(
248 "dashboard PUT returned id '{}' instead of '{}'",
249 response.id, desired.id
250 ),
251 ));
252 return;
253 }
254
255 let mut paths: Vec<_> = dashboards::subset_losses(
256 &Value::Object(desired.data.clone()),
257 &Value::Object(response.data),
258 )
259 .into_iter()
260 .map(|loss| loss.path)
261 .collect();
262 if paths.is_empty() {
263 succeeded.push(serde_json::json!({"id": desired.id, "action": action}));
264 return;
265 }
266 paths.sort();
267 let warnings = match dashboards::get(transport, &desired.id).await {
268 Ok(dashboard) if dashboard.id == desired.id => dashboard
269 .warnings
270 .into_iter()
271 .map(|warning| warning.message)
272 .collect(),
273 Ok(dashboard) => {
274 failed.push(failed_row(
275 &desired.id,
276 true,
277 format!(
278 "dashboard loss audit returned id '{}' instead of '{}'",
279 dashboard.id, desired.id
280 ),
281 ));
282 Vec::new()
283 }
284 Err(error) => {
285 failed.push(failed_row(
286 &desired.id,
287 true,
288 format!("dashboard loss audit failed: {}", error.message),
289 ));
290 Vec::new()
291 }
292 };
293 lossy.push(serde_json::json!({
294 "id": desired.id,
295 "applied": true,
296 "paths": paths,
297 "warnings": warnings,
298 }));
299}
300
301fn validate_import_plan(plan: &DashboardImportPlan) -> Result<()> {
302 if plan.total == 0 {
303 return invalid_plan("total must be greater than zero");
304 }
305 if plan.total != plan.specs.len() + plan.skipped.len() {
306 return invalid_plan("total does not equal pending and skipped dashboards");
307 }
308 if !plan.skipped.is_empty() && plan.overwrite {
309 return invalid_plan("skipped dashboards require skip-existing mode");
310 }
311
312 let mut ids = Vec::with_capacity(plan.specs.len());
313 for spec in &plan.specs {
314 dashboards::validate_spec(spec)?;
315 if ids
316 .last()
317 .is_some_and(|previous: &String| previous >= &spec.id)
318 {
319 return invalid_plan("pending dashboards must be unique and sorted by id");
320 }
321 ids.push(spec.id.clone());
322 }
323 let pending: BTreeSet<_> = ids.iter().cloned().collect();
324 if plan.preview.targets != ids {
325 return invalid_plan("preview targets do not match pending dashboards");
326 }
327 if plan.preview.preview_action != format!("Import {} dashboard(s)", plan.specs.len()) {
328 return invalid_plan("preview action does not match pending dashboards");
329 }
330 if plan.preview.preview_details != import_preview_details(&plan.specs, &plan.before) {
331 return invalid_plan("preview details do not match pending dashboards");
332 }
333
334 if plan.before.keys().cloned().collect::<BTreeSet<_>>() != pending {
335 return invalid_plan("preflight snapshots do not match pending dashboards");
336 }
337 for spec in &plan.specs {
338 match plan.before.get(&spec.id).expect("checked key set") {
339 None => {}
340 Some(snapshot) => {
341 dashboards::validate_spec(snapshot)?;
342 if snapshot.id != spec.id {
343 return invalid_plan("preflight snapshot id does not match its target");
344 }
345 if !plan.overwrite {
346 return invalid_plan("replacement plan requires overwrite");
347 }
348 }
349 }
350 }
351
352 let mut skipped_ids = BTreeSet::new();
353 let mut previous = None;
354 for row in &plan.skipped {
355 let object = row
356 .as_object()
357 .filter(|object| object.len() == 2)
358 .ok_or_else(|| Error::new(ErrorKind::Error, "invalid dashboard import skipped row"))?;
359 if !object.keys().map(String::as_str).eq(["id", "reason"]) {
360 return invalid_plan("invalid dashboard import skipped row order");
361 }
362 let id = object
363 .get("id")
364 .and_then(Value::as_str)
365 .filter(|id| !id.trim().is_empty())
366 .ok_or_else(|| Error::new(ErrorKind::Error, "invalid dashboard import skipped row"))?;
367 if object.get("reason").and_then(Value::as_str) != Some("exists")
368 || previous.is_some_and(|previous: &str| previous >= id)
369 || !skipped_ids.insert(id.to_owned())
370 || pending.contains(id)
371 {
372 return invalid_plan("invalid dashboard import skipped rows");
373 }
374 previous = Some(id);
375 }
376 Ok(())
377}
378
379fn failed_row(id: &str, applied: bool, error: impl Into<String>) -> Value {
380 serde_json::json!({"id": id, "applied": applied, "error": error.into()})
381}
382async fn read_spec(transport: &Transport, id: &str) -> Result<DashboardSpec> {
383 let dashboard = dashboards::get(transport, id).await?;
384 if dashboard.id != id {
385 return Err(Error::new(
386 ErrorKind::Http,
387 format!(
388 "decoding dashboard: expected id '{id}', got '{}'",
389 dashboard.id
390 ),
391 ));
392 }
393 let spec = DashboardSpec {
394 id: dashboard.id,
395 data: dashboard.data,
396 };
397 dashboards::validate_spec(&spec)?;
398 Ok(spec)
399}
400
401fn import_preview_details(
402 specs: &[DashboardSpec],
403 before: &BTreeMap<String, Option<DashboardSpec>>,
404) -> Vec<String> {
405 specs
406 .iter()
407 .filter_map(|spec| match before.get(&spec.id) {
408 Some(None) => Some(format!("{} create {}", spec.id, dashboard_title(spec))),
409 Some(Some(current)) if current == spec => {
410 Some(format!("{} no-op {}", spec.id, dashboard_title(spec)))
411 }
412 Some(Some(current)) => Some(format!(
413 "{} replace {} -> {}",
414 spec.id,
415 dashboard_title(current),
416 dashboard_title(spec)
417 )),
418 None => None,
419 })
420 .collect()
421}
422
423fn dashboard_title(spec: &DashboardSpec) -> &str {
424 spec.data
425 .get("title")
426 .and_then(Value::as_str)
427 .expect("validated dashboard specs have a title")
428}