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