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