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}