Skip to content

Commit 41f66b9

Browse files
committed
Add autocomplete to LogContext editor
1 parent 4ec31cf commit 41f66b9

File tree

8 files changed

+129
-44
lines changed

8 files changed

+129
-44
lines changed

src/LogContext/LogContextProvider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,6 @@ export class LogContextProvider {
126126
runContextQuery?: (() => void),
127127
origQuery?: ElasticsearchQuery
128128
): ReactNode {
129-
return ( LogContextUI({row, runContextQuery, origQuery, updateQuery: query=>{this.contextQuery=query}}))
129+
return ( LogContextUI({row, runContextQuery, origQuery, updateQuery: query=>{this.contextQuery=query}, datasource:this.datasource}))
130130
}
131131
}

src/LogContext/components/LogContextQueryBuilderSidebar.tsx

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { useEffect, useMemo, useState } from "react";
22
// import { Field } from '@grafana/data';
33
import { useTheme2, CollapsableSection, Icon } from '@grafana/ui';
4-
import { LogContextProps } from "./LogContextUI";
4+
import { LogContextProps, useSearchableFields } from "./LogContextUI";
55
import { css, cx } from "@emotion/css";
66
import { LuceneQuery } from "utils/lucene";
77
import { useQueryBuilderContext } from 'LogContext/QueryBuilder';
@@ -20,8 +20,8 @@ const excludedFields = [
2020
'timestamp_nanos',
2121
];
2222

23-
function isPrimitive(val: any) {
24-
return val === null || ['string', 'number', "boolean", "undefined"].includes(typeof val)
23+
function isPrimitive(valT: any) {
24+
return ['string', 'number', "boolean", "undefined"].includes(valT)
2525
}
2626

2727
type FieldContingency = { [value: string]: {
@@ -103,19 +103,18 @@ type QueryBuilderProps = {
103103

104104
export function LogContextQueryBuilderSidebar(props: LogContextProps & QueryBuilderProps) {
105105
const builder = useQueryBuilderContext();
106+
const searchableFields = useSearchableFields();
106107

107108
const {row, updateQuery} = props;
108109
const [fields, setFields] = useState<Field[]>([]);
109110

110-
111-
const filteredFields = useMemo(() => (
112-
row.dataFrame.fields
111+
const filteredFields = useMemo(() => {
112+
return searchableFields
113+
// exclude some low-filterability fields
114+
.filter((f)=> !excludedFields.includes(f.name) && isPrimitive(f.type))
113115
// sort fields by name
114116
.sort((f1, f2)=> (f1.name>f2.name ? 1 : -1))
115-
// exclude some low-filterability fields
116-
.filter((f)=> !excludedFields.includes(f.name) && isPrimitive(f.values[0])
117-
)
118-
), [row.dataFrame.fields]);
117+
}, [searchableFields]);
119118

120119
useEffect(() => {
121120
const fields = filteredFields

src/LogContext/components/LogContextQueryEditor.tsx

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,55 @@
1-
import React, { useRef} from "react";
1+
import React, { useCallback, useEffect, useMemo, useRef} from "react";
22
import { css } from "@emotion/css";
33

44
import { useQueryBuilderContext } from 'LogContext/QueryBuilder';
55

66
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
77
import {linter, Diagnostic, lintGutter} from "@codemirror/lint"
8+
import {autocompletion, CompletionContext, Completion} from "@codemirror/autocomplete"
9+
import { useDatasource } from "components/QueryEditor/ElasticsearchQueryContext";
10+
import { useSearchableFields } from "./LogContextUI";
811

12+
function useAutocompleter(){
13+
const datasource = useDatasource()
14+
const searchableFields = useSearchableFields();
15+
16+
useEffect(()=>{
17+
}, [searchableFields])
18+
19+
const getSuggestions = useCallback(async (word: string)=>{
20+
let suggestions: {from: number, options: Completion[]} = {from: 0, options: []};
21+
22+
const wordIsField = word.match(/([\w\.]+):(\S*)/)
23+
if (wordIsField?.length) {
24+
const [_match, fieldName, _fieldValue] = wordIsField;
25+
const candidateValues = await datasource.getTagValues({key:fieldName})
26+
suggestions.from = fieldName.length+1 // Replace only the value part
27+
suggestions.options = candidateValues.map(v=>({
28+
type: 'text',
29+
label: typeof v.text === 'number' ? `${v.text}` : `"${v.text}"`
30+
}))
31+
} else {
32+
const candidateFields = searchableFields
33+
suggestions.from = 0
34+
suggestions.options = candidateFields.map(f=>({
35+
type: 'variable',
36+
label: f.name,
37+
detail: `${f.type}`
38+
}))
39+
}
40+
return suggestions
41+
42+
},[datasource, searchableFields])
43+
return getSuggestions
44+
}
945

1046
export function LogContextQueryEditor(){
1147
const editorRef = useRef<ReactCodeMirrorRef|null>(null)
1248
const builder = useQueryBuilderContext();
1349
const {parsedQuery} = builder;
1450

51+
const autocompleter = useAutocompleter()
52+
1553
const queryLinter = linter( view => {
1654
let diagnostics: Diagnostic[] = [];
1755

@@ -26,13 +64,26 @@ export function LogContextQueryEditor(){
2664
}
2765
return diagnostics
2866
})
67+
68+
const autocomplete = useMemo(()=>autocompletion({
69+
override: [async (context: CompletionContext)=>{
70+
let word = context.matchBefore(/\S*/);
71+
if (!word){ return null }
72+
const suggestions = await autocompleter(word?.text);
73+
return {
74+
from: word.from + suggestions.from,
75+
options: suggestions.options
76+
}
77+
}]
78+
}),[autocompleter])
79+
2980
return (<CodeMirror
3081
ref={editorRef}
3182
className={css`height:100%`} // XXX : need to set height for both wrapper elements
3283
height="100%"
3384
theme={'dark'}
3485
value={builder.query}
3586
onChange={(query)=>builder.setQuery(query || '' )}
36-
extensions={[queryLinter, lintGutter()]}
87+
extensions={[queryLinter, lintGutter(), autocomplete]}
3788
/>);
3889
}

src/LogContext/components/LogContextUI.tsx

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1-
import React, { useEffect, useState, useCallback, useMemo } from "react";
2-
import { LogRowModel, } from '@grafana/data';
1+
import React, { useEffect, useState, useCallback, useMemo, createContext } from "react";
2+
import { Field, LogRowModel, } from '@grafana/data';
33
import { ElasticsearchQuery as DataQuery } from '../../types';
44
import { LogContextQueryEditor } from "./LogContextQueryEditor";
55

66
import { css } from "@emotion/css";
77
import { Button } from "@grafana/ui";
88
import { useQueryBuilder, QueryBuilderContext } from 'LogContext/QueryBuilder';
99
import { LogContextQueryBuilderSidebar } from "./LogContextQueryBuilderSidebar";
10+
import { DatasourceContext } from "components/QueryEditor/ElasticsearchQueryContext";
11+
import { QuickwitDataSource } from "datasource";
12+
import { getHook } from "utils/context";
1013

1114
const logContextUiStyle = css`
1215
display: flex;
@@ -15,20 +18,39 @@ const logContextUiStyle = css`
1518
height: 200px;
1619
`
1720

21+
1822
export interface LogContextProps {
1923
row: LogRowModel,
2024
runContextQuery?: (() => void)
2125
origQuery?: DataQuery
2226
}
2327
export interface LogContextUIProps extends LogContextProps {
28+
datasource: QuickwitDataSource,
2429
updateQuery: (query: string) => void
2530
}
31+
const SearchableFieldsContext = createContext<Field[]|undefined>(undefined)
32+
export const useSearchableFields = getHook(SearchableFieldsContext)
2633

2734
export function LogContextUI(props: LogContextUIProps ){
2835
const builder = useQueryBuilder();
2936
const {query, parsedQuery, setQuery, setParsedQuery} = builder;
3037
const [canRunQuery, setCanRunQuery] = useState<boolean>(false);
3138
const { origQuery, updateQuery, runContextQuery } = props;
39+
const datasource = props.datasource;
40+
const [fields, setFields] = useState<typeof props.row.dataFrame.fields>([]);
41+
42+
useEffect(()=>{
43+
const dfFields = props.row.dataFrame.fields
44+
// Get datasource fields definitions
45+
datasource.getTagKeys({searchable:true}).then((dsFields)=>{
46+
const dsFieldsNames = dsFields.map(f=>f.text)
47+
setFields( dfFields.filter(f=> dsFieldsNames.includes(f.name)))
48+
49+
});
50+
51+
52+
53+
}, [props.row, datasource, setFields])
3254

3355
useEffect(()=>{
3456
setQuery(origQuery?.query || '')
@@ -55,13 +77,17 @@ export function LogContextUI(props: LogContextUIProps ){
5577

5678
return (
5779
<div className={logContextUiStyle}>
58-
<QueryBuilderContext.Provider value={builder}>
59-
<LogContextQueryBuilderSidebar {...props} updateQuery={setParsedQuery}/>
60-
<div className={css`width:100%; display:flex; flex-direction:column; gap:0.5rem; min-width:0;`}>
61-
{ActionBar}
62-
<LogContextQueryEditor />
63-
</div>
64-
</QueryBuilderContext.Provider>
80+
<DatasourceContext.Provider value={props.datasource}>
81+
<QueryBuilderContext.Provider value={builder}>
82+
<SearchableFieldsContext.Provider value={fields}>
83+
<LogContextQueryBuilderSidebar {...props} updateQuery={setParsedQuery}/>
84+
<div className={css`width:100%; display:flex; flex-direction:column; gap:0.5rem; min-width:0;`}>
85+
{ActionBar}
86+
<LogContextQueryEditor />
87+
</div>
88+
</SearchableFieldsContext.Provider>
89+
</QueryBuilderContext.Provider>
90+
</DatasourceContext.Provider>
6591
</div>
6692
);
6793
}

src/components/QueryEditor/ElasticsearchQueryContext.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ import { reducer as metricsReducer } from './MetricAggregationsEditor/state/redu
1111
import { aliasPatternReducer, queryReducer, initQuery, initExploreQuery } from './state';
1212
import { getHook } from 'utils/context';
1313

14-
const RangeContext = createContext<TimeRange | undefined>(undefined);
14+
export const RangeContext = createContext<TimeRange | undefined>(undefined);
1515
export const useRange = getHook(RangeContext);
1616

17-
const QueryContext = createContext<ElasticsearchQuery | undefined>(undefined);
17+
export const QueryContext = createContext<ElasticsearchQuery | undefined>(undefined);
1818
export const useQuery = getHook(QueryContext);
1919

20-
const DatasourceContext = createContext<ElasticDatasource | undefined>(undefined);
20+
export const DatasourceContext = createContext<ElasticDatasource | undefined>(undefined);
2121
export const useDatasource = getHook(DatasourceContext);
2222

2323
interface Props {

src/datasource.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ export const REF_ID_STARTER_LOG_VOLUME = 'log-volume-';
5454

5555
export type ElasticDatasource = QuickwitDataSource;
5656

57+
type FieldCapsSpec = {
58+
aggregatable?: boolean,
59+
searchable?: boolean,
60+
type?: string[],
61+
_range?: TimeRange
62+
}
63+
5764
export class QuickwitDataSource
5865
extends DataSourceWithBackend<ElasticsearchQuery, QuickwitOptions>
5966
implements
@@ -368,21 +375,21 @@ export class QuickwitDataSource
368375
);
369376
}
370377

371-
getFields(aggregatable?: boolean, type?: string[], _range?: TimeRange): Observable<MetricFindValue[]> {
378+
getFields(spec: FieldCapsSpec={}): Observable<MetricFindValue[]> {
372379
// TODO: use the time range.
373380
return from(this.getResource('_elastic/' + this.index + '/_field_caps')).pipe(
374381
map((field_capabilities_response: FieldCapabilitiesResponse) => {
375382
const shouldAddField = (field: any) => {
376-
if (aggregatable === undefined) {
377-
return true;
383+
if (spec.aggregatable !== undefined && field.aggregatable !== spec.aggregatable) {
384+
return false
378385
}
379-
if (aggregatable !== undefined && field.aggregatable !== aggregatable) {
380-
return false;
386+
if (spec.searchable !== undefined && field.searchable !== spec.searchable){
387+
return false
381388
}
382-
if (type?.length === 0) {
383-
return true;
389+
if (spec.type && spec.type.length !== 0 && !(spec.type.includes(field.type) || spec.type.includes(fieldTypeMap[field.type]))) {
390+
return false
384391
}
385-
return type?.includes(field.type) || type?.includes(fieldTypeMap[field.type]);
392+
return true
386393
};
387394
const fieldCapabilities = Object.entries(field_capabilities_response.fields)
388395
.flatMap(([field_name, field_capabilities]) => {
@@ -412,8 +419,8 @@ export class QuickwitDataSource
412419
/**
413420
* Get tag keys for adhoc filters
414421
*/
415-
getTagKeys() {
416-
return lastValueFrom(this.getFields());
422+
getTagKeys(spec?: FieldCapsSpec) {
423+
return lastValueFrom(this.getFields(spec));
417424
}
418425

419426
/**
@@ -514,7 +521,7 @@ export class QuickwitDataSource
514521
if (query) {
515522
if (parsedQuery.find === 'fields') {
516523
parsedQuery.type = this.interpolateLuceneQuery(parsedQuery.type);
517-
return lastValueFrom(this.getFields(true, parsedQuery.type, range));
524+
return lastValueFrom(this.getFields({aggregatable:true, type:parsedQuery.type, _range:range}));
518525
}
519526
if (parsedQuery.find === 'terms') {
520527
parsedQuery.field = this.interpolateLuceneQuery(parsedQuery.field);

src/hooks/useFields.test.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { ElasticsearchQuery, MetricAggregationType, BucketAggregationType } from
1111
import { useFields } from './useFields';
1212
import { renderHook } from '@testing-library/react-hooks';
1313

14+
15+
1416
describe('useFields hook', () => {
1517
// TODO: If we move the field type to the configuration objects as described in the hook's source
1618
// we can stop testing for getField to be called with the correct parameters.
@@ -48,39 +50,39 @@ describe('useFields hook', () => {
4850
{ wrapper, initialProps: 'cardinality' }
4951
);
5052
result.current();
51-
expect(getFields).toHaveBeenLastCalledWith(true, [], timeRange);
53+
expect(getFields).toHaveBeenLastCalledWith({aggregatable:true, type:[], _range:timeRange});
5254

5355
// All other metric aggregations only work on numbers
5456
rerender('avg');
5557
result.current();
56-
expect(getFields).toHaveBeenLastCalledWith(true, ['number'], timeRange);
58+
expect(getFields).toHaveBeenLastCalledWith({aggregatable:true, type:['number'], _range:timeRange});
5759

5860
//
5961
// BUCKET AGGREGATIONS
6062
//
6163
// Date Histrogram only works on dates
6264
rerender('date_histogram');
6365
result.current();
64-
expect(getFields).toHaveBeenLastCalledWith(true, ['date'], timeRange);
66+
expect(getFields).toHaveBeenLastCalledWith({aggregatable:true, type:['date'], _range:timeRange});
6567

6668
// Histrogram only works on numbers
6769
rerender('histogram');
6870
result.current();
69-
expect(getFields).toHaveBeenLastCalledWith(true, ['number'], timeRange);
71+
expect(getFields).toHaveBeenLastCalledWith({aggregatable:true, type:['number'], _range:timeRange});
7072

7173
// Geohash Grid only works on geo_point data
7274
rerender('geohash_grid');
7375
result.current();
74-
expect(getFields).toHaveBeenLastCalledWith(true, ['geo_point'], timeRange);
76+
expect(getFields).toHaveBeenLastCalledWith({aggregatable:true, type:['geo_point'], _range:timeRange});
7577

7678
// All other bucket aggregation work on any kind of data
7779
rerender('terms');
7880
result.current();
79-
expect(getFields).toHaveBeenLastCalledWith(true, [], timeRange);
81+
expect(getFields).toHaveBeenLastCalledWith({aggregatable:true, type:[], _range:timeRange});
8082

8183
// top_metrics work on only on numeric data in 7.7
8284
rerender('top_metrics');
8385
result.current();
84-
expect(getFields).toHaveBeenLastCalledWith(true, ['number'], timeRange);
86+
expect(getFields).toHaveBeenLastCalledWith({aggregatable:true, type:['number'], _range:timeRange});
8587
});
8688
});

src/hooks/useFields.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export const useFields = (type: AggregationType | string[]) => {
6262
return async (q?: string) => {
6363
// _mapping doesn't support filtering, we avoid sending a request everytime q changes
6464
if (!rawFields) {
65-
rawFields = await lastValueFrom(datasource.getFields(true, filter, range));
65+
rawFields = await lastValueFrom(datasource.getFields({aggregatable:true, type:filter, _range:range}));
6666
}
6767

6868
return rawFields.filter(({ text }) => q === undefined || text.includes(q)).map(toSelectableValue);

0 commit comments

Comments
 (0)