refactor query language

This commit is contained in:
nate 2026-04-09 23:30:57 +04:00
parent 783427ab34
commit 9b2f1de4e7
10 changed files with 111 additions and 89 deletions

View File

@ -20,7 +20,7 @@ const inflight = new Map<string, Promise<MonitorRow[]>>();
async function fetchForRegion(region: string): Promise<MonitorRow[]> { async function fetchForRegion(region: string): Promise<MonitorRow[]> {
return sql<MonitorRow[]>` return sql<MonitorRow[]>`
SELECT id, url, method, request_headers, request_body, timeout_ms, interval_s, query, regions, 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 FROM monitors
WHERE enabled = true WHERE enabled = true
AND ( AND (

View File

@ -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" })), 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." })), 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)." })), 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" })), 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." })), 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." })), 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 tags = body.tags ? dedupeTags(body.tags) : [];
const channelIds = body.channel_ids ? await validateChannelIds(accountId, body.channel_ids) : []; const channelIds = body.channel_ids ? await validateChannelIds(accountId, body.channel_ids) : [];
const [monitor] = await sql` 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 ( VALUES (
${accountId}, ${body.name}, ${body.url}, ${accountId}, ${body.name}, ${body.url},
${(body.method ?? 'GET').toUpperCase()}, ${(body.method ?? 'GET').toUpperCase()},
@ -96,6 +97,7 @@ export const monitors = new Elysia({ prefix: "/monitors" })
${retryGap}, ${retryGap},
${body.resend_interval ?? 0}, ${body.resend_interval ?? 0},
${body.cert_alert_days ?? 0}, ${body.cert_alert_days ?? 0},
${body.max_redirects ?? 1},
${body.query ? sql.json(body.query) : null}, ${body.query ? sql.json(body.query) : null},
${sql.array(regions)}, ${sql.array(regions)},
${sql.array(tags)}, ${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), retry_interval_s = COALESCE(${body.retry_interval_s ?? null}, retry_interval_s),
resend_interval = COALESCE(${body.resend_interval ?? null}, resend_interval), resend_interval = COALESCE(${body.resend_interval ?? null}, resend_interval),
cert_alert_days = COALESCE(${body.cert_alert_days ?? null}, cert_alert_days), 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), query = COALESCE(${body.query ? sql.json(body.query) : null}, query),
regions = COALESCE(${body.regions ? sql.array(body.regions) : null}, regions), regions = COALESCE(${body.regions ? sql.array(body.regions) : null}, regions),
tags = COALESCE(${body.tags ? sql.array(dedupeTags(body.tags)) : null}, tags), tags = COALESCE(${body.tags ? sql.array(dedupeTags(body.tags)) : null}, tags),

View File

@ -196,6 +196,7 @@ export const ingest = new Elysia()
up: t.Boolean(), up: t.Boolean(),
error: t.Optional(t.Nullable(t.String())), error: t.Optional(t.Nullable(t.String())),
cert_expiry_days: t.Optional(t.Nullable(t.Number())), cert_expiry_days: t.Optional(t.Nullable(t.Number())),
cert_issuer: t.Optional(t.Nullable(t.String())),
meta: t.Optional(t.Any()), meta: t.Optional(t.Any()),
region: t.Optional(t.Nullable(t.String())), region: t.Optional(t.Nullable(t.String())),
run_id: t.Optional(t.Nullable(t.String())), run_id: t.Optional(t.Nullable(t.String())),

View File

@ -9,6 +9,7 @@ pub struct Response {
pub headers: std::collections::HashMap<String, String>, pub headers: std::collections::HashMap<String, String>,
pub latency_ms: Option<u64>, pub latency_ms: Option<u64>,
pub cert_expiry_days: Option<i64>, pub cert_expiry_days: Option<i64>,
pub cert_issuer: Option<String>,
} }
pub fn evaluate(query: &Value, response: &Response) -> Result<bool> { pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
@ -36,7 +37,11 @@ pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
return Ok(!evaluate(not, response)?); 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))); let val = Value::Number(serde_json::Number::from(response.latency_ms.unwrap_or(0)));
return eval_condition(cond, &val, response); return eval_condition(cond, &val, response);
} }
@ -44,6 +49,12 @@ pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
let val = Value::Number(serde_json::Number::from(response.cert_expiry_days.unwrap_or(0))); let val = Value::Number(serde_json::Number::from(response.cert_expiry_days.unwrap_or(0)));
return eval_condition(cond, &val, response); 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") { if let Some(json_path_map) = map.get("$json") {
let path_map = match json_path_map { let path_map = match json_path_map {
Value::Object(m) => m, Value::Object(m) => m,
@ -56,10 +67,10 @@ pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
return Ok(true); 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 { let sel_obj = match sel_map {
Value::Object(m) => m, 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); let doc = Html::parse_document(&response.body);
for (selector, condition) in sel_obj { 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<bool> { fn eval_op(op: &str, field_val: &Value, val: &Value, _response: &Response) -> Result<bool> {
let ok = match op { let ok = match op {
"$eq" => field_val == val, "$eq" => field_val == val,
"$ne" => field_val != val, "$ne" => field_val != val,
"$gt" => cmp_num(field_val, val, |a,b| a > b), "$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), "$lt" => cmp_num(field_val, val, |a,b| a < b),
"$lte" => cmp_num(field_val, val, |a,b| a <= b), "$le" => cmp_num(field_val, val, |a,b| a <= b),
"$contains" => { "$co" => {
let needle = val.as_str().unwrap_or(""); let needle = val.as_str().unwrap_or("");
field_val.as_str().map(|s| s.contains(needle)).unwrap_or(false) field_val.as_str().map(|s| s.contains(needle)).unwrap_or(false)
} }
"$startsWith" => { "$sw" => {
let needle = val.as_str().unwrap_or(""); let needle = val.as_str().unwrap_or("");
field_val.as_str().map(|s| s.starts_with(needle)).unwrap_or(false) field_val.as_str().map(|s| s.starts_with(needle)).unwrap_or(false)
} }
"$endsWith" => { "$ew" => {
let needle = val.as_str().unwrap_or(""); let needle = val.as_str().unwrap_or("");
field_val.as_str().map(|s| s.ends_with(needle)).unwrap_or(false) field_val.as_str().map(|s| s.ends_with(needle)).unwrap_or(false)
} }
"$regex" => { "$re" => {
let pattern = val.as_str().unwrap_or(""); let pattern = val.as_str().unwrap_or("");
if pattern.len() > 200 { return Ok(false); } if pattern.len() > 200 { return Ok(false); }
let re = match Regex::new(pattern) { 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(); let exists = !field_val.is_null();
exists == should_exist 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}"); tracing::warn!("Unknown query operator: {op}");
false false

View File

@ -105,6 +105,7 @@ pub async fn fetch_and_run(
up: false, up: false,
error: Some(format!("timed out after {}ms", timeout_ms)), error: Some(format!("timed out after {}ms", timeout_ms)),
cert_expiry_days: None, cert_expiry_days: None,
cert_issuer: None,
meta: None, meta: None,
region: Some(region_owned.to_string()), region: Some(region_owned.to_string()),
run_id: Some(run_id_owned.clone()), 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 url = monitor.url.clone();
let req_headers = monitor.request_headers.clone(); let req_headers = monitor.request_headers.clone();
let req_body = monitor.request_body.clone(); let req_body = monitor.request_body.clone();
let max_redirects = monitor.max_redirects;
let (tx, rx) = tokio::sync::oneshot::channel::<Result<(u16, HashMap<String, String>, String), String>>(); let (tx, rx) = tokio::sync::oneshot::channel::<Result<(u16, HashMap<String, String>, String), String>>();
std::thread::spawn(move || { std::thread::spawn(move || {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { 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 { let _ = tx.send(match result {
Ok(r) => r, Ok(r) => r,
@ -199,6 +201,7 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor, scheduled_at: Op
up: false, up: false,
error: Some(e.clone()), error: Some(e.clone()),
cert_expiry_days: None, cert_expiry_days: None,
cert_issuer: None,
meta: None, meta: None,
region: Some(region.to_string()), region: Some(region.to_string()),
run_id: Some(run_id.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 { Some(tokio::spawn(async move {
match tokio::time::timeout( match tokio::time::timeout(
std::time::Duration::from_secs(5), std::time::Duration::from_secs(5),
check_cert_expiry(&cert_url), check_cert(&cert_url),
).await { ).await {
Ok(Ok(days)) => days, Ok(Ok(info)) => info,
_ => None, _ => None,
} }
})) }))
@ -230,6 +233,7 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor, scheduled_at: Op
headers: headers.clone(), headers: headers.clone(),
latency_ms: Some(latency_ms), latency_ms: Some(latency_ms),
cert_expiry_days: None, cert_expiry_days: None,
cert_issuer: None,
}; };
match query::evaluate(q, &response) { match query::evaluate(q, &response) {
Ok(result) => (result, None), 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) ((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), Some(h) => h.await.unwrap_or(None),
None => 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!({ let meta = json!({
"headers": headers, "headers": headers,
@ -264,6 +270,7 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor, scheduled_at: Op
up, up,
error: query_error, error: query_error,
cert_expiry_days, cert_expiry_days,
cert_issuer,
meta: Some(meta), meta: Some(meta),
region: Some(region.to_string()), region: Some(region.to_string()),
run_id: Some(run_id.to_string()), run_id: Some(run_id.to_string()),
@ -278,6 +285,7 @@ fn run_check_blocking(
headers: Option<&HashMap<String, String>>, headers: Option<&HashMap<String, String>>,
body: Option<&str>, body: Option<&str>,
timeout: std::time::Duration, timeout: std::time::Duration,
max_redirects: u32,
) -> Result<(u16, HashMap<String, String>, String), String> { ) -> Result<(u16, HashMap<String, String>, String), String> {
let root_certs = ROOT_CERTS.with(|c| Arc::clone(c)); let root_certs = ROOT_CERTS.with(|c| Arc::clone(c));
@ -289,6 +297,7 @@ fn run_check_blocking(
.timeout_global(Some(timeout)) .timeout_global(Some(timeout))
.timeout_connect(Some(timeout)) .timeout_connect(Some(timeout))
.http_status_as_error(false) .http_status_as_error(false)
.max_redirects(max_redirects)
.user_agent("Mozilla/5.0 (compatible; PingQL/1.0; +https://pingql.com)") .user_agent("Mozilla/5.0 (compatible; PingQL/1.0; +https://pingql.com)")
.tls_config(tls) .tls_config(tls)
.build() .build()
@ -359,7 +368,12 @@ fn run_check_blocking(
Ok((status, resp_headers, body_out)) Ok((status, resp_headers, body_out))
} }
async fn check_cert_expiry(url: &str) -> Result<Option<i64>> { struct CertInfo {
expiry_days: i64,
issuer: String,
}
async fn check_cert(url: &str) -> Result<Option<CertInfo>> {
use rustls::ClientConfig; use rustls::ClientConfig;
use rustls::pki_types::ServerName; use rustls::pki_types::ServerName;
use tokio::net::TcpStream; use tokio::net::TcpStream;
@ -394,7 +408,8 @@ async fn check_cert_expiry(url: &str) -> Result<Option<i64>> {
.unwrap() .unwrap()
.as_secs() as i64; .as_secs() as i64;
let days = (not_after - now) / 86400; 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) Ok(None)

View File

@ -1,4 +1,6 @@
use serde::{Deserialize, Deserializer, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
fn default_max_redirects() -> u32 { 1 }
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
@ -27,6 +29,8 @@ pub struct Monitor {
pub max_retries: u32, pub max_retries: u32,
#[serde(default)] #[serde(default)]
pub retry_interval_s: u64, pub retry_interval_s: u64,
#[serde(default = "default_max_redirects")]
pub max_redirects: u32,
pub query: Option<Value>, pub query: Option<Value>,
pub scheduled_at: Option<String>, // ISO string for backward compat in PingResult pub scheduled_at: Option<String>, // ISO string for backward compat in PingResult
#[serde(deserialize_with = "deserialize_ms")] #[serde(deserialize_with = "deserialize_ms")]
@ -45,6 +49,7 @@ pub struct PingResult {
pub up: bool, pub up: bool,
pub error: Option<String>, pub error: Option<String>,
pub cert_expiry_days: Option<i64>, pub cert_expiry_days: Option<i64>,
pub cert_issuer: Option<String>,
pub meta: Option<Value>, pub meta: Option<Value>,
pub region: Option<String>, pub region: Option<String>,
pub run_id: Option<String>, pub run_id: Option<String>,

View File

@ -33,6 +33,7 @@ export async function migrate(sql: any) {
retry_interval_s INTEGER NOT NULL DEFAULT 30, retry_interval_s INTEGER NOT NULL DEFAULT 30,
resend_interval INTEGER NOT NULL DEFAULT 0, resend_interval INTEGER NOT NULL DEFAULT 0,
cert_alert_days 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 '{}', tags TEXT[] NOT NULL DEFAULT '{}',
channel_ids UUID[] NOT NULL DEFAULT '{}', channel_ids UUID[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT now(), created_at TIMESTAMPTZ DEFAULT now(),

View File

@ -1,17 +1,18 @@
const FIELDS = [ const FIELDS = [
{ name: 'status', label: 'Status Code', type: 'number', operators: ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in'] }, { name: 'status', label: 'Status Code', type: 'number', operators: ['$eq', '$ne', '$gt', '$ge', '$lt', '$le'] },
{ name: 'body', label: 'Response Body', type: 'string', operators: ['$eq', '$ne', '$contains', '$startsWith', '$endsWith', '$regex', '$exists'] }, { name: 'body', label: 'Response Body', type: 'string', operators: ['$eq', '$ne', '$co', '$sw', '$ew', '$re', '$exists'] },
{ name: 'headers.*', label: 'Header', type: 'string', operators: ['$eq', '$ne', '$contains', '$startsWith', '$endsWith', '$regex', '$exists'] }, { name: 'headers.*', label: 'Header', type: 'string', operators: ['$eq', '$ne', '$co', '$sw', '$ew', '$re', '$exists'] },
{ name: '$select', label: 'CSS Selector', type: 'selector', operators: ['$eq', '$ne', '$contains', '$startsWith', '$endsWith', '$regex'] }, { 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', '$gte', '$lt', '$lte', '$contains', '$regex'] }, { name: '$json', label: 'JSON Path', type: 'jsonpath', operators: ['$eq', '$ne', '$gt', '$ge', '$lt', '$le', '$co', '$re'] },
{ name: '$responseTime', label: 'Response Time (ms)', type: 'number', operators: ['$eq', '$gt', '$gte', '$lt', '$lte'] }, { 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', '$gte', '$lt', '$lte'] }, { 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 = { const OP_LABELS = {
'$eq': '=', '$ne': '≠', '$gt': '>', '$gte': '≥', '$lt': '<', '$lte': '≤', '$eq': '=', '$ne': '≠', '$gt': '>', '$ge': '≥', '$lt': '<', '$le': '≤',
'$contains': 'contains', '$startsWith': 'starts with', '$endsWith': 'ends with', '$co': 'contains', '$sw': 'starts with', '$ew': 'ends with',
'$regex': 'matches regex', '$exists': 'exists', '$in': 'in', '$re': 'matches regex', '$exists': 'exists',
}; };
class QueryBuilder { class QueryBuilder {
@ -36,7 +37,7 @@ class QueryBuilder {
// it instead of guessing. // it instead of guessing.
_defaultRules() { _defaultRules() {
return [ 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: '' }, { field: 'status', operator: '$lt', value: '300', headerName: '', selectorValue: '', jsonPath: '' },
]; ];
} }
@ -57,11 +58,11 @@ class QueryBuilder {
const parsedVal = this._parseValue(value, field, operator); const parsedVal = this._parseValue(value, field, operator);
if (field === '$responseTime' || field === '$certExpiry') { if (field === '$time' || field === '$certExpiry' || field === '$certIssuer') {
return { [field]: { [operator]: parsedVal } }; return { [field]: { [operator]: parsedVal } };
} }
if (field === '$select') { if (field === '$html') {
return { '$select': { [rule.selectorValue || '*']: { [operator]: parsedVal } } }; return { '$html': { [rule.selectorValue || '*']: { [operator]: parsedVal } } };
} }
if (field === '$json') { if (field === '$json') {
return { '$json': { [rule.jsonPath || '$']: { [operator]: parsedVal } } }; return { '$json': { [rule.jsonPath || '$']: { [operator]: parsedVal } } };
@ -78,15 +79,8 @@ class QueryBuilder {
_parseValue(value, field, operator) { _parseValue(value, field, operator) {
if (operator === '$exists') return value !== 'false' && value !== '0'; 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 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')) { if (fieldDef?.type === 'number' || (numericOps.includes(operator) && fieldDef?.type === 'jsonpath')) {
const n = Number(value); const n = Number(value);
return isNaN(n) ? value : n; return isNaN(n) ? value : n;
@ -123,8 +117,8 @@ class QueryBuilder {
_queryToRule(clause) { _queryToRule(clause) {
if (!clause || typeof clause !== 'object') return this._emptyRule(); if (!clause || typeof clause !== 'object') return this._emptyRule();
if ('$responseTime' in clause || '$certExpiry' in clause) { if ('$time' in clause || '$certExpiry' in clause || '$certIssuer' in clause) {
const field = '$responseTime' in clause ? '$responseTime' : '$certExpiry'; const field = '$time' in clause ? '$time' : '$certIssuer' in clause ? '$certIssuer' : '$certExpiry';
const ops = clause[field]; const ops = clause[field];
if (typeof ops === 'object') { if (typeof ops === 'object') {
const [operator, value] = Object.entries(ops)[0] || ['$lt', '']; const [operator, value] = Object.entries(ops)[0] || ['$lt', ''];
@ -132,13 +126,13 @@ class QueryBuilder {
} }
} }
if ('$select' in clause) { if ('$html' in clause) {
const selMap = clause.$select; const selMap = clause.$html;
if (selMap && typeof selMap === 'object') { if (selMap && typeof selMap === 'object') {
const [selectorValue, condition] = Object.entries(selMap)[0] || ['*', {}]; const [selectorValue, condition] = Object.entries(selMap)[0] || ['*', {}];
if (condition && typeof condition === 'object') { if (condition && typeof condition === 'object') {
const [operator, value] = Object.entries(condition)[0] || ['$eq', '']; 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 operators = fieldDef.operators;
const needsHeader = rule.field === 'headers.*'; const needsHeader = rule.field === 'headers.*';
const needsSelector = rule.field === '$select'; const needsSelector = rule.field === '$html';
const needsJsonPath = rule.field === '$json'; const needsJsonPath = rule.field === '$json';
return ` return `

View File

@ -65,7 +65,7 @@
<a href="#ql-fields" class="nav-link">Fields</a> <a href="#ql-fields" class="nav-link">Fields</a>
<a href="#ql-operators" class="nav-link">Operators</a> <a href="#ql-operators" class="nav-link">Operators</a>
<a href="#ql-json" class="nav-link">$json</a> <a href="#ql-json" class="nav-link">$json</a>
<a href="#ql-select" class="nav-link">$select</a> <a href="#ql-select" class="nav-link">$html</a>
<a href="#ql-logical" class="nav-link">Logical</a> <a href="#ql-logical" class="nav-link">Logical</a>
<a href="#ql-consider" class="nav-link">$consider</a> <a href="#ql-consider" class="nav-link">$consider</a>
@ -147,6 +147,7 @@
<span class="k">"retry_interval_s"</span>: <span class="n">30</span>, <span class="c">// optional - seconds between retries. Default: 30</span> <span class="k">"retry_interval_s"</span>: <span class="n">30</span>, <span class="c">// optional - seconds between retries. Default: 30</span>
<span class="k">"resend_interval"</span>: <span class="n">10</span>, <span class="c">// optional - re-alert every Nth consecutive DOWN beat. 0 = never. Default: 0</span> <span class="k">"resend_interval"</span>: <span class="n">10</span>, <span class="c">// optional - re-alert every Nth consecutive DOWN beat. 0 = never. Default: 0</span>
<span class="k">"cert_alert_days"</span>: <span class="n">0</span>, <span class="c">// optional - alert when TLS cert is within N days of expiry. 0 disables. Default: 0 (disabled)</span> <span class="k">"cert_alert_days"</span>: <span class="n">0</span>, <span class="c">// optional - alert when TLS cert is within N days of expiry. 0 disables. Default: 0 (disabled)</span>
<span class="k">"max_redirects"</span>: <span class="n">1</span>, <span class="c">// optional - follow up to N redirects (0-4). Default: 1</span>
<span class="k">"channel_ids"</span>: [<span class="s">"&lt;uuid&gt;"</span>], <span class="c">// optional - notification channels to attach</span> <span class="k">"channel_ids"</span>: [<span class="s">"&lt;uuid&gt;"</span>], <span class="c">// optional - notification channels to attach</span>
<span class="k">"query"</span>: { ... } <span class="c">// optional - see Query Language below</span> <span class="k">"query"</span>: { ... } <span class="c">// optional - see Query Language below</span>
}</pre> }</pre>
@ -166,6 +167,7 @@
<tr><td>retry_interval_s</td><td>number</td><td>Seconds between retries. Default: 30.</td></tr> <tr><td>retry_interval_s</td><td>number</td><td>Seconds between retries. Default: 30.</td></tr>
<tr><td>resend_interval</td><td>number</td><td>If a monitor stays DOWN, re-fire a notification every Nth consecutive down beat. 0 disables resend. Default: 0.</td></tr> <tr><td>resend_interval</td><td>number</td><td>If a monitor stays DOWN, re-fire a notification every Nth consecutive down beat. 0 disables resend. Default: 0.</td></tr>
<tr><td>cert_alert_days</td><td>number</td><td>Fire a separate <code>cert</code> notification when the TLS certificate is within N days of expiring. 0 disables. Default: 0 (disabled).</td></tr> <tr><td>cert_alert_days</td><td>number</td><td>Fire a separate <code>cert</code> notification when the TLS certificate is within N days of expiring. 0 disables. Default: 0 (disabled).</td></tr>
<tr><td>max_redirects</td><td>number</td><td>Follow up to N redirects (0-4). 0 = don't follow. Default: 1.</td></tr>
<tr><td>channel_ids</td><td>string[]</td><td>Notification channel IDs to attach. See <a href="#notifications">Notifications</a>.</td></tr> <tr><td>channel_ids</td><td>string[]</td><td>Notification channel IDs to attach. See <a href="#notifications">Notifications</a>.</td></tr>
<tr><td>query</td><td>object</td><td>Query conditions - see below</td></tr> <tr><td>query</td><td>object</td><td>Query conditions - see below</td></tr>
</tbody> </tbody>
@ -420,11 +422,13 @@ Content-Type: application/json
<tbody> <tbody>
<tr><td>status</td><td>number</td><td>HTTP status code</td></tr> <tr><td>status</td><td>number</td><td>HTTP status code</td></tr>
<tr><td>body</td><td>string</td><td>Full response body as text</td></tr> <tr><td>body</td><td>string</td><td>Full response body as text</td></tr>
<tr><td>size</td><td>number</td><td>Response body size in bytes</td></tr>
<tr><td>headers.<em>name</em></td><td>string</td><td>Response header, e.g. <code>headers.content-type</code></td></tr> <tr><td>headers.<em>name</em></td><td>string</td><td>Response header, e.g. <code>headers.content-type</code></td></tr>
<tr><td>$responseTime</td><td>number</td><td>Request latency in milliseconds</td></tr> <tr><td>$time</td><td>number</td><td>Request latency in milliseconds</td></tr>
<tr><td>$certExpiry</td><td>number</td><td>Days until SSL certificate expires</td></tr> <tr><td>$certExpiry</td><td>number</td><td>Days until SSL certificate expires</td></tr>
<tr><td>$certIssuer</td><td>string</td><td>Certificate authority name (e.g. "Let's Encrypt")</td></tr>
<tr><td>$json</td><td>object</td><td>JSONPath expression against response body</td></tr> <tr><td>$json</td><td>object</td><td>JSONPath expression against response body</td></tr>
<tr><td>$select</td><td>object</td><td>CSS selector against response HTML</td></tr> <tr><td>$html</td><td>object</td><td>CSS selector against response HTML</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@ -437,14 +441,13 @@ Content-Type: application/json
<tbody> <tbody>
<tr><td>$eq</td><td>Equal to</td><td>any</td></tr> <tr><td>$eq</td><td>Equal to</td><td>any</td></tr>
<tr><td>$ne</td><td>Not equal to</td><td>any</td></tr> <tr><td>$ne</td><td>Not equal to</td><td>any</td></tr>
<tr><td>$gt / $gte</td><td>Greater than / or equal</td><td>number</td></tr> <tr><td>$gt / $ge</td><td>Greater than / or equal</td><td>number</td></tr>
<tr><td>$lt / $lte</td><td>Less than / or equal</td><td>number</td></tr> <tr><td>$lt / $le</td><td>Less than / or equal</td><td>number</td></tr>
<tr><td>$contains</td><td>String contains substring</td><td>string</td></tr> <tr><td>$co</td><td>String contains substring</td><td>string</td></tr>
<tr><td>$startsWith</td><td>String starts with</td><td>string</td></tr> <tr><td>$sw</td><td>String starts with</td><td>string</td></tr>
<tr><td>$endsWith</td><td>String ends with</td><td>string</td></tr> <tr><td>$ew</td><td>String ends with</td><td>string</td></tr>
<tr><td>$regex</td><td>Matches regular expression</td><td>string</td></tr> <tr><td>$re</td><td>Matches regular expression</td><td>string</td></tr>
<tr><td>$exists</td><td>Field is present and non-null</td><td>any</td></tr> <tr><td>$exists</td><td>Field is present and non-null</td><td>any</td></tr>
<tr><td>$in</td><td>Value is in array</td><td>any</td></tr>
</tbody> </tbody>
</table> </table>
<div class="cb"> <div class="cb">
@ -454,8 +457,8 @@ Content-Type: application/json
<span class="c">// operator form</span> <span class="c">// operator form</span>
{ <span class="k">"status"</span>: { <span class="o">"$lt"</span>: <span class="n">400</span> } } { <span class="k">"status"</span>: { <span class="o">"$lt"</span>: <span class="n">400</span> } }
{ <span class="k">"body"</span>: { <span class="o">"$contains"</span>: <span class="s">"healthy"</span> } } { <span class="k">"body"</span>: { <span class="o">"$co"</span>: <span class="s">"healthy"</span> } }
{ <span class="k">"headers.content-type"</span>: { <span class="o">"$contains"</span>: <span class="s">"application/json"</span> } }</pre> { <span class="k">"headers.content-type"</span>: { <span class="o">"$co"</span>: <span class="s">"application/json"</span> } }</pre>
</div> </div>
</div> </div>
@ -472,17 +475,17 @@ Content-Type: application/json
</div> </div>
</div> </div>
<!-- $select --> <!-- $html -->
<div id="ql-select" class="section"> <div id="ql-select" class="section">
<h2>$select - CSS Selector</h2> <h2>$html - HTML Selector</h2>
<p>Extract text content from an HTML response using a CSS selector. Useful for monitoring public pages without an API.</p> <p>Extract text content from an HTML response using a CSS selector. Useful for monitoring public pages without an API.</p>
<div class="cb"> <div class="cb">
<div class="cb-header"><span class="cb-lang">json</span></div> <div class="cb-header"><span class="cb-lang">json</span></div>
<pre><span class="c">// matches if &lt;h1&gt; text is exactly "Example Domain"</span> <pre><span class="c">// matches if &lt;h1&gt; text is exactly "Example Domain"</span>
{ <span class="o">"$select"</span>: { <span class="s">"h1"</span>: { <span class="o">"$eq"</span>: <span class="s">"Example Domain"</span> } } } { <span class="o">"$html"</span>: { <span class="s">"h1"</span>: { <span class="o">"$eq"</span>: <span class="s">"Example Domain"</span> } } }
<span class="c">// matches if status badge contains "operational"</span> <span class="c">// matches if status badge contains "operational"</span>
{ <span class="o">"$select"</span>: { <span class="s">".status-badge"</span>: { <span class="o">"$contains"</span>: <span class="s">"operational"</span> } } }</pre> { <span class="o">"$html"</span>: { <span class="s">".status-badge"</span>: { <span class="o">"$co"</span>: <span class="s">"operational"</span> } } }</pre>
</div> </div>
</div> </div>
@ -492,7 +495,7 @@ Content-Type: application/json
<div class="cb"> <div class="cb">
<div class="cb-header"><span class="cb-lang">json</span></div> <div class="cb-header"><span class="cb-lang">json</span></div>
<pre><span class="c">// $and - all conditions must match</span> <pre><span class="c">// $and - all conditions must match</span>
{ <span class="o">"$and"</span>: [{ <span class="k">"status"</span>: <span class="n">200</span> }, { <span class="k">"body"</span>: { <span class="o">"$contains"</span>: <span class="s">"ok"</span> } }] } { <span class="o">"$and"</span>: [{ <span class="k">"status"</span>: <span class="n">200</span> }, { <span class="k">"body"</span>: { <span class="o">"$co"</span>: <span class="s">"ok"</span> } }] }
<span class="c">// $or - any condition must match</span> <span class="c">// $or - any condition must match</span>
{ <span class="o">"$or"</span>: [{ <span class="k">"status"</span>: <span class="n">200</span> }, { <span class="k">"status"</span>: <span class="n">204</span> }] } { <span class="o">"$or"</span>: [{ <span class="k">"status"</span>: <span class="n">200</span> }, { <span class="k">"status"</span>: <span class="n">204</span> }] }
@ -522,7 +525,7 @@ Content-Type: application/json
<div class="cb"> <div class="cb">
<div class="cb-header"><span class="cb-lang">json</span></div> <div class="cb-header"><span class="cb-lang">json</span></div>
<pre><span class="c">// down if response time exceeds 2 seconds</span> <pre><span class="c">// down if response time exceeds 2 seconds</span>
{ <span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="k">"$responseTime"</span>: { <span class="o">"$gt"</span>: <span class="n">2000</span> } } { <span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="k">"$time"</span>: { <span class="o">"$gt"</span>: <span class="n">2000</span> } }
<span class="c">// down if cert expires in less than 7 days</span> <span class="c">// down if cert expires in less than 7 days</span>
{ <span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="k">"$certExpiry"</span>: { <span class="o">"$lt"</span>: <span class="n">7</span> } } { <span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="k">"$certExpiry"</span>: { <span class="o">"$lt"</span>: <span class="n">7</span> } }
@ -531,8 +534,8 @@ Content-Type: application/json
{ {
<span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="o">"$consider"</span>: <span class="s">"down"</span>,
<span class="o">"$or"</span>: [ <span class="o">"$or"</span>: [
{ <span class="k">"status"</span>: { <span class="o">"$gte"</span>: <span class="n">500</span> } }, { <span class="k">"status"</span>: { <span class="o">"$ge"</span>: <span class="n">500</span> } },
{ <span class="k">"$responseTime"</span>: { <span class="o">"$gt"</span>: <span class="n">5000</span> } } { <span class="k">"$time"</span>: { <span class="o">"$gt"</span>: <span class="n">5000</span> } }
] ]
}</pre> }</pre>
</div> </div>
@ -544,7 +547,7 @@ Content-Type: application/json
<h3>Basic health endpoint</h3> <h3>Basic health endpoint</h3>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div> <div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{ <span class="k">"status"</span>: <span class="n">200</span>, <span class="k">"body"</span>: { <span class="o">"$contains"</span>: <span class="s">"healthy"</span> } }</pre></div> <pre>{ <span class="k">"status"</span>: <span class="n">200</span>, <span class="k">"body"</span>: { <span class="o">"$co"</span>: <span class="s">"healthy"</span> } }</pre></div>
<h3>JSON API response shape</h3> <h3>JSON API response shape</h3>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div> <div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
@ -557,7 +560,7 @@ Content-Type: application/json
<h3>Performance monitor (mark down if slow)</h3> <h3>Performance monitor (mark down if slow)</h3>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div> <div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{ <span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="k">"$responseTime"</span>: { <span class="o">"$gt"</span>: <span class="n">1000</span> } }</pre></div> <pre>{ <span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="k">"$time"</span>: { <span class="o">"$gt"</span>: <span class="n">1000</span> } }</pre></div>
<h3>Cert expiry alert</h3> <h3>Cert expiry alert</h3>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div> <div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
@ -565,10 +568,10 @@ Content-Type: application/json
<h3>Status page (HTML)</h3> <h3>Status page (HTML)</h3>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div> <div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{ <span class="o">"$select"</span>: { <span class="s">".status-indicator"</span>: { <span class="o">"$eq"</span>: <span class="s">"All systems operational"</span> } } }</pre></div> <pre>{ <span class="o">"$html"</span>: { <span class="s">".status-indicator"</span>: { <span class="o">"$eq"</span>: <span class="s">"All systems operational"</span> } } }</pre></div>
<h3>Page down if a JSON queue is backed up</h3> <h3>Page down if a JSON queue is backed up</h3>
<p>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.</p> <p>Alert when a JSON field crosses a threshold. Most monitoring tools can't compose this without a script.</p>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div> <div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{ <pre>{
<span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="o">"$consider"</span>: <span class="s">"down"</span>,
@ -576,15 +579,15 @@ Content-Type: application/json
}</pre></div> }</pre></div>
<h3>Down if any signal looks bad</h3> <h3>Down if any signal looks bad</h3>
<p>Compose multiple conditions with <code>$or</code> and flip the result with <code>$consider</code>. Each condition can mix status, body, headers, JSON, and CSS-selector checks freely.</p> <p>Compose multiple conditions with <code>$or</code> and flip the result with <code>$consider</code>.</p>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div> <div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{ <pre>{
<span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="o">"$consider"</span>: <span class="s">"down"</span>,
<span class="o">"$or"</span>: [ <span class="o">"$or"</span>: [
{ <span class="k">"status"</span>: { <span class="o">"$gte"</span>: <span class="n">500</span> } }, { <span class="k">"status"</span>: { <span class="o">"$ge"</span>: <span class="n">500</span> } },
{ <span class="k">"$responseTime"</span>: { <span class="o">"$gt"</span>: <span class="n">3000</span> } }, { <span class="k">"$time"</span>: { <span class="o">"$gt"</span>: <span class="n">3000</span> } },
{ <span class="o">"$json"</span>: { <span class="s">"$.healthy"</span>: { <span class="o">"$eq"</span>: <span class="n">false</span> } } }, { <span class="o">"$json"</span>: { <span class="s">"$.healthy"</span>: { <span class="o">"$eq"</span>: <span class="n">false</span> } } },
{ <span class="o">"$select"</span>: { <span class="s">".error-banner"</span>: { <span class="o">"$exists"</span>: <span class="n">true</span> } } } { <span class="o">"$html"</span>: { <span class="s">".error-banner"</span>: { <span class="o">"$exists"</span>: <span class="n">true</span> } } }
] ]
}</pre></div> }</pre></div>
@ -594,8 +597,8 @@ Content-Type: application/json
<pre>{ <pre>{
<span class="o">"$and"</span>: [ <span class="o">"$and"</span>: [
{ <span class="k">"status"</span>: <span class="n">200</span> }, { <span class="k">"status"</span>: <span class="n">200</span> },
{ <span class="k">"headers.content-type"</span>: { <span class="o">"$contains"</span>: <span class="s">"application/json"</span> } }, { <span class="k">"headers.content-type"</span>: { <span class="o">"$co"</span>: <span class="s">"application/json"</span> } },
{ <span class="o">"$json"</span>: { <span class="s">"$.version"</span>: { <span class="o">"$startsWith"</span>: <span class="s">"v2"</span> } } }, { <span class="o">"$json"</span>: { <span class="s">"$.version"</span>: { <span class="o">"$sw"</span>: <span class="s">"v2"</span> } } },
{ <span class="o">"$json"</span>: { <span class="s">"$.db.connections"</span>: { <span class="o">"$lt"</span>: <span class="n">100</span> } } } { <span class="o">"$json"</span>: { <span class="s">"$.db.connections"</span>: { <span class="o">"$lt"</span>: <span class="n">100</span> } } }
] ]
}</pre></div> }</pre></div>
@ -611,7 +614,7 @@ Content-Type: application/json
{ <span class="o">"$json"</span>: { <span class="s">"$.env"</span>: { <span class="o">"$eq"</span>: <span class="s">"staging"</span> } } } { <span class="o">"$json"</span>: { <span class="s">"$.env"</span>: { <span class="o">"$eq"</span>: <span class="s">"staging"</span> } } }
] }, ] },
{ <span class="o">"$or"</span>: [ { <span class="o">"$or"</span>: [
{ <span class="k">"$responseTime"</span>: { <span class="o">"$lt"</span>: <span class="n">2000</span> } }, { <span class="k">"$time"</span>: { <span class="o">"$lt"</span>: <span class="n">2000</span> } },
{ <span class="o">"$json"</span>: { <span class="s">"$.cache"</span>: { <span class="o">"$eq"</span>: <span class="s">"hit"</span> } } } { <span class="o">"$json"</span>: { <span class="s">"$.cache"</span>: { <span class="o">"$eq"</span>: <span class="s">"hit"</span> } } }
] } ] }
] ]

View File

@ -375,7 +375,7 @@
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg> <svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
</div> </div>
<h3 class="font-semibold text-white mb-2">JSONPath & CSS selectors</h3> <h3 class="font-semibold text-white mb-2">JSONPath & CSS selectors</h3>
<p class="text-sm text-gray-400 leading-relaxed">Drill into JSON responses with <code class="text-brand font-mono text-xs">$json</code> or scrape any HTML page with <code class="text-brand font-mono text-xs">$select</code>. No API required.</p> <p class="text-sm text-gray-400 leading-relaxed">Drill into JSON responses with <code class="text-brand font-mono text-xs">$json</code> or scrape any HTML page with <code class="text-brand font-mono text-xs">$html</code>. No API required.</p>
</div> </div>
<div class="glow-card rounded-xl p-6"> <div class="glow-card rounded-xl p-6">