pingql/apps/web/src/dashboard/query-builder.js

307 lines
13 KiB
JavaScript

// 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 = `
<div class="space-y-3">
<div class="flex items-center gap-2 mb-4">
<span class="text-sm text-gray-400">Match</span>
<select id="qb-logic" class="bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded px-2 py-1 focus:border-blue-500 focus:outline-none">
<option value="$and" ${this.logic === '$and' ? 'selected' : ''}>ALL</option>
<option value="$or" ${this.logic === '$or' ? 'selected' : ''}>ANY</option>
</select>
<span class="text-sm text-gray-400">of the following conditions</span>
</div>
<div id="qb-rules" class="space-y-2">
${this.rules.map((rule, i) => this._renderRule(rule, i)).join('')}
</div>
<button id="qb-add" class="text-sm text-blue-400 hover:text-blue-300 mt-2">+ Add condition</button>
<div class="mt-4 p-3 bg-gray-950 rounded border border-gray-800">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-gray-500 font-mono">Query JSON</span>
<button id="qb-copy" class="text-xs text-blue-400 hover:text-blue-300">Copy</button>
</div>
<pre id="qb-preview" class="text-xs text-gray-300 font-mono whitespace-pre-wrap overflow-x-auto">${query ? escapeHtml(JSON.stringify(query, null, 2)) : '<span class="text-gray-600">No conditions set</span>'}</pre>
</div>
</div>
`;
// 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 `
<div class="qb-rule flex items-center gap-2 p-2 bg-gray-800/50 rounded border border-gray-700/50">
<select class="qb-field bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded px-2 py-1.5 focus:border-blue-500 focus:outline-none min-w-[140px]">
${FIELDS.map(f => `<option value="${f.name}" ${f.name === rule.field ? 'selected' : ''}>${f.label}</option>`).join('')}
</select>
${needsHeader ? `<input type="text" class="qb-header bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded px-2 py-1.5 w-32 focus:border-blue-500 focus:outline-none" placeholder="header name" value="${escapeHtml(rule.headerName)}">` : ''}
${needsSelector ? `<input type="text" class="qb-selector bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded px-2 py-1.5 w-32 focus:border-blue-500 focus:outline-none" placeholder="CSS selector" value="${escapeHtml(rule.selectorValue)}">` : ''}
${needsJsonPath ? `<input type="text" class="qb-jsonpath bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded px-2 py-1.5 w-32 focus:border-blue-500 focus:outline-none" placeholder="$.path" value="${escapeHtml(rule.jsonPath)}">` : ''}
<select class="qb-op bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded px-2 py-1.5 focus:border-blue-500 focus:outline-none">
${operators.map(op => `<option value="${op}" ${op === rule.operator ? 'selected' : ''}>${OP_LABELS[op] || op}</option>`).join('')}
</select>
<input type="text" class="qb-value bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded px-2 py-1.5 flex-1 focus:border-blue-500 focus:outline-none" placeholder="${rule.operator === '$exists' ? 'true/false' : 'value'}" value="${escapeHtml(rule.value)}">
<button class="qb-remove text-gray-500 hover:text-red-400 px-1 ${this.rules.length <= 1 ? 'invisible' : ''}" title="Remove">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
`;
}
_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';
}
}
}