mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-08-05 04:23:28 +00:00
feat(tools): add grafana panel json to rust generator
This commit is contained in:
parent
008eb247e9
commit
462de8f11d
10
Justfile
10
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:
|
||||
|
||||
@ -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"] }
|
||||
|
||||
@ -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")),
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
50
tools/dashboard_gen/src/bin/panel_json_to_rust.rs
Normal file
50
tools/dashboard_gen/src/bin/panel_json_to_rust.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
185
tools/dashboard_gen/src/codegen.rs
Normal file
185
tools/dashboard_gen/src/codegen.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
128
tools/dashboard_gen/src/input.rs
Normal file
128
tools/dashboard_gen/src/input.rs
Normal 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,
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
106
tools/dashboard_gen/src/styling.rs
Normal file
106
tools/dashboard_gen/src/styling.rs
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user