325 lines
14 KiB
JavaScript
325 lines
14 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.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 = `
|
|
<div class="space-y-3">
|
|
<div class="flex items-center gap-2 mb-4 flex-wrap">
|
|
<span class="text-sm text-gray-400">Consider</span>
|
|
<select id="qb-consider" class="bg-gray-800 border border-gray-700 text-sm rounded px-2 py-1 focus:border-blue-500 focus:outline-none font-medium ${this.consider === 'down' ? 'text-red-400' : 'text-green-400'}">
|
|
<option value="up" ${this.consider === 'up' ? 'selected' : ''}>UP</option>
|
|
<option value="down" ${this.consider === 'down' ? 'selected' : ''}>DOWN</option>
|
|
</select>
|
|
<span class="text-sm text-gray-400">when</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 match</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-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();
|
|
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';
|
|
}
|
|
}
|
|
}
|