From 008eb247e9191caad402722c1212e33feb676dcb Mon Sep 17 00:00:00 2001 From: Daniil Polyakov Date: Fri, 24 Jul 2026 18:22:49 +0300 Subject: [PATCH] feat(tools): add dashboard generator --- .github/workflows/ci.yml | 31 ++ Cargo.lock | 19 + Cargo.toml | 1 + Justfile | 7 + monitoring/grafana/dashboards/sequencer.json | 67 ++-- tools/dashboard_gen/Cargo.toml | 13 + tools/dashboard_gen/src/lib.rs | 397 +++++++++++++++++++ tools/dashboard_gen/src/main.rs | 111 ++++++ tools/dashboard_gen/src/schema.rs | 260 ++++++++++++ 9 files changed, 863 insertions(+), 43 deletions(-) create mode 100644 tools/dashboard_gen/Cargo.toml create mode 100644 tools/dashboard_gen/src/lib.rs create mode 100644 tools/dashboard_gen/src/main.rs create mode 100644 tools/dashboard_gen/src/schema.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88be5dab..1a075d77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -353,3 +353,34 @@ jobs: exit 1 fi echo "โœ… Artifacts are up to date" + + # The dashboard generator is plain Rust (no Docker), so this runs directly in + # the CI image container rather than through `run-in-ci-image`. + dashboards: + needs: ci-image + runs-on: ubuntu-latest + container: + image: ${{ needs.ci-image.outputs.image }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + timeout-minutes: 30 + + name: dashboards + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.sha || github.head_ref }} + + - name: Regenerate dashboards + run: just regenerate-dashboards + + - name: Check if dashboards match repository + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + if ! git diff --exit-code monitoring/grafana/dashboards/; then + echo "โŒ Dashboards in the repository are out of date!" + echo "Please run 'just regenerate-dashboards' and commit the changes." + exit 1 + fi + echo "โœ… Dashboards are up to date" diff --git a/Cargo.lock b/Cargo.lock index eec70062..35eb6480 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2292,6 +2292,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dashboard_gen" +version = "0.1.0" +dependencies = [ + "json-pretty-compact", + "serde", + "serde_json", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -4596,6 +4605,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json-pretty-compact" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62a9c1f06b173b0da0ccc8cae00599d7b3ceda6e76d68be9b6bc2c941adafe0e" +dependencies = [ + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "jsonrpsee" version = "0.26.0" diff --git a/Cargo.toml b/Cargo.toml index 534b4009..467dd9dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,7 @@ members = [ "tools/crypto_primitives_bench", "tools/integration_bench", "tools/cross_zone_chat", + "tools/dashboard_gen", ] [workspace.dependencies] diff --git a/Justfile b/Justfile index ee122105..51899221 100644 --- a/Justfile +++ b/Justfile @@ -53,6 +53,13 @@ regenerate-test-fixture: @echo "๐Ÿงช Regenerating test fixture" RISC0_DEV_MODE=1 cargo run -p test_fixtures --bin regenerate_test_fixture +# Regenerate the committed Grafana dashboards from the Rust generator +# (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 + # Run criterion benches: fast crypto primitives, then the slow PPE verify (real proving setup). bench: @echo "๐Ÿ“Š Running criterion benches" diff --git a/monitoring/grafana/dashboards/sequencer.json b/monitoring/grafana/dashboards/sequencer.json index 39824dd2..3cd0fbcc 100644 --- a/monitoring/grafana/dashboards/sequencer.json +++ b/monitoring/grafana/dashboards/sequencer.json @@ -1,24 +1,20 @@ { - "annotations": { "list": [] }, + "annotations": { "list": [ ] }, "editable": true, "graphTooltip": 1, "panels": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, "fieldConfig": { - "defaults": { - "color": { "mode": "fixed", "fixedColor": "blue" }, - "unit": "short", - "decimals": 0 - }, - "overrides": [] + "defaults": { "color": { "mode": "fixed", "fixedColor": "blue" }, "unit": "short", "decimals": 0 }, + "overrides": [ ] }, "gridPos": { "h": 7, "w": 6, "x": 0, "y": 0 }, "id": 1, "options": { "colorMode": "value", "graphMode": "area", - "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } + "reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false } }, "targets": [ { @@ -34,16 +30,13 @@ { "datasource": { "type": "prometheus", "uid": "prometheus" }, "fieldConfig": { - "defaults": { - "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10 }, - "unit": "short" - }, - "overrides": [] + "defaults": { "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10 }, "unit": "short" }, + "overrides": [ ] }, "gridPos": { "h": 7, "w": 18, "x": 6, "y": 0 }, "id": 2, "options": { - "legend": { "displayMode": "list", "placement": "bottom", "calcs": ["last", "max"] }, + "legend": { "displayMode": "list", "placement": "bottom", "calcs": [ "last", "max" ] }, "tooltip": { "mode": "single" } }, "targets": [ @@ -60,15 +53,12 @@ { "datasource": { "type": "prometheus", "uid": "prometheus" }, "fieldConfig": { - "defaults": { - "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10 }, - "unit": "s" - }, + "defaults": { "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10 }, "unit": "s" }, "overrides": [ { "matcher": { "id": "byName", "options": "avg" }, "properties": [ - { "id": "custom.lineStyle", "value": { "dash": [8, 4], "fill": "dash" } }, + { "id": "custom.lineStyle", "value": { "dash": [ 8, 4 ], "fill": "dash" } }, { "id": "color", "value": { "mode": "fixed", "fixedColor": "text" } } ] } @@ -77,7 +67,7 @@ "gridPos": { "h": 9, "w": 24, "x": 0, "y": 7 }, "id": 3, "options": { - "legend": { "displayMode": "table", "placement": "bottom", "calcs": ["last", "max"] }, + "legend": { "displayMode": "table", "placement": "bottom", "calcs": [ "last", "max" ] }, "tooltip": { "mode": "multi", "sort": "desc" } }, "targets": [ @@ -118,16 +108,13 @@ { "datasource": { "type": "prometheus", "uid": "prometheus" }, "fieldConfig": { - "defaults": { - "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10 }, - "unit": "s" - }, - "overrides": [] + "defaults": { "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10 }, "unit": "s" }, + "overrides": [ ] }, "gridPos": { "h": 9, "w": 12, "x": 0, "y": 16 }, "id": 4, "options": { - "legend": { "displayMode": "table", "placement": "bottom", "calcs": ["last", "max"] }, + "legend": { "displayMode": "table", "placement": "bottom", "calcs": [ "last", "max" ] }, "tooltip": { "mode": "multi", "sort": "desc" } }, "targets": [ @@ -166,12 +153,12 @@ "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10, "spanNulls": true }, "unit": "short" }, - "overrides": [] + "overrides": [ ] }, "gridPos": { "h": 9, "w": 12, "x": 12, "y": 16 }, "id": 5, "options": { - "legend": { "displayMode": "list", "placement": "bottom", "calcs": ["last", "max"] }, + "legend": { "displayMode": "list", "placement": "bottom", "calcs": [ "last", "max" ] }, "tooltip": { "mode": "single" } }, "targets": [ @@ -188,15 +175,12 @@ { "datasource": { "type": "prometheus", "uid": "prometheus" }, "fieldConfig": { - "defaults": { - "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10 }, - "unit": "short" - }, + "defaults": { "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10 }, "unit": "short" }, "overrides": [ { "matcher": { "id": "byName", "options": "avg" }, "properties": [ - { "id": "custom.lineStyle", "value": { "dash": [8, 4], "fill": "dash" } }, + { "id": "custom.lineStyle", "value": { "dash": [ 8, 4 ], "fill": "dash" } }, { "id": "color", "value": { "mode": "fixed", "fixedColor": "text" } } ] } @@ -205,7 +189,7 @@ "gridPos": { "h": 9, "w": 12, "x": 0, "y": 25 }, "id": 6, "options": { - "legend": { "displayMode": "table", "placement": "bottom", "calcs": ["last", "max"] }, + "legend": { "displayMode": "table", "placement": "bottom", "calcs": [ "last", "max" ] }, "tooltip": { "mode": "multi", "sort": "desc" } }, "targets": [ @@ -246,25 +230,22 @@ { "datasource": { "type": "prometheus", "uid": "prometheus" }, "fieldConfig": { - "defaults": { - "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10 }, - "unit": "short" - }, + "defaults": { "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10 }, "unit": "short" }, "overrides": [ { "matcher": { "id": "byName", "options": "failed" }, - "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } }] + "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] }, { "matcher": { "id": "byName", "options": "submitted" }, - "properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } }] + "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] } ] }, "gridPos": { "h": 9, "w": 12, "x": 12, "y": 25 }, "id": 7, "options": { - "legend": { "displayMode": "table", "placement": "bottom", "calcs": ["last", "max"] }, + "legend": { "displayMode": "table", "placement": "bottom", "calcs": [ "last", "max" ] }, "tooltip": { "mode": "multi", "sort": "desc" } }, "targets": [ @@ -287,8 +268,8 @@ ], "refresh": "5s", "schemaVersion": 39, - "tags": ["sequencer"], - "templating": { "list": [] }, + "tags": [ "sequencer" ], + "templating": { "list": [ ] }, "time": { "from": "now-15m", "to": "now" }, "timezone": "", "title": "Sequencer", diff --git a/tools/dashboard_gen/Cargo.toml b/tools/dashboard_gen/Cargo.toml new file mode 100644 index 00000000..d05ac951 --- /dev/null +++ b/tools/dashboard_gen/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "dashboard_gen" +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. + +[dependencies] +serde = { workspace = true, features = ["derive", "alloc"] } +serde_json.workspace = true +json-pretty-compact = "0.1.2" diff --git a/tools/dashboard_gen/src/lib.rs b/tools/dashboard_gen/src/lib.rs new file mode 100644 index 00000000..fad718e8 --- /dev/null +++ b/tools/dashboard_gen/src/lib.rs @@ -0,0 +1,397 @@ +//! A tiny, hand-rolled Grafana dashboard builder. +//! +//! This is a deliberately small subset of what the (Rust-less) Grafana +//! Foundation SDK does: model only the panel types and options we actually use, +//! expose a fluent builder, and render to the same dashboard JSON Grafana +//! provisions. The payoff over hand-written JSON: +//! +//! * metric names live in Rust `const`s, so a rename is a compile error here; +//! * repetitive structure (percentile targets, grid layout, legend/tooltip defaults) collapses into +//! a single call instead of copy-pasted JSON. +//! +//! Build a [`Dashboard`] and serialize it directly. + +use schema::{ + Calc, Color, 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, +}; +use serde::Serialize; + +mod schema; + +/// 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")] +pub struct Target { + datasource: Datasource, + expr: String, + #[serde(rename = "legendFormat")] + legend: String, + ref_id: String, +} + +impl Target { + pub fn new(expr: impl Into) -> Self { + Self { + datasource: Datasource::prometheus(), + expr: expr.into(), + legend: String::new(), + ref_id: ref_letter(0), + } + } + + pub fn legend(mut self, legend: impl Into) -> Self { + self.legend = legend.into(); + self + } +} + +/// A per-series style override, matched by series name. +#[derive(Serialize)] +pub struct FieldOverride { + matcher: Matcher, + properties: Vec, +} + +impl FieldOverride { + pub fn by_name(name: impl Into) -> Self { + Self { + matcher: Matcher { + id: MatcherKind::ByName, + options: name.into(), + }, + properties: Vec::new(), + } + } + + pub fn fixed_color(mut self, color: impl Into) -> Self { + self.properties.push(OverrideProperty { + id: PropertyId::Color, + value: PropertyValue::Color(Color::fixed(color.into())), + }); + self + } + + pub fn dashed_line(mut self) -> Self { + self.properties.push(OverrideProperty { + id: PropertyId::LineStyle, + value: PropertyValue::LineStyle(LineStyle { + dash: [8, 4], + fill: Fill::Dash, + }), + }); + self + } +} + +#[derive(Clone, Copy)] +enum Kind { + Stat, + TimeSeries, +} + +/// A dashboard panel builder. Grid position and panel id are assigned by +/// [`Dashboard::row`]; everything else is set here. +pub struct Panel { + title: String, + kind: Kind, + targets: Vec, + width: u32, + unit: Option, + decimals: Option, + fixed_color: Option, + span_nulls: bool, + overrides: Vec, +} + +impl Panel { + fn new(title: impl Into, kind: Kind) -> Self { + Self { + title: title.into(), + kind, + targets: Vec::new(), + width: 0, + unit: None, + decimals: None, + fixed_color: None, + span_nulls: false, + overrides: Vec::new(), + } + } + + /// A single big-number panel. + pub fn stat(title: impl Into) -> Self { + Self::new(title, Kind::Stat) + } + + /// A time-series line panel. + pub fn timeseries(title: impl Into) -> Self { + Self::new(title, Kind::TimeSeries) + } + + /// 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 { + self.width = width; + self + } + + pub fn unit(mut self, unit: impl Into) -> Self { + self.unit = Some(unit.into()); + self + } + + pub 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()); + self + } + + pub fn span_nulls(mut self) -> Self { + self.span_nulls = true; + self + } + + pub fn target(mut self, target: Target) -> Self { + self.targets.push(target); + self + } + + pub fn targets(mut self, targets: impl IntoIterator) -> Self { + self.targets.extend(targets); + self + } + + pub fn with_override(mut self, over: FieldOverride) -> Self { + self.overrides.push(over); + self + } + + fn finalize(self, id: u32, grid_pos: GridPos) -> PanelModel { + let targets: Vec = self + .targets + .into_iter() + .enumerate() + .map(|(i, mut t)| { + t.ref_id = ref_letter(i); + t + }) + .collect(); + + let unit = self.unit.unwrap_or_else(default_unit); + let (defaults, options, panel_type) = match self.kind { + Kind::Stat => { + let defaults = Defaults { + color: self.fixed_color.map(Color::fixed), + custom: None, + unit, + decimals: self.decimals, + }; + let options = Options::Stat(StatOptions { + color_mode: StatColorMode::Value, + graph_mode: GraphMode::Area, + reduce_options: ReduceOptions { + calcs: vec![Calc::LastNotNull], + fields: "", + values: false, + }, + }); + (defaults, options, PanelType::Stat) + } + Kind::TimeSeries => { + let defaults = Defaults { + color: None, + custom: Some(Custom { + draw_style: DrawStyle::Line, + line_width: 1, + fill_opacity: 10, + span_nulls: self.span_nulls.then_some(true), + }), + unit, + decimals: None, + }; + // Panels with several series read better as a sortable table with + // a multi-series tooltip; single-series panels stay compact. + let multi = targets.len() > 1; + let options = Options::TimeSeries(TimeSeriesOptions { + legend: Legend { + display_mode: if multi { + LegendDisplay::Table + } else { + LegendDisplay::List + }, + placement: Placement::Bottom, + calcs: vec![Calc::Last, Calc::Max], + }, + tooltip: if multi { + Tooltip { + mode: TooltipMode::Multi, + sort: Some(SortOrder::Desc), + } + } else { + Tooltip { + mode: TooltipMode::Single, + sort: None, + } + }, + }); + (defaults, options, PanelType::Timeseries) + } + }; + + PanelModel { + datasource: Datasource::prometheus(), + field_config: FieldConfig { + defaults, + overrides: self.overrides, + }, + grid_pos, + id, + options, + targets, + title: self.title, + panel_type, + } + } +} + +/// A dashboard, built row by row. Serialize it directly to get the JSON. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Dashboard { + annotations: EmptyList, + editable: bool, + graph_tooltip: u32, + panels: Vec, + refresh: String, + schema_version: u32, + tags: Vec, + templating: EmptyList, + time: TimeRange, + timezone: String, + title: String, + uid: String, + + // Layout cursor โ€” not part of the dashboard schema. + #[serde(skip)] + next_id: u32, + #[serde(skip)] + cursor_y: u32, +} + +impl Dashboard { + pub fn new(title: impl Into, uid: impl Into) -> Self { + Self { + annotations: EmptyList::default(), + editable: true, + graph_tooltip: 1, + panels: Vec::new(), + refresh: "5s".to_owned(), + schema_version: 39, + tags: Vec::new(), + templating: EmptyList::default(), + time: TimeRange { + from: "now-15m", + to: "now", + }, + timezone: String::new(), + title: title.into(), + uid: uid.into(), + next_id: 1, + cursor_y: 0, + } + } + + pub fn tag(mut self, tag: impl Into) -> Self { + self.tags.push(tag.into()); + self + } + + pub fn refresh(mut self, refresh: impl Into) -> Self { + self.refresh = refresh.into(); + self + } + + /// 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. + 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 mut x = 0; + for panel in panels { + let w = if panel.width == 0 { + auto_width + } else { + panel.width + }; + let grid_pos = GridPos { + h: height, + w, + x, + y: self.cursor_y, + }; + let id = self.next_id; + self.next_id += 1; + x += w; + self.panels.push(panel.finalize(id, grid_pos)); + } + self.cursor_y += height; + self + } +} + +/// Percentile line targets for a summary metric: `p50`, `p90`, โ€ฆ each querying +/// the matching `quantile="0.x"` series. +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}}`). +pub fn percentiles_labeled(metric: &str, percentiles: &[u32], legend_suffix: &str) -> Vec { + percentiles + .iter() + .map(|&p| { + let quantile = f64::from(p) / 100.0; + Target::new(format!("{metric}{{quantile=\"{quantile}\"}}")) + .legend(format!("p{p}{legend_suffix}")) + }) + .collect() +} + +/// An `avg` target for a summary metric: `rate(sum) / rate(count)` over 1m. +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. +pub fn rate_per_min(metric: &str, legend: &str) -> Target { + Target::new(format!("rate({metric}[1m]) * 60")).legend(legend) +} diff --git a/tools/dashboard_gen/src/main.rs b/tools/dashboard_gen/src/main.rs new file mode 100644 index 00000000..f6d177bc --- /dev/null +++ b/tools/dashboard_gen/src/main.rs @@ -0,0 +1,111 @@ +//! 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 + +use dashboard_gen::{ + Dashboard, FieldOverride, Panel, Target, avg, percentiles, percentiles_labeled, rate_per_min, +}; +use json_pretty_compact::PrettyCompactFormatter; +use serde::Serialize as _; + +// Rendered Prometheus metric names. In a real setup these would be re-exported +// from `sequencer_core::metrics` (base name + unit/counter suffix rule) so that +// a rename in the recorder is a compile error here โ€” that is the whole point of +// generating dashboards from the same codebase that emits the metrics. +const BLOCK_COUNT: &str = "block_count_total"; +const BLOCK_CREATION_TIME: &str = "block_creation_time_seconds"; +const TX_APPLY_TIME: &str = "mempool_transaction_application_time_seconds"; +const MEMPOOL_SIZE: &str = "mempool_size"; +const TX_PER_BLOCK: &str = "transactions_per_block"; +const SUBMITTED_TX: &str = "submitted_transaction_count_total"; +const FAILED_TX: &str = "failed_transaction_count_total"; + +const PERCENTILES: &[u32] = &[50, 90, 95, 99]; + +fn sequencer_dashboard() -> Dashboard { + Dashboard::new("Sequencer", "sequencer") + .tag("sequencer") + .row( + 7, + [ + Panel::stat("Chain height") + .width(6) + .unit("short") + .decimals(0) + .fixed_color("blue") + .target(Target::new(BLOCK_COUNT).legend("height")), + Panel::timeseries("Block production rate") + .width(18) + .unit("short") + .target(rate_per_min(BLOCK_COUNT, "blocks/min")), + ], + ) + .row( + 9, + [Panel::timeseries("Block creation time") + .width(24) + .unit("s") + .targets(percentiles(BLOCK_CREATION_TIME, PERCENTILES)) + .target(avg(BLOCK_CREATION_TIME)) + .with_override( + FieldOverride::by_name("avg") + .dashed_line() + .fixed_color("text"), + )], + ) + .row( + 9, + [ + Panel::timeseries("Transaction application time") + .width(12) + .unit("s") + .targets(percentiles_labeled( + TX_APPLY_TIME, + PERCENTILES, + " ยท {{kind}} ยท {{origin}}", + )), + Panel::timeseries("Mempool size") + .width(12) + .unit("short") + .span_nulls() + .target(Target::new(MEMPOOL_SIZE).legend("mempool size")), + ], + ) + .row( + 9, + [ + Panel::timeseries("Transactions per block") + .width(12) + .unit("short") + .targets(percentiles(TX_PER_BLOCK, PERCENTILES)) + .target(avg(TX_PER_BLOCK)) + .with_override( + FieldOverride::by_name("avg") + .dashed_line() + .fixed_color("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")), + ], + ) +} + +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}"); +} diff --git a/tools/dashboard_gen/src/schema.rs b/tools/dashboard_gen/src/schema.rs new file mode 100644 index 00000000..3a925742 --- /dev/null +++ b/tools/dashboard_gen/src/schema.rs @@ -0,0 +1,260 @@ +//! The serializable Grafana dashboard schema โ€” the internal data model the +//! public builders assemble into. + +use serde::Serialize; + +use crate::{DATASOURCE_UID, FieldOverride, Target}; + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +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 { + Dash, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DrawStyle { + Line, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum MatcherKind { + ByName, +} + +#[derive(Clone, Copy, Serialize)] +pub enum PropertyId { + #[serde(rename = "color")] + Color, + #[serde(rename = "custom.lineStyle")] + LineStyle, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Calc { + LastNotNull, + Last, + Max, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum StatColorMode { + Value, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum GraphMode { + Area, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum LegendDisplay { + Table, + List, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Placement { + Bottom, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum TooltipMode { + Single, + Multi, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SortOrder { + Desc, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PanelType { + Stat, + Timeseries, +} + +#[derive(Clone, Serialize)] +pub struct Datasource { + #[serde(rename = "type")] + pub kind: DatasourceKind, + pub uid: &'static str, +} + +impl Datasource { + pub fn prometheus() -> Self { + Self { + kind: DatasourceKind::Prometheus, + uid: DATASOURCE_UID, + } + } +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Color { + pub mode: ColorMode, + pub fixed_color: String, +} + +impl Color { + pub fn fixed(color: String) -> Self { + Self { + mode: ColorMode::Fixed, + fixed_color: color, + } + } +} + +#[derive(Serialize)] +pub struct LineStyle { + pub dash: [u32; 2], + pub fill: Fill, +} + +#[derive(Serialize)] +#[serde(untagged)] +pub enum PropertyValue { + Color(Color), + LineStyle(LineStyle), +} + +#[derive(Serialize)] +pub struct OverrideProperty { + pub id: PropertyId, + pub value: PropertyValue, +} + +#[derive(Serialize)] +pub struct Matcher { + pub id: MatcherKind, + 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")] + pub color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub custom: Option, + pub unit: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub decimals: Option, +} + +#[derive(Serialize)] +pub struct FieldConfig { + pub defaults: Defaults, + pub overrides: Vec, +} + +#[derive(Serialize)] +pub struct ReduceOptions { + pub calcs: Vec, + // Empty string means "all fields"; genuinely free-form, not a vocabulary. + pub fields: &'static str, + pub values: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StatOptions { + pub color_mode: StatColorMode, + pub graph_mode: GraphMode, + pub reduce_options: ReduceOptions, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Legend { + pub display_mode: LegendDisplay, + pub placement: Placement, + pub calcs: Vec, +} + +#[derive(Serialize)] +pub struct Tooltip { + pub mode: TooltipMode, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort: Option, +} + +#[derive(Serialize)] +pub struct TimeSeriesOptions { + pub legend: Legend, + pub tooltip: Tooltip, +} + +#[derive(Serialize)] +#[serde(untagged)] +pub enum Options { + Stat(StatOptions), + TimeSeries(TimeSeriesOptions), +} + +#[derive(Clone, Copy, Serialize)] +pub struct GridPos { + pub h: u32, + pub w: u32, + pub x: u32, + pub y: u32, +} + +/// A fully positioned panel, ready to serialize. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PanelModel { + pub datasource: Datasource, + pub field_config: FieldConfig, + pub grid_pos: GridPos, + pub id: u32, + pub options: Options, + pub targets: Vec, + pub title: String, + #[serde(rename = "type")] + pub panel_type: PanelType, +} + +#[derive(Serialize, Default)] +pub struct EmptyList { + pub list: [u8; 0], +} + +#[derive(Serialize)] +pub struct TimeRange { + // Free-form Grafana time expressions, not a closed vocabulary. + pub from: &'static str, + pub to: &'static str, +}