From 9b2f1de4e7adb507dae46a3c4dfb08c7e347a68a Mon Sep 17 00:00:00 2001 From: nate Date: Thu, 9 Apr 2026 23:30:57 +0400 Subject: [PATCH] refactor query language --- apps/api/src/cache/monitor-list.ts | 2 +- apps/api/src/routes/monitors.ts | 5 +- apps/api/src/routes/pings.ts | 1 + apps/monitor/src/query.rs | 42 ++++++++-------- apps/monitor/src/runner.rs | 27 +++++++--- apps/monitor/src/types.rs | 5 ++ apps/shared/db.ts | 1 + apps/web/src/dashboard/query-builder.js | 50 +++++++++---------- apps/web/src/views/docs.ejs | 65 +++++++++++++------------ apps/web/src/views/landing.ejs | 2 +- 10 files changed, 111 insertions(+), 89 deletions(-) diff --git a/apps/api/src/cache/monitor-list.ts b/apps/api/src/cache/monitor-list.ts index d2eb6b5..71f8345 100644 --- a/apps/api/src/cache/monitor-list.ts +++ b/apps/api/src/cache/monitor-list.ts @@ -20,7 +20,7 @@ const inflight = new Map>(); async function fetchForRegion(region: string): Promise { return sql` SELECT id, url, method, request_headers, request_body, timeout_ms, interval_s, query, regions, - max_retries, retry_interval_s, created_at + max_retries, retry_interval_s, max_redirects, created_at FROM monitors WHERE enabled = true AND ( diff --git a/apps/api/src/routes/monitors.ts b/apps/api/src/routes/monitors.ts index 56f5d00..bf9f22d 100644 --- a/apps/api/src/routes/monitors.ts +++ b/apps/api/src/routes/monitors.ts @@ -17,6 +17,7 @@ const MonitorBody = t.Object({ retry_interval_s: t.Optional(t.Number({ minimum: 1, maximum: 600, default: 30, description: "Seconds between retries" })), resend_interval: t.Optional(t.Number({ minimum: 0, maximum: 1000, default: 0, description: "Re-alert every Nth consecutive down beat. 0 = never resend." })), cert_alert_days: t.Optional(t.Number({ minimum: 0, maximum: 365, default: 0, description: "Alert when TLS cert is within N days of expiry. 0 disables (default)." })), + max_redirects: t.Optional(t.Number({ minimum: 0, maximum: 4, default: 1, description: "Follow up to N redirects. 0 = don't follow. Default 1." })), query: t.Optional(t.Any({ description: "PingQL query - filter conditions for up/down" })), regions: t.Optional(t.Array(t.String(), { description: "Regions to run checks from. Empty array = all regions." })), channel_ids: t.Optional(t.Array(t.String(), { description: "Notification channel IDs to attach to this monitor." })), @@ -84,7 +85,7 @@ export const monitors = new Elysia({ prefix: "/monitors" }) const tags = body.tags ? dedupeTags(body.tags) : []; const channelIds = body.channel_ids ? await validateChannelIds(accountId, body.channel_ids) : []; const [monitor] = await sql` - INSERT INTO monitors (account_id, name, url, method, request_headers, request_body, timeout_ms, interval_s, max_retries, retry_interval_s, resend_interval, cert_alert_days, query, regions, tags, channel_ids) + INSERT INTO monitors (account_id, name, url, method, request_headers, request_body, timeout_ms, interval_s, max_retries, retry_interval_s, resend_interval, cert_alert_days, max_redirects, query, regions, tags, channel_ids) VALUES ( ${accountId}, ${body.name}, ${body.url}, ${(body.method ?? 'GET').toUpperCase()}, @@ -96,6 +97,7 @@ export const monitors = new Elysia({ prefix: "/monitors" }) ${retryGap}, ${body.resend_interval ?? 0}, ${body.cert_alert_days ?? 0}, + ${body.max_redirects ?? 1}, ${body.query ? sql.json(body.query) : null}, ${sql.array(regions)}, ${sql.array(tags)}, @@ -157,6 +159,7 @@ export const monitors = new Elysia({ prefix: "/monitors" }) retry_interval_s = COALESCE(${body.retry_interval_s ?? null}, retry_interval_s), resend_interval = COALESCE(${body.resend_interval ?? null}, resend_interval), cert_alert_days = COALESCE(${body.cert_alert_days ?? null}, cert_alert_days), + max_redirects = COALESCE(${body.max_redirects ?? null}, max_redirects), query = COALESCE(${body.query ? sql.json(body.query) : null}, query), regions = COALESCE(${body.regions ? sql.array(body.regions) : null}, regions), tags = COALESCE(${body.tags ? sql.array(dedupeTags(body.tags)) : null}, tags), diff --git a/apps/api/src/routes/pings.ts b/apps/api/src/routes/pings.ts index 27df6a6..dd807e3 100644 --- a/apps/api/src/routes/pings.ts +++ b/apps/api/src/routes/pings.ts @@ -196,6 +196,7 @@ export const ingest = new Elysia() up: t.Boolean(), error: t.Optional(t.Nullable(t.String())), cert_expiry_days: t.Optional(t.Nullable(t.Number())), + cert_issuer: t.Optional(t.Nullable(t.String())), meta: t.Optional(t.Any()), region: t.Optional(t.Nullable(t.String())), run_id: t.Optional(t.Nullable(t.String())), diff --git a/apps/monitor/src/query.rs b/apps/monitor/src/query.rs index 1047a5b..be00d4a 100644 --- a/apps/monitor/src/query.rs +++ b/apps/monitor/src/query.rs @@ -9,6 +9,7 @@ pub struct Response { pub headers: std::collections::HashMap, pub latency_ms: Option, pub cert_expiry_days: Option, + pub cert_issuer: Option, } pub fn evaluate(query: &Value, response: &Response) -> Result { @@ -36,7 +37,11 @@ pub fn evaluate(query: &Value, response: &Response) -> Result { return Ok(!evaluate(not, response)?); } - if let Some(cond) = map.get("$responseTime") { + if let Some(cond) = map.get("size") { + let val = Value::Number(serde_json::Number::from(response.body.len())); + return eval_condition(cond, &val, response); + } + if let Some(cond) = map.get("$time") { let val = Value::Number(serde_json::Number::from(response.latency_ms.unwrap_or(0))); return eval_condition(cond, &val, response); } @@ -44,6 +49,12 @@ pub fn evaluate(query: &Value, response: &Response) -> Result { let val = Value::Number(serde_json::Number::from(response.cert_expiry_days.unwrap_or(0))); return eval_condition(cond, &val, response); } + if let Some(cond) = map.get("$certIssuer") { + let val = response.cert_issuer.as_ref() + .map(|s| Value::String(s.clone())) + .unwrap_or(Value::Null); + return eval_condition(cond, &val, response); + } if let Some(json_path_map) = map.get("$json") { let path_map = match json_path_map { Value::Object(m) => m, @@ -56,10 +67,10 @@ pub fn evaluate(query: &Value, response: &Response) -> Result { return Ok(true); } - if let Some(sel_map) = map.get("$select") { + if let Some(sel_map) = map.get("$html") { let sel_obj = match sel_map { Value::Object(m) => m, - _ => bail!("$select expects an object {{ selector: condition }}"), + _ => bail!("$html expects an object {{ selector: condition }}"), }; let doc = Html::parse_document(&response.body); for (selector, condition) in sel_obj { @@ -152,27 +163,27 @@ fn eval_condition(condition: &Value, field_val: &Value, response: &Response) -> } } -fn eval_op(op: &str, field_val: &Value, val: &Value, response: &Response) -> Result { +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), + "$ge" => 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" => { + "$le" => cmp_num(field_val, val, |a,b| a <= b), + "$co" => { let needle = val.as_str().unwrap_or(""); field_val.as_str().map(|s| s.contains(needle)).unwrap_or(false) } - "$startsWith" => { + "$sw" => { let needle = val.as_str().unwrap_or(""); field_val.as_str().map(|s| s.starts_with(needle)).unwrap_or(false) } - "$endsWith" => { + "$ew" => { let needle = val.as_str().unwrap_or(""); field_val.as_str().map(|s| s.ends_with(needle)).unwrap_or(false) } - "$regex" => { + "$re" => { let pattern = val.as_str().unwrap_or(""); if pattern.len() > 200 { return Ok(false); } let re = match Regex::new(pattern) { @@ -186,17 +197,6 @@ fn eval_op(op: &str, field_val: &Value, val: &Value, response: &Response) -> Res let exists = !field_val.is_null(); exists == should_exist } - "$in" => { - if let Value::Array(arr) = val { - arr.contains(field_val) - } else { - false - } - } - "$select" => { - let sel_str = val.as_str().unwrap_or(""); - css_select(&response.body, sel_str).is_some() - } _ => { tracing::warn!("Unknown query operator: {op}"); false diff --git a/apps/monitor/src/runner.rs b/apps/monitor/src/runner.rs index dd415de..a743d30 100644 --- a/apps/monitor/src/runner.rs +++ b/apps/monitor/src/runner.rs @@ -105,6 +105,7 @@ pub async fn fetch_and_run( up: false, error: Some(format!("timed out after {}ms", timeout_ms)), cert_expiry_days: None, + cert_issuer: None, meta: None, region: Some(region_owned.to_string()), run_id: Some(run_id_owned.clone()), @@ -166,11 +167,12 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor, scheduled_at: Op let url = monitor.url.clone(); let req_headers = monitor.request_headers.clone(); let req_body = monitor.request_body.clone(); + let max_redirects = monitor.max_redirects; let (tx, rx) = tokio::sync::oneshot::channel::, String), String>>(); std::thread::spawn(move || { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - run_check_blocking(&url, &method, req_headers.as_ref(), req_body.as_deref(), timeout) + run_check_blocking(&url, &method, req_headers.as_ref(), req_body.as_deref(), timeout, max_redirects) })); let _ = tx.send(match result { Ok(r) => r, @@ -199,6 +201,7 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor, scheduled_at: Op up: false, error: Some(e.clone()), cert_expiry_days: None, + cert_issuer: None, meta: None, region: Some(region.to_string()), run_id: Some(run_id.to_string()), @@ -211,9 +214,9 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor, scheduled_at: Op Some(tokio::spawn(async move { match tokio::time::timeout( std::time::Duration::from_secs(5), - check_cert_expiry(&cert_url), + check_cert(&cert_url), ).await { - Ok(Ok(days)) => days, + Ok(Ok(info)) => info, _ => None, } })) @@ -230,6 +233,7 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor, scheduled_at: Op headers: headers.clone(), latency_ms: Some(latency_ms), cert_expiry_days: None, + cert_issuer: None, }; match query::evaluate(q, &response) { Ok(result) => (result, None), @@ -242,10 +246,12 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor, scheduled_at: Op ((200..300).contains(&status), None) }; - let cert_expiry_days = match cert_handle { + let cert_info = match cert_handle { Some(h) => h.await.unwrap_or(None), None => None, }; + let cert_expiry_days = cert_info.as_ref().map(|c| c.expiry_days); + let cert_issuer = cert_info.map(|c| c.issuer); let meta = json!({ "headers": headers, @@ -264,6 +270,7 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor, scheduled_at: Op up, error: query_error, cert_expiry_days, + cert_issuer, meta: Some(meta), region: Some(region.to_string()), run_id: Some(run_id.to_string()), @@ -278,6 +285,7 @@ fn run_check_blocking( headers: Option<&HashMap>, body: Option<&str>, timeout: std::time::Duration, + max_redirects: u32, ) -> Result<(u16, HashMap, String), String> { let root_certs = ROOT_CERTS.with(|c| Arc::clone(c)); @@ -289,6 +297,7 @@ fn run_check_blocking( .timeout_global(Some(timeout)) .timeout_connect(Some(timeout)) .http_status_as_error(false) + .max_redirects(max_redirects) .user_agent("Mozilla/5.0 (compatible; PingQL/1.0; +https://pingql.com)") .tls_config(tls) .build() @@ -359,7 +368,12 @@ fn run_check_blocking( Ok((status, resp_headers, body_out)) } -async fn check_cert_expiry(url: &str) -> Result> { +struct CertInfo { + expiry_days: i64, + issuer: String, +} + +async fn check_cert(url: &str) -> Result> { use rustls::ClientConfig; use rustls::pki_types::ServerName; use tokio::net::TcpStream; @@ -394,7 +408,8 @@ async fn check_cert_expiry(url: &str) -> Result> { .unwrap() .as_secs() as i64; let days = (not_after - now) / 86400; - return Ok(Some(days)); + let issuer = cert.issuer().to_string(); + return Ok(Some(CertInfo { expiry_days: days, issuer })); } Ok(None) diff --git a/apps/monitor/src/types.rs b/apps/monitor/src/types.rs index 113cb8e..1ce1c74 100644 --- a/apps/monitor/src/types.rs +++ b/apps/monitor/src/types.rs @@ -1,4 +1,6 @@ use serde::{Deserialize, Deserializer, Serialize}; + +fn default_max_redirects() -> u32 { 1 } use serde_json::Value; use std::collections::HashMap; @@ -27,6 +29,8 @@ pub struct Monitor { pub max_retries: u32, #[serde(default)] pub retry_interval_s: u64, + #[serde(default = "default_max_redirects")] + pub max_redirects: u32, pub query: Option, pub scheduled_at: Option, // ISO string for backward compat in PingResult #[serde(deserialize_with = "deserialize_ms")] @@ -45,6 +49,7 @@ pub struct PingResult { pub up: bool, pub error: Option, pub cert_expiry_days: Option, + pub cert_issuer: Option, pub meta: Option, pub region: Option, pub run_id: Option, diff --git a/apps/shared/db.ts b/apps/shared/db.ts index 74ac5ce..e30e908 100644 --- a/apps/shared/db.ts +++ b/apps/shared/db.ts @@ -33,6 +33,7 @@ export async function migrate(sql: any) { retry_interval_s INTEGER NOT NULL DEFAULT 30, resend_interval INTEGER NOT NULL DEFAULT 0, cert_alert_days INTEGER NOT NULL DEFAULT 0, + max_redirects INTEGER NOT NULL DEFAULT 1, tags TEXT[] NOT NULL DEFAULT '{}', channel_ids UUID[] NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT now(), diff --git a/apps/web/src/dashboard/query-builder.js b/apps/web/src/dashboard/query-builder.js index 14582a2..b85228f 100644 --- a/apps/web/src/dashboard/query-builder.js +++ b/apps/web/src/dashboard/query-builder.js @@ -1,17 +1,18 @@ 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'] }, + { name: 'status', label: 'Status Code', type: 'number', operators: ['$eq', '$ne', '$gt', '$ge', '$lt', '$le'] }, + { name: 'body', label: 'Response Body', type: 'string', operators: ['$eq', '$ne', '$co', '$sw', '$ew', '$re', '$exists'] }, + { name: 'headers.*', label: 'Header', type: 'string', operators: ['$eq', '$ne', '$co', '$sw', '$ew', '$re', '$exists'] }, + { name: '$html', label: 'HTML Selector', type: 'selector', operators: ['$eq', '$ne', '$co', '$sw', '$ew', '$re'] }, + { name: '$json', label: 'JSON Path', type: 'jsonpath', operators: ['$eq', '$ne', '$gt', '$ge', '$lt', '$le', '$co', '$re'] }, + { name: '$time', label: 'Response Time (ms)', type: 'number', operators: ['$eq', '$gt', '$ge', '$lt', '$le'] }, + { name: '$certExpiry', label: 'Cert Expiry (days)', type: 'number', operators: ['$eq', '$gt', '$ge', '$lt', '$le'] }, + { name: '$certIssuer', label: 'Cert Issuer', type: 'string', operators: ['$eq', '$ne', '$co', '$sw', '$ew', '$re'] }, ]; const OP_LABELS = { - '$eq': '=', '$ne': '≠', '$gt': '>', '$gte': '≥', '$lt': '<', '$lte': '≤', - '$contains': 'contains', '$startsWith': 'starts with', '$endsWith': 'ends with', - '$regex': 'matches regex', '$exists': 'exists', '$in': 'in', + '$eq': '=', '$ne': '≠', '$gt': '>', '$ge': '≥', '$lt': '<', '$le': '≤', + '$co': 'contains', '$sw': 'starts with', '$ew': 'ends with', + '$re': 'matches regex', '$exists': 'exists', }; class QueryBuilder { @@ -36,7 +37,7 @@ class QueryBuilder { // it instead of guessing. _defaultRules() { return [ - { field: 'status', operator: '$gte', value: '200', headerName: '', selectorValue: '', jsonPath: '' }, + { field: 'status', operator: '$ge', value: '200', headerName: '', selectorValue: '', jsonPath: '' }, { field: 'status', operator: '$lt', value: '300', headerName: '', selectorValue: '', jsonPath: '' }, ]; } @@ -57,11 +58,11 @@ class QueryBuilder { const parsedVal = this._parseValue(value, field, operator); - if (field === '$responseTime' || field === '$certExpiry') { + if (field === '$time' || field === '$certExpiry' || field === '$certIssuer') { return { [field]: { [operator]: parsedVal } }; } - if (field === '$select') { - return { '$select': { [rule.selectorValue || '*']: { [operator]: parsedVal } } }; + if (field === '$html') { + return { '$html': { [rule.selectorValue || '*']: { [operator]: parsedVal } } }; } if (field === '$json') { return { '$json': { [rule.jsonPath || '$']: { [operator]: parsedVal } } }; @@ -78,15 +79,8 @@ class QueryBuilder { _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); - const numericOps = ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte']; + const numericOps = ['$eq', '$ne', '$gt', '$ge', '$lt', '$le']; if (fieldDef?.type === 'number' || (numericOps.includes(operator) && fieldDef?.type === 'jsonpath')) { const n = Number(value); return isNaN(n) ? value : n; @@ -123,8 +117,8 @@ class QueryBuilder { _queryToRule(clause) { if (!clause || typeof clause !== 'object') return this._emptyRule(); - if ('$responseTime' in clause || '$certExpiry' in clause) { - const field = '$responseTime' in clause ? '$responseTime' : '$certExpiry'; + if ('$time' in clause || '$certExpiry' in clause || '$certIssuer' in clause) { + const field = '$time' in clause ? '$time' : '$certIssuer' in clause ? '$certIssuer' : '$certExpiry'; const ops = clause[field]; if (typeof ops === 'object') { const [operator, value] = Object.entries(ops)[0] || ['$lt', '']; @@ -132,13 +126,13 @@ class QueryBuilder { } } - if ('$select' in clause) { - const selMap = clause.$select; + if ('$html' in clause) { + const selMap = clause.$html; if (selMap && typeof selMap === 'object') { const [selectorValue, condition] = Object.entries(selMap)[0] || ['*', {}]; if (condition && typeof condition === 'object') { const [operator, value] = Object.entries(condition)[0] || ['$eq', '']; - return { field: '$select', operator, value: String(value), headerName: '', selectorValue, jsonPath: '' }; + return { field: '$html', operator, value: String(value), headerName: '', selectorValue, jsonPath: '' }; } } } @@ -246,7 +240,7 @@ class QueryBuilder { const operators = fieldDef.operators; const needsHeader = rule.field === 'headers.*'; - const needsSelector = rule.field === '$select'; + const needsSelector = rule.field === '$html'; const needsJsonPath = rule.field === '$json'; return ` diff --git a/apps/web/src/views/docs.ejs b/apps/web/src/views/docs.ejs index 6ac4388..1eee720 100644 --- a/apps/web/src/views/docs.ejs +++ b/apps/web/src/views/docs.ejs @@ -65,7 +65,7 @@ Fields Operators $json - $select + $html Logical $consider @@ -147,6 +147,7 @@ "retry_interval_s": 30, // optional - seconds between retries. Default: 30 "resend_interval": 10, // optional - re-alert every Nth consecutive DOWN beat. 0 = never. Default: 0 "cert_alert_days": 0, // optional - alert when TLS cert is within N days of expiry. 0 disables. Default: 0 (disabled) + "max_redirects": 1, // optional - follow up to N redirects (0-4). Default: 1 "channel_ids": ["<uuid>"], // optional - notification channels to attach "query": { ... } // optional - see Query Language below } @@ -166,6 +167,7 @@ retry_interval_snumberSeconds between retries. Default: 30. resend_intervalnumberIf a monitor stays DOWN, re-fire a notification every Nth consecutive down beat. 0 disables resend. Default: 0. cert_alert_daysnumberFire a separate cert notification when the TLS certificate is within N days of expiring. 0 disables. Default: 0 (disabled). + max_redirectsnumberFollow up to N redirects (0-4). 0 = don't follow. Default: 1. channel_idsstring[]Notification channel IDs to attach. See Notifications. queryobjectQuery conditions - see below @@ -420,11 +422,13 @@ Content-Type: application/json statusnumberHTTP status code bodystringFull response body as text + sizenumberResponse body size in bytes headers.namestringResponse header, e.g. headers.content-type - $responseTimenumberRequest latency in milliseconds + $timenumberRequest latency in milliseconds $certExpirynumberDays until SSL certificate expires + $certIssuerstringCertificate authority name (e.g. "Let's Encrypt") $jsonobjectJSONPath expression against response body - $selectobjectCSS selector against response HTML + $htmlobjectCSS selector against response HTML @@ -437,14 +441,13 @@ Content-Type: application/json $eqEqual toany $neNot equal toany - $gt / $gteGreater than / or equalnumber - $lt / $lteLess than / or equalnumber - $containsString contains substringstring - $startsWithString starts withstring - $endsWithString ends withstring - $regexMatches regular expressionstring + $gt / $geGreater than / or equalnumber + $lt / $leLess than / or equalnumber + $coString contains substringstring + $swString starts withstring + $ewString ends withstring + $reMatches regular expressionstring $existsField is present and non-nullany - $inValue is in arrayany
@@ -454,8 +457,8 @@ Content-Type: application/json // operator form { "status": { "$lt": 400 } } -{ "body": { "$contains": "healthy" } } -{ "headers.content-type": { "$contains": "application/json" } } +{ "body": { "$co": "healthy" } } +{ "headers.content-type": { "$co": "application/json" } }
@@ -472,17 +475,17 @@ Content-Type: application/json - +
-

$select - CSS Selector

+

$html - HTML Selector

Extract text content from an HTML response using a CSS selector. Useful for monitoring public pages without an API.

json
// matches if <h1> text is exactly "Example Domain"
-{ "$select": { "h1": { "$eq": "Example Domain" } } }
+{ "$html": { "h1": { "$eq": "Example Domain" } } }
 
 // matches if status badge contains "operational"
-{ "$select": { ".status-badge": { "$contains": "operational" } } }
+{ "$html": { ".status-badge": { "$co": "operational" } } }
@@ -492,7 +495,7 @@ Content-Type: application/json
json
// $and - all conditions must match
-{ "$and": [{ "status": 200 }, { "body": { "$contains": "ok" } }] }
+{ "$and": [{ "status": 200 }, { "body": { "$co": "ok" } }] }
 
 // $or - any condition must match
 { "$or": [{ "status": 200 }, { "status": 204 }] }
@@ -522,7 +525,7 @@ Content-Type: application/json
         
json
// down if response time exceeds 2 seconds
-{ "$consider": "down", "$responseTime": { "$gt": 2000 } }
+{ "$consider": "down", "$time": { "$gt": 2000 } }
 
 // down if cert expires in less than 7 days
 { "$consider": "down", "$certExpiry": { "$lt": 7 } }
@@ -531,8 +534,8 @@ Content-Type: application/json
 {
   "$consider": "down",
   "$or": [
-    { "status": { "$gte": 500 } },
-    { "$responseTime": { "$gt": 5000 } }
+    { "status": { "$ge": 500 } },
+    { "$time": { "$gt": 5000 } }
   ]
 }
@@ -544,7 +547,7 @@ Content-Type: application/json

Basic health endpoint

json
-
{ "status": 200, "body": { "$contains": "healthy" } }
+
{ "status": 200, "body": { "$co": "healthy" } }

JSON API response shape

json
@@ -557,7 +560,7 @@ Content-Type: application/json

Performance monitor (mark down if slow)

json
-
{ "$consider": "down", "$responseTime": { "$gt": 1000 } }
+
{ "$consider": "down", "$time": { "$gt": 1000 } }

Cert expiry alert

json
@@ -565,10 +568,10 @@ Content-Type: application/json

Status page (HTML)

json
-
{ "$select": { ".status-indicator": { "$eq": "All systems operational" } } }
+
{ "$html": { ".status-indicator": { "$eq": "All systems operational" } } }

Page down if a JSON queue is backed up

-

The killer demo: alert when a JSON field crosses a threshold. Uptime Kuma's keyword/json-query checks can't compose this - you'd need a script. Here it's one expression.

+

Alert when a JSON field crosses a threshold. Most monitoring tools can't compose this without a script.

json
{
   "$consider": "down",
@@ -576,15 +579,15 @@ Content-Type: application/json
 }

Down if any signal looks bad

-

Compose multiple conditions with $or and flip the result with $consider. Each condition can mix status, body, headers, JSON, and CSS-selector checks freely.

+

Compose multiple conditions with $or and flip the result with $consider.

json
{
   "$consider": "down",
   "$or": [
-    { "status": { "$gte": 500 } },
-    { "$responseTime": { "$gt": 3000 } },
+    { "status": { "$ge": 500 } },
+    { "$time": { "$gt": 3000 } },
     { "$json": { "$.healthy": { "$eq": false } } },
-    { "$select": { ".error-banner": { "$exists": true } } }
+    { "$html": { ".error-banner": { "$exists": true } } }
   ]
 }
@@ -594,8 +597,8 @@ Content-Type: application/json
{
   "$and": [
     { "status": 200 },
-    { "headers.content-type": { "$contains": "application/json" } },
-    { "$json": { "$.version": { "$startsWith": "v2" } } },
+    { "headers.content-type": { "$co": "application/json" } },
+    { "$json": { "$.version": { "$sw": "v2" } } },
     { "$json": { "$.db.connections": { "$lt": 100 } } }
   ]
 }
@@ -611,7 +614,7 @@ Content-Type: application/json { "$json": { "$.env": { "$eq": "staging" } } } ] }, { "$or": [ - { "$responseTime": { "$lt": 2000 } }, + { "$time": { "$lt": 2000 } }, { "$json": { "$.cache": { "$eq": "hit" } } } ] } ] diff --git a/apps/web/src/views/landing.ejs b/apps/web/src/views/landing.ejs index 8376b96..8eb2a60 100644 --- a/apps/web/src/views/landing.ejs +++ b/apps/web/src/views/landing.ejs @@ -375,7 +375,7 @@

JSONPath & CSS selectors

-

Drill into JSON responses with $json or scrape any HTML page with $select. No API required.

+

Drill into JSON responses with $json or scrape any HTML page with $html. No API required.