feat(tools): add grafana panel json to rust generator

This commit is contained in:
Daniil Polyakov 2026-07-28 01:09:55 +03:00
parent 008eb247e9
commit 462de8f11d
9 changed files with 698 additions and 84 deletions

View File

@ -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:

View File

@ -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"] }

View File

@ -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")),
),
],
)
}

View File

@ -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
}
}
}

View File

@ -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<String> {
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<String, std::fmt::Error> {
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::<Color>(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",
}
}

View File

@ -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<Target>,
}
#[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<Override>,
}
#[derive(Deserialize, Default)]
pub struct Defaults {
#[serde(default)]
pub unit: Option<String>,
#[serde(default)]
pub decimals: Option<u32>,
#[serde(default)]
pub color: Option<Color>,
#[serde(default)]
pub custom: Option<Custom>,
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct Custom {
#[serde(default)]
pub span_nulls: Option<bool>,
#[serde(default)]
pub line_interpolation: Option<LineInterpolation>,
#[serde(default)]
pub show_points: Option<ShowPoints>,
#[serde(default)]
pub gradient_mode: Option<GradientMode>,
#[serde(default)]
pub stacking: Option<Stacking>,
#[serde(default)]
pub axis_placement: Option<AxisPlacement>,
#[serde(default)]
pub axis_label: Option<String>,
}
#[derive(Deserialize, Default)]
pub struct Stacking {
#[serde(default)]
pub mode: Option<StackingMode>,
}
#[derive(Deserialize)]
pub struct Override {
#[serde(default)]
pub matcher: Matcher,
#[serde(default)]
pub properties: Vec<Property>,
}
#[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,
}

View File

@ -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<String>) -> Self {
self.legend = legend.into();
self
@ -79,14 +79,16 @@ impl FieldOverride {
}
}
pub fn fixed_color(mut self, color: impl Into<String>) -> 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<String>,
decimals: Option<u32>,
fixed_color: Option<String>,
color: Option<Color>,
span_nulls: bool,
overrides: Vec<FieldOverride>,
// Optional timeseries styling, set via the `styling` setters.
line_interpolation: Option<LineInterpolation>,
show_points: Option<ShowPoints>,
gradient_mode: Option<GradientMode>,
stacking: Option<StackingMode>,
axis_placement: Option<AxisPlacement>,
axis_label: Option<String>,
}
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<String>) -> 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<String>) -> 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<Item = Target>) -> 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<String>) -> Self {
self.tags.push(tag.into());
self
}
#[must_use]
pub fn refresh(mut self, refresh: impl Into<String>) -> 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<Item = Panel>) -> Self {
let panels: Vec<Panel> = 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<Target> {
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<Target> {
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()
}

View File

@ -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<bool>,
// 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<LineInterpolation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub show_points: Option<ShowPoints>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gradient_mode: Option<GradientMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stacking: Option<Stacking>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub axis_placement: Option<AxisPlacement>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub axis_label: Option<String>,
}
#[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<String>) -> 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<bool>,
}
#[derive(Serialize)]
pub struct Defaults {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<Color>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom: Option<Custom>,
pub unit: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decimals: Option<u32>,
}
@ -184,7 +260,7 @@ pub struct FieldConfig {
pub struct ReduceOptions {
pub calcs: Vec<Calc>,
// 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<SortOrder>,
}
@ -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,
}

View File

@ -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<String>) -> 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
}
}