Skip to main content

stmo_cli/commands/
dashboards.rs

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}