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}