feat(metrics): improve mempool and failed transaction metrics & panels

This commit is contained in:
Daniil Polyakov 2026-07-28 17:06:18 +03:00
parent ab12fb104c
commit 849bb6bbe1
9 changed files with 427 additions and 83 deletions

View File

@ -1,6 +1,7 @@
pub const BLOCK_CREATION_TIME: &str = "block_creation_time"; pub const BLOCK_CREATION_TIME: &str = "block_creation_time";
pub const BLOCK_COUNT: &str = "block_count"; pub const BLOCK_COUNT: &str = "block_count";
pub const MEMPOOL_SIZE: &str = "mempool_size"; 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 MEMPOOL_TRANSACTION_APPLICATION_TIME: &str = "mempool_transaction_application_time";
pub const TRANSACTIONS_PER_BLOCK: &str = "transactions_per_block"; pub const TRANSACTIONS_PER_BLOCK: &str = "transactions_per_block";
pub const FAILED_TRANSACTION_COUNT: &str = "failed_transaction_count"; pub const FAILED_TRANSACTION_COUNT: &str = "failed_transaction_count";

View File

@ -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); .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( pub fn record_mempool_transaction_application_time(
origin: TransactionOrigin, origin: TransactionOrigin,
kind: TxKind, kind: TxKind,

View File

@ -224,6 +224,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
let is_fresh_start = initial_checkpoint.is_none(); let is_fresh_start = initial_checkpoint.is_none();
let (mempool, mempool_handle) = MemPool::new(config.mempool_max_size); 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( let block_publisher = BP::new(
&config.bedrock_config, &config.bedrock_config,

View File

@ -19,7 +19,7 @@
"targets": [ "targets": [
{ {
"datasource": { "type": "prometheus", "uid": "prometheus" }, "datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "block_count_total", "expr": "block_count",
"legendFormat": "height", "legendFormat": "height",
"refId": "A" "refId": "A"
} }
@ -42,7 +42,7 @@
"targets": [ "targets": [
{ {
"datasource": { "type": "prometheus", "uid": "prometheus" }, "datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "rate(block_count_total[1m]) * 60", "expr": "rate(block_count[1m]) * 60",
"legendFormat": "blocks/min", "legendFormat": "blocks/min",
"refId": "A" "refId": "A"
} }
@ -73,31 +73,31 @@
"targets": [ "targets": [
{ {
"datasource": { "type": "prometheus", "uid": "prometheus" }, "datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "block_creation_time_seconds{quantile=\"0.5\"}", "expr": "block_creation_time{quantile=\"0.5\"}",
"legendFormat": "p50", "legendFormat": "p50",
"refId": "A" "refId": "A"
}, },
{ {
"datasource": { "type": "prometheus", "uid": "prometheus" }, "datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "block_creation_time_seconds{quantile=\"0.9\"}", "expr": "block_creation_time{quantile=\"0.9\"}",
"legendFormat": "p90", "legendFormat": "p90",
"refId": "B" "refId": "B"
}, },
{ {
"datasource": { "type": "prometheus", "uid": "prometheus" }, "datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "block_creation_time_seconds{quantile=\"0.95\"}", "expr": "block_creation_time{quantile=\"0.95\"}",
"legendFormat": "p95", "legendFormat": "p95",
"refId": "C" "refId": "C"
}, },
{ {
"datasource": { "type": "prometheus", "uid": "prometheus" }, "datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "block_creation_time_seconds{quantile=\"0.99\"}", "expr": "block_creation_time{quantile=\"0.99\"}",
"legendFormat": "p99", "legendFormat": "p99",
"refId": "D" "refId": "D"
}, },
{ {
"datasource": { "type": "prometheus", "uid": "prometheus" }, "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", "legendFormat": "avg",
"refId": "E" "refId": "E"
} }
@ -120,25 +120,25 @@
"targets": [ "targets": [
{ {
"datasource": { "type": "prometheus", "uid": "prometheus" }, "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}}", "legendFormat": "p50 · {{kind}} · {{origin}}",
"refId": "A" "refId": "A"
}, },
{ {
"datasource": { "type": "prometheus", "uid": "prometheus" }, "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}}", "legendFormat": "p90 · {{kind}} · {{origin}}",
"refId": "B" "refId": "B"
}, },
{ {
"datasource": { "type": "prometheus", "uid": "prometheus" }, "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}}", "legendFormat": "p95 · {{kind}} · {{origin}}",
"refId": "C" "refId": "C"
}, },
{ {
"datasource": { "type": "prometheus", "uid": "prometheus" }, "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}}", "legendFormat": "p99 · {{kind}} · {{origin}}",
"refId": "D" "refId": "D"
} }
@ -146,32 +146,6 @@
"title": "Transaction application time", "title": "Transaction application time",
"type": "timeseries" "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" }, "datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": { "fieldConfig": {
@ -186,8 +160,8 @@
} }
] ]
}, },
"gridPos": { "h": 9, "w": 12, "x": 0, "y": 25 }, "gridPos": { "h": 9, "w": 12, "x": 12, "y": 16 },
"id": 6, "id": 5,
"options": { "options": {
"legend": { "displayMode": "table", "placement": "bottom", "calcs": [ "last", "max" ] }, "legend": { "displayMode": "table", "placement": "bottom", "calcs": [ "last", "max" ] },
"tooltip": { "mode": "multi", "sort": "desc" } "tooltip": { "mode": "multi", "sort": "desc" }
@ -230,7 +204,130 @@
{ {
"datasource": { "type": "prometheus", "uid": "prometheus" }, "datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": { "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": [ "overrides": [
{ {
"matcher": { "id": "byName", "options": "failed" }, "matcher": { "id": "byName", "options": "failed" },
@ -242,8 +339,8 @@
} }
] ]
}, },
"gridPos": { "h": 9, "w": 12, "x": 12, "y": 25 }, "gridPos": { "h": 8, "w": 18, "x": 6, "y": 33 },
"id": 7, "id": 9,
"options": { "options": {
"legend": { "displayMode": "table", "placement": "bottom", "calcs": [ "last", "max" ] }, "legend": { "displayMode": "table", "placement": "bottom", "calcs": [ "last", "max" ] },
"tooltip": { "mode": "multi", "sort": "desc" } "tooltip": { "mode": "multi", "sort": "desc" }
@ -251,18 +348,18 @@
"targets": [ "targets": [
{ {
"datasource": { "type": "prometheus", "uid": "prometheus" }, "datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "rate(submitted_transaction_count_total[1m]) * 60", "expr": "rate(submitted_transaction_count[1m]) * 60",
"legendFormat": "submitted", "legendFormat": "submitted",
"refId": "A" "refId": "A"
}, },
{ {
"datasource": { "type": "prometheus", "uid": "prometheus" }, "datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "rate(failed_transaction_count_total[1m]) * 60", "expr": "rate(failed_transaction_count[1m]) * 60",
"legendFormat": "failed", "legendFormat": "failed",
"refId": "B" "refId": "B"
} }
], ],
"title": "Transaction throughput (per minute)", "title": "Submitted vs failed transactions (per minute)",
"type": "timeseries" "type": "timeseries"
} }
], ],

View File

@ -10,8 +10,8 @@
)] )]
use dashboard_gen::{ use dashboard_gen::{
Color, Dashboard, FieldOverride, Panel, Target, Unit, avg, percentiles, percentiles_labeled, Color, Dashboard, FieldOverride, Panel, Target, Thresholds, Unit, avg, percentiles,
rate_per_min, percentiles_labeled, rate_per_min,
}; };
use json_pretty_compact::PrettyCompactFormatter; use json_pretty_compact::PrettyCompactFormatter;
use serde::Serialize as _; use serde::Serialize as _;
@ -68,19 +68,6 @@ fn sequencer_dashboard() -> Dashboard {
PERCENTILES, PERCENTILES,
" · {{kind}} · {{origin}}", " · {{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") Panel::timeseries("Transactions per block")
.width(12) .width(12)
.unit(Unit::Short) .unit(Unit::Short)
@ -94,9 +81,78 @@ fn sequencer_dashboard() -> Dashboard {
.dashed_line() .dashed_line()
.color(Color::fixed("text")), .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) .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( .target(rate_per_min(
sequencer_service_metrics::names::SUBMITTED_TRANSACTION_COUNT, sequencer_service_metrics::names::SUBMITTED_TRANSACTION_COUNT,
"submitted", "submitted",

View File

@ -11,9 +11,10 @@ use std::fmt::Write as _;
use crate::{ use crate::{
Unit, Unit,
input::PanelInput, input::{Defaults, PanelInput},
schema::{ schema::{
AxisPlacement, Color, GradientMode, LineInterpolation, PanelType, ShowPoints, StackingMode, AxisPlacement, Color, GradientMode, LineInterpolation, PanelType, ShowPoints, StackingMode,
ThresholdMode, Thresholds,
}, },
}; };
@ -33,25 +34,12 @@ fn panel_expr_inner(panel: &PanelInput) -> Result<String, std::fmt::Error> {
let mut expr = match panel.panel_type { let mut expr = match panel.panel_type {
PanelType::Stat => format!("Panel::stat({:?})", panel.title), PanelType::Stat => format!("Panel::stat({:?})", panel.title),
PanelType::Timeseries => format!("Panel::timeseries({:?})", panel.title), PanelType::Timeseries => format!("Panel::timeseries({:?})", panel.title),
PanelType::Gauge => format!("Panel::gauge({:?})", panel.title),
}; };
write!(expr, "\n .width({})", panel.grid_pos.w)?; write!(expr, "\n .width({})", panel.grid_pos.w)?;
let defaults = &panel.field_config.defaults; let defaults = &panel.field_config.defaults;
// `short` is the builder's own default unit, so it round-trips without a call. write_defaults(&mut expr, defaults)?;
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(custom) = &defaults.custom { if let Some(custom) = &defaults.custom {
if custom.span_nulls == Some(true) { if custom.span_nulls == Some(true) {
@ -146,6 +134,65 @@ fn panel_expr_inner(panel: &PanelInput) -> Result<String, std::fmt::Error> {
Ok(expr) 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 { const fn line_interp(value: LineInterpolation) -> &'static str {
match value { match value {
LineInterpolation::Linear => "Linear", LineInterpolation::Linear => "Linear",

View File

@ -14,6 +14,7 @@ use serde_json::Value;
use crate::schema::{ use crate::schema::{
AxisPlacement, Color, GradientMode, LineInterpolation, PanelType, ShowPoints, StackingMode, AxisPlacement, Color, GradientMode, LineInterpolation, PanelType, ShowPoints, StackingMode,
Thresholds,
}; };
#[derive(Deserialize)] #[derive(Deserialize)]
@ -54,6 +55,12 @@ pub struct Defaults {
pub color: Option<Color>, pub color: Option<Color>,
#[serde(default)] #[serde(default)]
pub custom: Option<Custom>, 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)] #[derive(Deserialize, Default)]

View File

@ -14,12 +14,15 @@
// Styling vocabularies passed to the optional `styling` setters are part of the // 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`). // public API (and are used by this module's `Panel` fields and `finalize`).
pub use codegen::panel_to_rust_source; 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::{ use schema::{
Calc, Custom, Datasource, Defaults, DrawStyle, EmptyList, FieldConfig, Fill, GraphMode, Calc, Custom, Datasource, Defaults, DrawStyle, EmptyList, FieldConfig, Fill, GaugeOptions,
GridPos, Legend, LegendDisplay, LineStyle, Matcher, MatcherKind, Options, OverrideProperty, GraphMode, GridPos, Legend, LegendDisplay, LineStyle, Matcher, MatcherKind, Options,
PanelModel, PanelType, Placement, PropertyId, PropertyValue, ReduceOptions, SortOrder, OverrideProperty, PanelModel, PanelType, Placement, PropertyId, PropertyValue, ReduceOptions,
Stacking, StatColorMode, StatOptions, TimeRange, TimeSeriesOptions, Tooltip, TooltipMode, SortOrder, Stacking, StatColorMode, StatOptions, TimeRange, TimeSeriesOptions, Tooltip,
TooltipMode,
}; };
use serde::Serialize; use serde::Serialize;
pub use unit::Unit; pub use unit::Unit;
@ -107,6 +110,7 @@ impl FieldOverride {
enum Kind { enum Kind {
Stat, Stat,
TimeSeries, TimeSeries,
Gauge,
} }
/// A dashboard panel builder. Grid position and panel id are assigned by /// A dashboard panel builder. Grid position and panel id are assigned by
@ -119,6 +123,9 @@ pub struct Panel {
unit: Option<Unit>, unit: Option<Unit>,
decimals: Option<u32>, decimals: Option<u32>,
color: Option<Color>, color: Option<Color>,
min: Option<f64>,
max: Option<f64>,
thresholds: Option<Thresholds>,
span_nulls: bool, span_nulls: bool,
overrides: Vec<FieldOverride>, overrides: Vec<FieldOverride>,
// Optional timeseries styling, set via the `styling` setters. // Optional timeseries styling, set via the `styling` setters.
@ -140,6 +147,9 @@ impl Panel {
unit: None, unit: None,
decimals: None, decimals: None,
color: None, color: None,
min: None,
max: None,
thresholds: None,
span_nulls: false, span_nulls: false,
overrides: Vec::new(), overrides: Vec::new(),
line_interpolation: None, line_interpolation: None,
@ -161,6 +171,12 @@ impl Panel {
Self::new(title, Kind::TimeSeries) 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<String>) -> Self {
Self::new(title, Kind::Gauge)
}
/// Grid width in Grafana's 24-column units. Unset panels split the row's /// Grid width in Grafana's 24-column units. Unset panels split the row's
/// remaining width evenly. /// remaining width evenly.
#[must_use] #[must_use]
@ -187,6 +203,26 @@ impl Panel {
self 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] #[must_use]
pub const fn span_nulls(mut self) -> Self { pub const fn span_nulls(mut self) -> Self {
self.span_nulls = true; self.span_nulls = true;
@ -230,6 +266,9 @@ impl Panel {
custom: None, custom: None,
unit, unit,
decimals: self.decimals, decimals: self.decimals,
min: self.min,
max: self.max,
thresholds: self.thresholds,
}; };
let options = Options::Stat(StatOptions { let options = Options::Stat(StatOptions {
color_mode: StatColorMode::Value, color_mode: StatColorMode::Value,
@ -242,6 +281,27 @@ impl Panel {
}); });
(defaults, options, PanelType::Stat) (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 => { Kind::TimeSeries => {
let defaults = Defaults { let defaults = Defaults {
color: None, color: None,
@ -262,6 +322,9 @@ impl Panel {
}), }),
unit, unit,
decimals: None, decimals: None,
min: self.min,
max: self.max,
thresholds: self.thresholds,
}; };
// Panels with several series read better as a sortable table with // Panels with several series read better as a sortable table with
// a multi-series tooltip; single-series panels stay compact. // a multi-series tooltip; single-series panels stay compact.

View File

@ -94,6 +94,54 @@ pub enum SortOrder {
pub enum PanelType { pub enum PanelType {
Stat, Stat,
Timeseries, Timeseries,
Gauge,
}
#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ThresholdMode {
Absolute,
/// Steps expressed as a percentage of the minmax 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<f64>,
}
/// A threshold ladder, driving gauge/stat coloring.
#[derive(Clone, Serialize, Deserialize)]
pub struct Thresholds {
pub mode: ThresholdMode,
pub steps: Vec<ThresholdStep>,
}
impl Thresholds {
/// Start a ladder with the color used below every threshold.
pub fn base(color: impl Into<String>) -> 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<String>) -> Self {
self.steps.push(ThresholdStep {
color: color.into(),
value: Some(value),
});
self
}
} }
// Optional timeseries styling vocabularies. Each derives `PartialEq` so the // Optional timeseries styling vocabularies. Each derives `PartialEq` so the
@ -248,6 +296,12 @@ pub struct Defaults {
pub unit: Unit, pub unit: Unit,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub decimals: Option<u32>, pub decimals: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thresholds: Option<Thresholds>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -293,11 +347,20 @@ pub struct TimeSeriesOptions {
pub tooltip: Tooltip, 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)] #[derive(Serialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum Options { pub enum Options {
Stat(StatOptions), Stat(StatOptions),
TimeSeries(TimeSeriesOptions), TimeSeries(TimeSeriesOptions),
Gauge(GaugeOptions),
} }
#[derive(Clone, Copy, Serialize)] #[derive(Clone, Copy, Serialize)]