Compare commits

...

15 Commits

Author SHA1 Message Date
Zoltán Bedi
8555bcba5b Add tests for migrations 2025-12-11 21:36:38 +01:00
Zoltán Bedi
4c601944e1 Add docs 2025-12-11 21:17:36 +01:00
Zoltán Bedi
a632f1f26a Extract labels 2025-12-11 15:50:53 +01:00
Zoltán Bedi
4d82e4295f Enhance SelectRow component with improved alias option handling
- Updated alias options for time series and variable queries to prevent duplicates and ensure correct options are displayed.
2025-12-10 12:14:54 +01:00
Zoltán Bedi
a91f64b9f5 Refactor response parser and tests for backwards compatibility
- Updated `transformMetricFindResponse` to handle cases without `__text` and `__value` fields, ensuring all values are treated as text-only entries.
- Adjusted test cases to reflect the new behavior and maintain backwards compatibility.
- Enhanced property handling to skip reserved field names when applicable.
2025-12-09 14:25:40 +01:00
Zoltán Bedi
b0e1ff8073 Enhance SQL Query Editor with variable query support
- Added `isVariableQuery` prop to `SqlQueryEditor`, `SelectRow`, and `VisualEditor` components to handle variable queries.
- Updated alias options in `SelectRow` to include variable query specific options.
- Modified `VariableQueryEditor` to set `isVariableQuery` to true when passing props to `SqlQueryEditorLazy`.
2025-12-09 13:19:42 +01:00
Zoltán Bedi
5c455ec2bc Refactor response parser to enhance metric transformation logic
- Updated the `transformMetricFindResponse` function to handle multiple fields more effectively, ensuring all values are included in the output.
- Introduced helper functions for better code organization and readability.
- Adjusted tests to reflect changes in the transformation logic, ensuring accurate validation of properties and deduplication behavior.
2025-12-09 12:05:31 +01:00
Zoltán Bedi
d5215a5be2 PostgreSQL: Add variable query editor support
- Introduced a new feature toggle for the PostgreSQL variable query editor `postgresVariableQueryEditor`.
2025-12-08 23:18:00 +01:00
Bogdan Matei
15c93100ab Dashboards: Fix autogrid selection / drag and drop (#114964)
Some checks failed
Frontend performance tests / performance-tests (push) Has been cancelled
Actionlint / Lint GitHub Actions files (push) Has been cancelled
Backend Code Checks / Detect whether code changed (push) Has been cancelled
Backend Code Checks / Validate Backend Configs (push) Has been cancelled
Backend Unit Tests / Detect whether code changed (push) Has been cancelled
Backend Unit Tests / Grafana (1/8) (push) Has been cancelled
Backend Unit Tests / Grafana (2/8) (push) Has been cancelled
Backend Unit Tests / Grafana (3/8) (push) Has been cancelled
Backend Unit Tests / Grafana (4/8) (push) Has been cancelled
Backend Unit Tests / Grafana (5/8) (push) Has been cancelled
Backend Unit Tests / Grafana (6/8) (push) Has been cancelled
Backend Unit Tests / Grafana (7/8) (push) Has been cancelled
Backend Unit Tests / Grafana (8/8) (push) Has been cancelled
Backend Unit Tests / Grafana Enterprise (1/8) (push) Has been cancelled
Backend Unit Tests / Grafana Enterprise (2/8) (push) Has been cancelled
Backend Unit Tests / Grafana Enterprise (3/8) (push) Has been cancelled
Backend Unit Tests / Grafana Enterprise (4/8) (push) Has been cancelled
Backend Unit Tests / Grafana Enterprise (5/8) (push) Has been cancelled
Backend Unit Tests / Grafana Enterprise (6/8) (push) Has been cancelled
Backend Unit Tests / Grafana Enterprise (7/8) (push) Has been cancelled
Backend Unit Tests / Grafana Enterprise (8/8) (push) Has been cancelled
Backend Unit Tests / All backend unit tests complete (push) Has been cancelled
Lint Frontend / Detect whether code changed (push) Has been cancelled
Lint Frontend / Lint (push) Has been cancelled
Lint Frontend / Typecheck (push) Has been cancelled
Lint Frontend / Verify API clients (push) Has been cancelled
Lint Frontend / Verify API clients (enterprise) (push) Has been cancelled
Verify i18n / verify-i18n (push) Has been cancelled
End-to-end tests / Detect whether code changed (push) Has been cancelled
End-to-end tests / Build & Package Grafana (push) Has been cancelled
End-to-end tests / Build E2E test runner (push) Has been cancelled
End-to-end tests / push-docker-image (push) Has been cancelled
End-to-end tests / dashboards-suite (old arch) (push) Has been cancelled
End-to-end tests / panels-suite (old arch) (push) Has been cancelled
End-to-end tests / smoke-tests-suite (old arch) (push) Has been cancelled
End-to-end tests / various-suite (old arch) (push) Has been cancelled
End-to-end tests / Verify Storybook (Playwright) (push) Has been cancelled
End-to-end tests / Playwright E2E tests (1/8) (push) Has been cancelled
End-to-end tests / Playwright E2E tests (2/8) (push) Has been cancelled
End-to-end tests / Playwright E2E tests (3/8) (push) Has been cancelled
End-to-end tests / Playwright E2E tests (4/8) (push) Has been cancelled
End-to-end tests / Playwright E2E tests (5/8) (push) Has been cancelled
End-to-end tests / Playwright E2E tests (6/8) (push) Has been cancelled
End-to-end tests / Playwright E2E tests (7/8) (push) Has been cancelled
End-to-end tests / Playwright E2E tests (8/8) (push) Has been cancelled
End-to-end tests / run-azure-monitor-e2e (push) Has been cancelled
End-to-end tests / All Playwright tests complete (push) Has been cancelled
End-to-end tests / A11y test (push) Has been cancelled
End-to-end tests / Publish metrics (push) Has been cancelled
End-to-end tests / All E2E tests complete (push) Has been cancelled
Frontend tests / Detect whether code changed (push) Has been cancelled
Frontend tests / Unit tests (1 / 16) (push) Has been cancelled
Frontend tests / Unit tests (10 / 16) (push) Has been cancelled
Frontend tests / Unit tests (11 / 16) (push) Has been cancelled
Frontend tests / Unit tests (12 / 16) (push) Has been cancelled
Frontend tests / Unit tests (13 / 16) (push) Has been cancelled
Frontend tests / Unit tests (14 / 16) (push) Has been cancelled
Frontend tests / Unit tests (15 / 16) (push) Has been cancelled
Frontend tests / Unit tests (16 / 16) (push) Has been cancelled
Frontend tests / Unit tests (2 / 16) (push) Has been cancelled
Frontend tests / Unit tests (3 / 16) (push) Has been cancelled
Frontend tests / Unit tests (4 / 16) (push) Has been cancelled
Frontend tests / Unit tests (5 / 16) (push) Has been cancelled
Frontend tests / Unit tests (6 / 16) (push) Has been cancelled
Frontend tests / Unit tests (7 / 16) (push) Has been cancelled
Frontend tests / Unit tests (8 / 16) (push) Has been cancelled
Frontend tests / Unit tests (9 / 16) (push) Has been cancelled
Frontend tests / Decoupled plugin tests (push) Has been cancelled
Frontend tests / Packages unit tests (push) Has been cancelled
Frontend tests / All frontend unit tests complete (push) Has been cancelled
Frontend tests / Devenv frontend-service build (push) Has been cancelled
Integration Tests / Detect whether code changed (push) Has been cancelled
Integration Tests / Sqlite (1/4) (push) Has been cancelled
Integration Tests / Sqlite (2/4) (push) Has been cancelled
Integration Tests / Sqlite (3/4) (push) Has been cancelled
Integration Tests / Sqlite (4/4) (push) Has been cancelled
Integration Tests / Sqlite Without CGo (1/4) (push) Has been cancelled
Integration Tests / Sqlite Without CGo (2/4) (push) Has been cancelled
Integration Tests / Sqlite Without CGo (3/4) (push) Has been cancelled
Integration Tests / Sqlite Without CGo (4/4) (push) Has been cancelled
Integration Tests / Sqlite Without CGo (profiled) (push) Has been cancelled
Integration Tests / MySQL (1/16) (push) Has been cancelled
Integration Tests / MySQL (10/16) (push) Has been cancelled
Integration Tests / MySQL (11/16) (push) Has been cancelled
Integration Tests / MySQL (12/16) (push) Has been cancelled
Integration Tests / MySQL (13/16) (push) Has been cancelled
Integration Tests / MySQL (14/16) (push) Has been cancelled
Integration Tests / MySQL (15/16) (push) Has been cancelled
Integration Tests / MySQL (16/16) (push) Has been cancelled
Integration Tests / MySQL (2/16) (push) Has been cancelled
Integration Tests / MySQL (3/16) (push) Has been cancelled
Integration Tests / MySQL (4/16) (push) Has been cancelled
Integration Tests / MySQL (5/16) (push) Has been cancelled
Integration Tests / MySQL (6/16) (push) Has been cancelled
Integration Tests / MySQL (7/16) (push) Has been cancelled
Integration Tests / MySQL (8/16) (push) Has been cancelled
Integration Tests / MySQL (9/16) (push) Has been cancelled
Integration Tests / Postgres (1/16) (push) Has been cancelled
Integration Tests / Postgres (10/16) (push) Has been cancelled
Integration Tests / Postgres (11/16) (push) Has been cancelled
Integration Tests / Postgres (12/16) (push) Has been cancelled
Integration Tests / Postgres (13/16) (push) Has been cancelled
Integration Tests / Postgres (14/16) (push) Has been cancelled
Integration Tests / Postgres (15/16) (push) Has been cancelled
Integration Tests / Postgres (16/16) (push) Has been cancelled
Integration Tests / Postgres (2/16) (push) Has been cancelled
Integration Tests / Postgres (3/16) (push) Has been cancelled
Integration Tests / Postgres (4/16) (push) Has been cancelled
Integration Tests / Postgres (5/16) (push) Has been cancelled
Integration Tests / Postgres (6/16) (push) Has been cancelled
Integration Tests / Postgres (7/16) (push) Has been cancelled
Integration Tests / Postgres (8/16) (push) Has been cancelled
Integration Tests / Postgres (9/16) (push) Has been cancelled
Integration Tests / All backend integration tests complete (push) Has been cancelled
Reject GitHub secrets / reject-gh-secrets (push) Has been cancelled
Build Release Packages / setup (push) Has been cancelled
Build Release Packages / Dispatch grafana-enterprise build (push) Has been cancelled
Build Release Packages / / darwin-amd64 (push) Has been cancelled
Build Release Packages / / darwin-arm64 (push) Has been cancelled
Build Release Packages / / linux-amd64 (push) Has been cancelled
Build Release Packages / / linux-armv6 (push) Has been cancelled
Build Release Packages / / linux-armv7 (push) Has been cancelled
Build Release Packages / / linux-arm64 (push) Has been cancelled
Build Release Packages / / linux-s390x (push) Has been cancelled
Build Release Packages / / windows-amd64 (push) Has been cancelled
Build Release Packages / / windows-arm64 (push) Has been cancelled
Build Release Packages / Upload artifacts (push) Has been cancelled
Build Release Packages / publish-dockerhub (push) Has been cancelled
Build Release Packages / Dispatch publish NPM canaries (push) Has been cancelled
Build Release Packages / notify-pr (push) Has been cancelled
Run dashboard schema v2 e2e / dashboard-schema-v2-e2e (push) Has been cancelled
Shellcheck / Shellcheck scripts (push) Has been cancelled
Run Storybook a11y tests / Detect whether code changed (push) Has been cancelled
Run Storybook a11y tests / Run Storybook a11y tests (light theme) (push) Has been cancelled
Run Storybook a11y tests / Run Storybook a11y tests (dark theme) (push) Has been cancelled
Swagger generated code / Detect whether code changed (push) Has been cancelled
Swagger generated code / Verify committed API specs match (push) Has been cancelled
Dispatch sync to mirror / dispatch-job (push) Has been cancelled
CodeQL checks / Detect whether code changed (push) Has been cancelled
CodeQL checks / Analyze (actions) (push) Has been cancelled
CodeQL checks / Analyze (go) (push) Has been cancelled
CodeQL checks / Analyze (javascript) (push) Has been cancelled
golangci-lint / Detect whether code changed (push) Has been cancelled
golangci-lint / go-fmt (push) Has been cancelled
golangci-lint / lint-go (push) Has been cancelled
Crowdin Upload Action / upload-sources-to-crowdin (push) Has been cancelled
publish-kinds-next / main (push) Has been cancelled
trigger-dashboard-search-e2e / trigger-search-e2e (push) Has been cancelled
Relyance Compliance Inspection / relyance-compliance-inspector (push) Has been cancelled
Crowdin Download Action / download-sources-from-crowdin (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
2025-12-08 16:52:00 +00:00
Sergej-Vlasov
ab9b070eb0 VariablesEditView: Update cloned variable key when duplicating (#114908)
update cloned variable key when duplicating
2025-12-08 16:34:21 +00:00
Tim Levett
e40673b298 github-action: Breaking change label prompts you to create a what's new (#113241)
* (workflow) add in what's new comment when we have a breaking change

* levitate as well

* fix add to what's new label
2025-12-08 16:23:14 +00:00
Victor Marin
7ea009c7f8 Dashboards: Per panel filtering for timeseries (#114499)
* wip per panel group by

* wip groupBy per panel

* wip groupBy per panel

* groupBy per panel action tests

* fix

* fix

* fix

* fix

* CR mods

* switch to dropdown

* adjust apply

* optimise action logic to avoid unnecessary triggers

* canary scenes

* wip

(cherry picked from commit 51a00db93d0805f481a9e48213382468f1eb2986)

* optimise action logic to avoid unnecessary triggers

(cherry picked from commit c4de2dfff8)

* refactor

* refactor

* memoize values/ refactor

* refactor

* refactor components - do not make async call unless queries/groupByOptions change

* canary scenes

* fix test

* Optimise handlers

* Reset options if they are not applied

* refactor subscriptions

* refactor

* scenes bump

* fixes

* properly deactivate header actions on panel edit

* list

* refactor showing menu using css, remove header deactivation code from panel-edit

* cleanup

* cleanup

* cleanup + action redesign

* i18n

* wip

* wip

* wip

* wip

* wip

* tests

* pr mods

* translations

* fix

* fix

* fixes

* translations

* translations

* extra ff check

* CR mods

---------

Co-authored-by: Sergej-Vlasov <sergej.s.vlasov@gmail.com>
Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
2025-12-08 16:18:04 +00:00
Kristina Demeshchik
fef6196195 Dashboard: Default weekStart to an empty string (#114932)
* Default clien scene-based logic to empty string to match backend + non-scene logic

* re-gen snapshots
2025-12-08 10:49:36 -05:00
Ashley Harrison
b50cf6e067 FieldColor: Group new accessible options within the select menu (#114690)
* group new accessible options within the select menu

* move comment
2025-12-08 15:10:16 +00:00
Lauren
ccdb6ff261 Alerting: Fix alert instances count display (#114965)
Alerting: fix alert instances count display
2025-12-08 14:54:46 +00:00
42 changed files with 1478 additions and 460 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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[] {

View File

@@ -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
*/ */

View File

@@ -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;

View File

@@ -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",

View File

@@ -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}
/> />
)} )}

View File

@@ -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} />

View File

@@ -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

View File

@@ -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>

View File

@@ -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,

View File

@@ -107,6 +107,8 @@
} }
}, },
"label": { "label": {
"__text": "__text",
"__value": "__value",
"time": "time", "time": "time",
"value": "value" "value": "value"
}, },

View File

@@ -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
* *

View File

@@ -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();
});
}); });

View File

@@ -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}>

View File

@@ -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';

View File

@@ -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",

View File

@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
85 unlimitedLayoutsNesting experimental @grafana/dashboards-squad false false true
86 perPanelNonApplicableDrilldowns experimental @grafana/dashboards-squad false false true
87 panelGroupBy experimental @grafana/dashboards-squad false false true
88 perPanelFiltering experimental @grafana/dashboards-squad false false true
89 panelFilterVariable experimental @grafana/dashboards-squad false false true
90 pdfTables preview @grafana/grafana-operator-experience-squad false false false
91 canvasPanelPanZoom preview @grafana/dataviz-squad false false true
260 externalVizSuggestions experimental @grafana/dataviz-squad false false true
261 preventPanelChromeOverflow preview @grafana/grafana-frontend-platform false false true
262 jaegerEnableGrpcEndpoint experimental @grafana/oss-big-tent false false false
263 postgresVariableQueryEditor experimental @grafana/oss-big-tent false false true
264 pluginStoreServiceLoading experimental @grafana/plugins-platform-backend false false false
265 newPanelPadding preview @grafana/dashboards-squad false false true
266 onlyStoreActionSets GA @grafana/identity-access-team false false false

File diff suppressed because it is too large Load Diff

View File

@@ -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({

View File

@@ -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>

View File

@@ -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',

View File

@@ -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 });
} }

View File

@@ -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: {

View File

@@ -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

View File

@@ -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": "",
} }
`; `;

View File

@@ -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;
} }

View File

@@ -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),

View File

@@ -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} />;
}

View File

@@ -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 {

View File

@@ -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');
});
});
});

View File

@@ -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,
};
}

View File

@@ -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',
},
});
});
});

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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' });
}
}

View File

@@ -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}
/> />

View File

@@ -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}
/>
); );
} }
} }

View File

@@ -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',
},
]);
});
});
}); });

View File

@@ -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;
}

View File

@@ -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}}'",

View File

@@ -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"