diff --git a/Justfile b/Justfile index 51899221..1a51980e 100644 --- a/Justfile +++ b/Justfile @@ -57,8 +57,14 @@ regenerate-test-fixture: # (tools/dashboard_gen) and commit the result. CI checks these are up to date. regenerate-dashboards: @echo "πŸ“Š Regenerating Grafana dashboards" - @cargo build -q -p dashboard_gen - @cargo run -q -p dashboard_gen > monitoring/grafana/dashboards/sequencer.json + @cargo build -q -p dashboard_gen --bin gen_sequencer_dashboard + @cargo run -q -p dashboard_gen --bin gen_sequencer_dashboard > monitoring/grafana/dashboards/sequencer.json + +# Transpile a single Grafana panel JSON (stdin) β€” Inspect β†’ Panel JSON β€” into a +# Rust builder expression (stdout), omitting Grafana defaults. Paste into a row. +# Usage: `just panel-to-rust < panel.json`. +panel-to-rust: + @cargo run -q -p dashboard_gen --bin panel_json_to_rust # Run criterion benches: fast crypto primitives, then the slow PPE verify (real proving setup). bench: diff --git a/tools/dashboard_gen/Cargo.toml b/tools/dashboard_gen/Cargo.toml index d05ac951..a21916e1 100644 --- a/tools/dashboard_gen/Cargo.toml +++ b/tools/dashboard_gen/Cargo.toml @@ -4,8 +4,8 @@ version = "0.1.0" edition = "2024" license = { workspace = true } -# NOTE: experiment β€” intentionally NOT inheriting `[lints] workspace = true`. -# If we keep this crate, turn workspace lints on and fix the fallout. +[lints] +workspace = true [dependencies] serde = { workspace = true, features = ["derive", "alloc"] } diff --git a/tools/dashboard_gen/src/main.rs b/tools/dashboard_gen/src/bin/gen_sequencer_dashboard.rs similarity index 83% rename from tools/dashboard_gen/src/main.rs rename to tools/dashboard_gen/src/bin/gen_sequencer_dashboard.rs index f6d177bc..22a4b8ee 100644 --- a/tools/dashboard_gen/src/main.rs +++ b/tools/dashboard_gen/src/bin/gen_sequencer_dashboard.rs @@ -1,12 +1,17 @@ //! Generates the sequencer dashboard and prints it to stdout. -//! -//! Compare against the committed file: -//! cargo run -p dashboard_gen | diff - monitoring/grafana/dashboards/sequencer.json -//! Regenerate it: -//! cargo run -p dashboard_gen > monitoring/grafana/dashboards/sequencer.json + +#![expect( + clippy::print_stdout, + reason = "CLI tool: emitting the dashboard JSON on stdout is the deliverable" +)] +#![expect( + clippy::non_ascii_literal, + reason = "legend separators use `Β·` intentionally, matching the rendered Grafana labels" +)] use dashboard_gen::{ - Dashboard, FieldOverride, Panel, Target, avg, percentiles, percentiles_labeled, rate_per_min, + Color, Dashboard, FieldOverride, Panel, Target, avg, percentiles, percentiles_labeled, + rate_per_min, }; use json_pretty_compact::PrettyCompactFormatter; use serde::Serialize as _; @@ -35,7 +40,7 @@ fn sequencer_dashboard() -> Dashboard { .width(6) .unit("short") .decimals(0) - .fixed_color("blue") + .color(Color::fixed("blue")) .target(Target::new(BLOCK_COUNT).legend("height")), Panel::timeseries("Block production rate") .width(18) @@ -53,7 +58,7 @@ fn sequencer_dashboard() -> Dashboard { .with_override( FieldOverride::by_name("avg") .dashed_line() - .fixed_color("text"), + .color(Color::fixed("text")), )], ) .row( @@ -85,15 +90,17 @@ fn sequencer_dashboard() -> Dashboard { .with_override( FieldOverride::by_name("avg") .dashed_line() - .fixed_color("text"), + .color(Color::fixed("text")), ), Panel::timeseries("Transaction throughput (per minute)") .width(12) .unit("short") .target(rate_per_min(SUBMITTED_TX, "submitted")) .target(rate_per_min(FAILED_TX, "failed")) - .with_override(FieldOverride::by_name("failed").fixed_color("red")) - .with_override(FieldOverride::by_name("submitted").fixed_color("green")), + .with_override(FieldOverride::by_name("failed").color(Color::fixed("red"))) + .with_override( + FieldOverride::by_name("submitted").color(Color::fixed("green")), + ), ], ) } diff --git a/tools/dashboard_gen/src/bin/panel_json_to_rust.rs b/tools/dashboard_gen/src/bin/panel_json_to_rust.rs new file mode 100644 index 00000000..9e10f046 --- /dev/null +++ b/tools/dashboard_gen/src/bin/panel_json_to_rust.rs @@ -0,0 +1,50 @@ +//! Reads a single Grafana panel JSON on stdin (Grafana β†’ panel menu β†’ Inspect β†’ +//! Panel JSON) and prints the Rust `Panel::…` builder expression that rebuilds +//! it through `dashboard_gen`, omitting values Grafana supplies by default. +//! +//! Paste the result into a dashboard's `.row(…)` and run `cargo fmt`. + +#![expect( + clippy::print_stderr, + reason = "CLI tool: diagnostics on stderr are the deliverable" +)] +#![expect( + clippy::non_ascii_literal, + reason = "help text mirrors Grafana's `Inspect β†’ Panel JSON` menu path" +)] + +use std::{ + io::{self, Read as _, Write as _}, + process::ExitCode, +}; + +fn main() -> ExitCode { + let mut input = String::new(); + if let Err(err) = io::stdin().read_to_string(&mut input) { + eprintln!("error: failed to read panel JSON from stdin: {err}"); + return ExitCode::FAILURE; + } + + match dashboard_gen::panel_to_rust_source(&input) { + Ok(source) => { + if let Err(err) = io::stdout().write_all(source.as_bytes()) { + eprintln!("error: failed to write generated source to stdout: {err}"); + return ExitCode::FAILURE; + } + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("error: could not parse panel JSON: {err}"); + eprintln!( + "hint: paste one panel (Inspect β†’ Panel JSON);\ + only stat and timeseries are supported." + ); + eprintln!( + "hint: this tool supports only the subset of Grafana's panel JSON, you might need \ + to manually implement support for new fields." + ); + + ExitCode::FAILURE + } + } +} diff --git a/tools/dashboard_gen/src/codegen.rs b/tools/dashboard_gen/src/codegen.rs new file mode 100644 index 00000000..efa8d8c3 --- /dev/null +++ b/tools/dashboard_gen/src/codegen.rs @@ -0,0 +1,185 @@ +//! Reverse of the builder: turn a single parsed panel back into the Rust +//! `Panel::…` builder expression, omitting values Grafana supplies by default. +//! Backs the `panel_json_to_rust` binary. +//! +//! Input is parsed leniently (see [`crate::input`]) so a raw Grafana panel +//! export β€” full of fields and vocabularies we don't model β€” still works; +//! anything unrecognized is dropped. The emitted expression is valid but only +//! lightly formatted, so `cargo fmt` re-indents it once it lands in a file. + +use std::fmt::Write as _; + +use crate::{ + input::PanelInput, + schema::{ + AxisPlacement, Color, GradientMode, LineInterpolation, PanelType, ShowPoints, StackingMode, + }, +}; + +/// Parse one Grafana panel JSON (Inspect β†’ Panel JSON) and emit the Rust +/// builder expression that reproduces it (minus Grafana defaults). +pub fn panel_to_rust_source(json: &str) -> serde_json::Result { + let panel: PanelInput = serde_json::from_str(json)?; + Ok(format!("{}\n", panel_expr(&panel))) +} + +/// A `Panel::…()` expression with one method call per line. +fn panel_expr(panel: &PanelInput) -> String { + panel_expr_inner(panel).expect("writing to a String never fails") +} + +fn panel_expr_inner(panel: &PanelInput) -> Result { + let mut expr = match panel.panel_type { + PanelType::Stat => format!("Panel::stat({:?})", panel.title), + PanelType::Timeseries => format!("Panel::timeseries({:?})", panel.title), + }; + write!(expr, "\n .width({})", panel.grid_pos.w)?; + + let defaults = &panel.field_config.defaults; + // `short` is the builder's own default unit, so it round-trips without a call. + if let Some(unit) = defaults.unit.as_deref().filter(|u| *u != "short") { + write!(expr, "\n .unit({unit:?})")?; + } + if let Some(decimals) = defaults.decimals { + write!(expr, "\n .decimals({decimals})")?; + } + // Only a fixed color is worth emitting; `palette-classic` is Grafana's default. + if let Some(Color::Fixed { fixed_color }) = &defaults.color { + write!(expr, "\n .color(Color::fixed({fixed_color:?}))")?; + } + + if let Some(custom) = &defaults.custom { + if custom.span_nulls == Some(true) { + expr.push_str("\n .span_nulls()"); + } + // Each optional styling field is emitted only when it differs from the + // Grafana default (matching the setters' panic-on-default contract). + if let Some(value) = custom + .line_interpolation + .filter(|&v| v != LineInterpolation::Linear) + { + write!( + expr, + "\n .line_interpolation(LineInterpolation::{})", + line_interp(value) + )?; + } + if let Some(value) = custom.show_points.filter(|&v| v != ShowPoints::Auto) { + write!( + expr, + "\n .show_points(ShowPoints::{})", + show_points(value) + )?; + } + if let Some(value) = custom.gradient_mode.filter(|&v| v != GradientMode::None) { + write!( + expr, + "\n .gradient_mode(GradientMode::{})", + gradient_mode(value) + )?; + } + let stacking = custom.stacking.as_ref().and_then(|s| s.mode); + if let Some(mode) = stacking.filter(|&m| m != StackingMode::None) { + write!( + expr, + "\n .stacking(StackingMode::{})", + stacking_mode(mode) + )?; + } + if let Some(value) = custom.axis_placement.filter(|&v| v != AxisPlacement::Auto) { + write!( + expr, + "\n .axis_placement(AxisPlacement::{})", + axis_placement(value) + )?; + } + if let Some(label) = custom.axis_label.as_deref().filter(|l| !l.is_empty()) { + write!(expr, "\n .axis_label({label:?})")?; + } + } + + for over in &panel.field_config.overrides { + // Only `byName` matchers map to the builder; skip anything else. + let Some(name) = over.matcher.by_name() else { + continue; + }; + let mut calls = String::new(); + for property in &over.properties { + match property.id.as_str() { + "color" => { + if let Ok(Color::Fixed { fixed_color }) = + serde_json::from_value::(property.value.clone()) + { + write!(calls, ".color(Color::fixed({fixed_color:?}))")?; + } + } + "custom.lineStyle" => calls.push_str(".dashed_line()"), + _ => {} // property kind the builder can't express β€” drop it + } + } + // An override with nothing representable adds no information. + if !calls.is_empty() { + write!( + expr, + "\n .with_override(FieldOverride::by_name({name:?}){calls})" + )?; + } + } + + for target in &panel.targets { + if target.expr.is_empty() { + continue; + } + write!(expr, "\n .target(Target::new({:?})", target.expr)?; + // `__auto` is Grafana's "no explicit legend" sentinel, i.e. the default. + if !target.legend.is_empty() && target.legend != "__auto" { + write!(expr, ".legend({:?})", target.legend)?; + } + expr.push(')'); + } + + Ok(expr) +} + +const fn line_interp(value: LineInterpolation) -> &'static str { + match value { + LineInterpolation::Linear => "Linear", + LineInterpolation::Smooth => "Smooth", + LineInterpolation::StepBefore => "StepBefore", + LineInterpolation::StepAfter => "StepAfter", + } +} + +const fn show_points(value: ShowPoints) -> &'static str { + match value { + ShowPoints::Auto => "Auto", + ShowPoints::Never => "Never", + ShowPoints::Always => "Always", + } +} + +const fn gradient_mode(value: GradientMode) -> &'static str { + match value { + GradientMode::None => "None", + GradientMode::Opacity => "Opacity", + GradientMode::Hue => "Hue", + GradientMode::Scheme => "Scheme", + } +} + +const fn stacking_mode(value: StackingMode) -> &'static str { + match value { + StackingMode::None => "None", + StackingMode::Normal => "Normal", + StackingMode::Percent => "Percent", + } +} + +const fn axis_placement(value: AxisPlacement) -> &'static str { + match value { + AxisPlacement::Auto => "Auto", + AxisPlacement::Left => "Left", + AxisPlacement::Right => "Right", + AxisPlacement::Hidden => "Hidden", + } +} diff --git a/tools/dashboard_gen/src/input.rs b/tools/dashboard_gen/src/input.rs new file mode 100644 index 00000000..e35a3ca3 --- /dev/null +++ b/tools/dashboard_gen/src/input.rs @@ -0,0 +1,128 @@ +//! Lenient, deserialize-only model of a Grafana panel, for the panelβ†’Rust +//! transpiler (`codegen`). +//! +//! A real Grafana export is far wider than what we emit: `unit` may be absent, +//! `options.tooltip.sort` may be `"none"`, overrides carry property types we +//! don't model, and there are dozens of fields we ignore. So this model is +//! deliberately separate from the strict `schema` (sized for *output*): it +//! captures only what codegen reads, makes every field optional, and lets serde +//! drop everything else β€” including the whole `options` block, which codegen +//! reconstructs from the panel type rather than reading. + +use serde::Deserialize; +use serde_json::Value; + +use crate::schema::{ + AxisPlacement, Color, GradientMode, LineInterpolation, PanelType, ShowPoints, StackingMode, +}; + +#[derive(Deserialize)] +pub struct PanelInput { + #[serde(rename = "type")] + pub panel_type: PanelType, + #[serde(default)] + pub title: String, + #[serde(rename = "gridPos", default)] + pub grid_pos: GridPos, + #[serde(rename = "fieldConfig", default)] + pub field_config: FieldConfig, + #[serde(default)] + pub targets: Vec, +} + +#[derive(Deserialize, Default)] +pub struct GridPos { + #[serde(default)] + pub w: u32, +} + +#[derive(Deserialize, Default)] +pub struct FieldConfig { + #[serde(default)] + pub defaults: Defaults, + #[serde(default)] + pub overrides: Vec, +} + +#[derive(Deserialize, Default)] +pub struct Defaults { + #[serde(default)] + pub unit: Option, + #[serde(default)] + pub decimals: Option, + #[serde(default)] + pub color: Option, + #[serde(default)] + pub custom: Option, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct Custom { + #[serde(default)] + pub span_nulls: Option, + #[serde(default)] + pub line_interpolation: Option, + #[serde(default)] + pub show_points: Option, + #[serde(default)] + pub gradient_mode: Option, + #[serde(default)] + pub stacking: Option, + #[serde(default)] + pub axis_placement: Option, + #[serde(default)] + pub axis_label: Option, +} + +#[derive(Deserialize, Default)] +pub struct Stacking { + #[serde(default)] + pub mode: Option, +} + +#[derive(Deserialize)] +pub struct Override { + #[serde(default)] + pub matcher: Matcher, + #[serde(default)] + pub properties: Vec, +} + +#[derive(Deserialize, Default)] +pub struct Matcher { + #[serde(default)] + pub id: String, + #[serde(default)] + pub options: Value, +} + +impl Matcher { + /// The series name for a `byName` matcher; `None` for matcher kinds the + /// builder can't express (which the caller skips). + pub fn by_name(&self) -> Option<&str> { + if self.id == "byName" { + self.options.as_str() + } else { + None + } + } +} + +/// One override property. `id`/`value` are kept raw so unknown kinds are simply +/// ignored by codegen rather than failing the whole parse. +#[derive(Deserialize)] +pub struct Property { + #[serde(default)] + pub id: String, + #[serde(default)] + pub value: Value, +} + +#[derive(Deserialize, Default)] +pub struct Target { + #[serde(default)] + pub expr: String, + #[serde(rename = "legendFormat", default)] + pub legend: String, +} diff --git a/tools/dashboard_gen/src/lib.rs b/tools/dashboard_gen/src/lib.rs index fad718e8..243b22cb 100644 --- a/tools/dashboard_gen/src/lib.rs +++ b/tools/dashboard_gen/src/lib.rs @@ -11,29 +11,28 @@ //! //! Build a [`Dashboard`] and serialize it directly. +// Styling vocabularies passed to the optional `styling` setters are part of the +// public API (and are used by this module's `Panel` fields and `finalize`). +pub use codegen::panel_to_rust_source; +pub use schema::{AxisPlacement, Color, GradientMode, LineInterpolation, ShowPoints, StackingMode}; use schema::{ - Calc, Color, Custom, Datasource, Defaults, DrawStyle, EmptyList, FieldConfig, Fill, GraphMode, + Calc, Custom, Datasource, Defaults, DrawStyle, EmptyList, FieldConfig, Fill, GraphMode, GridPos, Legend, LegendDisplay, LineStyle, Matcher, MatcherKind, Options, OverrideProperty, PanelModel, PanelType, Placement, PropertyId, PropertyValue, ReduceOptions, SortOrder, - StatColorMode, StatOptions, TimeRange, TimeSeriesOptions, Tooltip, TooltipMode, + Stacking, StatColorMode, StatOptions, TimeRange, TimeSeriesOptions, Tooltip, TooltipMode, }; use serde::Serialize; +mod codegen; +mod input; mod schema; +mod styling; /// Datasource uid every panel/target points at. Dashboards stay portable across /// environments because they reference the datasource by this stable uid rather /// than by a per-environment URL. pub const DATASOURCE_UID: &str = "prometheus"; -fn default_unit() -> String { - "short".to_owned() -} - -fn ref_letter(index: usize) -> String { - ((b'A' + index as u8) as char).to_string() -} - /// A single Prometheus query within a panel. #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -55,6 +54,7 @@ impl Target { } } + #[must_use] pub fn legend(mut self, legend: impl Into) -> Self { self.legend = legend.into(); self @@ -79,14 +79,16 @@ impl FieldOverride { } } - pub fn fixed_color(mut self, color: impl Into) -> Self { + #[must_use] + pub fn color(mut self, color: Color) -> Self { self.properties.push(OverrideProperty { id: PropertyId::Color, - value: PropertyValue::Color(Color::fixed(color.into())), + value: PropertyValue::Color(color), }); self } + #[must_use] pub fn dashed_line(mut self) -> Self { self.properties.push(OverrideProperty { id: PropertyId::LineStyle, @@ -114,9 +116,16 @@ pub struct Panel { width: u32, unit: Option, decimals: Option, - fixed_color: Option, + color: Option, span_nulls: bool, overrides: Vec, + // Optional timeseries styling, set via the `styling` setters. + line_interpolation: Option, + show_points: Option, + gradient_mode: Option, + stacking: Option, + axis_placement: Option, + axis_label: Option, } impl Panel { @@ -128,9 +137,15 @@ impl Panel { width: 0, unit: None, decimals: None, - fixed_color: None, + color: None, span_nulls: false, overrides: Vec::new(), + line_interpolation: None, + show_points: None, + gradient_mode: None, + stacking: None, + axis_placement: None, + axis_label: None, } } @@ -146,41 +161,49 @@ impl Panel { /// Grid width in Grafana's 24-column units. Unset panels split the row's /// remaining width evenly. - pub fn width(mut self, width: u32) -> Self { + #[must_use] + pub const fn width(mut self, width: u32) -> Self { self.width = width; self } + #[must_use] pub fn unit(mut self, unit: impl Into) -> Self { self.unit = Some(unit.into()); self } - pub fn decimals(mut self, decimals: u32) -> Self { + #[must_use] + pub const fn decimals(mut self, decimals: u32) -> Self { self.decimals = Some(decimals); self } - pub fn fixed_color(mut self, color: impl Into) -> Self { - self.fixed_color = Some(color.into()); + #[must_use] + pub fn color(mut self, color: Color) -> Self { + self.color = Some(color); self } - pub fn span_nulls(mut self) -> Self { + #[must_use] + pub const fn span_nulls(mut self) -> Self { self.span_nulls = true; self } + #[must_use] pub fn target(mut self, target: Target) -> Self { self.targets.push(target); self } + #[must_use] pub fn targets(mut self, targets: impl IntoIterator) -> Self { self.targets.extend(targets); self } + #[must_use] pub fn with_override(mut self, over: FieldOverride) -> Self { self.overrides.push(over); self @@ -201,7 +224,7 @@ impl Panel { let (defaults, options, panel_type) = match self.kind { Kind::Stat => { let defaults = Defaults { - color: self.fixed_color.map(Color::fixed), + color: self.color, custom: None, unit, decimals: self.decimals, @@ -211,7 +234,7 @@ impl Panel { graph_mode: GraphMode::Area, reduce_options: ReduceOptions { calcs: vec![Calc::LastNotNull], - fields: "", + fields: String::new(), values: false, }, }); @@ -225,6 +248,15 @@ impl Panel { line_width: 1, fill_opacity: 10, span_nulls: self.span_nulls.then_some(true), + line_interpolation: self.line_interpolation, + show_points: self.show_points, + gradient_mode: self.gradient_mode, + stacking: self.stacking.map(|mode| Stacking { + mode, + group: "A".to_owned(), + }), + axis_placement: self.axis_placement, + axis_label: self.axis_label, }), unit, decimals: None, @@ -310,8 +342,8 @@ impl Dashboard { tags: Vec::new(), templating: EmptyList::default(), time: TimeRange { - from: "now-15m", - to: "now", + from: "now-15m".to_owned(), + to: "now".to_owned(), }, timezone: String::new(), title: title.into(), @@ -321,11 +353,13 @@ impl Dashboard { } } + #[must_use] pub fn tag(mut self, tag: impl Into) -> Self { self.tags.push(tag.into()); self } + #[must_use] pub fn refresh(mut self, refresh: impl Into) -> Self { self.refresh = refresh.into(); self @@ -334,15 +368,17 @@ impl Dashboard { /// Place a horizontal row of panels at the current vertical cursor. Panel /// ids, x offsets and y are assigned here; unset widths split the remaining /// 24 columns evenly. + #[must_use] pub fn row(mut self, height: u32, panels: impl IntoIterator) -> Self { let panels: Vec = panels.into_iter().collect(); let specified: u32 = panels.iter().map(|p| p.width).sum(); - let auto_count = panels.iter().filter(|p| p.width == 0).count() as u32; - let auto_width = if auto_count > 0 { - 24u32.saturating_sub(specified) / auto_count - } else { - 0 - }; + let auto_count = u32::try_from(panels.iter().filter(|p| p.width == 0).count()).unwrap_or(0); + // `checked_div` yields `None` when there are no auto-width panels; the + // fallback width is unused in that case. + let auto_width = 24_u32 + .saturating_sub(specified) + .checked_div(auto_count) + .unwrap_or(0); let mut x = 0; for panel in panels { @@ -358,28 +394,33 @@ impl Dashboard { y: self.cursor_y, }; let id = self.next_id; - self.next_id += 1; - x += w; + self.next_id = self.next_id.saturating_add(1); + x = x.saturating_add(w); self.panels.push(panel.finalize(id, grid_pos)); } - self.cursor_y += height; + self.cursor_y = self.cursor_y.saturating_add(height); self } } /// Percentile line targets for a summary metric: `p50`, `p90`, … each querying /// the matching `quantile="0.x"` series. +#[must_use] pub fn percentiles(metric: &str, percentiles: &[u32]) -> Vec { percentiles_labeled(metric, percentiles, "") } /// Like [`percentiles`], but appends `legend_suffix` to every legend β€” handy /// when the metric carries labels (e.g. ` Β· {{kind}} Β· {{origin}}`). +#[must_use] pub fn percentiles_labeled(metric: &str, percentiles: &[u32], legend_suffix: &str) -> Vec { percentiles .iter() .map(|&p| { - let quantile = f64::from(p) / 100.0; + // `quantile="0.x"` label, derived without float math: zero-pad to two + // digits then drop trailing zeros (50 β†’ "0.5", 95 β†’ "0.95"). + let quantile = format!("0.{p:02}"); + let quantile = quantile.trim_end_matches('0'); Target::new(format!("{metric}{{quantile=\"{quantile}\"}}")) .legend(format!("p{p}{legend_suffix}")) }) @@ -387,11 +428,22 @@ pub fn percentiles_labeled(metric: &str, percentiles: &[u32], legend_suffix: &st } /// An `avg` target for a summary metric: `rate(sum) / rate(count)` over 1m. +#[must_use] pub fn avg(metric: &str) -> Target { Target::new(format!("rate({metric}_sum[1m]) / rate({metric}_count[1m])")).legend("avg") } /// A per-minute rate target for a counter metric. +#[must_use] pub fn rate_per_min(metric: &str, legend: &str) -> Target { Target::new(format!("rate({metric}[1m]) * 60")).legend(legend) } + +fn default_unit() -> String { + "short".to_owned() +} + +fn ref_letter(index: usize) -> String { + let offset = u8::try_from(index).unwrap_or(0); + char::from(b'A'.saturating_add(offset)).to_string() +} diff --git a/tools/dashboard_gen/src/schema.rs b/tools/dashboard_gen/src/schema.rs index 3a925742..7b6138d8 100644 --- a/tools/dashboard_gen/src/schema.rs +++ b/tools/dashboard_gen/src/schema.rs @@ -1,7 +1,12 @@ //! The serializable Grafana dashboard schema β€” the internal data model the //! public builders assemble into. +//! +//! Most types are `Serialize`-only, sized for what we *emit*. A handful of +//! vocabularies (`PanelType`, `Color`, and the styling enums) additionally +//! derive `Deserialize` because the lenient `input` model β€” which backs the +//! panelβ†’Rust transpiler β€” reuses them. -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::{DATASOURCE_UID, FieldOverride, Target}; @@ -11,12 +16,6 @@ pub enum DatasourceKind { Prometheus, } -#[derive(Clone, Copy, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum ColorMode { - Fixed, -} - #[derive(Clone, Copy, Serialize)] #[serde(rename_all = "lowercase")] pub enum Fill { @@ -89,43 +88,130 @@ pub enum SortOrder { Desc, } -#[derive(Clone, Copy, Serialize)] +// Reused by the `input` model, hence `Deserialize`. +#[derive(Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum PanelType { Stat, Timeseries, } +// Optional timeseries styling vocabularies. Each derives `PartialEq` so the +// public setters can panic when handed the Grafana default (see `styling`), and +// `Deserialize` because the `input` model reuses them. + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LineInterpolation { + Linear, + Smooth, + StepBefore, + StepAfter, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ShowPoints { + Auto, + Never, + Always, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum GradientMode { + None, + Opacity, + Hue, + Scheme, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum StackingMode { + None, + Normal, + Percent, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AxisPlacement { + Auto, + Left, + Right, + Hidden, +} + +#[derive(Serialize)] +pub struct Stacking { + pub mode: StackingMode, + pub group: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Custom { + pub draw_style: DrawStyle, + pub line_width: u32, + pub fill_opacity: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub span_nulls: Option, + // Optional styling β€” omitted (left at Grafana's default) unless a setter + // fills it in. See `styling`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub line_interpolation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub show_points: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gradient_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stacking: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub axis_placement: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub axis_label: Option, +} + #[derive(Clone, Serialize)] pub struct Datasource { #[serde(rename = "type")] pub kind: DatasourceKind, - pub uid: &'static str, + pub uid: String, } impl Datasource { pub fn prometheus() -> Self { Self { kind: DatasourceKind::Prometheus, - uid: DATASOURCE_UID, + uid: DATASOURCE_UID.to_owned(), } } } -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Color { - pub mode: ColorMode, - pub fixed_color: String, +// Reused by the `input` model, hence `Deserialize`. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[serde(tag = "mode")] +pub enum Color { + Fixed { + #[serde(rename = "fixedColor")] + fixed_color: String, + }, + PaletteClassic, } impl Color { - pub fn fixed(color: String) -> Self { - Self { - mode: ColorMode::Fixed, - fixed_color: color, + pub fn fixed(color: impl Into) -> Self { + Self::Fixed { + fixed_color: color.into(), } } + + #[must_use] + pub const fn palette_classic() -> Self { + Self::PaletteClassic + } } #[derive(Serialize)] @@ -153,24 +239,14 @@ pub struct Matcher { pub options: String, } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Custom { - pub draw_style: DrawStyle, - pub line_width: u32, - pub fill_opacity: u32, - #[serde(skip_serializing_if = "Option::is_none")] - pub span_nulls: Option, -} - #[derive(Serialize)] pub struct Defaults { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub color: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub custom: Option, pub unit: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub decimals: Option, } @@ -184,7 +260,7 @@ pub struct FieldConfig { pub struct ReduceOptions { pub calcs: Vec, // Empty string means "all fields"; genuinely free-form, not a vocabulary. - pub fields: &'static str, + pub fields: String, pub values: bool, } @@ -207,7 +283,7 @@ pub struct Legend { #[derive(Serialize)] pub struct Tooltip { pub mode: TooltipMode, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none")] pub sort: Option, } @@ -247,6 +323,10 @@ pub struct PanelModel { pub panel_type: PanelType, } +#[expect( + clippy::trailing_empty_array, + reason = "Grafana expects `list: []` for the blocks we don't populate" +)] #[derive(Serialize, Default)] pub struct EmptyList { pub list: [u8; 0], @@ -255,6 +335,6 @@ pub struct EmptyList { #[derive(Serialize)] pub struct TimeRange { // Free-form Grafana time expressions, not a closed vocabulary. - pub from: &'static str, - pub to: &'static str, + pub from: String, + pub to: String, } diff --git a/tools/dashboard_gen/src/styling.rs b/tools/dashboard_gen/src/styling.rs new file mode 100644 index 00000000..5414ec73 --- /dev/null +++ b/tools/dashboard_gen/src/styling.rs @@ -0,0 +1,106 @@ +//! Optional timeseries styling setters. +//! +//! These are real setters β€” each fills in a field of the panel's `custom` block. +//! Every one documents Grafana's default and **panics if handed that default**: +//! passing the default is always redundant (Grafana emits it anyway), and the +//! generator only serializes fields that differ from the default. So if a call +//! wouldn't change the rendered panel, it's a mistake worth catching loudly at +//! generation time rather than shipping a no-op. +//! +//! These affect timeseries panels only; on a stat panel the `custom` block is +//! not emitted, so the value is silently dropped. + +use crate::{ + Panel, + schema::{AxisPlacement, GradientMode, LineInterpolation, ShowPoints, StackingMode}, +}; + +#[expect( + clippy::multiple_inherent_impl, + reason = "styling setters intentionally live in their own file, so `Panel` has a second inherent impl here" +)] +impl Panel { + /// Interpolation between points. Grafana default: `Linear`. + /// + /// Panics if passed `Linear` β€” that's the default and would be redundant. + #[must_use] + pub fn line_interpolation(mut self, value: LineInterpolation) -> Self { + assert_ne!( + value, + LineInterpolation::Linear, + "line_interpolation(Linear) is redundant: `linear` is Grafana's default. Omit the call.", + ); + self.line_interpolation = Some(value); + self + } + + /// Whether/when to draw point markers. Grafana default: `Auto`. + /// + /// Panics if passed `Auto` β€” that's the default and would be redundant. + #[must_use] + pub fn show_points(mut self, value: ShowPoints) -> Self { + assert_ne!( + value, + ShowPoints::Auto, + "show_points(Auto) is redundant: `auto` is Grafana's default. Omit the call.", + ); + self.show_points = Some(value); + self + } + + /// Area fill gradient. Grafana default: `None`. + /// + /// Panics if passed `None` β€” that's the default and would be redundant. + #[must_use] + pub fn gradient_mode(mut self, value: GradientMode) -> Self { + assert_ne!( + value, + GradientMode::None, + "gradient_mode(None) is redundant: `none` is Grafana's default. Omit the call.", + ); + self.gradient_mode = Some(value); + self + } + + /// Series stacking. Grafana default: `None`. + /// + /// Panics if passed `None` β€” that's the default and would be redundant. + #[must_use] + pub fn stacking(mut self, mode: StackingMode) -> Self { + assert_ne!( + mode, + StackingMode::None, + "stacking(None) is redundant: `none` is Grafana's default. Omit the call.", + ); + self.stacking = Some(mode); + self + } + + /// Y-axis placement. Grafana default: `Auto`. + /// + /// Panics if passed `Auto` β€” that's the default and would be redundant. + #[must_use] + pub fn axis_placement(mut self, value: AxisPlacement) -> Self { + assert_ne!( + value, + AxisPlacement::Auto, + "axis_placement(Auto) is redundant: `auto` is Grafana's default. Omit the call.", + ); + self.axis_placement = Some(value); + self + } + + /// Y-axis label. Grafana default: `""` (no label). + /// + /// Panics if passed an empty string β€” that's the default and would be redundant. + #[must_use] + pub fn axis_label(mut self, label: impl Into) -> Self { + let label = label.into(); + assert_ne!( + label, "", + "axis_label(\"\") is redundant: no label is Grafana's default. Omit the call.", + ); + self.axis_label = Some(label); + self + } +}