feat(dashboard_gen): remove panel json to rust generator

This commit is contained in:
Daniil Polyakov 2026-07-28 17:11:49 +03:00
parent aa6969386e
commit 343ba0b97c
12 changed files with 68 additions and 517 deletions

1
Cargo.lock generated
View File

@ -2296,6 +2296,7 @@ dependencies = [
name = "dashboard_gen"
version = "0.1.0"
dependencies = [
"clap",
"json-pretty-compact",
"sequencer_core_metrics",
"sequencer_service_metrics",

View File

@ -57,14 +57,8 @@ 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 --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
@cargo build -q -p dashboard_gen
@cargo run -q -p dashboard_gen -- sequencer > monitoring/grafana/dashboards/sequencer.json
# Run criterion benches: fast crypto primitives, then the slow PPE verify (real proving setup).
bench:

View File

@ -11,6 +11,7 @@ workspace = true
sequencer_core_metrics.workspace = true
sequencer_service_metrics.workspace = true
clap = { workspace = true, features = ["derive"] }
serde = { workspace = true, features = ["derive", "alloc"] }
serde_json.workspace = true
json-pretty-compact = "0.1.2"

View File

@ -1,50 +0,0 @@
//! 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

@ -1,242 +0,0 @@
//! 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::{
DEFAULT_FILL_OPACITY, Unit,
input::{Defaults, PanelInput},
schema::{
AxisPlacement, Color, GradientMode, LineInterpolation, PanelType, ShowPoints, StackingMode,
ThresholdMode, Thresholds,
},
};
/// 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),
PanelType::Gauge => format!("Panel::gauge({:?})", panel.title),
};
write!(expr, "\n .width({})", panel.grid_pos.w)?;
let defaults = &panel.field_config.defaults;
write_defaults(&mut expr, defaults)?;
if let Some(custom) = &defaults.custom {
// Emitted against the builder's default, not Grafana's (which is 0), so
// a genuinely unfilled panel still round-trips.
if let Some(opacity) = custom.fill_opacity.filter(|&o| o != DEFAULT_FILL_OPACITY) {
write!(expr, "\n .fill_opacity({opacity})")?;
}
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)
}
/// The field-level setters — everything outside the `custom` styling block.
fn write_defaults(expr: &mut String, defaults: &Defaults) -> Result<(), std::fmt::Error> {
// `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::from_id(unit).to_rust_source()
)?;
}
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(min) = defaults.min {
write!(expr, "\n .min({min:?})")?;
}
if let Some(max) = defaults.max {
write!(expr, "\n .max({max:?})")?;
}
let ladder = defaults
.thresholds
.as_ref()
.filter(|thresholds| is_expressible_ladder(thresholds))
.and_then(|thresholds| thresholds.steps.split_first());
if let Some((base, steps)) = ladder {
write!(expr, "\n .thresholds(Thresholds::base({:?})", base.color)?;
for step in steps {
// A non-base step without a value is nonsense Grafana wouldn't render.
if let Some(value) = step.value {
write!(expr, ".step({value:?}, {:?})", step.color)?;
}
}
expr.push(')');
}
Ok(())
}
/// Whether a ladder is worth emitting: `percentage` mode is beyond the builder,
/// and green/red-at-80 is the ladder Grafana attaches to every panel by default.
fn is_expressible_ladder(thresholds: &Thresholds) -> bool {
if thresholds.mode != ThresholdMode::Absolute {
return false;
}
!matches!(
thresholds.steps.as_slice(),
[base, red]
if base.color == "green"
&& base.value.is_none()
&& red.color == "red"
&& red.value == Some(80.0)
)
}
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,3 @@
//! One module per dashboard, each exposing a `dashboard()` builder.
pub mod sequencer;

View File

@ -1,9 +1,6 @@
//! Generates the sequencer dashboard and prints it to stdout.
//! The sequencer dashboard: chain progress, block timings, mempool and
//! transaction outcomes.
#![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"
@ -13,13 +10,11 @@ use dashboard_gen::{
Color, Dashboard, FieldOverride, GradientMode, Panel, Target, Thresholds, Unit, avg,
percentile_legend, percentile_variable, rate_per_min, selected_percentile,
};
use json_pretty_compact::PrettyCompactFormatter;
use serde::Serialize as _;
const PERCENTILES: &[u32] = &[50, 90, 95, 99];
const DEFAULT_PERCENTILE: u32 = 95;
fn sequencer_dashboard() -> Dashboard {
pub fn dashboard() -> Dashboard {
Dashboard::new("Sequencer", "sequencer")
.tag("sequencer")
.variable(percentile_variable(PERCENTILES, DEFAULT_PERCENTILE))
@ -186,15 +181,3 @@ fn sequencer_dashboard() -> Dashboard {
],
)
}
fn main() {
let dashboard = sequencer_dashboard();
let formatter = PrettyCompactFormatter::new();
let mut output = Vec::new();
let mut ser = serde_json::Serializer::with_formatter(&mut output, formatter);
dashboard.serialize(&mut ser).unwrap();
let json = String::from_utf8(output).unwrap();
println!("{json}");
}

View File

@ -1,137 +0,0 @@
//! 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,
Thresholds,
};
#[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>,
#[serde(default)]
pub min: Option<f64>,
#[serde(default)]
pub max: Option<f64>,
#[serde(default)]
pub thresholds: Option<Thresholds>,
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct Custom {
#[serde(default)]
pub fill_opacity: Option<u32>,
#[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

@ -13,7 +13,6 @@
// 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, Thresholds,
Variable,
@ -28,8 +27,6 @@ use schema::{
use serde::Serialize;
pub use unit::Unit;
mod codegen;
mod input;
mod schema;
mod styling;
mod unit;

View File

@ -0,0 +1,45 @@
//! Builds one of the Grafana dashboards and prints its JSON to stdout.
#![expect(
clippy::print_stdout,
reason = "CLI tool: emitting the dashboard JSON on stdout is the deliverable"
)]
use clap::{Parser, ValueEnum};
use dashboard_gen::Dashboard;
use json_pretty_compact::PrettyCompactFormatter;
use serde::Serialize as _;
mod dashboards;
#[derive(Debug, Parser)]
#[clap(version)]
struct Args {
/// Which dashboard to build.
dashboard: DashboardKind,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum DashboardKind {
Sequencer,
}
impl DashboardKind {
fn build(self) -> Dashboard {
match self {
Self::Sequencer => dashboards::sequencer::dashboard(),
}
}
}
fn main() {
let Args { dashboard } = Args::parse();
let formatter = PrettyCompactFormatter::new();
let mut output = Vec::new();
let mut ser = serde_json::Serializer::with_formatter(&mut output, formatter);
dashboard.build().serialize(&mut ser).unwrap();
let json = String::from_utf8(output).unwrap();
println!("{json}");
}

View File

@ -1,12 +1,9 @@
//! 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.
//! Every type here is `Serialize`-only: the model is sized for what we emit.
use serde::{Deserialize, Serialize};
use serde::Serialize;
use crate::{DATASOURCE_UID, FieldOverride, Target, unit::Unit};
@ -88,8 +85,7 @@ pub enum SortOrder {
Desc,
}
// Reused by the `input` model, hence `Deserialize`.
#[derive(Clone, Copy, Serialize, Deserialize)]
#[derive(Clone, Copy, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum PanelType {
Stat,
@ -97,7 +93,7 @@ pub enum PanelType {
Gauge,
}
#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ThresholdMode {
Absolute,
@ -108,14 +104,14 @@ pub enum ThresholdMode {
/// One threshold step: the color values at or above `value` take. The base step
/// carries `value: null` — Grafana's "everything below the first threshold".
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Serialize)]
pub struct ThresholdStep {
pub color: String,
pub value: Option<f64>,
}
/// A threshold ladder, driving gauge/stat coloring.
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Serialize)]
pub struct Thresholds {
pub mode: ThresholdMode,
pub steps: Vec<ThresholdStep>,
@ -145,10 +141,9 @@ impl Thresholds {
}
// 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.
// public setters can panic when handed the Grafana default (see `styling`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum LineInterpolation {
Linear,
@ -157,7 +152,7 @@ pub enum LineInterpolation {
StepAfter,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ShowPoints {
Auto,
@ -165,7 +160,7 @@ pub enum ShowPoints {
Always,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum GradientMode {
None,
@ -174,7 +169,7 @@ pub enum GradientMode {
Scheme,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum StackingMode {
None,
@ -182,7 +177,7 @@ pub enum StackingMode {
Percent,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AxisPlacement {
Auto,
@ -237,8 +232,7 @@ impl Datasource {
}
}
// Reused by the `input` model, hence `Deserialize`.
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Serialize)]
#[serde(rename_all = "kebab-case")]
#[serde(tag = "mode")]
pub enum Color {

View File

@ -60,44 +60,6 @@ impl Unit {
Self::Custom(id) => id,
}
}
/// Reverse of [`Self::as_id`]: map a Grafana unit id back to a `Unit`,
/// falling back to [`Self::Custom`] for ids we don't name. Used by the
/// panel→Rust transpiler.
#[must_use]
pub(crate) fn from_id(id: &str) -> Self {
match id {
"short" => Self::Short,
"percent" => Self::Percent,
"percentunit" => Self::PercentUnit,
"s" => Self::Seconds,
"ms" => Self::Milliseconds,
"ns" => Self::Nanoseconds,
"bytes" => Self::Bytes,
"Bps" => Self::BytesPerSec,
"reqps" => Self::RequestsPerSec,
"ops" => Self::OpsPerSec,
other => Self::custom(other),
}
}
/// The Rust builder expression that reconstructs this unit, for codegen.
#[must_use]
pub(crate) fn to_rust_source(&self) -> String {
match self {
Self::Custom(id) => format!("Unit::custom({id:?})"),
named @ (Self::Short
| Self::Percent
| Self::PercentUnit
| Self::Seconds
| Self::Milliseconds
| Self::Nanoseconds
| Self::Bytes
| Self::BytesPerSec
| Self::RequestsPerSec
| Self::OpsPerSec) => format!("Unit::{named:?}"),
}
}
}
impl Serialize for Unit {