diff --git a/apps/monitor/src/query.rs b/apps/monitor/src/query.rs index 3801883..423f705 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) => { + // $upIf / $downIf — named groups, $downIf takes precedence + if map.contains_key("$upIf") || map.contains_key("$downIf") { + if let Some(down) = map.get("$downIf") { + if evaluate(down, response).unwrap_or(false) { return Ok(false); } + } + if let Some(up) = map.get("$upIf") { + return evaluate(up, response); + } + return Ok(true); // only $downIf set and didn't match → up + } + // $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/new.html b/apps/web/src/dashboard/new.html index 3d0b071..d224174 100644 --- a/apps/web/src/dashboard/new.html +++ b/apps/web/src/dashboard/new.html @@ -53,8 +53,8 @@
- -

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

+ +

Define up/down conditions. Down if takes priority over Up if. Leave empty to use default (status < 400).

@@ -71,7 +71,7 @@ if (!requireAuth()) throw 'auth'; let currentQuery = null; - const qb = new QueryBuilder(document.getElementById('query-builder'), (q) => { + const qb = new GroupedQueryBuilder(document.getElementById('query-builder'), (q) => { currentQuery = q; }); diff --git a/apps/web/src/dashboard/query-builder.js b/apps/web/src/dashboard/query-builder.js index 7691581..eb48a2d 100644 --- a/apps/web/src/dashboard/query-builder.js +++ b/apps/web/src/dashboard/query-builder.js @@ -304,3 +304,150 @@ class QueryBuilder { } } } + +// ── Grouped Query Builder ───────────────────────────────────────────────────── +// Two named condition groups: "Up if..." and "Down if..." +// $downIf takes precedence over $upIf. Both optional. + +class GroupedQueryBuilder { + constructor(container, onChange) { + this.container = container; + this.onChange = onChange; + this.groups = { + upIf: new QueryBuilder(null, () => this._emit()), + downIf: new QueryBuilder(null, () => this._emit()), + }; + this.groups.upIf.rules = []; + this.groups.downIf.rules = []; + this._render(); + } + + _emit() { + this.onChange?.(this.getQuery()); + } + + getQuery() { + const up = this.groups.upIf.rules.length ? this.groups.upIf.getQuery() : null; + const down = this.groups.downIf.rules.length ? this.groups.downIf.getQuery() : null; + if (!up && !down) return null; + const q = {}; + if (up) q.$upIf = up; + if (down) q.$downIf = down; + return q; + } + + setQuery(query) { + if (!query || typeof query !== 'object') return; + if (query.$upIf) { this.groups.upIf.setQuery(query.$upIf); } + if (query.$downIf) { this.groups.downIf.setQuery(query.$downIf); } + this._render(); + } + + _render() { + this.container.innerHTML = ` +
+
+
+
+
+ Query JSON + +
+

+        
+
+ `; + + this._renderGroup('upIf', this.container.querySelector('#gqb-upif'), '🟢 Up if', 'text-green-400', 'border-green-900/50'); + this._renderGroup('downIf', this.container.querySelector('#gqb-downif'), '🔴 Down if', 'text-red-400', 'border-red-900/50'); + this._updatePreview(); + + this.container.querySelector('#gqb-copy').addEventListener('click', () => { + const q = this.getQuery(); + navigator.clipboard.writeText(JSON.stringify(q ?? {}, null, 2)); + const btn = this.container.querySelector('#gqb-copy'); + btn.textContent = 'Copied!'; + setTimeout(() => btn.textContent = 'Copy', 1500); + }); + } + + _renderGroup(key, el, label, labelClass, borderClass) { + const group = this.groups[key]; + const hasRules = group.rules.length > 0; + + el.innerHTML = ` +
+
+ ${label} +
+ ${hasRules ? ` +
+ Match + +
+ ` : ''} + + ${hasRules ? `` : ''} +
+
+ ${hasRules ? ` +
+ ${group.rules.map((rule, i) => group._renderRule(rule, i)).join('')} +
+ ` : `

No conditions — defaults to status < 400

`} +
+ `; + + // Bind logic toggle + const logicSel = el.querySelector(`#gqb-logic-${key}`); + if (logicSel) { + logicSel.addEventListener('change', (e) => { + group.logic = e.target.value; + this._renderGroup(key, el, label, labelClass, borderClass); + this._updatePreview(); + this._emit(); + }); + } + + // Bind add + el.querySelector(`#gqb-add-${key}`).addEventListener('click', () => { + group.rules.push(group._emptyRule()); + this._renderGroup(key, el, label, labelClass, borderClass); + this._updatePreview(); + this._emit(); + }); + + // Bind clear + const clearBtn = el.querySelector(`#gqb-clear-${key}`); + if (clearBtn) { + clearBtn.addEventListener('click', () => { + group.rules = []; + this._renderGroup(key, el, label, labelClass, borderClass); + this._updatePreview(); + this._emit(); + }); + } + + // Bind rule events — wrap onChange to re-render preview + if (hasRules) { + group.container = el.querySelector(`#gqb-rules-${key}`); + group.onChange = () => { this._updatePreview(); this._emit(); }; + el.querySelectorAll('.qb-rule').forEach((ruleEl, i) => { + group._bindRuleEvents(ruleEl, i); + }); + } + } + + _updatePreview() { + const q = this.getQuery(); + const pre = this.container.querySelector('#gqb-preview'); + if (pre) { + pre.innerHTML = q + ? escapeHtml(JSON.stringify(q, null, 2)) + : 'No conditions — defaults to status < 400'; + } + } +} diff --git a/apps/web/src/query/index.ts b/apps/web/src/query/index.ts index 70c1bd4..f43377f 100644 --- a/apps/web/src/query/index.ts +++ b/apps/web/src/query/index.ts @@ -86,6 +86,13 @@ export function evaluate(query: unknown, ctx: EvalContext): boolean { const q = query as Record; + // $upIf / $downIf — named condition groups, $downIf takes precedence + if ("$upIf" in q || "$downIf" in q) { + if ("$downIf" in q && evaluate(q.$downIf, ctx)) return false; + if ("$upIf" in q) return evaluate(q.$upIf, ctx); + return true; // only $downIf set and it didn't match → up + } + // $and if ("$and" in q) { const clauses = q.$and;