mirror of
https://github.com/grafana/grafana.git
synced 2025-12-21 03:54:29 +08:00
Compare commits
6 Commits
zoltan/pos
...
elasticsea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d419c33970 | ||
|
|
9b6f306956 | ||
|
|
598be0cf49 | ||
|
|
bad5bd627d | ||
|
|
850963c8fe | ||
|
|
5e79510352 |
@@ -174,6 +174,8 @@ You can also override this setting in a dashboard panel under its data source op
|
||||
Frozen indices are [deprecated in Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.17/frozen-indices.html) since v7.14.
|
||||
{{< /admonition >}}
|
||||
|
||||
- **Default query mode** - Specifies which query mode the data source uses by default. Options are `Metrics`, `Logs`, `Raw data`, and `Raw document`. The default is `Metrics`.
|
||||
|
||||
### Logs
|
||||
|
||||
In this section you can configure which fields the data source uses for log messages and log levels.
|
||||
|
||||
@@ -279,41 +279,7 @@ Refer to [Templates](ref:templates) for an introduction to creating template var
|
||||
|
||||
If you add a `Query` template variable you can write a PostgreSQL query to retrieve items such as measurement names, key names, or key values, which will be displayed in the drop-down menu.
|
||||
|
||||
The PostgreSQL variable query editor supports both **Builder** and **Code** modes, similar to the standard query editor.
|
||||
|
||||
#### Builder mode for variables
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Builder mode for variable queries is currently behind the `postgresVariableQueryEditor` feature toggle.
|
||||
{{< /admonition >}}
|
||||
|
||||
Builder mode provides a visual interface for creating variable queries. When using Builder mode for variable queries, the **Alias** dropdown includes predefined options `__text` and `__value` to easily create key/value variables.
|
||||
|
||||
{{< figure src="/static/img/docs/postgresql-variable-query-editor.png" class="docs-image--no-shadow" caption="PostgreSQL variable query editor in Builder mode" >}}
|
||||
|
||||
For example, to create a variable that displays hostnames but uses IDs as values:
|
||||
|
||||
1. Select your table from the **Table** dropdown.
|
||||
2. Add a column for the display text (for example, `hostname`) and set its **Alias** to `__text`.
|
||||
3. Add another column for the value (for example, `id`) and set its **Alias** to `__value`.
|
||||
|
||||
This generates a query equivalent to `SELECT hostname AS __text, id AS __value FROM host`.
|
||||
|
||||
#### Multiple properties
|
||||
|
||||
When you create a key/value variable with `__text` and `__value`, you can also include additional columns to store extra properties. These additional properties can be accessed using dot notation.
|
||||
|
||||
For example, if you have a variable named `server` with columns for `hostname` (as `__text`), `id` (as `__value`), and `region`, you can access the region property using `${server.region}`.
|
||||
|
||||
To add multiple properties:
|
||||
|
||||
1. Set up your `__text` and `__value` columns as described above.
|
||||
2. Add additional columns for any extra properties you want to include.
|
||||
3. Access the properties in your queries or panels using `${variableName.propertyName}`.
|
||||
|
||||
#### Code mode for variables
|
||||
|
||||
In Code mode, you can write PostgreSQL queries directly. For example, you can use a variable to retrieve all the values from the `hostname` column in a table by creating the following query in the templating variable _Query_ setting.
|
||||
For example, you can use a variable to retrieve all the values from the `hostname` column in a table by creating the following query in the templating variable _Query_ setting.
|
||||
|
||||
```sql
|
||||
SELECT hostname FROM host
|
||||
@@ -331,9 +297,7 @@ To use time range dependent macros like `$__timeFilter(column)` in your query, y
|
||||
SELECT event_name FROM event_log WHERE $__timeFilter(time_column)
|
||||
```
|
||||
|
||||
Another option is a query that can create a key/value variable. The query should return two columns that are named `__text` and `__value`. The `__text` column must contain unique values (if not, only the first value is used). This allows the drop-down options to display a text-friendly name as the text while using an ID as the value.
|
||||
|
||||
You can create key/value variables using Builder mode by selecting the predefined `__text` and `__value` alias options, or write the query directly in Code mode. For example, a query could use `hostname` as the text and `id` as the value:
|
||||
Another option is a query that can create a key/value variable. The query should return two columns that are named `__text` and `__value`. The `__text` column must contain unique values (if not, only the first value is used). This allows the drop-down options to display a text-friendly name as the text while using an ID as the value. For example, a query could use `hostname` as the text and `id` as the value:
|
||||
|
||||
```sql
|
||||
SELECT hostname AS __text, id AS __value FROM host
|
||||
|
||||
@@ -1161,10 +1161,6 @@ export interface FeatureToggles {
|
||||
*/
|
||||
jaegerEnableGrpcEndpoint?: boolean;
|
||||
/**
|
||||
* Enable the new variable query editor for the PostgreSQL data source
|
||||
*/
|
||||
postgresVariableQueryEditor?: boolean;
|
||||
/**
|
||||
* Load plugins on store service startup instead of wire provider, and call RegisterFixedRoles after all plugins are loaded
|
||||
* @default false
|
||||
*/
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
"@grafana/i18n": "12.4.0-pre",
|
||||
"@grafana/plugin-ui": "^0.11.0",
|
||||
"@grafana/runtime": "12.4.0-pre",
|
||||
"@grafana/schema": "12.4.0-pre",
|
||||
"@grafana/ui": "12.4.0-pre",
|
||||
"@react-awesome-query-builder/ui": "6.6.15",
|
||||
"immutable": "5.1.4",
|
||||
|
||||
@@ -15,8 +15,7 @@ import { RawEditor } from './query-editor-raw/RawEditor';
|
||||
import { VisualEditor } from './visual-query-builder/VisualEditor';
|
||||
|
||||
export interface SqlQueryEditorProps extends QueryEditorProps<SqlDatasource, SQLQuery, SQLOptions> {
|
||||
queryHeaderProps?: Pick<QueryHeaderProps, 'dialect' | 'hideRunButton' | 'hideFormatSelector'>;
|
||||
isVariableQuery?: boolean;
|
||||
queryHeaderProps?: Pick<QueryHeaderProps, 'dialect'>;
|
||||
}
|
||||
|
||||
export default function SqlQueryEditor({
|
||||
@@ -26,7 +25,6 @@ export default function SqlQueryEditor({
|
||||
onRunQuery,
|
||||
range,
|
||||
queryHeaderProps,
|
||||
isVariableQuery = false,
|
||||
}: SqlQueryEditorProps) {
|
||||
const [isQueryRunnable, setIsQueryRunnable] = useState(true);
|
||||
const db = datasource.getDB();
|
||||
@@ -101,8 +99,6 @@ export default function SqlQueryEditor({
|
||||
query={queryWithDefaults}
|
||||
isQueryRunnable={isQueryRunnable}
|
||||
dialect={dialect}
|
||||
hideRunButton={queryHeaderProps?.hideRunButton}
|
||||
hideFormatSelector={queryHeaderProps?.hideFormatSelector}
|
||||
/>
|
||||
|
||||
<Space v={0.5} />
|
||||
@@ -115,7 +111,6 @@ export default function SqlQueryEditor({
|
||||
queryRowFilter={queryRowFilter}
|
||||
onValidate={setIsQueryRunnable}
|
||||
range={range}
|
||||
isVariableQuery={isVariableQuery}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -25,8 +25,6 @@ export interface QueryHeaderProps {
|
||||
preconfiguredDataset: string;
|
||||
query: QueryWithDefaults;
|
||||
queryRowFilter: QueryRowFilter;
|
||||
hideRunButton?: boolean;
|
||||
hideFormatSelector?: boolean;
|
||||
}
|
||||
|
||||
export function QueryHeader({
|
||||
@@ -39,8 +37,6 @@ export function QueryHeader({
|
||||
preconfiguredDataset,
|
||||
query,
|
||||
queryRowFilter,
|
||||
hideRunButton,
|
||||
hideFormatSelector,
|
||||
}: QueryHeaderProps) {
|
||||
const { editorMode } = query;
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
@@ -127,16 +123,14 @@ export function QueryHeader({
|
||||
return (
|
||||
<>
|
||||
<EditorHeader>
|
||||
{!hideFormatSelector && (
|
||||
<InlineSelect
|
||||
label={t('grafana-sql.components.query-header.label-format', 'Format')}
|
||||
value={query.format}
|
||||
placeholder={t('grafana-sql.components.query-header.placeholder-select-format', 'Select format')}
|
||||
menuShouldPortal
|
||||
onChange={onFormatChange}
|
||||
options={QUERY_FORMAT_OPTIONS}
|
||||
/>
|
||||
)}
|
||||
<InlineSelect
|
||||
label={t('grafana-sql.components.query-header.label-format', 'Format')}
|
||||
value={query.format}
|
||||
placeholder={t('grafana-sql.components.query-header.placeholder-select-format', 'Select format')}
|
||||
menuShouldPortal
|
||||
onChange={onFormatChange}
|
||||
options={QUERY_FORMAT_OPTIONS}
|
||||
/>
|
||||
|
||||
{editorMode === EditorMode.Builder && (
|
||||
<>
|
||||
@@ -228,27 +222,26 @@ export function QueryHeader({
|
||||
|
||||
<FlexItem grow={1} />
|
||||
|
||||
{!hideRunButton &&
|
||||
(isQueryRunnable ? (
|
||||
<Button icon="play" variant="primary" size="sm" onClick={() => onRunQuery()}>
|
||||
{isQueryRunnable ? (
|
||||
<Button icon="play" variant="primary" size="sm" onClick={() => onRunQuery()}>
|
||||
<Trans i18nKey="grafana-sql.components.query-header.run-query">Run query</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip
|
||||
theme="error"
|
||||
content={
|
||||
<Trans i18nKey="grafana-sql.components.query-header.content-invalid-query">
|
||||
Your query is invalid. Check below for details. <br />
|
||||
However, you can still run this query.
|
||||
</Trans>
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<Button icon="exclamation-triangle" variant="secondary" size="sm" onClick={() => onRunQuery()}>
|
||||
<Trans i18nKey="grafana-sql.components.query-header.run-query">Run query</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip
|
||||
theme="error"
|
||||
content={
|
||||
<Trans i18nKey="grafana-sql.components.query-header.content-invalid-query">
|
||||
Your query is invalid. Check below for details. <br />
|
||||
However, you can still run this query.
|
||||
</Trans>
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<Button icon="exclamation-triangle" variant="secondary" size="sm" onClick={() => onRunQuery()}>
|
||||
<Trans i18nKey="grafana-sql.components.query-header.run-query">Run query</Trans>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<RadioButtonGroup options={editorModes} size="sm" value={editorMode} onChange={onEditorModeChange} />
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { uniqueId } from 'lodash';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { SelectableValue, toOption } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
@@ -20,56 +20,19 @@ interface SelectRowProps {
|
||||
onQueryChange: (sql: SQLQuery) => void;
|
||||
db: DB;
|
||||
columns: Array<SelectableValue<string>>;
|
||||
isVariableQuery?: boolean;
|
||||
}
|
||||
|
||||
export function SelectRow({ query, onQueryChange, db, columns, isVariableQuery }: SelectRowProps) {
|
||||
export function SelectRow({ query, onQueryChange, db, columns }: SelectRowProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { onSqlChange } = useSqlChange({ query, onQueryChange, db });
|
||||
const timeSeriesAliasOpts: Array<SelectableValue<string>> = [];
|
||||
|
||||
// Get currently used aliases from all columns
|
||||
const usedAliases = useMemo(() => {
|
||||
const aliases = new Set<string>();
|
||||
query.sql?.columns?.forEach((col) => {
|
||||
if (col.alias) {
|
||||
// Remove quotes from alias
|
||||
const cleanAlias = col.alias.replace(/"/g, '');
|
||||
aliases.add(cleanAlias);
|
||||
}
|
||||
});
|
||||
return aliases;
|
||||
}, [query.sql?.columns]);
|
||||
|
||||
// Function to get available alias options for a specific column
|
||||
const getAliasOptions = useCallback(
|
||||
(currentAlias?: string): Array<SelectableValue<string>> => {
|
||||
const aliasOpts: Array<SelectableValue<string>> = [];
|
||||
const cleanCurrentAlias = currentAlias?.replace(/"/g, '');
|
||||
|
||||
// Add necessary alias options for time series format
|
||||
if (query.format === QueryFormat.Timeseries) {
|
||||
if (!usedAliases.has('time') || cleanCurrentAlias === 'time') {
|
||||
aliasOpts.push({ label: t('grafana-sql.components.select-row.label.time', 'time'), value: 'time' });
|
||||
}
|
||||
if (!usedAliases.has('value') || cleanCurrentAlias === 'value') {
|
||||
aliasOpts.push({ label: t('grafana-sql.components.select-row.label.value', 'value'), value: 'value' });
|
||||
}
|
||||
}
|
||||
|
||||
// Add variable query alias options for __text and __value
|
||||
if (isVariableQuery) {
|
||||
if (!usedAliases.has('__text') || cleanCurrentAlias === '__text') {
|
||||
aliasOpts.push({ label: t('grafana-sql.components.select-row.label.__text', '__text'), value: '__text' });
|
||||
}
|
||||
if (!usedAliases.has('__value') || cleanCurrentAlias === '__value') {
|
||||
aliasOpts.push({ label: t('grafana-sql.components.select-row.label.__value', '__value'), value: '__value' });
|
||||
}
|
||||
}
|
||||
|
||||
return aliasOpts;
|
||||
},
|
||||
[query.format, isVariableQuery, usedAliases]
|
||||
);
|
||||
// Add necessary alias options for time series format
|
||||
// when that format has been selected
|
||||
if (query.format === QueryFormat.Timeseries) {
|
||||
timeSeriesAliasOpts.push({ label: t('grafana-sql.components.select-row.label.time', 'time'), value: 'time' });
|
||||
timeSeriesAliasOpts.push({ label: t('grafana-sql.components.select-row.label.value', 'value'), value: 'value' });
|
||||
}
|
||||
|
||||
const onAggregationChange = useCallback(
|
||||
(item: QueryEditorFunctionExpression, index: number) => (aggregation: SelectableValue<string>) => {
|
||||
@@ -182,7 +145,7 @@ export function SelectRow({ query, onQueryChange, db, columns, isVariableQuery }
|
||||
value={item.alias ? toOption(item.alias) : null}
|
||||
inputId={`select-alias-${index}-${uniqueId()}`}
|
||||
data-testid={selectors.components.SQLQueryEditor.selectAlias}
|
||||
options={getAliasOptions(item.alias)}
|
||||
options={timeSeriesAliasOpts}
|
||||
onChange={onAliasChange(item, index)}
|
||||
isClearable
|
||||
menuShouldPortal
|
||||
|
||||
@@ -16,18 +16,9 @@ interface VisualEditorProps extends QueryEditorProps {
|
||||
db: DB;
|
||||
queryRowFilter: QueryRowFilter;
|
||||
onValidate: (isValid: boolean) => void;
|
||||
isVariableQuery?: boolean;
|
||||
}
|
||||
|
||||
export const VisualEditor = ({
|
||||
query,
|
||||
db,
|
||||
queryRowFilter,
|
||||
onChange,
|
||||
onValidate,
|
||||
range,
|
||||
isVariableQuery,
|
||||
}: VisualEditorProps) => {
|
||||
export const VisualEditor = ({ query, db, queryRowFilter, onChange, onValidate, range }: VisualEditorProps) => {
|
||||
const state = useAsync(async () => {
|
||||
const fields = await db.fields(query);
|
||||
return fields;
|
||||
@@ -37,13 +28,7 @@ export const VisualEditor = ({
|
||||
<>
|
||||
<EditorRows>
|
||||
<EditorRow>
|
||||
<SelectRow
|
||||
columns={state.value || []}
|
||||
query={query}
|
||||
onQueryChange={onChange}
|
||||
db={db}
|
||||
isVariableQuery={isVariableQuery}
|
||||
/>
|
||||
<SelectRow columns={state.value || []} query={query} onQueryChange={onChange} db={db} />
|
||||
</EditorRow>
|
||||
{queryRowFilter.filter && (
|
||||
<EditorRow>
|
||||
|
||||
@@ -2,19 +2,20 @@ import { lastValueFrom, Observable, throwError } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
CoreApp,
|
||||
getDefaultTimeRange,
|
||||
DataFrame,
|
||||
DataFrameView,
|
||||
DataQuery,
|
||||
DataQueryRequest,
|
||||
DataQueryResponse,
|
||||
DataSourceInstanceSettings,
|
||||
getDefaultTimeRange,
|
||||
getSearchFilterScopedVar,
|
||||
LegacyMetricFindQueryOptions,
|
||||
MetricFindValue,
|
||||
ScopedVars,
|
||||
TimeRange,
|
||||
CoreApp,
|
||||
getSearchFilterScopedVar,
|
||||
LegacyMetricFindQueryOptions,
|
||||
VariableWithMultiSupport,
|
||||
TimeRange,
|
||||
} from '@grafana/data';
|
||||
import { EditorMode } from '@grafana/plugin-ui';
|
||||
import {
|
||||
@@ -23,16 +24,15 @@ import {
|
||||
FetchResponse,
|
||||
getBackendSrv,
|
||||
getTemplateSrv,
|
||||
reportInteraction,
|
||||
TemplateSrv,
|
||||
toDataQueryResponse,
|
||||
TemplateSrv,
|
||||
reportInteraction,
|
||||
} from '@grafana/runtime';
|
||||
import { DataQuery } from '@grafana/schema';
|
||||
|
||||
import { ResponseParser } from '../ResponseParser';
|
||||
import { SqlQueryEditorLazy } from '../components/QueryEditorLazy';
|
||||
import { MACRO_NAMES } from '../constants';
|
||||
import { DB, QueryFormat, SQLOptions, SQLQuery, SqlQueryModel } from '../types';
|
||||
import { DB, SQLQuery, SQLOptions, SqlQueryModel, QueryFormat } from '../types';
|
||||
import migrateAnnotation from '../utils/migration';
|
||||
|
||||
export abstract class SqlDatasource extends DataSourceWithBackend<SQLQuery, SQLOptions> {
|
||||
@@ -182,7 +182,7 @@ export abstract class SqlDatasource extends DataSourceWithBackend<SQLQuery, SQLO
|
||||
return;
|
||||
}
|
||||
|
||||
async metricFindQuery(query: SQLQuery | string, options?: LegacyMetricFindQueryOptions): Promise<MetricFindValue[]> {
|
||||
async metricFindQuery(query: string, options?: LegacyMetricFindQueryOptions): Promise<MetricFindValue[]> {
|
||||
const range = options?.range;
|
||||
if (range == null) {
|
||||
// i cannot create a scenario where this happens, we handle it just to be sure.
|
||||
@@ -194,17 +194,12 @@ export abstract class SqlDatasource extends DataSourceWithBackend<SQLQuery, SQLO
|
||||
refId = options.variable.name;
|
||||
}
|
||||
|
||||
const queryString = typeof query === 'string' ? query : query.rawSql;
|
||||
if (!queryString) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const scopedVars = {
|
||||
...options?.scopedVars,
|
||||
...getSearchFilterScopedVar({ query: queryString, wildcardChar: '%', options }),
|
||||
...getSearchFilterScopedVar({ query, wildcardChar: '%', options }),
|
||||
};
|
||||
|
||||
const rawSql = this.templateSrv.replace(queryString, scopedVars, this.interpolateVariable);
|
||||
const rawSql = this.templateSrv.replace(query, scopedVars, this.interpolateVariable);
|
||||
|
||||
const interpolatedQuery: SQLQuery = {
|
||||
refId: refId,
|
||||
|
||||
@@ -107,8 +107,6 @@
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"__text": "__text",
|
||||
"__value": "__value",
|
||||
"time": "time",
|
||||
"value": "value"
|
||||
},
|
||||
|
||||
@@ -1913,13 +1913,6 @@ var (
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaOSSBigTent,
|
||||
},
|
||||
{
|
||||
Name: "postgresVariableQueryEditor",
|
||||
Description: "Enable the new variable query editor for the PostgreSQL data source",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaOSSBigTent,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "pluginStoreServiceLoading",
|
||||
Description: "Load plugins on store service startup instead of wire provider, and call RegisterFixedRoles after all plugins are loaded",
|
||||
|
||||
1
pkg/services/featuremgmt/toggles_gen.csv
generated
1
pkg/services/featuremgmt/toggles_gen.csv
generated
@@ -260,7 +260,6 @@ newVizSuggestions,preview,@grafana/dataviz-squad,false,false,true
|
||||
externalVizSuggestions,experimental,@grafana/dataviz-squad,false,false,true
|
||||
preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true
|
||||
jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false
|
||||
postgresVariableQueryEditor,experimental,@grafana/oss-big-tent,false,false,true
|
||||
pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||
newPanelPadding,preview,@grafana/dashboards-squad,false,false,true
|
||||
onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false
|
||||
|
||||
|
13
pkg/services/featuremgmt/toggles_gen.json
generated
13
pkg/services/featuremgmt/toggles_gen.json
generated
@@ -2692,19 +2692,6 @@
|
||||
"expression": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "postgresVariableQueryEditor",
|
||||
"resourceVersion": "1765231394616",
|
||||
"creationTimestamp": "2025-12-08T22:03:14Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enable the new variable query editor for the PostgreSQL data source",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/oss-big-tent",
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "preferLibraryPanelTitle",
|
||||
|
||||
@@ -18,12 +18,12 @@ import {
|
||||
} from './dataquery.gen';
|
||||
import {
|
||||
defaultBucketAgg,
|
||||
defaultMetricAgg,
|
||||
findMetricById,
|
||||
highlightTags,
|
||||
defaultGeoHashPrecisionString,
|
||||
queryTypeToMetricType,
|
||||
} from './queryDef';
|
||||
import { TermsQuery } from './types';
|
||||
import { QueryType, TermsQuery } from './types';
|
||||
import { convertOrderByToMetricId, getScriptValue } from './utils';
|
||||
|
||||
// Omitting 1m, 1h, 1d for now, as these cover the main use cases for calendar_interval
|
||||
@@ -31,9 +31,11 @@ export const calendarIntervals: string[] = ['1w', '1M', '1q', '1y'];
|
||||
|
||||
export class ElasticQueryBuilder {
|
||||
timeField: string;
|
||||
defaultQueryMode?: QueryType;
|
||||
|
||||
constructor(options: { timeField: string }) {
|
||||
constructor(options: { timeField: string; defaultQueryMode?: QueryType }) {
|
||||
this.timeField = options.timeField;
|
||||
this.defaultQueryMode = options.defaultQueryMode;
|
||||
}
|
||||
|
||||
getRangeFilter() {
|
||||
@@ -174,7 +176,10 @@ export class ElasticQueryBuilder {
|
||||
|
||||
build(target: ElasticsearchDataQuery) {
|
||||
// make sure query has defaults;
|
||||
target.metrics = target.metrics || [defaultMetricAgg()];
|
||||
if (!target.metrics || target.metrics.length === 0) {
|
||||
const metricType = queryTypeToMetricType(this.defaultQueryMode);
|
||||
target.metrics = [{ type: metricType, id: '1' }];
|
||||
}
|
||||
target.bucketAggs = target.bucketAggs || [defaultBucketAgg()];
|
||||
target.timeField = this.timeField;
|
||||
let metric: MetricAggregation;
|
||||
|
||||
@@ -62,10 +62,10 @@ export const ElasticsearchProvider = ({
|
||||
// useStatelessReducer will then call `onChange` with the newly generated query
|
||||
useEffect(() => {
|
||||
if (shouldRunInit && isUninitialized) {
|
||||
dispatch(initQuery());
|
||||
dispatch(initQuery(datasource.defaultQueryMode));
|
||||
setShouldRunInit(false);
|
||||
}
|
||||
}, [shouldRunInit, dispatch, isUninitialized]);
|
||||
}, [shouldRunInit, dispatch, isUninitialized, datasource.defaultQueryMode]);
|
||||
|
||||
if (isUninitialized) {
|
||||
return null;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Action } from '@reduxjs/toolkit';
|
||||
|
||||
import { ElasticsearchDataQuery, MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
|
||||
|
||||
import { defaultMetricAgg } from '../../../../queryDef';
|
||||
import { defaultMetricAgg, queryTypeToMetricType } from '../../../../queryDef';
|
||||
import { removeEmpty } from '../../../../utils';
|
||||
import { initQuery } from '../../state';
|
||||
import { isMetricAggregationWithMeta, isMetricAggregationWithSettings, isPipelineAggregation } from '../aggregations';
|
||||
@@ -162,7 +162,8 @@ export const reducer = (
|
||||
if (state && state.length > 0) {
|
||||
return state;
|
||||
}
|
||||
return [defaultMetricAgg('1')];
|
||||
const metricType = queryTypeToMetricType(action.payload);
|
||||
return [{ type: metricType, id: '1' }];
|
||||
}
|
||||
|
||||
return state;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { ElasticsearchDataQuery } from '../../dataquery.gen';
|
||||
import { useDispatch } from '../../hooks/useStatelessReducer';
|
||||
import { renderWithESProvider } from '../../test-helpers/render';
|
||||
|
||||
import { changeMetricType } from './MetricAggregationsEditor/state/actions';
|
||||
import { QueryTypeSelector } from './QueryTypeSelector';
|
||||
|
||||
jest.mock('../../hooks/useStatelessReducer');
|
||||
|
||||
describe('QueryTypeSelector', () => {
|
||||
let dispatch: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch = jest.fn();
|
||||
jest.mocked(useDispatch).mockReturnValue(dispatch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render radio buttons with correct options', () => {
|
||||
const query: ElasticsearchDataQuery = {
|
||||
refId: 'A',
|
||||
query: '',
|
||||
metrics: [{ id: '1', type: 'count' }],
|
||||
bucketAggs: [{ type: 'date_histogram', id: '2' }],
|
||||
};
|
||||
|
||||
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Metrics' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'Logs' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'Raw Data' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'Raw Document' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should dispatch changeMetricType action when radio button is changed', async () => {
|
||||
const query: ElasticsearchDataQuery = {
|
||||
refId: 'A',
|
||||
query: '',
|
||||
metrics: [{ id: '1', type: 'count' }],
|
||||
bucketAggs: [{ type: 'date_histogram', id: '2' }],
|
||||
};
|
||||
|
||||
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
|
||||
|
||||
const logsRadio = screen.getByRole('radio', { name: 'Logs' });
|
||||
await userEvent.click(logsRadio);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'logs' }));
|
||||
});
|
||||
|
||||
it('should convert query type to metric type correctly for raw_data', async () => {
|
||||
const query: ElasticsearchDataQuery = {
|
||||
refId: 'A',
|
||||
query: '',
|
||||
metrics: [{ id: '1', type: 'count' }],
|
||||
bucketAggs: [{ type: 'date_histogram', id: '2' }],
|
||||
};
|
||||
|
||||
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
|
||||
|
||||
const rawDataRadio = screen.getByRole('radio', { name: 'Raw Data' });
|
||||
await userEvent.click(rawDataRadio);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'raw_data' }));
|
||||
});
|
||||
|
||||
it('should convert query type to metric type correctly for raw_document', async () => {
|
||||
const query: ElasticsearchDataQuery = {
|
||||
refId: 'A',
|
||||
query: '',
|
||||
metrics: [{ id: '1', type: 'count' }],
|
||||
bucketAggs: [{ type: 'date_histogram', id: '2' }],
|
||||
};
|
||||
|
||||
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
|
||||
|
||||
const rawDocumentRadio = screen.getByRole('radio', { name: 'Raw Document' });
|
||||
await userEvent.click(rawDocumentRadio);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'raw_document' }));
|
||||
});
|
||||
|
||||
it('should convert metrics query type to count metric type', async () => {
|
||||
const query: ElasticsearchDataQuery = {
|
||||
refId: 'A',
|
||||
query: '',
|
||||
metrics: [{ id: '1', type: 'logs' }],
|
||||
bucketAggs: [{ type: 'date_histogram', id: '2' }],
|
||||
};
|
||||
|
||||
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
|
||||
|
||||
const metricsRadio = screen.getByRole('radio', { name: 'Metrics' });
|
||||
await userEvent.click(metricsRadio);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'count' }));
|
||||
});
|
||||
|
||||
it('should return null when query has no metrics', () => {
|
||||
const query: ElasticsearchDataQuery = {
|
||||
refId: 'A',
|
||||
query: '',
|
||||
metrics: [],
|
||||
bucketAggs: [{ type: 'date_histogram', id: '2' }],
|
||||
};
|
||||
|
||||
const { container } = renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,35 +1,14 @@
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { RadioButtonGroup } from '@grafana/ui';
|
||||
|
||||
import { MetricAggregation } from '../../dataquery.gen';
|
||||
import { QUERY_TYPE_SELECTOR_OPTIONS } from '../../configuration/utils';
|
||||
import { useDispatch } from '../../hooks/useStatelessReducer';
|
||||
import { queryTypeToMetricType } from '../../queryDef';
|
||||
import { QueryType } from '../../types';
|
||||
|
||||
import { useQuery } from './ElasticsearchQueryContext';
|
||||
import { changeMetricType } from './MetricAggregationsEditor/state/actions';
|
||||
import { metricAggregationConfig } from './MetricAggregationsEditor/utils';
|
||||
|
||||
const OPTIONS: Array<SelectableValue<QueryType>> = [
|
||||
{ value: 'metrics', label: 'Metrics' },
|
||||
{ value: 'logs', label: 'Logs' },
|
||||
{ value: 'raw_data', label: 'Raw Data' },
|
||||
{ value: 'raw_document', label: 'Raw Document' },
|
||||
];
|
||||
|
||||
function queryTypeToMetricType(type: QueryType): MetricAggregation['type'] {
|
||||
switch (type) {
|
||||
case 'logs':
|
||||
case 'raw_data':
|
||||
case 'raw_document':
|
||||
return type;
|
||||
case 'metrics':
|
||||
return 'count';
|
||||
default:
|
||||
// should never happen
|
||||
throw new Error(`invalid query type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const QueryTypeSelector = () => {
|
||||
const query = useQuery();
|
||||
const dispatch = useDispatch();
|
||||
@@ -47,5 +26,12 @@ export const QueryTypeSelector = () => {
|
||||
dispatch(changeMetricType({ id: firstMetric.id, type: queryTypeToMetricType(newQueryType) }));
|
||||
};
|
||||
|
||||
return <RadioButtonGroup<QueryType> fullWidth={false} options={OPTIONS} value={queryType} onChange={onChange} />;
|
||||
return (
|
||||
<RadioButtonGroup<QueryType>
|
||||
fullWidth={false}
|
||||
options={QUERY_TYPE_SELECTOR_OPTIONS}
|
||||
value={queryType}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Action, createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { ElasticsearchDataQuery } from '../../dataquery.gen';
|
||||
import { QueryType } from '../../types';
|
||||
|
||||
/**
|
||||
* When the `initQuery` Action is dispatched, the query gets populated with default values where values are not present.
|
||||
* This means it won't override any existing value in place, but just ensure the query is in a "runnable" state.
|
||||
*/
|
||||
export const initQuery = createAction('init');
|
||||
export const initQuery = createAction<QueryType | undefined>('init');
|
||||
|
||||
export const changeQuery = createAction<ElasticsearchDataQuery['query']>('change_query');
|
||||
|
||||
|
||||
@@ -41,4 +41,16 @@ describe('ElasticDetails', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should change default query mode when selected', async () => {
|
||||
const onChangeMock = jest.fn();
|
||||
render(<ElasticDetails onChange={onChangeMock} value={createDefaultConfigOptions()} />);
|
||||
const selectEl = screen.getByLabelText('Default query mode');
|
||||
|
||||
await selectEvent.select(selectEl, 'Logs', { container: document.body });
|
||||
|
||||
expect(onChangeMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ jsonData: expect.objectContaining({ defaultQueryMode: 'logs' }) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { DataSourceSettings, SelectableValue } from '@grafana/data';
|
||||
import type { DataSourceSettings, SelectableValue } from '@grafana/data';
|
||||
import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/plugin-ui';
|
||||
import { InlineField, Input, Select, InlineSwitch } from '@grafana/ui';
|
||||
|
||||
import { ElasticsearchOptions, Interval } from '../types';
|
||||
import type { ElasticsearchOptions, Interval, QueryType } from '../types';
|
||||
|
||||
import { QUERY_TYPE_SELECTOR_OPTIONS } from './utils';
|
||||
|
||||
const indexPatternTypes: Array<SelectableValue<'none' | Interval>> = [
|
||||
{ label: 'No pattern', value: 'none' },
|
||||
@@ -127,6 +129,29 @@ export const ElasticDetails = ({ value, onChange }: Props) => {
|
||||
onChange={jsonDataSwitchChangeHandler('includeFrozen', value, onChange)}
|
||||
/>
|
||||
</InlineField>
|
||||
|
||||
<InlineField
|
||||
label="Default query mode"
|
||||
htmlFor="es_config_defaultQueryMode"
|
||||
labelWidth={29}
|
||||
tooltip="Default query mode to use for the data source. Defaults to 'Metrics'."
|
||||
>
|
||||
<Select
|
||||
inputId="es_config_defaultQueryMode"
|
||||
value={value.jsonData.defaultQueryMode}
|
||||
options={QUERY_TYPE_SELECTOR_OPTIONS}
|
||||
onChange={(selectedMode) => {
|
||||
onChange({
|
||||
...value,
|
||||
jsonData: {
|
||||
...value.jsonData,
|
||||
defaultQueryMode: selectedMode.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
width={24}
|
||||
/>
|
||||
</InlineField>
|
||||
</ConfigSubSection>
|
||||
);
|
||||
};
|
||||
@@ -209,3 +234,6 @@ const intervalHandler =
|
||||
export function defaultMaxConcurrentShardRequests() {
|
||||
return 5;
|
||||
}
|
||||
export function defaultQueryMode(): QueryType {
|
||||
return 'metrics';
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export function createDefaultConfigOptions(): DataSourceSettings<ElasticsearchOp
|
||||
maxConcurrentShardRequests: 300,
|
||||
logMessageField: 'test.message',
|
||||
logLevelField: 'test.level',
|
||||
defaultQueryMode: 'metrics',
|
||||
},
|
||||
secureJsonFields: {},
|
||||
} as DataSourceSettings<ElasticsearchOptions>;
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { DataSourceSettings } from '@grafana/data';
|
||||
import { DataSourceSettings, SelectableValue } from '@grafana/data';
|
||||
|
||||
import { ElasticsearchOptions } from '../types';
|
||||
import { ElasticsearchOptions, QueryType } from '../types';
|
||||
|
||||
import { defaultMaxConcurrentShardRequests } from './ElasticDetails';
|
||||
import { defaultMaxConcurrentShardRequests, defaultQueryMode } from './ElasticDetails';
|
||||
|
||||
export const QUERY_TYPE_SELECTOR_OPTIONS: Array<SelectableValue<QueryType>> = [
|
||||
{ value: 'metrics', label: 'Metrics' },
|
||||
{ value: 'logs', label: 'Logs' },
|
||||
{ value: 'raw_data', label: 'Raw Data' },
|
||||
{ value: 'raw_document', label: 'Raw Document' },
|
||||
];
|
||||
|
||||
export const coerceOptions = (
|
||||
options: DataSourceSettings<ElasticsearchOptions, {}>
|
||||
@@ -16,6 +23,7 @@ export const coerceOptions = (
|
||||
logMessageField: options.jsonData.logMessageField || '',
|
||||
logLevelField: options.jsonData.logLevelField || '',
|
||||
includeFrozen: options.jsonData.includeFrozen ?? false,
|
||||
defaultQueryMode: options.jsonData.defaultQueryMode || defaultQueryMode(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
isElasticsearchResponseWithAggregations,
|
||||
isElasticsearchResponseWithHits,
|
||||
ElasticsearchHits,
|
||||
QueryType,
|
||||
} from './types';
|
||||
import { getScriptValue, isTimeSeriesQuery } from './utils';
|
||||
|
||||
@@ -127,6 +128,7 @@ export class ElasticDatasource
|
||||
includeFrozen: boolean;
|
||||
isProxyAccess: boolean;
|
||||
databaseVersion: SemVer | null;
|
||||
defaultQueryMode?: QueryType;
|
||||
|
||||
constructor(
|
||||
instanceSettings: DataSourceInstanceSettings<ElasticsearchOptions>,
|
||||
@@ -146,9 +148,6 @@ export class ElasticDatasource
|
||||
this.intervalPattern = settingsData.interval;
|
||||
this.interval = settingsData.timeInterval;
|
||||
this.maxConcurrentShardRequests = settingsData.maxConcurrentShardRequests;
|
||||
this.queryBuilder = new ElasticQueryBuilder({
|
||||
timeField: this.timeField,
|
||||
});
|
||||
this.logLevelField = settingsData.logLevelField || '';
|
||||
this.dataLinks = settingsData.dataLinks || [];
|
||||
this.includeFrozen = settingsData.includeFrozen ?? false;
|
||||
@@ -157,7 +156,11 @@ export class ElasticDatasource
|
||||
this.annotations = {
|
||||
QueryEditor: ElasticsearchAnnotationsQueryEditor,
|
||||
};
|
||||
|
||||
this.defaultQueryMode = settingsData.defaultQueryMode;
|
||||
this.queryBuilder = new ElasticQueryBuilder({
|
||||
timeField: this.timeField,
|
||||
defaultQueryMode: this.defaultQueryMode,
|
||||
});
|
||||
if (this.logLevelField === '') {
|
||||
this.logLevelField = undefined;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isPipelineAgg, isPipelineAggWithMultipleBucketPaths } from './queryDef';
|
||||
import { isPipelineAgg, isPipelineAggWithMultipleBucketPaths, queryTypeToMetricType } from './queryDef';
|
||||
import type { QueryType } from './types';
|
||||
|
||||
describe('ElasticQueryDef', () => {
|
||||
describe('isPipelineMetric', () => {
|
||||
@@ -36,4 +37,49 @@ describe('ElasticQueryDef', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryTypeToMetricType', () => {
|
||||
describe('when type is undefined', () => {
|
||||
test('should return count as default', () => {
|
||||
const result = queryTypeToMetricType(undefined);
|
||||
expect(result).toBe('count');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when type is metrics', () => {
|
||||
test('should return count', () => {
|
||||
const result = queryTypeToMetricType('metrics' as QueryType);
|
||||
expect(result).toBe('count');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when type is logs', () => {
|
||||
test('should return logs', () => {
|
||||
const result = queryTypeToMetricType('logs' as QueryType);
|
||||
expect(result).toBe('logs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when type is raw_data', () => {
|
||||
test('should return raw_data', () => {
|
||||
const result = queryTypeToMetricType('raw_data' as QueryType);
|
||||
expect(result).toBe('raw_data');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when type is raw_document', () => {
|
||||
test('should return raw_document', () => {
|
||||
const result = queryTypeToMetricType('raw_document' as QueryType);
|
||||
expect(result).toBe('raw_document');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when type is invalid', () => {
|
||||
test('should throw an error', () => {
|
||||
expect(() => {
|
||||
queryTypeToMetricType('invalid_type' as QueryType);
|
||||
}).toThrow('invalid query type: invalid_type');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MetricAggregationType,
|
||||
MovingAverageModelOption,
|
||||
} from './dataquery.gen';
|
||||
import type { QueryType } from './types';
|
||||
|
||||
export const extendedStats: ExtendedStat[] = [
|
||||
{ label: 'Avg', value: 'avg' },
|
||||
@@ -42,6 +43,24 @@ export function defaultBucketAgg(id = '1'): DateHistogram {
|
||||
return { type: 'date_histogram', id, settings: { interval: 'auto' } };
|
||||
}
|
||||
|
||||
export function queryTypeToMetricType(type?: QueryType): MetricAggregation['type'] {
|
||||
if (!type) {
|
||||
return 'count'; // Default fallback
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'logs':
|
||||
case 'raw_data':
|
||||
case 'raw_document':
|
||||
return type;
|
||||
case 'metrics':
|
||||
return 'count';
|
||||
default:
|
||||
// should never happen
|
||||
throw new Error(`invalid query type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const findMetricById = (metrics: MetricAggregation[], id: MetricAggregation['id']) =>
|
||||
metrics.find((metric) => metric.id === id);
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface ElasticsearchOptions extends DataSourceJsonData {
|
||||
index?: string;
|
||||
sigV4Auth?: boolean;
|
||||
oauthPassThru?: boolean;
|
||||
defaultQueryMode?: QueryType;
|
||||
}
|
||||
|
||||
export type QueryType = 'metrics' | 'logs' | 'raw_data' | 'raw_document';
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { QueryEditorProps } from '@grafana/data';
|
||||
import { QueryHeaderProps, SQLOptions, SQLQuery, SqlQueryEditorLazy } from '@grafana/sql';
|
||||
|
||||
import { PostgresDatasource } from './datasource';
|
||||
import { migrateVariableQuery } from './migrations';
|
||||
|
||||
const queryHeaderProps: Pick<QueryHeaderProps, 'dialect' | 'hideRunButton' | 'hideFormatSelector'> = {
|
||||
dialect: 'postgres',
|
||||
hideRunButton: true,
|
||||
hideFormatSelector: true,
|
||||
};
|
||||
|
||||
export function VariableQueryEditor(props: QueryEditorProps<PostgresDatasource, SQLQuery, SQLOptions>) {
|
||||
const newProps = {
|
||||
...props,
|
||||
query: migrateVariableQuery(props.query),
|
||||
queryHeaderProps,
|
||||
isVariableQuery: true,
|
||||
};
|
||||
return <SqlQueryEditorLazy {...newProps} />;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { DataSourceInstanceSettings, ScopedVars, VariableWithMultiSupport } from '@grafana/data';
|
||||
import { LanguageDefinition } from '@grafana/plugin-ui';
|
||||
import { config, TemplateSrv } from '@grafana/runtime';
|
||||
import { TemplateSrv } from '@grafana/runtime';
|
||||
import {
|
||||
COMMON_FNS,
|
||||
DB,
|
||||
@@ -16,23 +16,15 @@ import {
|
||||
|
||||
import { PostgresQueryModel } from './PostgresQueryModel';
|
||||
import { getSchema, getTimescaleDBVersion, getVersion, showTables } from './postgresMetaQuery';
|
||||
import { transformMetricFindResponse } from './responseParser';
|
||||
import { fetchColumns, fetchTables, getSqlCompletionProvider } from './sqlCompletionProvider';
|
||||
import { getFieldConfig, toRawSql } from './sqlUtil';
|
||||
import { PostgresOptions } from './types';
|
||||
import { SQLVariableSupport } from './variables';
|
||||
|
||||
export class PostgresDatasource extends SqlDatasource {
|
||||
sqlLanguageDefinition: LanguageDefinition | undefined = undefined;
|
||||
|
||||
constructor(instanceSettings: DataSourceInstanceSettings<PostgresOptions>) {
|
||||
super(instanceSettings);
|
||||
if (config.featureToggles.postgresVariableQueryEditor) {
|
||||
this.variables = new SQLVariableSupport(this);
|
||||
this.responseParser = {
|
||||
transformMetricFindResponse: transformMetricFindResponse,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getQueryModel(target?: SQLQuery, templateSrv?: TemplateSrv, scopedVars?: ScopedVars): PostgresQueryModel {
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { QueryFormat, SQLQuery } from '@grafana/sql';
|
||||
|
||||
import { migrateVariableQuery } from './migrations';
|
||||
|
||||
describe('migrateVariableQuery', () => {
|
||||
describe('when given a string query (legacy format)', () => {
|
||||
it('should convert to SQLQuery format with rawSql and query fields', () => {
|
||||
const result = migrateVariableQuery('SELECT hostname FROM hosts');
|
||||
|
||||
expect(result.rawSql).toBe('SELECT hostname FROM hosts');
|
||||
expect(result.query).toBe('SELECT hostname FROM hosts');
|
||||
expect(result.refId).toBe('SQLVariableQueryEditor-VariableQuery');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const result = migrateVariableQuery('');
|
||||
|
||||
expect(result.rawSql).toBe('');
|
||||
expect(result.query).toBe('');
|
||||
});
|
||||
|
||||
it('should handle complex SQL queries', () => {
|
||||
const complexQuery = `SELECT hostname AS __text, id AS __value FROM hosts WHERE region = 'us-east-1'`;
|
||||
const result = migrateVariableQuery(complexQuery);
|
||||
|
||||
expect(result.rawSql).toBe(complexQuery);
|
||||
expect(result.query).toBe(complexQuery);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when given an SQLQuery object', () => {
|
||||
it('should preserve the rawSql and add query field', () => {
|
||||
const sqlQuery = {
|
||||
refId: 'A',
|
||||
rawSql: 'SELECT id FROM table',
|
||||
};
|
||||
const result = migrateVariableQuery(sqlQuery);
|
||||
|
||||
expect(result.rawSql).toBe('SELECT id FROM table');
|
||||
expect(result.query).toBe('SELECT id FROM table');
|
||||
expect(result.refId).toBe('A');
|
||||
});
|
||||
|
||||
it('should handle SQLQuery with empty rawSql', () => {
|
||||
const sqlQuery = {
|
||||
refId: 'A',
|
||||
rawSql: '',
|
||||
};
|
||||
const result = migrateVariableQuery(sqlQuery);
|
||||
|
||||
expect(result.rawSql).toBe('');
|
||||
expect(result.query).toBe('');
|
||||
});
|
||||
|
||||
it('should handle SQLQuery without rawSql', () => {
|
||||
const sqlQuery = {
|
||||
refId: 'A',
|
||||
};
|
||||
const result = migrateVariableQuery(sqlQuery);
|
||||
|
||||
expect(result.query).toBe('');
|
||||
});
|
||||
|
||||
it('should preserve all existing SQLQuery properties', () => {
|
||||
const sqlQuery: SQLQuery = {
|
||||
refId: 'B',
|
||||
rawSql: 'SELECT * FROM users',
|
||||
format: QueryFormat.Table,
|
||||
table: 'users',
|
||||
dataset: 'mydb',
|
||||
};
|
||||
const result = migrateVariableQuery(sqlQuery);
|
||||
|
||||
expect(result.refId).toBe('B');
|
||||
expect(result.rawSql).toBe('SELECT * FROM users');
|
||||
expect(result.query).toBe('SELECT * FROM users');
|
||||
expect(result.format).toBe(QueryFormat.Table);
|
||||
expect(result.table).toBe('users');
|
||||
expect(result.dataset).toBe('mydb');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { applyQueryDefaults, type SQLQuery } from '@grafana/sql';
|
||||
|
||||
import type { VariableQuery } from './types';
|
||||
|
||||
export function migrateVariableQuery(rawQuery: string | SQLQuery): VariableQuery {
|
||||
if (typeof rawQuery !== 'string') {
|
||||
return {
|
||||
...rawQuery,
|
||||
query: rawQuery.rawSql || '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...applyQueryDefaults({
|
||||
refId: 'SQLVariableQueryEditor-VariableQuery',
|
||||
rawSql: rawQuery,
|
||||
}),
|
||||
query: rawQuery,
|
||||
};
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { FieldType, DataFrame } from '@grafana/data';
|
||||
|
||||
import { transformMetricFindResponse } from './responseParser';
|
||||
|
||||
describe('transformMetricFindResponse function', () => {
|
||||
it('should handle big arrays', () => {
|
||||
const stringValues = new Array(150_000).fill('a');
|
||||
const numberValues = new Array(150_000).fill(1);
|
||||
|
||||
const frame: DataFrame = {
|
||||
fields: [
|
||||
{ name: 'name', type: FieldType.string, config: {}, values: stringValues },
|
||||
{ name: 'value', type: FieldType.number, config: {}, values: numberValues },
|
||||
],
|
||||
length: stringValues.length,
|
||||
};
|
||||
|
||||
const result = transformMetricFindResponse(frame);
|
||||
|
||||
// Without __text and __value fields, all values are added as text-only entries
|
||||
// 150,000 'a' values + 150,000 1 values = 300,000 total
|
||||
// After deduplication by text, we get 2 unique items ('a' and 1)
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
const textValues = result.map((r) => r.text);
|
||||
expect(textValues).toContain('a');
|
||||
expect(textValues).toContain(1);
|
||||
});
|
||||
|
||||
it('should add all values from multiple fields without __text/__value (backwards compatible)', () => {
|
||||
const frame: DataFrame = {
|
||||
fields: [
|
||||
{ name: 'id', type: FieldType.string, config: {}, values: ['user1', 'user2', 'user3'] },
|
||||
{
|
||||
name: 'email',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: ['user1@test.com', 'user2@test.com', 'user3@test.com'],
|
||||
},
|
||||
{ name: 'role', type: FieldType.string, config: {}, values: ['admin', 'user', 'guest'] },
|
||||
],
|
||||
length: 3,
|
||||
};
|
||||
|
||||
const result = transformMetricFindResponse(frame);
|
||||
|
||||
// Without __text and __value, all values from all fields are added as text-only entries
|
||||
expect(result).toHaveLength(9);
|
||||
|
||||
// Entries should only have text, no value or properties
|
||||
const user1Entry = result.find((r) => r.text === 'user1');
|
||||
expect(user1Entry).toEqual({ text: 'user1' });
|
||||
|
||||
const emailEntry = result.find((r) => r.text === 'user1@test.com');
|
||||
expect(emailEntry).toEqual({ text: 'user1@test.com' });
|
||||
});
|
||||
|
||||
it('should handle single field (backwards compatible)', () => {
|
||||
const frame: DataFrame = {
|
||||
fields: [{ name: 'name', type: FieldType.string, config: {}, values: ['value1', 'value2'] }],
|
||||
length: 2,
|
||||
};
|
||||
|
||||
const result = transformMetricFindResponse(frame);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
// Without __text and __value, values are added as text-only entries
|
||||
expect(result[0]).toEqual({ text: 'value1' });
|
||||
expect(result[1]).toEqual({ text: 'value2' });
|
||||
});
|
||||
|
||||
it('should still handle __text and __value fields', () => {
|
||||
const frame: DataFrame = {
|
||||
fields: [
|
||||
{ name: '__text', type: FieldType.string, config: {}, values: ['Display 1', 'Display 2'] },
|
||||
{ name: '__value', type: FieldType.string, config: {}, values: ['val1', 'val2'] },
|
||||
],
|
||||
length: 2,
|
||||
};
|
||||
|
||||
const result = transformMetricFindResponse(frame);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
text: 'Display 1',
|
||||
value: 'val1',
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
text: 'Display 2',
|
||||
value: 'val2',
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip fields named "text" or "value" in properties when __text and __value are present', () => {
|
||||
const frame: DataFrame = {
|
||||
fields: [
|
||||
{ name: '__text', type: FieldType.string, config: {}, values: ['Display 1', 'Display 2'] },
|
||||
{ name: '__value', type: FieldType.string, config: {}, values: ['val1', 'val2'] },
|
||||
{ name: 'text', type: FieldType.string, config: {}, values: ['Text 1', 'Text 2'] },
|
||||
{ name: 'value', type: FieldType.string, config: {}, values: ['Value 1', 'Value 2'] },
|
||||
{ name: 'description', type: FieldType.string, config: {}, values: ['Desc 1', 'Desc 2'] },
|
||||
],
|
||||
length: 2,
|
||||
};
|
||||
|
||||
const result = transformMetricFindResponse(frame);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
// Fields named 'text' and 'value' should not be in properties
|
||||
expect(result[0]).toEqual({
|
||||
text: 'Display 1',
|
||||
value: 'val1',
|
||||
properties: {
|
||||
description: 'Desc 1',
|
||||
},
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
text: 'Display 2',
|
||||
value: 'val2',
|
||||
properties: {
|
||||
description: 'Desc 2',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should add additional fields as properties when __text and __value are present', () => {
|
||||
const frame: DataFrame = {
|
||||
fields: [
|
||||
{ name: '__text', type: FieldType.string, config: {}, values: ['Display 1', 'Display 2'] },
|
||||
{ name: '__value', type: FieldType.string, config: {}, values: ['val1', 'val2'] },
|
||||
{ name: 'category', type: FieldType.string, config: {}, values: ['cat1', 'cat2'] },
|
||||
{ name: 'priority', type: FieldType.number, config: {}, values: [1, 2] },
|
||||
],
|
||||
length: 2,
|
||||
};
|
||||
|
||||
const result = transformMetricFindResponse(frame);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
text: 'Display 1',
|
||||
value: 'val1',
|
||||
properties: {
|
||||
category: 'cat1',
|
||||
priority: '1',
|
||||
},
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
text: 'Display 2',
|
||||
value: 'val2',
|
||||
properties: {
|
||||
category: 'cat2',
|
||||
priority: '2',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { uniqBy } from 'lodash';
|
||||
|
||||
import { DataFrame, Field, MetricFindValue } from '@grafana/data';
|
||||
|
||||
const RESERVED_PROPERTY_NAMES = ['text', 'value', '__text', '__value'];
|
||||
|
||||
export function transformMetricFindResponse(frame: DataFrame): MetricFindValue[] {
|
||||
const values: MetricFindValue[] = [];
|
||||
const textField = frame.fields.find((f) => f.name === '__text');
|
||||
const valueField = frame.fields.find((f) => f.name === '__value');
|
||||
|
||||
if (textField && valueField) {
|
||||
for (let i = 0; i < textField.values.length; i++) {
|
||||
values.push({ text: '' + textField.values[i], value: '' + valueField.values[i] });
|
||||
|
||||
const properties = buildProperties(frame.fields, i);
|
||||
if (properties) {
|
||||
values[i].properties = properties;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const field of frame.fields) {
|
||||
for (const value of field.values) {
|
||||
values.push({ text: value });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return uniqBy(values, 'text');
|
||||
}
|
||||
|
||||
function buildProperties(fields: Field[], rowIndex: number): Record<string, string> | undefined {
|
||||
if (fields.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const properties: Record<string, string> = {};
|
||||
|
||||
for (const field of fields) {
|
||||
if (!RESERVED_PROPERTY_NAMES.includes(field.name)) {
|
||||
properties[field.name] = '' + field.values[rowIndex];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(properties).length > 0 ? properties : undefined;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SQLOptions, SQLQuery } from '@grafana/sql';
|
||||
import { SQLOptions } from '@grafana/sql';
|
||||
|
||||
export enum PostgresTLSModes {
|
||||
disable = 'disable',
|
||||
@@ -25,7 +25,3 @@ export interface PostgresOptions extends SQLOptions {
|
||||
export interface SecureJsonData {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface VariableQuery extends SQLQuery {
|
||||
query: string;
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { from, map, Observable } from 'rxjs';
|
||||
|
||||
import { CustomVariableSupport, DataQueryRequest, MetricFindValue } from '@grafana/data';
|
||||
import { applyQueryDefaults, SQLQuery } from '@grafana/sql';
|
||||
|
||||
import { VariableQueryEditor } from './VariableQueryEditor';
|
||||
import { PostgresDatasource } from './datasource';
|
||||
import { migrateVariableQuery } from './migrations';
|
||||
|
||||
export class SQLVariableSupport extends CustomVariableSupport<PostgresDatasource, SQLQuery> {
|
||||
constructor(private readonly datasource: PostgresDatasource) {
|
||||
super();
|
||||
}
|
||||
|
||||
editor = VariableQueryEditor;
|
||||
|
||||
query(request: DataQueryRequest<SQLQuery>): Observable<{ data: MetricFindValue[] }> {
|
||||
if (!request.targets || request.targets.length === 0) {
|
||||
return from(Promise.resolve([])).pipe(map((data) => ({ data })));
|
||||
}
|
||||
|
||||
const queryObj = migrateVariableQuery(request.targets[0]);
|
||||
const result = this.datasource.metricFindQuery(queryObj, { scopedVars: request.scopedVars, range: request.range });
|
||||
|
||||
return from(result).pipe(map((data) => ({ data })));
|
||||
}
|
||||
|
||||
getDefaultQuery(): Partial<SQLQuery> {
|
||||
return applyQueryDefaults({ refId: 'SQLVariableQueryEditor-VariableQuery' });
|
||||
}
|
||||
}
|
||||
@@ -3702,7 +3702,6 @@ __metadata:
|
||||
"@grafana/i18n": "npm:12.4.0-pre"
|
||||
"@grafana/plugin-ui": "npm:^0.11.0"
|
||||
"@grafana/runtime": "npm:12.4.0-pre"
|
||||
"@grafana/schema": "npm:12.4.0-pre"
|
||||
"@grafana/ui": "npm:12.4.0-pre"
|
||||
"@react-awesome-query-builder/ui": "npm:6.6.15"
|
||||
"@testing-library/dom": "npm:10.4.1"
|
||||
|
||||
Reference in New Issue
Block a user