From 12257d9dfee1e57a97e908b0150ca0b8185b03a4 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Tue, 9 Jul 2024 15:03:50 -0700 Subject: [PATCH] clearnup against smaller upstream --- pkg/apimachinery/identity/namespace.go | 1 - .../apis/dashboard/access/sql_dashboards.go | 409 +++++ pkg/registry/apis/dashboard/access/token.go | 67 + pkg/registry/apis/dashboard/access/types.go | 35 + pkg/registry/apis/dashboard/legacy_storage.go | 189 +- pkg/registry/apis/dashboard/register.go | 113 +- pkg/registry/apis/dashboard/sub_dto.go | 36 +- pkg/registry/apis/dashboard/sub_versions.go | 117 ++ .../sqlstore/migrations/migrations.go | 3 - pkg/storage/unified/README.md | 30 - pkg/storage/unified/apistore/history.go | 103 -- pkg/storage/unified/apistore/storage.go | 12 +- .../unified/basic/basic_sql_backend.go | 227 --- .../unified/basic/migrations/migrations.go | 26 - .../unified/entitybridge/entitybridge.go | 94 +- pkg/storage/unified/resource/cdk_backend.go | 28 +- pkg/storage/unified/resource/cdk_blob.go | 176 -- pkg/storage/unified/resource/cdk_blob_test.go | 67 - .../unified/resource/client_wrapper.go | 16 - pkg/storage/unified/resource/go.sum | 3 +- pkg/storage/unified/resource/noop.go | 29 +- pkg/storage/unified/resource/resource.pb.go | 1634 +++-------------- pkg/storage/unified/resource/resource.proto | 188 +- .../unified/resource/resource_grpc.pb.go | 308 ---- pkg/storage/unified/resource/server.go | 147 +- pkg/storage/unified/resource/server_test.go | 4 - pkg/storage/unified/sql/server.go | 33 - pkg/storage/unified/sqlnext/data/common.sql | 61 - .../unified/sqlnext/data/resource_get.sql | 34 - .../unified/sqlnext/data/resource_insert.sql | 31 - pkg/storage/unified/sqlnext/data/rv_get.sql | 8 - pkg/storage/unified/sqlnext/data/rv_inc.sql | 9 - .../unified/sqlnext/data/rv_insert.sql | 13 - pkg/storage/unified/sqlnext/data/rv_lock.sql | 7 - pkg/storage/unified/sqlnext/resource_mig.go | 161 -- pkg/storage/unified/sqlnext/sql_resources.go | 160 -- .../AppChrome/MegaMenu/MegaMenu.tsx | 38 +- .../AppChrome/MegaMenu/MegaMenuItem.tsx | 9 +- .../AppChrome/MegaMenu/MegaMenuItemText.tsx | 29 +- .../components/AppChrome/MegaMenu/hooks.ts | 14 + .../components/EmptyListCTA/EmptyListCTA.tsx | 7 +- .../core/components/TagFilter/TagBadge.tsx | 57 +- .../core/components/TagFilter/TagFilter.tsx | 58 +- public/app/core/reducers/root.ts | 2 + public/app/core/utils/fetch.ts | 4 +- public/app/features/admin/AdminSettings.tsx | 5 +- .../app/features/admin/UserLdapSyncInfo.tsx | 4 +- .../alerting/unified/AlertsFolderView.tsx | 5 +- .../unified/PanelAlertTabContent.test.tsx | 27 +- .../components/alert-groups/MatcherFilter.tsx | 17 +- .../EditDefaultPolicyForm.tsx | 8 +- .../EditNotificationPolicyForm.tsx | 2 +- .../notification-policies/Filters.tsx | 26 +- .../routeTimingsFields.ts | 6 +- .../rule-editor/NotificationsStep.tsx | 33 +- .../components/rule-editor/QueryRows.tsx | 11 +- .../route-settings/RouteSettings.tsx | 2 +- .../query-and-alert-condition/reducer.ts | 14 +- .../components/rule-viewer/tabs/Details.tsx | 2 +- .../unified/components/rules/NoRulesCTA.tsx | 2 +- .../rules/RuleDetailsMatchingInstances.tsx | 5 +- .../CentralAlertHistoryScene.tsx | 17 +- .../central-state-history/EventDetails.tsx | 236 +++ .../EventListSceneObject.tsx | 215 ++- .../getPanelDataForRule.test.ts | 53 + .../rules/central-state-history/utils.ts | 5 +- .../state-history/useRuleHistoryRecords.tsx | 5 +- .../components/silences/SilencesFilter.tsx | 17 +- .../components/silences/SilencesTable.tsx | 4 +- .../hooks/useCombinedRuleNamespaces.ts | 9 +- .../unified/hooks/useFilteredAmGroups.ts | 12 +- .../unified/hooks/useFilteredRules.ts | 6 +- .../unified/hooks/usePanelCombinedRules.ts | 2 +- .../unified/insights/DataSourcesInfo.tsx | 2 +- .../alerting/unified/insights/RatingModal.tsx | 2 +- .../unified/insights/SectionFooter.tsx | 2 +- .../unified/insights/SectionSubheader.tsx | 2 +- public/app/features/alerting/unified/mocks.ts | 9 +- .../unified/utils/alertmanager.test.ts | 63 +- .../alerting/unified/utils/alertmanager.ts | 37 +- .../alerting/unified/utils/amroutes.test.ts | 12 +- .../alerting/unified/utils/amroutes.ts | 7 +- .../alerting/unified/utils/matchers.test.ts | 104 +- .../alerting/unified/utils/matchers.ts | 45 + public/app/features/apiserver/client.test.ts | 35 + public/app/features/apiserver/client.ts | 32 + public/app/features/apiserver/types.ts | 10 + .../auth-config/ProviderConfigForm.test.tsx | 13 + public/app/features/auth-config/fields.tsx | 51 +- public/app/features/auth-config/types.ts | 3 + public/app/features/auth-config/utils/data.ts | 11 +- .../BrowseDashboardsPage.tsx | 6 +- .../inspect/HelpWizard/utils.ts | 16 +- .../inspect/InspectDataTab.tsx | 2 +- .../PanelDataPane/PanelDataQueriesTab.tsx | 11 +- .../panel-edit/VizPanelManager.tsx | 20 +- .../scene/DashboardGridItem.test.tsx | 57 +- .../scene/DashboardGridItem.tsx | 19 +- .../scene/DashboardScene.test.tsx | 25 +- .../dashboard-scene/scene/DashboardScene.tsx | 16 +- .../scene/NavToolbarActions.tsx | 17 +- .../scene/RowRepeaterBehavior.test.tsx | 25 +- .../scene/RowRepeaterBehavior.ts | 7 +- .../scene/Scopes/ScopesFiltersScene.tsx | 6 +- .../scene/Scopes/ScopesTreeSearch.tsx | 27 +- .../transformSaveModelToScene.ts | 2 - .../settings/VersionsEditView.test.tsx | 8 +- .../settings/VersionsEditView.tsx | 24 +- .../editors/AdHocFiltersVariableEditor.tsx | 8 +- .../editors/GroupByVariableEditor.tsx | 7 +- .../variables/editors/QueryVariableEditor.tsx | 6 +- .../version-history/HistorySrv.test.ts | 14 +- .../settings/version-history/HistorySrv.ts | 49 +- .../version-history/RevertDashboardModal.tsx | 2 +- .../version-history/VersionHistoryHeader.tsx | 4 +- .../version-history/VersionHistoryTable.tsx | 9 +- .../settings/version-history/index.ts | 2 +- .../dashboard-scene/utils/test-utils.ts | 2 + .../VersionsSettings.test.tsx | 16 +- .../DashboardSettings/VersionsSettings.tsx | 29 +- .../PanelEditor/PanelEditorQueries.tsx | 3 +- .../VersionHistoryComparison.tsx | 3 +- .../VersionHistory/VersionHistoryTable.tsx | 7 +- .../VersionHistory/useDashboardRestore.tsx | 4 +- .../features/dashboard/state/PanelModel.ts | 5 +- .../app/features/datasources/state/actions.ts | 10 +- .../explore/ContentOutline/ContentOutline.tsx | 45 +- public/app/features/explore/Logs/Logs.tsx | 26 +- .../app/features/library-panels/state/api.ts | 1 - .../migrate-to-cloud/api/endpoints.gen.ts | 2 +- .../cloud/EmptyState/InfoPane.tsx | 20 + .../cloud/EmptyState/MigrationStepsPane.tsx | 68 + .../MigrationTokenPane/MigrationTokenPane.tsx | 21 +- .../features/migrate-to-cloud/cloud/Page.tsx | 34 +- .../migrate-to-cloud/onprem/CTAInfo.tsx | 27 + .../migrate-to-cloud/onprem/MigrationInfo.tsx | 12 +- .../onprem/MigrationSummary.tsx | 110 ++ .../features/migrate-to-cloud/onprem/Page.tsx | 121 +- .../migrate-to-cloud/onprem/SnapshotCTAs.tsx | 67 + public/app/features/preferences/api/index.ts | 19 + .../features/preferences/api/user/baseAPI.ts | 36 + .../preferences/api/user/endpoints.gen.ts | 102 + .../query/components/QueryEditorRow.tsx | 3 +- .../query/components/QueryEditorRows.tsx | 7 +- .../features/query/components/QueryGroup.tsx | 17 +- .../query/state/updateQueries.test.ts | 10 + .../app/features/query/state/updateQueries.ts | 2 +- .../trails/ActionTabs/LayoutSwitcher.tsx | 2 + .../app/features/trails/ActionTabs/types.ts | 8 +- public/app/features/trails/DataTrail.tsx | 1 - public/app/features/trails/MetricScene.tsx | 6 +- public/app/features/trails/shared.ts | 2 + public/app/features/users/UsersListPage.tsx | 5 +- .../variables/pickers/shared/VariableLink.tsx | 63 +- .../components/AnnotationsHelp.tsx | 7 +- .../gke-prometheus-pod-node-monitoring.json | 980 ++++++---- .../cloudwatch/__mocks__/queries.ts | 4 +- .../MetricsQueryEditor/MetricsQueryEditor.tsx | 10 +- .../SQLBuilderEditor.test.tsx | 2 +- .../SQLBuilderSelectRow.test.tsx | 2 +- .../SQLBuilderEditor/SQLGroupBy.test.tsx | 2 +- .../QueryEditor/QueryEditor.test.tsx | 24 +- .../components/QueryEditor/QueryHeader.tsx | 6 +- .../MetricStatEditor/MetricStatEditor.tsx | 2 +- .../datasource/cloudwatch/dataquery.cue | 8 +- .../datasource/cloudwatch/dataquery.gen.ts | 8 +- .../migrations/dashboardMigrations.ts | 2 +- .../CloudWatchMetricsQueryRunner.test.ts | 8 +- .../plugins/datasource/cloudwatch/tracking.ts | 6 +- .../datasource/cloudwatch/utils/utils.ts | 2 +- .../tempo/configuration/ConfigEditor.tsx | 6 +- .../tempo/configuration/StreamingSection.tsx | 81 + .../datasource/tempo/datasource.test.ts | 14 +- .../plugins/datasource/tempo/datasource.ts | 119 +- .../app/plugins/datasource/tempo/streaming.ts | 1 + public/app/plugins/datasource/tempo/types.ts | 3 + .../panel/alertlist/UnifiedAlertList.tsx | 4 +- public/app/plugins/panel/alertlist/util.ts | 5 +- public/app/store/configureStore.ts | 2 + public/app/types/unified-alerting-dto.ts | 8 +- 180 files changed, 3986 insertions(+), 4622 deletions(-) create mode 100644 pkg/registry/apis/dashboard/access/sql_dashboards.go create mode 100644 pkg/registry/apis/dashboard/access/token.go create mode 100644 pkg/registry/apis/dashboard/access/types.go create mode 100644 pkg/registry/apis/dashboard/sub_versions.go delete mode 100644 pkg/storage/unified/README.md delete mode 100644 pkg/storage/unified/apistore/history.go delete mode 100644 pkg/storage/unified/basic/basic_sql_backend.go delete mode 100644 pkg/storage/unified/basic/migrations/migrations.go delete mode 100644 pkg/storage/unified/resource/cdk_blob.go delete mode 100644 pkg/storage/unified/resource/cdk_blob_test.go delete mode 100644 pkg/storage/unified/sqlnext/data/common.sql delete mode 100644 pkg/storage/unified/sqlnext/data/resource_get.sql delete mode 100644 pkg/storage/unified/sqlnext/data/resource_insert.sql delete mode 100644 pkg/storage/unified/sqlnext/data/rv_get.sql delete mode 100644 pkg/storage/unified/sqlnext/data/rv_inc.sql delete mode 100644 pkg/storage/unified/sqlnext/data/rv_insert.sql delete mode 100644 pkg/storage/unified/sqlnext/data/rv_lock.sql delete mode 100644 pkg/storage/unified/sqlnext/resource_mig.go delete mode 100644 pkg/storage/unified/sqlnext/sql_resources.go create mode 100644 public/app/core/components/AppChrome/MegaMenu/hooks.ts create mode 100644 public/app/features/alerting/unified/components/rules/central-state-history/EventDetails.tsx create mode 100644 public/app/features/alerting/unified/components/rules/central-state-history/getPanelDataForRule.test.ts create mode 100644 public/app/features/apiserver/client.test.ts create mode 100644 public/app/features/migrate-to-cloud/cloud/EmptyState/InfoPane.tsx create mode 100644 public/app/features/migrate-to-cloud/cloud/EmptyState/MigrationStepsPane.tsx create mode 100644 public/app/features/migrate-to-cloud/onprem/CTAInfo.tsx create mode 100644 public/app/features/migrate-to-cloud/onprem/MigrationSummary.tsx create mode 100644 public/app/features/migrate-to-cloud/onprem/SnapshotCTAs.tsx create mode 100644 public/app/features/preferences/api/index.ts create mode 100644 public/app/features/preferences/api/user/baseAPI.ts create mode 100644 public/app/features/preferences/api/user/endpoints.gen.ts create mode 100644 public/app/plugins/datasource/tempo/configuration/StreamingSection.tsx diff --git a/pkg/apimachinery/identity/namespace.go b/pkg/apimachinery/identity/namespace.go index 2b72ae71f58..aa3a38d3994 100644 --- a/pkg/apimachinery/identity/namespace.go +++ b/pkg/apimachinery/identity/namespace.go @@ -15,7 +15,6 @@ const ( NamespaceAnonymous Namespace = "anonymous" NamespaceRenderService Namespace = "render" NamespaceAccessPolicy Namespace = "access-policy" - NamespaceProvisioning Namespace = "provisioning" NamespaceEmpty Namespace = "" ) diff --git a/pkg/registry/apis/dashboard/access/sql_dashboards.go b/pkg/registry/apis/dashboard/access/sql_dashboards.go new file mode 100644 index 00000000000..86bbbfe1239 --- /dev/null +++ b/pkg/registry/apis/dashboard/access/sql_dashboards.go @@ -0,0 +1,409 @@ +package access + +import ( + "context" + "database/sql" + "fmt" + "path/filepath" + "strings" + "time" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + + "github.com/grafana/grafana/pkg/apimachinery/utils" + dashboardsV0 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/provisioning" + "github.com/grafana/grafana/pkg/services/sqlstore/session" +) + +var ( + _ DashboardAccess = (*dashboardSqlAccess)(nil) +) + +type dashboardRow struct { + // Dashboard resource + Dash *dashboardsV0.Dashboard + + // Title -- this may come from saved metadata rather than the body + Title string + + // The folder UID (needed for access control checks) + FolderUID string + + // Needed for fast summary access + Tags []string + + // Size (in bytes) of the dashboard payload + Bytes int + + // The token we can use that will start a new connection that includes + // this same dashboard + token *continueToken +} + +type dashboardSqlAccess struct { + sql db.DB + sess *session.SessionDB + namespacer request.NamespaceMapper + dashStore dashboards.Store + provisioning provisioning.ProvisioningService +} + +func NewDashboardAccess(sql db.DB, namespacer request.NamespaceMapper, dashStore dashboards.Store, provisioning provisioning.ProvisioningService) DashboardAccess { + return &dashboardSqlAccess{ + sql: sql, + sess: sql.GetSqlxSession(), + namespacer: namespacer, + dashStore: dashStore, + provisioning: provisioning, + } +} + +const selector = `SELECT + dashboard.org_id, dashboard.id, + dashboard.uid,slug, + dashboard.folder_uid, + dashboard.created,dashboard.created_by,CreatedUSER.login, + dashboard.updated,dashboard.updated_by,UpdatedUSER.login, + plugin_id, + dashboard_provisioning.name as origin_name, + dashboard_provisioning.external_id as origin_path, + dashboard_provisioning.check_sum as origin_key, + dashboard_provisioning.updated as origin_ts, + dashboard.version, + title, + dashboard.data + FROM dashboard + LEFT OUTER JOIN dashboard_provisioning ON dashboard.id = dashboard_provisioning.dashboard_id + LEFT OUTER JOIN user AS CreatedUSER ON dashboard.created_by = CreatedUSER.id + LEFT OUTER JOIN user AS UpdatedUSER ON dashboard.created_by = UpdatedUSER.id + WHERE is_folder = false` + +func (a *dashboardSqlAccess) getRows(ctx context.Context, query *DashboardQuery, onlySummary bool) (*rowsWrapper, int, error) { + if !query.Labels.Empty() { + return nil, 0, fmt.Errorf("label selection not yet supported") + } + if len(query.Requirements.SortBy) > 0 { + return nil, 0, fmt.Errorf("sorting not yet supported") + } + if query.Requirements.ListHistory != "" { + return nil, 0, fmt.Errorf("ListHistory not yet supported") + } + if query.Requirements.ListDeleted { + return nil, 0, fmt.Errorf("ListDeleted not yet supported") + } + + token, err := readContinueToken(query) + if err != nil { + return nil, 0, err + } + + limit := query.Limit + if limit < 1 { + limit = 15 // + } + args := []any{query.OrgID} + + sqlcmd := selector + + // We can not do this yet because title + tags are in the body + if onlySummary && false { + sqlcmd = strings.Replace(sqlcmd, "dashboard.data", `"{}"`, 1) + } + + sqlcmd = fmt.Sprintf("%s AND dashboard.org_id=$%d", sqlcmd, len(args)) + if query.UID != "" { + args = append(args, query.UID) + sqlcmd = fmt.Sprintf("%s AND dashboard.uid=$%d", sqlcmd, len(args)) + } else { + args = append(args, token.id) + sqlcmd = fmt.Sprintf("%s AND dashboard.id>=$%d", sqlcmd, len(args)) + } + + if query.Requirements.Folder != nil { + args = append(args, *query.Requirements.Folder) + sqlcmd = fmt.Sprintf("%s AND dashboard.folder_uid=$%d", sqlcmd, len(args)) + } + + args = append(args, (limit + 2)) // add more so we can include a next token + sqlcmd = fmt.Sprintf("%s ORDER BY dashboard.id asc LIMIT $%d", sqlcmd, len(args)) + + rows, err := a.doQuery(ctx, sqlcmd, args...) + if err != nil { + if rows != nil { + _ = rows.Close() + } + rows = nil + } + return rows, limit, err +} + +// GetDashboards implements DashboardAccess. +func (a *dashboardSqlAccess) GetDashboards(ctx context.Context, query *DashboardQuery) (*dashboardsV0.DashboardList, error) { + rows, limit, err := a.getRows(ctx, query, false) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + totalSize := 0 + list := &dashboardsV0.DashboardList{} + for { + row, err := rows.Next() + if err != nil || row == nil { + return list, err + } + + totalSize += row.Bytes + if len(list.Items) > 0 && (totalSize > query.MaxBytes || len(list.Items) >= limit) { + if query.Requirements.Folder != nil { + row.token.folder = *query.Requirements.Folder + } + list.Continue = row.token.String() // will skip this one but start here next time + return list, err + } + list.Items = append(list.Items, *row.Dash) + } +} + +func (a *dashboardSqlAccess) GetDashboard(ctx context.Context, orgId int64, uid string) (*dashboardsV0.Dashboard, error) { + r, err := a.GetDashboards(ctx, &DashboardQuery{ + OrgID: orgId, + UID: uid, + Labels: labels.Everything(), + }) + if err != nil { + return nil, err + } + if len(r.Items) > 0 { + return &r.Items[0], nil + } + return nil, fmt.Errorf("not found") +} + +func (a *dashboardSqlAccess) doQuery(ctx context.Context, query string, args ...any) (*rowsWrapper, error) { + user, err := appcontext.User(ctx) + if err != nil { + return nil, err + } + rows, err := a.sess.Query(ctx, query, args...) + return &rowsWrapper{ + rows: rows, + a: a, + // This looks up rules from the permissions on a user + canReadDashboard: accesscontrol.Checker(user, dashboards.ActionDashboardsRead), + }, err +} + +type rowsWrapper struct { + a *dashboardSqlAccess + rows *sql.Rows + idx int + total int64 + + canReadDashboard func(scopes ...string) bool +} + +func (r *rowsWrapper) Close() error { + return r.rows.Close() +} + +func (r *rowsWrapper) Next() (*dashboardRow, error) { + // breaks after first readable value + for r.rows.Next() { + r.idx++ + d, err := r.a.scanRow(r.rows) + if d != nil { + // Access control checker + scopes := []string{dashboards.ScopeDashboardsProvider.GetResourceScopeUID(d.Dash.Name)} + if d.FolderUID != "" { // Copied from searchV2... not sure the logic is right + scopes = append(scopes, dashboards.ScopeFoldersProvider.GetResourceScopeUID(d.FolderUID)) + } + if !r.canReadDashboard(scopes...) { + continue + } + d.token.size = r.total // size before next! + r.total += int64(d.Bytes) + } + + // returns the first folder it can + return d, err + } + return nil, nil +} + +func (a *dashboardSqlAccess) scanRow(rows *sql.Rows) (*dashboardRow, error) { + dash := &dashboardsV0.Dashboard{ + TypeMeta: dashboardsV0.DashboardResourceInfo.TypeMeta(), + ObjectMeta: v1.ObjectMeta{Annotations: make(map[string]string)}, + } + row := &dashboardRow{Dash: dash} + + var dashboard_id int64 + var orgId int64 + var slug string + var folder_uid sql.NullString + var updated time.Time + var updatedByID int64 + var updatedByName sql.NullString + + var created time.Time + var createdByID int64 + var createdByName sql.NullString + + var plugin_id string + var origin_name sql.NullString + var origin_path sql.NullString + var origin_ts sql.NullInt64 + var origin_hash sql.NullString + var data []byte // the dashboard JSON + var version int64 + + err := rows.Scan(&orgId, &dashboard_id, &dash.Name, + &slug, &folder_uid, + &created, &createdByID, &createdByName, + &updated, &updatedByID, &updatedByName, + &plugin_id, + &origin_name, &origin_path, &origin_hash, &origin_ts, + &version, + &row.Title, &data, + ) + + row.token = &continueToken{orgId: orgId, id: dashboard_id} + if err == nil { + dash.ResourceVersion = fmt.Sprintf("%d", created.UnixMilli()) + dash.Namespace = a.namespacer(orgId) + dash.UID = gapiutil.CalculateClusterWideUID(dash) + dash.SetCreationTimestamp(v1.NewTime(created)) + meta, err := utils.MetaAccessor(dash) + if err != nil { + return nil, err + } + meta.SetUpdatedTimestamp(&updated) + meta.SetSlug(slug) + if createdByID > 0 { + meta.SetCreatedBy(fmt.Sprintf("user:%d/%s", createdByID, createdByName.String)) + } + if updatedByID > 0 { + meta.SetUpdatedBy(fmt.Sprintf("user:%d/%s", updatedByID, updatedByName.String)) + } + if folder_uid.Valid { + meta.SetFolder(folder_uid.String) + row.FolderUID = folder_uid.String + } + + if origin_name.Valid { + ts := time.Unix(origin_ts.Int64, 0) + + resolvedPath := a.provisioning.GetDashboardProvisionerResolvedPath(origin_name.String) + originPath, err := filepath.Rel( + resolvedPath, + origin_path.String, + ) + if err != nil { + return nil, err + } + + meta.SetOriginInfo(&utils.ResourceOriginInfo{ + Name: origin_name.String, + Path: originPath, + Hash: origin_hash.String, + Timestamp: &ts, + }) + } else if plugin_id != "" { + meta.SetOriginInfo(&utils.ResourceOriginInfo{ + Name: "plugin", + Path: plugin_id, + }) + } + + row.Bytes = len(data) + if row.Bytes > 0 { + err = dash.Spec.UnmarshalJSON(data) + if err != nil { + return row, err + } + dash.Spec.Set("id", dashboard_id) // add it so we can get it from the body later + row.Title = dash.Spec.GetNestedString("title") + row.Tags = dash.Spec.GetNestedStringSlice("tags") + } + } + return row, err +} + +// DeleteDashboard implements DashboardAccess. +func (a *dashboardSqlAccess) DeleteDashboard(ctx context.Context, orgId int64, uid string) (*dashboardsV0.Dashboard, bool, error) { + dash, err := a.GetDashboard(ctx, orgId, uid) + if err != nil { + return nil, false, err + } + + id := dash.Spec.GetNestedInt64("id") + if id == 0 { + return nil, false, fmt.Errorf("could not find id in saved body") + } + + err = a.dashStore.DeleteDashboard(ctx, &dashboards.DeleteDashboardCommand{ + OrgID: orgId, + ID: id, + }) + if err != nil { + return nil, false, err + } + return dash, true, nil +} + +// SaveDashboard implements DashboardAccess. +func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, dash *dashboardsV0.Dashboard) (*dashboardsV0.Dashboard, bool, error) { + created := false + user, err := appcontext.User(ctx) + if err != nil { + return nil, created, err + } + if dash.Name != "" { + dash.Spec.Set("uid", dash.Name) + + // Get the previous version to set the internal ID + old, _ := a.dashStore.GetDashboard(ctx, &dashboards.GetDashboardQuery{ + OrgID: orgId, + UID: dash.Name, + }) + if old != nil { + dash.Spec.Set("id", old.ID) + } else { + dash.Spec.Remove("id") // existing of "id" makes it an update + created = true + } + } else { + dash.Spec.Remove("id") + dash.Spec.Remove("uid") + } + + meta, err := utils.MetaAccessor(dash) + if err != nil { + return nil, false, err + } + out, err := a.dashStore.SaveDashboard(ctx, dashboards.SaveDashboardCommand{ + OrgID: orgId, + Dashboard: simplejson.NewFromAny(dash.Spec.UnstructuredContent()), + FolderUID: meta.GetFolder(), + Overwrite: true, // already passed the revisionVersion checks! + UserID: user.UserID, + }) + if err != nil { + return nil, false, err + } + if out != nil { + created = (out.Created.Unix() == out.Updated.Unix()) // and now? + } + dash, err = a.GetDashboard(ctx, orgId, out.UID) + return dash, created, err +} diff --git a/pkg/registry/apis/dashboard/access/token.go b/pkg/registry/apis/dashboard/access/token.go new file mode 100644 index 00000000000..f8b32bbcb5d --- /dev/null +++ b/pkg/registry/apis/dashboard/access/token.go @@ -0,0 +1,67 @@ +package access + +import ( + "fmt" + "strconv" + "strings" + + "github.com/grafana/grafana/pkg/util" +) + +type continueToken struct { + orgId int64 + id int64 // the internal id (sort by!) + folder string // from the query + size int64 +} + +func readContinueToken(q *DashboardQuery) (continueToken, error) { + var err error + token := continueToken{} + if q.ContinueToken == "" { + return token, nil + } + parts := strings.Split(q.ContinueToken, "/") + if len(parts) < 3 { + return token, fmt.Errorf("invalid continue token (too few parts)") + } + sub := strings.Split(parts[0], ":") + if sub[0] != "org" { + return token, fmt.Errorf("expected org in first slug") + } + token.orgId, err = strconv.ParseInt(sub[1], 10, 64) + if err != nil { + return token, fmt.Errorf("error parsing orgid") + } + + sub = strings.Split(parts[1], ":") + if sub[0] != "start" { + return token, fmt.Errorf("expected internal ID in second slug") + } + token.id, err = strconv.ParseInt(sub[1], 10, 64) + if err != nil { + return token, fmt.Errorf("error parsing updated") + } + + sub = strings.Split(parts[2], ":") + if sub[0] != "folder" { + return token, fmt.Errorf("expected folder UID in third slug") + } + token.folder = sub[1] + + // Check if the folder filter is the same from the previous query + if q.Requirements.Folder == nil { + if token.folder != "" { + return token, fmt.Errorf("invalid token, the folder must match previous query") + } + } else if token.folder != *q.Requirements.Folder { + return token, fmt.Errorf("invalid token, the folder must match previous query") + } + + return token, err +} + +func (r *continueToken) String() string { + return fmt.Sprintf("org:%d/start:%d/folder:%s/%s", + r.orgId, r.id, r.folder, util.ByteCountSI(r.size)) +} diff --git a/pkg/registry/apis/dashboard/access/types.go b/pkg/registry/apis/dashboard/access/types.go new file mode 100644 index 00000000000..50065c8f2a2 --- /dev/null +++ b/pkg/registry/apis/dashboard/access/types.go @@ -0,0 +1,35 @@ +package access + +import ( + "context" + + "k8s.io/apimachinery/pkg/labels" + + dashboardsV0 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" + "github.com/grafana/grafana/pkg/services/apiserver/storage/entity" +) + +// This does not check if you have permissions! + +type DashboardQuery struct { + OrgID int64 + UID string // to select a single dashboard + Limit int + MaxBytes int + + // FolderUID etc + Requirements entity.Requirements + // Post processing label filter + Labels labels.Selector + + // The token from previous query + ContinueToken string +} + +type DashboardAccess interface { + GetDashboard(ctx context.Context, orgId int64, uid string) (*dashboardsV0.Dashboard, error) + GetDashboards(ctx context.Context, query *DashboardQuery) (*dashboardsV0.DashboardList, error) + + SaveDashboard(ctx context.Context, orgId int64, dash *dashboardsV0.Dashboard) (*dashboardsV0.Dashboard, bool, error) + DeleteDashboard(ctx context.Context, orgId int64, uid string) (*dashboardsV0.Dashboard, bool, error) +} diff --git a/pkg/registry/apis/dashboard/legacy_storage.go b/pkg/registry/apis/dashboard/legacy_storage.go index b41c995386f..f08ac34550f 100644 --- a/pkg/registry/apis/dashboard/legacy_storage.go +++ b/pkg/registry/apis/dashboard/legacy_storage.go @@ -1,71 +1,170 @@ package dashboard import ( + "context" + "fmt" + "strings" + "time" + + "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/registry/generic" - genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" "k8s.io/apiserver/pkg/registry/rest" common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" - grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" - "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" - "github.com/grafana/grafana/pkg/storage/unified/apistore" - "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/dashboard/access" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/apiserver/storage/entity" +) + +var ( + _ rest.Storage = (*dashboardStorage)(nil) + _ rest.Scoper = (*dashboardStorage)(nil) + _ rest.SingularNameProvider = (*dashboardStorage)(nil) + _ rest.Getter = (*dashboardStorage)(nil) + _ rest.Lister = (*dashboardStorage)(nil) + _ rest.Creater = (*dashboardStorage)(nil) + _ rest.Updater = (*dashboardStorage)(nil) + _ rest.GracefulDeleter = (*dashboardStorage)(nil) ) type dashboardStorage struct { resource common.ResourceInfo - access legacy.DashboardAccess + access access.DashboardAccess tableConverter rest.TableConvertor - - server resource.ResourceServer } -func (s *dashboardStorage) newStore(scheme *runtime.Scheme, defaultOptsGetter generic.RESTOptionsGetter) (grafanarest.LegacyStorage, error) { - server, err := resource.NewResourceServer(resource.ResourceServerOptions{ - Backend: s.access, - Search: s.access, - Blob: s.access, - // WriteAccess: resource.WriteAccessHooks{ - // Folder: func(ctx context.Context, user identity.Requester, uid string) bool { - // // ??? - // }, - // }, - }) +func (s *dashboardStorage) New() runtime.Object { + return s.resource.NewFunc() +} + +func (s *dashboardStorage) Destroy() {} + +func (s *dashboardStorage) NamespaceScoped() bool { + return true +} + +func (s *dashboardStorage) GetSingularName() string { + return s.resource.GetSingularName() +} + +func (s *dashboardStorage) NewList() runtime.Object { + return s.resource.NewListFunc() +} + +func (s *dashboardStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return s.tableConverter.ConvertToTable(ctx, object, tableOptions) +} + +func (s *dashboardStorage) Create(ctx context.Context, + obj runtime.Object, + createValidation rest.ValidateObjectFunc, + options *metav1.CreateOptions, +) (runtime.Object, error) { + info, err := request.NamespaceInfoFrom(ctx, true) if err != nil { return nil, err } - s.server = server - resourceInfo := s.resource - defaultOpts, err := defaultOptsGetter.GetRESTOptions(resourceInfo.GroupResource()) + p, ok := obj.(*v0alpha1.Dashboard) + if !ok { + return nil, fmt.Errorf("expected dashboard?") + } + + // HACK to simplify unique name testing from kubectl + t := p.Spec.GetNestedString("title") + if strings.Contains(t, "${NOW}") { + t = strings.ReplaceAll(t, "${NOW}", fmt.Sprintf("%d", time.Now().Unix())) + p.Spec.Set("title", t) + } + + dash, _, err := s.access.SaveDashboard(ctx, info.OrgID, p) + return dash, err +} + +func (s *dashboardStorage) Update(ctx context.Context, + name string, + objInfo rest.UpdatedObjectInfo, + createValidation rest.ValidateObjectFunc, + updateValidation rest.ValidateObjectUpdateFunc, + forceAllowCreate bool, + options *metav1.UpdateOptions, +) (runtime.Object, bool, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, false, err + } + + created := false + old, err := s.Get(ctx, name, nil) + if err != nil { + return old, created, err + } + + obj, err := objInfo.UpdatedObject(ctx, old) + if err != nil { + return old, created, err + } + p, ok := obj.(*v0alpha1.Dashboard) + if !ok { + return nil, created, fmt.Errorf("expected dashboard after update") + } + + _, created, err = s.access.SaveDashboard(ctx, info.OrgID, p) + if err == nil { + r, err := s.Get(ctx, name, nil) + return r, created, err + } + return nil, created, err +} + +// GracefulDeleter +func (s *dashboardStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, false, err + } + + return s.access.DeleteDashboard(ctx, info.OrgID, name) +} + +func (s *dashboardStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { + orgId, err := request.OrgIDForList(ctx) if err != nil { return nil, err } - client := resource.NewLocalResourceStoreClient(server) - optsGetter := apistore.NewRESTOptionsGetter(client, - defaultOpts.StorageConfig.Codec, - ) - strategy := grafanaregistry.NewStrategy(scheme) - store := &genericregistry.Store{ - NewFunc: resourceInfo.NewFunc, - NewListFunc: resourceInfo.NewListFunc, - KeyRootFunc: grafanaregistry.KeyRootFunc(resourceInfo.GroupResource()), - KeyFunc: grafanaregistry.NamespaceKeyFunc(resourceInfo.GroupResource()), - PredicateFunc: grafanaregistry.Matcher, - DefaultQualifiedResource: resourceInfo.GroupResource(), - SingularQualifiedResource: resourceInfo.SingularGroupResource(), - CreateStrategy: strategy, - UpdateStrategy: strategy, - DeleteStrategy: strategy, - TableConvertor: s.tableConverter, - } + // fmt.Printf("LIST: %s\n", options.Continue) - options := &generic.StoreOptions{RESTOptions: optsGetter} - if err := store.CompleteWithOptions(options); err != nil { + // translate grafana.app/* label selectors into field requirements + requirements, newSelector, err := entity.ReadLabelSelectors(options.LabelSelector) + if err != nil { return nil, err } - return store, err + + query := &access.DashboardQuery{ + OrgID: orgId, + Limit: int(options.Limit), + MaxBytes: 2 * 1024 * 1024, // 2MB, + ContinueToken: options.Continue, + Requirements: requirements, + Labels: newSelector, + } + + return s.access.GetDashboards(ctx, query) +} + +func (s *dashboardStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + + return s.access.GetDashboard(ctx, info.OrgID, name) +} + +// GracefulDeleter +func (s *dashboardStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) { + return nil, fmt.Errorf("DeleteCollection for dashboards not implemented") } diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index 269e4ec6c9f..ff30d0b90f5 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -1,10 +1,6 @@ package dashboard import ( - "fmt" - "time" - - "github.com/prometheus/client_golang/prometheus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -15,22 +11,22 @@ import ( common "k8s.io/kube-openapi/pkg/common" "k8s.io/kube-openapi/pkg/spec3" - dashboard "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" + "github.com/prometheus/client_golang/prometheus" + + "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" + "github.com/grafana/grafana/pkg/registry/apis/dashboard/access" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" - gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils" "github.com/grafana/grafana/pkg/services/dashboards" + dashver "github.com/grafana/grafana/pkg/services/dashboardversion" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/storage/unified/apistore" ) var _ builder.APIGroupBuilder = (*DashboardsAPIBuilder)(nil) @@ -39,8 +35,11 @@ var _ builder.APIGroupBuilder = (*DashboardsAPIBuilder)(nil) type DashboardsAPIBuilder struct { dashboardService dashboards.DashboardService - accessControl accesscontrol.AccessControl - legacy *dashboardStorage + dashboardVersionService dashver.Service + accessControl accesscontrol.AccessControl + namespacer request.NamespaceMapper + access access.DashboardAccess + dashStore dashboards.Store log log.Logger } @@ -48,12 +47,12 @@ type DashboardsAPIBuilder struct { func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar, dashboardService dashboards.DashboardService, + dashboardVersionService dashver.Service, accessControl accesscontrol.AccessControl, provisioning provisioning.ProvisioningService, dashStore dashboards.Store, reg prometheus.Registerer, sql db.DB, - tracing *tracing.TracingService, ) *DashboardsAPIBuilder { if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { return nil // skip registration unless opting into experimental apis @@ -61,42 +60,20 @@ func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, namespacer := request.GetNamespaceMapper(cfg) builder := &DashboardsAPIBuilder{ - log: log.New("grafana-apiserver.dashboards"), - - dashboardService: dashboardService, - accessControl: accessControl, - - legacy: &dashboardStorage{ - resource: dashboard.DashboardResourceInfo, - access: legacy.NewDashboardAccess(sql, namespacer, dashStore, provisioning), - tableConverter: gapiutil.NewTableConverter( - dashboard.DashboardResourceInfo.GroupResource(), - []metav1.TableColumnDefinition{ - {Name: "Name", Type: "string", Format: "name"}, - {Name: "Title", Type: "string", Format: "string", Description: "The dashboard name"}, - {Name: "Created At", Type: "date"}, - }, - func(obj any) ([]interface{}, error) { - dash, ok := obj.(*dashboard.Dashboard) - if ok { - if dash != nil { - return []interface{}{ - dash.Name, - dash.Spec.GetNestedString("title"), - dash.CreationTimestamp.UTC().Format(time.RFC3339), - }, nil - } - } - return nil, fmt.Errorf("expected dashboard or summary") - }), - }, + dashboardService: dashboardService, + dashboardVersionService: dashboardVersionService, + dashStore: dashStore, + accessControl: accessControl, + namespacer: namespacer, + access: access.NewDashboardAccess(sql, namespacer, dashStore, provisioning), + log: log.New("grafana-apiserver.dashboards"), } apiregistration.RegisterAPI(builder) return builder } func (b *DashboardsAPIBuilder) GetGroupVersion() schema.GroupVersion { - return dashboard.DashboardResourceInfo.GroupVersion() + return v0alpha1.DashboardResourceInfo.GroupVersion() } func (b *DashboardsAPIBuilder) GetDesiredDualWriterMode(dualWrite bool, modeMap map[string]grafanarest.DualWriterMode) grafanarest.DualWriterMode { @@ -106,18 +83,16 @@ func (b *DashboardsAPIBuilder) GetDesiredDualWriterMode(dualWrite bool, modeMap func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { scheme.AddKnownTypes(gv, - &dashboard.Dashboard{}, - &dashboard.DashboardList{}, - &dashboard.DashboardWithAccessInfo{}, - &dashboard.DashboardVersionList{}, - &dashboard.VersionsQueryOptions{}, - &metav1.PartialObjectMetadata{}, - &metav1.PartialObjectMetadataList{}, + &v0alpha1.Dashboard{}, + &v0alpha1.DashboardList{}, + &v0alpha1.DashboardWithAccessInfo{}, + &v0alpha1.DashboardVersionList{}, + &v0alpha1.VersionsQueryOptions{}, ) } func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { - resourceInfo := dashboard.DashboardResourceInfo + resourceInfo := v0alpha1.DashboardResourceInfo addKnownTypes(scheme, resourceInfo.GroupVersion()) // Link this version to the internal representation. @@ -143,44 +118,44 @@ func (b *DashboardsAPIBuilder) GetAPIGroupInfo( desiredMode grafanarest.DualWriterMode, reg prometheus.Registerer, ) (*genericapiserver.APIGroupInfo, error) { - apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(dashboard.GROUP, scheme, metav1.ParameterCodec, codecs) + apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(v0alpha1.GROUP, scheme, metav1.ParameterCodec, codecs) - dash := b.legacy.resource - legacyStore, err := b.legacy.newStore(scheme, optsGetter) + resourceInfo := v0alpha1.DashboardResourceInfo + store, err := newStorage(scheme) if err != nil { return nil, err } + legacyStore := &dashboardStorage{ + resource: resourceInfo, + access: b.access, + tableConverter: store.TableConvertor, + } + storage := map[string]rest.Storage{} - storage[dash.StoragePath()] = legacyStore - storage[dash.StoragePath("dto")] = &DTOConnector{ + storage[resourceInfo.StoragePath()] = legacyStore + storage[resourceInfo.StoragePath("dto")] = &DTOConnector{ + builder: b, + } + storage[resourceInfo.StoragePath("versions")] = &VersionsREST{ builder: b, } - storage[dash.StoragePath("history")] = apistore.NewHistoryConnector( - b.legacy.server, // as client??? - dashboard.DashboardResourceInfo.GroupResource(), - ) // Dual writes if a RESTOptionsGetter is provided if desiredMode != grafanarest.Mode0 && optsGetter != nil { - store, err := newStorage(scheme) - if err != nil { - return nil, err - } - options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} if err := store.CompleteWithOptions(options); err != nil { return nil, err } - storage[dash.StoragePath()] = grafanarest.NewDualWriter(grafanarest.Mode1, legacyStore, store, reg) + storage[resourceInfo.StoragePath()] = grafanarest.NewDualWriter(grafanarest.Mode1, legacyStore, store, reg) } - apiGroupInfo.VersionedResourcesStorageMap[dashboard.VERSION] = storage + apiGroupInfo.VersionedResourcesStorageMap[v0alpha1.VERSION] = storage return &apiGroupInfo, nil } func (b *DashboardsAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions { - return dashboard.GetOpenAPIDefinitions + return v0alpha1.GetOpenAPIDefinitions } func (b *DashboardsAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) { @@ -191,8 +166,8 @@ func (b *DashboardsAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.Op root := "/apis/" + b.GetGroupVersion().String() + "/" // Hide the ability to list or watch across all tenants - delete(oas.Paths.Paths, root+dashboard.DashboardResourceInfo.GroupResource().Resource) - delete(oas.Paths.Paths, root+"watch/"+dashboard.DashboardResourceInfo.GroupResource().Resource) + delete(oas.Paths.Paths, root+v0alpha1.DashboardResourceInfo.GroupResource().Resource) + delete(oas.Paths.Paths, root+"watch/"+v0alpha1.DashboardResourceInfo.GroupResource().Resource) // The root API discovery list sub := oas.Paths.Paths[root] diff --git a/pkg/registry/apis/dashboard/sub_dto.go b/pkg/registry/apis/dashboard/sub_dto.go index a336cf61b10..3a5a86cba94 100644 --- a/pkg/registry/apis/dashboard/sub_dto.go +++ b/pkg/registry/apis/dashboard/sub_dto.go @@ -2,7 +2,6 @@ package dashboard import ( "context" - "encoding/json" "fmt" "net/http" @@ -10,7 +9,6 @@ import ( "k8s.io/apiserver/pkg/registry/rest" "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/apimachinery/utils" dashboard "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/slugify" @@ -18,7 +16,6 @@ import ( "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/guardian" - "github.com/grafana/grafana/pkg/storage/unified/resource" ) // The DTO returns everything the UI needs in a single request @@ -26,10 +23,8 @@ type DTOConnector struct { builder *DashboardsAPIBuilder } -var ( - _ rest.Connecter = (*DTOConnector)(nil) - _ rest.StorageMetadata = (*DTOConnector)(nil) -) +var _ = rest.Connecter(&DTOConnector{}) +var _ = rest.StorageMetadata(&DTOConnector{}) func (r *DTOConnector) New() runtime.Object { return &dashboard.DashboardWithAccessInfo{} @@ -93,35 +88,10 @@ func (r *DTOConnector) Connect(ctx context.Context, name string, opts runtime.Ob r.getAnnotationPermissionsByScope(ctx, user, &access.AnnotationsPermissions.Dashboard, accesscontrol.ScopeAnnotationsTypeDashboard) r.getAnnotationPermissionsByScope(ctx, user, &access.AnnotationsPermissions.Organization, accesscontrol.ScopeAnnotationsTypeOrganization) - key := &resource.ResourceKey{ - Namespace: info.Value, - Group: dashboard.GROUP, - Resource: dashboard.DashboardResourceInfo.GroupResource().Resource, - Name: name, - } - store := r.builder.legacy.access - rsp, err := store.Read(ctx, &resource.ReadRequest{Key: key}) + dash, err := r.builder.access.GetDashboard(ctx, info.OrgID, name) if err != nil { return nil, err } - dash := &dashboard.Dashboard{} - err = json.Unmarshal(rsp.Value, dash) - if err != nil { - return nil, err - } - - // TODO, load the full spec from blob storage - if false { - blob, err := store.GetBlob(ctx, key, &utils.BlobInfo{UID: "dto"}, true) - if err != nil { - return nil, err - } - err = json.Unmarshal(blob.Value, &dash.Spec) - if err != nil { - return nil, err - } - } - access.Slug = slugify.Slugify(dash.Spec.GetNestedString("title")) access.Url = dashboards.GetDashboardFolderURL(false, name, access.Slug) diff --git a/pkg/registry/apis/dashboard/sub_versions.go b/pkg/registry/apis/dashboard/sub_versions.go new file mode 100644 index 00000000000..4787c5387df --- /dev/null +++ b/pkg/registry/apis/dashboard/sub_versions.go @@ -0,0 +1,117 @@ +package dashboard + +import ( + "context" + "fmt" + "net/http" + "strconv" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + dashboard "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + dashver "github.com/grafana/grafana/pkg/services/dashboardversion" +) + +type VersionsREST struct { + builder *DashboardsAPIBuilder +} + +var _ = rest.Connecter(&VersionsREST{}) +var _ = rest.StorageMetadata(&VersionsREST{}) + +func (r *VersionsREST) New() runtime.Object { + return &dashboard.DashboardVersionList{} +} + +func (r *VersionsREST) Destroy() { +} + +func (r *VersionsREST) ConnectMethods() []string { + return []string{"GET"} +} + +func (r *VersionsREST) ProducesMIMETypes(verb string) []string { + return nil +} + +func (r *VersionsREST) ProducesObject(verb string) interface{} { + return &dashboard.DashboardVersionList{} +} + +func (r *VersionsREST) NewConnectOptions() (runtime.Object, bool, string) { + return nil, true, "" +} + +func (r *VersionsREST) Connect(ctx context.Context, uid string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + path := req.URL.Path + idx := strings.LastIndex(path, "/versions/") + if idx > 0 { + key := path[strings.LastIndex(path, "/")+1:] + version, err := strconv.Atoi(key) + if err != nil { + responder.Error(err) + return + } + + dto, err := r.builder.dashboardVersionService.Get(ctx, &dashver.GetDashboardVersionQuery{ + DashboardUID: uid, + OrgID: info.OrgID, + Version: version, + }) + if err != nil { + responder.Error(err) + return + } + + data, _ := dto.Data.Map() + + // Convert the version to a regular dashboard + dash := &dashboard.Dashboard{ + ObjectMeta: metav1.ObjectMeta{ + Name: uid, + CreationTimestamp: metav1.NewTime(dto.Created), + }, + Spec: common.Unstructured{Object: data}, + } + responder.Object(100, dash) + return + } + + // Or list versions + rsp, err := r.builder.dashboardVersionService.List(ctx, &dashver.ListDashboardVersionsQuery{ + DashboardUID: uid, + OrgID: info.OrgID, + }) + if err != nil { + responder.Error(err) + return + } + versions := &dashboard.DashboardVersionList{} + for _, v := range rsp { + info := dashboard.DashboardVersionInfo{ + Version: v.Version, + Created: v.Created.UnixMilli(), + Message: v.Message, + } + if v.ParentVersion != v.Version { + info.ParentVersion = v.ParentVersion + } + if v.CreatedBy > 0 { + info.CreatedBy = fmt.Sprintf("%d", v.CreatedBy) + } + versions.Items = append(versions.Items, info) + } + responder.Object(http.StatusOK, versions) + }), nil +} diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 018d59d4c88..ae88297da96 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore/migrations/ssosettings" "github.com/grafana/grafana/pkg/services/sqlstore/migrations/ualert" . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" - basicResourceMigrations "github.com/grafana/grafana/pkg/storage/unified/basic/migrations" ) // --- Migration Guide line --- @@ -124,8 +123,6 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) { accesscontrol.AddManagedFolderAlertingSilencesActionsMigrator(mg) ualert.AddRecordingRuleColumns(mg) - - basicResourceMigrations.AddBasicResourceMigrations(mg) } func addStarMigrations(mg *Migrator) { diff --git a/pkg/storage/unified/README.md b/pkg/storage/unified/README.md deleted file mode 100644 index cd85de407f8..00000000000 --- a/pkg/storage/unified/README.md +++ /dev/null @@ -1,30 +0,0 @@ -This includes four packages - -## resource - -this is a go module that can be imported into external projects - -This includes the protobuf based client+server and all the logic required to convert requests into write events. - -Protobuf TODO? -* can/should we use upstream k8s proto for query object? -* starting a project today... should we use proto3? - - -## apistore - -The apiserver storage.Interface that links the storage to kubernetes - -Mostly a copy of te - - -## entitybridge - -Implementes a resource store using the existing entity service. This will let us evolve the -kubernetes interface.Store using existing system structures while we explore better options. - - -## sqlnext - -VERY early stub exploring alternative sql structure... really just a stub right now - diff --git a/pkg/storage/unified/apistore/history.go b/pkg/storage/unified/apistore/history.go deleted file mode 100644 index 9116645656f..00000000000 --- a/pkg/storage/unified/apistore/history.go +++ /dev/null @@ -1,103 +0,0 @@ -package apistore - -import ( - "context" - "encoding/json" - "net/http" - "strconv" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apiserver/pkg/registry/rest" - - "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" - "github.com/grafana/grafana/pkg/storage/unified/resource" -) - -type HistoryConnector interface { - rest.Storage - rest.Connecter - rest.StorageMetadata -} - -func NewHistoryConnector(search resource.ResourceIndexServer, gr schema.GroupResource) HistoryConnector { - return &historyREST{ - search: search, - gr: gr, - } -} - -type historyREST struct { - search resource.ResourceIndexServer // should be a client! - gr schema.GroupResource -} - -func (r *historyREST) New() runtime.Object { - return &metav1.PartialObjectMetadataList{} -} - -func (r *historyREST) Destroy() { -} - -func (r *historyREST) ConnectMethods() []string { - return []string{"GET"} -} - -func (r *historyREST) ProducesMIMETypes(verb string) []string { - return nil -} - -func (r *historyREST) ProducesObject(verb string) interface{} { - return &metav1.PartialObjectMetadataList{} -} - -func (r *historyREST) NewConnectOptions() (runtime.Object, bool, string) { - return nil, false, "" -} - -func (r *historyREST) Connect(ctx context.Context, uid string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { - info, err := request.NamespaceInfoFrom(ctx, true) - if err != nil { - return nil, err - } - - key := &resource.ResourceKey{ - Namespace: info.Value, - Group: r.gr.Group, - Resource: r.gr.Resource, - Name: uid, - } - - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - query := req.URL.Query() - rsp, err := r.search.History(ctx, &resource.HistoryRequest{ - NextPageToken: query.Get("token"), - Limit: 100, // TODO, from query - Key: key, - }) - if err != nil { - responder.Error(err) - return - } - - list := &metav1.PartialObjectMetadataList{ - ListMeta: metav1.ListMeta{ - Continue: rsp.NextPageToken, - }, - } - if rsp.ResourceVersion > 0 { - list.ResourceVersion = strconv.FormatInt(rsp.ResourceVersion, 10) - } - for _, v := range rsp.Items { - partial := metav1.PartialObjectMetadata{} - err = json.Unmarshal(v.PartialObjectMeta, &partial) - if err != nil { - responder.Error(err) - return - } - list.Items = append(list.Items, partial) - } - responder.Object(http.StatusOK, list) - }), nil -} diff --git a/pkg/storage/unified/apistore/storage.go b/pkg/storage/unified/apistore/storage.go index 1f158bcd655..24df3d67ca9 100644 --- a/pkg/storage/unified/apistore/storage.go +++ b/pkg/storage/unified/apistore/storage.go @@ -86,9 +86,9 @@ func getKey(val string) (*resource.ResourceKey, error) { if err != nil { return nil, err } - if k.Group == "" { - return nil, apierrors.NewInternalError(fmt.Errorf("missing group in request")) - } + // if k.Group == "" { + // return nil, apierrors.NewInternalError(fmt.Errorf("missing group in request")) + // } if k.Resource == "" { return nil, apierrors.NewInternalError(fmt.Errorf("missing resource in request")) } @@ -164,9 +164,9 @@ func (s *Storage) Delete(ctx context.Context, key string, out runtime.Object, pr return err } - if validateDeletion != nil { - return fmt.Errorf("not supported (validate deletion)") - } + // if validateDeletion != nil { + // return fmt.Errorf("not supported (validate deletion)") + // } cmd := &resource.DeleteRequest{Key: k} if preconditions != nil { diff --git a/pkg/storage/unified/basic/basic_sql_backend.go b/pkg/storage/unified/basic/basic_sql_backend.go deleted file mode 100644 index d746a0d73fe..00000000000 --- a/pkg/storage/unified/basic/basic_sql_backend.go +++ /dev/null @@ -1,227 +0,0 @@ -package basic - -import ( - "context" - "database/sql" - "fmt" - "log/slog" - "time" - - "go.opentelemetry.io/otel/trace" - "go.opentelemetry.io/otel/trace/noop" - "k8s.io/apimachinery/pkg/runtime/schema" - - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/sqlstore/session" - "github.com/grafana/grafana/pkg/storage/unified/resource" -) - -const trace_prefix = "basic.sql.resource." -const table_name = "basic_resource" - -type ResourceServerOptions struct { - DB db.DB - GroupResource schema.GroupResource - Tracer trace.Tracer - MaxItems int -} - -// This storage engine is not designed to support large collections -// The goal with this package is a production ready implementation that -// can support modest requirements. By design, this will scan all -// results on all list operations, so we do not want this to grow too big -func NewResourceServer(opts ResourceServerOptions) (resource.ResourceServer, error) { - if opts.Tracer == nil { - opts.Tracer = noop.NewTracerProvider().Tracer("resource-server") - } - - store := &basicSQLBackend{ - db: opts.DB, - gr: opts.GroupResource, - tracer: opts.Tracer, - log: slog.Default().With("logger", "basic-sql-resource"), - } - - return resource.NewResourceServer(resource.ResourceServerOptions{ - Tracer: opts.Tracer, - Backend: store, - Diagnostics: store, - Lifecycle: store, - }) -} - -type basicSQLBackend struct { - log *slog.Logger - db db.DB - gr schema.GroupResource - maxItems int - tracer trace.Tracer - - // Simple watch stream -- NOTE, this only works for single tenant! - broadcaster resource.Broadcaster[*resource.WrittenEvent] - - stream chan<- *resource.WrittenEvent -} - -func (s *basicSQLBackend) Init() (err error) { - s.broadcaster, err = resource.NewBroadcaster(context.Background(), func(c chan<- *resource.WrittenEvent) error { - s.stream = c - return nil - }) - return -} - -func (s *basicSQLBackend) IsHealthy(ctx context.Context, r *resource.HealthCheckRequest) (*resource.HealthCheckResponse, error) { - return &resource.HealthCheckResponse{Status: resource.HealthCheckResponse_SERVING}, nil -} - -func (s *basicSQLBackend) Stop() { - if s.stream != nil { - close(s.stream) - } -} - -func (s *basicSQLBackend) validateKey(key *resource.ResourceKey) error { - if s.gr.Group != "" && s.gr.Group != key.Group { - return fmt.Errorf("expected group: %s, found: %s", s.gr.Group, key.Group) - } - if s.gr.Resource != "" && s.gr.Resource != key.Resource { - return fmt.Errorf("expected resource: %s, found: %s", s.gr.Resource, key.Resource) - } - return nil -} - -func (s *basicSQLBackend) WriteEvent(ctx context.Context, event resource.WriteEvent) (rv int64, err error) { - _, span := s.tracer.Start(ctx, trace_prefix+"WriteEvent") - defer span.End() - - key := event.Key - err = s.validateKey(key) - if err != nil { - return - } - gvk := event.Object.GetGroupVersionKind() - - // This delegates resource version creation to auto-increment - // At scale, this is not a great strategy since everything is locked across all resources while this executes - appender := func(tx *session.SessionTx) (int64, error) { - return tx.ExecWithReturningId(ctx, - `INSERT INTO `+table_name+` (api_group,api_version,namespace,resource,name,value) VALUES($1,$2,$3,$4,$5,$6)`, - key.Group, gvk.Version, key.Namespace, key.Resource, key.Name, event.Value) - } - - wiper := func(tx *session.SessionTx) (sql.Result, error) { - return tx.Exec(ctx, `DELETE FROM `+table_name+` WHERE `+ - `api_group=$1 AND `+ - `namespace=$2 AND `+ - `resource=$3 AND `+ - `name=$4`, - key.Group, key.Namespace, key.Resource, key.Name) - } - - err = s.db.GetSqlxSession().WithTransaction(ctx, func(tx *session.SessionTx) error { - switch event.Type { - case resource.WatchEvent_ADDED: - count := 0 - err = tx.Get(ctx, &count, `SELECT count(*) FROM `+table_name+` WHERE api_group=$1 AND resource=$2`, key.Group, key.Resource) - if err != nil { - return err - } - if count >= s.maxItems { - return fmt.Errorf("the storage backend only supports %d items", s.maxItems) - } - rv, err = appender(tx) - - case resource.WatchEvent_MODIFIED: - _, err = wiper(tx) - if err == nil { - rv, err = appender(tx) - } - case resource.WatchEvent_DELETED: - _, err = wiper(tx) - default: - return fmt.Errorf("unsupported event type") - } - return err - }) - - // Async notify all subscribers - if s.stream != nil { - go func() { - write := &resource.WrittenEvent{ - WriteEvent: event, - Timestamp: time.Now().UnixMilli(), - ResourceVersion: rv, - } - s.stream <- write - }() - } - return -} - -func (s *basicSQLBackend) WatchWriteEvents(ctx context.Context) (<-chan *resource.WrittenEvent, error) { - return s.broadcaster.Subscribe(ctx) -} - -func (s *basicSQLBackend) Read(ctx context.Context, req *resource.ReadRequest) (*resource.ReadResponse, error) { - _, span := s.tracer.Start(ctx, trace_prefix+"Read") - defer span.End() - - key := req.Key - err := s.validateKey(key) - if err != nil { - return nil, err - } - - rows, err := s.db.GetSqlxSession().Query(ctx, "SELECT rv,value FROM "+table_name+" WHERE api_group=$1 AND namespace=$2 AND resource=$3 AND name=$4", - key.Group, key.Namespace, key.Resource, key.Name) - if err != nil { - return nil, err - } - if rows.Next() { - rsp := &resource.ReadResponse{} - err = rows.Scan(&rsp.ResourceVersion, &rsp.Value) - if err == nil && rows.Next() { - return nil, fmt.Errorf("unexpected multiple results found") // should not be possible with the index strategy - } - return rsp, err - } - return nil, fmt.Errorf("NOT FOUND ERROR") -} - -// This implementation is only ever called from inside single tenant grafana, so there is no need to decode -// the value and try filtering first -- that will happen one layer up anyway -func (s *basicSQLBackend) PrepareList(ctx context.Context, req *resource.ListRequest) (*resource.ListResponse, error) { - if req.NextPageToken != "" { - return nil, fmt.Errorf("this storage backend does not support paging") - } - _, span := s.tracer.Start(ctx, trace_prefix+"PrepareList") - defer span.End() - - key := req.Options.Key - err := s.validateKey(key) - if err != nil { - return nil, err - } - rsp := &resource.ListResponse{} - rows, err := s.db.GetSqlxSession().Query(ctx, - "SELECT rv,value FROM "+table_name+ - " WHERE api_group=$1 AND namespace=$2 AND resource=$3 "+ - " ORDER BY name asc LIMIT $4", - key.Group, key.Namespace, key.Resource, s.maxItems+1) - if err != nil { - return nil, err - } - for rows.Next() { - wrapper := &resource.ResourceWrapper{} - err = rows.Scan(&wrapper.ResourceVersion, &wrapper.Value) - if err != nil { - break - } - rsp.Items = append(rsp.Items, wrapper) - } - if len(rsp.Items) > s.maxItems { - err = fmt.Errorf("more values that are supported by this storage engine") - } - return rsp, err -} diff --git a/pkg/storage/unified/basic/migrations/migrations.go b/pkg/storage/unified/basic/migrations/migrations.go deleted file mode 100644 index 6b11d762539..00000000000 --- a/pkg/storage/unified/basic/migrations/migrations.go +++ /dev/null @@ -1,26 +0,0 @@ -package migrations - -import "github.com/grafana/grafana/pkg/services/sqlstore/migrator" - -func AddBasicResourceMigrations(mg *migrator.Migrator) { - mg.AddMigration("create unified storage basic resource table", migrator.NewAddTableMigration(migrator.Table{ - Name: "basic_resource", - Columns: []*migrator.Column{ - // Sequential resource version - {Name: "rv", Type: migrator.DB_BigInt, Nullable: false, IsPrimaryKey: true, IsAutoIncrement: true}, - - // Properties that exist in path/key (and duplicated in the json value) - {Name: "api_group", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, // avoid "group" so escaping is easier :) - {Name: "api_version", Type: migrator.DB_NVarchar, Length: 32, Nullable: false}, // informational - {Name: "namespace", Type: migrator.DB_NVarchar, Length: 63, Nullable: true}, // namespace is not required (cluster scope) - {Name: "resource", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, - {Name: "name", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, - - // The k8s resource JSON text (without the resourceVersion populated) - {Name: "value", Type: migrator.DB_MediumText, Nullable: false}, - }, - Indices: []*migrator.Index{ - {Cols: []string{"api_group", "resource", "namespace", "name"}, Type: migrator.UniqueIndex}, - }, - })) -} diff --git a/pkg/storage/unified/entitybridge/entitybridge.go b/pkg/storage/unified/entitybridge/entitybridge.go index aa33261b0cc..77ffd3f70c1 100644 --- a/pkg/storage/unified/entitybridge/entitybridge.go +++ b/pkg/storage/unified/entitybridge/entitybridge.go @@ -3,101 +3,35 @@ package entitybridge import ( "context" "fmt" - "os" - "path/filepath" - "time" - "gocloud.dev/blob/fileblob" "k8s.io/apimachinery/pkg/selection" "k8s.io/klog/v2" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" - "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/store/entity" - "github.com/grafana/grafana/pkg/services/store/entity/db/dbimpl" "github.com/grafana/grafana/pkg/services/store/entity/sqlstash" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" ) // Creates a ResourceServer using the existing entity tables -// NOTE: most of the field values are ignored -func ProvideResourceServer(db db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tracer tracing.Tracer) (resource.ResourceServer, error) { - opts := resource.ResourceServerOptions{ - Tracer: tracer, +// NOTE: the server is optional and only used to pass init+close functions +func EntityAsResourceServer(client entity.EntityStoreClient, server sqlstash.SqlEntityServer, tracer tracing.Tracer) (resource.ResourceServer, error) { + if client == nil { + return nil, fmt.Errorf("client must be defined") } - supportBlobs := true - useEntitySQL := true - - // Create a local blob filesystem blob store - if supportBlobs { - dir := filepath.Join(cfg.DataPath, "unistore", "blobs") - if err := os.MkdirAll(dir, 0o750); err != nil { - return nil, err - } - - bucket, err := fileblob.OpenBucket(dir, &fileblob.Options{ - CreateDir: true, - Metadata: fileblob.MetadataDontWrite, // skip - }) - if err != nil { - return nil, err - } - opts.Blob, err = resource.NewCDKBlobStore(context.Background(), resource.CDKBlobStoreOptions{ - Tracer: tracer, - Bucket: bucket, - URLExpiration: time.Minute * 20, - }) - if err != nil { - return nil, err - } + // Use this bridge as the resource store + bridge := &entityBridge{ + client: client, + server: server, } - - if useEntitySQL { - eDB, err := dbimpl.ProvideEntityDB(db, cfg, features, tracer) - if err != nil { - return nil, err - } - - server, err := sqlstash.ProvideSQLEntityServer(eDB, tracer) - if err != nil { - return nil, err - } - client := entity.NewEntityStoreClientLocal(server) - - // Use this bridge as the resource store - bridge := &entityBridge{ - server: server, - client: client, - } - opts.Backend = bridge - opts.Diagnostics = bridge - opts.Lifecycle = bridge - } else { - dir := filepath.Join(cfg.DataPath, "unistore", "resource") - if err := os.MkdirAll(dir, 0o750); err != nil { - return nil, err - } - - bucket, err := fileblob.OpenBucket(dir, &fileblob.Options{ - CreateDir: true, - Metadata: fileblob.MetadataDontWrite, // skip - }) - if err != nil { - return nil, err - } - opts.Backend, err = resource.NewCDKBackend(context.Background(), resource.CDKBackendOptions{ - Tracer: tracer, - Bucket: bucket, - }) - if err != nil { - return nil, err - } - } - return resource.NewResourceServer(opts) + return resource.NewResourceServer(resource.ResourceServerOptions{ + Tracer: tracer, + Backend: bridge, + Diagnostics: bridge, + Lifecycle: bridge, + }) } // This is only created if we use the entity implementation diff --git a/pkg/storage/unified/resource/cdk_backend.go b/pkg/storage/unified/resource/cdk_backend.go index fc201b13413..fd25da06ed3 100644 --- a/pkg/storage/unified/resource/cdk_backend.go +++ b/pkg/storage/unified/resource/cdk_backend.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "go.opentelemetry.io/otel/trace" @@ -25,8 +26,6 @@ type CDKBackendOptions struct { Tracer trace.Tracer Bucket *blob.Bucket RootFolder string - - NextResourceVersion NextResourceVersion } func NewCDKBackend(ctx context.Context, opts CDKBackendOptions) (StorageBackend, error) { @@ -49,25 +48,22 @@ func NewCDKBackend(ctx context.Context, opts CDKBackendOptions) (StorageBackend, return nil, fmt.Errorf("the root folder does not exist") } - // This is not safe when running in HA! - if opts.NextResourceVersion == nil { - opts.NextResourceVersion = newResourceVersionCounter(time.Now().UnixMilli()) - } - - return &cdkBackend{ + backend := &cdkBackend{ tracer: opts.Tracer, bucket: opts.Bucket, root: opts.RootFolder, - nextRV: opts.NextResourceVersion, - }, nil + } + backend.rv.Swap(time.Now().UnixMilli()) + return backend, nil } type cdkBackend struct { tracer trace.Tracer bucket *blob.Bucket root string - nextRV NextResourceVersion - mutex sync.Mutex + + mutex sync.Mutex + rv atomic.Int64 // Simple watch stream -- NOTE, this only works for single tenant! broadcaster Broadcaster[*WrittenEvent] @@ -117,7 +113,7 @@ func (s *cdkBackend) WriteEvent(ctx context.Context, event WriteEvent) (rv int64 s.mutex.Lock() defer s.mutex.Unlock() - rv = s.nextRV() + rv = s.rv.Add(1) err = s.bucket.WriteAll(ctx, s.getPath(event.Key, rv), event.Value, &blob.WriterOptions{ ContentType: "application/json", }) @@ -163,7 +159,7 @@ func (s *cdkBackend) Read(ctx context.Context, req *ReadRequest) (*ReadResponse, } raw, err := s.bucket.ReadAll(ctx, path) - if err == nil && isDeletedMarker(raw) { + if raw == nil || (err == nil && isDeletedMarker(raw)) { return nil, apierrors.NewNotFound(schema.GroupResource{ Group: req.Key.Group, Resource: req.Key.Resource, @@ -193,7 +189,9 @@ func (s *cdkBackend) PrepareList(ctx context.Context, req *ListRequest) (*ListRe return nil, err } - rsp := &ListResponse{} + rsp := &ListResponse{ + ResourceVersion: s.rv.Load(), + } for _, item := range resources { latest := item.versions[0] raw, err := s.bucket.ReadAll(ctx, latest.key) diff --git a/pkg/storage/unified/resource/cdk_blob.go b/pkg/storage/unified/resource/cdk_blob.go deleted file mode 100644 index e97682e999a..00000000000 --- a/pkg/storage/unified/resource/cdk_blob.go +++ /dev/null @@ -1,176 +0,0 @@ -package resource - -import ( - "bytes" - context "context" - "crypto/md5" - "encoding/hex" - "fmt" - "mime" - "time" - - "github.com/google/uuid" - "go.opentelemetry.io/otel/trace" - "go.opentelemetry.io/otel/trace/noop" - "gocloud.dev/blob" - _ "gocloud.dev/blob/fileblob" - _ "gocloud.dev/blob/memblob" - - "github.com/grafana/grafana/pkg/apimachinery/utils" -) - -type CDKBlobStoreOptions struct { - Tracer trace.Tracer - Bucket *blob.Bucket - RootFolder string - URLExpiration time.Duration -} - -func NewCDKBlobStore(ctx context.Context, opts CDKBlobStoreOptions) (BlobStore, error) { - if opts.Tracer == nil { - opts.Tracer = noop.NewTracerProvider().Tracer("cdk-blob-store") - } - - if opts.Bucket == nil { - return nil, fmt.Errorf("missing bucket") - } - if opts.URLExpiration < 1 { - opts.URLExpiration = time.Minute * 10 // 10 min default - } - - found, _, err := opts.Bucket.ListPage(ctx, blob.FirstPageToken, 1, &blob.ListOptions{ - Prefix: opts.RootFolder, - Delimiter: "/", - }) - if err != nil { - return nil, err - } - if found == nil { - return nil, fmt.Errorf("the root folder does not exist") - } - - return &cdkBlobStore{ - tracer: opts.Tracer, - bucket: opts.Bucket, - root: opts.RootFolder, - cansignurls: false, // TODO depends on the implementation - expiration: opts.URLExpiration, - }, nil -} - -type cdkBlobStore struct { - tracer trace.Tracer - bucket *blob.Bucket - root string - cansignurls bool - expiration time.Duration -} - -func (s *cdkBlobStore) getBlobPath(key *ResourceKey, info *utils.BlobInfo) (string, error) { - var buffer bytes.Buffer - buffer.WriteString(s.root) - - if key.Namespace == "" { - buffer.WriteString("__cluster__/") - } else { - buffer.WriteString(key.Namespace) - buffer.WriteString("/") - } - - if key.Group == "" { - return "", fmt.Errorf("missing group") - } - buffer.WriteString(key.Group) - buffer.WriteString("/") - - if key.Resource == "" { - return "", fmt.Errorf("missing resource") - } - buffer.WriteString(key.Resource) - buffer.WriteString("/") - - if key.Name == "" { - return "", fmt.Errorf("missing name") - } - buffer.WriteString(key.Name) - buffer.WriteString("/") - buffer.WriteString(info.UID) - - ext, err := mime.ExtensionsByType(info.MimeType) - if err != nil { - return "", err - } - if len(ext) > 0 { - buffer.WriteString(ext[0]) - } - return buffer.String(), nil -} - -func (s *cdkBlobStore) SupportsSignedURLs() bool { - return s.cansignurls -} - -func (s *cdkBlobStore) PutBlob(ctx context.Context, req *PutBlobRequest) (*PutBlobResponse, error) { - info := &utils.BlobInfo{ - UID: uuid.New().String(), - } - info.SetContentType(req.ContentType) - path, err := s.getBlobPath(req.Resource, info) - if err != nil { - return nil, err - } - - rsp := &PutBlobResponse{Uid: info.UID, MimeType: info.MimeType, Charset: info.Charset} - if req.Method == PutBlobRequest_HTTP { - rsp.Url, err = s.bucket.SignedURL(ctx, path, &blob.SignedURLOptions{ - Method: "PUT", - Expiry: s.expiration, - ContentType: req.ContentType, - }) - return rsp, err - } - if len(req.Value) < 1 { - return nil, fmt.Errorf("missing content value") - } - - // Write the value - err = s.bucket.WriteAll(ctx, path, req.Value, &blob.WriterOptions{ - ContentType: req.ContentType, - }) - if err != nil { - return nil, err - } - - attrs, err := s.bucket.Attributes(ctx, path) - if err != nil { - return nil, err - } - rsp.Size = attrs.Size - - // Set the MD5 hash if missing - if len(attrs.MD5) == 0 { - h := md5.New() - _, _ = h.Write(req.Value) - attrs.MD5 = h.Sum(nil) - } - rsp.Hash = hex.EncodeToString(attrs.MD5[:]) - return rsp, err -} - -func (s *cdkBlobStore) GetBlob(ctx context.Context, resource *ResourceKey, info *utils.BlobInfo, mustProxy bool) (*GetBlobResponse, error) { - path, err := s.getBlobPath(resource, info) - if err != nil { - return nil, err - } - rsp := &GetBlobResponse{ContentType: info.ContentType()} - if mustProxy || !s.cansignurls { - rsp.Value, err = s.bucket.ReadAll(ctx, path) - return rsp, err - } - rsp.Url, err = s.bucket.SignedURL(ctx, path, &blob.SignedURLOptions{ - Method: "GET", - Expiry: s.expiration, - ContentType: rsp.ContentType, - }) - return rsp, err -} diff --git a/pkg/storage/unified/resource/cdk_blob_test.go b/pkg/storage/unified/resource/cdk_blob_test.go deleted file mode 100644 index a0ce25a3ff8..00000000000 --- a/pkg/storage/unified/resource/cdk_blob_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package resource - -import ( - "context" - "fmt" - "os" - "testing" - - "github.com/stretchr/testify/require" - "gocloud.dev/blob/fileblob" - "gocloud.dev/blob/memblob" - - "github.com/grafana/grafana/pkg/apimachinery/utils" -) - -func TestCDKBlobStore(t *testing.T) { - bucket := memblob.OpenBucket(nil) - if false { - tmp, err := os.MkdirTemp("", "xxx-*") - require.NoError(t, err) - - bucket, err = fileblob.OpenBucket(tmp, &fileblob.Options{ - CreateDir: true, - Metadata: fileblob.MetadataDontWrite, // skip - }) - require.NoError(t, err) - - fmt.Printf("ROOT: %s\n\n", tmp) - } - ctx := context.Background() - - store, err := NewCDKBlobStore(ctx, CDKBlobStoreOptions{ - Bucket: bucket, - //RootFolder: "xyz", - }) - require.NoError(t, err) - - t.Run("can write then read a blob", func(t *testing.T) { - raw := testdata(t, "01_create_playlist.json") - key := &ResourceKey{ - Group: "playlist.grafana.app", - Resource: "rrrr", // can be anything - Namespace: "default", - Name: "fdgsv37qslr0ga", - } - - rsp, err := store.PutBlob(ctx, &PutBlobRequest{ - Resource: key, - Method: PutBlobRequest_GRPC, - ContentType: "application/json", - Value: raw, - }) - require.NoError(t, err) - require.Equal(t, "4933beea0c6d6dfd73150451098c70f0", rsp.Hash) - - found, err := store.GetBlob(ctx, key, &utils.BlobInfo{ - UID: rsp.Uid, - Size: rsp.Size, - Hash: rsp.Hash, - MimeType: rsp.MimeType, - Charset: rsp.Charset, - }, false) - require.NoError(t, err) - require.Equal(t, raw, found.Value) - require.Equal(t, "application/json", found.ContentType) - }) -} diff --git a/pkg/storage/unified/resource/client_wrapper.go b/pkg/storage/unified/resource/client_wrapper.go index e4ecf52d06d..7ad67e937d8 100644 --- a/pkg/storage/unified/resource/client_wrapper.go +++ b/pkg/storage/unified/resource/client_wrapper.go @@ -25,22 +25,6 @@ func NewLocalResourceStoreClient(server ResourceStoreServer) ResourceStoreClient return NewResourceStoreClient(grpchan.InterceptClientConn(channel, grpcUtils.UnaryClientInterceptor, grpcUtils.StreamClientInterceptor)) } -func NewLocalResourceSearchClient(server ResourceStoreServer) ResourceIndexClient { - channel := &inprocgrpc.Channel{} - - auth := &grpcUtils.Authenticator{} - - channel.RegisterService( - grpchan.InterceptServer( - &ResourceStore_ServiceDesc, - grpcAuth.UnaryServerInterceptor(auth.Authenticate), - grpcAuth.StreamServerInterceptor(auth.Authenticate), - ), - server, - ) - return NewResourceIndexClient(grpchan.InterceptClientConn(channel, grpcUtils.UnaryClientInterceptor, grpcUtils.StreamClientInterceptor)) -} - func NewResourceStoreClientGRPC(channel *grpc.ClientConn) ResourceStoreClient { return NewResourceStoreClient(grpchan.InterceptClientConn(channel, grpcUtils.UnaryClientInterceptor, grpcUtils.StreamClientInterceptor)) } diff --git a/pkg/storage/unified/resource/go.sum b/pkg/storage/unified/resource/go.sum index d1d127ec502..1ffe513565f 100644 --- a/pkg/storage/unified/resource/go.sum +++ b/pkg/storage/unified/resource/go.sum @@ -80,8 +80,9 @@ google.golang.org/api v0.176.0 h1:dHj1/yv5Dm/eQTXiP9hNCRT3xzJHWXeNdRq29XbMxoE= google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUEr4jDysRDLrm4PHePlge4v4TGAlxY= google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117 h1:+rdxYoE3E5htTEWIe15GlN6IfvbURM//Jt0mmkmm6ZU= google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA= google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/pkg/storage/unified/resource/noop.go b/pkg/storage/unified/resource/noop.go index b24c4ef0259..92778817ba1 100644 --- a/pkg/storage/unified/resource/noop.go +++ b/pkg/storage/unified/resource/noop.go @@ -2,14 +2,11 @@ package resource import ( "context" - - "github.com/grafana/grafana/pkg/apimachinery/utils" ) var ( - _ ResourceIndexServer = &noopService{} - _ DiagnosticsServer = &noopService{} - _ LifecycleHooks = &noopService{} + _ DiagnosticsServer = &noopService{} + _ LifecycleHooks = &noopService{} ) // noopService is a helper implementation to simplify tests @@ -42,25 +39,3 @@ func (n *noopService) Read(context.Context, *ReadRequest) (*ReadResponse, error) func (n *noopService) List(context.Context, *ListRequest) (*ListResponse, error) { return nil, ErrNotImplementedYet } - -// History implements ResourceServer. -func (n *noopService) History(context.Context, *HistoryRequest) (*HistoryResponse, error) { - return nil, ErrNotImplementedYet -} - -// Origin implements ResourceServer. -func (n *noopService) Origin(context.Context, *OriginRequest) (*OriginResponse, error) { - return nil, ErrNotImplementedYet -} - -func (n *noopService) SupportsSignedURLs() bool { - return false -} - -func (n *noopService) PutBlob(context.Context, *PutBlobRequest) (*PutBlobResponse, error) { - return nil, ErrNotImplementedYet -} - -func (n *noopService) GetBlob(ctx context.Context, resource *ResourceKey, info *utils.BlobInfo, mustProxy bool) (*GetBlobResponse, error) { - return nil, ErrNotImplementedYet -} diff --git a/pkg/storage/unified/resource/resource.pb.go b/pkg/storage/unified/resource/resource.pb.go index f4fa4297747..4bd90a20330 100644 --- a/pkg/storage/unified/resource/resource.pb.go +++ b/pkg/storage/unified/resource/resource.pb.go @@ -66,52 +66,6 @@ func (ResourceVersionMatch) EnumDescriptor() ([]byte, []int) { return file_resource_proto_rawDescGZIP(), []int{0} } -type Sort_Order int32 - -const ( - Sort_ASC Sort_Order = 0 - Sort_DESC Sort_Order = 1 -) - -// Enum value maps for Sort_Order. -var ( - Sort_Order_name = map[int32]string{ - 0: "ASC", - 1: "DESC", - } - Sort_Order_value = map[string]int32{ - "ASC": 0, - "DESC": 1, - } -) - -func (x Sort_Order) Enum() *Sort_Order { - p := new(Sort_Order) - *p = x - return p -} - -func (x Sort_Order) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (Sort_Order) Descriptor() protoreflect.EnumDescriptor { - return file_resource_proto_enumTypes[1].Descriptor() -} - -func (Sort_Order) Type() protoreflect.EnumType { - return &file_resource_proto_enumTypes[1] -} - -func (x Sort_Order) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use Sort_Order.Descriptor instead. -func (Sort_Order) EnumDescriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{13, 0} -} - type WatchEvent_Type int32 const ( @@ -154,11 +108,11 @@ func (x WatchEvent_Type) String() string { } func (WatchEvent_Type) Descriptor() protoreflect.EnumDescriptor { - return file_resource_proto_enumTypes[2].Descriptor() + return file_resource_proto_enumTypes[1].Descriptor() } func (WatchEvent_Type) Type() protoreflect.EnumType { - return &file_resource_proto_enumTypes[2] + return &file_resource_proto_enumTypes[1] } func (x WatchEvent_Type) Number() protoreflect.EnumNumber { @@ -167,7 +121,7 @@ func (x WatchEvent_Type) Number() protoreflect.EnumNumber { // Deprecated: Use WatchEvent_Type.Descriptor instead. func (WatchEvent_Type) EnumDescriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{18, 0} + return file_resource_proto_rawDescGZIP(), []int{17, 0} } type HealthCheckResponse_ServingStatus int32 @@ -206,11 +160,11 @@ func (x HealthCheckResponse_ServingStatus) String() string { } func (HealthCheckResponse_ServingStatus) Descriptor() protoreflect.EnumDescriptor { - return file_resource_proto_enumTypes[3].Descriptor() + return file_resource_proto_enumTypes[2].Descriptor() } func (HealthCheckResponse_ServingStatus) Type() protoreflect.EnumType { - return &file_resource_proto_enumTypes[3] + return &file_resource_proto_enumTypes[2] } func (x HealthCheckResponse_ServingStatus) Number() protoreflect.EnumNumber { @@ -219,55 +173,7 @@ func (x HealthCheckResponse_ServingStatus) Number() protoreflect.EnumNumber { // Deprecated: Use HealthCheckResponse_ServingStatus.Descriptor instead. func (HealthCheckResponse_ServingStatus) EnumDescriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{25, 0} -} - -type PutBlobRequest_Method int32 - -const ( - // Use the inline raw []byte - PutBlobRequest_GRPC PutBlobRequest_Method = 0 - // Get a signed URL and PUT the value - PutBlobRequest_HTTP PutBlobRequest_Method = 1 -) - -// Enum value maps for PutBlobRequest_Method. -var ( - PutBlobRequest_Method_name = map[int32]string{ - 0: "GRPC", - 1: "HTTP", - } - PutBlobRequest_Method_value = map[string]int32{ - "GRPC": 0, - "HTTP": 1, - } -) - -func (x PutBlobRequest_Method) Enum() *PutBlobRequest_Method { - p := new(PutBlobRequest_Method) - *p = x - return p -} - -func (x PutBlobRequest_Method) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (PutBlobRequest_Method) Descriptor() protoreflect.EnumDescriptor { - return file_resource_proto_enumTypes[4].Descriptor() -} - -func (PutBlobRequest_Method) Type() protoreflect.EnumType { - return &file_resource_proto_enumTypes[4] -} - -func (x PutBlobRequest_Method) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use PutBlobRequest_Method.Descriptor instead. -func (PutBlobRequest_Method) EnumDescriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{26, 0} + return file_resource_proto_rawDescGZIP(), []int{19, 0} } type ResourceKey struct { @@ -895,10 +801,8 @@ type DeleteResponse struct { // Status code Status *StatusResult `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` - // The new resource version + // The resource version for the deletion marker ResourceVersion int64 `protobuf:"varint,2,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"` - // The deleted payload - Value []byte `protobuf:"bytes,3,opt,name=value,proto3" json:"value,omitempty"` } func (x *DeleteResponse) Reset() { @@ -947,13 +851,6 @@ func (x *DeleteResponse) GetResourceVersion() int64 { return 0 } -func (x *DeleteResponse) GetValue() []byte { - if x != nil { - return x.Value - } - return nil -} - type ReadRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1141,61 +1038,6 @@ func (x *Requirement) GetValues() []string { return nil } -type Sort struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Field string `protobuf:"bytes,1,opt,name=field,proto3" json:"field,omitempty"` - Order Sort_Order `protobuf:"varint,2,opt,name=order,proto3,enum=resource.Sort_Order" json:"order,omitempty"` -} - -func (x *Sort) Reset() { - *x = Sort{} - if protoimpl.UnsafeEnabled { - mi := &file_resource_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *Sort) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Sort) ProtoMessage() {} - -func (x *Sort) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[13] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Sort.ProtoReflect.Descriptor instead. -func (*Sort) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{13} -} - -func (x *Sort) GetField() string { - if x != nil { - return x.Field - } - return "" -} - -func (x *Sort) GetOrder() Sort_Order { - if x != nil { - return x.Order - } - return Sort_ASC -} - type ListOptions struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1216,7 +1058,7 @@ type ListOptions struct { func (x *ListOptions) Reset() { *x = ListOptions{} if protoimpl.UnsafeEnabled { - mi := &file_resource_proto_msgTypes[14] + mi := &file_resource_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1229,7 +1071,7 @@ func (x *ListOptions) String() string { func (*ListOptions) ProtoMessage() {} func (x *ListOptions) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[14] + mi := &file_resource_proto_msgTypes[13] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1242,7 +1084,7 @@ func (x *ListOptions) ProtoReflect() protoreflect.Message { // Deprecated: Use ListOptions.ProtoReflect.Descriptor instead. func (*ListOptions) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{14} + return file_resource_proto_rawDescGZIP(), []int{13} } func (x *ListOptions) GetKey() *ResourceKey { @@ -1287,7 +1129,7 @@ type ListRequest struct { func (x *ListRequest) Reset() { *x = ListRequest{} if protoimpl.UnsafeEnabled { - mi := &file_resource_proto_msgTypes[15] + mi := &file_resource_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1300,7 +1142,7 @@ func (x *ListRequest) String() string { func (*ListRequest) ProtoMessage() {} func (x *ListRequest) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[15] + mi := &file_resource_proto_msgTypes[14] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1313,7 +1155,7 @@ func (x *ListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRequest.ProtoReflect.Descriptor instead. func (*ListRequest) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{15} + return file_resource_proto_rawDescGZIP(), []int{14} } func (x *ListRequest) GetNextPageToken() string { @@ -1377,7 +1219,7 @@ type ListResponse struct { func (x *ListResponse) Reset() { *x = ListResponse{} if protoimpl.UnsafeEnabled { - mi := &file_resource_proto_msgTypes[16] + mi := &file_resource_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1390,7 +1232,7 @@ func (x *ListResponse) String() string { func (*ListResponse) ProtoMessage() {} func (x *ListResponse) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[16] + mi := &file_resource_proto_msgTypes[15] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1403,7 +1245,7 @@ func (x *ListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListResponse.ProtoReflect.Descriptor instead. func (*ListResponse) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{16} + return file_resource_proto_rawDescGZIP(), []int{15} } func (x *ListResponse) GetItems() []*ResourceWrapper { @@ -1452,7 +1294,7 @@ type WatchRequest struct { func (x *WatchRequest) Reset() { *x = WatchRequest{} if protoimpl.UnsafeEnabled { - mi := &file_resource_proto_msgTypes[17] + mi := &file_resource_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1465,7 +1307,7 @@ func (x *WatchRequest) String() string { func (*WatchRequest) ProtoMessage() {} func (x *WatchRequest) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[17] + mi := &file_resource_proto_msgTypes[16] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1478,7 +1320,7 @@ func (x *WatchRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchRequest.ProtoReflect.Descriptor instead. func (*WatchRequest) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{17} + return file_resource_proto_rawDescGZIP(), []int{16} } func (x *WatchRequest) GetSince() int64 { @@ -1527,7 +1369,7 @@ type WatchEvent struct { func (x *WatchEvent) Reset() { *x = WatchEvent{} if protoimpl.UnsafeEnabled { - mi := &file_resource_proto_msgTypes[18] + mi := &file_resource_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1540,7 +1382,7 @@ func (x *WatchEvent) String() string { func (*WatchEvent) ProtoMessage() {} func (x *WatchEvent) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[18] + mi := &file_resource_proto_msgTypes[17] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1553,7 +1395,7 @@ func (x *WatchEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchEvent.ProtoReflect.Descriptor instead. func (*WatchEvent) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{18} + return file_resource_proto_rawDescGZIP(), []int{17} } func (x *WatchEvent) GetTimestamp() int64 { @@ -1584,388 +1426,6 @@ func (x *WatchEvent) GetPrevious() *WatchEvent_Resource { return nil } -type HistoryRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Starting from the requested page (other query parameters must match!) - NextPageToken string `protobuf:"bytes,1,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` - // Maximum number of items to return - Limit int64 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` - // Resource identifier - Key *ResourceKey `protobuf:"bytes,3,opt,name=key,proto3" json:"key,omitempty"` - // List the deleted values (eg, show trash) - ShowDeleted bool `protobuf:"varint,4,opt,name=show_deleted,json=showDeleted,proto3" json:"show_deleted,omitempty"` -} - -func (x *HistoryRequest) Reset() { - *x = HistoryRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_resource_proto_msgTypes[19] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *HistoryRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*HistoryRequest) ProtoMessage() {} - -func (x *HistoryRequest) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[19] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use HistoryRequest.ProtoReflect.Descriptor instead. -func (*HistoryRequest) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{19} -} - -func (x *HistoryRequest) GetNextPageToken() string { - if x != nil { - return x.NextPageToken - } - return "" -} - -func (x *HistoryRequest) GetLimit() int64 { - if x != nil { - return x.Limit - } - return 0 -} - -func (x *HistoryRequest) GetKey() *ResourceKey { - if x != nil { - return x.Key - } - return nil -} - -func (x *HistoryRequest) GetShowDeleted() bool { - if x != nil { - return x.ShowDeleted - } - return false -} - -type HistoryResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Items []*ResourceMeta `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"` - // More results exist... pass this in the next request - NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` - // ResourceVersion of the list response - ResourceVersion int64 `protobuf:"varint,3,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"` -} - -func (x *HistoryResponse) Reset() { - *x = HistoryResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_resource_proto_msgTypes[20] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *HistoryResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*HistoryResponse) ProtoMessage() {} - -func (x *HistoryResponse) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[20] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use HistoryResponse.ProtoReflect.Descriptor instead. -func (*HistoryResponse) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{20} -} - -func (x *HistoryResponse) GetItems() []*ResourceMeta { - if x != nil { - return x.Items - } - return nil -} - -func (x *HistoryResponse) GetNextPageToken() string { - if x != nil { - return x.NextPageToken - } - return "" -} - -func (x *HistoryResponse) GetResourceVersion() int64 { - if x != nil { - return x.ResourceVersion - } - return 0 -} - -type OriginRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Starting from the requested page (other query parameters must match!) - NextPageToken string `protobuf:"bytes,1,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` - // Maximum number of items to return - Limit int64 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` - // Resource identifier - Key *ResourceKey `protobuf:"bytes,3,opt,name=key,proto3" json:"key,omitempty"` - // List the deleted values (eg, show trash) - Origin string `protobuf:"bytes,4,opt,name=origin,proto3" json:"origin,omitempty"` -} - -func (x *OriginRequest) Reset() { - *x = OriginRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_resource_proto_msgTypes[21] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *OriginRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*OriginRequest) ProtoMessage() {} - -func (x *OriginRequest) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[21] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use OriginRequest.ProtoReflect.Descriptor instead. -func (*OriginRequest) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{21} -} - -func (x *OriginRequest) GetNextPageToken() string { - if x != nil { - return x.NextPageToken - } - return "" -} - -func (x *OriginRequest) GetLimit() int64 { - if x != nil { - return x.Limit - } - return 0 -} - -func (x *OriginRequest) GetKey() *ResourceKey { - if x != nil { - return x.Key - } - return nil -} - -func (x *OriginRequest) GetOrigin() string { - if x != nil { - return x.Origin - } - return "" -} - -type ResourceOriginInfo struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // The resource - Key *ResourceKey `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` - // Size of the full resource body - ResourceSize int32 `protobuf:"varint,2,opt,name=resource_size,json=resourceSize,proto3" json:"resource_size,omitempty"` - // Hash for the resource - ResourceHash string `protobuf:"bytes,3,opt,name=resource_hash,json=resourceHash,proto3" json:"resource_hash,omitempty"` - // The origin name - Origin string `protobuf:"bytes,4,opt,name=origin,proto3" json:"origin,omitempty"` - // Path on the origin - Path string `protobuf:"bytes,5,opt,name=path,proto3" json:"path,omitempty"` - // Verification hash from the origin - Hash string `protobuf:"bytes,6,opt,name=hash,proto3" json:"hash,omitempty"` - // Change time from the origin - Timestamp int64 `protobuf:"varint,7,opt,name=timestamp,proto3" json:"timestamp,omitempty"` -} - -func (x *ResourceOriginInfo) Reset() { - *x = ResourceOriginInfo{} - if protoimpl.UnsafeEnabled { - mi := &file_resource_proto_msgTypes[22] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ResourceOriginInfo) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ResourceOriginInfo) ProtoMessage() {} - -func (x *ResourceOriginInfo) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[22] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ResourceOriginInfo.ProtoReflect.Descriptor instead. -func (*ResourceOriginInfo) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{22} -} - -func (x *ResourceOriginInfo) GetKey() *ResourceKey { - if x != nil { - return x.Key - } - return nil -} - -func (x *ResourceOriginInfo) GetResourceSize() int32 { - if x != nil { - return x.ResourceSize - } - return 0 -} - -func (x *ResourceOriginInfo) GetResourceHash() string { - if x != nil { - return x.ResourceHash - } - return "" -} - -func (x *ResourceOriginInfo) GetOrigin() string { - if x != nil { - return x.Origin - } - return "" -} - -func (x *ResourceOriginInfo) GetPath() string { - if x != nil { - return x.Path - } - return "" -} - -func (x *ResourceOriginInfo) GetHash() string { - if x != nil { - return x.Hash - } - return "" -} - -func (x *ResourceOriginInfo) GetTimestamp() int64 { - if x != nil { - return x.Timestamp - } - return 0 -} - -type OriginResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Items []*ResourceOriginInfo `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"` - // More results exist... pass this in the next request - NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` - // ResourceVersion of the list response - ResourceVersion int64 `protobuf:"varint,3,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"` -} - -func (x *OriginResponse) Reset() { - *x = OriginResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_resource_proto_msgTypes[23] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *OriginResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*OriginResponse) ProtoMessage() {} - -func (x *OriginResponse) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[23] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use OriginResponse.ProtoReflect.Descriptor instead. -func (*OriginResponse) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{23} -} - -func (x *OriginResponse) GetItems() []*ResourceOriginInfo { - if x != nil { - return x.Items - } - return nil -} - -func (x *OriginResponse) GetNextPageToken() string { - if x != nil { - return x.NextPageToken - } - return "" -} - -func (x *OriginResponse) GetResourceVersion() int64 { - if x != nil { - return x.ResourceVersion - } - return 0 -} - type HealthCheckRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1977,7 +1437,7 @@ type HealthCheckRequest struct { func (x *HealthCheckRequest) Reset() { *x = HealthCheckRequest{} if protoimpl.UnsafeEnabled { - mi := &file_resource_proto_msgTypes[24] + mi := &file_resource_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1990,7 +1450,7 @@ func (x *HealthCheckRequest) String() string { func (*HealthCheckRequest) ProtoMessage() {} func (x *HealthCheckRequest) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[24] + mi := &file_resource_proto_msgTypes[18] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2003,7 +1463,7 @@ func (x *HealthCheckRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HealthCheckRequest.ProtoReflect.Descriptor instead. func (*HealthCheckRequest) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{24} + return file_resource_proto_rawDescGZIP(), []int{18} } func (x *HealthCheckRequest) GetService() string { @@ -2024,7 +1484,7 @@ type HealthCheckResponse struct { func (x *HealthCheckResponse) Reset() { *x = HealthCheckResponse{} if protoimpl.UnsafeEnabled { - mi := &file_resource_proto_msgTypes[25] + mi := &file_resource_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2037,7 +1497,7 @@ func (x *HealthCheckResponse) String() string { func (*HealthCheckResponse) ProtoMessage() {} func (x *HealthCheckResponse) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[25] + mi := &file_resource_proto_msgTypes[19] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2050,7 +1510,7 @@ func (x *HealthCheckResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HealthCheckResponse.ProtoReflect.Descriptor instead. func (*HealthCheckResponse) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{25} + return file_resource_proto_rawDescGZIP(), []int{19} } func (x *HealthCheckResponse) GetStatus() HealthCheckResponse_ServingStatus { @@ -2060,327 +1520,6 @@ func (x *HealthCheckResponse) GetStatus() HealthCheckResponse_ServingStatus { return HealthCheckResponse_UNKNOWN } -type PutBlobRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // The resource that will use this blob - // NOTE: the name may not yet exist, but group+resource are required - Resource *ResourceKey `protobuf:"bytes,1,opt,name=resource,proto3" json:"resource,omitempty"` - // How to upload - Method PutBlobRequest_Method `protobuf:"varint,2,opt,name=method,proto3,enum=resource.PutBlobRequest_Method" json:"method,omitempty"` - // Content type header - ContentType string `protobuf:"bytes,3,opt,name=content_type,json=contentType,proto3" json:"content_type,omitempty"` - // Raw value to write - // Not valid when method == HTTP - Value []byte `protobuf:"bytes,4,opt,name=value,proto3" json:"value,omitempty"` -} - -func (x *PutBlobRequest) Reset() { - *x = PutBlobRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_resource_proto_msgTypes[26] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *PutBlobRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PutBlobRequest) ProtoMessage() {} - -func (x *PutBlobRequest) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[26] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use PutBlobRequest.ProtoReflect.Descriptor instead. -func (*PutBlobRequest) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{26} -} - -func (x *PutBlobRequest) GetResource() *ResourceKey { - if x != nil { - return x.Resource - } - return nil -} - -func (x *PutBlobRequest) GetMethod() PutBlobRequest_Method { - if x != nil { - return x.Method - } - return PutBlobRequest_GRPC -} - -func (x *PutBlobRequest) GetContentType() string { - if x != nil { - return x.ContentType - } - return "" -} - -func (x *PutBlobRequest) GetValue() []byte { - if x != nil { - return x.Value - } - return nil -} - -type PutBlobResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Status code - Status *StatusResult `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` - // The blob uid. This must be saved into the resource to support access - Uid string `protobuf:"bytes,2,opt,name=uid,proto3" json:"uid,omitempty"` - // The URL where this value can be PUT - Url string `protobuf:"bytes,3,opt,name=url,proto3" json:"url,omitempty"` - // Size of the uploaded blob - Size int64 `protobuf:"varint,4,opt,name=size,proto3" json:"size,omitempty"` - // Content hash used for an etag - Hash string `protobuf:"bytes,5,opt,name=hash,proto3" json:"hash,omitempty"` - // Validated mimetype (from content_type) - MimeType string `protobuf:"bytes,6,opt,name=mime_type,json=mimeType,proto3" json:"mime_type,omitempty"` - // Validated charset (from content_type) - Charset string `protobuf:"bytes,7,opt,name=charset,proto3" json:"charset,omitempty"` -} - -func (x *PutBlobResponse) Reset() { - *x = PutBlobResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_resource_proto_msgTypes[27] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *PutBlobResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PutBlobResponse) ProtoMessage() {} - -func (x *PutBlobResponse) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[27] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use PutBlobResponse.ProtoReflect.Descriptor instead. -func (*PutBlobResponse) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{27} -} - -func (x *PutBlobResponse) GetStatus() *StatusResult { - if x != nil { - return x.Status - } - return nil -} - -func (x *PutBlobResponse) GetUid() string { - if x != nil { - return x.Uid - } - return "" -} - -func (x *PutBlobResponse) GetUrl() string { - if x != nil { - return x.Url - } - return "" -} - -func (x *PutBlobResponse) GetSize() int64 { - if x != nil { - return x.Size - } - return 0 -} - -func (x *PutBlobResponse) GetHash() string { - if x != nil { - return x.Hash - } - return "" -} - -func (x *PutBlobResponse) GetMimeType() string { - if x != nil { - return x.MimeType - } - return "" -} - -func (x *PutBlobResponse) GetCharset() string { - if x != nil { - return x.Charset - } - return "" -} - -type GetBlobRequest struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Resource *ResourceKey `protobuf:"bytes,1,opt,name=resource,proto3" json:"resource,omitempty"` - // The new resource version - ResourceVersion int64 `protobuf:"varint,2,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"` - // Do not return a pre-signed URL (when possible) - MustProxyBytes bool `protobuf:"varint,3,opt,name=must_proxy_bytes,json=mustProxyBytes,proto3" json:"must_proxy_bytes,omitempty"` -} - -func (x *GetBlobRequest) Reset() { - *x = GetBlobRequest{} - if protoimpl.UnsafeEnabled { - mi := &file_resource_proto_msgTypes[28] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *GetBlobRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetBlobRequest) ProtoMessage() {} - -func (x *GetBlobRequest) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[28] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetBlobRequest.ProtoReflect.Descriptor instead. -func (*GetBlobRequest) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{28} -} - -func (x *GetBlobRequest) GetResource() *ResourceKey { - if x != nil { - return x.Resource - } - return nil -} - -func (x *GetBlobRequest) GetResourceVersion() int64 { - if x != nil { - return x.ResourceVersion - } - return 0 -} - -func (x *GetBlobRequest) GetMustProxyBytes() bool { - if x != nil { - return x.MustProxyBytes - } - return false -} - -type GetBlobResponse struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // Status code sent on errors - Status *StatusResult `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` - // (optional) When possible, the system will return a presigned URL - // that can be used to actually read the full blob+metadata - // When this is set, neither info nor value will be set - Url string `protobuf:"bytes,2,opt,name=url,proto3" json:"url,omitempty"` - // Content type - ContentType string `protobuf:"bytes,3,opt,name=content_type,json=contentType,proto3" json:"content_type,omitempty"` - // The raw object value - Value []byte `protobuf:"bytes,4,opt,name=value,proto3" json:"value,omitempty"` -} - -func (x *GetBlobResponse) Reset() { - *x = GetBlobResponse{} - if protoimpl.UnsafeEnabled { - mi := &file_resource_proto_msgTypes[29] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *GetBlobResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetBlobResponse) ProtoMessage() {} - -func (x *GetBlobResponse) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[29] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetBlobResponse.ProtoReflect.Descriptor instead. -func (*GetBlobResponse) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{29} -} - -func (x *GetBlobResponse) GetStatus() *StatusResult { - if x != nil { - return x.Status - } - return nil -} - -func (x *GetBlobResponse) GetUrl() string { - if x != nil { - return x.Url - } - return "" -} - -func (x *GetBlobResponse) GetContentType() string { - if x != nil { - return x.ContentType - } - return "" -} - -func (x *GetBlobResponse) GetValue() []byte { - if x != nil { - return x.Value - } - return nil -} - type WatchEvent_Resource struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -2393,7 +1532,7 @@ type WatchEvent_Resource struct { func (x *WatchEvent_Resource) Reset() { *x = WatchEvent_Resource{} if protoimpl.UnsafeEnabled { - mi := &file_resource_proto_msgTypes[30] + mi := &file_resource_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2406,7 +1545,7 @@ func (x *WatchEvent_Resource) String() string { func (*WatchEvent_Resource) ProtoMessage() {} func (x *WatchEvent_Resource) ProtoReflect() protoreflect.Message { - mi := &file_resource_proto_msgTypes[30] + mi := &file_resource_proto_msgTypes[20] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2419,7 +1558,7 @@ func (x *WatchEvent_Resource) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchEvent_Resource.ProtoReflect.Descriptor instead. func (*WatchEvent_Resource) Descriptor() ([]byte, []int) { - return file_resource_proto_rawDescGZIP(), []int{18, 0} + return file_resource_proto_rawDescGZIP(), []int{17, 0} } func (x *WatchEvent_Resource) GetVersion() int64 { @@ -2505,280 +1644,154 @@ var file_resource_proto_rawDesc = []byte{ 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x10, 0x0a, 0x03, 0x75, - 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x69, 0x64, 0x22, 0x81, 0x01, - 0x0a, 0x0e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x2e, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, - 0x75, 0x73, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, - 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x76, - 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x22, 0x61, 0x0a, 0x0b, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, - 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x7f, 0x0a, 0x0c, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x06, 0x73, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, - 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x53, 0x0a, 0x0b, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, - 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, - 0x6f, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, - 0x28, 0x09, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x22, 0x64, 0x0a, 0x04, 0x53, 0x6f, - 0x72, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12, 0x2a, 0x0a, 0x05, 0x6f, 0x72, 0x64, 0x65, - 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x14, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x2e, 0x53, 0x6f, 0x72, 0x74, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x05, 0x6f, - 0x72, 0x64, 0x65, 0x72, 0x22, 0x1a, 0x0a, 0x05, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x07, 0x0a, - 0x03, 0x41, 0x53, 0x43, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x45, 0x53, 0x43, 0x10, 0x01, - 0x22, 0x94, 0x01, 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, - 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2d, 0x0a, 0x06, 0x6c, 0x61, 0x62, - 0x65, 0x6c, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x2d, 0x0a, 0x06, 0x66, 0x69, 0x65, 0x6c, - 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x52, - 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x22, 0xec, 0x01, 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, - 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, + 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x69, 0x64, 0x22, 0x6b, 0x0a, + 0x0e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x2e, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x43, 0x0a, 0x0d, 0x76, 0x65, - 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x0e, 0x32, 0x1e, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x61, 0x74, 0x63, - 0x68, 0x52, 0x0c, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x12, - 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, - 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x2f, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, - 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x07, 0x6f, - 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0xc4, 0x01, 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, - 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x57, 0x72, 0x61, 0x70, 0x70, 0x65, - 0x72, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, - 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, - 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, - 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x30, 0x0a, 0x14, 0x72, - 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x63, 0x6f, - 0x75, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, 0x72, 0x65, 0x6d, 0x61, 0x69, - 0x6e, 0x69, 0x6e, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0xb9, 0x01, - 0x0a, 0x0c, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, - 0x0a, 0x05, 0x73, 0x69, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x73, - 0x69, 0x6e, 0x63, 0x65, 0x12, 0x2f, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x07, 0x6f, 0x70, - 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x2e, 0x0a, 0x13, 0x73, 0x65, 0x6e, 0x64, 0x5f, 0x69, 0x6e, - 0x69, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x11, 0x73, 0x65, 0x6e, 0x64, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x45, - 0x76, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x32, 0x0a, 0x15, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x5f, 0x77, - 0x61, 0x74, 0x63, 0x68, 0x5f, 0x62, 0x6f, 0x6f, 0x6b, 0x6d, 0x61, 0x72, 0x6b, 0x73, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x57, 0x61, 0x74, 0x63, 0x68, - 0x42, 0x6f, 0x6f, 0x6b, 0x6d, 0x61, 0x72, 0x6b, 0x73, 0x22, 0xdf, 0x02, 0x0a, 0x0a, 0x57, 0x61, - 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, - 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x2d, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, - 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x39, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x12, 0x39, 0x0a, 0x08, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, - 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x52, 0x08, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x1a, 0x3a, 0x0a, 0x08, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, - 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, - 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x52, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, - 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, - 0x41, 0x44, 0x44, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x4d, 0x4f, 0x44, 0x49, 0x46, - 0x49, 0x45, 0x44, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, - 0x10, 0x03, 0x12, 0x0c, 0x0a, 0x08, 0x42, 0x4f, 0x4f, 0x4b, 0x4d, 0x41, 0x52, 0x4b, 0x10, 0x04, - 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x22, 0x9a, 0x01, 0x0a, 0x0e, - 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, - 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, - 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, - 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x27, 0x0a, 0x03, - 0x6b, 0x65, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, - 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x68, 0x6f, 0x77, 0x5f, 0x64, 0x65, - 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x73, 0x68, 0x6f, - 0x77, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x22, 0x92, 0x01, 0x0a, 0x0f, 0x48, 0x69, 0x73, - 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x05, - 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x72, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4d, - 0x65, 0x74, 0x61, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, - 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, + 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x61, 0x0a, 0x0b, 0x52, 0x65, + 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, + 0x65, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x7f, 0x0a, + 0x0c, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a, + 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, + 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x29, 0x0a, + 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x53, + 0x0a, 0x0b, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x10, 0x0a, + 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, + 0x1a, 0x0a, 0x08, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x08, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x73, 0x22, 0x94, 0x01, 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, + 0x6f, 0x6e, 0x73, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2d, 0x0a, 0x06, + 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x2d, 0x0a, 0x06, 0x66, + 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x52, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x22, 0xec, 0x01, 0x0a, 0x0b, 0x4c, + 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, + 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x8e, 0x01, - 0x0a, 0x0d, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, - 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, - 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x27, 0x0a, - 0x03, 0x6b, 0x65, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, - 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x22, 0xe5, - 0x01, 0x0a, 0x12, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4f, 0x72, 0x69, 0x67, 0x69, - 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x23, - 0x0a, 0x0d, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, - 0x69, 0x7a, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, - 0x68, 0x61, 0x73, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x48, 0x61, 0x73, 0x68, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x72, 0x69, 0x67, - 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, - 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x70, 0x61, 0x74, 0x68, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x06, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x04, 0x68, 0x61, 0x73, 0x68, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, - 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x22, 0x97, 0x01, 0x0a, 0x0e, 0x4f, 0x72, 0x69, 0x67, 0x69, - 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x05, 0x69, 0x74, 0x65, - 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4f, 0x72, 0x69, 0x67, - 0x69, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x26, 0x0a, - 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, - 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, - 0x22, 0x2e, 0x0a, 0x12, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x22, 0xab, 0x01, 0x0a, 0x13, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, - 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2b, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x53, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x4f, 0x0a, - 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0b, - 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x53, - 0x45, 0x52, 0x56, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x0f, 0x0a, 0x0b, 0x4e, 0x4f, 0x54, 0x5f, - 0x53, 0x45, 0x52, 0x56, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x13, 0x0a, 0x0f, 0x53, 0x45, 0x52, - 0x56, 0x49, 0x43, 0x45, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x03, 0x22, 0xd3, - 0x01, 0x0a, 0x0e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x31, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x12, 0x37, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, - 0x65, 0x74, 0x68, 0x6f, 0x64, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x21, 0x0a, - 0x0c, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, - 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, - 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x1c, 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, - 0x12, 0x08, 0x0a, 0x04, 0x47, 0x52, 0x50, 0x43, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x54, - 0x54, 0x50, 0x10, 0x01, 0x22, 0xc4, 0x01, 0x0a, 0x0f, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, - 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, - 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x69, 0x64, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, - 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x12, 0x0a, 0x04, - 0x73, 0x69, 0x7a, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, - 0x12, 0x12, 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x68, 0x61, 0x73, 0x68, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x69, 0x6d, 0x65, 0x5f, 0x74, 0x79, 0x70, - 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x54, 0x79, 0x70, - 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x68, 0x61, 0x72, 0x73, 0x65, 0x74, 0x18, 0x07, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x07, 0x63, 0x68, 0x61, 0x72, 0x73, 0x65, 0x74, 0x22, 0x98, 0x01, 0x0a, 0x0e, - 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, - 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, - 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x28, 0x0a, 0x10, - 0x6d, 0x75, 0x73, 0x74, 0x5f, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x6d, 0x75, 0x73, 0x74, 0x50, 0x72, 0x6f, 0x78, - 0x79, 0x42, 0x79, 0x74, 0x65, 0x73, 0x22, 0x8c, 0x01, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x42, 0x6c, - 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x06, 0x73, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x75, - 0x6c, 0x74, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, - 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x21, 0x0a, 0x0c, - 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, - 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x2a, 0x33, 0x0a, 0x14, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x12, 0x10, 0x0a, - 0x0c, 0x4e, 0x6f, 0x74, 0x4f, 0x6c, 0x64, 0x65, 0x72, 0x54, 0x68, 0x61, 0x6e, 0x10, 0x00, 0x12, - 0x09, 0x0a, 0x05, 0x45, 0x78, 0x61, 0x63, 0x74, 0x10, 0x01, 0x32, 0xed, 0x02, 0x0a, 0x0d, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x04, - 0x52, 0x65, 0x61, 0x64, 0x12, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x43, 0x0a, + 0x0d, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1e, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, + 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, + 0x61, 0x74, 0x63, 0x68, 0x52, 0x0c, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x61, 0x74, + 0x63, 0x68, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x2f, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, + 0x6f, 0x6e, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, + 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0xc4, 0x01, 0x0a, 0x0c, 0x4c, 0x69, + 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x05, 0x69, 0x74, + 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x57, 0x72, 0x61, + 0x70, 0x70, 0x65, 0x72, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, + 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, + 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x30, + 0x0a, 0x14, 0x72, 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x69, 0x74, 0x65, 0x6d, + 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, 0x72, 0x65, + 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x6f, 0x75, 0x6e, 0x74, + 0x22, 0xb9, 0x01, 0x0a, 0x0c, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x69, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x05, 0x73, 0x69, 0x6e, 0x63, 0x65, 0x12, 0x2f, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, + 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x2e, 0x0a, 0x13, 0x73, 0x65, 0x6e, 0x64, + 0x5f, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x73, 0x65, 0x6e, 0x64, 0x49, 0x6e, 0x69, 0x74, 0x69, + 0x61, 0x6c, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x32, 0x0a, 0x15, 0x61, 0x6c, 0x6c, 0x6f, + 0x77, 0x5f, 0x77, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x62, 0x6f, 0x6f, 0x6b, 0x6d, 0x61, 0x72, 0x6b, + 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x57, 0x61, + 0x74, 0x63, 0x68, 0x42, 0x6f, 0x6f, 0x6b, 0x6d, 0x61, 0x72, 0x6b, 0x73, 0x22, 0xdf, 0x02, 0x0a, + 0x0a, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x74, + 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, + 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x2d, 0x0a, 0x04, 0x74, 0x79, 0x70, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x54, 0x79, + 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x39, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, + 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x12, 0x39, 0x0a, 0x08, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x52, 0x08, 0x70, 0x72, 0x65, 0x76, 0x69, 0x6f, 0x75, 0x73, 0x1a, 0x3a, + 0x0a, 0x08, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x76, 0x65, 0x72, + 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x52, 0x0a, 0x04, 0x54, 0x79, + 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, + 0x09, 0x0a, 0x05, 0x41, 0x44, 0x44, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x4d, 0x4f, + 0x44, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, + 0x54, 0x45, 0x44, 0x10, 0x03, 0x12, 0x0c, 0x0a, 0x08, 0x42, 0x4f, 0x4f, 0x4b, 0x4d, 0x41, 0x52, + 0x4b, 0x10, 0x04, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x22, 0x2e, + 0x0a, 0x12, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x22, 0xab, + 0x01, 0x0a, 0x13, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2b, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x4f, 0x0a, 0x0d, 0x53, + 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0b, 0x0a, 0x07, + 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x45, 0x52, + 0x56, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x0f, 0x0a, 0x0b, 0x4e, 0x4f, 0x54, 0x5f, 0x53, 0x45, + 0x52, 0x56, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x13, 0x0a, 0x0f, 0x53, 0x45, 0x52, 0x56, 0x49, + 0x43, 0x45, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x03, 0x2a, 0x33, 0x0a, 0x14, + 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, + 0x61, 0x74, 0x63, 0x68, 0x12, 0x10, 0x0a, 0x0c, 0x4e, 0x6f, 0x74, 0x4f, 0x6c, 0x64, 0x65, 0x72, + 0x54, 0x68, 0x61, 0x6e, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x78, 0x61, 0x63, 0x74, 0x10, + 0x01, 0x32, 0xed, 0x02, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, + 0x6f, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, 0x12, 0x15, 0x2e, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, + 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x43, 0x72, + 0x65, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, + 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x3b, 0x0a, 0x06, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x55, - 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, - 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, - 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x4c, 0x69, - 0x73, 0x74, 0x12, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, - 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x37, 0x0a, 0x05, 0x57, 0x61, 0x74, 0x63, 0x68, 0x12, 0x16, 0x2e, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, - 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x30, 0x01, 0x32, 0xc3, 0x01, 0x0a, 0x0d, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x35, 0x0a, 0x04, - 0x52, 0x65, 0x61, 0x64, 0x12, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x18, - 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, - 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x2e, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x12, 0x17, 0x2e, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x2e, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x32, 0x8b, 0x01, 0x0a, 0x09, 0x42, 0x6c, 0x6f, 0x62, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x3e, - 0x0a, 0x07, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x12, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x2e, 0x50, 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x50, - 0x75, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, - 0x0a, 0x07, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x12, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x47, - 0x65, 0x74, 0x42, 0x6c, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x57, - 0x0a, 0x0b, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x73, 0x12, 0x48, 0x0a, - 0x09, 0x49, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x1c, 0x2e, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, - 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x39, 0x5a, 0x37, 0x67, 0x69, 0x74, 0x68, 0x75, - 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, 0x72, - 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, - 0x65, 0x2f, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x55, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x55, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x17, + 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x35, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, 0x0a, 0x05, 0x57, 0x61, 0x74, 0x63, + 0x68, 0x12, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, + 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x30, + 0x01, 0x32, 0x57, 0x0a, 0x0b, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x73, + 0x12, 0x48, 0x0a, 0x09, 0x49, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x1c, 0x2e, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, + 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, + 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x39, 0x5a, 0x37, 0x67, 0x69, + 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, + 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x74, 0x6f, + 0x72, 0x61, 0x67, 0x65, 0x2f, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x2f, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -2793,106 +1806,73 @@ func file_resource_proto_rawDescGZIP() []byte { return file_resource_proto_rawDescData } -var file_resource_proto_enumTypes = make([]protoimpl.EnumInfo, 5) -var file_resource_proto_msgTypes = make([]protoimpl.MessageInfo, 31) +var file_resource_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_resource_proto_msgTypes = make([]protoimpl.MessageInfo, 21) var file_resource_proto_goTypes = []interface{}{ (ResourceVersionMatch)(0), // 0: resource.ResourceVersionMatch - (Sort_Order)(0), // 1: resource.Sort.Order - (WatchEvent_Type)(0), // 2: resource.WatchEvent.Type - (HealthCheckResponse_ServingStatus)(0), // 3: resource.HealthCheckResponse.ServingStatus - (PutBlobRequest_Method)(0), // 4: resource.PutBlobRequest.Method - (*ResourceKey)(nil), // 5: resource.ResourceKey - (*ResourceWrapper)(nil), // 6: resource.ResourceWrapper - (*ResourceMeta)(nil), // 7: resource.ResourceMeta - (*StatusResult)(nil), // 8: resource.StatusResult - (*CreateRequest)(nil), // 9: resource.CreateRequest - (*CreateResponse)(nil), // 10: resource.CreateResponse - (*UpdateRequest)(nil), // 11: resource.UpdateRequest - (*UpdateResponse)(nil), // 12: resource.UpdateResponse - (*DeleteRequest)(nil), // 13: resource.DeleteRequest - (*DeleteResponse)(nil), // 14: resource.DeleteResponse - (*ReadRequest)(nil), // 15: resource.ReadRequest - (*ReadResponse)(nil), // 16: resource.ReadResponse - (*Requirement)(nil), // 17: resource.Requirement - (*Sort)(nil), // 18: resource.Sort - (*ListOptions)(nil), // 19: resource.ListOptions - (*ListRequest)(nil), // 20: resource.ListRequest - (*ListResponse)(nil), // 21: resource.ListResponse - (*WatchRequest)(nil), // 22: resource.WatchRequest - (*WatchEvent)(nil), // 23: resource.WatchEvent - (*HistoryRequest)(nil), // 24: resource.HistoryRequest - (*HistoryResponse)(nil), // 25: resource.HistoryResponse - (*OriginRequest)(nil), // 26: resource.OriginRequest - (*ResourceOriginInfo)(nil), // 27: resource.ResourceOriginInfo - (*OriginResponse)(nil), // 28: resource.OriginResponse - (*HealthCheckRequest)(nil), // 29: resource.HealthCheckRequest - (*HealthCheckResponse)(nil), // 30: resource.HealthCheckResponse - (*PutBlobRequest)(nil), // 31: resource.PutBlobRequest - (*PutBlobResponse)(nil), // 32: resource.PutBlobResponse - (*GetBlobRequest)(nil), // 33: resource.GetBlobRequest - (*GetBlobResponse)(nil), // 34: resource.GetBlobResponse - (*WatchEvent_Resource)(nil), // 35: resource.WatchEvent.Resource + (WatchEvent_Type)(0), // 1: resource.WatchEvent.Type + (HealthCheckResponse_ServingStatus)(0), // 2: resource.HealthCheckResponse.ServingStatus + (*ResourceKey)(nil), // 3: resource.ResourceKey + (*ResourceWrapper)(nil), // 4: resource.ResourceWrapper + (*ResourceMeta)(nil), // 5: resource.ResourceMeta + (*StatusResult)(nil), // 6: resource.StatusResult + (*CreateRequest)(nil), // 7: resource.CreateRequest + (*CreateResponse)(nil), // 8: resource.CreateResponse + (*UpdateRequest)(nil), // 9: resource.UpdateRequest + (*UpdateResponse)(nil), // 10: resource.UpdateResponse + (*DeleteRequest)(nil), // 11: resource.DeleteRequest + (*DeleteResponse)(nil), // 12: resource.DeleteResponse + (*ReadRequest)(nil), // 13: resource.ReadRequest + (*ReadResponse)(nil), // 14: resource.ReadResponse + (*Requirement)(nil), // 15: resource.Requirement + (*ListOptions)(nil), // 16: resource.ListOptions + (*ListRequest)(nil), // 17: resource.ListRequest + (*ListResponse)(nil), // 18: resource.ListResponse + (*WatchRequest)(nil), // 19: resource.WatchRequest + (*WatchEvent)(nil), // 20: resource.WatchEvent + (*HealthCheckRequest)(nil), // 21: resource.HealthCheckRequest + (*HealthCheckResponse)(nil), // 22: resource.HealthCheckResponse + (*WatchEvent_Resource)(nil), // 23: resource.WatchEvent.Resource } var file_resource_proto_depIdxs = []int32{ - 5, // 0: resource.CreateRequest.key:type_name -> resource.ResourceKey - 8, // 1: resource.CreateResponse.status:type_name -> resource.StatusResult - 5, // 2: resource.UpdateRequest.key:type_name -> resource.ResourceKey - 8, // 3: resource.UpdateResponse.status:type_name -> resource.StatusResult - 5, // 4: resource.DeleteRequest.key:type_name -> resource.ResourceKey - 8, // 5: resource.DeleteResponse.status:type_name -> resource.StatusResult - 5, // 6: resource.ReadRequest.key:type_name -> resource.ResourceKey - 8, // 7: resource.ReadResponse.status:type_name -> resource.StatusResult - 1, // 8: resource.Sort.order:type_name -> resource.Sort.Order - 5, // 9: resource.ListOptions.key:type_name -> resource.ResourceKey - 17, // 10: resource.ListOptions.labels:type_name -> resource.Requirement - 17, // 11: resource.ListOptions.fields:type_name -> resource.Requirement - 0, // 12: resource.ListRequest.version_match:type_name -> resource.ResourceVersionMatch - 19, // 13: resource.ListRequest.options:type_name -> resource.ListOptions - 6, // 14: resource.ListResponse.items:type_name -> resource.ResourceWrapper - 19, // 15: resource.WatchRequest.options:type_name -> resource.ListOptions - 2, // 16: resource.WatchEvent.type:type_name -> resource.WatchEvent.Type - 35, // 17: resource.WatchEvent.resource:type_name -> resource.WatchEvent.Resource - 35, // 18: resource.WatchEvent.previous:type_name -> resource.WatchEvent.Resource - 5, // 19: resource.HistoryRequest.key:type_name -> resource.ResourceKey - 7, // 20: resource.HistoryResponse.items:type_name -> resource.ResourceMeta - 5, // 21: resource.OriginRequest.key:type_name -> resource.ResourceKey - 5, // 22: resource.ResourceOriginInfo.key:type_name -> resource.ResourceKey - 27, // 23: resource.OriginResponse.items:type_name -> resource.ResourceOriginInfo - 3, // 24: resource.HealthCheckResponse.status:type_name -> resource.HealthCheckResponse.ServingStatus - 5, // 25: resource.PutBlobRequest.resource:type_name -> resource.ResourceKey - 4, // 26: resource.PutBlobRequest.method:type_name -> resource.PutBlobRequest.Method - 8, // 27: resource.PutBlobResponse.status:type_name -> resource.StatusResult - 5, // 28: resource.GetBlobRequest.resource:type_name -> resource.ResourceKey - 8, // 29: resource.GetBlobResponse.status:type_name -> resource.StatusResult - 15, // 30: resource.ResourceStore.Read:input_type -> resource.ReadRequest - 9, // 31: resource.ResourceStore.Create:input_type -> resource.CreateRequest - 11, // 32: resource.ResourceStore.Update:input_type -> resource.UpdateRequest - 13, // 33: resource.ResourceStore.Delete:input_type -> resource.DeleteRequest - 20, // 34: resource.ResourceStore.List:input_type -> resource.ListRequest - 22, // 35: resource.ResourceStore.Watch:input_type -> resource.WatchRequest - 15, // 36: resource.ResourceIndex.Read:input_type -> resource.ReadRequest - 24, // 37: resource.ResourceIndex.History:input_type -> resource.HistoryRequest - 26, // 38: resource.ResourceIndex.Origin:input_type -> resource.OriginRequest - 31, // 39: resource.BlobStore.PutBlob:input_type -> resource.PutBlobRequest - 33, // 40: resource.BlobStore.GetBlob:input_type -> resource.GetBlobRequest - 29, // 41: resource.Diagnostics.IsHealthy:input_type -> resource.HealthCheckRequest - 16, // 42: resource.ResourceStore.Read:output_type -> resource.ReadResponse - 10, // 43: resource.ResourceStore.Create:output_type -> resource.CreateResponse - 12, // 44: resource.ResourceStore.Update:output_type -> resource.UpdateResponse - 14, // 45: resource.ResourceStore.Delete:output_type -> resource.DeleteResponse - 21, // 46: resource.ResourceStore.List:output_type -> resource.ListResponse - 23, // 47: resource.ResourceStore.Watch:output_type -> resource.WatchEvent - 16, // 48: resource.ResourceIndex.Read:output_type -> resource.ReadResponse - 25, // 49: resource.ResourceIndex.History:output_type -> resource.HistoryResponse - 28, // 50: resource.ResourceIndex.Origin:output_type -> resource.OriginResponse - 32, // 51: resource.BlobStore.PutBlob:output_type -> resource.PutBlobResponse - 34, // 52: resource.BlobStore.GetBlob:output_type -> resource.GetBlobResponse - 30, // 53: resource.Diagnostics.IsHealthy:output_type -> resource.HealthCheckResponse - 42, // [42:54] is the sub-list for method output_type - 30, // [30:42] is the sub-list for method input_type - 30, // [30:30] is the sub-list for extension type_name - 30, // [30:30] is the sub-list for extension extendee - 0, // [0:30] is the sub-list for field type_name + 3, // 0: resource.CreateRequest.key:type_name -> resource.ResourceKey + 6, // 1: resource.CreateResponse.status:type_name -> resource.StatusResult + 3, // 2: resource.UpdateRequest.key:type_name -> resource.ResourceKey + 6, // 3: resource.UpdateResponse.status:type_name -> resource.StatusResult + 3, // 4: resource.DeleteRequest.key:type_name -> resource.ResourceKey + 6, // 5: resource.DeleteResponse.status:type_name -> resource.StatusResult + 3, // 6: resource.ReadRequest.key:type_name -> resource.ResourceKey + 6, // 7: resource.ReadResponse.status:type_name -> resource.StatusResult + 3, // 8: resource.ListOptions.key:type_name -> resource.ResourceKey + 15, // 9: resource.ListOptions.labels:type_name -> resource.Requirement + 15, // 10: resource.ListOptions.fields:type_name -> resource.Requirement + 0, // 11: resource.ListRequest.version_match:type_name -> resource.ResourceVersionMatch + 16, // 12: resource.ListRequest.options:type_name -> resource.ListOptions + 4, // 13: resource.ListResponse.items:type_name -> resource.ResourceWrapper + 16, // 14: resource.WatchRequest.options:type_name -> resource.ListOptions + 1, // 15: resource.WatchEvent.type:type_name -> resource.WatchEvent.Type + 23, // 16: resource.WatchEvent.resource:type_name -> resource.WatchEvent.Resource + 23, // 17: resource.WatchEvent.previous:type_name -> resource.WatchEvent.Resource + 2, // 18: resource.HealthCheckResponse.status:type_name -> resource.HealthCheckResponse.ServingStatus + 13, // 19: resource.ResourceStore.Read:input_type -> resource.ReadRequest + 7, // 20: resource.ResourceStore.Create:input_type -> resource.CreateRequest + 9, // 21: resource.ResourceStore.Update:input_type -> resource.UpdateRequest + 11, // 22: resource.ResourceStore.Delete:input_type -> resource.DeleteRequest + 17, // 23: resource.ResourceStore.List:input_type -> resource.ListRequest + 19, // 24: resource.ResourceStore.Watch:input_type -> resource.WatchRequest + 21, // 25: resource.Diagnostics.IsHealthy:input_type -> resource.HealthCheckRequest + 14, // 26: resource.ResourceStore.Read:output_type -> resource.ReadResponse + 8, // 27: resource.ResourceStore.Create:output_type -> resource.CreateResponse + 10, // 28: resource.ResourceStore.Update:output_type -> resource.UpdateResponse + 12, // 29: resource.ResourceStore.Delete:output_type -> resource.DeleteResponse + 18, // 30: resource.ResourceStore.List:output_type -> resource.ListResponse + 20, // 31: resource.ResourceStore.Watch:output_type -> resource.WatchEvent + 22, // 32: resource.Diagnostics.IsHealthy:output_type -> resource.HealthCheckResponse + 26, // [26:33] is the sub-list for method output_type + 19, // [19:26] is the sub-list for method input_type + 19, // [19:19] is the sub-list for extension type_name + 19, // [19:19] is the sub-list for extension extendee + 0, // [0:19] is the sub-list for field type_name } func init() { file_resource_proto_init() } @@ -3058,18 +2038,6 @@ func file_resource_proto_init() { } } file_resource_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Sort); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_resource_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*ListOptions); i { case 0: return &v.state @@ -3081,7 +2049,7 @@ func file_resource_proto_init() { return nil } } - file_resource_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { + file_resource_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*ListRequest); i { case 0: return &v.state @@ -3093,7 +2061,7 @@ func file_resource_proto_init() { return nil } } - file_resource_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { + file_resource_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*ListResponse); i { case 0: return &v.state @@ -3105,7 +2073,7 @@ func file_resource_proto_init() { return nil } } - file_resource_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { + file_resource_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*WatchRequest); i { case 0: return &v.state @@ -3117,7 +2085,7 @@ func file_resource_proto_init() { return nil } } - file_resource_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { + file_resource_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*WatchEvent); i { case 0: return &v.state @@ -3129,67 +2097,7 @@ func file_resource_proto_init() { return nil } } - file_resource_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*HistoryRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_resource_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*HistoryResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_resource_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*OriginRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_resource_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ResourceOriginInfo); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_resource_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*OriginResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_resource_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} { + file_resource_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*HealthCheckRequest); i { case 0: return &v.state @@ -3201,7 +2109,7 @@ func file_resource_proto_init() { return nil } } - file_resource_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} { + file_resource_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*HealthCheckResponse); i { case 0: return &v.state @@ -3213,55 +2121,7 @@ func file_resource_proto_init() { return nil } } - file_resource_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PutBlobRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_resource_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PutBlobResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_resource_proto_msgTypes[28].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GetBlobRequest); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_resource_proto_msgTypes[29].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GetBlobResponse); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - file_resource_proto_msgTypes[30].Exporter = func(v interface{}, i int) interface{} { + file_resource_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*WatchEvent_Resource); i { case 0: return &v.state @@ -3279,10 +2139,10 @@ func file_resource_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_resource_proto_rawDesc, - NumEnums: 5, - NumMessages: 31, + NumEnums: 3, + NumMessages: 21, NumExtensions: 0, - NumServices: 4, + NumServices: 2, }, GoTypes: file_resource_proto_goTypes, DependencyIndexes: file_resource_proto_depIdxs, diff --git a/pkg/storage/unified/resource/resource.proto b/pkg/storage/unified/resource/resource.proto index 46fd9a8c7b7..8990e9823a0 100644 --- a/pkg/storage/unified/resource/resource.proto +++ b/pkg/storage/unified/resource/resource.proto @@ -122,11 +122,8 @@ message DeleteResponse { // Status code StatusResult status = 1; - // The new resource version + // The resource version for the deletion marker int64 resource_version = 2; - - // The deleted payload - bytes value = 3; } message ReadRequest { @@ -159,14 +156,6 @@ message Requirement { repeated string values = 3; // typically one value, but depends on the operator } -message Sort { - enum Order { - ASC = 0; - DESC = 1; - } - string field = 1; - Order order = 2; -} message ListOptions { // Group+Namespace+Resource (not name) @@ -270,77 +259,6 @@ message WatchEvent { Resource previous = 4; } -message HistoryRequest { - // Starting from the requested page (other query parameters must match!) - string next_page_token = 1; - - // Maximum number of items to return - int64 limit = 2; - - // Resource identifier - ResourceKey key = 3; - - // List the deleted values (eg, show trash) - bool show_deleted = 4; -} - -message HistoryResponse { - repeated ResourceMeta items = 1; - - // More results exist... pass this in the next request - string next_page_token = 2; - - // ResourceVersion of the list response - int64 resource_version = 3; -} - -message OriginRequest { - // Starting from the requested page (other query parameters must match!) - string next_page_token = 1; - - // Maximum number of items to return - int64 limit = 2; - - // Resource identifier - ResourceKey key = 3; - - // List the deleted values (eg, show trash) - string origin = 4; -} - -message ResourceOriginInfo { - // The resource - ResourceKey key = 1; - - // Size of the full resource body - int32 resource_size = 2; - - // Hash for the resource - string resource_hash = 3; - - // The origin name - string origin = 4; - - // Path on the origin - string path = 5; - - // Verification hash from the origin - string hash = 6; - - // Change time from the origin - int64 timestamp = 7; -} - -message OriginResponse { - repeated ResourceOriginInfo items = 1; - - // More results exist... pass this in the next request - string next_page_token = 2; - - // ResourceVersion of the list response - int64 resource_version = 3; -} - message HealthCheckRequest { string service = 1; } @@ -356,84 +274,6 @@ message HealthCheckResponse { } -//---------------------------- -// Blob Support -//---------------------------- - -message PutBlobRequest { - enum Method { - // Use the inline raw []byte - GRPC = 0; - - // Get a signed URL and PUT the value - HTTP = 1; - } - - // The resource that will use this blob - // NOTE: the name may not yet exist, but group+resource are required - ResourceKey resource = 1; - - // How to upload - Method method = 2; - - // Content type header - string content_type = 3; - - // Raw value to write - // Not valid when method == HTTP - bytes value = 4; -} - -message PutBlobResponse { - // Status code - StatusResult status = 1; - - // The blob uid. This must be saved into the resource to support access - string uid = 2; - - // The URL where this value can be PUT - string url = 3; - - // Size of the uploaded blob - int64 size = 4; - - // Content hash used for an etag - string hash = 5; - - // Validated mimetype (from content_type) - string mime_type = 6; - - // Validated charset (from content_type) - string charset = 7; -} - -message GetBlobRequest { - ResourceKey resource = 1; - - // The new resource version - int64 resource_version = 2; - - // Do not return a pre-signed URL (when possible) - bool must_proxy_bytes = 3; -} - -message GetBlobResponse { - // Status code sent on errors - StatusResult status = 1; - - // (optional) When possible, the system will return a presigned URL - // that can be used to actually read the full blob+metadata - // When this is set, neither info nor value will be set - string url = 2; - - // Content type - string content_type = 3; - - // The raw object value - bytes value = 4; -} - - // This provides the CRUD+List+Watch support needed for a k8s apiserver // The semantics and behaviors of this service are constrained by kubernetes // This does not understand the resource schemas, only deals with json bytes @@ -455,32 +295,6 @@ service ResourceStore { rpc Watch(WatchRequest) returns (stream WatchEvent); } -// Unlike the ResourceStore, this service can be exposed to clients directly -// It should be implemented with efficient indexes and does not need read-after-write semantics -service ResourceIndex { - // TODO: rpc Search(...) ... eventually a typed response - - rpc Read(ReadRequest) returns (ReadResponse); // Duplicated -- for client read only usage - - // Show resource history (and trash) - rpc History(HistoryRequest) returns (HistoryResponse); - - // Used for efficient provisioning - rpc Origin(OriginRequest) returns (OriginResponse); -} - -service BlobStore { - // Upload a blob that will be saved in a resource - rpc PutBlob(PutBlobRequest) returns (PutBlobResponse); - - // Get blob contents. When possible, this will return a signed URL - // For large payloads, signed URLs are required to avoid protobuf message size limits - rpc GetBlob(GetBlobRequest) returns (GetBlobResponse); - - // NOTE: there is no direct access to delete blobs - // >> cleanup will be managed via garbage collection or direct access to the underlying storage -} - // Clients can use this service directly // NOTE: This is read only, and no read afer write guarantees service Diagnostics { diff --git a/pkg/storage/unified/resource/resource_grpc.pb.go b/pkg/storage/unified/resource/resource_grpc.pb.go index ff2f9abde25..17b4d1c4c22 100644 --- a/pkg/storage/unified/resource/resource_grpc.pb.go +++ b/pkg/storage/unified/resource/resource_grpc.pb.go @@ -347,314 +347,6 @@ var ResourceStore_ServiceDesc = grpc.ServiceDesc{ Metadata: "resource.proto", } -const ( - ResourceIndex_Read_FullMethodName = "/resource.ResourceIndex/Read" - ResourceIndex_History_FullMethodName = "/resource.ResourceIndex/History" - ResourceIndex_Origin_FullMethodName = "/resource.ResourceIndex/Origin" -) - -// ResourceIndexClient is the client API for ResourceIndex service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. -// -// Unlike the ResourceStore, this service can be exposed to clients directly -// It should be implemented with efficient indexes and does not need read-after-write semantics -type ResourceIndexClient interface { - Read(ctx context.Context, in *ReadRequest, opts ...grpc.CallOption) (*ReadResponse, error) - // Show resource history (and trash) - History(ctx context.Context, in *HistoryRequest, opts ...grpc.CallOption) (*HistoryResponse, error) - // Used for efficient provisioning - Origin(ctx context.Context, in *OriginRequest, opts ...grpc.CallOption) (*OriginResponse, error) -} - -type resourceIndexClient struct { - cc grpc.ClientConnInterface -} - -func NewResourceIndexClient(cc grpc.ClientConnInterface) ResourceIndexClient { - return &resourceIndexClient{cc} -} - -func (c *resourceIndexClient) Read(ctx context.Context, in *ReadRequest, opts ...grpc.CallOption) (*ReadResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ReadResponse) - err := c.cc.Invoke(ctx, ResourceIndex_Read_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *resourceIndexClient) History(ctx context.Context, in *HistoryRequest, opts ...grpc.CallOption) (*HistoryResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(HistoryResponse) - err := c.cc.Invoke(ctx, ResourceIndex_History_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *resourceIndexClient) Origin(ctx context.Context, in *OriginRequest, opts ...grpc.CallOption) (*OriginResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(OriginResponse) - err := c.cc.Invoke(ctx, ResourceIndex_Origin_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -// ResourceIndexServer is the server API for ResourceIndex service. -// All implementations should embed UnimplementedResourceIndexServer -// for forward compatibility -// -// Unlike the ResourceStore, this service can be exposed to clients directly -// It should be implemented with efficient indexes and does not need read-after-write semantics -type ResourceIndexServer interface { - Read(context.Context, *ReadRequest) (*ReadResponse, error) - // Show resource history (and trash) - History(context.Context, *HistoryRequest) (*HistoryResponse, error) - // Used for efficient provisioning - Origin(context.Context, *OriginRequest) (*OriginResponse, error) -} - -// UnimplementedResourceIndexServer should be embedded to have forward compatible implementations. -type UnimplementedResourceIndexServer struct { -} - -func (UnimplementedResourceIndexServer) Read(context.Context, *ReadRequest) (*ReadResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Read not implemented") -} -func (UnimplementedResourceIndexServer) History(context.Context, *HistoryRequest) (*HistoryResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method History not implemented") -} -func (UnimplementedResourceIndexServer) Origin(context.Context, *OriginRequest) (*OriginResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Origin not implemented") -} - -// UnsafeResourceIndexServer may be embedded to opt out of forward compatibility for this service. -// Use of this interface is not recommended, as added methods to ResourceIndexServer will -// result in compilation errors. -type UnsafeResourceIndexServer interface { - mustEmbedUnimplementedResourceIndexServer() -} - -func RegisterResourceIndexServer(s grpc.ServiceRegistrar, srv ResourceIndexServer) { - s.RegisterService(&ResourceIndex_ServiceDesc, srv) -} - -func _ResourceIndex_Read_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ReadRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ResourceIndexServer).Read(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: ResourceIndex_Read_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ResourceIndexServer).Read(ctx, req.(*ReadRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _ResourceIndex_History_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(HistoryRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ResourceIndexServer).History(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: ResourceIndex_History_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ResourceIndexServer).History(ctx, req.(*HistoryRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _ResourceIndex_Origin_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(OriginRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ResourceIndexServer).Origin(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: ResourceIndex_Origin_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ResourceIndexServer).Origin(ctx, req.(*OriginRequest)) - } - return interceptor(ctx, in, info, handler) -} - -// ResourceIndex_ServiceDesc is the grpc.ServiceDesc for ResourceIndex service. -// It's only intended for direct use with grpc.RegisterService, -// and not to be introspected or modified (even as a copy) -var ResourceIndex_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "resource.ResourceIndex", - HandlerType: (*ResourceIndexServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "Read", - Handler: _ResourceIndex_Read_Handler, - }, - { - MethodName: "History", - Handler: _ResourceIndex_History_Handler, - }, - { - MethodName: "Origin", - Handler: _ResourceIndex_Origin_Handler, - }, - }, - Streams: []grpc.StreamDesc{}, - Metadata: "resource.proto", -} - -const ( - BlobStore_PutBlob_FullMethodName = "/resource.BlobStore/PutBlob" - BlobStore_GetBlob_FullMethodName = "/resource.BlobStore/GetBlob" -) - -// BlobStoreClient is the client API for BlobStore service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. -type BlobStoreClient interface { - // Upload a blob that will be saved in a resource - PutBlob(ctx context.Context, in *PutBlobRequest, opts ...grpc.CallOption) (*PutBlobResponse, error) - // Get blob contents. When possible, this will return a signed URL - // For large payloads, signed URLs are required to avoid protobuf message size limits - GetBlob(ctx context.Context, in *GetBlobRequest, opts ...grpc.CallOption) (*GetBlobResponse, error) -} - -type blobStoreClient struct { - cc grpc.ClientConnInterface -} - -func NewBlobStoreClient(cc grpc.ClientConnInterface) BlobStoreClient { - return &blobStoreClient{cc} -} - -func (c *blobStoreClient) PutBlob(ctx context.Context, in *PutBlobRequest, opts ...grpc.CallOption) (*PutBlobResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(PutBlobResponse) - err := c.cc.Invoke(ctx, BlobStore_PutBlob_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *blobStoreClient) GetBlob(ctx context.Context, in *GetBlobRequest, opts ...grpc.CallOption) (*GetBlobResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(GetBlobResponse) - err := c.cc.Invoke(ctx, BlobStore_GetBlob_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -// BlobStoreServer is the server API for BlobStore service. -// All implementations should embed UnimplementedBlobStoreServer -// for forward compatibility -type BlobStoreServer interface { - // Upload a blob that will be saved in a resource - PutBlob(context.Context, *PutBlobRequest) (*PutBlobResponse, error) - // Get blob contents. When possible, this will return a signed URL - // For large payloads, signed URLs are required to avoid protobuf message size limits - GetBlob(context.Context, *GetBlobRequest) (*GetBlobResponse, error) -} - -// UnimplementedBlobStoreServer should be embedded to have forward compatible implementations. -type UnimplementedBlobStoreServer struct { -} - -func (UnimplementedBlobStoreServer) PutBlob(context.Context, *PutBlobRequest) (*PutBlobResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method PutBlob not implemented") -} -func (UnimplementedBlobStoreServer) GetBlob(context.Context, *GetBlobRequest) (*GetBlobResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetBlob not implemented") -} - -// UnsafeBlobStoreServer may be embedded to opt out of forward compatibility for this service. -// Use of this interface is not recommended, as added methods to BlobStoreServer will -// result in compilation errors. -type UnsafeBlobStoreServer interface { - mustEmbedUnimplementedBlobStoreServer() -} - -func RegisterBlobStoreServer(s grpc.ServiceRegistrar, srv BlobStoreServer) { - s.RegisterService(&BlobStore_ServiceDesc, srv) -} - -func _BlobStore_PutBlob_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(PutBlobRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(BlobStoreServer).PutBlob(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: BlobStore_PutBlob_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(BlobStoreServer).PutBlob(ctx, req.(*PutBlobRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _BlobStore_GetBlob_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(GetBlobRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(BlobStoreServer).GetBlob(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: BlobStore_GetBlob_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(BlobStoreServer).GetBlob(ctx, req.(*GetBlobRequest)) - } - return interceptor(ctx, in, info, handler) -} - -// BlobStore_ServiceDesc is the grpc.ServiceDesc for BlobStore service. -// It's only intended for direct use with grpc.RegisterService, -// and not to be introspected or modified (even as a copy) -var BlobStore_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "resource.BlobStore", - HandlerType: (*BlobStoreServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "PutBlob", - Handler: _BlobStore_PutBlob_Handler, - }, - { - MethodName: "GetBlob", - Handler: _BlobStore_GetBlob_Handler, - }, - }, - Streams: []grpc.StreamDesc{}, - Metadata: "resource.proto", -} - const ( Diagnostics_IsHealthy_FullMethodName = "/resource.Diagnostics/IsHealthy" ) diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index 17f1fe1a6ce..0f455ca68a5 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -22,7 +22,7 @@ import ( // Package-level errors. var ( - ErrNotFound = errors.New("resource not found") + ErrNotFound = errors.New("entity not found") ErrOptimisticLockingFailed = errors.New("optimistic locking failed") ErrUserNotFoundInContext = errors.New("user not found in context") ErrUnableToReadResourceJSON = errors.New("unable to read resource json") @@ -32,8 +32,6 @@ var ( // ResourceServer implements all services type ResourceServer interface { ResourceStoreServer - ResourceIndexServer - BlobStoreServer DiagnosticsServer LifecycleHooks } @@ -62,23 +60,6 @@ type StorageBackend interface { WatchWriteEvents(ctx context.Context) (<-chan *WrittenEvent, error) } -// This interface is not exposed to end users directly -// Access to this interface is already gated by access control -type BlobStore interface { - // Indicates if storage layer supports signed urls - SupportsSignedURLs() bool - - // Get the raw blob bytes and metadata -- limited to protobuf message size - // For larger payloads, we should use presigned URLs to upload from the client - PutBlob(context.Context, *PutBlobRequest) (*PutBlobResponse, error) - - // Get blob contents. When possible, this will return a signed URL - // For large payloads, signed URLs are required to avoid protobuf message size limits - GetBlob(ctx context.Context, resource *ResourceKey, info *utils.BlobInfo, mustProxy bool) (*GetBlobResponse, error) - - // TODO? List+Delete? This is for admin access -} - type ResourceServerOptions struct { // OTel tracer Tracer trace.Tracer @@ -86,12 +67,6 @@ type ResourceServerOptions struct { // Real storage backend Backend StorageBackend - // The blob storage engine - Blob BlobStore - - // Real storage backend - Search ResourceIndexServer - // Diagnostics Diagnostics DiagnosticsServer @@ -114,12 +89,6 @@ func NewResourceServer(opts ResourceServerOptions) (ResourceServer, error) { if opts.Backend == nil { return nil, fmt.Errorf("missing Backend implementation") } - if opts.Search == nil { - opts.Search = &noopService{} - } - if opts.Blob == nil { - opts.Blob = &noopService{} - } if opts.Diagnostics == nil { opts.Diagnostics = &noopService{} } @@ -141,7 +110,6 @@ func NewResourceServer(opts ResourceServerOptions) (ResourceServer, error) { tracer: opts.Tracer, log: slog.Default().With("logger", "resource-server"), backend: opts.Backend, - search: opts.Search, diagnostics: opts.Diagnostics, access: opts.WriteAccess, lifecycle: opts.Lifecycle, @@ -157,8 +125,6 @@ type server struct { tracer trace.Tracer log *slog.Logger backend StorageBackend - search ResourceIndexServer - blob BlobStore diagnostics DiagnosticsServer access WriteAccessHooks lifecycle LifecycleHooks @@ -276,24 +242,13 @@ func (s *server) newEventBuilder(ctx context.Context, key *ResourceKey, value, o } obj.SetOriginInfo(origin) - // Make sure old values do not mutate things they should not + // Ensure old values do not mutate things they should not if event.OldMeta != nil { old := event.OldMeta - if obj.GetUID() != event.OldMeta.GetUID() { - return nil, apierrors.NewBadRequest( - fmt.Sprintf("UIDs do not match (old: %s, new: %s)", old.GetUID(), obj.GetUID())) - } - - // Can not change creation timestamps+user - if obj.GetCreatedBy() != event.OldMeta.GetCreatedBy() { - return nil, apierrors.NewBadRequest( - fmt.Sprintf("created by changed (old: %s, new: %s)", old.GetCreatedBy(), obj.GetCreatedBy())) - } - if obj.GetCreationTimestamp() != event.OldMeta.GetCreationTimestamp() { - return nil, apierrors.NewBadRequest( - fmt.Sprintf("creation timestamp changed (old:%v, new:%v)", old.GetCreationTimestamp(), obj.GetCreationTimestamp())) - } + obj.SetUID(old.GetUID()) + obj.SetCreatedBy(old.GetCreatedBy()) + obj.SetCreationTimestamp(old.GetCreationTimestamp()) } return event, nil } @@ -431,7 +386,7 @@ func (s *server) Delete(ctx context.Context, req *DeleteRequest) (*DeleteRespons if err != nil { return nil, err } - if latest.ResourceVersion != req.ResourceVersion { + if req.ResourceVersion > 0 && latest.ResourceVersion != req.ResourceVersion { return nil, ErrOptimisticLockingFailed } @@ -481,17 +436,15 @@ func (s *server) Read(ctx context.Context, req *ReadRequest) (*ReadResponse, err return nil, err } - if req.Key.Group == "" { - status, _ := errToStatus(apierrors.NewBadRequest("missing group")) - return &ReadResponse{Status: status}, nil - } + // if req.Key.Group == "" { + // status, _ := errToStatus(apierrors.NewBadRequest("missing group")) + // return &ReadResponse{Status: status}, nil + // } if req.Key.Resource == "" { status, _ := errToStatus(apierrors.NewBadRequest("missing resource")) return &ReadResponse{Status: status}, nil } - // TODO: shall we also check for the namespace and Name ? Or is that a backend concern? - rsp, err := s.backend.Read(ctx, req) if err != nil { if rsp == nil { @@ -536,8 +489,6 @@ func (s *server) Watch(req *WatchRequest, srv ResourceStore_WatchServer) error { return err } - fmt.Printf("WATCH %v\n", req.Options.Key) - ctx := srv.Context() // Start listening -- this will buffer any changes that happen while we backfill @@ -589,76 +540,6 @@ func (s *server) Watch(req *WatchRequest, srv ResourceStore_WatchServer) error { } } -// GetBlob implements ResourceServer. -func (s *server) PutBlob(ctx context.Context, req *PutBlobRequest) (*PutBlobResponse, error) { - if err := s.Init(); err != nil { - return nil, err - } - rsp, err := s.blob.PutBlob(ctx, req) - rsp.Status, err = errToStatus(err) - return rsp, err -} - -func (s *server) getPartialObject(ctx context.Context, key *ResourceKey, rv int64) (utils.GrafanaMetaAccessor, *StatusResult) { - rsp, err := s.backend.Read(ctx, &ReadRequest{ - Key: key, - ResourceVersion: rv, - }) - if err != nil { - rsp.Status, _ = errToStatus(err) - } - if rsp.Status != nil { - return nil, rsp.Status - } - - partial := &metav1.PartialObjectMetadata{} - err = json.Unmarshal(rsp.Value, partial) - if err != nil { - rsp.Status, _ = errToStatus(fmt.Errorf("error reading body %w", err)) - return nil, rsp.Status - } - obj, err := utils.MetaAccessor(partial) - if err != nil { - rsp.Status, _ = errToStatus(fmt.Errorf("error getting accessor %w", err)) - return nil, rsp.Status - } - return obj, nil -} - -// GetBlob implements ResourceServer. -func (s *server) GetBlob(ctx context.Context, req *GetBlobRequest) (*GetBlobResponse, error) { - if err := s.Init(); err != nil { - return nil, err - } - - // NOTE: in SQL... this could be simple select rather than a full fetch and extract - obj, status := s.getPartialObject(ctx, req.Resource, req.ResourceVersion) - if status != nil { - return &GetBlobResponse{Status: status}, nil - } - - info := obj.GetBlob() - if info == nil || info.UID == "" { - return &GetBlobResponse{Status: &StatusResult{ - Status: "Failure", - Message: "Resource does not have a linked blob", - Code: 404, - }}, nil - } - - rsp, err := s.blob.GetBlob(ctx, req.Resource, info, req.MustProxyBytes) - rsp.Status, err = errToStatus(err) - return rsp, err -} - -// History implements ResourceServer. -func (s *server) History(ctx context.Context, req *HistoryRequest) (*HistoryResponse, error) { - if err := s.Init(); err != nil { - return nil, err - } - return s.search.History(ctx, req) -} - // IsHealthy implements ResourceServer. func (s *server) IsHealthy(ctx context.Context, req *HealthCheckRequest) (*HealthCheckResponse, error) { if err := s.Init(); err != nil { @@ -666,11 +547,3 @@ func (s *server) IsHealthy(ctx context.Context, req *HealthCheckRequest) (*Healt } return s.diagnostics.IsHealthy(ctx, req) } - -// Origin implements ResourceServer. -func (s *server) Origin(ctx context.Context, req *OriginRequest) (*OriginResponse, error) { - if err := s.Init(); err != nil { - return nil, err - } - return s.search.Origin(ctx, req) -} diff --git a/pkg/storage/unified/resource/server_test.go b/pkg/storage/unified/resource/server_test.go index cff77381043..e3bfd646e08 100644 --- a/pkg/storage/unified/resource/server_test.go +++ b/pkg/storage/unified/resource/server_test.go @@ -38,10 +38,6 @@ func TestSimpleServer(t *testing.T) { Metadata: fileblob.MetadataDontWrite, // skip }) require.NoError(t, err) -<<<<<<< HEAD - -======= ->>>>>>> origin/resource-store-bridge fmt.Printf("ROOT: %s\n\n", tmp) } store, err := NewCDKBackend(ctx, CDKBackendOptions{ diff --git a/pkg/storage/unified/sql/server.go b/pkg/storage/unified/sql/server.go index 1dddd998cb3..eac94aba2b7 100644 --- a/pkg/storage/unified/sql/server.go +++ b/pkg/storage/unified/sql/server.go @@ -1,13 +1,6 @@ package sql import ( - "context" - "os" - "path/filepath" - "time" - - "gocloud.dev/blob/fileblob" - "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -22,32 +15,6 @@ func ProvideResourceServer(db db.DB, cfg *setting.Cfg, features featuremgmt.Feat Tracer: tracer, } - supportBlobs := true - - // Create a local blob filesystem blob store - if supportBlobs { - dir := filepath.Join(cfg.DataPath, "unistore", "blobs") - if err := os.MkdirAll(dir, 0o750); err != nil { - return nil, err - } - - bucket, err := fileblob.OpenBucket(dir, &fileblob.Options{ - CreateDir: true, - Metadata: fileblob.MetadataDontWrite, // skip - }) - if err != nil { - return nil, err - } - opts.Blob, err = resource.NewCDKBlobStore(context.Background(), resource.CDKBlobStoreOptions{ - Tracer: tracer, - Bucket: bucket, - URLExpiration: time.Minute * 20, - }) - if err != nil { - return nil, err - } - } - eDB, err := dbimpl.ProvideResourceDB(db, cfg, features, tracer) if err != nil { return nil, err diff --git a/pkg/storage/unified/sqlnext/data/common.sql b/pkg/storage/unified/sqlnext/data/common.sql deleted file mode 100644 index 867061825d2..00000000000 --- a/pkg/storage/unified/sqlnext/data/common.sql +++ /dev/null @@ -1,61 +0,0 @@ -{{/* - This is the list of all the fields in *entity.Entity, in a way that is - suitable to be imported by other templates that need to select these fields - from either the "entity" or the "entity_history" tables. - - Example usage: - - SELECT {{ template "common_entity_select_into" . }} - FROM {{ .Ident "entity" }} AS e - -*/}} -{{ define "common_entity_select_into" }} - - e.{{ .Ident "guid" | .Into .Entity.Guid }}, - e.{{ .Ident "resource_version" | .Into .Entity.ResourceVersion }}, - - e.{{ .Ident "key" | .Into .Entity.Key }}, - - e.{{ .Ident "group" | .Into .Entity.Group }}, - e.{{ .Ident "group_version" | .Into .Entity.GroupVersion }}, - e.{{ .Ident "resource" | .Into .Entity.Resource }}, - e.{{ .Ident "namespace" | .Into .Entity.Namespace }}, - e.{{ .Ident "name" | .Into .Entity.Name }}, - - e.{{ .Ident "folder" | .Into .Entity.Folder }}, - - e.{{ .Ident "meta" | .Into .Entity.Meta }}, - e.{{ .Ident "body" | .Into .Entity.Body }}, - e.{{ .Ident "status" | .Into .Entity.Status }}, - - e.{{ .Ident "size" | .Into .Entity.Size }}, - e.{{ .Ident "etag" | .Into .Entity.ETag }}, - - e.{{ .Ident "created_at" | .Into .Entity.CreatedAt }}, - e.{{ .Ident "created_by" | .Into .Entity.CreatedBy }}, - e.{{ .Ident "updated_at" | .Into .Entity.UpdatedAt }}, - e.{{ .Ident "updated_by" | .Into .Entity.UpdatedBy }}, - - e.{{ .Ident "origin" | .Into .Entity.Origin.Source }}, - e.{{ .Ident "origin_key" | .Into .Entity.Origin.Key }}, - e.{{ .Ident "origin_ts" | .Into .Entity.Origin.Time }}, - - e.{{ .Ident "title" | .Into .Entity.Title }}, - e.{{ .Ident "slug" | .Into .Entity.Slug }}, - e.{{ .Ident "description" | .Into .Entity.Description }}, - - e.{{ .Ident "message" | .Into .Entity.Message }}, - e.{{ .Ident "labels" | .Into .Entity.Labels }}, - e.{{ .Ident "fields" | .Into .Entity.Fields }}, - e.{{ .Ident "errors" | .Into .Entity.Errors }}, - - e.{{ .Ident "action" | .Into .Entity.Action }} -{{ end }} - -{{/* Build an ORDER BY clause from a []SortBy contained in a .Sort field */}} -{{ define "common_order_by" }} - {{ $comma := listSep ", " }} - {{ range .Sort }} - {{- call $comma -}} {{ $.Ident .Field }} {{ .Direction.String }} - {{ end }} -{{ end }} diff --git a/pkg/storage/unified/sqlnext/data/resource_get.sql b/pkg/storage/unified/sqlnext/data/resource_get.sql deleted file mode 100644 index 6da2184d37e..00000000000 --- a/pkg/storage/unified/sqlnext/data/resource_get.sql +++ /dev/null @@ -1,34 +0,0 @@ -SELECT - {{ .Ident "rv" | .Into .Resource.Version }}, - {{ .Ident "value" | .Into .Resource.Value }}, - {{ .Ident "blob" | .Into .Resource.Blob }}, - -FROM "resource" - - WHERE 1 = 1 - AND {{ .Ident "namespace" }} = {{ .Arg .Key.Namespace }} - AND {{ .Ident "group" }} = {{ .Arg .Key.Group }} - AND {{ .Ident "resource" }} = {{ .Arg .Key.Resource }} - AND {{ .Ident "name" }} = {{ .Arg .Key.Name }} - - {{/* - Resource versions work like snapshots at the kind level. Thus, a request - to retrieve a specific resource version should be interpreted as asking - for a resource as of how it existed at that point in time. This is why we - request matching entities with at most the provided resource version, and - return only the one with the highest resource version. In the case of not - specifying a resource version (i.e. resource version zero), it is - interpreted as the latest version of the given entity, thus we instead - query the "entity" table (which holds only the latest version of - non-deleted entities) and we don't need to specify anything else. The - "entity" table has a unique constraint on (namespace, group, resource, - name), so we're guaranteed to have at most one matching row. - */}} - {{ if gt .ResourceVersion 0 }} - AND {{ .Ident "rv" }} <= {{ .Arg .ResourceVersion }} - ORDER BY {{ .Ident "rv" }} DESC - LIMIT 1 - {{ else }} - AND {{ .Ident "is_current" }} = true - {{ end }} -; \ No newline at end of file diff --git a/pkg/storage/unified/sqlnext/data/resource_insert.sql b/pkg/storage/unified/sqlnext/data/resource_insert.sql deleted file mode 100644 index 5497041466e..00000000000 --- a/pkg/storage/unified/sqlnext/data/resource_insert.sql +++ /dev/null @@ -1,31 +0,0 @@ -INSERT INTO "resource" - {{/* Explicitly specify fields that will be set */}} - ( - {{ .Ident "event" }}, - {{ .Ident "group" }}, - {{ .Ident "api_version" }}, - {{ .Ident "namespace" }}, - {{ .Ident "resource" }}, - {{ .Ident "name" }}, - {{ .Ident "operation" }}, - {{ .Ident "message" }}, - {{ .Ident "value" }}, - {{ .Ident "hash" }}, - {{ .Ident "blob" }}, - ) - - {{/* Provide the values */}} - VALUES ( - {{ .Arg .Event.ID }}, - {{ .Arg .Event.Group }}, - {{ .Arg .Event.ApiVersion }}, - {{ .Arg .Event.Namespace }}, - {{ .Arg .Event.Resource }}, - {{ .Arg .Event.Name }}, - {{ .Arg .Event.Operation }}, - {{ .Arg .Event.Message }}, - {{ .Arg .Event.Value }}, - {{ .Arg .Event.Hash }}, - {{ .Arg .Event.Blob }}, - ) -; diff --git a/pkg/storage/unified/sqlnext/data/rv_get.sql b/pkg/storage/unified/sqlnext/data/rv_get.sql deleted file mode 100644 index 2a49c64bb05..00000000000 --- a/pkg/storage/unified/sqlnext/data/rv_get.sql +++ /dev/null @@ -1,8 +0,0 @@ -SELECT - {{ .Ident "rv" | .Into .ResourceVersion }} - - FROM {{ .Ident "resource_version" }} - WHERE 1 = 1 - AND {{ .Ident "group" }} = {{ .Arg .Group }} - AND {{ .Ident "resource" }} = {{ .Arg .Resource }} -; diff --git a/pkg/storage/unified/sqlnext/data/rv_inc.sql b/pkg/storage/unified/sqlnext/data/rv_inc.sql deleted file mode 100644 index da824f71323..00000000000 --- a/pkg/storage/unified/sqlnext/data/rv_inc.sql +++ /dev/null @@ -1,9 +0,0 @@ -UPDATE {{ .Ident "resource_version" }} - SET - {{ .Ident "rv" }} = {{ .Arg .ResourceVersion }} + 1, - - WHERE 1 = 1 - AND {{ .Ident "group" }} = {{ .Arg .Group }} - AND {{ .Ident "resource" }} = {{ .Arg .Resource }} - AND {{ .Ident "rv" }} = {{ .Arg .ResourceVersion }} -; diff --git a/pkg/storage/unified/sqlnext/data/rv_insert.sql b/pkg/storage/unified/sqlnext/data/rv_insert.sql deleted file mode 100644 index 88d3cc62866..00000000000 --- a/pkg/storage/unified/sqlnext/data/rv_insert.sql +++ /dev/null @@ -1,13 +0,0 @@ -INSERT INTO {{ .Ident "resource_version" }} - ( - {{ .Ident "group" }}, - {{ .Ident "resource" }}, - {{ .Ident "rv" }}, - ) - - VALUES ( - {{ .Arg .Group }}, - {{ .Arg .Resource }}, - 1, - ) -; diff --git a/pkg/storage/unified/sqlnext/data/rv_lock.sql b/pkg/storage/unified/sqlnext/data/rv_lock.sql deleted file mode 100644 index 773a9208e0e..00000000000 --- a/pkg/storage/unified/sqlnext/data/rv_lock.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT {{ .Ident "rv" | .Into .ResourceVersion }} - FROM {{ .Ident "resource_version" }} - WHERE 1 = 1 - AND {{ .Ident "group" }} = {{ .Arg .Group }} - AND {{ .Ident "resource" }} = {{ .Arg .Resource }} - {{ .SelectFor "UPDATE" }} -; diff --git a/pkg/storage/unified/sqlnext/resource_mig.go b/pkg/storage/unified/sqlnext/resource_mig.go deleted file mode 100644 index 8a9d1d776c0..00000000000 --- a/pkg/storage/unified/sqlnext/resource_mig.go +++ /dev/null @@ -1,161 +0,0 @@ -package sqlnext - -import ( - "fmt" - - "github.com/grafana/grafana/pkg/services/sqlstore/migrator" -) - -func InitResourceTables(mg *migrator.Migrator) string { - marker := "Initialize resource tables (vX)" // changing this key wipe+rewrite everything - mg.AddMigration(marker, &migrator.RawSQLMigration{}) - - tables := []migrator.Table{} - - // This table helps support incrementing the resource version within a group+resource - tables = append(tables, migrator.Table{ - Name: "resource_version", - Columns: []*migrator.Column{ - {Name: "group", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, - {Name: "resource", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, - {Name: "rv", Type: migrator.DB_BigInt, Nullable: false}, // resource version - }, - Indices: []*migrator.Index{ - {Cols: []string{"group", "resource"}, Type: migrator.UniqueIndex}, - }, - }) - - tables = append(tables, migrator.Table{ - Name: "resource", // write only log? all events - Columns: []*migrator.Column{ - // SnowflakeID -- Each Create/Update/Delete call is an event - // Using snowflake ID doubles this field as an approximate timestamp - {Name: "event", Type: migrator.DB_BigInt, Nullable: false, IsPrimaryKey: true}, - - // This will be null on insert, and then updated once we are ready to commit the transaction - {Name: "rv", Type: migrator.DB_BigInt, Nullable: true}, - {Name: "previous_rv", Type: migrator.DB_BigInt, Nullable: true}, // needed? - - // Allows fast search for the first page in any query. - // Subsequent pages must use MAX(rv) AND is_compacted=false GROUP ... - {Name: "is_current", Type: migrator.DB_Bool, Nullable: false}, - - // Indicates that this is no longer the current version - // This value is updated every few minutes and makes the paged queries more efficient - {Name: "is_compacted", Type: migrator.DB_Bool, Nullable: false}, - - // Properties that exist in path/key (and duplicated in the json value) - {Name: "group", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, - {Name: "api_version", Type: migrator.DB_NVarchar, Length: 32, Nullable: false}, - {Name: "namespace", Type: migrator.DB_NVarchar, Length: 63, Nullable: true}, // namespace is not required (cluster scope) - {Name: "resource", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, - {Name: "name", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, - - // The operation that wrote this resource version - // 1: created, 2: updated, 3: deleted - {Name: "operation", Type: migrator.DB_Int, Nullable: false}, - - // Optional Commit message (currently only used for dashboards) - {Name: "message", Type: migrator.DB_Text, Nullable: false}, // defaults to empty string - - // The k8s resource JSON text (without the resourceVersion populated) - {Name: "value", Type: migrator.DB_MediumText, Nullable: false}, - - // Content hash -- this is appropriate to use for an etag value - {Name: "hash", Type: migrator.DB_NVarchar, Length: 32, Nullable: false}, - - // Path to linked blob (or null). This blob may be saved in SQL, or in an object store - {Name: "blob_uid", Type: migrator.DB_NVarchar, Length: 60, Nullable: true}, - }, - Indices: []*migrator.Index{ - {Cols: []string{"rv"}, Type: migrator.UniqueIndex}, - {Cols: []string{"is_current"}, Type: migrator.IndexType}, - {Cols: []string{"is_compacted"}, Type: migrator.IndexType}, - {Cols: []string{"operation"}, Type: migrator.IndexType}, - {Cols: []string{"namespace"}, Type: migrator.IndexType}, - {Cols: []string{"group", "resource", "name"}, Type: migrator.IndexType}, - {Cols: []string{"blob_uid"}, Type: migrator.IndexType}, - }, - }) - - // The values in this table are created by parsing the the value JSON and writing these as searchable columns - // These *could* be in the same table, but this structure allows us to replace the table by first - // building a parallel structure, then swapping them... maybe :) - tables = append(tables, migrator.Table{ - Name: "resource_meta", // write only log? all events - Columns: []*migrator.Column{ - {Name: "event", Type: migrator.DB_BigInt, Nullable: false, IsPrimaryKey: true}, - - // Hashed label set - {Name: "label_set", Type: migrator.DB_NVarchar, Length: 64, Nullable: true}, // null is no labels - - // Helpful filters - {Name: "folder", Type: migrator.DB_NVarchar, Length: 190, Nullable: true}, // uid of folder - - // For sorting values come from metadata.annotations#grafana.app/* - {Name: "created_at", Type: migrator.DB_BigInt, Nullable: false}, - {Name: "updated_at", Type: migrator.DB_BigInt, Nullable: false}, - - // Origin metadata helps implement efficient provisioning checks - {Name: "origin", Type: migrator.DB_NVarchar, Length: 64, Nullable: true}, // The origin name - {Name: "origin_path", Type: migrator.DB_Text, Nullable: true}, // Path to resource - {Name: "origin_hash", Type: migrator.DB_NVarchar, Length: 128, Nullable: true}, // Origin hash - {Name: "origin_ts", Type: migrator.DB_BigInt, Nullable: true}, // Origin timestamp - }, - Indices: []*migrator.Index{ - {Cols: []string{"event"}, Type: migrator.IndexType}, - {Cols: []string{"folder"}, Type: migrator.IndexType}, - {Cols: []string{"created_at"}, Type: migrator.IndexType}, - {Cols: []string{"updated_at"}, Type: migrator.IndexType}, - {Cols: []string{"origin"}, Type: migrator.IndexType}, - }, - }) - - // This table is optional, blobs can also be saved to object store or disk - // This is an append only store - tables = append(tables, migrator.Table{ - Name: "resource_blob", // even things that failed? - Columns: []*migrator.Column{ - {Name: "uid", Type: migrator.DB_NVarchar, Length: 60, Nullable: false, IsPrimaryKey: true}, - {Name: "value", Type: migrator.DB_Blob, Nullable: true}, - {Name: "etag", Type: migrator.DB_NVarchar, Length: 64, Nullable: false}, - {Name: "size", Type: migrator.DB_BigInt, Nullable: false}, - {Name: "content_type", Type: migrator.DB_NVarchar, Length: 255, Nullable: false}, - - // These is used for auditing and cleanup (could be path?) - {Name: "namespace", Type: migrator.DB_NVarchar, Length: 63, Nullable: true}, - {Name: "group", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, - {Name: "resource", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, - {Name: "name", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, - }, - Indices: []*migrator.Index{ - {Cols: []string{"uid"}, Type: migrator.UniqueIndex}, - - // Used for auditing - {Cols: []string{"namespace", "group", "resource", "name"}, Type: migrator.IndexType}, - }, - }) - - tables = append(tables, migrator.Table{ - Name: "resource_label_set", - Columns: []*migrator.Column{ - {Name: "label_set", Type: migrator.DB_NVarchar, Length: 64, Nullable: false}, - {Name: "label", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, - {Name: "value", Type: migrator.DB_Text, Nullable: false}, - }, - Indices: []*migrator.Index{ - {Cols: []string{"label_set", "label"}, Type: migrator.UniqueIndex}, - }, - }) - - // Initialize all tables - for t := range tables { - mg.AddMigration("drop table "+tables[t].Name, migrator.NewDropTableMigration(tables[t].Name)) - mg.AddMigration("create table "+tables[t].Name, migrator.NewAddTableMigration(tables[t])) - for i := range tables[t].Indices { - mg.AddMigration(fmt.Sprintf("create table %s, index: %d", tables[t].Name, i), migrator.NewAddIndexMigration(tables[t], tables[t].Indices[i])) - } - } - - return marker -} diff --git a/pkg/storage/unified/sqlnext/sql_resources.go b/pkg/storage/unified/sqlnext/sql_resources.go deleted file mode 100644 index 93484ed34d6..00000000000 --- a/pkg/storage/unified/sqlnext/sql_resources.go +++ /dev/null @@ -1,160 +0,0 @@ -package sqlnext - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/prometheus/client_golang/prometheus" - "go.opentelemetry.io/otel/trace" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/sqlstore/migrator" - "github.com/grafana/grafana/pkg/services/sqlstore/session" - "github.com/grafana/grafana/pkg/services/store/entity/db" - "github.com/grafana/grafana/pkg/services/store/entity/sqlstash" - "github.com/grafana/grafana/pkg/storage/unified/resource" - "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" -) - -// Package-level errors. -var ( - ErrNotImplementedYet = errors.New("not implemented yet (sqlnext)") -) - -func ProvideSQLResourceServer(db db.EntityDBInterface, tracer tracing.Tracer) (resource.ResourceServer, error) { - ctx, cancel := context.WithCancel(context.Background()) - - store := &sqlResourceStore{ - db: db, - log: log.New("sql-resource-server"), - ctx: ctx, - cancel: cancel, - tracer: tracer, - } - - if err := prometheus.Register(sqlstash.NewStorageMetrics()); err != nil { - return nil, err - } - - return resource.NewResourceServer(resource.ResourceServerOptions{ - Tracer: tracer, - Backend: store, - Diagnostics: store, - Lifecycle: store, - }) -} - -type sqlResourceStore struct { - log log.Logger - db db.EntityDBInterface // needed to keep xorm engine in scope - sess *session.SessionDB - dialect migrator.Dialect - ctx context.Context // TODO: remove - cancel context.CancelFunc - tracer trace.Tracer - - //broadcaster sqlstash.Broadcaster[*resource.WatchEvent] - //stream chan *resource.WatchEvent - - sqlDB db.DB - sqlDialect sqltemplate.Dialect -} - -func (s *sqlResourceStore) Init() error { - if s.sess != nil { - return nil - } - - if s.db == nil { - return errors.New("missing db") - } - - err := s.db.Init() - if err != nil { - return err - } - - sqlDB, err := s.db.GetDB() - if err != nil { - return err - } - s.sqlDB = sqlDB - - driverName := sqlDB.DriverName() - driverName = strings.TrimSuffix(driverName, "WithHooks") - switch driverName { - case db.DriverMySQL: - s.sqlDialect = sqltemplate.MySQL - case db.DriverPostgres: - s.sqlDialect = sqltemplate.PostgreSQL - case db.DriverSQLite, db.DriverSQLite3: - s.sqlDialect = sqltemplate.SQLite - default: - return fmt.Errorf("no dialect for driver %q", driverName) - } - - sess, err := s.db.GetSession() - if err != nil { - return err - } - - engine, err := s.db.GetEngine() - if err != nil { - return err - } - - s.sess = sess - s.dialect = migrator.NewDialect(engine.DriverName()) - - // TODO.... set up the broadcaster - - return nil -} - -func (s *sqlResourceStore) IsHealthy(ctx context.Context, r *resource.HealthCheckRequest) (*resource.HealthCheckResponse, error) { - // ctxLogger := s.log.FromContext(log.WithContextualAttributes(ctx, []any{"method", "isHealthy"})) - - if err := s.sqlDB.PingContext(ctx); err != nil { - return nil, err - } - // TODO: check the status of the watcher implementation as well - return &resource.HealthCheckResponse{Status: resource.HealthCheckResponse_SERVING}, nil -} - -func (s *sqlResourceStore) Stop() { - s.cancel() -} - -func (s *sqlResourceStore) WriteEvent(ctx context.Context, event resource.WriteEvent) (int64, error) { - _, span := s.tracer.Start(ctx, "storage_server.WriteEvent") - defer span.End() - - // TODO... actually write write the event! - - return 0, ErrNotImplementedYet -} - -func (s *sqlResourceStore) WatchWriteEvents(ctx context.Context) (<-chan *resource.WrittenEvent, error) { - return nil, ErrNotImplementedYet -} - -func (s *sqlResourceStore) Read(ctx context.Context, req *resource.ReadRequest) (*resource.ReadResponse, error) { - _, span := s.tracer.Start(ctx, "storage_server.GetResource") - defer span.End() - - fmt.Printf("TODO, GET: %+v", req.Key) - - return nil, ErrNotImplementedYet -} - -func (s *sqlResourceStore) PrepareList(ctx context.Context, req *resource.ListRequest) (*resource.ListResponse, error) { - _, span := s.tracer.Start(ctx, "storage_server.List") - defer span.End() - - fmt.Printf("TODO, LIST: %+v", req.Options.Key) - - return nil, ErrNotImplementedYet -} diff --git a/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx b/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx index ca58940a152..6de0eb2d84f 100644 --- a/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx @@ -1,16 +1,19 @@ import { css } from '@emotion/css'; import { DOMAttributes } from '@react-types/shared'; -import { memo, forwardRef } from 'react'; +import { memo, forwardRef, useCallback } from 'react'; import { useLocation } from 'react-router-dom'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { config, reportInteraction } from '@grafana/runtime'; import { CustomScrollbar, Icon, IconButton, useStyles2, Stack } from '@grafana/ui'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { t } from 'app/core/internationalization'; +import { usePatchUserPreferencesMutation } from 'app/features/preferences/api/index'; import { useSelector } from 'app/types'; import { MegaMenuItem } from './MegaMenuItem'; +import { usePinnedItems } from './hooks'; import { enrichWithInteractionTracking, getActiveItem } from './utils'; export const MENU_WIDTH = '300px'; @@ -26,6 +29,8 @@ export const MegaMenu = memo( const location = useLocation(); const { chrome } = useGrafana(); const state = chrome.useState(); + const [patchPreferences] = usePatchUserPreferencesMutation(); + const pinnedItems = usePinnedItems(); // Remove profile + help from tree const navItems = navTree @@ -46,6 +51,35 @@ export const MegaMenu = memo( }); }; + const isPinned = useCallback( + (id?: string) => { + if (!id || !pinnedItems?.length) { + return false; + } + return pinnedItems?.includes(id); + }, + [pinnedItems] + ); + + const onPinItem = (id?: string) => { + if (id && config.featureToggles.pinNavItems) { + const navItem = navTree.find((item) => item.id === id); + const isSaved = isPinned(id); + const newItems = isSaved ? pinnedItems.filter((i) => id !== i) : [...pinnedItems, id]; + const interactionName = isSaved ? 'grafana_nav_item_unpinned' : 'grafana_nav_item_pinned'; + reportInteraction(interactionName, { + path: navItem?.url ?? id, + }); + patchPreferences({ + patchPrefsCmd: { + navbar: { + savedItemIds: newItems, + }, + }, + }); + } + }; + return (
@@ -79,8 +113,10 @@ export const MegaMenu = memo( )} ))} diff --git a/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx index aee471e961d..ee5d85cc97c 100644 --- a/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItem.tsx @@ -19,11 +19,13 @@ interface Props { activeItem?: NavModelItem; onClick?: () => void; level?: number; + onPin: (id?: string) => void; + isPinned: (id?: string) => boolean; } const MAX_DEPTH = 2; -export function MegaMenuItem({ link, activeItem, level = 0, onClick }: Props) { +export function MegaMenuItem({ link, activeItem, level = 0, onClick, onPin, isPinned }: Props) { const { chrome } = useGrafana(); const state = chrome.useState(); const menuIsDocked = state.megaMenuDocked; @@ -102,6 +104,9 @@ export function MegaMenuItem({ link, activeItem, level = 0, onClick }: Props) { }} target={link.target} url={link.url} + id={link.id} + onPin={onPin} + isPinned={isPinned(link.id)} >
)) ) : ( diff --git a/public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx index c6d15f4ab95..573bf0190c0 100644 --- a/public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; import { Icon, Link, useTheme2 } from '@grafana/ui'; export interface Props { @@ -11,9 +12,12 @@ export interface Props { onClick?: () => void; target?: HTMLAnchorElement['target']; url: string; + id?: string; + onPin: (id?: string) => void; + isPinned?: boolean; } -export function MegaMenuItemText({ children, isActive, onClick, target, url }: Props) { +export function MegaMenuItemText({ children, isActive, onClick, target, url, id, onPin, isPinned }: Props) { const theme = useTheme2(); const styles = getStyles(theme, isActive); const LinkComponent = !target && url.startsWith('/') ? Link : 'a'; @@ -26,6 +30,17 @@ export function MegaMenuItemText({ children, isActive, onClick, target, url }: P // As nav links are supposed to link to internal urls this option should be used with caution target === '_blank' && } + {config.featureToggles.pinNavItems && ( + { + e.preventDefault(); + e.stopPropagation(); + onPin(id); + }} + /> + )}
); @@ -90,5 +105,17 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({ gap: '0.5rem', height: '100%', width: '100%', + justifyContent: 'space-between', + '.pin-icon': { + display: 'none', + padding: theme.spacing(0.5), + width: theme.spacing(3), + height: theme.spacing(3), + }, + '&:hover': { + '.pin-icon': { + display: 'block', + }, + }, }), }); diff --git a/public/app/core/components/AppChrome/MegaMenu/hooks.ts b/public/app/core/components/AppChrome/MegaMenu/hooks.ts new file mode 100644 index 00000000000..0334df81fd0 --- /dev/null +++ b/public/app/core/components/AppChrome/MegaMenu/hooks.ts @@ -0,0 +1,14 @@ +import { useMemo } from 'react'; + +import { config } from '@grafana/runtime'; +import { useGetUserPreferencesQuery } from 'app/features/preferences/api'; + +export const usePinnedItems = () => { + const preferences = useGetUserPreferencesQuery(); + const pinnedItems = useMemo(() => preferences.data?.navbar?.savedItemIds || [], [preferences]); + + if (config.featureToggles.pinNavItems) { + return pinnedItems; + } + return []; +}; diff --git a/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx b/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx index 6f6a667365a..cbe06efd0aa 100644 --- a/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx +++ b/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { MouseEvent } from 'react'; import { selectors } from '@grafana/e2e-selectors'; -import { Button, CallToActionCard, Icon, IconName, LinkButton } from '@grafana/ui'; +import { Alert, Button, CallToActionCard, Icon, IconName, LinkButton } from '@grafana/ui'; export interface Props { title: string; @@ -59,10 +59,9 @@ const EmptyListCTA = ({ '' )} {infoBox ? ( -
- {infoBoxTitle &&
{infoBoxTitle}
} +
-
+
) : ( '' )} diff --git a/public/app/core/components/TagFilter/TagBadge.tsx b/public/app/core/components/TagFilter/TagBadge.tsx index c685d7c66e4..d5cdce494b5 100644 --- a/public/app/core/components/TagFilter/TagBadge.tsx +++ b/public/app/core/components/TagFilter/TagBadge.tsx @@ -1,6 +1,8 @@ +import { css } from '@emotion/css'; import * as React from 'react'; -import { getTagColorsFromName, Icon } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { getTagColorsFromName, Icon, useStyles2 } from '@grafana/ui'; export interface Props { label: string; @@ -9,26 +11,39 @@ export interface Props { onClick?: React.MouseEventHandler; } -export class TagBadge extends React.Component { - constructor(props: Props) { - super(props); - } +export const TagBadge = ({ count, label, onClick, removeIcon }: Props) => { + const { color } = getTagColorsFromName(label); + const styles = useStyles2(getStyles); - render() { - const { label, removeIcon, count, onClick } = this.props; - const { color } = getTagColorsFromName(label); + const countLabel = count !== 0 && {`(${count})`}; - const tagStyle = { - backgroundColor: color, - }; + return ( + + {removeIcon && } + {label} {countLabel} + + ); +}; - const countLabel = count !== 0 && {`(${count})`}; - - return ( - - {removeIcon && } - {label} {countLabel} - - ); - } -} +export const getStyles = (theme: GrafanaTheme2) => ({ + badge: css({ + ...theme.typography.bodySmall, + backgroundColor: theme.v1.palette.gray1, + borderRadius: theme.shape.radius.default, + color: theme.v1.palette.white, + display: 'inline-block', + height: '20px', + lineHeight: '20px', + padding: theme.spacing(0, 0.75), + verticalAlign: 'baseline', + whiteSpace: 'nowrap', + '&:hover': { + opacity: 0.85, + }, + }), +}); diff --git a/public/app/core/components/TagFilter/TagFilter.tsx b/public/app/core/components/TagFilter/TagFilter.tsx index 98081dd6717..26732fc4eb4 100644 --- a/public/app/core/components/TagFilter/TagFilter.tsx +++ b/public/app/core/components/TagFilter/TagFilter.tsx @@ -6,7 +6,7 @@ import { escapeStringForRegex, GrafanaTheme2 } from '@grafana/data'; import { Icon, MultiSelect, useStyles2 } from '@grafana/ui'; import { t } from 'app/core/internationalization'; -import { TagBadge } from './TagBadge'; +import { TagBadge, getStyles as getTagBadgeStyles } from './TagBadge'; import { TagOption, TagSelectOption } from './TagOption'; export interface TermCount { @@ -167,31 +167,35 @@ export const TagFilter = ({ TagFilter.displayName = 'TagFilter'; -const getStyles = (theme: GrafanaTheme2) => ({ - tagFilter: css` - position: relative; - min-width: 180px; - flex-grow: 1; +const getStyles = (theme: GrafanaTheme2) => { + const tagBadgeStyles = getTagBadgeStyles(theme); - .label-tag { - margin-left: 6px; - cursor: pointer; - } - `, - clear: css` - background: none; - border: none; - text-decoration: underline; - font-size: 12px; - padding: none; - position: absolute; - top: -17px; - right: 0; - cursor: pointer; - color: ${theme.colors.text.secondary}; + return { + tagFilter: css({ + position: 'relative', + minWidth: '180px', + flexGrow: 1, - &:hover { - color: ${theme.colors.text.primary}; - } - `, -}); + [`.${tagBadgeStyles.badge}`]: { + marginLeft: '6px', + cursor: 'pointer', + }, + }), + clear: css({ + background: 'none', + border: 'none', + textDecoration: 'underline', + fontSize: '12px', + padding: 'none', + position: 'absolute', + top: '-17px', + right: 0, + cursor: 'pointer', + color: theme.colors.text.secondary, + + '&:hover': { + color: theme.colors.text.primary, + }, + }), + }; +}; diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index ec33626bc43..73b73b52d4d 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -28,6 +28,7 @@ import usersReducers from 'app/features/users/state/reducers'; import templatingReducers from 'app/features/variables/state/keyedVariablesReducer'; import { alertingApi } from '../../features/alerting/unified/api/alertingApi'; +import { userPreferencesAPI } from '../../features/preferences/api'; import { queryLibraryApi } from '../../features/query-library/api/factory'; import { cleanUpAction } from '../actions/cleanUp'; @@ -59,6 +60,7 @@ const rootReducers = { [browseDashboardsAPI.reducerPath]: browseDashboardsAPI.reducer, [cloudMigrationAPI.reducerPath]: cloudMigrationAPI.reducer, [queryLibraryApi.reducerPath]: queryLibraryApi.reducer, + [userPreferencesAPI.reducerPath]: userPreferencesAPI.reducer, }; const addedReducers = {}; diff --git a/public/app/core/utils/fetch.ts b/public/app/core/utils/fetch.ts index 49253f74a31..8b4031123b7 100644 --- a/public/app/core/utils/fetch.ts +++ b/public/app/core/utils/fetch.ts @@ -1,6 +1,6 @@ import { omitBy } from 'lodash'; -import { deprecationWarning } from '@grafana/data'; +import { deprecationWarning, safeStringifyValue } from '@grafana/data'; import { BackendSrvRequest } from '@grafana/runtime'; export const parseInitFromOptions = (options: BackendSrvRequest): RequestInit => { @@ -93,7 +93,7 @@ export const parseBody = (options: BackendSrvRequest, isAppJson: boolean) => { return options.data; } - return isAppJson ? JSON.stringify(options.data) : new URLSearchParams(options.data); + return isAppJson ? safeStringifyValue(options.data) : new URLSearchParams(options.data); }; export async function parseResponseBody( diff --git a/public/app/features/admin/AdminSettings.tsx b/public/app/features/admin/AdminSettings.tsx index fd9a4abf5a9..ddad7b7993a 100644 --- a/public/app/features/admin/AdminSettings.tsx +++ b/public/app/features/admin/AdminSettings.tsx @@ -1,6 +1,7 @@ import { useAsync } from 'react-use'; import { getBackendSrv } from '@grafana/runtime'; +import { Alert } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { AdminSettingsTable } from './AdminSettingsTable'; @@ -13,10 +14,10 @@ function AdminSettings() { return ( -
+ These system settings are defined in grafana.ini or custom.ini (or overridden in ENV variables). To change these you currently need to restart Grafana. -
+ {loading && } diff --git a/public/app/features/admin/UserLdapSyncInfo.tsx b/public/app/features/admin/UserLdapSyncInfo.tsx index df2f270c620..f76ca72a5cd 100644 --- a/public/app/features/admin/UserLdapSyncInfo.tsx +++ b/public/app/features/admin/UserLdapSyncInfo.tsx @@ -5,6 +5,8 @@ import { Button, LinkButton } from '@grafana/ui'; import { contextSrv } from 'app/core/core'; import { AccessControlAction, SyncInfo, UserDTO } from 'app/types'; +import { TagBadge } from '../../core/components/TagFilter/TagBadge'; + interface Props { ldapSyncInfo: SyncInfo; user: UserDTO; @@ -40,7 +42,7 @@ export class UserLdapSyncInfo extends PureComponent { External sync User synced via LDAP. Some changes must be done in LDAP or mappings. - LDAP + diff --git a/public/app/features/alerting/unified/AlertsFolderView.tsx b/public/app/features/alerting/unified/AlertsFolderView.tsx index 1beb52abf25..bfebca0c03a 100644 --- a/public/app/features/alerting/unified/AlertsFolderView.tsx +++ b/public/app/features/alerting/unified/AlertsFolderView.tsx @@ -14,8 +14,9 @@ import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces'; import { usePagination } from './hooks/usePagination'; import { useURLSearchParams } from './hooks/useURLSearchParams'; import { fetchPromRulesAction, fetchRulerRulesAction } from './state/actions'; -import { combineMatcherStrings, labelsMatchMatchers, parseMatchers } from './utils/alertmanager'; +import { combineMatcherStrings, labelsMatchMatchers } from './utils/alertmanager'; import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; +import { parsePromQLStyleMatcherLooseSafe } from './utils/matchers'; import { createViewLink } from './utils/misc'; interface Props { @@ -168,7 +169,7 @@ function filterAndSortRules( labelFilter: string, sortOrder: SortOrder ) { - const matchers = parseMatchers(labelFilter); + const matchers = parsePromQLStyleMatcherLooseSafe(labelFilter); let rules = originalRules.filter( (rule) => rule.name.toLowerCase().includes(nameFilter.toLowerCase()) && labelsMatchMatchers(rule.labels, matchers) ); diff --git a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx index aaa604e5fb3..71d76da285e 100644 --- a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx +++ b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx @@ -1,6 +1,6 @@ import { render } from '@testing-library/react'; import { TestProvider } from 'test/helpers/TestProvider'; -import { byTestId } from 'testing-library-selector'; +import { byTestId, byText } from 'testing-library-selector'; import { DataSourceApi } from '@grafana/data'; import { PromOptions, PrometheusDatasource } from '@grafana/prometheus'; @@ -25,6 +25,7 @@ import { mockRulerAlertingRule, mockRulerRuleGroup, } from './mocks'; +import { captureRequests } from './mocks/server/events'; import { RuleFormValues } from './types/rule-form'; import * as config from './utils/config'; import { Annotation } from './utils/constants'; @@ -183,6 +184,7 @@ const panel = new PanelModel({ const ui = { row: byTestId('row'), createButton: byTestId('create-alert-rule-button'), + notSavedYet: byText('Dashboard not saved'), }; const server = setupMswServer(); @@ -281,6 +283,29 @@ describe('PanelAlertTabContent', () => { }); }); + it('should not make requests for unsaved dashboard', async () => { + const capture = captureRequests(); + + const unsavedDashboard = { + ...dashboard, + uid: null, + } as DashboardModel; + + renderAlertTabContent( + unsavedDashboard, + new PanelModel({ + ...panel, + datasource: undefined, + maxDataPoints: 100, + interval: '10s', + }) + ); + + expect(await ui.notSavedYet.find()).toBeInTheDocument(); + const requests = await capture; + expect(requests.length).toBe(0); + }); + it('Will take into account datasource minInterval', async () => { (getDatasourceSrv() as unknown as MockDataSourceSrv).datasources[dataSources.prometheus.uid].interval = '7m'; diff --git a/public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx b/public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx index 8643ac99555..52b7ed0652b 100644 --- a/public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx @@ -6,7 +6,7 @@ import { GrafanaTheme2 } from '@grafana/data'; import { Field, Icon, Input, Label, Stack, Tooltip, useStyles2 } from '@grafana/ui'; import { logInfo, LogMessages } from '../../Analytics'; -import { parseMatchers } from '../../utils/alertmanager'; +import { parsePromQLStyleMatcherLoose } from '../../utils/matchers'; interface Props { defaultQueryString?: string; @@ -28,13 +28,22 @@ export const MatcherFilter = ({ onFilterChange, defaultQueryString }: Props) => ); const searchIcon = ; - const inputInvalid = defaultQueryString ? parseMatchers(defaultQueryString).length === 0 : false; + let inputValid = Boolean(defaultQueryString && defaultQueryString.length >= 3); + try { + if (!defaultQueryString) { + inputValid = true; + } else { + parsePromQLStyleMatcherLoose(defaultQueryString); + } + } catch (err) { + inputValid = false; + } return ( diff --git a/public/app/features/alerting/unified/components/notification-policies/EditDefaultPolicyForm.tsx b/public/app/features/alerting/unified/components/notification-policies/EditDefaultPolicyForm.tsx index 4c48f4c1a3a..5f75bfcde1c 100644 --- a/public/app/features/alerting/unified/components/notification-policies/EditDefaultPolicyForm.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/EditDefaultPolicyForm.tsx @@ -87,7 +87,7 @@ export const AmRootRouteForm = ({ { - const matchers = parseMatchers(queryString ?? '').map(matcherToObjectMatcher); + const matchers = parsePromQLStyleMatcherLooseSafe(queryString ?? '').map(matcherToObjectMatcher); handleChangeLabels()(matchers); }, [handleChangeLabels, queryString]); @@ -50,7 +54,17 @@ const NotificationPoliciesFilter = ({ const selectedContactPoint = receiverOptions.find((option) => option.value === contactPoint) ?? null; const hasFilters = queryString || contactPoint; - const inputInvalid = queryString && queryString.length > 3 ? parseMatchers(queryString).length === 0 : false; + + let inputValid = Boolean(queryString && queryString.length > 3); + try { + if (!queryString) { + inputValid = true; + } else { + parsePromQLStyleMatcherLoose(queryString); + } + } catch (err) { + inputValid = false; + } return ( @@ -73,8 +87,8 @@ const NotificationPoliciesFilter = ({ } - invalid={inputInvalid} - error={inputInvalid ? 'Query must use valid matcher syntax' : null} + invalid={!inputValid} + error={!inputValid ? 'Query must use valid matcher syntax' : null} > <> - Firing alert rule instances are routed to notification policies based on matching labels. All alert rules - and instances, irrespective of their labels, match the default notification policy. If there are no nested - policies, or no nested policies match the labels in the alert rule or alert instance, then the default - notification policy is the matching policy. + Firing alert instances are routed to notification policies based on matching labels. The default + notification policy matches all alert instances. - - - Read about notification routing. - - <> @@ -194,12 +183,12 @@ function NeedHelpInfoForNotificationPolicy() { connect them to your notification policy by adding label matchers. - Read about Labels and annotations. + Read about notification policies. @@ -220,20 +209,18 @@ function NeedHelpInfoForContactpoint() {
Notifications for firing alert instances are grouped based on folder and alert rule name.
- The waiting time until the initial notification is sent for a new group created by an incoming alert is 30 - seconds. + The wait time before sending the first notification for a new group of alerts is 30 seconds.
- The waiting time to send a batch of new alerts for that group after the first notification was sent is 5 - minutes. + The waiting time before sending a notification about changes in the alert group after the first notification + has been sent is 5 minutes.
- The waiting time to resend an alert after they have successfully been sent is 4 hours. + The wait time before resending a notification that has already been sent successfully is 4 hours.
Grouping and wait time values are defined in your default notification policy. } - // todo: update the link with the new documentation about simplified routing - externalLink="`https://grafana.com/docs/grafana/latest/alerting/fundamentals/notification-policies/notifications/`" - linkText="Read more about notifiying contact points" + externalLink="https://grafana.com/docs/grafana/latest/alerting/fundamentals/notifications/" + linkText="Read more about notifications" title="Notify contact points" /> ); diff --git a/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx b/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx index d8e1d43b7a9..7862535b4e7 100644 --- a/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/QueryRows.tsx @@ -5,6 +5,7 @@ import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd'; import { DataQuery, DataSourceInstanceSettings, + getDataSourceRef, LoadingState, PanelData, rangeUtil, @@ -226,10 +227,7 @@ function copyModel(item: AlertQuery, settings: DataSourceInstanceSettings): Omit ...item, model: { ...omit(item.model, 'datasource'), - datasource: { - type: settings.type, - uid: settings.uid, - }, + datasource: getDataSourceRef(settings), }, datasourceUid: settings.uid, }; @@ -244,10 +242,7 @@ function newModel(item: AlertQuery, settings: DataSourceInstanceSettings): Omit< model: { refId: item.refId, hide: false, - datasource: { - type: settings.type, - uid: settings.uid, - }, + datasource: getDataSourceRef(settings), }, }; } diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/RouteSettings.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/RouteSettings.tsx index cfbc879842a..094c9277ecd 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/RouteSettings.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/RouteSettings.tsx @@ -68,7 +68,7 @@ export const RoutingSettings = ({ alertManager }: RoutingSettingsProps) => { {overrideGrouping && ( ; diff --git a/public/app/features/alerting/unified/components/rules/NoRulesCTA.tsx b/public/app/features/alerting/unified/components/rules/NoRulesCTA.tsx index 0e635b2dbd7..b9deb610400 100644 --- a/public/app/features/alerting/unified/components/rules/NoRulesCTA.tsx +++ b/public/app/features/alerting/unified/components/rules/NoRulesCTA.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; -import { GrafanaTheme2 } from '@grafana/data/src/themes'; +import { GrafanaTheme2 } from '@grafana/data'; import { CallToActionCard, useStyles2, Stack } from '@grafana/ui'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx index 80ae03c9e10..c9491eda390 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx @@ -10,13 +10,14 @@ import { AlertInstanceStateFilter, InstanceStateFilter, } from 'app/features/alerting/unified/components/rules/AlertInstanceStateFilter'; -import { labelsMatchMatchers, parseMatchers } from 'app/features/alerting/unified/utils/alertmanager'; +import { labelsMatchMatchers } from 'app/features/alerting/unified/utils/alertmanager'; import { createViewLink, sortAlerts } from 'app/features/alerting/unified/utils/misc'; import { SortOrder } from 'app/plugins/panel/alertlist/types'; import { Alert, CombinedRule, PaginationProps } from 'app/types/unified-alerting'; import { mapStateWithReasonToBaseState } from 'app/types/unified-alerting-dto'; import { GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from '../../utils/datasource'; +import { parsePromQLStyleMatcherLooseSafe } from '../../utils/matchers'; import { isAlertingRule } from '../../utils/rules'; import { AlertInstancesTable } from './AlertInstancesTable'; @@ -148,7 +149,7 @@ function filterAlerts( ): Alert[] { let filteredAlerts = [...alerts]; if (alertInstanceLabel) { - const matchers = parseMatchers(alertInstanceLabel || ''); + const matchers = alertInstanceLabel ? parsePromQLStyleMatcherLooseSafe(alertInstanceLabel) : []; filteredAlerts = filteredAlerts.filter(({ labels }) => labelsMatchMatchers(labels, matchers)); } if (alertInstanceState) { diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryScene.tsx b/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryScene.tsx index 6e52b27e1d2..9a1aa40e9cf 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryScene.tsx +++ b/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryScene.tsx @@ -64,7 +64,12 @@ export const CentralAlertHistoryScene = () => { new SceneTimePicker({}), new SceneRefreshPicker({}), ], - $timeRange: new SceneTimeRange({}), //needed for using the time range sync in the url + // use default time range as from 1 hour ago to now, as the limit of the history api is 5000 events, + // and using a wider time range might lead to showing gaps in the events list and the chart. + $timeRange: new SceneTimeRange({ + from: 'now-1h', + to: 'now', + }), $variables: new SceneVariableSet({ variables: [filterVariable], }), @@ -170,16 +175,16 @@ export const FilterInfo = () => { - + Filter events using label querying without spaces, ex:
{`{severity="critical", instance=~"cluster-us-.+"}`}
- Invalid use of spaces: -
{`{severity= "critical"}`}
+ Invalid use of spaces: +
{`{severity= "alerting.critical"}`}
{`{severity ="critical"}`}
- Valid use of spaces: + Valid use of spaces:
{`{severity=" critical"}`}
- + Filter alerts using label querying without braces, ex:
{`severity="critical", instance=~"cluster-us-.+"`}
diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/EventDetails.tsx b/public/app/features/alerting/unified/components/rules/central-state-history/EventDetails.tsx new file mode 100644 index 00000000000..3b42644ed13 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/central-state-history/EventDetails.tsx @@ -0,0 +1,236 @@ +import { css } from '@emotion/css'; +import { max, min, uniqBy } from 'lodash'; +import { useMemo } from 'react'; + +import { FieldType, GrafanaTheme2, LoadingState, PanelData, dateTime, makeTimeRange } from '@grafana/data'; +import { Icon, Stack, Text, useStyles2 } from '@grafana/ui'; +import { Trans, t } from 'app/core/internationalization'; +import { CombinedRule } from 'app/types/unified-alerting'; + +import { useCombinedRule } from '../../../hooks/useCombinedRule'; +import { parse } from '../../../utils/rule-id'; +import { isGrafanaRulerRule } from '../../../utils/rules'; +import { MetaText } from '../../MetaText'; +import { VizWrapper } from '../../rule-editor/VizWrapper'; +import { AnnotationValue } from '../../rule-viewer/tabs/Details'; +import { LogRecord } from '../state-history/common'; + +import { EventState } from './EventListSceneObject'; + +interface EventDetailsProps { + record: LogRecord; + logRecords: LogRecord[]; +} +export function EventDetails({ record, logRecords }: EventDetailsProps) { + // get the rule from the ruleUID + const ruleUID = record.line?.ruleUID ?? ''; + const identifier = useMemo(() => { + return parse(ruleUID, true); + }, [ruleUID]); + const { error, loading, result: rule } = useCombinedRule({ ruleIdentifier: identifier }); + + if (error) { + return ( + + Error loading rule for this event. + + ); + } + if (loading) { + return ( + + Loading... + + ); + } + + if (!rule) { + return ( + + Rule not found for this event. + + ); + } + + const getTransitionsCountByRuleUID = (ruleUID: string) => { + return logRecords.filter((record) => record.line.ruleUID === ruleUID).length; + }; + + return ( + + + + + + + + + + ); +} + +interface StateTransitionProps { + record: LogRecord; +} +function StateTransition({ record }: StateTransitionProps) { + return ( + + + State transition + + + + + + + + ); +} + +interface AnnotationsProps { + rule: CombinedRule; +} +const Annotations = ({ rule }: AnnotationsProps) => { + const styles = useStyles2(getStyles); + const annotations = rule.annotations; + if (!annotations) { + return null; + } + return ( + <> + + Annotations + + {Object.keys(annotations).length === 0 ? ( + + No annotations + + ) : ( +
+ {Object.entries(annotations).map(([name, value]) => ( + + {name} + + + ))} +
+ )} + + ); +}; + +/** + * + * This component renders the visualization for the rule condition values over the selected time range. + * The visualization is a time series graph with the condition values on the y-axis and time on the x-axis. + * The values are extracted from the log records already fetched from the history api. + * The graph is rendered only if the rule is a Grafana rule. + * + */ +interface QueryVizualizationProps { + ruleUID: string; + rule: CombinedRule; + logRecords: LogRecord[]; +} +const QueryVizualization = ({ ruleUID, rule, logRecords }: QueryVizualizationProps) => { + if (!isGrafanaRulerRule(rule?.rulerRule)) { + return ( + + Rule is not a Grafana rule + + ); + } + // get the condition from the rule + const condition = rule?.rulerRule.grafana_alert?.condition ?? 'A'; + // get the panel data for the rule + const panelData = getPanelDataForRule(ruleUID, logRecords, condition); + // render the visualization + return ; +}; + +/** + * This function returns the time series panel data for the condtion values of the rule, within the selected time range. + * The values are extracted from the log records already fetched from the history api. + * @param ruleUID + * @param logRecords + * @param condition + * @returns PanelData + */ +export function getPanelDataForRule(ruleUID: string, logRecords: LogRecord[], condition: string) { + const ruleLogRecords = logRecords + .filter((record) => record.line.ruleUID === ruleUID) + // sort by timestamp as time series data is expected to be sorted by time + .sort((a, b) => a.timestamp - b.timestamp); + + // get unique records by timestamp, as timeseries data should have unique timestamps, and it might be possible to have multiple records with the same timestamp + const uniqueRecords = uniqBy(ruleLogRecords, (record) => record.timestamp); + + const timestamps = uniqueRecords.map((record) => record.timestamp); + const values = uniqueRecords.map((record) => (record.line.values ? record.line.values[condition] : 0)); + const minTimestamp = min(timestamps); + const maxTimestamp = max(timestamps); + + const PanelDataObj: PanelData = { + series: [ + { + name: 'Rule condition history', + fields: [ + { name: 'Time', values: timestamps, config: {}, type: FieldType.time }, + { name: 'values', values: values, type: FieldType.number, config: {} }, + ], + length: timestamps.length, + }, + ], + state: LoadingState.Done, + timeRange: makeTimeRange(dateTime(minTimestamp), dateTime(maxTimestamp)), + }; + return PanelDataObj; +} + +interface ValueInTransitionProps { + record: LogRecord; +} +function ValueInTransition({ record }: ValueInTransitionProps) { + const values = record?.line?.values + ? JSON.stringify(record.line.values) + : t('alerting.central-alert-history.details.no-values', 'No values'); + return ( + + + Value in transition + + + + {values} + + + + ); +} +interface NumberTransitionsProps { + transitions: number; +} +function NumberTransitions({ transitions }: NumberTransitionsProps) { + return ( + + + + State transitions for selected period + + + + {transitions} + + + ); +} +const getStyles = (theme: GrafanaTheme2) => { + return { + metadataWrapper: css({ + display: 'grid', + gridTemplateColumns: 'auto auto', + rowGap: theme.spacing(3), + columnGap: theme.spacing(12), + }), + }; +}; diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx b/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx index 33d39a6eb01..3474049887d 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx +++ b/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx @@ -1,13 +1,13 @@ -import { css } from '@emotion/css'; -import { useMemo, useState } from 'react'; +import { css, cx } from '@emotion/css'; +import { ReactElement, useMemo, useState } from 'react'; import { useMeasure } from 'react-use'; -import { DataFrameJSON, GrafanaTheme2, TimeRange } from '@grafana/data'; +import { DataFrameJSON, GrafanaTheme2, IconName, TimeRange } from '@grafana/data'; import { isFetchError } from '@grafana/runtime'; import { SceneComponentProps, SceneObjectBase, TextBoxVariable, VariableValue, sceneGraph } from '@grafana/scenes'; -import { Alert, Icon, LoadingBar, Stack, Text, Tooltip, useStyles2, withErrorBoundary } from '@grafana/ui'; +import { Alert, Icon, LoadingBar, Pagination, Stack, Text, Tooltip, useStyles2, withErrorBoundary } from '@grafana/ui'; import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound'; -import { t } from 'app/core/internationalization'; +import { Trans, t } from 'app/core/internationalization'; import { GrafanaAlertStateWithReason, isAlertStateWithReason, @@ -17,8 +17,10 @@ import { } from 'app/types/unified-alerting-dto'; import { stateHistoryApi } from '../../../api/stateHistoryApi'; -import { labelsMatchMatchers, parseMatchers } from '../../../utils/alertmanager'; +import { usePagination } from '../../../hooks/usePagination'; +import { labelsMatchMatchers } from '../../../utils/alertmanager'; import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource'; +import { parsePromQLStyleMatcherLooseSafe } from '../../../utils/matchers'; import { stringifyErrorLike } from '../../../utils/misc'; import { AlertLabels } from '../../AlertLabels'; import { CollapseToggle } from '../../CollapseToggle'; @@ -26,8 +28,10 @@ import { LogRecord } from '../state-history/common'; import { isLine, isNumbers } from '../state-history/useRuleHistoryRecords'; import { LABELS_FILTER } from './CentralAlertHistoryScene'; +import { EventDetails } from './EventDetails'; export const LIMIT_EVENTS = 5000; // limit is hard-capped at 5000 at the BE level. +const PAGE_SIZE = 100; /** * @@ -35,13 +39,11 @@ export const LIMIT_EVENTS = 5000; // limit is hard-capped at 5000 at the BE leve * It fetches the events from the history api and displays them in a list. * The list is filtered by the labels in the filter variable and by the time range variable in the scene graph. */ -export const HistoryEventsList = ({ - timeRange, - valueInfilterTextBox, -}: { +interface HistoryEventsListProps { timeRange?: TimeRange; valueInfilterTextBox: VariableValue; -}) => { +} +export const HistoryEventsList = ({ timeRange, valueInfilterTextBox }: HistoryEventsListProps) => { const from = timeRange?.from.unix(); const to = timeRange?.to.unix(); @@ -85,12 +87,23 @@ interface HistoryLogEventsProps { logRecords: LogRecord[]; } function HistoryLogEvents({ logRecords }: HistoryLogEventsProps) { + const { page, pageItems, numberOfPages, onPageChange } = usePagination(logRecords, 1, PAGE_SIZE); return ( -
    - {logRecords.map((record) => { - return ; - })} -
+ +
    + {pageItems.map((record) => { + return ( + + ); + })} +
+ {/* This paginations improves the performance considerably , making the page load faster */} + +
); } @@ -102,17 +115,25 @@ function HistoryErrorMessage({ error }: HistoryErrorMessageProps) { if (isFetchError(error) && error.status === 404) { return ; } - const title = t('central-alert-history.error', 'Something went wrong loading the alert state history'); + const title = t('alerting.central-alert-history.error', 'Something went wrong loading the alert state history'); + const errorStr = stringifyErrorLike(error); - return {stringifyErrorLike(error)}; + return {errorStr}; } -function EventRow({ record }: { record: LogRecord }) { +interface EventRowProps { + record: LogRecord; + logRecords: LogRecord[]; +} +function EventRow({ record, logRecords }: EventRowProps) { const styles = useStyles2(getStyles); const [isCollapsed, setIsCollapsed] = useState(true); return ( -
-
+ +
-
+ {!isCollapsed && ( +
+ +
+ )} + ); } -function AlertRuleName({ labels, ruleUID }: { labels: Record; ruleUID?: string }) { +interface AlertRuleNameProps { + labels: Record; + ruleUID?: string; +} +function AlertRuleName({ labels, ruleUID }: AlertRuleNameProps) { const styles = useStyles2(getStyles); const alertRuleName = labels['alertname']; if (!ruleUID) { - return {alertRuleName}; + return ( + + Unknown + {alertRuleName} + + ); } return ( @@ -170,55 +205,90 @@ function EventTransition({ previous, current }: EventTransitionProps) { ); } -function EventState({ state }: { state: GrafanaAlertStateWithReason }) { - const styles = useStyles2(getStyles); +interface StateIconProps { + iconName: IconName; + iconColor: string; + tooltipContent: string; + labelText: ReactElement; + showLabel: boolean; +} +const StateIcon = ({ iconName, iconColor, tooltipContent, labelText, showLabel }: StateIconProps) => ( + + + + {showLabel && ( + + {labelText} + + )} + + +); +interface EventStateProps { + state: GrafanaAlertStateWithReason; + showLabel?: boolean; +} +export function EventState({ state, showLabel = false }: EventStateProps) { + const styles = useStyles2(getStyles); + const toolTip = t('alerting.central-alert-history.details.no-recognized-state', 'No recognized state'); if (!isGrafanaAlertState(state) && !isAlertStateWithReason(state)) { return ( - - - + Unknown} + showLabel={Boolean(showLabel)} + iconColor={styles.warningColor} + /> ); } const baseState = mapStateWithReasonToBaseState(state); const reason = mapStateWithReasonToReason(state); - - switch (baseState) { - case 'Normal': - return ( - - - - ); - case 'Alerting': - return ( - - - - ); - case 'NoData': //todo:change icon - return ( - - - {/* no idea which icon to use */} - - ); - case 'Error': - return ( - - - - ); - - case 'Pending': - return ( - - - - ); - default: - return ; + interface StateConfig { + iconName: IconName; + iconColor: string; + tooltipContent: string; + labelText: ReactElement; } + interface StateConfigMap { + [key: string]: StateConfig; + } + const stateConfig: StateConfigMap = { + Normal: { + iconName: 'check-circle', + iconColor: Boolean(reason) ? styles.warningColor : styles.normalColor, + tooltipContent: Boolean(reason) ? `Normal (${reason})` : 'Normal', + labelText: Normal, + }, + Alerting: { + iconName: 'exclamation-circle', + iconColor: styles.alertingColor, + tooltipContent: 'Alerting', + labelText: Alerting, + }, + NoData: { + iconName: 'exclamation-triangle', + iconColor: styles.warningColor, + tooltipContent: 'Insufficient data', + labelText: No data, + }, + Error: { + iconName: 'exclamation-circle', + tooltipContent: 'Error', + iconColor: styles.warningColor, + labelText: Error, + }, + Pending: { + iconName: 'circle', + iconColor: styles.warningColor, + tooltipContent: Boolean(reason) ? `Pending (${reason})` : 'Pending', + labelText: Pending, + }, + }; + + const config = stateConfig[baseState] || { iconName: 'exclamation-triangle', tooltipContent: 'Unknown State' }; + return ; } interface TimestampProps { @@ -253,12 +323,16 @@ export const getStyles = (theme: GrafanaTheme2) => { alignItems: 'center', padding: `${theme.spacing(1)} ${theme.spacing(1)} ${theme.spacing(1)} 0`, flexWrap: 'nowrap', - borderBottom: `1px solid ${theme.colors.border.weak}`, - '&:hover': { backgroundColor: theme.components.table.rowHoverBackground, }, }), + collapsedHeader: css({ + borderBottom: `1px solid ${theme.colors.border.weak}`, + }), + notCollapsedHeader: css({ + borderBottom: 'none', + }), collapseToggle: css({ background: 'none', @@ -303,6 +377,11 @@ export const getStyles = (theme: GrafanaTheme2) => { display: 'block', color: theme.colors.text.link, }), + expandedRow: css({ + padding: theme.spacing(2), + marginLeft: theme.spacing(2), + borderLeft: `1px solid ${theme.colors.border.weak}`, + }), }; }; @@ -334,7 +413,7 @@ function useRuleHistoryRecords(stateHistory?: DataFrameJSON, filter?: string) { return { historyRecords: [] }; } - const filterMatchers = filter ? parseMatchers(filter) : []; + const filterMatchers = filter ? parsePromQLStyleMatcherLooseSafe(filter) : []; const [tsValues, lines] = stateHistory.data.values; const timestamps = isNumbers(tsValues) ? tsValues : []; diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/getPanelDataForRule.test.ts b/public/app/features/alerting/unified/components/rules/central-state-history/getPanelDataForRule.test.ts new file mode 100644 index 00000000000..43abc2c5d6b --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/central-state-history/getPanelDataForRule.test.ts @@ -0,0 +1,53 @@ +import { dateTime } from '@grafana/data'; + +import { LogRecord } from '../state-history/common'; + +import { getPanelDataForRule } from './EventDetails'; + +const initialTimeStamp = 1000000; +const instanceLabels = { foo: 'bar', severity: 'critical', cluster: 'dev-us' }; // actually, it doesn't matter what is here +const records: LogRecord[] = [ + { + timestamp: initialTimeStamp, + line: { previous: 'Normal', current: 'Alerting', labels: instanceLabels, ruleUID: 'ruleUID1', values: { C: 1 } }, + }, + { + timestamp: initialTimeStamp + 1000, + line: { previous: 'Alerting', current: 'Normal', labels: instanceLabels, ruleUID: 'ruleUID2' }, + }, + { + timestamp: initialTimeStamp + 2000, + line: { previous: 'Normal', current: 'Alerting', labels: instanceLabels, ruleUID: 'ruleUID3' }, + }, + // not sorted by timestamp + { + timestamp: initialTimeStamp + 4000, + line: { previous: 'Normal', current: 'Alerting', labels: instanceLabels, ruleUID: 'ruleUID1', values: { C: 8 } }, + }, + { + timestamp: initialTimeStamp + 3000, + line: { previous: 'Alerting', current: 'Normal', labels: instanceLabels, ruleUID: 'ruleUID1', values: { C: 0 } }, + }, + //duplicate record in the same timestamp + { + timestamp: initialTimeStamp + 3000, + line: { previous: 'Alerting', current: 'Normal', labels: instanceLabels, ruleUID: 'ruleUID1', values: { C: 0 } }, + }, + { + timestamp: initialTimeStamp + 5000, + line: { previous: 'Alerting', current: 'Normal', labels: instanceLabels, ruleUID: 'ruleUID1', values: { C: 0 } }, + }, +]; +describe('getPanelDataForRule', () => { + it('should return correct panel data for a given rule (sorted by time and unique)', () => { + const result = getPanelDataForRule('ruleUID1', records, 'C'); + + expect(result.series[0].fields[0].values).toEqual([1000000, 1003000, 1004000, 1005000]); + expect(result.series[0].fields[1].values).toEqual([1, 0, 8, 0]); + expect(result.series[0].fields[0].type).toEqual('time'); + expect(result.series[0].fields[1].type).toEqual('number'); + expect(result.state).toEqual('Done'); + expect(result.timeRange.from).toEqual(dateTime(1000000)); + expect(result.timeRange.to).toEqual(dateTime(1005000)); + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts b/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts index 02cd7a7dc8d..324849c6da5 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts +++ b/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts @@ -3,7 +3,8 @@ import { groupBy } from 'lodash'; import { DataFrame, Field as DataFrameField, DataFrameJSON, Field, FieldType } from '@grafana/data'; import { fieldIndexComparer } from '@grafana/data/src/field/fieldComparers'; -import { labelsMatchMatchers, parseMatchers } from '../../../utils/alertmanager'; +import { labelsMatchMatchers } from '../../../utils/alertmanager'; +import { parsePromQLStyleMatcherLooseSafe } from '../../../utils/matchers'; import { LogRecord } from '../state-history/common'; import { isLine, isNumbers } from '../state-history/useRuleHistoryRecords'; @@ -61,7 +62,7 @@ function groupDataFramesByTimeAndFilterByLabels(dataFrames: DataFrame[]): DataFr const filterValue = getFilterInQueryParams(); const dataframesFiltered = dataFrames.filter((frame) => { const labels = JSON.parse(frame.name ?? ''); // in name we store the labels stringified - const matchers = Boolean(filterValue) ? parseMatchers(filterValue) : []; + const matchers = Boolean(filterValue) ? parsePromQLStyleMatcherLooseSafe(filterValue) : []; return labelsMatchMatchers(labels, matchers); }); // Extract time fields from filtered data frames diff --git a/public/app/features/alerting/unified/components/rules/state-history/useRuleHistoryRecords.tsx b/public/app/features/alerting/unified/components/rules/state-history/useRuleHistoryRecords.tsx index 67efa185b3a..8187fb06e8a 100644 --- a/public/app/features/alerting/unified/components/rules/state-history/useRuleHistoryRecords.tsx +++ b/public/app/features/alerting/unified/components/rules/state-history/useRuleHistoryRecords.tsx @@ -13,7 +13,8 @@ import { fieldIndexComparer } from '@grafana/data/src/field/fieldComparers'; import { MappingType, ThresholdsMode } from '@grafana/schema'; import { useTheme2 } from '@grafana/ui'; -import { labelsMatchMatchers, parseMatchers } from '../../../utils/alertmanager'; +import { labelsMatchMatchers } from '../../../utils/alertmanager'; +import { parsePromQLStyleMatcherLooseSafe } from '../../../utils/matchers'; import { extractCommonLabels, Line, LogRecord, omitLabels } from './common'; @@ -50,7 +51,7 @@ export function useRuleHistoryRecords(stateHistory?: DataFrameJSON, filter?: str const commonLabels = extractCommonLabels(groupLabelsArray); - const filterMatchers = filter ? parseMatchers(filter) : []; + const filterMatchers = filter ? parsePromQLStyleMatcherLooseSafe(filter) : []; const filteredGroupedLines = Object.entries(logRecordsByInstance).filter(([key]) => { const labels = JSON.parse(key); return labelsMatchMatchers(labels, filterMatchers); diff --git a/public/app/features/alerting/unified/components/silences/SilencesFilter.tsx b/public/app/features/alerting/unified/components/silences/SilencesFilter.tsx index 7cb57c91055..a15698fc893 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesFilter.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesFilter.tsx @@ -6,7 +6,7 @@ import { GrafanaTheme2 } from '@grafana/data'; import { Button, Field, Icon, Input, Label, Tooltip, useStyles2, Stack } from '@grafana/ui'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; -import { parseMatchers } from '../../utils/alertmanager'; +import { parsePromQLStyleMatcherLoose } from '../../utils/matchers'; import { getSilenceFiltersFromUrlParams } from '../../utils/misc'; const getQueryStringKey = () => uniqueId('query-string-'); @@ -30,7 +30,16 @@ export const SilencesFilter = () => { setTimeout(() => setQueryStringKey(getQueryStringKey())); }; - const inputInvalid = queryString && queryString.length > 3 ? parseMatchers(queryString).length === 0 : false; + let inputValid = queryString && queryString.length > 3; + try { + if (!queryString) { + inputValid = true; + } else { + parsePromQLStyleMatcherLoose(queryString); + } + } catch (err) { + inputValid = false; + } return (
@@ -53,8 +62,8 @@ export const SilencesFilter = () => { } - invalid={inputInvalid} - error={inputInvalid ? 'Query must use valid matcher syntax' : null} + invalid={!inputValid} + error={!inputValid ? 'Query must use valid matcher syntax' : null} > { } } if (queryString) { - const matchers = parseMatchers(queryString); + const matchers = parsePromQLStyleMatcherLooseSafe(queryString); const matchersMatch = matchers.every((matcher) => silence.matchers?.some( ({ name, value, isEqual, isRegex }) => diff --git a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts index 737d01d532c..4aef92130ab 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts @@ -468,7 +468,7 @@ function hashQuery(query: string) { This hook returns combined Grafana rules. Optionally, it can filter rules by dashboard UID and panel ID. */ export function useCombinedRules( - dashboardUID?: string, + dashboardUID?: string | null, panelId?: number, poll?: boolean ): { @@ -483,10 +483,12 @@ export function useCombinedRules( } = alertRuleApi.endpoints.prometheusRuleNamespaces.useQuery( { ruleSourceName: GRAFANA_RULES_SOURCE_NAME, - dashboardUid: dashboardUID, + dashboardUid: dashboardUID ?? undefined, panelId, }, { + // "null" means the dashboard isn't saved yet, as opposed to "undefined" which means we don't want to filter by dashboard UID + skip: dashboardUID === null, pollingInterval: poll ? RULE_LIST_POLL_INTERVAL_MS : undefined, } ); @@ -498,10 +500,11 @@ export function useCombinedRules( } = alertRuleApi.endpoints.rulerRules.useQuery( { rulerConfig: grafanaRulerConfig, - filter: { dashboardUID, panelId }, + filter: { dashboardUID: dashboardUID ?? undefined, panelId }, }, { pollingInterval: poll ? RULE_LIST_POLL_INTERVAL_MS : undefined, + skip: dashboardUID === null, } ); diff --git a/public/app/features/alerting/unified/hooks/useFilteredAmGroups.ts b/public/app/features/alerting/unified/hooks/useFilteredAmGroups.ts index 3aa49042d93..3d88eafd006 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredAmGroups.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredAmGroups.ts @@ -3,19 +3,21 @@ import { useMemo } from 'react'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types'; -import { labelsMatchMatchers, parseMatchers } from '../utils/alertmanager'; +import { labelsMatchMatchers } from '../utils/alertmanager'; +import { parsePromQLStyleMatcherLooseSafe } from '../utils/matchers'; import { getFiltersFromUrlParams } from '../utils/misc'; export const useFilteredAmGroups = (groups: AlertmanagerGroup[]) => { const [queryParams] = useQueryParams(); - const filters = getFiltersFromUrlParams(queryParams); - const matchers = parseMatchers(filters.queryString || ''); + const { queryString, alertState } = getFiltersFromUrlParams(queryParams); return useMemo(() => { + const matchers = queryString ? parsePromQLStyleMatcherLooseSafe(queryString) : []; + return groups.reduce((filteredGroup: AlertmanagerGroup[], group) => { const alerts = group.alerts.filter(({ labels, status }) => { const labelsMatch = labelsMatchMatchers(labels, matchers); - const filtersMatch = filters.alertState ? status.state === filters.alertState : true; + const filtersMatch = alertState ? status.state === alertState : true; return labelsMatch && filtersMatch; }); if (alerts.length > 0) { @@ -28,5 +30,5 @@ export const useFilteredAmGroups = (groups: AlertmanagerGroup[]) => { } return filteredGroup; }, []); - }, [groups, filters, matchers]); + }, [queryString, groups, alertState]); }; diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.ts index eaa13b43840..f7674c92500 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.ts @@ -9,10 +9,10 @@ import { CombinedRuleGroup, CombinedRuleNamespace, Rule } from 'app/types/unifie import { isPromAlertingRuleState, PromRuleType, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto'; import { applySearchFilterToQuery, getSearchFilterFromQuery, RulesFilter } from '../search/rulesSearchParser'; -import { labelsMatchMatchers, matcherToMatcherField, parseMatchers } from '../utils/alertmanager'; +import { labelsMatchMatchers, matcherToMatcherField } from '../utils/alertmanager'; import { Annotation } from '../utils/constants'; import { isCloudRulesSource } from '../utils/datasource'; -import { parseMatcher } from '../utils/matchers'; +import { parseMatcher, parsePromQLStyleMatcherLoose } from '../utils/matchers'; import { getRuleHealth, isAlertingRule, @@ -71,7 +71,7 @@ export function useRulesFilter() { dataSource: queryParams.get('dataSource') ?? undefined, alertState: queryParams.get('alertState') ?? undefined, ruleType: queryParams.get('ruleType') ?? undefined, - labels: parseMatchers(queryParams.get('queryString') ?? '').map(matcherToMatcherField), + labels: parsePromQLStyleMatcherLoose(queryParams.get('queryString') ?? '').map(matcherToMatcherField), }; const hasLegacyFilters = Object.values(legacyFilters).some((legacyFilter) => !isEmpty(legacyFilter)); diff --git a/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts b/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts index 388c3ff2b1b..61b9323fbbd 100644 --- a/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts +++ b/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts @@ -3,7 +3,7 @@ import { CombinedRule } from 'app/types/unified-alerting'; import { useCombinedRules } from './useCombinedRuleNamespaces'; interface Options { - dashboardUID: string; + dashboardUID: string | null; panelId: number; poll?: boolean; diff --git a/public/app/features/alerting/unified/insights/DataSourcesInfo.tsx b/public/app/features/alerting/unified/insights/DataSourcesInfo.tsx index bb3831c689b..81e5d619945 100644 --- a/public/app/features/alerting/unified/insights/DataSourcesInfo.tsx +++ b/public/app/features/alerting/unified/insights/DataSourcesInfo.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; -import { GrafanaTheme2 } from '@grafana/data/src/themes'; +import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; import { DataSourceInformation } from '../home/Insights'; diff --git a/public/app/features/alerting/unified/insights/RatingModal.tsx b/public/app/features/alerting/unified/insights/RatingModal.tsx index 0dd41eb7a61..8611d4602e5 100644 --- a/public/app/features/alerting/unified/insights/RatingModal.tsx +++ b/public/app/features/alerting/unified/insights/RatingModal.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import { useState } from 'react'; -import { GrafanaTheme2 } from '@grafana/data/src/themes'; +import { GrafanaTheme2 } from '@grafana/data'; import { Button, Dropdown, Icon, IconButton, Menu, Modal, useStyles2 } from '@grafana/ui'; import { trackInsightsFeedback } from '../Analytics'; diff --git a/public/app/features/alerting/unified/insights/SectionFooter.tsx b/public/app/features/alerting/unified/insights/SectionFooter.tsx index 560b562abff..7006169da10 100644 --- a/public/app/features/alerting/unified/insights/SectionFooter.tsx +++ b/public/app/features/alerting/unified/insights/SectionFooter.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import * as React from 'react'; -import { GrafanaTheme2 } from '@grafana/data/src/themes'; +import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; export function SectionFooter({ children }: React.PropsWithChildren<{}>) { diff --git a/public/app/features/alerting/unified/insights/SectionSubheader.tsx b/public/app/features/alerting/unified/insights/SectionSubheader.tsx index 302a391129d..a40e4c04117 100644 --- a/public/app/features/alerting/unified/insights/SectionSubheader.tsx +++ b/public/app/features/alerting/unified/insights/SectionSubheader.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import * as React from 'react'; -import { GrafanaTheme2 } from '@grafana/data/src/themes'; +import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; import { DataSourceInformation } from '../home/Insights'; diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 34c1a5ead12..8af7b141bff 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -21,7 +21,6 @@ import { DataSourceSrv, GetDataSourceListFilters, config } from '@grafana/runtim import { defaultDashboard } from '@grafana/schema'; import { contextSrv } from 'app/core/services/context_srv'; import { MOCK_GRAFANA_ALERT_RULE_TITLE } from 'app/features/alerting/unified/mocks/server/handlers/alertRules'; -import { parseMatchers } from 'app/features/alerting/unified/utils/alertmanager'; import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; import { AlertManagerCortexConfig, @@ -64,6 +63,8 @@ import { import { DashboardSearchItem, DashboardSearchItemType } from '../../search/types'; +import { parsePromQLStyleMatcherLooseSafe } from './utils/matchers'; + let nextDataSourceId = 1; export function mockDataSource( @@ -328,12 +329,12 @@ export const mockSilences = [ mockSilence({ id: MOCK_SILENCE_ID_EXISTING, comment: 'Happy path silence' }), mockSilence({ id: 'ce031625-61c7-47cd-9beb-8760bccf0ed7', - matchers: parseMatchers('foo!=bar'), + matchers: parsePromQLStyleMatcherLooseSafe('foo!=bar'), comment: 'Silence with negated matcher', }), mockSilence({ id: MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID, - matchers: parseMatchers(`__alert_rule_uid__=${MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID}`), + matchers: parsePromQLStyleMatcherLooseSafe(`__alert_rule_uid__=${MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID}`), comment: 'Silence with alert rule UID matcher', metadata: { rule_title: MOCK_GRAFANA_ALERT_RULE_TITLE, @@ -341,7 +342,7 @@ export const mockSilences = [ }), mockSilence({ id: MOCK_SILENCE_ID_LACKING_PERMISSIONS, - matchers: parseMatchers('something=else'), + matchers: parsePromQLStyleMatcherLooseSafe('something=else'), comment: 'Silence without permissions to edit', accessControl: {}, }), diff --git a/public/app/features/alerting/unified/utils/alertmanager.test.ts b/public/app/features/alerting/unified/utils/alertmanager.test.ts index d8f3cbe59c1..3c0293aef94 100644 --- a/public/app/features/alerting/unified/utils/alertmanager.test.ts +++ b/public/app/features/alerting/unified/utils/alertmanager.test.ts @@ -1,8 +1,8 @@ import { Matcher, MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types'; import { Labels } from 'app/types/unified-alerting-dto'; -import { parseMatchers, labelsMatchMatchers, removeMuteTimingFromRoute, matchersToString } from './alertmanager'; -import { parseMatcher } from './matchers'; +import { labelsMatchMatchers, removeMuteTimingFromRoute, matchersToString } from './alertmanager'; +import { parseMatcher, parsePromQLStyleMatcherLooseSafe } from './matchers'; describe('Alertmanager utils', () => { describe('parseMatcher', () => { @@ -64,57 +64,6 @@ describe('Alertmanager utils', () => { }); }); - describe('parseMatchers', () => { - it('should parse all operators', () => { - expect(parseMatchers('foo=bar, bar=~ba.+, severity!=warning, email!~@grafana.com')).toEqual([ - { name: 'foo', value: 'bar', isRegex: false, isEqual: true }, - { name: 'bar', value: 'ba.+', isEqual: true, isRegex: true }, - { name: 'severity', value: 'warning', isRegex: false, isEqual: false }, - { name: 'email', value: '@grafana.com', isRegex: true, isEqual: false }, - ]); - }); - - it('should parse with spaces and brackets', () => { - expect(parseMatchers('{ foo=bar }')).toEqual([ - { - name: 'foo', - value: 'bar', - isRegex: false, - isEqual: true, - }, - ]); - }); - - it('should parse with spaces in the value', () => { - expect(parseMatchers('foo=bar bazz')).toEqual([ - { - name: 'foo', - value: 'bar bazz', - isRegex: false, - isEqual: true, - }, - ]); - }); - - it('should return nothing for invalid operator', () => { - expect(parseMatchers('foo=!bar')).toEqual([]); - }); - - it('should parse matchers with or without quotes', () => { - expect(parseMatchers('foo="bar",bar=bazz')).toEqual([ - { name: 'foo', value: 'bar', isRegex: false, isEqual: true }, - { name: 'bar', value: 'bazz', isEqual: true, isRegex: false }, - ]); - }); - - it('should parse matchers for key with special characters', () => { - expect(parseMatchers('foo.bar-baz="bar",baz-bar.foo=bazz')).toEqual([ - { name: 'foo.bar-baz', value: 'bar', isRegex: false, isEqual: true }, - { name: 'baz-bar.foo', value: 'bazz', isEqual: true, isRegex: false }, - ]); - }); - }); - describe('labelsMatchMatchers', () => { it('should return true for matching labels', () => { const labels: Labels = { @@ -123,7 +72,7 @@ describe('Alertmanager utils', () => { bazz: 'buzz', }; - const matchers = parseMatchers('foo=bar,bar=bazz'); + const matchers = parsePromQLStyleMatcherLooseSafe('foo=bar,bar=bazz'); expect(labelsMatchMatchers(labels, matchers)).toBe(true); }); it('should return false for no matching labels', () => { @@ -131,7 +80,7 @@ describe('Alertmanager utils', () => { foo: 'bar', bar: 'bazz', }; - const matchers = parseMatchers('foo=buzz'); + const matchers = parsePromQLStyleMatcherLooseSafe('foo=buzz'); expect(labelsMatchMatchers(labels, matchers)).toBe(false); }); it('should match with different operators', () => { @@ -140,7 +89,7 @@ describe('Alertmanager utils', () => { bar: 'bazz', email: 'admin@grafana.com', }; - const matchers = parseMatchers('foo!=bazz,bar=~ba.+'); + const matchers = parsePromQLStyleMatcherLooseSafe('foo!=bazz,bar=~ba.+'); expect(labelsMatchMatchers(labels, matchers)).toBe(true); }); }); @@ -198,7 +147,7 @@ describe('Alertmanager utils', () => { const matchersString = matchersToString(matchers); - expect(matchersString).toBe('{severity="critical",resource=~"cpu",rule_uid!="2Otf8canzz",cluster!~"prom"}'); + expect(matchersString).toBe('{ severity="critical", resource=~"cpu", rule_uid!="2Otf8canzz", cluster!~"prom" }'); }); }); }); diff --git a/public/app/features/alerting/unified/utils/alertmanager.ts b/public/app/features/alerting/unified/utils/alertmanager.ts index 2738b703844..8a73dd2685c 100644 --- a/public/app/features/alerting/unified/utils/alertmanager.ts +++ b/public/app/features/alerting/unified/utils/alertmanager.ts @@ -16,7 +16,7 @@ import { MatcherFieldValue } from '../types/silence-form'; import { getAllDataSources } from './config'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './datasource'; -import { MatcherFormatter, unquoteWithUnescape } from './matchers'; +import { MatcherFormatter, parsePromQLStyleMatcherLooseSafe, unquoteWithUnescape } from './matchers'; export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig { // add default receiver if it does not exist @@ -106,10 +106,10 @@ export function matchersToString(matchers: Matcher[]) { const combinedMatchers = matcherFields.reduce((acc, current) => { const currentMatcherString = `${current.name}${current.operator}"${current.value}"`; - return acc ? `${acc},${currentMatcherString}` : currentMatcherString; + return acc ? `${acc}, ${currentMatcherString}` : currentMatcherString; }, ''); - return `{${combinedMatchers}}`; + return `{ ${combinedMatchers} }`; } export const matcherFieldOptions: SelectableValue[] = [ @@ -124,35 +124,6 @@ export function matcherToObjectMatcher(matcher: Matcher): ObjectMatcher { return [matcher.name, operator, matcher.value]; } -export function parseMatchers(matcherQueryString: string): Matcher[] { - const matcherRegExp = /\b([\w.-]+)(=~|!=|!~|=(?="?\w))"?([^"\n,}]*)"?/g; - const matchers: Matcher[] = []; - - matcherQueryString.replace(matcherRegExp, (_, key, operator, value) => { - const isEqual = operator === MatcherOperator.equal || operator === MatcherOperator.regex; - const isRegex = operator === MatcherOperator.regex || operator === MatcherOperator.notRegex; - matchers.push({ - name: key, - value: isRegex ? getValidRegexString(value.trim()) : value.trim(), - isEqual, - isRegex, - }); - return ''; - }); - - return matchers; -} - -function getValidRegexString(regex: string): string { - // Regexes provided by users might be invalid, so we need to catch the error - try { - new RegExp(regex); - return regex; - } catch (error) { - return ''; - } -} - export function labelsMatchMatchers(labels: Labels, matchers: Matcher[]): boolean { return matchers.every(({ name, value, isRegex, isEqual }) => { return Object.entries(labels).some(([labelKey, labelValue]) => { @@ -177,7 +148,7 @@ export function labelsMatchMatchers(labels: Labels, matchers: Matcher[]): boolea } export function combineMatcherStrings(...matcherStrings: string[]): string { - const matchers = matcherStrings.map(parseMatchers).flat(); + const matchers = matcherStrings.map(parsePromQLStyleMatcherLooseSafe).flat(); const uniqueMatchers = uniqWith(matchers, isEqual); return matchersToString(uniqueMatchers); } diff --git a/public/app/features/alerting/unified/utils/amroutes.test.ts b/public/app/features/alerting/unified/utils/amroutes.test.ts index 343e4587c4a..40441e890cc 100644 --- a/public/app/features/alerting/unified/utils/amroutes.test.ts +++ b/public/app/features/alerting/unified/utils/amroutes.test.ts @@ -72,10 +72,10 @@ describe('formAmRouteToAmRoute', () => { // Assert expect(amRoute.matchers).toStrictEqual([ - '"foo"="bar"', - '"foo"="bar\\"baz"', - '"foo"="bar\\\\baz"', - '"foo"="\\\\bar\\\\baz\\"\\\\"', + 'foo="bar"', + 'foo="bar\\"baz"', + 'foo="bar\\\\baz"', + 'foo="\\\\bar\\\\baz\\"\\\\"', ]); }); @@ -97,7 +97,7 @@ describe('formAmRouteToAmRoute', () => { // Assert expect(amRoute.matchers).toStrictEqual([ - '"foo"="bar"', + 'foo="bar"', '"foo with spaces"="bar"', '"foo\\\\slash"="bar"', '"foo\\"quote"="bar"', @@ -116,7 +116,7 @@ describe('formAmRouteToAmRoute', () => { const amRoute = formAmRouteToAmRoute('mimir-am', route, { id: 'root' }); // Assert - expect(amRoute.matchers).toStrictEqual(['"foo"=""']); + expect(amRoute.matchers).toStrictEqual(['foo=""']); }); it('should allow matchers with empty values for Grafana AM', () => { diff --git a/public/app/features/alerting/unified/utils/amroutes.ts b/public/app/features/alerting/unified/utils/amroutes.ts index fd59aac2a84..f186a2e3aea 100644 --- a/public/app/features/alerting/unified/utils/amroutes.ts +++ b/public/app/features/alerting/unified/utils/amroutes.ts @@ -8,7 +8,7 @@ import { MatcherFieldValue } from '../types/silence-form'; import { matcherToMatcherField } from './alertmanager'; import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; -import { normalizeMatchers, parseMatcherToArray, quoteWithEscape, unquoteWithUnescape } from './matchers'; +import { encodeMatcher, normalizeMatchers, parseMatcherToArray, unquoteWithUnescape } from './matchers'; import { findExistingRoute } from './routeTree'; import { isValidPrometheusDuration, safeParsePrometheusDuration } from './time'; @@ -189,9 +189,8 @@ export const formAmRouteToAmRoute = ( // Grafana maintains a fork of AM to support all utf-8 characters in the "object_matchers" property values but this // does not exist in upstream AlertManager if (alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME) { - amRoute.matchers = formAmRoute.object_matchers?.map( - ({ name, operator, value }) => `${quoteWithEscape(name)}${operator}${quoteWithEscape(value)}` - ); + // to support UTF-8 characters we must wrap label keys and values with double quotes if they contain reserved characters. + amRoute.matchers = formAmRoute.object_matchers?.map(encodeMatcher); amRoute.object_matchers = undefined; } else { amRoute.object_matchers = normalizeMatchers(amRoute); diff --git a/public/app/features/alerting/unified/utils/matchers.test.ts b/public/app/features/alerting/unified/utils/matchers.test.ts index c10b03749b6..69487604a8c 100644 --- a/public/app/features/alerting/unified/utils/matchers.test.ts +++ b/public/app/features/alerting/unified/utils/matchers.test.ts @@ -1,14 +1,18 @@ -import { MatcherOperator, Route } from '../../../../plugins/datasource/alertmanager/types'; +import { Matcher, MatcherOperator, Route } from '../../../../plugins/datasource/alertmanager/types'; import { + encodeMatcher, getMatcherQueryParams, isPromQLStyleMatcher, matcherToObjectMatcher, normalizeMatchers, parseMatcher, parsePromQLStyleMatcher, + parsePromQLStyleMatcherLoose, + parsePromQLStyleMatcherLooseSafe, parseQueryParamMatchers, quoteWithEscape, + quoteWithEscapeIfRequired, unquoteWithUnescape, } from './matchers'; @@ -175,4 +179,102 @@ describe('parsePromQLStyleMatcher', () => { it('should throw when not using correct syntax', () => { expect(() => parsePromQLStyleMatcher('foo="bar"')).toThrow(); }); + + it('should only encode matchers if the label key contains reserved characters', () => { + expect(quoteWithEscapeIfRequired('foo')).toBe('foo'); + expect(quoteWithEscapeIfRequired('foo bar')).toBe('"foo bar"'); + expect(quoteWithEscapeIfRequired('foo{}bar')).toBe('"foo{}bar"'); + expect(quoteWithEscapeIfRequired('foo\\bar')).toBe('"foo\\\\bar"'); + }); + + it('should properly encode a matcher field', () => { + expect(encodeMatcher({ name: 'foo', operator: MatcherOperator.equal, value: 'baz' })).toBe('foo="baz"'); + expect(encodeMatcher({ name: 'foo bar', operator: MatcherOperator.equal, value: 'baz' })).toBe('"foo bar"="baz"'); + expect(encodeMatcher({ name: 'foo{}bar', operator: MatcherOperator.equal, value: 'baz qux' })).toBe( + '"foo{}bar"="baz qux"' + ); + }); +}); + +describe('parsePromQLStyleMatcherLooseSafe', () => { + it('should parse all operators', () => { + expect(parsePromQLStyleMatcherLooseSafe('foo=bar, bar=~ba.+, severity!=warning, email!~@grafana.com')).toEqual< + Matcher[] + >([ + { name: 'foo', value: 'bar', isRegex: false, isEqual: true }, + { name: 'bar', value: 'ba.+', isEqual: true, isRegex: true }, + { name: 'severity', value: 'warning', isRegex: false, isEqual: false }, + { name: 'email', value: '@grafana.com', isRegex: true, isEqual: false }, + ]); + }); + + it('should parse with spaces and brackets', () => { + expect(parsePromQLStyleMatcherLooseSafe('{ foo=bar }')).toEqual([ + { + name: 'foo', + value: 'bar', + isRegex: false, + isEqual: true, + }, + ]); + }); + + it('should parse with spaces in the value', () => { + expect(parsePromQLStyleMatcherLooseSafe('foo=bar bazz')).toEqual([ + { + name: 'foo', + value: 'bar bazz', + isRegex: false, + isEqual: true, + }, + ]); + }); + + it('should return nothing for invalid operator', () => { + expect(parsePromQLStyleMatcherLooseSafe('foo=!bar')).toEqual([ + { + name: 'foo', + value: '!bar', + isRegex: false, + isEqual: true, + }, + ]); + }); + + it('should parse matchers with or without quotes', () => { + expect(parsePromQLStyleMatcherLooseSafe('foo="bar",bar=bazz')).toEqual([ + { name: 'foo', value: 'bar', isRegex: false, isEqual: true }, + { name: 'bar', value: 'bazz', isEqual: true, isRegex: false }, + ]); + }); + + it('should parse matchers for key with special characters', () => { + expect(parsePromQLStyleMatcherLooseSafe('foo.bar-baz="bar",baz-bar.foo=bazz')).toEqual([ + { name: 'foo.bar-baz', value: 'bar', isRegex: false, isEqual: true }, + { name: 'baz-bar.foo', value: 'bazz', isEqual: true, isRegex: false }, + ]); + }); +}); + +describe('parsePromQLStyleMatcherLoose', () => { + it('should throw on invalid matcher', () => { + expect(() => { + parsePromQLStyleMatcherLoose('foo'); + }).toThrow(); + + expect(() => { + parsePromQLStyleMatcherLoose('foo;bar'); + }).toThrow(); + }); + + it('should return empty array for empty input', () => { + expect(parsePromQLStyleMatcherLoose('')).toStrictEqual([]); + }); + + it('should also accept { } syntax', () => { + expect(parsePromQLStyleMatcherLoose('{ foo=bar, bar=baz }')).toStrictEqual([ + { isEqual: true, isRegex: false, name: 'foo', value: 'bar' }, + { isEqual: true, isRegex: false, name: 'bar', value: 'baz' }, + ]); + }); }); diff --git a/public/app/features/alerting/unified/utils/matchers.ts b/public/app/features/alerting/unified/utils/matchers.ts index a596a318da0..917e055e3bf 100644 --- a/public/app/features/alerting/unified/utils/matchers.ts +++ b/public/app/features/alerting/unified/utils/matchers.ts @@ -10,6 +10,7 @@ import { compact, uniqBy } from 'lodash'; import { Matcher, MatcherOperator, ObjectMatcher, Route } from 'app/plugins/datasource/alertmanager/types'; import { Labels } from '../../../../types/unified-alerting-dto'; +import { MatcherFieldValue } from '../types/silence-form'; import { isPrivateLabelKey } from './labels'; @@ -57,6 +58,8 @@ export function parseMatcher(matcher: string): Matcher { /** * This function combines parseMatcher and parsePromQLStyleMatcher, always returning an array of Matcher[] regardless of input syntax + * 1. { foo=bar, bar=baz } + * 2. foo=bar */ export function parseMatcherToArray(matcher: string): Matcher[] { return isPromQLStyleMatcher(matcher) ? parsePromQLStyleMatcher(matcher) : [parseMatcher(matcher)]; @@ -70,6 +73,15 @@ export function parsePromQLStyleMatcher(matcher: string): Matcher[] { throw new Error('not a PromQL style matcher'); } + return parsePromQLStyleMatcherLoose(matcher); +} + +/** + * This function behaves the same as "parsePromQLStyleMatcher" but does not check if the matcher is formatted with { } + * In other words; it accepts both "{ foo=bar, bar=baz }" and "foo=bar,bar=baz" + * @throws + */ +export function parsePromQLStyleMatcherLoose(matcher: string): Matcher[] { // split by `,` but not when it's used as a label value const commaUnlessQuoted = /,(?=(?:[^"]*"[^"]*")*[^"]*$)/; const parts = matcher.replace(/^\{/, '').replace(/\}$/, '').trim().split(commaUnlessQuoted); @@ -83,6 +95,18 @@ export function parsePromQLStyleMatcher(matcher: string): Matcher[] { })); } +/** + * This function behaves the same as "parsePromQLStyleMatcherLoose" but instead of throwing an error for incorrect syntax + * it returns an empty Array of matchers instead. + */ +export function parsePromQLStyleMatcherLooseSafe(matcher: string): Matcher[] { + try { + return parsePromQLStyleMatcherLoose(matcher); + } catch { + return []; + } +} + // Parses a list of entries like like "['foo=bar', 'baz=~bad*']" into SilenceMatcher[] export function parseQueryParamMatchers(matcherPairs: string[]): Matcher[] { const parsedMatchers = matcherPairs.filter((x) => !!x.trim()).map((x) => parseMatcher(x)); @@ -144,6 +168,27 @@ export function quoteWithEscape(input: string) { return `"${escaped}"`; } +// The list of reserved characters that indicate we should be escaping the label key / value are +// { } ! = ~ , \ " ' ` and any whitespace (\s), encoded in the regular expression below +// +// See Alertmanager PR: https://github.com/prometheus/alertmanager/pull/3453 +const RESERVED_CHARACTERS = /[\{\}\!\=\~\,\\\"\'\`\s]+/; + +/** + * Quotes string only when reserved characters are used + */ +export function quoteWithEscapeIfRequired(input: string) { + const shouldQuote = RESERVED_CHARACTERS.test(input); + return shouldQuote ? quoteWithEscape(input) : input; +} + +export const encodeMatcher = ({ name, operator, value }: MatcherFieldValue) => { + const encodedLabelName = quoteWithEscapeIfRequired(name); + const encodedLabelValue = quoteWithEscape(value); + + return `${encodedLabelName}${operator}${encodedLabelValue}`; +}; + /** * Unquotes and unescapes a string **if it has been quoted** */ diff --git a/public/app/features/apiserver/client.test.ts b/public/app/features/apiserver/client.test.ts new file mode 100644 index 00000000000..dd233cd6d98 --- /dev/null +++ b/public/app/features/apiserver/client.test.ts @@ -0,0 +1,35 @@ +import { getBackendSrv } from '@grafana/runtime'; + +import { DatasourceAPIVersions } from './client'; + +jest.mock('@grafana/runtime', () => ({ + getBackendSrv: jest.fn().mockReturnValue({ + get: jest.fn(), + }), + config: {}, +})); + +describe('DatasourceAPIVersions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('get', async () => { + const getMock = jest.fn().mockResolvedValue({ + groups: [ + { name: 'testdata.datasource.grafana.app', preferredVersion: { version: 'v1' } }, + { name: 'prometheus.datasource.grafana.app', preferredVersion: { version: 'v2' } }, + { name: 'myorg-myplugin.datasource.grafana.app', preferredVersion: { version: 'v3' } }, + ], + }); + getBackendSrv().get = getMock; + const apiVersions = new DatasourceAPIVersions(); + expect(await apiVersions.get('testdata')).toBe('v1'); + expect(await apiVersions.get('grafana-testdata-datasource')).toBe('v1'); + expect(await apiVersions.get('prometheus')).toBe('v2'); + expect(await apiVersions.get('graphite')).toBeUndefined(); + expect(await apiVersions.get('myorg-myplugin-datasource')).toBe('v3'); + expect(getMock).toHaveBeenCalledTimes(1); + expect(getMock).toHaveBeenCalledWith('/apis'); + }); +}); diff --git a/public/app/features/apiserver/client.ts b/public/app/features/apiserver/client.ts index 6a23c3dce50..609d6a4c9ec 100644 --- a/public/app/features/apiserver/client.ts +++ b/public/app/features/apiserver/client.ts @@ -14,6 +14,7 @@ import { AnnoKeyOriginPath, AnnoKeyOriginHash, AnnoKeyOriginName, + K8sAPIGroupList, } from './types'; export interface GroupVersionResource { @@ -110,3 +111,34 @@ function setOriginAsUI(meta: Partial) { meta.annotations[AnnoKeyOriginPath] = window.location.pathname; meta.annotations[AnnoKeyOriginHash] = config.buildInfo.versionString; } + +export class DatasourceAPIVersions { + private apiVersions?: { [pluginID: string]: string }; + + async get(pluginID: string): Promise { + if (this.apiVersions) { + return this.apiVersions[pluginID]; + } + const apis = await getBackendSrv().get('/apis'); + const apiVersions: { [pluginID: string]: string } = {}; + apis.groups.forEach((group) => { + if (group.name.includes('datasource.grafana.app')) { + const id = group.name.split('.')[0]; + apiVersions[id] = group.preferredVersion.version; + // workaround for plugins that don't append '-datasource' for the group name + // e.g. org-plugin-datasource uses org-plugin.datasource.grafana.app + if (!id.endsWith('-datasource')) { + if (!id.includes('-')) { + // workaroud for Grafana plugins that don't include the org either + // e.g. testdata uses testdata.datasource.grafana.app + apiVersions[`grafana-${id}-datasource`] = group.preferredVersion.version; + } else { + apiVersions[`${id}-datasource`] = group.preferredVersion.version; + } + } + } + }); + this.apiVersions = apiVersions; + return apiVersions[pluginID]; + } +} diff --git a/public/app/features/apiserver/types.ts b/public/app/features/apiserver/types.ts index 9065f67f668..e3ee1ded77c 100644 --- a/public/app/features/apiserver/types.ts +++ b/public/app/features/apiserver/types.ts @@ -150,3 +150,13 @@ export interface ResourceClient { update(obj: ResourceForCreate): Promise>; delete(name: string): Promise; } + +export interface K8sAPIGroup { + name: string; + versions: Array<{ groupVersion: string; version: string }>; + preferredVersion: { groupVersion: string; version: string }; +} +export interface K8sAPIGroupList { + kind: 'APIGroupList'; + groups: K8sAPIGroup[]; +} diff --git a/public/app/features/auth-config/ProviderConfigForm.test.tsx b/public/app/features/auth-config/ProviderConfigForm.test.tsx index ad1b87ec39a..2ae4728227a 100644 --- a/public/app/features/auth-config/ProviderConfigForm.test.tsx +++ b/public/app/features/auth-config/ProviderConfigForm.test.tsx @@ -57,9 +57,18 @@ const testConfig: SSOProvider = { allowedDomains: '', allowedGroups: '', scopes: '', + orgMapping: '', }, }; +jest.mock('app/core/core', () => { + return { + contextSrv: { + isGrafanaAdmin: true, + }, + }; +}); + const emptyConfig = { ...testConfig, settings: { ...testConfig.settings, enabled: false, clientId: '', clientSecret: '' }, @@ -120,6 +129,8 @@ describe('ProviderConfigForm', () => { await user.click(screen.getByText('User mapping')); await user.type(screen.getByRole('textbox', { name: /Role attribute path/i }), 'new-attribute-path'); await user.click(screen.getByRole('checkbox', { name: /Role attribute strict mode/i })); + await user.type(screen.getByRole('combobox', { name: /Organization mapping/i }), 'Group A:1:Editor{enter}'); + await user.type(screen.getByRole('combobox', { name: /Organization mapping/i }), 'Group B:2:Admin{enter}'); await user.click(screen.getByText('Extra security measures')); await user.type(screen.getByRole('combobox', { name: /Allowed domains/i }), 'grafana.com{enter}'); @@ -143,6 +154,7 @@ describe('ProviderConfigForm', () => { clientSecret: 'test-client-secret', enabled: true, name: 'GitHub', + orgMapping: '["Group A:1:Editor","Group B:2:Admin"]', roleAttributePath: 'new-attribute-path', roleAttributeStrict: true, scopes: 'user:email', @@ -203,6 +215,7 @@ describe('ProviderConfigForm', () => { tlsClientKey: '', usePkce: false, useRefreshToken: false, + orgMapping: '', }, }, { showErrorAlert: false } diff --git a/public/app/features/auth-config/fields.tsx b/public/app/features/auth-config/fields.tsx index 6b34468b22b..171f3df0243 100644 --- a/public/app/features/auth-config/fields.tsx +++ b/public/app/features/auth-config/fields.tsx @@ -38,7 +38,7 @@ export const sectionFields: Section = { { name: 'User mapping', id: 'user', - fields: ['roleAttributePath', 'roleAttributeStrict', 'allowAssignGrafanaAdmin', 'skipOrgRoleSync'], + fields: ['roleAttributePath', 'roleAttributeStrict', 'orgMapping', 'allowAssignGrafanaAdmin', 'skipOrgRoleSync'], }, { name: 'Extra security measures', @@ -86,6 +86,8 @@ export const sectionFields: Section = { 'idTokenAttributeName', 'roleAttributePath', 'roleAttributeStrict', + 'orgMapping', + 'orgAttributePath', 'allowAssignGrafanaAdmin', 'skipOrgRoleSync', ], @@ -121,7 +123,7 @@ export const sectionFields: Section = { { name: 'User mapping', id: 'user', - fields: ['roleAttributePath', 'roleAttributeStrict', 'allowAssignGrafanaAdmin', 'skipOrgRoleSync'], + fields: ['roleAttributePath', 'roleAttributeStrict', 'orgMapping', 'allowAssignGrafanaAdmin', 'skipOrgRoleSync'], }, { name: 'Extra security measures', @@ -149,7 +151,7 @@ export const sectionFields: Section = { { name: 'User mapping', id: 'user', - fields: ['roleAttributePath', 'roleAttributeStrict', 'allowAssignGrafanaAdmin', 'skipOrgRoleSync'], + fields: ['roleAttributePath', 'roleAttributeStrict', 'orgMapping', 'allowAssignGrafanaAdmin', 'skipOrgRoleSync'], }, { name: 'Extra security measures', @@ -176,7 +178,7 @@ export const sectionFields: Section = { { name: 'User mapping', id: 'user', - fields: ['roleAttributePath', 'roleAttributeStrict', 'allowAssignGrafanaAdmin', 'skipOrgRoleSync'], + fields: ['roleAttributePath', 'roleAttributeStrict', 'orgMapping', 'allowAssignGrafanaAdmin', 'skipOrgRoleSync'], }, { name: 'Extra security measures', @@ -213,7 +215,14 @@ export const sectionFields: Section = { { name: 'User mapping', id: 'user', - fields: ['roleAttributePath', 'roleAttributeStrict', 'allowAssignGrafanaAdmin', 'skipOrgRoleSync'], + fields: [ + 'roleAttributePath', + 'roleAttributeStrict', + 'orgMapping', + 'orgAttributePath', + 'allowAssignGrafanaAdmin', + 'skipOrgRoleSync', + ], }, { name: 'Extra security measures', @@ -448,6 +457,22 @@ export function fieldMap(provider: string): Record { description: 'Prevent synchronizing users’ organization roles from your IdP.', type: 'switch', }, + orgMapping: { + label: 'Organization mapping', + description: orgMappingDescription(provider), + type: 'select', + hidden: !contextSrv.isGrafanaAdmin, + multi: true, + allowCustomValue: true, + options: [], + placeholder: 'Enter mappings (my-team:1:Viewer...) and press Enter to add', + }, + orgAttributePath: { + label: 'Organization attribute path', + description: 'JMESPath expression to use for organization lookup.', + type: 'text', + hidden: !['generic_oauth', 'okta'].includes(provider), + }, defineAllowedGroups: { label: 'Define allowed groups', type: 'switch', @@ -602,3 +627,19 @@ export function fieldMap(provider: string): Record { function isNumeric(value: string) { return /^-?\d+$/.test(value); } + +function orgMappingDescription(provider: string): string { + switch (provider) { + case 'azuread': + return 'List of "::" mappings.'; + case 'github': + return 'List of "::" mappings.'; + case 'gitlab': + return 'List of "::'; + case 'google': + return 'List of "::'; + default: + // Generic OAuth, Okta + return 'List of "::" mappings.'; + } +} diff --git a/public/app/features/auth-config/types.ts b/public/app/features/auth-config/types.ts index b139efda4fb..19c827fd356 100644 --- a/public/app/features/auth-config/types.ts +++ b/public/app/features/auth-config/types.ts @@ -36,6 +36,7 @@ export type SSOProviderSettingsBase = { roleAttributeStrict?: boolean; signoutRedirectUrl?: string; skipOrgRoleSync?: boolean; + orgAttributePath?: string; teamIdsAttributePath?: string; teamsUrl?: string; tlsClientCa?: string; @@ -70,6 +71,7 @@ export type SSOProvider = { allowedDomains?: string; allowedGroups?: string; scopes?: string; + orgMapping?: string; }; }; @@ -80,6 +82,7 @@ export type SSOProviderDTO = Partial & { allowedDomains?: Array>; allowedGroups?: Array>; scopes?: Array>; + orgMapping?: Array>; }; export interface AuthConfigState { diff --git a/public/app/features/auth-config/utils/data.ts b/public/app/features/auth-config/utils/data.ts index fa7d425939c..9b610a3cc82 100644 --- a/public/app/features/auth-config/utils/data.ts +++ b/public/app/features/auth-config/utils/data.ts @@ -51,6 +51,11 @@ const strToValue = (val: string | string[]): SelectableValue[] => { if (Array.isArray(val)) { return val.map((v) => ({ label: v, value: v })); } + // Stored as JSON Array + if (val.startsWith('[') && val.endsWith(']')) { + return JSON.parse(val).map((v: string) => ({ label: v, value: v })); + } + return val.split(/[\s,]/).map((s) => ({ label: s, value: s })); }; @@ -70,7 +75,11 @@ export function dataToDTO(data?: SSOProvider): SSOProviderDTO { } const valuesToString = (values: Array>) => { - return values.map(({ value }) => value).join(','); + if (values.length <= 1) { + return values.map(({ value }) => value).join(','); + } + // Store as JSON array if there are multiple values + return JSON.stringify(values.map(({ value }) => value)); }; const getFieldsForProvider = (provider: string) => { diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index cac9d26c593..1aabecafc40 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -1,9 +1,10 @@ import { css } from '@emotion/css'; import { memo, useEffect, useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme2 } from '@grafana/data'; -import { locationService, reportInteraction } from '@grafana/runtime'; +import { reportInteraction } from '@grafana/runtime'; import { FilterInput, useStyles2 } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; @@ -39,7 +40,8 @@ const BrowseDashboardsPage = memo(({ match }: Props) => { const styles = useStyles2(getStyles); const [searchState, stateManager] = useSearchStateManager(); const isSearching = stateManager.hasSearchFilters(); - const search = locationService.getSearch(); + const location = useLocation(); + const search = useMemo(() => new URLSearchParams(location.search), [location.search]); useEffect(() => { stateManager.initStateFromUrl(folderUID); diff --git a/public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts b/public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts index b49c7663770..b4ad0c41b37 100644 --- a/public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts +++ b/public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts @@ -15,6 +15,7 @@ import { VizPanel } from '@grafana/scenes'; import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; import { DashboardGridItem } from '../../scene/DashboardGridItem'; +import { DashboardScene } from '../../scene/DashboardScene'; import { LibraryVizPanel } from '../../scene/LibraryVizPanel'; import { gridItemToPanel, vizPanelToPanel } from '../../serialization/transformSceneToSaveModel'; import { getQueryRunnerFor } from '../../utils/utils'; @@ -62,15 +63,28 @@ export function getGithubMarkdown(panel: VizPanel, snapshot: string): string { } export async function getDebugDashboard(panel: VizPanel, rand: Randomize, timeRange: TimeRange) { - let saveModel; + let saveModel: ReturnType = { type: '' }; const isLibraryPanel = panel.parent instanceof LibraryVizPanel; const gridItem = (isLibraryPanel ? panel.parent.parent : panel.parent) as DashboardGridItem; + const scene = panel.getRoot() as DashboardScene; if (isLibraryPanel) { saveModel = { ...gridItemToPanel(gridItem), ...vizPanelToPanel(panel), }; + } else if (scene.state.editPanel) { + // If panel edit mode is open when the user chooses the "get help" panel menu option + // we want the debug dashboard to include the panel with any changes that were made while + // in panel edit mode. + const sourcePanel = scene.state.editPanel.state.vizManager.state.sourcePanel.resolve(); + const dashGridItem = sourcePanel.parent instanceof LibraryVizPanel ? sourcePanel.parent.parent : sourcePanel.parent; + if (dashGridItem instanceof DashboardGridItem) { + saveModel = { + ...gridItemToPanel(dashGridItem), + ...vizPanelToPanel(scene.state.editPanel.state.vizManager.state.panel.clone()), + }; + } } else { saveModel = gridItemToPanel(gridItem); } diff --git a/public/app/features/dashboard-scene/inspect/InspectDataTab.tsx b/public/app/features/dashboard-scene/inspect/InspectDataTab.tsx index 3e835a1c0af..9b6805153b4 100644 --- a/public/app/features/dashboard-scene/inspect/InspectDataTab.tsx +++ b/public/app/features/dashboard-scene/inspect/InspectDataTab.tsx @@ -25,7 +25,7 @@ export class InspectDataTab extends SceneObjectBase { super({ ...state, options: { - withTransforms: true, + withTransforms: false, withFieldConfig: true, }, }); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx index cc0470c1bf2..94d59e73eb4 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { CoreApp, DataSourceApi, DataSourceInstanceSettings, IconName } from '@grafana/data'; +import { CoreApp, DataSourceApi, DataSourceInstanceSettings, IconName, getDataSourceRef } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config, getDataSourceSrv } from '@grafana/runtime'; import { SceneObjectBase, SceneComponentProps, sceneGraph, SceneQueryRunner } from '@grafana/scenes'; @@ -81,8 +81,9 @@ export class PanelDataQueriesTab extends SceneObjectBase) => { const queries = this.getQueries(); const dsSettings = this._panelManager.state.dsSettings; - this.onQueriesChange(addQuery(queries, query, { type: dsSettings?.type, uid: dsSettings?.uid })); + this.onQueriesChange( + addQuery(queries, query, dsSettings ? getDataSourceRef(dsSettings) : { type: undefined, uid: undefined }) + ); }; isExpressionsSupported(dsSettings: DataSourceInstanceSettings): boolean { diff --git a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx index 40ae36a0958..33e36101985 100644 --- a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx +++ b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx @@ -9,6 +9,7 @@ import { GrafanaTheme2, PanelModel, filterFieldConfigOverrides, + getDataSourceRef, isStandardFieldProp, restoreCustomOverrideRules, } from '@grafana/data'; @@ -159,8 +160,8 @@ export class VizPanelManager extends SceneObjectBase { this.queryRunner.setState({ datasource: { + ...getDataSourceRef(dsSettings), uid: lastUsedDatasource?.datasourceUid, - type: dsSettings.type, }, }); } @@ -173,12 +174,7 @@ export class VizPanelManager extends SceneObjectBase { if (datasource && dsSettings) { this.setState({ datasource, dsSettings }); - storeLastUsedDataSourceInLocalStorage( - { - type: dsSettings.type, - uid: dsSettings.uid, - } || { default: true } - ); + storeLastUsedDataSourceInLocalStorage(getDataSourceRef(dsSettings) || { default: true }); } } catch (err) { //set default datasource if we fail to load the datasource @@ -192,10 +188,7 @@ export class VizPanelManager extends SceneObjectBase { }); this.queryRunner.setState({ - datasource: { - uid: dsSettings.uid, - type: dsSettings.type, - }, + datasource: getDataSourceRef(dsSettings), }); } @@ -296,10 +289,7 @@ export class VizPanelManager extends SceneObjectBase { const queries = defaultQueries || (await updateQueries(nextDS, newSettings.uid, currentQueries, currentDS)); queryRunner.setState({ - datasource: { - type: newSettings.type, - uid: newSettings.uid, - }, + datasource: getDataSourceRef(newSettings), queries, }); if (defaultQueries) { diff --git a/public/app/features/dashboard-scene/scene/DashboardGridItem.test.tsx b/public/app/features/dashboard-scene/scene/DashboardGridItem.test.tsx index fc045edfa16..6a710357cdf 100644 --- a/public/app/features/dashboard-scene/scene/DashboardGridItem.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardGridItem.test.tsx @@ -1,6 +1,7 @@ import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; import { setPluginImportUtils } from '@grafana/runtime'; -import { SceneGridLayout, VizPanel } from '@grafana/scenes'; +import { SceneGridLayout, TestVariable, VizPanel } from '@grafana/scenes'; +import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; import { activateFullSceneTree, buildPanelRepeaterScene } from '../utils/test-utils'; @@ -40,7 +41,7 @@ describe('PanelRepeaterGridItem', () => { expect(repeater.state.repeatedPanels?.length).toBe(5); }); - it('Should adjust container height to fit panels direction is horizontal', async () => { + it('Should adjust container height to fit panels if direction is horizontal', async () => { const { scene, repeater } = buildPanelRepeaterScene({ variableQueryTime: 0, maxPerRow: 2, itemHeight: 10 }); const layoutForceRender = jest.fn(); @@ -144,4 +145,56 @@ describe('PanelRepeaterGridItem', () => { expect(gridItem.getClassName()).toBe(''); }); + + it('should have correct height after repeat is performed', () => { + const { scene, repeater } = buildPanelRepeaterScene({ + variableQueryTime: 0, + height: 4, + maxPerRow: 4, + repeatDirection: 'h', + numberOfOptions: 5, + }); + + activateFullSceneTree(scene); + + expect(repeater.state.height).toBe(4); + }); + + it('should have same item height if number of repititions changes', async () => { + const { scene, repeater } = buildPanelRepeaterScene({ + variableQueryTime: 0, + height: 4, + maxPerRow: 4, + repeatDirection: 'h', + numberOfOptions: 5, + }); + activateFullSceneTree(scene); + + scene.state.$variables!.setState({ + variables: [ + new TestVariable({ + name: 'server', + query: 'A.*', + value: ALL_VARIABLE_VALUE, + text: ALL_VARIABLE_TEXT, + isMulti: true, + includeAll: true, + delayMs: 0, + optionsToReturn: [ + { label: 'A', value: '1' }, + { label: 'B', value: '2' }, + { label: 'C', value: '3' }, + { label: 'D', value: '4' }, + { label: 'E', value: '5' }, + { label: 'F', value: '6' }, + { label: 'G', value: '7' }, + { label: 'H', value: '8' }, + { label: 'I', value: '9' }, + { label: 'J', value: '10' }, + ], + }), + ], + }); + expect(repeater.state.height).toBe(6); + }); }); diff --git a/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx b/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx index 0a48d944190..1c7e76adf45 100644 --- a/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx @@ -163,6 +163,8 @@ export class DashboardGridItem extends SceneObjectBase i return; } + // Needed to calculate item height + const prevRepeatCount = this._prevRepeatValues?.length ?? values.length; this._prevRepeatValues = values; const panelToRepeat = this.state.body instanceof LibraryVizPanel ? this.state.body.state.panel! : this.state.body; const repeatedPanels: VizPanel[] = []; @@ -188,16 +190,15 @@ export class DashboardGridItem extends SceneObjectBase i const direction = this.getRepeatDirection(); const stateChange: Partial = { repeatedPanels: repeatedPanels }; - const itemHeight = this.state.itemHeight ?? 10; - const prevHeight = this.state.height; - const maxPerRow = this.getMaxPerRow(); + const prevHeight = this.state.height ?? 0; + const maxPerRow = direction === 'h' ? this.getMaxPerRow() : 1; + const prevRowCount = Math.ceil(prevRepeatCount / maxPerRow); + const newRowCount = Math.ceil(repeatedPanels.length / maxPerRow); - if (direction === 'h') { - const rowCount = Math.ceil(repeatedPanels.length / maxPerRow); - stateChange.height = rowCount * itemHeight; - } else { - stateChange.height = repeatedPanels.length * itemHeight; - } + // If item height is not defined, calculate based on total height and row count + const itemHeight = this.state.itemHeight ?? prevHeight / prevRowCount; + stateChange.itemHeight = itemHeight; + stateChange.height = Math.ceil(newRowCount * itemHeight); this.setState(stateChange); diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx index 1f8a113de7e..9e2ca410700 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx @@ -1,4 +1,4 @@ -import { CoreApp, LoadingState, getDefaultTimeRange } from '@grafana/data'; +import { CoreApp, LoadingState, getDefaultTimeRange, store } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { sceneGraph, @@ -14,6 +14,7 @@ import { } from '@grafana/scenes'; import { Dashboard, DashboardCursorSync, LibraryPanel } from '@grafana/schema'; import appEvents from 'app/core/app_events'; +import { LS_PANEL_COPY_KEY } from 'app/core/constants'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { VariablesChanged } from 'app/features/variables/types'; @@ -25,7 +26,7 @@ import { transformSaveModelToScene, } from '../serialization/transformSaveModelToScene'; import { DecoratedRevisionModel } from '../settings/VersionsEditView'; -import { getHistorySrv } from '../settings/version-history/HistorySrv'; +import { historySrv } from '../settings/version-history/HistorySrv'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { djb2Hash } from '../utils/djb2Hash'; import { findVizPanelByKey } from '../utils/utils'; @@ -620,7 +621,7 @@ describe('DashboardScene', () => { scene.copyPanel(vizPanel); - expect(scene.state.hasCopiedPanel).toBe(false); + expect(store.exists(LS_PANEL_COPY_KEY)).toBe(false); }); it('Should fail to copy a library panel if it does not have a grid item parent', () => { @@ -638,14 +639,14 @@ describe('DashboardScene', () => { scene.copyPanel(libVizPanel.state.panel as VizPanel); - expect(scene.state.hasCopiedPanel).toBe(false); + expect(store.exists(LS_PANEL_COPY_KEY)).toBe(false); }); it('Should copy a panel', () => { const vizPanel = ((scene.state.body as SceneGridLayout).state.children[0] as DashboardGridItem).state.body; scene.copyPanel(vizPanel as VizPanel); - expect(scene.state.hasCopiedPanel).toBe(true); + expect(store.exists(LS_PANEL_COPY_KEY)).toBe(true); }); it('Should copy a library viz panel', () => { @@ -654,11 +655,11 @@ describe('DashboardScene', () => { scene.copyPanel(libVizPanel.state.panel as VizPanel); - expect(scene.state.hasCopiedPanel).toBe(true); + expect(store.exists(LS_PANEL_COPY_KEY)).toBe(true); }); it('Should paste a panel', () => { - scene.setState({ hasCopiedPanel: true }); + store.set(LS_PANEL_COPY_KEY, JSON.stringify({ key: 'panel-7' })); jest.spyOn(JSON, 'parse').mockReturnThis(); jest.mocked(buildGridItemForPanel).mockReturnValue( new DashboardGridItem({ @@ -680,11 +681,11 @@ describe('DashboardScene', () => { expect(body.state.children.length).toBe(6); expect(gridItem.state.body!.state.key).toBe('panel-7'); expect(gridItem.state.y).toBe(0); - expect(scene.state.hasCopiedPanel).toBe(false); + expect(store.exists(LS_PANEL_COPY_KEY)).toBe(false); }); it('Should paste a library viz panel', () => { - scene.setState({ hasCopiedPanel: true }); + store.set(LS_PANEL_COPY_KEY, JSON.stringify({ key: 'panel-7' })); jest.spyOn(JSON, 'parse').mockReturnValue({ libraryPanel: { uid: 'uid', name: 'libraryPanel' } }); jest.mocked(buildGridItemForLibPanel).mockReturnValue( new DashboardGridItem({ @@ -709,7 +710,7 @@ describe('DashboardScene', () => { expect(libVizPanel.state.panelKey).toBe('panel-7'); expect(libVizPanel.state.panel?.state.key).toBe('panel-7'); expect(gridItem.state.y).toBe(0); - expect(scene.state.hasCopiedPanel).toBe(false); + expect(store.exists(LS_PANEL_COPY_KEY)).toBe(false); }); it('Should remove a panel', () => { @@ -1137,7 +1138,7 @@ describe('DashboardScene', () => { version: 4, }); - jest.mocked(getHistorySrv().restoreDashboard).mockResolvedValue({ version: newVersion }); + jest.mocked(historySrv.restoreDashboard).mockResolvedValue({ version: newVersion }); jest.mocked(transformSaveModelToScene).mockReturnValue(mockScene); return scene.onRestore(getVersionMock()).then((res) => { @@ -1150,7 +1151,7 @@ describe('DashboardScene', () => { it('should return early if historySrv does not return a valid version number', () => { jest - .mocked(getHistorySrv().restoreDashboard) + .mocked(historySrv.restoreDashboard) .mockResolvedValueOnce({ version: null }) .mockResolvedValueOnce({ version: undefined }) .mockResolvedValueOnce({ version: Infinity }) diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 1b5e2691591..8c66b347edd 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -49,7 +49,7 @@ import { import { gridItemToPanel } from '../serialization/transformSceneToSaveModel'; import { DecoratedRevisionModel } from '../settings/VersionsEditView'; import { DashboardEditView } from '../settings/utils'; -import { getHistorySrv } from '../settings/version-history'; +import { historySrv } from '../settings/version-history'; import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper'; import { dashboardSceneGraph, getLibraryVizPanelFromVizPanel } from '../utils/dashboardSceneGraph'; import { djb2Hash } from '../utils/djb2Hash'; @@ -121,8 +121,6 @@ export interface DashboardSceneState extends SceneObjectState { editPanel?: PanelEditor; /** Scene object that handles the current drawer or modal */ overlay?: SceneObject; - /** True when a user copies a panel in the dashboard */ - hasCopiedPanel?: boolean; /** The dashboard doesn't have panels */ isEmpty?: boolean; /** Scene object that handles the scopes selector */ @@ -172,7 +170,6 @@ export class DashboardScene extends SceneObjectBase { editable: true, body: state.body ?? new SceneFlexLayout({ children: [] }), links: state.links ?? [], - hasCopiedPanel: store.exists(LS_PANEL_COPY_KEY), scopes: state.uid && config.featureToggles.scopeFilters ? new ScopesScene() : undefined, ...state, }); @@ -357,20 +354,19 @@ export class DashboardScene extends SceneObjectBase { } public onRestore = async (version: DecoratedRevisionModel): Promise => { - const versionRsp = await getHistorySrv().restoreDashboard(version.uid, version.version); + const versionRsp = await historySrv.restoreDashboard(version.uid, version.version); - const rev = (versionRsp as SaveDashboardResponseDTO).version; - if (!Number.isInteger(version)) { + if (!Number.isInteger(versionRsp.version)) { return false; } const dashboardDTO: DashboardDTO = { - dashboard: new DashboardModel(version.data!), + dashboard: new DashboardModel(version.data), meta: this.state.meta, }; const dashScene = transformSaveModelToScene(dashboardDTO); const newState = sceneUtils.cloneSceneObjectState(dashScene.state); - newState.version = rev; + newState.version = versionRsp.version; this.setState(newState); this.exitEditMode({ skipConfirm: true, restoreInitialState: false }); @@ -649,7 +645,6 @@ export class DashboardScene extends SceneObjectBase { store.set(LS_PANEL_COPY_KEY, JSON.stringify(jsonData)); appEvents.emit(AppEvents.alertSuccess, ['Panel copied. Use **Paste panel** toolbar action to paste.']); - this.setState({ hasCopiedPanel: true }); } public pastePanel() { @@ -704,7 +699,6 @@ export class DashboardScene extends SceneObjectBase { children: [gridItem, ...sceneGridLayout.state.children], }); - this.setState({ hasCopiedPanel: false }); store.delete(LS_PANEL_COPY_KEY); } diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index e851c1ea4cc..c40f3e08697 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { useEffect, useId, useState } from 'react'; import * as React from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, store } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config, locationService } from '@grafana/runtime'; import { @@ -18,6 +18,7 @@ import { } from '@grafana/ui'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator'; +import { LS_PANEL_COPY_KEY } from 'app/core/constants'; import { contextSrv } from 'app/core/core'; import { Trans, t } from 'app/core/internationalization'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; @@ -51,17 +52,7 @@ NavToolbarActions.displayName = 'NavToolbarActions'; * This part is split into a separate component to help test this */ export function ToolbarActions({ dashboard }: Props) { - const { - isEditing, - viewPanelScene, - isDirty, - uid, - meta, - editview, - editPanel, - editable, - hasCopiedPanel: copiedPanel, - } = dashboard.useState(); + const { isEditing, viewPanelScene, isDirty, uid, meta, editview, editPanel, editable } = dashboard.useState(); const { isPlaying } = playlistSrv.useState(); const [isAddPanelMenuOpen, setIsAddPanelMenuOpen] = useState(false); @@ -72,7 +63,7 @@ export function ToolbarActions({ dashboard }: Props) { const isViewingPanel = Boolean(viewPanelScene); const isEditedPanelDirty = useVizManagerDirty(editPanel); const isEditingLibraryPanel = useEditingLibraryPanel(editPanel); - const hasCopiedPanel = Boolean(copiedPanel); + const hasCopiedPanel = store.exists(LS_PANEL_COPY_KEY); // Means we are not in settings view, fullscreen panel or edit panel const isShowingDashboard = !editview && !isViewingPanel && !isEditingPanel; const isEditingAndShowingDashboard = isEditing && isShowingDashboard; diff --git a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx b/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx index ec36dafc8e2..eb6d5fb73e5 100644 --- a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx +++ b/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.test.tsx @@ -94,6 +94,24 @@ describe('RowRepeaterBehavior', () => { }); }); + describe('Should not repeat row', () => { + it('Should ignore repeat process if the variable is not a multi select variable', async () => { + const { scene, grid, repeatBehavior } = buildScene({ variableQueryTime: 0 }, undefined, { isMulti: false }); + const gridStateUpdates = []; + grid.subscribeToState((state) => gridStateUpdates.push(state)); + + activateFullSceneTree(scene); + await new Promise((r) => setTimeout(r, 1)); + + // trigger another repeat cycle by changing the variable + repeatBehavior.performRepeat(); + + await new Promise((r) => setTimeout(r, 1)); + + expect(gridStateUpdates.length).toBe(0); + }); + }); + describe('Given scene empty row', () => { let scene: DashboardScene; let grid: SceneGridLayout; @@ -139,7 +157,11 @@ interface SceneOptions { repeatDirection?: RepeatDirection; } -function buildScene(options: SceneOptions, variableOptions?: VariableValueOption[]) { +function buildScene( + options: SceneOptions, + variableOptions?: VariableValueOption[], + variableStateOverrides?: { isMulti: boolean } +) { const repeatBehavior = new RowRepeaterBehavior({ variableName: 'server' }); const grid = new SceneGridLayout({ @@ -223,6 +245,7 @@ function buildScene(options: SceneOptions, variableOptions?: VariableValueOption { label: 'D', value: 'D1' }, { label: 'E', value: 'E1' }, ], + ...variableStateOverrides, }), ], }), diff --git a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts b/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts index 5ef6c6137ec..9b96f31c1d6 100644 --- a/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts +++ b/public/app/features/dashboard-scene/scene/RowRepeaterBehavior.ts @@ -77,7 +77,12 @@ export class RowRepeaterBehavior extends SceneObjectBase currentNode.isExpanded = isExpanded; currentNode.query = query; - this.setState({ nodes, loadingNodeName: undefined }); - if (isExpanded || isDifferentQuery) { - this.setState({ loadingNodeName: name }); + this.setState({ nodes, loadingNodeName: name }); this.nodesFetchingSub = from(fetchNodes(name, query)) .pipe( @@ -138,6 +136,8 @@ export class ScopesFiltersScene extends SceneObjectBase this.nodesFetchingSub?.unsubscribe(); }); + } else { + this.setState({ nodes, loadingNodeName: undefined }); } } diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeSearch.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeSearch.tsx index cc9f4a61d8c..e0962914f71 100644 --- a/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeSearch.tsx +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeSearch.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; -import { debounce } from 'lodash'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; +import { useDebounce } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; import { FilterInput, useStyles2 } from '@grafana/ui'; @@ -18,13 +18,23 @@ export interface ScopesTreeSearchProps { export function ScopesTreeSearch({ anyChildExpanded, nodePath, query, onNodeUpdate }: ScopesTreeSearchProps) { const styles = useStyles2(getStyles); - const [queryValue, setQueryValue] = useState(query); + const [inputState, setInputState] = useState<{ value: string; isDirty: boolean }>({ value: query, isDirty: false }); useEffect(() => { - setQueryValue(query); - }, [query]); + if (!inputState.isDirty && inputState.value !== query) { + setInputState({ value: query, isDirty: false }); + } + }, [inputState, query]); - const onQueryUpdate = useMemo(() => debounce(onNodeUpdate, 500), [onNodeUpdate]); + useDebounce( + () => { + if (inputState.isDirty) { + onNodeUpdate(nodePath, true, inputState.value); + } + }, + 500, + [inputState.isDirty, inputState.value] + ); if (anyChildExpanded) { return null; @@ -33,12 +43,11 @@ export function ScopesTreeSearch({ anyChildExpanded, nodePath, query, onNodeUpda return ( { - setQueryValue(value); - onQueryUpdate(nodePath, true, value); + setInputState({ value, isDirty: true }); }} /> ); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index 1e11bc51bde..7f312c60208 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -438,7 +438,6 @@ export function buildGridItemForLibPanel(panel: PanelModel) { x: panel.gridPos.x, width: panel.gridPos.w, height: panel.gridPos.h, - itemHeight: panel.gridPos.h, body, }); } @@ -503,7 +502,6 @@ export function buildGridItemForPanel(panel: PanelModel): DashboardGridItem { y: panel.gridPos.y, width: repeatOptions.repeatDirection === 'h' ? 24 : panel.gridPos.w, height: panel.gridPos.h, - itemHeight: panel.gridPos.h, body, maxPerRow: panel.maxPerRow, ...repeatOptions, diff --git a/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx index b1256e997b5..f9024d2b8ed 100644 --- a/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx +++ b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx @@ -4,7 +4,7 @@ import { DashboardScene } from '../scene/DashboardScene'; import { activateFullSceneTree } from '../utils/test-utils'; import { VERSIONS_FETCH_LIMIT, VersionsEditView } from './VersionsEditView'; -import { getHistorySrv } from './version-history'; +import { historySrv } from './version-history'; jest.mock('./version-history/HistorySrv'); @@ -20,7 +20,7 @@ describe('VersionsEditView', () => { } as unknown as React.FormEvent; beforeEach(async () => { - jest.mocked(getHistorySrv().getHistoryList).mockResolvedValue(getVersions()); + jest.mocked(historySrv.getHistoryList).mockResolvedValue(getVersions()); const result = await buildTestScene(); dashboard = result.dashboard; @@ -81,7 +81,7 @@ describe('VersionsEditView', () => { versionsView.onCheck(mockEvent, 4); jest - .mocked(getHistorySrv().getDashboardVersion) + .mocked(historySrv.getDashboardVersion) .mockResolvedValueOnce({ data: 'lhs' }) .mockResolvedValue({ data: 'rhs' }); @@ -100,7 +100,7 @@ describe('VersionsEditView', () => { versionsView.onCheck(mockEvent, 2); jest - .mocked(getHistorySrv().getDashboardVersion) + .mocked(historySrv.getDashboardVersion) .mockResolvedValueOnce({ data: 'lhs' }) .mockResolvedValue({ data: 'rhs' }); diff --git a/public/app/features/dashboard-scene/settings/VersionsEditView.tsx b/public/app/features/dashboard-scene/settings/VersionsEditView.tsx index 3ed616d8095..c2acbb9b7af 100644 --- a/public/app/features/dashboard-scene/settings/VersionsEditView.tsx +++ b/public/app/features/dashboard-scene/settings/VersionsEditView.tsx @@ -11,15 +11,21 @@ import { getDashboardSceneFor } from '../utils/utils'; import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils'; import { + RevisionsModel, VersionHistoryComparison, VersionHistoryHeader, VersionHistoryTable, VersionsHistoryButtons, + historySrv, } from './version-history'; -import { DecoratedRevisionModel, VersionModel, getHistorySrv } from './version-history/HistorySrv'; export const VERSIONS_FETCH_LIMIT = 10; +export type DecoratedRevisionModel = RevisionsModel & { + createdDateString: string; + ageString: string; +}; + export interface VersionsEditViewState extends DashboardEditViewState { versions?: DecoratedRevisionModel[]; isLoading?: boolean; @@ -96,7 +102,7 @@ export class VersionsEditView extends SceneObjectBase imp this.setState({ isAppending: append }); - getHistorySrv() + historySrv .getHistoryList(uid, { limit: this._limit, start: this._start }) .then((result) => { this.setState({ @@ -122,8 +128,8 @@ export class VersionsEditView extends SceneObjectBase imp return; } - const lhs = await getHistorySrv().getDashboardVersion(this._dashboard.state.uid, baseInfo.version); - const rhs = await getHistorySrv().getDashboardVersion(this._dashboard.state.uid, newInfo.version); + const lhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, baseInfo.version); + const rhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, newInfo.version); this.setState({ baseInfo, @@ -132,8 +138,8 @@ export class VersionsEditView extends SceneObjectBase imp newInfo, viewMode: 'compare', diffData: { - lhs: JSON.stringify(lhs), - rhs: JSON.stringify(rhs), + lhs: lhs.data, + rhs: rhs.data, }, }); }; @@ -152,15 +158,15 @@ export class VersionsEditView extends SceneObjectBase imp }); }; - public onCheck = (ev: React.FormEvent, versionId: number | string) => { + public onCheck = (ev: React.FormEvent, versionId: number) => { this.setState({ versions: this.versions.map((version) => - version.version === versionId ? { ...version, checked: ev.currentTarget.checked } : version + version.id === versionId ? { ...version, checked: ev.currentTarget.checked } : version ), }); }; - private decorateVersions(versions: VersionModel[]): DecoratedRevisionModel[] { + private decorateVersions(versions: RevisionsModel[]): DecoratedRevisionModel[] { const timeZone = this.getTimeRange().getTimeZone(); return versions.map((version) => { diff --git a/public/app/features/dashboard-scene/settings/variables/editors/AdHocFiltersVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/AdHocFiltersVariableEditor.tsx index dc03d1b347b..5e5ff504462 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/AdHocFiltersVariableEditor.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/AdHocFiltersVariableEditor.tsx @@ -1,9 +1,8 @@ import { useAsync } from 'react-use'; -import { DataSourceInstanceSettings, MetricFindValue } from '@grafana/data'; +import { DataSourceInstanceSettings, MetricFindValue, getDataSourceRef } from '@grafana/data'; import { getDataSourceSrv } from '@grafana/runtime'; import { AdHocFiltersVariable } from '@grafana/scenes'; -import { DataSourceRef } from '@grafana/schema'; import { AdHocVariableForm } from '../components/AdHocVariableForm'; @@ -25,10 +24,7 @@ export function AdHocFiltersVariableEditor(props: AdHocFiltersVariableEditorProp : 'This data source does not support ad hoc filters yet.'; const onDataSourceChange = (ds: DataSourceInstanceSettings) => { - const dsRef: DataSourceRef = { - uid: ds.uid, - type: ds.type, - }; + const dsRef = getDataSourceRef(ds); variable.setState({ datasource: dsRef, diff --git a/public/app/features/dashboard-scene/settings/variables/editors/GroupByVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/GroupByVariableEditor.tsx index 808dee6dad2..2663ab7c47c 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/GroupByVariableEditor.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/GroupByVariableEditor.tsx @@ -1,6 +1,6 @@ import { useAsync } from 'react-use'; -import { DataSourceInstanceSettings, DataSourceRef, MetricFindValue } from '@grafana/data'; +import { DataSourceInstanceSettings, MetricFindValue, getDataSourceRef } from '@grafana/data'; import { getDataSourceSrv } from '@grafana/runtime'; import { GroupByVariable } from '@grafana/scenes'; @@ -24,10 +24,7 @@ export function GroupByVariableEditor(props: GroupByVariableEditorProps) { : 'This data source does not support group by variable yet.'; const onDataSourceChange = async (ds: DataSourceInstanceSettings) => { - const dsRef: DataSourceRef = { - uid: ds.uid, - type: ds.type, - }; + const dsRef = getDataSourceRef(ds); variable.setState({ datasource: dsRef }); onRunQuery(); diff --git a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx index 539290d26e9..05ea0873e91 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx @@ -1,9 +1,9 @@ import { FormEvent } from 'react'; import * as React from 'react'; -import { SelectableValue, DataSourceInstanceSettings } from '@grafana/data'; +import { SelectableValue, DataSourceInstanceSettings, getDataSourceRef } from '@grafana/data'; import { QueryVariable, sceneGraph } from '@grafana/scenes'; -import { DataSourceRef, VariableRefresh, VariableSort } from '@grafana/schema'; +import { VariableRefresh, VariableSort } from '@grafana/schema'; import { QueryVariableEditorForm } from '../components/QueryVariableForm'; @@ -36,7 +36,7 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito variable.setState({ allValue: event.currentTarget.value }); }; const onDataSourceChange = (dsInstanceSettings: DataSourceInstanceSettings) => { - const datasource: DataSourceRef = { uid: dsInstanceSettings.uid, type: dsInstanceSettings.type }; + const datasource = getDataSourceRef(dsInstanceSettings); if (variable.state.datasource && variable.state.datasource.type !== datasource.type) { variable.setState({ datasource, query: '', definition: '' }); diff --git a/public/app/features/dashboard-scene/settings/version-history/HistorySrv.test.ts b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.test.ts index e7bc09af7d9..c2caee31a0e 100644 --- a/public/app/features/dashboard-scene/settings/version-history/HistorySrv.test.ts +++ b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.test.ts @@ -1,6 +1,6 @@ import { createDashboardModelFixture } from 'app/features/dashboard/state/__fixtures__/dashboardFixtures'; -import { getHistorySrv } from './HistorySrv'; +import { HistorySrv } from './HistorySrv'; import { restore, versions } from './__mocks__/dashboardHistoryMocks'; const getMock = jest.fn().mockResolvedValue({}); @@ -27,7 +27,7 @@ describe('historySrv', () => { const versionsResponse = versions(); const restoreResponse = restore; - let historySrv = getHistorySrv(); + let historySrv = new HistorySrv(); const dash = createDashboardModelFixture({ uid: '_U4zObQMz' }); const emptyDash = createDashboardModelFixture(); @@ -40,7 +40,7 @@ describe('historySrv', () => { describe('getHistoryList', () => { it('should return a versions array for the given dashboard id', () => { getMock.mockImplementation(() => Promise.resolve(versionsResponse)); - historySrv = getHistorySrv(); + historySrv = new HistorySrv(); return historySrv.getHistoryList(dash.uid, historyListOpts).then((versions) => { expect(versions).toEqual(versionsResponse); @@ -63,7 +63,7 @@ describe('historySrv', () => { describe('getDashboardVersion', () => { it('should return a version object for the given dashboard id and version', () => { getMock.mockImplementation(() => Promise.resolve(versionsResponse[0])); - historySrv = getHistorySrv(); + historySrv = new HistorySrv(); return historySrv.getDashboardVersion(dash.uid, 4).then((version) => { expect(version).toEqual(versionsResponse[0]); @@ -71,7 +71,7 @@ describe('historySrv', () => { }); it('should return an empty object when not given an id', async () => { - historySrv = getHistorySrv(); + historySrv = new HistorySrv(); const rsp = await historySrv.getDashboardVersion(emptyDash.uid, 6); expect(rsp).toEqual({}); @@ -82,14 +82,14 @@ describe('historySrv', () => { it('should return a success response given valid parameters', () => { const version = 6; postMock.mockImplementation(() => Promise.resolve(restoreResponse(version))); - historySrv = getHistorySrv(); + historySrv = new HistorySrv(); return historySrv.restoreDashboard(dash.uid, version).then((response) => { expect(response).toEqual(restoreResponse(version)); }); }); it('should return an empty object when not given an id', async () => { - historySrv = getHistorySrv(); + historySrv = new HistorySrv(); const rsp = await historySrv.restoreDashboard(emptyDash.uid, 6); expect(rsp).toEqual({}); }); diff --git a/public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts index 1721a2dcb5d..61312b8ad45 100644 --- a/public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts +++ b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts @@ -1,56 +1,43 @@ import { getBackendSrv } from '@grafana/runtime'; import { Dashboard } from '@grafana/schema'; -import { SaveDashboardResponseDTO } from 'app/types'; export interface HistoryListOpts { limit: number; start: number; } -// The raw version returned from api -export interface VersionModel { +export interface RevisionsModel { + id: number; + checked: boolean; uid: string; - version: number; // resourceVersion in k8s must be numeric - created: string; + parentVersion: number; + version: number; + created: Date; createdBy: string; message: string; + data: Dashboard; } -// The version used in UI components -export type DecoratedRevisionModel = VersionModel & { - checked: boolean; - createdDateString: string; - ageString: string; - data?: Dashboard; -}; - -export interface HistorySrv { - getHistoryList(dashboardUID: string, options: HistoryListOpts): Promise; - getDashboardVersion(dashboardUID: string, version: number | string): Promise; // Just the spec (for now) - restoreDashboard(dashboardUID: string, version: number | string): Promise; -} - -class LegacyHistorySrv implements HistorySrv { +export class HistorySrv { getHistoryList(dashboardUID: string, options: HistoryListOpts) { if (typeof dashboardUID !== 'string') { return Promise.resolve([]); } - return getBackendSrv().get(`api/dashboards/uid/${dashboardUID}/versions`, options); + return getBackendSrv().get(`api/dashboards/uid/${dashboardUID}/versions`, options); } - async getDashboardVersion(dashboardUID: string, version: number): Promise { + getDashboardVersion(dashboardUID: string, version: number) { if (typeof dashboardUID !== 'string') { return Promise.resolve({}); } - const info = await getBackendSrv().get(`api/dashboards/uid/${dashboardUID}/versions/${version}`); - return info.data; // the dashboard body + return getBackendSrv().get(`api/dashboards/uid/${dashboardUID}/versions/${version}`); } - restoreDashboard(dashboardUID: string, version: number): Promise { + restoreDashboard(dashboardUID: string, version: number) { if (typeof dashboardUID !== 'string') { - return Promise.resolve({} as unknown as SaveDashboardResponseDTO); + return Promise.resolve({}); } const url = `api/dashboards/uid/${dashboardUID}/restore`; @@ -59,11 +46,5 @@ class LegacyHistorySrv implements HistorySrv { } } -let historySrv: HistorySrv | undefined = undefined; - -export function getHistorySrv(): HistorySrv { - if (!historySrv) { - historySrv = new LegacyHistorySrv(); - } - return historySrv; -} +const historySrv = new HistorySrv(); +export { historySrv }; diff --git a/public/app/features/dashboard-scene/settings/version-history/RevertDashboardModal.tsx b/public/app/features/dashboard-scene/settings/version-history/RevertDashboardModal.tsx index 87d96754e27..c3e44c132d1 100644 --- a/public/app/features/dashboard-scene/settings/version-history/RevertDashboardModal.tsx +++ b/public/app/features/dashboard-scene/settings/version-history/RevertDashboardModal.tsx @@ -1,7 +1,7 @@ import { ConfirmModal } from '@grafana/ui'; import { useAppNotification } from 'app/core/copy/appNotification'; -import { DecoratedRevisionModel } from './HistorySrv'; +import { DecoratedRevisionModel } from '../VersionsEditView'; export interface RevertDashboardModalProps { hideModal: () => void; diff --git a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryHeader.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryHeader.tsx index 73a7e1f6af6..05987ee2838 100644 --- a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryHeader.tsx +++ b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryHeader.tsx @@ -6,8 +6,8 @@ import { Icon, IconButton, useStyles2 } from '@grafana/ui'; type VersionHistoryHeaderProps = { onClick?: () => void; - baseVersion?: number | string; - newVersion?: number | string; + baseVersion?: number; + newVersion?: number; isNewLatest?: boolean; }; diff --git a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx index 264c7c1e313..5ae97bc63e7 100644 --- a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx +++ b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx @@ -4,13 +4,14 @@ import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Checkbox, Button, Tag, ModalsController, useStyles2 } from '@grafana/ui'; -import { DecoratedRevisionModel } from './HistorySrv'; +import { DecoratedRevisionModel } from '../VersionsEditView'; + import { RevertDashboardModal } from './RevertDashboardModal'; type VersionsTableProps = { versions: DecoratedRevisionModel[]; canCompare: boolean; - onCheck: (ev: React.FormEvent, versionId: number | string) => void; + onCheck: (ev: React.FormEvent, versionId: number) => void; onRestore: (version: DecoratedRevisionModel) => Promise; }; @@ -32,7 +33,7 @@ export const VersionHistoryTable = ({ versions, canCompare, onCheck, onRestore } {versions.map((version, idx) => ( - + onCheck(ev, version.version)} + onChange={(ev) => onCheck(ev, version.id)} disabled={!version.checked && canCompare} /> diff --git a/public/app/features/dashboard-scene/settings/version-history/index.ts b/public/app/features/dashboard-scene/settings/version-history/index.ts index b9c4201ba43..c87d2d0b9b7 100644 --- a/public/app/features/dashboard-scene/settings/version-history/index.ts +++ b/public/app/features/dashboard-scene/settings/version-history/index.ts @@ -1,4 +1,4 @@ -export { HistorySrv, getHistorySrv } from './HistorySrv'; +export { HistorySrv, historySrv, RevisionsModel } from './HistorySrv'; export { VersionHistoryTable } from './VersionHistoryTable'; export { VersionHistoryHeader } from './VersionHistoryHeader'; export { VersionsHistoryButtons } from './VersionHistoryButtons'; diff --git a/public/app/features/dashboard-scene/utils/test-utils.ts b/public/app/features/dashboard-scene/utils/test-utils.ts index 235dae85035..b8f284622d0 100644 --- a/public/app/features/dashboard-scene/utils/test-utils.ts +++ b/public/app/features/dashboard-scene/utils/test-utils.ts @@ -96,6 +96,7 @@ interface SceneOptions { variableQueryTime: number; maxPerRow?: number; itemHeight?: number; + height?: number; repeatDirection?: RepeatDirection; x?: number; y?: number; @@ -113,6 +114,7 @@ export function buildPanelRepeaterScene(options: SceneOptions, source?: VizPanel repeatDirection: options.repeatDirection, maxPerRow: options.maxPerRow, itemHeight: options.itemHeight, + height: options.height, body: source ?? new VizPanel({ diff --git a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx index 0ee9b2809ba..77983e110fb 100644 --- a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx @@ -5,7 +5,7 @@ import { BrowserRouter } from 'react-router-dom'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { GrafanaContext } from 'app/core/context/GrafanaContext'; -import { getHistorySrv } from 'app/features/dashboard-scene/settings/version-history/HistorySrv'; +import { historySrv } from 'app/features/dashboard-scene/settings/version-history/HistorySrv'; import { configureStore } from '../../../../store/configureStore'; import { createDashboardModelFixture } from '../../state/__fixtures__/dashboardFixtures'; @@ -135,8 +135,8 @@ describe('VersionSettings', () => { }); test('clicking show more appends results to the table', async () => { - getHistorySrv() - .getHistoryList // @ts-ignore + historySrv.getHistoryList + // @ts-ignore .mockImplementationOnce(() => Promise.resolve(versions.slice(0, VERSIONS_FETCH_LIMIT))) .mockImplementationOnce( () => new Promise((resolve) => setTimeout(() => resolve(versions.slice(VERSIONS_FETCH_LIMIT)), 1000)) @@ -144,7 +144,7 @@ describe('VersionSettings', () => { setup(); - expect(getHistorySrv().getHistoryList).toBeCalledTimes(1); + expect(historySrv.getHistoryList).toBeCalledTimes(1); await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); @@ -153,7 +153,7 @@ describe('VersionSettings', () => { const showMoreButton = screen.getByRole('button', { name: /show more versions/i }); await user.click(showMoreButton); - expect(getHistorySrv().getHistoryList).toBeCalledTimes(2); + expect(historySrv.getHistoryList).toBeCalledTimes(2); expect(screen.getByText(/Fetching more entries/i)).toBeInTheDocument(); jest.advanceTimersByTime(1000); @@ -166,14 +166,14 @@ describe('VersionSettings', () => { test('selecting two versions and clicking compare button should render compare view', async () => { // @ts-ignore historySrv.getHistoryList.mockResolvedValue(versions.slice(0, VERSIONS_FETCH_LIMIT)); - getHistorySrv() - .getDashboardVersion // @ts-ignore + historySrv.getDashboardVersion + // @ts-ignore .mockImplementationOnce(() => Promise.resolve(diffs.lhs)) .mockImplementationOnce(() => Promise.resolve(diffs.rhs)); setup(); - expect(getHistorySrv().getHistoryList).toBeCalledTimes(1); + expect(historySrv.getHistoryList).toBeCalledTimes(1); await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); diff --git a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx index c61c131902d..c22f31c9437 100644 --- a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx @@ -3,12 +3,12 @@ import * as React from 'react'; import { Spinner, HorizontalGroup } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; -import { VersionHistoryHeader, VersionsHistoryButtons } from 'app/features/dashboard-scene/settings/version-history'; import { - DecoratedRevisionModel, - VersionModel, - getHistorySrv, -} from 'app/features/dashboard-scene/settings/version-history/HistorySrv'; + historySrv, + RevisionsModel, + VersionHistoryHeader, + VersionsHistoryButtons, +} from 'app/features/dashboard-scene/settings/version-history'; import { VersionHistoryComparison } from '../VersionHistory/VersionHistoryComparison'; import { VersionHistoryTable } from '../VersionHistory/VersionHistoryTable'; @@ -28,6 +28,11 @@ type State = { isNewLatest: boolean; }; +export type DecoratedRevisionModel = RevisionsModel & { + createdDateString: string; + ageString: string; +}; + export const VERSIONS_FETCH_LIMIT = 10; export class VersionsSettings extends PureComponent { @@ -57,7 +62,7 @@ export class VersionsSettings extends PureComponent { getVersions = (append = false) => { this.setState({ isAppending: append }); - getHistorySrv() + historySrv .getHistoryList(this.props.dashboard.uid, { limit: this.limit, start: this.start }) .then((res) => { this.setState({ @@ -79,8 +84,8 @@ export class VersionsSettings extends PureComponent { isLoading: true, }); - const lhs = await getHistorySrv().getDashboardVersion(this.props.dashboard.uid, baseInfo.version); - const rhs = await getHistorySrv().getDashboardVersion(this.props.dashboard.uid, newInfo.version); + const lhs = await historySrv.getDashboardVersion(this.props.dashboard.uid, baseInfo.version); + const rhs = await historySrv.getDashboardVersion(this.props.dashboard.uid, newInfo.version); this.setState({ baseInfo, @@ -89,13 +94,13 @@ export class VersionsSettings extends PureComponent { newInfo, viewMode: 'compare', diffData: { - lhs: JSON.stringify(lhs), - rhs: JSON.stringify(rhs), + lhs: lhs.data, + rhs: rhs.data, }, }); }; - decorateVersions = (versions: VersionModel[]) => + decorateVersions = (versions: RevisionsModel[]) => versions.map((version) => ({ ...version, createdDateString: this.props.dashboard.formatDate(version.created), @@ -110,7 +115,7 @@ export class VersionsSettings extends PureComponent { onCheck = (ev: React.FormEvent, versionId: number) => { this.setState({ versions: this.state.versions.map((version) => - version.version === versionId ? { ...version, checked: ev.currentTarget.checked } : version + version.id === versionId ? { ...version, checked: ev.currentTarget.checked } : version ), }); }; diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditorQueries.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditorQueries.tsx index 7f87d410e20..0c81aafeea8 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditorQueries.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditorQueries.tsx @@ -40,8 +40,7 @@ export class PanelEditorQueries extends PureComponent { cacheTimeout: datasourceSettings?.meta.queryOptions?.cacheTimeout ? panel.cacheTimeout : undefined, dataSource: { default: datasourceSettings?.isDefault, - type: datasourceSettings?.type, - uid: datasourceSettings?.uid, + ...(datasourceSettings ? getDataSourceRef(datasourceSettings) : { type: undefined, uid: undefined }), }, queryCachingTTL: datasourceSettings?.cachingConfig?.enabled ? panel.queryCachingTTL : undefined, queries: panel.targets, diff --git a/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx b/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx index f44443a53f4..2f23cd32e17 100644 --- a/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx +++ b/public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx @@ -4,9 +4,10 @@ import { GrafanaTheme2 } from '@grafana/data'; import { Button, ModalsController, CollapsableSection, useStyles2, Stack, Icon, Box } from '@grafana/ui'; import { DiffGroup } from 'app/features/dashboard-scene/settings/version-history/DiffGroup'; import { DiffViewer } from 'app/features/dashboard-scene/settings/version-history/DiffViewer'; -import { DecoratedRevisionModel } from 'app/features/dashboard-scene/settings/version-history/HistorySrv'; import { jsonDiff } from 'app/features/dashboard-scene/settings/version-history/utils'; +import { DecoratedRevisionModel } from '../DashboardSettings/VersionsSettings'; + import { RevertDashboardModal } from './RevertDashboardModal'; type DiffViewProps = { diff --git a/public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx b/public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx index 504db7cbb35..2cd8514992a 100644 --- a/public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx +++ b/public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx @@ -3,7 +3,8 @@ import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Checkbox, Button, Tag, ModalsController, useStyles2 } from '@grafana/ui'; -import { DecoratedRevisionModel } from 'app/features/dashboard-scene/settings/version-history/HistorySrv'; + +import { DecoratedRevisionModel } from '../DashboardSettings/VersionsSettings'; import { RevertDashboardModal } from './RevertDashboardModal'; @@ -31,7 +32,7 @@ export const VersionHistoryTable = ({ versions, canCompare, onCheck }: VersionsT {versions.map((version, idx) => ( - + onCheck(ev, version.version)} + onChange={(ev) => onCheck(ev, version.id)} disabled={!version.checked && canCompare} /> diff --git a/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx b/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx index b92b77d1a1c..af1f894d3f2 100644 --- a/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx +++ b/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx @@ -4,7 +4,7 @@ import { useAsyncFn } from 'react-use'; import { locationUtil } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { useAppNotification } from 'app/core/copy/appNotification'; -import { getHistorySrv } from 'app/features/dashboard-scene/settings/version-history/HistorySrv'; +import { historySrv } from 'app/features/dashboard-scene/settings/version-history'; import { useSelector } from 'app/types'; import { dashboardWatcher } from '../../../live/dashboard/dashboardWatcher'; @@ -13,7 +13,7 @@ import { DashboardModel } from '../../state'; const restoreDashboard = async (version: number, dashboard: DashboardModel) => { // Skip the watcher logic for this save since it's handled by the hook dashboardWatcher.ignoreNextSave(); - return await getHistorySrv().restoreDashboard(dashboard.uid, version); + return await historySrv.restoreDashboard(dashboard.uid, version); }; export const useDashboardRestore = (version: number) => { diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index 56d76acd5ef..b8ca782906c 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -544,10 +544,7 @@ export class PanelModel implements DataConfigSource, IPanelModel { updateQueries(options: QueryGroupOptions) { const { dataSource } = options; - this.datasource = { - uid: dataSource.uid, - type: dataSource.type, - }; + this.datasource = dataSource; this.cacheTimeout = options.cacheTimeout; this.queryCachingTTL = options.queryCachingTTL; diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts index 7a09899fe5b..24ab46c15b7 100644 --- a/public/app/features/datasources/state/actions.ts +++ b/public/app/features/datasources/state/actions.ts @@ -18,6 +18,7 @@ import { import { updateNavIndex } from 'app/core/actions'; import { appEvents, contextSrv } from 'app/core/core'; import { getBackendSrv } from 'app/core/services/backend_srv'; +import { DatasourceAPIVersions } from 'app/features/apiserver/client'; import { ROUTES as CONNECTIONS_ROUTES } from 'app/features/connections/constants'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { getPluginSettings } from 'app/features/plugins/pluginSettings'; @@ -264,6 +265,8 @@ export function loadDataSourcePlugins(): ThunkResult { }; } +const dsApiVersions = new DatasourceAPIVersions(); + export function updateDataSource(dataSource: DataSourceSettings) { return async ( dispatch: ( @@ -271,13 +274,16 @@ export function updateDataSource(dataSource: DataSourceSettings) { ) => DataSourceSettings ) => { try { + if (config.featureToggles.grafanaAPIServerWithExperimentalAPIs) { + dataSource.apiVersion = await dsApiVersions.get(dataSource.type); + } await api.updateDataSource(dataSource); } catch (err) { const formattedError = parseHealthCheckError(err); dispatch(testDataSourceFailed(formattedError)); - - return Promise.reject(err); + const errorInfo = isFetchError(err) ? err.data : { message: 'An unexpected error occurred.', traceID: '' }; + return Promise.reject(errorInfo); } await getDatasourceSrv().reload(); diff --git a/public/app/features/explore/ContentOutline/ContentOutline.tsx b/public/app/features/explore/ContentOutline/ContentOutline.tsx index a40362363de..415f34f3e52 100644 --- a/public/app/features/explore/ContentOutline/ContentOutline.tsx +++ b/public/app/features/explore/ContentOutline/ContentOutline.tsx @@ -7,6 +7,7 @@ import { reportInteraction } from '@grafana/runtime'; import { useStyles2, PanelContainer, CustomScrollbar } from '@grafana/ui'; import { ContentOutlineItemContextProps, useContentOutlineContext } from './ContentOutlineContext'; +import { ITEM_TYPES } from './ContentOutlineItem'; import { ContentOutlineItemButton } from './ContentOutlineItemButton'; function scrollableChildren(item: ContentOutlineItemContextProps) { @@ -56,7 +57,12 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement | }, {}); }); - const scrollIntoView = (ref: HTMLElement | null, itemPanelId: string, customOffsetTop = 0) => { + const scrollIntoView = ( + ref: HTMLElement | null, + itemPanelId: string, + itemType: ITEM_TYPES | undefined, + customOffsetTop = 0 + ) => { let scrollValue = 0; let el: HTMLElement | null | undefined = ref; @@ -73,11 +79,24 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement | top: scrollValue + customOffsetTop, behavior: 'smooth', }); + }; - reportInteraction('explore_toolbar_contentoutline_clicked', { - item: 'select_section', - type: itemPanelId, - }); + const handleItemClicked = (item: ContentOutlineItemContextProps) => { + if (item.level === 'child' && item.type === 'filter') { + const activeParent = outlineItems.find((parent) => { + return parent.children?.find((child) => child.id === item.id); + }); + + if (activeParent) { + scrollIntoView(activeParent.ref, activeParent.panelId, activeParent.type, activeParent.customTopOffset); + } + } else { + scrollIntoView(item.ref, item.panelId, item.type, item.customTopOffset); + reportInteraction('explore_toolbar_contentoutline_clicked', { + item: 'select_section', + type: item.panelId, + }); + } }; const toggle = () => { @@ -131,16 +150,6 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement | } }, [outlineItems, verticalScroll]); - const activateFilter = (filterId: string) => { - const activeParent = outlineItems.find((item) => { - return item.children?.find((child) => child.id === filterId); - }); - - if (activeParent) { - scrollIntoView(activeParent.ref, activeParent.panelId, activeParent.customTopOffset); - } - }; - return ( @@ -173,7 +182,7 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement | isChildActive(item, activeSectionChildId) && !contentOutlineExpanded && sectionsExpanded[item.id], })} icon={item.icon} - onClick={() => scrollIntoView(item.ref, item.panelId)} + onClick={() => handleItemClicked(item)} tooltip={item.title} collapsible={isCollapsible(item)} collapsed={!sectionsExpanded[item.id]} @@ -208,9 +217,7 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement | })} indentStyle={styles.indentChild} onClick={(e) => { - child.type === 'filter' - ? activateFilter(child.id) - : scrollIntoView(child.ref, child.panelId, child.customTopOffset); + handleItemClicked(child); child.onClick?.(e); }} tooltip={child.title} diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 0544b3720fd..6cce1574c5c 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -262,6 +262,10 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { highlight: currentLevelSelected && !allLevelsSelected, onClick: (e: React.MouseEvent) => { toggleLegendRef.current?.(level.levelStr, mapMouseEventToMode(e)); + reportInteraction('explore_toolbar_contentoutline_clicked', { + item: 'section', + type: `Logs:filter:${level.levelStr}`, + }); }, ref: null, color: LogLevelColor[level.logLevel], @@ -658,6 +662,11 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { ); + + reportInteraction('explore_toolbar_contentoutline_clicked', { + item: 'section', + type: 'Logs:pinned:pinned-log-limit-reached', + }); return; } @@ -677,16 +686,31 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { ref: null, color: LogLevelColor[row.logLevel], childOnTop: true, - onClick: () => onOpenContext(row, () => {}), + onClick: () => { + onOpenContext(row, () => {}); + reportInteraction('explore_toolbar_contentoutline_clicked', { + item: 'section', + type: 'Logs:pinned:pinned-log-clicked', + }); + }, onRemove: (id: string) => { unregister?.(id); if (getPinnedLogsCount() < PINNED_LOGS_LIMIT) { setPinLineButtonTooltipTitle('Pin to content outline'); } + reportInteraction('explore_toolbar_contentoutline_clicked', { + item: 'section', + type: 'Logs:pinned:pinned-log-deleted', + }); }, }); props.onPinLineCallback?.(); + + reportInteraction('explore_toolbar_contentoutline_clicked', { + item: 'section', + type: 'Logs:pinned:pinned-log-added', + }); }; const getPinnedLogsCount = () => { diff --git a/public/app/features/library-panels/state/api.ts b/public/app/features/library-panels/state/api.ts index d5611d98307..ce5faba1f2c 100644 --- a/public/app/features/library-panels/state/api.ts +++ b/public/app/features/library-panels/state/api.ts @@ -170,7 +170,6 @@ export function libraryVizPanelToSaveModel(libraryPanel: LibraryVizPanel) { export async function updateLibraryVizPanel(libraryPanel: LibraryVizPanel): Promise { const { uid, folderUID, name, model, version, kind } = libraryVizPanelToSaveModel(libraryPanel); - console.log('updateLibraryVizPanel', model); const { result } = await getBackendSrv().patch(`/api/library-elements/${uid}`, { folderUID, name, diff --git a/public/app/features/migrate-to-cloud/api/endpoints.gen.ts b/public/app/features/migrate-to-cloud/api/endpoints.gen.ts index 537535a7920..cd533d124aa 100644 --- a/public/app/features/migrate-to-cloud/api/endpoints.gen.ts +++ b/public/app/features/migrate-to-cloud/api/endpoints.gen.ts @@ -143,7 +143,7 @@ export type ErrorResponseBody = { /** a human readable version of the error */ message: string; /** Status An optional status to denote the cause of the error. - + For example, a 412 Precondition Failed error may include additional information of why that error happened. */ status?: string; }; diff --git a/public/app/features/migrate-to-cloud/cloud/EmptyState/InfoPane.tsx b/public/app/features/migrate-to-cloud/cloud/EmptyState/InfoPane.tsx new file mode 100644 index 00000000000..c94e1a59a3f --- /dev/null +++ b/public/app/features/migrate-to-cloud/cloud/EmptyState/InfoPane.tsx @@ -0,0 +1,20 @@ +import { Box } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; + +import { InfoItem } from '../../shared/InfoItem'; +import { MigrationTokenPane } from '../MigrationTokenPane/MigrationTokenPane'; + +export const InfoPane = () => { + return ( + + + + You can migrate some resources from your self-managed Grafana installation to this cloud stack. To do this + securely, you'll need to generate a migration token. Your self-managed instance will use the token to + authenticate with this cloud stack. + + + + + ); +}; diff --git a/public/app/features/migrate-to-cloud/cloud/EmptyState/MigrationStepsPane.tsx b/public/app/features/migrate-to-cloud/cloud/EmptyState/MigrationStepsPane.tsx new file mode 100644 index 00000000000..bc641972e7a --- /dev/null +++ b/public/app/features/migrate-to-cloud/cloud/EmptyState/MigrationStepsPane.tsx @@ -0,0 +1,68 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Box, Stack, TextLink, useStyles2 } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; + +import { InfoItem } from '../../shared/InfoItem'; + +export const MigrationStepsPane = () => { + const styles = useStyles2(getStyles); + + return ( + + + + + The migration process must be started from your self-managed Grafana instance. + +
    +
  1. + + Log in to your self-managed instance and navigate to Administration, General, Migrate to Grafana Cloud. + +
  2. +
  3. + + Select "Migrate this instance to Cloud". + +
  4. +
  5. + + You'll be prompted for a migration token. Generate one from this screen. + +
  6. +
  7. + + In your self-managed instance, select "Upload everything" to upload data sources and + dashboards to this cloud stack. + +
  8. +
  9. + + If some of your data sources will not work over the public internet, you’ll need to install Private Data + Source Connect in your self-managed environment. + +
  10. +
+
+
+ + {t('migrate-to-cloud.get-started.configure-pdc-link', 'Configure PDC for this stack')} + + + {t('migrate-to-cloud.migrate-to-this-stack.link-title', 'View the full migration guide')} + +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + list: css({ + padding: 'revert', + }), +}); diff --git a/public/app/features/migrate-to-cloud/cloud/MigrationTokenPane/MigrationTokenPane.tsx b/public/app/features/migrate-to-cloud/cloud/MigrationTokenPane/MigrationTokenPane.tsx index 825c86a52e1..34b3cf825e6 100644 --- a/public/app/features/migrate-to-cloud/cloud/MigrationTokenPane/MigrationTokenPane.tsx +++ b/public/app/features/migrate-to-cloud/cloud/MigrationTokenPane/MigrationTokenPane.tsx @@ -4,7 +4,6 @@ import { Box, Button, Text } from '@grafana/ui'; import { t, Trans } from 'app/core/internationalization'; import { useCreateCloudMigrationTokenMutation } from '../../api'; -import { InfoItem } from '../../shared/InfoItem'; import { TokenErrorAlert } from '../TokenErrorAlert'; import { MigrationTokenModal } from './MigrationTokenModal'; @@ -28,14 +27,12 @@ export const MigrationTokenPane = () => { return ( <> - - - - Your self-managed Grafana instance will require a special authentication token to securely connect to this - cloud stack. - - - + + {createTokenResponse?.isError ? ( ) : ( @@ -45,12 +42,6 @@ export const MigrationTokenPane = () => { )} - - { - const styles = useStyles2(getStyles); - return ( -
- + + - - -
+ +
+ ); }; - -const getStyles = (theme: GrafanaTheme2) => ({ - container: css({ - maxWidth: theme.breakpoints.values.xl, - }), -}); diff --git a/public/app/features/migrate-to-cloud/onprem/CTAInfo.tsx b/public/app/features/migrate-to-cloud/onprem/CTAInfo.tsx new file mode 100644 index 00000000000..eed3befa65d --- /dev/null +++ b/public/app/features/migrate-to-cloud/onprem/CTAInfo.tsx @@ -0,0 +1,27 @@ +import { ReactNode } from 'react'; + +import { Stack, Box, Text } from '@grafana/ui'; + +interface CTAInfoProps { + title: NonNullable; + accessory?: ReactNode; + children: ReactNode; +} + +export function CTAInfo(props: CTAInfoProps) { + const { title, accessory, children } = props; + + return ( + + {accessory && {accessory}} + + + + {title} + + + {children} + + + ); +} diff --git a/public/app/features/migrate-to-cloud/onprem/MigrationInfo.tsx b/public/app/features/migrate-to-cloud/onprem/MigrationInfo.tsx index 59062c4d39f..9aa974e78f3 100644 --- a/public/app/features/migrate-to-cloud/onprem/MigrationInfo.tsx +++ b/public/app/features/migrate-to-cloud/onprem/MigrationInfo.tsx @@ -1,19 +1,19 @@ import { ReactNode } from 'react'; -import { Stack, Text } from '@grafana/ui'; +import { Box, Text } from '@grafana/ui'; interface MigrationInfoProps { title: NonNullable; - value: NonNullable; + children: NonNullable; } -export function MigrationInfo({ title, value }: MigrationInfoProps) { +export function MigrationInfo({ title, children }: MigrationInfoProps) { return ( - + {title} - {value} - + {children} + ); } diff --git a/public/app/features/migrate-to-cloud/onprem/MigrationSummary.tsx b/public/app/features/migrate-to-cloud/onprem/MigrationSummary.tsx new file mode 100644 index 00000000000..f27383c62ec --- /dev/null +++ b/public/app/features/migrate-to-cloud/onprem/MigrationSummary.tsx @@ -0,0 +1,110 @@ +import { Box, Button, Space, Stack, Text } from '@grafana/ui'; +import { Trans, t } from 'app/core/internationalization'; + +import { GetSessionApiResponse, GetSnapshotResponseDto } from '../api'; + +import { MigrationInfo } from './MigrationInfo'; + +interface MigrationSummaryProps { + snapshot: GetSnapshotResponseDto | undefined; + session: GetSessionApiResponse; + isBusy: boolean; + + disconnectIsLoading: boolean; + onDisconnect: () => void; + + showBuildSnapshot: boolean; + buildSnapshotIsLoading: boolean; + onBuildSnapshot: () => void; + + showUploadSnapshot: boolean; + uploadSnapshotIsLoading: boolean; + onUploadSnapshot: () => void; +} + +export function MigrationSummary(props: MigrationSummaryProps) { + const { + session, + snapshot, + isBusy, + disconnectIsLoading, + onDisconnect, + showBuildSnapshot, + buildSnapshotIsLoading, + onBuildSnapshot, + + showUploadSnapshot, + uploadSnapshotIsLoading, + onUploadSnapshot, + } = props; + + const totalCount = 0; + const errorCount = 0; + const successCount = 0; + + return ( + + + + {snapshot?.created ? ( + snapshot?.created + ) : ( + + Not yet created + + )} + + + + {totalCount} + + + + {errorCount} + + + + {successCount} + + + + {session.slug} + + + + + + {showBuildSnapshot && ( + + )} + + {showUploadSnapshot && ( + + )} + + ); +} diff --git a/public/app/features/migrate-to-cloud/onprem/Page.tsx b/public/app/features/migrate-to-cloud/onprem/Page.tsx index 91b59f801a4..b991818abff 100644 --- a/public/app/features/migrate-to-cloud/onprem/Page.tsx +++ b/public/app/features/migrate-to-cloud/onprem/Page.tsx @@ -1,11 +1,12 @@ import { skipToken } from '@reduxjs/toolkit/query/react'; import { useCallback, useEffect, useState } from 'react'; -import { Alert, Box, Button, Stack } from '@grafana/ui'; +import { Alert, Box, Stack } from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; import { SnapshotDto, + useCancelSnapshotMutation, useCreateSnapshotMutation, useDeleteSessionMutation, useGetSessionListQuery, @@ -16,22 +17,25 @@ import { import { DisconnectModal } from './DisconnectModal'; import { EmptyState } from './EmptyState/EmptyState'; -import { MigrationInfo } from './MigrationInfo'; +import { MigrationSummary } from './MigrationSummary'; import { ResourcesTable } from './ResourcesTable'; +import { BuildSnapshotCTA, CreatingSnapshotCTA } from './SnapshotCTAs'; /** * Here's how migrations work: * - * A single on-prem instance can be configured to be migrated to multiple cloud instances. - * - GetMigrationList returns this the list of migration targets for the on prem instance - * - If GetMigrationList returns an empty list, then the empty state with a prompt to enter a token should be shown + * A single on-prem instance can be configured to be migrated to multiple cloud instances. We call these 'sessions'. + * - GetSessionList returns this the list of migration targets for the on prem instance + * - If GetMigrationList returns an empty list, then an empty state to prompt for token should be shown * - The UI (at the moment) only shows the most recently created migration target (the last one returned from the API) * and doesn't allow for others to be created * - * A single on-prem migration 'target' (CloudMigrationResponse) can have multiple migration runs (CloudMigrationRun) - * - To list the migration resources: - * 1. call GetCloudMigratiopnRunList to list all runs - * 2. call GetCloudMigrationRun with the ID from first step to list the result of that migration + * A single on-prem migration 'target' (CloudMigrationSession) can have multiple snapshots. + * A snapshot represents a copy of all migratable resources at a fixed point in time. + * A snapshots are created asynchronously in the background, so GetSnapshot must be polled to get the current status. + * + * After a snapshot has been created, it will be PENDING_UPLOAD. UploadSnapshot is then called which asynchronously + * uploads and migrates the snapshot to the cloud instance. */ function useGetLatestSession() { @@ -52,6 +56,10 @@ const SHOULD_POLL_STATUSES: Array = [ 'PROCESSING', ]; +const SNAPSHOT_BUILDING_STATUSES: Array = ['INITIALIZING', 'CREATING']; + +const SNAPSHOT_UPLOADING_STATUSES: Array = ['UPLOADING', 'PENDING_PROCESSING', 'PROCESSING']; + const STATUS_POLL_INTERVAL = 5 * 1000; function useGetLatestSnapshot(sessionUid?: string) { @@ -91,23 +99,27 @@ export const Page = () => { const snapshot = useGetLatestSnapshot(session.data?.uid); const [performCreateSnapshot, createSnapshotResult] = useCreateSnapshotMutation(); const [performUploadSnapshot, uploadSnapshotResult] = useUploadSnapshotMutation(); + const [performCancelSnapshot, cancelSnapshotResult] = useCancelSnapshotMutation(); const [performDisconnect, disconnectResult] = useDeleteSessionMutation(); const sessionUid = session.data?.uid; const snapshotUid = snapshot.data?.uid; - const migrationMeta = session.data; const isInitialLoading = session.isLoading; + const status = snapshot.data?.status; // isBusy is not a loading state, but indicates that the system is doing *something* // and all buttons should be disabled const isBusy = createSnapshotResult.isLoading || uploadSnapshotResult.isLoading || + cancelSnapshotResult.isLoading || session.isLoading || snapshot.isLoading || disconnectResult.isLoading; - const resources = snapshot.data?.results; + const showBuildSnapshot = !snapshot.isLoading && !snapshot.data; + const showBuildingSnapshot = SNAPSHOT_BUILDING_STATUSES.includes(status); + const showUploadSnapshot = status === 'PENDING_UPLOAD' || SNAPSHOT_UPLOADING_STATUSES.includes(status); const handleDisconnect = useCallback(async () => { if (sessionUid) { @@ -127,16 +139,23 @@ export const Page = () => { } }, [performUploadSnapshot, sessionUid, snapshotUid]); + const handleCancelSnapshot = useCallback(() => { + if (sessionUid && snapshotUid) { + performCancelSnapshot({ uid: sessionUid, snapshotUid: snapshotUid }); + } + }, [performCancelSnapshot, sessionUid, snapshotUid]); + if (isInitialLoading) { // TODO: better loading state return
Loading...
; - } else if (!migrationMeta) { + } else if (!session.data) { return ; } return ( <> + {/* TODO: show errors from all mutation's in a... modal? */} {createSnapshotResult.isError && ( { )} - {migrationMeta.slug && ( - - - {migrationMeta.slug}{' '} - - - } - /> + {session.data && ( + + )} - + {(showBuildSnapshot || showBuildingSnapshot) && ( + + {showBuildSnapshot && ( + + )} - - - + {showBuildingSnapshot && ( + + )} )} - {resources && } + {snapshot.data?.results && snapshot.data.results.length > 0 && ( + + )} void; +} + +export function BuildSnapshotCTA(props: SnapshotCTAProps) { + const { disabled, isLoading, onClick } = props; + + return ( + } + > + + + This tool can migrate some resources from this installation to your cloud stack. To get started, you'll + need to create a snapshot of this installation. Creating a snapshot typically takes less than two minutes. The + snapshot is stored alongside this Grafana installation. + + + + + + Once the snapshot is complete, you will be able to upload it to your cloud stack. + + + + + + ); +} + +export function CreatingSnapshotCTA(props: SnapshotCTAProps) { + const { disabled, isLoading, onClick } = props; + + return ( + } + > + + + We're creating a point-in-time snapshot of the current state of this installation. Once the snapshot is + complete. you'll be able to upload it to Grafana Cloud. + + + + + + Creating a snapshot typically takes less than two minutes. + + + + + + ); +} diff --git a/public/app/features/preferences/api/index.ts b/public/app/features/preferences/api/index.ts new file mode 100644 index 00000000000..1da53081140 --- /dev/null +++ b/public/app/features/preferences/api/index.ts @@ -0,0 +1,19 @@ +import { generatedAPI } from './user/endpoints.gen'; + +export const { useGetUserPreferencesQuery, usePatchUserPreferencesMutation, useUpdateUserPreferencesMutation } = + generatedAPI; + +export const userPreferencesAPI = generatedAPI.enhanceEndpoints({ + addTagTypes: ['UserPreferences'], + endpoints: { + getUserPreferences: { + providesTags: ['UserPreferences'], + }, + updateUserPreferences: { + invalidatesTags: ['UserPreferences'], + }, + patchUserPreferences: { + invalidatesTags: ['UserPreferences'], + }, + }, +}); diff --git a/public/app/features/preferences/api/user/baseAPI.ts b/public/app/features/preferences/api/user/baseAPI.ts new file mode 100644 index 00000000000..b1b75e7b036 --- /dev/null +++ b/public/app/features/preferences/api/user/baseAPI.ts @@ -0,0 +1,36 @@ +import { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react'; +import { lastValueFrom } from 'rxjs'; + +import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime'; + +interface RequestOptions extends BackendSrvRequest { + manageError?: (err: unknown) => { error: unknown }; + showErrorAlert?: boolean; + body?: BackendSrvRequest['data']; +} + +function createBackendSrvBaseQuery({ baseURL }: { baseURL: string }): BaseQueryFn { + async function backendSrvBaseQuery(requestOptions: RequestOptions) { + try { + const { data: responseData, ...meta } = await lastValueFrom( + getBackendSrv().fetch({ + ...requestOptions, + url: baseURL + requestOptions.url, + showErrorAlert: requestOptions.showErrorAlert, + data: requestOptions.body, + }) + ); + return { data: responseData, meta }; + } catch (error) { + return requestOptions.manageError ? requestOptions.manageError(error) : { error }; + } + } + + return backendSrvBaseQuery; +} + +export const baseAPI = createApi({ + reducerPath: 'userPreferencesAPI', + baseQuery: createBackendSrvBaseQuery({ baseURL: '/api' }), + endpoints: () => ({}), +}); diff --git a/public/app/features/preferences/api/user/endpoints.gen.ts b/public/app/features/preferences/api/user/endpoints.gen.ts new file mode 100644 index 00000000000..e5aff73929c --- /dev/null +++ b/public/app/features/preferences/api/user/endpoints.gen.ts @@ -0,0 +1,102 @@ +import { baseAPI as api } from './baseAPI'; +const injectedRtkApi = api.injectEndpoints({ + endpoints: (build) => ({ + getUserPreferences: build.query({ + query: () => ({ url: `/user/preferences` }), + }), + patchUserPreferences: build.mutation({ + query: (queryArg) => ({ url: `/user/preferences`, method: 'PATCH', body: queryArg.patchPrefsCmd }), + }), + updateUserPreferences: build.mutation({ + query: (queryArg) => ({ url: `/user/preferences`, method: 'PUT', body: queryArg.updatePrefsCmd }), + }), + }), + overrideExisting: false, +}); +export { injectedRtkApi as generatedAPI }; +export type GetUserPreferencesApiResponse = /** status 200 (empty) */ Preferences; +export type GetUserPreferencesApiArg = void; +export type PatchUserPreferencesApiResponse = + /** status 200 An OKResponse is returned if the request was successful. */ SuccessResponseBody; +export type PatchUserPreferencesApiArg = { + patchPrefsCmd: PatchPrefsCmd; +}; +export type UpdateUserPreferencesApiResponse = + /** status 200 An OKResponse is returned if the request was successful. */ SuccessResponseBody; +export type UpdateUserPreferencesApiArg = { + updatePrefsCmd: UpdatePrefsCmd; +}; +export type CookiePreferencesDefinesModelForCookiePreferences = { + analytics?: { + [key: string]: any; + }; + functional?: { + [key: string]: any; + }; + performance?: { + [key: string]: any; + }; +}; +export type NavbarPreferenceDefinesModelForNavbarPreference = { + savedItemIds?: string[]; +}; +export type QueryHistoryPreferenceDefinesModelForQueryHistoryPreference = { + /** HomeTab one of: '' | 'query' | 'starred'; */ + homeTab?: string; +}; +export type Preferences = { + cookiePreferences?: CookiePreferencesDefinesModelForCookiePreferences; + /** UID for the home dashboard */ + homeDashboardUID?: string; + /** Selected language (beta) */ + language?: string; + navbar?: NavbarPreferenceDefinesModelForNavbarPreference; + queryHistory?: QueryHistoryPreferenceDefinesModelForQueryHistoryPreference; + /** Theme light, dark, empty is default */ + theme?: string; + /** The timezone selection + TODO: this should use the timezone defined in common */ + timezone?: string; + /** WeekStart day of the week (sunday, monday, etc) */ + weekStart?: string; +}; +export type ErrorResponseBody = { + /** Error An optional detailed description of the actual error. Only included if running in developer mode. */ + error?: string; + /** a human readable version of the error */ + message: string; + /** Status An optional status to denote the cause of the error. + + For example, a 412 Precondition Failed error may include additional information of why that error happened. */ + status?: string; +}; +export type SuccessResponseBody = { + message?: string; +}; +export type CookieType = string; +export type PatchPrefsCmd = { + cookies?: CookieType[]; + /** The numerical :id of a favorited dashboard */ + homeDashboardId?: number; + homeDashboardUID?: string; + language?: string; + navbar?: NavbarPreferenceDefinesModelForNavbarPreference; + queryHistory?: QueryHistoryPreferenceDefinesModelForQueryHistoryPreference; + theme?: 'light' | 'dark'; + timezone?: 'utc' | 'browser'; + weekStart?: string; +}; +export type UpdatePrefsCmd = { + cookies?: CookieType[]; + /** The numerical :id of a favorited dashboard */ + homeDashboardId?: number; + homeDashboardUID?: string; + language?: string; + navbar?: NavbarPreferenceDefinesModelForNavbarPreference; + queryHistory?: QueryHistoryPreferenceDefinesModelForQueryHistoryPreference; + theme?: 'light' | 'dark' | 'system'; + timezone?: 'utc' | 'browser'; + weekStart?: string; +}; +export const { useGetUserPreferencesQuery, usePatchUserPreferencesMutation, useUpdateUserPreferencesMutation } = + injectedRtkApi; diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index d58d80f4dcd..a68d235fe1f 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -20,6 +20,7 @@ import { PanelEvents, QueryResultMetaNotice, TimeRange, + getDataSourceRef, toLegacyResponseData, } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; @@ -364,7 +365,7 @@ export class QueryEditorRow extends PureComponent { if (query.datasource === undefined) { - query.datasource = { type: this.props.dataSource.type, uid: this.props.dataSource.uid }; + query.datasource = getDataSourceRef(this.props.dataSource); } this.props.onChange({ diff --git a/public/app/features/query/components/QueryEditorRows.tsx b/public/app/features/query/components/QueryEditorRows.tsx index 024c1da6d80..c3854935f4b 100644 --- a/public/app/features/query/components/QueryEditorRows.tsx +++ b/public/app/features/query/components/QueryEditorRows.tsx @@ -5,10 +5,10 @@ import { CoreApp, DataQuery, DataSourceInstanceSettings, - DataSourceRef, EventBusExtended, HistoryItem, PanelData, + getDataSourceRef, } from '@grafana/data'; import { getDataSourceSrv, reportInteraction } from '@grafana/runtime'; @@ -65,10 +65,7 @@ export class QueryEditorRows extends PureComponent { return item; } - const dataSourceRef: DataSourceRef = { - type: dataSource.type, - uid: dataSource.uid, - }; + const dataSourceRef = getDataSourceRef(dataSource); if (item.datasource) { const previous = getDataSourceSrv().getInstanceSettings(item.datasource); diff --git a/public/app/features/query/components/QueryGroup.tsx b/public/app/features/query/components/QueryGroup.tsx index 79361af6ad3..ffa97a3fce2 100644 --- a/public/app/features/query/components/QueryGroup.tsx +++ b/public/app/features/query/components/QueryGroup.tsx @@ -7,6 +7,7 @@ import { CoreApp, DataSourceApi, DataSourceInstanceSettings, + getDataSourceRef, getDefaultTimeRange, LoadingState, PanelData, @@ -149,8 +150,7 @@ export class QueryGroup extends PureComponent { dataSource: { name: newSettings.name, uid: newSettings.uid, - type: newSettings.meta.id, - default: newSettings.isDefault, + ...getDataSourceRef(newSettings), }, }); @@ -174,11 +174,16 @@ export class QueryGroup extends PureComponent { newQuery(): Partial { const { dsSettings, defaultDataSource } = this.state; - const ds = !dsSettings?.meta.mixed ? dsSettings : defaultDataSource; + const ds = + dsSettings && !dsSettings.meta.mixed + ? getDataSourceRef(dsSettings) + : defaultDataSource + ? defaultDataSource.getRef() + : { type: undefined, uid: undefined }; return { ...this.state.dataSource?.getDefaultQuery?.(CoreApp.PanelEditor), - datasource: { uid: ds?.uid, type: ds?.type }, + datasource: ds, }; } @@ -241,7 +246,9 @@ export class QueryGroup extends PureComponent { onAddQuery = (query: Partial) => { const { dsSettings, queries } = this.state; - this.onQueriesChange(addQuery(queries, query, { type: dsSettings?.type, uid: dsSettings?.uid })); + this.onQueriesChange( + addQuery(queries, query, dsSettings ? getDataSourceRef(dsSettings) : { type: undefined, uid: undefined }) + ); this.onScrollBottom(); }; diff --git a/public/app/features/query/state/updateQueries.test.ts b/public/app/features/query/state/updateQueries.test.ts index 155b70545af..f213f75a420 100644 --- a/public/app/features/query/state/updateQueries.test.ts +++ b/public/app/features/query/state/updateQueries.test.ts @@ -12,6 +12,7 @@ const oldUidDS = { meta: { id: 'old-type', }, + getRef: () => ({ uid: 'old-uid', type: 'old-type' }), } as DataSourceApi; const mixedDS = { @@ -20,6 +21,7 @@ const mixedDS = { id: 'mixed', mixed: true, }, + getRef: () => ({ uid: 'mixed' }), } as DataSourceApi; const newUidDS = { @@ -28,6 +30,7 @@ const newUidDS = { meta: { id: 'new-type', }, + getRef: () => ({ uid: 'new-uid', type: 'new-type' }), } as DataSourceApi; const newUidSameTypeDS = { @@ -36,6 +39,7 @@ const newUidSameTypeDS = { meta: { id: 'old-type', }, + getRef: () => ({ uid: 'new-uid-same-type', type: 'old-type' }), } as DataSourceApi; const templateSrv = new TemplateSrv(); @@ -376,6 +380,7 @@ describe('updateQueries with import', () => { const importedQueries = queries.map((q) => ({ ...q, imported: true })); return Promise.resolve(importedQueries); }, + getRef: () => ({ uid: 'new-uid', type: 'new-type' }), } as DataSourceWithQueryImportSupport; const oldUidDSWithAbstract = { @@ -389,6 +394,7 @@ describe('updateQueries with import', () => { const exportedQueries = queries.map((q) => ({ ...q, exported: true })); return Promise.resolve(exportedQueries); }, + getRef: () => ({ uid: 'old-uid', type: 'old-type' }), } as DataSourceWithQueryExportSupport; const queries = [ @@ -452,6 +458,7 @@ describe('updateQueries with import', () => { importFromAbstractQueries: () => { return Promise.resolve([]); }, + getRef: () => ({ uid: 'new-uid', type: 'new-type' }), } as DataSourceWithQueryImportSupport; const oldUidDSWithAbstract = { @@ -464,6 +471,7 @@ describe('updateQueries with import', () => { const exportedQueries = queries.map((q) => ({ ...q, exported: true })); return Promise.resolve(exportedQueries); }, + getRef: () => ({ uid: 'old-uid', type: 'old-type' }), } as DataSourceWithQueryExportSupport; const queries = [ @@ -510,6 +518,7 @@ describe('updateQueries with import', () => { const importedQueries = queries.map((q) => ({ ...q, imported: true })); return Promise.resolve(importedQueries); }, + getRef: () => ({ uid: 'new-uid', type: 'new-type' }), } as DataSourceApi; const oldUidDS = { @@ -573,6 +582,7 @@ describe('updateQueries with import', () => { importQueries: (queries, origin) => { return Promise.resolve([] as DataQuery[]); }, + getRef: () => ({ uid: 'new-uid', type: 'new-type' }), } as DataSourceApi; const oldUidDS = { diff --git a/public/app/features/query/state/updateQueries.ts b/public/app/features/query/state/updateQueries.ts index e8464ac40f5..f19030e6fd4 100644 --- a/public/app/features/query/state/updateQueries.ts +++ b/public/app/features/query/state/updateQueries.ts @@ -10,7 +10,7 @@ export async function updateQueries( currentDS?: DataSourceApi ): Promise { let nextQueries = queries; - const datasource = { type: nextDS.type, uid: nextDSUidOrVariableExpression }; + const datasource = { ...nextDS.getRef(), uid: nextDSUidOrVariableExpression }; const DEFAULT_QUERY = { ...nextDS?.getDefaultQuery?.(CoreApp.PanelEditor), datasource, refId: 'A' }; // we are changing data source type diff --git a/public/app/features/trails/ActionTabs/LayoutSwitcher.tsx b/public/app/features/trails/ActionTabs/LayoutSwitcher.tsx index e9382e9fa1b..b1f621b03c7 100644 --- a/public/app/features/trails/ActionTabs/LayoutSwitcher.tsx +++ b/public/app/features/trails/ActionTabs/LayoutSwitcher.tsx @@ -4,6 +4,7 @@ import { Field, RadioButtonGroup } from '@grafana/ui'; import { MetricScene } from '../MetricScene'; import { reportExploreMetrics } from '../interactions'; +import { TRAIL_BREAKDOWN_VIEW_KEY } from '../shared'; import { LayoutType } from './types'; @@ -38,6 +39,7 @@ export class LayoutSwitcher extends SceneObjectBase { public onLayoutChange = (layout: LayoutType) => { reportExploreMetrics('breakdown_layout_changed', { layout }); + localStorage.setItem(TRAIL_BREAKDOWN_VIEW_KEY, layout); this.getMetricScene().setState({ layout }); }; diff --git a/public/app/features/trails/ActionTabs/types.ts b/public/app/features/trails/ActionTabs/types.ts index 37a6333e91a..d65482c4a29 100644 --- a/public/app/features/trails/ActionTabs/types.ts +++ b/public/app/features/trails/ActionTabs/types.ts @@ -1 +1,7 @@ -export type LayoutType = 'single' | 'grid' | 'rows'; +const LAYOUT_TYPES = ['single', 'grid', 'rows'] as const; + +export type LayoutType = (typeof LAYOUT_TYPES)[number]; + +export function isLayoutType(layoutType: string | null | undefined): layoutType is LayoutType { + return !!layoutType && layoutType in LAYOUT_TYPES; +} diff --git a/public/app/features/trails/DataTrail.tsx b/public/app/features/trails/DataTrail.tsx index d13e890a318..d8b243a109b 100644 --- a/public/app/features/trails/DataTrail.tsx +++ b/public/app/features/trails/DataTrail.tsx @@ -272,7 +272,6 @@ function getStyles(theme: GrafanaTheme2) { flexGrow: 1, display: 'flex', gap: theme.spacing(1), - minHeight: '100%', flexDirection: 'column', background: theme.isLight ? theme.colors.background.primary : theme.colors.background.canvas, padding: theme.spacing(2, 3, 2, 3), diff --git a/public/app/features/trails/MetricScene.tsx b/public/app/features/trails/MetricScene.tsx index 4cb134f0e52..65c43546b20 100644 --- a/public/app/features/trails/MetricScene.tsx +++ b/public/app/features/trails/MetricScene.tsx @@ -19,7 +19,7 @@ import { getExploreUrl } from '../../core/utils/explore'; import { buildBreakdownActionScene } from './ActionTabs/BreakdownScene'; import { buildMetricOverviewScene } from './ActionTabs/MetricOverviewScene'; import { buildRelatedMetricsScene } from './ActionTabs/RelatedMetricsScene'; -import { LayoutType } from './ActionTabs/types'; +import { isLayoutType, LayoutType } from './ActionTabs/types'; import { getAutoQueriesForMetric } from './AutomaticMetricQueries/AutoQueryEngine'; import { AutoQueryDef, AutoQueryInfo } from './AutomaticMetricQueries/types'; import { MAIN_PANEL_MAX_HEIGHT, MAIN_PANEL_MIN_HEIGHT, MetricGraphScene } from './MetricGraphScene'; @@ -32,6 +32,7 @@ import { getVariablesWithMetricConstant, MakeOptional, MetricSelectedEvent, + TRAIL_BREAKDOWN_VIEW_KEY, trailDS, VAR_GROUP_BY, VAR_METRIC_EXPR, @@ -53,12 +54,13 @@ export class MetricScene extends SceneObjectBase { public constructor(state: MakeOptional) { const autoQuery = state.autoQuery ?? getAutoQueriesForMetric(state.metric); + const layout = localStorage.getItem(TRAIL_BREAKDOWN_VIEW_KEY); super({ $variables: state.$variables ?? getVariableSet(state.metric), body: state.body ?? new MetricGraphScene({}), autoQuery, queryDef: state.queryDef ?? autoQuery.main, - layout: state.layout ?? 'grid', + layout: isLayoutType(layout) ? layout : 'grid', ...state, }); diff --git a/public/app/features/trails/shared.ts b/public/app/features/trails/shared.ts index 4b12725cb9a..8da3b78f63c 100644 --- a/public/app/features/trails/shared.ts +++ b/public/app/features/trails/shared.ts @@ -34,6 +34,8 @@ export const RECENT_TRAILS_KEY = 'grafana.trails.recent'; export const TRAIL_BOOKMARKS_KEY = 'grafana.trails.bookmarks'; +export const TRAIL_BREAKDOWN_VIEW_KEY = 'grafana.trails.breakdown.view'; + export type MakeOptional = Pick, K> & Omit; export function getVariablesWithMetricConstant(metric: string) { diff --git a/public/app/features/users/UsersListPage.tsx b/public/app/features/users/UsersListPage.tsx index 357326d28bc..452901a89bb 100644 --- a/public/app/features/users/UsersListPage.tsx +++ b/public/app/features/users/UsersListPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { renderMarkdown } from '@grafana/data'; +import { Alert } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { contextSrv } from 'app/core/core'; import { OrgUser, OrgRole, StoreState } from 'app/types'; @@ -104,7 +105,9 @@ export const UsersListPageUnconnected = ({ {externalUserMngInfoHtml && ( -
+ +
+ )} {isLoading && renderTable()} diff --git a/public/app/features/variables/pickers/shared/VariableLink.tsx b/public/app/features/variables/pickers/shared/VariableLink.tsx index 25e6c0694e6..f481fa68454 100644 --- a/public/app/features/variables/pickers/shared/VariableLink.tsx +++ b/public/app/features/variables/pickers/shared/VariableLink.tsx @@ -7,6 +7,7 @@ import { Icon, useStyles2 } from '@grafana/ui'; import { LoadingIndicator } from '@grafana/ui/src/components/PanelChrome/LoadingIndicator'; import { t } from 'app/core/internationalization'; +import { getStyles as getTagBadgeStyles } from '../../../../core/components/TagFilter/TagBadge'; import { ALL_VARIABLE_TEXT } from '../../constants'; interface Props { @@ -76,34 +77,38 @@ const VariableLinkText = ({ text }: VariableLinkTextProps) => { ); }; -const getStyles = (theme: GrafanaTheme2) => ({ - container: css` - max-width: 500px; - padding-right: 10px; - padding: 0 ${theme.spacing(1)}; - background-color: ${theme.components.input.background}; - border: 1px solid ${theme.components.input.borderColor}; - border-radius: ${theme.shape.radius.default}; - display: flex; - align-items: center; - color: ${theme.colors.text}; - height: ${theme.spacing(theme.components.height.md)}; +const getStyles = (theme: GrafanaTheme2) => { + const tagBadgeStyles = getTagBadgeStyles(theme); - .label-tag { - margin: 0 5px; - } + return { + container: css({ + maxWidth: '500px', + paddingRight: '10px', + padding: theme.spacing(0, 1), + backgroundColor: theme.components.input.background, + border: `1px solid ${theme.components.input.borderColor}`, + borderRadius: theme.shape.radius.default, + display: 'flex', + alignItems: 'center', + color: theme.colors.text.primary, + height: theme.spacing(theme.components.height.md), - &:disabled { - background-color: ${theme.colors.action.disabledBackground}; - color: ${theme.colors.action.disabledText}; - border: 1px solid ${theme.colors.action.disabledBackground}; - } - `, - textAndTags: css` - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - margin-right: ${theme.spacing(0.25)}; - user-select: none; - `, -}); + [`.${tagBadgeStyles.badge}`]: { + margin: '0 5px', + }, + + '&:disabled': { + backgroundColor: theme.colors.action.disabledBackground, + color: theme.colors.action.disabledText, + border: `1px solid ${theme.colors.action.disabledBackground}`, + }, + }), + textAndTags: css({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + marginRight: theme.spacing(0.25), + userSelect: 'none', + }), + }; +}; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/AnnotationsHelp.tsx b/public/app/plugins/datasource/cloud-monitoring/components/AnnotationsHelp.tsx index 91a81625017..807850f41c0 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/AnnotationsHelp.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/AnnotationsHelp.tsx @@ -1,8 +1,9 @@ +import { Alert } from '@grafana/ui'; + export const AnnotationsHelp = () => { return ( -
+
-
Annotation Query Format

An annotation is an event that is overlaid on top of graphs. Annotation rendering is expensive so it is important to limit the number of rows returned.{' '} @@ -37,6 +38,6 @@ export const AnnotationsHelp = () => { {`${'{{resource.label.label_name}}'}`} = Resource label metadata e.g. resource.label.zone

-
+ ); }; diff --git a/public/app/plugins/datasource/cloud-monitoring/dashboards/gke-prometheus-pod-node-monitoring.json b/public/app/plugins/datasource/cloud-monitoring/dashboards/gke-prometheus-pod-node-monitoring.json index 627f821feec..ada7a9849f8 100644 --- a/public/app/plugins/datasource/cloud-monitoring/dashboards/gke-prometheus-pod-node-monitoring.json +++ b/public/app/plugins/datasource/cloud-monitoring/dashboards/gke-prometheus-pod-node-monitoring.json @@ -9,7 +9,10 @@ "list": [ { "builtIn": 1, - "datasource": "-- Grafana --", + "datasource": { + "type": "datasource", + "uid": "grafana" + }, "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", @@ -19,466 +22,683 @@ ] }, "editable": true, - "gnetId": null, + "fiscalYearStartMonth": 0, "graphTooltip": 0, "id": null, "links": [], "panels": [ { - "datasource": null, - "description": "", - "fieldConfig": { "defaults": { "custom": {} }, "overrides": [] }, - "gridPos": { "h": 3, "w": 24, "x": 0, "y": 0 }, - "id": 0, - "options": { - "mode": "markdown", - "content": "This dashboard has example charts for metrics exported by Prometheus, for example metrics from [Kubernetes pod metrics](https://github.com/kubernetes/kube-state-metrics/blob/master/docs/pod-metrics.md)" + "datasource": { + "type": "datasource", + "uid": "grafana" }, - "pluginVersion": "7.4.0-pre", - "timeFrom": null, - "timeShift": null, - "title": "GKE Prometheus Pod/Node Monitoring", + "description": "", + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 7, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "This dashboard has example charts for metrics exported by Prometheus, for example metrics from [Kubernetes Pods](https://github.com/kubernetes/kube-state-metrics/blob/master/docs/pod-metrics.md) and [Kubernetes Nodes](https://github.com/kubernetes/kube-state-metrics/blob/main/docs/metrics/cluster/node-metrics.md).\n\nNote that if you are using the deprecated [Stackdriver Prometheus sidecar](https://cloud.google.com/monitoring/api/metrics_other#prometheus) then this dashboard will not function as expected. This dashboard requires metrics to be collected by Google Managed Prometheus as they will then be exposed under the `prometheus.googleapis.com` metrics descriptor.\n\nIf you are using a GKE cluster that has been configured to automatically scrape Kube state metrics then some of the panels on this dashboard will not show data as the default configuration only sends a [subset](https://cloud.google.com/kubernetes-engine/docs/how-to/kube-state-metrics) of the available Kube state metrics.\n\nTo retrieve all available Kube state metrics [this](https://cloud.google.com/stackdriver/docs/managed-prometheus/exporters/kube_state_metrics#install-exporter) documentation can be followed.", + "mode": "markdown" + }, + "pluginVersion": "11.2.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "refId": "A" + } + ], + "title": "GKE Prometheus Pod/Node Monitoring Test", "type": "text" }, { - "aliasColors": {}, - "bars": true, - "dashLength": 10, - "dashes": false, - "datasource": "$datasource", - "decimals": 2, + "datasource": { + "type": "stackdriver", + "uid": "$datasource" + }, "description": "", - "fieldConfig": { "defaults": { "custom": {} }, "overrides": [] }, - "fill": 1, - "fillGradient": 0, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, - "hiddenSeries": false, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 3 + }, "id": 1, - "legend": { - "alignAsTable": false, - "avg": false, - "current": false, - "max": false, - "min": false, - "rightSide": false, - "show": true, - "total": false, - "values": false, - "sideWidth": 220 + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "width": 220 + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } }, - "lines": false, - "linewidth": 1, - "nullPointMode": "connected", - "options": { "alertThreshold": false }, - "percentage": true, "pluginVersion": "7.4.0-pre", - "pointradius": 2, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": true, - "steppedLine": false, "targets": [ { - "queryType": "metrics", + "aliasBy": "{{metric.label.phase}}", + "datasource": { + "uid": "$datasource" + }, + "queryType": "timeSeriesList", "refId": "A", - "metricQuery": { - "aliasBy": "", + "timeSeriesList": { "alignmentPeriod": "$alignmentPeriod", "crossSeriesReducer": "REDUCE_SUM", - "perSeriesAligner": "ALIGN_MEAN", - "filters": ["resource.type", "=", "k8s_container"], + "filters": ["metric.type", "=", "prometheus.googleapis.com/kube_pod_status_phase/gauge"], "groupBys": ["metric.label.phase"], - "metricKind": "", - "metricType": "external.googleapis.com/prometheus/kube_pod_status_phase", - "projectName": "$project", - "unit": "", - "valueType": "" + "perSeriesAligner": "ALIGN_MEAN", + "projectName": "$project" } } ], - "thresholds": [], - "timeFrom": null, - "timeRegions": [], - "timeShift": null, "title": "kube_pod_status_phase [SUM]", - "tooltip": { "shared": true, "sort": 0, "value_type": "individual" }, - "type": "graph", - "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, - "yaxes": [ - { "format": "percent", "label": null, "logBase": 1, "max": null, "min": null, "show": true }, - { "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": true } - ], - "yaxis": { "align": false, "alignLevel": null } + "type": "timeseries" }, { - "aliasColors": {}, - "bars": true, - "dashLength": 10, - "dashes": false, - "datasource": "$datasource", - "decimals": 2, + "datasource": { + "type": "stackdriver", + "uid": "$datasource" + }, "description": "", - "fieldConfig": { "defaults": { "custom": {} }, "overrides": [] }, - "fill": 1, - "fillGradient": 0, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, - "hiddenSeries": false, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 3 + }, "id": 2, - "legend": { - "alignAsTable": false, - "avg": false, - "current": false, - "max": false, - "min": false, - "rightSide": false, - "show": true, - "total": false, - "values": false, - "sideWidth": 220 + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "width": 220 + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } }, - "lines": false, - "linewidth": 1, - "nullPointMode": "connected", - "options": { "alertThreshold": false }, - "percentage": true, "pluginVersion": "7.4.0-pre", - "pointradius": 2, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": true, - "steppedLine": false, "targets": [ { - "queryType": "metrics", + "aliasBy": "{{resource.label.namespace}}", + "datasource": { + "uid": "$datasource" + }, + "queryType": "timeSeriesList", "refId": "A", - "metricQuery": { - "aliasBy": "", + "timeSeriesList": { "alignmentPeriod": "$alignmentPeriod", "crossSeriesReducer": "REDUCE_SUM", + "filters": ["metric.type", "=", "prometheus.googleapis.com/kube_pod_container_status_ready/gauge"], + "groupBys": ["resource.label.namespace"], "perSeriesAligner": "ALIGN_MEAN", - "filters": ["resource.type", "=", "k8s_container"], - "groupBys": ["metric.label.namespace"], - "metricKind": "", - "metricType": "external.googleapis.com/prometheus/kube_pod_status_ready", - "projectName": "$project", - "unit": "", - "valueType": "" + "projectName": "$project" } } ], - "thresholds": [], - "timeFrom": null, - "timeRegions": [], - "timeShift": null, "title": "kube_pod_status_ready [SUM]", - "tooltip": { "shared": true, "sort": 0, "value_type": "individual" }, - "type": "graph", - "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, - "yaxes": [ - { "format": "percent", "label": null, "logBase": 1, "max": null, "min": null, "show": true }, - { "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": true } - ], - "yaxis": { "align": false, "alignLevel": null } + "type": "timeseries" }, { - "aliasColors": {}, - "bars": true, - "dashLength": 10, - "dashes": false, - "datasource": "$datasource", - "decimals": 2, + "datasource": { + "type": "stackdriver", + "uid": "$datasource" + }, "description": "", - "fieldConfig": { "defaults": { "custom": {} }, "overrides": [] }, - "fill": 1, - "fillGradient": 0, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, - "hiddenSeries": false, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 11 + }, "id": 3, - "legend": { - "alignAsTable": false, - "avg": false, - "current": false, - "max": false, - "min": false, - "rightSide": false, - "show": true, - "total": false, - "values": false, - "sideWidth": 220 + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "width": 220 + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } }, - "lines": false, - "linewidth": 1, - "nullPointMode": "connected", - "options": { "alertThreshold": false }, - "percentage": true, "pluginVersion": "7.4.0-pre", - "pointradius": 2, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": true, - "steppedLine": false, "targets": [ { - "queryType": "metrics", + "aliasBy": "{{resource.label.namespace}}", + "datasource": { + "uid": "$datasource" + }, + "queryType": "timeSeriesList", "refId": "A", - "metricQuery": { - "aliasBy": "", + "timeSeriesList": { "alignmentPeriod": "$alignmentPeriod", "crossSeriesReducer": "REDUCE_SUM", + "filters": ["metric.type", "=", "prometheus.googleapis.com/kube_pod_container_status_running/gauge"], + "groupBys": ["resource.label.namespace"], "perSeriesAligner": "ALIGN_MEAN", - "filters": ["resource.type", "=", "k8s_container"], - "groupBys": ["metric.label.namespace"], - "metricKind": "", - "metricType": "external.googleapis.com/prometheus/kube_pod_container_status_running", - "projectName": "$project", - "unit": "", - "valueType": "" + "projectName": "$project" } } ], - "thresholds": [], - "timeFrom": null, - "timeRegions": [], - "timeShift": null, "title": "kube_pod_container_status_running [SUM]", - "tooltip": { "shared": true, "sort": 0, "value_type": "individual" }, - "type": "graph", - "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, - "yaxes": [ - { "format": "percent", "label": null, "logBase": 1, "max": null, "min": null, "show": true }, - { "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": true } - ], - "yaxis": { "align": false, "alignLevel": null } + "type": "timeseries" }, { - "aliasColors": {}, - "bars": true, - "dashLength": 10, - "dashes": false, - "datasource": "$datasource", - "decimals": 2, + "datasource": { + "type": "stackdriver", + "uid": "$datasource" + }, "description": "", - "fieldConfig": { "defaults": { "custom": {} }, "overrides": [] }, - "fill": 1, - "fillGradient": 0, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, - "hiddenSeries": false, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 11 + }, "id": 4, - "legend": { - "alignAsTable": false, - "avg": false, - "current": false, - "max": false, - "min": false, - "rightSide": false, - "show": true, - "total": false, - "values": false, - "sideWidth": 220 + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "width": 220 + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } }, - "lines": false, - "linewidth": 1, - "nullPointMode": "connected", - "options": { "alertThreshold": false }, - "percentage": true, "pluginVersion": "7.4.0-pre", - "pointradius": 2, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": true, - "steppedLine": false, "targets": [ { - "queryType": "metrics", + "aliasBy": "{{metric.label.condition}}", + "datasource": { + "uid": "$datasource" + }, + "queryType": "timeSeriesList", "refId": "A", - "metricQuery": { - "aliasBy": "", + "timeSeriesList": { "alignmentPeriod": "$alignmentPeriod", "crossSeriesReducer": "REDUCE_SUM", - "perSeriesAligner": "ALIGN_MEAN", - "filters": ["resource.type", "=", "k8s_container"], + "filters": ["metric.type", "=", "prometheus.googleapis.com/kube_node_status_condition/gauge"], "groupBys": ["metric.label.condition"], - "metricKind": "", - "metricType": "external.googleapis.com/prometheus/kube_node_status_condition", - "projectName": "$project", - "unit": "", - "valueType": "" + "perSeriesAligner": "ALIGN_MEAN", + "projectName": "$project" } } ], - "thresholds": [], - "timeFrom": null, - "timeRegions": [], - "timeShift": null, "title": "kube_node_status_condition [SUM]", - "tooltip": { "shared": true, "sort": 0, "value_type": "individual" }, - "type": "graph", - "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, - "yaxes": [ - { "format": "percent", "label": null, "logBase": 1, "max": null, "min": null, "show": true }, - { "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": true } - ], - "yaxis": { "align": false, "alignLevel": null } + "type": "timeseries" }, { - "aliasColors": {}, - "bars": true, - "dashLength": 10, - "dashes": false, - "datasource": "$datasource", - "decimals": 2, + "datasource": { + "type": "stackdriver", + "uid": "$datasource" + }, "description": "", - "fieldConfig": { "defaults": { "custom": {} }, "overrides": [] }, - "fill": 1, - "fillGradient": 0, - "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 }, - "hiddenSeries": false, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 19 + }, "id": 5, - "legend": { - "alignAsTable": false, - "avg": false, - "current": false, - "max": false, - "min": false, - "rightSide": false, - "show": true, - "total": false, - "values": false, - "sideWidth": 220 + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "width": 220 + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } }, - "lines": false, - "linewidth": 1, - "nullPointMode": "connected", - "options": { "alertThreshold": false }, - "percentage": true, "pluginVersion": "7.4.0-pre", - "pointradius": 2, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": true, - "steppedLine": false, "targets": [ { - "queryType": "metrics", + "aliasBy": "{{metric.label.node}}", + "datasource": { + "uid": "$datasource" + }, + "queryType": "timeSeriesList", "refId": "A", - "metricQuery": { - "aliasBy": "", + "timeSeriesList": { "alignmentPeriod": "$alignmentPeriod", - "perSeriesAligner": "ALIGN_MEAN", - "filters": ["resource.type", "=", "k8s_container"], + "crossSeriesReducer": "REDUCE_NONE", + "filters": [ + "metric.label.unit", + "=", + "integer", + "AND", + "metric.type", + "=", + "prometheus.googleapis.com/kube_node_status_capacity/gauge" + ], "groupBys": [], - "metricKind": "", - "metricType": "external.googleapis.com/prometheus/kube_node_status_capacity_pods", - "projectName": "$project", - "unit": "", - "valueType": "" + "perSeriesAligner": "ALIGN_MEAN", + "projectName": "$project" } } ], - "thresholds": [], - "timeFrom": null, - "timeRegions": [], - "timeShift": null, "title": "kube_node_status_capacity_pods", - "tooltip": { "shared": true, "sort": 0, "value_type": "individual" }, - "type": "graph", - "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, - "yaxes": [ - { "format": "percent", "label": null, "logBase": 1, "max": null, "min": null, "show": true }, - { "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": true } - ], - "yaxis": { "align": false, "alignLevel": null } + "type": "timeseries" }, { - "aliasColors": {}, - "bars": true, - "dashLength": 10, - "dashes": false, - "datasource": "$datasource", - "decimals": 2, + "datasource": { + "type": "stackdriver", + "uid": "$datasource" + }, "description": "", - "fieldConfig": { "defaults": { "custom": {} }, "overrides": [] }, - "fill": 1, - "fillGradient": 0, - "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 }, - "hiddenSeries": false, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 19 + }, "id": 6, - "legend": { - "alignAsTable": false, - "avg": false, - "current": false, - "max": false, - "min": false, - "rightSide": false, - "show": true, - "total": false, - "values": false, - "sideWidth": 220 + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "width": 220 + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } }, - "lines": false, - "linewidth": 1, - "nullPointMode": "connected", - "options": { "alertThreshold": false }, - "percentage": true, "pluginVersion": "7.4.0-pre", - "pointradius": 2, - "points": false, - "renderer": "flot", - "seriesOverrides": [], - "spaceLength": 10, - "stack": true, - "steppedLine": false, "targets": [ { - "queryType": "metrics", + "aliasBy": "{{metric.label.node}}", + "datasource": { + "uid": "$datasource" + }, + "queryType": "timeSeriesList", "refId": "A", - "metricQuery": { - "aliasBy": "", + "timeSeriesList": { "alignmentPeriod": "$alignmentPeriod", - "perSeriesAligner": "ALIGN_MEAN", - "filters": ["resource.type", "=", "k8s_container"], + "filters": [ + "metric.label.unit", + "=", + "integer", + "AND", + "metric.type", + "=", + "prometheus.googleapis.com/kube_node_status_allocatable/gauge" + ], "groupBys": [], - "metricKind": "", - "metricType": "external.googleapis.com/prometheus/kube_node_status_allocatable_pods", - "projectName": "$project", - "unit": "", - "valueType": "" + "perSeriesAligner": "ALIGN_MEAN", + "projectName": "$project" } } ], - "thresholds": [], - "timeFrom": null, - "timeRegions": [], - "timeShift": null, "title": "kube_node_status_allocatable_pods", - "tooltip": { "shared": true, "sort": 0, "value_type": "individual" }, - "type": "graph", - "xaxis": { "buckets": null, "mode": "time", "name": null, "show": true, "values": [] }, - "yaxes": [ - { "format": "percent", "label": null, "logBase": 1, "max": null, "min": null, "show": true }, - { "format": "short", "label": null, "logBase": 1, "max": null, "min": null, "show": true } - ], - "yaxis": { "align": false, "alignLevel": null } + "type": "timeseries" } ], - "schemaVersion": 26, + "refresh": "", + "schemaVersion": 39, "tags": ["Compute", "Cloud Monitoring", "GCP"], "templating": { "list": [ { "current": {}, - "description": null, - "error": null, "hide": 0, "includeAll": false, "label": "Datasource", @@ -493,12 +713,11 @@ "type": "datasource" }, { - "allValue": null, "current": {}, - "datasource": "$datasource", + "datasource": { + "uid": "$datasource" + }, "definition": "Google Cloud Monitoring - Projects", - "description": null, - "error": null, "hide": 0, "includeAll": false, "label": "Project", @@ -521,46 +740,39 @@ "skipUrlSync": false, "sort": 0, "tagValuesQuery": "", - "tags": [], "tagsQuery": "", "type": "query", "useTags": false }, { - "allValue": null, - "current": { "selected": false, "text": "grafana auto", "value": "grafana-auto" }, - "datasource": "${datasource}", + "current": { + "selected": false, + "text": "grafana auto", + "value": "grafana-auto" + }, + "datasource": { + "uid": "${datasource}" + }, "definition": "", - "description": null, - "error": null, "hide": 0, "includeAll": false, "label": "Alignment Period", "multi": false, "name": "alignmentPeriod", - "options": [ - { "selected": true, "text": "grafana auto", "value": "grafana-auto" }, - { "selected": false, "text": "stackdriver auto", "value": "stackdriver-auto" }, - { "selected": false, "text": "cloud monitoring auto", "value": "cloud-monitoring-auto" }, - { "selected": false, "text": "1m", "value": "+60s" }, - { "selected": false, "text": "2m", "value": "+120s" }, - { "selected": false, "text": "5m", "value": "+300s" }, - { "selected": false, "text": "10m", "value": "+600s" }, - { "selected": false, "text": "30m", "value": "+1800s" }, - { "selected": false, "text": "1h", "value": "+3600s" }, - { "selected": false, "text": "3h", "value": "+7200s" }, - { "selected": false, "text": "6h", "value": "+21600s" }, - { "selected": false, "text": "1d", "value": "+86400s" }, - { "selected": false, "text": "3d", "value": "+259200s" }, - { "selected": false, "text": "1w", "value": "+604800s" } - ], + "options": [], "query": { "labelKey": "", "loading": false, "projectName": "$project", "projects": [ - { "name": "project-1", "value": "project-1" }, - { "name": "project-2", "value": "project-2" } + { + "name": "project-1", + "value": "project-1" + }, + { + "name": "project-2", + "value": "project-2" + } ], "refId": "CloudMonitoringVariableQueryEditor-VariableQuery", "selectedMetricType": "actions.googleapis.com/smarthome_action/num_active_users", @@ -569,12 +781,11 @@ "selectedService": "actions.googleapis.com", "sloServices": [] }, - "refresh": 0, + "refresh": 1, "regex": "", "skipUrlSync": false, "sort": 0, "tagValuesQuery": "", - "tags": [], "tagsQuery": "", "type": "query", "useTags": false @@ -586,5 +797,6 @@ "timezone": "", "title": "GKE Prometheus Pod/Node Monitoring", "uid": "", - "version": 5 + "version": 5, + "weekStart": "" } diff --git a/public/app/plugins/datasource/cloudwatch/__mocks__/queries.ts b/public/app/plugins/datasource/cloudwatch/__mocks__/queries.ts index 00c5d4c0d18..36b3a2df017 100644 --- a/public/app/plugins/datasource/cloudwatch/__mocks__/queries.ts +++ b/public/app/plugins/datasource/cloudwatch/__mocks__/queries.ts @@ -44,7 +44,7 @@ export const validMetricQueryBuilderQuery: CloudWatchMetricsQuery = { region: 'us-east-1', namespace: 'ec2', dimensions: { somekey: 'somevalue' }, - metricQueryType: MetricQueryType.Query, + metricQueryType: MetricQueryType.Insights, metricEditorMode: MetricEditorMode.Builder, sql: { from: { @@ -77,7 +77,7 @@ export const validMetricQueryCodeQuery: CloudWatchMetricsQuery = { statistic: 'Average', sqlExpression: 'SELECT * FROM "AWS/EC2" WHERE "InstanceId" = \'i-123\'', refId: 'A', - metricQueryType: MetricQueryType.Query, + metricQueryType: MetricQueryType.Insights, metricEditorMode: MetricEditorMode.Code, hide: false, }; diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MetricsQueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MetricsQueryEditor.tsx index 6798f650186..83263fac95a 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MetricsQueryEditor.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/MetricsQueryEditor.tsx @@ -31,7 +31,7 @@ export interface Props extends QueryEditorProps> = [ { label: 'Metric Search', value: MetricQueryType.Search }, - { label: 'Metric Query', value: MetricQueryType.Query }, + { label: 'Metric Insights', value: MetricQueryType.Insights }, ]; const editorModes = [ { label: 'Builder', value: MetricEditorMode.Builder }, @@ -48,7 +48,7 @@ export const MetricsQueryEditor = (props: Props) => { (newMetricEditorMode: MetricEditorMode) => { if ( codeEditorIsDirty && - query.metricQueryType === MetricQueryType.Query && + query.metricQueryType === MetricQueryType.Insights && query.metricEditorMode === MetricEditorMode.Code ) { setShowConfirm(true); @@ -90,7 +90,7 @@ export const MetricsQueryEditor = (props: Props) => { { onChange({ ...query, ...DEFAULT_METRICS_QUERY, - metricQueryType: MetricQueryType.Query, + metricQueryType: MetricQueryType.Insights, metricEditorMode: MetricEditorMode.Builder, }); }} @@ -152,7 +152,7 @@ export const MetricsQueryEditor = (props: Props) => { )} )} - {query.metricQueryType === MetricQueryType.Query && ( + {query.metricQueryType === MetricQueryType.Insights && ( <> {query.metricEditorMode === MetricEditorMode.Code && ( ({ region: 'us-east-1', namespace: 'ec2', dimensions: { somekey: 'somevalue' }, - metricQueryType: MetricQueryType.Query, + metricQueryType: MetricQueryType.Insights, metricEditorMode: MetricEditorMode.Builder, sql: sql, }); diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLBuilderSelectRow.test.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLBuilderSelectRow.test.tsx index fc8178311b0..65b709bf72c 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLBuilderSelectRow.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLBuilderSelectRow.test.tsx @@ -16,7 +16,7 @@ const makeSQLQuery = (sql?: SQLExpression): CloudWatchMetricsQuery => ({ region: 'us-east-1', namespace: 'ec2', dimensions: { somekey: 'somevalue' }, - metricQueryType: MetricQueryType.Query, + metricQueryType: MetricQueryType.Insights, metricEditorMode: MetricEditorMode.Builder, sql: sql, }); diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLGroupBy.test.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLGroupBy.test.tsx index 388fe8722c6..9f98569e3e7 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLGroupBy.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLGroupBy.test.tsx @@ -17,7 +17,7 @@ const makeSQLQuery = (sql?: SQLExpression): CloudWatchMetricsQuery => ({ region: 'us-east-1', namespace: 'ec2', dimensions: { somekey: 'somevalue' }, - metricQueryType: MetricQueryType.Query, + metricQueryType: MetricQueryType.Insights, metricEditorMode: MetricEditorMode.Builder, sql: sql, }); diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryEditor.test.tsx index 7276d65efbc..ed093bed858 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryEditor.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryEditor.test.tsx @@ -21,7 +21,7 @@ import { QueryEditor } from './QueryEditor'; const migratedFields = { statistic: 'Average', metricEditorMode: MetricEditorMode.Builder, - metricQueryType: MetricQueryType.Query, + metricQueryType: MetricQueryType.Insights, }; const props: QueryEditorProps = { @@ -201,12 +201,12 @@ describe('QueryEditor should render right editor', () => { describe('should not be displayed when a monitoring account is returned and', () => { const cases: MonitoringBadgeScenario[] = [ { - name: 'it is metric query builder query and toggle is enabled', + name: 'it is metric insights builder query and toggle is enabled', query: validMetricQueryBuilderQuery, toggle: true, }, { - name: 'it is metric query code query and toggle is not enabled', + name: 'it is metric insights code query and toggle is not enabled', query: validMetricQueryCodeQuery, toggle: false, }, @@ -232,24 +232,24 @@ describe('QueryEditor should render right editor', () => { }); describe('QueryHeader', () => { - it('should display metric actions in header when metric query is used', async () => { + it('should display metric actions in header when metric insights is used', async () => { render(); expect(await screen.findByText('CloudWatch Metrics')).toBeInTheDocument(); expect(screen.getByLabelText(/Region.*/)).toBeInTheDocument(); expect(screen.getByLabelText('Builder')).toBeInTheDocument(); expect(screen.getByLabelText('Code')).toBeInTheDocument(); - expect(screen.getByText('Metric Query')).toBeInTheDocument(); + expect(screen.getByText('Metric Insights')).toBeInTheDocument(); }); - it('should display metric actions in header when metric query is used', async () => { + it('should display metric actions in header when metric insights is used', async () => { render(); expect(await screen.findByText('CloudWatch Logs')).toBeInTheDocument(); expect(screen.getByLabelText(/Region.*/)).toBeInTheDocument(); expect(screen.queryByLabelText('Builder')).not.toBeInTheDocument(); expect(screen.queryByLabelText('Code')).not.toBeInTheDocument(); - expect(screen.queryByText('Metric Query')).not.toBeInTheDocument(); + expect(screen.queryByText('Metric Insights')).not.toBeInTheDocument(); }); }); @@ -270,18 +270,18 @@ describe('QueryEditor should render right editor', () => { expect(radio instanceof HTMLInputElement && radio.checked).toBeTruthy(); }); - it('when metric query type is metric query and editor mode is builder', async () => { + it('when metric query type is metric insights and editor mode is builder', async () => { render(); - expect(await screen.findByText('Metric Query')).toBeInTheDocument(); + expect(await screen.findByText('Metric Insights')).toBeInTheDocument(); const radio = screen.getByLabelText('Builder'); expect(radio instanceof HTMLInputElement && radio.checked).toBeTruthy(); }); - it('when metric query type is metric query and editor mode is raw', async () => { + it('when metric query type is metric Insights and editor mode is raw', async () => { render(); - expect(await screen.findByText('Metric Query')).toBeInTheDocument(); + expect(await screen.findByText('Metric Insights')).toBeInTheDocument(); const radio = screen.getByLabelText('Code'); expect(radio instanceof HTMLInputElement && radio.checked).toBeTruthy(); }); @@ -347,7 +347,7 @@ describe('QueryEditor should render right editor', () => { config.featureToggles.cloudwatchMetricInsightsCrossAccount = true; props.datasource.resources.getAccounts = jest.fn().mockResolvedValue(['account123']); render(); - await screen.findByText('Metric Query'); + await screen.findByText('Metric Insights'); expect(await screen.findByText('Account')).toBeInTheDocument(); }); }); diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryHeader.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryHeader.tsx index 7e77f93b4a5..7908c3437ac 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryHeader.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/QueryHeader.tsx @@ -58,12 +58,16 @@ const QueryHeader = ({ onChange({ ...query, region }); } }; + const metricInsightsCrossAccountEnabled = config.featureToggles.cloudwatchMetricInsightsCrossAccount; const shouldDisplayMonitoringBadge = config.featureToggles.cloudWatchCrossAccountQuerying && isMonitoringAccount && (query.queryMode === 'Logs' || - (isCloudWatchMetricsQuery(query) && query.metricQueryType === MetricQueryType.Search)); + (isCloudWatchMetricsQuery(query) && query.metricQueryType === MetricQueryType.Search) || + (metricInsightsCrossAccountEnabled && + isCloudWatchMetricsQuery(query) && + query.metricQueryType === MetricQueryType.Insights)); return ( <> diff --git a/public/app/plugins/datasource/cloudwatch/components/shared/MetricStatEditor/MetricStatEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/shared/MetricStatEditor/MetricStatEditor.tsx index 9f9ecd45fdf..63d54a33e56 100644 --- a/public/app/plugins/datasource/cloudwatch/components/shared/MetricStatEditor/MetricStatEditor.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/shared/MetricStatEditor/MetricStatEditor.tsx @@ -26,7 +26,7 @@ const percentileSyntaxRE = /^(p|tm|tc|ts|wm)\d{2}(?:\.\d{1,2})?$/; const boundariesInnerParenthesesSyntax = `\\d*(\\.\\d+)?%?:\\d*(\\.\\d+)?%?`; const boundariesSyntaxRE = new RegExp(`^(PR|TM|TC|TS|WM)\\((${boundariesInnerParenthesesSyntax})\\)$`); -// used in both Metric Query editor and in Annotations Editor +// used in both Metric query editor and in Annotations Editor export const MetricStatEditor = ({ refId, metricStat, diff --git a/public/app/plugins/datasource/cloudwatch/dataquery.cue b/public/app/plugins/datasource/cloudwatch/dataquery.cue index bc3cb0d1821..e1a5277f394 100644 --- a/public/app/plugins/datasource/cloudwatch/dataquery.cue +++ b/public/app/plugins/datasource/cloudwatch/dataquery.cue @@ -56,7 +56,7 @@ composableKinds: DataQuery: { // Whether a query is a Metrics, Logs, or Annotations query queryMode?: #CloudWatchQueryMode - // Whether to use a metric search or metric query. Metric query is referred to as "Metrics Insights" in the AWS console. + // Whether to use a metric search or metric insights query metricQueryType?: #MetricQueryType // Whether to use the query builder or code editor to create the query metricEditorMode?: #MetricEditorMode @@ -69,14 +69,14 @@ composableKinds: DataQuery: { label?: string // Math expression query expression?: string - // When the metric query type is `metricQueryType` is set to `Query`, this field is used to specify the query string. + // When the metric query type is set to `Insights`, this field is used to specify the query string. sqlExpression?: string - // When the metric query type is `metricQueryType` is set to `Query` and the `metricEditorMode` is set to `Builder`, this field is used to build up an object representation of a SQL query. + // When the metric query type is set to `Insights` and the `metricEditorMode` is set to `Builder`, this field is used to build up an object representation of a SQL query. sql?: #SQLExpression } @cuetsy(kind="interface") #CloudWatchQueryMode: "Metrics" | "Logs" | "Annotations" @cuetsy(kind="type") - #MetricQueryType: 0 | 1 @cuetsy(kind="enum", memberNames="Search|Query") + #MetricQueryType: 0 | 1 @cuetsy(kind="enum", memberNames="Search|Insights") #MetricEditorMode: 0 | 1 @cuetsy(kind="enum", memberNames="Builder|Code") #SQLExpression: { // SELECT part of the SQL expression diff --git a/public/app/plugins/datasource/cloudwatch/dataquery.gen.ts b/public/app/plugins/datasource/cloudwatch/dataquery.gen.ts index 9cf62e4b588..ff71dad3656 100644 --- a/public/app/plugins/datasource/cloudwatch/dataquery.gen.ts +++ b/public/app/plugins/datasource/cloudwatch/dataquery.gen.ts @@ -84,7 +84,7 @@ export interface CloudWatchMetricsQuery extends common.DataQuery, MetricStat { */ metricEditorMode?: MetricEditorMode; /** - * Whether to use a metric search or metric query. Metric query is referred to as "Metrics Insights" in the AWS console. + * Whether to use a metric search or metric insights query */ metricQueryType?: MetricQueryType; /** @@ -92,11 +92,11 @@ export interface CloudWatchMetricsQuery extends common.DataQuery, MetricStat { */ queryMode?: CloudWatchQueryMode; /** - * When the metric query type is `metricQueryType` is set to `Query` and the `metricEditorMode` is set to `Builder`, this field is used to build up an object representation of a SQL query. + * When the metric query type is set to `Insights` and the `metricEditorMode` is set to `Builder`, this field is used to build up an object representation of a SQL query. */ sql?: SQLExpression; /** - * When the metric query type is `metricQueryType` is set to `Query`, this field is used to specify the query string. + * When the metric query type is set to `Insights`, this field is used to specify the query string. */ sqlExpression?: string; } @@ -104,7 +104,7 @@ export interface CloudWatchMetricsQuery extends common.DataQuery, MetricStat { export type CloudWatchQueryMode = ('Metrics' | 'Logs' | 'Annotations'); export enum MetricQueryType { - Query = 1, + Insights = 1, Search = 0, } diff --git a/public/app/plugins/datasource/cloudwatch/migrations/dashboardMigrations.ts b/public/app/plugins/datasource/cloudwatch/migrations/dashboardMigrations.ts index 03da65ed526..d492a5ad3ce 100644 --- a/public/app/plugins/datasource/cloudwatch/migrations/dashboardMigrations.ts +++ b/public/app/plugins/datasource/cloudwatch/migrations/dashboardMigrations.ts @@ -58,7 +58,7 @@ export function migrateCloudWatchQuery(query: CloudWatchMetricsQuery) { } if (!query.hasOwnProperty('metricEditorMode')) { - if (query.metricQueryType === MetricQueryType.Query) { + if (query.metricQueryType === MetricQueryType.Insights) { query.metricEditorMode = MetricEditorMode.Code; } else { query.metricEditorMode = query.expression ? MetricEditorMode.Code : MetricEditorMode.Builder; diff --git a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.test.ts b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.test.ts index 0ac0bedbc3e..637328f022a 100644 --- a/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.test.ts +++ b/public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchMetricsQueryRunner.test.ts @@ -493,7 +493,7 @@ describe('CloudWatchMetricsQueryRunner', () => { matchExact: true, statistic: '', expression: '', - metricQueryType: MetricQueryType.Query, + metricQueryType: MetricQueryType.Insights, metricEditorMode: MetricEditorMode.Code, sqlExpression: 'SELECT SUM($metric) FROM "$namespace" GROUP BY InstanceId,InstanceType LIMIT $limit', }, @@ -734,7 +734,7 @@ describe('CloudWatchMetricsQueryRunner', () => { matchExact: true, statistic: '', expression: '', - metricQueryType: MetricQueryType.Query, + metricQueryType: MetricQueryType.Insights, metricEditorMode: MetricEditorMode.Code, sqlExpression: 'SELECT SUM($metric) FROM "$namespace" GROUP BY ${labels:raw} LIMIT $limit', }; @@ -968,11 +968,11 @@ describe('CloudWatchMetricsQueryRunner', () => { }); }); - describe('metric query queries', () => { + describe('metric insights queries', () => { beforeEach(() => { baseQuery = { ...baseQuery, - metricQueryType: MetricQueryType.Query, + metricQueryType: MetricQueryType.Insights, metricEditorMode: MetricEditorMode.Code, }; }); diff --git a/public/app/plugins/datasource/cloudwatch/tracking.ts b/public/app/plugins/datasource/cloudwatch/tracking.ts index a44c6d866a3..3607b3ef99a 100644 --- a/public/app/plugins/datasource/cloudwatch/tracking.ts +++ b/public/app/plugins/datasource/cloudwatch/tracking.ts @@ -106,12 +106,12 @@ export const onDashboardLoadedHandler = ({ q.metricQueryType === MetricQueryType.Search && q.metricEditorMode === MetricEditorMode.Code ); e.metrics_search_match_exact_count += +Boolean(isMetricSearchBuilder(q) && q.matchExact); - e.metrics_query_count += +Boolean(q.metricQueryType === MetricQueryType.Query); + e.metrics_query_count += +Boolean(q.metricQueryType === MetricQueryType.Insights); e.metrics_query_builder_count += +Boolean( - q.metricQueryType === MetricQueryType.Query && q.metricEditorMode === MetricEditorMode.Builder + q.metricQueryType === MetricQueryType.Insights && q.metricEditorMode === MetricEditorMode.Builder ); e.metrics_query_code_count += +Boolean( - q.metricQueryType === MetricQueryType.Query && q.metricEditorMode === MetricEditorMode.Code + q.metricQueryType === MetricQueryType.Insights && q.metricEditorMode === MetricEditorMode.Code ); e.metrics_queries_with_account_count += +Boolean( config.featureToggles.cloudWatchCrossAccountQuerying && isMetricSearchBuilder(q) && q.accountId diff --git a/public/app/plugins/datasource/cloudwatch/utils/utils.ts b/public/app/plugins/datasource/cloudwatch/utils/utils.ts index 1666c8a3865..b98751ce156 100644 --- a/public/app/plugins/datasource/cloudwatch/utils/utils.ts +++ b/public/app/plugins/datasource/cloudwatch/utils/utils.ts @@ -21,7 +21,7 @@ export const filterMetricsQuery = (query: CloudWatchMetricsQuery): boolean => { return !!namespace && !!metricName && !!statistic; } else if (metricQueryType === MetricQueryType.Search && metricEditorMode === MetricEditorMode.Code) { return !!expression; - } else if (metricQueryType === MetricQueryType.Query) { + } else if (metricQueryType === MetricQueryType.Insights) { // still TBD how to validate the visual query builder for SQL return !!sqlExpression; } diff --git a/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx index 6dcca3cea3d..35e05ae4794 100644 --- a/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx +++ b/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx @@ -23,6 +23,7 @@ import { SecureSocksProxySettings, useStyles2, Divider, Stack } from '@grafana/u import { QuerySettings } from './QuerySettings'; import { ServiceGraphSettings } from './ServiceGraphSettings'; +import { StreamingSection } from './StreamingSection'; import { TraceQLSearchSettings } from './TraceQLSearchSettings'; export type Props = DataSourcePluginOptionsEditorProps; @@ -48,8 +49,11 @@ export const ConfigEditor = ({ options, onOptionsChange }: Props) => { onChange: onOptionsChange, })} /> - + + + + diff --git a/public/app/plugins/datasource/tempo/configuration/StreamingSection.tsx b/public/app/plugins/datasource/tempo/configuration/StreamingSection.tsx new file mode 100644 index 00000000000..bba18945603 --- /dev/null +++ b/public/app/plugins/datasource/tempo/configuration/StreamingSection.tsx @@ -0,0 +1,81 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { + DataSourceJsonData, + DataSourcePluginOptionsEditorProps, + GrafanaTheme2, + updateDatasourcePluginJsonDataOption, +} from '@grafana/data'; +import { ConfigSection } from '@grafana/experimental'; +import { InlineFieldRow, InlineField, InlineSwitch, Alert, Stack, useStyles2 } from '@grafana/ui'; + +import { FeatureName, featuresToTempoVersion } from '../datasource'; + +interface StreamingOptions extends DataSourceJsonData { + streamingEnabled?: { + search?: boolean; + }; +} +interface Props extends DataSourcePluginOptionsEditorProps {} + +export const StreamingSection = ({ options, onOptionsChange }: Props) => { + const styles = useStyles2(getStyles); + return ( + +
{`Enable streaming for different Tempo features. + Currently supported only for search queries and from Tempo version ${featuresToTempoVersion[FeatureName.streaming]} onwards.`}
+ + Learn more + + + } + > + + If your Tempo instance is behind a load balancer or proxy that does not supporting gRPC or HTTP2, streaming will + probably not work and should be disabled. + + + + ) => { + updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'streamingEnabled', { + ...options.jsonData.streamingEnabled, + search: event.currentTarget.checked, + }); + }} + /> + + +
+ ); +}; +const getStyles = (theme: GrafanaTheme2) => { + return { + a: css({ + color: theme.colors.text.link, + textDecoration: 'underline', + marginLeft: '5px', + '&:hover': { + textDecoration: 'none', + }, + }), + }; +}; diff --git a/public/app/plugins/datasource/tempo/datasource.test.ts b/public/app/plugins/datasource/tempo/datasource.test.ts index 771220631ee..43dbb94b2a7 100644 --- a/public/app/plugins/datasource/tempo/datasource.test.ts +++ b/public/app/plugins/datasource/tempo/datasource.test.ts @@ -75,7 +75,7 @@ describe('Tempo data source', () => { const range = { from: dateTime(new Date(2022, 8, 13, 16, 0, 0, 0)), to: dateTime(new Date(2022, 8, 13, 16, 15, 0, 0)), - raw: { from: '15m', to: 'now' }, + raw: { from: 'now-15m', to: 'now' }, }; const traceqlQuery = { targets: [{ refId: 'refid1', queryType: 'traceql', query: '{}' }], @@ -365,9 +365,14 @@ describe('Tempo data source', () => { describe('test the testDatasource function', () => { it('should return a success msg if response.ok is true', async () => { mockObservable = () => of({ ok: true }); + const handleStreamingSearch = jest + .spyOn(TempoDatasource.prototype, 'handleStreamingSearch') + .mockImplementation(() => of({ data: [] })); + const ds = new TempoDatasource(defaultSettings); const response = await ds.testDatasource(); expect(response.status).toBe('success'); + expect(handleStreamingSearch).toHaveBeenCalled(); }); }); @@ -389,7 +394,7 @@ describe('Tempo data source', () => { const range = { from: dateTime(new Date(2022, 8, 13, 16, 0, 0, 0)), to: dateTime(new Date(2022, 8, 13, 16, 15, 0, 0)), - raw: { from: '15m', to: 'now' }, + raw: { from: 'now-15m', to: 'now' }, }; const request = ds.traceIdQueryRequest( @@ -434,7 +439,7 @@ describe('Tempo data source', () => { range: { from: dateTime(new Date(2022, 8, 13, 16, 0, 0, 0)), to: dateTime(new Date(2022, 8, 13, 16, 15, 0, 0)), - raw: { from: '15m', to: 'now' }, + raw: { from: 'now-15m', to: 'now' }, }, }, [{ refId: 'refid1', queryType: 'traceql', query: '' } as TempoQuery] @@ -1264,6 +1269,9 @@ export const defaultSettings: DataSourceInstanceSettings = { nodeGraph: { enabled: true, }, + streamingEnabled: { + search: true, + }, }, readOnly: false, }; diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index 93db409c37f..ae754a0c2c7 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -1,5 +1,5 @@ import { groupBy } from 'lodash'; -import { EMPTY, from, lastValueFrom, merge, Observable, of } from 'rxjs'; +import { EMPTY, forkJoin, from, lastValueFrom, merge, Observable, of } from 'rxjs'; import { catchError, concatMap, map, mergeMap, toArray } from 'rxjs/operators'; import semver from 'semver'; @@ -41,7 +41,7 @@ import { } from './SearchTraceQLEditor/utils'; import { TempoVariableQuery, TempoVariableQueryType } from './VariableQueryEditor'; import { PrometheusDatasource, PromQuery } from './_importedDependencies/datasources/prometheus/types'; -import { TraceqlFilter, TraceqlSearchScope } from './dataquery.gen'; +import { SearchTableType, TraceqlFilter, TraceqlSearchScope } from './dataquery.gen'; import { defaultTableFilter, durationMetric, @@ -69,7 +69,7 @@ import { TempoVariableSupport } from './variables'; export const DEFAULT_LIMIT = 20; export const DEFAULT_SPSS = 3; // spans per span set -enum FeatureName { +export enum FeatureName { streaming = 'streaming', } @@ -77,7 +77,7 @@ enum FeatureName { ** feature available. If the running Tempo instance on the user's backend is older than the ** target version, the feature is disabled in Grafana (frontend). */ -const featuresToTempoVersion = { +export const featuresToTempoVersion = { [FeatureName.streaming]: '2.2.0', }; @@ -115,6 +115,10 @@ export class TempoDatasource extends DataSourceWithBackend): Observable { const subQueries: Array> = []; const filteredTargets = options.targets.filter((target) => !target.hide); @@ -317,7 +342,7 @@ export class TempoDatasource extends DataSourceWithBackend => { - if ( - config.featureToggles.traceQLStreaming && - this.isFeatureAvailable(FeatureName.streaming) && - config.liveEnabled - ) { + if (this.isStreamingSearchEnabled()) { return this.handleStreamingSearch(options, targets.traceql, queryValue); } else { return this._request('/api/search', { @@ -717,24 +734,86 @@ export class TempoDatasource extends DataSourceWithBackend { + const observables = []; + const options: BackendSrvRequest = { headers: {}, method: 'GET', url: `${this.instanceSettings.url}/api/echo`, }; - - return await lastValueFrom( + observables.push( getBackendSrv() .fetch(options) .pipe( mergeMap(() => { - return of({ status: 'success', message: 'Data source successfully connected.' }); + return of({ status: 'success', message: 'Health check succeeded' }); }), catchError((err) => { - return of({ status: 'error', message: getErrorMessage(err.data.message, 'Unable to connect with Tempo') }); + return of({ + status: 'error', + message: getErrorMessage(err.data.message, 'Unable to connect with Tempo'), + }); }) ) ); + + if (this.streamingEnabled?.search) { + const now = new Date(); + const from = new Date(now); + from.setMinutes(from.getMinutes() - 15); + observables.push( + this.handleStreamingSearch( + { + range: { + from: dateTime(from), + to: dateTime(now), + raw: { from: 'now-15m', to: 'now' }, + }, + requestId: '', + interval: '', + intervalMs: 0, + scopedVars: {}, + targets: [], + timezone: '', + app: '', + startTime: 0, + }, + [ + { + datasource: this.instanceSettings, + limit: 1, + query: '{}', + queryType: 'traceql', + refId: 'A', + tableType: SearchTableType.Traces, + filters: [], + }, + ], + '{}' + ).pipe( + mergeMap(() => { + return of({ status: 'success', message: 'Streaming test succeeded.' }); + }), + catchError((err) => { + return of({ + status: 'error', + message: getErrorMessage(err.data.message, 'Test for streaming failed, consider disabling streaming'), + }); + }) + ) + ); + } + + return await lastValueFrom( + forkJoin(observables).pipe( + mergeMap((observableResults) => { + const erroredResult = observableResults.find((result) => result.status !== 'success'); + return erroredResult + ? of(erroredResult) + : of({ status: 'success', message: 'Successfully connected to Tempo data source.' }); + }) + ) + ); } getQueryDisplayText(query: TempoQuery) { diff --git a/public/app/plugins/datasource/tempo/streaming.ts b/public/app/plugins/datasource/tempo/streaming.ts index 23eef3182ce..71c336edf2f 100644 --- a/public/app/plugins/datasource/tempo/streaming.ts +++ b/public/app/plugins/datasource/tempo/streaming.ts @@ -20,6 +20,7 @@ import { SearchStreamingState } from './dataquery.gen'; import { DEFAULT_SPSS, TempoDatasource } from './datasource'; import { formatTraceQLResponse } from './resultTransformer'; import { SearchMetrics, TempoJsonData, TempoQuery } from './types'; + function getLiveStreamKey(): string { return uuidv4(); } diff --git a/public/app/plugins/datasource/tempo/types.ts b/public/app/plugins/datasource/tempo/types.ts index 09c31e05b93..31439867edd 100644 --- a/public/app/plugins/datasource/tempo/types.ts +++ b/public/app/plugins/datasource/tempo/types.ts @@ -21,6 +21,9 @@ export interface TempoJsonData extends DataSourceJsonData { spanStartTimeShift?: string; spanEndTimeShift?: string; }; + streamingEnabled?: { + search?: boolean; + }; } export interface TempoQuery extends TempoBase { diff --git a/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx b/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx index 0e0b3ce4fc6..2e615646324 100644 --- a/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx +++ b/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx @@ -25,9 +25,9 @@ import { fetchAllPromAndRulerRulesAction, fetchPromAndRulerRulesAction, } from 'app/features/alerting/unified/state/actions'; -import { parseMatchers } from 'app/features/alerting/unified/utils/alertmanager'; import { Annotation } from 'app/features/alerting/unified/utils/constants'; import { GRAFANA_DATASOURCE_NAME, GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; +import { parsePromQLStyleMatcherLooseSafe } from 'app/features/alerting/unified/utils/matchers'; import { isAsyncRequestMapSlicePartiallyDispatched, isAsyncRequestMapSlicePartiallyFulfilled, @@ -132,7 +132,7 @@ function UnifiedAlertList(props: PanelProps) { }; const matcherList = useMemo( - () => parseMatchers(parsedOptions.alertInstanceLabelFilter), + () => parsePromQLStyleMatcherLooseSafe(parsedOptions.alertInstanceLabelFilter), [parsedOptions.alertInstanceLabelFilter] ); diff --git a/public/app/plugins/panel/alertlist/util.ts b/public/app/plugins/panel/alertlist/util.ts index 5acf905d27c..a7984904696 100644 --- a/public/app/plugins/panel/alertlist/util.ts +++ b/public/app/plugins/panel/alertlist/util.ts @@ -1,14 +1,15 @@ import { isEmpty } from 'lodash'; import { Labels } from '@grafana/data'; -import { labelsMatchMatchers, parseMatchers } from 'app/features/alerting/unified/utils/alertmanager'; +import { labelsMatchMatchers } from 'app/features/alerting/unified/utils/alertmanager'; +import { parsePromQLStyleMatcherLooseSafe } from 'app/features/alerting/unified/utils/matchers'; import { Alert, hasAlertState } from 'app/types/unified-alerting'; import { GrafanaAlertState, PromAlertingRuleState } from 'app/types/unified-alerting-dto'; import { UnifiedAlertListOptions } from './types'; function hasLabelFilter(alertInstanceLabelFilter: string, labels: Labels) { - const matchers = parseMatchers(alertInstanceLabelFilter); + const matchers = parsePromQLStyleMatcherLooseSafe(alertInstanceLabelFilter); return labelsMatchMatchers(labels, matchers); } diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts index 6d43a8bb000..6697ec42208 100644 --- a/public/app/store/configureStore.ts +++ b/public/app/store/configureStore.ts @@ -5,6 +5,7 @@ import { Middleware } from 'redux'; import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi'; import { cloudMigrationAPI } from 'app/features/migrate-to-cloud/api'; +import { userPreferencesAPI } from 'app/features/preferences/api'; import { StoreState } from 'app/types/store'; import { buildInitialState } from '../core/reducers/navModel'; @@ -39,6 +40,7 @@ export function configureStore(initialState?: Partial) { browseDashboardsAPI.middleware, cloudMigrationAPI.middleware, queryLibraryApi.middleware, + userPreferencesAPI.middleware, ...extraMiddleware ), devTools: process.env.NODE_ENV !== 'production', diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index 47c94d70367..f1503e356b2 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -47,9 +47,15 @@ export function mapStateWithReasonToReason(state: GrafanaAlertStateWithReason): return match ? match[1] : ''; } +type StateWithReasonToBaseStateReturnType = T extends GrafanaAlertStateWithReason + ? GrafanaAlertState + : T extends PromAlertingRuleState + ? PromAlertingRuleState + : never; + export function mapStateWithReasonToBaseState( state: GrafanaAlertStateWithReason | PromAlertingRuleState -): GrafanaAlertState | PromAlertingRuleState { +): StateWithReasonToBaseStateReturnType { if (isAlertStateWithReason(state)) { const fields = state.split(' '); return fields[0] as GrafanaAlertState;