// 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.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; if (conditions.length === 1) return conditions[0]; return { [this.logic]: conditions }; } _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.render(); return; } if ('$and' in query || '$or' in query) { this.logic = '$and' in query ? '$and' : '$or'; const clauses = query[this.logic]; if (Array.isArray(clauses)) { this.rules = clauses.map(c => this._queryToRule(c)).filter(Boolean); } } else { this.rules = [this._queryToRule(query)].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 = `
Match of the following conditions
${this.rules.map((rule, i) => this._renderRule(rule, i)).join('')}
Query JSON
${query ? escapeHtml(JSON.stringify(query, null, 2)) : 'No conditions set'}
`; // Bind events this.container.querySelector('#qb-logic').addEventListener('change', (e) => { this.logic = e.target.value; this.render(); this.onChange?.(this.getQuery()); }); this.container.querySelector('#qb-add').addEventListener('click', () => { this.rules.push(this._emptyRule()); this.render(); this.onChange?.(this.getQuery()); }); this.container.querySelector('#qb-copy').addEventListener('click', () => { const q = this.getQuery(); navigator.clipboard.writeText(q ? JSON.stringify(q, null, 2) : '{}'); const btn = this.container.querySelector('#qb-copy'); btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy'; }, 1500); }); this.container.querySelectorAll('.qb-rule').forEach((el, i) => { this._bindRuleEvents(el, i); }); } _renderRule(rule, index) { const fieldDef = FIELDS.find(f => f.name === rule.field) || FIELDS[0]; const operators = fieldDef.operators; const needsHeader = rule.field === 'headers.*'; const needsSelector = rule.field === '$select'; const needsJsonPath = rule.field === '$json'; return `
${needsHeader ? `` : ''} ${needsSelector ? `` : ''} ${needsJsonPath ? `` : ''}
`; } _bindRuleEvents(el, index) { const rule = this.rules[index]; el.querySelector('.qb-field').addEventListener('change', (e) => { rule.field = e.target.value; const fieldDef = FIELDS.find(f => f.name === rule.field); if (fieldDef && !fieldDef.operators.includes(rule.operator)) { rule.operator = fieldDef.operators[0]; } this.render(); this.onChange?.(this.getQuery()); }); el.querySelector('.qb-op').addEventListener('change', (e) => { rule.operator = e.target.value; this.render(); this.onChange?.(this.getQuery()); }); el.querySelector('.qb-value').addEventListener('input', (e) => { rule.value = e.target.value; this._updatePreview(); this.onChange?.(this.getQuery()); }); el.querySelector('.qb-header')?.addEventListener('input', (e) => { rule.headerName = e.target.value; this._updatePreview(); this.onChange?.(this.getQuery()); }); el.querySelector('.qb-selector')?.addEventListener('input', (e) => { rule.selectorValue = e.target.value; this._updatePreview(); this.onChange?.(this.getQuery()); }); el.querySelector('.qb-jsonpath')?.addEventListener('input', (e) => { rule.jsonPath = e.target.value; this._updatePreview(); this.onChange?.(this.getQuery()); }); el.querySelector('.qb-remove')?.addEventListener('click', () => { this.rules.splice(index, 1); this.render(); this.onChange?.(this.getQuery()); }); } _updatePreview() { const q = this.getQuery(); const preview = this.container.querySelector('#qb-preview'); if (preview) { preview.textContent = q ? JSON.stringify(q, null, 2) : 'No conditions set'; } } }