Merge branch 'main' into oscark/fix-link-overflowing-on-small-viewport

This commit is contained in:
oscarkilhed
2025-12-09 12:38:42 +01:00
133 changed files with 3784 additions and 676 deletions
+3 -3
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.
@@ -7234,9 +7234,9 @@
"type": "elasticsearch",
"uid": "gdev-elasticsearch"
},
"baseFilters": null,
"baseFilters": [],
"filters": [],
"defaultKeys": null,
"defaultKeys": [],
"hide": "dontHide",
"skipUrlSync": false,
"allowCustomValue": true
@@ -7335,9 +7335,9 @@
},
"spec": {
"name": "adhoc",
"baseFilters": null,
"baseFilters": [],
"filters": [],
"defaultKeys": null,
"defaultKeys": [],
"hide": "dontHide",
"skipUrlSync": false,
"allowCustomValue": true
@@ -307,9 +307,9 @@
"kind": "AdhocVariable",
"spec": {
"name": "adhoc",
"baseFilters": null,
"baseFilters": [],
"filters": [],
"defaultKeys": null,
"defaultKeys": [],
"hide": "dontHide",
"skipUrlSync": false,
"allowCustomValue": true
@@ -317,9 +317,9 @@
"datasource": {},
"spec": {
"name": "adhoc",
"baseFilters": null,
"baseFilters": [],
"filters": [],
"defaultKeys": null,
"defaultKeys": [],
"hide": "dontHide",
"skipUrlSync": false,
"allowCustomValue": true
@@ -604,9 +604,9 @@
"type": "loki",
"uid": "PDDA8E780A17E7EF1"
},
"baseFilters": null,
"baseFilters": [],
"filters": [],
"defaultKeys": null,
"defaultKeys": [],
"label": "Ad-hoc",
"hide": "dontHide",
"skipUrlSync": false,
@@ -616,9 +616,9 @@
},
"spec": {
"name": "adhoc",
"baseFilters": null,
"baseFilters": [],
"filters": [],
"defaultKeys": null,
"defaultKeys": [],
"label": "Ad-hoc",
"hide": "dontHide",
"skipUrlSync": false,
@@ -1885,7 +1885,7 @@
"name": "Filters",
"baseFilters": [],
"filters": [],
"defaultKeys": null,
"defaultKeys": [],
"hide": "dontHide",
"skipUrlSync": false,
"allowCustomValue": true
@@ -1931,7 +1931,7 @@
"name": "Filters",
"baseFilters": [],
"filters": [],
"defaultKeys": null,
"defaultKeys": [],
"hide": "dontHide",
"skipUrlSync": false,
"allowCustomValue": true
@@ -2184,7 +2184,7 @@
"name": "Filters",
"baseFilters": [],
"filters": [],
"defaultKeys": null,
"defaultKeys": [],
"hide": "dontHide",
"skipUrlSync": false,
"allowCustomValue": true
@@ -2219,7 +2219,7 @@
"name": "Filters",
"baseFilters": [],
"filters": [],
"defaultKeys": null,
"defaultKeys": [],
"hide": "dontHide",
"skipUrlSync": false,
"allowCustomValue": true
@@ -2499,7 +2499,7 @@
"name": "Filters",
"baseFilters": [],
"filters": [],
"defaultKeys": null,
"defaultKeys": [],
"hide": "dontHide",
"skipUrlSync": false,
"allowCustomValue": true
@@ -2542,7 +2542,7 @@
"name": "Filters",
"baseFilters": [],
"filters": [],
"defaultKeys": null,
"defaultKeys": [],
"hide": "dontHide",
"skipUrlSync": false,
"allowCustomValue": true
@@ -1539,26 +1539,38 @@ func buildAdhocVariable(ctx context.Context, varMap map[string]interface{}, comm
},
}
// Transform baseFilters if they exist
// Transform baseFilters if they exist, otherwise default to empty array
if baseFilters, exists := varMap["baseFilters"]; exists {
if baseFiltersArray, ok := baseFilters.([]interface{}); ok {
adhocVar.Spec.BaseFilters = transformAdHocFilters(baseFiltersArray)
}
}
// Ensure baseFilters is always set (default to empty array if not present or invalid)
if adhocVar.Spec.BaseFilters == nil {
adhocVar.Spec.BaseFilters = []dashv2alpha1.DashboardAdHocFilterWithLabels{}
}
// Transform filters if they exist
// Transform filters if they exist, otherwise default to empty array
if filters, exists := varMap["filters"]; exists {
if filtersArray, ok := filters.([]interface{}); ok {
adhocVar.Spec.Filters = transformAdHocFilters(filtersArray)
}
}
// Ensure filters is always set (default to empty array if not present or invalid)
if adhocVar.Spec.Filters == nil {
adhocVar.Spec.Filters = []dashv2alpha1.DashboardAdHocFilterWithLabels{}
}
// Transform defaultKeys if they exist
// Transform defaultKeys if they exist, otherwise default to empty array
if defaultKeys, exists := varMap["defaultKeys"]; exists {
if defaultKeysArray, ok := defaultKeys.([]interface{}); ok {
adhocVar.Spec.DefaultKeys = transformMetricFindValues(defaultKeysArray)
}
}
// Ensure defaultKeys is always set (default to empty array if not present or invalid)
if adhocVar.Spec.DefaultKeys == nil {
adhocVar.Spec.DefaultKeys = []dashv2alpha1.DashboardMetricFindValue{}
}
// Only include datasource if datasourceUID exists (matching frontend behavior)
if datasourceUID != "" {
+28
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
}
}
}
}
}
@@ -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()
@@ -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{}
}
@@ -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{},
}
}
@@ -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()
@@ -317,6 +317,7 @@ func AddAuthNKnownTypes(scheme *runtime.Scheme) error {
&ServiceAccountList{},
&Team{},
&TeamList{},
&GetSearchTeams{},
&TeamBinding{},
&TeamBindingList{},
&ExternalGroupMapping{},
@@ -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"`
}
+177
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"},
},
},
}
}
+135 -3
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.
@@ -37,6 +37,11 @@ refs:
destination: /docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/
fixed-role-definitions:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/#fixed-role-definitions
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/security-and-account-management/authentication-and-permissions/access-control/rbac-fixed-basic-role-definitions/#fixed-role-definitions
---
# Externally shared dashboards
@@ -49,6 +54,8 @@ Externally shared dashboards allow you to share your Grafana dashboard with anyo
If you change a dashboard, ensure that you save the changes before sharing.
In order to create public dashboards, you need to be an Admin, have the `dashboards.public:write` permission, or the `fixed:dashboards.public:write` [RBAC role](ref:fixed-role-definitions).
{{< admonition type="warning" >}}
Sharing your dashboard externally could result in a large number of queries to the data sources used by your dashboard.
This can be mitigated by using the Enterprise [caching](ref:caching) and/or rate limiting features.
@@ -95,7 +95,7 @@ test.describe(
await createNewPanelButton.click();
// Close the data source picker modal
const closeButton = page.getByRole('button', { name: 'Close menu' });
const closeButton = page.getByRole('button', { name: 'Close', exact: true });
await closeButton.click({ force: true });
// Select prom data source from the data source list
-5
View File
@@ -710,11 +710,6 @@
"count": 1
}
},
"packages/grafana-ui/src/components/QueryField/QueryField.story.tsx": {
"no-restricted-syntax": {
"count": 1
}
},
"packages/grafana-ui/src/components/QueryField/QueryField.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
+1 -1
View File
@@ -92,7 +92,7 @@
"@emotion/eslint-plugin": "11.12.0",
"@grafana/eslint-config": "8.2.0",
"@grafana/eslint-plugin": "link:./packages/grafana-eslint-rules",
"@grafana/plugin-e2e": "^3.0.1",
"@grafana/plugin-e2e": "^3.0.3",
"@grafana/test-utils": "workspace:*",
"@manypkg/get-packages": "^3.0.0",
"@npmcli/package-json": "^6.0.0",
@@ -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,
@@ -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[] {
+5 -1
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;
@@ -30,6 +30,7 @@ export type PluginExtensionLink = PluginExtensionBase & {
onClick?: (event?: React.MouseEvent) => void;
icon?: IconName;
category?: string;
openInNewTab?: boolean;
};
export type PluginExtensionComponentMeta = Omit<PluginExtensionComponent, 'component'>;
@@ -86,6 +87,7 @@ export type PluginExtensionAddedComponentConfig<Props = {}> = PluginExtensionCon
*/
component: React.ComponentType<Props>;
};
export type PluginExtensionAddedFunctionConfig<Signature = unknown> = PluginExtensionConfigBase & {
/**
* The target extension points where the component will be added
@@ -106,6 +108,7 @@ export type PluginAddedLinksConfigureFunc<Context extends object> = (context: Re
onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => void;
icon: IconName;
category: string;
openInNewTab: boolean;
}>
| undefined;
@@ -137,6 +140,10 @@ export type PluginExtensionAddedLinkConfig<Context extends object = object> = Pl
// (Optional) A category to be used when grouping the options in the ui
category?: string;
// (Optional) If true, opens the link in a new tab (renders with target="_blank")
// (Important: this is not guaranteed, depends on the extension point if it implements it.)
openInNewTab?: boolean;
};
export type PluginExtensionExposedComponentConfig<Props = {}> = PluginExtensionConfigBase & {
@@ -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;
@@ -93,7 +93,7 @@ export const Icon = React.memo(
// this prevents content layout shift whilst the icon asynchronously loads
// which happens even if the icon is in the cache(!)
loader={
<span
<svg
className={cx(
css({
width: svgWid,
@@ -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
*
@@ -1,6 +1,9 @@
import { Meta, StoryFn } from '@storybook/react';
import { useId } from 'react';
import { TypeaheadInput } from '../../types/completion';
import { Field } from '../Forms/Field';
import { Label } from '../Forms/Label';
import { QueryField, QueryFieldProps } from './QueryField';
@@ -24,8 +27,6 @@ const meta: Meta<typeof QueryField> = {
'syntaxLoaded',
],
},
// TODO fix a11y issue in story and remove this
a11y: { test: 'off' },
},
argTypes: {
query: {
@@ -34,7 +35,16 @@ const meta: Meta<typeof QueryField> = {
},
};
export const Basic: StoryFn<typeof QueryField> = (args: Omit<QueryFieldProps, 'theme'>) => <QueryField {...args} />;
export const Basic: StoryFn<typeof QueryField> = (args: Omit<QueryFieldProps, 'theme'>) => {
const id = useId();
// have to manually set an id on the label
// can't use htmlFor as QueryField is a contenteditable div, not an input
return (
<Field label={<Label id={id}>Query field</Label>}>
<QueryField {...args} aria-labelledby={id} />
</Field>
);
};
Basic.args = {
onTypeahead: async (_input: TypeaheadInput) => ({
@@ -25,6 +25,7 @@ import { makeValue, SCHEMA } from '../../utils/slate';
export interface QueryFieldProps extends Themeable2 {
additionalPlugins?: Plugin[];
['aria-labelledby']?: string;
cleanText?: (text: string) => string;
disabled?: boolean;
// We have both value and local state. This is usually an antipattern but we need to keep local state
@@ -201,7 +202,7 @@ export class UnThemedQueryField extends PureComponent<QueryFieldProps, QueryFiel
}
render() {
const { disabled, theme } = this.props;
const { disabled, theme, ['aria-labelledby']: ariaLabelledby } = this.props;
const wrapperClassName = classnames('slate-query-field__wrapper', {
'slate-query-field__wrapper--disabled': disabled,
});
@@ -214,6 +215,7 @@ export class UnThemedQueryField extends PureComponent<QueryFieldProps, QueryFiel
ref={(editor) => {
this.editor = editor;
}}
aria-labelledby={ariaLabelledby}
schema={SCHEMA}
autoCorrect={false}
readOnly={this.props.disabled}
@@ -206,7 +206,7 @@ const getStyles = (theme: GrafanaTheme2) => {
background: 'transparent',
border: `1px solid transparent`,
'&:hover, &:focus': {
'&:hover': {
color: theme.colors.text.primary,
background: theme.colors.action.hover,
},
@@ -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();
});
});
@@ -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}>
+5 -1
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';
+10
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",
+1
View File
@@ -80,6 +80,7 @@ type IdentityAccessManagementAPIBuilder struct {
dual dualwrite.Service
unified resource.ResourceClient
userSearchClient resourcepb.ResourceIndexClient
teamSearch *TeamSearchHandler
teamGroupsHandler externalgroupmapping.TeamGroupsHandler
+12 -1
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 {
+141
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),
}
}
@@ -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())
})
}
+192
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)
}
+286
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
}
-4
View File
@@ -64,10 +64,6 @@ func NewAPIBuilder(providerType string, url *url.URL, insecure bool, caFile stri
}
func RegisterAPIService(apiregistration builder.APIRegistrar, cfg *setting.Cfg) (*APIBuilder, error) {
if !cfg.OpenFeature.APIEnabled {
return nil, nil
}
var staticEvaluator featuremgmt.StaticFlagEvaluator // No static evaluator needed for non-static provider
var err error
if cfg.OpenFeature.ProviderType == setting.StaticProviderType {
+3 -3
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 {
+7 -4
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)
+2 -2
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
}
+10
View File
@@ -5,6 +5,7 @@ import (
"net"
"path/filepath"
"strconv"
"strings"
"github.com/grafana/grafana/pkg/services/apiserver/options"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -40,6 +41,15 @@ func applyGrafanaConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles, o
apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver")
runtimeConfig := apiserverCfg.Key("runtime_config").String()
runtimeConfigSplit := strings.Split(runtimeConfig, ",")
// TODO: temporary fix to allow disabling local features service and still being able to use its authz handler
if !cfg.OpenFeature.APIEnabled {
runtimeConfigSplit = append(runtimeConfigSplit, "features.grafana.app/v0alpha1=false")
}
runtimeConfig = strings.Join(runtimeConfigSplit, ",")
if runtimeConfig != "" {
if err := o.APIEnablementOptions.RuntimeConfig.Set(runtimeConfig); err != nil {
return fmt.Errorf("failed to set runtime config: %w", err)
+8 -1
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,
+1
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
+299 -355
View File
File diff suppressed because it is too large Load Diff
+2 -2
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)
+82
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
}
+227
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)
})
}
+9 -8
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) {
@@ -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
}
@@ -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",
@@ -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
}
@@ -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
}
}
@@ -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"
}
}
+144
View File
@@ -0,0 +1,144 @@
package apis
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/util/testutil"
)
const pluginsDiscoveryJSON = `[
{
"version": "v0alpha1",
"freshness": "Current",
"resources": [
{
"resource": "pluginmetas",
"responseKind": {
"group": "",
"kind": "PluginMeta",
"version": ""
},
"scope": "Namespaced",
"singularResource": "pluginmeta",
"subresources": [
{
"responseKind": {
"group": "",
"kind": "PluginMeta",
"version": ""
},
"subresource": "status",
"verbs": [
"get",
"patch",
"update"
]
}
],
"verbs": [
"get",
"list"
]
},
{
"resource": "plugins",
"responseKind": {
"group": "",
"kind": "Plugin",
"version": ""
},
"scope": "Namespaced",
"singularResource": "plugins",
"subresources": [
{
"responseKind": {
"group": "",
"kind": "Plugin",
"version": ""
},
"subresource": "status",
"verbs": [
"get",
"patch",
"update"
]
}
],
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
}
]
}
]`
func setupHelper(t *testing.T, openFeatureAPIEnabled bool) *K8sTestHelper {
t.Helper()
helper := NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerRuntimeConfig: "plugins.grafana.app/v0alpha1=true",
OpenFeatureAPIEnabled: openFeatureAPIEnabled,
})
t.Cleanup(func() { helper.Shutdown() })
return helper
}
func TestIntegrationAPIServerRuntimeConfig(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
t.Run("discovery with openfeature api enabled", func(t *testing.T) {
helper := setupHelper(t, true)
disco, err := helper.GetGroupVersionInfoJSON("features.grafana.app")
require.NoError(t, err)
require.JSONEq(t, `[
{
"freshness": "Current",
"resources": [
{
"resource": "noop",
"responseKind": {
"group": "",
"kind": "Status",
"version": ""
},
"scope": "Namespaced",
"singularResource": "noop",
"verbs": [
"get"
]
}
],
"version": "v0alpha1"
}
]`, disco)
// plugins should still be discoverable
disco, err = helper.GetGroupVersionInfoJSON("plugins.grafana.app")
require.NoError(t, err)
require.JSONEq(t, pluginsDiscoveryJSON, disco)
require.NoError(t, err)
})
t.Run("discovery with openfeature api false", func(t *testing.T) {
helper := setupHelper(t, false)
_, err := helper.GetGroupVersionInfoJSON("features.grafana.app")
require.Error(t, err, "expected error when openfeature api is disabled")
// plugins should still be discoverable
disco, err := helper.GetGroupVersionInfoJSON("plugins.grafana.app")
require.NoError(t, err)
require.JSONEq(t, pluginsDiscoveryJSON, disco)
require.NoError(t, err)
})
}
+4 -2
View File
@@ -22,9 +22,11 @@ func TestIntegrationFeatures(t *testing.T) {
// Enable a random flag -- check that it is reported as enabled
flag := featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs
// the test below tests using enable_api = true, without that, the runtime_config has been instructed to skip the API
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: false, // allow anon user
OpenFeatureAPIEnabled: true,
AppModeProduction: true,
DisableAnonymous: false, // allow anon user
EnableFeatureToggles: []string{
flag, // used in test below
},
+3 -4
View File
@@ -792,7 +792,7 @@ func (c *K8sTestHelper) NewDiscoveryClient() *discovery.DiscoveryClient {
return client
}
func (c *K8sTestHelper) GetGroupVersionInfoJSON(group string) string {
func (c *K8sTestHelper) GetGroupVersionInfoJSON(group string) (string, error) {
c.t.Helper()
disco := c.NewDiscoveryClient()
@@ -823,12 +823,11 @@ func (c *K8sTestHelper) GetGroupVersionInfoJSON(group string) string {
if item.Metadata.Name == group {
v, err := json.MarshalIndent(item.Versions, "", " ")
require.NoError(c.t, err)
return string(v)
return string(v), nil
}
}
require.Failf(c.t, "could not find discovery info for: %s", group)
return ""
return "", goerrors.New("could not find discovery info for: " + group)
}
func (c *K8sTestHelper) CreateDS(cmd *datasources.AddDataSourceCommand) *datasources.DataSource {
@@ -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",
+2 -1
View File
@@ -50,7 +50,8 @@ func TestIntegrationPlaylist(t *testing.T) {
}))
// The accepted verbs will change when dual write is enabled
disco := h.GetGroupVersionInfoJSON("playlist.grafana.app")
disco, err := h.GetGroupVersionInfoJSON("playlist.grafana.app")
require.NoError(t, err)
// t.Logf("%s", disco)
require.JSONEq(t, `[
{
+2 -1
View File
@@ -13,7 +13,8 @@ func TestIntegrationPluginsIntegrationDiscovery(t *testing.T) {
t.Run("discovery", func(t *testing.T) {
helper := setupHelper(t)
disco := helper.GetGroupVersionInfoJSON("plugins.grafana.app")
disco, err := helper.GetGroupVersionInfoJSON("plugins.grafana.app")
require.NoError(t, err)
require.JSONEq(t, `[
{
"version": "v0alpha1",
+16
View File
@@ -316,6 +316,21 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
_, err = serverSect.NewKey("static_root_path", publicDir)
require.NoError(t, err)
openFeatureSect, err := cfg.NewSection("feature_toggles.openfeature")
require.NoError(t, err)
_, err = openFeatureSect.NewKey("enable_api", strconv.FormatBool(opts.OpenFeatureAPIEnabled))
require.NoError(t, err)
if !opts.OpenFeatureAPIEnabled {
_, err = openFeatureSect.NewKey("provider", "static") // in practice, APIEnabled being false goes with goff type, but trying to make tests work
require.NoError(t, err)
_, err = openFeatureSect.NewKey("targetingKey", "grafana")
require.NoError(t, err)
// so staticFlags can be provided to static provider
_, err := cfg.NewSection("feature_toggles")
require.NoError(t, err)
}
anonSect, err := cfg.NewSection("auth.anonymous")
require.NoError(t, err)
_, err = anonSect.NewKey("enabled", "true")
@@ -654,6 +669,7 @@ type GrafanaOpts struct {
DisableDBCleanup bool
DisableDataMigrations bool
SecretsManagerEnableDBMigrations bool
OpenFeatureAPIEnabled bool
// Allow creating grafana dir beforehand
Dir string
+28 -3
View File
@@ -62,6 +62,7 @@ type QueryModel struct {
IsCounter bool `json:"isCounter"`
CounterMax string `json:"counterMax"`
CounterResetValue string `json:"counterResetValue"`
ExplicitTags bool `json:"explicitTags"`
}
func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.InstanceFactoryFunc {
@@ -236,8 +237,19 @@ func createInitialFrame(val OpenTsdbCommon, length int, refID string) *data.Fram
labels[label] = value
}
tagKeys := make([]string, 0, len(val.Tags)+len(val.AggregateTags))
for tagKey := range val.Tags {
tagKeys = append(tagKeys, tagKey)
}
sort.Strings(tagKeys)
tagKeys = append(tagKeys, val.AggregateTags...)
frame := data.NewFrameOfFieldTypes(val.Metric, length, data.FieldTypeTime, data.FieldTypeFloat64)
frame.Meta = &data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti, TypeVersion: data.FrameTypeVersion{0, 1}}
frame.Meta = &data.FrameMeta{
Type: data.FrameTypeTimeSeriesMulti,
TypeVersion: data.FrameTypeVersion{0, 1},
Custom: map[string]any{"tagKeys": tagKeys},
}
frame.RefID = refID
timeField := frame.Fields[0]
timeField.Name = data.TimeSeriesTimeFieldName
@@ -355,10 +367,19 @@ func (s *Service) buildMetric(query backend.DataQuery) map[string]any {
if !model.DisableDownsampling {
downsampleInterval := model.DownsampleInterval
if downsampleInterval == "" {
downsampleInterval = "1m" // default value for blank
if ms := query.Interval.Milliseconds(); ms > 0 {
downsampleInterval = FormatDownsampleInterval(ms)
} else {
downsampleInterval = "1m"
}
} else if strings.Contains(downsampleInterval, ".") && strings.HasSuffix(downsampleInterval, "s") {
if val, err := strconv.ParseFloat(strings.TrimSuffix(downsampleInterval, "s"), 64); err == nil {
downsampleInterval = strconv.FormatInt(int64(val*1000), 10) + "ms"
}
}
downsample := downsampleInterval + "-" + model.DownsampleAggregator
if model.DownsampleFillPolicy != "none" {
if model.DownsampleFillPolicy != "" && model.DownsampleFillPolicy != "none" {
metric["downsample"] = downsample + "-" + model.DownsampleFillPolicy
} else {
metric["downsample"] = downsample
@@ -408,6 +429,10 @@ func (s *Service) buildMetric(query backend.DataQuery) map[string]any {
metric["filters"] = model.Filters
}
if model.ExplicitTags {
metric["explicitTags"] = true
}
return metric
}
+201
View File
@@ -70,6 +70,164 @@ func TestCheckHealth(t *testing.T) {
}
}
func TestBuildMetric(t *testing.T) {
service := &Service{}
t.Run("Metric with no downsampleInterval should use query interval", func(t *testing.T) {
query := backend.DataQuery{
JSON: []byte(`
{
"metric": "cpu.average.percent",
"aggregator": "avg",
"disableDownsampling": false,
"downsampleInterval": "",
"downsampleAggregator": "avg",
"downsampleFillPolicy": "none"
}`,
),
Interval: 30 * time.Second,
}
metric := service.buildMetric(query)
require.Equal(t, "30s-avg", metric["downsample"], "should use query interval formatted as seconds")
})
t.Run("Metric with downsampleInterval converts decimal seconds to milliseconds", func(t *testing.T) {
query := backend.DataQuery{
JSON: []byte(`
{
"metric": "cpu.average.percent",
"aggregator": "avg",
"disableDownsampling": false,
"downsampleInterval": "0.5s",
"downsampleAggregator": "avg",
"downsampleFillPolicy": "none"
}`,
),
}
metric := service.buildMetric(query)
require.Equal(t, "500ms-avg", metric["downsample"], "should convert 0.5s to 500ms")
})
t.Run("Metric with no downsampleInterval uses milliseconds for sub-second query interval", func(t *testing.T) {
query := backend.DataQuery{
JSON: []byte(`
{
"metric": "cpu.average.percent",
"aggregator": "avg",
"disableDownsampling": false,
"downsampleInterval": "",
"downsampleAggregator": "avg",
"downsampleFillPolicy": "none"
}`,
),
Interval: 500 * time.Millisecond,
}
metric := service.buildMetric(query)
require.Equal(t, "500ms-avg", metric["downsample"], "should use query interval formatted as milliseconds")
})
t.Run("Metric with no downsampleInterval uses minutes for longer intervals", func(t *testing.T) {
query := backend.DataQuery{
JSON: []byte(`
{
"metric": "cpu.average.percent",
"aggregator": "avg",
"disableDownsampling": false,
"downsampleInterval": "",
"downsampleAggregator": "sum",
"downsampleFillPolicy": "none"
}`,
),
Interval: 5 * time.Minute,
}
metric := service.buildMetric(query)
require.Equal(t, "5m-sum", metric["downsample"], "should use query interval formatted as minutes")
})
t.Run("Metric with no downsampleInterval uses hours for multi-hour intervals", func(t *testing.T) {
query := backend.DataQuery{
JSON: []byte(`
{
"metric": "cpu.average.percent",
"aggregator": "avg",
"disableDownsampling": false,
"downsampleInterval": "",
"downsampleAggregator": "max",
"downsampleFillPolicy": "none"
}`,
),
Interval: 2 * time.Hour,
}
metric := service.buildMetric(query)
require.Equal(t, "2h-max", metric["downsample"], "should use query interval formatted as hours")
})
t.Run("Metric with no downsampleInterval uses days for multi-day intervals", func(t *testing.T) {
query := backend.DataQuery{
JSON: []byte(`
{
"metric": "cpu.average.percent",
"aggregator": "avg",
"disableDownsampling": false,
"downsampleInterval": "",
"downsampleAggregator": "min",
"downsampleFillPolicy": "none"
}`,
),
Interval: 48 * time.Hour,
}
metric := service.buildMetric(query)
require.Equal(t, "2d-min", metric["downsample"], "should use query interval formatted as days")
})
t.Run("Build metric with explicitTags enabled", func(t *testing.T) {
query := backend.DataQuery{
JSON: []byte(`
{
"metric": "cpu.average.percent",
"aggregator": "avg",
"disableDownsampling": true,
"explicitTags": true,
"tags": {
"host": "server01"
}
}`,
),
}
metric := service.buildMetric(query)
require.True(t, metric["explicitTags"].(bool), "explicitTags should be true")
metricTags := metric["tags"].(map[string]any)
require.Equal(t, "server01", metricTags["host"])
})
t.Run("Build metric with explicitTags disabled does not include explicitTags", func(t *testing.T) {
query := backend.DataQuery{
JSON: []byte(`
{
"metric": "cpu.average.percent",
"aggregator": "avg",
"disableDownsampling": true,
"explicitTags": false,
"tags": {
"host": "server01"
}
}`,
),
}
metric := service.buildMetric(query)
require.Nil(t, metric["explicitTags"], "explicitTags should not be present when false")
})
}
func TestOpenTsdbExecutor(t *testing.T) {
service := &Service{}
@@ -119,6 +277,7 @@ func TestOpenTsdbExecutor(t *testing.T) {
testFrame.Meta = &data.FrameMeta{
Type: data.FrameTypeTimeSeriesMulti,
TypeVersion: data.FrameTypeVersion{0, 1},
Custom: map[string]any{"tagKeys": []string{"app", "env"}},
}
testFrame.RefID = "A"
tsdbVersion := float32(4)
@@ -160,6 +319,7 @@ func TestOpenTsdbExecutor(t *testing.T) {
testFrame.Meta = &data.FrameMeta{
Type: data.FrameTypeTimeSeriesMulti,
TypeVersion: data.FrameTypeVersion{0, 1},
Custom: map[string]any{"tagKeys": []string{"app", "env"}},
}
testFrame.RefID = "A"
tsdbVersion := float32(3)
@@ -232,6 +392,7 @@ func TestOpenTsdbExecutor(t *testing.T) {
testFrame.Meta = &data.FrameMeta{
Type: data.FrameTypeTimeSeriesMulti,
TypeVersion: data.FrameTypeVersion{0, 1},
Custom: map[string]any{"tagKeys": []string{"app", "env"}},
}
testFrame.RefID = "A"
tsdbVersion := float32(3)
@@ -275,6 +436,7 @@ func TestOpenTsdbExecutor(t *testing.T) {
testFrame.Meta = &data.FrameMeta{
Type: data.FrameTypeTimeSeriesMulti,
TypeVersion: data.FrameTypeVersion{0, 1},
Custom: map[string]any{"tagKeys": []string{"app", "env"}},
}
testFrame.RefID = myRefid
@@ -290,6 +452,45 @@ func TestOpenTsdbExecutor(t *testing.T) {
}
})
t.Run("tagKeys are returned sorted alphabetically in frame metadata", func(t *testing.T) {
response := `
[
{
"metric": "cpu.usage",
"dps": [
[1405544146, 75.5]
],
"tags" : {
"zone": "us-east-1",
"host": "server01",
"app": "api",
"env": "production"
}
}
]`
tsdbVersion := float32(4)
resp := http.Response{Body: io.NopCloser(strings.NewReader(response))}
resp.StatusCode = 200
result, err := service.parseResponse(logger, &resp, "A", tsdbVersion)
require.NoError(t, err)
frame := result.Responses["A"].Frames[0]
require.NotNil(t, frame.Meta, "frame metadata should not be nil")
require.NotNil(t, frame.Meta.Custom, "frame custom metadata should not be nil")
customMeta, ok := frame.Meta.Custom.(map[string]any)
require.True(t, ok, "custom metadata should be a map")
tagKeys, ok := customMeta["tagKeys"].([]string)
require.True(t, ok, "tagKeys should be present and be a string slice")
require.Len(t, tagKeys, 4, "should have 4 tag keys")
expectedTagKeys := []string{"app", "env", "host", "zone"}
require.Equal(t, expectedTagKeys, tagKeys, "tagKeys should be sorted alphabetically")
})
t.Run("Build metric with downsampling enabled", func(t *testing.T) {
query := backend.DataQuery{
JSON: []byte(`
+3 -2
View File
@@ -7,8 +7,9 @@ type OpenTsdbQuery struct {
}
type OpenTsdbCommon struct {
Metric string `json:"metric"`
Tags map[string]string `json:"tags"`
Metric string `json:"metric"`
Tags map[string]string `json:"tags"`
AggregateTags []string `json:"aggregateTags"`
}
type OpenTsdbResponse struct {
+31
View File
@@ -0,0 +1,31 @@
package opentsdb
import (
"strconv"
"time"
)
func FormatDownsampleInterval(ms int64) string {
duration := time.Duration(ms) * time.Millisecond
seconds := int64(duration / time.Second)
if seconds < 60 {
if seconds < 1 {
return strconv.FormatInt(ms, 10) + "ms"
}
return strconv.FormatInt(seconds, 10) + "s"
}
minutes := int64(duration / time.Minute)
if minutes < 60 {
return strconv.FormatInt(minutes, 10) + "m"
}
hours := int64(duration / time.Hour)
if hours < 24 {
return strconv.FormatInt(hours, 10) + "h"
}
days := int64(duration / (24 * time.Hour))
return strconv.FormatInt(days, 10) + "d"
}
@@ -119,7 +119,7 @@ export class AppChromeService {
};
private getUpdatedHistory(newState: AppChromeState): HistoryEntry[] {
const breadcrumbs = buildBreadcrumbs(newState.sectionNav.node, newState.pageNav, { text: 'Home', url: '/' }, true);
const breadcrumbs = buildBreadcrumbs(newState.sectionNav.node, newState.pageNav, { text: 'Home', url: '/' });
const newPageNav = newState.pageNav || newState.sectionNav.node;
let entries = store.getObject<HistoryEntry[]>(HISTORY_LOCAL_STORAGE_KEY, []);
@@ -1,34 +0,0 @@
import { css } from '@emotion/css';
import type { JSX } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
export interface Props {
children: JSX.Element;
}
export const FeatureHighlight = ({ children }: Props): JSX.Element => {
const styles = useStyles2(getStyles);
return (
<>
{children}
<span className={styles.highlight} />
</>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
highlight: css({
backgroundColor: theme.colors.success.main,
borderRadius: theme.shape.radius.circle,
width: '6px',
height: '6px',
display: 'inline-block;',
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
}),
};
};
@@ -62,10 +62,6 @@ export const MegaMenu = memo(
const activeItem = getActiveItem(navItems, state.sectionNav.node, location.pathname);
const handleMegaMenu = () => {
chrome.setMegaMenuOpen(!state.megaMenuOpen);
};
const handleDockedMenu = () => {
chrome.setMegaMenuDocked(!state.megaMenuDocked);
if (state.megaMenuDocked) {
@@ -108,7 +104,7 @@ export const MegaMenu = memo(
return (
<div data-testid={selectors.components.NavMenu.Menu} ref={ref} {...restProps}>
<MegaMenuHeader handleDockedMenu={handleDockedMenu} handleMegaMenu={handleMegaMenu} onClose={onClose} />
<MegaMenuHeader handleDockedMenu={handleDockedMenu} onClose={onClose} />
<nav className={styles.content}>
<ScrollContainer height="100%" overflowX="hidden" showScrollIndicators>
<>
@@ -159,7 +155,7 @@ const getStyles = (theme: GrafanaTheme2) => {
display: 'flex',
flexDirection: 'column',
listStyleType: 'none',
padding: theme.spacing(1, 1, 2, 1),
padding: theme.spacing(1, 1, 2, 0.5),
[theme.breakpoints.up('md')]: {
width: MENU_WIDTH,
},
@@ -2,15 +2,16 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { IconButton, Stack, ToolbarButton, useTheme2 } from '@grafana/ui';
import { IconButton, Stack, useTheme2 } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { HOME_NAV_ID } from 'app/core/reducers/navModel';
import { useSelector } from 'app/types/store';
import { Branding } from '../../Branding/Branding';
import { HomeLink } from '../../Branding/Branding';
import { OrganizationSwitcher } from '../OrganizationSwitcher/OrganizationSwitcher';
import { getChromeHeaderLevelHeight } from '../TopBar/useChromeHeaderHeight';
export interface Props {
handleMegaMenu: () => void;
handleDockedMenu: () => void;
onClose: () => void;
}
@@ -18,26 +19,20 @@ export interface Props {
export const DOCK_MENU_BUTTON_ID = 'dock-menu-button';
export const MEGA_MENU_HEADER_TOGGLE_ID = 'mega-menu-header-toggle';
export function MegaMenuHeader({ handleMegaMenu, handleDockedMenu, onClose }: Props) {
export function MegaMenuHeader({ handleDockedMenu, onClose }: Props) {
const theme = useTheme2();
const { chrome } = useGrafana();
const state = chrome.useState();
const homeNav = useSelector((state) => state.navIndex)[HOME_NAV_ID];
const styles = getStyles(theme);
return (
<div className={styles.header}>
<Stack alignItems="center" minWidth={0} gap={0.25}>
<ToolbarButton
narrow
id={MEGA_MENU_HEADER_TOGGLE_ID}
onClick={handleMegaMenu}
tooltip={t('navigation.megamenu.close', 'Close menu')}
>
<Branding.MenuLogo className={styles.img} />
</ToolbarButton>
<Stack alignItems="center" minWidth={0} gap={1}>
<HomeLink homeNav={homeNav} inMegaMenuOverlay={!state.megaMenuDocked} />
<OrganizationSwitcher />
</Stack>
<div className={styles.flexGrow} />
<IconButton
id={DOCK_MENU_BUTTON_ID}
className={styles.dockMenuButton}
@@ -51,11 +46,10 @@ export function MegaMenuHeader({ handleMegaMenu, handleDockedMenu, onClose }: Pr
variant="secondary"
/>
<IconButton
className={styles.mobileCloseButton}
tooltip={t('navigation.megamenu.close', 'Close menu')}
name="times"
onClick={onClose}
size="xl"
size="lg"
variant="secondary"
/>
</div>
@@ -78,18 +72,9 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: 'flex',
gap: theme.spacing(1),
justifyContent: 'space-between',
padding: theme.spacing(0, 1, 0, 0.75),
padding: theme.spacing(0, 1, 0, 1),
height: getChromeHeaderLevelHeight(),
flexShrink: 0,
}),
img: css({
alignSelf: 'center',
height: theme.spacing(3),
width: theme.spacing(3),
}),
mobileCloseButton: css({
[theme.breakpoints.up('md')]: {
display: 'none',
},
}),
flexGrow: css({ flexGrow: 1 }),
});
@@ -11,7 +11,6 @@ import { useGrafana } from 'app/core/context/GrafanaContext';
import { Indent } from '../../Indent/Indent';
import { FeatureHighlight } from './FeatureHighlight';
import { MegaMenuItemText } from './MegaMenuItemText';
import { hasChildMatch } from './utils';
@@ -31,7 +30,6 @@ export function MegaMenuItem({ link, activeItem, level = 0, onClick, onPin, isPi
const state = chrome.useState();
const menuIsDocked = state.megaMenuDocked;
const location = useLocation();
const FeatureHighlightWrapper = link.highlightText ? FeatureHighlight : React.Fragment;
const hasActiveChild = hasChildMatch(link, activeItem);
const isActive = link === activeItem || (level === MAX_DEPTH && hasActiveChild);
const [sectionExpanded, setSectionExpanded] = useLocalStorage(
@@ -81,11 +79,7 @@ export function MegaMenuItem({ link, activeItem, level = 0, onClick, onPin, isPi
return (
<li ref={item} className={styles.listItem}>
<div
className={cx(styles.menuItem, {
[styles.menuItemWithIcon]: Boolean(level === 0 && iconElement),
})}
>
<div className={styles.menuItem}>
{level !== 0 && <Indent level={level === MAX_DEPTH ? level - 1 : level} spacing={3} />}
{level === MAX_DEPTH && <div className={styles.itemConnector} />}
<div className={styles.collapsibleSectionWrapper}>
@@ -106,7 +100,7 @@ export function MegaMenuItem({ link, activeItem, level = 0, onClick, onPin, isPi
[styles.labelWrapperWithIcon]: Boolean(level === 0 && iconElement),
})}
>
{level === 0 && iconElement && <FeatureHighlightWrapper>{iconElement}</FeatureHighlightWrapper>}
{level === 0 && iconElement}
<Text truncate element="p">
{link.text}
</Text>
@@ -179,12 +173,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
alignItems: 'center',
gap: theme.spacing(1.5),
height: theme.spacing(4),
paddingLeft: theme.spacing(0.5),
position: 'relative',
}),
menuItemWithIcon: css({
paddingLeft: theme.spacing(0),
}),
collapseButtonWrapper: css({
display: 'flex',
justifyContent: 'center',
@@ -217,17 +207,17 @@ const getStyles = (theme: GrafanaTheme2) => ({
labelWrapper: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.75),
minWidth: 0,
gap: theme.spacing(1),
paddingLeft: theme.spacing(1),
}),
labelWrapperWithIcon: css({
paddingLeft: theme.spacing(0.5),
gap: theme.spacing(0.75),
minWidth: 0,
}),
hasActiveChild: css({
color: theme.colors.text.primary,
}),
labelWrapperWithIcon: css({
minWidth: theme.spacing(7),
paddingLeft: theme.spacing(0.5),
}),
children: css({
display: 'flex',
listStyleType: 'none',
@@ -35,11 +35,7 @@ export function MegaMenuItemText({ children, isActive, onClick, target, url, onP
);
return (
<div
className={cx(styles.wrapper, {
[styles.wrapperActive]: isActive,
})}
>
<div className={cx(styles.wrapper, isActive && styles.wrapperActive)}>
<LinkComponent
data-testid={selectors.components.NavMenu.item}
className={styles.container}
@@ -92,6 +88,7 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({
borderTopRightRadius: theme.shape.radius.default,
borderBottomRightRadius: theme.shape.radius.default,
position: 'relative',
color: theme.colors.text.primary,
'&::before': {
backgroundImage: theme.colors.gradients.brandVertical,
@@ -101,7 +98,8 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({
height: '100%',
position: 'absolute',
transform: 'translateX(-50%)',
width: theme.spacing(0.5),
left: 0,
width: theme.spacing(0.25),
},
}),
container: css({
@@ -15,7 +15,7 @@ import { contextSrv } from 'app/core/services/context_srv';
import { ScopesSelector } from 'app/features/scopes/selector/ScopesSelector';
import { useSelector } from 'app/types/store';
import { Branding } from '../../Branding/Branding';
import { HomeLink } from '../../Branding/Branding';
import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs';
import { buildBreadcrumbs } from '../../Breadcrumbs/utils';
import { ExtensionToolbarItem } from '../ExtensionSidebar/ExtensionToolbarItem';
@@ -77,11 +77,11 @@ export const SingleTopBar = memo(function SingleTopBar({
tooltip={t('navigation.megamenu.open', 'Open menu')}
>
<Stack gap={0} alignItems="center">
<Branding.MenuLogo className={styles.img} />
<Icon size="sm" name="angle-down" />
<Icon name="bars" size="xl" />
</Stack>
</ToolbarButton>
)}
{!menuDockedAndOpen && <HomeLink homeNav={homeNav} />}
{topLevelScopes ? <ScopesSelector /> : undefined}
<Breadcrumbs breadcrumbs={breadcrumbs} className={styles.breadcrumbsWrapper} />
{!showToolbarLevel && breadcrumbActions}
@@ -1,8 +1,10 @@
import { css, cx } from '@emotion/css';
import { FC, type JSX } from 'react';
import { colorManipulator } from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
import { colorManipulator, GrafanaTheme2, NavModelItem } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { reportInteraction } from '@grafana/runtime';
import { Tooltip, useStyles2, useTheme2 } from '@grafana/ui';
import g8LoginDarkSvg from 'img/g8_login_dark.svg';
import g8LoginLightSvg from 'img/g8_login_light.svg';
import grafanaIconSvg from 'img/grafana_icon.svg';
@@ -52,6 +54,56 @@ const MenuLogo: FC<BrandComponentProps> = ({ className }) => {
return <img className={className} src={grafanaIconSvg} alt="Grafana" />;
};
/**
* inMegaMenuOverlay = true we just render the logo without link (used in mega menu)
*/
export function HomeLink({ homeNav, inMegaMenuOverlay }: { homeNav?: NavModelItem; inMegaMenuOverlay?: boolean }) {
const styles = useStyles2(homeLinkStyles);
const onHomeClicked = () => {
reportInteraction('grafana_home_clicked');
};
if (inMegaMenuOverlay) {
return (
<div className={styles.homeLink}>
<Branding.MenuLogo />
</div>
);
}
return (
<Tooltip placement="bottom" content={homeNav?.text || 'Home'}>
<a
onClick={onHomeClicked}
data-testid={selectors.components.Breadcrumbs.breadcrumb('Home')}
className={styles.homeLink}
title={homeNav?.text || 'Home'}
href={homeNav?.url}
>
<Branding.MenuLogo />
</a>
</Tooltip>
);
}
function homeLinkStyles(theme: GrafanaTheme2) {
return {
homeLink: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: theme.spacing(3),
width: theme.spacing(3),
margin: theme.spacing(0, 0.5),
img: {
maxHeight: '100%',
maxWidth: '100%',
},
}),
};
}
const LoginBoxBackground = () => {
const theme = useTheme2();
return css({
@@ -91,10 +91,7 @@ describe('breadcrumb utils', () => {
url: '/my-parent-section',
},
};
expect(buildBreadcrumbs(sectionNav, pageNav, mockHomeNav)).toEqual([
{ text: 'Home', href: '/home' },
{ text: 'My page', href: '/my-page' },
]);
expect(buildBreadcrumbs(sectionNav, pageNav, mockHomeNav)).toEqual([{ text: 'My page', href: '/my-page' }]);
});
it('matches the home nav ignoring query parameters', () => {
@@ -114,10 +111,7 @@ describe('breadcrumb utils', () => {
url: '/my-parent-section',
},
};
expect(buildBreadcrumbs(sectionNav, pageNav, mockHomeNav)).toEqual([
{ text: 'Home', href: '/home?orgId=1' },
{ text: 'My page', href: '/my-page' },
]);
expect(buildBreadcrumbs(sectionNav, pageNav, mockHomeNav)).toEqual([{ text: 'My page', href: '/my-page' }]);
});
it('does ignore duplicates', () => {
@@ -2,12 +2,7 @@ import { NavModelItem } from '@grafana/data';
import { Breadcrumb } from './types';
export function buildBreadcrumbs(
sectionNav: NavModelItem,
pageNav?: NavModelItem,
homeNav?: NavModelItem,
skipHome?: boolean
) {
export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelItem, homeNav?: NavModelItem) {
const crumbs: Breadcrumb[] = [];
let foundHome = false;
let lastPath: string | undefined = undefined;
@@ -27,9 +22,6 @@ export function buildBreadcrumbs(
// Check if we found home/root if if so return early
if (homeNav && urlToMatch === homeNav.url) {
if (!skipHome) {
crumbs.unshift({ text: homeNav.text, href: node.url ?? '' });
}
foundHome = true;
return;
}
@@ -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({
@@ -21,6 +21,7 @@ const setup = (props: Partial<PageProps>) => {
{
id: HOME_NAV_ID,
text: 'Home',
url: '/',
},
{
text: 'Section name',
@@ -10,13 +10,17 @@ import { buildBreadcrumbs } from '../Breadcrumbs/utils';
export function usePageTitle(navModel?: NavModel, pageNav?: NavModelItem) {
const homeNav = useSelector((state) => state.navIndex)?.[HOME_NAV_ID];
useEffect(() => {
const sectionNav = (navModel?.node !== navModel?.main ? navModel?.node : navModel?.main) ?? { text: 'Grafana' };
const sectionNav = (navModel?.node !== navModel?.main ? navModel?.node : navModel?.main) ?? {
text: Branding.AppTitle,
};
const parts: string[] = buildBreadcrumbs(sectionNav, pageNav, homeNav)
.map((crumb) => crumb.text)
.reverse();
// Override `Home` with the custom brand title
parts[parts.length - 1] = Branding.AppTitle;
if (parts[parts.length - 1] !== Branding.AppTitle) {
parts.push(Branding.AppTitle);
}
document.title = parts.join(' - ');
}, [homeNav, navModel, pageNav]);
@@ -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>
@@ -186,6 +186,17 @@ describe('NavToolbarActions', () => {
});
});
});
describe('where dashboard is not editable', () => {
it('should set dashboard to editable on make editable button press', async () => {
const { dashboard } = setup({}, true);
await userEvent.click(await screen.findByTestId(selectors.components.NavToolbar.editDashboard.editButton));
expect(dashboard.state.editable).toBe(true);
expect(dashboard.state.meta.canEdit).toBe(true);
expect(dashboard.state.meta.canSave).toBe(true);
});
});
});
describe('Given new sharing button', () => {
@@ -214,7 +225,7 @@ describe('NavToolbarActions', () => {
});
});
function setup(meta?: DashboardMeta) {
function setup(meta?: DashboardMeta, editable?: boolean) {
const dashboard = new DashboardScene({
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
meta: {
@@ -229,6 +240,7 @@ function setup(meta?: DashboardMeta) {
...meta,
},
title: 'hello',
editable: editable || true,
uid: 'dash-1',
body: DefaultGridLayoutManager.fromVizPanels([
new VizPanel({
@@ -351,7 +351,7 @@ export function ToolbarActions({ dashboard }: Props) {
onClick={() => {
trackDashboardSceneEditButtonClicked(dashboard.state.uid);
dashboard.onEnterEditMode();
dashboard.setState({ editable: true, meta: { ...meta, canEdit: true } });
dashboard.setState({ meta: { ...meta, canEdit: true, canSave: true } });
}}
tooltip={t('dashboard.toolbar.enter-edit-mode.tooltip', 'This dashboard was marked as read only')}
key="edit"
@@ -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',
@@ -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 });
}
@@ -19,6 +19,7 @@ import kbn from 'app/core/utils/kbn';
import { ShowConfirmModalEvent } from 'app/types/events';
import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup';
import { dashboardEditActions } from '../../edit-pane/shared';
import { serializeRow } from '../../serialization/layoutSerializers/RowsLayoutSerializer';
import { getElements } from '../../serialization/layoutSerializers/utils';
import { getDashboardSceneFor } from '../../utils/utils';
@@ -110,7 +111,18 @@ export class RowItem
}
public switchLayout(layout: DashboardLayoutManager) {
this.setState({ layout });
const currentLayout = this.state.layout;
dashboardEditActions.edit({
description: t('dashboard.edit-actions.switch-layout-row', 'Switch layout'),
source: this,
perform: () => {
this.setState({ layout });
},
undo: () => {
this.setState({ layout: currentLayout });
},
});
}
public useEditPaneOptions = useEditOptions.bind(this);
@@ -19,6 +19,7 @@ import kbn from 'app/core/utils/kbn';
import { ShowConfirmModalEvent } from 'app/types/events';
import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup';
import { dashboardEditActions } from '../../edit-pane/shared';
import { serializeTab } from '../../serialization/layoutSerializers/TabsLayoutSerializer';
import { getElements } from '../../serialization/layoutSerializers/utils';
import { getDashboardSceneFor } from '../../utils/utils';
@@ -108,7 +109,18 @@ export class TabItem
}
public switchLayout(layout: DashboardLayoutManager) {
this.setState({ layout });
const currentLayout = this.state.layout;
dashboardEditActions.edit({
description: t('dashboard.edit-actions.switch-layout-tab', 'Switch layout'),
source: this,
perform: () => {
this.setState({ layout });
},
undo: () => {
this.setState({ layout: currentLayout });
},
});
}
public useEditPaneOptions = useEditOptions.bind(this);
@@ -34,10 +34,11 @@ setPluginImportUtils({
getPanelPluginFromCache: (id: string) => undefined,
});
export function buildTestScene(isEditing = false) {
export function buildTestScene(isEditing?: boolean, editable?: boolean) {
const testScene = new DashboardScene({
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
isEditing: isEditing,
isEditing: isEditing || false,
editable: editable || true,
body: new DefaultGridLayoutManager({
grid: new SceneGridLayout({
children: [new DashboardGridItem({ body: new VizPanel({ key: 'panel-1', pluginId: 'text' }) })],
@@ -76,4 +77,15 @@ describe('MakeDashboardEditableButton', () => {
expect(DashboardInteractions.editButtonClicked).toHaveBeenCalledWith({ outlineExpanded: false });
});
});
it('should set state correctly', async () => {
const scene = buildTestScene(false, false);
render(<MakeDashboardEditableButton dashboard={scene} />);
await userEvent.click(await screen.findByTestId(selectors.components.NavToolbar.editDashboard.editButton));
expect(scene.state.editable).toBe(true);
expect(scene.state.meta.canEdit).toBe(true);
expect(scene.state.meta.canSave).toBe(true);
});
});
@@ -13,7 +13,7 @@ export const MakeDashboardEditableButton = ({ dashboard }: ToolbarActionProps) =
onClick={() => {
trackDashboardSceneEditButtonClicked(dashboard.state.uid);
dashboard.onEnterEditMode();
dashboard.setState({ editable: true, meta: { ...dashboard.state.meta, canEdit: true } });
dashboard.setState({ meta: { ...dashboard.state.meta, canEdit: true, canSave: true } });
}}
tooltip={t('dashboard.toolbar.new.enter-edit-mode.tooltip', 'This dashboard was marked as read only')}
variant="secondary"
@@ -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: {
@@ -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
@@ -709,6 +709,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
"title": "Repeating rows",
"uid": "Repeating-rows-uid",
"version": 1,
"weekStart": "",
}
`;

Some files were not shown because too many files have changed in this diff Show More