diff --git a/apps/monitor/src/query.rs b/apps/monitor/src/query.rs index 3801883..13a1520 100644 --- a/apps/monitor/src/query.rs +++ b/apps/monitor/src/query.rs @@ -50,6 +50,17 @@ pub struct Response { pub fn evaluate(query: &Value, response: &Response) -> Result { match query { Value::Object(map) => { + // $consider — "up" (default) or "down": flips result if conditions match + if let Some(consider) = map.get("$consider") { + let is_down = consider.as_str() == Some("down"); + let rest: serde_json::Map = map.iter() + .filter(|(k, _)| k.as_str() != "$consider") + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let matches = evaluate(&Value::Object(rest), response).unwrap_or(false); + return Ok(if is_down { !matches } else { matches }); + } + // $and / $or / $not if let Some(and) = map.get("$and") { let Value::Array(clauses) = and else { bail!("$and expects array") }; diff --git a/apps/web/src/dashboard/query-builder.js b/apps/web/src/dashboard/query-builder.js index 7691581..28de061 100644 --- a/apps/web/src/dashboard/query-builder.js +++ b/apps/web/src/dashboard/query-builder.js @@ -21,6 +21,7 @@ class QueryBuilder { this.container = container; this.onChange = onChange; this.logic = '$and'; + this.consider = 'up'; // 'up' | 'down' this.rules = [this._emptyRule()]; this.render(); } @@ -34,8 +35,9 @@ class QueryBuilder { .map(r => this._ruleToQuery(r)) .filter(Boolean); if (conditions.length === 0) return null; - if (conditions.length === 1) return conditions[0]; - return { [this.logic]: conditions }; + const base = conditions.length === 1 ? conditions[0] : { [this.logic]: conditions }; + if (this.consider === 'down') return { $consider: 'down', ...base }; + return base; } _ruleToQuery(rule) { @@ -86,18 +88,23 @@ class QueryBuilder { if (!query || typeof query !== 'object') { this.rules = [this._emptyRule()]; this.logic = '$and'; + this.consider = 'up'; this.render(); return; } - if ('$and' in query || '$or' in query) { - this.logic = '$and' in query ? '$and' : '$or'; - const clauses = query[this.logic]; + // Strip $consider before parsing rules + this.consider = query.$consider === 'down' ? 'down' : 'up'; + const q = Object.fromEntries(Object.entries(query).filter(([k]) => k !== '$consider')); + + if ('$and' in q || '$or' in q) { + this.logic = '$and' in q ? '$and' : '$or'; + const clauses = q[this.logic]; if (Array.isArray(clauses)) { this.rules = clauses.map(c => this._queryToRule(c)).filter(Boolean); } } else { - this.rules = [this._queryToRule(query)].filter(Boolean); + this.rules = [this._queryToRule(q)].filter(Boolean); } if (this.rules.length === 0) this.rules = [this._emptyRule()]; @@ -164,13 +171,18 @@ class QueryBuilder { this.container.innerHTML = `
-
- Match +
+ Consider + + when - of the following conditions + of the following match
@@ -190,6 +202,12 @@ class QueryBuilder { `; // Bind events + this.container.querySelector('#qb-consider').addEventListener('change', (e) => { + this.consider = e.target.value; + this.render(); + this.onChange?.(this.getQuery()); + }); + this.container.querySelector('#qb-logic').addEventListener('change', (e) => { this.logic = e.target.value; this.render(); diff --git a/apps/web/src/query/index.ts b/apps/web/src/query/index.ts index 70c1bd4..ffd49db 100644 --- a/apps/web/src/query/index.ts +++ b/apps/web/src/query/index.ts @@ -86,6 +86,14 @@ export function evaluate(query: unknown, ctx: EvalContext): boolean { const q = query as Record; + // $consider — "up" (default) or "down": flips result if conditions match + if ("$consider" in q) { + const consider = q.$consider as string; + const rest = Object.fromEntries(Object.entries(q).filter(([k]) => k !== "$consider")); + const matches = evaluate(rest, ctx); + return consider === "down" ? !matches : matches; + } + // $and if ("$and" in q) { const clauses = q.$and;