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        client.favorite_dashboard(&created.slug).await?;
272        (created.id, created.slug.clone(), Some(yaml_path.clone()))
273    } else {
274        let server_dashboard = client.get_dashboard(dashboard_slug).await?;
275
276        let server_widget_ids: std::collections::HashSet<u64> = server_dashboard
277            .widgets
278            .iter()
279            .map(|w| w.id)
280            .collect();
281
282        let local_widget_ids: std::collections::HashSet<u64> = local_metadata
283            .widgets
284            .iter()
285            .filter(|w| w.id != 0)
286            .map(|w| w.id)
287            .collect();
288
289        for widget_id in &server_widget_ids {
290            if !local_widget_ids.contains(widget_id) {
291                client.delete_widget(*widget_id).await?;
292            }
293        }
294
295        (server_dashboard.id, dashboard_slug.to_string(), None)
296    };
297
298    for widget in &local_metadata.widgets {
299        if widget.id == 0 {
300            let create_widget = CreateWidget {
301                dashboard_id: server_dashboard_id,
302                visualization_id: widget.visualization_id,
303                text: widget.text.clone(),
304                width: 1,
305                options: widget.options.clone(),
306            };
307            client.create_widget(&create_widget).await?;
308        }
309    }
310
311    let updated_dashboard = Dashboard {
312        id: server_dashboard_id,
313        name: local_metadata.name.clone(),
314        slug: local_metadata.slug.clone(),
315        user_id: local_metadata.user_id,
316        is_archived: local_metadata.is_archived,
317        is_draft: local_metadata.is_draft,
318        filters_enabled: local_metadata.filters_enabled,
319        tags: local_metadata.tags.clone(),
320        widgets: vec![],
321    };
322
323    client.update_dashboard(&updated_dashboard).await?;
324
325    let refreshed = client.get_dashboard(&slug_for_refetch).await?;
326
327    save_dashboard_yaml(&refreshed, old_yaml_path)?;
328
329    Ok(refreshed.name)
330}
331
332pub async fn archive(client: &RedashClient, dashboard_slugs: Vec<String>) -> Result<()> {
333    if dashboard_slugs.is_empty() {
334        anyhow::bail!("No dashboard slugs specified.\n\nExample:\n  stmo-cli dashboards archive firefox-desktop-on-steamos bug-2006698---ccov-build-regression");
335    }
336
337    println!("Archiving {} dashboards...\n", dashboard_slugs.len());
338
339    let mut success_count = 0;
340    let mut failed_slugs = Vec::new();
341
342    for slug in &dashboard_slugs {
343        match client.get_dashboard(slug).await {
344            Ok(dashboard) => {
345                match client.archive_dashboard(slug).await {
346                    Ok(()) => {
347                        let yaml_files: Vec<_> = fs::read_dir("dashboards")
348                            .context("Failed to read dashboards directory")?
349                            .filter_map(std::result::Result::ok)
350                            .filter(|entry| {
351                                entry.path().extension().is_some_and(|ext| ext == "yaml")
352                                    && entry
353                                        .file_name()
354                                        .to_str()
355                                        .and_then(|name| name.strip_suffix(".yaml"))
356                                        .and_then(|name| name.split_once('-'))
357                                        .map(|(_, file_slug)| file_slug)
358                                        .is_some_and(|file_slug| file_slug == slug)
359                            })
360                            .collect();
361
362                        for file in yaml_files {
363                            fs::remove_file(file.path())
364                                .context(format!("Failed to delete {}", file.path().display()))?;
365                        }
366
367                        println!("  ✓ {} archived and local file deleted", dashboard.name);
368                        success_count += 1;
369                    }
370                    Err(e) => {
371                        eprintln!("  ⚠ Dashboard '{slug}' failed to archive: {e}");
372                        failed_slugs.push(slug.clone());
373                    }
374                }
375            }
376            Err(e) => {
377                eprintln!("  ⚠ Dashboard '{slug}' failed to fetch for archival: {e}");
378                failed_slugs.push(slug.clone());
379            }
380        }
381    }
382
383    if failed_slugs.is_empty() {
384        println!("\n✓ All dashboards archived successfully");
385        Ok(())
386    } else {
387        println!("\n✓ {success_count} dashboard(s) archived successfully");
388        anyhow::bail!(
389            "{} dashboard(s) failed to archive: {}",
390            failed_slugs.len(),
391            failed_slugs.join(", ")
392        );
393    }
394}
395
396pub async fn unarchive(client: &RedashClient, dashboard_slugs: Vec<String>) -> Result<()> {
397    if dashboard_slugs.is_empty() {
398        anyhow::bail!("No dashboard slugs specified.\n\nExample:\n  stmo-cli dashboards unarchive firefox-desktop-on-steamos bug-2006698---ccov-build-regression");
399    }
400
401    println!("Unarchiving {} dashboards...\n", dashboard_slugs.len());
402
403    let mut success_count = 0;
404    let mut failed_slugs = Vec::new();
405
406    for slug in &dashboard_slugs {
407        match client.get_dashboard(slug).await {
408            Ok(dashboard) => {
409                match client.unarchive_dashboard(dashboard.id).await {
410                    Ok(unarchived) => {
411                        println!("  ✓ {} unarchived", unarchived.name);
412                        success_count += 1;
413                    }
414                    Err(e) => {
415                        eprintln!("  ⚠ Dashboard '{slug}' failed to unarchive: {e}");
416                        failed_slugs.push(slug.clone());
417                    }
418                }
419            }
420            Err(e) => {
421                eprintln!("  ⚠ Dashboard '{slug}' failed to fetch for unarchival: {e}");
422                failed_slugs.push(slug.clone());
423            }
424        }
425    }
426
427    if failed_slugs.is_empty() {
428        println!("\n✓ All dashboards unarchived successfully");
429        println!("\nUse 'dashboards fetch' to download the YAML files:");
430        println!("  stmo-cli dashboards fetch {}", dashboard_slugs.join(" "));
431        Ok(())
432    } else {
433        println!("\n✓ {success_count} dashboard(s) unarchived successfully");
434        anyhow::bail!(
435            "{} dashboard(s) failed to unarchive: {}",
436            failed_slugs.len(),
437            failed_slugs.join(", ")
438        );
439    }
440}
441
442#[cfg(test)]
443#[allow(clippy::missing_errors_doc)]
444mod tests {
445    use super::*;
446    use tempfile::TempDir;
447
448    #[test]
449    fn test_extract_dashboard_slugs_from_directory_empty() {
450        let temp_dir = TempDir::new().unwrap();
451        let result = extract_dashboard_slugs_from_path(temp_dir.path());
452        assert!(result.is_ok());
453        let slugs = result.unwrap();
454        assert!(slugs.is_empty());
455    }
456
457    #[test]
458    fn test_extract_dashboard_slugs_with_triple_dash() {
459        let temp_dir = TempDir::new().unwrap();
460        let temp_path = temp_dir.path();
461
462        fs::write(temp_path.join("2006698-bug-2006698---ccov-build-regression.yaml"), "test").unwrap();
463        fs::write(temp_path.join("2570-firefox-desktop-on-steamos.yaml"), "test").unwrap();
464
465        let result = extract_dashboard_slugs_from_path(temp_path);
466        assert!(result.is_ok());
467
468        let slugs = result.unwrap();
469
470        assert!(slugs.contains(&"bug-2006698---ccov-build-regression".to_string()));
471        assert!(slugs.contains(&"firefox-desktop-on-steamos".to_string()));
472    }
473
474    #[test]
475    fn test_extract_dashboard_slugs_deduplication() {
476        let temp_dir = TempDir::new().unwrap();
477        let temp_path = temp_dir.path();
478
479        fs::write(temp_path.join("2006698-bug-2006698---ccov-build-regression.yaml"), "test").unwrap();
480        fs::write(temp_path.join("2006699-bug-2006698---ccov-build-regression.yaml"), "test").unwrap();
481
482        let result = extract_dashboard_slugs_from_path(temp_path);
483        assert!(result.is_ok());
484
485        let slugs = result.unwrap();
486
487        assert_eq!(slugs.len(), 1);
488        assert_eq!(slugs[0], "bug-2006698---ccov-build-regression");
489    }
490
491    #[test]
492    fn test_extract_dashboard_slugs_ignores_non_yaml() {
493        let temp_dir = TempDir::new().unwrap();
494        let temp_path = temp_dir.path();
495
496        fs::write(temp_path.join("2006698-bug-2006698---ccov-build-regression.yaml"), "test").unwrap();
497        fs::write(temp_path.join("2570-firefox-desktop-on-steamos.txt"), "test").unwrap();
498        fs::write(temp_path.join("README.md"), "test").unwrap();
499
500        let result = extract_dashboard_slugs_from_path(temp_path);
501        assert!(result.is_ok());
502
503        let slugs = result.unwrap();
504
505        assert_eq!(slugs.len(), 1);
506        assert_eq!(slugs[0], "bug-2006698---ccov-build-regression");
507    }
508
509    #[test]
510    fn test_extract_dashboard_slugs_sorted() {
511        let temp_dir = TempDir::new().unwrap();
512        let temp_path = temp_dir.path();
513
514        fs::write(temp_path.join("3000-zebra-dashboard.yaml"), "test").unwrap();
515        fs::write(temp_path.join("2006698-bug-2006698---ccov-build-regression.yaml"), "test").unwrap();
516        fs::write(temp_path.join("1000-alpha-dashboard.yaml"), "test").unwrap();
517
518        let result = extract_dashboard_slugs_from_path(temp_path);
519        assert!(result.is_ok());
520
521        let slugs = result.unwrap();
522
523        assert_eq!(slugs.len(), 3);
524        assert_eq!(slugs[0], "alpha-dashboard");
525        assert_eq!(slugs[1], "bug-2006698---ccov-build-regression");
526        assert_eq!(slugs[2], "zebra-dashboard");
527    }
528}