diff --git a/src/LogContext/components/LogContextQueryBuilderSidebar.tsx b/src/LogContext/components/LogContextQueryBuilderSidebar.tsx index 647ce88..ffe7477 100644 --- a/src/LogContext/components/LogContextQueryBuilderSidebar.tsx +++ b/src/LogContext/components/LogContextQueryBuilderSidebar.tsx @@ -1,10 +1,10 @@ import React, { useEffect, useMemo, useState } from "react"; // import { Field } from '@grafana/data'; import { useTheme2, CollapsableSection, Icon } from '@grafana/ui'; -import { LogContextProps, useSearchableFields } from "./LogContextUI"; +import { LogContextProps } from "./LogContextUI"; import { css, cx } from "@emotion/css"; import { LuceneQuery } from "utils/lucene"; -import { useQueryBuilderContext } from 'LogContext/QueryBuilder'; +import { useQueryBuilderContext } from '@/QueryBuilder/lucene'; // TODO : define sensible defaults here @@ -98,23 +98,25 @@ const lcSidebarStyle = css` ` type QueryBuilderProps = { + searchableFields: any[], updateQuery: (query: LuceneQuery) => void } export function LogContextQueryBuilderSidebar(props: LogContextProps & QueryBuilderProps) { const builder = useQueryBuilderContext(); - const searchableFields = useSearchableFields(); - const {row, updateQuery} = props; + const {row, updateQuery, searchableFields} = props; const [fields, setFields] = useState([]); const filteredFields = useMemo(() => { - return searchableFields + const searchableFieldsNames = searchableFields.map(f=>f.text); + return row.dataFrame.fields + .filter(f=>searchableFieldsNames.includes(f.name)) // exclude some low-filterability fields .filter((f)=> !excludedFields.includes(f.name) && isPrimitive(f.type)) // sort fields by name .sort((f1, f2)=> (f1.name>f2.name ? 1 : -1)) - }, [searchableFields]); + }, [row, searchableFields]); useEffect(() => { const fields = filteredFields diff --git a/src/LogContext/components/LogContextQueryEditor.tsx b/src/LogContext/components/LogContextQueryEditor.tsx deleted file mode 100644 index cd1fc37..0000000 --- a/src/LogContext/components/LogContextQueryEditor.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef} from "react"; -import { css } from "@emotion/css"; - -import { useQueryBuilderContext } from 'LogContext/QueryBuilder'; - -import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror'; -import {linter, Diagnostic, lintGutter} from "@codemirror/lint" -import {autocompletion, CompletionContext, Completion} from "@codemirror/autocomplete" -import { useDatasource } from "components/QueryEditor/ElasticsearchQueryContext"; -import { useSearchableFields } from "./LogContextUI"; - -function useAutocompleter(){ - const datasource = useDatasource() - const searchableFields = useSearchableFields(); - - useEffect(()=>{ - }, [searchableFields]) - - const getSuggestions = useCallback(async (word: string)=>{ - let suggestions: {from: number, options: Completion[]} = {from: 0, options: []}; - - const wordIsField = word.match(/([\w\.]+):(\S*)/) - if (wordIsField?.length) { - const [_match, fieldName, _fieldValue] = wordIsField; - const candidateValues = await datasource.getTagValues({key:fieldName}) - suggestions.from = fieldName.length+1 // Replace only the value part - suggestions.options = candidateValues.map(v=>({ - type: 'text', - label: typeof v.text === 'number' ? `${v.text}` : `"${v.text}"` - })) - } else { - const candidateFields = searchableFields - suggestions.from = 0 - suggestions.options = candidateFields.map(f=>({ - type: 'variable', - label: f.name, - detail: `${f.type}` - })) - } - return suggestions - - },[datasource, searchableFields]) - return getSuggestions -} - -export function LogContextQueryEditor(){ - const editorRef = useRef(null) - const builder = useQueryBuilderContext(); - const {parsedQuery} = builder; - - const autocompleter = useAutocompleter() - - const queryLinter = linter( view => { - let diagnostics: Diagnostic[] = []; - - const error = parsedQuery?.parseError - if (error) { - diagnostics.push({ - severity: "error", - message: error.message, - from: view.state.doc.line(error.location.start.line).from + error.location.start.column -1, - to: view.state.doc.line(error.location.end.line).from + error.location.end.column -1, - }) ; - } - return diagnostics - }) - - const autocomplete = useMemo(()=>autocompletion({ - override: [async (context: CompletionContext)=>{ - let word = context.matchBefore(/\S*/); - if (!word){ return null } - const suggestions = await autocompleter(word?.text); - return { - from: word.from + suggestions.from, - options: suggestions.options - } - }] - }),[autocompleter]) - - return (builder.setQuery(query || '' )} - extensions={[queryLinter, lintGutter(), autocomplete]} - />); -} diff --git a/src/LogContext/components/LogContextUI.tsx b/src/LogContext/components/LogContextUI.tsx index 34d42e0..8d92c17 100644 --- a/src/LogContext/components/LogContextUI.tsx +++ b/src/LogContext/components/LogContextUI.tsx @@ -1,15 +1,15 @@ -import React, { useEffect, useState, useCallback, useMemo, createContext } from "react"; -import { Field, LogRowModel, } from '@grafana/data'; +import React, { useEffect, useState, useCallback, useMemo } from "react"; +import { LogRowModel } from '@grafana/data'; import { ElasticsearchQuery as DataQuery } from '../../types'; -import { LogContextQueryEditor } from "./LogContextQueryEditor"; +import { LuceneQueryEditor } from "../../components/LuceneQueryEditor"; import { css } from "@emotion/css"; import { Button } from "@grafana/ui"; -import { useQueryBuilder, QueryBuilderContext } from 'LogContext/QueryBuilder'; +import { useQueryBuilder, QueryBuilderContext } from '@/QueryBuilder/lucene'; import { LogContextQueryBuilderSidebar } from "./LogContextQueryBuilderSidebar"; import { DatasourceContext } from "components/QueryEditor/ElasticsearchQueryContext"; import { QuickwitDataSource } from "datasource"; -import { getHook } from "utils/context"; +import { useDatasourceFields } from "datasource.utils"; const logContextUiStyle = css` display: flex; @@ -28,29 +28,13 @@ export interface LogContextUIProps extends LogContextProps { datasource: QuickwitDataSource, updateQuery: (query: string) => void } -const SearchableFieldsContext = createContext(undefined) -export const useSearchableFields = getHook(SearchableFieldsContext) export function LogContextUI(props: LogContextUIProps ){ const builder = useQueryBuilder(); const {query, parsedQuery, setQuery, setParsedQuery} = builder; const [canRunQuery, setCanRunQuery] = useState(false); const { origQuery, updateQuery, runContextQuery } = props; - const datasource = props.datasource; - const [fields, setFields] = useState([]); - - useEffect(()=>{ - const dfFields = props.row.dataFrame.fields - // Get datasource fields definitions - datasource.getTagKeys({searchable:true}).then((dsFields)=>{ - const dsFieldsNames = dsFields.map(f=>f.text) - setFields( dfFields.filter(f=> dsFieldsNames.includes(f.name))) - - }); - - - - }, [props.row, datasource, setFields]) + const {fields, getSuggestions} = useDatasourceFields(props.datasource); useEffect(()=>{ setQuery(origQuery?.query || '') @@ -79,13 +63,11 @@ export function LogContextUI(props: LogContextUIProps ){
- - +
{ActionBar} - +
-
diff --git a/src/QueryBuilder.ts b/src/QueryBuilder/elastic.ts similarity index 98% rename from src/QueryBuilder.ts rename to src/QueryBuilder/elastic.ts index 81012d9..f66c64b 100644 --- a/src/QueryBuilder.ts +++ b/src/QueryBuilder/elastic.ts @@ -1,6 +1,6 @@ import { TermsQuery, -} from './types'; +} from '../types'; export class ElasticQueryBuilder { timeField: string; diff --git a/src/LogContext/QueryBuilder.ts b/src/QueryBuilder/lucene.ts similarity index 83% rename from src/LogContext/QueryBuilder.ts rename to src/QueryBuilder/lucene.ts index ee85308..4beb8a9 100644 --- a/src/LogContext/QueryBuilder.ts +++ b/src/QueryBuilder/lucene.ts @@ -1,9 +1,9 @@ -import * as lucene from "utils/lucene"; import { useState, useCallback, createContext } from 'react'; -import { LuceneQuery as QueryBuilder } from 'utils/lucene'; -import { getHook } from "utils/context"; +import { getHook } from "@/utils/context"; +import * as lucene from "@/utils/lucene"; +import { LuceneQuery as QueryBuilder } from '@/utils/lucene'; -type LuceneQueryBuilder = { +export type LuceneQueryBuilder = { query: string, parsedQuery: lucene.LuceneQuery, setQuery: (query: string) => void diff --git a/src/components/LuceneQueryEditor.tsx b/src/components/LuceneQueryEditor.tsx new file mode 100644 index 0000000..f0bb3c8 --- /dev/null +++ b/src/components/LuceneQueryEditor.tsx @@ -0,0 +1,61 @@ +import React, { useRef, useCallback } from "react"; +import { css } from "@emotion/css"; + +import { LuceneQueryBuilder } from '@/QueryBuilder/lucene'; + +import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror'; +import {linter, Diagnostic, lintGutter} from "@codemirror/lint" +import {autocompletion, CompletionContext} from "@codemirror/autocomplete" + + +export type LuceneQueryEditorProps = { + placeholder?: string, + builder: LuceneQueryBuilder, + autocompleter: (word: string) => any, + onChange: (query: string) => void +} + +export function LuceneQueryEditor(props: LuceneQueryEditorProps){ + const editorRef = useRef(null) + + const queryLinter = linter( view => { + let diagnostics: Diagnostic[] = []; + + const error = props.builder.parsedQuery?.parseError + if (error) { + diagnostics.push({ + severity: "error", + message: error.message, + from: view.state.doc.line(error.location.start.line).from + error.location.start.column -1, + to: view.state.doc.line(error.location.end.line).from + error.location.end.column -1, + }) ; + } + return diagnostics + }) + + + const {autocompleter} = props; + const datasourceCompletions = useCallback(async (context: CompletionContext)=>{ + let word = context.matchBefore(/\S*/); + if (!word){ return null } + const suggestions = await autocompleter(word?.text); + return { + from: word.from + suggestions.from, + options: suggestions.options + } + },[autocompleter]) + + + const autocomplete = autocompletion({ override: [datasourceCompletions] }) + + return (); +} diff --git a/src/components/QueryEditor/index.tsx b/src/components/QueryEditor/index.tsx index f627bc4..16c255d 100644 --- a/src/components/QueryEditor/index.tsx +++ b/src/components/QueryEditor/index.tsx @@ -1,9 +1,9 @@ import { css } from '@emotion/css'; -import React from 'react'; +import React, { createContext, useCallback, useEffect } from 'react'; -import { CoreApp, getDefaultTimeRange, GrafanaTheme2, QueryEditorProps } from '@grafana/data'; -import { InlineLabel, QueryField, useStyles2 } from '@grafana/ui'; +import { CoreApp, Field, getDefaultTimeRange, GrafanaTheme2, QueryEditorProps } from '@grafana/data'; +import { InlineLabel, useStyles2 } from '@grafana/ui'; import { ElasticDatasource } from '@/datasource'; import { useNextId } from '@/hooks/useNextId'; @@ -11,13 +11,18 @@ import { useDispatch } from '@/hooks/useStatelessReducer'; import { ElasticsearchQuery } from '@/types'; import { BucketAggregationsEditor } from './BucketAggregationsEditor'; -import { ElasticsearchProvider } from './ElasticsearchQueryContext'; +import { ElasticsearchProvider, useDatasource } from './ElasticsearchQueryContext'; import { MetricAggregationsEditor } from './MetricAggregationsEditor'; import { metricAggregationConfig } from './MetricAggregationsEditor/utils'; import { changeQuery } from './state'; import { QuickwitOptions } from '../../quickwit'; import { QueryTypeSelector } from './QueryTypeSelector'; +import { useQueryBuilder } from '@/QueryBuilder/lucene'; +import { getHook } from 'utils/context'; +import { LuceneQueryEditor } from '@/components/LuceneQueryEditor'; +import { useDatasourceFields } from 'datasource.utils'; + export type ElasticQueryEditorProps = QueryEditorProps; export const QueryEditor = ({ query, onChange, onRunQuery, datasource, range, app }: ElasticQueryEditorProps) => { @@ -45,24 +50,32 @@ const getStyles = (theme: GrafanaTheme2) => ({ `, }); +const SearchableFieldsContext = createContext(undefined) +export const useSearchableFields = getHook(SearchableFieldsContext) + interface Props { value: ElasticsearchQuery; } export const ElasticSearchQueryField = ({ value, onChange }: { value?: string; onChange: (v: string) => void }) => { const styles = useStyles2(getStyles); + const builder = useQueryBuilder(); + const {setQuery} = builder; + const datasource = useDatasource() + const { getSuggestions } = useDatasourceFields(datasource); + + useEffect(()=>{ + setQuery(value || '') + }, [setQuery, value]) + + const onEditorChange = useCallback((query: string)=>{ + setQuery(query); + onChange(query) + },[setQuery, onChange]) return (
- {}} - onChange={onChange} - placeholder="Enter a lucene query" - portalOrigin="elasticsearch" - /> +
); }; diff --git a/src/datasource.ts b/src/datasource.ts index 938eaed..d5bb945 100644 --- a/src/datasource.ts +++ b/src/datasource.ts @@ -36,7 +36,7 @@ import { TemplateSrv, getDataSourceSrv } from '@grafana/runtime'; import { QuickwitOptions } from 'quickwit'; -import { ElasticQueryBuilder } from 'QueryBuilder'; +import { ElasticQueryBuilder } from '@/QueryBuilder/elastic'; import { colors } from '@grafana/ui'; import { BarAlignment, DataQuery, GraphDrawStyle, StackingMode } from '@grafana/schema'; diff --git a/src/datasource.utils.ts b/src/datasource.utils.ts new file mode 100644 index 0000000..fbe5ef3 --- /dev/null +++ b/src/datasource.utils.ts @@ -0,0 +1,53 @@ +import { QuickwitDataSource } from "datasource"; +import { useState, useEffect, useCallback } from "react"; +import{ MetricFindValue } from '@grafana/data'; + +/** + * Provide suggestions based on datasource fields + */ + +export type Suggestion = { + from: number, + options: Array<{ + label: string, + detail?: string, + type?: string, + }> +} + +export function useDatasourceFields(datasource: QuickwitDataSource) { + const [fields, setFields] = useState([]); + + useEffect(() => { + if (datasource.getTagKeys) { + datasource.getTagKeys({ searchable: true }).then(setFields); + } + }, [datasource, setFields]); + + const getSuggestions = useCallback(async (word: string): Promise => { + let suggestions: Suggestion = { from: 0, options: [] }; + + const wordIsField = word.match(/([\w\.]+):"?(\S*)/); + if (wordIsField?.length) { + const [_match, fieldName, _fieldValue] = wordIsField; + const candidateValues = await datasource.getTagValues({ key: fieldName }); + suggestions.from = fieldName.length + 1; // Replace only the value part + suggestions.options = candidateValues.map(v => ({ + type: 'text', + label: typeof v.text === 'number' ? `${v.text}` : `"${v.text}"` + })); + } else { + const candidateFields = fields; + suggestions.from = 0; + suggestions.options = candidateFields.map(f => ({ + type: 'variable', + label: f.text, + detail: `${f.value}` + })); + } + return suggestions; + + }, [datasource, fields]); + + return {fields, getSuggestions} +}