diff --git a/Cargo.lock b/Cargo.lock index f9428cf..7cc9360 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index a4a4bc7..a2765db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/prometheus-http-client/src/plot.rs b/prometheus-http-client/src/plot.rs index 5e89235..05dc5a0 100644 --- a/prometheus-http-client/src/plot.rs +++ b/prometheus-http-client/src/plot.rs @@ -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, - /// 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, - /// 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, - /// 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, + // 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, + // Timezone offset to use when labelling the timestamps being plotted + utc_offset: FixedOffset, + // Optional title override - used when prometheus aggregations remove __name__ + title: Option, + // 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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() }) diff --git a/signal-gateway/Cargo.toml b/signal-gateway/Cargo.toml index 5032fc2..ef72b67 100644 --- a/signal-gateway/Cargo.toml +++ b/signal-gateway/Cargo.toml @@ -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 } diff --git a/signal-gateway/src/gateway/mod.rs b/signal-gateway/src/gateway/mod.rs index baf6894..7ac20d0 100644 --- a/signal-gateway/src/gateway/mod.rs +++ b/signal-gateway/src/gateway/mod.rs @@ -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, diff --git a/signal-gateway/src/plotter.rs b/signal-gateway/src/plotter.rs index 2a83388..ad530ea 100644 --- a/signal-gateway/src/plotter.rs +++ b/signal-gateway/src/plotter.rs @@ -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, + /// Timezone name for plot timestamps, e.g. "US/Mountain" or "Europe/Paris". + /// Mutually exclusive with --utc-offset. + #[conf(long, env)] + pub timezone: Option, + /// 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::() + .map_err(|e| format!("invalid width: {e}"))?; + let height = right + .trim() + .parse::() + .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 { + 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)?;