mirror of
https://github.com/grafana/grafana.git
synced 2025-12-22 04:34:27 +08:00
Compare commits
15 Commits
alerting/c
...
zoltan/pos
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8555bcba5b | ||
|
|
4c601944e1 | ||
|
|
a632f1f26a | ||
|
|
4d82e4295f | ||
|
|
a91f64b9f5 | ||
|
|
b0e1ff8073 | ||
|
|
5c455ec2bc | ||
|
|
d5215a5be2 | ||
|
|
15c93100ab | ||
|
|
ab9b070eb0 | ||
|
|
e40673b298 | ||
|
|
7ea009c7f8 | ||
|
|
fef6196195 | ||
|
|
b50cf6e067 | ||
|
|
ccdb6ff261 |
6
.github/workflows/add-to-whats-new.yml
vendored
6
.github/workflows/add-to-whats-new.yml
vendored
@@ -1,11 +1,11 @@
|
|||||||
name: Add comment about adding a What's new note
|
name: Add comment about adding a What's new note for either what's new or breaking changes
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [labeled]
|
types: [labeled]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
add-comment:
|
add-comment:
|
||||||
if: ${{ ! github.event.pull_request.head.repo.fork && contains(github.event.pull_request.labels.*.name, 'add to what''s new') }}
|
if: ${{ ! github.event.pull_request.head.repo.fork && (contains(github.event.pull_request.labels.*.name, 'add to what''s new') || contains(github.event.pull_request.labels.*.name, 'breaking change') || contains(github.event.pull_request.labels.*.name, 'levitate breaking change')) }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
@@ -13,4 +13,4 @@ jobs:
|
|||||||
- uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
- uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||||
with:
|
with:
|
||||||
message: |
|
message: |
|
||||||
Since you've added the `Add to what's new` label, consider drafting a [What's new note](https://admin.grafana.com/content-admin/#/collections/whats-new/new) for this feature.
|
Since you've added the `What's New` or a breaking change label, consider drafting a [What's new note](https://admin.grafana.com/content-admin/#/collections/whats-new/new) for this feature.
|
||||||
|
|||||||
@@ -279,7 +279,41 @@ 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.
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
SELECT hostname FROM host
|
SELECT hostname FROM host
|
||||||
@@ -297,7 +331,9 @@ To use time range dependent macros like `$__timeFilter(column)` in your query, y
|
|||||||
SELECT event_name FROM event_log WHERE $__timeFilter(time_column)
|
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. 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.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
SELECT hostname AS __text, id AS __value FROM host
|
SELECT hostname AS __text, id AS __value FROM host
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
import stringHash from 'string-hash';
|
import stringHash from 'string-hash';
|
||||||
import tinycolor from 'tinycolor2';
|
import tinycolor from 'tinycolor2';
|
||||||
|
|
||||||
|
import { t } from '@grafana/i18n';
|
||||||
|
|
||||||
import { getContrastRatio } from '../themes/colorManipulator';
|
import { getContrastRatio } from '../themes/colorManipulator';
|
||||||
import { GrafanaTheme2 } from '../themes/types';
|
import { GrafanaTheme2 } from '../themes/types';
|
||||||
import { reduceField } from '../transformations/fieldReducer';
|
import { reduceField } from '../transformations/fieldReducer';
|
||||||
@@ -30,10 +32,14 @@ export interface FieldColorMode extends RegistryItem {
|
|||||||
isContinuous?: boolean;
|
isContinuous?: boolean;
|
||||||
isByValue?: boolean;
|
isByValue?: boolean;
|
||||||
useSeriesName?: boolean;
|
useSeriesName?: boolean;
|
||||||
|
group?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
||||||
|
const accessibleGroup = t('grafana-data.field.fieldColor.accessibleGroup', 'Accessible');
|
||||||
|
const otherGroup = t('grafana-data.field.fieldColor.otherGroup', 'Others');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: FieldColorModeId.Fixed,
|
id: FieldColorModeId.Fixed,
|
||||||
@@ -88,6 +94,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
|||||||
isContinuous: true,
|
isContinuous: true,
|
||||||
isByValue: true,
|
isByValue: true,
|
||||||
interpolator: interpolateViridis,
|
interpolator: interpolateViridis,
|
||||||
|
group: accessibleGroup,
|
||||||
}),
|
}),
|
||||||
new FieldColorSchemeMode({
|
new FieldColorSchemeMode({
|
||||||
id: FieldColorModeId.ContinuousMagma,
|
id: FieldColorModeId.ContinuousMagma,
|
||||||
@@ -95,6 +102,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
|||||||
isContinuous: true,
|
isContinuous: true,
|
||||||
isByValue: true,
|
isByValue: true,
|
||||||
interpolator: interpolateMagma,
|
interpolator: interpolateMagma,
|
||||||
|
group: accessibleGroup,
|
||||||
}),
|
}),
|
||||||
new FieldColorSchemeMode({
|
new FieldColorSchemeMode({
|
||||||
id: FieldColorModeId.ContinuousPlasma,
|
id: FieldColorModeId.ContinuousPlasma,
|
||||||
@@ -102,6 +110,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
|||||||
isContinuous: true,
|
isContinuous: true,
|
||||||
isByValue: true,
|
isByValue: true,
|
||||||
interpolator: interpolatePlasma,
|
interpolator: interpolatePlasma,
|
||||||
|
group: accessibleGroup,
|
||||||
}),
|
}),
|
||||||
new FieldColorSchemeMode({
|
new FieldColorSchemeMode({
|
||||||
id: FieldColorModeId.ContinuousInferno,
|
id: FieldColorModeId.ContinuousInferno,
|
||||||
@@ -109,6 +118,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
|||||||
isContinuous: true,
|
isContinuous: true,
|
||||||
isByValue: true,
|
isByValue: true,
|
||||||
interpolator: interpolateInferno,
|
interpolator: interpolateInferno,
|
||||||
|
group: accessibleGroup,
|
||||||
}),
|
}),
|
||||||
new FieldColorSchemeMode({
|
new FieldColorSchemeMode({
|
||||||
id: FieldColorModeId.ContinuousCividis,
|
id: FieldColorModeId.ContinuousCividis,
|
||||||
@@ -116,6 +126,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
|||||||
isContinuous: true,
|
isContinuous: true,
|
||||||
isByValue: true,
|
isByValue: true,
|
||||||
interpolator: interpolateCividis,
|
interpolator: interpolateCividis,
|
||||||
|
group: accessibleGroup,
|
||||||
}),
|
}),
|
||||||
new FieldColorSchemeMode({
|
new FieldColorSchemeMode({
|
||||||
id: FieldColorModeId.ContinuousGrYlRd,
|
id: FieldColorModeId.ContinuousGrYlRd,
|
||||||
@@ -123,6 +134,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
|||||||
isContinuous: true,
|
isContinuous: true,
|
||||||
isByValue: true,
|
isByValue: true,
|
||||||
getColors: (theme: GrafanaTheme2) => ['green', 'yellow', 'red'],
|
getColors: (theme: GrafanaTheme2) => ['green', 'yellow', 'red'],
|
||||||
|
group: otherGroup,
|
||||||
}),
|
}),
|
||||||
new FieldColorSchemeMode({
|
new FieldColorSchemeMode({
|
||||||
id: FieldColorModeId.ContinuousRdYlGr,
|
id: FieldColorModeId.ContinuousRdYlGr,
|
||||||
@@ -130,6 +142,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
|||||||
isContinuous: true,
|
isContinuous: true,
|
||||||
isByValue: true,
|
isByValue: true,
|
||||||
getColors: (theme: GrafanaTheme2) => ['red', 'yellow', 'green'],
|
getColors: (theme: GrafanaTheme2) => ['red', 'yellow', 'green'],
|
||||||
|
group: otherGroup,
|
||||||
}),
|
}),
|
||||||
new FieldColorSchemeMode({
|
new FieldColorSchemeMode({
|
||||||
id: FieldColorModeId.ContinuousBlYlRd,
|
id: FieldColorModeId.ContinuousBlYlRd,
|
||||||
@@ -137,6 +150,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
|||||||
isContinuous: true,
|
isContinuous: true,
|
||||||
isByValue: true,
|
isByValue: true,
|
||||||
getColors: (theme: GrafanaTheme2) => ['dark-blue', 'super-light-yellow', 'dark-red'],
|
getColors: (theme: GrafanaTheme2) => ['dark-blue', 'super-light-yellow', 'dark-red'],
|
||||||
|
group: otherGroup,
|
||||||
}),
|
}),
|
||||||
new FieldColorSchemeMode({
|
new FieldColorSchemeMode({
|
||||||
id: FieldColorModeId.ContinuousYlRd,
|
id: FieldColorModeId.ContinuousYlRd,
|
||||||
@@ -144,6 +158,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
|||||||
isContinuous: true,
|
isContinuous: true,
|
||||||
isByValue: true,
|
isByValue: true,
|
||||||
getColors: (theme: GrafanaTheme2) => ['super-light-yellow', 'dark-red'],
|
getColors: (theme: GrafanaTheme2) => ['super-light-yellow', 'dark-red'],
|
||||||
|
group: otherGroup,
|
||||||
}),
|
}),
|
||||||
new FieldColorSchemeMode({
|
new FieldColorSchemeMode({
|
||||||
id: FieldColorModeId.ContinuousBlPu,
|
id: FieldColorModeId.ContinuousBlPu,
|
||||||
@@ -151,6 +166,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
|||||||
isContinuous: true,
|
isContinuous: true,
|
||||||
isByValue: true,
|
isByValue: true,
|
||||||
getColors: (theme: GrafanaTheme2) => ['blue', 'purple'],
|
getColors: (theme: GrafanaTheme2) => ['blue', 'purple'],
|
||||||
|
group: otherGroup,
|
||||||
}),
|
}),
|
||||||
new FieldColorSchemeMode({
|
new FieldColorSchemeMode({
|
||||||
id: FieldColorModeId.ContinuousYlBl,
|
id: FieldColorModeId.ContinuousYlBl,
|
||||||
@@ -158,6 +174,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
|||||||
isContinuous: true,
|
isContinuous: true,
|
||||||
isByValue: true,
|
isByValue: true,
|
||||||
getColors: (theme: GrafanaTheme2) => ['super-light-yellow', 'dark-blue'],
|
getColors: (theme: GrafanaTheme2) => ['super-light-yellow', 'dark-blue'],
|
||||||
|
group: otherGroup,
|
||||||
}),
|
}),
|
||||||
new FieldColorSchemeMode({
|
new FieldColorSchemeMode({
|
||||||
id: FieldColorModeId.ContinuousBlues,
|
id: FieldColorModeId.ContinuousBlues,
|
||||||
@@ -165,6 +182,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
|||||||
isContinuous: true,
|
isContinuous: true,
|
||||||
isByValue: true,
|
isByValue: true,
|
||||||
getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-blue'],
|
getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-blue'],
|
||||||
|
group: otherGroup,
|
||||||
}),
|
}),
|
||||||
new FieldColorSchemeMode({
|
new FieldColorSchemeMode({
|
||||||
id: FieldColorModeId.ContinuousReds,
|
id: FieldColorModeId.ContinuousReds,
|
||||||
@@ -172,6 +190,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
|||||||
isContinuous: true,
|
isContinuous: true,
|
||||||
isByValue: true,
|
isByValue: true,
|
||||||
getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-red'],
|
getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-red'],
|
||||||
|
group: otherGroup,
|
||||||
}),
|
}),
|
||||||
new FieldColorSchemeMode({
|
new FieldColorSchemeMode({
|
||||||
id: FieldColorModeId.ContinuousGreens,
|
id: FieldColorModeId.ContinuousGreens,
|
||||||
@@ -179,6 +198,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
|||||||
isContinuous: true,
|
isContinuous: true,
|
||||||
isByValue: true,
|
isByValue: true,
|
||||||
getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-green'],
|
getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-green'],
|
||||||
|
group: otherGroup,
|
||||||
}),
|
}),
|
||||||
new FieldColorSchemeMode({
|
new FieldColorSchemeMode({
|
||||||
id: FieldColorModeId.ContinuousPurples,
|
id: FieldColorModeId.ContinuousPurples,
|
||||||
@@ -186,6 +206,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
|
|||||||
isContinuous: true,
|
isContinuous: true,
|
||||||
isByValue: true,
|
isByValue: true,
|
||||||
getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-purple'],
|
getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-purple'],
|
||||||
|
group: otherGroup,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
@@ -197,6 +218,7 @@ interface BaseFieldColorSchemeModeOptions {
|
|||||||
isContinuous: boolean;
|
isContinuous: boolean;
|
||||||
isByValue: boolean;
|
isByValue: boolean;
|
||||||
useSeriesName?: boolean;
|
useSeriesName?: boolean;
|
||||||
|
group?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FieldColorSchemeModeInterpolator extends BaseFieldColorSchemeModeOptions {
|
interface FieldColorSchemeModeInterpolator extends BaseFieldColorSchemeModeOptions {
|
||||||
@@ -222,6 +244,7 @@ export class FieldColorSchemeMode implements FieldColorMode {
|
|||||||
colorCacheTheme?: GrafanaTheme2;
|
colorCacheTheme?: GrafanaTheme2;
|
||||||
interpolator?: (value: number) => string;
|
interpolator?: (value: number) => string;
|
||||||
getNamedColors?: (theme: GrafanaTheme2) => string[];
|
getNamedColors?: (theme: GrafanaTheme2) => string[];
|
||||||
|
group?: string;
|
||||||
|
|
||||||
constructor(options: FieldColorSchemeModeOptions) {
|
constructor(options: FieldColorSchemeModeOptions) {
|
||||||
this.id = options.id;
|
this.id = options.id;
|
||||||
@@ -232,6 +255,7 @@ export class FieldColorSchemeMode implements FieldColorMode {
|
|||||||
this.isByValue = options.isByValue;
|
this.isByValue = options.isByValue;
|
||||||
this.useSeriesName = options.useSeriesName;
|
this.useSeriesName = options.useSeriesName;
|
||||||
this.interpolator = options.interpolator;
|
this.interpolator = options.interpolator;
|
||||||
|
this.group = options.group;
|
||||||
}
|
}
|
||||||
|
|
||||||
getColors(theme: GrafanaTheme2): string[] {
|
getColors(theme: GrafanaTheme2): string[] {
|
||||||
|
|||||||
@@ -377,10 +377,14 @@ export interface FeatureToggles {
|
|||||||
*/
|
*/
|
||||||
perPanelNonApplicableDrilldowns?: boolean;
|
perPanelNonApplicableDrilldowns?: boolean;
|
||||||
/**
|
/**
|
||||||
* Enabled a group by action per panel
|
* Enables a group by action per panel
|
||||||
*/
|
*/
|
||||||
panelGroupBy?: boolean;
|
panelGroupBy?: boolean;
|
||||||
/**
|
/**
|
||||||
|
* Enables filtering by grouping labels on the panel level through legend or tooltip
|
||||||
|
*/
|
||||||
|
perPanelFiltering?: boolean;
|
||||||
|
/**
|
||||||
* Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard
|
* Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard
|
||||||
*/
|
*/
|
||||||
panelFilterVariable?: boolean;
|
panelFilterVariable?: boolean;
|
||||||
@@ -1157,6 +1161,10 @@ export interface FeatureToggles {
|
|||||||
*/
|
*/
|
||||||
jaegerEnableGrpcEndpoint?: boolean;
|
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
|
* Load plugins on store service startup instead of wire provider, and call RegisterFixedRoles after all plugins are loaded
|
||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1490,6 +1490,16 @@ export const versionedComponents = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
VizTooltipFooter: {
|
||||||
|
buttons: {
|
||||||
|
apply: {
|
||||||
|
['12.1.0']: 'data-testid viz-tooltip-footer-apply-filters-button',
|
||||||
|
},
|
||||||
|
applyInverse: {
|
||||||
|
['12.1.0']: 'data-testid viz-tooltip-footer-apply-inverse-filters-button',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
} satisfies VersionedSelectorGroup;
|
} satisfies VersionedSelectorGroup;
|
||||||
|
|
||||||
export type VersionedComponents = typeof versionedComponents;
|
export type VersionedComponents = typeof versionedComponents;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@grafana/i18n": "12.4.0-pre",
|
"@grafana/i18n": "12.4.0-pre",
|
||||||
"@grafana/plugin-ui": "^0.11.0",
|
"@grafana/plugin-ui": "^0.11.0",
|
||||||
"@grafana/runtime": "12.4.0-pre",
|
"@grafana/runtime": "12.4.0-pre",
|
||||||
|
"@grafana/schema": "12.4.0-pre",
|
||||||
"@grafana/ui": "12.4.0-pre",
|
"@grafana/ui": "12.4.0-pre",
|
||||||
"@react-awesome-query-builder/ui": "6.6.15",
|
"@react-awesome-query-builder/ui": "6.6.15",
|
||||||
"immutable": "5.1.4",
|
"immutable": "5.1.4",
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { RawEditor } from './query-editor-raw/RawEditor';
|
|||||||
import { VisualEditor } from './visual-query-builder/VisualEditor';
|
import { VisualEditor } from './visual-query-builder/VisualEditor';
|
||||||
|
|
||||||
export interface SqlQueryEditorProps extends QueryEditorProps<SqlDatasource, SQLQuery, SQLOptions> {
|
export interface SqlQueryEditorProps extends QueryEditorProps<SqlDatasource, SQLQuery, SQLOptions> {
|
||||||
queryHeaderProps?: Pick<QueryHeaderProps, 'dialect'>;
|
queryHeaderProps?: Pick<QueryHeaderProps, 'dialect' | 'hideRunButton' | 'hideFormatSelector'>;
|
||||||
|
isVariableQuery?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SqlQueryEditor({
|
export default function SqlQueryEditor({
|
||||||
@@ -25,6 +26,7 @@ export default function SqlQueryEditor({
|
|||||||
onRunQuery,
|
onRunQuery,
|
||||||
range,
|
range,
|
||||||
queryHeaderProps,
|
queryHeaderProps,
|
||||||
|
isVariableQuery = false,
|
||||||
}: SqlQueryEditorProps) {
|
}: SqlQueryEditorProps) {
|
||||||
const [isQueryRunnable, setIsQueryRunnable] = useState(true);
|
const [isQueryRunnable, setIsQueryRunnable] = useState(true);
|
||||||
const db = datasource.getDB();
|
const db = datasource.getDB();
|
||||||
@@ -99,6 +101,8 @@ export default function SqlQueryEditor({
|
|||||||
query={queryWithDefaults}
|
query={queryWithDefaults}
|
||||||
isQueryRunnable={isQueryRunnable}
|
isQueryRunnable={isQueryRunnable}
|
||||||
dialect={dialect}
|
dialect={dialect}
|
||||||
|
hideRunButton={queryHeaderProps?.hideRunButton}
|
||||||
|
hideFormatSelector={queryHeaderProps?.hideFormatSelector}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Space v={0.5} />
|
<Space v={0.5} />
|
||||||
@@ -111,6 +115,7 @@ export default function SqlQueryEditor({
|
|||||||
queryRowFilter={queryRowFilter}
|
queryRowFilter={queryRowFilter}
|
||||||
onValidate={setIsQueryRunnable}
|
onValidate={setIsQueryRunnable}
|
||||||
range={range}
|
range={range}
|
||||||
|
isVariableQuery={isVariableQuery}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export interface QueryHeaderProps {
|
|||||||
preconfiguredDataset: string;
|
preconfiguredDataset: string;
|
||||||
query: QueryWithDefaults;
|
query: QueryWithDefaults;
|
||||||
queryRowFilter: QueryRowFilter;
|
queryRowFilter: QueryRowFilter;
|
||||||
|
hideRunButton?: boolean;
|
||||||
|
hideFormatSelector?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QueryHeader({
|
export function QueryHeader({
|
||||||
@@ -37,6 +39,8 @@ export function QueryHeader({
|
|||||||
preconfiguredDataset,
|
preconfiguredDataset,
|
||||||
query,
|
query,
|
||||||
queryRowFilter,
|
queryRowFilter,
|
||||||
|
hideRunButton,
|
||||||
|
hideFormatSelector,
|
||||||
}: QueryHeaderProps) {
|
}: QueryHeaderProps) {
|
||||||
const { editorMode } = query;
|
const { editorMode } = query;
|
||||||
const [_, copyToClipboard] = useCopyToClipboard();
|
const [_, copyToClipboard] = useCopyToClipboard();
|
||||||
@@ -123,6 +127,7 @@ export function QueryHeader({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<EditorHeader>
|
<EditorHeader>
|
||||||
|
{!hideFormatSelector && (
|
||||||
<InlineSelect
|
<InlineSelect
|
||||||
label={t('grafana-sql.components.query-header.label-format', 'Format')}
|
label={t('grafana-sql.components.query-header.label-format', 'Format')}
|
||||||
value={query.format}
|
value={query.format}
|
||||||
@@ -131,6 +136,7 @@ export function QueryHeader({
|
|||||||
onChange={onFormatChange}
|
onChange={onFormatChange}
|
||||||
options={QUERY_FORMAT_OPTIONS}
|
options={QUERY_FORMAT_OPTIONS}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{editorMode === EditorMode.Builder && (
|
{editorMode === EditorMode.Builder && (
|
||||||
<>
|
<>
|
||||||
@@ -222,7 +228,8 @@ export function QueryHeader({
|
|||||||
|
|
||||||
<FlexItem grow={1} />
|
<FlexItem grow={1} />
|
||||||
|
|
||||||
{isQueryRunnable ? (
|
{!hideRunButton &&
|
||||||
|
(isQueryRunnable ? (
|
||||||
<Button icon="play" variant="primary" size="sm" onClick={() => onRunQuery()}>
|
<Button icon="play" variant="primary" size="sm" onClick={() => onRunQuery()}>
|
||||||
<Trans i18nKey="grafana-sql.components.query-header.run-query">Run query</Trans>
|
<Trans i18nKey="grafana-sql.components.query-header.run-query">Run query</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -241,7 +248,7 @@ export function QueryHeader({
|
|||||||
<Trans i18nKey="grafana-sql.components.query-header.run-query">Run query</Trans>
|
<Trans i18nKey="grafana-sql.components.query-header.run-query">Run query</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
))}
|
||||||
|
|
||||||
<RadioButtonGroup options={editorModes} size="sm" value={editorMode} onChange={onEditorModeChange} />
|
<RadioButtonGroup options={editorModes} size="sm" value={editorMode} onChange={onEditorModeChange} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { uniqueId } from 'lodash';
|
import { uniqueId } from 'lodash';
|
||||||
import { useCallback } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { SelectableValue, toOption } from '@grafana/data';
|
import { SelectableValue, toOption } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
@@ -20,19 +20,56 @@ interface SelectRowProps {
|
|||||||
onQueryChange: (sql: SQLQuery) => void;
|
onQueryChange: (sql: SQLQuery) => void;
|
||||||
db: DB;
|
db: DB;
|
||||||
columns: Array<SelectableValue<string>>;
|
columns: Array<SelectableValue<string>>;
|
||||||
|
isVariableQuery?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectRow({ query, onQueryChange, db, columns }: SelectRowProps) {
|
export function SelectRow({ query, onQueryChange, db, columns, isVariableQuery }: SelectRowProps) {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const { onSqlChange } = useSqlChange({ query, onQueryChange, db });
|
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
|
// Add necessary alias options for time series format
|
||||||
// when that format has been selected
|
|
||||||
if (query.format === QueryFormat.Timeseries) {
|
if (query.format === QueryFormat.Timeseries) {
|
||||||
timeSeriesAliasOpts.push({ label: t('grafana-sql.components.select-row.label.time', 'time'), value: 'time' });
|
if (!usedAliases.has('time') || cleanCurrentAlias === 'time') {
|
||||||
timeSeriesAliasOpts.push({ label: t('grafana-sql.components.select-row.label.value', 'value'), value: 'value' });
|
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]
|
||||||
|
);
|
||||||
|
|
||||||
const onAggregationChange = useCallback(
|
const onAggregationChange = useCallback(
|
||||||
(item: QueryEditorFunctionExpression, index: number) => (aggregation: SelectableValue<string>) => {
|
(item: QueryEditorFunctionExpression, index: number) => (aggregation: SelectableValue<string>) => {
|
||||||
@@ -145,7 +182,7 @@ export function SelectRow({ query, onQueryChange, db, columns }: SelectRowProps)
|
|||||||
value={item.alias ? toOption(item.alias) : null}
|
value={item.alias ? toOption(item.alias) : null}
|
||||||
inputId={`select-alias-${index}-${uniqueId()}`}
|
inputId={`select-alias-${index}-${uniqueId()}`}
|
||||||
data-testid={selectors.components.SQLQueryEditor.selectAlias}
|
data-testid={selectors.components.SQLQueryEditor.selectAlias}
|
||||||
options={timeSeriesAliasOpts}
|
options={getAliasOptions(item.alias)}
|
||||||
onChange={onAliasChange(item, index)}
|
onChange={onAliasChange(item, index)}
|
||||||
isClearable
|
isClearable
|
||||||
menuShouldPortal
|
menuShouldPortal
|
||||||
|
|||||||
@@ -16,9 +16,18 @@ interface VisualEditorProps extends QueryEditorProps {
|
|||||||
db: DB;
|
db: DB;
|
||||||
queryRowFilter: QueryRowFilter;
|
queryRowFilter: QueryRowFilter;
|
||||||
onValidate: (isValid: boolean) => void;
|
onValidate: (isValid: boolean) => void;
|
||||||
|
isVariableQuery?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VisualEditor = ({ query, db, queryRowFilter, onChange, onValidate, range }: VisualEditorProps) => {
|
export const VisualEditor = ({
|
||||||
|
query,
|
||||||
|
db,
|
||||||
|
queryRowFilter,
|
||||||
|
onChange,
|
||||||
|
onValidate,
|
||||||
|
range,
|
||||||
|
isVariableQuery,
|
||||||
|
}: VisualEditorProps) => {
|
||||||
const state = useAsync(async () => {
|
const state = useAsync(async () => {
|
||||||
const fields = await db.fields(query);
|
const fields = await db.fields(query);
|
||||||
return fields;
|
return fields;
|
||||||
@@ -28,7 +37,13 @@ export const VisualEditor = ({ query, db, queryRowFilter, onChange, onValidate,
|
|||||||
<>
|
<>
|
||||||
<EditorRows>
|
<EditorRows>
|
||||||
<EditorRow>
|
<EditorRow>
|
||||||
<SelectRow columns={state.value || []} query={query} onQueryChange={onChange} db={db} />
|
<SelectRow
|
||||||
|
columns={state.value || []}
|
||||||
|
query={query}
|
||||||
|
onQueryChange={onChange}
|
||||||
|
db={db}
|
||||||
|
isVariableQuery={isVariableQuery}
|
||||||
|
/>
|
||||||
</EditorRow>
|
</EditorRow>
|
||||||
{queryRowFilter.filter && (
|
{queryRowFilter.filter && (
|
||||||
<EditorRow>
|
<EditorRow>
|
||||||
|
|||||||
@@ -2,20 +2,19 @@ import { lastValueFrom, Observable, throwError } from 'rxjs';
|
|||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getDefaultTimeRange,
|
CoreApp,
|
||||||
DataFrame,
|
DataFrame,
|
||||||
DataFrameView,
|
DataFrameView,
|
||||||
DataQuery,
|
|
||||||
DataQueryRequest,
|
DataQueryRequest,
|
||||||
DataQueryResponse,
|
DataQueryResponse,
|
||||||
DataSourceInstanceSettings,
|
DataSourceInstanceSettings,
|
||||||
MetricFindValue,
|
getDefaultTimeRange,
|
||||||
ScopedVars,
|
|
||||||
CoreApp,
|
|
||||||
getSearchFilterScopedVar,
|
getSearchFilterScopedVar,
|
||||||
LegacyMetricFindQueryOptions,
|
LegacyMetricFindQueryOptions,
|
||||||
VariableWithMultiSupport,
|
MetricFindValue,
|
||||||
|
ScopedVars,
|
||||||
TimeRange,
|
TimeRange,
|
||||||
|
VariableWithMultiSupport,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { EditorMode } from '@grafana/plugin-ui';
|
import { EditorMode } from '@grafana/plugin-ui';
|
||||||
import {
|
import {
|
||||||
@@ -24,15 +23,16 @@ import {
|
|||||||
FetchResponse,
|
FetchResponse,
|
||||||
getBackendSrv,
|
getBackendSrv,
|
||||||
getTemplateSrv,
|
getTemplateSrv,
|
||||||
toDataQueryResponse,
|
|
||||||
TemplateSrv,
|
|
||||||
reportInteraction,
|
reportInteraction,
|
||||||
|
TemplateSrv,
|
||||||
|
toDataQueryResponse,
|
||||||
} from '@grafana/runtime';
|
} from '@grafana/runtime';
|
||||||
|
import { DataQuery } from '@grafana/schema';
|
||||||
|
|
||||||
import { ResponseParser } from '../ResponseParser';
|
import { ResponseParser } from '../ResponseParser';
|
||||||
import { SqlQueryEditorLazy } from '../components/QueryEditorLazy';
|
import { SqlQueryEditorLazy } from '../components/QueryEditorLazy';
|
||||||
import { MACRO_NAMES } from '../constants';
|
import { MACRO_NAMES } from '../constants';
|
||||||
import { DB, SQLQuery, SQLOptions, SqlQueryModel, QueryFormat } from '../types';
|
import { DB, QueryFormat, SQLOptions, SQLQuery, SqlQueryModel } from '../types';
|
||||||
import migrateAnnotation from '../utils/migration';
|
import migrateAnnotation from '../utils/migration';
|
||||||
|
|
||||||
export abstract class SqlDatasource extends DataSourceWithBackend<SQLQuery, SQLOptions> {
|
export abstract class SqlDatasource extends DataSourceWithBackend<SQLQuery, SQLOptions> {
|
||||||
@@ -182,7 +182,7 @@ export abstract class SqlDatasource extends DataSourceWithBackend<SQLQuery, SQLO
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
async metricFindQuery(query: string, options?: LegacyMetricFindQueryOptions): Promise<MetricFindValue[]> {
|
async metricFindQuery(query: SQLQuery | string, options?: LegacyMetricFindQueryOptions): Promise<MetricFindValue[]> {
|
||||||
const range = options?.range;
|
const range = options?.range;
|
||||||
if (range == null) {
|
if (range == null) {
|
||||||
// i cannot create a scenario where this happens, we handle it just to be sure.
|
// i cannot create a scenario where this happens, we handle it just to be sure.
|
||||||
@@ -194,12 +194,17 @@ export abstract class SqlDatasource extends DataSourceWithBackend<SQLQuery, SQLO
|
|||||||
refId = options.variable.name;
|
refId = options.variable.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queryString = typeof query === 'string' ? query : query.rawSql;
|
||||||
|
if (!queryString) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const scopedVars = {
|
const scopedVars = {
|
||||||
...options?.scopedVars,
|
...options?.scopedVars,
|
||||||
...getSearchFilterScopedVar({ query, wildcardChar: '%', options }),
|
...getSearchFilterScopedVar({ query: queryString, wildcardChar: '%', options }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const rawSql = this.templateSrv.replace(query, scopedVars, this.interpolateVariable);
|
const rawSql = this.templateSrv.replace(queryString, scopedVars, this.interpolateVariable);
|
||||||
|
|
||||||
const interpolatedQuery: SQLQuery = {
|
const interpolatedQuery: SQLQuery = {
|
||||||
refId: refId,
|
refId: refId,
|
||||||
|
|||||||
@@ -107,6 +107,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"label": {
|
"label": {
|
||||||
|
"__text": "__text",
|
||||||
|
"__value": "__value",
|
||||||
"time": "time",
|
"time": "time",
|
||||||
"value": "value"
|
"value": "value"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ export interface PanelContext {
|
|||||||
*/
|
*/
|
||||||
onAddAdHocFilter?: (item: AdHocFilterItem) => void;
|
onAddAdHocFilter?: (item: AdHocFilterItem) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns filters based on existing grouping or an empty array
|
||||||
|
*/
|
||||||
|
getFiltersBasedOnGrouping?: (items: AdHocFilterItem[]) => AdHocFilterItem[];
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* Used to apply multiple filters at once
|
||||||
|
*/
|
||||||
|
onAddAdHocFilters?: (items: AdHocFilterItem[]) => void;
|
||||||
/**
|
/**
|
||||||
* Enables modifying thresholds directly from the panel
|
* Enables modifying thresholds directly from the panel
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { MemoryRouter } from 'react-router-dom-v5-compat';
|
import { MemoryRouter } from 'react-router-dom-v5-compat';
|
||||||
|
|
||||||
import { Field, FieldType, LinkModel } from '@grafana/data';
|
import { Field, FieldType, LinkModel } from '@grafana/data';
|
||||||
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
|
|
||||||
import { VizTooltipFooter, AdHocFilterModel } from './VizTooltipFooter';
|
import { VizTooltipFooter, AdHocFilterModel } from './VizTooltipFooter';
|
||||||
|
|
||||||
@@ -89,4 +90,65 @@ describe('VizTooltipFooter', () => {
|
|||||||
|
|
||||||
expect(screen.queryByRole('button', { name: /filter for 'testValue'/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: /filter for 'testValue'/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should render filter by grouping buttons and fire onclick', async () => {
|
||||||
|
const onForClick = jest.fn();
|
||||||
|
const onOutClick = jest.fn();
|
||||||
|
|
||||||
|
const filterByGroupedLabels = {
|
||||||
|
onFilterForGroupedLabels: onForClick,
|
||||||
|
onFilterOutGroupedLabels: onOutClick,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<VizTooltipFooter dataLinks={[]} filterByGroupedLabels={filterByGroupedLabels} />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
const onForButton = screen.getByRole('button', { name: /Apply as filter/i });
|
||||||
|
expect(onForButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
const onOutButton = screen.getByRole('button', { name: /Apply as inverse filter/i });
|
||||||
|
expect(onOutButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(onForButton);
|
||||||
|
expect(onForClick).toHaveBeenCalled();
|
||||||
|
|
||||||
|
await userEvent.click(onOutButton);
|
||||||
|
expect(onOutClick).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render filter by grouping buttons when there are one-click links', () => {
|
||||||
|
const filterByGroupedLabels = {
|
||||||
|
onFilterForGroupedLabels: jest.fn(),
|
||||||
|
onFilterOutGroupedLabels: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClick = jest.fn();
|
||||||
|
const field: Field = {
|
||||||
|
name: '',
|
||||||
|
type: FieldType.string,
|
||||||
|
values: [],
|
||||||
|
config: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const oneClickLink: LinkModel<Field> = {
|
||||||
|
href: '#',
|
||||||
|
onClick,
|
||||||
|
title: 'One Click Link',
|
||||||
|
origin: field,
|
||||||
|
target: undefined,
|
||||||
|
oneClick: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<VizTooltipFooter dataLinks={[oneClickLink]} filterByGroupedLabels={filterByGroupedLabels} />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId(selectors.components.VizTooltipFooter.buttons.apply)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId(selectors.components.VizTooltipFooter.buttons.applyInverse)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { css } from '@emotion/css';
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { ActionModel, Field, GrafanaTheme2, LinkModel, ThemeSpacingTokens } from '@grafana/data';
|
import { ActionModel, Field, GrafanaTheme2, LinkModel, ThemeSpacingTokens } from '@grafana/data';
|
||||||
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { Trans } from '@grafana/i18n';
|
import { Trans } from '@grafana/i18n';
|
||||||
|
|
||||||
import { useStyles2 } from '../../themes/ThemeContext';
|
import { useStyles2 } from '../../themes/ThemeContext';
|
||||||
@@ -17,10 +18,16 @@ export interface AdHocFilterModel extends AdHocFilterItem {
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FilterByGroupedLabelsModel {
|
||||||
|
onFilterForGroupedLabels?: () => void;
|
||||||
|
onFilterOutGroupedLabels?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface VizTooltipFooterProps {
|
interface VizTooltipFooterProps {
|
||||||
dataLinks: Array<LinkModel<Field>>;
|
dataLinks: Array<LinkModel<Field>>;
|
||||||
actions?: Array<ActionModel<Field>>;
|
actions?: Array<ActionModel<Field>>;
|
||||||
adHocFilters?: AdHocFilterModel[];
|
adHocFilters?: AdHocFilterModel[];
|
||||||
|
filterByGroupedLabels?: FilterByGroupedLabelsModel;
|
||||||
annotate?: () => void;
|
annotate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +92,13 @@ const renderActions = makeRenderLinksOrActions<ActionModel>(
|
|||||||
(item, i) => <ActionButton key={i} action={item} variant="secondary" />
|
(item, i) => <ActionButton key={i} action={item} variant="secondary" />
|
||||||
);
|
);
|
||||||
|
|
||||||
export const VizTooltipFooter = ({ dataLinks, actions = [], annotate, adHocFilters = [] }: VizTooltipFooterProps) => {
|
export const VizTooltipFooter = ({
|
||||||
|
dataLinks,
|
||||||
|
actions = [],
|
||||||
|
annotate,
|
||||||
|
adHocFilters = [],
|
||||||
|
filterByGroupedLabels,
|
||||||
|
}: VizTooltipFooterProps) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const hasOneClickLink = useMemo(() => dataLinks.some((link) => link.oneClick === true), [dataLinks]);
|
const hasOneClickLink = useMemo(() => dataLinks.some((link) => link.oneClick === true), [dataLinks]);
|
||||||
const hasOneClickAction = useMemo(() => actions.some((action) => action.oneClick === true), [actions]);
|
const hasOneClickAction = useMemo(() => actions.some((action) => action.oneClick === true), [actions]);
|
||||||
@@ -105,6 +118,39 @@ export const VizTooltipFooter = ({ dataLinks, actions = [], annotate, adHocFilte
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!hasOneClickLink && !hasOneClickAction && filterByGroupedLabels && (
|
||||||
|
<div className={styles.footerSection}>
|
||||||
|
<Stack direction="column" gap={0.5} width="fit-content">
|
||||||
|
<Button
|
||||||
|
icon="filter"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={filterByGroupedLabels.onFilterForGroupedLabels}
|
||||||
|
>
|
||||||
|
<Trans
|
||||||
|
i18nKey="grafana-ui.viz-tooltip.footer-apply-series-as-filter"
|
||||||
|
data-testid={selectors.components.VizTooltipFooter.buttons.apply}
|
||||||
|
>
|
||||||
|
Apply as filter
|
||||||
|
</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon="filter"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={filterByGroupedLabels.onFilterOutGroupedLabels}
|
||||||
|
>
|
||||||
|
<Trans
|
||||||
|
i18nKey="grafana-ui.viz-tooltip.footer-apply-series-as-inverse-filter"
|
||||||
|
data-testid={selectors.components.VizTooltipFooter.buttons.applyInverse}
|
||||||
|
>
|
||||||
|
Apply as inverse filter
|
||||||
|
</Trans>
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!hasOneClickLink && !hasOneClickAction && annotate != null && (
|
{!hasOneClickLink && !hasOneClickAction && annotate != null && (
|
||||||
<div className={styles.footerSection}>
|
<div className={styles.footerSection}>
|
||||||
<Button icon="comment-alt" variant="secondary" size="sm" id={ADD_ANNOTATION_ID} onClick={annotate}>
|
<Button icon="comment-alt" variant="secondary" size="sm" id={ADD_ANNOTATION_ID} onClick={annotate}>
|
||||||
|
|||||||
@@ -84,7 +84,11 @@ export { EmotionPerfTest } from '../components/ThemeDemos/EmotionPerfTest';
|
|||||||
export { ThemeDemo } from '../components/ThemeDemos/ThemeDemo';
|
export { ThemeDemo } from '../components/ThemeDemos/ThemeDemo';
|
||||||
|
|
||||||
export { VizTooltipContent } from '../components/VizTooltip/VizTooltipContent';
|
export { VizTooltipContent } from '../components/VizTooltip/VizTooltipContent';
|
||||||
export { VizTooltipFooter, type AdHocFilterModel } from '../components/VizTooltip/VizTooltipFooter';
|
export {
|
||||||
|
VizTooltipFooter,
|
||||||
|
type AdHocFilterModel,
|
||||||
|
type FilterByGroupedLabelsModel,
|
||||||
|
} from '../components/VizTooltip/VizTooltipFooter';
|
||||||
export { VizTooltipHeader } from '../components/VizTooltip/VizTooltipHeader';
|
export { VizTooltipHeader } from '../components/VizTooltip/VizTooltipHeader';
|
||||||
export { VizTooltipWrapper } from '../components/VizTooltip/VizTooltipWrapper';
|
export { VizTooltipWrapper } from '../components/VizTooltip/VizTooltipWrapper';
|
||||||
export { VizTooltipRow } from '../components/VizTooltip/VizTooltipRow';
|
export { VizTooltipRow } from '../components/VizTooltip/VizTooltipRow';
|
||||||
|
|||||||
@@ -609,7 +609,14 @@ var (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "panelGroupBy",
|
Name: "panelGroupBy",
|
||||||
Description: "Enabled a group by action per panel",
|
Description: "Enables a group by action per panel",
|
||||||
|
Stage: FeatureStageExperimental,
|
||||||
|
FrontendOnly: true,
|
||||||
|
Owner: grafanaDashboardsSquad,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "perPanelFiltering",
|
||||||
|
Description: "Enables filtering by grouping labels on the panel level through legend or tooltip",
|
||||||
Stage: FeatureStageExperimental,
|
Stage: FeatureStageExperimental,
|
||||||
FrontendOnly: true,
|
FrontendOnly: true,
|
||||||
Owner: grafanaDashboardsSquad,
|
Owner: grafanaDashboardsSquad,
|
||||||
@@ -1906,6 +1913,13 @@ var (
|
|||||||
Stage: FeatureStageExperimental,
|
Stage: FeatureStageExperimental,
|
||||||
Owner: grafanaOSSBigTent,
|
Owner: grafanaOSSBigTent,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "postgresVariableQueryEditor",
|
||||||
|
Description: "Enable the new variable query editor for the PostgreSQL data source",
|
||||||
|
Stage: FeatureStageExperimental,
|
||||||
|
Owner: grafanaOSSBigTent,
|
||||||
|
FrontendOnly: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "pluginStoreServiceLoading",
|
Name: "pluginStoreServiceLoading",
|
||||||
Description: "Load plugins on store service startup instead of wire provider, and call RegisterFixedRoles after all plugins are loaded",
|
Description: "Load plugins on store service startup instead of wire provider, and call RegisterFixedRoles after all plugins are loaded",
|
||||||
|
|||||||
2
pkg/services/featuremgmt/toggles_gen.csv
generated
2
pkg/services/featuremgmt/toggles_gen.csv
generated
@@ -85,6 +85,7 @@ dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true
|
|||||||
unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true
|
unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true
|
||||||
perPanelNonApplicableDrilldowns,experimental,@grafana/dashboards-squad,false,false,true
|
perPanelNonApplicableDrilldowns,experimental,@grafana/dashboards-squad,false,false,true
|
||||||
panelGroupBy,experimental,@grafana/dashboards-squad,false,false,true
|
panelGroupBy,experimental,@grafana/dashboards-squad,false,false,true
|
||||||
|
perPanelFiltering,experimental,@grafana/dashboards-squad,false,false,true
|
||||||
panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,true
|
panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,true
|
||||||
pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false
|
pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false
|
||||||
canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true
|
canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true
|
||||||
@@ -259,6 +260,7 @@ newVizSuggestions,preview,@grafana/dataviz-squad,false,false,true
|
|||||||
externalVizSuggestions,experimental,@grafana/dataviz-squad,false,false,true
|
externalVizSuggestions,experimental,@grafana/dataviz-squad,false,false,true
|
||||||
preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true
|
preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true
|
||||||
jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false
|
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
|
pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||||
newPanelPadding,preview,@grafana/dashboards-squad,false,false,true
|
newPanelPadding,preview,@grafana/dashboards-squad,false,false,true
|
||||||
onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false
|
onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false
|
||||||
|
|||||||
|
653
pkg/services/featuremgmt/toggles_gen.json
generated
653
pkg/services/featuremgmt/toggles_gen.json
generated
File diff suppressed because it is too large
Load Diff
@@ -29,22 +29,39 @@ export const FieldColorEditor = ({ value, onChange, item, id }: Props) => {
|
|||||||
? fieldColorModeRegistry.list()
|
? fieldColorModeRegistry.list()
|
||||||
: fieldColorModeRegistry.list().filter((m) => !m.isByValue);
|
: fieldColorModeRegistry.list().filter((m) => !m.isByValue);
|
||||||
|
|
||||||
const options = availableOptions
|
const filteredOptions = availableOptions.filter((option) => !option.excludeFromPicker);
|
||||||
.filter((mode) => !mode.excludeFromPicker)
|
|
||||||
.map((mode) => {
|
|
||||||
let suffix = mode.isByValue ? ' (by value)' : '';
|
|
||||||
|
|
||||||
return {
|
const options: Array<SelectableValue<string>> = [];
|
||||||
value: mode.id,
|
// collect any grouped options in this map
|
||||||
label: `${mode.name}${suffix}`,
|
// this allows us to easily push to the child array without having to rescan the options array
|
||||||
description: mode.description,
|
// it also allows us to maintain group position in the order they're first encountered
|
||||||
isContinuous: mode.isContinuous,
|
const groupMap = new Map<string, Array<SelectableValue<string>>>();
|
||||||
isByValue: mode.isByValue,
|
|
||||||
|
for (const option of filteredOptions) {
|
||||||
|
const suffix = option.isByValue ? ' (by value)' : '';
|
||||||
|
|
||||||
|
const groupName = option.group;
|
||||||
|
const selectOption = {
|
||||||
|
value: option.id,
|
||||||
|
label: `${option.name}${suffix}`,
|
||||||
|
description: option.description,
|
||||||
component() {
|
component() {
|
||||||
return <FieldColorModeViz mode={mode} theme={theme} />;
|
return <FieldColorModeViz mode={option} theme={theme} />;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
|
||||||
|
if (groupName) {
|
||||||
|
let group = groupMap.get(groupName);
|
||||||
|
if (!group) {
|
||||||
|
group = [];
|
||||||
|
groupMap.set(groupName, group);
|
||||||
|
options.push({ label: groupName, options: group });
|
||||||
|
}
|
||||||
|
group.push(selectOption);
|
||||||
|
} else {
|
||||||
|
options.push(selectOption);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const onModeChange = (newMode: SelectableValue<string>) => {
|
const onModeChange = (newMode: SelectableValue<string>) => {
|
||||||
onChange({
|
onChange({
|
||||||
|
|||||||
@@ -44,20 +44,21 @@ interface ShowMoreInstancesProps {
|
|||||||
|
|
||||||
function ShowMoreInstances({ stats, onClick, href }: ShowMoreInstancesProps) {
|
function ShowMoreInstances({ stats, onClick, href }: ShowMoreInstancesProps) {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
const { visibleItemsCount, totalItemsCount } = stats;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.footerRow}>
|
<div className={styles.footerRow}>
|
||||||
<div>
|
<div>
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="alerting.rule-details-matching-instances.showing-count"
|
i18nKey="alerting.rule-details-matching-instances.showing-count"
|
||||||
values={{ visibleItems: stats.visibleItemsCount, totalItems: stats.totalItemsCount }}
|
values={{ visibleItemsCount, totalItemsCount }}
|
||||||
>
|
>
|
||||||
Showing {'{{visibleItems}}'} out of {'{{totalItems}}'} instances
|
Showing {{ visibleItemsCount }} out of {{ totalItemsCount }} instances
|
||||||
</Trans>
|
</Trans>
|
||||||
</div>
|
</div>
|
||||||
<LinkButton size="sm" variant="secondary" data-testid="show-all" onClick={onClick} href={href}>
|
<LinkButton size="sm" variant="secondary" data-testid="show-all" onClick={onClick} href={href}>
|
||||||
<Trans i18nKey="alerting.rule-details-matching-instances.button-show-all" count={stats.totalItemsCount}>
|
<Trans i18nKey="alerting.rule-details-matching-instances.button-show-all" values={{ totalItemsCount }}>
|
||||||
Show all {'{{totalItems}}'} alert instances
|
Show all {{ totalItemsCount }} alert instances
|
||||||
</Trans>
|
</Trans>
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -130,6 +130,12 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
width: `var(${DRAGGED_ITEM_WIDTH})`,
|
width: `var(${DRAGGED_ITEM_WIDTH})`,
|
||||||
height: `var(${DRAGGED_ITEM_HEIGHT})`,
|
height: `var(${DRAGGED_ITEM_HEIGHT})`,
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
|
|
||||||
|
// Unfortunately, we need to re-enforce the absolute position here. Otherwise, the position will be overwritten with
|
||||||
|
// a relative position by .dashboard-visible-hidden-element
|
||||||
|
'&.dashboard-visible-hidden-element': {
|
||||||
|
position: 'absolute',
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
draggedRepeatWrapper: css({
|
draggedRepeatWrapper: css({
|
||||||
visibility: 'hidden',
|
visibility: 'hidden',
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
|
|||||||
top: number;
|
top: number;
|
||||||
left: number;
|
left: number;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
private _lastDropTargetGridItemKey: string | null = null;
|
||||||
|
|
||||||
public constructor(state: Partial<AutoGridLayoutState>) {
|
public constructor(state: Partial<AutoGridLayoutState>) {
|
||||||
super({
|
super({
|
||||||
@@ -145,6 +146,7 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
|
|||||||
}
|
}
|
||||||
|
|
||||||
this._draggedGridItem = gridItem;
|
this._draggedGridItem = gridItem;
|
||||||
|
this._lastDropTargetGridItemKey = gridItem.state.key!;
|
||||||
|
|
||||||
const { top, left, width, height } = this._draggedGridItem.getBoundingBox();
|
const { top, left, width, height } = this._draggedGridItem.getBoundingBox();
|
||||||
this._initialGridItemPosition = { pageX: evt.pageX, pageY: evt.pageY, top, left: left };
|
this._initialGridItemPosition = { pageX: evt.pageX, pageY: evt.pageY, top, left: left };
|
||||||
@@ -166,6 +168,7 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
|
|||||||
|
|
||||||
this._draggedGridItem = null;
|
this._draggedGridItem = null;
|
||||||
this._initialGridItemPosition = null;
|
this._initialGridItemPosition = null;
|
||||||
|
this._lastDropTargetGridItemKey = null;
|
||||||
this._resetPanelPositionAndSize();
|
this._resetPanelPositionAndSize();
|
||||||
|
|
||||||
this.setState({ draggingKey: undefined });
|
this.setState({ draggingKey: undefined });
|
||||||
@@ -196,7 +199,7 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
|
|||||||
})
|
})
|
||||||
?.getAttribute('data-auto-grid-item-drop-target');
|
?.getAttribute('data-auto-grid-item-drop-target');
|
||||||
|
|
||||||
if (dropTargetGridItemKey) {
|
if (dropTargetGridItemKey && dropTargetGridItemKey !== this._lastDropTargetGridItemKey) {
|
||||||
this._onDragOverItem(dropTargetGridItemKey);
|
this._onDragOverItem(dropTargetGridItemKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,12 +210,14 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
|
|||||||
const draggedIdx = children.findIndex((child) => child === this._draggedGridItem);
|
const draggedIdx = children.findIndex((child) => child === this._draggedGridItem);
|
||||||
const draggedOverIdx = children.findIndex((child) => child.state.key === key);
|
const draggedOverIdx = children.findIndex((child) => child.state.key === key);
|
||||||
|
|
||||||
if (draggedIdx === -1 || draggedOverIdx === -1) {
|
if (draggedIdx === -1 || draggedOverIdx === -1 || draggedIdx === draggedOverIdx) {
|
||||||
|
this._lastDropTargetGridItemKey = key;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
children.splice(draggedIdx, 1);
|
children.splice(draggedIdx, 1);
|
||||||
children.splice(draggedOverIdx, 0, this._draggedGridItem!);
|
children.splice(draggedOverIdx, 0, this._draggedGridItem!);
|
||||||
|
this._lastDropTargetGridItemKey = this._draggedGridItem!.state.key!;
|
||||||
|
|
||||||
this.setState({ children });
|
this.setState({ children });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { EventBusSrv } from '@grafana/data';
|
import { AdHocVariableModel, EventBusSrv, GroupByVariableModel, VariableModel } from '@grafana/data';
|
||||||
import { BackendSrv, setBackendSrv } from '@grafana/runtime';
|
import { BackendSrv, config, setBackendSrv } from '@grafana/runtime';
|
||||||
import { PanelContext } from '@grafana/ui';
|
import { GroupByVariable, sceneGraph } from '@grafana/scenes';
|
||||||
|
import { AdHocFilterItem, PanelContext } from '@grafana/ui';
|
||||||
|
|
||||||
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||||
import { findVizPanelByKey } from '../utils/utils';
|
import { findVizPanelByKey } from '../utils/utils';
|
||||||
@@ -159,6 +160,146 @@ describe('setDashboardPanelContext', () => {
|
|||||||
expect(variable.state.filters[1].operator).toBe('!=');
|
expect(variable.state.filters[1].operator).toBe('!=');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getFiltersBasedOnGrouping', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
config.featureToggles.groupByVariable = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
config.featureToggles.groupByVariable = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return filters based on grouping', () => {
|
||||||
|
const { scene, context } = buildTestScene({ existingFilterVariable: true, existingGroupByVariable: true });
|
||||||
|
|
||||||
|
const groupBy = sceneGraph.getVariables(scene).state.variables.find((f) => f instanceof GroupByVariable);
|
||||||
|
|
||||||
|
groupBy?.changeValueTo(['container', 'cluster']);
|
||||||
|
|
||||||
|
const filters: AdHocFilterItem[] = [
|
||||||
|
{ key: 'container', value: 'container', operator: '=' },
|
||||||
|
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||||
|
{ key: 'cpu', value: 'cpu', operator: '=' },
|
||||||
|
{ key: 'id', value: 'id', operator: '=' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = context.getFiltersBasedOnGrouping?.(filters);
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ key: 'container', value: 'container', operator: '=' },
|
||||||
|
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty filters if there is no groupBy selection', () => {
|
||||||
|
const { context } = buildTestScene({ existingFilterVariable: true, existingGroupByVariable: true });
|
||||||
|
|
||||||
|
const filters: AdHocFilterItem[] = [
|
||||||
|
{ key: 'container', value: 'container', operator: '=' },
|
||||||
|
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||||
|
{ key: 'cpu', value: 'cpu', operator: '=' },
|
||||||
|
{ key: 'id', value: 'id', operator: '=' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = context.getFiltersBasedOnGrouping?.(filters);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty filters if there is no groupBy variable', () => {
|
||||||
|
const { context } = buildTestScene({ existingFilterVariable: true, existingGroupByVariable: false });
|
||||||
|
|
||||||
|
const filters: AdHocFilterItem[] = [
|
||||||
|
{ key: 'container', value: 'container', operator: '=' },
|
||||||
|
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||||
|
{ key: 'cpu', value: 'cpu', operator: '=' },
|
||||||
|
{ key: 'id', value: 'id', operator: '=' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = context.getFiltersBasedOnGrouping?.(filters);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty filters if panel and groupBy ds differs', () => {
|
||||||
|
const { scene, context } = buildTestScene({
|
||||||
|
existingFilterVariable: true,
|
||||||
|
existingGroupByVariable: true,
|
||||||
|
groupByDatasourceUid: 'different-ds',
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupBy = sceneGraph.getVariables(scene).state.variables.find((f) => f instanceof GroupByVariable);
|
||||||
|
|
||||||
|
groupBy?.changeValueTo(['container', 'cluster']);
|
||||||
|
|
||||||
|
const filters: AdHocFilterItem[] = [
|
||||||
|
{ key: 'container', value: 'container', operator: '=' },
|
||||||
|
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||||
|
{ key: 'cpu', value: 'cpu', operator: '=' },
|
||||||
|
{ key: 'id', value: 'id', operator: '=' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = context.getFiltersBasedOnGrouping?.(filters);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onAddAdHocFilters', () => {
|
||||||
|
it('should add adhoc filters', () => {
|
||||||
|
const { scene, context } = buildTestScene({
|
||||||
|
existingFilterVariable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const variable = getAdHocFilterVariableFor(scene, { uid: 'my-ds-uid' });
|
||||||
|
|
||||||
|
const filters: AdHocFilterItem[] = [
|
||||||
|
{ key: 'existing', value: 'val', operator: '=' },
|
||||||
|
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||||
|
];
|
||||||
|
|
||||||
|
context.onAddAdHocFilters?.(filters);
|
||||||
|
expect(variable.state.filters).toEqual([
|
||||||
|
{ key: 'existing', value: 'val', operator: '=' },
|
||||||
|
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update and add adhoc filters', () => {
|
||||||
|
const { scene, context } = buildTestScene({
|
||||||
|
existingFilterVariable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const variable = getAdHocFilterVariableFor(scene, { uid: 'my-ds-uid' });
|
||||||
|
|
||||||
|
variable.setState({ filters: [{ key: 'existing', value: 'val', operator: '=' }] });
|
||||||
|
|
||||||
|
const filters: AdHocFilterItem[] = [
|
||||||
|
{ key: 'existing', value: 'val', operator: '!=' },
|
||||||
|
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||||
|
{ key: 'cpu', value: 'cpu', operator: '=' },
|
||||||
|
{ key: 'id', value: 'id', operator: '=' },
|
||||||
|
];
|
||||||
|
|
||||||
|
context.onAddAdHocFilters?.(filters);
|
||||||
|
expect(variable.state.filters).toEqual([
|
||||||
|
{ key: 'existing', value: 'val', operator: '!=' },
|
||||||
|
{ key: 'cluster', value: 'cluster', operator: '=' },
|
||||||
|
{ key: 'cpu', value: 'cpu', operator: '=' },
|
||||||
|
{ key: 'id', value: 'id', operator: '=' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not do anything if filters empty', () => {
|
||||||
|
const { scene, context } = buildTestScene({
|
||||||
|
existingFilterVariable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const variable = getAdHocFilterVariableFor(scene, { uid: 'my-ds-uid' });
|
||||||
|
|
||||||
|
const filters: AdHocFilterItem[] = [];
|
||||||
|
|
||||||
|
context.onAddAdHocFilters?.(filters);
|
||||||
|
expect(variable.state.filters).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
interface SceneOptions {
|
interface SceneOptions {
|
||||||
@@ -169,9 +310,29 @@ interface SceneOptions {
|
|||||||
canDelete?: boolean;
|
canDelete?: boolean;
|
||||||
orgCanEdit?: boolean;
|
orgCanEdit?: boolean;
|
||||||
existingFilterVariable?: boolean;
|
existingFilterVariable?: boolean;
|
||||||
|
existingGroupByVariable?: boolean;
|
||||||
|
groupByDatasourceUid?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTestScene(options: SceneOptions) {
|
function buildTestScene(options: SceneOptions) {
|
||||||
|
const varList: VariableModel[] = [];
|
||||||
|
|
||||||
|
if (options.existingFilterVariable) {
|
||||||
|
varList.push({
|
||||||
|
type: 'adhoc',
|
||||||
|
name: 'Filters',
|
||||||
|
datasource: { uid: 'my-ds-uid' },
|
||||||
|
} as AdHocVariableModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.existingGroupByVariable) {
|
||||||
|
varList.push({
|
||||||
|
type: 'groupby',
|
||||||
|
name: 'Group By',
|
||||||
|
datasource: { uid: options.groupByDatasourceUid ?? 'my-ds-uid', type: 'prometheus' },
|
||||||
|
} as GroupByVariableModel);
|
||||||
|
}
|
||||||
|
|
||||||
const scene = transformSaveModelToScene({
|
const scene = transformSaveModelToScene({
|
||||||
dashboard: {
|
dashboard: {
|
||||||
title: 'hello',
|
title: 'hello',
|
||||||
@@ -203,15 +364,7 @@ function buildTestScene(options: SceneOptions) {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
templating: {
|
templating: {
|
||||||
list: options.existingFilterVariable
|
list: varList,
|
||||||
? [
|
|
||||||
{
|
|
||||||
type: 'adhoc',
|
|
||||||
name: 'Filters',
|
|
||||||
datasource: { uid: 'my-ds-uid' },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -133,6 +133,43 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
|
|||||||
updateAdHocFilterVariable(filterVar, newFilter);
|
updateAdHocFilterVariable(filterVar, newFilter);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
context.getFiltersBasedOnGrouping = (items: AdHocFilterItem[]) => {
|
||||||
|
const dashboard = getDashboardSceneFor(vizPanel);
|
||||||
|
|
||||||
|
const queryRunner = getQueryRunnerFor(vizPanel);
|
||||||
|
if (!queryRunner) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupByVar = getGroupByVariableFor(dashboard, queryRunner.state.datasource);
|
||||||
|
|
||||||
|
if (!groupByVar) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentValues = Array.isArray(groupByVar.state.value)
|
||||||
|
? groupByVar.state.value
|
||||||
|
: groupByVar.state.value
|
||||||
|
? [groupByVar.state.value]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return items
|
||||||
|
.map((item) => (currentValues.find((key) => key === item.key) ? item : undefined))
|
||||||
|
.filter((item) => item !== undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
context.onAddAdHocFilters = (items: AdHocFilterItem[]) => {
|
||||||
|
const dashboard = getDashboardSceneFor(vizPanel);
|
||||||
|
|
||||||
|
const queryRunner = getQueryRunnerFor(vizPanel);
|
||||||
|
if (!queryRunner) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterVar = getAdHocFilterVariableFor(dashboard, queryRunner.state.datasource);
|
||||||
|
bulkUpdateAdHocFiltersVariable(filterVar, items);
|
||||||
|
};
|
||||||
|
|
||||||
context.canExecuteActions = () => {
|
context.canExecuteActions = () => {
|
||||||
const dashboard = getDashboardSceneFor(vizPanel);
|
const dashboard = getDashboardSceneFor(vizPanel);
|
||||||
return dashboard.canEditDashboard();
|
return dashboard.canEditDashboard();
|
||||||
@@ -167,6 +204,21 @@ function reRunBuiltInAnnotationsLayer(scene: DashboardScene) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getGroupByVariableFor(scene: DashboardScene, ds: DataSourceRef | null | undefined) {
|
||||||
|
const variables = sceneGraph.getVariables(scene);
|
||||||
|
|
||||||
|
for (const variable of variables.state.variables) {
|
||||||
|
if (sceneUtils.isGroupByVariable(variable)) {
|
||||||
|
const filtersDs = variable.state.datasource;
|
||||||
|
if (filtersDs === ds || filtersDs?.uid === ds?.uid) {
|
||||||
|
return variable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function getAdHocFilterVariableFor(scene: DashboardScene, ds: DataSourceRef | null | undefined) {
|
export function getAdHocFilterVariableFor(scene: DashboardScene, ds: DataSourceRef | null | undefined) {
|
||||||
const variables = sceneGraph.getVariables(scene);
|
const variables = sceneGraph.getVariables(scene);
|
||||||
|
|
||||||
@@ -195,6 +247,35 @@ export function getAdHocFilterVariableFor(scene: DashboardScene, ds: DataSourceR
|
|||||||
return newVariable;
|
return newVariable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bulkUpdateAdHocFiltersVariable(filterVar: AdHocFiltersVariable, newFilters: AdHocFilterItem[]) {
|
||||||
|
if (!newFilters.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedFilters = filterVar.state.filters.slice();
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
for (const newFilter of newFilters) {
|
||||||
|
const filterToReplaceIndex = updatedFilters.findIndex(
|
||||||
|
(filter) =>
|
||||||
|
filter.key === newFilter.key && filter.value === newFilter.value && filter.operator !== newFilter.operator
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filterToReplaceIndex >= 0) {
|
||||||
|
updatedFilters.splice(filterToReplaceIndex, 1, newFilter);
|
||||||
|
hasChanges = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedFilters.push(newFilter);
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
filterVar.updateFilters(updatedFilters);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateAdHocFilterVariable(filterVar: AdHocFiltersVariable, newFilter: AdHocFilterItem) {
|
function updateAdHocFilterVariable(filterVar: AdHocFiltersVariable, newFilter: AdHocFilterItem) {
|
||||||
// This function handles 'Filter for value' and 'Filter out value' from table cell
|
// This function handles 'Filter for value' and 'Filter out value' from table cell
|
||||||
// We are allowing to add filters with the same key because elastic search ds supports that
|
// We are allowing to add filters with the same key because elastic search ds supports that
|
||||||
|
|||||||
@@ -709,6 +709,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
|
|||||||
"title": "Repeating rows",
|
"title": "Repeating rows",
|
||||||
"uid": "Repeating-rows-uid",
|
"uid": "Repeating-rows-uid",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
"weekStart": "",
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
|
|||||||
description: state.description || undefined,
|
description: state.description || undefined,
|
||||||
uid: state.uid,
|
uid: state.uid,
|
||||||
id: state.id,
|
id: state.id,
|
||||||
|
editable: state.editable,
|
||||||
preload: state.preload,
|
preload: state.preload,
|
||||||
time: {
|
time: {
|
||||||
from: timeRange.from,
|
from: timeRange.from,
|
||||||
@@ -158,7 +159,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
|
|||||||
},
|
},
|
||||||
version: state.version,
|
version: state.version,
|
||||||
fiscalYearStartMonth: timeRange.fiscalYearStartMonth,
|
fiscalYearStartMonth: timeRange.fiscalYearStartMonth,
|
||||||
weekStart: timeRange.weekStart,
|
weekStart: timeRange.weekStart ?? '',
|
||||||
tags: state.tags,
|
tags: state.tags,
|
||||||
links: state.links,
|
links: state.links,
|
||||||
graphTooltip,
|
graphTooltip,
|
||||||
@@ -170,10 +171,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Only add optional fields if they are explicitly set (not default values)
|
// Only add optional fields if they are explicitly set (not default values)
|
||||||
if (state.editable !== undefined) {
|
if (timeRange.timeZone !== '') {
|
||||||
dashboard.editable = state.editable;
|
|
||||||
}
|
|
||||||
if (timeRange.timeZone !== undefined && timeRange.timeZone !== '') {
|
|
||||||
dashboard.timezone = timeRange.timeZone;
|
dashboard.timezone = timeRange.timeZone;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { NavModel, NavModelItem, PageLayoutType } from '@grafana/data';
|
import { NavModel, NavModelItem, PageLayoutType } from '@grafana/data';
|
||||||
import { SceneComponentProps, SceneObjectBase, SceneVariable, SceneVariables, sceneGraph } from '@grafana/scenes';
|
import { SceneComponentProps, SceneObjectBase, SceneVariable, SceneVariables, sceneGraph } from '@grafana/scenes';
|
||||||
@@ -109,10 +110,8 @@ export class VariablesEditView extends SceneObjectBase<VariablesEditViewState> i
|
|||||||
newName = `copy_of_${variableToUpdate.state.name}_${copyNumber}`;
|
newName = `copy_of_${variableToUpdate.state.name}_${copyNumber}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
//clone the original variable
|
//clone the original variable, update name and key
|
||||||
const newVariable = variableToUpdate.clone(variableToUpdate.state);
|
const newVariable = variableToUpdate.clone({ ...variableToUpdate.state, name: newName, key: uuidv4() });
|
||||||
// update state name of the new variable
|
|
||||||
newVariable.setState({ name: newName });
|
|
||||||
|
|
||||||
const updatedVariables = [
|
const updatedVariables = [
|
||||||
...variables.slice(0, variableIndex + 1),
|
...variables.slice(0, variableIndex + 1),
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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 { DataSourceInstanceSettings, ScopedVars, VariableWithMultiSupport } from '@grafana/data';
|
||||||
import { LanguageDefinition } from '@grafana/plugin-ui';
|
import { LanguageDefinition } from '@grafana/plugin-ui';
|
||||||
import { TemplateSrv } from '@grafana/runtime';
|
import { config, TemplateSrv } from '@grafana/runtime';
|
||||||
import {
|
import {
|
||||||
COMMON_FNS,
|
COMMON_FNS,
|
||||||
DB,
|
DB,
|
||||||
@@ -16,15 +16,23 @@ import {
|
|||||||
|
|
||||||
import { PostgresQueryModel } from './PostgresQueryModel';
|
import { PostgresQueryModel } from './PostgresQueryModel';
|
||||||
import { getSchema, getTimescaleDBVersion, getVersion, showTables } from './postgresMetaQuery';
|
import { getSchema, getTimescaleDBVersion, getVersion, showTables } from './postgresMetaQuery';
|
||||||
|
import { transformMetricFindResponse } from './responseParser';
|
||||||
import { fetchColumns, fetchTables, getSqlCompletionProvider } from './sqlCompletionProvider';
|
import { fetchColumns, fetchTables, getSqlCompletionProvider } from './sqlCompletionProvider';
|
||||||
import { getFieldConfig, toRawSql } from './sqlUtil';
|
import { getFieldConfig, toRawSql } from './sqlUtil';
|
||||||
import { PostgresOptions } from './types';
|
import { PostgresOptions } from './types';
|
||||||
|
import { SQLVariableSupport } from './variables';
|
||||||
|
|
||||||
export class PostgresDatasource extends SqlDatasource {
|
export class PostgresDatasource extends SqlDatasource {
|
||||||
sqlLanguageDefinition: LanguageDefinition | undefined = undefined;
|
sqlLanguageDefinition: LanguageDefinition | undefined = undefined;
|
||||||
|
|
||||||
constructor(instanceSettings: DataSourceInstanceSettings<PostgresOptions>) {
|
constructor(instanceSettings: DataSourceInstanceSettings<PostgresOptions>) {
|
||||||
super(instanceSettings);
|
super(instanceSettings);
|
||||||
|
if (config.featureToggles.postgresVariableQueryEditor) {
|
||||||
|
this.variables = new SQLVariableSupport(this);
|
||||||
|
this.responseParser = {
|
||||||
|
transformMetricFindResponse: transformMetricFindResponse,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getQueryModel(target?: SQLQuery, templateSrv?: TemplateSrv, scopedVars?: ScopedVars): PostgresQueryModel {
|
getQueryModel(target?: SQLQuery, templateSrv?: TemplateSrv, scopedVars?: ScopedVars): PostgresQueryModel {
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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 } from '@grafana/sql';
|
import { SQLOptions, SQLQuery } from '@grafana/sql';
|
||||||
|
|
||||||
export enum PostgresTLSModes {
|
export enum PostgresTLSModes {
|
||||||
disable = 'disable',
|
disable = 'disable',
|
||||||
@@ -25,3 +25,7 @@ export interface PostgresOptions extends SQLOptions {
|
|||||||
export interface SecureJsonData {
|
export interface SecureJsonData {
|
||||||
password?: string;
|
password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VariableQuery extends SQLQuery {
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
XAxisInteractionAreaPlugin,
|
XAxisInteractionAreaPlugin,
|
||||||
usePanelContext,
|
usePanelContext,
|
||||||
} from '@grafana/ui';
|
} from '@grafana/ui';
|
||||||
import { TimeRange2, TooltipHoverMode } from '@grafana/ui/internal';
|
import { FILTER_OUT_OPERATOR, TimeRange2, TooltipHoverMode } from '@grafana/ui/internal';
|
||||||
import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries';
|
import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries';
|
||||||
import { config } from 'app/core/config';
|
import { config } from 'app/core/config';
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ import { OutsideRangePlugin } from './plugins/OutsideRangePlugin';
|
|||||||
import { ThresholdControlsPlugin } from './plugins/ThresholdControlsPlugin';
|
import { ThresholdControlsPlugin } from './plugins/ThresholdControlsPlugin';
|
||||||
import { getXAnnotationFrames } from './plugins/utils';
|
import { getXAnnotationFrames } from './plugins/utils';
|
||||||
import { getPrepareTimeseriesSuggestion } from './suggestions';
|
import { getPrepareTimeseriesSuggestion } from './suggestions';
|
||||||
import { getTimezones, prepareGraphableFields } from './utils';
|
import { getGroupedFilters, getTimezones, prepareGraphableFields } from './utils';
|
||||||
|
|
||||||
interface TimeSeriesPanelProps extends PanelProps<Options> {}
|
interface TimeSeriesPanelProps extends PanelProps<Options> {}
|
||||||
|
|
||||||
@@ -56,6 +56,8 @@ export const TimeSeriesPanel = ({
|
|||||||
showThresholds,
|
showThresholds,
|
||||||
eventBus,
|
eventBus,
|
||||||
canExecuteActions,
|
canExecuteActions,
|
||||||
|
getFiltersBasedOnGrouping,
|
||||||
|
onAddAdHocFilters,
|
||||||
} = usePanelContext();
|
} = usePanelContext();
|
||||||
|
|
||||||
const { dataLinkPostProcessor } = useDataLinksContext();
|
const { dataLinkPostProcessor } = useDataLinksContext();
|
||||||
@@ -175,6 +177,11 @@ export const TimeSeriesPanel = ({
|
|||||||
dismiss();
|
dismiss();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const groupingFilters =
|
||||||
|
seriesIdx !== null && config.featureToggles.perPanelFiltering && getFiltersBasedOnGrouping
|
||||||
|
? getGroupedFilters(alignedFrame, seriesIdx, getFiltersBasedOnGrouping)
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// not sure it header time here works for annotations, since it's taken from nearest datapoint index
|
// not sure it header time here works for annotations, since it's taken from nearest datapoint index
|
||||||
<TimeSeriesTooltip
|
<TimeSeriesTooltip
|
||||||
@@ -189,6 +196,17 @@ export const TimeSeriesPanel = ({
|
|||||||
maxHeight={options.tooltip.maxHeight}
|
maxHeight={options.tooltip.maxHeight}
|
||||||
replaceVariables={replaceVariables}
|
replaceVariables={replaceVariables}
|
||||||
dataLinks={dataLinks}
|
dataLinks={dataLinks}
|
||||||
|
filterByGroupedLabels={
|
||||||
|
config.featureToggles.perPanelFiltering && groupingFilters.length && onAddAdHocFilters
|
||||||
|
? {
|
||||||
|
onFilterForGroupedLabels: () => onAddAdHocFilters(groupingFilters),
|
||||||
|
onFilterOutGroupedLabels: () =>
|
||||||
|
onAddAdHocFilters(
|
||||||
|
groupingFilters.map((item) => ({ ...item, operator: FILTER_OUT_OPERATOR }))
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
canExecuteActions={userCanExecuteActions}
|
canExecuteActions={userCanExecuteActions}
|
||||||
compareDiffMs={compareDiffMs}
|
compareDiffMs={compareDiffMs}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
getContentItems,
|
getContentItems,
|
||||||
VizTooltipItem,
|
VizTooltipItem,
|
||||||
AdHocFilterModel,
|
AdHocFilterModel,
|
||||||
|
FilterByGroupedLabelsModel,
|
||||||
} from '@grafana/ui/internal';
|
} from '@grafana/ui/internal';
|
||||||
|
|
||||||
import { getFieldActions } from '../status-history/utils';
|
import { getFieldActions } from '../status-history/utils';
|
||||||
@@ -50,6 +51,7 @@ export interface TimeSeriesTooltipProps {
|
|||||||
dataLinks: LinkModel[];
|
dataLinks: LinkModel[];
|
||||||
hideZeros?: boolean;
|
hideZeros?: boolean;
|
||||||
adHocFilters?: AdHocFilterModel[];
|
adHocFilters?: AdHocFilterModel[];
|
||||||
|
filterByGroupedLabels?: FilterByGroupedLabelsModel;
|
||||||
canExecuteActions?: boolean;
|
canExecuteActions?: boolean;
|
||||||
compareDiffMs?: number[];
|
compareDiffMs?: number[];
|
||||||
}
|
}
|
||||||
@@ -70,8 +72,10 @@ export const TimeSeriesTooltip = ({
|
|||||||
adHocFilters,
|
adHocFilters,
|
||||||
canExecuteActions,
|
canExecuteActions,
|
||||||
compareDiffMs,
|
compareDiffMs,
|
||||||
|
filterByGroupedLabels,
|
||||||
}: TimeSeriesTooltipProps) => {
|
}: TimeSeriesTooltipProps) => {
|
||||||
const pluginContext = usePluginContext();
|
const pluginContext = usePluginContext();
|
||||||
|
|
||||||
const xField = series.fields[0];
|
const xField = series.fields[0];
|
||||||
let xVal = xField.values[dataIdxs[0]!];
|
let xVal = xField.values[dataIdxs[0]!];
|
||||||
|
|
||||||
@@ -107,7 +111,13 @@ export const TimeSeriesTooltip = ({
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
footer = (
|
footer = (
|
||||||
<VizTooltipFooter dataLinks={dataLinks} actions={actions} annotate={annotate} adHocFilters={adHocFilters} />
|
<VizTooltipFooter
|
||||||
|
dataLinks={dataLinks}
|
||||||
|
actions={actions}
|
||||||
|
annotate={annotate}
|
||||||
|
adHocFilters={adHocFilters}
|
||||||
|
filterByGroupedLabels={filterByGroupedLabels}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { createTheme, FieldType, createDataFrame, toDataFrame } from '@grafana/data';
|
import { createTheme, FieldType, createDataFrame, toDataFrame } from '@grafana/data';
|
||||||
import { LineInterpolation } from '@grafana/ui';
|
import { LineInterpolation } from '@grafana/ui';
|
||||||
|
|
||||||
import { prepareGraphableFields } from './utils';
|
import { AdHocFilterItem } from '../../../../../packages/grafana-ui/src/components/Table/TableNG/types';
|
||||||
|
|
||||||
|
import { getGroupedFilters, prepareGraphableFields } from './utils';
|
||||||
|
|
||||||
describe('prepare timeseries graph', () => {
|
describe('prepare timeseries graph', () => {
|
||||||
it('errors with no time fields', () => {
|
it('errors with no time fields', () => {
|
||||||
@@ -178,4 +180,83 @@ describe('prepare timeseries graph', () => {
|
|||||||
expect(frames![0].fields[1].config.custom.lineInterpolation).toEqual(LineInterpolation.StepAfter);
|
expect(frames![0].fields[1].config.custom.lineInterpolation).toEqual(LineInterpolation.StepAfter);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getGroupedFilters', () => {
|
||||||
|
it('returns empty array if no field', () => {
|
||||||
|
const df = createDataFrame({
|
||||||
|
fields: [{ name: 'time', type: FieldType.time, values: [1, 2, 3] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getGroupedFilters(df, 1, jest.fn())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array if no labels', () => {
|
||||||
|
const df = createDataFrame({
|
||||||
|
fields: [
|
||||||
|
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
|
||||||
|
{
|
||||||
|
name: 'value',
|
||||||
|
type: FieldType.number,
|
||||||
|
values: [1, 2, 3],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getGroupedFilters(df, 1, jest.fn())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array if field not filterable', () => {
|
||||||
|
const df = createDataFrame({
|
||||||
|
fields: [
|
||||||
|
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
|
||||||
|
{
|
||||||
|
name: 'value',
|
||||||
|
type: FieldType.number,
|
||||||
|
values: [1, 2, 3],
|
||||||
|
labels: {
|
||||||
|
test: 'value',
|
||||||
|
label: 'value2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getGroupedFilters(df, 1, jest.fn())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns grouped filters', () => {
|
||||||
|
const df = createDataFrame({
|
||||||
|
fields: [
|
||||||
|
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
|
||||||
|
{
|
||||||
|
name: 'value',
|
||||||
|
type: FieldType.number,
|
||||||
|
values: [1, 2, 3],
|
||||||
|
labels: {
|
||||||
|
test: 'value',
|
||||||
|
label: 'value2',
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
filterable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtersGroupingFn = (filters: AdHocFilterItem[]) => filters;
|
||||||
|
|
||||||
|
expect(getGroupedFilters(df, 1, filtersGroupingFn)).toEqual([
|
||||||
|
{
|
||||||
|
key: 'test',
|
||||||
|
operator: '=',
|
||||||
|
value: 'value',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'label',
|
||||||
|
operator: '=',
|
||||||
|
value: 'value2',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { convertFieldType } from '@grafana/data/internal';
|
import { convertFieldType } from '@grafana/data/internal';
|
||||||
import { GraphFieldConfig, LineInterpolation, TooltipDisplayMode, VizTooltipOptions } from '@grafana/schema';
|
import { GraphFieldConfig, LineInterpolation, TooltipDisplayMode, VizTooltipOptions } from '@grafana/schema';
|
||||||
import { buildScaleKey } from '@grafana/ui/internal';
|
import { AdHocFilterItem } from '@grafana/ui';
|
||||||
|
import { buildScaleKey, FILTER_FOR_OPERATOR } from '@grafana/ui/internal';
|
||||||
|
|
||||||
import { HeatmapTooltip } from '../heatmap/panelcfg.gen';
|
import { HeatmapTooltip } from '../heatmap/panelcfg.gen';
|
||||||
|
|
||||||
@@ -329,3 +330,28 @@ export function getTimezones(timezones: string[] | undefined, defaultTimezone: s
|
|||||||
export const isTooltipScrollable = (tooltipOptions: VizTooltipOptions | HeatmapTooltip) => {
|
export const isTooltipScrollable = (tooltipOptions: VizTooltipOptions | HeatmapTooltip) => {
|
||||||
return tooltipOptions.mode === TooltipDisplayMode.Multi && tooltipOptions.maxHeight != null;
|
return tooltipOptions.mode === TooltipDisplayMode.Multi && tooltipOptions.maxHeight != null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getGroupedFilters(
|
||||||
|
frame: DataFrame,
|
||||||
|
seriesIdx: number,
|
||||||
|
getFiltersBasedOnGrouping: (filters: AdHocFilterItem[]) => AdHocFilterItem[]
|
||||||
|
) {
|
||||||
|
const groupingFilters: AdHocFilterItem[] = [];
|
||||||
|
const xField = frame.fields[seriesIdx];
|
||||||
|
|
||||||
|
if (xField && xField.labels && xField.config.filterable) {
|
||||||
|
const seriesFilters: AdHocFilterItem[] = [];
|
||||||
|
|
||||||
|
Object.entries(xField.labels).forEach(([key, value]) => {
|
||||||
|
seriesFilters.push({
|
||||||
|
key,
|
||||||
|
operator: FILTER_FOR_OPERATOR,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
groupingFilters.push(...getFiltersBasedOnGrouping(seriesFilters));
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupingFilters;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2334,9 +2334,8 @@
|
|||||||
"label-tenant-sources": "Tenant sources"
|
"label-tenant-sources": "Tenant sources"
|
||||||
},
|
},
|
||||||
"rule-details-matching-instances": {
|
"rule-details-matching-instances": {
|
||||||
"button-show-all_one": "Show all {{totalItems}} alert instances",
|
"button-show-all": "Show all {{totalItemsCount}} alert instances",
|
||||||
"button-show-all_other": "Show all {{totalItems}} alert instances",
|
"showing-count": "Showing {{visibleItemsCount}} out of {{totalItemsCount}} instances"
|
||||||
"showing-count": "Showing {{visibleItems}} out of {{totalItems}} instances"
|
|
||||||
},
|
},
|
||||||
"rule-editor": {
|
"rule-editor": {
|
||||||
"get-content": {
|
"get-content": {
|
||||||
@@ -8252,6 +8251,12 @@
|
|||||||
"nextNYears_other": "Next {{count}} years"
|
"nextNYears_other": "Next {{count}} years"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"field": {
|
||||||
|
"fieldColor": {
|
||||||
|
"accessibleGroup": "Accessible",
|
||||||
|
"otherGroup": "Others"
|
||||||
|
}
|
||||||
|
},
|
||||||
"valueFormats": {
|
"valueFormats": {
|
||||||
"categories": {
|
"categories": {
|
||||||
"acceleration": {
|
"acceleration": {
|
||||||
@@ -9252,6 +9257,8 @@
|
|||||||
"actions-confirmation-label": "Confirmation message",
|
"actions-confirmation-label": "Confirmation message",
|
||||||
"actions-confirmation-message": "Provide a descriptive prompt to confirm or cancel the action.",
|
"actions-confirmation-message": "Provide a descriptive prompt to confirm or cancel the action.",
|
||||||
"footer-add-annotation": "Add annotation",
|
"footer-add-annotation": "Add annotation",
|
||||||
|
"footer-apply-series-as-filter": "Apply as filter",
|
||||||
|
"footer-apply-series-as-inverse-filter": "Apply as inverse filter",
|
||||||
"footer-click-to-action": "Click to {{actionTitle}}",
|
"footer-click-to-action": "Click to {{actionTitle}}",
|
||||||
"footer-click-to-navigate": "Click to open {{linkTitle}}",
|
"footer-click-to-navigate": "Click to open {{linkTitle}}",
|
||||||
"footer-filter-for-value": "Filter for '{{value}}'",
|
"footer-filter-for-value": "Filter for '{{value}}'",
|
||||||
|
|||||||
@@ -3702,6 +3702,7 @@ __metadata:
|
|||||||
"@grafana/i18n": "npm:12.4.0-pre"
|
"@grafana/i18n": "npm:12.4.0-pre"
|
||||||
"@grafana/plugin-ui": "npm:^0.11.0"
|
"@grafana/plugin-ui": "npm:^0.11.0"
|
||||||
"@grafana/runtime": "npm:12.4.0-pre"
|
"@grafana/runtime": "npm:12.4.0-pre"
|
||||||
|
"@grafana/schema": "npm:12.4.0-pre"
|
||||||
"@grafana/ui": "npm:12.4.0-pre"
|
"@grafana/ui": "npm:12.4.0-pre"
|
||||||
"@react-awesome-query-builder/ui": "npm:6.6.15"
|
"@react-awesome-query-builder/ui": "npm:6.6.15"
|
||||||
"@testing-library/dom": "npm:10.4.1"
|
"@testing-library/dom": "npm:10.4.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user