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}