Merge pull request #22 from zaphar/loki_queries

Loki queries
This commit is contained in:
Jeremy Wall 2024-03-05 21:22:49 -05:00 committed by GitHub
commit 5a76207cca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 985 additions and 151 deletions

166
Cargo.lock generated
View File

@ -429,12 +429,33 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.1"
@ -579,6 +600,7 @@ dependencies = [
"maud",
"parse_duration",
"prometheus-http-query",
"reqwest",
"serde",
"serde_json",
"serde_yaml",
@ -719,6 +741,19 @@ dependencies = [
"tokio-rustls",
]
[[package]]
name = "hyper-tls"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
dependencies = [
"bytes",
"hyper 0.14.28",
"native-tls",
"tokio",
"tokio-native-tls",
]
[[package]]
name = "hyper-util"
version = "0.1.3"
@ -885,6 +920,24 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "native-tls"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e"
dependencies = [
"lazy_static",
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
@ -1002,6 +1055,50 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "openssl"
version = "0.10.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15c9d69dd87a29568d4d017cfe8ec518706046a05184e5aea92d0af890b803c8"
dependencies = [
"bitflags 2.4.2",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-probe"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-sys"
version = "0.9.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e1bf214306098e4832460f797824c05d25aacdf896f64a985fb0fd992454ae"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "overload"
version = "0.1.1"
@ -1063,6 +1160,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkg-config"
version = "0.3.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2900ede94e305130c13ddd391e0ab7cbaeb783945ae07a279c268cb05109c6cb"
[[package]]
name = "polling"
version = "3.3.2"
@ -1183,10 +1286,12 @@ dependencies = [
"http-body 0.4.6",
"hyper 0.14.28",
"hyper-rustls",
"hyper-tls",
"ipnet",
"js-sys",
"log",
"mime",
"native-tls",
"once_cell",
"percent-encoding",
"pin-project-lite",
@ -1198,6 +1303,7 @@ dependencies = [
"sync_wrapper",
"system-configuration",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tower-service",
"url",
@ -1284,6 +1390,15 @@ version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c"
[[package]]
name = "schannel"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534"
dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "sct"
version = "0.7.1"
@ -1294,6 +1409,29 @@ dependencies = [
"untrusted",
]
[[package]]
name = "security-framework"
version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "serde"
version = "1.0.196"
@ -1444,6 +1582,18 @@ dependencies = [
"libc",
]
[[package]]
name = "tempfile"
version = "3.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a365e8cd18e44762ef95d87f284f4b5cd04107fec2ff3052bd6a3e6069669e67"
dependencies = [
"cfg-if",
"fastrand",
"rustix",
"windows-sys 0.52.0",
]
[[package]]
name = "thread_local"
version = "1.1.7"
@ -1527,6 +1677,16 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.24.1"
@ -1717,6 +1877,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.4"

View File

@ -23,3 +23,4 @@ tokio = { version = "1.36.0", features = ["net", "rt", "rt-multi-thread"] }
tower-http = { version = "0.5.1", features = ["trace"] }
tracing = "0.1.40"
tracing-subscriber = "0.3.18"
reqwest = { version = "0.11.24", features = ["rustls-tls"] }

View File

@ -58,3 +58,16 @@
query: 'node_memory_MemFree_bytes{job="nodestats"}'
meta:
name_format: "`${labels.instance}`"
- title: Log Test Dashboard 1
span:
end: now
duration: 1h
step_duration: 5min
logs:
- title: Systemd Service Logs
query_type: Range
yaxes:
- anchor: "y" # This axis is y
source: http://heimdall:3100
query: |
{job="systemd-journal"}

View File

@ -7,7 +7,7 @@
};
naersk.url = "github:nix-community/naersk";
flake-compat = {
url = github:edolstra/flake-compat;
url = "github:edolstra/flake-compat";
flake = false;
};
flake-utils.url = "github:numtide/flake-utils";

11
jsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "es2022",
"noImplicitThis": true,
"checkJs": true,
"allowJs": true
},
"include": [
"static/*.js"
]
}

View File

@ -13,14 +13,16 @@
// limitations under the License.
use std::path::Path;
use anyhow::Result;
use chrono::prelude::*;
use chrono::Duration;
use serde::{Serialize, Deserialize};
use serde::{Deserialize, Serialize};
use serde_yaml;
use tracing::{debug, error};
use anyhow::Result;
use crate::query::{QueryConn, QueryType, QueryResult, to_samples};
use crate::query::{
loki_to_sample, prom_to_samples, LokiConn, PromQueryConn, QueryResult, QueryType,
};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PlotMeta {
@ -73,7 +75,8 @@ pub struct GraphSpan {
#[derive(Deserialize)]
pub struct Dashboard {
pub title: String,
pub graphs: Vec<Graph>,
pub graphs: Option<Vec<Graph>>,
pub logs: Option<Vec<LogStream>>,
pub span: Option<GraphSpan>,
}
@ -92,6 +95,8 @@ pub enum Orientation {
Vertical,
}
// NOTE(zapher): These two structs look repetitive but we haven't hit the rule of three yet.
// If we do then it might be time to restructure them a bit.
#[derive(Deserialize)]
pub struct Graph {
pub title: String,
@ -103,21 +108,49 @@ pub struct Graph {
pub d3_tick_format: Option<String>,
}
pub async fn query_data(graph: &Graph, dash: &Dashboard, query_span: Option<GraphSpan>) -> Result<Vec<QueryResult>> {
#[derive(Deserialize)]
pub struct LogStream {
pub title: String,
pub legend_orientation: Option<Orientation>,
pub source: String,
pub yaxes: Vec<AxisDefinition>,
pub query: String,
pub span: Option<GraphSpan>,
pub limit: Option<usize>,
pub query_type: QueryType,
}
pub async fn prom_query_data(
graph: &Graph,
dash: &Dashboard,
query_span: Option<GraphSpan>,
) -> Result<Vec<QueryResult>> {
let connections = graph.get_query_connections(&dash.span, &query_span);
let mut data = Vec::new();
for conn in connections {
data.push(to_samples(
conn.get_results()
.await?
.data()
.clone(),
data.push(prom_to_samples(
conn.get_results().await?.data().clone(),
conn.meta,
));
}
Ok(data)
}
pub async fn loki_query_data(
stream: &LogStream,
dash: &Dashboard,
query_span: Option<GraphSpan>,
) -> Result<QueryResult> {
let conn = stream.get_query_connection(&dash.span, &query_span);
let response = conn.get_results().await?;
if response.status == "success" {
Ok(loki_to_sample(response.data))
} else {
// TODO(jwall): Better error handling than this
panic!("Loki query status: {}", response.status)
}
}
fn duration_from_string(duration_string: &str) -> Option<Duration> {
match parse_duration::parse(duration_string) {
Ok(d) => match Duration::from_std(d) {
@ -172,15 +205,20 @@ impl Graph {
&'graph self,
graph_span: &'graph Option<GraphSpan>,
query_span: &'graph Option<GraphSpan>,
) -> Vec<QueryConn<'conn>> {
) -> Vec<PromQueryConn<'conn>> {
let mut conns = Vec::new();
for plot in self.plots.iter() {
debug!(
query = plot.query,
source = plot.source,
"Getting query connection for graph"
"Getting query connection for graph",
);
let mut conn = PromQueryConn::new(
&plot.source,
&plot.query,
self.query_type.clone(),
plot.meta.clone(),
);
let mut conn = QueryConn::new(&plot.source, &plot.query, self.query_type.clone(), plot.meta.clone());
// Query params take precendence over all other settings. Then graph settings take
// precedences and finally the dashboard settings take precendence
if let Some((end, duration, step_duration)) = graph_span_to_tuple(query_span) {
@ -196,6 +234,34 @@ impl Graph {
}
}
impl LogStream {
pub fn get_query_connection<'conn, 'stream: 'conn>(
&'stream self,
graph_span: &'stream Option<GraphSpan>,
query_span: &'stream Option<GraphSpan>,
) -> LokiConn<'conn> {
debug!(
query = self.query,
source = self.source,
"Getting query connection for log streams",
);
let mut conn = LokiConn::new(&self.source, &self.query, self.query_type.clone());
// Query params take precendence over all other settings. Then graph settings take
// 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);
}
if let Some(limit) = self.limit {
conn = conn.with_limit(limit);
}
conn
}
}
pub fn read_dashboard_list(path: &Path) -> anyhow::Result<Vec<Dashboard>> {
let f = std::fs::File::open(path)?;
Ok(serde_yaml::from_reader(f)?)

View File

@ -11,15 +11,15 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::path::PathBuf;
use anyhow;
use axum::{self, extract::State, routing::*, Router};
use clap::{self, Parser, ValueEnum};
use dashboard::{Dashboard, query_data};
use dashboard::{prom_query_data, Dashboard};
use std::path::PathBuf;
use tokio::net::TcpListener;
use tower_http::trace::TraceLayer;
use tracing::{error, info};
use tracing::Level;
use tracing::{error, info};
use tracing_subscriber::FmtSubscriber;
mod dashboard;
@ -49,13 +49,15 @@ struct Cli {
}
async fn validate(dash: &Dashboard) -> anyhow::Result<()> {
for graph in dash.graphs.iter() {
let data = query_data(graph, &dash, None).await;
if let Some(ref graphs) = dash.graphs {
for graph in graphs.iter() {
let data = prom_query_data(graph, &dash, None).await;
if data.is_err() {
error!(err=?data, "Invalid dashboard query or queries");
}
let _ = data?;
}
}
return Ok(());
}
@ -94,12 +96,18 @@ async fn main() -> anyhow::Result<()> {
"/embed/dash/:dash_idx/graph/:graph_idx",
get(routes::graph_embed).with_state(State(config.clone())),
)
.route(
"/embed/dash/:dash_idx/log/:graph_idx",
get(routes::log_embed).with_state(State(config.clone())),
)
.route("/dash/:dash_idx", get(routes::dashboard_direct))
.route("/", get(routes::index).with_state(State(config.clone())))
.layer(TraceLayer::new_for_http())
.with_state(State(config.clone()));
let socket_addr = args.listen.unwrap_or("127.0.0.1:3000".to_string());
let listener = TcpListener::bind(socket_addr).await.expect("Unable to bind listener to address");
let listener = TcpListener::bind(socket_addr)
.await
.expect("Unable to bind listener to address");
axum::serve(listener, router).await?;
Ok(())
}

192
src/query/loki.rs Normal file
View File

@ -0,0 +1,192 @@
// Copyright 2024 Jeremy Wall
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::collections::HashMap;
use anyhow::Result;
use chrono::prelude::*;
use reqwest;
use serde::{Deserialize, Serialize};
use tracing::{debug, error};
use super::{LogLine, QueryResult, QueryType, TimeSpan};
// TODO(jwall): Should I allow non stream returns?
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub enum ResultType {
/// Returned by query endpoints
#[serde(rename = "vector")]
Vector,
/// Returned by query_range endpoints
#[serde(rename = "matrix")]
Matrix,
/// Returned by query and query_range endpoints
#[serde(rename = "streams")]
Streams,
}
// Note that the value and volue types return a pair where the first item is a string but
// will in actuality always be an f64 number.
#[derive(Serialize, Deserialize, Debug)]
pub struct LokiResult {
#[serde(alias = "metric")]
#[serde(alias = "stream")]
labels: HashMap<String, String>,
/// Calculated Value returned by vector result types
value: Option<(String, String)>,
/// Stream of Log lines, Returned by matrix and stream result types
values: Option<Vec<(String, String)>>,
}
#[derive(Serialize, Deserialize)]
pub struct LokiResponse {
pub status: String,
pub data: LokiData,
}
#[derive(Serialize, Deserialize)]
pub struct LokiData {
#[serde(rename = "resultType")]
result_type: ResultType,
result: Vec<LokiResult>,
//stats: // TODO
}
pub fn loki_to_sample(data: LokiData) -> QueryResult {
match data.result_type {
ResultType::Vector => {
let mut values = Vec::with_capacity(data.result.len());
for result in data.result {
if let Some(value) = result.value {
values.push((
result.labels,
LogLine {
timestamp: value.0.parse::<f64>().expect("Invalid f64 type"),
line: value.1,
},
));
} else {
error!(
?result,
"Invalid LokiResult: No value field when result type is {:?}",
data.result_type,
);
}
}
QueryResult::StreamInstant(values)
}
// Stream types are nanoseconds. // Matrix types are seconds
ResultType::Matrix | ResultType::Streams => {
let mut values = Vec::with_capacity(data.result.len());
let multiple = (if data.result_type == ResultType::Matrix { 1000000 } else { 1 }) as f64;
for result in data.result {
if let Some(value) = result.values {
values.push((
result.labels,
value
.into_iter()
.map(|(timestamp, line)| LogLine {
timestamp: multiple * timestamp.parse::<f64>().expect("Invalid f64 type"),
line,
})
.collect(),
));
} else {
error!(
?result,
"Invalid LokiResult: No values field when result type is {:?}",
data.result_type,
);
}
}
QueryResult::Stream(values)
}
}
}
pub struct LokiConn<'conn> {
url: &'conn str,
query: &'conn str,
span: Option<TimeSpan>,
query_type: QueryType,
limit: Option<usize>,
}
const SCALAR_API_PATH: &'static str = "/loki/api/v1/query";
const RANGE_API_PATH: &'static str = "/loki/api/v1/query_range";
impl<'conn> LokiConn<'conn> {
pub fn new<'a: 'conn>(url: &'a str, query: &'a str, query_type: QueryType) -> Self {
Self {
url,
query,
query_type,
span: None,
limit: None,
}
}
pub fn with_limit(mut self, limit: usize) -> Self {
self.limit = Some(limit);
self
}
pub fn with_span(
mut self,
end: DateTime<Utc>,
duration: chrono::Duration,
step: chrono::Duration,
) -> Self {
self.span = Some(TimeSpan {
end,
duration,
step_seconds: step.num_seconds(),
});
self
}
pub async fn get_results(&self) -> Result<LokiResponse> {
let url = match self.query_type {
QueryType::Scalar => format!("{}{}", self.url, SCALAR_API_PATH),
QueryType::Range => format!("{}{}", self.url, RANGE_API_PATH),
};
let client = reqwest::Client::new();
let mut req = client.get(url).query(&[("query", self.query)]);
debug!(?req, "Building loki reqwest client");
if self.limit.is_some() {
debug!(?req, "adding limit");
req = req.query(&[("limit", &self.limit.map(|u| u.to_string()).unwrap())]);
}
if let QueryType::Range = self.query_type {
debug!(?req, "Configuring span query params");
let (since, end, step_resolution) = if let Some(span) = &self.span {
(
span.duration,
span.end.timestamp(),
span.step_seconds as f64,
)
} else {
let end = Utc::now();
(chrono::Duration::minutes(10), end.timestamp(), 30 as f64)
};
req = req.query(&[
("end", &end.to_string()),
("since", &format!("{}s", since.num_seconds())),
("step", &step_resolution.to_string()),
]);
}
debug!(?req, "Sending request");
Ok(req.send().await?.json().await?)
}
}

95
src/query/mod.rs Normal file
View File

@ -0,0 +1,95 @@
// Copyright 2023 Jeremy Wall
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::collections::HashMap;
use chrono::prelude::*;
use serde::{Deserialize, Serialize};
use crate::dashboard::PlotMeta;
mod loki;
mod prom;
#[derive(Deserialize, Clone, Debug)]
pub enum QueryType {
Range,
Scalar,
}
#[derive(Debug)]
pub struct TimeSpan {
pub end: DateTime<Utc>,
pub duration: chrono::Duration,
pub step_seconds: i64,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct DataPoint {
timestamp: f64,
value: f64,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct LogLine {
timestamp: f64,
line: String,
}
#[derive(Serialize, Deserialize)]
pub enum QueryResult {
Series(Vec<(HashMap<String, String>, PlotMeta, Vec<DataPoint>)>),
Scalar(Vec<(HashMap<String, String>, PlotMeta, DataPoint)>),
StreamInstant(Vec<(HashMap<String, String>, LogLine)>),
Stream(Vec<(HashMap<String, String>, Vec<LogLine>)>),
}
impl std::fmt::Debug for QueryResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
QueryResult::Series(v) => {
f.write_fmt(format_args!("Series trace count = {}", v.len()))?;
for (idx, (tags, meta, trace)) in v.iter().enumerate() {
f.write_fmt(format_args!(
"; {}: tags {:?} meta: {:?} datapoint count = {};",
idx,
tags,
meta,
trace.len()
))?;
}
}
QueryResult::Scalar(v) => {
f.write_fmt(format_args!("{} traces", v.len()))?;
}
QueryResult::StreamInstant(v) => {
f.write_fmt(format_args!("{} traces", v.len()))?;
}
QueryResult::Stream(v) => {
f.write_fmt(format_args!("stream trace count = {}", v.len()))?;
for (idx, (tags, trace)) in v.iter().enumerate() {
f.write_fmt(format_args!(
"; {}: tags {:?} line count = {}",
idx,
tags,
trace.len()
))?
}
}
}
Ok(())
}
}
pub use loki::*;
pub use prom::*;

View File

@ -1,6 +1,4 @@
use std::collections::HashMap;
// Copyright 2023 Jeremy Wall
// Copyright 2024 Jeremy Wall
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@ -13,31 +11,21 @@ use std::collections::HashMap;
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::collections::HashMap;
use chrono::prelude::*;
use prometheus_http_query::{
response::{Data, PromqlResult},
Client,
};
use serde::{Deserialize, Serialize};
use tracing::debug;
use crate::dashboard::PlotMeta;
#[derive(Deserialize, Clone, Debug)]
pub enum QueryType {
Range,
Scalar,
}
use super::{DataPoint, QueryResult, QueryType, TimeSpan};
#[derive(Debug)]
pub struct TimeSpan {
pub end: DateTime<Utc>,
pub duration: chrono::Duration,
pub step_seconds: i64,
}
#[derive(Debug)]
pub struct QueryConn<'conn> {
pub struct PromQueryConn<'conn> {
source: &'conn str,
query: &'conn str,
span: Option<TimeSpan>,
@ -45,8 +33,13 @@ pub struct QueryConn<'conn> {
pub meta: PlotMeta,
}
impl<'conn> QueryConn<'conn> {
pub fn new<'a: 'conn>(source: &'a str, query: &'a str, query_type: QueryType, meta: PlotMeta) -> Self {
impl<'conn> PromQueryConn<'conn> {
pub fn new<'a: 'conn>(
source: &'a str,
query: &'a str,
query_type: QueryType,
meta: PlotMeta,
) -> Self {
Self {
source,
query,
@ -113,42 +106,7 @@ impl<'conn> QueryConn<'conn> {
}
}
#[derive(Serialize, Deserialize, Debug)]
pub struct DataPoint {
timestamp: f64,
value: f64,
}
#[derive(Serialize, Deserialize)]
pub enum QueryResult {
Series(Vec<(HashMap<String, String>, PlotMeta, Vec<DataPoint>)>),
Scalar(Vec<(HashMap<String, String>, PlotMeta, DataPoint)>),
}
impl std::fmt::Debug for QueryResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
QueryResult::Series(v) => {
f.write_fmt(format_args!("Series trace count = {}", v.len()))?;
for (idx, (tags, meta, trace)) in v.iter().enumerate() {
f.write_fmt(format_args!(
"; {}: tags {:?} meta: {:?} datapoint count = {};",
idx,
tags,
meta,
trace.len()
))?;
}
}
QueryResult::Scalar(v) => {
f.write_fmt(format_args!("{} traces", v.len()))?;
}
}
Ok(())
}
}
pub fn to_samples(data: Data, meta: PlotMeta) -> QueryResult {
pub fn prom_to_samples(data: Data, meta: PlotMeta) -> QueryResult {
match data {
Data::Matrix(mut range) => QueryResult::Series(
range

View File

@ -22,10 +22,12 @@ use axum::{
// https://maud.lambda.xyz/getting-started.html
use maud::{html, Markup};
use serde::{Serialize, Deserialize};
use serde::{Deserialize, Serialize};
use tracing::debug;
use crate::dashboard::{Dashboard, Graph, GraphSpan, AxisDefinition, Orientation, query_data};
use crate::dashboard::{
loki_query_data, prom_query_data, AxisDefinition, Dashboard, Graph, GraphSpan, Orientation, LogStream,
};
use crate::query::QueryResult;
type Config = State<Arc<Vec<Dashboard>>>;
@ -37,17 +39,57 @@ pub struct GraphPayload {
pub plots: Vec<QueryResult>,
}
// TODO(jwall): Should this be a completely different payload?
pub async fn loki_query(
State(config): Config,
Path((dash_idx, loki_idx)): Path<(usize, usize)>,
Query(query): Query<HashMap<String, String>>,
) -> Json<GraphPayload> {
let dash = config
.get(dash_idx)
.expect(&format!("No such dashboard index {}", dash_idx));
let log = dash
.logs
.as_ref()
.expect("No logs in this dashboard")
.get(loki_idx)
.expect(&format!("No such log query {}", loki_idx));
let plots = vec![loki_query_data(log, dash, query_to_graph_span(query))
.await
.expect("Unable to get log query results")];
Json(GraphPayload {
legend_orientation: None,
yaxes: log.yaxes.clone(),
plots,
})
}
pub async fn graph_query(
State(config): Config,
Path((dash_idx, graph_idx)): Path<(usize, usize)>,
Query(query): Query<HashMap<String, String>>,
) -> Json<GraphPayload> {
debug!("Getting data for query");
let dash = config.get(dash_idx).expect("No such dashboard index");
let dash = config
.get(dash_idx)
.expect(&format!("No such dashboard index {}", dash_idx));
let graph = dash
.graphs
.as_ref()
.expect("No graphs in this dashboard")
.get(graph_idx)
.expect(&format!("No such graph in dasboard {}", dash_idx));
let plots = prom_query_data(graph, dash, query_to_graph_span(query))
.await
.expect("Unable to get query results");
Json(GraphPayload {
legend_orientation: graph.legend_orientation.clone(),
yaxes: graph.yaxes.clone(),
plots,
})
}
fn query_to_graph_span(query: HashMap<String, String>) -> Option<GraphSpan> {
let query_span = {
if query.contains_key("end")
&& query.contains_key("duration")
@ -62,16 +104,32 @@ pub async fn graph_query(
None
}
};
let plots = query_data(graph, dash, query_span).await.expect("Unable to get query results");
Json(GraphPayload{legend_orientation: graph.legend_orientation.clone(), yaxes: graph.yaxes.clone(), plots})
query_span
}
pub fn mk_api_routes(config: Arc<Vec<Dashboard>>) -> Router<Config> {
// Query routes
Router::new().route(
Router::new()
.route(
"/dash/:dash_idx/graph/:graph_idx",
get(graph_query).with_state(config),
get(graph_query).with_state(config.clone()),
)
.route(
"/dash/:dash_idx/log/:log_idx",
get(loki_query).with_state(config),
)
}
pub fn log_component(dash_idx: usize, log_idx: usize, log: &LogStream) -> Markup {
let log_id = format!("log-{}-{}", dash_idx, log_idx);
let log_data_uri = format!("/api/dash/{}/log/{}", dash_idx, log_idx);
let log_embed_uri = format!("/embed/dash/{}/log/{}", dash_idx, log_idx);
html! {
div {
h2 { (log.title) " - " a href=(log_embed_uri) { "embed url" } }
graph-plot uri=(log_data_uri) id=(log_id) { }
}
}
}
pub fn graph_component(dash_idx: usize, graph_idx: usize, graph: &Graph) -> Markup {
@ -82,9 +140,9 @@ pub fn graph_component(dash_idx: usize, graph_idx: usize, graph: &Graph) -> Mark
div {
h2 { (graph.title) " - " a href=(graph_embed_uri) { "embed url" } }
@if graph.d3_tick_format.is_some() {
timeseries-graph uri=(graph_data_uri) id=(graph_id) d3-tick-format=(graph.d3_tick_format.as_ref().unwrap()) { }
graph-plot uri=(graph_data_uri) id=(graph_id) d3-tick-format=(graph.d3_tick_format.as_ref().unwrap()) { }
} @else {
timeseries-graph uri=(graph_data_uri) id=(graph_id) { }
graph-plot uri=(graph_data_uri) id=(graph_id) { }
}
}
)
@ -96,31 +154,68 @@ pub async fn graph_ui(
) -> Markup {
let graph = config
.get(dash_idx)
.expect("No such dashboard")
.expect(&format!("No such dashboard {}", dash_idx))
.graphs
.as_ref()
.expect("No graphs in this dashboard")
.get(graph_idx)
.expect("No such graph");
graph_component(dash_idx, graph_idx, graph)
}
pub async fn log_ui(
State(config): State<Config>,
Path((dash_idx, log_idx)): Path<(usize, usize)>,
) -> Markup {
let log = config
.get(dash_idx)
.expect(&format!("No such dashboard {}", dash_idx))
.logs
.as_ref()
.expect("No graphs in this dashboard")
.get(log_idx)
.expect("No such graph");
log_component(dash_idx, log_idx, log)
}
pub async fn dash_ui(State(config): State<Config>, Path(dash_idx): Path<usize>) -> Markup {
// TODO(zaphar): Should do better http error reporting here.
dash_elements(config, dash_idx)
}
fn dash_elements(config: State<Arc<Vec<Dashboard>>>, dash_idx: usize) -> maud::PreEscaped<String> {
let dash = config.get(dash_idx).expect("No such dashboard");
let graph_iter = dash
let dash = config
.get(dash_idx)
.expect(&format!("No such dashboard {}", dash_idx));
let graph_components = if let Some(graphs) = dash
.graphs
.iter()
.as_ref() {
let graph_iter = graphs.iter()
.enumerate()
.collect::<Vec<(usize, &Graph)>>();
html!(
h1 { (dash.title) }
span-selector class="row-flex" {}
Some(html! {
@for (idx, graph) in &graph_iter {
(graph_component(dash_idx, *idx, *graph))
}
})
} else {
None
};
let log_components = if let Some(logs) = dash.logs.as_ref() {
let log_iter = logs.iter().enumerate().collect::<Vec<(usize, &LogStream)>>();
Some(html! {
@for (idx, log) in &log_iter {
(log_component(dash_idx, *idx, *log))
}
})
} else {
None
};
html!(
h1 { (dash.title) }
span-selector class="row-flex" {}
@if graph_components.is_some() { (graph_components.unwrap()) }
@if log_components.is_some() { (log_components.unwrap()) }
)
}
@ -139,7 +234,7 @@ pub fn mk_ui_routes(config: Arc<Vec<Dashboard>>) -> Router<Config> {
fn graph_lib_prelude() -> Markup {
html! {
script src="/js/plotly.js" { }
script defer src="/js/lib.js" { }
script type="module" defer src="/js/lib.js" { }
link rel="stylesheet" href="/static/site.css" { }
}
}
@ -161,6 +256,23 @@ pub async fn graph_embed(
}
}
pub async fn log_embed(
State(config): State<Config>,
Path((dash_idx, log_idx)): Path<(usize, usize)>,
) -> Markup {
html! {
html {
head {
title { ("Heracles - Prometheus Unshackled") }
}
body {
(graph_lib_prelude())
(log_ui(State(config.clone()), Path((dash_idx, log_idx))).await)
}
}
}
}
async fn index_html(config: Config, dash_idx: Option<usize>) -> Markup {
html! {
html {

View File

@ -12,25 +12,145 @@
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* @typedef PlotList
* @type {object}
* @property {Array=} Series
* @property {Array=} Scalar
* @property {Array<{timestamp: string, line: string}>=} StreamInstant - Timestamps are in seconds
* @property {Array<{timestamp: string, line: string}>=} Stream - Timestamps are in nanoseconds
*/
/**
* @typedef QueryData
* @type {object}
* @property {object} yaxes
* @property {?string} legend_orientation
* @property {Array<PlotList>} plots
*/
/**
* @typedef HeaderOrCell
* @type {object}
* @property {array} values
* @property {{color: string}=} fill
* @property {object=} font
* @property {string=} font.family
* @property {number=} font.size
* @property {string=} font.color
* @property {{width: number, color: string}=} line
* @property {Array<number>=} columnwidth
*/
/**
* @typedef TableTrace
* @type {object}
* @property {string=} name
* @property {string} type
* @property {string=} mode
* @property {HeaderOrCell} header
* @property {HeaderOrCell} cells - An Array of columns for the table.
* @property {string=} xaxis
* @property {string=} yaxis
*/
/**
* @typedef GraphTrace
* @type {object}
* @property {string=} name
* @property {string=} fill
* @property type {string}
* @property {string=} mode
* @property {Array} x
* @property {Array} y
* @property {string=} xaxis
* @property {string=} yaxis
*/
/**
* @typedef PlotTrace
* @type {(TableTrace|GraphTrace)}
*/
/**
* Map ansi terminal codes to html color codes.
* @param {string} line
*/
function ansiToHtml(line) {
const ansiToHtmlMap = {
// Map ANSI color codes to HTML color names or hex values
// We don't necessarily handle all the colors but this is enough to start.
"30": "black",
"31": "red",
"32": "green",
"33": "yellow",
"34": "blue",
"35": "magenta",
"36": "cyan",
"37": "white",
"39": "initial"
};
// NOTE(zaphar): Yes this is gross and I should really do a better parser but I'm lazy.
// Replace ANSI codes with HTML span elements styled with the corresponding color
return line.replace(/\x1b\[([0-9;]*)m/g, (match, p1) => {
const parts = p1.split(';'); // ANSI codes can be compounded, e.g., "1;31" for bold red
let styles = '';
for (let part of parts) {
if (ansiToHtmlMap[part]) {
// If the code is a color, map it to a CSS color
styles += `color: ${ansiToHtmlMap[part]};`;
}
// TODO(zaphar): Add more conditions here to handle other styles like bold or underline?
}
return styles ? `<span style="${styles}">` : '</span>';
}) + '</span>';
}
/**
* Get's a css variable's value from the document.
* @param {string} variableName - Name of the variable to get `--var-name`
* @returns string
*/
function getCssVariableValue(variableName) {
return getComputedStyle(document.documentElement).getPropertyValue(variableName);
}
class TimeseriesGraph extends HTMLElement {
/**
* Custom element for showing a plotly graph.
*
* @extends HTMLElement
*/
export class GraphPlot extends HTMLElement {
/** @type {?string} */
#uri;
/** @type {?number} */
#width;
/** @type {?number} */
#height;
/** @type {?number} */
#intervalId;
/** @type {?number} */
#pollSeconds;
/** @type {?string} */
#end;
/** @type {?number} */
#duration;
/** @type {?string} */
#step_duration;
/** @type {?string} */
#d3TickFormat = "~s";
/** @type {?HTMLDivElement} */
#targetNode = null;
/** @type {?HTMLElement} */
#menuContainer = null;
/** @type {Object<string, HTMLSelectElement>} */
#filterSelectElements = {};
/** @type {Object<string, Array<string>>} */
#filterLabels = {};
/** @type {Object<string, Array<string>>} */
#filteredLabelSets = {};
constructor() {
super();
this.#width = 800;
@ -45,25 +165,32 @@ class TimeseriesGraph extends HTMLElement {
static observedAttributes = ['uri', 'width', 'height', 'poll-seconds', 'end', 'duration', 'step-duration', 'd3-tick-format'];
/**
* Callback for attributes changes.
*
* @param {string} name - The name of the attribute.
* @param {?string} _oldValue - The old value for the attribute
* @param {?string} newValue - The new value for the attribute
*/
attributeChangedCallback(name, _oldValue, newValue) {
switch (name) {
case 'uri':
this.#uri = newValue;
break;
case 'width':
this.#width = newValue;
this.#width = Number(newValue);
break;
case 'height':
this.#height = newValue;
this.#height = Number(newValue);
break;
case 'poll-seconds':
this.#pollSeconds = newValue;
this.#pollSeconds = Number(newValue);
break;
case 'end':
this.#end = newValue;
break;
case 'duration':
this.#duration = newValue;
this.#duration = Number(newValue);
break;
case 'step-duration':
this.#step_duration = newValue;
@ -79,14 +206,13 @@ class TimeseriesGraph extends HTMLElement {
connectedCallback() {
this.#uri = this.getAttribute('uri') || this.#uri;
this.#width = this.getAttribute('width') || this.#width;
this.#height = this.getAttribute('height') || this.#height;
this.#pollSeconds = this.getAttribute('poll-seconds') || this.#pollSeconds;
this.#width = Number(this.getAttribute('width') || this.#width);
this.#height = Number(this.getAttribute('height') || this.#height);
this.#pollSeconds = Number(this.getAttribute('poll-seconds') || this.#pollSeconds);
this.#end = this.getAttribute('end') || null;
this.#duration = this.getAttribute('duration') || null;
this.#duration = Number(this.getAttribute('duration')) || null;
this.#step_duration = this.getAttribute('step-duration') || null;
this.#d3TickFormat = this.getAttribute('d3-tick-format') || this.#d3TickFormat;
var self = this;
this.reset();
}
@ -94,12 +220,19 @@ class TimeseriesGraph extends HTMLElement {
this.stopInterval()
}
static elementName = "timeseries-graph";
static elementName = "graph-plot";
/*
* Get's the target node for placing the plotly graph.
*
* @returns {?HTMLDivElement}
*/
getTargetNode() {
return this.#targetNode;
}
/**
*/
stopInterval() {
if (this.#intervalId) {
clearInterval(this.#intervalId);
@ -107,6 +240,10 @@ class TimeseriesGraph extends HTMLElement {
}
}
/**
* Resets the entire graph and then restarts polling.
* @param {boolean=} updateOnly
*/
reset(updateOnly) {
var self = this;
self.stopInterval()
@ -121,12 +258,18 @@ class TimeseriesGraph extends HTMLElement {
});
}
/** Registers the custom element if it doesn't already exist */
static registerElement() {
if (!customElements.get(TimeseriesGraph.elementName)) {
customElements.define(TimeseriesGraph.elementName, TimeseriesGraph);
if (!customElements.get(GraphPlot.elementName)) {
customElements.define(GraphPlot.elementName, GraphPlot);
}
}
/**
* Returns the uri formatted with any query strings if necessary.
*
* @returns {string}
*/
getUri() {
if (this.#end && this.#duration && this.#step_duration) {
return this.#uri + "?end=" + this.#end + "&duration=" + this.#duration + "&step_duration=" + this.#step_duration;
@ -135,6 +278,11 @@ class TimeseriesGraph extends HTMLElement {
}
}
/**
* Returns the data from an api call.
*
* @return {Promise<QueryData>}
*/
async fetchData() {
// TODO(zaphar): Can we do some massaging on these
// to get the full set of labels and possible values?
@ -143,6 +291,12 @@ class TimeseriesGraph extends HTMLElement {
return data;
}
/**
* Formats the name for the plot trace.
* @param {{name_format: ?string}} meta
* @param {Map<string, string>} labels
* @return string
*/
formatName(meta, labels) {
var name = "";
const formatter = meta.name_format
@ -158,6 +312,9 @@ class TimeseriesGraph extends HTMLElement {
return name;
}
/**
* @param {Object<string, string>} labels
*/
populateFilterData(labels) {
for (var key in labels) {
const label = this.#filterLabels[key];
@ -171,13 +328,18 @@ class TimeseriesGraph extends HTMLElement {
}
}
/**
* @param {string} key
* @returns {HTMLDivElement}
*/
buildSelectElement(key) {
// TODO(jwall): Should we have a select all?
var id = key + "-select" + Math.random();
const element = document.createElement("div");
const select = document.createElement("select");
select.setAttribute("name", id);
select.setAttribute("multiple", true);
// TODO(jwall): This is how you set boolean attributes. Use the attribute named... :-(
select.setAttribute("multiple", "multiple");
const optElement = document.createElement("option");
const optValue = "Select " + key;
optElement.innerText = optValue;
@ -185,7 +347,7 @@ class TimeseriesGraph extends HTMLElement {
for (var opt of this.#filterLabels[key]) {
const optElement = document.createElement("option");
optElement.setAttribute("value", opt);
optElement.setAttribute("selected", true);
optElement.setAttribute("selected", "selected");
optElement.innerText = opt;
select.appendChild(optElement);
}
@ -194,7 +356,7 @@ class TimeseriesGraph extends HTMLElement {
select.onchange = function(evt) {
evt.stopPropagation();
var filteredValues = [];
for (var opt of evt.target.selectedOptions) {
for (var opt of /** @type {HTMLSelectElement} */(evt.target).selectedOptions) {
filteredValues.push(opt.getAttribute("value"));
}
self.#filteredLabelSets[key] = filteredValues;
@ -218,6 +380,9 @@ class TimeseriesGraph extends HTMLElement {
this.#menuContainer.replaceChildren(...children);
}
/**
* @param {QueryData} graph
*/
getLabelsForData(graph) {
const data = graph.plots;
for (var subplot of data) {
@ -233,6 +398,12 @@ class TimeseriesGraph extends HTMLElement {
this.populateFilterData(labels);
}
}
if (subplot.Stream) {
for (const pair of subplot.Stream) {
const labels = pair[0];
this.populateFilterData(labels);
}
}
}
}
@ -248,6 +419,11 @@ class TimeseriesGraph extends HTMLElement {
};
}
/**
* Update the graph with new data.
*
* @param {?QueryData=} maybeGraph
*/
async updateGraph(maybeGraph) {
var graph = maybeGraph;
if (!graph) {
@ -258,13 +434,13 @@ class TimeseriesGraph extends HTMLElement {
var layout = {
displayModeBar: false,
responsive: true,
plot_bgcolor: getCssVariableValue('--paper-background-color').trim(),
plot_bgcolor: getCssVariableValue('--plot-background-color').trim(),
paper_bgcolor: getCssVariableValue('--paper-background-color').trim(),
font: {
color: getCssVariableValue('--text-color').trim()
},
xaxis: {
gridcolor: getCssVariableValue("--accent-color")
gridcolor: getCssVariableValue("--grid-line-color")
},
legend: {
orientation: 'v'
@ -276,13 +452,12 @@ class TimeseriesGraph extends HTMLElement {
var nextYaxis = this.yaxisNameGenerator();
for (const yaxis of yaxes) {
yaxis.tickformat = yaxis.tickformat || this.#d3TickFormat;
yaxis.gridColor = getCssVariableValue("--accent-color");
yaxis.gridColor = getCssVariableValue("--grid-line-color");
layout[nextYaxis()] = yaxis;
}
var traces = [];
var traces = /** @type {Array<PlotTrace>} */ ([]);
for (var subplot_idx in data) {
const subplot = data[subplot_idx];
const subplotCount = Number(subplot_idx) + 1;
var nextYaxis = this.yaxisNameGenerator();
if (subplot.Series) {
// https://plotly.com/javascript/reference/scatter/
@ -298,7 +473,7 @@ class TimeseriesGraph extends HTMLElement {
var yaxis = meta.yaxis || "y";
// https://plotly.com/javascript/reference/layout/yaxis/
const series = triple[2];
var trace = {
const trace = /** @type GraphTrace */({
type: "scatter",
mode: "lines+text",
x: [],
@ -307,7 +482,7 @@ class TimeseriesGraph extends HTMLElement {
xaxis: "x",
yaxis: yaxis,
//yhoverformat: yaxis.tickformat,
};
});
if (meta.fill) {
trace.fill = meta.fill;
}
@ -331,32 +506,88 @@ class TimeseriesGraph extends HTMLElement {
}
const meta = triple[1];
const series = triple[2];
var trace = {
const trace = /** @type GraphTrace */({
type: "bar",
x: [],
y: [],
yhoverformat: meta["d3_tick_format"],
};
});
var name = this.formatName(meta, labels);
if (name) { trace.name = name; }
trace.y.push(series.value);
trace.x.push(trace.name);
traces.push(trace);
}
} else if (subplot.Stream) {
// TODO(jwall): It would be nice if scroll behavior would handle replots better.
// TODO(jwall): It's possible that this should actually be a separate custom
// element.
const trace = /** @type TableTrace */({
type: "table",
columnwidth: [15, 20, 70],
header: {
align: "left",
values: ["Timestamp","Labels", "Log"],
fill: { color: layout.xaxis.paper_bgcolor },
font: { color: getCssVariableValue('--text-color').trim() }
},
cells: {
align: "left",
values: [],
fill: { color: layout.plot_bgcolor }
},
});
const dateColumn = [];
const metaColumn = [];
const logColumn = [];
loopStream: for (const pair of subplot.Stream) {
const labels = pair[0];
var labelList = [];
for (var label in labels) {
var show = this.#filteredLabelSets[label];
if (show && !show.includes(labels[label])) {
continue loopStream;
}
labelList.push(`${label}:${labels[label]}`);
}
const labelsName = labelList.join("<br>");
const lines = pair[1];
// TODO(jwall): Headers
for (const line of lines) {
// For streams the timestamps are in nanoseconds
// TODO(zaphar): We should improve the timstamp formatting a bit
let timestamp = new Date(line.timestamp / 1000000);
dateColumn.push(timestamp.toISOString());
metaColumn.push(labelsName);
logColumn.push(ansiToHtml(line.line));
}
}
trace.cells.values.push(dateColumn);
trace.cells.values.push(metaColumn);
trace.cells.values.push(logColumn);
traces.push(trace);
}
}
// https://plotly.com/javascript/plotlyjs-function-reference/#plotlyreact
// @ts-ignore
Plotly.react(this.getTargetNode(), traces, layout, null);
}
}
TimeseriesGraph.registerElement();
GraphPlot.registerElement();
class SpanSelector extends HTMLElement {
/** Custom Element for selecting a timespan for the dashboard. */
export class SpanSelector extends HTMLElement {
/** @type {HTMLElement} */
#targetNode = null;
/** @type {HTMLInputElement} */
#endInput = null;
/** @type {HTMLInputElement} */
#durationInput = null;
/** @type {HTMLInputElement} */
#stepDurationInput = null;
/** @type {HTMLButtonElement} */
#updateInput = null
constructor() {
@ -388,8 +619,9 @@ class SpanSelector extends HTMLElement {
this.#updateInput.onclick = undefined;
}
/** Updates all the graphs on the dashboard with the new timespan. */
updateGraphs() {
for (var node of document.getElementsByTagName(TimeseriesGraph.elementName)) {
for (var node of document.getElementsByTagName(GraphPlot.elementName)) {
node.setAttribute('end', this.#endInput.value);
node.setAttribute('duration', this.#durationInput.value);
node.setAttribute('step-duration', this.#stepDurationInput.value);
@ -398,6 +630,7 @@ class SpanSelector extends HTMLElement {
static elementName = "span-selector";
/** Register the element if it doesn't exist */
static registerElement() {
if (!customElements.get(SpanSelector.elementName)) {
customElements.define(SpanSelector.elementName, SpanSelector);
@ -406,3 +639,4 @@ class SpanSelector extends HTMLElement {
}
SpanSelector.registerElement();

View File

@ -3,23 +3,12 @@
--background-color: #FFFFFF; /* Light background */
--text-color: #333333; /* Dark text for contrast */
--paper-background-color: #F0F0F0;
--plot-background-color: #FFFFFF;
--accent-color: #6200EE; /* For buttons and interactive elements */
/* Graph colors */
--graph-color-1: #007BFF; /* Blue */
--graph-color-2: #28A745; /* Green */
--graph-color-3: #DC3545; /* Red */
--graph-color-4: #FFC107; /* Yellow */
--graph-color-5: #17A2B8; /* Cyan */
--graph-color-6: #6C757D; /* Gray */
/* Axis and grid lines */
--axis-color: #CCCCCC;
--grid-line-color: #EEEEEE;
/* Tooltip background */
--tooltip-background-color: #FFFFFF;
--tooltip-text-color: #000000;
}
@media (prefers-color-scheme: dark) {
@ -27,24 +16,13 @@
/* Solarized Dark Base Colors */
--background-color: #002b36; /* base03 */
--paper-background-color: #003c4a;
--plot-background-color: rgb(24, 34, 21);
--text-color: #839496; /* base0 */
--accent-color: #268bd2; /* blue */
/* Graph colors - Solarized Accent Colors */
--graph-color-1: #b58900; /* yellow */
--graph-color-2: #cb4b16; /* orange */
--graph-color-3: #dc322f; /* red */
--graph-color-4: #d33682; /* magenta */
--graph-color-5: #6c71c4; /* violet */
--graph-color-6: #2aa198; /* cyan */
/* Axis and grid lines */
--axis-color: #586e75; /* base01 */
--grid-line-color: #073642; /* base02 */
/* Tooltip background */
--tooltip-background-color: #002b36; /* base03 */
--tooltip-text-color: #839496; /* base0 */
}
}
@ -84,7 +62,7 @@ body * {
flex: 0 1 auto;
}
timeseries-graph {
graph-plot {
background-color: var(--paper-background-color);
border-radius: 4px;
display: flex;