1#![allow(clippy::missing_errors_doc)]
2
3use anyhow::{Context, Result};
4use std::collections::HashMap;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use crate::api::RedashClient;
9use crate::models::{
10 CreateDashboard, CreateWidget, Dashboard, DashboardMetadata, Query, WidgetMetadata,
11 build_dashboard_level_parameter_mappings,
12};
13
14fn extract_dashboard_slugs_from_path(dashboards_dir: &Path) -> Result<Vec<String>> {
15 if !dashboards_dir.exists() {
16 return Ok(Vec::new());
17 }
18
19 let mut dashboard_slugs = Vec::new();
20
21 for entry in fs::read_dir(dashboards_dir).context("Failed to read dashboards directory")? {
22 let entry = entry.context("Failed to read directory entry")?;
23 let path = entry.path();
24
25 if path.extension().is_some_and(|ext| ext == "yaml")
26 && let Some(filename) = path.file_name().and_then(|f| f.to_str())
27 && let Some(slug) = filename
28 .strip_suffix(".yaml")
29 .and_then(|s| s.split_once('-'))
30 .map(|(_, slug)| slug)
31 {
32 dashboard_slugs.push(slug.to_string());
33 }
34 }
35
36 dashboard_slugs.sort_unstable();
37 dashboard_slugs.dedup();
38
39 Ok(dashboard_slugs)
40}
41
42fn extract_dashboard_slugs_from_directory() -> Result<Vec<String>> {
43 extract_dashboard_slugs_from_path(Path::new("dashboards"))
44}
45
46pub async fn discover(client: &RedashClient) -> Result<()> {
47 println!("Fetching your favorite dashboards from Redash...\n");
48 let dashboards = client.fetch_favorite_dashboards().await?;
49
50 if dashboards.is_empty() {
51 println!("No dashboards found.");
52 return Ok(());
53 }
54
55 println!("Found {} dashboards:\n", dashboards.len());
56
57 for dashboard in &dashboards {
58 let status_flags = match (dashboard.is_draft, dashboard.is_archived) {
59 (true, true) => " [DRAFT, ARCHIVED]",
60 (true, false) => " [DRAFT]",
61 (false, true) => " [ARCHIVED]",
62 (false, false) => "",
63 };
64 println!(" {} - {}{}", dashboard.slug, dashboard.name, status_flags);
65 }
66
67 println!("\nUsage:");
68 println!(" stmo-cli dashboards fetch <slug> [<slug>...]");
69 println!(
70 " stmo-cli dashboards fetch firefox-desktop-on-steamos bug-2006698---ccov-build-regression"
71 );
72
73 Ok(())
74}
75
76pub async fn fetch(client: &RedashClient, dashboard_slugs: Vec<String>) -> Result<()> {
77 if dashboard_slugs.is_empty() {
78 anyhow::bail!(
79 "No dashboard slugs specified. Use 'dashboards discover' to see available dashboards.\n\nExample:\n stmo-cli dashboards fetch firefox-desktop-on-steamos bug-2006698---ccov-build-regression"
80 );
81 }
82
83 fs::create_dir_all("dashboards").context("Failed to create dashboards directory")?;
84
85 println!("Fetching {} dashboards...\n", dashboard_slugs.len());
86
87 let mut success_count = 0;
88 let mut failed_slugs = Vec::new();
89
90 for slug in &dashboard_slugs {
91 match client.get_dashboard(slug).await {
92 Ok(dashboard) => {
93 let filename = format!("dashboards/{}-{}.yaml", dashboard.id, dashboard.slug);
94
95 let metadata = DashboardMetadata {
96 id: dashboard.id,
97 name: dashboard.name.clone(),
98 slug: dashboard.slug.clone(),
99 user_id: dashboard.user_id,
100 is_draft: dashboard.is_draft,
101 is_archived: dashboard.is_archived,
102 filters_enabled: dashboard.filters_enabled,
103 tags: dashboard.tags.clone(),
104 widgets: dashboard
105 .widgets
106 .iter()
107 .map(|w| WidgetMetadata {
108 id: w.id,
109 width: w.width,
110 visualization_id: w.visualization_id,
111 query_id: w.visualization.as_ref().map(|v| v.query.id),
112 visualization_name: w.visualization.as_ref().map(|v| v.name.clone()),
113 text: w.text.clone(),
114 options: w.options.clone(),
115 })
116 .collect(),
117 };
118
119 let yaml_content = serde_yaml::to_string(&metadata)
120 .context("Failed to serialize dashboard metadata")?;
121 fs::write(&filename, yaml_content)
122 .context(format!("Failed to write {filename}"))?;
123
124 let status = if dashboard.is_archived {
125 " [ARCHIVED]"
126 } else {
127 ""
128 };
129 println!(" ✓ {} - {}{}", dashboard.id, dashboard.name, status);
130 success_count += 1;
131 }
132 Err(e) => {
133 eprintln!(" ⚠ Dashboard '{slug}' failed to fetch: {e}");
134 failed_slugs.push(slug.clone());
135 }
136 }
137 }
138
139 if failed_slugs.is_empty() {
140 println!("\n✓ All dashboards fetched successfully");
141 println!(
142 "\nTip: Favorite these dashboards in the Redash web UI so they appear in 'dashboards discover'."
143 );
144 Ok(())
145 } else {
146 println!("\n✓ {success_count} dashboard(s) fetched successfully");
147 anyhow::bail!(
148 "{} dashboard(s) failed to fetch: {}",
149 failed_slugs.len(),
150 failed_slugs.join(", ")
151 );
152 }
153}
154
155pub async fn deploy(client: &RedashClient, dashboard_slugs: Vec<String>, all: bool) -> Result<()> {
156 let existing_dashboard_slugs = extract_dashboard_slugs_from_directory()?;
157
158 let slugs_to_deploy = if all {
159 if existing_dashboard_slugs.is_empty() {
160 anyhow::bail!("No dashboards found in dashboards/ directory. Use 'fetch' first.");
161 }
162 println!(
163 "Deploying {} dashboards from local directory...\n",
164 existing_dashboard_slugs.len()
165 );
166 existing_dashboard_slugs
167 } else if !dashboard_slugs.is_empty() {
168 println!(
169 "Deploying {} specific dashboards...\n",
170 dashboard_slugs.len()
171 );
172 dashboard_slugs
173 } else {
174 anyhow::bail!(
175 "No dashboard slugs specified. Use --all to deploy all tracked dashboards, or provide specific slugs.\n\nExamples:\n stmo-cli dashboards deploy --all\n stmo-cli dashboards deploy firefox-desktop-on-steamos bug-2006698---ccov-build-regression"
176 );
177 };
178
179 let mut success_count = 0;
180 let mut failed_slugs = Vec::new();
181
182 for slug in &slugs_to_deploy {
183 match deploy_single_dashboard(client, slug).await {
184 Ok(name) => {
185 println!(" ✓ {name}");
186 success_count += 1;
187 }
188 Err(e) => {
189 eprintln!(" ⚠ Dashboard '{slug}' failed to deploy: {e}");
190 failed_slugs.push(slug.clone());
191 }
192 }
193 }
194
195 if failed_slugs.is_empty() {
196 println!("\n✓ All dashboards deployed successfully");
197 Ok(())
198 } else {
199 println!("\n✓ {success_count} dashboard(s) deployed successfully");
200 anyhow::bail!(
201 "{} dashboard(s) failed to deploy: {}",
202 failed_slugs.len(),
203 failed_slugs.join(", ")
204 );
205 }
206}
207
208fn save_dashboard_yaml(
209 dashboard: &crate::models::Dashboard,
210 old_yaml_path: Option<std::path::PathBuf>,
211) -> Result<()> {
212 use crate::models::Widget;
213
214 let filename = format!("dashboards/{}-{}.yaml", dashboard.id, dashboard.slug);
215
216 let metadata = DashboardMetadata {
217 id: dashboard.id,
218 name: dashboard.name.clone(),
219 slug: dashboard.slug.clone(),
220 user_id: dashboard.user_id,
221 is_draft: dashboard.is_draft,
222 is_archived: dashboard.is_archived,
223 filters_enabled: dashboard.filters_enabled,
224 tags: dashboard.tags.clone(),
225 widgets: dashboard
226 .widgets
227 .iter()
228 .map(|w: &Widget| WidgetMetadata {
229 id: w.id,
230 width: w.width,
231 visualization_id: w.visualization_id,
232 query_id: w.visualization.as_ref().map(|v| v.query.id),
233 visualization_name: w.visualization.as_ref().map(|v| v.name.clone()),
234 text: w.text.clone(),
235 options: w.options.clone(),
236 })
237 .collect(),
238 };
239
240 let yaml_content =
241 serde_yaml::to_string(&metadata).context("Failed to serialize dashboard metadata")?;
242 fs::write(&filename, &yaml_content).context(format!("Failed to write {filename}"))?;
243
244 if let Some(old_path) = old_yaml_path
245 && old_path != std::path::Path::new(&filename)
246 {
247 fs::remove_file(&old_path).context(format!("Failed to delete {}", old_path.display()))?;
248 }
249
250 Ok(())
251}
252
253async fn resolve_visualization_id(
254 client: &RedashClient,
255 widget: &WidgetMetadata,
256 query_cache: &mut HashMap<u64, Query>,
257) -> Result<Option<u64>> {
258 if let Some(viz_id) = widget.visualization_id {
259 return Ok(Some(viz_id));
260 }
261
262 let (Some(query_id), Some(viz_name)) = (widget.query_id, widget.visualization_name.as_deref())
263 else {
264 return Ok(None);
265 };
266
267 if let std::collections::hash_map::Entry::Vacant(e) = query_cache.entry(query_id) {
268 e.insert(client.get_query(query_id).await?);
269 }
270
271 let query = query_cache.get(&query_id).expect("just inserted");
272 if let Some(viz) = query.visualizations.iter().find(|v| v.name == viz_name) {
273 Ok(Some(viz.id))
274 } else {
275 let available: Vec<&str> = query
276 .visualizations
277 .iter()
278 .map(|v| v.name.as_str())
279 .collect();
280 anyhow::bail!(
281 "No visualization named '{viz_name}' found on query {query_id}. Available: {available:?}"
282 );
283 }
284}
285
286async fn auto_populate_parameter_mappings(
287 client: &RedashClient,
288 query_id: u64,
289 existing_mappings: Option<&serde_json::Value>,
290 query_cache: &mut HashMap<u64, Query>,
291) -> Result<Option<serde_json::Value>> {
292 let should_build = match existing_mappings {
293 None => true,
294 Some(serde_json::Value::Object(m)) => m.is_empty(),
295 Some(_) => false,
296 };
297 if !should_build {
298 return Ok(None);
299 }
300 if let std::collections::hash_map::Entry::Vacant(e) = query_cache.entry(query_id) {
301 e.insert(client.get_query(query_id).await?);
302 }
303 Ok(query_cache
304 .get(&query_id)
305 .filter(|q| !q.options.parameters.is_empty())
306 .map(|q| build_dashboard_level_parameter_mappings(&q.options.parameters)))
307}
308
309fn find_dashboard_yaml(dashboard_slug: &str) -> Result<PathBuf> {
310 let yaml_files: Vec<_> = fs::read_dir("dashboards")
311 .context("Failed to read dashboards directory")?
312 .filter_map(std::result::Result::ok)
313 .filter(|entry| {
314 entry.path().extension().is_some_and(|ext| ext == "yaml")
315 && entry
316 .file_name()
317 .to_str()
318 .and_then(|name| name.strip_suffix(".yaml"))
319 .and_then(|name| name.split_once('-'))
320 .map(|(_, slug)| slug)
321 .is_some_and(|slug| slug == dashboard_slug)
322 })
323 .collect();
324
325 if yaml_files.is_empty() {
326 anyhow::bail!("No YAML file found for dashboard '{dashboard_slug}'");
327 }
328 if yaml_files.len() > 1 {
329 anyhow::bail!("Multiple YAML files found for dashboard '{dashboard_slug}'");
330 }
331 Ok(yaml_files[0].path())
332}
333
334async fn resolve_widget_options(
335 client: &RedashClient,
336 widget: &WidgetMetadata,
337 query_cache: &mut HashMap<u64, Query>,
338) -> Result<(crate::models::WidgetOptions, bool)> {
339 let mut options = widget.options.clone();
340 let has_params = if let Some(query_id) = widget.query_id
341 && let Some(mappings) = auto_populate_parameter_mappings(
342 client,
343 query_id,
344 options.parameter_mappings.as_ref(),
345 query_cache,
346 )
347 .await?
348 {
349 options.parameter_mappings = Some(mappings);
350 true
351 } else {
352 false
353 };
354 Ok((options, has_params))
355}
356
357async fn deploy_single_dashboard(client: &RedashClient, dashboard_slug: &str) -> Result<String> {
358 let yaml_path = find_dashboard_yaml(dashboard_slug)?;
359 let yaml_content = fs::read_to_string(&yaml_path)
360 .context(format!("Failed to read {}", yaml_path.display()))?;
361
362 let local_metadata: DashboardMetadata =
363 serde_yaml::from_str(&yaml_content).context("Failed to parse dashboard YAML")?;
364
365 let (server_dashboard_id, slug_for_refetch, old_yaml_path) = if local_metadata.id == 0 {
366 let created = client
367 .create_dashboard(&CreateDashboard {
368 name: local_metadata.name.clone(),
369 })
370 .await?;
371 println!(
372 " ✓ Created new dashboard: {} - {}",
373 created.id, created.name
374 );
375 client.favorite_dashboard(&created.slug).await?;
376 (created.id, created.slug.clone(), Some(yaml_path.clone()))
377 } else {
378 let server_dashboard = client.get_dashboard(dashboard_slug).await?;
379
380 let server_widget_ids: std::collections::HashSet<u64> =
381 server_dashboard.widgets.iter().map(|w| w.id).collect();
382
383 let local_widget_ids: std::collections::HashSet<u64> = local_metadata
384 .widgets
385 .iter()
386 .filter(|w| w.id != 0)
387 .map(|w| w.id)
388 .collect();
389
390 for widget_id in &server_widget_ids {
391 if !local_widget_ids.contains(widget_id) {
392 client.delete_widget(*widget_id).await?;
393 }
394 }
395
396 (server_dashboard.id, dashboard_slug.to_string(), None)
397 };
398
399 let mut query_cache: HashMap<u64, Query> = HashMap::new();
400 let mut any_widget_has_params = false;
401
402 for widget in &local_metadata.widgets {
403 let (options, has_params) =
404 resolve_widget_options(client, widget, &mut query_cache).await?;
405 if has_params {
406 any_widget_has_params = true;
407 }
408 let payload = CreateWidget {
409 dashboard_id: server_dashboard_id,
410 visualization_id: resolve_visualization_id(client, widget, &mut query_cache).await?,
411 text: widget.text.clone(),
412 options,
413 width: if widget.id == 0 { 1 } else { widget.width },
414 };
415 if widget.id == 0 {
416 client.create_widget(&payload).await?;
417 } else {
418 client.update_widget(widget.id, &payload).await?;
419 }
420 }
421
422 let updated_dashboard = Dashboard {
423 id: server_dashboard_id,
424 name: local_metadata.name.clone(),
425 slug: local_metadata.slug.clone(),
426 user_id: local_metadata.user_id,
427 is_archived: local_metadata.is_archived,
428 is_draft: local_metadata.is_draft,
429 filters_enabled: any_widget_has_params || local_metadata.filters_enabled,
430 tags: local_metadata.tags.clone(),
431 widgets: vec![],
432 };
433
434 client.update_dashboard(&updated_dashboard).await?;
435
436 let refreshed = client.get_dashboard(&slug_for_refetch).await?;
437
438 save_dashboard_yaml(&refreshed, old_yaml_path)?;
439
440 Ok(refreshed.name)
441}
442
443pub async fn archive(client: &RedashClient, dashboard_slugs: Vec<String>) -> Result<()> {
444 if dashboard_slugs.is_empty() {
445 anyhow::bail!(
446 "No dashboard slugs specified.\n\nExample:\n stmo-cli dashboards archive firefox-desktop-on-steamos bug-2006698---ccov-build-regression"
447 );
448 }
449
450 println!("Archiving {} dashboards...\n", dashboard_slugs.len());
451
452 let mut success_count = 0;
453 let mut failed_slugs = Vec::new();
454
455 for slug in &dashboard_slugs {
456 match client.get_dashboard(slug).await {
457 Ok(dashboard) => match client.archive_dashboard(dashboard.id).await {
458 Ok(()) => {
459 let yaml_files: Vec<_> = fs::read_dir("dashboards")
460 .context("Failed to read dashboards directory")?
461 .filter_map(std::result::Result::ok)
462 .filter(|entry| {
463 entry.path().extension().is_some_and(|ext| ext == "yaml")
464 && entry
465 .file_name()
466 .to_str()
467 .and_then(|name| name.strip_suffix(".yaml"))
468 .and_then(|name| name.split_once('-'))
469 .map(|(_, file_slug)| file_slug)
470 .is_some_and(|file_slug| file_slug == slug)
471 })
472 .collect();
473
474 for file in yaml_files {
475 fs::remove_file(file.path())
476 .context(format!("Failed to delete {}", file.path().display()))?;
477 }
478
479 println!(" ✓ {} archived and local file deleted", dashboard.name);
480 success_count += 1;
481 }
482 Err(e) => {
483 eprintln!(" ⚠ Dashboard '{slug}' failed to archive: {e}");
484 failed_slugs.push(slug.clone());
485 }
486 },
487 Err(e) => {
488 eprintln!(" ⚠ Dashboard '{slug}' failed to fetch for archival: {e}");
489 failed_slugs.push(slug.clone());
490 }
491 }
492 }
493
494 if failed_slugs.is_empty() {
495 println!("\n✓ All dashboards archived successfully");
496 Ok(())
497 } else {
498 println!("\n✓ {success_count} dashboard(s) archived successfully");
499 anyhow::bail!(
500 "{} dashboard(s) failed to archive: {}",
501 failed_slugs.len(),
502 failed_slugs.join(", ")
503 );
504 }
505}
506
507pub async fn unarchive(client: &RedashClient, dashboard_slugs: Vec<String>) -> Result<()> {
508 if dashboard_slugs.is_empty() {
509 anyhow::bail!(
510 "No dashboard slugs specified.\n\nExample:\n stmo-cli dashboards unarchive firefox-desktop-on-steamos bug-2006698---ccov-build-regression"
511 );
512 }
513
514 println!("Unarchiving {} dashboards...\n", dashboard_slugs.len());
515
516 let mut success_count = 0;
517 let mut failed_slugs = Vec::new();
518
519 for slug in &dashboard_slugs {
520 match client.get_dashboard(slug).await {
521 Ok(dashboard) => match client.unarchive_dashboard(dashboard.id).await {
522 Ok(unarchived) => {
523 println!(" ✓ {} unarchived", unarchived.name);
524 success_count += 1;
525 }
526 Err(e) => {
527 eprintln!(" ⚠ Dashboard '{slug}' failed to unarchive: {e}");
528 failed_slugs.push(slug.clone());
529 }
530 },
531 Err(e) => {
532 eprintln!(" ⚠ Dashboard '{slug}' failed to fetch for unarchival: {e}");
533 failed_slugs.push(slug.clone());
534 }
535 }
536 }
537
538 if failed_slugs.is_empty() {
539 println!("\n✓ All dashboards unarchived successfully");
540 println!("\nUse 'dashboards fetch' to download the YAML files:");
541 println!(" stmo-cli dashboards fetch {}", dashboard_slugs.join(" "));
542 Ok(())
543 } else {
544 println!("\n✓ {success_count} dashboard(s) unarchived successfully");
545 anyhow::bail!(
546 "{} dashboard(s) failed to unarchive: {}",
547 failed_slugs.len(),
548 failed_slugs.join(", ")
549 );
550 }
551}
552
553#[cfg(test)]
554#[allow(clippy::missing_errors_doc)]
555mod tests {
556 use super::*;
557 use tempfile::TempDir;
558
559 #[test]
560 fn test_extract_dashboard_slugs_from_directory_empty() {
561 let temp_dir = TempDir::new().unwrap();
562 let result = extract_dashboard_slugs_from_path(temp_dir.path());
563 assert!(result.is_ok());
564 let slugs = result.unwrap();
565 assert!(slugs.is_empty());
566 }
567
568 #[test]
569 fn test_extract_dashboard_slugs_with_triple_dash() {
570 let temp_dir = TempDir::new().unwrap();
571 let temp_path = temp_dir.path();
572
573 fs::write(
574 temp_path.join("2006698-bug-2006698---ccov-build-regression.yaml"),
575 "test",
576 )
577 .unwrap();
578 fs::write(
579 temp_path.join("2570-firefox-desktop-on-steamos.yaml"),
580 "test",
581 )
582 .unwrap();
583
584 let result = extract_dashboard_slugs_from_path(temp_path);
585 assert!(result.is_ok());
586
587 let slugs = result.unwrap();
588
589 assert!(slugs.contains(&"bug-2006698---ccov-build-regression".to_string()));
590 assert!(slugs.contains(&"firefox-desktop-on-steamos".to_string()));
591 }
592
593 #[test]
594 fn test_extract_dashboard_slugs_deduplication() {
595 let temp_dir = TempDir::new().unwrap();
596 let temp_path = temp_dir.path();
597
598 fs::write(
599 temp_path.join("2006698-bug-2006698---ccov-build-regression.yaml"),
600 "test",
601 )
602 .unwrap();
603 fs::write(
604 temp_path.join("2006699-bug-2006698---ccov-build-regression.yaml"),
605 "test",
606 )
607 .unwrap();
608
609 let result = extract_dashboard_slugs_from_path(temp_path);
610 assert!(result.is_ok());
611
612 let slugs = result.unwrap();
613
614 assert_eq!(slugs.len(), 1);
615 assert_eq!(slugs[0], "bug-2006698---ccov-build-regression");
616 }
617
618 #[test]
619 fn test_extract_dashboard_slugs_ignores_non_yaml() {
620 let temp_dir = TempDir::new().unwrap();
621 let temp_path = temp_dir.path();
622
623 fs::write(
624 temp_path.join("2006698-bug-2006698---ccov-build-regression.yaml"),
625 "test",
626 )
627 .unwrap();
628 fs::write(
629 temp_path.join("2570-firefox-desktop-on-steamos.txt"),
630 "test",
631 )
632 .unwrap();
633 fs::write(temp_path.join("README.md"), "test").unwrap();
634
635 let result = extract_dashboard_slugs_from_path(temp_path);
636 assert!(result.is_ok());
637
638 let slugs = result.unwrap();
639
640 assert_eq!(slugs.len(), 1);
641 assert_eq!(slugs[0], "bug-2006698---ccov-build-regression");
642 }
643
644 #[test]
645 fn test_extract_dashboard_slugs_sorted() {
646 let temp_dir = TempDir::new().unwrap();
647 let temp_path = temp_dir.path();
648
649 fs::write(temp_path.join("3000-zebra-dashboard.yaml"), "test").unwrap();
650 fs::write(
651 temp_path.join("2006698-bug-2006698---ccov-build-regression.yaml"),
652 "test",
653 )
654 .unwrap();
655 fs::write(temp_path.join("1000-alpha-dashboard.yaml"), "test").unwrap();
656
657 let result = extract_dashboard_slugs_from_path(temp_path);
658 assert!(result.is_ok());
659
660 let slugs = result.unwrap();
661
662 assert_eq!(slugs.len(), 3);
663 assert_eq!(slugs[0], "alpha-dashboard");
664 assert_eq!(slugs[1], "bug-2006698---ccov-build-regression");
665 assert_eq!(slugs[2], "zebra-dashboard");
666 }
667}