Merge branch 'main' into oscark/fix-link-overflowing-on-small-viewport
This commit is contained in:
@@ -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.
|
||||
|
||||
+2
-2
@@ -7234,9 +7234,9 @@
|
||||
"type": "elasticsearch",
|
||||
"uid": "gdev-elasticsearch"
|
||||
},
|
||||
"baseFilters": null,
|
||||
"baseFilters": [],
|
||||
"filters": [],
|
||||
"defaultKeys": null,
|
||||
"defaultKeys": [],
|
||||
"hide": "dontHide",
|
||||
"skipUrlSync": false,
|
||||
"allowCustomValue": true
|
||||
|
||||
+2
-2
@@ -7335,9 +7335,9 @@
|
||||
},
|
||||
"spec": {
|
||||
"name": "adhoc",
|
||||
"baseFilters": null,
|
||||
"baseFilters": [],
|
||||
"filters": [],
|
||||
"defaultKeys": null,
|
||||
"defaultKeys": [],
|
||||
"hide": "dontHide",
|
||||
"skipUrlSync": false,
|
||||
"allowCustomValue": true
|
||||
|
||||
+2
-2
@@ -307,9 +307,9 @@
|
||||
"kind": "AdhocVariable",
|
||||
"spec": {
|
||||
"name": "adhoc",
|
||||
"baseFilters": null,
|
||||
"baseFilters": [],
|
||||
"filters": [],
|
||||
"defaultKeys": null,
|
||||
"defaultKeys": [],
|
||||
"hide": "dontHide",
|
||||
"skipUrlSync": false,
|
||||
"allowCustomValue": true
|
||||
|
||||
+2
-2
@@ -317,9 +317,9 @@
|
||||
"datasource": {},
|
||||
"spec": {
|
||||
"name": "adhoc",
|
||||
"baseFilters": null,
|
||||
"baseFilters": [],
|
||||
"filters": [],
|
||||
"defaultKeys": null,
|
||||
"defaultKeys": [],
|
||||
"hide": "dontHide",
|
||||
"skipUrlSync": false,
|
||||
"allowCustomValue": true
|
||||
|
||||
+2
-2
@@ -604,9 +604,9 @@
|
||||
"type": "loki",
|
||||
"uid": "PDDA8E780A17E7EF1"
|
||||
},
|
||||
"baseFilters": null,
|
||||
"baseFilters": [],
|
||||
"filters": [],
|
||||
"defaultKeys": null,
|
||||
"defaultKeys": [],
|
||||
"label": "Ad-hoc",
|
||||
"hide": "dontHide",
|
||||
"skipUrlSync": false,
|
||||
|
||||
+2
-2
@@ -616,9 +616,9 @@
|
||||
},
|
||||
"spec": {
|
||||
"name": "adhoc",
|
||||
"baseFilters": null,
|
||||
"baseFilters": [],
|
||||
"filters": [],
|
||||
"defaultKeys": null,
|
||||
"defaultKeys": [],
|
||||
"label": "Ad-hoc",
|
||||
"hide": "dontHide",
|
||||
"skipUrlSync": false,
|
||||
|
||||
+1
-1
@@ -1885,7 +1885,7 @@
|
||||
"name": "Filters",
|
||||
"baseFilters": [],
|
||||
"filters": [],
|
||||
"defaultKeys": null,
|
||||
"defaultKeys": [],
|
||||
"hide": "dontHide",
|
||||
"skipUrlSync": false,
|
||||
"allowCustomValue": true
|
||||
|
||||
+1
-1
@@ -1931,7 +1931,7 @@
|
||||
"name": "Filters",
|
||||
"baseFilters": [],
|
||||
"filters": [],
|
||||
"defaultKeys": null,
|
||||
"defaultKeys": [],
|
||||
"hide": "dontHide",
|
||||
"skipUrlSync": false,
|
||||
"allowCustomValue": true
|
||||
|
||||
+1
-1
@@ -2184,7 +2184,7 @@
|
||||
"name": "Filters",
|
||||
"baseFilters": [],
|
||||
"filters": [],
|
||||
"defaultKeys": null,
|
||||
"defaultKeys": [],
|
||||
"hide": "dontHide",
|
||||
"skipUrlSync": false,
|
||||
"allowCustomValue": true
|
||||
|
||||
+1
-1
@@ -2219,7 +2219,7 @@
|
||||
"name": "Filters",
|
||||
"baseFilters": [],
|
||||
"filters": [],
|
||||
"defaultKeys": null,
|
||||
"defaultKeys": [],
|
||||
"hide": "dontHide",
|
||||
"skipUrlSync": false,
|
||||
"allowCustomValue": true
|
||||
|
||||
+1
-1
@@ -2499,7 +2499,7 @@
|
||||
"name": "Filters",
|
||||
"baseFilters": [],
|
||||
"filters": [],
|
||||
"defaultKeys": null,
|
||||
"defaultKeys": [],
|
||||
"hide": "dontHide",
|
||||
"skipUrlSync": false,
|
||||
"allowCustomValue": true
|
||||
|
||||
+1
-1
@@ -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 != "" {
|
||||
|
||||
@@ -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
@@ -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"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+135
-3
@@ -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.
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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}>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -80,6 +80,7 @@ type IdentityAccessManagementAPIBuilder struct {
|
||||
dual dualwrite.Service
|
||||
unified resource.ResourceClient
|
||||
userSearchClient resourcepb.ResourceIndexClient
|
||||
teamSearch *TeamSearchHandler
|
||||
|
||||
teamGroupsHandler externalgroupmapping.TeamGroupsHandler
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+1
@@ -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
|
||||
|
||||
|
+299
-355
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+18
@@ -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
|
||||
}
|
||||
}
|
||||
+13
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, `[
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
+5
-4
@@ -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);
|
||||
|
||||
+14
-2
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
@@ -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
Reference in New Issue
Block a user