From 500132ba053520e63130c87c77a132babc25366e Mon Sep 17 00:00:00 2001 From: M1 Date: Mon, 16 Mar 2026 12:26:17 +0400 Subject: [PATCH] feat: dashboard, visual query builder, expanded query language, cert expiry support --- apps/monitor/Cargo.toml | 5 + apps/monitor/src/query.rs | 180 +++++++++++--- apps/monitor/src/runner.rs | 66 ++++- apps/monitor/src/types.rs | 1 + apps/web/src/dashboard/app.js | 81 ++++++ apps/web/src/dashboard/detail.html | 291 ++++++++++++++++++++++ apps/web/src/dashboard/home.html | 109 ++++++++ apps/web/src/dashboard/index.html | 129 ++++++++++ apps/web/src/dashboard/new.html | 106 ++++++++ apps/web/src/dashboard/query-builder.js | 301 ++++++++++++++++++++++ apps/web/src/index.ts | 4 +- apps/web/src/query/index.ts | 317 ++++++++++++++++++++++++ apps/web/src/routes/checks.ts | 7 +- apps/web/src/routes/dashboard.ts | 15 ++ 14 files changed, 1578 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/dashboard/app.js create mode 100644 apps/web/src/dashboard/detail.html create mode 100644 apps/web/src/dashboard/home.html create mode 100644 apps/web/src/dashboard/index.html create mode 100644 apps/web/src/dashboard/new.html create mode 100644 apps/web/src/dashboard/query-builder.js create mode 100644 apps/web/src/query/index.ts create mode 100644 apps/web/src/routes/dashboard.ts diff --git a/apps/monitor/Cargo.toml b/apps/monitor/Cargo.toml index fbe1d6e..583c7fa 100644 --- a/apps/monitor/Cargo.toml +++ b/apps/monitor/Cargo.toml @@ -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" diff --git a/apps/monitor/src/query.rs b/apps/monitor/src/query.rs index 80308c2..f0365b8 100644 --- a/apps/monitor/src/query.rs +++ b/apps/monitor/src/query.rs @@ -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, + pub latency_ms: Option, + pub cert_expiry_days: Option, } /// Returns true if `query` matches `response`. No query = always up. pub fn evaluate(query: &Value, response: &Response) -> Result { 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 { 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 { 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::() { + 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 { 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 { + 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), diff --git a/apps/monitor/src/runner.rs b/apps/monitor/src/runner.rs index 6d47cf2..8f24ef5 100644 --- a/apps/monitor/src/runner.rs +++ b/apps/monitor/src/runner.rs @@ -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> { + 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, diff --git a/apps/monitor/src/types.rs b/apps/monitor/src/types.rs index 15c66b7..5f6f505 100644 --- a/apps/monitor/src/types.rs +++ b/apps/monitor/src/types.rs @@ -16,5 +16,6 @@ pub struct CheckResult { pub latency_ms: Option, pub up: bool, pub error: Option, + pub cert_expiry_days: Option, pub meta: Option, } diff --git a/apps/web/src/dashboard/app.js b/apps/web/src/dashboard/app.js new file mode 100644 index 0000000..5e392b6 --- /dev/null +++ b/apps/web/src/dashboard/app.js @@ -0,0 +1,81 @@ +// PingQL Dashboard — shared utilities + +const API_BASE = window.location.origin; + +function getAccountKey() { + return localStorage.getItem('pingql_key'); +} + +function setAccountKey(key) { + localStorage.setItem('pingql_key', key); +} + +function logout() { + localStorage.removeItem('pingql_key'); + window.location.href = '/dashboard'; +} + +function requireAuth() { + if (!getAccountKey()) { + window.location.href = '/dashboard'; + return false; + } + return true; +} + +async function api(path, opts = {}) { + const key = getAccountKey(); + const res = await fetch(`${API_BASE}${path}`, { + ...opts, + headers: { + 'Content-Type': 'application/json', + ...(key ? { Authorization: `Bearer ${key}` } : {}), + ...opts.headers, + }, + body: opts.body ? JSON.stringify(opts.body) : undefined, + }); + if (res.status === 401) { + logout(); + throw new Error('Unauthorized'); + } + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'API error'); + return data; +} + +// Format relative time +function timeAgo(date) { + const s = Math.floor((Date.now() - new Date(date).getTime()) / 1000); + if (s < 60) return `${s}s ago`; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + return `${Math.floor(s / 86400)}d ago`; +} + +// Render a tiny sparkline SVG from latency values +function sparkline(values, width = 120, height = 32) { + if (!values.length) return ''; + const max = Math.max(...values, 1); + const min = Math.min(...values, 0); + const range = max - min || 1; + const step = width / Math.max(values.length - 1, 1); + const points = values.map((v, i) => { + const x = i * step; + const y = height - ((v - min) / range) * (height - 4) - 2; + return `${x},${y}`; + }).join(' '); + return ``; +} + +// Status badge +function statusBadge(up) { + if (up === true) return ''; + if (up === false) return ''; + return ''; +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} diff --git a/apps/web/src/dashboard/detail.html b/apps/web/src/dashboard/detail.html new file mode 100644 index 0000000..a304740 --- /dev/null +++ b/apps/web/src/dashboard/detail.html @@ -0,0 +1,291 @@ + + + + + + PingQL — Monitor Detail + + + + + + + + + + +
+
+ ← Back +
+ +
Loading...
+ +
+ + + + diff --git a/apps/web/src/dashboard/home.html b/apps/web/src/dashboard/home.html new file mode 100644 index 0000000..16a4b63 --- /dev/null +++ b/apps/web/src/dashboard/home.html @@ -0,0 +1,109 @@ + + + + + + PingQL — Dashboard + + + + + + + + + + +
+
+

Monitors

+
+
+ +
+
Loading...
+
+ + +
+ + + + diff --git a/apps/web/src/dashboard/index.html b/apps/web/src/dashboard/index.html new file mode 100644 index 0000000..1e72956 --- /dev/null +++ b/apps/web/src/dashboard/index.html @@ -0,0 +1,129 @@ + + + + + + PingQL — Login + + + + +
+
+

PingQL

+

Uptime monitoring for developers

+
+ +
+
+ + + + +
+ +
+

No account?

+ +
+
+
+ + + + diff --git a/apps/web/src/dashboard/new.html b/apps/web/src/dashboard/new.html new file mode 100644 index 0000000..b46fa8f --- /dev/null +++ b/apps/web/src/dashboard/new.html @@ -0,0 +1,106 @@ + + + + + + PingQL — New Monitor + + + + + + + + + + +
+
+ ← Back to monitors +

Create Monitor

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +

Define when this monitor should be considered "up". Defaults to status < 400.

+
+
+ + + + +
+
+ + + + diff --git a/apps/web/src/dashboard/query-builder.js b/apps/web/src/dashboard/query-builder.js new file mode 100644 index 0000000..aca3a35 --- /dev/null +++ b/apps/web/src/dashboard/query-builder.js @@ -0,0 +1,301 @@ +// PingQL Visual Query Builder + +const FIELDS = [ + { name: 'status', label: 'Status Code', type: 'number', operators: ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in'] }, + { name: 'body', label: 'Response Body', type: 'string', operators: ['$eq', '$ne', '$contains', '$startsWith', '$endsWith', '$regex', '$exists'] }, + { name: 'headers.*', label: 'Header', type: 'string', operators: ['$eq', '$ne', '$contains', '$startsWith', '$endsWith', '$regex', '$exists'] }, + { name: '$select', label: 'CSS Selector', type: 'selector', operators: ['$eq', '$ne', '$contains', '$startsWith', '$endsWith', '$regex'] }, + { name: '$json', label: 'JSON Path', type: 'jsonpath', operators: ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$contains', '$regex'] }, + { name: '$responseTime', label: 'Response Time (ms)', type: 'number', operators: ['$eq', '$gt', '$gte', '$lt', '$lte'] }, + { name: '$certExpiry', label: 'Cert Expiry (days)', type: 'number', operators: ['$eq', '$gt', '$gte', '$lt', '$lte'] }, +]; + +const OP_LABELS = { + '$eq': '=', '$ne': '≠', '$gt': '>', '$gte': '≥', '$lt': '<', '$lte': '≤', + '$contains': 'contains', '$startsWith': 'starts with', '$endsWith': 'ends with', + '$regex': 'matches regex', '$exists': 'exists', '$in': 'in', +}; + +class QueryBuilder { + constructor(container, onChange) { + this.container = container; + this.onChange = onChange; + this.logic = '$and'; + this.rules = [this._emptyRule()]; + this.render(); + } + + _emptyRule() { + return { field: 'status', operator: '$eq', value: '', headerName: '', selectorValue: '', jsonPath: '' }; + } + + getQuery() { + const conditions = this.rules + .map(r => this._ruleToQuery(r)) + .filter(Boolean); + if (conditions.length === 0) return null; + if (conditions.length === 1) return conditions[0]; + return { [this.logic]: conditions }; + } + + _ruleToQuery(rule) { + const { field, operator, value } = rule; + if (!value && operator !== '$exists') return null; + + const parsedVal = this._parseValue(value, field, operator); + + if (field === '$responseTime' || field === '$certExpiry') { + return { [field]: { [operator]: parsedVal } }; + } + if (field === '$select') { + return { '$select': rule.selectorValue || '', [operator]: parsedVal }; + } + if (field === '$json') { + return { '$json': rule.jsonPath || '', [operator]: parsedVal }; + } + if (field === 'headers.*') { + const headerField = `headers.${rule.headerName || 'content-type'}`; + if (operator === '$exists') return { [headerField]: { '$exists': parsedVal } }; + return { [headerField]: { [operator]: parsedVal } }; + } + if (operator === '$exists') return { [field]: { '$exists': parsedVal } }; + // Simple shorthand for $eq on basic fields + if (operator === '$eq') return { [field]: parsedVal }; + return { [field]: { [operator]: parsedVal } }; + } + + _parseValue(value, field, operator) { + if (operator === '$exists') return value !== 'false' && value !== '0'; + if (operator === '$in') { + return value.split(',').map(v => { + const trimmed = v.trim(); + const n = Number(trimmed); + return isNaN(n) ? trimmed : n; + }); + } + const fieldDef = FIELDS.find(f => f.name === field); + if (fieldDef?.type === 'number') { + const n = Number(value); + return isNaN(n) ? value : n; + } + return value; + } + + setQuery(query) { + if (!query || typeof query !== 'object') { + this.rules = [this._emptyRule()]; + this.logic = '$and'; + this.render(); + return; + } + + if ('$and' in query || '$or' in query) { + this.logic = '$and' in query ? '$and' : '$or'; + const clauses = query[this.logic]; + if (Array.isArray(clauses)) { + this.rules = clauses.map(c => this._queryToRule(c)).filter(Boolean); + } + } else { + this.rules = [this._queryToRule(query)].filter(Boolean); + } + + if (this.rules.length === 0) this.rules = [this._emptyRule()]; + this.render(); + } + + _queryToRule(clause) { + if (!clause || typeof clause !== 'object') return this._emptyRule(); + + if ('$responseTime' in clause || '$certExpiry' in clause) { + const field = '$responseTime' in clause ? '$responseTime' : '$certExpiry'; + const ops = clause[field]; + if (typeof ops === 'object') { + const [operator, value] = Object.entries(ops)[0] || ['$lt', '']; + return { field, operator, value: String(value), headerName: '', selectorValue: '', jsonPath: '' }; + } + } + + if ('$select' in clause) { + const selectorValue = clause.$select; + for (const [op, val] of Object.entries(clause)) { + if (op !== '$select') { + return { field: '$select', operator: op, value: String(val), headerName: '', selectorValue, jsonPath: '' }; + } + } + } + + if ('$json' in clause) { + const jsonPath = clause.$json; + for (const [op, val] of Object.entries(clause)) { + if (op !== '$json') { + return { field: '$json', operator: op, value: String(val), headerName: '', selectorValue: '', jsonPath }; + } + } + } + + for (const [field, condition] of Object.entries(clause)) { + if (field.startsWith('headers.')) { + const headerName = field.slice(8); + if (typeof condition === 'object' && condition !== null) { + const [operator, value] = Object.entries(condition)[0] || ['$eq', '']; + return { field: 'headers.*', operator, value: String(value), headerName, selectorValue: '', jsonPath: '' }; + } + return { field: 'headers.*', operator: '$eq', value: String(condition), headerName, selectorValue: '', jsonPath: '' }; + } + + if (typeof condition === 'object' && condition !== null && !Array.isArray(condition)) { + const [operator, value] = Object.entries(condition)[0] || ['$eq', '']; + return { field, operator, value: String(value), headerName: '', selectorValue: '', jsonPath: '' }; + } + + return { field, operator: '$eq', value: String(condition), headerName: '', selectorValue: '', jsonPath: '' }; + } + + return this._emptyRule(); + } + + render() { + const query = this.getQuery(); + + this.container.innerHTML = ` +
+
+ Match + + of the following conditions +
+ +
+ ${this.rules.map((rule, i) => this._renderRule(rule, i)).join('')} +
+ + + +
+
+ Query JSON + +
+
${query ? escapeHtml(JSON.stringify(query, null, 2)) : 'No conditions set'}
+
+
+ `; + + // Bind events + this.container.querySelector('#qb-logic').addEventListener('change', (e) => { + this.logic = e.target.value; + this.render(); + this.onChange?.(this.getQuery()); + }); + + this.container.querySelector('#qb-add').addEventListener('click', () => { + this.rules.push(this._emptyRule()); + this.render(); + this.onChange?.(this.getQuery()); + }); + + this.container.querySelector('#qb-copy').addEventListener('click', () => { + const q = this.getQuery(); + navigator.clipboard.writeText(q ? JSON.stringify(q, null, 2) : '{}'); + const btn = this.container.querySelector('#qb-copy'); + btn.textContent = 'Copied!'; + setTimeout(() => { btn.textContent = 'Copy'; }, 1500); + }); + + this.container.querySelectorAll('.qb-rule').forEach((el, i) => { + this._bindRuleEvents(el, i); + }); + } + + _renderRule(rule, index) { + const fieldDef = FIELDS.find(f => f.name === rule.field) || FIELDS[0]; + const operators = fieldDef.operators; + + const needsHeader = rule.field === 'headers.*'; + const needsSelector = rule.field === '$select'; + const needsJsonPath = rule.field === '$json'; + + return ` +
+ + + ${needsHeader ? `` : ''} + ${needsSelector ? `` : ''} + ${needsJsonPath ? `` : ''} + + + + + + +
+ `; + } + + _bindRuleEvents(el, index) { + const rule = this.rules[index]; + + el.querySelector('.qb-field').addEventListener('change', (e) => { + rule.field = e.target.value; + const fieldDef = FIELDS.find(f => f.name === rule.field); + if (fieldDef && !fieldDef.operators.includes(rule.operator)) { + rule.operator = fieldDef.operators[0]; + } + this.render(); + this.onChange?.(this.getQuery()); + }); + + el.querySelector('.qb-op').addEventListener('change', (e) => { + rule.operator = e.target.value; + this.render(); + this.onChange?.(this.getQuery()); + }); + + el.querySelector('.qb-value').addEventListener('input', (e) => { + rule.value = e.target.value; + this._updatePreview(); + this.onChange?.(this.getQuery()); + }); + + el.querySelector('.qb-header')?.addEventListener('input', (e) => { + rule.headerName = e.target.value; + this._updatePreview(); + this.onChange?.(this.getQuery()); + }); + + el.querySelector('.qb-selector')?.addEventListener('input', (e) => { + rule.selectorValue = e.target.value; + this._updatePreview(); + this.onChange?.(this.getQuery()); + }); + + el.querySelector('.qb-jsonpath')?.addEventListener('input', (e) => { + rule.jsonPath = e.target.value; + this._updatePreview(); + this.onChange?.(this.getQuery()); + }); + + el.querySelector('.qb-remove')?.addEventListener('click', () => { + this.rules.splice(index, 1); + this.render(); + this.onChange?.(this.getQuery()); + }); + } + + _updatePreview() { + const q = this.getQuery(); + const preview = this.container.querySelector('#qb-preview'); + if (preview) { + preview.textContent = q ? JSON.stringify(q, null, 2) : 'No conditions set'; + } + } +} diff --git a/apps/web/src/index.ts b/apps/web/src/index.ts index 76f6e7f..e92c7d2 100644 --- a/apps/web/src/index.ts +++ b/apps/web/src/index.ts @@ -5,6 +5,7 @@ import { checks } from "./routes/checks"; import { monitors } from "./routes/monitors"; import { auth } from "./routes/auth"; import { internal } from "./routes/internal"; +import { dashboard } from "./routes/dashboard"; import { migrate } from "./db"; await migrate(); @@ -12,7 +13,8 @@ await migrate(); const app = new Elysia() .use(cors()) .use(swagger({ path: "/docs", documentation: { info: { title: "PingQL API", version: "0.1.0" } } })) - .get("/", () => ({ name: "PingQL", version: "0.1.0", docs: "/docs" })) + .get("/", () => ({ name: "PingQL", version: "0.1.0", docs: "/docs", dashboard: "/dashboard" })) + .use(dashboard) .use(auth) .use(monitors) .use(checks) diff --git a/apps/web/src/query/index.ts b/apps/web/src/query/index.ts new file mode 100644 index 0000000..a85175a --- /dev/null +++ b/apps/web/src/query/index.ts @@ -0,0 +1,317 @@ +/** + * PingQL Query Engine — TypeScript implementation + * + * MongoDB-inspired query language for evaluating HTTP response conditions. + * Mirrors the Rust implementation but also powers the visual query builder. + */ + +// ── Types ────────────────────────────────────────────────────────────── + +export interface QueryField { + name: string; + description: string; + type: "number" | "string" | "boolean" | "object"; + operators: string[]; +} + +export interface EvalContext { + status: number; + body: string; + headers: Record; + latency_ms?: number; + cert_expiry_days?: number; +} + +export interface ValidationError { + path: string; + message: string; +} + +// ── Available fields ─────────────────────────────────────────────────── + +export function getAvailableFields(): QueryField[] { + return [ + { + name: "status", + description: "HTTP status code (e.g. 200, 404, 500)", + type: "number", + operators: ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in"], + }, + { + name: "body", + description: "Response body as text", + type: "string", + operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex", "$exists"], + }, + { + name: "headers.*", + description: "Response header value (e.g. headers.content-type)", + type: "string", + operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex", "$exists"], + }, + { + name: "$select", + description: "CSS selector — returns text content of first matching element", + type: "string", + operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex"], + }, + { + name: "$json", + description: "JSONPath expression evaluated against response body (e.g. $.data.status)", + type: "string", + operators: ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$contains", "$regex"], + }, + { + name: "$responseTime", + description: "Request latency in milliseconds", + type: "number", + operators: ["$eq", "$gt", "$gte", "$lt", "$lte"], + }, + { + name: "$certExpiry", + description: "Days until SSL certificate expires", + type: "number", + operators: ["$eq", "$gt", "$gte", "$lt", "$lte"], + }, + ]; +} + +// ── Evaluate ─────────────────────────────────────────────────────────── + +export function evaluate(query: unknown, ctx: EvalContext): boolean { + if (query === null || query === undefined) return true; + if (typeof query !== "object" || Array.isArray(query)) { + throw new Error("Query must be an object"); + } + + const q = query as Record; + + // $and + if ("$and" in q) { + const clauses = q.$and; + if (!Array.isArray(clauses)) throw new Error("$and expects array"); + return clauses.every((c) => evaluate(c, ctx)); + } + + // $or + if ("$or" in q) { + const clauses = q.$or; + if (!Array.isArray(clauses)) throw new Error("$or expects array"); + return clauses.some((c) => evaluate(c, ctx)); + } + + // $not + if ("$not" in q) { + return !evaluate(q.$not, ctx); + } + + // $responseTime + if ("$responseTime" in q) { + const val = ctx.latency_ms ?? 0; + return evalCondition(q.$responseTime, val); + } + + // $certExpiry + if ("$certExpiry" in q) { + const val = ctx.cert_expiry_days ?? Infinity; + return evalCondition(q.$certExpiry, val); + } + + // $select — CSS selector shorthand + if ("$select" in q) { + // Server-side: we don't have a DOM parser here, so we just validate structure. + // Actual evaluation happens in Rust runner. This returns true for validation purposes. + return true; + } + + // $json — JSONPath shorthand + if ("$json" in q) { + const expr = q.$json as string; + const cond = q as Record; + const resolved = resolveJsonPath(ctx.body, expr); + // Apply operators from remaining keys + for (const [op, opVal] of Object.entries(cond)) { + if (op === "$json") continue; + if (!evalOp(op, resolved, opVal)) return false; + } + return true; + } + + // Field-level checks + for (const [field, condition] of Object.entries(q)) { + if (field.startsWith("$")) continue; // skip unknown $ ops + const fieldVal = resolveField(field, ctx); + if (!evalCondition(condition, fieldVal)) return false; + } + + return true; +} + +function resolveField(field: string, ctx: EvalContext): unknown { + switch (field) { + case "status": + case "status_code": + return ctx.status; + case "body": + return ctx.body; + default: + if (field.startsWith("headers.")) { + const key = field.slice(8).toLowerCase(); + return ctx.headers[key] ?? null; + } + return null; + } +} + +function resolveJsonPath(body: string, expr: string): unknown { + try { + const obj = JSON.parse(body); + // Simple dot-notation JSONPath: $.foo.bar[0].baz + const path = expr.replace(/^\$\.?/, ""); + if (!path) return obj; + const parts = path.split(/\.|\[(\d+)\]/).filter(Boolean); + let current: unknown = obj; + for (const part of parts) { + if (current === null || current === undefined) return null; + current = (current as Record)[part]; + } + return current ?? null; + } catch { + return null; + } +} + +function evalCondition(condition: unknown, fieldVal: unknown): boolean { + if (condition === null || condition === undefined) return true; + + // Direct equality shorthand: { "status": 200 } + if (typeof condition === "number" || typeof condition === "string" || typeof condition === "boolean") { + return fieldVal === condition; + } + + if (typeof condition === "object" && !Array.isArray(condition)) { + const ops = condition as Record; + for (const [op, opVal] of Object.entries(ops)) { + if (!evalOp(op, fieldVal, opVal)) return false; + } + return true; + } + + return true; +} + +function evalOp(op: string, fieldVal: unknown, opVal: unknown): boolean { + switch (op) { + case "$eq": + return fieldVal === opVal; + case "$ne": + return fieldVal !== opVal; + case "$gt": + return toNum(fieldVal) > toNum(opVal); + case "$gte": + return toNum(fieldVal) >= toNum(opVal); + case "$lt": + return toNum(fieldVal) < toNum(opVal); + case "$lte": + return toNum(fieldVal) <= toNum(opVal); + case "$contains": + return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.includes(opVal); + case "$startsWith": + return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.startsWith(opVal); + case "$endsWith": + return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.endsWith(opVal); + case "$regex": { + if (typeof fieldVal !== "string" || typeof opVal !== "string") return false; + try { + return new RegExp(opVal).test(fieldVal); + } catch { + return false; + } + } + case "$exists": + return opVal ? fieldVal !== null && fieldVal !== undefined : fieldVal === null || fieldVal === undefined; + case "$in": + return Array.isArray(opVal) && opVal.includes(fieldVal); + default: + return true; // unknown op — skip + } +} + +function toNum(v: unknown): number { + return typeof v === "number" ? v : Number(v) || 0; +} + +// ── Validate ─────────────────────────────────────────────────────────── + +const VALID_OPS = new Set([ + "$eq", "$ne", "$gt", "$gte", "$lt", "$lte", + "$contains", "$startsWith", "$endsWith", "$regex", + "$exists", "$in", + "$select", "$json", + "$and", "$or", "$not", + "$responseTime", "$certExpiry", +]); + +const VALID_FIELDS = new Set([ + "status", "status_code", "body", +]); + +export function validateQuery(query: unknown, path = ""): ValidationError[] { + const errors: ValidationError[] = []; + + if (query === null || query === undefined) return errors; + + if (typeof query !== "object" || Array.isArray(query)) { + errors.push({ path: path || "$", message: "Query must be an object" }); + return errors; + } + + const q = query as Record; + + for (const [key, value] of Object.entries(q)) { + const keyPath = path ? `${path}.${key}` : key; + + if (key === "$and" || key === "$or") { + if (!Array.isArray(value)) { + errors.push({ path: keyPath, message: `${key} expects an array` }); + } else { + value.forEach((clause, i) => { + errors.push(...validateQuery(clause, `${keyPath}[${i}]`)); + }); + } + } else if (key === "$not") { + errors.push(...validateQuery(value, keyPath)); + } else if (key === "$responseTime" || key === "$certExpiry") { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + errors.push({ path: keyPath, message: `${key} expects an operator object (e.g. { "$lt": 500 })` }); + } else { + for (const op of Object.keys(value as Record)) { + if (!VALID_OPS.has(op)) { + errors.push({ path: `${keyPath}.${op}`, message: `Unknown operator: ${op}` }); + } + } + } + } else if (key === "$select" || key === "$json") { + if (typeof value !== "string") { + errors.push({ path: keyPath, message: `${key} expects a string` }); + } + } else if (key.startsWith("$")) { + // It's an operator inside a field condition — skip validation here + } else { + // Field name + if (!VALID_FIELDS.has(key) && !key.startsWith("headers.")) { + errors.push({ path: keyPath, message: `Unknown field: ${key}. Use status, body, or headers.*` }); + } + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + for (const op of Object.keys(value as Record)) { + if (!op.startsWith("$")) continue; + if (!VALID_OPS.has(op)) { + errors.push({ path: `${keyPath}.${op}`, message: `Unknown operator: ${op}` }); + } + } + } + } + } + + return errors; +} diff --git a/apps/web/src/routes/checks.ts b/apps/web/src/routes/checks.ts index cc1c70f..59fe5f2 100644 --- a/apps/web/src/routes/checks.ts +++ b/apps/web/src/routes/checks.ts @@ -8,6 +8,10 @@ export const checks = new Elysia() const token = headers["x-monitor-token"]; if (token !== process.env.MONITOR_TOKEN) return error(401, { error: "Unauthorized" }); + // Merge cert_expiry_days into meta if present + const meta = body.meta ? { ...body.meta } : {}; + if (body.cert_expiry_days != null) meta.cert_expiry_days = body.cert_expiry_days; + await sql` INSERT INTO check_results (monitor_id, status_code, latency_ms, up, error, meta) VALUES ( @@ -16,7 +20,7 @@ export const checks = new Elysia() ${body.latency_ms ?? null}, ${body.up}, ${body.error ?? null}, - ${body.meta ? sql.json(body.meta) : null} + ${Object.keys(meta).length > 0 ? sql.json(meta) : null} ) `; return { ok: true }; @@ -27,6 +31,7 @@ export const checks = new Elysia() latency_ms: t.Optional(t.Number()), up: t.Boolean(), error: t.Optional(t.Nullable(t.String())), + cert_expiry_days: t.Optional(t.Nullable(t.Number())), meta: t.Optional(t.Any()), }), detail: { summary: "Ingest result (monitor runner)", tags: ["internal"] }, diff --git a/apps/web/src/routes/dashboard.ts b/apps/web/src/routes/dashboard.ts new file mode 100644 index 0000000..a33b23a --- /dev/null +++ b/apps/web/src/routes/dashboard.ts @@ -0,0 +1,15 @@ +import { Elysia } from "elysia"; +import { resolve } from "path"; + +const dir = resolve(import.meta.dir, "../dashboard"); + +export const dashboard = new Elysia() + // Static assets + .get("/dashboard/app.js", () => Bun.file(`${dir}/app.js`)) + .get("/dashboard/query-builder.js", () => Bun.file(`${dir}/query-builder.js`)) + + // Pages + .get("/dashboard", () => Bun.file(`${dir}/index.html`)) + .get("/dashboard/home", () => Bun.file(`${dir}/home.html`)) + .get("/dashboard/monitors/new", () => Bun.file(`${dir}/new.html`)) + .get("/dashboard/monitors/:id", () => Bun.file(`${dir}/detail.html`));