feat: multiple subplots per graph

This commit is contained in:
Jeremy Wall 2024-02-16 17:23:11 -05:00
parent 9a89412fc8
commit 4674a821d8
5 changed files with 207 additions and 125 deletions

View File

@ -2,29 +2,45 @@
- title: Test Dasbboard 1 - title: Test Dasbboard 1
graphs: graphs:
- title: Node cpu - title: Node cpu
source: http://heimdall:9001 d3_tick_format: "~s"
query: 'sum by (instance)(irate(node_cpu_seconds_total{job="nodestats"}[5m]))' plots:
- source: http://heimdall:9001
query: 'sum by (instance)(irate(node_cpu_seconds_total{job="nodestats"}[5m]))'
meta:
name_label: instance
query_type: Range query_type: Range
span: span:
end: now end: now
duration: 1d duration: 1d
step_duration: 10min step_duration: 10min
name_label: instance
- title: Test Dasbboard 2 - title: Test Dasbboard 2
span: span:
end: 2024-02-10T00:00:00.00Z end: 2024-02-10T00:00:00.00Z
duration: 2 days duration: 2 days
step_duration: 1 minute step_duration: 1 minute
graphs: graphs:
- title: Node cpu sytem percent - title: Node cpu percent
source: http://heimdall:9001
query: |
sum by (instance)(irate(node_cpu_seconds_total{mode="system",job="nodestats"}[5m])) / sum by (instance)(irate(node_cpu_seconds_total{job="nodestats"}[5m]))
d3_tick_format: "~%" d3_tick_format: "~%"
query_type: Range query_type: Range
name_label: instance plots:
- source: http://heimdall:9001
query: |
sum by (instance)(irate(node_cpu_seconds_total{mode="system",job="nodestats"}[5m])) / sum by (instance)(irate(node_cpu_seconds_total{job="nodestats"}[5m]))
meta:
d3_tick_format: "~s"
name_label: instance
name_prefix: "System"
- source: http://heimdall:9001
query: |
sum by (instance)(irate(node_cpu_seconds_total{mode="user",job="nodestats"}[5m])) / sum by (instance)(irate(node_cpu_seconds_total{job="nodestats"}[5m]))
meta:
#d3_tick_format: "~s"
name_label: instance
name_suffix: "User"
- title: Node memory - title: Node memory
source: http://heimdall:9001
query: 'node_memory_MemFree_bytes{job="nodestats"}'
query_type: Scalar query_type: Scalar
name_label: instance plots:
- source: http://heimdall:9001
query: 'node_memory_MemFree_bytes{job="nodestats"}'
meta:
name_label: instance

View File

@ -19,10 +19,11 @@ use serde::Deserialize;
use serde_yaml; use serde_yaml;
use tracing::{debug, error}; use tracing::{debug, error};
use crate::query::{QueryConn, QueryType}; use crate::query::{QueryConn, QueryType, PlotMeta};
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct GraphSpan { pub struct GraphSpan {
// serialized with https://datatracker.ietf.org/doc/html/rfc3339 and special handling for 'now'
pub end: String, pub end: String,
pub duration: String, pub duration: String,
pub step_duration: String, pub step_duration: String,
@ -32,17 +33,21 @@ pub struct GraphSpan {
pub struct Dashboard { pub struct Dashboard {
pub title: String, pub title: String,
pub graphs: Vec<Graph>, pub graphs: Vec<Graph>,
pub span: Option<GraphSpan> pub span: Option<GraphSpan>,
}
#[derive(Deserialize)]
pub struct SubPlot {
pub source: String,
pub query: String,
pub meta: PlotMeta,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct Graph { pub struct Graph {
pub title: String, pub title: String,
pub source: String, pub plots: Vec<SubPlot>,
pub query: String,
// serialized with https://datatracker.ietf.org/doc/html/rfc3339
pub span: Option<GraphSpan>, pub span: Option<GraphSpan>,
pub name_label: String,
pub query_type: QueryType, pub query_type: QueryType,
pub d3_tick_format: Option<String>, pub d3_tick_format: Option<String>,
} }
@ -97,23 +102,31 @@ fn graph_span_to_tuple(span: &Option<GraphSpan>) -> Option<(DateTime<Utc>, Durat
} }
impl Graph { impl Graph {
pub fn get_query_connection<'conn, 'graph: 'conn>(&'graph self, graph_span: &'graph Option<GraphSpan>, query_span: &'graph Option<GraphSpan>) -> QueryConn<'conn> { pub fn get_query_connections<'conn, 'graph: 'conn>(
debug!( &'graph self,
query = self.query, graph_span: &'graph Option<GraphSpan>,
source = self.source, query_span: &'graph Option<GraphSpan>,
"Getting query connection for graph" ) -> Vec<QueryConn<'conn>> {
); let mut conns = Vec::new();
let mut conn = QueryConn::new(&self.source, &self.query, self.query_type.clone()); for plot in self.plots.iter() {
// Query params take precendence over all other settings. Then graph settings take debug!(
// precedences and finally the dashboard settings take precendence query = plot.query,
if let Some((end, duration, step_duration)) = graph_span_to_tuple(query_span) { source = plot.source,
conn = conn.with_span(end, duration, step_duration); "Getting query connection for graph"
} else if let Some((end, duration, step_duration)) = graph_span_to_tuple(&self.span) { );
conn = conn.with_span(end, duration, step_duration); let mut conn = QueryConn::new(&plot.source, &plot.query, self.query_type.clone(), plot.meta.clone());
} else if let Some((end, duration, step_duration)) = graph_span_to_tuple(graph_span) { // Query params take precendence over all other settings. Then graph settings take
conn = conn.with_span(end, duration, step_duration); // precedences and finally the dashboard settings take precendence
if let Some((end, duration, step_duration)) = graph_span_to_tuple(query_span) {
conn = conn.with_span(end, duration, step_duration);
} else if let Some((end, duration, step_duration)) = graph_span_to_tuple(&self.span) {
conn = conn.with_span(end, duration, step_duration);
} else if let Some((end, duration, step_duration)) = graph_span_to_tuple(graph_span) {
conn = conn.with_span(end, duration, step_duration);
}
conns.push(conn);
} }
conn conns
} }
} }

View File

@ -38,20 +38,31 @@ pub struct QueryConn<'conn> {
query: &'conn str, query: &'conn str,
span: Option<TimeSpan>, span: Option<TimeSpan>,
query_type: QueryType, query_type: QueryType,
pub meta: PlotMeta,
} }
impl<'conn> QueryConn<'conn> { impl<'conn> QueryConn<'conn> {
pub fn new<'a: 'conn>(source: &'a str, query: &'a str, query_type: QueryType) -> Self { pub fn new<'a: 'conn>(source: &'a str, query: &'a str, query_type: QueryType, meta: PlotMeta) -> Self {
Self { Self {
source, source,
query, query,
query_type, query_type,
meta,
span: None, span: None,
} }
} }
pub fn with_span(mut self, end: DateTime<Utc>, duration: chrono::Duration, step: chrono::Duration) -> Self { pub fn with_span(
self.span = Some(TimeSpan { end, duration, step_seconds: step.num_seconds() , }); mut self,
end: DateTime<Utc>,
duration: chrono::Duration,
step: chrono::Duration,
) -> Self {
self.span = Some(TimeSpan {
end,
duration,
step_seconds: step.num_seconds(),
});
self self
} }
@ -64,25 +75,35 @@ impl<'conn> QueryConn<'conn> {
step_seconds, step_seconds,
}) = self.span }) = self.span
{ {
let start = end - du; let start = end - du;
debug!(?start, ?end, step_seconds, "Running Query with range values"); debug!(
?start,
?end,
step_seconds,
"Running Query with range values"
);
(start.timestamp(), end.timestamp(), step_seconds as f64) (start.timestamp(), end.timestamp(), step_seconds as f64)
} else { } else {
let end = Utc::now(); let end = Utc::now();
let start = end - chrono::Duration::minutes(10); let start = end - chrono::Duration::minutes(10);
debug!(?start, ?end, step_seconds=30, "Running Query with range values"); debug!(
?start,
?end,
step_seconds = 30,
"Running Query with range values"
);
(start.timestamp(), end.timestamp(), 30 as f64) (start.timestamp(), end.timestamp(), 30 as f64)
}; };
//debug!(start, end, step_resolution, "Running Query with range values"); //debug!(start, end, step_resolution, "Running Query with range values");
match self.query_type { match self.query_type {
QueryType::Range => { QueryType::Range => {
let results = client let results = client
.query_range(self.query, start, end, step_resolution) .query_range(self.query, start, end, step_resolution)
.get() .get()
.await?; .await?;
//debug!(?results, "range results"); //debug!(?results, "range results");
Ok(results) Ok(results)
}, }
QueryType::Scalar => Ok(client.query(self.query).get().await?), QueryType::Scalar => Ok(client.query(self.query).get().await?),
} }
} }
@ -94,19 +115,33 @@ pub struct DataPoint {
value: f64, value: f64,
} }
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PlotMeta {
name_prefix: Option<String>,
name_suffix: Option<String>,
name_label: Option<String>,
d3_tick_format: Option<String>,
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub enum QueryResult { pub enum QueryResult {
Series(Vec<(HashMap<String, String>, Vec<DataPoint>)>), Series(Vec<(HashMap<String, String>, PlotMeta, Vec<DataPoint>)>),
Scalar(Vec<(HashMap<String, String>, DataPoint)>), Scalar(Vec<(HashMap<String, String>, PlotMeta, DataPoint)>),
} }
impl std::fmt::Debug for QueryResult { impl std::fmt::Debug for QueryResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
QueryResult::Series(v) => { QueryResult::Series(v) => {
f.write_fmt(format_args!("Series trace count = {}", v.len()))?; f.write_fmt(format_args!("Series trace count = {}", v.len()))?;
for (idx, (tags, trace)) in v.iter().enumerate() { for (idx, (tags, meta, trace)) in v.iter().enumerate() {
f.write_fmt(format_args!("; {}: meta {:?} datapoint count = {};", idx, tags, trace.len()))?; f.write_fmt(format_args!(
"; {}: tags {:?} meta: {:?} datapoint count = {};",
idx,
tags,
meta,
trace.len()
))?;
} }
} }
QueryResult::Scalar(v) => { QueryResult::Scalar(v) => {
@ -117,7 +152,7 @@ impl std::fmt::Debug for QueryResult {
} }
} }
pub fn to_samples(data: Data) -> QueryResult { pub fn to_samples(data: Data, meta: PlotMeta) -> QueryResult {
match data { match data {
Data::Matrix(mut range) => QueryResult::Series( Data::Matrix(mut range) => QueryResult::Series(
range range
@ -126,6 +161,7 @@ pub fn to_samples(data: Data) -> QueryResult {
let (metric, mut samples) = rv.into_inner(); let (metric, mut samples) = rv.into_inner();
( (
metric, metric,
meta.clone(),
samples samples
.drain(0..) .drain(0..)
.map(|s| DataPoint { .map(|s| DataPoint {
@ -144,6 +180,7 @@ pub fn to_samples(data: Data) -> QueryResult {
let (metric, sample) = iv.into_inner(); let (metric, sample) = iv.into_inner();
( (
metric, metric,
meta.clone(),
DataPoint { DataPoint {
timestamp: sample.timestamp(), timestamp: sample.timestamp(),
value: sample.value(), value: sample.value(),
@ -154,6 +191,7 @@ pub fn to_samples(data: Data) -> QueryResult {
), ),
Data::Scalar(sample) => QueryResult::Scalar(vec![( Data::Scalar(sample) => QueryResult::Scalar(vec![(
HashMap::new(), HashMap::new(),
meta.clone(),
DataPoint { DataPoint {
timestamp: sample.timestamp(), timestamp: sample.timestamp(),
value: sample.value(), value: sample.value(),

View File

@ -33,7 +33,7 @@ pub async fn graph_query(
State(config): Config, State(config): Config,
Path((dash_idx, graph_idx)): Path<(usize, usize)>, Path((dash_idx, graph_idx)): Path<(usize, usize)>,
Query(query): Query<HashMap<String, String>>, Query(query): Query<HashMap<String, String>>,
) -> Json<QueryResult> { ) -> Json<Vec<QueryResult>> {
debug!("Getting data for query"); debug!("Getting data for query");
let dash = config.get(dash_idx).expect("No such dashboard index"); let dash = config.get(dash_idx).expect("No such dashboard index");
let graph = dash let graph = dash
@ -45,7 +45,6 @@ pub async fn graph_query(
&& query.contains_key("duration") && query.contains_key("duration")
&& query.contains_key("step_duration") && query.contains_key("step_duration")
{ {
// TODO(jwall): handle the now case.
Some(GraphSpan { Some(GraphSpan {
end: query["end"].clone(), end: query["end"].clone(),
duration: query["duration"].clone(), duration: query["duration"].clone(),
@ -55,21 +54,23 @@ pub async fn graph_query(
None None
} }
}; };
let data = to_samples( let connections = graph.get_query_connections(&dash.span, &query_span);
graph let mut data = Vec::new();
.get_query_connection(&dash.span, &query_span) for conn in connections {
.get_results() data.push(to_samples(
.await conn.get_results()
.expect("Unable to get query results") .await
.data() .expect("Unable to get query results")
.clone(), .data()
); .clone(),
conn.meta,
));
}
Json(data) Json(data)
} }
pub fn mk_api_routes(config: Arc<Vec<Dashboard>>) -> Router<Config> { pub fn mk_api_routes(config: Arc<Vec<Dashboard>>) -> Router<Config> {
// Query routes // Query routes
// TODO(zaphar): Allow passing the timespan in via query
Router::new().route( Router::new().route(
"/dash/:dash_idx/graph/:graph_idx", "/dash/:dash_idx/graph/:graph_idx",
get(graph_query).with_state(config), get(graph_query).with_state(config),
@ -83,9 +84,9 @@ pub fn graph_component(dash_idx: usize, graph_idx: usize, graph: &Graph) -> Mark
div { div {
h2 { (graph.title) } h2 { (graph.title) }
@if graph.d3_tick_format.is_some() { @if graph.d3_tick_format.is_some() {
timeseries-graph uri=(graph_data_uri) id=(graph_id) label=(graph.name_label) d3-tick-format=(graph.d3_tick_format.as_ref().unwrap()) { } timeseries-graph uri=(graph_data_uri) id=(graph_id) d3-tick-format=(graph.d3_tick_format.as_ref().unwrap()) { }
} @else { } @else {
timeseries-graph uri=(graph_data_uri) id=(graph_id) label=(graph.name_label) { } timeseries-graph uri=(graph_data_uri) id=(graph_id) { }
} }
} }
) )

View File

@ -18,7 +18,6 @@ class TimeseriesGraph extends HTMLElement {
#height; #height;
#intervalId; #intervalId;
#pollSeconds; #pollSeconds;
#label;
#end; #end;
#duration; #duration;
#step_duration; #step_duration;
@ -32,7 +31,7 @@ class TimeseriesGraph extends HTMLElement {
this.#targetNode = this.appendChild(document.createElement("div")); this.#targetNode = this.appendChild(document.createElement("div"));
} }
static observedAttributes = ['uri', 'width', 'height', 'poll-seconds', 'end', 'duration', 'step-duration', 'd3-tick-format']; static observedAttributes = ['uri', 'width', 'height', 'poll-seconds', 'end', 'duration', 'step-duration'];
attributeChangedCallback(name, _oldValue, newValue) { attributeChangedCallback(name, _oldValue, newValue) {
switch (name) { switch (name) {
@ -48,9 +47,6 @@ class TimeseriesGraph extends HTMLElement {
case 'poll-seconds': case 'poll-seconds':
this.#pollSeconds = newValue; this.#pollSeconds = newValue;
break; break;
case 'label':
this.#label = newValue;
break;
case 'end': case 'end':
this.#end = newValue; this.#end = newValue;
break; break;
@ -74,7 +70,6 @@ class TimeseriesGraph extends HTMLElement {
this.#width = this.getAttribute('width') || this.#width; this.#width = this.getAttribute('width') || this.#width;
this.#height = this.getAttribute('height') || this.#height; this.#height = this.getAttribute('height') || this.#height;
this.#pollSeconds = this.getAttribute('poll-seconds') || this.#pollSeconds; this.#pollSeconds = this.getAttribute('poll-seconds') || this.#pollSeconds;
this.#label = this.getAttribute('label') || null;
this.#end = this.getAttribute('end') || null; this.#end = this.getAttribute('end') || null;
this.#duration = this.getAttribute('duration') || null; this.#duration = this.getAttribute('duration') || null;
this.#step_duration = this.getAttribute('step-duration') || null; this.#step_duration = this.getAttribute('step-duration') || null;
@ -134,62 +129,81 @@ class TimeseriesGraph extends HTMLElement {
orientation: 'h' orientation: 'h'
} }
}; };
const layout = { var layout = {
displayModeBar: false, displayModeBar: false,
responsive: true, responsive: true,
yaxis: {
tickformat: this.#d3TickFormat,
//showticksuffix: 'all',
//ticksuffix: '%',
//exponentFormat: 'SI'
}
}; };
console.debug("layout", layout); var traces = [];
if (data.Series) { for (var subplot_idx in data) {
// https://plotly.com/javascript/reference/scatter/ const subplot = data[subplot_idx];
var traces = []; const subplotCount = Number(subplot_idx) + 1;
for (const pair of data.Series) { const yaxis = "y" + subplotCount
const series = pair[1]; if (subplot.Series) {
const labels = pair[0]; // https://plotly.com/javascript/reference/scatter/
var trace = { for (const triple of subplot.Series) {
type: "scatter", const labels = triple[0];
mode: "lines+text", const meta = triple[1];
x: [], layout["yaxis" + subplotCount] = {
y: [] anchor: yaxis,
}; tickformat: meta["d3_tick_format"] || this.#d3TickFormat
if (labels[this.#label]) { };
trace.name = labels[this.#label]; const series = triple[2];
}; var trace = {
for (const point of series) { type: "scatter",
trace.x.push(new Date(point.timestamp * 1000)); mode: "lines+text",
trace.y.push(point.value); x: [],
y: [],
yaxis: yaxis,
yhoverformat: meta["d3_tick_format"],
};
const namePrefix = meta["name_prefix"];
const nameSuffix = meta["name_suffix"];
const nameLabel = meta["name_label"];
var name = "";
if (namePrefix) {
name = namePrefix + "-";
};
if (nameLabel && labels[nameLabel]) {
name = name + labels[nameLabel];
};
if (nameSuffix) {
name = name + " - " + nameSuffix;
};
if (name) { trace.name = name; }
for (const point of series) {
trace.x.push(new Date(point.timestamp * 1000));
trace.y.push(point.value);
}
traces.push(trace);
}
} else if (subplot.Scalar) {
// https://plotly.com/javascript/reference/bar/
for (const triple of subplot.Scalar) {
const labels = triple[0];
const meta = triple[1];
const series = triple[2];
var trace = {
type: "bar",
x: [],
y: [],
yaxis: yaxis,
yhoverformat: meta["d3_tick_format"],
};
let nameLabel = meta["name_label"];
if (nameLabel && labels[nameLabel]) {
trace.name = labels[nameLabel];
};
if (nameLabel && labels[nameLabel]) {
trace.x.push(labels[nameLabel]);
};
trace.y.push(series.value);
traces.push(trace);
} }
traces.push(trace);
} }
// https://plotly.com/javascript/plotlyjs-function-reference/#plotlyreact
Plotly.react(this.getTargetNode(), traces, layout, config);
} else if (data.Scalar) {
// https://plotly.com/javascript/reference/bar/
var traces = [];
for (const pair of data.Scalar) {
const series = pair[1];
const labels = pair[0];
var trace = {
type: "bar",
x: [],
y: []
};
if (labels[this.#label]) {
trace.name = labels[this.#label];
};
if (labels[this.#label]) {
trace.x.push(labels[this.#label]);
};
trace.y.push(series.value);
traces.push(trace);
}
Plotly.react(this.getTargetNode(), traces, layout, config);
} }
console.debug("traces: ", traces);
// https://plotly.com/javascript/plotlyjs-function-reference/#plotlyreact
Plotly.react(this.getTargetNode(), traces, layout, config);
} }
} }
@ -201,20 +215,20 @@ class SpanSelector extends HTMLElement {
#durationInput = null; #durationInput = null;
#stepDurationInput = null; #stepDurationInput = null;
#updateInput = null #updateInput = null
constructor() { constructor() {
super(); super();
this.#targetNode = this.appendChild(document.createElement('div')); this.#targetNode = this.appendChild(document.createElement('div'));
this.#targetNode.appendChild(document.createElement('span')).innerText = "end: "; this.#targetNode.appendChild(document.createElement('span')).innerText = "end: ";
this.#endInput = this.#targetNode.appendChild(document.createElement('input')); this.#endInput = this.#targetNode.appendChild(document.createElement('input'));
this.#targetNode.appendChild(document.createElement('span')).innerText = "duration: "; this.#targetNode.appendChild(document.createElement('span')).innerText = "duration: ";
this.#durationInput = this.#targetNode.appendChild(document.createElement('input')); this.#durationInput = this.#targetNode.appendChild(document.createElement('input'));
this.#targetNode.appendChild(document.createElement('span')).innerText = "step duration: "; this.#targetNode.appendChild(document.createElement('span')).innerText = "step duration: ";
this.#stepDurationInput = this.#targetNode.appendChild(document.createElement('input')); this.#stepDurationInput = this.#targetNode.appendChild(document.createElement('input'));
this.#updateInput = this.#targetNode.appendChild(document.createElement('button')); this.#updateInput = this.#targetNode.appendChild(document.createElement('button'));
this.#updateInput.innerText = "Update"; this.#updateInput.innerText = "Update";
} }
@ -225,7 +239,7 @@ class SpanSelector extends HTMLElement {
self.updateGraphs() self.updateGraphs()
}; };
} }
disconnectedCallback() { disconnectedCallback() {
this.#updateInput.onclick = undefined; this.#updateInput.onclick = undefined;
} }