Compare commits

...

2 Commits

Author SHA1 Message Date
08267f7727 feat: Huzzah! the graph renders! 2024-02-07 19:18:30 -06:00
e0bbf15c6f refactor: serve the js libraries via url 2024-02-07 14:37:24 -06:00
5 changed files with 109 additions and 10 deletions

View File

@ -11,6 +11,7 @@ anyhow = "1.0.79"
async-io = "2.3.1" async-io = "2.3.1"
axum = { version = "0.7.4", features = [ "ws" ] } axum = { version = "0.7.4", features = [ "ws" ] }
axum-macros = "0.4.1" axum-macros = "0.4.1"
chrono = { version = "0.4.33", features = ["alloc", "std", "now"] }
clap = { version = "4.4.18", features = ["derive"] } clap = { version = "4.4.18", features = ["derive"] }
maud = { version = "0.26.0", features = ["axum"] } maud = { version = "0.26.0", features = ["axum"] }
prometheus-http-query = "0.8.2" prometheus-http-query = "0.8.2"

View File

@ -63,6 +63,7 @@ async fn main() -> anyhow::Result<()> {
let config = std::sync::Arc::new(dashboard::read_dashboard_list(args.config.as_path())?); let config = std::sync::Arc::new(dashboard::read_dashboard_list(args.config.as_path())?);
let router = Router::new() let router = Router::new()
// JSON api endpoints // JSON api endpoints
.nest("/js", routes::mk_js_routes(config.clone()))
.nest("/api", routes::mk_api_routes(config.clone())) .nest("/api", routes::mk_api_routes(config.clone()))
// HTMX ui component endpoints // HTMX ui component endpoints
.nest("/ui", routes::mk_ui_routes(config.clone())) .nest("/ui", routes::mk_ui_routes(config.clone()))

View File

@ -16,6 +16,7 @@ use std::collections::HashMap;
use prometheus_http_query::{Client, response::{PromqlResult, Data}}; use prometheus_http_query::{Client, response::{PromqlResult, Data}};
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use tracing::debug; use tracing::debug;
use chrono::prelude::*;
pub struct QueryConn<'conn> { pub struct QueryConn<'conn> {
source: &'conn str, source: &'conn str,
@ -33,13 +34,16 @@ impl<'conn> QueryConn<'conn> {
pub async fn get_results(&self) -> anyhow::Result<PromqlResult> { pub async fn get_results(&self) -> anyhow::Result<PromqlResult> {
debug!("Getting results for query"); debug!("Getting results for query");
let client = Client::try_from(self.source)?; let client = Client::try_from(self.source)?;
Ok(client.query(self.query).get().await?) let end = Utc::now().timestamp();
let start = end - (60 * 10);
let step_resolution = 10 as f64;
Ok(client.query_range(self.query, start, end, step_resolution).get().await?)
} }
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct DataPoint { pub struct DataPoint {
timesstamp: f64, timestamp: f64,
value: f64, value: f64,
} }
@ -56,18 +60,18 @@ pub fn to_samples(data: Data) -> QueryResult {
QueryResult::Series(range.drain(0..).map(|rv| { QueryResult::Series(range.drain(0..).map(|rv| {
let (metric, mut samples) = rv.into_inner(); let (metric, mut samples) = rv.into_inner();
(metric, samples.drain(0..).map(|s| { (metric, samples.drain(0..).map(|s| {
DataPoint { timesstamp: s.timestamp(), value: s.value() } DataPoint { timestamp: s.timestamp(), value: s.value() }
}).collect()) }).collect())
}).collect()) }).collect())
} }
Data::Vector(mut vector) => { Data::Vector(mut vector) => {
QueryResult::Series(vector.drain(0..).map(|iv| { QueryResult::Series(vector.drain(0..).map(|iv| {
let (metric, sample) = iv.into_inner(); let (metric, sample) = iv.into_inner();
(metric, vec![DataPoint { timesstamp: sample.timestamp(), value: sample.value() }]) (metric, vec![DataPoint { timestamp: sample.timestamp(), value: sample.value() }])
}).collect()) }).collect())
} }
Data::Scalar(sample) => { Data::Scalar(sample) => {
QueryResult::Scalar(DataPoint { timesstamp: sample.timestamp(), value: sample.value() }) QueryResult::Scalar(DataPoint { timestamp: sample.timestamp(), value: sample.value() })
} }
} }
} }

View File

@ -15,9 +15,11 @@ use std::sync::Arc;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
response::Response,
routing::get, routing::get,
Json, Router, Json, Router,
}; };
use axum_macros::debug_handler;
use maud::{html, Markup, PreEscaped}; use maud::{html, Markup, PreEscaped};
use tracing::debug; use tracing::debug;
@ -61,7 +63,15 @@ pub fn mk_api_routes(config: Arc<Vec<Dashboard>>) -> Router<Config> {
// TODO(jwall): This should probably be encapsulated in a web component? // TODO(jwall): This should probably be encapsulated in a web component?
pub fn graph_component(dash_idx: usize, graph_idx: usize, graph: &Graph) -> Markup { pub fn graph_component(dash_idx: usize, graph_idx: usize, graph: &Graph) -> Markup {
let graph_id = format!("graph-{}-{}", dash_idx, graph_idx); let graph_id = format!("graph-{}-{}", dash_idx, graph_idx);
let script = format!("var data = []; Plotly.newPlot('{}', data, {{ width: 500, height: 500 }});", graph_id); let graph_data_uri = format!("/api/dash/{}/graph/{}", dash_idx, graph_idx);
// initialize the plot with Plotly.react
// Update plot with Plotly.react which is more efficient
let script = format!(
"var graph{graph_idx} = new Timeseries('{uri}', '{graph_id}'); graph{graph_idx}.updateGraph();",
uri = graph_data_uri,
graph_id = graph_id,
graph_idx = graph_idx,
);
html!( html!(
div { div {
h2 { (graph.title) } h2 { (graph.title) }
@ -89,7 +99,11 @@ pub async fn graph_ui(
pub async fn dash_ui(State(config): State<Config>, Path(dash_idx): Path<usize>) -> Markup { pub async fn dash_ui(State(config): State<Config>, Path(dash_idx): Path<usize>) -> Markup {
// TODO(zaphar): Should do better http error reporting here. // TODO(zaphar): Should do better http error reporting here.
let dash = config.get(dash_idx).expect("No such dashboard"); let dash = config.get(dash_idx).expect("No such dashboard");
let graph_iter = dash.graphs.iter().enumerate().collect::<Vec<(usize, &Graph)>>(); let graph_iter = dash
.graphs
.iter()
.enumerate()
.collect::<Vec<(usize, &Graph)>>();
html!( html!(
h1 { (dash.title) } h1 { (dash.title) }
@for (idx, graph) in &graph_iter { @for (idx, graph) in &graph_iter {
@ -100,7 +114,10 @@ pub async fn dash_ui(State(config): State<Config>, Path(dash_idx): Path<usize>)
pub fn mk_ui_routes(config: Arc<Vec<Dashboard>>) -> Router<Config> { pub fn mk_ui_routes(config: Arc<Vec<Dashboard>>) -> Router<Config> {
Router::new() Router::new()
.route("/dash/:dash_idx", get(dash_ui).with_state(State(config.clone()))) .route(
"/dash/:dash_idx",
get(dash_ui).with_state(State(config.clone())),
)
.route( .route(
"/dash/:dash_idx/graph/:graph_idx", "/dash/:dash_idx/graph/:graph_idx",
get(graph_ui).with_state(State(config)), get(graph_ui).with_state(State(config)),
@ -114,8 +131,9 @@ pub async fn index(State(config): State<Config>) -> Markup {
title { ("Heracles - Prometheus Unshackled") } title { ("Heracles - Prometheus Unshackled") }
} }
body { body {
script { (PreEscaped(include_str!("../static/plotly-2.27.0.min.js"))) } script src="/js/plotly.js" { }
script { (PreEscaped(include_str!("../static/htmx.min.js"))) } script src="/js/htmx.js" { }
script src="/js/lib.js" { }
(app(State(config.clone())).await) (app(State(config.clone())).await)
} }
} }
@ -141,3 +159,31 @@ pub async fn app(State(config): State<Config>) -> Markup {
} }
} }
} }
pub fn javascript_response(content: &str) -> Response<String> {
Response::builder()
.header("Content-Type", "text/javascript")
.body(content.to_string())
.expect("Invalid javascript response")
}
// TODO(jwall): Should probably hook in one of the axum directory serving crates here.
pub async fn htmx() -> Response<String> {
javascript_response(include_str!("../static/htmx.min.js"))
}
pub async fn plotly() -> Response<String> {
javascript_response(include_str!("../static/plotly-2.27.0.min.js"))
}
pub async fn lib() -> Response<String> {
javascript_response(include_str!("../static/lib.js"))
}
pub fn mk_js_routes(config: Arc<Vec<Dashboard>>) -> Router<Config> {
Router::new()
.route("/plotly.js", get(plotly))
.route("/lib.js", get(lib))
.route("/htmx.js", get(htmx))
.with_state(State(config))
}

47
static/lib.js Normal file
View File

@ -0,0 +1,47 @@
class Timeseries {
#uri;
#title;
#targetEl;
//#width;
//#height;
constructor(uri, targetEl, /** width, height **/) {
this.#uri = uri;
this.#targetEl = targetEl;
//this.#width = width;
//this.#height = height;
}
async fetchData() {
const response = await fetch(this.#uri);
const data = await response.json();
return data;
}
async updateGraph() {
const data = await this.fetchData();
if (data.Series) {
var traces = [];
for (const pair of data.Series) {
var trace = {
type: "scatter",
mode: "lines",
x: [],
y: []
};
//const labels = pair[0];
const series = pair[1];
for (const point of series) {
trace.x.push(point.timestamp);
trace.y.push(point.value);
}
traces.push(trace);
}
console.log("Traces: ", traces);
Plotly.react(this.#targetEl, traces, { width: 500, height: 500 });
} else if (data.Scalar) {
// The graph should be a single value
}
}
}