feat: dashboard, visual query builder, expanded query language, cert expiry support

This commit is contained in:
M1
2026-03-16 12:26:17 +04:00
parent 97c08b1951
commit 500132ba05
14 changed files with 1578 additions and 34 deletions
+5
View File
@@ -14,3 +14,8 @@ regex = "1"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
rustls = "0.23"
rustls-native-certs = "0.8"
webpki-roots = "0.26"
x509-parser = "0.16"
tokio-rustls = "0.26"
+150 -30
View File
@@ -10,14 +10,28 @@
/// { "status": { "$ne": 500 } }
/// { "status": { "$gte": 200, "$lt": 300 } }
/// { "body": { "$contains": "healthy" } }
/// { "body": { "$startsWith": "OK" } }
/// { "body": { "$endsWith": "done" } }
/// { "body": { "$regex": "ok|healthy" } }
/// { "body": { "$exists": true } }
/// { "status": { "$in": [200, 201, 204] } }
///
/// CSS selector (HTML parsing):
/// { "$select": "span.status", "$eq": "operational" }
///
/// JSONPath:
/// { "$json": "$.data.status", "$eq": "ok" }
///
/// Response time:
/// { "$responseTime": { "$lt": 500 } }
///
/// Certificate expiry:
/// { "$certExpiry": { "$gt": 30 } }
///
/// Logical:
/// { "$and": [ { "status": 200 }, { "body": { "$contains": "ok" } } ] }
/// { "$or": [ { "status": 200 }, { "status": 204 } ] }
/// { "$not": { "status": 500 } }
use anyhow::{bail, Result};
use regex::Regex;
@@ -28,13 +42,15 @@ pub struct Response {
pub status: u16,
pub body: String,
pub headers: std::collections::HashMap<String, String>,
pub latency_ms: Option<u64>,
pub cert_expiry_days: Option<i64>,
}
/// Returns true if `query` matches `response`. No query = always up.
pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
match query {
Value::Object(map) => {
// $and / $or
// $and / $or / $not
if let Some(and) = map.get("$and") {
let Value::Array(clauses) = and else { bail!("$and expects array") };
return Ok(clauses.iter().all(|c| evaluate(c, response).unwrap_or(false)));
@@ -43,6 +59,33 @@ pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
let Value::Array(clauses) = or else { bail!("$or expects array") };
return Ok(clauses.iter().any(|c| evaluate(c, response).unwrap_or(false)));
}
if let Some(not) = map.get("$not") {
return Ok(!evaluate(not, response)?);
}
// $responseTime
if let Some(cond) = map.get("$responseTime") {
let val = Value::Number(serde_json::Number::from(response.latency_ms.unwrap_or(0)));
return eval_condition(cond, &val, response);
}
// $certExpiry
if let Some(cond) = map.get("$certExpiry") {
let val = Value::Number(serde_json::Number::from(response.cert_expiry_days.unwrap_or(0)));
return eval_condition(cond, &val, response);
}
// $json — JSONPath shorthand
if let Some(json_path) = map.get("$json") {
let path_str = json_path.as_str().unwrap_or("");
let resolved = resolve_json_path(&response.body, path_str);
for (op, val) in map {
if op == "$json" { continue; }
if !eval_op(op, &resolved, val, response)? { return Ok(false); }
}
return Ok(true);
}
// CSS selector shorthand: { "$select": "...", "$eq": "..." }
if let Some(sel) = map.get("$select") {
let sel_str = sel.as_str().unwrap_or("");
@@ -50,12 +93,29 @@ pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
if let Some(op_val) = map.get("$eq") {
return Ok(selected.as_deref() == op_val.as_str());
}
if let Some(op_val) = map.get("$ne") {
return Ok(selected.as_deref() != op_val.as_str());
}
if let Some(op_val) = map.get("$contains") {
let needle = op_val.as_str().unwrap_or("");
return Ok(selected.map(|s| s.contains(needle)).unwrap_or(false));
}
if let Some(op_val) = map.get("$startsWith") {
let needle = op_val.as_str().unwrap_or("");
return Ok(selected.map(|s| s.starts_with(needle)).unwrap_or(false));
}
if let Some(op_val) = map.get("$endsWith") {
let needle = op_val.as_str().unwrap_or("");
return Ok(selected.map(|s| s.ends_with(needle)).unwrap_or(false));
}
if let Some(op_val) = map.get("$regex") {
let pattern = op_val.as_str().unwrap_or("");
let re = Regex::new(pattern).unwrap_or_else(|_| Regex::new("$^").unwrap());
return Ok(selected.map(|s| re.is_match(&s)).unwrap_or(false));
}
return Ok(selected.is_some());
}
// Field-level checks
for (field, condition) in map {
let field_val = resolve_field(field, response);
@@ -83,6 +143,43 @@ fn resolve_field(field: &str, r: &Response) -> Value {
}
}
fn resolve_json_path(body: &str, path: &str) -> Value {
let obj: Value = match serde_json::from_str(body) {
Ok(v) => v,
Err(_) => return Value::Null,
};
let path = path.trim_start_matches("$").trim_start_matches(".");
if path.is_empty() { return obj; }
let mut current = &obj;
for part in path.split('.') {
// Handle array indexing like "items[0]"
if let Some(idx_start) = part.find('[') {
let key = &part[..idx_start];
if !key.is_empty() {
current = match current.get(key) {
Some(v) => v,
None => return Value::Null,
};
}
let idx_str = part[idx_start + 1..].trim_end_matches(']');
if let Ok(idx) = idx_str.parse::<usize>() {
current = match current.get(idx) {
Some(v) => v,
None => return Value::Null,
};
} else {
return Value::Null;
}
} else {
current = match current.get(part) {
Some(v) => v,
None => return Value::Null,
};
}
}
current.clone()
}
fn eval_condition(condition: &Value, field_val: &Value, response: &Response) -> Result<bool> {
match condition {
// Shorthand: { "status": 200 }
@@ -91,35 +188,9 @@ fn eval_condition(condition: &Value, field_val: &Value, response: &Response) ->
Value::Bool(b) => Ok(field_val.as_bool() == Some(*b)),
Value::Object(ops) => {
for (op, val) in ops {
let ok = match op.as_str() {
"$eq" => field_val == val,
"$ne" => field_val != val,
"$gt" => cmp_num(field_val, val, |a,b| a > b),
"$gte" => cmp_num(field_val, val, |a,b| a >= b),
"$lt" => cmp_num(field_val, val, |a,b| a < b),
"$lte" => cmp_num(field_val, val, |a,b| a <= b),
"$contains" => {
let needle = val.as_str().unwrap_or("");
field_val.as_str().map(|s| s.contains(needle)).unwrap_or(false)
}
"$regex" => {
let pattern = val.as_str().unwrap_or("");
let re = Regex::new(pattern).unwrap_or_else(|_| Regex::new("$^").unwrap());
field_val.as_str().map(|s| re.is_match(s)).unwrap_or(false)
}
"$select" => {
// Nested: { "body": { "$select": "css", "$eq": "val" } }
let sel_str = val.as_str().unwrap_or("");
let selected = css_select(&response.body, sel_str);
if let Some(eq_val) = ops.get("$eq") {
selected.as_deref() == eq_val.as_str()
} else {
selected.is_some()
}
}
_ => true, // unknown op — skip
};
if !ok { return Ok(false); }
if !eval_op(op, field_val, val, response)? {
return Ok(false);
}
}
Ok(true)
}
@@ -127,6 +198,55 @@ fn eval_condition(condition: &Value, field_val: &Value, response: &Response) ->
}
}
fn eval_op(op: &str, field_val: &Value, val: &Value, response: &Response) -> Result<bool> {
let ok = match op {
"$eq" => field_val == val,
"$ne" => field_val != val,
"$gt" => cmp_num(field_val, val, |a,b| a > b),
"$gte" => cmp_num(field_val, val, |a,b| a >= b),
"$lt" => cmp_num(field_val, val, |a,b| a < b),
"$lte" => cmp_num(field_val, val, |a,b| a <= b),
"$contains" => {
let needle = val.as_str().unwrap_or("");
field_val.as_str().map(|s| s.contains(needle)).unwrap_or(false)
}
"$startsWith" => {
let needle = val.as_str().unwrap_or("");
field_val.as_str().map(|s| s.starts_with(needle)).unwrap_or(false)
}
"$endsWith" => {
let needle = val.as_str().unwrap_or("");
field_val.as_str().map(|s| s.ends_with(needle)).unwrap_or(false)
}
"$regex" => {
let pattern = val.as_str().unwrap_or("");
let re = Regex::new(pattern).unwrap_or_else(|_| Regex::new("$^").unwrap());
field_val.as_str().map(|s| re.is_match(s)).unwrap_or(false)
}
"$exists" => {
let should_exist = val.as_bool().unwrap_or(true);
let exists = !field_val.is_null();
exists == should_exist
}
"$in" => {
if let Value::Array(arr) = val {
arr.contains(field_val)
} else {
false
}
}
"$select" => {
// Nested: { "body": { "$select": "css", "$eq": "val" } }
let sel_str = val.as_str().unwrap_or("");
let selected = css_select(&response.body, sel_str);
// If no comparison operator follows, just check existence
selected.is_some()
}
_ => true, // unknown op — skip
};
Ok(ok)
}
fn cmp_num(a: &Value, b: &Value, f: impl Fn(f64, f64) -> bool) -> bool {
match (a.as_f64(), b.as_f64()) {
(Some(x), Some(y)) => f(x, y),
+64 -2
View File
@@ -1,8 +1,9 @@
use crate::query::{self, Response};
use crate::types::{CheckResult, Monitor};
use anyhow::Result;
use serde_json::{json, Value};
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use tracing::{debug, warn};
@@ -44,6 +45,13 @@ pub async fn fetch_and_run(
async fn run_check(client: &reqwest::Client, monitor: &Monitor) -> CheckResult {
let start = Instant::now();
// Check cert expiry for HTTPS URLs
let cert_expiry_days = if monitor.url.starts_with("https://") {
check_cert_expiry(&monitor.url).await.ok().flatten()
} else {
None
};
let result = client.get(&monitor.url).send().await;
let latency_ms = start.elapsed().as_millis() as u64;
@@ -54,6 +62,7 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor) -> CheckResult {
latency_ms: Some(latency_ms),
up: false,
error: Some(e.to_string()),
cert_expiry_days,
meta: None,
},
Ok(resp) => {
@@ -66,7 +75,13 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor) -> CheckResult {
// Evaluate query if present
let (up, query_error) = if let Some(q) = &monitor.query {
let response = Response { status, body: body.clone(), headers: headers.clone() };
let response = Response {
status,
body: body.clone(),
headers: headers.clone(),
latency_ms: Some(latency_ms),
cert_expiry_days,
};
match query::evaluate(q, &response) {
Ok(result) => (result, None),
Err(e) => {
@@ -93,12 +108,59 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor) -> CheckResult {
latency_ms: Some(latency_ms),
up,
error: query_error,
cert_expiry_days,
meta: Some(meta),
}
}
}
}
/// Check SSL certificate expiry for a given HTTPS URL.
/// Returns the number of days until the certificate expires.
async fn check_cert_expiry(url: &str) -> Result<Option<i64>> {
use rustls::ClientConfig;
use rustls::pki_types::ServerName;
use tokio::net::TcpStream;
use tokio_rustls::TlsConnector;
use x509_parser::prelude::*;
// Parse host and port from URL
let url_parsed = reqwest::Url::parse(url)?;
let host = url_parsed.host_str().unwrap_or("");
let port = url_parsed.port().unwrap_or(443);
// Build a rustls config that captures certificates
let mut root_store = rustls::RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(config));
let server_name = ServerName::try_from(host.to_string())?;
let stream = TcpStream::connect(format!("{host}:{port}")).await?;
let tls_stream = connector.connect(server_name, stream).await?;
// Get peer certificates
let (_, conn) = tls_stream.get_ref();
let certs = conn.peer_certificates().unwrap_or(&[]);
if let Some(cert_der) = certs.first() {
let (_, cert) = X509Certificate::from_der(cert_der.as_ref())?;
let not_after = cert.validity().not_after.timestamp();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let days = (not_after - now) / 86400;
return Ok(Some(days));
}
Ok(None)
}
async fn post_result(
client: &reqwest::Client,
coordinator_url: &str,
+1
View File
@@ -16,5 +16,6 @@ pub struct CheckResult {
pub latency_ms: Option<u64>,
pub up: bool,
pub error: Option<String>,
pub cert_expiry_days: Option<i64>,
pub meta: Option<Value>,
}