// 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.consider = 'up'; // 'up' | 'down' 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; const base = conditions.length === 1 ? conditions[0] : { [this.logic]: conditions }; if (this.consider === 'down') return { $consider: 'down', ...base }; return base; } _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); const numericOps = ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte']; if (fieldDef?.type === 'number' || (numericOps.includes(operator) && fieldDef?.type === 'jsonpath')) { 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.consider = 'up'; this.render(); return; } // 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(q)].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 selMap = clause.$select; 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: '' }; } } } if ('$json' in clause) { const pathMap = clause.$json; if (pathMap && typeof pathMap === 'object') { const [jsonPath, condition] = Object.entries(pathMap)[0] || ['$', {}]; if (condition && typeof condition === 'object') { const [operator, value] = Object.entries(condition)[0] || ['$eq', '']; return { field: '$json', operator, value: String(value), 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 = `
${query ? escapeHtml(JSON.stringify(query, null, 2)) : 'No conditions set'}