From 849bb6bbe1f040f0908b2c0ddaa053eddb58549c Mon Sep 17 00:00:00 2001 From: Daniil Polyakov Date: Tue, 28 Jul 2026 17:06:18 +0300 Subject: [PATCH] feat(metrics): improve mempool and failed transaction metrics & panels --- lez/sequencer/core/metrics/src/names.rs | 1 + lez/sequencer/core/metrics/src/writing.rs | 9 + lez/sequencer/core/src/lib.rs | 1 + monitoring/grafana/dashboards/sequencer.json | 187 +++++++++++++----- .../src/bin/gen_sequencer_dashboard.rs | 90 +++++++-- tools/dashboard_gen/src/codegen.rs | 79 ++++++-- tools/dashboard_gen/src/input.rs | 7 + tools/dashboard_gen/src/lib.rs | 73 ++++++- tools/dashboard_gen/src/schema.rs | 63 ++++++ 9 files changed, 427 insertions(+), 83 deletions(-) diff --git a/lez/sequencer/core/metrics/src/names.rs b/lez/sequencer/core/metrics/src/names.rs index 1ea87c26..a8aae270 100644 --- a/lez/sequencer/core/metrics/src/names.rs +++ b/lez/sequencer/core/metrics/src/names.rs @@ -1,6 +1,7 @@ pub const BLOCK_CREATION_TIME: &str = "block_creation_time"; pub const BLOCK_COUNT: &str = "block_count"; pub const MEMPOOL_SIZE: &str = "mempool_size"; +pub const MEMPOOL_MAX_SIZE: &str = "mempool_max_size"; pub const MEMPOOL_TRANSACTION_APPLICATION_TIME: &str = "mempool_transaction_application_time"; pub const TRANSACTIONS_PER_BLOCK: &str = "transactions_per_block"; pub const FAILED_TRANSACTION_COUNT: &str = "failed_transaction_count"; diff --git a/lez/sequencer/core/metrics/src/writing.rs b/lez/sequencer/core/metrics/src/writing.rs index 2a392b84..320e01fb 100644 --- a/lez/sequencer/core/metrics/src/writing.rs +++ b/lez/sequencer/core/metrics/src/writing.rs @@ -51,6 +51,15 @@ pub fn record_mempool_size(size: usize) { .set(u64::try_from(size).expect("Mempool size should fit into u64") as f64); } +pub fn record_mempool_max_size(size: usize) { + gauge!( + description: "Configured maximum size of the mempool", + unit: Unit::Count, + names::MEMPOOL_MAX_SIZE + ) + .set(u64::try_from(size).expect("Mempool max size should fit into u64") as f64); +} + pub fn record_mempool_transaction_application_time( origin: TransactionOrigin, kind: TxKind, diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 7cbe8fa7..d20c46cc 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -224,6 +224,7 @@ impl SequencerCore { let is_fresh_start = initial_checkpoint.is_none(); let (mempool, mempool_handle) = MemPool::new(config.mempool_max_size); + sequencer_core_metrics::record_mempool_max_size(config.mempool_max_size); let block_publisher = BP::new( &config.bedrock_config, diff --git a/monitoring/grafana/dashboards/sequencer.json b/monitoring/grafana/dashboards/sequencer.json index 3cd0fbcc..c2711d73 100644 --- a/monitoring/grafana/dashboards/sequencer.json +++ b/monitoring/grafana/dashboards/sequencer.json @@ -19,7 +19,7 @@ "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "block_count_total", + "expr": "block_count", "legendFormat": "height", "refId": "A" } @@ -42,7 +42,7 @@ "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "rate(block_count_total[1m]) * 60", + "expr": "rate(block_count[1m]) * 60", "legendFormat": "blocks/min", "refId": "A" } @@ -73,31 +73,31 @@ "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "block_creation_time_seconds{quantile=\"0.5\"}", + "expr": "block_creation_time{quantile=\"0.5\"}", "legendFormat": "p50", "refId": "A" }, { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "block_creation_time_seconds{quantile=\"0.9\"}", + "expr": "block_creation_time{quantile=\"0.9\"}", "legendFormat": "p90", "refId": "B" }, { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "block_creation_time_seconds{quantile=\"0.95\"}", + "expr": "block_creation_time{quantile=\"0.95\"}", "legendFormat": "p95", "refId": "C" }, { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "block_creation_time_seconds{quantile=\"0.99\"}", + "expr": "block_creation_time{quantile=\"0.99\"}", "legendFormat": "p99", "refId": "D" }, { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "rate(block_creation_time_seconds_sum[1m]) / rate(block_creation_time_seconds_count[1m])", + "expr": "rate(block_creation_time_sum[1m]) / rate(block_creation_time_count[1m])", "legendFormat": "avg", "refId": "E" } @@ -120,25 +120,25 @@ "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "mempool_transaction_application_time_seconds{quantile=\"0.5\"}", + "expr": "mempool_transaction_application_time{quantile=\"0.5\"}", "legendFormat": "p50 · {{kind}} · {{origin}}", "refId": "A" }, { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "mempool_transaction_application_time_seconds{quantile=\"0.9\"}", + "expr": "mempool_transaction_application_time{quantile=\"0.9\"}", "legendFormat": "p90 · {{kind}} · {{origin}}", "refId": "B" }, { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "mempool_transaction_application_time_seconds{quantile=\"0.95\"}", + "expr": "mempool_transaction_application_time{quantile=\"0.95\"}", "legendFormat": "p95 · {{kind}} · {{origin}}", "refId": "C" }, { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "mempool_transaction_application_time_seconds{quantile=\"0.99\"}", + "expr": "mempool_transaction_application_time{quantile=\"0.99\"}", "legendFormat": "p99 · {{kind}} · {{origin}}", "refId": "D" } @@ -146,32 +146,6 @@ "title": "Transaction application time", "type": "timeseries" }, - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "fieldConfig": { - "defaults": { - "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10, "spanNulls": true }, - "unit": "short" - }, - "overrides": [ ] - }, - "gridPos": { "h": 9, "w": 12, "x": 12, "y": 16 }, - "id": 5, - "options": { - "legend": { "displayMode": "list", "placement": "bottom", "calcs": [ "last", "max" ] }, - "tooltip": { "mode": "single" } - }, - "targets": [ - { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "mempool_size", - "legendFormat": "mempool size", - "refId": "A" - } - ], - "title": "Mempool size", - "type": "timeseries" - }, { "datasource": { "type": "prometheus", "uid": "prometheus" }, "fieldConfig": { @@ -186,8 +160,8 @@ } ] }, - "gridPos": { "h": 9, "w": 12, "x": 0, "y": 25 }, - "id": 6, + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 16 }, + "id": 5, "options": { "legend": { "displayMode": "table", "placement": "bottom", "calcs": [ "last", "max" ] }, "tooltip": { "mode": "multi", "sort": "desc" } @@ -230,7 +204,130 @@ { "datasource": { "type": "prometheus", "uid": "prometheus" }, "fieldConfig": { - "defaults": { "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10 }, "unit": "short" }, + "defaults": { + "unit": "percent", + "decimals": 1, + "min": 0.0, + "max": 100.0, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 70.0 }, + { "color": "red", "value": 90.0 } + ] + } + }, + "overrides": [ ] + }, + "gridPos": { "h": 8, "w": 6, "x": 0, "y": 25 }, + "id": 6, + "options": { + "reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "100 * mempool_size / mempool_max_size", + "legendFormat": "utilization", + "refId": "A" + } + ], + "title": "Mempool utilization", + "type": "gauge" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10, "spanNulls": true }, + "unit": "short", + "min": 0.0 + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "capacity" }, + "properties": [ + { "id": "custom.lineStyle", "value": { "dash": [ 8, 4 ], "fill": "dash" } }, + { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } + ] + }, + { + "matcher": { "id": "byName", "options": "queued" }, + "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } } ] + } + ] + }, + "gridPos": { "h": 8, "w": 18, "x": 6, "y": 25 }, + "id": 7, + "options": { + "legend": { "displayMode": "table", "placement": "bottom", "calcs": [ "last", "max" ] }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "mempool_size", + "legendFormat": "queued", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "mempool_max_size", + "legendFormat": "capacity", + "refId": "B" + } + ], + "title": "Mempool size vs capacity", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "decimals": 2, + "min": 0.0, + "max": 100.0, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 1.0 }, + { "color": "red", "value": 5.0 } + ] + } + }, + "overrides": [ ] + }, + "gridPos": { "h": 8, "w": 6, "x": 0, "y": 33 }, + "id": 8, + "options": { + "reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "100 * increase(failed_transaction_count[$__range]) / clamp_min(increase(submitted_transaction_count[$__range]), 1)", + "legendFormat": "failed", + "refId": "A" + } + ], + "title": "Failed transactions share", + "type": "gauge" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10 }, + "unit": "short", + "min": 0.0 + }, "overrides": [ { "matcher": { "id": "byName", "options": "failed" }, @@ -242,8 +339,8 @@ } ] }, - "gridPos": { "h": 9, "w": 12, "x": 12, "y": 25 }, - "id": 7, + "gridPos": { "h": 8, "w": 18, "x": 6, "y": 33 }, + "id": 9, "options": { "legend": { "displayMode": "table", "placement": "bottom", "calcs": [ "last", "max" ] }, "tooltip": { "mode": "multi", "sort": "desc" } @@ -251,18 +348,18 @@ "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "rate(submitted_transaction_count_total[1m]) * 60", + "expr": "rate(submitted_transaction_count[1m]) * 60", "legendFormat": "submitted", "refId": "A" }, { "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "rate(failed_transaction_count_total[1m]) * 60", + "expr": "rate(failed_transaction_count[1m]) * 60", "legendFormat": "failed", "refId": "B" } ], - "title": "Transaction throughput (per minute)", + "title": "Submitted vs failed transactions (per minute)", "type": "timeseries" } ], diff --git a/tools/dashboard_gen/src/bin/gen_sequencer_dashboard.rs b/tools/dashboard_gen/src/bin/gen_sequencer_dashboard.rs index 4e0716ab..da44f508 100644 --- a/tools/dashboard_gen/src/bin/gen_sequencer_dashboard.rs +++ b/tools/dashboard_gen/src/bin/gen_sequencer_dashboard.rs @@ -10,8 +10,8 @@ )] use dashboard_gen::{ - Color, Dashboard, FieldOverride, Panel, Target, Unit, avg, percentiles, percentiles_labeled, - rate_per_min, + Color, Dashboard, FieldOverride, Panel, Target, Thresholds, Unit, avg, percentiles, + percentiles_labeled, rate_per_min, }; use json_pretty_compact::PrettyCompactFormatter; use serde::Serialize as _; @@ -68,19 +68,6 @@ fn sequencer_dashboard() -> Dashboard { PERCENTILES, " · {{kind}} · {{origin}}", )), - Panel::timeseries("Mempool size") - .width(12) - .unit(Unit::Short) - .span_nulls() - .target( - Target::new(sequencer_core_metrics::names::MEMPOOL_SIZE) - .legend("mempool size"), - ), - ], - ) - .row( - 9, - [ Panel::timeseries("Transactions per block") .width(12) .unit(Unit::Short) @@ -94,9 +81,78 @@ fn sequencer_dashboard() -> Dashboard { .dashed_line() .color(Color::fixed("text")), ), - Panel::timeseries("Transaction throughput (per minute)") - .width(12) + ], + ) + .row( + 8, + [ + Panel::gauge("Mempool utilization") + .width(6) + .unit(Unit::Percent) + .decimals(1) + .min(0.0) + .max(100.0) + .thresholds( + Thresholds::base("green") + .step(70.0, "orange") + .step(90.0, "red"), + ) + .target( + Target::new(format!( + "100 * {size} / {max_size}", + size = sequencer_core_metrics::names::MEMPOOL_SIZE, + max_size = sequencer_core_metrics::names::MEMPOOL_MAX_SIZE, + )) + .legend("utilization"), + ), + Panel::timeseries("Mempool size vs capacity") + .width(18) .unit(Unit::Short) + .span_nulls() + .min(0.0) + .target( + Target::new(sequencer_core_metrics::names::MEMPOOL_SIZE).legend("queued"), + ) + .target( + Target::new(sequencer_core_metrics::names::MEMPOOL_MAX_SIZE) + .legend("capacity"), + ) + .with_override( + FieldOverride::by_name("capacity") + .dashed_line() + .color(Color::fixed("red")), + ) + .with_override(FieldOverride::by_name("queued").color(Color::fixed("blue"))), + ], + ) + .row( + 8, + [ + Panel::gauge("Failed transactions share") + .width(6) + .unit(Unit::Percent) + .decimals(2) + .min(0.0) + .max(100.0) + .thresholds( + Thresholds::base("green") + .step(1.0, "orange") + .step(5.0, "red"), + ) + .target( + Target::new(format!( + // `clamp_min` keeps an idle window (nothing submitted) + // reading as 0% instead of a division by zero. + "100 * increase({failed}[$__range]) / clamp_min(increase({submitted}[$__range]), 1)", + failed = sequencer_core_metrics::names::FAILED_TRANSACTION_COUNT, + submitted = sequencer_service_metrics::names::SUBMITTED_TRANSACTION_COUNT, + )) + .legend("failed"), + ), + Panel::timeseries("Submitted vs failed transactions (per minute)") + .width(18) + .unit(Unit::Short) + .min(0.0) .target(rate_per_min( sequencer_service_metrics::names::SUBMITTED_TRANSACTION_COUNT, "submitted", diff --git a/tools/dashboard_gen/src/codegen.rs b/tools/dashboard_gen/src/codegen.rs index 15f79cb4..b554ffd6 100644 --- a/tools/dashboard_gen/src/codegen.rs +++ b/tools/dashboard_gen/src/codegen.rs @@ -11,9 +11,10 @@ use std::fmt::Write as _; use crate::{ Unit, - input::PanelInput, + input::{Defaults, PanelInput}, schema::{ AxisPlacement, Color, GradientMode, LineInterpolation, PanelType, ShowPoints, StackingMode, + ThresholdMode, Thresholds, }, }; @@ -33,25 +34,12 @@ fn panel_expr_inner(panel: &PanelInput) -> Result { let mut expr = match panel.panel_type { PanelType::Stat => format!("Panel::stat({:?})", panel.title), PanelType::Timeseries => format!("Panel::timeseries({:?})", panel.title), + PanelType::Gauge => format!("Panel::gauge({:?})", 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::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:?}))")?; - } + write_defaults(&mut expr, defaults)?; if let Some(custom) = &defaults.custom { if custom.span_nulls == Some(true) { @@ -146,6 +134,65 @@ fn panel_expr_inner(panel: &PanelInput) -> Result { 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", diff --git a/tools/dashboard_gen/src/input.rs b/tools/dashboard_gen/src/input.rs index e35a3ca3..ea3c4f78 100644 --- a/tools/dashboard_gen/src/input.rs +++ b/tools/dashboard_gen/src/input.rs @@ -14,6 +14,7 @@ use serde_json::Value; use crate::schema::{ AxisPlacement, Color, GradientMode, LineInterpolation, PanelType, ShowPoints, StackingMode, + Thresholds, }; #[derive(Deserialize)] @@ -54,6 +55,12 @@ pub struct Defaults { pub color: Option, #[serde(default)] pub custom: Option, + #[serde(default)] + pub min: Option, + #[serde(default)] + pub max: Option, + #[serde(default)] + pub thresholds: Option, } #[derive(Deserialize, Default)] diff --git a/tools/dashboard_gen/src/lib.rs b/tools/dashboard_gen/src/lib.rs index 918061a0..d1fb38c9 100644 --- a/tools/dashboard_gen/src/lib.rs +++ b/tools/dashboard_gen/src/lib.rs @@ -14,12 +14,15 @@ // 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}; +pub use schema::{ + AxisPlacement, Color, GradientMode, LineInterpolation, ShowPoints, StackingMode, Thresholds, +}; use schema::{ - Calc, Custom, Datasource, Defaults, DrawStyle, EmptyList, FieldConfig, Fill, GraphMode, - GridPos, Legend, LegendDisplay, LineStyle, Matcher, MatcherKind, Options, OverrideProperty, - PanelModel, PanelType, Placement, PropertyId, PropertyValue, ReduceOptions, SortOrder, - Stacking, StatColorMode, StatOptions, TimeRange, TimeSeriesOptions, Tooltip, TooltipMode, + Calc, Custom, Datasource, Defaults, DrawStyle, EmptyList, FieldConfig, Fill, GaugeOptions, + GraphMode, GridPos, Legend, LegendDisplay, LineStyle, Matcher, MatcherKind, Options, + OverrideProperty, PanelModel, PanelType, Placement, PropertyId, PropertyValue, ReduceOptions, + SortOrder, Stacking, StatColorMode, StatOptions, TimeRange, TimeSeriesOptions, Tooltip, + TooltipMode, }; use serde::Serialize; pub use unit::Unit; @@ -107,6 +110,7 @@ impl FieldOverride { enum Kind { Stat, TimeSeries, + Gauge, } /// A dashboard panel builder. Grid position and panel id are assigned by @@ -119,6 +123,9 @@ pub struct Panel { unit: Option, decimals: Option, color: Option, + min: Option, + max: Option, + thresholds: Option, span_nulls: bool, overrides: Vec, // Optional timeseries styling, set via the `styling` setters. @@ -140,6 +147,9 @@ impl Panel { unit: None, decimals: None, color: None, + min: None, + max: None, + thresholds: None, span_nulls: false, overrides: Vec::new(), line_interpolation: None, @@ -161,6 +171,12 @@ impl Panel { Self::new(title, Kind::TimeSeries) } + /// A radial gauge panel. Pair it with [`Panel::min`]/[`Panel::max`] — the + /// dial needs a range to fill — and [`Panel::thresholds`] for its coloring. + pub fn gauge(title: impl Into) -> Self { + Self::new(title, Kind::Gauge) + } + /// Grid width in Grafana's 24-column units. Unset panels split the row's /// remaining width evenly. #[must_use] @@ -187,6 +203,26 @@ impl Panel { self } + /// Lower bound of the value scale. + #[must_use] + pub const fn min(mut self, min: f64) -> Self { + self.min = Some(min); + self + } + + /// Upper bound of the value scale. + #[must_use] + pub const fn max(mut self, max: f64) -> Self { + self.max = Some(max); + self + } + + #[must_use] + pub fn thresholds(mut self, thresholds: Thresholds) -> Self { + self.thresholds = Some(thresholds); + self + } + #[must_use] pub const fn span_nulls(mut self) -> Self { self.span_nulls = true; @@ -230,6 +266,9 @@ impl Panel { custom: None, unit, decimals: self.decimals, + min: self.min, + max: self.max, + thresholds: self.thresholds, }; let options = Options::Stat(StatOptions { color_mode: StatColorMode::Value, @@ -242,6 +281,27 @@ impl Panel { }); (defaults, options, PanelType::Stat) } + Kind::Gauge => { + let defaults = Defaults { + color: self.color, + custom: None, + unit, + decimals: self.decimals, + min: self.min, + max: self.max, + thresholds: self.thresholds, + }; + let options = Options::Gauge(GaugeOptions { + reduce_options: ReduceOptions { + calcs: vec![Calc::LastNotNull], + fields: String::new(), + values: false, + }, + show_threshold_labels: false, + show_threshold_markers: true, + }); + (defaults, options, PanelType::Gauge) + } Kind::TimeSeries => { let defaults = Defaults { color: None, @@ -262,6 +322,9 @@ impl Panel { }), unit, decimals: None, + min: self.min, + max: self.max, + thresholds: self.thresholds, }; // Panels with several series read better as a sortable table with // a multi-series tooltip; single-series panels stay compact. diff --git a/tools/dashboard_gen/src/schema.rs b/tools/dashboard_gen/src/schema.rs index 88d24bd8..009c7c40 100644 --- a/tools/dashboard_gen/src/schema.rs +++ b/tools/dashboard_gen/src/schema.rs @@ -94,6 +94,54 @@ pub enum SortOrder { pub enum PanelType { Stat, Timeseries, + Gauge, +} + +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ThresholdMode { + Absolute, + /// Steps expressed as a percentage of the min–max range. The builder never + /// emits this; it exists so Grafana exports using it still parse. + Percentage, +} + +/// 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)] +pub struct ThresholdStep { + pub color: String, + pub value: Option, +} + +/// A threshold ladder, driving gauge/stat coloring. +#[derive(Clone, Serialize, Deserialize)] +pub struct Thresholds { + pub mode: ThresholdMode, + pub steps: Vec, +} + +impl Thresholds { + /// Start a ladder with the color used below every threshold. + pub fn base(color: impl Into) -> Self { + Self { + mode: ThresholdMode::Absolute, + steps: vec![ThresholdStep { + color: color.into(), + value: None, + }], + } + } + + /// Add a step: values at or above `value` render in `color`. + #[must_use] + pub fn step(mut self, value: f64, color: impl Into) -> Self { + self.steps.push(ThresholdStep { + color: color.into(), + value: Some(value), + }); + self + } } // Optional timeseries styling vocabularies. Each derives `PartialEq` so the @@ -248,6 +296,12 @@ pub struct Defaults { pub unit: Unit, #[serde(default, skip_serializing_if = "Option::is_none")] pub decimals: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thresholds: Option, } #[derive(Serialize)] @@ -293,11 +347,20 @@ pub struct TimeSeriesOptions { pub tooltip: Tooltip, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GaugeOptions { + pub reduce_options: ReduceOptions, + pub show_threshold_labels: bool, + pub show_threshold_markers: bool, +} + #[derive(Serialize)] #[serde(untagged)] pub enum Options { Stat(StatOptions), TimeSeries(TimeSeriesOptions), + Gauge(GaugeOptions), } #[derive(Clone, Copy, Serialize)]