From bfbeee063d8af5c5ba58ff1185f7dc2f2e61ba7d Mon Sep 17 00:00:00 2001 From: Patrik Cyvoct Date: Tue, 28 Apr 2026 23:33:06 +0200 Subject: [PATCH] fix: filters and configurable autocomplete chains - render Quickwit-safe filters for text phrases, simple text terms, JSON arrays, numeric arrays, boolean arrays, and scalar numeric/boolean values - fix quick-filter toggle behavior so filter-in/filter-out matches by operator and replaces opposite filters instead of adding duplicates - avoid mutating query filter objects when adding or toggling quick filters - add configurable filter autocomplete limit, defaulting to 1000 and supporting 0 for no terms limit - apply autocomplete limit to tag values and field autocomplete requests - add filter chain mode datasource option: no chain, sampled chain, or full chain - dedupe tag keys when fields expose multiple capabilities - improve template multi-value interpolation for Quickwit by inferring field context and avoiding bare IN fallbacks - add focused tests for filter rendering, quick-filter toggles, autocomplete limits, chain modes, and template interpolation Signed-off-by: Patrik Cyvoct --- src/QueryBuilder/elastic.test.ts | 25 ++ src/QueryBuilder/elastic.ts | 3 +- src/configuration/ConfigEditor.tsx | 53 ++- src/configuration/utils.ts | 7 + src/datasource/base.test.ts | 629 +++++++++++++++++++++++------ src/datasource/base.ts | 329 +++++++++++++-- src/modifyQuery.test.ts | 90 +++++ src/modifyQuery.ts | 40 +- src/quickwit.ts | 6 + 9 files changed, 1005 insertions(+), 177 deletions(-) create mode 100644 src/QueryBuilder/elastic.test.ts diff --git a/src/QueryBuilder/elastic.test.ts b/src/QueryBuilder/elastic.test.ts new file mode 100644 index 0000000..28ebbed --- /dev/null +++ b/src/QueryBuilder/elastic.test.ts @@ -0,0 +1,25 @@ +import { getDataQuery } from './elastic'; + +describe('getDataQuery', () => { + it('uses the requested terms size for autocomplete queries', () => { + const query = getDataQuery({ field: 'status', size: 250 }, 'getTerms'); + + expect(query.bucketAggs?.[0].settings).toEqual( + expect.objectContaining({ + size: '250', + shard_size: '250', + }) + ); + }); + + it('keeps zero as no terms limit', () => { + const query = getDataQuery({ field: 'status', size: 0 }, 'getTerms'); + + expect(query.bucketAggs?.[0].settings).toEqual( + expect.objectContaining({ + size: '0', + shard_size: '0', + }) + ); + }); +}); diff --git a/src/QueryBuilder/elastic.ts b/src/QueryBuilder/elastic.ts index 907f66e..d6df917 100644 --- a/src/QueryBuilder/elastic.ts +++ b/src/QueryBuilder/elastic.ts @@ -58,7 +58,8 @@ export function getDataQuery(queryDef: TermsQuery, refId: string): Elasticsearch const bucketAggs: BucketAggregation[] = []; if (queryDef.field) { - bucketAggs.push(getTermsAgg(queryDef.field, 100, 100, orderBy, order)) + const size = queryDef.size ?? 100; + bucketAggs.push(getTermsAgg(queryDef.field, size, size, orderBy, order)) } return { diff --git a/src/configuration/ConfigEditor.tsx b/src/configuration/ConfigEditor.tsx index 511902d..5bac6d8 100644 --- a/src/configuration/ConfigEditor.tsx +++ b/src/configuration/ConfigEditor.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from 'react'; -import { DataSourceHttpSettings, Input, InlineField, FieldSet } from '@grafana/ui'; -import { DataSourcePluginOptionsEditorProps, DataSourceSettings } from '@grafana/data'; -import { QuickwitOptions } from '../quickwit'; +import { DataSourceHttpSettings, Input, InlineField, FieldSet, RadioButtonGroup } from '@grafana/ui'; +import { DataSourcePluginOptionsEditorProps, DataSourceSettings, SelectableValue } from '@grafana/data'; +import { FilterAutocompleteChainMode, QuickwitOptions } from '../quickwit'; import { coerceOptions } from './utils'; import { Divider } from '../components/Divider'; import { DataLinks } from './DataLinks'; @@ -9,6 +9,12 @@ import _ from 'lodash'; interface Props extends DataSourcePluginOptionsEditorProps {} +const filterChainModeOptions: Array> = [ + { label: 'No chain', value: 'none' }, + { label: 'Sample', value: 'sample' }, + { label: 'Full', value: 'full' }, +]; + export const ConfigEditor = (props: Props) => { const { options: originalOptions, onOptionsChange } = props; const options = coerceOptions(originalOptions); @@ -103,6 +109,47 @@ export const QuickwitDetails = ({ value, onChange }: DetailsProps) => { width={40} /> + + + onChange({ + ...value, + jsonData: { ...value.jsonData, filterAutocompleteLimit: event.currentTarget.value }, + }) + } + placeholder="1000" + width={40} + /> + + + + onChange({ + ...value, + jsonData: { + ...value.jsonData, + filterAutocompleteChainMode: mode, + filterAutocompleteUseFilterChains: mode !== 'none', + }, + }) + } + /> + diff --git a/src/configuration/utils.ts b/src/configuration/utils.ts index 748c2d0..1a7c47d 100644 --- a/src/configuration/utils.ts +++ b/src/configuration/utils.ts @@ -4,12 +4,19 @@ import { QuickwitOptions } from 'quickwit'; export const coerceOptions = ( options: DataSourceSettings ): DataSourceSettings => { + const filterAutocompleteChainMode = + options.jsonData.filterAutocompleteChainMode ?? + (options.jsonData.filterAutocompleteUseFilterChains === false ? 'none' : 'sample'); + return { ...options, jsonData: { ...options.jsonData, logMessageField: options.jsonData.logMessageField || '', logLevelField: options.jsonData.logLevelField || '', + filterAutocompleteLimit: options.jsonData.filterAutocompleteLimit ?? '1000', + filterAutocompleteChainMode, + filterAutocompleteUseFilterChains: filterAutocompleteChainMode !== 'none', }, }; }; diff --git a/src/datasource/base.test.ts b/src/datasource/base.test.ts index 22f1d43..a4b9eff 100644 --- a/src/datasource/base.test.ts +++ b/src/datasource/base.test.ts @@ -3,7 +3,13 @@ import { from } from 'rxjs'; import { addAddHocFilter } from '../modifyQuery'; import { ElasticsearchQuery } from '../types'; -import { BaseQuickwitDataSource, formatQuery, luceneEscape } from './base'; +import { + BaseQuickwitDataSource, + formatQuery, + luceneEscape, + parseFilterAutocompleteChainMode, + parseFilterAutocompleteLimit, +} from './base'; describe('BaseQuickwitDataSource', () => { describe('luceneEscape', () => { @@ -29,153 +35,164 @@ describe('BaseQuickwitDataSource', () => { }); describe('formatQuery', () => { + describe('String values', () => { + it('should return escaped string for simple string values', () => { + const result = formatQuery('simple_value', { id: 'test_var' }); + expect(result).toBe('simple_value'); + }); - describe('String values', () => { - it('should return escaped string for simple string values', () => { - const result = formatQuery('simple_value', { id: 'test_var' }); - expect(result).toBe('simple_value'); - }); + it('should escape special characters in string values', () => { + const result = formatQuery('value+with-special:chars', { id: 'test_var' }); + expect(result).toBe('value\\+with\\-special\\:chars'); + }); - it('should escape special characters in string values', () => { - const result = formatQuery('value+with-special:chars', { id: 'test_var' }); - expect(result).toBe('value\\+with\\-special\\:chars'); + it('should not escape numeric strings', () => { + const result = formatQuery('123', { id: 'test_var' }); + expect(result).toBe('123'); + }); }); - it('should not escape numeric strings', () => { - const result = formatQuery('123', { id: 'test_var' }); - expect(result).toBe('123'); - }); - }); + describe('Array values with valid field configuration', () => { + const validVariable = { + id: 'test_var', + query: '{"field": "status"}' + }; - describe('Array values with valid field configuration', () => { - const validVariable = { - id: 'test_var', - query: '{"field": "status"}' - }; + it('should format array values with field-specific OR syntax', () => { + const result = formatQuery(['error', 'warning'], validVariable); + expect(result).toBe('"error" OR status:"warning"'); + }); - it('should format array values with field-specific OR syntax', () => { - const result = formatQuery(['error', 'warning'], validVariable); - expect(result).toBe('"error" OR status:"warning"'); - }); + it('should handle single-item arrays', () => { + const result = formatQuery(['error'], validVariable); + expect(result).toBe('"error"'); + }); - it('should handle single-item arrays', () => { - const result = formatQuery(['error'], validVariable); - expect(result).toBe('"error"'); + it('should escape special characters in array values', () => { + const result = formatQuery(['error+critical', 'warning:high'], validVariable); + expect(result).toBe('"error\\+critical" OR status:"warning\\:high"'); + }); }); - it('should escape special characters in array values', () => { - const result = formatQuery(['error+critical', 'warning:high'], validVariable); - expect(result).toBe('"error\\+critical" OR status:"warning\\:high"'); - }); - }); + describe('Array values without field configuration', () => { + it('should use OR syntax when variable.query is undefined', () => { + const variable = { id: 'test_var' }; + const result = formatQuery(['error', 'warning'], variable); + expect(result).toBe('"error" OR "warning"'); + }); - describe('Array values without field configuration', () => { - it('should use IN syntax when variable.query is undefined', () => { - const variable = { id: 'test_var' }; - const result = formatQuery(['error', 'warning'], variable); - expect(result).toBe('IN ["error" "warning"]'); - }); + it('should use OR syntax when variable.query is null', () => { + const variable = { id: 'test_var', query: null }; + const result = formatQuery(['error', 'warning'], variable); + expect(result).toBe('"error" OR "warning"'); + }); - it('should use IN syntax when variable.query is null', () => { - const variable = { id: 'test_var', query: null }; - const result = formatQuery(['error', 'warning'], variable); - expect(result).toBe('IN ["error" "warning"]'); - }); + it('should use OR syntax when variable.query contains invalid JSON', () => { + const variable = { id: 'test_var', query: 'not valid json' }; + const result = formatQuery(['error', 'warning'], variable); + expect(result).toBe('"error" OR "warning"'); + }); - it('should use IN syntax when variable.query contains invalid JSON', () => { - const variable = { id: 'test_var', query: 'not valid json' }; - const result = formatQuery(['error', 'warning'], variable); - expect(result).toBe('IN ["error" "warning"]'); - }); + it('should use OR syntax when variable.query is valid JSON but missing field', () => { + const variable = { id: 'test_var', query: '{"other": "value"}' }; + const result = formatQuery(['error', 'warning'], variable); + expect(result).toBe('"error" OR "warning"'); + }); - it('should use IN syntax when variable.query is valid JSON but missing field', () => { - const variable = { id: 'test_var', query: '{"other": "value"}' }; - const result = formatQuery(['error', 'warning'], variable); - expect(result).toBe('IN ["error" "warning"]'); - }); + it('should use OR syntax when field is not a string', () => { + const variable = { id: 'test_var', query: '{"field": 123}' }; + const result = formatQuery(['error', 'warning'], variable); + expect(result).toBe('"error" OR "warning"'); + }); - it('should use IN syntax when field is not a string', () => { - const variable = { id: 'test_var', query: '{"field": 123}' }; - const result = formatQuery(['error', 'warning'], variable); - expect(result).toBe('IN ["error" "warning"]'); - }); - }); + it('should infer field-specific OR syntax from the query string', () => { + const variable = { id: 'levels' }; + const result = formatQuery(['error', 'warning'], variable, 'severity_text:$levels'); + expect(result).toBe('"error" OR severity_text:"warning"'); + }); - describe('Empty arrays', () => { - it('should return __empty__ for empty arrays', () => { - const variable = { id: 'test_var', query: '{"field": "status"}' }; - const result = formatQuery([], variable); - expect(result).toBe('__empty__'); + it('should infer field-specific OR syntax from braced variables in the query string', () => { + const variable = { id: 'services' }; + const result = formatQuery(['web', 'api'], variable, 'resource_attributes.service.name:${services}'); + expect(result).toBe('"web" OR resource_attributes.service.name:"api"'); + }); }); - it('should return __empty__ for empty arrays even without field config', () => { - const variable = { id: 'test_var' }; - const result = formatQuery([], variable); - expect(result).toBe('__empty__'); - }); - }); + describe('Empty arrays', () => { + it('should return __empty__ for empty arrays', () => { + const variable = { id: 'test_var', query: '{"field": "status"}' }; + const result = formatQuery([], variable); + expect(result).toBe('__empty__'); + }); - describe('Error handling and robustness', () => { - it('should not throw when variable.query is undefined', () => { - expect(() => { - formatQuery(['test'], { id: 'test_var' }); - }).not.toThrow(); + it('should return __empty__ for empty arrays even without field config', () => { + const variable = { id: 'test_var' }; + const result = formatQuery([], variable); + expect(result).toBe('__empty__'); + }); }); - it('should not throw when variable.query is malformed JSON', () => { - expect(() => { - formatQuery(['test'], { id: 'test_var', query: '{invalid json}' }); - }).not.toThrow(); - }); + describe('Error handling and robustness', () => { + it('should not throw when variable.query is undefined', () => { + expect(() => { + formatQuery(['test'], { id: 'test_var' }); + }).not.toThrow(); + }); - it('should not throw when variable.query is empty string', () => { - expect(() => { - formatQuery(['test'], { id: 'test_var', query: '' }); - }).not.toThrow(); - }); + it('should not throw when variable.query is malformed JSON', () => { + expect(() => { + formatQuery(['test'], { id: 'test_var', query: '{invalid json}' }); + }).not.toThrow(); + }); - it('should handle non-string, non-array values', () => { - const result = formatQuery(123 as any, { id: 'test_var' }); - expect(result).toBe('123'); - }); + it('should not throw when variable.query is empty string', () => { + expect(() => { + formatQuery(['test'], { id: 'test_var', query: '' }); + }).not.toThrow(); + }); - it('should handle boolean values', () => { - const result = formatQuery(true as any, { id: 'test_var' }); - expect(result).toBe('true'); - }); - }); + it('should handle non-string, non-array values', () => { + const result = formatQuery(123 as any, { id: 'test_var' }); + expect(result).toBe('123'); + }); - describe('Real-world scenarios', () => { - it('should handle variables from template variable queries', () => { - // Simulates a properly configured template variable - const templateVariable = { - id: 'log_level', - query: '{"field": "level"}' - }; - const result = formatQuery(['ERROR', 'WARN', 'INFO'], templateVariable); - expect(result).toBe('"ERROR" OR level:"WARN" OR level:"INFO"'); + it('should handle boolean values', () => { + const result = formatQuery(true as any, { id: 'test_var' }); + expect(result).toBe('true'); + }); }); - it('should handle variables without query configuration (legacy/simple variables)', () => { - // Simulates a simple template variable without field configuration - const simpleVariable = { - id: 'service_names' - }; - const result = formatQuery(['web-service', 'api-service'], simpleVariable); - expect(result).toBe('IN ["web\\-service" "api\\-service"]'); - }); + describe('Real-world scenarios', () => { + it('should handle variables from template variable queries', () => { + // Simulates a properly configured template variable + const templateVariable = { + id: 'log_level', + query: '{"field": "level"}' + }; + const result = formatQuery(['ERROR', 'WARN', 'INFO'], templateVariable); + expect(result).toBe('"ERROR" OR level:"WARN" OR level:"INFO"'); + }); - it('should handle variables with corrupted configuration', () => { - // Simulates a variable with corrupted/invalid configuration - const corruptedVariable = { - id: 'corrupted_var', - query: '{"field": undefined}' // Invalid JSON that might come from UI bugs - }; - const result = formatQuery(['value1', 'value2'], corruptedVariable); - expect(result).toBe('IN ["value1" "value2"]'); + it('should handle variables without query configuration (legacy/simple variables)', () => { + // Simulates a simple template variable without field configuration + const simpleVariable = { + id: 'service_names' + }; + const result = formatQuery(['web-service', 'api-service'], simpleVariable); + expect(result).toBe('"web\\-service" OR "api\\-service"'); + }); + + it('should handle variables with corrupted configuration', () => { + // Simulates a variable with corrupted/invalid configuration + const corruptedVariable = { + id: 'corrupted_var', + query: '{"field": undefined}' // Invalid JSON that might come from UI bugs + }; + const result = formatQuery(['value1', 'value2'], corruptedVariable); + expect(result).toBe('"value1" OR "value2"'); + }); }); }); - }); describe('quick filters', () => { const addFilterToQuery = ( @@ -186,7 +203,7 @@ describe('BaseQuickwitDataSource', () => { negate = false ) => { return (BaseQuickwitDataSource.prototype as any).addFilterToQuery.call( - { fieldTypes }, + Object.assign(Object.create(BaseQuickwitDataSource.prototype), { fieldTypes }), query, key, value, @@ -205,6 +222,9 @@ describe('BaseQuickwitDataSource', () => { ) as string; }; + const datasourceContext = (overrides: Record) => + Object.assign(Object.create(BaseQuickwitDataSource.prototype), overrides); + it('adds text filters with whitespace as phrase filters', () => { const query = { refId: 'A', query: '', metrics: [], bucketAggs: [], filters: [] } as any; @@ -261,6 +281,79 @@ describe('BaseQuickwitDataSource', () => { expect(updatedQuery.filters?.[0].filter.operator).toBe('term'); }); + it('reuses trailing empty filters without mutating the original query', () => { + const query = { + refId: 'A', + query: '', + metrics: [], + bucketAggs: [], + filters: [{ id: 'empty', filter: { key: '', operator: '=', value: '' } }], + } as any; + + const updatedQuery = addFilterToQuery( + { service_name: 'text' }, + query, + 'service_name', + 'frontend' + ); + + expect(updatedQuery).not.toBe(query); + expect(updatedQuery.filters?.[0]).toEqual({ + id: 'empty', + hide: false, + filter: { key: 'service_name', operator: 'term', value: 'frontend' }, + }); + expect(query.filters[0].filter).toEqual({ key: '', operator: '=', value: '' }); + }); + + it('adds quick filters when filters are undefined', () => { + const query = { refId: 'A', query: '', metrics: [], bucketAggs: [] } as any; + + const updatedQuery = addFilterToQuery( + { service_name: 'text' }, + query, + 'service_name', + 'frontend' + ); + + expect(updatedQuery.filters).toEqual([ + expect.objectContaining({ + hide: false, + filter: { key: 'service_name', operator: 'term', value: 'frontend' }, + }), + ]); + }); + + it('keeps JSON array text filters as equality filters', () => { + const query = { refId: 'A', query: '', metrics: [], bucketAggs: [], filters: [] } as any; + + const updatedQuery = addFilterToQuery( + { 'attributes.tags': 'text' }, + query, + 'attributes.tags', + '["paperclip"]' + ); + + expect(updatedQuery.filters?.[0].filter).toEqual({ + key: 'attributes.tags', + operator: '=', + value: '["paperclip"]', + }); + }); + + it('renders JSON array text filters with Quickwit array syntax', () => { + const result = renderAdHocFilters( + { 'attributes.tags': 'text' }, + [{ + key: 'attributes.tags', + operator: '=', + value: '["paperclip","stapler"]', + }] + ); + + expect(result).toBe('attributes.tags:IN ["paperclip" "stapler"]'); + }); + it('keeps punctuated text filters as phrase filters', () => { const query = { refId: 'A', query: '', metrics: [], bucketAggs: [], filters: [] } as any; @@ -301,15 +394,98 @@ describe('BaseQuickwitDataSource', () => { expect(result).toBe('attributes.grpc_message:error\\:foo'); }); + it('toggles off matching quick filters by operator', () => { + const query = { + refId: 'A', + query: '', + metrics: [], + bucketAggs: [], + filters: [{ id: 'existing', filter: { key: 'service_name', operator: 'term', value: 'frontend' } }], + } as any; + + const updatedQuery = (BaseQuickwitDataSource.prototype as any).toggleQueryFilter.call( + datasourceContext({ fieldTypes: { service_name: 'text' } }), + query, + { type: 'FILTER_FOR', options: { key: 'service_name', value: 'frontend' } } + ); + + expect(updatedQuery.filters).toEqual([{ id: expect.any(String), filter: { key: '', operator: '=', value: '' } }]); + }); + + it('replaces opposite quick filters instead of adding duplicates', () => { + const query = { + refId: 'A', + query: '', + metrics: [], + bucketAggs: [], + filters: [{ id: 'existing', filter: { key: 'service_name', operator: 'not term', value: 'frontend' } }], + } as any; + + const updatedQuery = (BaseQuickwitDataSource.prototype as any).toggleQueryFilter.call( + datasourceContext({ fieldTypes: { service_name: 'text' } }), + query, + { type: 'FILTER_FOR', options: { key: 'service_name', value: 'frontend' } } + ); + + expect(updatedQuery.filters).toEqual([ + { id: 'existing', hide: false, filter: { key: 'service_name', operator: 'term', value: 'frontend' } }, + ]); + }); + + it('can check quick filters by operator when Grafana provides filter direction', () => { + const query = { + refId: 'A', + query: '', + metrics: [], + bucketAggs: [], + filters: [{ id: 'existing', filter: { key: 'service_name', operator: 'not term', value: 'frontend' } }], + } as any; + const datasource = datasourceContext({ fieldTypes: { service_name: 'text' } }); + + expect((BaseQuickwitDataSource.prototype as any).queryHasFilter.call( + datasource, + query, + { key: 'service_name', value: 'frontend', type: 'FILTER_FOR' } + )).toBe(false); + expect((BaseQuickwitDataSource.prototype as any).queryHasFilter.call( + datasource, + query, + { key: 'service_name', value: 'frontend', type: 'FILTER_OUT' } + )).toBe(true); + }); + it('applies prior filters when loading tag values', async () => { const getTerms = jest.fn(() => from([[]])); await (BaseQuickwitDataSource.prototype as any).getTagValues.call( + datasourceContext({ + fieldTypes: {}, + filterAutocompleteLimit: 1000, + filterAutocompleteUseFilterChains: true, + getTerms, + }), { + key: 'status', + filters: [{ key: 'service', operator: '=', value: 'frontend' }], + } + ); + + expect(getTerms).toHaveBeenCalledWith( + { field: 'status', query: 'service:"frontend"', size: 1000 }, + undefined + ); + }); + + it('uses the datasource autocomplete limit when loading tag values', async () => { + const getTerms = jest.fn(() => from([[]])); + + await (BaseQuickwitDataSource.prototype as any).getTagValues.call( + datasourceContext({ fieldTypes: {}, - addAdHocFilters: BaseQuickwitDataSource.prototype.addAdHocFilters, + filterAutocompleteLimit: 250, + filterAutocompleteUseFilterChains: true, getTerms, - }, + }), { key: 'status', filters: [{ key: 'service', operator: '=', value: 'frontend' }], @@ -317,9 +493,216 @@ describe('BaseQuickwitDataSource', () => { ); expect(getTerms).toHaveBeenCalledWith( - { field: 'status', query: 'service:"frontend"' }, + { field: 'status', query: 'service:"frontend"', size: 250 }, undefined ); }); + + it('can disable filter chains for tag values', async () => { + const getTerms = jest.fn(() => from([[]])); + + await (BaseQuickwitDataSource.prototype as any).getTagValues.call( + datasourceContext({ + fieldTypes: {}, + filterAutocompleteLimit: 1000, + filterAutocompleteUseFilterChains: false, + getTerms, + }), + { + key: 'status', + filters: [{ key: 'service', operator: '=', value: 'frontend' }], + } + ); + + expect(getTerms).toHaveBeenCalledWith( + { field: 'status', query: '', size: 1000 }, + undefined + ); + }); + + it('derives chained tag keys from fields present in matching documents', async () => { + const getFields = jest.fn(() => + from([[ + { text: 'service', type: 'string' }, + { text: 'status', type: 'string' }, + { text: 'missing', type: 'string' }, + ]]) + ); + const query = jest.fn(() => + from([{ + data: [ + { + fields: [ + { name: 'service' }, + { name: 'status' }, + { name: 'sort' }, + ], + }, + ], + }]) + ); + + const result = await (BaseQuickwitDataSource.prototype as any).getTagKeys.call( + datasourceContext({ + fieldTypes: {}, + filterAutocompleteLimit: 5, + filterAutocompleteUseFilterChains: true, + getFields, + query, + }), + { + filters: [{ key: 'service', operator: '=', value: 'frontend' }], + } + ); + + expect(result).toEqual([ + { text: 'service', type: 'string' }, + { text: 'status', type: 'string' }, + ]); + expect(query).toHaveBeenCalledWith( + expect.objectContaining({ + requestId: expect.stringMatching(/^getFilterKeys-/), + targets: [ + expect.objectContaining({ + query: 'service:"frontend"', + metrics: [{ id: 'filterKeys', type: 'raw_data', settings: { size: '5' } }], + }), + ], + }) + ); + }); + + it('derives full chained tag keys from every matching document', async () => { + const getFields = jest.fn(() => + from([[ + { text: 'service', type: 'string' }, + { text: 'status', type: 'string' }, + { text: 'rare.field', type: 'string' }, + { text: 'missing', type: 'string' }, + ]]) + ); + const postResource = jest.fn() + .mockResolvedValueOnce({ + num_hits: 2, + hits: [{ service: 'frontend', status: 'ok' }], + }) + .mockResolvedValueOnce({ + num_hits: 2, + hits: [{ service: 'frontend', rare: { field: 'present' } }], + }); + + const result = await (BaseQuickwitDataSource.prototype as any).getTagKeys.call( + datasourceContext({ + fieldTypes: {}, + filterAutocompleteChainMode: 'full', + getFields, + index: 'logs', + postResource, + }), + { + filters: [{ key: 'service', operator: '=', value: 'frontend' }], + } + ); + + expect(result).toEqual([ + { text: 'service', type: 'string' }, + { text: 'status', type: 'string' }, + { text: 'rare.field', type: 'string' }, + ]); + expect(postResource).toHaveBeenCalledTimes(2); + expect(postResource).toHaveBeenNthCalledWith( + 1, + 'indexes/logs/search', + expect.objectContaining({ + query: 'service:"frontend"', + max_hits: 1000, + start_offset: 0, + }), + expect.objectContaining({ requestId: expect.stringMatching(/^getFilterKeysFull-/) }) + ); + expect(postResource).toHaveBeenNthCalledWith( + 2, + 'indexes/logs/search', + expect.objectContaining({ + query: 'service:"frontend"', + max_hits: 1000, + start_offset: 1, + }), + expect.objectContaining({ requestId: expect.stringMatching(/^getFilterKeysFull-/) }) + ); + }); + + it('deduplicates tag keys that have multiple field capabilities', async () => { + const getFields = jest.fn(() => + from([[ + { text: 'service_name', type: 'string' }, + { text: 'service_name', type: 'keyword' }, + { text: 'severity_text', type: 'string' }, + ]]) + ); + + const result = await (BaseQuickwitDataSource.prototype as any).getTagKeys.call( + datasourceContext({ + filterAutocompleteUseFilterChains: true, + getFields, + }), + {} + ); + + expect(result).toEqual([ + { text: 'service_name', type: 'string' }, + { text: 'severity_text', type: 'string' }, + ]); + }); + + it('can disable filter chains for tag keys', async () => { + const getFields = jest.fn(() => from([[{ text: 'service', type: 'string' }]])); + const query = jest.fn(); + + const result = await (BaseQuickwitDataSource.prototype as any).getTagKeys.call( + datasourceContext({ + filterAutocompleteUseFilterChains: false, + getFields, + query, + }), + { + filters: [{ key: 'service', operator: '=', value: 'frontend' }], + } + ); + + expect(result).toEqual([{ text: 'service', type: 'string' }]); + expect(query).not.toHaveBeenCalled(); + }); + }); + + describe('filter autocomplete limit', () => { + it('defaults invalid and empty values to 1000', () => { + expect(parseFilterAutocompleteLimit(undefined)).toBe(1000); + expect(parseFilterAutocompleteLimit('')).toBe(1000); + expect(parseFilterAutocompleteLimit('invalid')).toBe(1000); + expect(parseFilterAutocompleteLimit('-1')).toBe(1000); + }); + + it('accepts positive values and zero', () => { + expect(parseFilterAutocompleteLimit('250')).toBe(250); + expect(parseFilterAutocompleteLimit('0')).toBe(0); + }); + }); + + describe('filter autocomplete chain mode', () => { + it('defaults missing and invalid modes to sample', () => { + expect(parseFilterAutocompleteChainMode(undefined)).toBe('sample'); + expect(parseFilterAutocompleteChainMode('invalid')).toBe('sample'); + }); + + it('accepts supported modes', () => { + expect(parseFilterAutocompleteChainMode('none')).toBe('none'); + expect(parseFilterAutocompleteChainMode('sample')).toBe('sample'); + expect(parseFilterAutocompleteChainMode('full')).toBe('full'); + }); + + it('maps legacy disabled filter chains to none', () => { + expect(parseFilterAutocompleteChainMode(undefined, false)).toBe('none'); + }); }); }); diff --git a/src/datasource/base.ts b/src/datasource/base.ts index f91446d..bce19df 100644 --- a/src/datasource/base.ts +++ b/src/datasource/base.ts @@ -21,7 +21,7 @@ import { DataSourceWithBackend, getTemplateSrv, TemplateSrv } from '@grafana/runtime'; -import { QuickwitOptions } from 'quickwit'; +import { FilterAutocompleteChainMode, QuickwitOptions } from 'quickwit'; import { getDataQuery } from 'QueryBuilder/elastic'; import { metricAggregationConfig } from 'components/QueryEditor/MetricAggregationsEditor/utils'; @@ -41,7 +41,34 @@ import { isSet } from '@/utils'; export type BaseQuickwitDataSourceConstructor = GConstructor -const getQueryUid = uidMaker("query") +const getQueryUid = uidMaker('query'); +export const DEFAULT_FILTER_AUTOCOMPLETE_LIMIT = 1000; +const DEFAULT_FILTER_AUTOCOMPLETE_CHAIN_MODE: FilterAutocompleteChainMode = 'sample'; +const FULL_FILTER_CHAIN_PAGE_SIZE = 1000; + +export function parseFilterAutocompleteLimit(value: unknown): number { + if (value === undefined || value === null || value === '') { + return DEFAULT_FILTER_AUTOCOMPLETE_LIMIT; + } + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + return DEFAULT_FILTER_AUTOCOMPLETE_LIMIT; + } + return Math.floor(parsed); +} + +export function parseFilterAutocompleteChainMode( + value: unknown, + legacyUseFilterChains?: boolean +): FilterAutocompleteChainMode { + if (value === 'none' || value === 'sample' || value === 'full') { + return value; + } + if (legacyUseFilterChains === false) { + return 'none'; + } + return DEFAULT_FILTER_AUTOCOMPLETE_CHAIN_MODE; +} type FieldCapsSpec = { aggregatable?: boolean, @@ -50,6 +77,11 @@ type FieldCapsSpec = { range?: TimeRange } +type QuickwitSearchResponse = { + num_hits?: number, + hits?: Array>, +} + export class BaseQuickwitDataSource extends DataSourceWithBackend implements @@ -63,6 +95,8 @@ export class BaseQuickwitDataSource queryEditorConfig?: { defaults?: DefaultsConfigOverrides }; + filterAutocompleteLimit: number; + filterAutocompleteChainMode: FilterAutocompleteChainMode; languageProvider: ElasticsearchLanguageProvider; // Populated lazily by getFields(). Used by modifyQuery to pick an operator // that matches text field semantics. @@ -85,6 +119,11 @@ export class BaseQuickwitDataSource this.logLevelField = settingsData.logLevelField || ''; this.dataLinks = settingsData.dataLinks || []; this.queryEditorConfig = settingsData.queryEditorConfig || {}; + this.filterAutocompleteLimit = parseFilterAutocompleteLimit(settingsData.filterAutocompleteLimit); + this.filterAutocompleteChainMode = parseFilterAutocompleteChainMode( + settingsData.filterAutocompleteChainMode, + settingsData.filterAutocompleteUseFilterChains + ); this.languageProvider = new ElasticsearchLanguageProvider(this); this.annotations = {}; @@ -154,66 +193,111 @@ export class BaseQuickwitDataSource const key = filter.options.key; const rawValue = String(filter.options.value ?? ''); const negate = filter.type === 'FILTER_OUT'; - - // If the same (key, value) filter is already present, toggle it off. - const existingIdx = (query.filters ?? []).findIndex( - (f) => f.filter.key === key && f.filter.value === rawValue + const operator = this.getFilterOperator(key, rawValue, negate); + const oppositeOperator = this.getFilterOperator(key, rawValue, !negate); + const emptyFilter = () => [{ id: newFilterId(), filter: { key: '', operator: '=', value: '' } }]; + const filters = query.filters ?? []; + + // If the same filter is already present, toggle it off. + const existingIdx = filters.findIndex( + (f) => !f.hide && f.filter.key === key && f.filter.value === rawValue && f.filter.operator === operator ); if (existingIdx !== -1) { - const next = [...(query.filters ?? [])]; + const next = [...filters]; next.splice(existingIdx, 1); - return { ...query, filters: next.length ? next : [{ id: newFilterId(), filter: { key: '', operator: '=', value: '' } }] }; + return { ...query, filters: next.length ? next : emptyFilter() }; } + + // If the opposite filter is present, replace it. + const oppositeIdx = filters.findIndex( + (f) => !f.hide && f.filter.key === key && f.filter.value === rawValue && f.filter.operator === oppositeOperator + ); + if (oppositeIdx !== -1) { + return { + ...query, + filters: filters.map((existing, index) => + index === oppositeIdx + ? { ...existing, hide: false, filter: { key, operator, value: rawValue } } + : existing + ), + }; + } + return this.addFilterToQuery(query, key, rawValue, negate); } - queryHasFilter(query: ElasticsearchQuery, filter: { key: string; value: string }): boolean { + queryHasFilter(query: ElasticsearchQuery, filter: { key: string; value: string; operator?: string; type?: string }): boolean { + const expectedOperator = filter.operator || ( + filter.type === 'FILTER_FOR' || filter.type === 'FILTER_OUT' + ? this.getFilterOperator(filter.key, String(filter.value ?? ''), filter.type === 'FILTER_OUT') + : undefined + ); return (query.filters ?? []).some( - (f) => f.filter.key === filter.key && f.filter.value === filter.value && !f.hide + (f) => + f.filter.key === filter.key && + f.filter.value === filter.value && + !f.hide && + (expectedOperator === undefined || f.filter.operator === expectedOperator) ); } - private addFilterToQuery(query: ElasticsearchQuery, key: string, rawValue: string, negate: boolean): ElasticsearchQuery { + private getFilterOperator(key: string, value: string, negate: boolean): string { const fieldType = this.fieldTypes[key]; const isText = fieldType === 'text'; - const value = rawValue; const useTermOperator = isText && value !== '' && isSimpleToken(value); - const operator = useTermOperator + return useTermOperator ? (negate ? 'not term' : 'term') : (negate ? '!=' : '='); + } + + private addFilterToQuery(query: ElasticsearchQuery, key: string, rawValue: string, negate: boolean): ElasticsearchQuery { + const operator = this.getFilterOperator(key, rawValue, negate); + const filters = query.filters ?? []; + const nextFilter = { key, operator, value: rawValue }; // If the user hasn't populated any filter yet, reuse the trailing empty one. - const len = query.filters?.length ?? 0; + const len = filters.length; if (len > 0) { - const last = query.filters![len - 1]; + const last = filters[len - 1]; if (!isSet(last.filter.key) && !isSet(last.filter.value)) { - last.filter.key = key; - last.filter.operator = operator; - last.filter.value = value; - return query; + return { + ...query, + filters: filters.map((filter, index) => + index === len - 1 ? { ...filter, hide: false, filter: nextFilter } : filter + ), + }; } } - query.filters?.push({ - id: newFilterId(), - hide: false, - filter: { key, operator, value }, - }); - return { ...query }; + return { + ...query, + filters: [ + ...filters, + { + id: newFilterId(), + hide: false, + filter: nextFilter, + }, + ], + }; } getDataQueryRequest(queryDef: TermsQuery, range: TimeRange, requestId?: string) { let dataQuery = getDataQuery(queryDef, 'getTerms'); + return this.getRequestForQuery(dataQuery, range, requestId); + } + + private getRequestForQuery(query: ElasticsearchQuery, range: TimeRange, requestId?: string) { const request: DataQueryRequest = { app: CoreApp.Unknown, requestId: requestId || getQueryUid.next(), interval: '', intervalMs: 0, range, - targets:[dataQuery], - timezone:'browser', - scopedVars:{}, + targets: [query], + timezone: 'browser', + scopedVars: {}, startTime: Date.now(), } return request @@ -294,17 +378,135 @@ export class BaseQuickwitDataSource * Get tag keys for adhoc filters */ getTagKeys(options: any = {}) { - const fields = this.getFields({aggregatable:true, range: options.timeRange}) - return lastValueFrom(fields, {defaultValue:[]}); + const fieldSpec = this.getTagKeyFieldSpec(options); + const filters = this.getFilterChain(options.filters); + if (!filters?.length) { + return lastValueFrom(this.getFields(fieldSpec), { defaultValue: [] }).then((fields) => this.dedupeTagKeys(fields)); + } + if (this.getFilterAutocompleteChainMode() === 'full') { + return this.getFullFilteredTagKeys(filters, fieldSpec); + } + return this.getSampledFilteredTagKeys(filters, fieldSpec); } /** * Get tag values for adhoc filters */ getTagValues(options: any) { - const query = this.addAdHocFilters('', options.filters); - const terms = this.getTerms({ field: options.key, query }, options.timeRange) - return lastValueFrom(terms, {defaultValue:[]}); + const query = this.addAdHocFilters('', this.getFilterChain(options.filters)); + const terms = this.getTerms({ field: options.key, query, size: this.filterAutocompleteLimit }, options.timeRange); + return lastValueFrom(terms, { defaultValue: [] }); + } + + private getFilterChain(filters?: AdHocVariableFilter[]) { + return this.getFilterAutocompleteChainMode() === 'none' ? undefined : filters; + } + + private getFilterAutocompleteChainMode() { + return parseFilterAutocompleteChainMode( + this.filterAutocompleteChainMode, + (this as { filterAutocompleteUseFilterChains?: boolean }).filterAutocompleteUseFilterChains + ); + } + + private getTagKeyFieldSpec(options: any = {}): FieldCapsSpec { + const spec: FieldCapsSpec = { range: options.timeRange }; + if (options.aggregatable !== undefined) { + spec.aggregatable = options.aggregatable; + } else if (options.searchable === undefined) { + spec.aggregatable = true; + } + if (options.searchable !== undefined) { + spec.searchable = options.searchable; + } + if (options.type !== undefined) { + spec.type = options.type; + } + return spec; + } + + private dedupeTagKeys(fields: MetricFindValue[]) { + const seen = new Set(); + return fields.filter((field) => { + const name = String(field.text); + if (seen.has(name)) { + return false; + } + seen.add(name); + return true; + }); + } + + private async getSampledFilteredTagKeys(filters: AdHocVariableFilter[], fieldSpec: FieldCapsSpec) { + const query = this.addAdHocFilters('', filters); + // Field-capabilities are schema-wide. To approximate chained field-key + // suggestions, sample matching documents and keep fields present in that + // sample. A limit of 0 means "no terms limit" elsewhere, but raw-document + // sampling still needs a finite size. + const sampleLimit = this.filterAutocompleteLimit > 0 ? this.filterAutocompleteLimit : DEFAULT_FILTER_AUTOCOMPLETE_LIMIT; + const target: ElasticsearchQuery = { + refId: 'filterKeys', + query, + metrics: [{ id: 'filterKeys', type: 'raw_data', settings: { size: sampleLimit.toString() } }], + bucketAggs: [], + filters: [], + }; + const range = fieldSpec.range || getDefaultTimeRange(); + const [fields, response] = await Promise.all([ + lastValueFrom(this.getFields(fieldSpec), { defaultValue: [] }), + lastValueFrom(this.query(this.getRequestForQuery(target, range, `getFilterKeys-${getQueryUid.next()}`)), { + defaultValue: { data: [] }, + }), + ]); + const presentFields = new Set(); + response.data?.forEach((frame: DataFrame) => { + frame.fields?.forEach((field: { name?: string }) => { + if (field.name && field.name !== 'sort') { + presentFields.add(field.name); + } + }); + }); + return this.dedupeTagKeys(fields).filter((field) => presentFields.has(String(field.text))); + } + + private async getFullFilteredTagKeys(filters: AdHocVariableFilter[], fieldSpec: FieldCapsSpec) { + const query = this.addAdHocFilters('', filters); + const range = fieldSpec.range || getDefaultTimeRange(); + const [fields, presentFields] = await Promise.all([ + lastValueFrom(this.getFields(fieldSpec), { defaultValue: [] }), + this.getAllMatchingFieldNames(query, range), + ]); + return this.dedupeTagKeys(fields).filter((field) => presentFields.has(String(field.text))); + } + + private async getAllMatchingFieldNames(query: string, range: TimeRange) { + const presentFields = new Set(); + let offset = 0; + let totalHits: number | undefined; + + do { + const response = await this.postResource( + `indexes/${this.index}/search`, + { + query: query || '*', + max_hits: FULL_FILTER_CHAIN_PAGE_SIZE, + start_offset: offset, + start_timestamp: Math.floor(range.from.valueOf() / SECOND), + end_timestamp: Math.ceil(range.to.valueOf() / SECOND), + }, + { requestId: `getFilterKeysFull-${getQueryUid.next()}` } + ); + const hits = response.hits ?? []; + totalHits = response.num_hits ?? totalHits ?? hits.length; + + hits.forEach((hit) => collectFieldNames(hit, presentFields)); + offset += hits.length; + if (hits.length === 0) { + break; + } + } while (totalHits === undefined || offset < totalHits); + + return presentFields; } /** @@ -388,7 +590,9 @@ export class BaseQuickwitDataSource } interpolateLuceneQuery(queryString: string, scopedVars?: ScopedVars) { - return this.templateSrv.replace(queryString, scopedVars, formatQuery); + return this.templateSrv.replace(queryString, scopedVars, (value: string | string[], variable: any) => + formatQuery(value, variable, queryString) + ); } interpolateVariablesInQueries(queries: ElasticsearchQuery[], scopedVars: ScopedVars | {}, filters?: AdHocVariableFilter[]): ElasticsearchQuery[] { @@ -475,7 +679,50 @@ export class BaseQuickwitDataSource return finalQuery; } } -export function formatQuery(value: string | string[], variable: any): string { + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function collectFieldNames(value: unknown, fields: Set, prefix = '') { + if (Array.isArray(value)) { + value.forEach((item) => collectFieldNames(item, fields, prefix)); + return; + } + if (!isRecord(value)) { + if (prefix) { + fields.add(prefix); + } + return; + } + + Object.entries(value).forEach(([key, nested]) => { + const fieldName = prefix ? `${prefix}.${key}` : key; + collectFieldNames(nested, fields, fieldName); + }); +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function inferFieldNameFromQuery(queryString: string | undefined, variable: any) { + const names = [variable?.id, variable?.name].filter((name): name is string => typeof name === 'string' && name !== ''); + if (!queryString || names.length === 0) { + return undefined; + } + for (const name of names) { + const escapedName = escapeRegExp(name); + const pattern = new RegExp(`(?:^|[\\s(])([^\\s:()]+):(\\$\\{${escapedName}\\}|\\$${escapedName}|\\[\\[${escapedName}\\]\\])`); + const match = queryString.match(pattern); + if (match?.[1]) { + return match[1]; + } + } + return undefined; +} + +export function formatQuery(value: string | string[], variable: any, queryString?: string): string { if (typeof value === 'string') { return luceneEscape(value); } @@ -489,19 +736,17 @@ export function formatQuery(value: string | string[], variable: any): string { } catch (e) { fieldName = undefined; } + fieldName = typeof fieldName === 'string' ? fieldName : inferFieldNameFromQuery(queryString, variable); const quotedValues = value.map((val) => '"' + luceneEscape(val) + '"'); // Quickwit query language does not support fieldName:(value1 OR value2 OR....) // like lucene does. // When we know the fieldName, we can directly generate a query // fieldName:value1 OR fieldName:value2 OR ... - // But when we don't know the fieldName, the simplest is to generate a query - // with the IN operator. Unfortunately, IN operator does not work on JSON field. - // TODO: fix that by using doing a regex on queryString to find the fieldName. - // Note that variable.id gives the name of the template variable to interpolate, - // so if we have `fieldName:${variable.id}` in the queryString, we can isolate - // the fieldName. + // If the variable query does not carry a field, infer it from common + // `field:$var` query strings. Without a field, fall back to default-search + // clauses instead of a bare IN set, which is easy to misread as field-scoped. if (typeof fieldName !== 'string') { - return 'IN [' + quotedValues.join(' ') + ']'; + return quotedValues.join(' OR '); } return quotedValues.join(' OR ' + fieldName + ':'); } else { diff --git a/src/modifyQuery.test.ts b/src/modifyQuery.test.ts index 85da34b..aa4772d 100644 --- a/src/modifyQuery.test.ts +++ b/src/modifyQuery.test.ts @@ -74,6 +74,60 @@ describe('addAddHocFilter', () => { expect(result).toBe('attributes.tags:IN ["foo bar" "baz qux"]'); }); + it('handles single-element numeric arrays', () => { + const result = addAddHocFilter('', { + key: 'attributes.codes', + operator: '=', + value: '[200]', + }); + expect(result).toBe('attributes.codes:200'); + }); + + it('handles multi-element numeric arrays with IN set query', () => { + const result = addAddHocFilter('', { + key: 'attributes.codes', + operator: '=', + value: '[200,500]', + }); + expect(result).toBe('attributes.codes:IN [200 500]'); + }); + + it('keeps negative numeric array values unquoted', () => { + const result = addAddHocFilter('', { + key: 'attributes.deltas', + operator: '=', + value: '[-1,2]', + }); + expect(result).toBe('attributes.deltas:IN [-1 2]'); + }); + + it('handles boolean arrays with IN set query', () => { + const result = addAddHocFilter('', { + key: 'attributes.flags', + operator: '=', + value: '[true,false]', + }); + expect(result).toBe('attributes.flags:IN [true false]'); + }); + + it('handles mixed scalar arrays with IN set query', () => { + const result = addAddHocFilter('', { + key: 'attributes.values', + operator: '=', + value: '["paperclip",200,true]', + }); + expect(result).toBe('attributes.values:IN ["paperclip" 200 true]'); + }); + + it('negates numeric arrays with IN set query', () => { + const result = addAddHocFilter('', { + key: 'attributes.codes', + operator: '!=', + value: '[200,500]', + }); + expect(result).toBe('-attributes.codes:IN [200 500]'); + }); + it('handles array values containing double quotes', () => { const result = addAddHocFilter('', { key: 'attributes.tags', @@ -130,6 +184,42 @@ describe('addAddHocFilter', () => { expect(result).toBe('status:200 AND attributes.controller:"BlogController"'); }); + it('renders numeric equality filters as unquoted literals', () => { + const result = addAddHocFilter('', { + key: 'attributes.status_code', + operator: '=', + value: '200', + }); + expect(result).toBe('attributes.status_code:200'); + }); + + it('keeps numeric zero as a valid filter value', () => { + const result = addAddHocFilter('', { + key: 'attributes.retry_count', + operator: '=', + value: 0 as any, + }); + expect(result).toBe('attributes.retry_count:0'); + }); + + it('keeps boolean false as a valid filter value', () => { + const result = addAddHocFilter('', { + key: 'attributes.cache_hit', + operator: '=', + value: false as any, + }); + expect(result).toBe('attributes.cache_hit:false'); + }); + + it('renders negated boolean equality filters as unquoted literals', () => { + const result = addAddHocFilter('', { + key: 'attributes.cache_hit', + operator: '!=', + value: false as any, + }); + expect(result).toBe('-attributes.cache_hit:false'); + }); + it('exists operator', () => { const result = addAddHocFilter('', { key: 'attributes.tags', diff --git a/src/modifyQuery.ts b/src/modifyQuery.ts index 3141735..5fed8b7 100644 --- a/src/modifyQuery.ts +++ b/src/modifyQuery.ts @@ -1,13 +1,19 @@ import { escapeFilter, escapeFilterValue, concatenate, LuceneQuery } from 'utils/lucene'; import { AdHocVariableFilter } from '@grafana/data'; -function tryParseJsonArray(value: string): string[] | null { - if (!value.startsWith('[')) { +type FilterArrayElement = string | number | boolean; + +function isFilterArrayElement(value: unknown): value is FilterArrayElement { + return ['string', 'number', 'boolean'].includes(typeof value); +} + +function tryParseJsonArray(value: string): FilterArrayElement[] | null { + if (!value.trimStart().startsWith('[')) { return null; } try { const parsed = JSON.parse(value); - if (Array.isArray(parsed) && parsed.every((el) => typeof el === 'string')) { + if (Array.isArray(parsed) && parsed.every(isFilterArrayElement)) { return parsed; } } catch { @@ -16,11 +22,26 @@ function tryParseJsonArray(value: string): string[] | null { return null; } +function formatArrayElement(value: FilterArrayElement) { + if (typeof value === 'string') { + return `"${escapeFilterValue(value)}"`; + } + return String(value); +} + +function hasFilterValue(value: unknown) { + return value !== undefined && value !== null && String(value) !== ''; +} + +function isLiteralValue(value: string) { + return /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value) || value === 'true' || value === 'false'; +} + /** * Adds a label:"value" expression to the query. */ export function addAddHocFilter(query: string, filter: AdHocVariableFilter): string { - const hasValidValue = ['exists', 'not exists'].includes(filter.operator) || !!filter.value + const hasValidValue = ['exists', 'not exists'].includes(filter.operator) || hasFilterValue(filter.value) if (!filter.key || !hasValidValue) { return query; } @@ -33,6 +54,8 @@ export function addAddHocFilter(query: string, filter: AdHocVariableFilter): str const equalityFilters = ['=', '!=']; if (equalityFilters.includes(filter.operator)) { + const modifier = filter.operator === '=' ? '' : '-'; + const key = escapeFilter(filter.key); // Grafana stringifies array values (e.g. ["paperclip","stapler"]) before // passing them as filter values. Tantivy indexes array elements as // individual terms — there's no way to match on array length, order, or @@ -43,14 +66,15 @@ export function addAddHocFilter(query: string, filter: AdHocVariableFilter): str if (arrayElements.length === 0) { return query; } - const modifier = filter.operator === '=' ? '' : '-'; - const key = escapeFilter(filter.key); if (arrayElements.length === 1) { - return concatenate(query, `${modifier}${key}:"${escapeFilterValue(arrayElements[0])}"`, 'AND'); + return concatenate(query, `${modifier}${key}:${formatArrayElement(arrayElements[0])}`, 'AND'); } - const terms = arrayElements.map((el) => `"${escapeFilterValue(el)}"`).join(' '); + const terms = arrayElements.map(formatArrayElement).join(' '); return concatenate(query, `${modifier}${key}:IN [${terms}]`, 'AND'); } + if (isLiteralValue(filter.value)) { + return concatenate(query, `${modifier}${key}:${filter.value}`, 'AND'); + } return LuceneQuery.parse(query).addFilter(filter.key, filter.value, filter.operator === '=' ? '' : '-').toString(); } /** diff --git a/src/quickwit.ts b/src/quickwit.ts index 2d1599f..8c38aa9 100644 --- a/src/quickwit.ts +++ b/src/quickwit.ts @@ -2,6 +2,8 @@ import { DataSourceJsonData } from "@grafana/data"; import { DataLinkConfig } from "./types"; import { DefaultsConfigOverrides } from "store/defaults/conf"; +export type FilterAutocompleteChainMode = 'none' | 'sample' | 'full'; + export interface QuickwitOptions extends DataSourceJsonData { timeField: string; interval?: string; @@ -9,6 +11,10 @@ export interface QuickwitOptions extends DataSourceJsonData { logLevelField?: string; dataLinks?: DataLinkConfig[]; index: string; + filterAutocompleteLimit?: string; + filterAutocompleteChainMode?: FilterAutocompleteChainMode; + // Backward compatibility for configs created before the mode selector. + filterAutocompleteUseFilterChains?: boolean; queryEditorConfig?: { defaults?: DefaultsConfigOverrides }