Compare commits

...

1 Commits

Author SHA1 Message Date
Renato Costa
716250ebad Patch steady: duplicated provisioned dashboards cleanup + bump facet limit (#115937)
* chore: add cleanup task for duplicated provisioned dashboards (#115103)

* chore: add cleanup task for duplicated provisioned dashboards

* fix: bump default facet search limit for unified search (#115690)

* fix: bump limit

* feat: add facetLimit query parameter to search API

* fix: set to 500

* fix: update snapshot

* fix: yarn generate-apis

---------

Co-authored-by: Mustafa Sencer Özcan <32759850+mustafasencer@users.noreply.github.com>
2026-01-07 09:48:32 -05:00
13 changed files with 625 additions and 23 deletions

View File

@@ -243,6 +243,7 @@ const injectedRtkApi = api
type: queryArg['type'],
folder: queryArg.folder,
facet: queryArg.facet,
facetLimit: queryArg.facetLimit,
tags: queryArg.tags,
libraryPanel: queryArg.libraryPanel,
permission: queryArg.permission,
@@ -663,6 +664,8 @@ export type SearchDashboardsAndFoldersApiArg = {
folder?: string;
/** count distinct terms for selected fields */
facet?: string[];
/** maximum number of terms to return per facet (default 50, max 1000) */
facetLimit?: number;
/** tag query filter */
tags?: string[];
/** find dashboards that reference a given libraryPanel */

View File

@@ -115,6 +115,15 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *
Schema: spec.ArrayProperty(spec.StringProperty()),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "facetLimit",
In: "query",
Description: "maximum number of terms to return per facet (default 50, max 1000)",
Required: false,
Schema: spec.Int64Property(),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "tags",
@@ -340,6 +349,7 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, user identity.Requester, getDashboardsUIDsSharedWithUser func() ([]string, error)) (*resourcepb.ResourceSearchRequest, error) {
// get limit and offset from query params
limit := 50
facetLimit := 50
offset := 0
page := 1
if queryParams.Has("limit") {
@@ -422,11 +432,19 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use
// The facet term fields
if facets, ok := queryParams["facet"]; ok {
if queryParams.Has("facetLimit") {
if parsed, err := strconv.Atoi(queryParams.Get("facetLimit")); err == nil && parsed > 0 {
facetLimit = parsed
if facetLimit > 1000 {
facetLimit = 1000
}
}
}
searchRequest.Facet = make(map[string]*resourcepb.ResourceSearchRequest_Facet)
for _, v := range facets {
searchRequest.Facet[v] = &resourcepb.ResourceSearchRequest_Facet{
Field: v,
Limit: 50,
Limit: int64(facetLimit),
}
}
}

View File

@@ -818,6 +818,38 @@ func TestConvertHttpSearchRequestToResourceSearchRequest(t *testing.T) {
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"facet fields with custom limit": {
queryString: "facet=tags&facetLimit=500",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{Key: dashboardKey},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
Facet: map[string]*resourcepb.ResourceSearchRequest_Facet{
"tags": {Field: "tags", Limit: 500},
},
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"facet fields with limit exceeding max": {
queryString: "facet=tags&facetLimit=5000",
expected: &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{Key: dashboardKey},
Query: "",
Limit: 50,
Offset: 0,
Page: 1,
Explain: false,
Fields: defaultFields,
Facet: map[string]*resourcepb.ResourceSearchRequest_Facet{
"tags": {Field: "tags", Limit: 1000},
},
Federated: []*resourcepb.ResourceKey{folderKey},
},
},
"tag filter": {
queryString: "tag=tag1&tag=tag2",
expected: &resourcepb.ResourceSearchRequest{

View File

@@ -304,8 +304,15 @@ type DeleteDashboardCommand struct {
RemovePermissions bool
}
type ProvisioningConfig struct {
Name string
OrgID int64
Folder string
AllowUIUpdates bool
}
type DeleteOrphanedProvisionedDashboardsCommand struct {
ReaderNames []string
Config []ProvisioningConfig
}
type DashboardProvisioningSearchResults struct {
@@ -405,6 +412,8 @@ type DashboardSearchProjection struct {
FolderTitle string
SortMeta int64
Tags []string
ManagedBy utils.ManagerKind
ManagerId string
Deleted *time.Time
}

View File

@@ -877,24 +877,32 @@ func (dr *DashboardServiceImpl) waitForSearchQuery(ctx context.Context, query *d
}
func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *dashboards.DeleteOrphanedProvisionedDashboardsCommand) error {
// cleanup duplicate provisioned dashboards first (this will have the same name and external_id)
// note: only works in modes 1-3
if err := dr.DeleteDuplicateProvisionedDashboards(ctx); err != nil {
dr.log.Error("Failed to delete duplicate provisioned dashboards", "error", err)
}
// check each org for orphaned provisioned dashboards
orgs, err := dr.orgService.Search(ctx, &org.SearchOrgsQuery{})
if err != nil {
return err
}
orgIDs := make([]int64, 0, len(orgs))
for _, org := range orgs {
orgIDs = append(orgIDs, org.ID)
}
if err := dr.DeleteDuplicateProvisionedDashboards(ctx, orgIDs, cmd.Config); err != nil {
dr.log.Error("Failed to delete duplicate provisioned dashboards", "error", err)
}
currentNames := make([]string, 0, len(cmd.Config))
for _, cfg := range cmd.Config {
currentNames = append(currentNames, cfg.Name)
}
for _, org := range orgs {
ctx, _ := identity.WithServiceIdentity(ctx, org.ID)
// find all dashboards in the org that have a file repo set that is not in the given readers list
foundDashs, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
ManagedBy: utils.ManagerKindClassicFP, //nolint:staticcheck
ManagerIdentityNotIn: cmd.ReaderNames,
ManagerIdentityNotIn: currentNames,
OrgId: org.ID,
})
if err != nil {
@@ -921,7 +929,129 @@ func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context.
return nil
}
func (dr *DashboardServiceImpl) DeleteDuplicateProvisionedDashboards(ctx context.Context) error {
// searchExistingProvisionedData fetches provisioned data for the purposes of
// duplication cleanup. Returns the set of folder UIDs for folders with the
// given title, and the set of resources contained in those folders.
func (dr *DashboardServiceImpl) searchExistingProvisionedData(
ctx context.Context, orgID int64, folderTitle string,
) ([]string, []dashboards.DashboardSearchProjection, error) {
ctx, user := identity.WithServiceIdentity(ctx, orgID)
cmd := folder.SearchFoldersQuery{
OrgID: orgID,
SignedInUser: user,
Title: folderTitle,
TitleExactMatch: true,
}
searchResults, err := dr.folderService.SearchFolders(ctx, cmd)
if err != nil {
return nil, nil, fmt.Errorf("checking if provisioning reset is required: %w", err)
}
var matchingFolders []string //nolint:prealloc
for _, result := range searchResults {
f, err := dr.folderService.Get(ctx, &folder.GetFolderQuery{
OrgID: orgID,
UID: &result.UID,
SignedInUser: user,
})
if err != nil {
return nil, nil, err
}
// We are only interested in folders at the top-level of the folder hierarchy.
// Cleanup is not performed for provisioned folders that were moved to
// a different location.
if f.ParentUID != "" {
continue
}
matchingFolders = append(matchingFolders, f.UID)
}
if len(matchingFolders) == 0 {
// If there are no folders with the same title as the provisioned folder we
// are looking for, there is nothing to be cleaned up.
return nil, nil, nil
}
resources, err := dr.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{
OrgId: orgID,
SignedInUser: user,
FolderUIDs: matchingFolders,
})
if err != nil {
return nil, nil, err
}
return matchingFolders, resources, nil
}
// maybeResetProvisioning will check for duplicated provisioned dashboards in the database. These duplications
// happen when multiple provisioned dashboards of the same title are found, or multiple provisioned
// folders are found. In this case, provisioned resources are deleted, allowing the provisioning
// process to start from scratch after this function returns.
func (dr *DashboardServiceImpl) maybeResetProvisioning(ctx context.Context, orgs []int64, configs []dashboards.ProvisioningConfig) {
if skipReason := canBeAutomaticallyCleanedUp(configs); skipReason != "" {
dr.log.Info("not eligible for automated cleanup", "reason", skipReason)
return
}
folderTitle := configs[0].Folder
provisionedNames := map[string]bool{}
for _, c := range configs {
provisionedNames[c.Name] = true
}
for _, orgID := range orgs {
ctx, user := identity.WithServiceIdentity(ctx, orgID)
provFolders, resources, err := dr.searchExistingProvisionedData(ctx, orgID, folderTitle)
if err != nil {
dr.log.Error("failed to search for provisioned data for cleanup", "org", orgID, "error", err)
continue
}
steps, err := cleanupSteps(provFolders, resources, provisionedNames)
if err != nil {
dr.log.Warn("not possible to perform automated duplicate cleanup", "org", orgID, "error", err)
continue
}
for _, step := range steps {
var err error
switch step.Type {
case searchstore.TypeDashboard:
err = dr.deleteDashboard(ctx, 0, step.UID, orgID, false)
case searchstore.TypeFolder:
err = dr.folderService.Delete(ctx, &folder.DeleteFolderCommand{
OrgID: orgID,
SignedInUser: user,
UID: step.UID,
})
}
if err == nil {
dr.log.Info("deleted duplicated provisioned resource",
"type", step.Type, "uid", step.UID,
)
} else {
dr.log.Error("failed to delete duplicated provisioned resource",
"type", step.Type, "uid", step.UID, "error", err,
)
}
}
}
}
func (dr *DashboardServiceImpl) DeleteDuplicateProvisionedDashboards(ctx context.Context, orgs []int64, configs []dashboards.ProvisioningConfig) error {
// Start from scratch if duplications that cannot be fixed by the logic
// below are found in the database.
dr.maybeResetProvisioning(ctx, orgs, configs)
// cleanup duplicate provisioned dashboards (i.e., with the same name and external_id).
// Note: only works in modes 1-3. This logic can be removed once mode5 is
// enabled everywhere.
duplicates, err := dr.dashboardStore.GetDuplicateProvisionedDashboards(ctx)
if err != nil {
return err
@@ -1511,6 +1641,8 @@ func (dr *DashboardServiceImpl) FindDashboards(ctx context.Context, query *dashb
FolderTitle: folderTitle,
FolderID: folderID,
FolderSlug: slugify.Slugify(folderTitle),
ManagedBy: hit.ManagedBy.Kind,
ManagerId: hit.ManagedBy.ID,
Tags: hit.Tags,
}

View File

@@ -779,7 +779,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
}, nil).Twice()
err := service.DeleteOrphanedProvisionedDashboards(context.Background(), &dashboards.DeleteOrphanedProvisionedDashboardsCommand{
ReaderNames: []string{"test"},
Config: []dashboards.ProvisioningConfig{{Name: "test"}},
})
require.NoError(t, err)
k8sCliMock.AssertExpectations(t)
@@ -874,7 +874,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
}, nil).Once()
err := singleOrgService.DeleteOrphanedProvisionedDashboards(ctx, &dashboards.DeleteOrphanedProvisionedDashboardsCommand{
ReaderNames: []string{"test"},
Config: []dashboards.ProvisioningConfig{{Name: "test"}},
})
require.NoError(t, err)
k8sCliMock.AssertExpectations(t)
@@ -906,7 +906,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) {
}, nil)
err := singleOrgService.DeleteOrphanedProvisionedDashboards(ctx, &dashboards.DeleteOrphanedProvisionedDashboardsCommand{
ReaderNames: []string{"test"},
Config: []dashboards.ProvisioningConfig{{Name: "test"}},
})
require.NoError(t, err)
k8sCliMock.AssertExpectations(t)

View File

@@ -0,0 +1,107 @@
package service
import (
"errors"
"fmt"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
)
// canBeAutomaticallyCleanedUp determines whether this instance can be automatically cleaned up
// if duplicated provisioned resources are found. To ensure the process does not delete
// resources it shouldn't, automatic cleanups only happen if all provisioned dashboards
// are stored in the same folder (by title), and no dashboards allow UI updates.
func canBeAutomaticallyCleanedUp(configs []dashboards.ProvisioningConfig) string {
if len(configs) == 0 {
return "no provisioned dashboards"
}
folderTitle := configs[0].Folder
if len(folderTitle) == 0 {
return fmt.Sprintf("dashboard has no folder: %s", configs[0].Name)
}
for _, cfg := range configs {
if cfg.AllowUIUpdates {
return "contains dashboards with allowUiUpdates"
}
if cfg.Folder != folderTitle {
return "dashboards provisioned across multiple folders"
}
}
return ""
}
type deleteProvisionedResource struct {
Type string
UID string
}
// cleanupSteps computes the sequence of steps to be performed in order to cleanup the
// provisioning resources and allow the process to start from scratch when duplication
// is detected. The sequence of steps will dictate the order in which dashboards and folders
// are to be deleted.
func cleanupSteps(provFolders []string, resources []dashboards.DashboardSearchProjection, configDashboards map[string]bool) ([]deleteProvisionedResource, error) {
var hasDuplicatedProvisionedDashboard bool
var hasUserCreatedResource bool
var uniqueNames = map[string]struct{}{}
var deleteProvisionedDashboards []deleteProvisionedResource //nolint:prealloc
for _, r := range resources {
// nolint:staticcheck
if r.IsFolder || r.ManagedBy != utils.ManagerKindClassicFP {
hasUserCreatedResource = true
continue
}
// Only delete dashboards if they are included in the provisioning configuration
// for this instance.
if !configDashboards[r.ManagerId] {
continue
}
if _, exists := uniqueNames[r.ManagerId]; exists {
hasDuplicatedProvisionedDashboard = true
}
uniqueNames[r.ManagerId] = struct{}{}
deleteProvisionedDashboards = append(deleteProvisionedDashboards, deleteProvisionedResource{
Type: searchstore.TypeDashboard,
UID: r.UID,
})
}
if len(provFolders) == 0 {
// When there are no provisioned folders, there is nothing to do.
return nil, nil
} else if len(provFolders) == 1 {
// If only one folder was found, keep it and delete the provisioned dashboards if
// duplication was found.
if hasDuplicatedProvisionedDashboard {
return deleteProvisionedDashboards, nil
}
} else {
// If multiple folders were found *and* a user-created resource exists in
// one of them, bail, as we wouldn't be able to delete one of the duplicated folders.
if hasUserCreatedResource {
return nil, errors.New("multiple provisioning folders exist with at least one user-created resource")
}
// Delete provisioned dashboards first, and then the folders.
steps := deleteProvisionedDashboards
for _, uid := range provFolders {
steps = append(steps, deleteProvisionedResource{
Type: searchstore.TypeFolder,
UID: uid,
})
}
return steps, nil
}
return nil, nil
}

View File

@@ -0,0 +1,279 @@
package service
import (
"testing"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/sqlstore/searchstore"
"github.com/stretchr/testify/require"
)
func Test_canBeAutomaticallyCleanedUp(t *testing.T) {
testCases := []struct {
name string
configs []dashboards.ProvisioningConfig
expectedSkip string
}{
{
name: "no dashboards defined in the configuration",
configs: []dashboards.ProvisioningConfig{},
expectedSkip: "no provisioned dashboards",
},
{
name: "first defined dashboard has no folder defined",
configs: []dashboards.ProvisioningConfig{
{Name: "1", Folder: ""},
{Folder: "f1"},
},
expectedSkip: "dashboard has no folder: 1",
},
{
name: "one of the provisioned dashboards has no folder defined",
configs: []dashboards.ProvisioningConfig{
{Name: "1", Folder: "f1"},
{Name: "2", Folder: "f1"},
{Name: "3", Folder: ""},
{Name: "4", Folder: "f1"},
},
expectedSkip: "dashboards provisioned across multiple folders",
},
{
name: "one of the provisioned dashboards allows UI updates",
configs: []dashboards.ProvisioningConfig{
{Name: "1", Folder: "f1"},
{Name: "2", Folder: "f1", AllowUIUpdates: true},
{Name: "3", Folder: "f1"},
{Name: "4", Folder: "f1"},
},
expectedSkip: "contains dashboards with allowUiUpdates",
},
{
name: "one of the provisioned dashboards is in a different folder",
configs: []dashboards.ProvisioningConfig{
{Name: "1", Folder: "f1"},
{Name: "2", Folder: "f1"},
{Name: "3", Folder: "f1"},
{Name: "4", Folder: "different"},
},
expectedSkip: "dashboards provisioned across multiple folders",
},
{
name: "can be skipped when all conditions are met",
configs: []dashboards.ProvisioningConfig{
{Name: "1", Folder: "f1"},
{Name: "2", Folder: "f1"},
{Name: "3", Folder: "f1"},
{Name: "4", Folder: "f1"},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.expectedSkip, canBeAutomaticallyCleanedUp(tc.configs))
})
}
}
func Test_cleanupSteps(t *testing.T) {
isDashboard, isFolder := false, true
fromUser := func(uid, name string, isFolder bool) dashboards.DashboardSearchProjection {
return dashboards.DashboardSearchProjection{
UID: uid,
ManagerId: name,
IsFolder: isFolder,
}
}
provisioned := func(uid, name string, isFolder bool) dashboards.DashboardSearchProjection {
dashboard := fromUser(uid, name, isFolder)
dashboard.ManagedBy = utils.ManagerKindClassicFP //nolint:staticcheck
return dashboard
}
testCases := []struct {
name string
provisionedFolders []string
provisionedResources []dashboards.DashboardSearchProjection
configDashboards []string
expectedSteps []deleteProvisionedResource
expectedErr string
}{
{
name: "no provisioned folders, nothing to do",
provisionedFolders: []string{},
configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3"},
provisionedResources: []dashboards.DashboardSearchProjection{
provisioned("d1", "Provisioned1", isDashboard),
},
},
{
name: "multiple folders, a user-created dashboard in one of them",
provisionedFolders: []string{"folder1", "folder2"},
configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3"},
provisionedResources: []dashboards.DashboardSearchProjection{
provisioned("d1", "Provisioned1", isDashboard),
provisioned("d2", "Provisioned2", isDashboard),
fromUser("d3", "User1", isDashboard),
provisioned("d4", "Provisioned3", isDashboard),
},
expectedErr: "multiple provisioning folders exist with at least one user-created resource",
},
{
name: "multiple folders, a user-created folder in one of them",
provisionedFolders: []string{"folder1", "folder2"},
configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3", "Provisioned4"},
provisionedResources: []dashboards.DashboardSearchProjection{
provisioned("d1", "Provisioned1", isDashboard),
provisioned("d2", "Provisioned2", isDashboard),
provisioned("d3", "Provisioned3", isDashboard),
fromUser("f1", "UserFolder1", isFolder),
},
expectedErr: "multiple provisioning folders exist with at least one user-created resource",
},
{
name: "single folder, some dashboards duplicated",
provisionedFolders: []string{"folder1"},
configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3", "Provisioned4"},
provisionedResources: []dashboards.DashboardSearchProjection{
// Provisioned1 is duplicated.
provisioned("d1", "Provisioned1", isDashboard),
provisioned("d2", "Provisioned2", isDashboard),
provisioned("d3", "Provisioned1", isDashboard),
provisioned("d4", "Provisioned3", isDashboard),
},
expectedSteps: []deleteProvisionedResource{
{Type: searchstore.TypeDashboard, UID: "d1"},
{Type: searchstore.TypeDashboard, UID: "d2"},
{Type: searchstore.TypeDashboard, UID: "d3"},
{Type: searchstore.TypeDashboard, UID: "d4"},
},
},
{
name: "single folder, duplicated dashboards, user-created dashboards are ignored",
provisionedFolders: []string{"folder1"},
configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3", "Provisioned4"},
provisionedResources: []dashboards.DashboardSearchProjection{
// Provisioned1 is duplicated.
provisioned("d1", "Provisioned1", isDashboard),
provisioned("d2", "Provisioned2", isDashboard),
fromUser("d3", "User1", isDashboard),
provisioned("d4", "Provisioned3", isDashboard),
provisioned("d5", "Provisioned1", isDashboard),
},
// User dashboard (d3) is not deleted.
expectedSteps: []deleteProvisionedResource{
{Type: searchstore.TypeDashboard, UID: "d1"},
{Type: searchstore.TypeDashboard, UID: "d2"},
{Type: searchstore.TypeDashboard, UID: "d4"},
{Type: searchstore.TypeDashboard, UID: "d5"},
},
},
{
name: "single folder, duplicated dashboards, user-created folders are ignored",
provisionedFolders: []string{"folder1"},
configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3"},
provisionedResources: []dashboards.DashboardSearchProjection{
// Provisioned1 is duplicated.
provisioned("d1", "Provisioned1", isDashboard),
provisioned("d2", "Provisioned2", isDashboard),
provisioned("d3", "Provisioned3", isDashboard),
provisioned("d4", "Provisioned1", isDashboard),
fromUser("f1", "UserFolder1", isFolder),
},
// User folder (f1) is not deleted.
expectedSteps: []deleteProvisionedResource{
{Type: searchstore.TypeDashboard, UID: "d1"},
{Type: searchstore.TypeDashboard, UID: "d2"},
{Type: searchstore.TypeDashboard, UID: "d3"},
{Type: searchstore.TypeDashboard, UID: "d4"},
},
},
{
name: "multiple folders, only provisioned dashboards",
provisionedFolders: []string{"folder1", "folder2"},
configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3", "Provisioned4"},
provisionedResources: []dashboards.DashboardSearchProjection{
provisioned("d1", "Provisioned1", isDashboard),
provisioned("d2", "Provisioned2", isDashboard),
provisioned("d3", "Provisioned3", isDashboard),
provisioned("d4", "Provisioned4", isDashboard),
},
// Delete all dashboards, then all folders.
expectedSteps: []deleteProvisionedResource{
{Type: searchstore.TypeDashboard, UID: "d1"},
{Type: searchstore.TypeDashboard, UID: "d2"},
{Type: searchstore.TypeDashboard, UID: "d3"},
{Type: searchstore.TypeDashboard, UID: "d4"},
{Type: searchstore.TypeFolder, UID: "folder1"},
{Type: searchstore.TypeFolder, UID: "folder2"},
},
},
{
name: "single folder, only deletes dashboards defined in the config file",
provisionedFolders: []string{"folder1"},
configDashboards: []string{"Provisioned1", "Provisioned2"},
provisionedResources: []dashboards.DashboardSearchProjection{
provisioned("d1", "Provisioned1", isDashboard),
provisioned("d2", "Provisioned2", isDashboard),
provisioned("d3", "Provisioned1", isDashboard),
provisioned("d4", "Provisioned4", isDashboard),
provisioned("d5", "Provisioned4", isDashboard),
},
// Delete duplicated dashboards, but keep Provisioned4, since it's not in the config file.
expectedSteps: []deleteProvisionedResource{
{Type: searchstore.TypeDashboard, UID: "d1"},
{Type: searchstore.TypeDashboard, UID: "d2"},
{Type: searchstore.TypeDashboard, UID: "d3"},
},
},
{
name: "single folder, no duplicated dashboards",
provisionedFolders: []string{"folder1"},
configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3", "Provisioned4"},
provisionedResources: []dashboards.DashboardSearchProjection{
provisioned("d1", "Provisioned1", isDashboard),
provisioned("d2", "Provisioned2", isDashboard),
provisioned("d3", "Provisioned3", isDashboard),
provisioned("d4", "Provisioned4", isDashboard),
},
expectedSteps: nil, // no duplicates, nothing to do
},
{
name: "single folder, no duplicated dashboards, multiple user-created resources",
provisionedFolders: []string{"folder1"},
configDashboards: []string{"Provisioned1", "Provisioned2", "Provisioned3", "Provisioned4"},
provisionedResources: []dashboards.DashboardSearchProjection{
provisioned("d1", "Provisioned1", isDashboard),
provisioned("d2", "Provisioned2", isDashboard),
fromUser("f1", "UserFolder1", isFolder),
provisioned("d3", "Provisioned3", isDashboard),
fromUser("d4", "User1", isDashboard),
provisioned("d5", "Provisioned4", isDashboard),
fromUser("d6", "User2", isDashboard),
fromUser("f2", "UserFolder2", isFolder),
},
expectedSteps: nil, // no duplicates in the provisioned set, nothing to do
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
provisionedSet := make(map[string]bool)
for _, name := range tc.configDashboards {
provisionedSet[name] = true
}
steps, err := cleanupSteps(tc.provisionedFolders, tc.provisionedResources, provisionedSet)
if tc.expectedErr == "" {
require.NoError(t, err)
require.Equal(t, tc.expectedSteps, steps)
} else {
require.Error(t, err)
require.Equal(t, tc.expectedErr, err.Error())
}
})
}
}

View File

@@ -202,6 +202,11 @@ func (s *Service) searchFoldersFromApiServer(ctx context.Context, query folder.S
if query.Title != "" {
// allow wildcard search
request.Query = "*" + strings.ToLower(query.Title) + "*"
// or perform exact match if requested
if query.TitleExactMatch {
request.Query = query.Title
}
// if using query, you need to specify the fields you want
request.Fields = dashboardsearch.IncludeFields
}

View File

@@ -224,12 +224,13 @@ type GetFoldersQuery struct {
}
type SearchFoldersQuery struct {
OrgID int64
UIDs []string
IDs []int64
Title string
Limit int64
SignedInUser identity.Requester `json:"-"`
OrgID int64
UIDs []string
IDs []int64
Title string
TitleExactMatch bool
Limit int64
SignedInUser identity.Requester `json:"-"`
}
// GetParentsQuery captures the information required by the folder service to

View File

@@ -153,13 +153,20 @@ func (provider *Provisioner) Provision(ctx context.Context) error {
// CleanUpOrphanedDashboards deletes provisioned dashboards missing a linked reader.
func (provider *Provisioner) CleanUpOrphanedDashboards(ctx context.Context) {
currentReaders := make([]string, len(provider.fileReaders))
configs := make([]dashboards.ProvisioningConfig, len(provider.fileReaders))
for index, reader := range provider.fileReaders {
currentReaders[index] = reader.Cfg.Name
configs[index] = dashboards.ProvisioningConfig{
Name: reader.Cfg.Name,
OrgID: reader.Cfg.OrgID,
Folder: reader.Cfg.Folder,
AllowUIUpdates: reader.Cfg.AllowUIUpdates,
}
}
if err := provider.provisioner.DeleteOrphanedProvisionedDashboards(ctx, &dashboards.DeleteOrphanedProvisionedDashboardsCommand{ReaderNames: currentReaders}); err != nil {
if err := provider.provisioner.DeleteOrphanedProvisionedDashboards(
ctx, &dashboards.DeleteOrphanedProvisionedDashboardsCommand{Config: configs},
); err != nil {
provider.log.Warn("Failed to delete orphaned provisioned dashboards", "err", err)
}
}

View File

@@ -1802,6 +1802,15 @@
}
}
},
{
"name": "facetLimit",
"in": "query",
"description": "maximum number of terms to return per facet (default 50, max 1000)",
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "tags",
"in": "query",

View File

@@ -106,7 +106,7 @@ export class UnifiedSearcher implements GrafanaSearcher {
async tags(query: SearchQuery): Promise<TermCount[]> {
const qry = query.query ?? '*';
let uri = `${searchURI}?facet=tags&query=${qry}&limit=1`;
let uri = `${searchURI}?facet=tags&facetLimit=1000&query=${qry}&limit=1`;
const resp = await getBackendSrv().get<SearchAPIResponse>(uri);
return resp.facets?.tags?.terms || [];
}