From d2868a1ce7ac694f5ac865ca510507c95d4e14da Mon Sep 17 00:00:00 2001 From: Alexander Emelin Date: Fri, 3 Jun 2022 23:11:32 +0300 Subject: [PATCH] SearchV2: instant local updates, folder events (#50001) --- pkg/api/folder.go | 28 +++- pkg/services/searchV2/bluge.go | 68 ++++++++- pkg/services/searchV2/index.go | 74 +++++++-- pkg/services/searchV2/index_test.go | 143 +++++++++++++++++- ...-dashboard-removed-on-folder-removed.jsonc | 131 ++++++++++++++++ .../searchV2/testdata/folders-indexed.jsonc | 131 ++++++++++++++++ ...s-panel-removed-on-dashboard-removed.jsonc | 114 ++++++++++++++ pkg/services/store/entity_events.go | 52 +++++-- pkg/services/store/entity_events_mock.go | 22 ++- 9 files changed, 728 insertions(+), 35 deletions(-) create mode 100644 pkg/services/searchV2/testdata/folders-dashboard-removed-on-folder-removed.jsonc create mode 100644 pkg/services/searchV2/testdata/folders-indexed.jsonc create mode 100644 pkg/services/searchV2/testdata/panels-panel-removed-on-dashboard-removed.jsonc diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 9efde7251f9..cb45ad640ab 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/libraryelements" + "github.com/grafana/grafana/pkg/services/store" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -70,6 +71,14 @@ func (hs *HTTPServer) CreateFolder(c *models.ReqContext) response.Response { if err != nil { return apierrors.ToFolderErrorResponse(err) } + if hs.entityEventsService != nil { + if err := hs.entityEventsService.SaveEvent(c.Req.Context(), store.SaveEventCmd{ + EntityId: store.CreateDatabaseEntityId(folder.Uid, c.OrgId, store.EntityTypeFolder), + EventType: store.EntityEventTypeCreate, + }); err != nil { + hs.log.Warn("failed to save folder entity event", "uid", folder.Uid, "error", err) + } + } g := guardian.New(c.Req.Context(), folder.Id, c.OrgId, c.SignedInUser) return response.JSON(http.StatusOK, hs.toFolderDto(c.Req.Context(), g, folder)) @@ -84,6 +93,14 @@ func (hs *HTTPServer) UpdateFolder(c *models.ReqContext) response.Response { if err != nil { return apierrors.ToFolderErrorResponse(err) } + if hs.entityEventsService != nil { + if err := hs.entityEventsService.SaveEvent(c.Req.Context(), store.SaveEventCmd{ + EntityId: store.CreateDatabaseEntityId(cmd.Uid, c.OrgId, store.EntityTypeFolder), + EventType: store.EntityEventTypeUpdate, + }); err != nil { + hs.log.Warn("failed to save folder entity event", "uid", cmd.Uid, "error", err) + } + } g := guardian.New(c.Req.Context(), cmd.Result.Id, c.OrgId, c.SignedInUser) return response.JSON(http.StatusOK, hs.toFolderDto(c.Req.Context(), g, cmd.Result)) @@ -98,10 +115,19 @@ func (hs *HTTPServer) DeleteFolder(c *models.ReqContext) response.Response { // return apierrors.ToFolderErrorResponse(err) } - f, err := hs.folderService.DeleteFolder(c.Req.Context(), c.SignedInUser, c.OrgId, web.Params(c.Req)[":uid"], c.QueryBool("forceDeleteRules")) + uid := web.Params(c.Req)[":uid"] + f, err := hs.folderService.DeleteFolder(c.Req.Context(), c.SignedInUser, c.OrgId, uid, c.QueryBool("forceDeleteRules")) if err != nil { return apierrors.ToFolderErrorResponse(err) } + if hs.entityEventsService != nil { + if err := hs.entityEventsService.SaveEvent(c.Req.Context(), store.SaveEventCmd{ + EntityId: store.CreateDatabaseEntityId(uid, c.OrgId, store.EntityTypeFolder), + EventType: store.EntityEventTypeDelete, + }); err != nil { + hs.log.Warn("failed to save folder entity event", "uid", uid, "error", err) + } + } return response.JSON(http.StatusOK, util.DynMap{ "title": f.Title, diff --git a/pkg/services/searchV2/bluge.go b/pkg/services/searchV2/bluge.go index 6982db40e79..2cb83549a44 100644 --- a/pkg/services/searchV2/bluge.go +++ b/pkg/services/searchV2/bluge.go @@ -105,7 +105,10 @@ func initIndex(dashboards []dashboard, logger log.Logger, extendDoc ExtendDashbo } // Index each panel in dashboard. - location += "/" + dash.uid + if location != "" { + location += "/" + } + location += dash.uid docs := getDashboardPanelDocs(dash, location) for _, panelDoc := range docs { batch.Insert(panelDoc) @@ -194,7 +197,6 @@ func getDashboardPanelDocs(dash dashboard, location string) []*bluge.Document { purl := fmt.Sprintf("%s?viewPanel=%d", url, panel.ID) doc := newSearchDocument(uid, panel.Title, panel.Description, purl). - AddField(bluge.NewKeywordField(documentFieldDSUID, dash.uid).StoreValue()). AddField(bluge.NewKeywordField(documentFieldPanelType, panel.Type).Aggregatable().StoreValue()). AddField(bluge.NewKeywordField(documentFieldLocation, location).Aggregatable().StoreValue()). AddField(bluge.NewKeywordField(documentFieldKind, string(entityKindPanel)).Aggregatable().StoreValue()) // likely want independent index for this @@ -263,10 +265,10 @@ func newSearchDocument(uid string, name string, descr string, url string) *bluge return doc } -func getDashboardPanelIDs(reader *bluge.Reader, dashboardUID string) ([]string, error) { +func getDashboardPanelIDs(reader *bluge.Reader, panelLocation string) ([]string, error) { var panelIDs []string fullQuery := bluge.NewBooleanQuery() - fullQuery.AddMust(bluge.NewTermQuery(dashboardUID).SetField(documentFieldDSUID)) + fullQuery.AddMust(bluge.NewTermQuery(panelLocation).SetField(documentFieldLocation)) fullQuery.AddMust(bluge.NewTermQuery(string(entityKindPanel)).SetField(documentFieldKind)) req := bluge.NewAllMatches(fullQuery) documentMatchIterator, err := reader.Search(context.Background(), req) @@ -291,6 +293,64 @@ func getDashboardPanelIDs(reader *bluge.Reader, dashboardUID string) ([]string, return panelIDs, err } +func getDocsIDsByLocationPrefix(reader *bluge.Reader, prefix string) ([]string, error) { + var ids []string + fullQuery := bluge.NewBooleanQuery() + fullQuery.AddMust(bluge.NewPrefixQuery(prefix).SetField(documentFieldLocation)) + req := bluge.NewAllMatches(fullQuery) + documentMatchIterator, err := reader.Search(context.Background(), req) + if err != nil { + return nil, err + } + match, err := documentMatchIterator.Next() + for err == nil && match != nil { + // load the identifier for this match + err = match.VisitStoredFields(func(field string, value []byte) bool { + if field == documentFieldUID { + ids = append(ids, string(value)) + } + return true + }) + if err != nil { + return nil, err + } + // load the next document match + match, err = documentMatchIterator.Next() + } + return ids, err +} + +func getDashboardLocation(reader *bluge.Reader, dashboardUID string) (string, bool, error) { + var dashboardLocation string + var found bool + fullQuery := bluge.NewBooleanQuery() + fullQuery.AddMust(bluge.NewTermQuery(dashboardUID).SetField(documentFieldUID)) + fullQuery.AddMust(bluge.NewTermQuery(string(entityKindDashboard)).SetField(documentFieldKind)) + req := bluge.NewAllMatches(fullQuery) + documentMatchIterator, err := reader.Search(context.Background(), req) + if err != nil { + return "", false, err + } + match, err := documentMatchIterator.Next() + for err == nil && match != nil { + // load the identifier for this match + err = match.VisitStoredFields(func(field string, value []byte) bool { + if field == documentFieldLocation { + dashboardLocation = string(value) + found = true + return false + } + return true + }) + if err != nil { + return "", false, err + } + // load the next document match + match, err = documentMatchIterator.Next() + } + return dashboardLocation, found, err +} + //nolint: gocyclo func doSearchQuery( ctx context.Context, diff --git a/pkg/services/searchV2/index.go b/pkg/services/searchV2/index.go index 29133fcd247..9e5520e654b 100644 --- a/pkg/services/searchV2/index.go +++ b/pkg/services/searchV2/index.go @@ -30,6 +30,7 @@ type dashboardLoader interface { } type eventStore interface { + OnEvent(handler store.EventHandler) GetLastEvent(ctx context.Context) (*store.EntityEvent, error) GetAllEventsAfter(ctx context.Context, id int64) ([]*store.EntityEvent, error) } @@ -95,6 +96,8 @@ func (i *dashboardIndex) run(ctx context.Context) error { return fmt.Errorf("can't build initial dashboard search index: %w", err) } + i.eventStore.OnEvent(i.applyEventOnIndex) + for { select { case <-partialUpdateTicker.C: @@ -293,7 +296,6 @@ func (i *dashboardIndex) applyIndexUpdates(ctx context.Context, lastEventID int6 } started := time.Now() for _, e := range events { - i.logger.Debug("processing event", "event", e) err := i.applyEventOnIndex(ctx, e) if err != nil { i.logger.Error("can't apply event", "error", err) @@ -306,31 +308,30 @@ func (i *dashboardIndex) applyIndexUpdates(ctx context.Context, lastEventID int6 } func (i *dashboardIndex) applyEventOnIndex(ctx context.Context, e *store.EntityEvent) error { + i.logger.Debug("processing event", "event", e) + if !strings.HasPrefix(e.EntityId, "database/") { i.logger.Warn("unknown storage", "entityId", e.EntityId) return nil } - parts := strings.Split(strings.TrimPrefix(e.EntityId, "database/"), "/") + // database/org/entityType/path* + parts := strings.SplitN(strings.TrimPrefix(e.EntityId, "database/"), "/", 3) if len(parts) != 3 { i.logger.Error("can't parse entityId", "entityId", e.EntityId) return nil } orgIDStr := parts[0] - kind := parts[1] - dashboardUID := parts[2] - if kind != "dashboard" { - i.logger.Error("unknown kind in entityId", "entityId", e.EntityId) - return nil - } - orgID, err := strconv.Atoi(orgIDStr) + orgID, err := strconv.ParseInt(orgIDStr, 10, 64) if err != nil { i.logger.Error("can't extract org ID", "entityId", e.EntityId) return nil } - return i.applyDashboardEvent(ctx, int64(orgID), dashboardUID, e.EventType) + kind := store.EntityType(parts[1]) + uid := parts[2] + return i.applyEvent(ctx, orgID, kind, uid, e.EventType) } -func (i *dashboardIndex) applyDashboardEvent(ctx context.Context, orgID int64, dashboardUID string, _ store.EntityEventType) error { +func (i *dashboardIndex) applyEvent(ctx context.Context, orgID int64, kind store.EntityType, uid string, _ store.EntityEventType) error { i.mu.Lock() _, ok := i.perOrgWriter[orgID] if !ok { @@ -340,7 +341,8 @@ func (i *dashboardIndex) applyDashboardEvent(ctx context.Context, orgID int64, d } i.mu.Unlock() - dbDashboards, err := i.loader.LoadDashboards(ctx, orgID, dashboardUID) + // Both dashboard and folder share same DB table. + dbDashboards, err := i.loader.LoadDashboards(ctx, orgID, uid) if err != nil { return err } @@ -364,7 +366,14 @@ func (i *dashboardIndex) applyDashboardEvent(ctx context.Context, orgID int64, d // In the future we can rely on operation types to reduce work here. if len(dbDashboards) == 0 { - newReader, err = i.removeDashboard(ctx, writer, reader, dashboardUID) + switch kind { + case store.EntityTypeDashboard: + newReader, err = i.removeDashboard(ctx, writer, reader, uid) + case store.EntityTypeFolder: + newReader, err = i.removeFolder(ctx, writer, reader, uid) + default: + return nil + } } else { newReader, err = i.updateDashboard(ctx, orgID, writer, reader, dbDashboards[0]) } @@ -377,8 +386,21 @@ func (i *dashboardIndex) applyDashboardEvent(ctx context.Context, orgID int64, d } func (i *dashboardIndex) removeDashboard(_ context.Context, writer *bluge.Writer, reader *bluge.Reader, dashboardUID string) (*bluge.Reader, error) { + dashboardLocation, ok, err := getDashboardLocation(reader, dashboardUID) + if err != nil { + return nil, err + } + if !ok { + // No dashboard, nothing to remove. + return reader, nil + } + // Find all panel docs to remove with dashboard. - panelIDs, err := getDashboardPanelIDs(reader, dashboardUID) + panelLocation := dashboardUID + if dashboardLocation != "" { + panelLocation = dashboardLocation + "/" + dashboardUID + } + panelIDs, err := getDocsIDsByLocationPrefix(reader, panelLocation) if err != nil { return nil, err } @@ -397,6 +419,23 @@ func (i *dashboardIndex) removeDashboard(_ context.Context, writer *bluge.Writer return writer.Reader() } +func (i *dashboardIndex) removeFolder(_ context.Context, writer *bluge.Writer, reader *bluge.Reader, folderUID string) (*bluge.Reader, error) { + ids, err := getDocsIDsByLocationPrefix(reader, folderUID) + if err != nil { + return nil, err + } + batch := bluge.NewBatch() + batch.Delete(bluge.NewDocument(folderUID).ID()) + for _, id := range ids { + batch.Delete(bluge.NewDocument(id).ID()) + } + err = writer.Batch(batch) + if err != nil { + return nil, err + } + return writer.Reader() +} + func stringInSlice(str string, slice []string) bool { for _, s := range slice { if s == str { @@ -437,14 +476,17 @@ func (i *dashboardIndex) updateDashboard(ctx context.Context, orgID int64, write var actualPanelIDs []string - location += "/" + dash.uid + if location != "" { + location += "/" + } + location += dash.uid panelDocs := getDashboardPanelDocs(dash, location) for _, panelDoc := range panelDocs { actualPanelIDs = append(actualPanelIDs, string(panelDoc.ID().Term())) batch.Update(panelDoc.ID(), panelDoc) } - indexedPanelIDs, err := getDashboardPanelIDs(reader, dash.uid) + indexedPanelIDs, err := getDashboardPanelIDs(reader, location) if err != nil { return nil, err } diff --git a/pkg/services/searchV2/index_test.go b/pkg/services/searchV2/index_test.go index 010d2cdc328..5692523a1e0 100644 --- a/pkg/services/searchV2/index_test.go +++ b/pkg/services/searchV2/index_test.go @@ -2,15 +2,17 @@ package searchV2 import ( "context" + "fmt" "path/filepath" "testing" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/searchV2/extract" "github.com/grafana/grafana/pkg/services/store" "github.com/blugelabs/bluge" - "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/experimental" "github.com/stretchr/testify/require" ) @@ -370,3 +372,142 @@ func TestDashboardIndex_MultipleTokensScattered(t *testing.T) { ) }) } + +var dashboardsWithFolders = []dashboard{ + { + id: 1, + uid: "1", + isFolder: true, + info: &extract.DashboardInfo{ + Title: "My folder", + }, + }, + { + id: 2, + uid: "2", + folderID: 1, + info: &extract.DashboardInfo{ + Title: "Dashboard in folder 1", + Panels: []extract.PanelInfo{ + { + ID: 1, + Title: "Panel 1", + }, + { + ID: 2, + Title: "Panel 2", + }, + }, + }, + }, + { + id: 3, + uid: "3", + folderID: 1, + info: &extract.DashboardInfo{ + Title: "Dashboard in folder 2", + Panels: []extract.PanelInfo{ + { + ID: 3, + Title: "Panel 3", + }, + }, + }, + }, + { + id: 4, + uid: "4", + info: &extract.DashboardInfo{ + Title: "One more dash", + Panels: []extract.PanelInfo{ + { + ID: 3, + Title: "Panel 4", + }, + }, + }, + }, +} + +func TestDashboardIndex_Folders(t *testing.T) { + t.Run("folders-indexed", func(t *testing.T) { + _, reader, _ := initTestIndexFromDashes(t, dashboardsWithFolders) + checkSearchResponse(t, filepath.Base(t.Name()), reader, testAllowAllFilter, + DashboardQuery{Query: "My folder", Kind: []string{string(entityKindFolder)}}, + ) + }) + t.Run("folders-dashboard-has-folder", func(t *testing.T) { + _, reader, _ := initTestIndexFromDashes(t, dashboardsWithFolders) + // TODO: golden file compare does not work here. + resp := doSearchQuery(context.Background(), testLogger, reader, testAllowAllFilter, + DashboardQuery{Query: "Dashboard in folder", Kind: []string{string(entityKindDashboard)}}, + &NoopQueryExtender{}, "") + custom, ok := resp.Frames[0].Meta.Custom.(*customMeta) + require.Equal(t, uint64(2), custom.Count) + require.True(t, ok, fmt.Sprintf("actual type: %T", resp.Frames[0].Meta.Custom)) + require.Equal(t, "/dashboards/f/1/", custom.Locations["1"].URL) + }) + t.Run("folders-dashboard-removed-on-folder-removed", func(t *testing.T) { + index, reader, writer := initTestIndexFromDashes(t, dashboardsWithFolders) + newReader, err := index.removeFolder(context.Background(), writer, reader, "1") + require.NoError(t, err) + // In response we expect one dashboard which does not belong to removed folder. + checkSearchResponse(t, filepath.Base(t.Name()), newReader, testAllowAllFilter, + DashboardQuery{Query: "dash", Kind: []string{string(entityKindDashboard)}}, + ) + }) + t.Run("folders-panels-removed-on-folder-removed", func(t *testing.T) { + index, reader, writer := initTestIndexFromDashes(t, dashboardsWithFolders) + newReader, err := index.removeFolder(context.Background(), writer, reader, "1") + require.NoError(t, err) + resp := doSearchQuery(context.Background(), testLogger, newReader, testAllowAllFilter, + DashboardQuery{Query: "Panel", Kind: []string{string(entityKindPanel)}}, + &NoopQueryExtender{}, "") + custom, ok := resp.Frames[0].Meta.Custom.(*customMeta) + require.True(t, ok) + require.Equal(t, uint64(1), custom.Count) // 1 panel which does not belong to dashboards in removed folder. + }) +} + +var dashboardsWithPanels = []dashboard{ + { + id: 1, + uid: "1", + info: &extract.DashboardInfo{ + Title: "My Dash", + Panels: []extract.PanelInfo{ + { + ID: 1, + Title: "Panel 1", + }, + { + ID: 2, + Title: "Panel 2", + }, + }, + }, + }, +} + +func TestDashboardIndex_Panels(t *testing.T) { + t.Run("panels-indexed", func(t *testing.T) { + _, reader, _ := initTestIndexFromDashes(t, dashboardsWithPanels) + // TODO: golden file compare does not work here. + resp := doSearchQuery( + context.Background(), testLogger, reader, testAllowAllFilter, + DashboardQuery{Query: "Panel", Kind: []string{string(entityKindPanel)}}, + &NoopQueryExtender{}, "") + custom, ok := resp.Frames[0].Meta.Custom.(*customMeta) + require.True(t, ok, fmt.Sprintf("actual type: %T", resp.Frames[0].Meta.Custom)) + require.Equal(t, uint64(2), custom.Count) + require.Equal(t, "/d/1/", custom.Locations["1"].URL) + }) + t.Run("panels-panel-removed-on-dashboard-removed", func(t *testing.T) { + index, reader, writer := initTestIndexFromDashes(t, dashboardsWithPanels) + newReader, err := index.removeDashboard(context.Background(), writer, reader, "1") + require.NoError(t, err) + checkSearchResponse(t, filepath.Base(t.Name()), newReader, testAllowAllFilter, + DashboardQuery{Query: "Panel", Kind: []string{string(entityKindPanel)}}, + ) + }) +} diff --git a/pkg/services/searchV2/testdata/folders-dashboard-removed-on-folder-removed.jsonc b/pkg/services/searchV2/testdata/folders-dashboard-removed-on-folder-removed.jsonc new file mode 100644 index 00000000000..5901cc46713 --- /dev/null +++ b/pkg/services/searchV2/testdata/folders-dashboard-removed-on-folder-removed.jsonc @@ -0,0 +1,131 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "type": "search-results", +// "custom": { +// "count": 1 +// } +// } +// Name: Query results +// Dimensions: 8 Fields by 1 Rows +// +----------------+----------------+----------------+------------------+----------------+--------------------------+--------------------------+----------------+ +// | Name: kind | Name: uid | Name: name | Name: panel_type | Name: url | Name: tags | Name: ds_uid | Name: location | +// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | +// | Type: []string | Type: []string | Type: []string | Type: []string | Type: []string | Type: []*json.RawMessage | Type: []*json.RawMessage | Type: []string | +// +----------------+----------------+----------------+------------------+----------------+--------------------------+--------------------------+----------------+ +// | dashboard | 4 | One more dash | | /pfix/d/4/ | null | null | | +// +----------------+----------------+----------------+------------------+----------------+--------------------------+--------------------------+----------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "frames": [ + { + "schema": { + "name": "Query results", + "meta": { + "type": "search-results", + "custom": { + "count": 1 + } + }, + "fields": [ + { + "name": "kind", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "uid", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "name", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "panel_type", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "url", + "type": "string", + "typeInfo": { + "frame": "string" + }, + "config": { + "links": [ + { + "title": "link", + "url": "${__value.text}" + } + ] + } + }, + { + "name": "tags", + "type": "other", + "typeInfo": { + "frame": "json.RawMessage", + "nullable": true + } + }, + { + "name": "ds_uid", + "type": "other", + "typeInfo": { + "frame": "json.RawMessage", + "nullable": true + } + }, + { + "name": "location", + "type": "string", + "typeInfo": { + "frame": "string" + } + } + ] + }, + "data": { + "values": [ + [ + "dashboard" + ], + [ + "4" + ], + [ + "One more dash" + ], + [ + "" + ], + [ + "/pfix/d/4/" + ], + [ + null + ], + [ + null + ], + [ + "" + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/services/searchV2/testdata/folders-indexed.jsonc b/pkg/services/searchV2/testdata/folders-indexed.jsonc new file mode 100644 index 00000000000..e961a5866e9 --- /dev/null +++ b/pkg/services/searchV2/testdata/folders-indexed.jsonc @@ -0,0 +1,131 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "type": "search-results", +// "custom": { +// "count": 1 +// } +// } +// Name: Query results +// Dimensions: 8 Fields by 1 Rows +// +----------------+----------------+----------------+------------------+-----------------------+--------------------------+--------------------------+----------------+ +// | Name: kind | Name: uid | Name: name | Name: panel_type | Name: url | Name: tags | Name: ds_uid | Name: location | +// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | +// | Type: []string | Type: []string | Type: []string | Type: []string | Type: []string | Type: []*json.RawMessage | Type: []*json.RawMessage | Type: []string | +// +----------------+----------------+----------------+------------------+-----------------------+--------------------------+--------------------------+----------------+ +// | folder | 1 | My folder | | /pfix/dashboards/f/1/ | null | null | | +// +----------------+----------------+----------------+------------------+-----------------------+--------------------------+--------------------------+----------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "frames": [ + { + "schema": { + "name": "Query results", + "meta": { + "type": "search-results", + "custom": { + "count": 1 + } + }, + "fields": [ + { + "name": "kind", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "uid", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "name", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "panel_type", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "url", + "type": "string", + "typeInfo": { + "frame": "string" + }, + "config": { + "links": [ + { + "title": "link", + "url": "${__value.text}" + } + ] + } + }, + { + "name": "tags", + "type": "other", + "typeInfo": { + "frame": "json.RawMessage", + "nullable": true + } + }, + { + "name": "ds_uid", + "type": "other", + "typeInfo": { + "frame": "json.RawMessage", + "nullable": true + } + }, + { + "name": "location", + "type": "string", + "typeInfo": { + "frame": "string" + } + } + ] + }, + "data": { + "values": [ + [ + "folder" + ], + [ + "1" + ], + [ + "My folder" + ], + [ + "" + ], + [ + "/pfix/dashboards/f/1/" + ], + [ + null + ], + [ + null + ], + [ + "" + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/services/searchV2/testdata/panels-panel-removed-on-dashboard-removed.jsonc b/pkg/services/searchV2/testdata/panels-panel-removed-on-dashboard-removed.jsonc new file mode 100644 index 00000000000..a16acfe4894 --- /dev/null +++ b/pkg/services/searchV2/testdata/panels-panel-removed-on-dashboard-removed.jsonc @@ -0,0 +1,114 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "type": "search-results", +// "custom": { +// "count": 0 +// } +// } +// Name: Query results +// Dimensions: 8 Fields by 0 Rows +// +----------------+----------------+----------------+------------------+----------------+--------------------------+--------------------------+----------------+ +// | Name: kind | Name: uid | Name: name | Name: panel_type | Name: url | Name: tags | Name: ds_uid | Name: location | +// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | +// | Type: []string | Type: []string | Type: []string | Type: []string | Type: []string | Type: []*json.RawMessage | Type: []*json.RawMessage | Type: []string | +// +----------------+----------------+----------------+------------------+----------------+--------------------------+--------------------------+----------------+ +// +----------------+----------------+----------------+------------------+----------------+--------------------------+--------------------------+----------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "frames": [ + { + "schema": { + "name": "Query results", + "meta": { + "type": "search-results", + "custom": { + "count": 0 + } + }, + "fields": [ + { + "name": "kind", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "uid", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "name", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "panel_type", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "url", + "type": "string", + "typeInfo": { + "frame": "string" + }, + "config": { + "links": [ + { + "title": "link", + "url": "${__value.text}" + } + ] + } + }, + { + "name": "tags", + "type": "other", + "typeInfo": { + "frame": "json.RawMessage", + "nullable": true + } + }, + { + "name": "ds_uid", + "type": "other", + "typeInfo": { + "frame": "json.RawMessage", + "nullable": true + } + }, + { + "name": "location", + "type": "string", + "typeInfo": { + "frame": "string" + } + } + ] + }, + "data": { + "values": [ + [], + [], + [], + [], + [], + [], + [], + [] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/services/store/entity_events.go b/pkg/services/store/entity_events.go index a0c8a45bf12..e8101ca9e23 100644 --- a/pkg/services/store/entity_events.go +++ b/pkg/services/store/entity_events.go @@ -24,6 +24,7 @@ type EntityType string const ( EntityTypeDashboard EntityType = "dashboard" + EntityTypeFolder EntityType = "folder" ) // CreateDatabaseEntityId creates entityId for entities stored in the existing SQL tables @@ -51,6 +52,8 @@ type SaveEventCmd struct { EventType EntityEventType } +type EventHandler func(ctx context.Context, e *EntityEvent) error + // EntityEventsService is a temporary solution to support change notifications in an HA setup // With this service each system can query for any events that have happened since a fixed time //go:generate mockery --name EntityEventsService --structname MockEntityEventsService --inpackage --filename entity_events_mock.go @@ -60,6 +63,7 @@ type EntityEventsService interface { SaveEvent(ctx context.Context, cmd SaveEventCmd) error GetLastEvent(ctx context.Context) (*EntityEvent, error) GetAllEventsAfter(ctx context.Context, id int64) ([]*EntityEvent, error) + OnEvent(handler EventHandler) deleteEventsOlderThan(ctx context.Context, duration time.Duration) error } @@ -70,27 +74,48 @@ func ProvideEntityEventsService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, f } return &entityEventService{ - sql: sqlStore, - features: features, - log: log.New("entity-events"), + sql: sqlStore, + features: features, + log: log.New("entity-events"), + eventHandlers: make([]EventHandler, 0), } } type entityEventService struct { - sql *sqlstore.SQLStore - log log.Logger - features featuremgmt.FeatureToggles + sql *sqlstore.SQLStore + log log.Logger + features featuremgmt.FeatureToggles + eventHandlers []EventHandler } func (e *entityEventService) SaveEvent(ctx context.Context, cmd SaveEventCmd) error { - return e.sql.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - _, err := sess.Insert(&EntityEvent{ - EventType: cmd.EventType, - EntityId: cmd.EntityId, - Created: time.Now().Unix(), - }) + entityEvent := &EntityEvent{ + EventType: cmd.EventType, + EntityId: cmd.EntityId, + Created: time.Now().Unix(), + } + err := e.sql.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + _, err := sess.Insert(entityEvent) return err }) + if err != nil { + return err + } + return e.broadcastEvent(ctx, entityEvent) +} + +func (e *entityEventService) broadcastEvent(ctx context.Context, event *EntityEvent) error { + for _, h := range e.eventHandlers { + err := h(ctx, event) + if err != nil { + return err + } + } + return nil +} + +func (e *entityEventService) OnEvent(handler EventHandler) { + e.eventHandlers = append(e.eventHandlers, handler) } func (e *entityEventService) GetLastEvent(ctx context.Context) (*EntityEvent, error) { @@ -164,6 +189,9 @@ func (d dummyEntityEventsService) SaveEvent(ctx context.Context, cmd SaveEventCm return nil } +func (d dummyEntityEventsService) OnEvent(handler EventHandler) { +} + func (d dummyEntityEventsService) GetLastEvent(ctx context.Context) (*EntityEvent, error) { return nil, nil } diff --git a/pkg/services/store/entity_events_mock.go b/pkg/services/store/entity_events_mock.go index 4360f8921a1..52dc2bdff45 100644 --- a/pkg/services/store/entity_events_mock.go +++ b/pkg/services/store/entity_events_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.10.6. DO NOT EDIT. +// Code generated by mockery v2.12.3. DO NOT EDIT. package store @@ -74,6 +74,11 @@ func (_m *MockEntityEventsService) IsDisabled() bool { return r0 } +// OnEvent provides a mock function with given fields: handler +func (_m *MockEntityEventsService) OnEvent(handler EventHandler) { + _m.Called(handler) +} + // Run provides a mock function with given fields: ctx func (_m *MockEntityEventsService) Run(ctx context.Context) error { ret := _m.Called(ctx) @@ -115,3 +120,18 @@ func (_m *MockEntityEventsService) deleteEventsOlderThan(ctx context.Context, du return r0 } + +type NewMockEntityEventsServiceT interface { + mock.TestingT + Cleanup(func()) +} + +// NewMockEntityEventsService creates a new instance of MockEntityEventsService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewMockEntityEventsService(t NewMockEntityEventsServiceT) *MockEntityEventsService { + mock := &MockEntityEventsService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +}