better API and configurability for plots, use in signal-gateway
this adds chrono_tz as a dependency for the timezone interpretation
This commit is contained in:
Generated
+36
-1
@@ -201,6 +201,16 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono-tz"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"phf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.53"
|
||||
@@ -1196,6 +1206,24 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.10"
|
||||
@@ -1394,7 +1422,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1676,6 +1704,7 @@ name = "signal-gateway"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"conf",
|
||||
"conf-extra",
|
||||
"displaydoc",
|
||||
@@ -1717,6 +1746,12 @@ version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.11"
|
||||
|
||||
@@ -25,6 +25,7 @@ prometheus-http-client = { path = "prometheus-http-client" }
|
||||
|
||||
async-trait = "0.1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
|
||||
chrono-tz = "0.10"
|
||||
conf = "0.4"
|
||||
conf-extra = "0.1"
|
||||
displaydoc = "0.2"
|
||||
|
||||
@@ -12,35 +12,36 @@ use std::{
|
||||
};
|
||||
|
||||
/// Styling options for the plot
|
||||
#[non_exhaustive]
|
||||
pub struct PlotStyle {
|
||||
/// The pixel size of the plot
|
||||
pub drawing_area: (u32, u32),
|
||||
/// The background color
|
||||
pub background: RGBAColor,
|
||||
/// The grid color
|
||||
pub grid: RGBAColor,
|
||||
/// The axis color
|
||||
pub axis: RGBAColor,
|
||||
/// The text color
|
||||
pub text_color: RGBAColor,
|
||||
/// The text font
|
||||
pub text_font: String,
|
||||
/// The text size
|
||||
pub text_size: u32,
|
||||
/// The caption size
|
||||
pub caption_size: u32,
|
||||
/// The colors to use for lines. If there are more lines than this, then colors will be repeated.
|
||||
pub data_colors: Vec<RGBAColor>,
|
||||
/// The colors to use for a "threshold" such as used in a PromQL alerting rule
|
||||
pub threshold_color: RGBAColor,
|
||||
/// Labels to skip rendering of
|
||||
pub skip_labels: Vec<String>,
|
||||
/// UTC offset to use when labelling the timestamps being plotted
|
||||
pub utc_offset_hours: i32,
|
||||
/// Optional title override - used when prometheus aggregations remove __name__
|
||||
pub title: Option<String>,
|
||||
/// The stroke width for data lines (default: 1)
|
||||
pub line_width: u32,
|
||||
// The pixel size of the plot
|
||||
drawing_area: (u32, u32),
|
||||
// The background color
|
||||
background: RGBAColor,
|
||||
// The grid color
|
||||
grid: RGBAColor,
|
||||
// The axis color
|
||||
axis: RGBAColor,
|
||||
// The text color
|
||||
text_color: RGBAColor,
|
||||
// The text font
|
||||
text_font: String,
|
||||
// The text size
|
||||
text_size: u32,
|
||||
// The caption size
|
||||
caption_size: u32,
|
||||
// The colors to use for lines. If there are more lines than this, then colors will be repeated.
|
||||
data_colors: Vec<RGBAColor>,
|
||||
// The colors to use for a "threshold" such as used in a PromQL alerting rule
|
||||
threshold_color: RGBAColor,
|
||||
// Labels to skip rendering of
|
||||
skip_labels: Vec<String>,
|
||||
// Timezone offset to use when labelling the timestamps being plotted
|
||||
utc_offset: FixedOffset,
|
||||
// Optional title override - used when prometheus aggregations remove __name__
|
||||
title: Option<String>,
|
||||
// The stroke width for data lines (default: 1)
|
||||
line_width: u32,
|
||||
}
|
||||
|
||||
impl Default for PlotStyle {
|
||||
@@ -69,7 +70,7 @@ impl Default for PlotStyle {
|
||||
.collect(),
|
||||
threshold_color: RED.mix(0.2),
|
||||
skip_labels: vec!["job".into(), "instance".into()],
|
||||
utc_offset_hours: 0,
|
||||
utc_offset: FixedOffset::east_opt(0).unwrap(),
|
||||
title: None,
|
||||
line_width: 1,
|
||||
}
|
||||
@@ -77,29 +78,93 @@ impl Default for PlotStyle {
|
||||
}
|
||||
|
||||
impl PlotStyle {
|
||||
/// Set the drawing_area
|
||||
/// Set the pixel dimensions of the plot (width, height). Default is 1920x1200.
|
||||
pub fn with_drawing_area(mut self, drawing_area: impl Into<(u32, u32)>) -> Self {
|
||||
self.drawing_area = drawing_area.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the title of the plot
|
||||
/// Override the title of the plot. By default, the title is derived from the metric name
|
||||
/// and common labels.
|
||||
pub fn with_title(mut self, title: impl Into<String>) -> Self {
|
||||
self.title = Some(title.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the UTC offset (timezone) used in the plot, in hours
|
||||
pub fn with_utc_offset(mut self, offset: i32) -> Self {
|
||||
self.utc_offset_hours = offset;
|
||||
/// Set the timezone offset for timestamp labels. Default is UTC.
|
||||
/// Supports fractional-hour timezones like UTC+5:30.
|
||||
pub fn with_utc_offset(mut self, offset: FixedOffset) -> Self {
|
||||
self.utc_offset = offset;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the stroke width for data lines
|
||||
/// Set the stroke width for data lines in pixels. Default is 1.
|
||||
pub fn with_line_width(mut self, width: u32) -> Self {
|
||||
self.line_width = width;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set label names to exclude from the legend (e.g., "job", "instance").
|
||||
pub fn with_skip_labels(mut self, labels: Vec<String>) -> Self {
|
||||
self.skip_labels = labels;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the background color of the plot. Default is white.
|
||||
pub fn with_background(mut self, color: impl Into<RGBAColor>) -> Self {
|
||||
self.background = color.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the color of the grid lines. Default is semi-transparent gray.
|
||||
pub fn with_grid_color(mut self, color: impl Into<RGBAColor>) -> Self {
|
||||
self.grid = color.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the color of the axis lines. Default is black.
|
||||
pub fn with_axis_color(mut self, color: impl Into<RGBAColor>) -> Self {
|
||||
self.axis = color.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the color of axis labels and legend text. Default is black.
|
||||
pub fn with_text_color(mut self, color: impl Into<RGBAColor>) -> Self {
|
||||
self.text_color = color.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the font family for text. Default is "sans-serif".
|
||||
pub fn with_text_font(mut self, font: impl Into<String>) -> Self {
|
||||
self.text_font = font.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the font size for axis labels and legend in pixels. Default is 18.
|
||||
pub fn with_text_size(mut self, size: u32) -> Self {
|
||||
self.text_size = size;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the font size for the plot title/caption in pixels. Default is 36.
|
||||
pub fn with_caption_size(mut self, size: u32) -> Self {
|
||||
self.caption_size = size;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the colors to cycle through for data lines. If there are more series than colors,
|
||||
/// colors will repeat.
|
||||
pub fn with_data_colors(mut self, colors: Vec<RGBAColor>) -> Self {
|
||||
self.data_colors = colors;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the color used to shade the threshold region in alert plots. Default is
|
||||
/// semi-transparent red.
|
||||
pub fn with_threshold_color(mut self, color: impl Into<RGBAColor>) -> Self {
|
||||
self.threshold_color = color.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A shaded region appearing on the plot to indicate values that would trigger an alert
|
||||
@@ -111,7 +176,7 @@ pub enum PlotThreshold {
|
||||
}
|
||||
|
||||
impl PlotStyle {
|
||||
/// Use a dark color scheme for the plot
|
||||
/// Switch to a dark color scheme: black background with white text and axes.
|
||||
pub fn dark_mode(mut self) -> Self {
|
||||
self.background = BLACK.into();
|
||||
self.grid = RGBAColor(100, 100, 100, 0.5);
|
||||
@@ -162,19 +227,17 @@ impl PlotStyle {
|
||||
// Format date-times differently depending on the range of date-times being displayed.
|
||||
//
|
||||
// If they are all on the same day, then omit the day, and put it in the caption instead
|
||||
let timezone =
|
||||
FixedOffset::east_opt(3600 * self.utc_offset_hours).ok_or("invalid timezone")?;
|
||||
let start_date_naive = x_range.start.with_timezone(&timezone).date_naive();
|
||||
let start_date_naive = x_range.start.with_timezone(&self.utc_offset).date_naive();
|
||||
let date_format_str =
|
||||
if start_date_naive == x_range.end.with_timezone(&timezone).date_naive() {
|
||||
if start_date_naive == x_range.end.with_timezone(&self.utc_offset).date_naive() {
|
||||
write!(&mut caption, " {start_date_naive}")?;
|
||||
"%H:%M:%S"
|
||||
} else {
|
||||
"%m/%d %H:%M:%S"
|
||||
};
|
||||
|
||||
// Add timezone offset to the caption
|
||||
write!(&mut caption, " UTC{o:+}", o = self.utc_offset_hours)?;
|
||||
// Add timezone offset to the caption (FixedOffset displays as +HH:MM or -HH:MM)
|
||||
write!(&mut caption, " UTC{}", self.utc_offset)?;
|
||||
|
||||
// Actually start writing the file
|
||||
let root_area = BitMapBackend::new(&path, self.drawing_area).into_drawing_area();
|
||||
@@ -199,7 +262,7 @@ impl PlotStyle {
|
||||
.bold_line_style(self.axis) // White bold lines
|
||||
.label_style(text_style.clone())
|
||||
.x_label_formatter(&|x| {
|
||||
x.with_timezone(&timezone)
|
||||
x.with_timezone(&self.utc_offset)
|
||||
.format(date_format_str)
|
||||
.to_string()
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
prometheus-http-client = { workspace = true }
|
||||
|
||||
chrono = { workspace = true }
|
||||
chrono-tz = { workspace = true }
|
||||
conf = { workspace = true }
|
||||
conf-extra = { workspace = true }
|
||||
displaydoc = { workspace = true }
|
||||
|
||||
@@ -205,7 +205,9 @@ impl Gateway {
|
||||
let plotter = config
|
||||
.plotter
|
||||
.as_ref()
|
||||
.map(|plotter_config| Plotter::new(plotter_config.clone()));
|
||||
.map(|plotter_config| Plotter::new(plotter_config.clone()))
|
||||
.transpose()
|
||||
.expect("Invalid plotter config");
|
||||
|
||||
Self {
|
||||
config,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::http::Alert;
|
||||
use chrono::Utc;
|
||||
use chrono::{FixedOffset, Local, Offset, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use conf::Conf;
|
||||
use prometheus_http_client::{
|
||||
AlertInfo, AlertsRequest, ExtractLabels, Labels, LabelsRequest, MetricTimeseries, MetricVal,
|
||||
@@ -13,11 +14,50 @@ use tracing::{info, warn};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
#[derive(Clone, Conf, Debug)]
|
||||
#[conf(at_most_one_of_fields(utc_offset, timezone))]
|
||||
pub struct PlotterConfig {
|
||||
#[conf(long, env)]
|
||||
pub prometheus_host: String,
|
||||
#[conf(long, env, default_value = "30m", value_parser = conf_extra::parse_duration)]
|
||||
pub plot_age_limit: Duration,
|
||||
/// Fixed timezone offset for plot timestamps, e.g. "+05:30" or "-08:00".
|
||||
/// Mutually exclusive with --timezone.
|
||||
#[conf(long, env)]
|
||||
pub utc_offset: Option<FixedOffset>,
|
||||
/// Timezone name for plot timestamps, e.g. "US/Mountain" or "Europe/Paris".
|
||||
/// Mutually exclusive with --utc-offset.
|
||||
#[conf(long, env)]
|
||||
pub timezone: Option<Tz>,
|
||||
/// Stroke width for plot lines
|
||||
#[conf(long, env, default_value = "2")]
|
||||
pub line_width: u32,
|
||||
/// Plot dimensions in pixels (width, height), e.g. "1920,1200" or "(1920, 1200)"
|
||||
#[conf(long, env, default_value = "1920,1200", value_parser = parse_dimensions)]
|
||||
pub plot_dimensions: (u32, u32),
|
||||
}
|
||||
|
||||
fn parse_dimensions(s: &str) -> Result<(u32, u32), String> {
|
||||
let s = s.trim();
|
||||
// Strip optional parentheses
|
||||
let s = s
|
||||
.strip_prefix('(')
|
||||
.and_then(|s| s.strip_suffix(')'))
|
||||
.unwrap_or(s);
|
||||
|
||||
let (left, right) = s
|
||||
.split_once(',')
|
||||
.ok_or_else(|| format!("expected 'width,height', got '{s}'"))?;
|
||||
|
||||
let width = left
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.map_err(|e| format!("invalid width: {e}"))?;
|
||||
let height = right
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.map_err(|e| format!("invalid height: {e}"))?;
|
||||
|
||||
Ok((width, height))
|
||||
}
|
||||
|
||||
pub struct Plotter {
|
||||
@@ -28,17 +68,21 @@ pub struct Plotter {
|
||||
}
|
||||
|
||||
impl Plotter {
|
||||
pub fn new(config: PlotterConfig) -> Self {
|
||||
pub fn new(config: PlotterConfig) -> Result<Self, &'static str> {
|
||||
if config.utc_offset.is_some() && config.timezone.is_some() {
|
||||
return Err("Cannot specify both --utc-offset and --timezone");
|
||||
}
|
||||
|
||||
let plot_dir = "/tmp".into();
|
||||
let reqwest_client = ReqwestClient::new();
|
||||
let skip_labels = vec!["job".into(), "instance".into()];
|
||||
|
||||
Self {
|
||||
Ok(Self {
|
||||
config,
|
||||
plot_dir,
|
||||
reqwest_client,
|
||||
skip_labels,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn create_plot(
|
||||
@@ -52,10 +96,21 @@ impl Plotter {
|
||||
dir = self.plot_dir,
|
||||
num = rand::rng().next_u64()
|
||||
);
|
||||
let mut plot_style = PlotStyle::default().dark_mode();
|
||||
plot_style.skip_labels = self.skip_labels.clone();
|
||||
let utc_offset = if let Some(offset) = self.config.utc_offset {
|
||||
offset
|
||||
} else if let Some(tz) = self.config.timezone {
|
||||
Utc::now().with_timezone(&tz).offset().fix()
|
||||
} else {
|
||||
*Local::now().offset()
|
||||
};
|
||||
let mut plot_style = PlotStyle::default()
|
||||
.dark_mode()
|
||||
.with_line_width(self.config.line_width)
|
||||
.with_drawing_area(self.config.plot_dimensions)
|
||||
.with_utc_offset(utc_offset)
|
||||
.with_skip_labels(self.skip_labels.clone());
|
||||
if let Some(title) = title {
|
||||
plot_style.title = Some(title.to_owned());
|
||||
plot_style = plot_style.with_title(title);
|
||||
}
|
||||
|
||||
plot_style.plot_timeseries(&filename, matrix, threshold)?;
|
||||
|
||||
Reference in New Issue
Block a user