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}