Skip to main content

stmo_cli/commands/
dashboards.rs

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