Compare commits

...

15 Commits

Author SHA1 Message Date
Andrew Hackmann
d419c33970 Merge remote-tracking branch 'origin/main' into elasticsearch-datasource-config-option 2025-12-08 16:32:02 -06:00
Andrew Hackmann
9b6f306956 retigger CI 2025-12-08 16:29:13 -06: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
Gábor Farkas
692712961b datasources: querier: configurable concurrent-query-limit (#114585) 2025-12-08 15:20:01 +01:00
Mihai Doarna
b2e1b257b3 IAM: Add search for teams in app platform (#113503)
* add legacy search (wip)

* fix search field name

* implement team search endpoint

* generate openapi spec

* generate endpoints for frontend

* minor fixes

* fix issues found while testing

* add more fields to search result

* add basic unit tests

* add more unit tests

* improve getColumns() func in legacy search

* configure search endpoint in team.cue

* add team search handler

* add the searchTeams endpoint to manifest.cue

* make gofmt

* update openapi spec

* generate frontend endpoints

* remove unused field

* move fields defiitions to separate builder

* fix legacy search

* fix unit tests

* fix unit test

* address feedback

* fix unit test

* update openapi specs

* yarn generate-apis

* add missing unit tests
2025-12-08 15:02:59 +02:00
Caue Marcondes
598be0cf49 addressing PR comments 2025-11-20 14:17:02 -05:00
Caue Marcondes
bad5bd627d syncing default query mode with url 2025-10-17 15:12:02 -04:00
Caue Marcondes
850963c8fe doc 2025-10-16 15:46:45 -04:00
Caue Marcondes
5e79510352 elasticsearch: Add default query mode config setting 2025-10-16 15:38:10 -04:00
70 changed files with 3168 additions and 479 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:
pull_request:
types: [labeled]
jobs:
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
permissions:
pull-requests: write
@@ -13,4 +13,4 @@ jobs:
- uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
with:
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

@@ -22,4 +22,32 @@ v0alpha1: {
serviceaccountv0alpha1,
externalGroupMappingv0alpha1
]
routes: {
namespaced: {
"/searchTeams": {
"GET": {
request: {
query: {
query?: string
}
}
response: {
#TeamHit: {
name: string
title: string
email: string
provisioned: bool
externalUID: string
}
offset: int64
totalHits: int64
hits: [...#TeamHit]
queryCost: float64
maxScore: float64
}
responseMetadata: objectMeta: false
}
}
}
}
}

View File

@@ -0,0 +1,33 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v0alpha1
import (
"github.com/grafana/grafana-app-sdk/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
type GetSearchTeamsRequestParamsObject struct {
metav1.TypeMeta `json:",inline"`
GetSearchTeamsRequestParams `json:",inline"`
}
func NewGetSearchTeamsRequestParamsObject() *GetSearchTeamsRequestParamsObject {
return &GetSearchTeamsRequestParamsObject{}
}
func (o *GetSearchTeamsRequestParamsObject) DeepCopyObject() runtime.Object {
dst := NewGetSearchTeamsRequestParamsObject()
o.DeepCopyInto(dst)
return dst
}
func (o *GetSearchTeamsRequestParamsObject) DeepCopyInto(dst *GetSearchTeamsRequestParamsObject) {
dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion
dst.TypeMeta.Kind = o.TypeMeta.Kind
dstGetSearchTeamsRequestParams := GetSearchTeamsRequestParams{}
_ = resource.CopyObjectInto(&dstGetSearchTeamsRequestParams, &o.GetSearchTeamsRequestParams)
}
var _ runtime.Object = NewGetSearchTeamsRequestParamsObject()

View File

@@ -0,0 +1,12 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v0alpha1
type GetSearchTeamsRequestParams struct {
Query *string `json:"query,omitempty"`
}
// NewGetSearchTeamsRequestParams creates a new GetSearchTeamsRequestParams object.
func NewGetSearchTeamsRequestParams() *GetSearchTeamsRequestParams {
return &GetSearchTeamsRequestParams{}
}

View File

@@ -0,0 +1,33 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v0alpha1
// +k8s:openapi-gen=true
type VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit struct {
Name string `json:"name"`
Title string `json:"title"`
Email string `json:"email"`
Provisioned bool `json:"provisioned"`
ExternalUID string `json:"externalUID"`
}
// NewVersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit creates a new VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit object.
func NewVersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit() *VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit {
return &VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit{}
}
// +k8s:openapi-gen=true
type GetSearchTeamsBody struct {
Offset int64 `json:"offset"`
TotalHits int64 `json:"totalHits"`
Hits []VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit `json:"hits"`
QueryCost float64 `json:"queryCost"`
MaxScore float64 `json:"maxScore"`
}
// NewGetSearchTeamsBody creates a new GetSearchTeamsBody object.
func NewGetSearchTeamsBody() *GetSearchTeamsBody {
return &GetSearchTeamsBody{
Hits: []VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit{},
}
}

View File

@@ -0,0 +1,37 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v0alpha1
import (
"github.com/grafana/grafana-app-sdk/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
// +k8s:openapi-gen=true
type GetSearchTeams struct {
metav1.TypeMeta `json:",inline"`
GetSearchTeamsBody `json:",inline"`
}
func NewGetSearchTeams() *GetSearchTeams {
return &GetSearchTeams{}
}
func (t *GetSearchTeamsBody) DeepCopyInto(dst *GetSearchTeamsBody) {
_ = resource.CopyObjectInto(dst, t)
}
func (o *GetSearchTeams) DeepCopyObject() runtime.Object {
dst := NewGetSearchTeams()
o.DeepCopyInto(dst)
return dst
}
func (o *GetSearchTeams) DeepCopyInto(dst *GetSearchTeams) {
dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion
dst.TypeMeta.Kind = o.TypeMeta.Kind
o.GetSearchTeamsBody.DeepCopyInto(&dst.GetSearchTeamsBody)
}
var _ runtime.Object = NewGetSearchTeams()

View File

@@ -317,6 +317,7 @@ func AddAuthNKnownTypes(scheme *runtime.Scheme) error {
&ServiceAccountList{},
&Team{},
&TeamList{},
&GetSearchTeams{},
&TeamBinding{},
&TeamBindingList{},
&ExternalGroupMapping{},

View File

@@ -0,0 +1,35 @@
package v0alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// +k8s:deepcopy-gen=true
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type TeamSearchResults struct {
metav1.TypeMeta `json:",inline"`
// Where the query started from
Offset int64 `json:"offset,omitempty"`
// The number of matching results
TotalHits int64 `json:"totalHits"`
// The team body
Hits []TeamHit `json:"hits"`
// Cost of running the query
QueryCost float64 `json:"queryCost,omitempty"`
// Max score
MaxScore float64 `json:"maxScore,omitempty"`
}
// +k8s:deepcopy-gen=true
type TeamHit struct {
Name string `json:"name"`
Title string `json:"title"`
Email string `json:"email,omitempty"`
Provisioned bool `json:"provisioned,omitempty"`
ExternalUID string `json:"externalUID,omitempty"`
}

View File

@@ -24,6 +24,8 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.ExternalGroupMappingTeamRef": schema_pkg_apis_iam_v0alpha1_ExternalGroupMappingTeamRef(ref),
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetGroups": schema_pkg_apis_iam_v0alpha1_GetGroups(ref),
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetGroupsBody": schema_pkg_apis_iam_v0alpha1_GetGroupsBody(ref),
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeams": schema_pkg_apis_iam_v0alpha1_GetSearchTeams(ref),
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeamsBody": schema_pkg_apis_iam_v0alpha1_GetSearchTeamsBody(ref),
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRole": schema_pkg_apis_iam_v0alpha1_GlobalRole(ref),
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRoleBinding": schema_pkg_apis_iam_v0alpha1_GlobalRoleBinding(ref),
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRoleBindingList": schema_pkg_apis_iam_v0alpha1_GlobalRoleBindingList(ref),
@@ -80,6 +82,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserStatus": schema_pkg_apis_iam_v0alpha1_UserStatus(ref),
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserstatusOperatorState": schema_pkg_apis_iam_v0alpha1_UserstatusOperatorState(ref),
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1Kinds7RoutesGroupsGETResponseExternalGroupMapping": schema_pkg_apis_iam_v0alpha1_VersionsV0alpha1Kinds7RoutesGroupsGETResponseExternalGroupMapping(ref),
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit": schema_pkg_apis_iam_v0alpha1_VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit(ref),
}
}
@@ -564,6 +567,132 @@ func schema_pkg_apis_iam_v0alpha1_GetGroupsBody(ref common.ReferenceCallback) co
}
}
func schema_pkg_apis_iam_v0alpha1_GetSearchTeams(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
Type: []string{"string"},
Format: "",
},
},
"offset": {
SchemaProps: spec.SchemaProps{
Default: 0,
Type: []string{"integer"},
Format: "int64",
},
},
"totalHits": {
SchemaProps: spec.SchemaProps{
Default: 0,
Type: []string{"integer"},
Format: "int64",
},
},
"hits": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit"),
},
},
},
},
},
"queryCost": {
SchemaProps: spec.SchemaProps{
Default: 0,
Type: []string{"number"},
Format: "double",
},
},
"maxScore": {
SchemaProps: spec.SchemaProps{
Default: 0,
Type: []string{"number"},
Format: "double",
},
},
},
Required: []string{"offset", "totalHits", "hits", "queryCost", "maxScore"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit"},
}
}
func schema_pkg_apis_iam_v0alpha1_GetSearchTeamsBody(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"offset": {
SchemaProps: spec.SchemaProps{
Default: 0,
Type: []string{"integer"},
Format: "int64",
},
},
"totalHits": {
SchemaProps: spec.SchemaProps{
Default: 0,
Type: []string{"integer"},
Format: "int64",
},
},
"hits": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit"),
},
},
},
},
},
"queryCost": {
SchemaProps: spec.SchemaProps{
Default: 0,
Type: []string{"number"},
Format: "double",
},
},
"maxScore": {
SchemaProps: spec.SchemaProps{
Default: 0,
Type: []string{"number"},
Format: "double",
},
},
},
Required: []string{"offset", "totalHits", "hits", "queryCost", "maxScore"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit"},
}
}
func schema_pkg_apis_iam_v0alpha1_GlobalRole(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@@ -2956,3 +3085,51 @@ func schema_pkg_apis_iam_v0alpha1_VersionsV0alpha1Kinds7RoutesGroupsGETResponseE
},
}
}
func schema_pkg_apis_iam_v0alpha1_VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"name": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"title": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"email": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"provisioned": {
SchemaProps: spec.SchemaProps{
Default: false,
Type: []string{"boolean"},
Format: "",
},
},
"externalUID": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"name", "title", "email", "provisioned", "externalUID"},
},
},
}
}

View File

@@ -157,9 +157,139 @@ var appManifestData = app.ManifestData{
},
},
Routes: app.ManifestVersionRoutes{
Namespaced: map[string]spec3.PathProps{},
Cluster: map[string]spec3.PathProps{},
Schemas: map[string]spec.Schema{},
Namespaced: map[string]spec3.PathProps{
"/searchTeams": {
Get: &spec3.Operation{
OperationProps: spec3.OperationProps{
OperationId: "getSearchTeams",
Parameters: []*spec3.Parameter{
{
ParameterProps: spec3.ParameterProps{
Name: "query",
In: "query",
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
},
},
Responses: &spec3.Responses{
ResponsesProps: spec3.ResponsesProps{
Default: &spec3.Response{
ResponseProps: spec3.ResponseProps{
Description: "Default OK response",
Content: map[string]*spec3.MediaType{
"application/json": {
MediaTypeProps: spec3.MediaTypeProps{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"apiVersion": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
},
},
"hits": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
},
},
"kind": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
},
},
"maxScore": {
SchemaProps: spec.SchemaProps{
Type: []string{"number"},
},
},
"offset": {
SchemaProps: spec.SchemaProps{
Type: []string{"integer"},
},
},
"queryCost": {
SchemaProps: spec.SchemaProps{
Type: []string{"number"},
},
},
"totalHits": {
SchemaProps: spec.SchemaProps{
Type: []string{"integer"},
},
},
},
Required: []string{
"offset",
"totalHits",
"hits",
"queryCost",
"maxScore",
"apiVersion",
"kind",
},
}},
}},
},
},
},
}},
},
},
},
},
Cluster: map[string]spec3.PathProps{},
Schemas: map[string]spec.Schema{
"getSearchTeamsTeamHit": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"email": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
"externalUID": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
"name": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
"provisioned": {
SchemaProps: spec.SchemaProps{
Type: []string{"boolean"},
},
},
"title": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
Required: []string{
"name",
"title",
"email",
"provisioned",
"externalUID",
},
},
},
},
},
},
},
@@ -196,6 +326,8 @@ func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exist
var customRouteToGoResponseType = map[string]any{
"v0alpha1|Team|groups|GET": v0alpha1.GetGroups{},
"v0alpha1||<namespace>/searchTeams|GET": v0alpha1.GetSearchTeams{},
}
// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists.

View File

@@ -174,6 +174,8 @@ You can also override this setting in a dashboard panel under its data source op
Frozen indices are [deprecated in Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.17/frozen-indices.html) since v7.14.
{{< /admonition >}}
- **Default query mode** - Specifies which query mode the data source uses by default. Options are `Metrics`, `Logs`, `Raw data`, and `Raw document`. The default is `Metrics`.
### Logs
In this section you can configure which fields the data source uses for log messages and log levels.

View File

@@ -3,6 +3,7 @@ export const addTagTypes = [
'API Discovery',
'Display',
'ExternalGroupMapping',
'Search',
'ServiceAccount',
'SSOSetting',
'TeamBinding',
@@ -152,6 +153,18 @@ const injectedRtkApi = api
}),
invalidatesTags: ['ExternalGroupMapping'],
}),
getSearchTeams: build.query<GetSearchTeamsApiResponse, GetSearchTeamsApiArg>({
query: (queryArg) => ({
url: `/searchTeams`,
params: {
query: queryArg.query,
limit: queryArg.limit,
offset: queryArg.offset,
page: queryArg.page,
},
}),
providesTags: ['Search'],
}),
listServiceAccount: build.query<ListServiceAccountApiResponse, ListServiceAccountApiArg>({
query: (queryArg) => ({
url: `/serviceaccounts`,
@@ -862,6 +875,27 @@ export type UpdateExternalGroupMappingApiArg = {
force?: boolean;
patch: Patch;
};
export type GetSearchTeamsApiResponse = /** status 200 undefined */ {
/** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */
apiVersion?: string;
hits: any[];
/** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */
kind?: string;
maxScore: number;
offset: number;
queryCost: number;
totalHits: number;
};
export type GetSearchTeamsApiArg = {
/** team name query string */
query?: string;
/** limit the number of results */
limit?: number;
/** start the query at the given offset */
offset?: number;
/** page number to start from */
page?: number;
};
export type ListServiceAccountApiResponse = /** status 200 OK */ ServiceAccountList;
export type ListServiceAccountApiArg = {
/** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */
@@ -2084,6 +2118,8 @@ export const {
useReplaceExternalGroupMappingMutation,
useDeleteExternalGroupMappingMutation,
useUpdateExternalGroupMappingMutation,
useGetSearchTeamsQuery,
useLazyGetSearchTeamsQuery,
useListServiceAccountQuery,
useLazyListServiceAccountQuery,
useCreateServiceAccountMutation,

View File

@@ -9,6 +9,8 @@ import {
import stringHash from 'string-hash';
import tinycolor from 'tinycolor2';
import { t } from '@grafana/i18n';
import { getContrastRatio } from '../themes/colorManipulator';
import { GrafanaTheme2 } from '../themes/types';
import { reduceField } from '../transformations/fieldReducer';
@@ -30,10 +32,14 @@ export interface FieldColorMode extends RegistryItem {
isContinuous?: boolean;
isByValue?: boolean;
useSeriesName?: boolean;
group?: string;
}
/** @internal */
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 [
{
id: FieldColorModeId.Fixed,
@@ -88,6 +94,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
isContinuous: true,
isByValue: true,
interpolator: interpolateViridis,
group: accessibleGroup,
}),
new FieldColorSchemeMode({
id: FieldColorModeId.ContinuousMagma,
@@ -95,6 +102,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
isContinuous: true,
isByValue: true,
interpolator: interpolateMagma,
group: accessibleGroup,
}),
new FieldColorSchemeMode({
id: FieldColorModeId.ContinuousPlasma,
@@ -102,6 +110,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
isContinuous: true,
isByValue: true,
interpolator: interpolatePlasma,
group: accessibleGroup,
}),
new FieldColorSchemeMode({
id: FieldColorModeId.ContinuousInferno,
@@ -109,6 +118,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
isContinuous: true,
isByValue: true,
interpolator: interpolateInferno,
group: accessibleGroup,
}),
new FieldColorSchemeMode({
id: FieldColorModeId.ContinuousCividis,
@@ -116,6 +126,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
isContinuous: true,
isByValue: true,
interpolator: interpolateCividis,
group: accessibleGroup,
}),
new FieldColorSchemeMode({
id: FieldColorModeId.ContinuousGrYlRd,
@@ -123,6 +134,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
isContinuous: true,
isByValue: true,
getColors: (theme: GrafanaTheme2) => ['green', 'yellow', 'red'],
group: otherGroup,
}),
new FieldColorSchemeMode({
id: FieldColorModeId.ContinuousRdYlGr,
@@ -130,6 +142,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
isContinuous: true,
isByValue: true,
getColors: (theme: GrafanaTheme2) => ['red', 'yellow', 'green'],
group: otherGroup,
}),
new FieldColorSchemeMode({
id: FieldColorModeId.ContinuousBlYlRd,
@@ -137,6 +150,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
isContinuous: true,
isByValue: true,
getColors: (theme: GrafanaTheme2) => ['dark-blue', 'super-light-yellow', 'dark-red'],
group: otherGroup,
}),
new FieldColorSchemeMode({
id: FieldColorModeId.ContinuousYlRd,
@@ -144,6 +158,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
isContinuous: true,
isByValue: true,
getColors: (theme: GrafanaTheme2) => ['super-light-yellow', 'dark-red'],
group: otherGroup,
}),
new FieldColorSchemeMode({
id: FieldColorModeId.ContinuousBlPu,
@@ -151,6 +166,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
isContinuous: true,
isByValue: true,
getColors: (theme: GrafanaTheme2) => ['blue', 'purple'],
group: otherGroup,
}),
new FieldColorSchemeMode({
id: FieldColorModeId.ContinuousYlBl,
@@ -158,6 +174,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
isContinuous: true,
isByValue: true,
getColors: (theme: GrafanaTheme2) => ['super-light-yellow', 'dark-blue'],
group: otherGroup,
}),
new FieldColorSchemeMode({
id: FieldColorModeId.ContinuousBlues,
@@ -165,6 +182,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
isContinuous: true,
isByValue: true,
getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-blue'],
group: otherGroup,
}),
new FieldColorSchemeMode({
id: FieldColorModeId.ContinuousReds,
@@ -172,6 +190,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
isContinuous: true,
isByValue: true,
getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-red'],
group: otherGroup,
}),
new FieldColorSchemeMode({
id: FieldColorModeId.ContinuousGreens,
@@ -179,6 +198,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
isContinuous: true,
isByValue: true,
getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-green'],
group: otherGroup,
}),
new FieldColorSchemeMode({
id: FieldColorModeId.ContinuousPurples,
@@ -186,6 +206,7 @@ export const fieldColorModeRegistry = new Registry<FieldColorMode>(() => {
isContinuous: true,
isByValue: true,
getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-purple'],
group: otherGroup,
}),
];
});
@@ -197,6 +218,7 @@ interface BaseFieldColorSchemeModeOptions {
isContinuous: boolean;
isByValue: boolean;
useSeriesName?: boolean;
group?: string;
}
interface FieldColorSchemeModeInterpolator extends BaseFieldColorSchemeModeOptions {
@@ -222,6 +244,7 @@ export class FieldColorSchemeMode implements FieldColorMode {
colorCacheTheme?: GrafanaTheme2;
interpolator?: (value: number) => string;
getNamedColors?: (theme: GrafanaTheme2) => string[];
group?: string;
constructor(options: FieldColorSchemeModeOptions) {
this.id = options.id;
@@ -232,6 +255,7 @@ export class FieldColorSchemeMode implements FieldColorMode {
this.isByValue = options.isByValue;
this.useSeriesName = options.useSeriesName;
this.interpolator = options.interpolator;
this.group = options.group;
}
getColors(theme: GrafanaTheme2): string[] {

View File

@@ -377,10 +377,14 @@ export interface FeatureToggles {
*/
perPanelNonApplicableDrilldowns?: boolean;
/**
* Enabled a group by action per panel
* Enables a group by action per panel
*/
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
*/
panelFilterVariable?: boolean;

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;
export type VersionedComponents = typeof versionedComponents;

View File

@@ -55,6 +55,15 @@ export interface PanelContext {
*/
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
*

View File

@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom-v5-compat';
import { Field, FieldType, LinkModel } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { VizTooltipFooter, AdHocFilterModel } from './VizTooltipFooter';
@@ -89,4 +90,65 @@ describe('VizTooltipFooter', () => {
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 { ActionModel, Field, GrafanaTheme2, LinkModel, ThemeSpacingTokens } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans } from '@grafana/i18n';
import { useStyles2 } from '../../themes/ThemeContext';
@@ -17,10 +18,16 @@ export interface AdHocFilterModel extends AdHocFilterItem {
onClick: () => void;
}
export interface FilterByGroupedLabelsModel {
onFilterForGroupedLabels?: () => void;
onFilterOutGroupedLabels?: () => void;
}
interface VizTooltipFooterProps {
dataLinks: Array<LinkModel<Field>>;
actions?: Array<ActionModel<Field>>;
adHocFilters?: AdHocFilterModel[];
filterByGroupedLabels?: FilterByGroupedLabelsModel;
annotate?: () => void;
}
@@ -85,7 +92,13 @@ const renderActions = makeRenderLinksOrActions<ActionModel>(
(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 hasOneClickLink = useMemo(() => dataLinks.some((link) => link.oneClick === true), [dataLinks]);
const hasOneClickAction = useMemo(() => actions.some((action) => action.oneClick === true), [actions]);
@@ -105,6 +118,39 @@ export const VizTooltipFooter = ({ dataLinks, actions = [], annotate, adHocFilte
))}
</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 && (
<div className={styles.footerSection}>
<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 { 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 { VizTooltipWrapper } from '../components/VizTooltip/VizTooltipWrapper';
export { VizTooltipRow } from '../components/VizTooltip/VizTooltipRow';

View File

@@ -51,6 +51,9 @@ func newIAMAuthorizer(accessClient authlib.AccessClient, legacyAccessClient auth
resourceAuthorizer[iamv0.ExternalGroupMappingResourceInfo.GetName()] = authorizer
resourceAuthorizer[iamv0.TeamResourceInfo.GetName()] = authorizer
serviceAuthorizer := gfauthorizer.NewServiceAuthorizer()
resourceAuthorizer["searchTeams"] = serviceAuthorizer
return &iamAuthorizer{resourceAuthorizer: resourceAuthorizer}
}
@@ -77,6 +80,13 @@ func newLegacyAccessClient(ac accesscontrol.AccessControl, store legacy.LegacyId
utils.VerbList: true,
},
},
accesscontrol.ResourceAuthorizerOptions{
Resource: "searchTeams",
Unchecked: map[string]bool{
utils.VerbGet: true,
utils.VerbList: true,
},
},
accesscontrol.ResourceAuthorizerOptions{
Resource: iamv0.TeamResourceInfo.GetName(),
Attr: "id",

View File

@@ -80,6 +80,7 @@ type IdentityAccessManagementAPIBuilder struct {
dual dualwrite.Service
unified resource.ResourceClient
userSearchClient resourcepb.ResourceIndexClient
teamSearch *TeamSearchHandler
teamGroupsHandler externalgroupmapping.TeamGroupsHandler

View File

@@ -29,6 +29,7 @@ import (
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
iamauthorizer "github.com/grafana/grafana/pkg/registry/apis/iam/authorizer"
"github.com/grafana/grafana/pkg/registry/apis/iam/externalgroupmapping"
"github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
@@ -46,7 +47,9 @@ import (
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ssosettings"
teamservice "github.com/grafana/grafana/pkg/services/team"
legacyuser "github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/storage/unified/apistore"
@@ -56,6 +59,7 @@ import (
const MaxConcurrentZanzanaWrites = 20
func RegisterAPIService(
cfg *setting.Cfg,
features featuremgmt.FeatureToggles,
apiregistration builder.APIRegistrar,
ssoService ssosettings.Service,
@@ -66,12 +70,14 @@ func RegisterAPIService(
reg prometheus.Registerer,
coreRolesStorage CoreRoleStorageBackend,
rolesStorage RoleStorageBackend,
tracing *tracing.TracingService,
roleBindingsStorage RoleBindingStorageBackend,
externalGroupMappingStorageBackend ExternalGroupMappingStorageBackend,
teamGroupsHandlerImpl externalgroupmapping.TeamGroupsHandler,
dual dualwrite.Service,
unified resource.ResourceClient,
userService legacyuser.Service,
teamService teamservice.Service,
) (*IdentityAccessManagementAPIBuilder, error) {
dbProvider := legacysql.NewDatabaseProvider(sql)
store := legacy.NewLegacySQLStores(dbProvider)
@@ -109,6 +115,7 @@ func RegisterAPIService(
unified: unified,
userSearchClient: resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0.UserResourceInfo.GroupResource(),
unified, user.NewUserLegacySearchClient(userService), features),
teamSearch: NewTeamSearchHandler(tracing, dual, team.NewLegacyTeamSearchClient(teamService), unified, features),
}
apiregistration.RegisterAPI(builder)
@@ -502,7 +509,11 @@ func (b *IdentityAccessManagementAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenA
func (b *IdentityAccessManagementAPIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes {
defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
return b.display.GetAPIRoutes(defs)
routes := b.teamSearch.GetAPIRoutes(defs)
routes.Namespace = append(routes.Namespace, b.display.GetAPIRoutes(defs).Namespace...)
return routes
}
func (b *IdentityAccessManagementAPIBuilder) GetAuthorizer() authorizer.Authorizer {

View File

@@ -0,0 +1,141 @@
package team
import (
"context"
"fmt"
"log/slog"
"math"
"strconv"
"google.golang.org/grpc"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/team"
res "github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/pkg/storage/unified/search/builders"
)
const (
TeamResource = "teams"
TeamResourceGroup = "iam.grafana.com"
)
// LegacyTeamSearchClient is a client for searching for teams in the legacy search engine.
type LegacyTeamSearchClient struct {
resourcepb.ResourceIndexClient
teamService team.Service
log *slog.Logger
}
// NewLegacyTeamSearchClient creates a new LegacyTeamSearchClient.
func NewLegacyTeamSearchClient(teamService team.Service) *LegacyTeamSearchClient {
return &LegacyTeamSearchClient{
teamService: teamService,
log: slog.Default().With("logger", "legacy-team-search-client"),
}
}
// Search searches for teams in the legacy search engine.
func (c *LegacyTeamSearchClient) Search(ctx context.Context, req *resourcepb.ResourceSearchRequest, _ ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) {
signedInUser, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
if req.Limit > 100 {
req.Limit = 100
}
if req.Limit <= 0 {
req.Limit = 1
}
if req.Page > math.MaxInt32 || req.Page < 0 {
return nil, fmt.Errorf("invalid page number: %d", req.Page)
}
query := &team.SearchTeamsQuery{
SignedInUser: signedInUser,
Limit: int(req.Limit),
Page: int(req.Page),
Query: req.Query,
OrgID: signedInUser.GetOrgID(),
}
res, err := c.teamService.SearchTeams(ctx, query)
if err != nil {
return nil, err
}
columns := getColumns(req.Fields)
list := &resourcepb.ResourceSearchResponse{
Results: &resourcepb.ResourceTable{
Columns: columns,
},
}
namespace := signedInUser.GetNamespace()
for _, t := range res.Teams {
cells := createCells(t, req.Fields)
list.Results.Rows = append(list.Results.Rows, &resourcepb.ResourceTableRow{
Key: getResourceKey(t, namespace),
Cells: cells,
})
}
list.TotalHits = res.TotalCount
return list, nil
}
func getResourceKey(t *team.TeamDTO, namespace string) *resourcepb.ResourceKey {
return &resourcepb.ResourceKey{
Namespace: namespace,
Group: TeamResourceGroup,
Resource: TeamResource,
Name: t.UID,
}
}
func getColumns(fields []string) []*resourcepb.ResourceTableColumnDefinition {
columns := getDefaultColumns()
for _, field := range fields {
if col, ok := builders.TeamSearchTableColumnDefinitions[field]; ok {
columns = append(columns, col)
}
}
return columns
}
func getDefaultColumns() []*resourcepb.ResourceTableColumnDefinition {
searchFields := res.StandardSearchFields()
return []*resourcepb.ResourceTableColumnDefinition{
searchFields.Field(res.SEARCH_FIELD_NAME),
searchFields.Field(res.SEARCH_FIELD_TITLE),
}
}
func createCells(t *team.TeamDTO, fields []string) [][]byte {
cells := createDefaultCells(t)
for _, field := range fields {
switch field {
case builders.TEAM_SEARCH_EMAIL:
cells = append(cells, []byte(t.Email))
case builders.TEAM_SEARCH_PROVISIONED:
cells = append(cells, []byte(strconv.FormatBool(t.IsProvisioned)))
case builders.TEAM_SEARCH_EXTERNAL_UID:
cells = append(cells, []byte(t.ExternalUID))
}
}
return cells
}
func createDefaultCells(t *team.TeamDTO) [][]byte {
return [][]byte{
[]byte(t.UID),
[]byte(t.Name),
}
}

View File

@@ -0,0 +1,107 @@
package team
import (
"context"
"errors"
"math"
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/team"
"github.com/grafana/grafana/pkg/services/team/teamtest"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)
func TestLegacyTeamSearchClient_Search(t *testing.T) {
t.Run("search by query", func(t *testing.T) {
mockTeamService := teamtest.NewFakeService()
client := NewLegacyTeamSearchClient(mockTeamService)
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1, Namespace: "default"})
req := &resourcepb.ResourceSearchRequest{
Limit: 10,
Page: 1,
Query: "test",
Fields: []string{"name", "email", "provisioned", "externalUID"},
}
mockTeamService.ExpectedSearchTeamsResult = team.SearchTeamQueryResult{
Teams: []*team.TeamDTO{
{
UID: "testTeamUID",
Name: "test team",
Email: "test@example.com",
IsProvisioned: true,
ExternalUID: "testExternalUID",
},
},
TotalCount: 1,
Page: 1,
PerPage: 10,
}
resp, err := client.Search(ctx, req)
require.NoError(t, err)
require.Equal(t, int64(1), resp.TotalHits)
require.Len(t, resp.Results.Rows, 1)
require.Len(t, resp.Results.Columns, 5)
require.Equal(t, "default", resp.Results.Rows[0].Key.Namespace)
require.Equal(t, "iam.grafana.com", resp.Results.Rows[0].Key.Group)
require.Equal(t, "teams", resp.Results.Rows[0].Key.Resource)
require.Equal(t, "testTeamUID", resp.Results.Rows[0].Key.Name)
require.Equal(t, "testTeamUID", string(resp.Results.Rows[0].Cells[0]))
require.Equal(t, "test team", string(resp.Results.Rows[0].Cells[1]))
require.Equal(t, "test@example.com", string(resp.Results.Rows[0].Cells[2]))
require.Equal(t, "true", string(resp.Results.Rows[0].Cells[3]))
require.Equal(t, "testExternalUID", string(resp.Results.Rows[0].Cells[4]))
})
t.Run("returns error if page is negative", func(t *testing.T) {
mockTeamService := teamtest.NewFakeService()
client := NewLegacyTeamSearchClient(mockTeamService)
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1, Namespace: "default"})
req := &resourcepb.ResourceSearchRequest{
Limit: 10,
Page: -1,
}
_, err := client.Search(ctx, req)
require.Error(t, err)
require.Equal(t, "invalid page number: -1", err.Error())
})
t.Run("returns error if page is greater than math.MaxInt32", func(t *testing.T) {
mockTeamService := teamtest.NewFakeService()
client := NewLegacyTeamSearchClient(mockTeamService)
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1, Namespace: "default"})
req := &resourcepb.ResourceSearchRequest{
Limit: 10,
Page: math.MaxInt32 + 1,
}
_, err := client.Search(ctx, req)
require.Error(t, err)
require.Equal(t, "invalid page number: 2147483648", err.Error())
})
t.Run("returns error if search teams fails", func(t *testing.T) {
mockTeamService := teamtest.NewFakeService()
client := NewLegacyTeamSearchClient(mockTeamService)
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1, Namespace: "default"})
req := &resourcepb.ResourceSearchRequest{
Limit: 10,
Page: 1,
Query: "test",
}
mockTeamService.ExpectedError = errors.New("search teams failed")
_, err := client.Search(ctx, req)
require.Error(t, err)
require.Equal(t, "search teams failed", err.Error())
})
}

View File

@@ -0,0 +1,192 @@
package iam
import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"go.opentelemetry.io/otel/trace"
common "k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/spec3"
"k8s.io/kube-openapi/pkg/validation/spec"
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/featuremgmt"
teamsearch "github.com/grafana/grafana/pkg/services/team/search"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/pkg/storage/unified/search/builders"
"github.com/grafana/grafana/pkg/util/errhttp"
)
type TeamSearchHandler struct {
log log.Logger
client resourcepb.ResourceIndexClient
tracer trace.Tracer
features featuremgmt.FeatureToggles
}
func NewTeamSearchHandler(tracer trace.Tracer, dual dualwrite.Service, legacyTeamSearcher resourcepb.ResourceIndexClient, resourceClient resource.ResourceClient, features featuremgmt.FeatureToggles) *TeamSearchHandler {
searchClient := resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0alpha1.TeamResourceInfo.GroupResource(), resourceClient, legacyTeamSearcher, features)
return &TeamSearchHandler{
client: searchClient,
log: log.New("grafana-apiserver.teams.search"),
tracer: tracer,
features: features,
}
}
func (s *TeamSearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *builder.APIRoutes {
searchResults := defs["github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeams"].Schema
return &builder.APIRoutes{
Namespace: []builder.APIRouteHandler{
{
Path: "searchTeams",
Spec: &spec3.PathProps{
Get: &spec3.Operation{
OperationProps: spec3.OperationProps{
Tags: []string{"Search"},
Description: "Team search",
Parameters: []*spec3.Parameter{
{
ParameterProps: spec3.ParameterProps{
Name: "namespace",
In: "path",
Required: true,
Example: "default",
Description: "workspace",
Schema: spec.StringProperty(),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "query",
In: "query",
Description: "team name query string",
Required: false,
Schema: spec.StringProperty(),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "limit",
In: "query",
Description: "limit the number of results",
Required: false,
Schema: spec.Int64Property(),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "offset",
In: "query",
Description: "start the query at the given offset",
Required: false,
Schema: spec.Int64Property(),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "page",
In: "query",
Description: "page number to start from",
Required: false,
Schema: spec.Int64Property(),
},
},
},
Responses: &spec3.Responses{
ResponsesProps: spec3.ResponsesProps{
StatusCodeResponses: map[int]*spec3.Response{
200: {
ResponseProps: spec3.ResponseProps{
Content: map[string]*spec3.MediaType{
"application/json": {
MediaTypeProps: spec3.MediaTypeProps{
Schema: &searchResults,
},
},
},
},
},
},
},
},
},
},
},
Handler: s.DoTeamSearch,
},
},
}
}
func (s *TeamSearchHandler) DoTeamSearch(w http.ResponseWriter, r *http.Request) {
ctx, span := s.tracer.Start(r.Context(), "team.search")
defer span.End()
queryParams, err := url.ParseQuery(r.URL.RawQuery)
if err != nil {
errhttp.Write(ctx, err, w)
return
}
limit := 50
offset := 0
page := 1
if queryParams.Has("limit") {
limit, _ = strconv.Atoi(queryParams.Get("limit"))
}
if queryParams.Has("offset") {
offset, _ = strconv.Atoi(queryParams.Get("offset"))
if offset > 0 {
page = (offset / limit) + 1
}
} else if queryParams.Has("page") {
page, _ = strconv.Atoi(queryParams.Get("page"))
offset = (page - 1) * limit
}
searchRequest := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{},
Query: queryParams.Get("query"),
Limit: int64(limit),
Offset: int64(offset),
Page: int64(page),
Explain: queryParams.Has("explain") && queryParams.Get("explain") != "false",
Fields: []string{
builders.TEAM_SEARCH_EMAIL,
builders.TEAM_SEARCH_PROVISIONED,
builders.TEAM_SEARCH_EXTERNAL_UID,
},
}
result, err := s.client.Search(ctx, searchRequest)
if err != nil {
errhttp.Write(ctx, err, w)
return
}
searchResults, err := teamsearch.ParseResults(result, searchRequest.Offset)
if err != nil {
errhttp.Write(ctx, err, w)
return
}
if err := s.write(w, searchResults); err != nil {
s.log.Error("failed to write team search results", "error", err)
errhttp.Write(ctx, err, w)
return
}
}
func (s *TeamSearchHandler) write(w http.ResponseWriter, obj any) error {
w.Header().Set("Content-Type", "application/json")
return json.NewEncoder(w).Encode(obj)
}

View File

@@ -0,0 +1,286 @@
package iam
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)
func TestTeamSearchFallback(t *testing.T) {
testCases := []struct {
name string
mode rest.DualWriterMode
expectedLegacyCalled bool
expectedUnifiedCalled bool
}{
{name: "mode 0", mode: rest.Mode0, expectedLegacyCalled: true, expectedUnifiedCalled: false},
{name: "mode 1", mode: rest.Mode1, expectedLegacyCalled: true, expectedUnifiedCalled: false},
{name: "mode 2", mode: rest.Mode2, expectedLegacyCalled: true, expectedUnifiedCalled: false},
{name: "mode 3", mode: rest.Mode3, expectedLegacyCalled: false, expectedUnifiedCalled: true},
{name: "mode 4", mode: rest.Mode4, expectedLegacyCalled: false, expectedUnifiedCalled: true},
{name: "mode 5", mode: rest.Mode5, expectedLegacyCalled: false, expectedUnifiedCalled: true},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
mockClient := &MockClient{}
mockLegacyClient := &MockClient{}
cfg := &setting.Cfg{
UnifiedStorage: map[string]setting.UnifiedStorageConfig{
"teams.iam.grafana.app": {DualWriterMode: testCase.mode},
},
}
dual := dualwrite.ProvideStaticServiceForTests(cfg)
searchHandler := NewTeamSearchHandler(tracing.NewNoopTracerService(), dual, mockLegacyClient, mockClient, nil)
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/teams/search", nil)
req.Header.Add("content-type", "application/json")
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
searchHandler.DoTeamSearch(rr, req)
if !testCase.expectedUnifiedCalled && mockClient.LastSearchRequest != nil {
t.Fatalf("expected Unified Search NOT to be called, but it was")
}
if testCase.expectedLegacyCalled && mockLegacyClient.LastSearchRequest == nil {
t.Fatalf("expected Legacy Search to be called, but it was not")
}
})
}
}
func TestTeamSearchHandler(t *testing.T) {
t.Run("search using default team search fields", func(t *testing.T) {
mockClient := &MockClient{}
features := featuremgmt.WithFeatures()
searchHandler := TeamSearchHandler{
log: log.New("grafana-apiserver.teams.search"),
client: mockClient,
tracer: tracing.NewNoopTracerService(),
features: features,
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/teams/search", nil)
req.Header.Add("content-type", "application/json")
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
searchHandler.DoTeamSearch(rr, req)
if mockClient.LastSearchRequest == nil {
t.Fatalf("expected Search to be called, but it was not")
}
expectedFields := []string{"email", "provisioned", "externalUID"}
if fmt.Sprintf("%v", mockClient.LastSearchRequest.Fields) != fmt.Sprintf("%v", expectedFields) {
t.Errorf("expected fields %v, got %v", expectedFields, mockClient.LastSearchRequest.Fields)
}
})
t.Run("returns error if search fails", func(t *testing.T) {
mockClient := &MockClient{
MockError: errors.New("search failed"),
}
features := featuremgmt.WithFeatures()
searchHandler := TeamSearchHandler{
log: log.New("grafana-apiserver.teams.search"),
client: mockClient,
tracer: tracing.NewNoopTracerService(),
features: features,
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/teams/search?query=test", nil)
req.Header.Add("content-type", "application/json")
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
searchHandler.DoTeamSearch(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("expected StatusInternalServerError, got %d", rr.Code)
}
})
t.Run("should calculate offset and page parameters", func(t *testing.T) {
limit := 50
for i, tt := range []struct {
offset int
page int
expectedOffset int
expectedPage int
}{
{
offset: 0,
page: 0,
expectedOffset: 0,
expectedPage: 1,
},
{
offset: 0,
page: 1,
expectedOffset: 0,
expectedPage: 1,
},
{
offset: 0,
page: 2,
expectedOffset: 50,
expectedPage: 2,
},
{
offset: 0,
page: 3,
expectedOffset: 100,
expectedPage: 3,
},
{
offset: 50,
page: 0,
expectedOffset: 50,
expectedPage: 2,
},
{
offset: 100,
page: 0,
expectedOffset: 100,
expectedPage: 3,
},
{
offset: 149,
page: 0,
expectedOffset: 149,
expectedPage: 3,
},
{
offset: 150,
page: 0,
expectedOffset: 150,
expectedPage: 4,
},
} {
mockClient := &MockClient{}
cfg := &setting.Cfg{
UnifiedStorage: map[string]setting.UnifiedStorageConfig{
"teams.iam.grafana.app": {DualWriterMode: rest.Mode0},
},
}
dual := dualwrite.ProvideStaticServiceForTests(cfg)
searchHandler := NewTeamSearchHandler(tracing.NewNoopTracerService(), dual, mockClient, mockClient, nil)
rr := httptest.NewRecorder()
endpoint := fmt.Sprintf("/teams/search?limit=%d", limit)
if tt.offset > 0 {
endpoint = fmt.Sprintf("%s&offset=%d", endpoint, tt.offset)
}
if tt.page > 0 {
endpoint = fmt.Sprintf("%s&page=%d", endpoint, tt.page)
}
req := httptest.NewRequest("GET", endpoint, nil)
req.Header.Add("content-type", "application/json")
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
searchHandler.DoTeamSearch(rr, req)
if mockClient.LastSearchRequest == nil {
t.Fatalf("expected Team Search to be called, but it was not")
}
require.Equal(t, tt.expectedOffset, int(mockClient.LastSearchRequest.Offset), fmt.Sprintf("mismatch offset in test %d", i))
require.Equal(t, tt.expectedPage, int(mockClient.LastSearchRequest.Page), fmt.Sprintf("mismatch page in test %d", i))
}
})
}
type MockClient struct {
resourcepb.ResourceIndexClient
resource.ResourceIndex
// Capture the last SearchRequest for assertions
LastSearchRequest *resourcepb.ResourceSearchRequest
MockResponses []*resourcepb.ResourceSearchResponse
MockError error
MockCalls []*resourcepb.ResourceSearchRequest
CallCount int
}
func (m *MockClient) Search(ctx context.Context, in *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) {
if m.MockError != nil {
return nil, m.MockError
}
m.LastSearchRequest = in
m.MockCalls = append(m.MockCalls, in)
var response *resourcepb.ResourceSearchResponse
if m.CallCount < len(m.MockResponses) {
response = m.MockResponses[m.CallCount]
}
m.CallCount = m.CallCount + 1
return response, nil
}
func (m *MockClient) GetStats(ctx context.Context, in *resourcepb.ResourceStatsRequest, opts ...grpc.CallOption) (*resourcepb.ResourceStatsResponse, error) {
return nil, nil
}
func (m *MockClient) CountManagedObjects(ctx context.Context, in *resourcepb.CountManagedObjectsRequest, opts ...grpc.CallOption) (*resourcepb.CountManagedObjectsResponse, error) {
return nil, nil
}
func (m *MockClient) Watch(ctx context.Context, in *resourcepb.WatchRequest, opts ...grpc.CallOption) (resourcepb.ResourceStore_WatchClient, error) {
return nil, nil
}
func (m *MockClient) Delete(ctx context.Context, in *resourcepb.DeleteRequest, opts ...grpc.CallOption) (*resourcepb.DeleteResponse, error) {
return nil, nil
}
func (m *MockClient) Create(ctx context.Context, in *resourcepb.CreateRequest, opts ...grpc.CallOption) (*resourcepb.CreateResponse, error) {
return nil, nil
}
func (m *MockClient) Update(ctx context.Context, in *resourcepb.UpdateRequest, opts ...grpc.CallOption) (*resourcepb.UpdateResponse, error) {
return nil, nil
}
func (m *MockClient) Read(ctx context.Context, in *resourcepb.ReadRequest, opts ...grpc.CallOption) (*resourcepb.ReadResponse, error) {
return nil, nil
}
func (m *MockClient) GetBlob(ctx context.Context, in *resourcepb.GetBlobRequest, opts ...grpc.CallOption) (*resourcepb.GetBlobResponse, error) {
return nil, nil
}
func (m *MockClient) PutBlob(ctx context.Context, in *resourcepb.PutBlobRequest, opts ...grpc.CallOption) (*resourcepb.PutBlobResponse, error) {
return nil, nil
}
func (m *MockClient) List(ctx context.Context, in *resourcepb.ListRequest, opts ...grpc.CallOption) (*resourcepb.ListResponse, error) {
return nil, nil
}
func (m *MockClient) ListManagedObjects(ctx context.Context, in *resourcepb.ListManagedObjectsRequest, opts ...grpc.CallOption) (*resourcepb.ListManagedObjectsResponse, error) {
return nil, nil
}
func (m *MockClient) IsHealthy(ctx context.Context, in *resourcepb.HealthCheckRequest, opts ...grpc.CallOption) (*resourcepb.HealthCheckResponse, error) {
return nil, nil
}
func (m *MockClient) BulkProcess(ctx context.Context, opts ...grpc.CallOption) (resourcepb.BulkStore_BulkProcessClient, error) {
return nil, nil
}
func (m *MockClient) UpdateIndex(ctx context.Context, reason string) error {
return nil
}

View File

@@ -338,8 +338,8 @@ func prepareQuery(
}, nil
}
func handlePreparedQuery(ctx context.Context, pq *preparedQuery) (*backend.QueryDataResponse, error) {
resp, err := service.QueryData(ctx, pq.logger, pq.cache, pq.exprSvc, pq.mReq, pq.builder, pq.headers)
func handlePreparedQuery(ctx context.Context, pq *preparedQuery, concurrentQueryLimit int) (*backend.QueryDataResponse, error) {
resp, err := service.QueryData(ctx, pq.logger, pq.cache, pq.exprSvc, pq.mReq, pq.builder, pq.headers, concurrentQueryLimit)
pq.reportMetrics()
return resp, err
}
@@ -357,7 +357,7 @@ func handleQuery(
responder.Error(err)
return nil, err
}
return handlePreparedQuery(ctx, pq)
return handlePreparedQuery(ctx, pq, b.concurrentQueryLimit)
}
type responderWrapper struct {

View File

@@ -3,10 +3,11 @@ package query
import (
"context"
"encoding/json"
"runtime"
"github.com/prometheus/client_golang/prometheus"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
apiruntime "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/rest"
@@ -62,6 +63,7 @@ func NewQueryAPIBuilder(
tracer tracing.Tracer,
legacyDatasourceLookup service.LegacyDataSourceLookup,
connections DataSourceConnectionProvider,
concurrentQueryLimit int,
) (*QueryAPIBuilder, error) {
// Include well typed query definitions
var queryTypes *query.QueryTypeDefinitionList
@@ -80,7 +82,7 @@ func NewQueryAPIBuilder(
}
return &QueryAPIBuilder{
concurrentQueryLimit: 4,
concurrentQueryLimit: concurrentQueryLimit,
log: log.New("query_apiserver"),
instanceProvider: instanceProvider,
authorizer: ar,
@@ -142,6 +144,7 @@ func RegisterAPIService(
tracer,
legacyDatasourceLookup,
&connectionsProvider{dsService: dataSourcesService, registry: reg},
cfg.SectionWithEnvOverrides("query").Key("concurrent_query_limit").MustInt(runtime.NumCPU()),
)
apiregistration.RegisterAPI(builder)
return builder, err
@@ -151,7 +154,7 @@ func (b *QueryAPIBuilder) GetGroupVersion() schema.GroupVersion {
return query.SchemeGroupVersion
}
func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
func addKnownTypes(scheme *apiruntime.Scheme, gv schema.GroupVersion) {
scheme.AddKnownTypes(gv,
&query.DataSourceApiServer{},
&query.DataSourceApiServerList{},
@@ -165,7 +168,7 @@ func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
)
}
func (b *QueryAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
func (b *QueryAPIBuilder) InstallSchema(scheme *apiruntime.Scheme) error {
addKnownTypes(scheme, query.SchemeGroupVersion)
metav1.AddToGroupVersion(scheme, query.SchemeGroupVersion)
return scheme.SetVersionPriority(query.SchemeGroupVersion)

View File

@@ -873,7 +873,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, accessClient, registerer, resourceClient, zanzanaClient)
storageBackendImpl := noopstorage.ProvideStorageBackend()
noopTeamGroupsREST := externalgroupmapping.ProvideNoopTeamGroupsREST()
identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, userService)
identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, tracingService, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, userService, teamService)
if err != nil {
return nil, err
}
@@ -1526,7 +1526,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, accessClient, registerer, resourceClient, zanzanaClient)
storageBackendImpl := noopstorage.ProvideStorageBackend()
noopTeamGroupsREST := externalgroupmapping.ProvideNoopTeamGroupsREST()
identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, userService)
identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, tracingService, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, userService, teamService)
if err != nil {
return nil, err
}

View File

@@ -609,7 +609,14 @@ var (
},
{
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,
FrontendOnly: true,
Owner: grafanaDashboardsSquad,

View File

@@ -85,6 +85,7 @@ dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true
unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true
perPanelNonApplicableDrilldowns,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
pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false
canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true
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

File diff suppressed because it is too large Load Diff

View File

@@ -226,7 +226,7 @@ func buildErrorResponses(err error, queries []*simplejson.Json) splitResponse {
return splitResponse{er, http.Header{}}
}
func QueryData(ctx context.Context, log log.Logger, dscache datasources.CacheService, exprService *expr.Service, reqDTO dtos.MetricRequest, qsDatasourceClientBuilder dsquerierclient.QSDatasourceClientBuilder, headers map[string]string) (*backend.QueryDataResponse, error) {
func QueryData(ctx context.Context, log log.Logger, dscache datasources.CacheService, exprService *expr.Service, reqDTO dtos.MetricRequest, qsDatasourceClientBuilder dsquerierclient.QSDatasourceClientBuilder, headers map[string]string, concurrentQueryLimit int) (*backend.QueryDataResponse, error) {
s := &ServiceImpl{
log: log,
dataSourceCache: dscache,
@@ -234,7 +234,7 @@ func QueryData(ctx context.Context, log log.Logger, dscache datasources.CacheSer
dataSourceRequestValidator: validations.ProvideValidator(),
qsDatasourceClientBuilder: qsDatasourceClientBuilder,
headers: headers,
concurrentQueryLimit: 16, // TODO: make it configurable
concurrentQueryLimit: concurrentQueryLimit,
}
user, err := identity.GetRequester(ctx)

View File

@@ -0,0 +1,82 @@
package search
import (
"fmt"
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/pkg/storage/unified/search/builders"
)
func ParseResults(result *resourcepb.ResourceSearchResponse, offset int64) (v0alpha1.TeamSearchResults, error) {
if result == nil {
return v0alpha1.TeamSearchResults{}, nil
} else if result.Error != nil {
return v0alpha1.TeamSearchResults{}, fmt.Errorf("%d error searching: %s: %s", result.Error.Code, result.Error.Message, result.Error.Details)
} else if result.Results == nil {
return v0alpha1.TeamSearchResults{}, nil
}
titleIDX := -1
emailIDX := -1
provisionedIDX := -1
externalUIDIDX := -1
for i, v := range result.Results.Columns {
if v == nil {
continue
}
switch v.Name {
case resource.SEARCH_FIELD_TITLE:
titleIDX = i
case builders.TEAM_SEARCH_EMAIL:
emailIDX = i
case builders.TEAM_SEARCH_PROVISIONED:
provisionedIDX = i
case builders.TEAM_SEARCH_EXTERNAL_UID:
externalUIDIDX = i
}
}
sr := v0alpha1.TeamSearchResults{
Offset: offset,
TotalHits: result.TotalHits,
QueryCost: result.QueryCost,
MaxScore: result.MaxScore,
Hits: make([]v0alpha1.TeamHit, len(result.Results.Rows)),
}
for i, row := range result.Results.Rows {
if len(row.Cells) != len(result.Results.Columns) {
return v0alpha1.TeamSearchResults{}, fmt.Errorf("error parsing team search response: mismatch number of columns and cells")
}
hit := &v0alpha1.TeamHit{
Name: row.Key.Name,
}
if titleIDX >= 0 && row.Cells[titleIDX] != nil {
hit.Title = string(row.Cells[titleIDX])
} else {
hit.Title = "(no title)"
}
if emailIDX >= 0 && row.Cells[emailIDX] != nil {
hit.Email = string(row.Cells[emailIDX])
}
if provisionedIDX >= 0 && row.Cells[provisionedIDX] != nil {
hit.Provisioned = string(row.Cells[provisionedIDX]) == "true"
}
if externalUIDIDX >= 0 && row.Cells[externalUIDIDX] != nil {
hit.ExternalUID = string(row.Cells[externalUIDIDX])
}
sr.Hits[i] = *hit
}
return sr, nil
}

View File

@@ -0,0 +1,227 @@
package search
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)
func TestParseResults(t *testing.T) {
t.Run("should parse results", func(t *testing.T) {
searchResp := &resourcepb.ResourceSearchResponse{
Results: &resourcepb.ResourceTable{
Columns: []*resourcepb.ResourceTableColumnDefinition{
{
Name: "title",
Type: resourcepb.ResourceTableColumnDefinition_STRING,
},
{
Name: "email",
Type: resourcepb.ResourceTableColumnDefinition_STRING,
},
{
Name: "provisioned",
Type: resourcepb.ResourceTableColumnDefinition_BOOLEAN,
},
{
Name: "externalUID",
Type: resourcepb.ResourceTableColumnDefinition_STRING,
},
},
Rows: []*resourcepb.ResourceTableRow{
{
Key: &resourcepb.ResourceKey{
Name: "uid",
Resource: "team",
},
Cells: [][]byte{
[]byte("Team 1"),
[]byte("team1@example.com"),
[]byte("true"),
[]byte("team1-uid"),
},
},
},
},
TotalHits: 1,
}
results, err := ParseResults(searchResp, 0)
require.NoError(t, err)
require.Len(t, results.Hits, 1)
require.Equal(t, "Team 1", results.Hits[0].Title)
require.Equal(t, "team1@example.com", results.Hits[0].Email)
require.True(t, results.Hits[0].Provisioned)
require.Equal(t, "team1-uid", results.Hits[0].ExternalUID)
})
t.Run("should handle nil result", func(t *testing.T) {
results, err := ParseResults(nil, 0)
require.NoError(t, err)
require.Empty(t, results.Hits)
require.Zero(t, results.TotalHits)
})
t.Run("should handle nil Results", func(t *testing.T) {
searchResp := &resourcepb.ResourceSearchResponse{
Results: nil,
TotalHits: 0,
}
results, err := ParseResults(searchResp, 0)
require.NoError(t, err)
require.Empty(t, results.Hits)
require.Zero(t, results.TotalHits)
})
t.Run("should handle nil Results.Rows", func(t *testing.T) {
searchResp := &resourcepb.ResourceSearchResponse{
Results: &resourcepb.ResourceTable{
Columns: []*resourcepb.ResourceTableColumnDefinition{
{
Name: "title",
Type: resourcepb.ResourceTableColumnDefinition_STRING,
},
},
Rows: nil,
},
TotalHits: 0,
}
results, err := ParseResults(searchResp, 0)
require.NoError(t, err)
require.Empty(t, results.Hits)
require.Zero(t, results.TotalHits)
})
t.Run("should return error for mismatched number of columns and cells", func(t *testing.T) {
searchResp := &resourcepb.ResourceSearchResponse{
Results: &resourcepb.ResourceTable{
Columns: []*resourcepb.ResourceTableColumnDefinition{
{
Name: "title",
Type: resourcepb.ResourceTableColumnDefinition_STRING,
},
{
Name: "email",
Type: resourcepb.ResourceTableColumnDefinition_STRING,
},
{
Name: "provisioned",
Type: resourcepb.ResourceTableColumnDefinition_BOOLEAN,
},
},
Rows: []*resourcepb.ResourceTableRow{
{
Key: &resourcepb.ResourceKey{
Name: "uid",
Resource: "team",
},
Cells: [][]byte{
[]byte("Team 1"),
[]byte("team1@example.com"),
},
},
},
},
TotalHits: 1,
}
results, err := ParseResults(searchResp, 0)
require.Error(t, err)
require.Contains(t, err.Error(), "mismatch number of columns and cells")
require.Empty(t, results.Hits)
})
t.Run("should return error for error response", func(t *testing.T) {
searchResp := &resourcepb.ResourceSearchResponse{
Error: &resourcepb.ErrorResult{
Code: 500,
Message: "Internal server error",
Details: &resourcepb.ErrorDetails{
Name: "test-resource",
},
},
}
results, err := ParseResults(searchResp, 0)
require.Error(t, err)
require.Contains(t, err.Error(), "500 error searching: Internal server error")
require.Empty(t, results.Hits)
})
t.Run("should use (no title) fallback when title cell is nil", func(t *testing.T) {
searchResp := &resourcepb.ResourceSearchResponse{
Results: &resourcepb.ResourceTable{
Columns: []*resourcepb.ResourceTableColumnDefinition{
{
Name: "title",
Type: resourcepb.ResourceTableColumnDefinition_STRING,
},
{
Name: "email",
Type: resourcepb.ResourceTableColumnDefinition_STRING,
},
},
Rows: []*resourcepb.ResourceTableRow{
{
Key: &resourcepb.ResourceKey{
Name: "uid",
Resource: "team",
},
Cells: [][]byte{
nil, // title cell is nil
[]byte("team1@example.com"),
},
},
},
},
TotalHits: 1,
}
results, err := ParseResults(searchResp, 0)
require.NoError(t, err)
require.Len(t, results.Hits, 1)
require.Equal(t, "(no title)", results.Hits[0].Title)
require.Equal(t, "team1@example.com", results.Hits[0].Email)
})
t.Run("should use (no title) fallback when title column is missing", func(t *testing.T) {
searchResp := &resourcepb.ResourceSearchResponse{
Results: &resourcepb.ResourceTable{
Columns: []*resourcepb.ResourceTableColumnDefinition{
{
Name: "email",
Type: resourcepb.ResourceTableColumnDefinition_STRING,
},
{
Name: "provisioned",
Type: resourcepb.ResourceTableColumnDefinition_BOOLEAN,
},
},
Rows: []*resourcepb.ResourceTableRow{
{
Key: &resourcepb.ResourceKey{
Name: "uid",
Resource: "team",
},
Cells: [][]byte{
[]byte("team1@example.com"),
[]byte("true"),
},
},
},
},
TotalHits: 1,
}
results, err := ParseResults(searchResp, 0)
require.NoError(t, err)
require.Len(t, results.Hits, 1)
require.Equal(t, "(no title)", results.Hits[0].Title)
require.Equal(t, "team1@example.com", results.Hits[0].Email)
require.True(t, results.Hits[0].Provisioned)
})
}

View File

@@ -7,13 +7,14 @@ import (
)
type FakeService struct {
ExpectedTeam team.Team
ExpectedIsMember bool
ExpectedIsAdmin bool
ExpectedTeamDTO *team.TeamDTO
ExpectedTeamsByUser []*team.TeamDTO
ExpectedMembers []*team.TeamMemberDTO
ExpectedError error
ExpectedTeam team.Team
ExpectedIsMember bool
ExpectedIsAdmin bool
ExpectedTeamDTO *team.TeamDTO
ExpectedTeamsByUser []*team.TeamDTO
ExpectedMembers []*team.TeamMemberDTO
ExpectedSearchTeamsResult team.SearchTeamQueryResult
ExpectedError error
}
func NewFakeService() *FakeService {
@@ -39,7 +40,7 @@ func (s *FakeService) DeleteTeam(ctx context.Context, cmd *team.DeleteTeamComman
}
func (s *FakeService) SearchTeams(ctx context.Context, query *team.SearchTeamsQuery) (team.SearchTeamQueryResult, error) {
return team.SearchTeamQueryResult{}, s.ExpectedError
return s.ExpectedSearchTeamsResult, s.ExpectedError
}
func (s *FakeService) GetTeamByID(ctx context.Context, query *team.GetTeamByIDQuery) (*team.TeamDTO, error) {

View File

@@ -67,5 +67,10 @@ func All(sql db.DB, sprinkles DashboardStats) ([]resource.DocumentBuilderInfo, e
return nil, err
}
return []resource.DocumentBuilderInfo{dashboards, users, extGroupMappings}, nil
teams, err := GetTeamSearchBuilder()
if err != nil {
return nil, err
}
return []resource.DocumentBuilderInfo{dashboards, users, extGroupMappings, teams}, nil
}

View File

@@ -70,6 +70,18 @@ func TestExternalGroupMappingDocumentBuilder(t *testing.T) {
})
}
func TestTeamSearchBuilder(t *testing.T) {
info, err := GetTeamSearchBuilder()
require.NoError(t, err)
doSnapshotTests(t, info.Builder, "team", &resourcepb.ResourceKey{
Namespace: "default",
Group: "iam.grafana.app",
Resource: "searchTeams",
}, []string{
"with-email-and-external-uid",
})
}
func TestDashboardDocumentBuilder(t *testing.T) {
key := &resourcepb.ResourceKey{
Namespace: "default",

View File

@@ -0,0 +1,87 @@
package builders
import (
"bytes"
"context"
"encoding/json"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)
const (
TEAM_SEARCH_EMAIL = "email"
TEAM_SEARCH_PROVISIONED = "provisioned"
TEAM_SEARCH_EXTERNAL_UID = "externalUID"
)
var TeamSearchTableColumnDefinitions = map[string]*resourcepb.ResourceTableColumnDefinition{
TEAM_SEARCH_EMAIL: {
Name: TEAM_SEARCH_EMAIL,
Type: resourcepb.ResourceTableColumnDefinition_STRING,
Description: "Email of the team",
},
TEAM_SEARCH_PROVISIONED: {
Name: TEAM_SEARCH_PROVISIONED,
Type: resourcepb.ResourceTableColumnDefinition_BOOLEAN,
Description: "Whether the team is provisioned",
},
TEAM_SEARCH_EXTERNAL_UID: {
Name: TEAM_SEARCH_EXTERNAL_UID,
Type: resourcepb.ResourceTableColumnDefinition_STRING,
Description: "External UID of the team",
},
}
func GetTeamSearchBuilder() (resource.DocumentBuilderInfo, error) {
values := make([]*resourcepb.ResourceTableColumnDefinition, 0, len(TeamSearchTableColumnDefinitions))
for _, v := range TeamSearchTableColumnDefinitions {
values = append(values, v)
}
fields, err := resource.NewSearchableDocumentFields(values)
return resource.DocumentBuilderInfo{
GroupResource: schema.GroupResource{
Group: "iam.grafana.app",
Resource: "searchTeams",
},
Fields: fields,
Builder: new(teamSearchBuilder),
}, err
}
var _ resource.DocumentBuilder = new(teamSearchBuilder)
type teamSearchBuilder struct{}
func (t *teamSearchBuilder) BuildDocument(ctx context.Context, key *resourcepb.ResourceKey, rv int64, value []byte) (*resource.IndexableDocument, error) {
team := &v0alpha1.Team{}
err := json.NewDecoder(bytes.NewReader(value)).Decode(team)
if err != nil {
return nil, err
}
obj, err := utils.MetaAccessor(team)
if err != nil {
return nil, err
}
doc := resource.NewIndexableDocument(key, rv, obj)
doc.Fields = make(map[string]any)
if team.Spec.Email != "" {
doc.Fields[TEAM_SEARCH_EMAIL] = team.Spec.Email
}
if team.Spec.Provisioned {
doc.Fields[TEAM_SEARCH_PROVISIONED] = team.Spec.Provisioned
}
if team.Spec.ExternalUID != "" {
doc.Fields[TEAM_SEARCH_EXTERNAL_UID] = team.Spec.ExternalUID
}
return doc, nil
}

View File

@@ -0,0 +1,18 @@
{
"key": {
"namespace": "default",
"group": "iam.grafana.app",
"resource": "searchTeams",
"name": "with-email-and-external-uid"
},
"name": "with-email-and-external-uid",
"rv": 1234,
"title": "Team With Email And External UID",
"title_ngram": "Team With Email And External UID",
"title_phrase": "team with email and external uid",
"fields": {
"email": "test@example.com",
"externalUID": "external-uid",
"provisioned": true
}
}

View File

@@ -0,0 +1,13 @@
{
"apiVersion": "iam.grafana.app/v0alpha1",
"kind": "Team",
"metadata": {
"name": "team-with-email-and-external-uid"
},
"spec": {
"title": "Team With Email And External UID",
"email": "test@example.com",
"provisioned": true,
"externalUID": "external-uid"
}
}

View File

@@ -912,6 +912,115 @@
}
]
},
"/apis/iam.grafana.app/v0alpha1/namespaces/{namespace}/searchTeams": {
"get": {
"tags": [
"Search"
],
"description": "Team search",
"parameters": [
{
"name": "namespace",
"in": "path",
"description": "workspace",
"required": true,
"schema": {
"type": "string"
},
"example": "default"
},
{
"name": "query",
"in": "query",
"description": "team name query string",
"schema": {
"type": "string"
}
},
{
"name": "limit",
"in": "query",
"description": "limit the number of results",
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "offset",
"in": "query",
"description": "start the query at the given offset",
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "page",
"in": "query",
"description": "page number to start from",
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"offset",
"totalHits",
"hits",
"queryCost",
"maxScore"
],
"properties": {
"apiVersion": {
"description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
"type": "string"
},
"hits": {
"type": "array",
"items": {
"default": {}
}
},
"kind": {
"description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
"type": "string"
},
"maxScore": {
"type": "number",
"format": "double",
"default": 0
},
"offset": {
"type": "integer",
"format": "int64",
"default": 0
},
"queryCost": {
"type": "number",
"format": "double",
"default": 0
},
"totalHits": {
"type": "integer",
"format": "int64",
"default": 0
}
}
}
}
}
}
}
}
},
"/apis/iam.grafana.app/v0alpha1/namespaces/{namespace}/serviceaccounts": {
"get": {
"tags": [
@@ -6373,6 +6482,90 @@
}
}
},
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeams": {
"type": "object",
"required": [
"offset",
"totalHits",
"hits",
"queryCost",
"maxScore"
],
"properties": {
"apiVersion": {
"description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
"type": "string"
},
"hits": {
"type": "array",
"items": {
"default": {}
}
},
"kind": {
"description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
"type": "string"
},
"maxScore": {
"type": "number",
"format": "double",
"default": 0
},
"offset": {
"type": "integer",
"format": "int64",
"default": 0
},
"queryCost": {
"type": "number",
"format": "double",
"default": 0
},
"totalHits": {
"type": "integer",
"format": "int64",
"default": 0
}
}
},
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeamsBody": {
"type": "object",
"required": [
"offset",
"totalHits",
"hits",
"queryCost",
"maxScore"
],
"properties": {
"hits": {
"type": "array",
"items": {
"default": {}
}
},
"maxScore": {
"type": "number",
"format": "double",
"default": 0
},
"offset": {
"type": "integer",
"format": "int64",
"default": 0
},
"queryCost": {
"type": "number",
"format": "double",
"default": 0
},
"totalHits": {
"type": "integer",
"format": "int64",
"default": 0
}
}
},
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRole": {
"type": "object",
"required": [
@@ -7726,6 +7919,38 @@
}
}
},
"github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit": {
"type": "object",
"required": [
"name",
"title",
"email",
"provisioned",
"externalUID"
],
"properties": {
"email": {
"type": "string",
"default": ""
},
"externalUID": {
"type": "string",
"default": ""
},
"name": {
"type": "string",
"default": ""
},
"provisioned": {
"type": "boolean",
"default": false
},
"title": {
"type": "string",
"default": ""
}
}
},
"io.k8s.apimachinery.pkg.apis.meta.v1.APIResource": {
"description": "APIResource specifies the name of a resource and whether it is namespaced.",
"type": "object",

View File

@@ -29,22 +29,39 @@ export const FieldColorEditor = ({ value, onChange, item, id }: Props) => {
? fieldColorModeRegistry.list()
: fieldColorModeRegistry.list().filter((m) => !m.isByValue);
const options = availableOptions
.filter((mode) => !mode.excludeFromPicker)
.map((mode) => {
let suffix = mode.isByValue ? ' (by value)' : '';
const filteredOptions = availableOptions.filter((option) => !option.excludeFromPicker);
return {
value: mode.id,
label: `${mode.name}${suffix}`,
description: mode.description,
isContinuous: mode.isContinuous,
isByValue: mode.isByValue,
component() {
return <FieldColorModeViz mode={mode} theme={theme} />;
},
};
});
const options: Array<SelectableValue<string>> = [];
// collect any grouped options in this map
// this allows us to easily push to the child array without having to rescan the options array
// it also allows us to maintain group position in the order they're first encountered
const groupMap = new Map<string, Array<SelectableValue<string>>>();
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() {
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>) => {
onChange({

View File

@@ -44,20 +44,21 @@ interface ShowMoreInstancesProps {
function ShowMoreInstances({ stats, onClick, href }: ShowMoreInstancesProps) {
const styles = useStyles2(getStyles);
const { visibleItemsCount, totalItemsCount } = stats;
return (
<div className={styles.footerRow}>
<div>
<Trans
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>
</div>
<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}>
Show all {'{{totalItems}}'} alert instances
<Trans i18nKey="alerting.rule-details-matching-instances.button-show-all" values={{ totalItemsCount }}>
Show all {{ totalItemsCount }} alert instances
</Trans>
</LinkButton>
</div>

View File

@@ -130,6 +130,12 @@ const getStyles = (theme: GrafanaTheme2) => ({
width: `var(${DRAGGED_ITEM_WIDTH})`,
height: `var(${DRAGGED_ITEM_HEIGHT})`,
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({
visibility: 'hidden',

View File

@@ -65,6 +65,7 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
top: number;
left: number;
} | null = null;
private _lastDropTargetGridItemKey: string | null = null;
public constructor(state: Partial<AutoGridLayoutState>) {
super({
@@ -145,6 +146,7 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
}
this._draggedGridItem = gridItem;
this._lastDropTargetGridItemKey = gridItem.state.key!;
const { top, left, width, height } = this._draggedGridItem.getBoundingBox();
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._initialGridItemPosition = null;
this._lastDropTargetGridItemKey = null;
this._resetPanelPositionAndSize();
this.setState({ draggingKey: undefined });
@@ -196,7 +199,7 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
})
?.getAttribute('data-auto-grid-item-drop-target');
if (dropTargetGridItemKey) {
if (dropTargetGridItemKey && dropTargetGridItemKey !== this._lastDropTargetGridItemKey) {
this._onDragOverItem(dropTargetGridItemKey);
}
}
@@ -207,12 +210,14 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
const draggedIdx = children.findIndex((child) => child === this._draggedGridItem);
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;
}
children.splice(draggedIdx, 1);
children.splice(draggedOverIdx, 0, this._draggedGridItem!);
this._lastDropTargetGridItemKey = this._draggedGridItem!.state.key!;
this.setState({ children });
}

View File

@@ -1,6 +1,7 @@
import { EventBusSrv } from '@grafana/data';
import { BackendSrv, setBackendSrv } from '@grafana/runtime';
import { PanelContext } from '@grafana/ui';
import { AdHocVariableModel, EventBusSrv, GroupByVariableModel, VariableModel } from '@grafana/data';
import { BackendSrv, config, setBackendSrv } from '@grafana/runtime';
import { GroupByVariable, sceneGraph } from '@grafana/scenes';
import { AdHocFilterItem, PanelContext } from '@grafana/ui';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { findVizPanelByKey } from '../utils/utils';
@@ -159,6 +160,146 @@ describe('setDashboardPanelContext', () => {
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 {
@@ -169,9 +310,29 @@ interface SceneOptions {
canDelete?: boolean;
orgCanEdit?: boolean;
existingFilterVariable?: boolean;
existingGroupByVariable?: boolean;
groupByDatasourceUid?: string;
}
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({
dashboard: {
title: 'hello',
@@ -203,15 +364,7 @@ function buildTestScene(options: SceneOptions) {
},
],
templating: {
list: options.existingFilterVariable
? [
{
type: 'adhoc',
name: 'Filters',
datasource: { uid: 'my-ds-uid' },
},
]
: [],
list: varList,
},
},
meta: {

View File

@@ -133,6 +133,43 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
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 = () => {
const dashboard = getDashboardSceneFor(vizPanel);
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) {
const variables = sceneGraph.getVariables(scene);
@@ -195,6 +247,35 @@ export function getAdHocFilterVariableFor(scene: DashboardScene, ds: DataSourceR
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) {
// 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

View File

@@ -709,6 +709,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
"title": "Repeating rows",
"uid": "Repeating-rows-uid",
"version": 1,
"weekStart": "",
}
`;

View File

@@ -143,6 +143,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
description: state.description || undefined,
uid: state.uid,
id: state.id,
editable: state.editable,
preload: state.preload,
time: {
from: timeRange.from,
@@ -158,7 +159,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
},
version: state.version,
fiscalYearStartMonth: timeRange.fiscalYearStartMonth,
weekStart: timeRange.weekStart,
weekStart: timeRange.weekStart ?? '',
tags: state.tags,
links: state.links,
graphTooltip,
@@ -170,10 +171,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
};
// Only add optional fields if they are explicitly set (not default values)
if (state.editable !== undefined) {
dashboard.editable = state.editable;
}
if (timeRange.timeZone !== undefined && timeRange.timeZone !== '') {
if (timeRange.timeZone !== '') {
dashboard.timezone = timeRange.timeZone;
}

View File

@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { NavModel, NavModelItem, PageLayoutType } from '@grafana/data';
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}`;
}
//clone the original variable
const newVariable = variableToUpdate.clone(variableToUpdate.state);
// update state name of the new variable
newVariable.setState({ name: newName });
//clone the original variable, update name and key
const newVariable = variableToUpdate.clone({ ...variableToUpdate.state, name: newName, key: uuidv4() });
const updatedVariables = [
...variables.slice(0, variableIndex + 1),

View File

@@ -18,12 +18,12 @@ import {
} from './dataquery.gen';
import {
defaultBucketAgg,
defaultMetricAgg,
findMetricById,
highlightTags,
defaultGeoHashPrecisionString,
queryTypeToMetricType,
} from './queryDef';
import { TermsQuery } from './types';
import { QueryType, TermsQuery } from './types';
import { convertOrderByToMetricId, getScriptValue } from './utils';
// Omitting 1m, 1h, 1d for now, as these cover the main use cases for calendar_interval
@@ -31,9 +31,11 @@ export const calendarIntervals: string[] = ['1w', '1M', '1q', '1y'];
export class ElasticQueryBuilder {
timeField: string;
defaultQueryMode?: QueryType;
constructor(options: { timeField: string }) {
constructor(options: { timeField: string; defaultQueryMode?: QueryType }) {
this.timeField = options.timeField;
this.defaultQueryMode = options.defaultQueryMode;
}
getRangeFilter() {
@@ -174,7 +176,10 @@ export class ElasticQueryBuilder {
build(target: ElasticsearchDataQuery) {
// make sure query has defaults;
target.metrics = target.metrics || [defaultMetricAgg()];
if (!target.metrics || target.metrics.length === 0) {
const metricType = queryTypeToMetricType(this.defaultQueryMode);
target.metrics = [{ type: metricType, id: '1' }];
}
target.bucketAggs = target.bucketAggs || [defaultBucketAgg()];
target.timeField = this.timeField;
let metric: MetricAggregation;

View File

@@ -62,10 +62,10 @@ export const ElasticsearchProvider = ({
// useStatelessReducer will then call `onChange` with the newly generated query
useEffect(() => {
if (shouldRunInit && isUninitialized) {
dispatch(initQuery());
dispatch(initQuery(datasource.defaultQueryMode));
setShouldRunInit(false);
}
}, [shouldRunInit, dispatch, isUninitialized]);
}, [shouldRunInit, dispatch, isUninitialized, datasource.defaultQueryMode]);
if (isUninitialized) {
return null;

View File

@@ -2,7 +2,7 @@ import { Action } from '@reduxjs/toolkit';
import { ElasticsearchDataQuery, MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { defaultMetricAgg } from '../../../../queryDef';
import { defaultMetricAgg, queryTypeToMetricType } from '../../../../queryDef';
import { removeEmpty } from '../../../../utils';
import { initQuery } from '../../state';
import { isMetricAggregationWithMeta, isMetricAggregationWithSettings, isPipelineAggregation } from '../aggregations';
@@ -162,7 +162,8 @@ export const reducer = (
if (state && state.length > 0) {
return state;
}
return [defaultMetricAgg('1')];
const metricType = queryTypeToMetricType(action.payload);
return [{ type: metricType, id: '1' }];
}
return state;

View File

@@ -0,0 +1,117 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ElasticsearchDataQuery } from '../../dataquery.gen';
import { useDispatch } from '../../hooks/useStatelessReducer';
import { renderWithESProvider } from '../../test-helpers/render';
import { changeMetricType } from './MetricAggregationsEditor/state/actions';
import { QueryTypeSelector } from './QueryTypeSelector';
jest.mock('../../hooks/useStatelessReducer');
describe('QueryTypeSelector', () => {
let dispatch: jest.Mock;
beforeEach(() => {
dispatch = jest.fn();
jest.mocked(useDispatch).mockReturnValue(dispatch);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should render radio buttons with correct options', () => {
const query: ElasticsearchDataQuery = {
refId: 'A',
query: '',
metrics: [{ id: '1', type: 'count' }],
bucketAggs: [{ type: 'date_histogram', id: '2' }],
};
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
expect(screen.getByRole('radio', { name: 'Metrics' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Logs' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Raw Data' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Raw Document' })).toBeInTheDocument();
});
it('should dispatch changeMetricType action when radio button is changed', async () => {
const query: ElasticsearchDataQuery = {
refId: 'A',
query: '',
metrics: [{ id: '1', type: 'count' }],
bucketAggs: [{ type: 'date_histogram', id: '2' }],
};
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
const logsRadio = screen.getByRole('radio', { name: 'Logs' });
await userEvent.click(logsRadio);
expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'logs' }));
});
it('should convert query type to metric type correctly for raw_data', async () => {
const query: ElasticsearchDataQuery = {
refId: 'A',
query: '',
metrics: [{ id: '1', type: 'count' }],
bucketAggs: [{ type: 'date_histogram', id: '2' }],
};
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
const rawDataRadio = screen.getByRole('radio', { name: 'Raw Data' });
await userEvent.click(rawDataRadio);
expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'raw_data' }));
});
it('should convert query type to metric type correctly for raw_document', async () => {
const query: ElasticsearchDataQuery = {
refId: 'A',
query: '',
metrics: [{ id: '1', type: 'count' }],
bucketAggs: [{ type: 'date_histogram', id: '2' }],
};
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
const rawDocumentRadio = screen.getByRole('radio', { name: 'Raw Document' });
await userEvent.click(rawDocumentRadio);
expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'raw_document' }));
});
it('should convert metrics query type to count metric type', async () => {
const query: ElasticsearchDataQuery = {
refId: 'A',
query: '',
metrics: [{ id: '1', type: 'logs' }],
bucketAggs: [{ type: 'date_histogram', id: '2' }],
};
renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
const metricsRadio = screen.getByRole('radio', { name: 'Metrics' });
await userEvent.click(metricsRadio);
expect(dispatch).toHaveBeenCalledWith(changeMetricType({ id: '1', type: 'count' }));
});
it('should return null when query has no metrics', () => {
const query: ElasticsearchDataQuery = {
refId: 'A',
query: '',
metrics: [],
bucketAggs: [{ type: 'date_histogram', id: '2' }],
};
const { container } = renderWithESProvider(<QueryTypeSelector />, { providerProps: { query } });
expect(container.firstChild).toBeNull();
});
});

View File

@@ -1,35 +1,14 @@
import { SelectableValue } from '@grafana/data';
import { RadioButtonGroup } from '@grafana/ui';
import { MetricAggregation } from '../../dataquery.gen';
import { QUERY_TYPE_SELECTOR_OPTIONS } from '../../configuration/utils';
import { useDispatch } from '../../hooks/useStatelessReducer';
import { queryTypeToMetricType } from '../../queryDef';
import { QueryType } from '../../types';
import { useQuery } from './ElasticsearchQueryContext';
import { changeMetricType } from './MetricAggregationsEditor/state/actions';
import { metricAggregationConfig } from './MetricAggregationsEditor/utils';
const OPTIONS: Array<SelectableValue<QueryType>> = [
{ value: 'metrics', label: 'Metrics' },
{ value: 'logs', label: 'Logs' },
{ value: 'raw_data', label: 'Raw Data' },
{ value: 'raw_document', label: 'Raw Document' },
];
function queryTypeToMetricType(type: QueryType): MetricAggregation['type'] {
switch (type) {
case 'logs':
case 'raw_data':
case 'raw_document':
return type;
case 'metrics':
return 'count';
default:
// should never happen
throw new Error(`invalid query type: ${type}`);
}
}
export const QueryTypeSelector = () => {
const query = useQuery();
const dispatch = useDispatch();
@@ -47,5 +26,12 @@ export const QueryTypeSelector = () => {
dispatch(changeMetricType({ id: firstMetric.id, type: queryTypeToMetricType(newQueryType) }));
};
return <RadioButtonGroup<QueryType> fullWidth={false} options={OPTIONS} value={queryType} onChange={onChange} />;
return (
<RadioButtonGroup<QueryType>
fullWidth={false}
options={QUERY_TYPE_SELECTOR_OPTIONS}
value={queryType}
onChange={onChange}
/>
);
};

View File

@@ -1,12 +1,13 @@
import { Action, createAction } from '@reduxjs/toolkit';
import { ElasticsearchDataQuery } from '../../dataquery.gen';
import { QueryType } from '../../types';
/**
* When the `initQuery` Action is dispatched, the query gets populated with default values where values are not present.
* This means it won't override any existing value in place, but just ensure the query is in a "runnable" state.
*/
export const initQuery = createAction('init');
export const initQuery = createAction<QueryType | undefined>('init');
export const changeQuery = createAction<ElasticsearchDataQuery['query']>('change_query');

View File

@@ -41,4 +41,16 @@ describe('ElasticDetails', () => {
})
);
});
it('should change default query mode when selected', async () => {
const onChangeMock = jest.fn();
render(<ElasticDetails onChange={onChangeMock} value={createDefaultConfigOptions()} />);
const selectEl = screen.getByLabelText('Default query mode');
await selectEvent.select(selectEl, 'Logs', { container: document.body });
expect(onChangeMock).toHaveBeenLastCalledWith(
expect.objectContaining({ jsonData: expect.objectContaining({ defaultQueryMode: 'logs' }) })
);
});
});

View File

@@ -1,10 +1,12 @@
import * as React from 'react';
import { DataSourceSettings, SelectableValue } from '@grafana/data';
import type { DataSourceSettings, SelectableValue } from '@grafana/data';
import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/plugin-ui';
import { InlineField, Input, Select, InlineSwitch } from '@grafana/ui';
import { ElasticsearchOptions, Interval } from '../types';
import type { ElasticsearchOptions, Interval, QueryType } from '../types';
import { QUERY_TYPE_SELECTOR_OPTIONS } from './utils';
const indexPatternTypes: Array<SelectableValue<'none' | Interval>> = [
{ label: 'No pattern', value: 'none' },
@@ -127,6 +129,29 @@ export const ElasticDetails = ({ value, onChange }: Props) => {
onChange={jsonDataSwitchChangeHandler('includeFrozen', value, onChange)}
/>
</InlineField>
<InlineField
label="Default query mode"
htmlFor="es_config_defaultQueryMode"
labelWidth={29}
tooltip="Default query mode to use for the data source. Defaults to 'Metrics'."
>
<Select
inputId="es_config_defaultQueryMode"
value={value.jsonData.defaultQueryMode}
options={QUERY_TYPE_SELECTOR_OPTIONS}
onChange={(selectedMode) => {
onChange({
...value,
jsonData: {
...value.jsonData,
defaultQueryMode: selectedMode.value,
},
});
}}
width={24}
/>
</InlineField>
</ConfigSubSection>
);
};
@@ -209,3 +234,6 @@ const intervalHandler =
export function defaultMaxConcurrentShardRequests() {
return 5;
}
export function defaultQueryMode(): QueryType {
return 'metrics';
}

View File

@@ -11,6 +11,7 @@ export function createDefaultConfigOptions(): DataSourceSettings<ElasticsearchOp
maxConcurrentShardRequests: 300,
logMessageField: 'test.message',
logLevelField: 'test.level',
defaultQueryMode: 'metrics',
},
secureJsonFields: {},
} as DataSourceSettings<ElasticsearchOptions>;

View File

@@ -1,8 +1,15 @@
import { DataSourceSettings } from '@grafana/data';
import { DataSourceSettings, SelectableValue } from '@grafana/data';
import { ElasticsearchOptions } from '../types';
import { ElasticsearchOptions, QueryType } from '../types';
import { defaultMaxConcurrentShardRequests } from './ElasticDetails';
import { defaultMaxConcurrentShardRequests, defaultQueryMode } from './ElasticDetails';
export const QUERY_TYPE_SELECTOR_OPTIONS: Array<SelectableValue<QueryType>> = [
{ value: 'metrics', label: 'Metrics' },
{ value: 'logs', label: 'Logs' },
{ value: 'raw_data', label: 'Raw Data' },
{ value: 'raw_document', label: 'Raw Document' },
];
export const coerceOptions = (
options: DataSourceSettings<ElasticsearchOptions, {}>
@@ -16,6 +23,7 @@ export const coerceOptions = (
logMessageField: options.jsonData.logMessageField || '',
logLevelField: options.jsonData.logLevelField || '',
includeFrozen: options.jsonData.includeFrozen ?? false,
defaultQueryMode: options.jsonData.defaultQueryMode || defaultQueryMode(),
},
};
};

View File

@@ -81,6 +81,7 @@ import {
isElasticsearchResponseWithAggregations,
isElasticsearchResponseWithHits,
ElasticsearchHits,
QueryType,
} from './types';
import { getScriptValue, isTimeSeriesQuery } from './utils';
@@ -127,6 +128,7 @@ export class ElasticDatasource
includeFrozen: boolean;
isProxyAccess: boolean;
databaseVersion: SemVer | null;
defaultQueryMode?: QueryType;
constructor(
instanceSettings: DataSourceInstanceSettings<ElasticsearchOptions>,
@@ -146,9 +148,6 @@ export class ElasticDatasource
this.intervalPattern = settingsData.interval;
this.interval = settingsData.timeInterval;
this.maxConcurrentShardRequests = settingsData.maxConcurrentShardRequests;
this.queryBuilder = new ElasticQueryBuilder({
timeField: this.timeField,
});
this.logLevelField = settingsData.logLevelField || '';
this.dataLinks = settingsData.dataLinks || [];
this.includeFrozen = settingsData.includeFrozen ?? false;
@@ -157,7 +156,11 @@ export class ElasticDatasource
this.annotations = {
QueryEditor: ElasticsearchAnnotationsQueryEditor,
};
this.defaultQueryMode = settingsData.defaultQueryMode;
this.queryBuilder = new ElasticQueryBuilder({
timeField: this.timeField,
defaultQueryMode: this.defaultQueryMode,
});
if (this.logLevelField === '') {
this.logLevelField = undefined;
}

View File

@@ -1,4 +1,5 @@
import { isPipelineAgg, isPipelineAggWithMultipleBucketPaths } from './queryDef';
import { isPipelineAgg, isPipelineAggWithMultipleBucketPaths, queryTypeToMetricType } from './queryDef';
import type { QueryType } from './types';
describe('ElasticQueryDef', () => {
describe('isPipelineMetric', () => {
@@ -36,4 +37,49 @@ describe('ElasticQueryDef', () => {
});
});
});
describe('queryTypeToMetricType', () => {
describe('when type is undefined', () => {
test('should return count as default', () => {
const result = queryTypeToMetricType(undefined);
expect(result).toBe('count');
});
});
describe('when type is metrics', () => {
test('should return count', () => {
const result = queryTypeToMetricType('metrics' as QueryType);
expect(result).toBe('count');
});
});
describe('when type is logs', () => {
test('should return logs', () => {
const result = queryTypeToMetricType('logs' as QueryType);
expect(result).toBe('logs');
});
});
describe('when type is raw_data', () => {
test('should return raw_data', () => {
const result = queryTypeToMetricType('raw_data' as QueryType);
expect(result).toBe('raw_data');
});
});
describe('when type is raw_document', () => {
test('should return raw_document', () => {
const result = queryTypeToMetricType('raw_document' as QueryType);
expect(result).toBe('raw_document');
});
});
describe('when type is invalid', () => {
test('should throw an error', () => {
expect(() => {
queryTypeToMetricType('invalid_type' as QueryType);
}).toThrow('invalid query type: invalid_type');
});
});
});
});

View File

@@ -7,6 +7,7 @@ import {
MetricAggregationType,
MovingAverageModelOption,
} from './dataquery.gen';
import type { QueryType } from './types';
export const extendedStats: ExtendedStat[] = [
{ label: 'Avg', value: 'avg' },
@@ -42,6 +43,24 @@ export function defaultBucketAgg(id = '1'): DateHistogram {
return { type: 'date_histogram', id, settings: { interval: 'auto' } };
}
export function queryTypeToMetricType(type?: QueryType): MetricAggregation['type'] {
if (!type) {
return 'count'; // Default fallback
}
switch (type) {
case 'logs':
case 'raw_data':
case 'raw_document':
return type;
case 'metrics':
return 'count';
default:
// should never happen
throw new Error(`invalid query type: ${type}`);
}
}
export const findMetricById = (metrics: MetricAggregation[], id: MetricAggregation['id']) =>
metrics.find((metric) => metric.id === id);

View File

@@ -63,6 +63,7 @@ export interface ElasticsearchOptions extends DataSourceJsonData {
index?: string;
sigV4Auth?: boolean;
oauthPassThru?: boolean;
defaultQueryMode?: QueryType;
}
export type QueryType = 'metrics' | 'logs' | 'raw_data' | 'raw_document';

View File

@@ -19,7 +19,7 @@ import {
XAxisInteractionAreaPlugin,
usePanelContext,
} 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 { config } from 'app/core/config';
@@ -31,7 +31,7 @@ import { OutsideRangePlugin } from './plugins/OutsideRangePlugin';
import { ThresholdControlsPlugin } from './plugins/ThresholdControlsPlugin';
import { getXAnnotationFrames } from './plugins/utils';
import { getPrepareTimeseriesSuggestion } from './suggestions';
import { getTimezones, prepareGraphableFields } from './utils';
import { getGroupedFilters, getTimezones, prepareGraphableFields } from './utils';
interface TimeSeriesPanelProps extends PanelProps<Options> {}
@@ -56,6 +56,8 @@ export const TimeSeriesPanel = ({
showThresholds,
eventBus,
canExecuteActions,
getFiltersBasedOnGrouping,
onAddAdHocFilters,
} = usePanelContext();
const { dataLinkPostProcessor } = useDataLinksContext();
@@ -175,6 +177,11 @@ export const TimeSeriesPanel = ({
dismiss();
};
const groupingFilters =
seriesIdx !== null && config.featureToggles.perPanelFiltering && getFiltersBasedOnGrouping
? getGroupedFilters(alignedFrame, seriesIdx, getFiltersBasedOnGrouping)
: [];
return (
// not sure it header time here works for annotations, since it's taken from nearest datapoint index
<TimeSeriesTooltip
@@ -189,6 +196,17 @@ export const TimeSeriesPanel = ({
maxHeight={options.tooltip.maxHeight}
replaceVariables={replaceVariables}
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}
compareDiffMs={compareDiffMs}
/>

View File

@@ -18,6 +18,7 @@ import {
getContentItems,
VizTooltipItem,
AdHocFilterModel,
FilterByGroupedLabelsModel,
} from '@grafana/ui/internal';
import { getFieldActions } from '../status-history/utils';
@@ -50,6 +51,7 @@ export interface TimeSeriesTooltipProps {
dataLinks: LinkModel[];
hideZeros?: boolean;
adHocFilters?: AdHocFilterModel[];
filterByGroupedLabels?: FilterByGroupedLabelsModel;
canExecuteActions?: boolean;
compareDiffMs?: number[];
}
@@ -70,8 +72,10 @@ export const TimeSeriesTooltip = ({
adHocFilters,
canExecuteActions,
compareDiffMs,
filterByGroupedLabels,
}: TimeSeriesTooltipProps) => {
const pluginContext = usePluginContext();
const xField = series.fields[0];
let xVal = xField.values[dataIdxs[0]!];
@@ -107,7 +111,13 @@ export const TimeSeriesTooltip = ({
: [];
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 { 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', () => {
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);
});
});
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';
import { convertFieldType } from '@grafana/data/internal';
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';
@@ -329,3 +330,28 @@ export function getTimezones(timezones: string[] | undefined, defaultTimezone: s
export const isTooltipScrollable = (tooltipOptions: VizTooltipOptions | HeatmapTooltip) => {
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"
},
"rule-details-matching-instances": {
"button-show-all_one": "Show all {{totalItems}} alert instances",
"button-show-all_other": "Show all {{totalItems}} alert instances",
"showing-count": "Showing {{visibleItems}} out of {{totalItems}} instances"
"button-show-all": "Show all {{totalItemsCount}} alert instances",
"showing-count": "Showing {{visibleItemsCount}} out of {{totalItemsCount}} instances"
},
"rule-editor": {
"get-content": {
@@ -8252,6 +8251,12 @@
"nextNYears_other": "Next {{count}} years"
}
},
"field": {
"fieldColor": {
"accessibleGroup": "Accessible",
"otherGroup": "Others"
}
},
"valueFormats": {
"categories": {
"acceleration": {
@@ -9252,6 +9257,8 @@
"actions-confirmation-label": "Confirmation message",
"actions-confirmation-message": "Provide a descriptive prompt to confirm or cancel the action.",
"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-navigate": "Click to open {{linkTitle}}",
"footer-filter-for-value": "Filter for '{{value}}'",