From 0b332867ecd6885764b33cd0aa43bf07b448ae79 Mon Sep 17 00:00:00 2001 From: "cody.lee" Date: Mon, 15 Jun 2026 16:49:38 +0000 Subject: [PATCH 1/5] feat(dashboards): add granular dashboard widget editing and schema discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `pup dashboards widgets ` for editing individual widgets within a dashboard, replacing the need to round-trip the entire dashboard JSON. Widget editing uses a safe fetch → mutate → PUT flow (the Datadog Dashboard API has no per-widget PATCH endpoint). The SDK losslessly round-trips unknown fields via `additional_properties`, so untouched widgets are preserved. Widget input is validated by detecting `WidgetDefinition::UnparsedObject` — the only validation gate available without importing private SDK internals. Widget schema discovery emits either a ready-to-edit skeleton JSON (for 8 high-traffic types: timeseries, query_value, query_table, toplist, note, free_text, heatmap, slo) or a minimal `{"definition":{"type":""}}` fallback for the other ~26 known types. The existing `pup dashboards widgets` saved/reporting widgets (V2 API) are promoted to a new top-level `pup widgets` command, freeing the natural `dashboards widgets` path for embedded-widget editing. Files changed: - src/commands/dashboards.rs: helpers, 7 widget commands, static type registry + templates, 22 new co-located tests - src/main.rs: DashboardWidgetActions enum, Commands::Widgets top-level variant, routing changes - src/test_commands.rs: 13 new integration tests covering clap parsing, mutual-exclusion, and read-only classification Environment: Datadog workspace Co-Authored-By: Claude Opus 4.8 --- src/commands/dashboards.rs | 637 ++++++++++++++++++++++++++++++++++++- src/main.rs | 223 ++++++++++--- src/test_commands.rs | 254 +++++++++++++++ 3 files changed, 1071 insertions(+), 43 deletions(-) diff --git a/src/commands/dashboards.rs b/src/commands/dashboards.rs index a35b1579..6846e1a0 100644 --- a/src/commands/dashboards.rs +++ b/src/commands/dashboards.rs @@ -1,10 +1,11 @@ use anyhow::Result; use datadog_api_client::datadogV1::api_dashboards::{DashboardsAPI, ListDashboardsOptionalParams}; -use datadog_api_client::datadogV1::model::Dashboard; +use datadog_api_client::datadogV1::model::{Dashboard, Widget, WidgetDefinition}; +use std::io::Read; use url::Url; use crate::config::Config; -use crate::formatter; +use crate::formatter::{self, Metadata}; use crate::util; pub async fn list(cfg: &Config) -> Result<()> { @@ -67,6 +68,359 @@ pub async fn url(cfg: &Config, id: &str, from: &str, to: &str, live: bool) -> Re Ok(()) } +// ---- Dashboard widget helpers ---- + +/// Read a widget JSON payload from a file path or stdin (`"-"`), then validate it. +fn read_widget_input(file: &str) -> Result { + let bytes: Vec = if file == "-" { + let mut buf = Vec::new(); + std::io::stdin() + .read_to_end(&mut buf) + .map_err(|e| anyhow::anyhow!("failed to read widget JSON from stdin: {e}"))?; + buf + } else { + std::fs::read(file) + .map_err(|e| anyhow::anyhow!("failed to read --file {file:?}: {e}"))? + }; + let widget: Widget = serde_json::from_slice(&bytes) + .map_err(|e| anyhow::anyhow!("failed to parse widget JSON: {e}"))?; + validate_widget(&widget)?; + Ok(widget) +} + +/// Verify the widget has a known definition type. +/// +/// `serde_json::from_slice::` never fails on an unknown `type` — instead +/// the SDK falls through to `WidgetDefinition::UnparsedObject`. We must explicitly +/// check for that variant; the internal `_unparsed` flag is `pub(crate)` and +/// unavailable here, but the public variant is sufficient. +fn validate_widget(widget: &Widget) -> Result<()> { + if matches!(widget.definition, WidgetDefinition::UnparsedObject(_)) { + return Err(anyhow::anyhow!( + "widget definition has an unknown or invalid `type`; \ + run `pup dashboards widgets types` to see supported types" + )); + } + Ok(()) +} + +/// Resolve a `--widget-id` or `--index` selector to an array position. +/// +/// Exactly one of the two `Option` arguments must be `Some` (enforced at the +/// clap layer before we get here; the fallback errors are defensive). +fn locate_widget_index( + widgets: &[Widget], + widget_id: Option, + index: Option, +) -> Result { + if let Some(idx) = index { + if idx >= widgets.len() { + return Err(anyhow::anyhow!( + "index {idx} out of range; dashboard has {} widget(s)", + widgets.len() + )); + } + return Ok(idx); + } + let wid = widget_id.ok_or_else(|| anyhow::anyhow!("--widget-id or --index is required"))?; + let hits: Vec = widgets + .iter() + .enumerate() + .filter(|(_, w)| w.id == Some(wid)) + .map(|(i, _)| i) + .collect(); + match hits.as_slice() { + [] => Err(anyhow::anyhow!( + "no widget with id {wid} found in dashboard" + )), + [i] => Ok(*i), + _ => Err(anyhow::anyhow!( + "widget id {wid} is ambiguous ({} matches); use --index instead", + hits.len() + )), + } +} + +// ---- Dashboard widget commands ---- + +pub async fn widget_list(cfg: &Config, dash_id: &str) -> Result<()> { + let api = crate::make_api!(DashboardsAPI, cfg); + let dashboard = api + .get_dashboard(dash_id.to_string()) + .await + .map_err(|e| anyhow::anyhow!("failed to get dashboard: {e:?}"))?; + let rows: Vec = dashboard + .widgets + .iter() + .enumerate() + .map(|(idx, w)| { + let def_val = + serde_json::to_value(&w.definition).unwrap_or(serde_json::Value::Null); + let widget_type = def_val + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let title = def_val + .get("title") + .and_then(|v| v.as_str()) + .map(|s| serde_json::Value::String(s.to_string())) + .unwrap_or(serde_json::Value::Null); + serde_json::json!({ + "index": idx, + "id": w.id, + "type": widget_type, + "title": title, + "layout": w.layout, + }) + }) + .collect(); + let count = rows.len(); + formatter::format_and_print( + &rows, + &cfg.output_format, + cfg.agent_mode, + Some(&Metadata { + count: Some(count), + truncated: false, + command: Some("dashboards widgets list".into()), + next_action: None, + }), + ) +} + +pub async fn widget_get( + cfg: &Config, + dash_id: &str, + widget_id: Option, + index: Option, +) -> Result<()> { + let api = crate::make_api!(DashboardsAPI, cfg); + let dashboard = api + .get_dashboard(dash_id.to_string()) + .await + .map_err(|e| anyhow::anyhow!("failed to get dashboard: {e:?}"))?; + let idx = locate_widget_index(&dashboard.widgets, widget_id, index)?; + formatter::output(cfg, &dashboard.widgets[idx]) +} + +pub async fn widget_add(cfg: &Config, dash_id: &str, file: &str) -> Result<()> { + let mut widget = read_widget_input(file)?; + let api = crate::make_api!(DashboardsAPI, cfg); + let mut dashboard = api + .get_dashboard(dash_id.to_string()) + .await + .map_err(|e| anyhow::anyhow!("failed to get dashboard: {e:?}"))?; + // Detect free vs ordered layout via JSON serialization (avoids needing to + // match on SDK enum variant names that may change across SDK revisions). + let is_free = serde_json::to_value(&dashboard.layout_type) + .ok() + .and_then(|v| v.as_str().map(|s| s == "free")) + .unwrap_or(false); + if is_free { + if widget.layout.is_none() { + return Err(anyhow::anyhow!( + "this is a free-layout dashboard; the widget JSON must include a \ + `layout` object with `x`, `y`, `width`, and `height` fields" + )); + } + } else { + // Ordered dashboards must not carry per-widget layout coordinates. + widget.layout = None; + } + widget.id = None; // let the API assign an id on create + dashboard.widgets.push(widget); + let resp = api + .update_dashboard(dash_id.to_string(), dashboard) + .await + .map_err(|e| anyhow::anyhow!("failed to update dashboard: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn widget_update( + cfg: &Config, + dash_id: &str, + widget_id: Option, + index: Option, + file: &str, +) -> Result<()> { + let mut widget = read_widget_input(file)?; + let api = crate::make_api!(DashboardsAPI, cfg); + let mut dashboard = api + .get_dashboard(dash_id.to_string()) + .await + .map_err(|e| anyhow::anyhow!("failed to get dashboard: {e:?}"))?; + let idx = locate_widget_index(&dashboard.widgets, widget_id, index)?; + // Preserve the existing widget's id so it keeps identity in the dashboard. + widget.id = dashboard.widgets[idx].id; + dashboard.widgets[idx] = widget; + let resp = api + .update_dashboard(dash_id.to_string(), dashboard) + .await + .map_err(|e| anyhow::anyhow!("failed to update dashboard: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn widget_remove( + cfg: &Config, + dash_id: &str, + widget_id: Option, + index: Option, +) -> Result<()> { + let api = crate::make_api!(DashboardsAPI, cfg); + let mut dashboard = api + .get_dashboard(dash_id.to_string()) + .await + .map_err(|e| anyhow::anyhow!("failed to get dashboard: {e:?}"))?; + let idx = locate_widget_index(&dashboard.widgets, widget_id, index)?; + dashboard.widgets.remove(idx); + let resp = api + .update_dashboard(dash_id.to_string(), dashboard) + .await + .map_err(|e| anyhow::anyhow!("failed to update dashboard: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub fn widget_types(cfg: &Config) -> Result<()> { + let types: Vec<&str> = WIDGET_TYPES.to_vec(); + formatter::format_and_print( + &types, + &cfg.output_format, + cfg.agent_mode, + Some(&Metadata { + count: Some(WIDGET_TYPES.len()), + truncated: false, + command: Some("dashboards widgets types".into()), + next_action: None, + }), + ) +} + +pub fn widget_schema(cfg: &Config, type_str: &str) -> Result<()> { + let tmpl = widget_template(type_str).ok_or_else(|| { + anyhow::anyhow!( + "unknown widget type {type_str:?}; \ + run `pup dashboards widgets types` to see supported types" + ) + })?; + formatter::output(cfg, &tmpl) +} + +// ---- Widget type registry and skeleton templates ---- + +/// All widget `type` strings recognised by the Datadog Dashboard API (V1). +/// +/// Kept in sync with the `WidgetDefinition` enum in the +/// `datadog-api-client` SDK. Unknown types round-trip via +/// `WidgetDefinition::UnparsedObject` and are rejected by `validate_widget`. +const WIDGET_TYPES: &[&str] = &[ + "alert_graph", + "alert_value", + "change", + "check_status", + "distribution", + "event_stream", + "event_timeline", + "free_text", + "funnel", + "geomap", + "group", + "heatmap", + "hostmap", + "iframe", + "image", + "list_stream", + "log_stream", + "manage_status", + "note", + "powerpack", + "query_value", + "query_table", + "run_workflow", + "scatterplot", + "servicemap", + "slo", + "slo_list", + "sunburst", + "timeseries", + "toplist", + "topology_map", + "trace_service", + "treemap", + "wildcard", +]; + +/// Return a ready-to-edit skeleton JSON for the given widget type. +/// +/// High-traffic types (timeseries, query_value, query_table, toplist, note, +/// free_text, heatmap, slo) get a real skeleton with the required fields. +/// Every other known type gets a minimal `{"definition":{"type":"…"}}` fallback +/// the user can flesh out. Unknown types return `None`. +fn widget_template(t: &str) -> Option { + let v = match t { + "timeseries" => serde_json::json!({ + "definition": { + "type": "timeseries", + "title": "", + "requests": [{"q": "", "display_type": "line"}] + } + }), + "query_value" => serde_json::json!({ + "definition": { + "type": "query_value", + "title": "", + "requests": [{"q": "", "aggregator": "avg"}] + } + }), + "query_table" => serde_json::json!({ + "definition": { + "type": "query_table", + "title": "", + "requests": [{"q": "", "aggregator": "avg"}] + } + }), + "toplist" => serde_json::json!({ + "definition": { + "type": "toplist", + "title": "", + "requests": [{"q": ""}] + } + }), + "note" => serde_json::json!({ + "definition": { + "type": "note", + "content": "" + } + }), + "free_text" => serde_json::json!({ + "definition": { + "type": "free_text", + "text": "" + } + }), + "heatmap" => serde_json::json!({ + "definition": { + "type": "heatmap", + "title": "", + "requests": [{"q": ""}] + } + }), + "slo" => serde_json::json!({ + "definition": { + "type": "slo", + "slo_id": "", + "view_type": "detail", + "time_windows": ["7d"] + } + }), + other if WIDGET_TYPES.contains(&other) => serde_json::json!({ + "definition": {"type": other} + }), + _ => return None, + }; + Some(v) +} + fn dashboard_url_with_time(base_url: &str, from: &str, to: &str, live: bool) -> Result { let mut url = Url::parse(base_url).map_err(|e| { anyhow::anyhow!("dashboard response included invalid url {base_url:?}: {e}") @@ -169,4 +523,283 @@ mod tests { "https://app.datadoghq.com/dashboard/abc-123/test-dashboard?from_ts=now-1w&to_ts=now&live=true" ); } + + // ---- widget_types / widget_schema (unit, no server) ---- + + #[test] + fn test_widget_types_list_not_empty() { + assert!( + super::WIDGET_TYPES.len() >= 20, + "expected at least 20 widget types, got {}", + super::WIDGET_TYPES.len() + ); + assert!(super::WIDGET_TYPES.contains(&"timeseries")); + assert!(super::WIDGET_TYPES.contains(&"query_value")); + assert!(super::WIDGET_TYPES.contains(&"note")); + } + + #[test] + fn test_widget_template_returns_known_skeleton() { + let tmpl = super::widget_template("timeseries").expect("timeseries should have a template"); + let ty = tmpl + .get("definition") + .and_then(|d| d.get("type")) + .and_then(|t| t.as_str()) + .expect("template must have definition.type"); + assert_eq!(ty, "timeseries"); + assert!( + tmpl["definition"].get("requests").is_some(), + "timeseries template must include requests" + ); + } + + #[test] + fn test_widget_template_generic_fallback() { + // "geomap" is a known type but has no custom skeleton + let tmpl = super::widget_template("geomap").expect("geomap should have a fallback"); + assert_eq!(tmpl["definition"]["type"], "geomap"); + } + + #[test] + fn test_widget_template_unknown_type_returns_none() { + assert!( + super::widget_template("not_a_real_widget_type").is_none(), + "unknown type must return None" + ); + } + + #[test] + fn test_validate_widget_rejects_unparsed_object() { + // An unknown type deserialized through the SDK falls through to + // WidgetDefinition::UnparsedObject and must be rejected. + let json = r#"{"definition":{"type":"not_a_real_type"}}"#; + let widget: datadog_api_client::datadogV1::model::Widget = + serde_json::from_str(json).expect("from_str must not fail even for unknown types"); + let result = super::validate_widget(&widget); + assert!( + result.is_err(), + "validate_widget must reject UnparsedObject definitions" + ); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("unknown or invalid"), + "error message should describe the problem, got: {msg}" + ); + } + + // ---- locate_widget_index (unit, no server) ---- + + fn make_widget(id: Option) -> datadog_api_client::datadogV1::model::Widget { + let json = format!( + r#"{{"definition":{{"type":"timeseries","requests":[{{"q":""}}]}},"id":{}}}"#, + match id { + Some(n) => n.to_string(), + None => "null".to_string(), + } + ); + serde_json::from_str(&json).unwrap() + } + + #[test] + fn test_locate_by_index_ok() { + let widgets = vec![make_widget(None), make_widget(None)]; + assert_eq!(super::locate_widget_index(&widgets, None, Some(1)).unwrap(), 1); + } + + #[test] + fn test_locate_by_index_out_of_range() { + let widgets = vec![make_widget(None)]; + assert!(super::locate_widget_index(&widgets, None, Some(5)).is_err()); + } + + #[test] + fn test_locate_by_id_found() { + let widgets = vec![make_widget(Some(1)), make_widget(Some(2))]; + assert_eq!( + super::locate_widget_index(&widgets, Some(2), None).unwrap(), + 1 + ); + } + + #[test] + fn test_locate_by_id_not_found() { + let widgets = vec![make_widget(Some(1))]; + assert!(super::locate_widget_index(&widgets, Some(99), None).is_err()); + } + + #[test] + fn test_locate_by_id_ambiguous() { + let widgets = vec![make_widget(Some(7)), make_widget(Some(7))]; + let err = super::locate_widget_index(&widgets, Some(7), None).unwrap_err(); + assert!( + err.to_string().contains("ambiguous"), + "expected ambiguous error, got: {err}" + ); + } + + // ---- widget_list (mockito) ---- + + const DASHBOARD_WITH_WIDGETS: &str = r#"{ + "id": "abc-123", + "title": "Test", + "layout_type": "ordered", + "widgets": [ + {"id": 1, "definition": {"type": "timeseries", "title": "My Chart", "requests": [{"q": "avg:system.cpu.user{*}"}]}}, + {"id": 2, "definition": {"type": "note", "content": "Hello"}} + ] + }"#; + + #[tokio::test] + async fn test_widget_list() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _mock = mock_any(&mut server, "GET", DASHBOARD_WITH_WIDGETS).await; + + let result = super::widget_list(&cfg, "abc-123").await; + assert!(result.is_ok(), "widget_list failed: {:?}", result.err()); + cleanup_env(); + } + + // ---- widget_get (mockito) ---- + + #[tokio::test] + async fn test_widget_get_by_index() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _mock = mock_any(&mut server, "GET", DASHBOARD_WITH_WIDGETS).await; + + let result = super::widget_get(&cfg, "abc-123", None, Some(0)).await; + assert!(result.is_ok(), "widget_get by index failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_widget_get_by_id() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _mock = mock_any(&mut server, "GET", DASHBOARD_WITH_WIDGETS).await; + + let result = super::widget_get(&cfg, "abc-123", Some(2), None).await; + assert!(result.is_ok(), "widget_get by id failed: {:?}", result.err()); + cleanup_env(); + } + + // ---- widget_add (mockito) ---- + + #[tokio::test] + async fn test_widget_add() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _mock_get = mock_any(&mut server, "GET", DASHBOARD_WITH_WIDGETS).await; + let _mock_put = mock_any(&mut server, "PUT", DASHBOARD_WITH_WIDGETS).await; + + let widget_json = r#"{"definition":{"type":"timeseries","requests":[{"q":"avg:system.cpu.user{*}"}]}}"#; + let path = write_temp_json("test_widget_add.json", widget_json); + let result = super::widget_add(&cfg, "abc-123", path.to_str().unwrap()).await; + assert!(result.is_ok(), "widget_add failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_widget_add_invalid_json_fails() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + + let path = write_temp_json("test_widget_add_bad.json", "not json at all"); + let result = super::widget_add(&cfg, "abc-123", path.to_str().unwrap()).await; + assert!(result.is_err(), "widget_add should fail on bad JSON"); + cleanup_env(); + } + + #[tokio::test] + async fn test_widget_add_unknown_type_fails() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + + let widget_json = r#"{"definition":{"type":"totally_unknown_type"}}"#; + let path = write_temp_json("test_widget_add_unknown.json", widget_json); + let result = super::widget_add(&cfg, "abc-123", path.to_str().unwrap()).await; + assert!( + result.is_err(), + "widget_add should reject an unknown widget type" + ); + cleanup_env(); + } + + // ---- widget_update (mockito) ---- + + #[tokio::test] + async fn test_widget_update_by_index() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _mock_get = mock_any(&mut server, "GET", DASHBOARD_WITH_WIDGETS).await; + let _mock_put = mock_any(&mut server, "PUT", DASHBOARD_WITH_WIDGETS).await; + + let widget_json = r#"{"definition":{"type":"note","content":"Updated"}}"#; + let path = write_temp_json("test_widget_update.json", widget_json); + let result = + super::widget_update(&cfg, "abc-123", None, Some(1), path.to_str().unwrap()).await; + assert!(result.is_ok(), "widget_update failed: {:?}", result.err()); + cleanup_env(); + } + + // ---- widget_remove (mockito) ---- + + #[tokio::test] + async fn test_widget_remove_by_id() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _mock_get = mock_any(&mut server, "GET", DASHBOARD_WITH_WIDGETS).await; + let _mock_put = mock_any(&mut server, "PUT", DASHBOARD_WITH_WIDGETS).await; + + let result = super::widget_remove(&cfg, "abc-123", Some(1), None).await; + assert!(result.is_ok(), "widget_remove failed: {:?}", result.err()); + cleanup_env(); + } + + // ---- widget_types / widget_schema (with config, no server) ---- + + #[tokio::test] + async fn test_widget_types_command_ok() { + let _lock = lock_env().await; + let cfg = test_config("http://unused.local"); + let result = super::widget_types(&cfg); + assert!(result.is_ok(), "widget_types failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_widget_schema_timeseries_ok() { + let _lock = lock_env().await; + let cfg = test_config("http://unused.local"); + let result = super::widget_schema(&cfg, "timeseries"); + assert!(result.is_ok(), "widget_schema(timeseries) failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_widget_schema_generic_fallback_ok() { + let _lock = lock_env().await; + let cfg = test_config("http://unused.local"); + let result = super::widget_schema(&cfg, "geomap"); + assert!(result.is_ok(), "widget_schema(geomap) failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_widget_schema_unknown_type_fails() { + let _lock = lock_env().await; + let cfg = test_config("http://unused.local"); + let result = super::widget_schema(&cfg, "totally_bogus_widget_type"); + assert!(result.is_err(), "widget_schema should fail for unknown types"); + cleanup_env(); + } } diff --git a/src/main.rs b/src/main.rs index 5c812ec1..2c482ecf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2731,6 +2731,37 @@ enum Commands { }, /// Print version information Version, + /// Manage saved widgets (CCM, logs, CSV, and product analytics reports) + /// + /// List, get, create, update, and delete saved reporting widgets scoped + /// to an experience type. + /// + /// EXPERIENCE TYPES: + /// ccm_reports Cloud Cost Management report widgets + /// logs_reports Log Management report widgets + /// csv_reports CSV export widgets + /// product_analytics Product analytics widgets + /// + /// COMMANDS: + /// list Search and list widgets + /// get Get widget details + /// create --file w.json Create a widget + /// update --file w.json Update a widget + /// delete Delete a widget + /// + /// EXAMPLES: + /// pup widgets list logs_reports + /// pup widgets get ccm_reports + /// pup widgets create logs_reports --file widget.json + /// + /// AUTHENTICATION: + /// Requires either OAuth2 authentication (pup auth login) or API keys + /// (DD_API_KEY and DD_APP_KEY environment variables). + #[command(verbatim_doc_comment)] + Widgets { + #[command(subcommand)] + action: WidgetActions, + }, /// Manage Datadog workflows /// /// Create, update, delete, and execute Datadog Workflow Automation workflows. @@ -3250,10 +3281,10 @@ enum DashboardActions { }, /// Delete a dashboard Delete { id: String }, - /// Manage saved widgets + /// Edit widgets embedded in a dashboard, and discover widget schemas Widgets { #[command(subcommand)] - action: WidgetActions, + action: DashboardWidgetActions, }, /// Manage annotations on dashboard pages Annotations { @@ -3262,6 +3293,73 @@ enum DashboardActions { }, } +// ---- Dashboard widget subcommands (embedded widgets in a dashboard) ---- + +#[derive(Subcommand)] +enum DashboardWidgetActions { + /// List all widgets in a dashboard (index, id, type, title, layout) + List { + /// Dashboard ID + dash_id: String, + }, + /// Show one widget's full JSON + Get { + /// Dashboard ID + dash_id: String, + /// Widget id (Widget.id from the dashboard JSON) + #[arg(long, conflicts_with = "index", required_unless_present = "index")] + widget_id: Option, + /// 0-based position of the widget in the dashboard widgets array + #[arg(long)] + index: Option, + }, + /// Append a widget from a JSON file ('-' for stdin) + Add { + /// Dashboard ID + dash_id: String, + /// Path to widget JSON file, or '-' to read from stdin + #[arg(long)] + file: String, + }, + /// Replace one widget from a JSON file ('-' for stdin) + Update { + /// Dashboard ID + dash_id: String, + /// Widget id (Widget.id from the dashboard JSON) + #[arg(long, conflicts_with = "index", required_unless_present = "index")] + widget_id: Option, + /// 0-based position of the widget in the dashboard widgets array + #[arg(long)] + index: Option, + /// Path to widget JSON file, or '-' to read from stdin + #[arg(long)] + file: String, + }, + /// Delete one widget from a dashboard + Remove { + /// Dashboard ID + dash_id: String, + /// Widget id (Widget.id from the dashboard JSON) + #[arg(long, conflicts_with = "index", required_unless_present = "index")] + widget_id: Option, + /// 0-based position of the widget in the dashboard widgets array + #[arg(long)] + index: Option, + }, + /// List all supported widget type strings + Types, + /// Emit a ready-to-edit skeleton JSON for a widget type + /// + /// Pass the type string (e.g. timeseries) to get a skeleton you can + /// fill in and pass to `pup dashboards widgets add --file`. + /// Run `pup dashboards widgets types` for the full list of types. + Schema { + /// Widget type string, e.g. timeseries, note, query_value + #[arg(help = "Widget type string (see `pup dashboards widgets types`)")] + r#type: String, + }, +} + // ---- Debugger ---- #[derive(Subcommand)] @@ -11313,56 +11411,43 @@ async fn main_inner() -> anyhow::Result<()> { } DashboardActions::Delete { id } => commands::dashboards::delete(&cfg, &id).await?, DashboardActions::Widgets { action } => match action { - WidgetActions::List { - experience_type, - filter_widget_type, - filter_creator_handle, - filter_is_favorited, - filter_title, - filter_tags, - sort, - page_number, - page_size, - } => { - commands::widgets::list( - &cfg, - &experience_type, - filter_widget_type, - filter_creator_handle, - filter_is_favorited, - filter_title, - filter_tags, - sort, - page_number, - page_size, - ) - .await?; + DashboardWidgetActions::List { dash_id } => { + commands::dashboards::widget_list(&cfg, &dash_id).await?; } - WidgetActions::Get { - experience_type, + DashboardWidgetActions::Get { + dash_id, widget_id, + index, } => { - commands::widgets::get(&cfg, &experience_type, &widget_id).await?; + commands::dashboards::widget_get(&cfg, &dash_id, widget_id, index).await?; } - WidgetActions::Create { - experience_type, - file, - } => { - commands::widgets::create(&cfg, &experience_type, &file).await?; + DashboardWidgetActions::Add { dash_id, file } => { + commands::dashboards::widget_add(&cfg, &dash_id, &file).await?; } - WidgetActions::Update { - experience_type, + DashboardWidgetActions::Update { + dash_id, widget_id, + index, file, } => { - commands::widgets::update(&cfg, &experience_type, &widget_id, &file) - .await?; + commands::dashboards::widget_update( + &cfg, &dash_id, widget_id, index, &file, + ) + .await?; } - WidgetActions::Delete { - experience_type, + DashboardWidgetActions::Remove { + dash_id, widget_id, + index, } => { - commands::widgets::delete(&cfg, &experience_type, &widget_id).await?; + commands::dashboards::widget_remove(&cfg, &dash_id, widget_id, index) + .await?; + } + DashboardWidgetActions::Types => { + commands::dashboards::widget_types(&cfg)?; + } + DashboardWidgetActions::Schema { r#type } => { + commands::dashboards::widget_schema(&cfg, &r#type)?; } }, } @@ -14851,6 +14936,62 @@ async fn main_inner() -> anyhow::Result<()> { AuthActions::List => commands::auth::list(&cfg)?, AuthActions::Test => commands::test::run(&cfg)?, }, + // --- Widgets (top-level saved/reporting widgets) --- + Commands::Widgets { action } => { + cfg.validate_auth()?; + match action { + WidgetActions::List { + experience_type, + filter_widget_type, + filter_creator_handle, + filter_is_favorited, + filter_title, + filter_tags, + sort, + page_number, + page_size, + } => { + commands::widgets::list( + &cfg, + &experience_type, + filter_widget_type, + filter_creator_handle, + filter_is_favorited, + filter_title, + filter_tags, + sort, + page_number, + page_size, + ) + .await?; + } + WidgetActions::Get { + experience_type, + widget_id, + } => { + commands::widgets::get(&cfg, &experience_type, &widget_id).await?; + } + WidgetActions::Create { + experience_type, + file, + } => { + commands::widgets::create(&cfg, &experience_type, &file).await?; + } + WidgetActions::Update { + experience_type, + widget_id, + file, + } => { + commands::widgets::update(&cfg, &experience_type, &widget_id, &file).await?; + } + WidgetActions::Delete { + experience_type, + widget_id, + } => { + commands::widgets::delete(&cfg, &experience_type, &widget_id).await?; + } + } + } // --- Workflows --- Commands::Workflows { action } => match action { WorkflowActions::Get { workflow_id } => { diff --git a/src/test_commands.rs b/src/test_commands.rs index 868f92b1..02801049 100644 --- a/src/test_commands.rs +++ b/src/test_commands.rs @@ -440,3 +440,257 @@ fn test_audit_alias_is_visible() { "`audit` alias should resolve to the audit-logs command" ); } + +// ------------------------------------------------------------------------- +// Dashboard embedded widgets (pup dashboards widgets *) +// ------------------------------------------------------------------------- + +#[test] +fn test_dashboards_widgets_add_parses_as_write() { + let matches = crate::Cli::command() + .try_get_matches_from([ + "pup", + "dashboards", + "widgets", + "add", + "abc-123", + "--file", + "w.json", + ]) + .unwrap(); + let leaf = crate::get_leaf_subcommand_name(&matches).unwrap(); + assert!( + crate::is_write_command_name(&leaf), + "dashboards widgets add must be classified as a write command, got leaf={leaf:?}" + ); +} + +#[test] +fn test_dashboards_widgets_remove_parses_as_write() { + let matches = crate::Cli::command() + .try_get_matches_from([ + "pup", + "dashboards", + "widgets", + "remove", + "abc-123", + "--index", + "0", + ]) + .unwrap(); + let leaf = crate::get_leaf_subcommand_name(&matches).unwrap(); + assert!( + crate::is_write_command_name(&leaf), + "dashboards widgets remove must be classified as a write command, got leaf={leaf:?}" + ); +} + +#[test] +fn test_dashboards_widgets_types_parses_as_read() { + let matches = crate::Cli::command() + .try_get_matches_from(["pup", "dashboards", "widgets", "types"]) + .unwrap(); + let leaf = crate::get_leaf_subcommand_name(&matches).unwrap(); + assert!( + !crate::is_write_command_name(&leaf), + "dashboards widgets types must be classified as a read command, got leaf={leaf:?}" + ); +} + +#[test] +fn test_dashboards_widgets_schema_parses_as_read() { + let matches = crate::Cli::command() + .try_get_matches_from(["pup", "dashboards", "widgets", "schema", "timeseries"]) + .unwrap(); + let leaf = crate::get_leaf_subcommand_name(&matches).unwrap(); + assert!( + !crate::is_write_command_name(&leaf), + "dashboards widgets schema must be classified as a read command, got leaf={leaf:?}" + ); +} + +#[test] +fn test_dashboards_widgets_add_parses_args() { + use clap::Parser; + + let cli = crate::Cli::try_parse_from([ + "pup", + "dashboards", + "widgets", + "add", + "abc-123", + "--file", + "widget.json", + ]) + .expect("dashboards widgets add should parse"); + + match cli.command { + crate::Commands::Dashboards { action } => { + let crate::DashboardActions::Widgets { action } = action else { + panic!("expected DashboardActions::Widgets"); + }; + let crate::DashboardWidgetActions::Add { dash_id, file } = action else { + panic!("expected DashboardWidgetActions::Add"); + }; + assert_eq!(dash_id, "abc-123"); + assert_eq!(file, "widget.json"); + } + _ => panic!("expected Commands::Dashboards"), + } +} + +#[test] +fn test_dashboards_widgets_get_by_index_parses() { + use clap::Parser; + + let cli = crate::Cli::try_parse_from([ + "pup", + "dashboards", + "widgets", + "get", + "abc-123", + "--index", + "2", + ]) + .expect("dashboards widgets get --index should parse"); + + match cli.command { + crate::Commands::Dashboards { action } => { + let crate::DashboardActions::Widgets { action } = action else { + panic!("expected DashboardActions::Widgets"); + }; + let crate::DashboardWidgetActions::Get { + dash_id, + widget_id, + index, + } = action + else { + panic!("expected DashboardWidgetActions::Get"); + }; + assert_eq!(dash_id, "abc-123"); + assert_eq!(widget_id, None); + assert_eq!(index, Some(2)); + } + _ => panic!("expected Commands::Dashboards"), + } +} + +#[test] +fn test_dashboards_widgets_get_requires_selector() { + // Neither --widget-id nor --index provided — should fail clap validation. + let result = crate::Cli::command().try_get_matches_from([ + "pup", + "dashboards", + "widgets", + "get", + "abc-123", + ]); + assert!( + result.is_err(), + "dashboards widgets get must require --widget-id or --index" + ); +} + +#[test] +fn test_dashboards_widgets_get_rejects_both_selectors() { + // Both --widget-id and --index provided — should fail clap's conflicts_with. + let result = crate::Cli::command().try_get_matches_from([ + "pup", + "dashboards", + "widgets", + "get", + "abc-123", + "--widget-id", + "1", + "--index", + "0", + ]); + assert!( + result.is_err(), + "dashboards widgets get must reject --widget-id and --index together" + ); +} + +#[test] +fn test_dashboards_widgets_schema_parses() { + use clap::Parser; + + let cli = + crate::Cli::try_parse_from(["pup", "dashboards", "widgets", "schema", "timeseries"]) + .expect("dashboards widgets schema should parse"); + + match cli.command { + crate::Commands::Dashboards { action } => { + let crate::DashboardActions::Widgets { action } = action else { + panic!("expected DashboardActions::Widgets"); + }; + let crate::DashboardWidgetActions::Schema { r#type } = action else { + panic!("expected DashboardWidgetActions::Schema"); + }; + assert_eq!(r#type, "timeseries"); + } + _ => panic!("expected Commands::Dashboards"), + } +} + +// ------------------------------------------------------------------------- +// Top-level saved widgets (pup widgets *) +// ------------------------------------------------------------------------- + +#[test] +fn test_widgets_list_parses() { + use clap::Parser; + + let cli = crate::Cli::try_parse_from([ + "pup", + "widgets", + "list", + "logs_reports", + "--page-size", + "10", + ]) + .expect("pup widgets list should parse"); + + match cli.command { + crate::Commands::Widgets { action } => match action { + crate::WidgetActions::List { + experience_type, + page_size, + .. + } => { + assert_eq!(experience_type, "logs_reports"); + assert_eq!(page_size, Some(10)); + } + _ => panic!("expected WidgetActions::List"), + }, + _ => panic!("expected Commands::Widgets"), + } +} + +#[test] +fn test_widgets_get_parses() { + use clap::Parser; + + let cli = crate::Cli::try_parse_from([ + "pup", + "widgets", + "get", + "ccm_reports", + "uuid-here-123", + ]) + .expect("pup widgets get should parse"); + + match cli.command { + crate::Commands::Widgets { action } => match action { + crate::WidgetActions::Get { + experience_type, + widget_id, + } => { + assert_eq!(experience_type, "ccm_reports"); + assert_eq!(widget_id, "uuid-here-123"); + } + _ => panic!("expected WidgetActions::Get"), + }, + _ => panic!("expected Commands::Widgets"), + } +} From b0902211ca9098d9136faba76250e3543c05abe1 Mon Sep 17 00:00:00 2001 From: "cody.lee" Date: Mon, 15 Jun 2026 17:34:00 +0000 Subject: [PATCH 2/5] refactor(dashboards): rename top-level `pup widgets` to `pup saved-widgets` Rename the Commands::Widgets variant to Commands::SavedWidgets with #[command(name = "saved-widgets")] so the top-level saved/reporting widgets command is invoked as `pup saved-widgets` instead of `pup widgets`. Also repositions the variant alphabetically (after Runbooks, before Scorecards). Updates routing arm and tests accordingly. Environment: Datadog workspace Co-Authored-By: Claude Sonnet 4.6 --- src/main.rs | 66 ++++++++++++++++++++++---------------------- src/test_commands.rs | 22 +++++++-------- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2c482ecf..7311d7d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2242,6 +2242,37 @@ enum Commands { #[command(subcommand)] action: RunbookActions, }, + /// Manage saved widgets (CCM, logs, CSV, and product analytics reports) + /// + /// List, get, create, update, and delete saved reporting widgets scoped + /// to an experience type. + /// + /// EXPERIENCE TYPES: + /// ccm_reports Cloud Cost Management report widgets + /// logs_reports Log Management report widgets + /// csv_reports CSV export widgets + /// product_analytics Product analytics widgets + /// + /// COMMANDS: + /// list Search and list widgets + /// get Get widget details + /// create --file w.json Create a widget + /// update --file w.json Update a widget + /// delete Delete a widget + /// + /// EXAMPLES: + /// pup saved-widgets list logs_reports + /// pup saved-widgets get ccm_reports + /// pup saved-widgets create logs_reports --file widget.json + /// + /// AUTHENTICATION: + /// Requires either OAuth2 authentication (pup auth login) or API keys + /// (DD_API_KEY and DD_APP_KEY environment variables). + #[command(verbatim_doc_comment, name = "saved-widgets")] + SavedWidgets { + #[command(subcommand)] + action: WidgetActions, + }, /// Manage service scorecards /// /// Manage service quality scorecards and rules. @@ -2731,37 +2762,6 @@ enum Commands { }, /// Print version information Version, - /// Manage saved widgets (CCM, logs, CSV, and product analytics reports) - /// - /// List, get, create, update, and delete saved reporting widgets scoped - /// to an experience type. - /// - /// EXPERIENCE TYPES: - /// ccm_reports Cloud Cost Management report widgets - /// logs_reports Log Management report widgets - /// csv_reports CSV export widgets - /// product_analytics Product analytics widgets - /// - /// COMMANDS: - /// list Search and list widgets - /// get Get widget details - /// create --file w.json Create a widget - /// update --file w.json Update a widget - /// delete Delete a widget - /// - /// EXAMPLES: - /// pup widgets list logs_reports - /// pup widgets get ccm_reports - /// pup widgets create logs_reports --file widget.json - /// - /// AUTHENTICATION: - /// Requires either OAuth2 authentication (pup auth login) or API keys - /// (DD_API_KEY and DD_APP_KEY environment variables). - #[command(verbatim_doc_comment)] - Widgets { - #[command(subcommand)] - action: WidgetActions, - }, /// Manage Datadog workflows /// /// Create, update, delete, and execute Datadog Workflow Automation workflows. @@ -14936,8 +14936,8 @@ async fn main_inner() -> anyhow::Result<()> { AuthActions::List => commands::auth::list(&cfg)?, AuthActions::Test => commands::test::run(&cfg)?, }, - // --- Widgets (top-level saved/reporting widgets) --- - Commands::Widgets { action } => { + // --- SavedWidgets (top-level saved/reporting widgets) --- + Commands::SavedWidgets { action } => { cfg.validate_auth()?; match action { WidgetActions::List { diff --git a/src/test_commands.rs b/src/test_commands.rs index 02801049..8cbba577 100644 --- a/src/test_commands.rs +++ b/src/test_commands.rs @@ -634,25 +634,25 @@ fn test_dashboards_widgets_schema_parses() { } // ------------------------------------------------------------------------- -// Top-level saved widgets (pup widgets *) +// Top-level saved widgets (pup saved-widgets *) // ------------------------------------------------------------------------- #[test] -fn test_widgets_list_parses() { +fn test_saved_widgets_list_parses() { use clap::Parser; let cli = crate::Cli::try_parse_from([ "pup", - "widgets", + "saved-widgets", "list", "logs_reports", "--page-size", "10", ]) - .expect("pup widgets list should parse"); + .expect("pup saved-widgets list should parse"); match cli.command { - crate::Commands::Widgets { action } => match action { + crate::Commands::SavedWidgets { action } => match action { crate::WidgetActions::List { experience_type, page_size, @@ -663,25 +663,25 @@ fn test_widgets_list_parses() { } _ => panic!("expected WidgetActions::List"), }, - _ => panic!("expected Commands::Widgets"), + _ => panic!("expected Commands::SavedWidgets"), } } #[test] -fn test_widgets_get_parses() { +fn test_saved_widgets_get_parses() { use clap::Parser; let cli = crate::Cli::try_parse_from([ "pup", - "widgets", + "saved-widgets", "get", "ccm_reports", "uuid-here-123", ]) - .expect("pup widgets get should parse"); + .expect("pup saved-widgets get should parse"); match cli.command { - crate::Commands::Widgets { action } => match action { + crate::Commands::SavedWidgets { action } => match action { crate::WidgetActions::Get { experience_type, widget_id, @@ -691,6 +691,6 @@ fn test_widgets_get_parses() { } _ => panic!("expected WidgetActions::Get"), }, - _ => panic!("expected Commands::Widgets"), + _ => panic!("expected Commands::SavedWidgets"), } } From fd248a99c5099bdcc03ee3fe8bf4dda5184b1a30 Mon Sep 17 00:00:00 2001 From: "cody.lee" Date: Mon, 15 Jun 2026 17:58:35 +0000 Subject: [PATCH 3/5] feat(dashboards): add descriptions to widget types and schema commands `pup dashboards widgets types` now returns objects with `type` and `description` fields sourced from the Datadog OpenAPI spec (v1). `pup dashboards widgets schema ` now returns a wrapper object with `type`, `description`, and `template` fields so callers see what the widget does alongside the ready-to-edit JSON skeleton. - WIDGET_TYPES changed from &[&str] to &[(&str, &str)] (type, description) - widget_types() emits [{type, description}] objects instead of plain strings - widget_schema() wraps the template in {type, description, template} Environment: Datadog workspace Co-Authored-By: Claude Sonnet 4.6 --- src/commands/dashboards.rs | 112 +++++++++++++++++++++---------------- 1 file changed, 64 insertions(+), 48 deletions(-) diff --git a/src/commands/dashboards.rs b/src/commands/dashboards.rs index 6846e1a0..f1fef56d 100644 --- a/src/commands/dashboards.rs +++ b/src/commands/dashboards.rs @@ -282,7 +282,10 @@ pub async fn widget_remove( } pub fn widget_types(cfg: &Config) -> Result<()> { - let types: Vec<&str> = WIDGET_TYPES.to_vec(); + let types: Vec = WIDGET_TYPES + .iter() + .map(|(t, d)| serde_json::json!({"type": t, "description": d})) + .collect(); formatter::format_and_print( &types, &cfg.output_format, @@ -297,57 +300,70 @@ pub fn widget_types(cfg: &Config) -> Result<()> { } pub fn widget_schema(cfg: &Config, type_str: &str) -> Result<()> { - let tmpl = widget_template(type_str).ok_or_else(|| { - anyhow::anyhow!( - "unknown widget type {type_str:?}; \ - run `pup dashboards widgets types` to see supported types" - ) - })?; - formatter::output(cfg, &tmpl) + let description = WIDGET_TYPES + .iter() + .find(|(t, _)| *t == type_str) + .map(|(_, d)| *d) + .ok_or_else(|| { + anyhow::anyhow!( + "unknown widget type {type_str:?}; \ + run `pup dashboards widgets types` to see supported types" + ) + })?; + let tmpl = widget_template(type_str).expect("type is in WIDGET_TYPES so template exists"); + formatter::output( + cfg, + &serde_json::json!({ + "type": type_str, + "description": description, + "template": tmpl, + }), + ) } // ---- Widget type registry and skeleton templates ---- -/// All widget `type` strings recognised by the Datadog Dashboard API (V1). +/// All widget `type` strings recognised by the Datadog Dashboard API (V1), +/// paired with their descriptions from the OpenAPI spec. /// /// Kept in sync with the `WidgetDefinition` enum in the /// `datadog-api-client` SDK. Unknown types round-trip via /// `WidgetDefinition::UnparsedObject` and are rejected by `validate_widget`. -const WIDGET_TYPES: &[&str] = &[ - "alert_graph", - "alert_value", - "change", - "check_status", - "distribution", - "event_stream", - "event_timeline", - "free_text", - "funnel", - "geomap", - "group", - "heatmap", - "hostmap", - "iframe", - "image", - "list_stream", - "log_stream", - "manage_status", - "note", - "powerpack", - "query_value", - "query_table", - "run_workflow", - "scatterplot", - "servicemap", - "slo", - "slo_list", - "sunburst", - "timeseries", - "toplist", - "topology_map", - "trace_service", - "treemap", - "wildcard", +const WIDGET_TYPES: &[(&str, &str)] = &[ + ("alert_graph", "Alert graphs are timeseries graphs showing the current status of any monitor defined on your system."), + ("alert_value", "Alert values are query values showing the current value of the metric in any monitor defined on your system."), + ("change", "The Change graph shows you the change in a value over the time period chosen."), + ("check_status", "Check status shows the current status or number of results for any check performed."), + ("distribution", "The Distribution visualization is another way of showing metrics aggregated across one or several tags, such as hosts."), + ("event_stream", "The event stream is a widget version of the stream of events on the Event Stream view. Only available on FREE layout dashboards."), + ("event_timeline", "The event timeline is a widget version of the timeline that appears at the top of the Event Stream view. Only available on FREE layout dashboards."), + ("free_text", "Free text is a widget that allows you to add headings to your dashboard. Commonly used to state the overall purpose of the dashboard."), + ("funnel", "The funnel visualization displays a funnel of user sessions that maps a sequence of view navigation and user interaction in your application."), + ("geomap", "This visualization displays a series of values by country on a world map."), + ("group", "The group widget allows you to keep similar graphs together on your dashboard. Each group has a custom header, can hold one to many graphs, and is collapsible."), + ("heatmap", "The heat map visualization shows metrics aggregated across many tags, such as hosts. The more hosts that have a particular value, the darker that square is."), + ("hostmap", "The host map widget graphs any metric across your hosts using the same visualization available from the main Host Map page."), + ("iframe", "The iframe widget allows you to embed a portion of any other web page on your dashboard."), + ("image", "The image widget allows you to embed an image on your dashboard. An image can be a PNG, JPG, or animated GIF."), + ("list_stream", "The list stream visualization displays a table of recent events in your application that match a search criteria using user-defined columns."), + ("log_stream", "The Log Stream displays a log flow matching the defined query."), + ("manage_status", "The monitor summary widget displays a summary view of all your Datadog monitors, or a subset based on a query."), + ("note", "The notes and links widget is similar to free text widget, but allows for more formatting options."), + ("powerpack", "The powerpack widget allows you to keep similar graphs together on your dashboard. Each group has a custom header, can hold one to many graphs, and is collapsible."), + ("query_value", "Query values display the current value of a given metric, APM, or log query."), + ("query_table", "The table visualization is available on dashboards. It displays columns of metrics grouped by tag key."), + ("run_workflow", "The run workflow widget allows you to run a workflow from a dashboard."), + ("scatterplot", "The scatter plot visualization allows you to graph a chosen scope over two different metrics with their respective aggregation."), + ("servicemap", "This widget displays a map of a service to all of the services that call it, and all of the services that it calls."), + ("slo", "Use the SLO and uptime widget to track your SLOs (Service Level Objectives) and uptime on dashboards."), + ("slo_list", "Use the SLO List widget to track your SLOs (Service Level Objectives) on dashboards."), + ("sunburst", "Sunbursts are spot on to highlight how groups contribute to the total of a query."), + ("timeseries", "The timeseries visualization allows you to display the evolution of one or more metrics, log events, or Indexed Spans over time."), + ("toplist", "The top list visualization enables you to display a list of Tag value like hostname or service with the most or least of any metric value, such as highest consumers of CPU, hosts with the least disk space, etc."), + ("topology_map", "This widget displays a topology of nodes and edges for different data sources. It replaces the service map widget."), + ("trace_service", "The service summary displays the graphs of a chosen service in your dashboard."), + ("treemap", "The treemap visualization enables you to display hierarchical and nested data. It is well suited for queries that describe part-whole relationships, such as resource usage by availability zone, data center, or team."), + ("wildcard", "Custom visualization widget using Vega or Vega-Lite specifications. Combines standard Datadog data requests with a Vega or Vega-Lite JSON specification for flexible, custom visualizations."), ]; /// Return a ready-to-edit skeleton JSON for the given widget type. @@ -413,7 +429,7 @@ fn widget_template(t: &str) -> Option { "time_windows": ["7d"] } }), - other if WIDGET_TYPES.contains(&other) => serde_json::json!({ + other if WIDGET_TYPES.iter().any(|(t, _)| *t == other) => serde_json::json!({ "definition": {"type": other} }), _ => return None, @@ -533,9 +549,9 @@ mod tests { "expected at least 20 widget types, got {}", super::WIDGET_TYPES.len() ); - assert!(super::WIDGET_TYPES.contains(&"timeseries")); - assert!(super::WIDGET_TYPES.contains(&"query_value")); - assert!(super::WIDGET_TYPES.contains(&"note")); + assert!(super::WIDGET_TYPES.iter().any(|(t, _)| *t == "timeseries")); + assert!(super::WIDGET_TYPES.iter().any(|(t, _)| *t == "query_value")); + assert!(super::WIDGET_TYPES.iter().any(|(t, _)| *t == "note")); } #[test] From 3f1c8e7c7675d5c3365486aa3173c441e32d0256 Mon Sep 17 00:00:00 2001 From: "cody.lee" Date: Mon, 15 Jun 2026 20:10:28 +0000 Subject: [PATCH 4/5] feat(dashboards): add full JSON Schema to widget schema command `pup dashboards widgets schema ` now outputs a `schema` field alongside the existing `template`. The schema is a JSON Schema document derived from the Datadog OpenAPI spec (v1/dashboard.yaml) covering all 34 widget types. Each type documents its required fields, optional properties, types, enum values, and descriptions for every field including nested objects (requests, style, view, layout, etc.). Output shape: {type, description, schema: {type, required, properties: {id, layout: {x,y,width,height}, definition: }}, template: } Environment: Datadog workspace Co-Authored-By: Claude Sonnet 4.6 --- src/commands/dashboards.rs | 666 ++++++++++++++++++++++++++++++++++++- 1 file changed, 664 insertions(+), 2 deletions(-) diff --git a/src/commands/dashboards.rs b/src/commands/dashboards.rs index f1fef56d..46762757 100644 --- a/src/commands/dashboards.rs +++ b/src/commands/dashboards.rs @@ -310,13 +310,15 @@ pub fn widget_schema(cfg: &Config, type_str: &str) -> Result<()> { run `pup dashboards widgets types` to see supported types" ) })?; - let tmpl = widget_template(type_str).expect("type is in WIDGET_TYPES so template exists"); + let schema = widget_json_schema(type_str).expect("type is in WIDGET_TYPES so schema exists"); + let template = widget_template(type_str).expect("type is in WIDGET_TYPES so template exists"); formatter::output( cfg, &serde_json::json!({ "type": type_str, "description": description, - "template": tmpl, + "schema": schema, + "template": template, }), ) } @@ -437,6 +439,666 @@ fn widget_template(t: &str) -> Option { Some(v) } +/// Return a JSON Schema describing the full widget object for the given type. +/// +/// Each widget type gets its own detailed schema derived from the Datadog +/// OpenAPI spec (v1/dashboard.yaml). The top-level object mirrors the SDK +/// `Widget` struct: `id` (assigned by API), `layout` (required for free +/// dashboards), and `definition` (the type-specific payload). +fn widget_json_schema(t: &str) -> Option { + let definition = match t { + "alert_graph" => serde_json::json!({ + "type": "object", + "required": ["type", "alert_id", "viz_type"], + "properties": { + "type": {"type": "string", "enum": ["alert_graph"], "description": "Type of the alert graph widget."}, + "alert_id": {"type": "string", "description": "ID of the alert to use in the widget."}, + "viz_type": {"type": "string", "enum": ["timeseries", "toplist"], "description": "Visualization type for the widget."}, + "title": {"type": "string", "description": "The title of the widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."}, + "time": {"type": "object", "description": "Time setting for the widget.", "properties": {"live_span": {"type": "string", "description": "Timeframe, e.g. \"1h\", \"4h\", \"1d\"."}}} + } + }), + "alert_value" => serde_json::json!({ + "type": "object", + "required": ["type", "alert_id"], + "properties": { + "type": {"type": "string", "enum": ["alert_value"], "description": "Type of the alert value widget."}, + "alert_id": {"type": "string", "description": "ID of the alert to use in the widget."}, + "precision": {"type": "integer", "description": "Number of decimal places to show. If not defined, uses the raw value."}, + "unit": {"type": "string", "description": "Unit to display with the value."}, + "text_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the value."}, + "title_size": {"type": "string", "description": "Size of the value in the widget."}, + "title": {"type": "string", "description": "Title of the widget."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."} + } + }), + "change" => serde_json::json!({ + "type": "object", + "required": ["type", "requests"], + "properties": { + "type": {"type": "string", "enum": ["change"], "description": "Type of the change widget."}, + "requests": {"type": "array", "description": "Array of one request object to display in the widget.", "items": {"type": "object", "properties": {"q": {"type": "string", "description": "Metric query."}}}}, + "custom_links": {"type": "array", "description": "List of custom links.", "items": {"type": "object"}}, + "title": {"type": "string", "description": "Title of the widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."}, + "time": {"type": "object", "description": "Time setting for the widget.", "properties": {"live_span": {"type": "string", "description": "Timeframe, e.g. \"1h\", \"4h\", \"1d\"."}}} + } + }), + "check_status" => serde_json::json!({ + "type": "object", + "required": ["type", "check", "grouping"], + "properties": { + "type": {"type": "string", "enum": ["check_status"], "description": "Type of the check status widget."}, + "check": {"type": "string", "description": "Name of the check to use in the widget."}, + "grouping": {"type": "string", "enum": ["check", "cluster"], "description": "Grouping method: \"check\" shows a single check, \"cluster\" shows a group."}, + "group": {"type": "string", "description": "Group reporting a single check."}, + "tags": {"type": "array", "description": "List of tags used to filter the groups reporting a cluster check.", "items": {"type": "string"}}, + "group_by": {"type": "array", "description": "List of tag prefixes to group by for a cluster check.", "items": {"type": "string"}}, + "title": {"type": "string", "description": "Title of the widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."}, + "time": {"type": "object", "description": "Time setting for the widget.", "properties": {"live_span": {"type": "string", "description": "Timeframe, e.g. \"1h\", \"4h\", \"1d\"."}}} + } + }), + "distribution" => serde_json::json!({ + "type": "object", + "required": ["type", "requests"], + "properties": { + "type": {"type": "string", "enum": ["distribution"], "description": "Type of the distribution widget."}, + "requests": {"type": "array", "description": "Array of one request object to display in the widget.", "items": {"type": "object", "properties": {"q": {"type": "string", "description": "Metric query."}}}}, + "markers": {"type": "array", "description": "List of markers to display on the graph.", "items": {"type": "object"}}, + "xaxis": {"type": "object", "description": "X-axis configuration."}, + "yaxis": {"type": "object", "description": "Y-axis configuration."}, + "custom_links": {"type": "array", "description": "List of custom links.", "items": {"type": "object"}}, + "title": {"type": "string", "description": "Title of the widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."}, + "time": {"type": "object", "description": "Time setting for the widget.", "properties": {"live_span": {"type": "string", "description": "Timeframe, e.g. \"1h\", \"4h\", \"1d\"."}}} + } + }), + "event_stream" => serde_json::json!({ + "type": "object", + "required": ["type", "query"], + "properties": { + "type": {"type": "string", "enum": ["event_stream"], "description": "Type of the event stream widget."}, + "query": {"type": "string", "description": "Query to filter the event stream with."}, + "event_size": {"type": "string", "enum": ["s", "l"], "description": "Size of each event: \"s\" (small) or \"l\" (large)."}, + "tags_execution": {"type": "string", "description": "Execution method for multi-value filters: \"and\" or \"or\"."}, + "title": {"type": "string", "description": "Title of the widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."}, + "time": {"type": "object", "description": "Time setting for the widget.", "properties": {"live_span": {"type": "string", "description": "Timeframe, e.g. \"1h\", \"4h\", \"1d\"."}}} + } + }), + "event_timeline" => serde_json::json!({ + "type": "object", + "required": ["type", "query"], + "properties": { + "type": {"type": "string", "enum": ["event_timeline"], "description": "Type of the event timeline widget."}, + "query": {"type": "string", "description": "Query to filter the event timeline with."}, + "tags_execution": {"type": "string", "description": "Execution method for multi-value filters: \"and\" or \"or\"."}, + "title": {"type": "string", "description": "Title of the widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."}, + "time": {"type": "object", "description": "Time setting for the widget.", "properties": {"live_span": {"type": "string", "description": "Timeframe, e.g. \"1h\", \"4h\", \"1d\"."}}} + } + }), + "free_text" => serde_json::json!({ + "type": "object", + "required": ["type", "text"], + "properties": { + "type": {"type": "string", "enum": ["free_text"], "description": "Type of the free text widget."}, + "text": {"type": "string", "description": "Text to display."}, + "color": {"type": "string", "description": "Color of the text."}, + "font_size": {"type": "string", "description": "Size of the text."}, + "text_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the text."}, + "background_color": {"type": "string", "description": "Background color of the widget."} + } + }), + "funnel" => serde_json::json!({ + "type": "object", + "required": ["type", "requests"], + "properties": { + "type": {"type": "string", "enum": ["funnel"], "description": "Type of the funnel widget."}, + "requests": { + "type": "array", + "description": "Request payload. Exactly one item.", + "minItems": 1, + "maxItems": 1, + "items": {"type": "object", "properties": { + "request_type": {"type": "string", "enum": ["funnel"], "description": "Request type."}, + "query": {"type": "object", "description": "Funnel query.", "properties": { + "data_source": {"type": "string", "enum": ["rum"], "description": "Data source."}, + "query_string": {"type": "string", "description": "Query filter string."}, + "steps": {"type": "array", "description": "Funnel steps.", "items": {"type": "object", "properties": {"facet": {"type": "string"}, "value": {"type": "string"}}}} + }} + }} + }, + "title": {"type": "string", "description": "The title of the widget."}, + "title_size": {"type": "string", "description": "The size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."}, + "time": {"type": "object", "description": "Time setting for the widget.", "properties": {"live_span": {"type": "string", "description": "Timeframe, e.g. \"1h\", \"4h\", \"1d\"."}}} + } + }), + "geomap" => serde_json::json!({ + "type": "object", + "required": ["type", "requests", "style", "view"], + "properties": { + "type": {"type": "string", "enum": ["geomap"], "description": "Type of the geomap widget."}, + "requests": {"type": "array", "description": "Array of request objects. May include a region layer and/or points layer request.", "items": {"type": "object"}}, + "style": {"type": "object", "description": "Widget style.", "properties": { + "palette": {"type": "string", "description": "Color palette to apply to the widget."}, + "palette_flip": {"type": "boolean", "description": "Boolean indicating whether to flip the palette tones."} + }}, + "view": {"type": "object", "description": "Widget view.", "required": ["focus"], "properties": { + "focus": {"type": "string", "description": "Two-letter ISO 3166 country code or \"WORLD\"."} + }}, + "custom_links": {"type": "array", "description": "List of custom links.", "items": {"type": "object"}}, + "title": {"type": "string", "description": "The title of the widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."}, + "time": {"type": "object", "description": "Time setting for the widget.", "properties": {"live_span": {"type": "string", "description": "Timeframe, e.g. \"1h\", \"4h\", \"1d\"."}}} + } + }), + "group" => serde_json::json!({ + "type": "object", + "required": ["type", "layout_type", "widgets"], + "properties": { + "type": {"type": "string", "enum": ["group"], "description": "Type of the group widget."}, + "layout_type": {"type": "string", "enum": ["ordered", "free"], "description": "Layout type of the group widget."}, + "widgets": {"type": "array", "description": "List of widgets in the group.", "items": {"type": "object"}}, + "title": {"type": "string", "description": "Title of the group widget."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "background_color": {"type": "string", "description": "Background color of the group widget header."}, + "banner_img": {"type": "string", "description": "URL of image to display as a banner for the group."}, + "show_title": {"type": "boolean", "default": true, "description": "Whether to show the title or not."} + } + }), + "heatmap" => serde_json::json!({ + "type": "object", + "required": ["type", "requests"], + "properties": { + "type": {"type": "string", "enum": ["heatmap"], "description": "Type of the heat map widget."}, + "requests": {"type": "array", "description": "List of heat map widget requests.", "items": {"type": "object", "properties": {"q": {"type": "string", "description": "Metric query."}}}}, + "markers": {"type": "array", "description": "List of markers to display on the graph.", "items": {"type": "object"}}, + "xaxis": {"type": "object", "description": "X-axis configuration."}, + "yaxis": {"type": "object", "description": "Y-axis configuration."}, + "custom_links": {"type": "array", "description": "List of custom links.", "items": {"type": "object"}}, + "show_legend": {"type": "boolean", "description": "Whether or not to display the legend on this widget."}, + "legend_size": {"type": "string", "enum": ["0", "2", "4", "8", "16", "auto"], "description": "Legend size."}, + "title": {"type": "string", "description": "Title of the widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."}, + "time": {"type": "object", "description": "Time setting for the widget.", "properties": {"live_span": {"type": "string", "description": "Timeframe, e.g. \"1h\", \"4h\", \"1d\"."}}} + } + }), + "hostmap" => serde_json::json!({ + "type": "object", + "required": ["type", "requests"], + "properties": { + "type": {"type": "string", "enum": ["hostmap"], "description": "Type of the host map widget."}, + "requests": {"type": "object", "description": "Host map request configuration.", "properties": { + "fill": {"type": "object", "description": "Request for the fill colour of each node.", "properties": {"q": {"type": "string", "description": "Metric query for the fill."}}}, + "size": {"type": "object", "description": "Request for the size of each node.", "properties": {"q": {"type": "string", "description": "Metric query for the size."}}} + }}, + "node_type": {"type": "string", "enum": ["host", "container"], "description": "Type of nodes to display: \"host\" or \"container\"."}, + "no_metric_hosts": {"type": "boolean", "description": "Whether to show hosts with no metrics."}, + "no_group_hosts": {"type": "boolean", "description": "Whether to show hosts that don't fit in a group."}, + "group": {"type": "array", "description": "List of tag prefixes to group by.", "items": {"type": "string"}}, + "scope": {"type": "array", "description": "List of tags used to filter the map.", "items": {"type": "string"}}, + "style": {"type": "object", "description": "Widget style.", "properties": { + "palette": {"type": "string", "description": "Color palette."}, + "palette_flip": {"type": "boolean", "description": "Whether to flip the palette."}, + "fill_min": {"type": "string", "description": "Minimum value for the fill colour scale."}, + "fill_max": {"type": "string", "description": "Maximum value for the fill colour scale."} + }}, + "custom_links": {"type": "array", "description": "List of custom links.", "items": {"type": "object"}}, + "title": {"type": "string", "description": "Title of the widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."}, + "notes": {"type": "string", "description": "Notes displayed on the title."} + } + }), + "iframe" => serde_json::json!({ + "type": "object", + "required": ["type", "url"], + "properties": { + "type": {"type": "string", "enum": ["iframe"], "description": "Type of the iframe widget."}, + "url": {"type": "string", "description": "URL of the iframe."} + } + }), + "image" => serde_json::json!({ + "type": "object", + "required": ["type", "url"], + "properties": { + "type": {"type": "string", "enum": ["image"], "description": "Type of the image widget."}, + "url": {"type": "string", "description": "URL of the image."}, + "url_dark_theme": {"type": "string", "description": "URL of the image to display in dark mode."}, + "sizing": {"type": "string", "enum": ["fill", "contain", "cover", "none", "scale-down", "zoom"], "description": "How the image is sized within the widget."}, + "margin": {"type": "string", "enum": ["sm", "md", "lg"], "description": "Margin around the image."}, + "has_background": {"type": "boolean", "default": true, "description": "Whether to display a background or not."}, + "has_border": {"type": "boolean", "default": true, "description": "Whether to display a border or not."}, + "horizontal_align": {"type": "string", "enum": ["center", "left", "right"], "description": "Horizontal alignment of the image."}, + "vertical_align": {"type": "string", "enum": ["top", "center", "bottom"], "description": "Vertical alignment of the image."} + } + }), + "list_stream" => serde_json::json!({ + "type": "object", + "required": ["type", "requests"], + "properties": { + "type": {"type": "string", "enum": ["list_stream"], "description": "Type of the list stream widget."}, + "requests": { + "type": "array", + "description": "Request payload. Exactly one item.", + "minItems": 1, + "maxItems": 1, + "items": {"type": "object", "properties": { + "response_format": {"type": "string", "enum": ["event_list"], "description": "Response format."}, + "columns": {"type": "array", "description": "Columns to display.", "items": {"type": "object", "properties": { + "field": {"type": "string", "description": "Column field name."}, + "width": {"type": "string", "enum": ["auto", "compact", "full"], "description": "Column width."} + }}}, + "query": {"type": "object", "description": "Query definition.", "properties": { + "data_source": {"type": "string", "description": "Data source, e.g. \"apm_issue_stream\", \"logs_stream\"."}, + "query_string": {"type": "string", "description": "Query filter string."} + }} + }} + }, + "title": {"type": "string", "description": "Title of the widget."}, + "show_legend": {"type": "boolean", "description": "Whether or not to display the legend on this widget."}, + "legend_size": {"type": "string", "enum": ["0", "2", "4", "8", "16", "auto"], "description": "Legend size."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."}, + "time": {"type": "object", "description": "Time setting for the widget.", "properties": {"live_span": {"type": "string", "description": "Timeframe, e.g. \"1h\", \"4h\", \"1d\"."}}} + } + }), + "log_stream" => serde_json::json!({ + "type": "object", + "required": ["type"], + "properties": { + "type": {"type": "string", "enum": ["log_stream"], "description": "Type of the log stream widget."}, + "indexes": {"type": "array", "description": "Array of index names to query. Use [] to query all indexes at once.", "items": {"type": "string"}}, + "query": {"type": "string", "description": "Query to filter the log stream with."}, + "columns": {"type": "array", "description": "Which columns to display on the widget.", "items": {"type": "string"}}, + "show_date_column": {"type": "boolean", "description": "Whether to show the date column or not."}, + "show_message_column": {"type": "boolean", "description": "Whether to show the message column or not."}, + "message_display": {"type": "string", "enum": ["inline", "expanded-md", "expanded-lg"], "description": "Amount of log lines to display."}, + "sort": {"type": "object", "description": "Sort configuration.", "properties": { + "column": {"type": "string", "description": "Column to sort by."}, + "order": {"type": "string", "enum": ["asc", "desc"], "description": "Sort order."} + }}, + "title": {"type": "string", "description": "Title of the widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."}, + "time": {"type": "object", "description": "Time setting for the widget.", "properties": {"live_span": {"type": "string", "description": "Timeframe, e.g. \"1h\", \"4h\", \"1d\"."}}} + } + }), + "manage_status" => serde_json::json!({ + "type": "object", + "required": ["type", "query"], + "properties": { + "type": {"type": "string", "enum": ["manage_status"], "description": "Type of the monitor summary widget."}, + "query": {"type": "string", "description": "Query to filter the monitors with."}, + "sort": {"type": "string", "description": "Sort order for the monitors.", "enum": ["name", "group", "status", "tags", "triggered", "group,asc", "name,asc", "status,asc", "tags,asc", "triggered,asc", "group,desc", "name,desc", "status,desc", "tags,desc", "triggered,desc"]}, + "summary_type": {"type": "string", "enum": ["monitors", "groups", "combined"], "description": "Summary type."}, + "display_format": {"type": "string", "enum": ["counts", "countsAndList", "list"], "description": "Display format."}, + "color_preference": {"type": "string", "enum": ["background", "text"], "description": "Whether to colorize text or background."}, + "hide_zero_counts": {"type": "boolean", "description": "Whether to show counts of 0 or not."}, + "show_last_triggered": {"type": "boolean", "description": "Whether to show the elapsed time since the monitor/group triggered."}, + "show_priority": {"type": "boolean", "default": false, "description": "Whether to show the priorities column."}, + "title": {"type": "string", "description": "Title of the widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."} + } + }), + "note" => serde_json::json!({ + "type": "object", + "required": ["type", "content"], + "properties": { + "type": {"type": "string", "enum": ["note"], "description": "Type of the note widget."}, + "content": {"type": "string", "description": "Content of the note."}, + "background_color": {"type": "string", "description": "Background color of the note."}, + "font_size": {"type": "string", "description": "Size of the text."}, + "text_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the text."}, + "vertical_align": {"type": "string", "enum": ["top", "center", "bottom"], "description": "Vertical alignment of the text."}, + "has_padding": {"type": "boolean", "default": true, "description": "Whether to add padding or not."}, + "show_tick": {"type": "boolean", "description": "Whether to show a tick or not."}, + "tick_pos": {"type": "string", "description": "Where to position the tick on an edge, as a percentage (e.g. \"50%\")."}, + "tick_edge": {"type": "string", "enum": ["bottom", "left", "right", "top"], "description": "Edge where the tick should be placed."} + } + }), + "powerpack" => serde_json::json!({ + "type": "object", + "required": ["type", "powerpack_id"], + "properties": { + "type": {"type": "string", "enum": ["powerpack"], "description": "Type of the powerpack widget."}, + "powerpack_id": {"type": "string", "description": "UUID of the associated powerpack."}, + "title": {"type": "string", "description": "Title of the widget."}, + "background_color": {"type": "string", "description": "Background color of the powerpack title."}, + "banner_img": {"type": "string", "description": "URL of image to display as a banner for the powerpack."}, + "template_variables": {"type": "object", "description": "Template variable values to apply to the powerpack."}, + "show_title": {"type": "boolean", "default": true, "description": "Whether to show the title or not."} + } + }), + "query_value" => serde_json::json!({ + "type": "object", + "required": ["type", "requests"], + "properties": { + "type": {"type": "string", "enum": ["query_value"], "description": "Type of the query value widget."}, + "requests": {"type": "array", "description": "Widget requests.", "items": {"type": "object", "properties": { + "q": {"type": "string", "description": "Metric query."}, + "aggregator": {"type": "string", "enum": ["avg", "last", "max", "min", "sum", "percentile"], "description": "Aggregation method."} + }}}, + "autoscale": {"type": "boolean", "description": "Whether to use auto-scaling or not."}, + "custom_unit": {"type": "string", "description": "Display a unit of your choice on the widget."}, + "precision": {"type": "integer", "description": "Number of decimals to show. If not defined, the widget uses the raw value."}, + "text_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the value."}, + "custom_links": {"type": "array", "description": "List of custom links.", "items": {"type": "object"}}, + "title": {"type": "string", "description": "Title of the widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."}, + "time": {"type": "object", "description": "Time setting for the widget.", "properties": {"live_span": {"type": "string", "description": "Timeframe, e.g. \"1h\", \"4h\", \"1d\"."}}} + } + }), + "query_table" => serde_json::json!({ + "type": "object", + "required": ["type", "requests"], + "properties": { + "type": {"type": "string", "enum": ["query_table"], "description": "Type of the table widget."}, + "requests": {"type": "array", "description": "Widget requests.", "items": {"type": "object", "properties": { + "q": {"type": "string", "description": "Metric query."}, + "aggregator": {"type": "string", "enum": ["avg", "last", "max", "min", "sum", "percentile"], "description": "Aggregation method."}, + "alias": {"type": "string", "description": "Column alias to display."} + }}}, + "has_search_bar": {"type": "string", "enum": ["always", "never", "auto"], "description": "Controls the display of the search bar."}, + "custom_links": {"type": "array", "description": "List of custom links.", "items": {"type": "object"}}, + "title": {"type": "string", "description": "Title of the widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."}, + "time": {"type": "object", "description": "Time setting for the widget.", "properties": {"live_span": {"type": "string", "description": "Timeframe, e.g. \"1h\", \"4h\", \"1d\"."}}} + } + }), + "run_workflow" => serde_json::json!({ + "type": "object", + "required": ["type", "workflow_id"], + "properties": { + "type": {"type": "string", "enum": ["run_workflow"], "description": "Type of the run workflow widget."}, + "workflow_id": {"type": "string", "description": "Workflow ID to execute."}, + "inputs": {"type": "array", "description": "Array of workflow inputs to map to dashboard template variables.", "items": {"type": "object", "properties": { + "name": {"type": "string", "description": "Name of the workflow input parameter."}, + "value": {"type": "string", "description": "Dashboard template variable name to use as the input value."} + }}}, + "custom_links": {"type": "array", "description": "List of custom links.", "items": {"type": "object"}}, + "title": {"type": "string", "description": "Title of your widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."}, + "time": {"type": "object", "description": "Time setting for the widget.", "properties": {"live_span": {"type": "string", "description": "Timeframe, e.g. \"1h\", \"4h\", \"1d\"."}}} + } + }), + "scatterplot" => serde_json::json!({ + "type": "object", + "required": ["type", "requests"], + "properties": { + "type": {"type": "string", "enum": ["scatterplot"], "description": "Type of the scatter plot widget."}, + "requests": {"type": "object", "description": "Scatter plot request configuration.", "properties": { + "x": {"type": "object", "description": "Request for the X-axis metric.", "properties": { + "q": {"type": "string", "description": "Metric query."}, + "aggregator": {"type": "string", "enum": ["avg", "last", "max", "min", "sum"], "description": "Aggregation method."} + }}, + "y": {"type": "object", "description": "Request for the Y-axis metric.", "properties": { + "q": {"type": "string", "description": "Metric query."}, + "aggregator": {"type": "string", "enum": ["avg", "last", "max", "min", "sum"], "description": "Aggregation method."} + }} + }}, + "xaxis": {"type": "object", "description": "X-axis configuration."}, + "yaxis": {"type": "object", "description": "Y-axis configuration."}, + "color_by_groups": {"type": "array", "description": "List of groups used for colors.", "items": {"type": "string"}}, + "custom_links": {"type": "array", "description": "List of custom links.", "items": {"type": "object"}}, + "title": {"type": "string", "description": "Title of the widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."}, + "time": {"type": "object", "description": "Time setting for the widget.", "properties": {"live_span": {"type": "string", "description": "Timeframe, e.g. \"1h\", \"4h\", \"1d\"."}}} + } + }), + "servicemap" => serde_json::json!({ + "type": "object", + "required": ["type", "filters", "service"], + "properties": { + "type": {"type": "string", "enum": ["servicemap"], "description": "Type of the service map widget."}, + "filters": {"type": "array", "description": "Your environment and primary tag (or * if enabled for your account).", "items": {"type": "string"}}, + "service": {"type": "string", "description": "The ID of the service you want to map."}, + "custom_links": {"type": "array", "description": "List of custom links.", "items": {"type": "object"}}, + "title": {"type": "string", "description": "The title of the widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."} + } + }), + "slo" => serde_json::json!({ + "type": "object", + "required": ["type", "view_type"], + "properties": { + "type": {"type": "string", "enum": ["slo"], "description": "Type of the SLO widget."}, + "view_type": {"type": "string", "default": "detail", "description": "Type of view displayed by the widget. Use \"detail\"."}, + "slo_id": {"type": "string", "description": "ID of the SLO to display."}, + "show_error_budget": {"type": "boolean", "description": "Whether to show the error budget."}, + "view_mode": {"type": "string", "enum": ["overall", "component", "both"], "description": "View mode."}, + "global_time_target": {"type": "string", "description": "Global time target."}, + "time_windows": {"type": "array", "description": "Time windows to monitor.", "items": {"type": "string", "enum": ["7d", "30d", "90d", "week_to_date", "previous_week", "month_to_date", "previous_month", "global_time"]}}, + "additional_query_filters": {"type": "string", "description": "Additional filters applied to the SLO query."}, + "title": {"type": "string", "description": "Title of the widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."} + } + }), + "slo_list" => serde_json::json!({ + "type": "object", + "required": ["type", "requests"], + "properties": { + "type": {"type": "string", "enum": ["slo_list"], "description": "Type of the SLO list widget."}, + "requests": {"type": "array", "description": "Array of one request object to display in the widget.", "items": {"type": "object", "properties": { + "request_type": {"type": "string", "enum": ["slo_list"], "description": "Request type."}, + "query": {"type": "object", "description": "SLO list query.", "properties": { + "q": {"type": "string", "description": "Query string to filter SLOs."}, + "limit": {"type": "integer", "description": "Maximum number of results to display."} + }} + }}}, + "title": {"type": "string", "description": "Title of the widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."} + } + }), + "sunburst" => serde_json::json!({ + "type": "object", + "required": ["type", "requests"], + "properties": { + "type": {"type": "string", "enum": ["sunburst"], "description": "Type of the sunburst widget."}, + "requests": {"type": "array", "description": "List of sunburst widget requests.", "items": {"type": "object", "properties": {"q": {"type": "string", "description": "Metric query."}}}}, + "hide_total": {"type": "boolean", "description": "Whether to hide the total value in this widget."}, + "legend": {"type": "object", "description": "Legend configuration.", "properties": { + "type": {"type": "string", "enum": ["inline", "table", "none"], "description": "Legend type."} + }}, + "custom_links": {"type": "array", "description": "List of custom links.", "items": {"type": "object"}}, + "title": {"type": "string", "description": "Title of your widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."}, + "time": {"type": "object", "description": "Time setting for the widget.", "properties": {"live_span": {"type": "string", "description": "Timeframe, e.g. \"1h\", \"4h\", \"1d\"."}}} + } + }), + "timeseries" => serde_json::json!({ + "type": "object", + "required": ["type", "requests"], + "properties": { + "type": {"type": "string", "enum": ["timeseries"], "description": "Type of the timeseries widget."}, + "requests": {"type": "array", "description": "List of timeseries widget requests.", "items": {"type": "object", "properties": { + "q": {"type": "string", "description": "Metric query string."}, + "display_type": {"type": "string", "enum": ["area", "bars", "line", "overlay"], "description": "Type of display used for the request."} + }}}, + "yaxis": {"type": "object", "description": "Y-axis configuration."}, + "right_yaxis": {"type": "object", "description": "Right Y-axis configuration."}, + "markers": {"type": "array", "description": "List of markers to display on the graph.", "items": {"type": "object"}}, + "custom_links": {"type": "array", "description": "List of custom links.", "items": {"type": "object"}}, + "show_legend": {"type": "boolean", "description": "(screenboard only) Show the legend for this widget."}, + "legend_size": {"type": "string", "enum": ["0", "2", "4", "8", "16", "auto"], "description": "Legend size."}, + "legend_layout": {"type": "string", "enum": ["auto", "horizontal", "vertical"], "description": "Layout of the legend."}, + "legend_columns": {"type": "array", "description": "Columns to display in the legend.", "items": {"type": "string", "enum": ["value", "avg", "sum", "min", "max"]}}, + "title": {"type": "string", "description": "Title of your widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."}, + "time": {"type": "object", "description": "Time setting for the widget.", "properties": {"live_span": {"type": "string", "description": "Timeframe, e.g. \"1h\", \"4h\", \"1d\"."}}} + } + }), + "toplist" => serde_json::json!({ + "type": "object", + "required": ["type", "requests"], + "properties": { + "type": {"type": "string", "enum": ["toplist"], "description": "Type of the top list widget."}, + "requests": {"type": "array", "description": "List of top list widget requests.", "items": {"type": "object", "properties": {"q": {"type": "string", "description": "Metric query."}}}}, + "style": {"type": "object", "description": "Style configuration.", "properties": { + "palette": {"type": "string", "description": "Color palette."}, + "palette_flip": {"type": "boolean", "description": "Whether to flip the palette."} + }}, + "custom_links": {"type": "array", "description": "List of custom links.", "items": {"type": "object"}}, + "title": {"type": "string", "description": "Title of your widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."}, + "time": {"type": "object", "description": "Time setting for the widget.", "properties": {"live_span": {"type": "string", "description": "Timeframe, e.g. \"1h\", \"4h\", \"1d\"."}}} + } + }), + "topology_map" => serde_json::json!({ + "type": "object", + "required": ["type", "requests"], + "properties": { + "type": {"type": "string", "enum": ["topology_map"], "description": "Type of the topology map widget."}, + "requests": {"type": "array", "description": "One or more topology requests.", "items": {"type": "object", "properties": { + "request_type": {"type": "string", "enum": ["topology"], "description": "Request type."}, + "query": {"type": "object", "description": "Topology query.", "properties": { + "service": {"type": "string", "description": "Service name."}, + "env": {"type": "string", "description": "Environment."}, + "data_source": {"type": "string", "enum": ["service_map", "data_streams"], "description": "Data source."} + }} + }}}, + "title": {"type": "string", "description": "Title of your widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."}, + "custom_links": {"type": "array", "description": "List of custom links.", "items": {"type": "object"}} + } + }), + "trace_service" => serde_json::json!({ + "type": "object", + "required": ["type", "env", "service", "span_name"], + "properties": { + "type": {"type": "string", "enum": ["trace_service"], "description": "Type of the service summary widget."}, + "env": {"type": "string", "description": "APM environment."}, + "service": {"type": "string", "description": "APM service."}, + "span_name": {"type": "string", "description": "APM span name."}, + "show_hits": {"type": "boolean", "description": "Whether to show the hits metrics or not."}, + "show_errors": {"type": "boolean", "description": "Whether to show the error metrics or not."}, + "show_latency": {"type": "boolean", "description": "Whether to show the latency metrics or not."}, + "show_breakdown": {"type": "boolean", "description": "Whether to show the latency breakdown or not."}, + "show_distribution": {"type": "boolean", "description": "Whether to show the latency distribution or not."}, + "show_resource_list": {"type": "boolean", "description": "Whether to show the resource list or not."}, + "size_format": {"type": "string", "enum": ["small", "medium", "large"], "description": "Widget size format."}, + "display_format": {"type": "string", "enum": ["one_column", "two_column", "three_column"], "description": "Number of columns to display."}, + "title": {"type": "string", "description": "Title of the widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "description": {"type": "string", "description": "Description of the widget."}, + "time": {"type": "object", "description": "Time setting for the widget.", "properties": {"live_span": {"type": "string", "description": "Timeframe, e.g. \"1h\", \"4h\", \"1d\"."}}} + } + }), + "treemap" => serde_json::json!({ + "type": "object", + "required": ["type", "requests"], + "properties": { + "type": {"type": "string", "enum": ["treemap"], "description": "Type of the treemap widget."}, + "requests": {"type": "array", "description": "List of treemap widget requests.", "items": {"type": "object", "properties": {"q": {"type": "string", "description": "Metric query."}}}}, + "size_by": {"type": "string", "enum": ["pct_of_parent", "pct_of_total"], "description": "What the size of each group represents."}, + "color_by": {"type": "string", "enum": ["user", "facet"], "description": "What the color of each group represents."}, + "group_by": {"type": "array", "description": "Group by configuration.", "items": {"type": "object"}}, + "custom_links": {"type": "array", "description": "List of custom links.", "items": {"type": "object"}}, + "title": {"type": "string", "description": "Title of your widget."}, + "description": {"type": "string", "description": "Description of the widget."}, + "time": {"type": "object", "description": "Time setting for the widget.", "properties": {"live_span": {"type": "string", "description": "Timeframe, e.g. \"1h\", \"4h\", \"1d\"."}}} + } + }), + "wildcard" => serde_json::json!({ + "type": "object", + "required": ["type", "requests", "specification"], + "properties": { + "type": {"type": "string", "enum": ["wildcard"], "description": "Type of the wildcard widget."}, + "requests": {"type": "array", "description": "List of data requests for the wildcard widget.", "items": {"type": "object", "properties": { + "formulas": {"type": "array", "description": "List of formulas.", "items": {"type": "object"}}, + "queries": {"type": "array", "description": "List of queries.", "items": {"type": "object", "properties": { + "data_source": {"type": "string", "description": "Data source, e.g. \"metrics\"."}, + "name": {"type": "string", "description": "Query alias name used in formulas."}, + "query": {"type": "string", "description": "Metric query string."}, + "aggregator": {"type": "string", "description": "Aggregation method."} + }}}, + "response_format": {"type": "string", "enum": ["timeseries", "scalar", "event_list"], "description": "Response format."} + }}}, + "specification": {"type": "object", "description": "Vega or Vega-Lite specification for the custom visualization."}, + "custom_links": {"type": "array", "description": "List of custom links.", "items": {"type": "object"}}, + "title": {"type": "string", "description": "Title of the widget."}, + "title_size": {"type": "string", "description": "Size of the title."}, + "title_align": {"type": "string", "enum": ["left", "center", "right"], "description": "Horizontal alignment of the title."}, + "time": {"type": "object", "description": "Time setting for the widget.", "properties": {"live_span": {"type": "string", "description": "Timeframe, e.g. \"1h\", \"4h\", \"1d\"."}}} + } + }), + _ => return None, + }; + + Some(serde_json::json!({ + "type": "object", + "required": ["definition"], + "properties": { + "id": { + "type": "integer", + "description": "ID of the widget. Assigned by the API; omit when creating." + }, + "layout": { + "type": "object", + "description": "Position and size. Required for free-layout dashboards; omit for ordered-layout.", + "required": ["x", "y", "width", "height"], + "properties": { + "x": {"type": "integer", "description": "Position on the x (horizontal) axis. Should be a non-negative integer."}, + "y": {"type": "integer", "description": "Position on the y (vertical) axis. Should be a non-negative integer."}, + "width": {"type": "integer", "description": "Width of the widget. Should be a non-negative integer."}, + "height": {"type": "integer", "description": "Height of the widget. Should be a non-negative integer."} + } + }, + "definition": definition + } + })) +} + fn dashboard_url_with_time(base_url: &str, from: &str, to: &str, live: bool) -> Result { let mut url = Url::parse(base_url).map_err(|e| { anyhow::anyhow!("dashboard response included invalid url {base_url:?}: {e}") From 44cb0e74d32f8eb1dea8d2be988c7e49cbecca75 Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Mon, 15 Jun 2026 16:10:59 -0500 Subject: [PATCH 5/5] style: apply cargo fmt Run cargo fmt to fix formatting in dashboards.rs and test_commands.rs. Environment: Datadog workspace Co-Authored-By: Claude Sonnet 4.6 --- src/commands/dashboards.rs | 43 ++++++++++++++++++++++++++++---------- src/test_commands.rs | 5 ++--- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/commands/dashboards.rs b/src/commands/dashboards.rs index 46762757..a82539b0 100644 --- a/src/commands/dashboards.rs +++ b/src/commands/dashboards.rs @@ -79,8 +79,7 @@ fn read_widget_input(file: &str) -> Result { .map_err(|e| anyhow::anyhow!("failed to read widget JSON from stdin: {e}"))?; buf } else { - std::fs::read(file) - .map_err(|e| anyhow::anyhow!("failed to read --file {file:?}: {e}"))? + std::fs::read(file).map_err(|e| anyhow::anyhow!("failed to read --file {file:?}: {e}"))? }; let widget: Widget = serde_json::from_slice(&bytes) .map_err(|e| anyhow::anyhow!("failed to parse widget JSON: {e}"))?; @@ -154,8 +153,7 @@ pub async fn widget_list(cfg: &Config, dash_id: &str) -> Result<()> { .iter() .enumerate() .map(|(idx, w)| { - let def_val = - serde_json::to_value(&w.definition).unwrap_or(serde_json::Value::Null); + let def_val = serde_json::to_value(&w.definition).unwrap_or(serde_json::Value::Null); let widget_type = def_val .get("type") .and_then(|v| v.as_str()) @@ -1281,7 +1279,10 @@ mod tests { #[test] fn test_locate_by_index_ok() { let widgets = vec![make_widget(None), make_widget(None)]; - assert_eq!(super::locate_widget_index(&widgets, None, Some(1)).unwrap(), 1); + assert_eq!( + super::locate_widget_index(&widgets, None, Some(1)).unwrap(), + 1 + ); } #[test] @@ -1349,7 +1350,11 @@ mod tests { let _mock = mock_any(&mut server, "GET", DASHBOARD_WITH_WIDGETS).await; let result = super::widget_get(&cfg, "abc-123", None, Some(0)).await; - assert!(result.is_ok(), "widget_get by index failed: {:?}", result.err()); + assert!( + result.is_ok(), + "widget_get by index failed: {:?}", + result.err() + ); cleanup_env(); } @@ -1361,7 +1366,11 @@ mod tests { let _mock = mock_any(&mut server, "GET", DASHBOARD_WITH_WIDGETS).await; let result = super::widget_get(&cfg, "abc-123", Some(2), None).await; - assert!(result.is_ok(), "widget_get by id failed: {:?}", result.err()); + assert!( + result.is_ok(), + "widget_get by id failed: {:?}", + result.err() + ); cleanup_env(); } @@ -1375,7 +1384,8 @@ mod tests { let _mock_get = mock_any(&mut server, "GET", DASHBOARD_WITH_WIDGETS).await; let _mock_put = mock_any(&mut server, "PUT", DASHBOARD_WITH_WIDGETS).await; - let widget_json = r#"{"definition":{"type":"timeseries","requests":[{"q":"avg:system.cpu.user{*}"}]}}"#; + let widget_json = + r#"{"definition":{"type":"timeseries","requests":[{"q":"avg:system.cpu.user{*}"}]}}"#; let path = write_temp_json("test_widget_add.json", widget_json); let result = super::widget_add(&cfg, "abc-123", path.to_str().unwrap()).await; assert!(result.is_ok(), "widget_add failed: {:?}", result.err()); @@ -1459,7 +1469,11 @@ mod tests { let _lock = lock_env().await; let cfg = test_config("http://unused.local"); let result = super::widget_schema(&cfg, "timeseries"); - assert!(result.is_ok(), "widget_schema(timeseries) failed: {:?}", result.err()); + assert!( + result.is_ok(), + "widget_schema(timeseries) failed: {:?}", + result.err() + ); cleanup_env(); } @@ -1468,7 +1482,11 @@ mod tests { let _lock = lock_env().await; let cfg = test_config("http://unused.local"); let result = super::widget_schema(&cfg, "geomap"); - assert!(result.is_ok(), "widget_schema(geomap) failed: {:?}", result.err()); + assert!( + result.is_ok(), + "widget_schema(geomap) failed: {:?}", + result.err() + ); cleanup_env(); } @@ -1477,7 +1495,10 @@ mod tests { let _lock = lock_env().await; let cfg = test_config("http://unused.local"); let result = super::widget_schema(&cfg, "totally_bogus_widget_type"); - assert!(result.is_err(), "widget_schema should fail for unknown types"); + assert!( + result.is_err(), + "widget_schema should fail for unknown types" + ); cleanup_env(); } } diff --git a/src/test_commands.rs b/src/test_commands.rs index 8cbba577..54af43ac 100644 --- a/src/test_commands.rs +++ b/src/test_commands.rs @@ -615,9 +615,8 @@ fn test_dashboards_widgets_get_rejects_both_selectors() { fn test_dashboards_widgets_schema_parses() { use clap::Parser; - let cli = - crate::Cli::try_parse_from(["pup", "dashboards", "widgets", "schema", "timeseries"]) - .expect("dashboards widgets schema should parse"); + let cli = crate::Cli::try_parse_from(["pup", "dashboards", "widgets", "schema", "timeseries"]) + .expect("dashboards widgets schema should parse"); match cli.command { crate::Commands::Dashboards { action } => {