From fd6e33865133db78b195455f8bf7f3cd110f91e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 20 May 2021 14:09:19 +0200 Subject: [PATCH 01/43] Timeline: Fixes crash when there was only 1 threshold step (#34471) --- public/app/plugins/panel/state-timeline/utils.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/public/app/plugins/panel/state-timeline/utils.ts b/public/app/plugins/panel/state-timeline/utils.ts index 99edbe8aa34..9f4f0a1d44b 100644 --- a/public/app/plugins/panel/state-timeline/utils.ts +++ b/public/app/plugins/panel/state-timeline/utils.ts @@ -318,15 +318,14 @@ export function prepareTimelineLegendItems( } const items: VizLegendItem[] = []; - const first = fields[0].config; - const colorMode = first.color?.mode ?? FieldColorModeId.Fixed; + const fieldConfig = fields[0].config; + const colorMode = fieldConfig.color?.mode ?? FieldColorModeId.Fixed; + const thresholds = fieldConfig.thresholds; // If thresholds are enabled show each step in the legend - if (colorMode === FieldColorModeId.Thresholds && first.thresholds?.steps) { - const steps = first.thresholds.steps; - const disp = getValueFormat( - first.thresholds.mode === ThresholdsMode.Percentage ? 'percent' : first.unit ?? 'fixed' - ); + if (colorMode === FieldColorModeId.Thresholds && thresholds?.steps && thresholds.steps.length > 1) { + const steps = thresholds.steps; + const disp = getValueFormat(thresholds.mode === ThresholdsMode.Percentage ? 'percent' : fieldConfig.unit ?? ''); const fmt = (v: number) => formattedValueToString(disp(v)); From 23939eab107443e0dc985195309a7fc732590cce Mon Sep 17 00:00:00 2001 From: Sofia Papagiannaki Date: Thu, 20 May 2021 15:49:33 +0300 Subject: [PATCH 02/43] [Alerting]: namespace fixes (#34470) * [Alerting]: forbid viewers for updating rules if viewers can edit check for CanSave instead of CanEdit * Clear ngalert tables when deleting the folder * Apply suggestions from code review * Log failure to check save permission Co-authored-by: gotjosh --- pkg/services/ngalert/ngalert.go | 1 + .../ngalert/notifier/alertmanager_test.go | 3 + pkg/services/ngalert/store/alert_rule.go | 9 +- pkg/services/ngalert/store/database.go | 3 +- pkg/services/ngalert/tests/util.go | 6 +- pkg/services/sqlstore/dashboard.go | 13 + .../api/alerting/api_alertmanager_test.go | 273 +++++++++++++++++- .../alerting/api_notification_channel_test.go | 3 +- pkg/tests/api/alerting/api_prometheus_test.go | 3 +- pkg/tests/api/alerting/api_ruler_test.go | 14 +- pkg/tests/testinfra/testinfra.go | 8 + 11 files changed, 315 insertions(+), 21 deletions(-) diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index 96e1e2b6143..e589aa47b07 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -69,6 +69,7 @@ func (ng *AlertNG) Init() error { BaseInterval: baseInterval, DefaultIntervalSeconds: defaultIntervalSeconds, SQLStore: ng.SQLStore, + Logger: ng.Log, } var err error diff --git a/pkg/services/ngalert/notifier/alertmanager_test.go b/pkg/services/ngalert/notifier/alertmanager_test.go index c074b91f06d..2814add0263 100644 --- a/pkg/services/ngalert/notifier/alertmanager_test.go +++ b/pkg/services/ngalert/notifier/alertmanager_test.go @@ -9,6 +9,8 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/infra/log" + gokit_log "github.com/go-kit/kit/log" "github.com/go-openapi/strfmt" "github.com/prometheus/alertmanager/api/v2/models" @@ -42,6 +44,7 @@ func setupAMTest(t *testing.T) *Alertmanager { BaseInterval: 10 * time.Second, DefaultIntervalSeconds: 60, SQLStore: sqlStore, + Logger: log.New("alertmanager-test"), } am, err := New(cfg, store, m) diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 0d118902d3e..5cd139c6d95 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -368,16 +368,19 @@ func (st DBstore) GetRuleGroupAlertRules(query *ngmodels.ListRuleGroupAlertRules } // GetNamespaceByTitle is a handler for retrieving a namespace by its title. Alerting rules follow a Grafana folder-like structure which we call namespaces. -func (st DBstore) GetNamespaceByTitle(namespace string, orgID int64, user *models.SignedInUser, withEdit bool) (*models.Folder, error) { +func (st DBstore) GetNamespaceByTitle(namespace string, orgID int64, user *models.SignedInUser, withCanSave bool) (*models.Folder, error) { s := dashboards.NewFolderService(orgID, user, st.SQLStore) folder, err := s.GetFolderByTitle(namespace) if err != nil { return nil, err } - if withEdit { + if withCanSave { g := guardian.New(folder.Id, orgID, user) - if canAdmin, err := g.CanEdit(); err != nil || !canAdmin { + if canSave, err := g.CanSave(); err != nil || !canSave { + if err != nil { + st.Logger.Error("checking can save permission has failed", "userId", user.UserId, "username", user.Login, "namespace", namespace, "orgId", orgID, "error", err) + } return nil, ngmodels.ErrCannotEditNamespace } } diff --git a/pkg/services/ngalert/store/database.go b/pkg/services/ngalert/store/database.go index 933a1c9e56c..9fdad89718a 100644 --- a/pkg/services/ngalert/store/database.go +++ b/pkg/services/ngalert/store/database.go @@ -3,8 +3,8 @@ package store import ( "time" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/sqlstore" ) @@ -28,4 +28,5 @@ type DBstore struct { // default alert definiiton interval DefaultIntervalSeconds int64 SQLStore *sqlstore.SQLStore + Logger log.Logger } diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index 610d7fea330..e3044416c44 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -38,7 +38,11 @@ func SetupTestEnv(t *testing.T, baseIntervalSeconds int64) *store.DBstore { err := ng.Init() require.NoError(t, err) - return &store.DBstore{SQLStore: ng.SQLStore, BaseInterval: time.Duration(baseIntervalSeconds) * time.Second} + return &store.DBstore{ + SQLStore: ng.SQLStore, + BaseInterval: time.Duration(baseIntervalSeconds) * time.Second, + Logger: log.New("ngalert-test"), + } } func overrideAlertNGInRegistry(t *testing.T, cfg *setting.Cfg) ngalert.AlertNG { diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index d9ec5657938..2e9066cc898 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -465,6 +465,19 @@ func deleteDashboard(cmd *models.DeleteDashboardCommand, sess *DBSession) error } } } + + // clean ngalert tables + ngalertDeletes := []string{ + "DELETE FROM alert_rule WHERE namespace_uid = (SELECT uid FROM dashboard WHERE id = ?)", + "DELETE FROM alert_rule_version WHERE rule_namespace_uid = (SELECT uid FROM dashboard WHERE id = ?)", + } + + for _, sql := range ngalertDeletes { + _, err := sess.Exec(sql, dashboard.Id) + if err != nil { + return err + } + } } if err := deleteAlertDefinition(dashboard.Id, sess); err != nil { diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index cbf99ee12c9..0338ae1fa1e 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -285,7 +285,6 @@ func TestAMConfigAccess(t *testing.T) { // Fetch Request resp, err := client.Do(req) if err != nil { - fmt.Println(err) return } t.Cleanup(func() { @@ -386,7 +385,8 @@ func TestAlertAndGroupsQuery(t *testing.T) { // Now, let's test the endpoint with some alerts. { // Create the namespace we'll save our alerts to. - require.NoError(t, createFolder(t, store, 0, "default")) + _, err := createFolder(t, store, 0, "default") + require.NoError(t, err) } // Create an alert that will fire as quickly as possible @@ -464,6 +464,255 @@ func TestAlertAndGroupsQuery(t *testing.T) { } } +func TestRulerAccess(t *testing.T) { + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{"ngalert"}, + EnableQuota: true, + DisableAnonymous: true, + ViewersCanEdit: true, + }) + + store := testinfra.SetUpDatabase(t, dir) + // override bus to get the GetSignedInUserQuery handler + store.Bus = bus.GetBus() + grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) + + // Create the namespace we'll save our alerts to. + _, err := createFolder(t, store, 0, "default") + require.NoError(t, err) + + // Create a users to make authenticated requests + require.NoError(t, createUser(t, store, models.ROLE_VIEWER, "viewer", "viewer")) + require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "editor", "editor")) + require.NoError(t, createUser(t, store, models.ROLE_ADMIN, "admin", "admin")) + + // Now, let's test the access policies. + testCases := []struct { + desc string + url string + expStatus int + expectedResponse string + }{ + { + desc: "un-authenticated request should fail", + url: "http://%s/api/ruler/grafana/api/v1/rules/default", + expStatus: http.StatusUnauthorized, + expectedResponse: `{"message": "Unauthorized"}`, + }, + { + desc: "viewer request should fail", + url: "http://viewer:viewer@%s/api/ruler/grafana/api/v1/rules/default", + expStatus: http.StatusForbidden, + expectedResponse: `{"error":"user does not have permissions to edit the namespace", "message":"user does not have permissions to edit the namespace"}`, + }, + { + desc: "editor request should succeed", + url: "http://editor:editor@%s/api/ruler/grafana/api/v1/rules/default", + expStatus: http.StatusAccepted, + expectedResponse: `{"message":"rule group updated successfully"}`, + }, + { + desc: "admin request should succeed", + url: "http://admin:admin@%s/api/ruler/grafana/api/v1/rules/default", + expStatus: http.StatusAccepted, + expectedResponse: `{"message":"rule group updated successfully"}`, + }, + } + + for i, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + interval, err := model.ParseDuration("1m") + require.NoError(t, err) + + rules := apimodels.PostableRuleGroupConfig{ + Name: "arulegroup", + Rules: []apimodels.PostableExtendedRuleNode{ + { + ApiRuleNode: &apimodels.ApiRuleNode{ + For: interval, + Labels: map[string]string{"label1": "val1"}, + Annotations: map[string]string{"annotation1": "val1"}, + }, + // this rule does not explicitly set no data and error states + // therefore it should get the default values + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: fmt.Sprintf("AlwaysFiring %d", i), + Condition: "A", + Data: []ngmodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: ngmodels.RelativeTimeRange{ + From: ngmodels.Duration(time.Duration(5) * time.Hour), + To: ngmodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: "-100", + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + }, + }, + } + buf := bytes.Buffer{} + enc := json.NewEncoder(&buf) + err = enc.Encode(&rules) + require.NoError(t, err) + + u := fmt.Sprintf(tc.url, grafanaListedAddr) + // nolint:gosec + resp, err := http.Post(u, "application/json", &buf) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, tc.expStatus, resp.StatusCode) + require.JSONEq(t, tc.expectedResponse, string(b)) + }) + } +} + +func TestDeleteFolderWithRules(t *testing.T) { + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{"ngalert"}, + EnableQuota: true, + DisableAnonymous: true, + ViewersCanEdit: true, + }) + + store := testinfra.SetUpDatabase(t, dir) + // override bus to get the GetSignedInUserQuery handler + store.Bus = bus.GetBus() + grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) + + // Create the namespace we'll save our alerts to. + namespaceUID, err := createFolder(t, store, 0, "default") + require.NoError(t, err) + + require.NoError(t, createUser(t, store, models.ROLE_VIEWER, "viewer", "viewer")) + require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "editor", "editor")) + + createRule(t, grafanaListedAddr, "default", "editor", "editor") + + // First, let's have an editor create a rule within the folder/namespace. + { + u := fmt.Sprintf("http://editor:editor@%s/api/ruler/grafana/api/v1/rules", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(u) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, 202, resp.StatusCode) + + re := regexp.MustCompile(`"uid":"([\w|-]+)"`) + b = re.ReplaceAll(b, []byte(`"uid":""`)) + re = regexp.MustCompile(`"updated":"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)"`) + b = re.ReplaceAll(b, []byte(`"updated":"2021-05-19T19:47:55Z"`)) + + expectedGetRulesResponseBody := fmt.Sprintf(`{ + "default": [ + { + "name": "arulegroup", + "interval": "1m", + "rules": [ + { + "expr": "", + "for": "2m", + "labels": { + "label1": "val1" + }, + "annotations": { + "annotation1": "val1" + }, + "grafana_alert": { + "id": 1, + "orgId": 1, + "title": "rule under folder default", + "condition": "A", + "data": [ + { + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "-100", + "model": { + "expression": "2 + 3 > 1", + "intervalMs": 1000, + "maxDataPoints": 43200, + "type": "math" + } + } + ], + "updated": "2021-05-19T19:47:55Z", + "intervalSeconds": 60, + "version": 1, + "uid": "", + "namespace_uid": %q, + "namespace_id": 1, + "rule_group": "arulegroup", + "no_data_state": "NoData", + "exec_err_state": "Alerting" + } + } + ] + } + ] + }`, namespaceUID) + assert.JSONEq(t, expectedGetRulesResponseBody, string(b)) + } + + // Next, the editor can delete the folder. + { + u := fmt.Sprintf("http://editor:editor@%s/api/folders/%s", grafanaListedAddr, namespaceUID) + req, err := http.NewRequest(http.MethodDelete, u, nil) + require.NoError(t, err) + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + require.JSONEq(t, `{"id":1,"message":"Folder default deleted","title":"default"}`, string(b)) + } + + // Finally, we ensure the rules were deleted. + { + u := fmt.Sprintf("http://editor:editor@%s/api/ruler/grafana/api/v1/rules", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(u) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, 202, resp.StatusCode) + assert.JSONEq(t, "{}", string(b)) + } +} + func TestAlertRuleCRUD(t *testing.T) { // Setup Grafana and its Database dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ @@ -478,10 +727,12 @@ func TestAlertRuleCRUD(t *testing.T) { grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) err := createUser(t, store, models.ROLE_EDITOR, "grafana", "password") + require.NoError(t, err) // Create the namespace we'll save our alerts to. - require.NoError(t, createFolder(t, store, 0, "default")) + _, err = createFolder(t, store, 0, "default") + require.NoError(t, err) interval, err := model.ParseDuration("1m") require.NoError(t, err) @@ -1197,7 +1448,8 @@ func TestQuota(t *testing.T) { grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) // Create the namespace we'll save our alerts to. - require.NoError(t, createFolder(t, store, 0, "default")) + _, err := createFolder(t, store, 0, "default") + require.NoError(t, err) // Create a user to make authenticated requests require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "grafana", "password")) @@ -1298,7 +1550,8 @@ func TestEval(t *testing.T) { require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "grafana", "password")) // Create the namespace we'll save our alerts to. - require.NoError(t, createFolder(t, store, 0, "default")) + _, err := createFolder(t, store, 0, "default") + require.NoError(t, err) // test eval conditions testCases := []struct { @@ -1641,7 +1894,7 @@ func TestEval(t *testing.T) { // createFolder creates a folder for storing our alerts under. Grafana uses folders as a replacement for alert namespaces to match its permission model. // We use the dashboard command using IsFolder = true to tell it's a folder, it takes the dashboard as the name of the folder. -func createFolder(t *testing.T, store *sqlstore.SQLStore, folderID int64, folderName string) error { +func createFolder(t *testing.T, store *sqlstore.SQLStore, folderID int64, folderName string) (string, error) { t.Helper() cmd := models.SaveDashboardCommand{ @@ -1652,9 +1905,13 @@ func createFolder(t *testing.T, store *sqlstore.SQLStore, folderID int64, folder "title": folderName, }), } - _, err := store.SaveDashboard(cmd) + f, err := store.SaveDashboard(cmd) - return err + if err != nil { + return "", err + } + + return f.Uid, nil } // rulesNamespaceWithoutVariableValues takes a apimodels.NamespaceConfigResponse JSON-based input and makes the dynamic fields static e.g. uid, dates, etc. diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index 5b18bc4fb93..885d681c84c 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -57,7 +57,8 @@ func TestNotificationChannels(t *testing.T) { { // Create the namespace we'll save our alerts to. - require.NoError(t, createFolder(t, s, 0, "default")) + _, err := createFolder(t, s, 0, "default") + require.NoError(t, err) // Post the alertmanager config. u := fmt.Sprintf("http://grafana:password@%s/api/alertmanager/grafana/config/api/v1/alerts", grafanaListedAddr) diff --git a/pkg/tests/api/alerting/api_prometheus_test.go b/pkg/tests/api/alerting/api_prometheus_test.go index d9c4db93bf3..18f535060e1 100644 --- a/pkg/tests/api/alerting/api_prometheus_test.go +++ b/pkg/tests/api/alerting/api_prometheus_test.go @@ -30,7 +30,8 @@ func TestPrometheusRules(t *testing.T) { grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) // Create the namespace under default organisation (orgID = 1) where we'll save our alerts to. - require.NoError(t, createFolder(t, store, 0, "default")) + _, err := createFolder(t, store, 0, "default") + require.NoError(t, err) // Create a user to make authenticated requests require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "grafana", "password")) diff --git a/pkg/tests/api/alerting/api_ruler_test.go b/pkg/tests/api/alerting/api_ruler_test.go index 531fe7585d8..99da0369d0c 100644 --- a/pkg/tests/api/alerting/api_ruler_test.go +++ b/pkg/tests/api/alerting/api_ruler_test.go @@ -34,16 +34,18 @@ func TestAlertRulePermissions(t *testing.T) { require.NoError(t, createUser(t, store, models.ROLE_EDITOR, "grafana", "password")) // Create the namespace we'll save our alerts to. - require.NoError(t, createFolder(t, store, 0, "folder1")) + _, err := createFolder(t, store, 0, "folder1") + require.NoError(t, err) + _, err = createFolder(t, store, 0, "folder2") // Create the namespace we'll save our alerts to. - require.NoError(t, createFolder(t, store, 0, "folder2")) + require.NoError(t, err) // Create rule under folder1 - createRule(t, grafanaListedAddr, "folder1") + createRule(t, grafanaListedAddr, "folder1", "grafana", "password") // Create rule under folder2 - createRule(t, grafanaListedAddr, "folder2") + createRule(t, grafanaListedAddr, "folder2", "grafana", "password") // With the rules created, let's make sure that rule definitions are stored. { @@ -240,7 +242,7 @@ func TestAlertRulePermissions(t *testing.T) { } } -func createRule(t *testing.T, grafanaListedAddr string, folder string) { +func createRule(t *testing.T, grafanaListedAddr string, folder string, user, password string) { t.Helper() interval, err := model.ParseDuration("1m") @@ -282,7 +284,7 @@ func createRule(t *testing.T, grafanaListedAddr string, folder string) { err = enc.Encode(&rules) require.NoError(t, err) - u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/%s", grafanaListedAddr, folder) + u := fmt.Sprintf("http://%s:%s@%s/api/ruler/grafana/api/v1/rules/%s", user, password, grafanaListedAddr, folder) // nolint:gosec resp, err := http.Post(u, "application/json", &buf) require.NoError(t, err) diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index 86eceb4959c..aa79c782c5c 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -239,6 +239,12 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) { _, err = anonSect.NewKey("plugin_admin_enabled", "true") require.NoError(t, err) } + if o.ViewersCanEdit { + usersSection, err := cfg.NewSection("users") + require.NoError(t, err) + _, err = usersSection.NewKey("viewers_can_edit", "true") + require.NoError(t, err) + } } cfgPath := filepath.Join(cfgDir, "test.ini") @@ -257,5 +263,7 @@ type GrafanaOpts struct { AnonymousUserRole models.RoleType EnableQuota bool DisableAnonymous bool + CatalogAppEnabled bool + ViewersCanEdit bool PluginAdminEnabled bool } From c4dcfdef56791052e1f5366ef398c1866a2b648a Mon Sep 17 00:00:00 2001 From: Will Browne Date: Thu, 20 May 2021 15:11:07 +0200 Subject: [PATCH 03/43] Plugins: Improve plugin installer error messages (#34437) * fix and improve error messages * enrich error message * ignore previous changes * revert manual version bump * remove condition * fix version param --- pkg/plugins/manager/installer/installer.go | 50 +++++++++++----------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/pkg/plugins/manager/installer/installer.go b/pkg/plugins/manager/installer/installer.go index 52184d79b85..6e2ef50543a 100644 --- a/pkg/plugins/manager/installer/installer.go +++ b/pkg/plugins/manager/installer/installer.go @@ -58,32 +58,23 @@ func (e *BadRequestError) Error() string { } type ErrVersionUnsupported struct { - PluginID string - RequestedVersion string - RecommendedVersion string + PluginID string + RequestedVersion string + SystemInfo string } func (e ErrVersionUnsupported) Error() string { - if len(e.RecommendedVersion) > 0 { - return fmt.Sprintf("%s v%s is not supported on your architecture and OS, latest suitable version is %s", - e.PluginID, e.RequestedVersion, e.RecommendedVersion) - } - return fmt.Sprintf("%s v%s is not supported on your architecture and OS", e.PluginID, e.RequestedVersion) + return fmt.Sprintf("%s v%s is not supported on your system (%s)", e.PluginID, e.RequestedVersion, e.SystemInfo) } type ErrVersionNotFound struct { - PluginID string - RequestedVersion string - RecommendedVersion string + PluginID string + RequestedVersion string + SystemInfo string } func (e ErrVersionNotFound) Error() string { - if len(e.RecommendedVersion) > 0 { - return fmt.Sprintf("%s v%s is not supported on your architecture and OS, latest suitable version is %s", - e.PluginID, e.RequestedVersion, e.RecommendedVersion) - } - return fmt.Sprintf("could not find a version %s for %s. The latest suitable version is %s", e.RequestedVersion, - e.PluginID, e.RecommendedVersion) + return fmt.Sprintf("%s v%s either does not exist or is not supported on your system (%s)", e.PluginID, e.RequestedVersion, e.SystemInfo) } func New(skipTLSVerify bool, grafanaVersion string, logger plugins.PluginInstallerLogger) *Installer { @@ -114,7 +105,7 @@ func (i *Installer) Install(ctx context.Context, pluginID, version, pluginsDir, return err } - v, err := selectVersion(&plugin, version) + v, err := i.selectVersion(&plugin, version) if err != nil { return err } @@ -429,7 +420,7 @@ func normalizeVersion(version string) string { // selectVersion returns latest version if none is specified or the specified version. If the version string is not // matched to existing version it errors out. It also errors out if version that is matched is not available for current // os and platform. It expects plugin.Versions to be sorted so the newest version is first. -func selectVersion(plugin *Plugin, version string) (*Version, error) { +func (i *Installer) selectVersion(plugin *Plugin, version string) (*Version, error) { var ver Version latestForArch := latestSupportedVersion(plugin) @@ -437,6 +428,7 @@ func selectVersion(plugin *Plugin, version string) (*Version, error) { return nil, ErrVersionUnsupported{ PluginID: plugin.ID, RequestedVersion: version, + SystemInfo: i.fullSystemInfoString(), } } @@ -451,24 +443,32 @@ func selectVersion(plugin *Plugin, version string) (*Version, error) { } if len(ver.Version) == 0 { + i.log.Debugf("Requested plugin version %s v%s not found but potential fallback version '%s' was found", + plugin.ID, version, latestForArch.Version) return nil, ErrVersionNotFound{ - PluginID: plugin.ID, - RequestedVersion: version, - RecommendedVersion: latestForArch.Version, + PluginID: plugin.ID, + RequestedVersion: version, + SystemInfo: i.fullSystemInfoString(), } } if !supportsCurrentArch(&ver) { + i.log.Debugf("Requested plugin version %s v%s not found but potential fallback version '%s' was found", + plugin.ID, version, latestForArch.Version) return nil, ErrVersionUnsupported{ - PluginID: plugin.ID, - RequestedVersion: version, - RecommendedVersion: latestForArch.Version, + PluginID: plugin.ID, + RequestedVersion: version, + SystemInfo: i.fullSystemInfoString(), } } return &ver, nil } +func (i *Installer) fullSystemInfoString() string { + return fmt.Sprintf("Grafana v%s %s", i.grafanaVersion, osAndArchString()) +} + func osAndArchString() string { osString := strings.ToLower(runtime.GOOS) arch := runtime.GOARCH From a7c5636948036a51fabef3320eef23e9c4dcc288 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Thu, 20 May 2021 15:33:44 +0200 Subject: [PATCH 04/43] Update _index.md (#34500) --- docs/sources/release-notes/_index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/sources/release-notes/_index.md b/docs/sources/release-notes/_index.md index 7df0119999e..31476eda7f3 100644 --- a/docs/sources/release-notes/_index.md +++ b/docs/sources/release-notes/_index.md @@ -8,6 +8,7 @@ weight = 10000 Here you can find detailed release notes that list everything that is included in every release as well as notices about deprecations, breaking changes as well as changes that relate to plugin development. +- [Release notes for 8.0.0-beta2]({{< relref "release-notes-8-0-0-beta2" >}}) - [Release notes for 8.0.0-beta1]({{< relref "release-notes-8-0-0-beta1" >}}) - [Release notes for 7.5.7]({{< relref "release-notes-7-5-7" >}}) - [Release notes for 7.5.6]({{< relref "release-notes-7-5-6" >}}) From 7580124d500c6b211a9a264deeb9311c9052b48c Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Thu, 20 May 2021 15:53:16 +0200 Subject: [PATCH 05/43] chore: update latest.json to v8-beta2 (#34504) --- latest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/latest.json b/latest.json index f1538848092..f094d908ff6 100644 --- a/latest.json +++ b/latest.json @@ -1,4 +1,4 @@ { "stable": "7.5.7", - "testing": "8.0.0-beta1" + "testing": "8.0.0-beta2" } From b76dfc8ed02314c6ce9f8611f36f6d7d41539147 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Thu, 20 May 2021 16:31:53 +0200 Subject: [PATCH 06/43] Chore: Upgrade loki dependency (#34487) Upgrades loki dependency to include grafana/loki#3743. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 7ce3e31d92b..7761246b9ca 100644 --- a/go.mod +++ b/go.mod @@ -52,7 +52,7 @@ require ( github.com/grafana/grafana-aws-sdk v0.4.0 github.com/grafana/grafana-live-sdk v0.0.6 github.com/grafana/grafana-plugin-sdk-go v0.99.0 - github.com/grafana/loki v1.6.2-0.20210510132741-f408e05ad426 + github.com/grafana/loki v1.6.2-0.20210520072447-15d417efe103 github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 github.com/hashicorp/go-hclog v0.16.0 github.com/hashicorp/go-plugin v1.4.0 diff --git a/go.sum b/go.sum index a8df6025fee..b200f536f05 100644 --- a/go.sum +++ b/go.sum @@ -924,8 +924,8 @@ github.com/grafana/grafana-plugin-sdk-go v0.79.0/go.mod h1:NvxLzGkVhnoBKwzkst6CF github.com/grafana/grafana-plugin-sdk-go v0.91.0/go.mod h1:Ot3k7nY7P6DXmUsDgKvNB7oG1v7PRyTdmnYVoS554bU= github.com/grafana/grafana-plugin-sdk-go v0.99.0 h1:pEmoSSYw7VsF+rhRgG4z+azE3eLwznomxVg9Ezppqzo= github.com/grafana/grafana-plugin-sdk-go v0.99.0/go.mod h1:D7x3ah+1d4phNXpbnOaxa/osSaZlwh9/ZUnGGzegRbk= -github.com/grafana/loki v1.6.2-0.20210510132741-f408e05ad426 h1:fVUMdXAjiHsx71Twl/oie1OLDH+dxL7+mBdQK/H2Wgs= -github.com/grafana/loki v1.6.2-0.20210510132741-f408e05ad426/go.mod h1:IfQ9BWq2sVAk3iKB4Pahz6QNTs5D4WpfJj/AY8xzmNw= +github.com/grafana/loki v1.6.2-0.20210520072447-15d417efe103 h1:qCmofFVwQR9QnsinstVqI1NPLMVl33jNCnOCXEAVn6E= +github.com/grafana/loki v1.6.2-0.20210520072447-15d417efe103/go.mod h1:GHIsn+EohCChsdu5YouNZewqLeV9L2FNw4DEJU3P9qE= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= From dbe281530c75b6e45e5beacae07353f29069e081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 20 May 2021 16:43:19 +0200 Subject: [PATCH 07/43] Timeline: Fix y-axis being cropped (#34508) --- public/app/plugins/panel/state-timeline/utils.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/public/app/plugins/panel/state-timeline/utils.ts b/public/app/plugins/panel/state-timeline/utils.ts index 9f4f0a1d44b..ca58344d077 100644 --- a/public/app/plugins/panel/state-timeline/utils.ts +++ b/public/app/plugins/panel/state-timeline/utils.ts @@ -25,7 +25,6 @@ import { } from '@grafana/ui'; import { TimelineCoreOptions, getConfig } from './timeline'; import { AxisPlacement, ScaleDirection, ScaleOrientation } from '@grafana/ui/src/components/uPlot/config'; -import { measureText } from '@grafana/ui/src/utils/measureText'; import { TimelineFieldConfig, TimelineOptions } from './types'; const defaultConfig: TimelineFieldConfig = { @@ -80,14 +79,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ return FALLBACK_COLOR; }; - const yAxisWidth = - frame.fields.reduce((maxWidth, field) => { - return Math.max( - maxWidth, - measureText(getFieldDisplayName(field, frame), Math.round(10 * devicePixelRatio)).width - ); - }, 0) + 24; - const opts: TimelineCoreOptions = { // should expose in panel config mode: mode!, @@ -154,7 +145,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ values: coreConfig.yValues, grid: false, ticks: false, - size: yAxisWidth, gap: 16, theme, }); From d95cc4a08f70720719d816afd23012f953c44593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Farkas?= Date: Thu, 20 May 2021 16:46:08 +0200 Subject: [PATCH 08/43] InfluxDB: InfluxQL query editor: generate better HTML (#34467) --- .../influxdb/components/VisualInfluxQLEditor/Seg.tsx | 4 +--- .../components/VisualInfluxQLEditor/TagsSection.test.tsx | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Seg.tsx b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Seg.tsx index aa2d2e3b6b3..cc94d6c288a 100644 --- a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Seg.tsx +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Seg.tsx @@ -176,11 +176,9 @@ export const Seg = ({ const [isOpen, setOpen] = useState(false); if (!isOpen) { const className = cx(defaultButtonClass, buttonClassName); - // this should not be a label, this should be a button, - // but this is what is used inside a Segment, and i just - // want the same look return ( { setOpen(true); diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/TagsSection.test.tsx b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/TagsSection.test.tsx index 15ea1908771..c276373684a 100644 --- a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/TagsSection.test.tsx +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/TagsSection.test.tsx @@ -31,7 +31,7 @@ async function assertSegmentSelect( callbackValue: unknown ) { // we find the segment - const segs = screen.getAllByText(segmentText, { selector: 'label' }); + const segs = screen.getAllByRole('button', { name: segmentText }); expect(segs.length).toBe(1); const seg = segs[0]; expect(seg).toBeInTheDocument(); From e2e78f14d2f6a4e81d435e2fa172408ae4a7328e Mon Sep 17 00:00:00 2001 From: Giordano Ricci Date: Thu, 20 May 2021 16:13:48 +0100 Subject: [PATCH 09/43] Elasticsearch: fix flaky test (#34517) --- .../elasticsearch/time_series_query_test.go | 127 ++++++++---------- 1 file changed, 58 insertions(+), 69 deletions(-) diff --git a/pkg/tsdb/elasticsearch/time_series_query_test.go b/pkg/tsdb/elasticsearch/time_series_query_test.go index 55508123f53..5a3d8a02b5e 100644 --- a/pkg/tsdb/elasticsearch/time_series_query_test.go +++ b/pkg/tsdb/elasticsearch/time_series_query_test.go @@ -937,87 +937,76 @@ func TestSettingsCasting(t *testing.T) { t.Run("Inline Script", func(t *testing.T) { t.Run("Correctly handles scripts for ES < 5.6", func(t *testing.T) { c := newFakeClient("5.0.0") - - for key := range scriptableAggType { - t.Run("Inline Script", func(t *testing.T) { - _, err := executeTsdbQuery(c, `{ - "timeField": "@timestamp", - "bucketAggs": [ - { "type": "date_histogram", "field": "@timestamp", "id": "2" } - ], - "metrics": [ - { - "id": "1", - "type": "`+key+`", - "settings": { - "script": "my_script" - } - }, - { - "id": "3", - "type": "`+key+`", - "settings": { - "script": { - "inline": "my_script" - } - } + _, err := executeTsdbQuery(c, `{ + "timeField": "@timestamp", + "bucketAggs": [ + { "type": "date_histogram", "field": "@timestamp", "id": "2" } + ], + "metrics": [ + { + "id": "1", + "type": "avg", + "settings": { + "script": "my_script" + } + }, + { + "id": "3", + "type": "avg", + "settings": { + "script": { + "inline": "my_script" } - ] - }`, from, to, 15*time.Second) + } + } + ] + }`, from, to, 15*time.Second) - assert.Nil(t, err) - sr := c.multisearchRequests[0].Requests[0] + assert.Nil(t, err) + sr := c.multisearchRequests[0].Requests[0] - newFormatAggSettings := sr.Aggs[0].Aggregation.Aggs[0].Aggregation.Aggregation.(*es.MetricAggregation).Settings - oldFormatAggSettings := sr.Aggs[0].Aggregation.Aggs[1].Aggregation.Aggregation.(*es.MetricAggregation).Settings + newFormatAggSettings := sr.Aggs[0].Aggregation.Aggs[0].Aggregation.Aggregation.(*es.MetricAggregation).Settings + oldFormatAggSettings := sr.Aggs[0].Aggregation.Aggs[1].Aggregation.Aggregation.(*es.MetricAggregation).Settings - assert.Equal(t, map[string]interface{}{"inline": "my_script"}, newFormatAggSettings["script"]) - assert.Equal(t, map[string]interface{}{"inline": "my_script"}, oldFormatAggSettings["script"]) - }) - } + assert.Equal(t, map[string]interface{}{"inline": "my_script"}, newFormatAggSettings["script"]) + assert.Equal(t, map[string]interface{}{"inline": "my_script"}, oldFormatAggSettings["script"]) }) t.Run("Correctly handles scripts for ES >= 5.6", func(t *testing.T) { c := newFakeClient("5.6.0") - - for key := range scriptableAggType { - fmt.Println(key) - t.Run("Inline Script", func(t *testing.T) { - _, err := executeTsdbQuery(c, `{ - "timeField": "@timestamp", - "bucketAggs": [ - { "type": "date_histogram", "field": "@timestamp", "id": "2" } - ], - "metrics": [ - { - "id": "1", - "type": "`+key+`", - "settings": { - "script": "my_script" - } - }, - { - "id": "3", - "type": "`+key+`", - "settings": { - "script": { - "inline": "my_script" - } - } + _, err := executeTsdbQuery(c, `{ + "timeField": "@timestamp", + "bucketAggs": [ + { "type": "date_histogram", "field": "@timestamp", "id": "2" } + ], + "metrics": [ + { + "id": "1", + "type": "avg", + "settings": { + "script": "my_script" + } + }, + { + "id": "3", + "type": "avg", + "settings": { + "script": { + "inline": "my_script" } - ] - }`, from, to, 15*time.Second) + } + } + ] + }`, from, to, 15*time.Second) - assert.Nil(t, err) - sr := c.multisearchRequests[0].Requests[0] + assert.Nil(t, err) + sr := c.multisearchRequests[0].Requests[0] - newFormatAggSettings := sr.Aggs[0].Aggregation.Aggs[0].Aggregation.Aggregation.(*es.MetricAggregation).Settings - oldFormatAggSettings := sr.Aggs[0].Aggregation.Aggs[1].Aggregation.Aggregation.(*es.MetricAggregation).Settings + newFormatAggSettings := sr.Aggs[0].Aggregation.Aggs[0].Aggregation.Aggregation.(*es.MetricAggregation).Settings + oldFormatAggSettings := sr.Aggs[0].Aggregation.Aggs[1].Aggregation.Aggregation.(*es.MetricAggregation).Settings - assert.Equal(t, "my_script", newFormatAggSettings["script"]) - assert.Equal(t, "my_script", oldFormatAggSettings["script"]) - }) - } + assert.Equal(t, "my_script", newFormatAggSettings["script"]) + assert.Equal(t, "my_script", oldFormatAggSettings["script"]) }) }) } From aa14621d29f083c58bea6dad07d25a9e517bd663 Mon Sep 17 00:00:00 2001 From: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Thu, 20 May 2021 16:19:24 +0100 Subject: [PATCH 10/43] Chore: Move immutable, is-hotkey, and react-inlinesvg deps to grafana-ui (#34290) --- package.json | 4 ---- packages/grafana-ui/package.json | 3 +++ 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 41b25fe6e75..c4e6f9e88f4 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,6 @@ "@types/file-saver": "2.0.1", "@types/history": "^4.7.8", "@types/hoist-non-react-statics": "3.3.1", - "@types/is-hotkey": "0.1.1", "@types/jest": "26.0.15", "@types/jquery": "3.3.38", "@types/jsurl": "^1.2.28", @@ -256,8 +255,6 @@ "history": "4.10.1", "hoist-non-react-statics": "3.3.2", "immer": "8.0.1", - "immutable": "3.8.2", - "is-hotkey": "0.1.6", "jquery": "3.5.1", "json-source-map": "0.6.1", "jsurl": "^0.1.5", @@ -281,7 +278,6 @@ "react-dom": "17.0.1", "react-grid-layout": "1.2.0", "react-highlight-words": "0.17.0", - "react-inlinesvg": "2.3.0", "react-loadable": "5.5.0", "react-popper": "2.2.4", "react-redux": "7.2.0", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 017edc7526a..8e4a2660c0a 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -45,6 +45,7 @@ "d3": "5.15.0", "hoist-non-react-statics": "3.3.2", "immutable": "3.8.2", + "is-hotkey": "0.1.6", "jquery": "3.5.1", "lodash": "4.17.21", "moment": "2.29.1", @@ -62,6 +63,7 @@ "react-dom": "17.0.1", "react-highlight-words": "0.16.0", "react-hook-form": "7.5.3", + "react-inlinesvg": "2.3.0", "react-popper": "2.2.4", "react-router-dom": "^5.2.0", "react-select": "4.3.0", @@ -86,6 +88,7 @@ "@types/common-tags": "^1.8.0", "@types/d3": "5.7.2", "@types/hoist-non-react-statics": "3.3.1", + "@types/is-hotkey": "0.1.1", "@types/jest": "26.0.15", "@types/jquery": "3.3.38", "@types/lodash": "4.14.123", From d0769397b2707d15693981c64cd4d4072f611b25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 20 May 2021 17:36:20 +0200 Subject: [PATCH 11/43] Histogram: Fix crash when state was undefined (when combine was enabled) (#34514) --- pkg/api/frontendsettings.go | 12 +++++++----- .../PanelEditor/VisualizationSelectPane.tsx | 2 +- public/app/plugins/panel/histogram/Histogram.tsx | 3 ++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 37c663bf7ee..c430e75b9c7 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -285,16 +285,18 @@ func getPanelSort(id string) int { sort = 10 case "status-grid": sort = 11 - case "graph": + case "histogram": sort = 12 - case "text": + case "graph": sort = 13 - case "alertlist": + case "text": sort = 14 - case "dashlist": + case "alertlist": sort = 15 - case "news": + case "dashlist": sort = 16 + case "news": + sort = 17 } return sort } diff --git a/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx b/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx index 6bdbef91b4a..a5dd75d7e8f 100644 --- a/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx +++ b/public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx @@ -57,7 +57,7 @@ export const VisualizationSelectPane: FC = ({ panel }) => { const match = filterPluginList(plugins, query, plugin.meta); if (match && match.length) { - onPluginTypeChange(match[0], true); + onPluginTypeChange(match[0], false); } } }, diff --git a/public/app/plugins/panel/histogram/Histogram.tsx b/public/app/plugins/panel/histogram/Histogram.tsx index 16bfccc3199..f8dfe3776d6 100644 --- a/public/app/plugins/panel/histogram/Histogram.tsx +++ b/public/app/plugins/panel/histogram/Histogram.tsx @@ -123,7 +123,8 @@ const prepConfig = (frame: DataFrame, theme: GrafanaTheme2) => { for (let i = 2; i < frame.fields.length; i++) { const field = frame.fields[i]; - field.state!.seriesIndex = seriesIndex++; + field.state = field.state ?? {}; + field.state.seriesIndex = seriesIndex++; const customConfig = { ...field.config.custom }; From 292789ba2d7a6bb6c9c44badbead0213cc8730b4 Mon Sep 17 00:00:00 2001 From: Dimitris Sotirakis Date: Thu, 20 May 2021 18:52:02 +0300 Subject: [PATCH 12/43] Chore: Increase number of backend test retries in `grabpl` to 5 in release pipelines (#34493) * Increase number of backend test retries to 5 * Exclude release-branch pipelines * Fixes according to reviewer's comments * Refactor * Remove unused arguments * Remove magic number --- .drone.yml | 24 ++++++++++++------------ scripts/lib.star | 11 ++++++++--- scripts/release.star | 7 +++++-- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/.drone.yml b/.drone.yml index 506649ea05e..1f874631a89 100644 --- a/.drone.yml +++ b/.drone.yml @@ -755,8 +755,8 @@ steps: image: grafana/build-container:1.4.1 commands: - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" - - ./bin/grabpl test-backend --edition oss - - ./bin/grabpl integration-tests --edition oss + - ./bin/grabpl test-backend --edition oss --tries 5 + - ./bin/grabpl integration-tests --edition oss --tries 5 depends_on: - initialize @@ -1130,8 +1130,8 @@ steps: image: grafana/build-container:1.4.1 commands: - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" - - ./bin/grabpl test-backend --edition enterprise - - ./bin/grabpl integration-tests --edition enterprise + - ./bin/grabpl test-backend --edition enterprise --tries 5 + - ./bin/grabpl integration-tests --edition enterprise --tries 5 depends_on: - initialize @@ -1196,8 +1196,8 @@ steps: image: grafana/build-container:1.4.1 commands: - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" - - ./bin/grabpl test-backend --edition enterprise2 - - ./bin/grabpl integration-tests --edition enterprise2 + - ./bin/grabpl test-backend --edition enterprise2 --tries 5 + - ./bin/grabpl integration-tests --edition enterprise2 --tries 5 depends_on: - initialize @@ -1710,8 +1710,8 @@ steps: image: grafana/build-container:1.4.1 commands: - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" - - ./bin/grabpl test-backend --edition oss - - ./bin/grabpl integration-tests --edition oss + - ./bin/grabpl test-backend --edition oss --tries 5 + - ./bin/grabpl integration-tests --edition oss --tries 5 depends_on: - initialize @@ -2074,8 +2074,8 @@ steps: image: grafana/build-container:1.4.1 commands: - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" - - ./bin/grabpl test-backend --edition enterprise - - ./bin/grabpl integration-tests --edition enterprise + - ./bin/grabpl test-backend --edition enterprise --tries 5 + - ./bin/grabpl integration-tests --edition enterprise --tries 5 depends_on: - initialize @@ -2140,8 +2140,8 @@ steps: image: grafana/build-container:1.4.1 commands: - "[ $(grep FocusConvey -R pkg | wc -l) -eq \"0\" ] || exit 1" - - ./bin/grabpl test-backend --edition enterprise2 - - ./bin/grabpl integration-tests --edition enterprise2 + - ./bin/grabpl test-backend --edition enterprise2 --tries 5 + - ./bin/grabpl integration-tests --edition enterprise2 --tries 5 depends_on: - initialize diff --git a/scripts/lib.star b/scripts/lib.star index 738ccfba059..2ccce14c7c2 100644 --- a/scripts/lib.star +++ b/scripts/lib.star @@ -434,7 +434,12 @@ def build_plugins_step(edition, sign=False): ], } -def test_backend_step(edition): +def test_backend_step(edition, tries=None): + test_backend_cmd = './bin/grabpl test-backend --edition {}'.format(edition) + integration_tests_cmd = './bin/grabpl integration-tests --edition {}'.format(edition) + if tries: + test_backend_cmd += ' --tries {}'.format(tries) + integration_tests_cmd += ' --tries {}'.format(tries) return { 'name': 'test-backend' + enterprise2_sfx(edition), 'image': build_image, @@ -445,9 +450,9 @@ def test_backend_step(edition): # First make sure that there are no tests with FocusConvey '[ $(grep FocusConvey -R pkg | wc -l) -eq "0" ] || exit 1', # Then execute non-integration tests in parallel, since it should be safe - './bin/grabpl test-backend --edition {}'.format(edition), + test_backend_cmd, # Then execute integration tests in serial - './bin/grabpl integration-tests --edition {}'.format(edition), + integration_tests_cmd, ], } diff --git a/scripts/release.star b/scripts/release.star index dce77e5694e..068081613cd 100644 --- a/scripts/release.star +++ b/scripts/release.star @@ -67,11 +67,14 @@ def get_steps(edition, ver_mode): should_publish = ver_mode in ('release', 'test-release',) should_upload = should_publish or ver_mode in ('release-branch',) include_enterprise2 = edition == 'enterprise' + tries = None + if should_publish: + tries = 5 steps = [ codespell_step(), shellcheck_step(), - test_backend_step(edition=edition), + test_backend_step(edition=edition, tries=tries), lint_backend_step(edition=edition), test_frontend_step(), build_backend_step(edition=edition, ver_mode=ver_mode), @@ -84,7 +87,7 @@ def get_steps(edition, ver_mode): if include_enterprise2: edition2 = 'enterprise2' steps.extend([ - test_backend_step(edition=edition2), + test_backend_step(edition=edition2, tries=tries), lint_backend_step(edition=edition2), build_backend_step(edition=edition2, ver_mode=ver_mode, variants=['linux-x64']), ]) From 676ddac088aa179bed643582aeadd4d80eef60a0 Mon Sep 17 00:00:00 2001 From: Vardan Torosyan Date: Thu, 20 May 2021 18:53:34 +0200 Subject: [PATCH 13/43] Docs: Document fine-grained access control (#33563) --- docs/sources/administration/provisioning.md | 6 + docs/sources/enterprise/_index.md | 1 + .../enterprise/access-control/_index.md | 60 ++ .../enterprise/access-control/permissions.md | 75 +++ .../enterprise/access-control/provisioning.md | 140 +++++ .../enterprise/access-control/roles.md | 101 +++ .../access-control/usage-scenarios.md | 226 +++++++ docs/sources/enterprise/auditing.md | 2 +- .../enterprise/datasource_permissions.md | 2 +- docs/sources/enterprise/enhanced_ldap.md | 2 +- .../enterprise/enterprise-configuration.md | 2 +- docs/sources/enterprise/export-pdf.md | 2 +- docs/sources/enterprise/license/_index.md | 2 +- docs/sources/enterprise/query-caching.md | 2 +- docs/sources/enterprise/reporting.md | 11 +- docs/sources/enterprise/request-security.md | 2 +- docs/sources/enterprise/saml.md | 2 +- docs/sources/enterprise/team-sync.md | 2 +- .../enterprise/usage-insights/_index.md | 2 +- docs/sources/enterprise/vault.md | 2 +- docs/sources/enterprise/white-labeling.md | 2 +- docs/sources/http_api/_index.md | 1 + docs/sources/http_api/access_control.md | 584 ++++++++++++++++++ docs/sources/http_api/admin.md | 69 +++ docs/sources/http_api/org.md | 68 +- docs/sources/http_api/reporting.md | 14 +- docs/sources/http_api/user.md | 61 +- docs/sources/manage-users/_index.md | 2 + docs/sources/permissions/_index.md | 4 + .../sources/permissions/organization_roles.md | 4 + .../sources/permissions/restricting-access.md | 2 + 31 files changed, 1436 insertions(+), 19 deletions(-) create mode 100644 docs/sources/enterprise/access-control/_index.md create mode 100644 docs/sources/enterprise/access-control/permissions.md create mode 100644 docs/sources/enterprise/access-control/provisioning.md create mode 100644 docs/sources/enterprise/access-control/roles.md create mode 100644 docs/sources/enterprise/access-control/usage-scenarios.md create mode 100644 docs/sources/http_api/access_control.md diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index a65976dc3f3..ff8d8db8c74 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -575,3 +575,9 @@ The following sections detail the supported settings and secure settings for eac | Name | | ---- | | url | + +## Grafana Enterprise + +Grafana Enterprise supports provisioning for the following resources: + +- [Access Control Provisioning]({{< relref "../enterprise/access-control/provisioning.md" >}}) diff --git a/docs/sources/enterprise/_index.md b/docs/sources/enterprise/_index.md index 778c37cc8e0..ffc761b2eb2 100644 --- a/docs/sources/enterprise/_index.md +++ b/docs/sources/enterprise/_index.md @@ -43,6 +43,7 @@ With Grafana Enterprise [enhanced LDAP]({{< relref "enhanced_ldap.md" >}}), you With Grafana Enterprise, you get access to new features, including: +- [Fine-grained access control]({{< relref "access-control/_index.md" >}}) to control access with fine-grained roles and permissions. - [Data source permissions]({{< relref "datasource_permissions.md" >}}) to restrict query access to specific teams and users. - [Reporting]({{< relref "reporting.md" >}}) to generate a PDF report from any dashboard and set up a schedule to have it emailed to whoever you choose. - [Export dashboard as PDF]({{< relref "export-pdf.md" >}}) diff --git a/docs/sources/enterprise/access-control/_index.md b/docs/sources/enterprise/access-control/_index.md new file mode 100644 index 00000000000..bf7b02dbcec --- /dev/null +++ b/docs/sources/enterprise/access-control/_index.md @@ -0,0 +1,60 @@ ++++ +title = "Fine-grained access control" +description = "Grant, change, or revoke access to Grafana resources" +keywords = ["grafana", "fine-grained-access-control", "roles", "permissions", "enterprise"] +weight = 100 ++++ + +# Fine-grained access control + +> **Note:** Fine-grained access control is in beta, and you can expect changes in future releases. + +Fine-grained access control provides a standardized way of granting, changing, and revoking access when it comes to viewing and modifying Grafana resources, such as users and reports. +Fine-grained access control works alongside the current [Grafana permissions]({{< relref "../../permissions/_index.md" >}}), and it allows you granular control of users’ actions. + +To learn more about how fine-grained access control works, refer to [Roles]({{< relref "./roles.md" >}}) and [Permissions]({{< relref "./permissions.md" >}}). +To use the fine-grained access control system, refer to [Fine-grained access control usage scenarios]({{< relref "./usage-scenarios.md" >}}). + +## Access management + +Fine-grained access control considers a) _who_ has an access (`identity`), and b) _what they can do_ and on which _Grafana resource_ (`role`). + +You can grant, change, or revoke access to _users_ (`identity`). When an authenticated user tries to access a Grafana resource, the authorization system checks the required fine-grained permissions for the resource and determines whether or not the action is allowed. Refer to [Fine-grained permissions]({{< relref "./permissions.md" >}}) for a complete list of available permissions. + +To grant or revoke access to your users, create or remove built-in role assignments. For more information, refer to [Built-in role assignments]({{< relref "./roles.md#built-in-role-assignments" >}}). + +## Resources with fine-grained permissions + +Fine-grained access control is currently available for [Reporting]({{< relref "../reporting.md" >}}) and [Managing Users]({{< relref "../../manage-users/_index.md" >}}). +To learn more about specific endpoints where you can use access control, refer to [Permissions]({{< relref "./permissions.md" >}}) and to the relevant API guide: + +- [Fine-grained access control API]({{< relref "../../http_api/access_control.md" >}}) +- [Admin API]({{< relref "../../http_api/admin.md" >}}) +- [Organization API]({{< relref "../../http_api/org.md" >}}) +- [Reporting API]({{< relref "../../http_api/reporting.md" >}}) +- [User API]({{< relref "../../http_api/user.md" >}}) + +## Enable fine-grained access control + +Fine-grained access control is available behind the `accesscontrol` feature toggle in Grafana Enterprise 8.0+. +You can enable it either in a [config file](({{< relref "../../administration/configuration.md#fconfig-file-locations" >}})) or by [configuring an environment variable](http://localhost:3002/docs/grafana/next/administration/configuration/#configure-with-environment-variables). + +### Enable in config file + +In your [config file](({{< relref "../../administration/configuration.md#config-file-locations" >}})), add `accesscontrol` as a [feature_toggle](({{< relref "../../administration/configuration.md#feature_toggle" >}})). + +``` +[feature_toggles] +# enable features, separated by spaces +enable = accesscontrol +``` + +### Enable with an environment variable + +You can use `GF_FEATURE_TOGGLES_ENABLE = accesscontrol` environment variable to override the config file configuration and enable fine-grained access control. + +Refer to [Configuring with environment variables]({{< relref "../../administration/configuration.md#configure-with-environment-variables" >}}) for more information. + +### Verify if enabled + +You can verify if fine-grained access control is enabled or not by sending an HTTP request to the [Check endpoint]({{< relref "../../http_api/access_control.md#check-if-enabled" >}}). diff --git a/docs/sources/enterprise/access-control/permissions.md b/docs/sources/enterprise/access-control/permissions.md new file mode 100644 index 00000000000..cc75a90b44b --- /dev/null +++ b/docs/sources/enterprise/access-control/permissions.md @@ -0,0 +1,75 @@ ++++ +title = "Permissions" +description = "Understand fine-grained access control permissions" +keywords = ["grafana", "fine-grained access-control", "roles", "permissions", "enterprise"] +weight = 115 ++++ + +# Permissions + +Each permission is defined by an action and a scope. When evaluating a fine-grained access decision, consider what specific action a user should be allowed to perform, and on what resources (its scope). + +To grant permissions to a user, create built-in role assignments. A built-in role assignment is a *modification* to one of the existing built-in roles in Grafana (Viewer, Editor, Admin) For more information, refer to [Built-in role assignments]({{< relref "./roles.md#built-in-role-assignments" >}}). + +To learn more about which permissions are used for which resources, refer to [Resources with fine-grained permissions]({{< relref "./_index.md#resources-with-fine-grained-permissions" >}}). + +action +: The specific action on a resource defines what a user is allowed to perform if they have permission with the relevant action assigned to it. + +scope +: The scope describes where an action can be performed, such as reading a specific user profile. In such case, a permission is associated with the scope `users:` to the relevant role. Also, you can combine multiple scopes by using the `/` delimiter. + +## Action definitions + +Note that below list is not exhaustive yet and more permissions will be available with further releases of fine-grained access control. + +Action | Applicable scopes | Description +--- | --- | --- +roles:list | roles:* | Allows to list available roles without permissions. +roles:read | roles:* | Allows to read a specific role with it's permissions. +roles:write | permissions:delegate | Allows to create or update a custom role. +roles:delete | permissions:delegate | Allows to delete a custom role. +roles.builtin:list | roles:* | Allows to list built-in role assignments. +roles.builtin:add | permissions:delegate | Allows to create a built-in role assignment. +roles.builtin:remove | permissions:delegate | Allows to delete a built-in role assignment. +reports.admin:write | reports:* | Allows to create or update reports. +reports:delete | reports:* | Allows to delete reports. +reports:read | reports:* | Allows to list all available reports and to get a specific report. +reports:send | reports:* | Allows to send report email. +reports.settings:write | n/a | Allows to update report settings. +reports.settings:read | n/a | Allows to read report settings. +provisioning:reload | service:access-control | Allows to reload provisioning files after an update. +users:read | global:users:* | Allows to read, search user profiles. +users:write | global:users:* | Allows to update user profiles. +users.teams:read | global:users:* | Allows to read user teams. +users.authtoken:list | global:users:* | Allows to list auth tokens assigned to users. +users.authtoken:update | global:users:* | Allows to update auth tokens assigned to users. +users.password:update | global:users:* | Allows to update users password. +users:delete | global:users:* | Allows to delete users. +users:create | n/a | Allows to create users. +users:enable | global:users:* | Allows to enable users. +users:disable | global:users:* | Allows to disable users. +users.permissions:update | global:users:* | Allows to update users org level permissions. +users:logout | global:users:* | Allows to enforce logout for users. +users.quotas:list | global:users:* | Allows to list user quotas. +users.quotas:update | global:users:* | Allows to update user quotas. +org.users.read | users:* | Allows to get user profiles within the organization. +org.users.add | users:* | Allows to add users to the organization. +org.users.remove | users:* | Allows to remove users from the organization. +org.users.role:update | users:* | Allows to update users organization role for the assigned organization. +ldap.user:read | n/a | Allows to read LDAP users. +ldap.user:sync | n/a | Allows to sync LDAP users. +ldap.status:read | n/a | Allows to check LDAP status. + +## Scope definitions + +Note that below list is not exhaustive yet and more scopes will be available with further releases of fine-grained access control. + +Scope | Description +--- | --- +roles:* | Indicates against what roles an action can be performed. For example, `roles:*` assumes any roles, and `roles:randomuid` assumes only a role with UID `randomuid`. +permissions:delegate | The scope is only applicable for roles associated with the Access Control itself and indicates that you can delegate your permissions only, or a subset of it, by creating a new role or making an assignment. +reports:* | Indicates against what reports an action can be performed. +service:access-control | Only relevant for provisioning and indicates that the action can be performed only for access control provisioning files. +global:users:* | Indicates that action can be performed against users globally. +users:* | Indicates that an action can be performed against users in organization level. diff --git a/docs/sources/enterprise/access-control/provisioning.md b/docs/sources/enterprise/access-control/provisioning.md new file mode 100644 index 00000000000..eeaef9f2b63 --- /dev/null +++ b/docs/sources/enterprise/access-control/provisioning.md @@ -0,0 +1,140 @@ ++++ +title = "Provisioning roles and assignments" +description = "Understand how to provision roles and assignments in fine-grained access control" +keywords = ["grafana", "fine-grained-access-control", "roles", "provisioning", "assignments", "permissions", "enterprise"] +weight = 120 ++++ + +# Provisioning + +You can create, change or remove [Custom roles]({{< relref "./roles.md#custom-roles" >}}) and create or remove [built-in role assignments]({{< relref "./roles.md#built-in-role-assignments" >}}), by adding one or more YAML configuration files in the [`provisioning/access-control/`]({{< relref "../../administration/configuration/#provisioning" >}}) directory. +Refer to [Grafana provisioning]({{< relref "../../administration/configuration/#provisioning" >}}) to learn more about provisioning. + +If you want to manage roles and built-in role assignments by API, refer to the [Fine-grained access control HTTP API]({{< relref "../../http_api/access_control/" >}}). + +## Configuration + +The configuration files must be located in the [`provisioning/access-control/`]({{< relref "../../administration/configuration/#provisioning" >}}) directory. +Grafana performs provisioning during the startup. Refer to the [Reload provisioning configurations]({{< relref "../../http_api/admin/#reload-provisioning-configurations" >}}) to understand how you can reload configuration at runtime. + +## Manage custom roles + +You can create, update and delete custom roles, as well as create and remove built-in role assignments for them. + +### Create or update roles + +To create or update custom roles, you can add a list of `roles` in the configuration. + +Note that in order to update a role, you would need to increment the [version]({{< relref "./roles.md#custom-roles" >}}). + +It is only possibly to provision [organization local]({{< relref "./roles#role-scopes" >}}) roles. For creating or updating _global_ roles, refer to the [Fine-grained access control HTTP API]({{< relref "../../http_api/access_control.md" >}}). + +### Delete roles + +To delete a role, you can add a list of roles under `deleteRoles` section in the configuration file. Note that deletion is performed after role insertion/update. + +### Create and remove built-in role assignments + +To create a built-in role assignment, you can add list of assignments under `builtInRoles` section in the configuration file, as an element of `roles`. To remove a built-in role assignment, leave `builtInRoles` list empty. + +Note that it is only possibly to provision [organization local]({{< relref "./roles#built-in-role-assignments" >}}) assignments. For creating or updating _global_ assignments, refer to the [Fine-grained access control HTTP API]({{< relref "../../http_api/access_control.md" >}}). + +## Manage default built-in role assignments + +During the startup, Grafana creates [default built-in role assignments]({{< relref "./roles#default-built-in-role-assignments" >}}) with [predefined roles]({{< relref "./roles#predefined-roles" >}}). You can remove and add back later those assignments by using provisioning. + +### Remove default assignment + +To remove default built-in role assignment, you can use `removeDefaultAssignments` element in the configuration file. You would need to provide built-in role name and predefined role name. + +### Add back default assignment + +To add back default built-in role assignment, you can use `addDefaultAssignments` element in the configuration file. You would need to provide built-in role name and predefined role name. + +## Example of a role configuration file + +```yaml +# config file version +apiVersion: 1 + +# list of default built-in role assignments that should be removed +removeDefaultAssignments: + # , must be one of the Organization roles (`Viewer`, `Editor`, `Admin`) or `Grafana Admin` + - builtInRole: "Grafana Admin" + # , must be one of the existing predefined roles + predefinedRole: "grafana:roles:permissions:admin" + +# list of default built-in role assignments that should be added back +addDefaultAssignments: + # , must be one of the Organization roles (`Viewer`, `Editor`, `Admin`) or `Grafana Admin` + - builtInRole: "Admin" + # , must be one of the existing predefined roles + predefinedRole: "grafana:roles:reporting:admin:read" + +# list of roles that should be deleted +deleteRoles: + # name of the role you want to create. Required if no uid is set + - name: ReportEditor + # uid of the role. Required if no name + uid: reporteditor1 + # org id. will default to Grafana's default if not specified + orgId: 1 + # force deletion revoking all grants of the role + force: true + +# list of roles to insert/update depending on what is available in the database +roles: + # name of the role you want to create. Required + - name: CustomEditor + # uid of the role. Has to be unique for all orgs. + uid: customeditor1 + # description of the role, informative purpose only. + description: "Role for our custom user editors" + # version of the role, Grafana will update the role when increased + version: 2 + # org id. will default to Grafana's default if not specified + orgId: 1 + # list of the permissions granted by this role + permissions: + # action allowed + - action: "users:read" + # scope it applies to + scope: "users:*" + - action: "users:write" + scope: "users:*" + - action: "users:create" + scope: "users:*" + # list of builtIn roles the role should be assigned to + builtInRoles: + # name of the builtin role you want to assign the role to + - name: "Editor" + # org id. will default to the role org id + orgId: 1 +``` + +## Supported settings + +The following sections detail the supported settings for roles and built-in role assignments. + +- Refer to [Permissions]({{< relref "./permissions.md#action-definitions" >}}) for full list of valid permissions. +- Check [Custom roles]({{< relref "./roles.md#custom-roles" >}}) to understand attributes for roles. +- The [default org ID]({{< relref "../../administration/configuration#auto_assign_org_id" >}}) is used if `orgId` is not specified in any of the configuration blocks. + +## Validation rules + +A basic set of validation rules are applied to the input `yaml` files. + +### Roles + +- `name` must not be empty +- `name` must not have `grafana:roles:` prefix. + +### Built-in role assignments + +- `name` must be one of the Organization roles (`Viewer`, `Editor`, `Admin`) or `Grafana Admin`. +- When `orgId` is not specified, it inherits the `orgId` from `role`. +- `orgId` in the `role` and in the assignment must be the same. + +### Role deletion + +- Either the role `name` or `uid` must be provided diff --git a/docs/sources/enterprise/access-control/roles.md b/docs/sources/enterprise/access-control/roles.md new file mode 100644 index 00000000000..31595e54ef9 --- /dev/null +++ b/docs/sources/enterprise/access-control/roles.md @@ -0,0 +1,101 @@ ++++ +title = "Roles" +description = "Understand roles in fine-grained access control" +keywords = ["grafana", "fine-grained-access-control", "roles", "predefined-roles", "built-in-role-assignments", "permissions", "enterprise"] +weight = 105 ++++ + +# Roles + +A role represents set of permissions that allow you to perform specific actions on Grafana resources. Refer to [Permissions]({{< relref "./permissions.md" >}}) to understand how permissions work. + +There are two types of roles: +- [Predefined roles]({{< relref "./roles.md#predefined-roles" >}}), which provide granular access for specific resources within Grafana and are managed by the Grafana itself. +- [Custom roles]({{< relref "./roles.md#custom-roles.md" >}}), which provide granular access based on the user specified set of permissions. + +You can use [Fine-grained access control API]({{< relref "../../http_api/access_control.md" >}}) to list available roles and permissions. + +## Role scopes + +A role can be either _global_ or _organization local_. _Global_ roles are not mapped to any specific organization and can be reused across multiple organizations, whereas _organization local_ roles are only available for that specific organization. + +## Predefined roles + +Predefined roles provide convenience and guarantee of consistent behaviour by combining relevant [permissions]({{< relref "./permissions.md" >}}) together. Predefined roles are created and updated by the Grafana, during the startup. +There are few basic rules for predefined roles: + +- All predefined roles are _global_ by default +- All predefined roles have a `grafana:roles:` prefix. +- You can’t change or delete a predefined role. + +Role name | Permissions | Description +--- | --- | --- +grafana:roles:permissions:admin:read | roles:read
roles:list
roles.builtin:list | Allows to list and get available roles and built-in role assignments. +grafana:roles:permissions:admin:edit | All permissions from `grafana:roles:permissions:admin:read` and
roles:write
roles:delete
roles.builtin:add
roles.builtin:remove | Allows every read action and in addition allows to create, change and delete custom roles and create or remove built-in role assignments. +grafana:roles:reporting:admin:read | reports:read
reports:send
reports.settings:read | Allows to read reports and report settings. +grafana:roles:reporting:admin:edit | All permissions from `grafana:roles:reporting:admin:read` and
reports.admin:write
reports:delete
reports.settings:write | Allows every read action for reports and in addition allows to administer reports. +grafana:roles:users:admin:read | users.authtoken:list
users.quotas:list
users:read
users.teams:read | Allows to list and get users and related information. +grafana:roles:users:admin:edit | All permissions from `grafana:roles:users:admin:read` and
users.password:update
users:write
users:create
users:delete
users:enable
users:disable
users.permissions:update
users:logout
users.authtoken:update
users.quotas:update | Allows every read action for users and in addition allows to administer users. +grafana:roles:users:org:read | org.users:read | Allows to get user organizations. +grafana:roles:users:org:edit | All permissions from `grafana:roles:users:org:read` and
org.users:add
org.users:remove
org.users.role:update | Allows every read action for user organizations and in addition allows to administer user organizations. +grafana:roles:ldap:admin:read | ldap.user:read
ldap.status:read | Allows to read LDAP information and status. +grafana:roles:ldap:admin:edit | All permissions from `grafana:roles:ldap:admin:read` and
ldap.user:sync | Allows every read action for LDAP and in addition allows to administer LDAP. + +## Custom roles + +Custom roles allow you to manage access to your users the way you want, by mapping [fine-grained permissions]({{< relref "./permissions.md" >}}) to it and creating [built-in role assignments]({{< ref "#built-in-role-assignments.md" >}}). + +To create, update or delete a custom role, you can use the [Fine-grained access control API]({{< relref "../../http_api/access_control.md" >}}) or [Grafana Provisioning]({{< relref "./provisioning.md" >}}). + +##### Role name + +A role's name is intended as a human friendly identifier for the role, helping administrators understand the purpose of a role. The name cannot be longer than 190 characters, and we recommend using ASCII characters. +Role names must be unique within an organization. + +Roles with names prefixed by `grafana:roles:` are predefined roles created by Grafana and cannot be created or modified by users. + +##### Role version + +The version of a role is a positive integer which defines the current version of the role. When updating a role, you can either omit the version field to increment the previous value by 1 or set a new version which must be strictly larger than the previous version for the update to succeed. + +##### Permissions + +You manage access to Grafana resources by mapping [permissions]({{< relref "./permissions.md" >}}) to roles. You can create and assign roles without any permissions as placeholders. + +##### Role UID + +Each custom role has a UID defined which is a unique identifier associated with the role allowing you to change or delete the role. You can either generate UID yourself, or let Grafana generate one for you. + +The same UID cannot be used for roles in different organizations within the same Grafana instance. + +### Create, update and delete roles + +You can create, update and delete custom roles by using the [Access Control HTTP API]({{< relref "../../http_api/access_control.md" >}}) or by using [Grafana Provisioning]({{< relref "./provisioning.md" >}}). + +By default, Grafana Server Admin has a [built-in role assignment]({{< ref "#built-in-role-assignments" >}}) which allows a user to create, update or delete custom roles. +If a Grafana Server Admin wants to delegate that privilege to other users, they can create a custom role with relevant [permissions]({{< relref "./permissions.md" >}}) and `permissions:delegate` scope will allow those users to manage roles themselves. + +Note that you won't be able to create, update or delete a custom role with permissions which you yourself do not have. For example, if the only permission you have is a `users:create`, you won't be able to create a role with other permissions. + +## Built-in role assignments + +To control what your users can access or not, you can assign or unassign [Custom roles]({{< ref "#custom-roles" >}}) or [Predefined roles]({{< ref "#predefined-roles" >}}) to the existing [Organization roles]({{< relref "../../permissions/organization_roles.md" >}}) or to [Grafana Server Admin]({{< relref "../../permissions/_index.md#grafana-server-admin-role" >}}) role. +These assignments are called built-in role assignments. + +During startup, Grafana will create default assignments for you. When you make any changes to the built-on role assignments, Grafana will take them into account and won’t overwrite during next start. + +### Create and remove built-in role assignments + +You can create or remove built-in role assignments using [Fine-grained access control API]({{< relref "../../http_api/access_control.md" >}}) or using [Grafana Provisioning]({{< relref "./provisioning">}}). + +### Scope of assignments + +A built-in role assignment can be either _global_ or _organization local_. _Global_ assignments are not mapped to any specific organization and will be applied to all organizations, whereas _organization local_ assignments are only applied for that specific organization. +You can only create _organization local_ assignments for _organization local_ roles. + +### Default built-in role assignments + +Built-in role | Associated role | Description +--- | --- | --- +Grafana Admin | grafana:roles:permissions:admin:edit
grafana:roles:permissions:admin:read
grafana:roles:reporting:admin:edit
grafana:roles:reporting:admin:read
grafana:roles:users:admin:edit
grafana:roles:users:admin:read
grafana:roles:users:org:edit
grafana:roles:users:org:read
grafana:roles:ldap:admin:edit
grafana:roles:ldap:admin:read | Allows access to resources which [Grafana Server Admin]({{< relref "../../permissions/_index.md#grafana-server-admin-role" >}}) has permissions by default. +Admin | grafana:roles:users:org:edit
grafana:roles:users:org:read
grafana:roles:reporting:admin:edit
grafana:roles:reporting:admin:read | Allows access to resource which [Admin]({{< relref "../../permissions/organization_roles.md" >}}) has permissions by default. diff --git a/docs/sources/enterprise/access-control/usage-scenarios.md b/docs/sources/enterprise/access-control/usage-scenarios.md new file mode 100644 index 00000000000..57a516cccf6 --- /dev/null +++ b/docs/sources/enterprise/access-control/usage-scenarios.md @@ -0,0 +1,226 @@ ++++ +title = "Fine-grained access control usage scenarios" +description = "Fine-grained access control usage scenarios" +keywords = ["grafana", "fine-grained-access-control", "roles", "permissions", "fine-grained-access-control-usage", "enterprise"] +weight = 125 ++++ + +# Fine-grained access control usage scenarios + +This guide contains several examples and usage scenarios of using fine-grained roles and permissions for controlling access to Grafana resources. + +Before you get started, make sure to [enable fine-grained access control]({{< relref "./_index.md#enable-fine-grained-access-control" >}}). + +## Check all built-in role assignments + +You can use the [Fine-grained access control HTTP API]({{< relref "../../http_api/access_control.md#get-all-built-in-role-assignments" >}}) to see all available built-in role assignments. +The response contains a mapping between one of the organization roles (`Viewer`, `Editor`, `Admin`) or `Grafana Admin` to the custom or predefined roles. + +Example request: +``` +curl --location --request GET '/api/access-control/builtin-roles' --header 'Authorization: Basic YWRtaW46cGFzc3dvcmQ=' +``` + +Example response: +``` +{ + "Admin": [ + ... + { + "version": 2, + "uid": "qQui_LCMk", + "name": "grafana:roles:users:org:edit", + "description": "Allows every read action for user organizations and in addition allows to administer user organizations.", + "global": true, + "updated": "2021-05-17T20:49:18+02:00", + "created": "2021-05-13T16:24:26+02:00" + }, + { + "version": 1, + "uid": "Kz9m_YjGz", + "name": "grafana:roles:reporting:admin:edit", + "description": "Gives access to edit any report or the organization's general reporting settings.", + "global": true, + "updated": "2021-05-13T16:24:26+02:00", + "created": "2021-05-13T16:24:26+02:00" + } + ... + ], + "Grafana Admin": [ + ... + { + "version": 2, + "uid": "qQui_LCMk", + "name": "grafana:roles:users:org:edit", + "description": "Allows every read action for user organizations and in addition allows to administer user organizations.", + "global": true, + "updated": "2021-05-17T20:49:18+02:00", + "created": "2021-05-13T16:24:26+02:00" + }, + { + "version": 2, + "uid": "ajum_YjGk", + "name": "grafana:roles:users:admin:read", + "description": "Allows to list and get users and related information.", + "global": true, + "updated": "2021-05-17T20:49:17+02:00", + "created": "2021-05-13T16:24:26+02:00" + }, + { + "version": 2, + "uid": "K3um_LCMk", + "name": "grafana:roles:users:admin:edit", + "description": "Allows every read action for users and in addition allows to administer users.", + "global": true, + "updated": "2021-05-17T20:49:17+02:00", + "created": "2021-05-13T16:24:26+02:00" + }, + ... + ] +} +``` + +To see what permissions each of the assigned roles have, you can a [Get a role]({{< relref "../../http_api/access_control.md#get-a-role" >}}) by using an HTTP API. + +Example request: + +``` +curl --location --request GET '/api/access-control/roles/qQui_LCMk' --header 'Authorization: Basic YWRtaW46cGFzc3dvcmQ=' +``` + +Example response: + +``` +{ + "version": 2, + "uid": "qQui_LCMk", + "name": "grafana:roles:users:org:edit", + "description": "Allows every read action for user organizations and in addition allows to administer user organizations.", + "global": true, + "permissions": [ + { + "action": "org.users:add", + "scope": "users:*", + "updated": "2021-05-17T20:49:18+02:00", + "created": "2021-05-17T20:49:18+02:00" + }, + { + "action": "org.users:read", + "scope": "users:*", + "updated": "2021-05-17T20:49:18+02:00", + "created": "2021-05-17T20:49:18+02:00" + }, + { + "action": "org.users:remove", + "scope": "users:*", + "updated": "2021-05-17T20:49:18+02:00", + "created": "2021-05-17T20:49:18+02:00" + }, + { + "action": "org.users.role:update", + "scope": "users:*", + "updated": "2021-05-17T20:49:18+02:00", + "created": "2021-05-17T20:49:18+02:00" + } + ], + "updated": "2021-05-17T20:49:18+02:00", + "created": "2021-05-13T16:24:26+02:00" +} +``` + +## Create your first custom role + +You can create your custom role by either using an [HTTP API]({{< relref "../../http_api/access_control.md#create-a-new-custom-role" >}}) or by using [Grafana provisioning]({{< relref "./provisioning.md" >}}). +You can take a look at [actions and scopes]({{< relref "./provisioning.md#action-definitions" >}}) to decide what permissions would you like to map to your role. + +Example HTTP request: +``` +curl --location --request POST '/api/access-control/roles/' \ +--header 'Authorization: Basic YWRtaW46cGFzc3dvcmQ=' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "version": 1, + "uid": "jZrmlLCkGksdka", + "name": "custom:users:admin", + "description": "My custom role which gives users permissions to create users", + "global": true, + "permissions": [ + { + "action": "users:create" + } + ] +}' +``` + +Example response: + +``` +{ + "version": 1, + "uid": "jZrmlLCkGksdka", + "name": "custom:users:admin", + "description": "My custom role which gives users permissions to create users", + "global": true, + "permissions": [ + { + "action": "users:create" + "updated": "2021-05-17T22:07:31.569936+02:00", + "created": "2021-05-17T22:07:31.569935+02:00" + } + ], + "updated": "2021-05-17T22:07:31.564403+02:00", + "created": "2021-05-17T22:07:31.564403+02:00" +} +``` + +Once the custom role is created, you can create a built-in role assignment by using an [HTTP API]({{< relref "../../http_api/access_control.md#create-a-built-in-role-assignment" >}}). +If you created your role using [Grafana provisioning]({{< relref "./provisioning.md" >}}), you can also create the assignment with it. + +Example HTTP request: + +``` +curl --location --request POST '/api/access-control/builtin-roles' \ +--header 'Authorization: Basic YWRtaW46cGFzc3dvcmQ=' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "roleUid": "jZrmlLCkGksdka", + "builtinRole": "Viewer", + "global": true +}' +``` + +Example response: + +``` +{ + "message": "Built-in role grant added" +} +``` + +## Allow Viewers to create reports + +In order to create reports, you would need to have `reports.admin:write` permission. By default, Grafana Admin's or organization Admin can create reports as there is a [built-in role assignment]({{< relref "./roles#built-in-role-assignments" >}}) which comes with `reports.admin:write` permission. + +If you want your users who have `Viewer` organization role to create reports, you have two options: + +1. First option is to create a built-in role assignment and map `grafana:roles:reporting:admin:edit` predefined role to the `Viewer` built-in role. Note that `grafana:roles:reporting:admin:edit` predefined role allows doing more than creating reports. Refer to [predefined roles]({{< relref "./roles.md#predefined-roles" >}}) for full list of permission assignments. +1. Second option is to [create a custom role]({{< ref "#create-your-custom-role" >}}) with `reports.admin:write` permission, and create a built-in role assignment for `Viewer` organization role. + +## Prevent Grafana Admin from creating and inviting users + +In order to create users, you would need to have `users:create` permission. By default, user with Grafana Admin role can create users as there is a [built-in role assignment]({{< relref "./roles#built-in-role-assignments" >}}) which comes with `users:create` permission. + +If you want to prevent Grafana Admin from creating users, you can do the following: + +1. [Check all built-in role assignments]({{< ref "#check-all-built-in-role-assignments" >}}) to see what built-in role assignments are available. +1. From built-in role assignments, find the role which gives `users:create` permission. Refer to [predefined roles]({{< relref "./roles.md#predefined-roles" >}}) for full list of permission assignments. +1. Remove the built-in role assignment by using an [Fine-grained access control HTTP API]({{< relref "../../http_api/access_control.md" >}}) or by using [Grafana provisioning]({{< relref "./provisioning" >}}). + +## Allow Editors to create new custom roles + +By default, Grafana Server Admin is the only user who can create and manage custom roles. If you want your users to do the same, you have two options: + +1. First option is to create a built-in role assignment and map `grafana:roles:permissions:admin:edit` and `grafana:roles:permissions:admin:read` predefined roles to the `Editor` built-in role. +1. Second option is to [create a custom role]({{< ref "#create-your-custom-role" >}}) with `roles.builtin:add` and `roles:write` permissions, and create a built-in role assignment for `Editor` organization role. + +Note that in any scenario, your `Editor` would be able to create and manage roles only with the permissions they have, or with a subset of them. diff --git a/docs/sources/enterprise/auditing.md b/docs/sources/enterprise/auditing.md index b7f76de7eac..b7a2ede145c 100644 --- a/docs/sources/enterprise/auditing.md +++ b/docs/sources/enterprise/auditing.md @@ -2,7 +2,7 @@ title = "Auditing" description = "Auditing" keywords = ["grafana", "auditing", "audit", "logs"] -weight = 700 +weight = 1100 +++ # Auditing diff --git a/docs/sources/enterprise/datasource_permissions.md b/docs/sources/enterprise/datasource_permissions.md index af4b80d5237..53074d14ce4 100644 --- a/docs/sources/enterprise/datasource_permissions.md +++ b/docs/sources/enterprise/datasource_permissions.md @@ -2,7 +2,7 @@ title = "Data source permissions" description = "Grafana Datasource Permissions Guide " keywords = ["grafana", "configuration", "documentation", "datasource", "permissions", "users", "teams", "enterprise"] -weight = 200 +weight = 500 +++ # Data source permissions diff --git a/docs/sources/enterprise/enhanced_ldap.md b/docs/sources/enterprise/enhanced_ldap.md index b1966d4d497..eebc87ca98c 100644 --- a/docs/sources/enterprise/enhanced_ldap.md +++ b/docs/sources/enterprise/enhanced_ldap.md @@ -2,7 +2,7 @@ title = "Enhanced LDAP Integration" description = "Grafana Enhanced LDAP Integration Guide " keywords = ["grafana", "configuration", "documentation", "ldap", "active directory", "enterprise"] -weight = 300 +weight = 600 +++ # Enhanced LDAP integration diff --git a/docs/sources/enterprise/enterprise-configuration.md b/docs/sources/enterprise/enterprise-configuration.md index 7b906018886..5b6ce4b8de6 100644 --- a/docs/sources/enterprise/enterprise-configuration.md +++ b/docs/sources/enterprise/enterprise-configuration.md @@ -2,7 +2,7 @@ title = "Enterprise configuration" description = "Enterprise configuration documentation" keywords = ["grafana", "configuration", "documentation", "enterprise"] -weight = 300 +weight = 700 +++ # Grafana Enterprise configuration diff --git a/docs/sources/enterprise/export-pdf.md b/docs/sources/enterprise/export-pdf.md index 4d626b3fa83..9ced0bc2250 100644 --- a/docs/sources/enterprise/export-pdf.md +++ b/docs/sources/enterprise/export-pdf.md @@ -2,7 +2,7 @@ title = "Export dashboard as PDF" description = "" keywords = ["grafana", "export", "pdf", "share"] -weight = 900 +weight = 1400 +++ # Export dashboard as PDF diff --git a/docs/sources/enterprise/license/_index.md b/docs/sources/enterprise/license/_index.md index 810af58a0f8..851250186d7 100644 --- a/docs/sources/enterprise/license/_index.md +++ b/docs/sources/enterprise/license/_index.md @@ -2,7 +2,7 @@ title = "Grafana Enterprise license" description = "Enterprise license" keywords = ["grafana", "licensing", "enterprise"] -weight = 100 +weight = 10 +++ # Grafana Enterprise license diff --git a/docs/sources/enterprise/query-caching.md b/docs/sources/enterprise/query-caching.md index ac4783504c5..799f2cd0ed1 100644 --- a/docs/sources/enterprise/query-caching.md +++ b/docs/sources/enterprise/query-caching.md @@ -2,7 +2,7 @@ title = "Query caching" description = "Grafana Enterprise data source query caching" keywords = ["grafana", "plugins", "query", "caching"] -weight = 110 +weight = 300 +++ # Query caching diff --git a/docs/sources/enterprise/reporting.md b/docs/sources/enterprise/reporting.md index 3f373151e2b..08c7cfe0a15 100644 --- a/docs/sources/enterprise/reporting.md +++ b/docs/sources/enterprise/reporting.md @@ -3,7 +3,7 @@ title = "Reporting" description = "" keywords = ["grafana", "reporting"] aliases = ["/docs/grafana/latest/administration/reports"] -weight = 400 +weight = 800 +++ # Reporting @@ -11,6 +11,9 @@ weight = 400 Reporting allows you to automatically generate PDFs from any of your dashboards and have Grafana email them to interested parties on a schedule. > Only available in Grafana Enterprise v6.4+. + +> If you have [Fine-grained access Control]({{< relref "../enterprise/access-control/_index.md" >}}) enabled, for some actions you would need to have relevant permissions. +Refer to specific guides to understand what permissions are required. {{< docs-imagebox img="/img/docs/enterprise/reports_list.png" max-width="500px" class="docs-image--no-shadow" >}} @@ -21,9 +24,13 @@ Any changes you make to a dashboard used in a report are reflected the next time - SMTP must be configured for reports to be sent. Refer to [SMTP]({{< relref "../administration/configuration.md#smtp" >}}) in [Configuration]({{< relref "../administration/configuration.md" >}}) for more information. - The Image Renderer plugin must be installed or the remote rendering service must be set up. Refer to [Image rendering]({{< relref "../administration/image_rendering.md" >}}) for more information. +## Access control + +When [Fine-grained access control]({{< relref "../enterprise/access-control/_index.md" >}}) is enabled, you need to have the relevant [Permissions]({{< relref "../enterprise/access-control/permissions.md" >}}) to create and manage reports. + ## Create or update a report -Currently only Organization Admins can create reports. +Only organization admins can create reports by default. You can customize who can create reports with [fine-grained access control]({{< relref "../enterprise/access-control/_index.md" >}}). 1. Click on the reports icon in the side menu. The Reports tab allows you to view, create, and update your reports. 1. Enter report information. All fields are required unless otherwise indicated. diff --git a/docs/sources/enterprise/request-security.md b/docs/sources/enterprise/request-security.md index b1f6ffc8883..4e6af243d5a 100644 --- a/docs/sources/enterprise/request-security.md +++ b/docs/sources/enterprise/request-security.md @@ -2,7 +2,7 @@ title = "Request security" description = "Grafana Enterprise request security" keywords = ["grafana", "security", "enterprise"] -weight = 110 +weight = 400 +++ # Request security diff --git a/docs/sources/enterprise/saml.md b/docs/sources/enterprise/saml.md index b157a4a0d2d..22a4f7e9d79 100644 --- a/docs/sources/enterprise/saml.md +++ b/docs/sources/enterprise/saml.md @@ -3,7 +3,7 @@ title = "SAML Authentication" description = "Grafana SAML Authentication" keywords = ["grafana", "saml", "documentation", "saml-auth"] aliases = ["/docs/grafana/latest/auth/saml/"] -weight = 500 +weight = 900 +++ # SAML authentication diff --git a/docs/sources/enterprise/team-sync.md b/docs/sources/enterprise/team-sync.md index c9e799c845d..21a44913629 100644 --- a/docs/sources/enterprise/team-sync.md +++ b/docs/sources/enterprise/team-sync.md @@ -3,7 +3,7 @@ title = "Team sync" description = "Grafana Team Sync" keywords = ["grafana", "auth", "documentation"] aliases = ["/docs/grafana/latest/auth/saml/"] -weight = 600 +weight = 1000 +++ # Team sync diff --git a/docs/sources/enterprise/usage-insights/_index.md b/docs/sources/enterprise/usage-insights/_index.md index d21a1eccd6f..f536826d1ec 100644 --- a/docs/sources/enterprise/usage-insights/_index.md +++ b/docs/sources/enterprise/usage-insights/_index.md @@ -3,7 +3,7 @@ title = "Usage insights" description = "Understand how your Grafana instance is used" keywords = ["grafana", "usage-insights", "enterprise"] aliases = ["/docs/grafana/latest/enterprise/usage-insights/"] -weight = 100 +weight = 200 +++ # Usage insights diff --git a/docs/sources/enterprise/vault.md b/docs/sources/enterprise/vault.md index b52b9360836..04462e168e7 100644 --- a/docs/sources/enterprise/vault.md +++ b/docs/sources/enterprise/vault.md @@ -2,7 +2,7 @@ title = "Vault" description = "" keywords = ["grafana", "vault", "configuration"] -weight = 700 +weight = 1200 +++ # Vault integration diff --git a/docs/sources/enterprise/white-labeling.md b/docs/sources/enterprise/white-labeling.md index 05e1589e3f5..e08b8e1fdba 100644 --- a/docs/sources/enterprise/white-labeling.md +++ b/docs/sources/enterprise/white-labeling.md @@ -3,7 +3,7 @@ title = "White labeling" description = "Change the look of Grafana to match your corporate brand" keywords = ["grafana", "white-labeling", "enterprise"] aliases = ["/docs/grafana/latest/enterprise/white-labeling/"] -weight = 700 +weight = 1300 +++ # White labeling diff --git a/docs/sources/http_api/_index.md b/docs/sources/http_api/_index.md index 3dfd57ac015..b3b6cdac669 100644 --- a/docs/sources/http_api/_index.md +++ b/docs/sources/http_api/_index.md @@ -37,6 +37,7 @@ dashboards, creating users and updating data sources. Grafana Enterprise includes all of the Grafana OSS APIs as well as those that follow: +- [Fine-Grained Access Control API]({{< relref "access_control.md" >}}) - [Data Source Permissions API]({{< relref "datasource_permissions.md" >}}) - [External Group Sync API]({{< relref "external_group_sync.md" >}}) - [License API]({{< relref "licensing.md" >}}) diff --git a/docs/sources/http_api/access_control.md b/docs/sources/http_api/access_control.md new file mode 100644 index 00000000000..5a092382f71 --- /dev/null +++ b/docs/sources/http_api/access_control.md @@ -0,0 +1,584 @@ ++++ +title = "Fine-grained access control HTTP API " +description = "Fine-grained access control API" +keywords = ["grafana", "http", "documentation", "api", "fine-grained-access-control", "acl", "enterprise"] +aliases = ["/docs/grafana/latest/http_api/accesscontrol/"] ++++ + +# Fine-grained access control API + +> Fine-grained access control API is only available in Grafana Enterprise. Read more about [Grafana Enterprise]({{< relref "../enterprise" >}}). + +The API can be used to create, update, get and list roles, and create or remove built-in role assignments. +To use the API, you would need to [enable fine-grained access control]({{< relref "../enterprise/access-control/_index.md#enable-fine-grained-access-control" >}}). + +The API does not currently work with an API Token. So in order to use these API endpoints you will have to use [Basic auth]({{< relref "./auth/#basic-auth" >}}). + +## Get status + +`GET /api/access-control/status` + +Returns an indicator to check if fine-grained access control is enabled or not. + +### Required permissions + +Action | Scope +--- | --- | +status:accesscontrol | services:accesscontrol + +#### Example request + +```http +GET /api/access-control/check +Accept: application/json +Content-Type: application/json +``` + +#### Example response + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 + +{ + "enabled": true +} +``` + +#### Status codes + +Code | Description +--- | --- | +200 | Returned a flag indicating if the fine-grained access control is enabled or no. +403 | Access denied +404 | Not found, an indication that fine-grained access control is not available at all. +500 | Unexpected error. Refer to body and/or server logs for more details. + +## Create and manage custom roles + +### Get all roles + +`GET /api/access-control/roles` + +Gets all existing roles. The response contains all global and organization local roles, for the organization which user is signed in. +Refer to the [Role scopes]({{< relref "../enterprise/access-control/roles.md#built-in-role-assignments" >}}) for more information. + +#### Required permissions + +Action | Scope +--- | --- | +roles:list | roles:* + +#### Example request + +```http +GET /api/access-control/roles +Accept: application/json +Content-Type: application/json +``` + +#### Example response + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 + +[ + { + "version": 1, + "uid": "Kz9m_YjGz", + "name": "grafana:roles:reporting:admin:edit", + "description": "Gives access to edit any report or the organization's general reporting settings.", + "global": true, + "updated": "2021-05-13T16:24:26+02:00", + "created": "2021-05-13T16:24:26+02:00" + }, + { + "version": 5, + "uid": "vi9mlLjGz", + "name": "grafana:roles:permissions:admin:read", + "description": "Gives access to read and list roles and permissions, as well as built-in role assignments.", + "global": true, + "updated": "2021-05-13T22:41:49+02:00", + "created": "2021-05-13T16:24:26+02:00" + } +] +``` + +#### Status codes + +Code | Description +--- | --- | +200 | Global and organization local roles are returned. +403 | Access denied +500 | Unexpected error. Refer to body and/or server logs for more details. + +### Get a role + +`GET /api/access-control/roles/:uid` + +Get a role for the given UID. + +#### Required permissions + +Action | Scope +--- | --- | +roles:read | roles:* + +#### Example request + +```http +GET /api/access-control/roles/PYnDO3rMk +Accept: application/json +Content-Type: application/json +``` + +#### Example response + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 + +{ + "version": 2, + "uid": "jZrmlLCGk", + "name": "grafana:roles:permissions:admin:edit", + "description": "Gives access to create, update and delete roles, as well as manage built-in role assignments.", + "global": true, + "permissions": [ + { + "action": "roles:delete", + "scope": "permissions:delegate", + "updated": "2021-05-13T16:24:26+02:00", + "created": "2021-05-13T16:24:26+02:00" + }, + { + "action": "roles:list", + "scope": "roles:*", + "updated": "2021-05-13T16:24:26+02:00", + "created": "2021-05-13T16:24:26+02:00" + } + ], + "updated": "2021-05-13T16:24:26+02:00", + "created": "2021-05-13T16:24:26+02:00" +} +``` + +#### Status codes + +Code | Description +--- | --- | +200 | Role is returned. +403 | Access denied +500 | Unexpected error. Refer to body and/or server logs for more details. + +### Create a new custom role + +`POST /api/access-control/roles` + +Creates a new custom role and maps given permissions to that role. Note that roles with the same prefix as [Predefined Roles]({{< relref "../enterprise/access-control/roles.md" >}}) can't be created. + +#### Required permissions + +`permission:delegate` scope ensures that users can only create custom roles with the same, or a subset of permissions which the user has. +For example, if a user does not have required permissions for creating users, they won't be able to create a custom role which allows to do that. This is done to prevent escalation of privileges. + +Action | Scope +--- | --- | +roles:write | permissions:delegate + +#### Example request + +```http +POST /api/access-control/roles +Accept: application/json +Content-Type: application/json + +{ + "version": 1, + "uid": "jZrmlLCGka", + "name": "custom:delete:roles", + "description": "My custom role which gives users permissions to delete roles", + "global": true, + "permissions": [ + { + "action": "roles:delete", + "scope": "permissions:delegate" + } + ] +} +``` + +#### JSON body schema + +Field Name | Date Type | Required | Description +--- | --- | --- | --- +uid | string | No | UID of the role. If not present, the UID will be automatically created for you and returned in response. Refer to the [Custom roles]({{< relref "../enterprise/access-control/roles.md#custom-roles" >}}) for more information. +global | boolean | No | A flag indicating if the role is global or not. If set to `false`, the default org ID of the authenticated user will be used from the request. Refer to the [Role scopes]({{< relref "../enterprise/access-control/roles.md#role-scopes" >}}) for more information. +version | number | No | Version of the role. If not present, version 0 will be assigned to the role and returned in the response. Refer to the [Custom roles]({{< relref "../enterprise/access-control/roles.md#custom-roles" >}}) for more information. +name | string | Yes | Name of the role. Refer to [Custom roles]({{< relref "../enterprise/access-control/roles.md#custom-roles" >}}) for more information. +description | string | No | Description of the role. +permissions | Permission | No | If not present, the role will be created without any permissions. + +**Permission** + +Field Name | Data Type | Required | Description +--- | --- | --- | --- +action | string | Yes | Refer to [Permissions]({{< relref "../enterprise/access-control/permissions.md" >}}) for full list of available actions. +scope | string | No | If not present, no scope will be mapped to the permission. Refer to [Permissions]({{< relref "../enterprise/access-control/permissions.md#scope-definitions" >}}) for full list of available scopes. + +#### Example response + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 + +{ + "version": 2, + "uid": "jZrmlLCGka", + "name": "custom:delete:create:roles", + "description": "My custom role which gives users permissions to delete and create roles", + "global": true, + "permissions": [ + { + "action": "roles:delete", + "scope": "permissions:delegate", + "updated": "2021-05-13T23:19:46+02:00", + "created": "2021-05-13T23:19:46+02:00" + } + ], + "updated": "2021-05-13T23:20:51.416518+02:00", + "created": "2021-05-13T23:19:46+02:00" +} +``` + +#### Status codes + +Code | Description +--- | --- | +200 | Role is updated. +400 | Bad request (invalid json, missing content-type, missing or invalid fields, etc.). +403 | Access denied +500 | Unexpected error. Refer to body and/or server logs for more details. + +### Update a custom role + +`PUT /api/access-control/roles/:uid` + +Update the role with the given UID, and it's permissions with the given UID. The operation is idempotent and all permissions of the role will be replaced with what is in the request. You would need to increment the version of the role with each update, otherwise the request will fail. + +#### Required permissions + +`permission:delegate` scope ensures that users can only update custom roles with the same, or a subset of permissions which the user has. +For example, if a user does not have required permissions for creating users, they won't be able to update a custom role which allows to do that. This is done to prevent escalation of privileges. + +Action | Scope +--- | --- | +roles:write | permissions:delegate + +#### Example request + +```http +PUT /api/access-control/roles/jZrmlLCGka +Accept: application/json +Content-Type: application/json + +{ + "version": 2, + "name": "custom:delete:create:roles", + "description": "My custom role which gives users permissions to delete and create roles", + "permissions": [ + { + "action": "roles:delete", + "scope": "permissions:delegate" + }, + { + "action": "roles:create", + "scope": "permissions:delegate" + } + ] +} +``` + +#### JSON body schema + +Field Name | Data Type | Required | Description +--- | --- | --- | --- +version | number | Yes | Version of the role. Must be incremented for update to work. +name | string | Yes | Name of the role. +description | string | No | Description of the role. +permissions | List of Permissions | No | The full list of permissions the role should have after the update. + +**Permission** + +Field Name | Data Type | Required | Description +--- | --- | --- | --- +action | string | Yes | Refer to [Permissions]({{< relref "../enterprise/access-control/permissions.md" >}}) for full list of available actions. +scope | string | No | If not present, no scope will be mapped to the permission. Refer to [Permissions]({{< relref "../enterprise/access-control/permissions.md#scope-definitions" >}}) for full list of available scopes. + +#### Example response + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 + +{ + "version": 3, + "name": "custom:delete:create:roles", + "description": "My custom role which gives users permissions to delete and create roles", + "permissions": [ + { + "action": "roles:delete", + "scope": "permissions:delegate", + "updated": "2021-05-13T23:19:46.546146+02:00", + "created": "2021-05-13T23:19:46.546146+02:00" + }, + { + "action": "roles:create", + "scope": "permissions:delegate", + "updated": "2021-05-13T23:19:46.546146+02:00", + "created": "2021-05-13T23:19:46.546146+02:00" + } + ], + "updated": "2021-05-13T23:19:46.540987+02:00", + "created": "2021-05-13T23:19:46.540986+02:00" +} +``` + +#### Status codes + +Code | Description +--- | --- | +200 | Role is updated. +400 | Bad request (invalid json, missing content-type, missing or invalid fields, etc.). +403 | Access denied +404 | Role was not found to update. +500 | Unexpected error. Refer to body and/or server logs for more details. + +### Delete a custom role + +`DELETE /api/access-control/roles/:uid?force=false` + +Delete a role with the given UID, and it's permissions. If the role is assigned to a built-in role, the deletion operation will fail, unless `force` query param is set to `true`, and in that case all assignments will also be deleted. + +#### Required permissions + +`permission:delegate` scope ensures that users can only delete a custom role with the same, or a subset of permissions which the user has. +For example, if a user does not have required permissions for creating users, they won't be able to delete a custom role which allows to do that. + +Action | Scope +--- | --- | +roles:delete | permissions:delegate + +#### Example request + +```http +DELETE /api/access-control/roles/jZrmlLCGka?force=true +Accept: application/json +``` + +#### Query parameters + +Param | Type | Required | Description +--- | --- | --- | --- +force | boolean | No | When set to `true`, the role will be deleted with all it's assignments. + +#### Example response + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 + +{ + "message": "Role deleted" +} +``` + +#### Status codes + +Code | Description +--- | --- | +200 | Role is deleted. +400 | Bad request (invalid json, missing content-type, missing or invalid fields, etc.). +403 | Access denied +500 | Unexpected error. Refer to body and/or server logs for more details. + +## Create and remove built-in role assignments + +API set allows to create or remove [built-in role assignments]({{< relref "../enterprise/access-control/roles.md#built-in-role-assignments" >}}) and list current assignments. + +### Get all built-in role assignments + +`GET /api/access-control/builtin-roles` + +Gets all built-in role assignments. + +#### Required permissions + +Action | Scope +--- | --- | +roles.builtin:list | roles:* + +#### Example request + +```http +GET /api/access-control/builtin-roles +Accept: application/json +Content-Type: application/json +``` + +#### Example response + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 + +{ + "Admin": [ + { + "version": 1, + "uid": "qQui_LCMk", + "name": "grafana:roles:users:org:edit", + "description": "", + "global": true, + "updated": "2021-05-13T16:24:26+02:00", + "created": "2021-05-13T16:24:26+02:00" + }, + { + "version": 1, + "uid": "PeXmlYjMk", + "name": "grafana:roles:users:org:read", + "description": "", + "global": true, + "updated": "2021-05-13T16:24:26+02:00", + "created": "2021-05-13T16:24:26+02:00" + } + ], + "Grafana Admin": [ + { + "version": 1, + "uid": "qQui_LCMk", + "name": "grafana:roles:users:org:edit", + "description": "", + "global": true, + "updated": "2021-05-13T16:24:26+02:00", + "created": "2021-05-13T16:24:26+02:00" + } + ] +} +``` + +#### Status codes + +Code | Description +--- | --- | +200 | Built-in role assignments are returned. +403 | Access denied +500 | Unexpected error. Refer to body and/or server logs for more details. + +### Create a built-in role assignment + +`POST /api/access-control/builtin-roles` + +Creates a new built-in role assignment. + +#### Required permissions + +`permission:delegate` scope ensures that users can only create built-in role assignments with the roles which have same, or a subset of permissions which the user has. +For example, if a user does not have required permissions for creating users, they won't be able to create a built-in role assignment which will allow to do that. This is done to prevent escalation of privileges. + +Action | Scope +--- | --- | +roles.builtin:add | permissions:delegate + +#### Example request + +```http +POST /api/access-control/builtin-roles +Accept: application/json +Content-Type: application/json + +{ + "roleUid": "LPMGN99Mk", + "builtinRole": "Grafana Admin", + "global": false +} +``` + +#### JSON body schema + +Field Name | Date Type | Required | Description +--- | --- | --- | --- +roleUid | string | Yes | UID of the role. +builtinRole | boolean | Yes | Can be one of `Viewer`, `Editor`, `Admin` or `Grafana Admin`. +global | boolean | No | A flag indicating if the assignment is global or not. If set to `false`, the default org ID of the authenticated user will be used from the request to create organization local assignment. Refer to the [Built-in role assignments]({{< relref "../enterprise/access-control/roles.md#built-in-role-assignments" >}}) for more information. + +#### Example response + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 + +{ + "message": "Built-in role grant added" +} +``` + +#### Status codes + +Code | Description +--- | --- | +200 | Role was assigned to built-in role. +400 | Bad request (invalid json, missing content-type, missing or invalid fields, etc.). +403 | Access denied +404 | Role not found +500 | Unexpected error. Refer to body and/or server logs for more details. + +### Remove a built-in role assignment + +`DELETE /api/access-control/builtin-roles/:builtinRole/roles/:roleUID` + +Deletes a built-in role assignment (for one of _Viewer_, _Editor_, _Admin_, or _Grafana Admin_) to the role with the provided UID. + +#### Required permissions + +`permission:delegate` scope ensures that users can only remove built-in role assignments with the roles which have same, or a subset of permissions which the user has. +For example, if a user does not have required permissions for creating users, they won't be able to remove a built-in role assignment which allows to do that. + +Action | Scope +--- | --- | +roles.builtin:remove | permissions:delegate + +#### Example request + +```http +DELETE /api/access-control/builtin-roles/Grafana%20Admin/roles/LPMGN99Mk?global=false +Accept: application/json +``` + +#### Query parameters + +Param | Type | Required | Description +--- | --- | --- | --- +global | boolean | No | A flag indicating if the assignment is global or not. If set to `false`, the default org ID of the authenticated user will be used from the request to remove assignment. Refer to the [Built-in role assignments]({{< relref "../enterprise/access-control/roles.md#built-in-role-assignments" >}}) for more information. + +#### Example response + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 + +{ + "message": "Built-in role grant removed" +} +``` + +#### Status codes + +Code | Description +--- | --- | +200 | Role was unassigned from built-in role. +400 | Bad request (invalid json, missing content-type, missing or invalid fields, etc.). +403 | Access denied +404 | Role not found. +500 | Unexpected error. Refer to body and/or server logs for more details. diff --git a/docs/sources/http_api/admin.md b/docs/sources/http_api/admin.md index ab1851cd5d5..c4e30667e66 100644 --- a/docs/sources/http_api/admin.md +++ b/docs/sources/http_api/admin.md @@ -11,6 +11,9 @@ The Admin HTTP API does not currently work with an API Token. API Tokens are cur the permission of server admin, only users can be given that permission. So in order to use these API calls you will have to use Basic Auth and the Grafana user must have the Grafana Admin permission. (The default admin user is called `admin` and has permission to use this API.) +> If you are running Grafana Enterprise and have [Fine-grained access control]({{< relref "../enterprise/access-control/_index.md" >}}) enabled, for some endpoints you would need to have relevant permissions. +Refer to specific resources to understand what permissions are required. + ## Settings `GET /api/admin/settings` @@ -209,6 +212,14 @@ Content-Type: application/json Create new user. Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. +#### Required permissions + +See note in the [introduction]({{< ref "#admin-api" >}}) for an explanation. + +Action | Scope +--- | --- | +users:create | n/a + **Example Request**: ```http @@ -243,6 +254,14 @@ Content-Type: application/json Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. Change password for a specific user. +#### Required permissions + +See note in the [introduction]({{< ref "#admin-api" >}}) for an explanation. + +Action | Scope +--- | --- | +users.password:update | global:users:* + **Example Request**: ```http @@ -268,6 +287,14 @@ Content-Type: application/json Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. +#### Required permissions + +See note in the [introduction]({{< ref "#admin-api" >}}) for an explanation. + +Action | Scope +--- | --- | +users.permissions:update | global:users:* + **Example Request**: ```http @@ -293,6 +320,14 @@ Content-Type: application/json Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. +#### Required permissions + +See note in the [introduction]({{< ref "#admin-api" >}}) for an explanation. + +Action | Scope +--- | --- | +users:delete | global:users:* + **Example Request**: ```http @@ -353,6 +388,14 @@ Return a list of all auth tokens (devices) that the user currently have logged i Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. +#### Required permissions + +See note in the [introduction]({{< ref "#admin-api" >}}) for an explanation. + +Action | Scope +--- | --- | +users.authtoken:list | global:users:* + **Example Request**: ```http @@ -404,6 +447,14 @@ and will be required to authenticate again upon next activity. Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. +#### Required permissions + +See note in the [introduction]({{< ref "#admin-api" >}}) for an explanation. + +Action | Scope +--- | --- | +users.authtoken:update | global:users:* + **Example Request**: ```http @@ -436,6 +487,14 @@ and will be required to authenticate again upon next activity. Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. +#### Required permissions + +See note in the [introduction]({{< ref "#admin-api" >}}) for an explanation. + +Action | Scope +--- | --- | +users.logout | global:users:* + **Example Request**: ```http @@ -465,12 +524,22 @@ Content-Type: application/json `POST /api/admin/provisioning/notifications/reload` +`POST /api/admin/provisioning/accesscontrol/reload` + Reloads the provisioning config files for specified type and provision entities again. It won't return until the new provisioned entities are already stored in the database. In case of dashboards, it will stop polling for changes in dashboard files and then restart it with new configurations after returning. Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation. +#### Required permissions + +See note in the [introduction]({{< ref "#admin-api" >}}) for an explanation. + +Action | Scope | Provision entity +--- | --- | --- +provisioning:reload | service:accesscontrol | accesscontrol + **Example Request**: ```http diff --git a/docs/sources/http_api/org.md b/docs/sources/http_api/org.md index 95753476ac1..cfd56262e7b 100644 --- a/docs/sources/http_api/org.md +++ b/docs/sources/http_api/org.md @@ -5,13 +5,15 @@ keywords = ["grafana", "http", "documentation", "api", "organization"] aliases = ["/docs/grafana/latest/http_api/organization/"] +++ - # Organization API The Organization HTTP API is divided in two resources, `/api/org` (current organization) and `/api/orgs` (admin organizations). One big difference between these are that the admin of all organizations API only works with basic authentication, see [Admin Organizations API](#admin-organizations-api) for more information. +> If you are running Grafana Enterprise and have [Fine-grained access control]({{< relref "../enterprise/access-control/_index.md" >}}) enabled, for some endpoints you would need to have relevant permissions. +Refer to specific resources to understand what permissions are required. + ## Current Organization API ### Get current Organization @@ -46,6 +48,14 @@ Content-Type: application/json Returns all org users within the current organization. Accessible to users with org admin role. +#### Required permissions + +See note in the [introduction]({{< ref "#organization-api" >}}) for an explanation. + +Action | Scope +--- | --- | +org.users:read | users:* + **Example Request**: ```http @@ -112,6 +122,14 @@ Content-Type: application/json `PATCH /api/org/users/:userId` +#### Required permissions + +See note in the [introduction]({{< ref "#organization-api" >}}) for an explanation. + +Action | Scope +--- | --- | +org.users.role:update | users:* + **Example Request**: ```http @@ -138,6 +156,14 @@ Content-Type: application/json `DELETE /api/org/users/:userId` +#### Required permissions + +See note in the [introduction]({{< ref "#organization-api" >}}) for an explanation. + +Action | Scope +--- | --- | +org.users:remove | users:* + **Example Request**: ```http @@ -188,6 +214,14 @@ Content-Type: application/json Adds a global user to the current organization. +#### Required permissions + +See note in the [introduction]({{< ref "#organization-api" >}}) for an explanation. + +Action | Scope +--- | --- | +org.users:add | users:* + **Example Request**: ```http @@ -407,6 +441,14 @@ Content-Type: application/json Only works with Basic Authentication (username and password), see [introduction](#admin-organizations-api). +#### Required permissions + +See note in the [introduction]({{< ref "#organization-api" >}}) for an explanation. + +Action | Scope +--- | --- | +org.users:read | users:* + **Example Request**: ```http @@ -440,6 +482,14 @@ Content-Type: application/json Only works with Basic Authentication (username and password), see [introduction](#admin-organizations-api). +#### Required permissions + +See note in the [introduction]({{< ref "#organization-api" >}}) for an explanation. + +Action | Scope +--- | --- | +org.users:add | users:* + **Example Request**: ```http @@ -468,6 +518,14 @@ Content-Type: application/json Only works with Basic Authentication (username and password), see [introduction](#admin-organizations-api). +#### Required permissions + +See note in the [introduction]({{< ref "#organization-api" >}}) for an explanation. + +Action | Scope +--- | --- | +org.users.role:update | users:* + **Example Request**: ```http @@ -495,6 +553,14 @@ Content-Type: application/json Only works with Basic Authentication (username and password), see [introduction](#admin-organizations-api). +#### Required permissions + +See note in the [introduction]({{< ref "#organization-api" >}}) for an explanation. + +Action | Scope +--- | --- | +org.users:remove | users:* + **Example Request**: ```http diff --git a/docs/sources/http_api/reporting.md b/docs/sources/http_api/reporting.md index b24bfb3af35..254c58863d5 100644 --- a/docs/sources/http_api/reporting.md +++ b/docs/sources/http_api/reporting.md @@ -11,7 +11,9 @@ This API allows you to interact programmatically with the [Reporting]({{< relref > Reporting is only available in Grafana Enterprise. Read more about [Grafana Enterprise]({{< relref "../enterprise" >}}). - +> If you have [Fine-grained access Control]({{< relref "../enterprise/access-control/_index.md" >}}) enabled, for some endpoints you would need to have relevant permissions. +Refer to specific resources to understand what permissions are required. + ## Send a report > Only available in Grafana Enterprise v7.0+. @@ -22,6 +24,14 @@ This API allows you to interact programmatically with the [Reporting]({{< relref Generate and send a report. This API waits for the report to be generated before returning. We recommend that you set the client's timeout to at least 60 seconds. +#### Required permissions + +See note in the [introduction]({{< ref "#reporting-api" >}}) for an explanation. + +Action | Scope +--- | --- | +reports:send | n/a + ### Example request ```http @@ -63,4 +73,4 @@ Code | Description 401 | Authentication failed, refer to [Authentication API]({{< relref "../http_api/auth.md" >}}). 403 | User is authenticated but is not authorized to generate the report. 404 | Report not found. -500 | Unexpected error or server misconfiguration. Refer to body and/or server logs for more details. +500 | Unexpected error or server misconfiguration. Refer to server logs for more details. diff --git a/docs/sources/http_api/user.md b/docs/sources/http_api/user.md index 963a8a144a1..e3e16070c5c 100644 --- a/docs/sources/http_api/user.md +++ b/docs/sources/http_api/user.md @@ -5,12 +5,23 @@ keywords = ["grafana", "http", "documentation", "api", "user"] aliases = ["/docs/grafana/latest/http_api/user/"] +++ -# User HTTP resources / actions +# User API +> If you are running Grafana Enterprise and have [Fine-grained access control]({{< relref "../enterprise/access-control/_index.md" >}}) enabled, for some endpoints you would need to have relevant permissions. +Refer to specific resources to understand what permissions are required. + ## Search Users `GET /api/users?perpage=10&page=1` +#### Required permissions + +See note in the [introduction]({{< ref "#user-api" >}}) for an explanation. + +Action | Scope +--- | --- | +users:read | global:users:* + **Example Request**: ```http @@ -58,6 +69,14 @@ Content-Type: application/json `GET /api/users/search?perpage=10&page=1&query=mygraf` +#### Required permissions + +See note in the [introduction]({{< ref "#user-api" >}}) for an explanation. + +Action | Scope +--- | --- | +users:read | global:users:* + **Example Request**: ```http @@ -111,6 +130,14 @@ Content-Type: application/json `GET /api/users/:id` +#### Required permissions + +See note in the [introduction]({{< ref "#user-api" >}}) for an explanation. + +Action | Scope +--- | --- | +users:read | users:* + **Example Request**: ```http @@ -148,6 +175,14 @@ Content-Type: application/json `GET /api/users/lookup?loginOrEmail=user@mygraf.com` +#### Required permissions + +See note in the [introduction]({{< ref "#user-api" >}}) for an explanation. + +Action | Scope +--- | --- | +users:read | global:users:* + **Example Request using the email as option**: ```http @@ -195,6 +230,14 @@ Content-Type: application/json `PUT /api/users/:id` +#### Required permissions + +See note in the [introduction]({{< ref "#user-api" >}}) for an explanation. + +Action | Scope +--- | --- | +users:write | users:* + **Example Request**: ```http @@ -226,6 +269,14 @@ Content-Type: application/json `GET /api/users/:id/orgs` +#### Required permissions + +See note in the [introduction]({{< ref "#user-api" >}}) for an explanation. + +Action | Scope +--- | --- | +users:read | users:* + **Example Request**: ```http @@ -256,6 +307,14 @@ Content-Type: application/json `GET /api/users/:id/teams` +#### Required permissions + +See note in the [introduction]({{< ref "#user-api" >}}) for an explanation. + +Action | Scope +--- | --- | +users.teams:read | users:* + **Example Request**: ```http diff --git a/docs/sources/manage-users/_index.md b/docs/sources/manage-users/_index.md index fae95f60625..cae1e05e26c 100644 --- a/docs/sources/manage-users/_index.md +++ b/docs/sources/manage-users/_index.md @@ -9,6 +9,8 @@ Grafana offers several options for grouping users. Each level has different tool One of the most important user management tasks is assigning roles, which govern what [permissions]({{< relref "../permissions/_index.md" >}}) a user has. The correct permissions ensure that users have access to only the resources they need. +> Refer to [Fine-grained access Control]({{< relref "../enterprise/access-control/_index.md" >}}) in Grafana Enterprise to understand how you can manage users with fine-grained permissions. + ## Server The highest and broadest level of user group in Grafana is the server. Every user with an account in a Grafana instance is a member of the server group. diff --git a/docs/sources/permissions/_index.md b/docs/sources/permissions/_index.md index 6f08ead231d..9b7f82fd09f 100644 --- a/docs/sources/permissions/_index.md +++ b/docs/sources/permissions/_index.md @@ -8,6 +8,8 @@ weight = 50 # Permissions +> Refer to [Fine-grained access Control]({{< relref "../enterprise/access-control/_index.md" >}}) in Grafana Enterprise for managing access with fine-grained permissions. + What you can do in Grafana is defined by the _permissions_ associated with your user account. There are three types of permissions: @@ -23,6 +25,8 @@ You can be granted permissions based on: - (Grafana Enterprise) Data source permissions. For more information, refer to [Data source permissions]({{< relref "../enterprise/datasource_permissions.md" >}}) in [Grafana Enterprise]({{< relref "../enterprise" >}}). - (Grafana Cloud) Grafana Cloud has additional roles. For more information, refer to [Grafana Cloud roles and permissions](/docs/grafana-cloud/cloud-portal/cloud-roles/). +If you are running Grafana Enterprise, you can grant access by using fine-grained roles and permissions, refer to [Fine-grained access Control]({{< relref "../enterprise/access-control/_index.md" >}}) for more information. + ## Grafana Server Admin role Grafana server administrators have the **Grafana Admin** flag enabled on their account. They can access the **Server Admin** menu and perform the following tasks: diff --git a/docs/sources/permissions/organization_roles.md b/docs/sources/permissions/organization_roles.md index d4cb187c5c7..85cac7aaf0a 100644 --- a/docs/sources/permissions/organization_roles.md +++ b/docs/sources/permissions/organization_roles.md @@ -7,6 +7,8 @@ weight = 100 # Organization roles +> Refer to [Fine-grained access Control]({{< relref "../enterprise/access-control/_index.md" >}}) in Grafana Enterprise for managing Organization roles with fine-grained permissions. + Users can belong to one or more organizations. A user's organization membership is tied to a role that defines what the user is allowed to do in that organization. Grafana supports multiple _organizations_ in order to support a wide variety of deployment models, including using a single Grafana instance to provide service to multiple potentially untrusted organizations. In most cases, Grafana is deployed with a single organization. @@ -36,6 +38,8 @@ The table below compares what each role can do. Read the sections below for more | Change team settings | x | | | | Configure app plugins | x | | | +If you are running Grafana Enterprise, you can grant and revoke access by using fine-grained roles and permissions, refer to [Fine-grained access Control]({{< relref "../enterprise/access-control/_index.md" >}}) for more information. + ## Organization admin role Can do everything scoped to the organization. For example: diff --git a/docs/sources/permissions/restricting-access.md b/docs/sources/permissions/restricting-access.md index 645e3ab68b3..2eaa6e29f05 100644 --- a/docs/sources/permissions/restricting-access.md +++ b/docs/sources/permissions/restricting-access.md @@ -5,6 +5,8 @@ weight = 500 # Restricting access +> Refer to [Fine-grained access Control]({{< relref "../enterprise/access-control/_index.md" >}}) in Grafana Enterprise to understand how to use fine-grained permissions to restrict access. + The highest permission always wins so if you for example want to hide a folder or dashboard from others you need to remove the **Organization Role** based permission from the Access Control List (ACL). - You cannot override permissions for users with the Organization Admin role. Admins always have access to everything. From 0ee0d65e129893f2ff4ba323813ae2359075e3f5 Mon Sep 17 00:00:00 2001 From: Diana Payton <52059945+oddlittlebird@users.noreply.github.com> Date: Thu, 20 May 2021 14:16:15 -0700 Subject: [PATCH 14/43] Docs: 8.0 panel edit updates (#34533) * Update timeseries.md * Update add-a-panel.md * Update _index.md * Update panel-editor.md * content updates * content edits * moved repeat panels topic * Update getting-started.md * edits * Update DRAFT.md --- docs/sources/basics/timeseries.md | 2 +- .../getting-started/getting-started.md | 7 ++-- docs/sources/linking/panel-links.md | 2 +- docs/sources/panels/DRAFT.md | 40 ++----------------- docs/sources/panels/add-a-panel.md | 38 +++++++++--------- docs/sources/panels/legend-options.md | 2 +- docs/sources/panels/panel-editor.md | 3 +- docs/sources/panels/panel-options.md | 38 ++++++++++++++++++ .../repeat-panels-or-rows.md | 3 +- docs/sources/panels/visualizations/_index.md | 8 ++-- .../shared/panels/panel-links-intro.md | 5 +++ .../shared/panels/repeat-panel-intro.md | 5 +++ 12 files changed, 84 insertions(+), 69 deletions(-) create mode 100644 docs/sources/panels/panel-options.md rename docs/sources/{variables => panels}/repeat-panels-or-rows.md (90%) create mode 100644 docs/sources/shared/panels/panel-links-intro.md create mode 100644 docs/sources/shared/panels/repeat-panel-intro.md diff --git a/docs/sources/basics/timeseries.md b/docs/sources/basics/timeseries.md index bc7b874606a..1348189d3f9 100644 --- a/docs/sources/basics/timeseries.md +++ b/docs/sources/basics/timeseries.md @@ -1,5 +1,5 @@ +++ -title = "Time series" +title = "Intro to time series" description = "Introduction to time series" keywords = ["grafana", "intro", "guide", "concepts", "timeseries"] weight = 400 diff --git a/docs/sources/getting-started/getting-started.md b/docs/sources/getting-started/getting-started.md index b49831fbc77..e70b547599d 100644 --- a/docs/sources/getting-started/getting-started.md +++ b/docs/sources/getting-started/getting-started.md @@ -22,7 +22,7 @@ To log in to Grafana for the first time: 1. Open your web browser and go to http://localhost:3000/. The default HTTP port that Grafana listens to is `3000` unless you have configured a different port. 1. On the login page, enter `admin` for username and password. -1. Click **Log In**. If login is successful, then you will see a prompt to change the password. +1. Click **Log in**. If login is successful, then you will see a prompt to change the password. 1. Click **OK** on the prompt, then change your password. > **Note:** We strongly recommend that you follow Grafana's best practices and change the default administrator password. Don't forget to record your credentials! @@ -31,8 +31,9 @@ To log in to Grafana for the first time: To create your first dashboard: -1. Click the **+** icon on the left panel, select **Create Dashboard**, and then click **Add an empty panel**. -1. In the New Dashboard/Edit Panel view, go to the **Query** tab. +1. Click the **+** icon on the side menu. +1. On the dashboard, click **Add an empty panel**. +1. In the New dashboard/Edit panel view, go to the **Query** tab. 1. Configure your [query]({{< relref "../panels/queries.md" >}}) by selecting ``-- Grafana --`` from the [data source selector]({{< relref "../panels/queries.md/#data-source-selector" >}}). This generates the Random Walk dashboard. 1. Click the **Save** icon in the top right corner of your screen to save the dashboard. 1. Add a descriptive name, and then click **Save**. diff --git a/docs/sources/linking/panel-links.md b/docs/sources/linking/panel-links.md index 4d050bbb4c2..a42f69f446c 100644 --- a/docs/sources/linking/panel-links.md +++ b/docs/sources/linking/panel-links.md @@ -8,7 +8,7 @@ weight = 300 # Panel links -Each panel can have its own set of links that are shown in the upper left corner of the panel. You can link to any available URL, including dashboards, panels, or external sites. You can even control the time range to ensure the user is zoomed in on the right data in Grafana. +{{< docs/shared "panels/panel-links-intro.md" >}} Click the icon on the top left corner of a panel to see available panel links. diff --git a/docs/sources/panels/DRAFT.md b/docs/sources/panels/DRAFT.md index f3a162271ca..81ca648c62c 100644 --- a/docs/sources/panels/DRAFT.md +++ b/docs/sources/panels/DRAFT.md @@ -1,58 +1,24 @@ +++ draft = "true" +description = "A topic to collect my thoughts and plans for Grafana 8.0. Can delete after that." +++ -Task: Add panel - -Default visualization: Time series - - change if you want to, or go to query - -Get data into your panel -- Select data source -- Add query and/or expression -- Transform data -- Troubleshoot - - Query options - - Inspect query - - Table view - -Make it look good -- Change the visualization -- Panel options -- Visualization-specific options -- Thresholds -- Standard options -- Value mappings -- Data links -- Overrides - -Next steps -- Set up alert - TOC # General Add a panel Panel editor - -# Work with your metrics Queries - Share query results -- Mixed data source queries -Expressions +- Mixed data source queries (not yet written) +Expressions (beta) Transformations Inspect a panel - -# Adjust appearance - Panel options Visualization-specific settings (Visualizations) Thresholds Value mappings Data links (can be for all fields or one) - -Visualizations > specific options - Overrides diff --git a/docs/sources/panels/add-a-panel.md b/docs/sources/panels/add-a-panel.md index 2191a78fc21..3229affda15 100644 --- a/docs/sources/panels/add-a-panel.md +++ b/docs/sources/panels/add-a-panel.md @@ -16,30 +16,34 @@ Panels allow you to show your data in visual form. This topic walks you through 1. Click **Add an empty panel**. -Grafana creates an empty graph panel with your default data source selected. +Grafana creates an empty time series panel with your default data source selected. -## 2. Edit panel settings - -While not required, we recommend that you add a helpful title and description to your panel. You can use [variables you have defined]({{< relref "../variables/_index.md" >}}) in either field, but not [global variables]({{< relref "../variables/variable-types/global-variables.md" >}}). - -![](/img/docs/panels/panel-settings-7-0.png) - -**Panel title -** Text entered in this field is displayed at the top of your panel in the panel editor and in the dashboard. - -**Description -** Text entered in this field is displayed in a tooltip in the upper left corner of the panel. Write a description of the panel and the data you are displaying. Pretend you are explaining it to a new user six months from now, when it is no longer fresh in your mind. Future editors (possibly yourself) will thank you. - -## 3. Write a query +## 2. Write a query Each panel needs at least one query to display a visualization. You write queries in the Query tab of the panel editor. For more information about the Query tab, refer to [Queries]({{< relref "queries.md" >}}). 1. Choose a data source. In the first line of the Query tab, click the drop-down list to see all available data sources. This list includes all data sources you added. Refer to [Add a data source]({{< relref "../datasources/add-a-data-source.md" >}}) if you need instructions. 1. Write or construct a query in the query language of your data source. Options will vary. Refer to your specific [data source documentation]({{< relref "../datasources/_index.md" >}}) for specific guidelines. -## 4. Choose a visualization type +## 3. Choose a visualization type -In the Visualization section of the Panel tab, click a visualization type. Grafana displays a preview of your query results with that visualization applied. +In the Visualization list, click a visualization type. Grafana displays a preview of your query results with that visualization applied. -For more information about individual visualizations, refer to [Visualizations]({{< relref "visualizations/_index.md" >}}). +![](/img/docs/panel-editor/select-visualization-8-0.png) + +For more information about individual visualizations, refer to [Visualizations options]({{< relref "visualizations/_index.md" >}}). + +## 4. (Optional) Edit panel settings + +While not required, most visualizations need some adjustment before they properly display the information that you need. Options are defined in the linked topics below. + +- [Panel options]({{< relref "./panel-options.md" >}}) +- [Visualization-specific options]({{< relref "./visualizations/_index.md" >}}) +- [Standard options]({{< relref "./standard-options.md" >}}) +- [Thresholds]({{< relref "./thresholds.md" >}}) +- [Value mappings]({{< relref "./value-mappings.md" >}}) +- [Data links]({{< relref "../linking/data-links.md" >}}) +- [Override fields]({{< relref "field-options/configure-specific-fields.md" >}}) ## 5. Apply changes and save @@ -54,7 +58,5 @@ Our Grafana Fundamentals tutorial is a great place to start, or you can learn mo - Learn more about [panel editor]({{< relref "panel-editor.md" >}}) options. - Add more [queries]({{< relref "queries.md" >}}). - [Transform]({{< relref "transformations/_index.md" >}}) your data. -- [Configure]({{< relref "field-options/_index.md" >}}) how your results are displayed in the visualization. - -- If you made a graph panel, set up an [alert]({{< relref "../alerting/_index.md" >}}). +- Set up an [alert]({{< relref "../alerting/_index.md" >}}). - Create [templates and variables]({{< relref "../variables/_index.md" >}}). diff --git a/docs/sources/panels/legend-options.md b/docs/sources/panels/legend-options.md index cdb6b0637af..469167667e8 100644 --- a/docs/sources/panels/legend-options.md +++ b/docs/sources/panels/legend-options.md @@ -1,7 +1,7 @@ +++ title = "Legend options" aliases = ["/docs/grafana/latest/panels/visualizations/panel-legend/"] -weight = 500 +weight = 950 +++ # Legend options diff --git a/docs/sources/panels/panel-editor.md b/docs/sources/panels/panel-editor.md index 082f8dbdec0..a9c5164fe99 100644 --- a/docs/sources/panels/panel-editor.md +++ b/docs/sources/panels/panel-editor.md @@ -68,7 +68,6 @@ The section contains tabs where you control almost every aspect of how your data Features in these tabs are documented in the following topics: - [Add a panel]({{< relref "add-a-panel.md" >}}) describes basic panel settings. -- [Visualizations]({{< relref "visualizations/_index.md" >}}) display options vary widely. They are described in the individual visualization topic. +- [Visualization]({{< relref "visualizations/_index.md" >}}) options vary widely. They are described in the individual visualization topic. - [Field options and overrides]({{< relref "field-options/_index.md" >}}) allow you to control almost every aspect of your visualization, including units, value mappings, and [Thresholds]({{< relref "thresholds.md" >}}). - [Panel links]({{< relref "../linking/panel-links.md" >}}) and [Data links]({{< relref "../linking/data-links.md" >}}) help you connect your visualization to other resources. - diff --git a/docs/sources/panels/panel-options.md b/docs/sources/panels/panel-options.md new file mode 100644 index 00000000000..eed0433cfa5 --- /dev/null +++ b/docs/sources/panels/panel-options.md @@ -0,0 +1,38 @@ ++++ +title = "Panel options" +weight = 900 ++++ + +# Panel options + +Panel options are common to all panels. They are basic options to add information and clarity to your panels. Fields are described below. + +While not required, we recommend that you add a helpful title and description to all panels. + +![](/img/docs/panels/panel-options-8-0.png) + +## Title + +Text entered in this field is displayed at the top of your panel in the panel editor and in the dashboard. You can use [variables you have defined]({{< relref "../variables/_index.md" >}}) in either field, but not [global variables]({{< relref "../variables/variable-types/global-variables.md" >}}). + +## Description + +Text entered in this field is displayed in a tooltip in the upper left corner of the panel. Write a description of the panel and the data you are displaying. Pretend you are explaining it to a new user six months from now, when it is no longer fresh in your mind. Future editors (possibly yourself) will thank you. + +You can use [variables you have defined]({{< relref "../variables/_index.md" >}}) in either field, but not [global variables]({{< relref "../variables/variable-types/global-variables.md" >}}). + +## Transparent background + +Toggle the transparent background option on your panel display. + +## Panel links + +{{< docs/shared "panels/panel-links-intro.md" >}} + +For more information, refer to [Panel links]({{< relref "../linking/panel-links.md" >}}). + +## Repeat options + +{{< docs/shared "panels/repeat-panels-intro.md" >}} + +For more information, refer to [Repeat panels or rows]({{< relref "./repeat-panels-or-rows.md" >}}). diff --git a/docs/sources/variables/repeat-panels-or-rows.md b/docs/sources/panels/repeat-panels-or-rows.md similarity index 90% rename from docs/sources/variables/repeat-panels-or-rows.md rename to docs/sources/panels/repeat-panels-or-rows.md index 9ec9af93552..5385a0c4051 100644 --- a/docs/sources/variables/repeat-panels-or-rows.md +++ b/docs/sources/panels/repeat-panels-or-rows.md @@ -1,12 +1,13 @@ +++ title = "Repeat panels or rows" keywords = ["grafana", "templating", "documentation", "guide", "template", "variable", "repeat"] +aliases = ["/docs/grafana/latest/variables/repeat-panels-or-rows/"] weight = 800 +++ # Repeat panels or rows -Grafana lets you create dynamic dashboards using _template variables_. All variables in your queries expand to the current value of the variable before the query is sent to the database. Variables let you reuse a single dashboard for all your services. +{{< docs/shared "panels/repeat-panels-intro.md" >}} Template variables can be very useful to dynamically change your queries across a whole dashboard. If you want Grafana to dynamically create new panels or rows based on what values you have selected, you can use the _Repeat_ feature. diff --git a/docs/sources/panels/visualizations/_index.md b/docs/sources/panels/visualizations/_index.md index 2dd61d59e44..3e2a8f33465 100644 --- a/docs/sources/panels/visualizations/_index.md +++ b/docs/sources/panels/visualizations/_index.md @@ -1,12 +1,10 @@ +++ -title = "Visualizations" +title = "Visualization options" weight = 300 +++ -# Visualizations +# Visualization options -Grafana offers a variety of visualizations to suit different use cases. This section of the documentation lists the different visualizations available in Grafana and their unique display settings. - -The default options and their unique display options are described in the pages in this section. +Grafana offers a variety of visualizations to suit different use cases. This section of the documentation lists the different visualizations available in Grafana and their unique options. You can add more panel types with [plugins]({{< relref "../../plugins/_index.md" >}}). diff --git a/docs/sources/shared/panels/panel-links-intro.md b/docs/sources/shared/panels/panel-links-intro.md new file mode 100644 index 00000000000..8f3980daed4 --- /dev/null +++ b/docs/sources/shared/panels/panel-links-intro.md @@ -0,0 +1,5 @@ +--- +title: Panel links intro +--- + +Each panel can have its own set of links that are shown in the upper left corner of the panel. You can link to any available URL, including dashboards, panels, or external sites. You can even control the time range to ensure the user is zoomed in on the right data in Grafana. \ No newline at end of file diff --git a/docs/sources/shared/panels/repeat-panel-intro.md b/docs/sources/shared/panels/repeat-panel-intro.md new file mode 100644 index 00000000000..effd55c5468 --- /dev/null +++ b/docs/sources/shared/panels/repeat-panel-intro.md @@ -0,0 +1,5 @@ +--- +title: Repeat panel intro +--- + +Grafana lets you create dynamic dashboards using _template variables_. All variables in your queries expand to the current value of the variable before the query is sent to the database. Variables let you reuse a single dashboard for all your services. \ No newline at end of file From 1e024f22b8f767da01c9322f489d7b71aeec19c3 Mon Sep 17 00:00:00 2001 From: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> Date: Thu, 20 May 2021 17:45:07 -0400 Subject: [PATCH 15/43] Added singlestat deprecation notice. (#34534) * Added singlestat deprecation notice. * removed extra space. --- docs/sources/whatsnew/whats-new-in-v8-0.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/sources/whatsnew/whats-new-in-v8-0.md b/docs/sources/whatsnew/whats-new-in-v8-0.md index 993616fd9a8..202f8d1fd40 100644 --- a/docs/sources/whatsnew/whats-new-in-v8-0.md +++ b/docs/sources/whatsnew/whats-new-in-v8-0.md @@ -181,6 +181,11 @@ You can now configure generic OAuth with empty scopes. This allows OAuth Identit You can now configure generic OAuth with strict parsing of the `role_attribute_path`. By default, if th `role_attribute_path` property does not return a role, then the user is assigned the `Viewer` role. You can disable the role assignment by setting `role_attribute_strict = true`. It denies user access if no role or an invalid role is returned. +#### Singlestat panel deprecated + +Support for Singlestat panel has been discontinued. When you upgrade to version 8.0, all existing Singlestat panels automatically becomes Stat panels. +Stat panel is available as plugin. + ## Enterprise features These features are included in the Grafana Enterprise edition. From 11b2f0ee4d3f2d22a0a93cbb0fe926a24933b0db Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 20 May 2021 16:03:23 -0700 Subject: [PATCH 16/43] Timeline: use full row height with one series (#34532) --- public/app/plugins/panel/state-timeline/TimelineChart.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/public/app/plugins/panel/state-timeline/TimelineChart.tsx b/public/app/plugins/panel/state-timeline/TimelineChart.tsx index 3fb00b81571..e78042e3748 100755 --- a/public/app/plugins/panel/state-timeline/TimelineChart.tsx +++ b/public/app/plugins/panel/state-timeline/TimelineChart.tsx @@ -42,6 +42,9 @@ export class TimelineChart extends React.Component { getTimeRange, eventBus, ...this.props, + + // When there is only one row, use the full space + rowHeight: alignedFrame.fields.length > 2 ? this.props.rowHeight : 1, }); }; From abe5c06d69902fc85e4ade9657478cc69cd47f2a Mon Sep 17 00:00:00 2001 From: Matthew Turland Date: Sat, 22 May 2021 11:59:49 -0500 Subject: [PATCH 17/43] Fix Quick Start link on Geting Started Influx page (#34549) Square brackets were used for the URL instead of parentheses, causing the markup to be displayed as literal text. --- docs/sources/getting-started/getting-started-influxdb.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/getting-started/getting-started-influxdb.md b/docs/sources/getting-started/getting-started-influxdb.md index d5126ad4cf1..b8cddfb6075 100644 --- a/docs/sources/getting-started/getting-started-influxdb.md +++ b/docs/sources/getting-started/getting-started-influxdb.md @@ -25,7 +25,7 @@ If you chose to use InfluxDB Cloud, then you should [download and install the In ## Step 4. Get data into InfluxDB -If you downloaded and installed InfluxDB on your local machine, then use the [Quick Start][https://docs.influxdata.com/influxdb/v2.0/write-data/#quick-start-for-influxdb-oss] feature to visualize InfluxDB metrics. +If you downloaded and installed InfluxDB on your local machine, then use the [Quick Start](https://docs.influxdata.com/influxdb/v2.0/write-data/#quick-start-for-influxdb-oss) feature to visualize InfluxDB metrics. If you are using the cloud account, then the wizards will guide you through the initial process. For more information, refer to [Configure Telegraf](https://docs.influxdata.com/influxdb/cloud/write-data/no-code/use-telegraf/#configure-telegraf). From 7204a6471767371bd3656c33b34ad87c81f99c24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Mon, 24 May 2021 06:11:01 +0200 Subject: [PATCH 18/43] LibraryElements: Creates usage stats for panels and variables (#34476) * LibraryPanels: Adds usage collection * Refactor: renames Panel and Variable consts * Chore: initialize stats * Refactor: moves library element migrations to migration namespace --- pkg/infra/metrics/metrics.go | 20 +++++++ pkg/infra/usagestats/usage_stats.go | 4 ++ pkg/infra/usagestats/usage_stats_test.go | 4 ++ pkg/models/libraryelements.go | 13 +++++ pkg/models/stats.go | 2 + pkg/services/libraryelements/database.go | 18 +++---- pkg/services/libraryelements/guard.go | 6 +-- .../libraryelements/libraryelements.go | 51 ------------------ .../libraryelements_create_test.go | 6 ++- .../libraryelements_get_all_test.go | 50 ++++++++--------- .../libraryelements_get_test.go | 4 +- .../libraryelements_patch_test.go | 32 +++++------ .../libraryelements_permissions_test.go | 10 ++-- .../libraryelements/libraryelements_test.go | 6 +-- pkg/services/libraryelements/models.go | 7 --- pkg/services/libraryelements/writers.go | 3 +- pkg/services/librarypanels/librarypanels.go | 2 +- .../librarypanels/librarypanels_test.go | 4 +- .../sqlstore/migrations/libraryelements.go | 53 +++++++++++++++++++ .../sqlstore/migrations/migrations.go | 1 + pkg/services/sqlstore/stats.go | 2 + pkg/services/sqlstore/stats_test.go | 2 + 22 files changed, 174 insertions(+), 126 deletions(-) create mode 100644 pkg/models/libraryelements.go create mode 100644 pkg/services/sqlstore/migrations/libraryelements.go diff --git a/pkg/infra/metrics/metrics.go b/pkg/infra/metrics/metrics.go index cb81d98c7d0..e325a75d126 100644 --- a/pkg/infra/metrics/metrics.go +++ b/pkg/infra/metrics/metrics.go @@ -185,6 +185,12 @@ var ( grafanaBuildVersion *prometheus.GaugeVec grafanaPluginBuildInfoDesc *prometheus.GaugeVec + + // StatsTotalLibraryPanels is a metric of total number of library panels stored in Grafana. + StatsTotalLibraryPanels prometheus.Gauge + + // StatsTotalLibraryVariables is a metric of total number of library variables stored in Grafana. + StatsTotalLibraryVariables prometheus.Gauge ) func init() { @@ -547,6 +553,18 @@ func init() { Help: "number of evaluation calls", Namespace: ExporterName, }) + + StatsTotalLibraryPanels = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "stat_totals_library_panels", + Help: "total amount of library panels in the database", + Namespace: ExporterName, + }) + + StatsTotalLibraryVariables = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "stat_totals_library_variables", + Help: "total amount of library variables in the database", + Namespace: ExporterName, + }) } // SetBuildInformation sets the build information for this binary @@ -640,6 +658,8 @@ func initMetricVars() { StatsTotalDashboardVersions, StatsTotalAnnotations, MAccessEvaluationCount, + StatsTotalLibraryPanels, + StatsTotalLibraryVariables, ) } diff --git a/pkg/infra/usagestats/usage_stats.go b/pkg/infra/usagestats/usage_stats.go index d2e5c78879f..2b43583acd0 100644 --- a/pkg/infra/usagestats/usage_stats.go +++ b/pkg/infra/usagestats/usage_stats.go @@ -72,6 +72,8 @@ func (uss *UsageStatsService) GetUsageReport(ctx context.Context) (UsageReport, metrics["stats.dashboard_versions.count"] = statsQuery.Result.DashboardVersions metrics["stats.annotations.count"] = statsQuery.Result.Annotations metrics["stats.alert_rules.count"] = statsQuery.Result.AlertRules + metrics["stats.library_panels.count"] = statsQuery.Result.LibraryPanels + metrics["stats.library_variables.count"] = statsQuery.Result.LibraryVariables validLicCount := 0 if uss.License.HasValidLicense() { validLicCount = 1 @@ -317,6 +319,8 @@ func (uss *UsageStatsService) updateTotalStats() { metrics.StatsTotalDashboardVersions.Set(float64(statsQuery.Result.DashboardVersions)) metrics.StatsTotalAnnotations.Set(float64(statsQuery.Result.Annotations)) metrics.StatsTotalAlertRules.Set(float64(statsQuery.Result.AlertRules)) + metrics.StatsTotalLibraryPanels.Set(float64(statsQuery.Result.LibraryPanels)) + metrics.StatsTotalLibraryVariables.Set(float64(statsQuery.Result.LibraryVariables)) dsStats := models.GetDataSourceStatsQuery{} if err := uss.Bus.Dispatch(&dsStats); err != nil { diff --git a/pkg/infra/usagestats/usage_stats_test.go b/pkg/infra/usagestats/usage_stats_test.go index 4e068704256..3a1125d505d 100644 --- a/pkg/infra/usagestats/usage_stats_test.go +++ b/pkg/infra/usagestats/usage_stats_test.go @@ -63,6 +63,8 @@ func TestMetrics(t *testing.T) { DashboardVersions: 16, Annotations: 17, AlertRules: 18, + LibraryPanels: 19, + LibraryVariables: 20, } getSystemStatsQuery = query return nil @@ -313,6 +315,8 @@ func TestMetrics(t *testing.T) { assert.Equal(t, 16, metrics.Get("stats.dashboard_versions.count").MustInt()) assert.Equal(t, 17, metrics.Get("stats.annotations.count").MustInt()) assert.Equal(t, 18, metrics.Get("stats.alert_rules.count").MustInt()) + assert.Equal(t, 19, metrics.Get("stats.library_panels.count").MustInt()) + assert.Equal(t, 20, metrics.Get("stats.library_variables.count").MustInt()) assert.Equal(t, 9, metrics.Get("stats.ds."+models.DS_ES+".count").MustInt()) assert.Equal(t, 10, metrics.Get("stats.ds."+models.DS_PROMETHEUS+".count").MustInt()) diff --git a/pkg/models/libraryelements.go b/pkg/models/libraryelements.go new file mode 100644 index 00000000000..9a989add8db --- /dev/null +++ b/pkg/models/libraryelements.go @@ -0,0 +1,13 @@ +package models + +// LibraryElementKind is used for the kind of library element +type LibraryElementKind int + +const ( + // PanelElement is used for library elements that are of the Panel kind + PanelElement LibraryElementKind = iota + 1 + // VariableElement is used for library elements that are of the Variable kind + VariableElement +) + +const LibraryElementConnectionTableName = "library_element_connection" diff --git a/pkg/models/stats.go b/pkg/models/stats.go index 640eb535d48..4387f6e86e7 100644 --- a/pkg/models/stats.go +++ b/pkg/models/stats.go @@ -19,6 +19,8 @@ type SystemStats struct { DashboardVersions int64 Annotations int64 AlertRules int64 + LibraryPanels int64 + LibraryVariables int64 Admins int Editors int diff --git a/pkg/services/libraryelements/database.go b/pkg/services/libraryelements/database.go index fa377f029c5..9a12d6c5780 100644 --- a/pkg/services/libraryelements/database.go +++ b/pkg/services/libraryelements/database.go @@ -22,7 +22,7 @@ SELECT DISTINCT , u1.email AS created_by_email , u2.login AS updated_by_name , u2.email AS updated_by_email - , (SELECT COUNT(connection_id) FROM ` + connectionTableName + ` WHERE element_id = le.id AND kind=1) AS connected_dashboards` + , (SELECT COUNT(connection_id) FROM ` + models.LibraryElementConnectionTableName + ` WHERE element_id = le.id AND kind=1) AS connected_dashboards` ) func getFromLibraryElementDTOWithMeta(dialect migrator.Dialect) string { @@ -41,9 +41,9 @@ func syncFieldsWithModel(libraryElement *LibraryElement) error { return err } - if LibraryElementKind(libraryElement.Kind) == Panel { + if models.LibraryElementKind(libraryElement.Kind) == models.PanelElement { model["title"] = libraryElement.Name - } else if LibraryElementKind(libraryElement.Kind) == Variable { + } else if models.LibraryElementKind(libraryElement.Kind) == models.VariableElement { model["name"] = libraryElement.Name } if model["type"] != nil { @@ -520,7 +520,7 @@ func (l *LibraryElementService) getConnections(c *models.ReqContext, uid string) var libraryElementConnections []libraryElementConnectionWithMeta builder := sqlstore.SQLBuilder{} builder.Write("SELECT lec.*, u1.login AS created_by_name, u1.email AS created_by_email") - builder.Write(" FROM " + connectionTableName + " AS lec") + builder.Write(" FROM " + models.LibraryElementConnectionTableName + " AS lec") builder.Write(" LEFT JOIN " + l.SQLStore.Dialect.Quote("user") + " AS u1 ON lec.created_by = u1.id") builder.Write(" INNER JOIN dashboard AS dashboard on lec.connection_id = dashboard.id") builder.Write(` WHERE lec.element_id=?`, element.ID) @@ -562,7 +562,7 @@ func (l *LibraryElementService) getElementsForDashboardID(c *models.ReqContext, ", coalesce(dashboard.uid, '') AS folder_uid" + getFromLibraryElementDTOWithMeta(l.SQLStore.Dialect) + " LEFT JOIN dashboard AS dashboard ON dashboard.id = le.folder_id" + - " INNER JOIN " + connectionTableName + " AS lce ON lce.element_id = le.id AND lce.kind=1 AND lce.connection_id=?" + " INNER JOIN " + models.LibraryElementConnectionTableName + " AS lce ON lce.element_id = le.id AND lce.kind=1 AND lce.connection_id=?" sess := session.SQL(sql, dashboardID) err := sess.Find(&libraryElements) if err != nil { @@ -610,7 +610,7 @@ func (l *LibraryElementService) getElementsForDashboardID(c *models.ReqContext, // connectElementsToDashboardID adds connections for all elements Library Elements in a Dashboard. func (l *LibraryElementService) connectElementsToDashboardID(c *models.ReqContext, elementUIDs []string, dashboardID int64) error { err := l.SQLStore.WithTransactionalDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error { - _, err := session.Exec("DELETE FROM "+connectionTableName+" WHERE kind=1 AND connection_id=?", dashboardID) + _, err := session.Exec("DELETE FROM "+models.LibraryElementConnectionTableName+" WHERE kind=1 AND connection_id=?", dashboardID) if err != nil { return err } @@ -646,7 +646,7 @@ func (l *LibraryElementService) connectElementsToDashboardID(c *models.ReqContex // disconnectElementsFromDashboardID deletes connections for all Library Elements in a Dashboard. func (l *LibraryElementService) disconnectElementsFromDashboardID(c *models.ReqContext, dashboardID int64) error { return l.SQLStore.WithTransactionalDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error { - _, err := session.Exec("DELETE FROM "+connectionTableName+" WHERE kind=1 AND connection_id=?", dashboardID) + _, err := session.Exec("DELETE FROM "+models.LibraryElementConnectionTableName+" WHERE kind=1 AND connection_id=?", dashboardID) if err != nil { return err } @@ -676,7 +676,7 @@ func (l *LibraryElementService) deleteLibraryElementsInFolderUID(c *models.ReqCo ConnectionID int64 `xorm:"connection_id"` } sql := "SELECT lec.connection_id FROM library_element AS le" - sql += " INNER JOIN " + connectionTableName + " AS lec on le.id = lec.element_id" + sql += " INNER JOIN " + models.LibraryElementConnectionTableName + " AS lec on le.id = lec.element_id" sql += " WHERE le.folder_id=? AND le.org_id=?" err = session.SQL(sql, folderID, c.SignedInUser.OrgId).Find(&connectionIDs) if err != nil { @@ -694,7 +694,7 @@ func (l *LibraryElementService) deleteLibraryElementsInFolderUID(c *models.ReqCo return err } for _, elementID := range elementIDs { - _, err := session.Exec("DELETE FROM "+connectionTableName+" WHERE element_id=?", elementID.ID) + _, err := session.Exec("DELETE FROM "+models.LibraryElementConnectionTableName+" WHERE element_id=?", elementID.ID) if err != nil { return err } diff --git a/pkg/services/libraryelements/guard.go b/pkg/services/libraryelements/guard.go index c5d41295c0c..980eb6cb3f7 100644 --- a/pkg/services/libraryelements/guard.go +++ b/pkg/services/libraryelements/guard.go @@ -11,11 +11,11 @@ func isGeneralFolder(folderID int64) bool { } func (l *LibraryElementService) requireSupportedElementKind(kindAsInt int64) error { - kind := LibraryElementKind(kindAsInt) + kind := models.LibraryElementKind(kindAsInt) switch kind { - case Panel: + case models.PanelElement: return nil - case Variable: + case models.VariableElement: return nil default: return errLibraryElementUnSupportedElementKind diff --git a/pkg/services/libraryelements/libraryelements.go b/pkg/services/libraryelements/libraryelements.go index 3fc2eafb194..3fff1d6364c 100644 --- a/pkg/services/libraryelements/libraryelements.go +++ b/pkg/services/libraryelements/libraryelements.go @@ -6,7 +6,6 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/services/sqlstore" - "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/setting" ) @@ -27,8 +26,6 @@ type LibraryElementService struct { log log.Logger } -const connectionTableName = "library_element_connection" - func init() { registry.RegisterService(&LibraryElementService{}) } @@ -66,51 +63,3 @@ func (l *LibraryElementService) DisconnectElementsFromDashboard(c *models.ReqCon func (l *LibraryElementService) DeleteLibraryElementsInFolder(c *models.ReqContext, folderUID string) error { return l.deleteLibraryElementsInFolderUID(c, folderUID) } - -// AddMigration defines database migrations. -// If Panel Library is not enabled does nothing. -func (l *LibraryElementService) AddMigration(mg *migrator.Migrator) { - libraryElementsV1 := migrator.Table{ - Name: "library_element", - Columns: []*migrator.Column{ - {Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, - {Name: "org_id", Type: migrator.DB_BigInt, Nullable: false}, - {Name: "folder_id", Type: migrator.DB_BigInt, Nullable: false}, - {Name: "uid", Type: migrator.DB_NVarchar, Length: 40, Nullable: false}, - {Name: "name", Type: migrator.DB_NVarchar, Length: 150, Nullable: false}, - {Name: "kind", Type: migrator.DB_BigInt, Nullable: false}, - {Name: "type", Type: migrator.DB_NVarchar, Length: 40, Nullable: false}, - {Name: "description", Type: migrator.DB_NVarchar, Length: 255, Nullable: false}, - {Name: "model", Type: migrator.DB_Text, Nullable: false}, - {Name: "created", Type: migrator.DB_DateTime, Nullable: false}, - {Name: "created_by", Type: migrator.DB_BigInt, Nullable: false}, - {Name: "updated", Type: migrator.DB_DateTime, Nullable: false}, - {Name: "updated_by", Type: migrator.DB_BigInt, Nullable: false}, - {Name: "version", Type: migrator.DB_BigInt, Nullable: false}, - }, - Indices: []*migrator.Index{ - {Cols: []string{"org_id", "folder_id", "name", "kind"}, Type: migrator.UniqueIndex}, - }, - } - - mg.AddMigration("create library_element table v1", migrator.NewAddTableMigration(libraryElementsV1)) - mg.AddMigration("add index library_element org_id-folder_id-name-kind", migrator.NewAddIndexMigration(libraryElementsV1, libraryElementsV1.Indices[0])) - - libraryElementConnectionV1 := migrator.Table{ - Name: connectionTableName, - Columns: []*migrator.Column{ - {Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, - {Name: "element_id", Type: migrator.DB_BigInt, Nullable: false}, - {Name: "kind", Type: migrator.DB_BigInt, Nullable: false}, - {Name: "connection_id", Type: migrator.DB_BigInt, Nullable: false}, - {Name: "created", Type: migrator.DB_DateTime, Nullable: false}, - {Name: "created_by", Type: migrator.DB_BigInt, Nullable: false}, - }, - Indices: []*migrator.Index{ - {Cols: []string{"element_id", "kind", "connection_id"}, Type: migrator.UniqueIndex}, - }, - } - - mg.AddMigration("create "+connectionTableName+" table v1", migrator.NewAddTableMigration(libraryElementConnectionV1)) - mg.AddMigration("add index "+connectionTableName+" element_id-kind-connection_id", migrator.NewAddIndexMigration(libraryElementConnectionV1, libraryElementConnectionV1.Indices[0])) -} diff --git a/pkg/services/libraryelements/libraryelements_create_test.go b/pkg/services/libraryelements/libraryelements_create_test.go index 1dce51efe0a..e200c471be0 100644 --- a/pkg/services/libraryelements/libraryelements_create_test.go +++ b/pkg/services/libraryelements/libraryelements_create_test.go @@ -5,6 +5,8 @@ import ( "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/models" ) func TestCreateLibraryElement(t *testing.T) { @@ -24,7 +26,7 @@ func TestCreateLibraryElement(t *testing.T) { FolderID: 1, UID: sc.initialResult.Result.UID, Name: "Text - Library Panel", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "text", Description: "A description", Model: map[string]interface{}{ @@ -69,7 +71,7 @@ func TestCreateLibraryElement(t *testing.T) { FolderID: 1, UID: result.Result.UID, Name: "Library Panel Name", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "text", Description: "A description", Model: map[string]interface{}{ diff --git a/pkg/services/libraryelements/libraryelements_get_all_test.go b/pkg/services/libraryelements/libraryelements_get_all_test.go index a20740f0c49..2358b18a21c 100644 --- a/pkg/services/libraryelements/libraryelements_get_all_test.go +++ b/pkg/services/libraryelements/libraryelements_get_all_test.go @@ -42,7 +42,7 @@ func TestGetAllLibraryElements(t *testing.T) { err := sc.reqContext.Req.ParseForm() require.NoError(t, err) - sc.reqContext.Req.Form.Add("kind", strconv.FormatInt(int64(Panel), 10)) + sc.reqContext.Req.Form.Add("kind", strconv.FormatInt(int64(models.PanelElement), 10)) resp = sc.service.getAllHandler(sc.reqContext) require.Equal(t, 200, resp.Status()) @@ -62,7 +62,7 @@ func TestGetAllLibraryElements(t *testing.T) { FolderID: 1, UID: result.Result.Elements[0].UID, Name: "Text - Library Panel", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "text", Description: "A description", Model: map[string]interface{}{ @@ -107,7 +107,7 @@ func TestGetAllLibraryElements(t *testing.T) { err := sc.reqContext.Req.ParseForm() require.NoError(t, err) - sc.reqContext.Req.Form.Add("kind", strconv.FormatInt(int64(Variable), 10)) + sc.reqContext.Req.Form.Add("kind", strconv.FormatInt(int64(models.VariableElement), 10)) resp = sc.service.getAllHandler(sc.reqContext) require.Equal(t, 200, resp.Status()) @@ -127,7 +127,7 @@ func TestGetAllLibraryElements(t *testing.T) { FolderID: 1, UID: result.Result.Elements[0].UID, Name: "query0", - Kind: int64(Variable), + Kind: int64(models.VariableElement), Type: "query", Description: "A description", Model: map[string]interface{}{ @@ -187,7 +187,7 @@ func TestGetAllLibraryElements(t *testing.T) { FolderID: 1, UID: result.Result.Elements[0].UID, Name: "Text - Library Panel", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "text", Description: "A description", Model: map[string]interface{}{ @@ -222,7 +222,7 @@ func TestGetAllLibraryElements(t *testing.T) { FolderID: 1, UID: result.Result.Elements[1].UID, Name: "Text - Library Panel2", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "text", Description: "A description", Model: map[string]interface{}{ @@ -286,7 +286,7 @@ func TestGetAllLibraryElements(t *testing.T) { FolderID: 1, UID: result.Result.Elements[0].UID, Name: "Text - Library Panel2", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "text", Description: "A description", Model: map[string]interface{}{ @@ -321,7 +321,7 @@ func TestGetAllLibraryElements(t *testing.T) { FolderID: 1, UID: result.Result.Elements[1].UID, Name: "Text - Library Panel", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "text", Description: "A description", Model: map[string]interface{}{ @@ -360,7 +360,7 @@ func TestGetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all library panels and two exist and typeFilter is set to existing types, it should succeed and the result should be correct", func(t *testing.T, sc scenarioContext) { - command := getCreateCommandWithModel(sc.folder.Id, "Gauge - Library Panel", Panel, []byte(` + command := getCreateCommandWithModel(sc.folder.Id, "Gauge - Library Panel", models.PanelElement, []byte(` { "datasource": "${DS_GDEV-TESTDATA}", "id": 1, @@ -372,7 +372,7 @@ func TestGetAllLibraryElements(t *testing.T) { resp := sc.service.createHandler(sc.reqContext, command) require.Equal(t, 200, resp.Status()) - command = getCreateCommandWithModel(sc.folder.Id, "BarGauge - Library Panel", Panel, []byte(` + command = getCreateCommandWithModel(sc.folder.Id, "BarGauge - Library Panel", models.PanelElement, []byte(` { "datasource": "${DS_GDEV-TESTDATA}", "id": 1, @@ -405,7 +405,7 @@ func TestGetAllLibraryElements(t *testing.T) { FolderID: 1, UID: result.Result.Elements[0].UID, Name: "BarGauge - Library Panel", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "bargauge", Description: "BarGauge description", Model: map[string]interface{}{ @@ -440,7 +440,7 @@ func TestGetAllLibraryElements(t *testing.T) { FolderID: 1, UID: result.Result.Elements[1].UID, Name: "Gauge - Library Panel", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "gauge", Description: "Gauge description", Model: map[string]interface{}{ @@ -479,7 +479,7 @@ func TestGetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all library panels and two exist and typeFilter is set to a nonexistent type, it should succeed and the result should be correct", func(t *testing.T, sc scenarioContext) { - command := getCreateCommandWithModel(sc.folder.Id, "Gauge - Library Panel", Panel, []byte(` + command := getCreateCommandWithModel(sc.folder.Id, "Gauge - Library Panel", models.PanelElement, []byte(` { "datasource": "${DS_GDEV-TESTDATA}", "id": 1, @@ -542,7 +542,7 @@ func TestGetAllLibraryElements(t *testing.T) { FolderID: newFolder.Id, UID: result.Result.Elements[0].UID, Name: "Text - Library Panel2", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "text", Description: "A description", Model: map[string]interface{}{ @@ -637,7 +637,7 @@ func TestGetAllLibraryElements(t *testing.T) { FolderID: 1, UID: result.Result.Elements[0].UID, Name: "Text - Library Panel", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "text", Description: "A description", Model: map[string]interface{}{ @@ -672,7 +672,7 @@ func TestGetAllLibraryElements(t *testing.T) { FolderID: 1, UID: result.Result.Elements[1].UID, Name: "Text - Library Panel2", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "text", Description: "A description", Model: map[string]interface{}{ @@ -736,7 +736,7 @@ func TestGetAllLibraryElements(t *testing.T) { FolderID: 1, UID: result.Result.Elements[0].UID, Name: "Text - Library Panel2", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "text", Description: "A description", Model: map[string]interface{}{ @@ -800,7 +800,7 @@ func TestGetAllLibraryElements(t *testing.T) { FolderID: 1, UID: result.Result.Elements[0].UID, Name: "Text - Library Panel", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "text", Description: "A description", Model: map[string]interface{}{ @@ -865,7 +865,7 @@ func TestGetAllLibraryElements(t *testing.T) { FolderID: 1, UID: result.Result.Elements[0].UID, Name: "Text - Library Panel2", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "text", Description: "A description", Model: map[string]interface{}{ @@ -904,7 +904,7 @@ func TestGetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all library panels and two exist and searchString exists in the description, it should succeed and the result should be correct", func(t *testing.T, sc scenarioContext) { - command := getCreateCommandWithModel(sc.folder.Id, "Text - Library Panel2", Panel, []byte(` + command := getCreateCommandWithModel(sc.folder.Id, "Text - Library Panel2", models.PanelElement, []byte(` { "datasource": "${DS_GDEV-TESTDATA}", "id": 1, @@ -939,7 +939,7 @@ func TestGetAllLibraryElements(t *testing.T) { FolderID: 1, UID: result.Result.Elements[0].UID, Name: "Text - Library Panel", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "text", Description: "A description", Model: map[string]interface{}{ @@ -978,7 +978,7 @@ func TestGetAllLibraryElements(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get all library panels and two exist and searchString exists in both name and description, it should succeed and the result should be correct", func(t *testing.T, sc scenarioContext) { - command := getCreateCommandWithModel(sc.folder.Id, "Some Other", Panel, []byte(` + command := getCreateCommandWithModel(sc.folder.Id, "Some Other", models.PanelElement, []byte(` { "datasource": "${DS_GDEV-TESTDATA}", "id": 1, @@ -1011,7 +1011,7 @@ func TestGetAllLibraryElements(t *testing.T) { FolderID: 1, UID: result.Result.Elements[0].UID, Name: "Some Other", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "text", Description: "A Library Panel", Model: map[string]interface{}{ @@ -1046,7 +1046,7 @@ func TestGetAllLibraryElements(t *testing.T) { FolderID: 1, UID: result.Result.Elements[1].UID, Name: "Text - Library Panel", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "text", Description: "A description", Model: map[string]interface{}{ @@ -1112,7 +1112,7 @@ func TestGetAllLibraryElements(t *testing.T) { FolderID: 1, UID: result.Result.Elements[0].UID, Name: "Text - Library Panel2", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "text", Description: "A description", Model: map[string]interface{}{ diff --git a/pkg/services/libraryelements/libraryelements_get_test.go b/pkg/services/libraryelements/libraryelements_get_test.go index 4f0218dceff..34e5d58bd1c 100644 --- a/pkg/services/libraryelements/libraryelements_get_test.go +++ b/pkg/services/libraryelements/libraryelements_get_test.go @@ -35,7 +35,7 @@ func TestGetLibraryElement(t *testing.T) { FolderID: 1, UID: res.Result.UID, Name: "Text - Library Panel", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "text", Description: "A description", Model: map[string]interface{}{ @@ -130,7 +130,7 @@ func TestGetLibraryElement(t *testing.T) { FolderID: 1, UID: res.Result.UID, Name: "Text - Library Panel", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "text", Description: "A description", Model: map[string]interface{}{ diff --git a/pkg/services/libraryelements/libraryelements_patch_test.go b/pkg/services/libraryelements/libraryelements_patch_test.go index 7af2290db0e..2bda2b43019 100644 --- a/pkg/services/libraryelements/libraryelements_patch_test.go +++ b/pkg/services/libraryelements/libraryelements_patch_test.go @@ -5,12 +5,14 @@ import ( "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/models" ) func TestPatchLibraryElement(t *testing.T) { scenarioWithPanel(t, "When an admin tries to patch a library panel that does not exist, it should fail", func(t *testing.T, sc scenarioContext) { - cmd := patchLibraryElementCommand{Kind: int64(Panel)} + cmd := patchLibraryElementCommand{Kind: int64(models.PanelElement)} sc.reqContext.ReplaceAllParams(map[string]string{":uid": "unknown"}) resp := sc.service.patchHandler(sc.reqContext, cmd) require.Equal(t, 404, resp.Status()) @@ -31,7 +33,7 @@ func TestPatchLibraryElement(t *testing.T) { "type": "graph" } `), - Kind: int64(Panel), + Kind: int64(models.PanelElement), Version: 1, } sc.reqContext.ReplaceAllParams(map[string]string{":uid": sc.initialResult.Result.UID}) @@ -45,7 +47,7 @@ func TestPatchLibraryElement(t *testing.T) { FolderID: newFolder.Id, UID: sc.initialResult.Result.UID, Name: "Panel - New name", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Type: "graph", Description: "An updated description", Model: map[string]interface{}{ @@ -83,7 +85,7 @@ func TestPatchLibraryElement(t *testing.T) { newFolder := createFolderWithACL(t, sc.sqlStore, "NewFolder", sc.user, []folderACLItem{}) cmd := patchLibraryElementCommand{ FolderID: newFolder.Id, - Kind: int64(Panel), + Kind: int64(models.PanelElement), Version: 1, } sc.reqContext.ReplaceAllParams(map[string]string{":uid": sc.initialResult.Result.UID}) @@ -104,7 +106,7 @@ func TestPatchLibraryElement(t *testing.T) { cmd := patchLibraryElementCommand{ FolderID: -1, Name: "New Name", - Kind: int64(Panel), + Kind: int64(models.PanelElement), Version: 1, } sc.reqContext.ReplaceAllParams(map[string]string{":uid": sc.initialResult.Result.UID}) @@ -125,7 +127,7 @@ func TestPatchLibraryElement(t *testing.T) { cmd := patchLibraryElementCommand{ FolderID: -1, Model: []byte(`{ "title": "New Model Title", "name": "New Model Name", "type":"graph", "description": "New description" }`), - Kind: int64(Panel), + Kind: int64(models.PanelElement), Version: 1, } sc.reqContext.ReplaceAllParams(map[string]string{":uid": sc.initialResult.Result.UID}) @@ -152,7 +154,7 @@ func TestPatchLibraryElement(t *testing.T) { cmd := patchLibraryElementCommand{ FolderID: -1, Model: []byte(`{ "description": "New description" }`), - Kind: int64(Panel), + Kind: int64(models.PanelElement), Version: 1, } sc.reqContext.ReplaceAllParams(map[string]string{":uid": sc.initialResult.Result.UID}) @@ -178,7 +180,7 @@ func TestPatchLibraryElement(t *testing.T) { cmd := patchLibraryElementCommand{ FolderID: -1, Model: []byte(`{ "type": "graph" }`), - Kind: int64(Panel), + Kind: int64(models.PanelElement), Version: 1, } sc.reqContext.ReplaceAllParams(map[string]string{":uid": sc.initialResult.Result.UID}) @@ -201,7 +203,7 @@ func TestPatchLibraryElement(t *testing.T) { scenarioWithPanel(t, "When another admin tries to patch a library panel, it should change UpdatedBy successfully and return correct result", func(t *testing.T, sc scenarioContext) { - cmd := patchLibraryElementCommand{FolderID: -1, Version: 1, Kind: int64(Panel)} + cmd := patchLibraryElementCommand{FolderID: -1, Version: 1, Kind: int64(models.PanelElement)} sc.reqContext.UserId = 2 sc.reqContext.ReplaceAllParams(map[string]string{":uid": sc.initialResult.Result.UID}) resp := sc.service.patchHandler(sc.reqContext, cmd) @@ -223,7 +225,7 @@ func TestPatchLibraryElement(t *testing.T) { cmd := patchLibraryElementCommand{ Name: "Text - Library Panel", Version: 1, - Kind: int64(Panel), + Kind: int64(models.PanelElement), } sc.reqContext.ReplaceAllParams(map[string]string{":uid": result.Result.UID}) resp = sc.service.patchHandler(sc.reqContext, cmd) @@ -239,7 +241,7 @@ func TestPatchLibraryElement(t *testing.T) { cmd := patchLibraryElementCommand{ FolderID: 1, Version: 1, - Kind: int64(Panel), + Kind: int64(models.PanelElement), } sc.reqContext.ReplaceAllParams(map[string]string{":uid": result.Result.UID}) resp = sc.service.patchHandler(sc.reqContext, cmd) @@ -251,7 +253,7 @@ func TestPatchLibraryElement(t *testing.T) { cmd := patchLibraryElementCommand{ FolderID: sc.folder.Id, Version: 1, - Kind: int64(Panel), + Kind: int64(models.PanelElement), } sc.reqContext.OrgId = 2 sc.reqContext.ReplaceAllParams(map[string]string{":uid": sc.initialResult.Result.UID}) @@ -264,7 +266,7 @@ func TestPatchLibraryElement(t *testing.T) { cmd := patchLibraryElementCommand{ FolderID: sc.folder.Id, Version: 1, - Kind: int64(Panel), + Kind: int64(models.PanelElement), } sc.reqContext.ReplaceAllParams(map[string]string{":uid": sc.initialResult.Result.UID}) resp := sc.service.patchHandler(sc.reqContext, cmd) @@ -278,14 +280,14 @@ func TestPatchLibraryElement(t *testing.T) { cmd := patchLibraryElementCommand{ FolderID: sc.folder.Id, Version: 1, - Kind: int64(Variable), + Kind: int64(models.VariableElement), } sc.reqContext.ReplaceAllParams(map[string]string{":uid": sc.initialResult.Result.UID}) resp := sc.service.patchHandler(sc.reqContext, cmd) require.Equal(t, 200, resp.Status()) var result = validateAndUnMarshalResponse(t, resp) sc.initialResult.Result.Type = "text" - sc.initialResult.Result.Kind = int64(Panel) + sc.initialResult.Result.Kind = int64(models.PanelElement) sc.initialResult.Result.Description = "A description" sc.initialResult.Result.Model = map[string]interface{}{ "datasource": "${DS_GDEV-TESTDATA}", diff --git a/pkg/services/libraryelements/libraryelements_permissions_test.go b/pkg/services/libraryelements/libraryelements_permissions_test.go index 3a2ca821d69..045d41f49ee 100644 --- a/pkg/services/libraryelements/libraryelements_permissions_test.go +++ b/pkg/services/libraryelements/libraryelements_permissions_test.go @@ -84,7 +84,7 @@ func TestLibraryElementPermissions(t *testing.T) { toFolder := createFolderWithACL(t, sc.sqlStore, "Folder", sc.user, testCase.items) sc.reqContext.SignedInUser.OrgRole = testCase.role - cmd := patchLibraryElementCommand{FolderID: toFolder.Id, Version: 1, Kind: int64(Panel)} + cmd := patchLibraryElementCommand{FolderID: toFolder.Id, Version: 1, Kind: int64(models.PanelElement)} sc.reqContext.ReplaceAllParams(map[string]string{":uid": result.Result.UID}) resp = sc.service.patchHandler(sc.reqContext, cmd) require.Equal(t, testCase.status, resp.Status()) @@ -99,7 +99,7 @@ func TestLibraryElementPermissions(t *testing.T) { toFolder := createFolderWithACL(t, sc.sqlStore, "Folder", sc.user, everyonePermissions) sc.reqContext.SignedInUser.OrgRole = testCase.role - cmd := patchLibraryElementCommand{FolderID: toFolder.Id, Version: 1, Kind: int64(Panel)} + cmd := patchLibraryElementCommand{FolderID: toFolder.Id, Version: 1, Kind: int64(models.PanelElement)} sc.reqContext.ReplaceAllParams(map[string]string{":uid": result.Result.UID}) resp = sc.service.patchHandler(sc.reqContext, cmd) require.Equal(t, testCase.status, resp.Status()) @@ -146,7 +146,7 @@ func TestLibraryElementPermissions(t *testing.T) { result := validateAndUnMarshalResponse(t, resp) sc.reqContext.SignedInUser.OrgRole = testCase.role - cmd := patchLibraryElementCommand{FolderID: 0, Version: 1, Kind: int64(Panel)} + cmd := patchLibraryElementCommand{FolderID: 0, Version: 1, Kind: int64(models.PanelElement)} sc.reqContext.ReplaceAllParams(map[string]string{":uid": result.Result.UID}) resp = sc.service.patchHandler(sc.reqContext, cmd) require.Equal(t, testCase.status, resp.Status()) @@ -160,7 +160,7 @@ func TestLibraryElementPermissions(t *testing.T) { result := validateAndUnMarshalResponse(t, resp) sc.reqContext.SignedInUser.OrgRole = testCase.role - cmd := patchLibraryElementCommand{FolderID: folder.Id, Version: 1, Kind: int64(Panel)} + cmd := patchLibraryElementCommand{FolderID: folder.Id, Version: 1, Kind: int64(models.PanelElement)} sc.reqContext.ReplaceAllParams(map[string]string{":uid": result.Result.UID}) resp = sc.service.patchHandler(sc.reqContext, cmd) require.Equal(t, testCase.status, resp.Status()) @@ -205,7 +205,7 @@ func TestLibraryElementPermissions(t *testing.T) { result := validateAndUnMarshalResponse(t, resp) sc.reqContext.SignedInUser.OrgRole = testCase.role - cmd := patchLibraryElementCommand{FolderID: -100, Version: 1, Kind: int64(Panel)} + cmd := patchLibraryElementCommand{FolderID: -100, Version: 1, Kind: int64(models.PanelElement)} sc.reqContext.ReplaceAllParams(map[string]string{":uid": result.Result.UID}) resp = sc.service.patchHandler(sc.reqContext, cmd) require.Equal(t, 404, resp.Status()) diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index 7cc9b854ae1..232dfaffe47 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -126,7 +126,7 @@ type libraryElementsSearchResult struct { } func getCreatePanelCommand(folderID int64, name string) CreateLibraryElementCommand { - command := getCreateCommandWithModel(folderID, name, Panel, []byte(` + command := getCreateCommandWithModel(folderID, name, models.PanelElement, []byte(` { "datasource": "${DS_GDEV-TESTDATA}", "id": 1, @@ -140,7 +140,7 @@ func getCreatePanelCommand(folderID int64, name string) CreateLibraryElementComm } func getCreateVariableCommand(folderID int64, name string) CreateLibraryElementCommand { - command := getCreateCommandWithModel(folderID, name, Variable, []byte(` + command := getCreateCommandWithModel(folderID, name, models.VariableElement, []byte(` { "datasource": "${DS_GDEV-TESTDATA}", "name": "query0", @@ -152,7 +152,7 @@ func getCreateVariableCommand(folderID int64, name string) CreateLibraryElementC return command } -func getCreateCommandWithModel(folderID int64, name string, kind LibraryElementKind, model []byte) CreateLibraryElementCommand { +func getCreateCommandWithModel(folderID int64, name string, kind models.LibraryElementKind, model []byte) CreateLibraryElementCommand { command := CreateLibraryElementCommand{ FolderID: folderID, Name: name, diff --git a/pkg/services/libraryelements/models.go b/pkg/services/libraryelements/models.go index 6dbe926e21c..fb0670c3992 100644 --- a/pkg/services/libraryelements/models.go +++ b/pkg/services/libraryelements/models.go @@ -6,13 +6,6 @@ import ( "time" ) -type LibraryElementKind int - -const ( - Panel LibraryElementKind = iota + 1 - Variable -) - type LibraryConnectionKind int const ( diff --git a/pkg/services/libraryelements/writers.go b/pkg/services/libraryelements/writers.go index f8dcd52bcdf..d935fbe8b26 100644 --- a/pkg/services/libraryelements/writers.go +++ b/pkg/services/libraryelements/writers.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/sqlstore" ) @@ -38,7 +39,7 @@ func writePerPageSQL(query searchLibraryElementsQuery, sqlStore *sqlstore.SQLSto } func writeKindSQL(query searchLibraryElementsQuery, builder *sqlstore.SQLBuilder) { - if LibraryElementKind(query.kind) == Panel || LibraryElementKind(query.kind) == Variable { + if models.LibraryElementKind(query.kind) == models.PanelElement || models.LibraryElementKind(query.kind) == models.VariableElement { builder.Write(" AND le.kind = ?", query.kind) } } diff --git a/pkg/services/librarypanels/librarypanels.go b/pkg/services/librarypanels/librarypanels.go index 10a8dd9378f..90daff312a0 100644 --- a/pkg/services/librarypanels/librarypanels.go +++ b/pkg/services/librarypanels/librarypanels.go @@ -76,7 +76,7 @@ func (lps *LibraryPanelService) LoadLibraryPanelsForDashboard(c *models.ReqConte continue } - if libraryelements.LibraryElementKind(elementInDB.Kind) != libraryelements.Panel { + if models.LibraryElementKind(elementInDB.Kind) != models.PanelElement { continue } diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index 7ee014d4a34..2972a71ac93 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -493,7 +493,7 @@ func TestConnectLibraryPanelsForDashboard(t *testing.T) { "description": "Unused description" } `), - Kind: int64(libraryelements.Panel), + Kind: int64(models.PanelElement), }) require.NoError(t, err) dashJSON := map[string]interface{}{ @@ -783,7 +783,7 @@ func scenarioWithLibraryPanel(t *testing.T, desc string, fn func(t *testing.T, s "description": "A description" } `), - Kind: int64(libraryelements.Panel), + Kind: int64(models.PanelElement), } resp, err := sc.elementService.CreateElement(sc.reqContext, command) require.NoError(t, err) diff --git a/pkg/services/sqlstore/migrations/libraryelements.go b/pkg/services/sqlstore/migrations/libraryelements.go new file mode 100644 index 00000000000..099e204fc1e --- /dev/null +++ b/pkg/services/sqlstore/migrations/libraryelements.go @@ -0,0 +1,53 @@ +package migrations + +import ( + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) + +// addLibraryElementsMigrations defines database migrations for library elements. +func addLibraryElementsMigrations(mg *migrator.Migrator) { + libraryElementsV1 := migrator.Table{ + Name: "library_element", + Columns: []*migrator.Column{ + {Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "org_id", Type: migrator.DB_BigInt, Nullable: false}, + {Name: "folder_id", Type: migrator.DB_BigInt, Nullable: false}, + {Name: "uid", Type: migrator.DB_NVarchar, Length: 40, Nullable: false}, + {Name: "name", Type: migrator.DB_NVarchar, Length: 150, Nullable: false}, + {Name: "kind", Type: migrator.DB_BigInt, Nullable: false}, + {Name: "type", Type: migrator.DB_NVarchar, Length: 40, Nullable: false}, + {Name: "description", Type: migrator.DB_NVarchar, Length: 255, Nullable: false}, + {Name: "model", Type: migrator.DB_Text, Nullable: false}, + {Name: "created", Type: migrator.DB_DateTime, Nullable: false}, + {Name: "created_by", Type: migrator.DB_BigInt, Nullable: false}, + {Name: "updated", Type: migrator.DB_DateTime, Nullable: false}, + {Name: "updated_by", Type: migrator.DB_BigInt, Nullable: false}, + {Name: "version", Type: migrator.DB_BigInt, Nullable: false}, + }, + Indices: []*migrator.Index{ + {Cols: []string{"org_id", "folder_id", "name", "kind"}, Type: migrator.UniqueIndex}, + }, + } + + mg.AddMigration("create library_element table v1", migrator.NewAddTableMigration(libraryElementsV1)) + mg.AddMigration("add index library_element org_id-folder_id-name-kind", migrator.NewAddIndexMigration(libraryElementsV1, libraryElementsV1.Indices[0])) + + libraryElementConnectionV1 := migrator.Table{ + Name: models.LibraryElementConnectionTableName, + Columns: []*migrator.Column{ + {Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "element_id", Type: migrator.DB_BigInt, Nullable: false}, + {Name: "kind", Type: migrator.DB_BigInt, Nullable: false}, + {Name: "connection_id", Type: migrator.DB_BigInt, Nullable: false}, + {Name: "created", Type: migrator.DB_DateTime, Nullable: false}, + {Name: "created_by", Type: migrator.DB_BigInt, Nullable: false}, + }, + Indices: []*migrator.Index{ + {Cols: []string{"element_id", "kind", "connection_id"}, Type: migrator.UniqueIndex}, + }, + } + + mg.AddMigration("create "+models.LibraryElementConnectionTableName+" table v1", migrator.NewAddTableMigration(libraryElementConnectionV1)) + mg.AddMigration("add index "+models.LibraryElementConnectionTableName+" element_id-kind-connection_id", migrator.NewAddIndexMigration(libraryElementConnectionV1, libraryElementConnectionV1.Indices[0])) +} diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 30c0afa4295..b57309ef307 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -40,6 +40,7 @@ func AddMigrations(mg *Migrator) { addShortURLMigrations(mg) ualert.AddTablesMigrations(mg) ualert.AddDashAlertMigration(mg) + addLibraryElementsMigrations(mg) } func addMigrationLogMigrations(mg *Migrator) { diff --git a/pkg/services/sqlstore/stats.go b/pkg/services/sqlstore/stats.go index b7341ab7d7f..2df3117b0de 100644 --- a/pkg/services/sqlstore/stats.go +++ b/pkg/services/sqlstore/stats.go @@ -80,6 +80,8 @@ func GetSystemStats(query *models.GetSystemStatsQuery) error { sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("team") + `) AS teams,`) sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("user_auth_token") + `) AS auth_tokens,`) sb.Write(`(SELECT COUNT(id) FROM ` + dialect.Quote("alert_rule") + `) AS alert_rules,`) + sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("library_element")+` WHERE kind = ?) AS library_panels,`, models.PanelElement) + sb.Write(`(SELECT COUNT(id) FROM `+dialect.Quote("library_element")+` WHERE kind = ?) AS library_variables,`, models.VariableElement) sb.Write(roleCounterSQL()) diff --git a/pkg/services/sqlstore/stats_test.go b/pkg/services/sqlstore/stats_test.go index 60f073223fc..4faee99cbdd 100644 --- a/pkg/services/sqlstore/stats_test.go +++ b/pkg/services/sqlstore/stats_test.go @@ -24,6 +24,8 @@ func TestStatsDataAccess(t *testing.T) { assert.Equal(t, 0, query.Result.Editors) assert.Equal(t, 0, query.Result.Viewers) assert.Equal(t, 3, query.Result.Admins) + assert.Equal(t, int64(0), query.Result.LibraryPanels) + assert.Equal(t, int64(0), query.Result.LibraryVariables) }) t.Run("Get system user count stats should not results in error", func(t *testing.T) { From 7b17801047ae79e1725639e502bd06fd9171f141 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Mon, 24 May 2021 09:31:34 +0200 Subject: [PATCH 19/43] LibraryPanels: Fixes error when importing plugin dashboard (#34557) --- pkg/api/dashboard_test.go | 4 - pkg/api/plugins.go | 4 +- pkg/plugins/ifaces.go | 2 +- pkg/plugins/manager/dashboard_import.go | 10 +- pkg/plugins/manager/dashboard_import_test.go | 3 +- pkg/plugins/plugindashboards/service.go | 2 +- pkg/services/librarypanels/librarypanels.go | 9 -- .../librarypanels/librarypanels_test.go | 100 ------------------ 8 files changed, 11 insertions(+), 123 deletions(-) diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 45389f4ee92..fd500c617af 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -1348,10 +1348,6 @@ func (m *mockLibraryPanelService) ConnectLibraryPanelsForDashboard(c *models.Req return nil } -func (m *mockLibraryPanelService) ImportDashboard(c *models.ReqContext, dashboard *simplejson.Json, importedID int64) error { - return nil -} - type mockLibraryElementService struct { } diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 761163c2d9d..48e75d4b972 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -220,13 +220,13 @@ func (hs *HTTPServer) ImportDashboard(c *models.ReqContext, apiCmd dtos.ImportDa } } - dashInfo, err := hs.PluginManager.ImportDashboard(apiCmd.PluginId, apiCmd.Path, c.OrgId, apiCmd.FolderId, + dashInfo, dash, err := hs.PluginManager.ImportDashboard(apiCmd.PluginId, apiCmd.Path, c.OrgId, apiCmd.FolderId, apiCmd.Dashboard, apiCmd.Overwrite, apiCmd.Inputs, c.SignedInUser, hs.DataService) if err != nil { return hs.dashboardSaveErrorToApiResponse(err) } - err = hs.LibraryPanelService.ImportDashboard(c, apiCmd.Dashboard, dashInfo.DashboardId) + err = hs.LibraryPanelService.ConnectLibraryPanelsForDashboard(c, dash) if err != nil { return response.Error(500, "Error while connecting library panels", err) } diff --git a/pkg/plugins/ifaces.go b/pkg/plugins/ifaces.go index ff695a0057f..f9855e3a2e7 100644 --- a/pkg/plugins/ifaces.go +++ b/pkg/plugins/ifaces.go @@ -49,7 +49,7 @@ type Manager interface { // ImportDashboard imports a dashboard. ImportDashboard(pluginID, path string, orgID, folderID int64, dashboardModel *simplejson.Json, overwrite bool, inputs []ImportDashboardInput, user *models.SignedInUser, - requestHandler DataRequestHandler) (PluginDashboardInfoDTO, error) + requestHandler DataRequestHandler) (PluginDashboardInfoDTO, *models.Dashboard, error) // ScanningErrors returns plugin scanning errors encountered. ScanningErrors() []PluginError // LoadPluginDashboard loads a plugin dashboard. diff --git a/pkg/plugins/manager/dashboard_import.go b/pkg/plugins/manager/dashboard_import.go index c6debf67021..442ea19484b 100644 --- a/pkg/plugins/manager/dashboard_import.go +++ b/pkg/plugins/manager/dashboard_import.go @@ -23,12 +23,12 @@ func (e DashboardInputMissingError) Error() string { func (pm *PluginManager) ImportDashboard(pluginID, path string, orgID, folderID int64, dashboardModel *simplejson.Json, overwrite bool, inputs []plugins.ImportDashboardInput, user *models.SignedInUser, - requestHandler plugins.DataRequestHandler) (plugins.PluginDashboardInfoDTO, error) { + requestHandler plugins.DataRequestHandler) (plugins.PluginDashboardInfoDTO, *models.Dashboard, error) { var dashboard *models.Dashboard if pluginID != "" { var err error if dashboard, err = pm.LoadPluginDashboard(pluginID, path); err != nil { - return plugins.PluginDashboardInfoDTO{}, err + return plugins.PluginDashboardInfoDTO{}, &models.Dashboard{}, err } } else { dashboard = models.NewDashboardFromJson(dashboardModel) @@ -41,7 +41,7 @@ func (pm *PluginManager) ImportDashboard(pluginID, path string, orgID, folderID generatedDash, err := evaluator.Eval() if err != nil { - return plugins.PluginDashboardInfoDTO{}, err + return plugins.PluginDashboardInfoDTO{}, &models.Dashboard{}, err } saveCmd := models.SaveDashboardCommand{ @@ -62,7 +62,7 @@ func (pm *PluginManager) ImportDashboard(pluginID, path string, orgID, folderID savedDash, err := dashboards.NewService(pm.SQLStore).ImportDashboard(dto) if err != nil { - return plugins.PluginDashboardInfoDTO{}, err + return plugins.PluginDashboardInfoDTO{}, &models.Dashboard{}, err } return plugins.PluginDashboardInfoDTO{ @@ -77,7 +77,7 @@ func (pm *PluginManager) ImportDashboard(pluginID, path string, orgID, folderID Imported: true, DashboardId: savedDash.Id, Slug: savedDash.Slug, - }, nil + }, savedDash, nil } type DashTemplateEvaluator struct { diff --git a/pkg/plugins/manager/dashboard_import_test.go b/pkg/plugins/manager/dashboard_import_test.go index a9983fe0a48..52f2d120f0d 100644 --- a/pkg/plugins/manager/dashboard_import_test.go +++ b/pkg/plugins/manager/dashboard_import_test.go @@ -21,12 +21,13 @@ func TestDashboardImport(t *testing.T) { mock := &dashboards.FakeDashboardService{} dashboards.MockDashboardService(mock) - info, err := pm.ImportDashboard("test-app", "dashboards/connections.json", 1, 0, nil, false, + info, dash, err := pm.ImportDashboard("test-app", "dashboards/connections.json", 1, 0, nil, false, []plugins.ImportDashboardInput{ {Name: "*", Type: "datasource", Value: "graphite"}, }, &models.SignedInUser{UserId: 1, OrgRole: models.ROLE_ADMIN}, nil) require.NoError(t, err) require.NotNil(t, info) + require.NotNil(t, dash) resultStr, err := mock.SavedDashboards[0].Dashboard.Data.EncodePretty() require.NoError(t, err) diff --git a/pkg/plugins/plugindashboards/service.go b/pkg/plugins/plugindashboards/service.go index ef8573b14b4..ccbf5a6f704 100644 --- a/pkg/plugins/plugindashboards/service.go +++ b/pkg/plugins/plugindashboards/service.go @@ -145,7 +145,7 @@ func (s *Service) autoUpdateAppDashboard(pluginDashInfo *plugins.PluginDashboard s.logger.Info("Auto updating App dashboard", "dashboard", dash.Title, "newRev", pluginDashInfo.Revision, "oldRev", pluginDashInfo.ImportedRevision) user := &models.SignedInUser{UserId: 0, OrgRole: models.ROLE_ADMIN} - _, err = s.PluginManager.ImportDashboard(pluginDashInfo.PluginId, pluginDashInfo.Path, orgID, 0, dash.Data, true, + _, _, err = s.PluginManager.ImportDashboard(pluginDashInfo.PluginId, pluginDashInfo.Path, orgID, 0, dash.Data, true, nil, user, s.DataService) return err } diff --git a/pkg/services/librarypanels/librarypanels.go b/pkg/services/librarypanels/librarypanels.go index 90daff312a0..1cbaba1d970 100644 --- a/pkg/services/librarypanels/librarypanels.go +++ b/pkg/services/librarypanels/librarypanels.go @@ -18,7 +18,6 @@ type Service interface { LoadLibraryPanelsForDashboard(c *models.ReqContext, dash *models.Dashboard) error CleanLibraryPanelsForDashboard(dash *models.Dashboard) error ConnectLibraryPanelsForDashboard(c *models.ReqContext, dash *models.Dashboard) error - ImportDashboard(c *models.ReqContext, dashboard *simplejson.Json, importedID int64) error } // LibraryPanelService is the service for the Panel Library feature. @@ -193,11 +192,3 @@ func (lps *LibraryPanelService) ConnectLibraryPanelsForDashboard(c *models.ReqCo return lps.LibraryElementService.ConnectElementsToDashboard(c, elementUIDs, dash.Id) } - -// ImportDashboard loops through all panels in dashboard JSON and connects any library panels to the dashboard. -func (lps *LibraryPanelService) ImportDashboard(c *models.ReqContext, dashboard *simplejson.Json, importedID int64) error { - dash := models.NewDashboardFromJson(dashboard) - dash.Id = importedID - - return lps.ConnectLibraryPanelsForDashboard(c, dash) -} diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index 2972a71ac93..1ec6da66584 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -572,106 +572,6 @@ func TestConnectLibraryPanelsForDashboard(t *testing.T) { }) } -func TestImportDashboard(t *testing.T) { - scenarioWithLibraryPanel(t, "When an admin tries to import a dashboard with a library panel, it should connect the two", - func(t *testing.T, sc scenarioContext) { - importedJSON := map[string]interface{}{ - "panels": []interface{}{}, - } - importedDashboard := models.Dashboard{ - Title: "Dummy dash that simulates an imported dash", - Data: simplejson.NewFromAny(importedJSON), - } - importedDashInDB := createDashboard(t, sc.sqlStore, sc.user, &importedDashboard, sc.folder.Id) - elements, err := sc.elementService.GetElementsForDashboard(sc.reqContext, importedDashInDB.Id) - require.NoError(t, err) - require.Len(t, elements, 0) - - dashJSON := map[string]interface{}{ - "title": "Testing ImportDashboard", - "panels": []interface{}{ - map[string]interface{}{ - "id": int64(1), - "gridPos": map[string]interface{}{ - "h": 6, - "w": 6, - "x": 0, - "y": 0, - }, - }, - map[string]interface{}{ - "id": int64(2), - "gridPos": map[string]interface{}{ - "h": 6, - "w": 6, - "x": 6, - "y": 0, - }, - "datasource": "${DS_GDEV-TESTDATA}", - "libraryPanel": map[string]interface{}{ - "uid": sc.initialResult.Result.UID, - "name": sc.initialResult.Result.Name, - }, - "title": "Text - Library Panel", - "type": "text", - }, - }, - } - dash := simplejson.NewFromAny(dashJSON) - err = sc.service.ImportDashboard(sc.reqContext, dash, importedDashInDB.Id) - require.NoError(t, err) - - elements, err = sc.elementService.GetElementsForDashboard(sc.reqContext, importedDashInDB.Id) - require.NoError(t, err) - require.Len(t, elements, 1) - require.Equal(t, sc.initialResult.Result.UID, elements[sc.initialResult.Result.UID].UID) - }) - - scenarioWithLibraryPanel(t, "When an admin tries to import a dashboard with a library panel without uid, it should fail", - func(t *testing.T, sc scenarioContext) { - importedJSON := map[string]interface{}{ - "panels": []interface{}{}, - } - importedDashboard := models.Dashboard{ - Title: "Dummy dash that simulates an imported dash", - Data: simplejson.NewFromAny(importedJSON), - } - importedDashInDB := createDashboard(t, sc.sqlStore, sc.user, &importedDashboard, sc.folder.Id) - - dashJSON := map[string]interface{}{ - "panels": []interface{}{ - map[string]interface{}{ - "id": int64(1), - "gridPos": map[string]interface{}{ - "h": 6, - "w": 6, - "x": 0, - "y": 0, - }, - }, - map[string]interface{}{ - "id": int64(2), - "gridPos": map[string]interface{}{ - "h": 6, - "w": 6, - "x": 6, - "y": 0, - }, - "datasource": "${DS_GDEV-TESTDATA}", - "libraryPanel": map[string]interface{}{ - "name": sc.initialResult.Result.Name, - }, - "title": "Text - Library Panel", - "type": "text", - }, - }, - } - dash := simplejson.NewFromAny(dashJSON) - err := sc.service.ImportDashboard(sc.reqContext, dash, importedDashInDB.Id) - require.EqualError(t, err, errLibraryPanelHeaderUIDMissing.Error()) - }) -} - type libraryPanel struct { ID int64 OrgID int64 From 68513b9a3fd2e04d2a2f1a1522920cca6b0017fd Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Mon, 24 May 2021 09:55:39 +0200 Subject: [PATCH 20/43] Chore: bump Acorn and Underscore (#34302) --- package.json | 3 ++- yarn.lock | 14 +++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index c4e6f9e88f4..54b02f8f927 100644 --- a/package.json +++ b/package.json @@ -308,7 +308,8 @@ "whatwg-fetch": "3.1.0" }, "resolutions": { - "caniuse-db": "1.0.30000772" + "caniuse-db": "1.0.30000772", + "underscore": "1.12.1" }, "workspaces": { "packages": [ diff --git a/yarn.lock b/yarn.lock index bcfcbbf38d1..dcb41f6a723 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5639,9 +5639,9 @@ acorn@^3.0.4: integrity sha1-ReN/s56No/JbruP/U2niu18iAXo= acorn@^5.0.0, acorn@^5.5.0: - version "5.7.3" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.3.tgz#67aa231bf8812974b85235a96771eb6bd07ea279" - integrity sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw== + version "5.7.4" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.4.tgz#3e8d8a9947d0599a1796d10225d7432f4a4acf5e" + integrity sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg== acorn@^6.0.1, acorn@^6.0.7, acorn@^6.2.1, acorn@^6.4.1: version "6.4.2" @@ -21802,10 +21802,10 @@ undefsafe@^2.0.2: dependencies: debug "^2.2.0" -underscore@1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.7.0.tgz#6bbaf0877500d36be34ecaa584e0db9fef035209" - integrity sha1-a7rwh3UA02vjTsqlhODbn+8DUgk= +underscore@1.12.1, underscore@1.7.0: + version "1.12.1" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.12.1.tgz#7bb8cc9b3d397e201cf8553336d262544ead829e" + integrity sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw== unfetch@^4.2.0: version "4.2.0" From 0c2bb9562a80dc6ece7368b10fac1bb7917ca1ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Farkas?= Date: Mon, 24 May 2021 10:25:30 +0200 Subject: [PATCH 21/43] devenv: slow_proxy_mac: make configurable and smaller (#34560) --- devenv/docker/blocks/slow_proxy_mac/.env | 2 ++ devenv/docker/blocks/slow_proxy_mac/Dockerfile | 9 ++++++--- .../blocks/slow_proxy_mac/docker-compose.yaml | 3 ++- devenv/docker/blocks/slow_proxy_mac/main.go | 17 +++++++++++++---- 4 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 devenv/docker/blocks/slow_proxy_mac/.env diff --git a/devenv/docker/blocks/slow_proxy_mac/.env b/devenv/docker/blocks/slow_proxy_mac/.env new file mode 100644 index 00000000000..74f4e9c1841 --- /dev/null +++ b/devenv/docker/blocks/slow_proxy_mac/.env @@ -0,0 +1,2 @@ +ORIGIN_SERVER=http://host.docker.internal:9090/ +SLEEP_DURATION=60s \ No newline at end of file diff --git a/devenv/docker/blocks/slow_proxy_mac/Dockerfile b/devenv/docker/blocks/slow_proxy_mac/Dockerfile index d48c945f5ab..735d433414c 100644 --- a/devenv/docker/blocks/slow_proxy_mac/Dockerfile +++ b/devenv/docker/blocks/slow_proxy_mac/Dockerfile @@ -1,7 +1,10 @@ - -FROM golang:latest +FROM golang:latest as builder ADD main.go / WORKDIR / -RUN GO111MODULE=off go build -o main . +RUN GO111MODULE=off CGO_ENABLED=0 go build -o main . + +FROM scratch +WORKDIR / EXPOSE 3011 +COPY --from=builder /main /main ENTRYPOINT ["/main"] diff --git a/devenv/docker/blocks/slow_proxy_mac/docker-compose.yaml b/devenv/docker/blocks/slow_proxy_mac/docker-compose.yaml index 47347042df7..135c230c9bc 100644 --- a/devenv/docker/blocks/slow_proxy_mac/docker-compose.yaml +++ b/devenv/docker/blocks/slow_proxy_mac/docker-compose.yaml @@ -3,4 +3,5 @@ ports: - '3011:3011' environment: - ORIGIN_SERVER: 'http://host.docker.internal:9090/' + ORIGIN_SERVER: ${ORIGIN_SERVER} + SLEEP_DURATION: ${SLEEP_DURATION} \ No newline at end of file diff --git a/devenv/docker/blocks/slow_proxy_mac/main.go b/devenv/docker/blocks/slow_proxy_mac/main.go index dece2525c13..4c98eb9d17c 100644 --- a/devenv/docker/blocks/slow_proxy_mac/main.go +++ b/devenv/docker/blocks/slow_proxy_mac/main.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "log" "net/http" "net/http/httputil" @@ -13,16 +12,26 @@ import ( func main() { origin := os.Getenv("ORIGIN_SERVER") if origin == "" { - origin = "http://host.docker.internal:9090/" + // it is never not-set, the default is in the `.env` file + log.Fatalf("missing env-variable ORIGIN_SERVER") } - sleep := time.Minute + sleepDurationStr := os.Getenv("SLEEP_DURATION") + if sleepDurationStr == "" { + // it is never not-set, the default is in the `.env` file + log.Fatalf("missing env-variable SLEEP_DURATION") + } + + sleep, err := time.ParseDuration(sleepDurationStr) + if err != nil { + log.Fatalf("failed to parse SLEEP_DURATION: %v", err) + } originURL, _ := url.Parse(origin) proxy := httputil.NewSingleHostReverseProxy(originURL) http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - fmt.Printf("sleeping for %s then proxying request: %s", sleep.String(), r.RequestURI) + log.Printf("sleeping for %s then proxying request: url '%s', headers: '%v'", sleep.String(), r.RequestURI, r.Header) <-time.After(sleep) proxy.ServeHTTP(w, r) }) From 8d05df83edc4ed2570ce36585c9554ec9ac08eea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Jamr=C3=B3z?= Date: Mon, 24 May 2021 10:34:37 +0200 Subject: [PATCH 22/43] CustomScrollbar: Invoke setScrollTop callback only after scrolling finishes (#34263) * Invoke setScrollTop callback only after scrolling finishes When the state is updated while scroll events are being dispatched (like in QueryGroup) it may cause resetting the scroll position to the first emitted event because setting the scroll happens only after render (useEffect). * Memoize onScrollStop callback --- .../CustomScrollbar/CustomScrollbar.tsx | 12 +++++++++--- packages/grafana-ui/src/components/index.ts | 2 +- .../dashboard/containers/DashboardPage.tsx | 9 ++++----- .../app/features/query/components/QueryGroup.tsx | 16 ++++++++++++---- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx index 15f23febba1..412ad8d9ff0 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx +++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx @@ -2,10 +2,12 @@ import React, { FC, useCallback, useEffect, useRef } from 'react'; import { isNil } from 'lodash'; import classNames from 'classnames'; import { css } from '@emotion/css'; -import Scrollbars from 'react-custom-scrollbars'; +import Scrollbars, { positionValues } from 'react-custom-scrollbars'; import { useStyles2 } from '../../themes'; import { GrafanaTheme2 } from '@grafana/data'; +export type ScrollbarPosition = positionValues; + interface Props { className?: string; autoHide?: boolean; @@ -15,7 +17,7 @@ interface Props { hideHorizontalTrack?: boolean; hideVerticalTrack?: boolean; scrollTop?: number; - setScrollTop?: (event: any) => void; + setScrollTop?: (position: ScrollbarPosition) => void; autoHeightMin?: number | string; updateAfterMountMs?: number; } @@ -101,11 +103,15 @@ export const CustomScrollbar: FC = ({ return
; }, []); + const onScrollStop = useCallback(() => { + ref.current && setScrollTop && setScrollTop(ref.current.getValues()); + }, [setScrollTop]); + return ( { $('body').toggleClass('panel-in-fullscreen', isFullscreen); } - setScrollTop = (e: MouseEvent): void => { - const target = e.target as HTMLElement; - this.setState({ scrollTop: target.scrollTop, updateScrollTop: undefined }); + setScrollTop = ({ scrollTop }: ScrollbarPosition): void => { + this.setState({ scrollTop, updateScrollTop: undefined }); }; onAddPanel = () => { diff --git a/public/app/features/query/components/QueryGroup.tsx b/public/app/features/query/components/QueryGroup.tsx index 8d3b7fb80c7..c919376e229 100644 --- a/public/app/features/query/components/QueryGroup.tsx +++ b/public/app/features/query/components/QueryGroup.tsx @@ -1,7 +1,16 @@ // Libraries import React, { PureComponent } from 'react'; // Components -import { Button, CustomScrollbar, HorizontalGroup, Icon, Modal, stylesFactory, Tooltip } from '@grafana/ui'; +import { + Button, + CustomScrollbar, + HorizontalGroup, + Icon, + Modal, + ScrollbarPosition, + stylesFactory, + Tooltip, +} from '@grafana/ui'; import { getDataSourceSrv, DataSourcePicker } from '@grafana/runtime'; import { QueryEditorRows } from './QueryEditorRows'; // Services @@ -275,9 +284,8 @@ export class QueryGroup extends PureComponent { this.onScrollBottom(); }; - setScrollTop = (event: React.MouseEvent) => { - const target = event.target as HTMLElement; - this.setState({ scrollTop: target.scrollTop }); + setScrollTop = ({ scrollTop }: ScrollbarPosition) => { + this.setState({ scrollTop: scrollTop }); }; onQueriesChange = (queries: DataQuery[]) => { From e21b90681f327f435d9f4c8469b76c3bedf33f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Farkas?= Date: Mon, 24 May 2021 12:24:42 +0200 Subject: [PATCH 23/43] influxdb: influxql: make measurement-autocomplete case insensitive (#34563) --- public/app/plugins/datasource/influxdb/query_builder.ts | 3 ++- .../plugins/datasource/influxdb/specs/query_builder.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/public/app/plugins/datasource/influxdb/query_builder.ts b/public/app/plugins/datasource/influxdb/query_builder.ts index 2993c998719..0155c0910d0 100644 --- a/public/app/plugins/datasource/influxdb/query_builder.ts +++ b/public/app/plugins/datasource/influxdb/query_builder.ts @@ -44,7 +44,8 @@ export class InfluxQueryBuilder { } else if (type === 'MEASUREMENTS') { query = 'SHOW MEASUREMENTS'; if (withMeasurementFilter) { - query += ' WITH MEASUREMENT =~ /' + kbn.regexEscape(withMeasurementFilter) + '/'; + // we do a case-insensitive regex-based lookup + query += ' WITH MEASUREMENT =~ /(?i)' + kbn.regexEscape(withMeasurementFilter) + '/'; } } else if (type === 'FIELDS') { measurement = this.target.measurement; diff --git a/public/app/plugins/datasource/influxdb/specs/query_builder.test.ts b/public/app/plugins/datasource/influxdb/specs/query_builder.test.ts index f32e8438967..59b8e839e98 100644 --- a/public/app/plugins/datasource/influxdb/specs/query_builder.test.ts +++ b/public/app/plugins/datasource/influxdb/specs/query_builder.test.ts @@ -56,13 +56,13 @@ describe('InfluxQueryBuilder', () => { it('should have WITH MEASUREMENT in measurement query for non-empty query with no tags', () => { const builder = new InfluxQueryBuilder({ measurement: '', tags: [] }); const query = builder.buildExploreQuery('MEASUREMENTS', undefined, 'something'); - expect(query).toBe('SHOW MEASUREMENTS WITH MEASUREMENT =~ /something/ LIMIT 100'); + expect(query).toBe('SHOW MEASUREMENTS WITH MEASUREMENT =~ /(?i)something/ LIMIT 100'); }); it('should escape the regex value in measurement query', () => { const builder = new InfluxQueryBuilder({ measurement: '', tags: [] }); const query = builder.buildExploreQuery('MEASUREMENTS', undefined, 'abc/edf/'); - expect(query).toBe('SHOW MEASUREMENTS WITH MEASUREMENT =~ /abc\\/edf\\// LIMIT 100'); + expect(query).toBe('SHOW MEASUREMENTS WITH MEASUREMENT =~ /(?i)abc\\/edf\\// LIMIT 100'); }); it('should have WITH MEASUREMENT WHERE in measurement query for non-empty query with tags', () => { @@ -71,7 +71,7 @@ describe('InfluxQueryBuilder', () => { tags: [{ key: 'app', value: 'email' }], }); const query = builder.buildExploreQuery('MEASUREMENTS', undefined, 'something'); - expect(query).toBe('SHOW MEASUREMENTS WITH MEASUREMENT =~ /something/ WHERE "app" = \'email\' LIMIT 100'); + expect(query).toBe('SHOW MEASUREMENTS WITH MEASUREMENT =~ /(?i)something/ WHERE "app" = \'email\' LIMIT 100'); }); it('should have where condition in measurement query for query with tags', () => { From 6796a89e9d03ab3efaa4e03ca26fcf54a05a8a4d Mon Sep 17 00:00:00 2001 From: mmenbawy Date: Mon, 24 May 2021 12:28:10 +0200 Subject: [PATCH 24/43] Loki: Bring back processed bytes as meta info (#34092) * Loki: Bring back processed bytes as meta info * style: Lint --- public/app/core/logs_model.test.ts | 12 +++++++++++- public/app/core/logs_model.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/public/app/core/logs_model.test.ts b/public/app/core/logs_model.test.ts index cac8a65d8b8..04aa3e0939a 100644 --- a/public/app/core/logs_model.test.ts +++ b/public/app/core/logs_model.test.ts @@ -698,6 +698,8 @@ describe('logSeriesToLogsModel', () => { meta: { searchWords: ['test'], limit: 1000, + stats: [{ displayName: 'Summary: total bytes processed', value: 97048, unit: 'decbytes' }], + custom: { lokiQueryStatKey: 'Summary: total bytes processed' }, preferredVisualisationType: 'logs', }, }, @@ -705,7 +707,10 @@ describe('logSeriesToLogsModel', () => { const metaData = { hasUniqueLabels: false, - meta: [{ label: LIMIT_LABEL, value: 1000, kind: 0 }], + meta: [ + { label: LIMIT_LABEL, value: 1000, kind: 0 }, + { label: 'Total bytes processed', value: '97.0 kB', kind: 1 }, + ], rows: [], }; @@ -739,6 +744,8 @@ describe('logSeriesToLogsModel', () => { meta: { searchWords: ['test'], limit: 1000, + stats: [{ displayName: 'Summary: total bytes processed', value: 97048, unit: 'decbytes' }], + custom: { lokiQueryStatKey: 'Summary: total bytes processed' }, preferredVisualisationType: 'logs', }, }), @@ -749,6 +756,8 @@ describe('logSeriesToLogsModel', () => { meta: { searchWords: ['test'], limit: 1000, + stats: [{ displayName: 'Summary: total bytes processed', value: 97048, unit: 'decbytes' }], + custom: { lokiQueryStatKey: 'Summary: total bytes processed' }, preferredVisualisationType: 'logs', }, }), @@ -758,6 +767,7 @@ describe('logSeriesToLogsModel', () => { expect(logsModel.meta).toMatchObject([ { kind: 2, label: 'Common labels', value: { foo: 'bar', level: 'dbug' } }, { kind: 0, label: LIMIT_LABEL, value: 2000 }, + { kind: 1, label: 'Total bytes processed', value: '194 kB' }, ]); expect(logsModel.rows).toHaveLength(3); expect(logsModel.rows).toMatchObject([ diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts index 79c5d7d9755..b079ea6943b 100644 --- a/public/app/core/logs_model.ts +++ b/public/app/core/logs_model.ts @@ -33,6 +33,7 @@ import { DataQuery, } from '@grafana/data'; import { getThemeColor } from 'app/core/utils/colors'; +import { SIPrefix } from '@grafana/data/src/valueFormats/symbolFormatters'; import { config } from '@grafana/runtime'; export const LIMIT_LABEL = 'Line limit'; @@ -443,10 +444,16 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel | undefi kind: LogsMetaKind.Number, }); } + + let totalBytes = 0; + const queriesVisited: { [refId: string]: boolean } = {}; // To add just 1 error message let errorMetaAdded = false; for (const series of logSeries) { + const totalBytesKey = series.meta?.custom?.lokiQueryStatKey; + const { refId } = series; // Stats are per query, keeping track by refId + if (!errorMetaAdded && series.meta?.custom?.error) { meta.push({ label: '', @@ -455,7 +462,28 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel | undefi }); errorMetaAdded = true; } + + if (refId && !queriesVisited[refId]) { + if (totalBytesKey && series.meta?.stats) { + const byteStat = series.meta.stats.find((stat) => stat.displayName === totalBytesKey); + if (byteStat) { + totalBytes += byteStat.value; + } + } + + queriesVisited[refId] = true; + } } + + if (totalBytes > 0) { + const { text, suffix } = SIPrefix('B')(totalBytes); + meta.push({ + label: 'Total bytes processed', + value: `${text} ${suffix}`, + kind: LogsMetaKind.String, + }); + } + return { hasUniqueLabels, meta, From b5de6e7a1d8510a083bb9ff9292456a6257ba4c2 Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Mon, 24 May 2021 13:30:02 +0200 Subject: [PATCH 25/43] Add @public release tag to Spinner component (#34576) --- packages/grafana-ui/src/components/Spinner/Spinner.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/grafana-ui/src/components/Spinner/Spinner.tsx b/packages/grafana-ui/src/components/Spinner/Spinner.tsx index 1ccf2c8748f..a77f80a9e5b 100644 --- a/packages/grafana-ui/src/components/Spinner/Spinner.tsx +++ b/packages/grafana-ui/src/components/Spinner/Spinner.tsx @@ -23,6 +23,10 @@ export type Props = { inline?: boolean; size?: number; }; + +/** + * @public + */ export const Spinner: FC = (props: Props) => { const { className, inline = false, iconClassName, style, size = 16 } = props; const styles = getStyles(size, inline); From 247bdc2f9b93b8e4bcd42f472a87d9081ed41829 Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Mon, 24 May 2021 13:56:48 +0200 Subject: [PATCH 26/43] Explore: Add caching for queries run from logs navigation (#34297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP: Implement simple caching * If results are cached, don't run new query and use those results * Add duplicate key check * Clean up * Clean up * Add tests for caching * Remove unused variables * Update public/app/features/explore/state/query.test.ts Co-authored-by: Piotr JamrĂłz * Update public/app/features/explore/state/query.test.ts Co-authored-by: Piotr JamrĂłz * Use decorateData to apply all decorators * Remove unused variables * Change loading stte to Done * Clear cache when running query from navigation Co-authored-by: Piotr JamrĂłz --- .../app/features/explore/ExploreToolbar.tsx | 11 +- public/app/features/explore/Logs.tsx | 6 + public/app/features/explore/LogsContainer.tsx | 11 +- .../features/explore/LogsNavigation.test.tsx | 2 + .../app/features/explore/LogsNavigation.tsx | 14 +- .../explore/state/explorePane.test.ts | 1 + .../app/features/explore/state/explorePane.ts | 1 + .../app/features/explore/state/query.test.ts | 106 ++++++- public/app/features/explore/state/query.ts | 263 +++++++++++------- public/app/features/explore/state/utils.ts | 24 ++ .../app/features/explore/utils/decorators.ts | 20 +- public/app/types/explore.ts | 7 + 12 files changed, 363 insertions(+), 103 deletions(-) diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index afb3a8b573a..72503cd8276 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -19,7 +19,7 @@ import { ExploreTimeControls } from './ExploreTimeControls'; import { LiveTailButton } from './LiveTailButton'; import { RunButton } from './RunButton'; import { LiveTailControls } from './useLiveTailControls'; -import { cancelQueries, clearQueries, runQueries } from './state/query'; +import { cancelQueries, clearQueries, runQueries, clearCache } from './state/query'; import ReturnToDashboardButton from './ReturnToDashboardButton'; import { isSplit } from './state/selectors'; @@ -54,6 +54,7 @@ interface DispatchProps { syncTimes: typeof syncTimes; changeRefreshInterval: typeof changeRefreshInterval; onChangeTimeZone: typeof updateTimeZoneForSession; + clearCache: typeof clearCache; } type Props = StateProps & DispatchProps & OwnProps; @@ -68,10 +69,13 @@ export class UnConnectedExploreToolbar extends PureComponent { }; onRunQuery = (loading = false) => { + const { clearCache, runQueries, cancelQueries, exploreId } = this.props; if (loading) { - return this.props.cancelQueries(this.props.exploreId); + return cancelQueries(exploreId); } else { - return this.props.runQueries(this.props.exploreId); + // We want to give user a chance tu re-run the query even if it is saved in cache + clearCache(exploreId); + return runQueries(exploreId); } }; @@ -274,6 +278,7 @@ const mapDispatchToProps: DispatchProps = { split: splitOpen, syncTimes, onChangeTimeZone: updateTimeZoneForSession, + clearCache, }; export const ExploreToolbar = hot(module)(connect(mapStateToProps, mapDispatchToProps)(UnConnectedExploreToolbar)); diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index e598af19e6b..a71f260badc 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -65,6 +65,8 @@ interface Props { onStopScanning?: () => void; getRowContext?: (row: LogRowModel, options?: RowContextOptions) => Promise; getFieldLinks: (field: Field, rowIndex: number) => Array>; + addResultsToCache: () => void; + clearCache: () => void; } interface State { @@ -244,6 +246,8 @@ export class UnthemedLogs extends PureComponent { getFieldLinks, theme, logsQueries, + clearCache, + addResultsToCache, } = this.props; const { @@ -361,6 +365,8 @@ export class UnthemedLogs extends PureComponent { loading={loading} queries={logsQueries ?? []} scrollToTopLogs={this.scrollToTopLogs} + addResultsToCache={addResultsToCache} + clearCache={clearCache} />
{!loading && !hasData && !scanning && ( diff --git a/public/app/features/explore/LogsContainer.tsx b/public/app/features/explore/LogsContainer.tsx index 47bcfdfe216..efc86135228 100644 --- a/public/app/features/explore/LogsContainer.tsx +++ b/public/app/features/explore/LogsContainer.tsx @@ -7,6 +7,7 @@ import { AbsoluteTimeRange, Field, LogRowModel, RawTimeRange } from '@grafana/da import { ExploreId, ExploreItemState } from 'app/types/explore'; import { StoreState } from 'app/types'; import { splitOpen } from './state/main'; +import { addResultsToCache, clearCache } from './state/query'; import { updateTimeRange } from './state/time'; import { getTimeZone } from '../profile/state/selectors'; import { LiveLogsWithTheme } from './LiveLogs'; @@ -15,7 +16,7 @@ import { LogsCrossFadeTransition } from './utils/LogsCrossFadeTransition'; import { LiveTailControls } from './useLiveTailControls'; import { getFieldLinksForExplore } from './utils/links'; -interface LogsContainerProps { +interface LogsContainerProps extends PropsFromRedux { exploreId: ExploreId; scanRange?: RawTimeRange; width: number; @@ -26,7 +27,7 @@ interface LogsContainerProps { onStopScanning: () => void; } -export class LogsContainer extends PureComponent { +export class LogsContainer extends PureComponent { onChangeTime = (absoluteRange: AbsoluteTimeRange) => { const { exploreId, updateTimeRange } = this.props; updateTimeRange({ exploreId, absoluteRange }); @@ -77,6 +78,8 @@ export class LogsContainer extends PureComponent addResultsToCache(exploreId)} + clearCache={() => clearCache(exploreId)} /> @@ -180,6 +185,8 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string } const mapDispatchToProps = { updateTimeRange, splitOpen, + addResultsToCache, + clearCache, }; const connector = connect(mapStateToProps, mapDispatchToProps); diff --git a/public/app/features/explore/LogsNavigation.test.tsx b/public/app/features/explore/LogsNavigation.test.tsx index 6570f5391fb..9b0772e1bd5 100644 --- a/public/app/features/explore/LogsNavigation.test.tsx +++ b/public/app/features/explore/LogsNavigation.test.tsx @@ -15,6 +15,8 @@ const setup = (propOverrides?: object) => { visibleRange: { from: 1619081941000, to: 1619081945930 }, onChangeTime: jest.fn(), scrollToTopLogs: jest.fn(), + addResultsToCache: jest.fn(), + clearCache: jest.fn(), ...propOverrides, }; diff --git a/public/app/features/explore/LogsNavigation.tsx b/public/app/features/explore/LogsNavigation.tsx index 98772edfe98..5eb09377ff7 100644 --- a/public/app/features/explore/LogsNavigation.tsx +++ b/public/app/features/explore/LogsNavigation.tsx @@ -14,6 +14,8 @@ type Props = { logsSortOrder?: LogsSortOrder | null; onChangeTime: (range: AbsoluteTimeRange) => void; scrollToTopLogs: () => void; + addResultsToCache: () => void; + clearCache: () => void; }; export type LogsPage = { @@ -30,6 +32,8 @@ function LogsNavigation({ scrollToTopLogs, visibleRange, queries, + clearCache, + addResultsToCache, }: Props) { const [pages, setPages] = useState([]); const [currentPageIndex, setCurrentPageIndex] = useState(0); @@ -53,6 +57,7 @@ function LogsNavigation({ let newPages: LogsPage[] = []; // We want to start new pagination if queries change or if absolute range is different than expected if (!isEqual(expectedRangeRef.current, absoluteRange) || !isEqual(expectedQueriesRef.current, queries)) { + clearCache(); setPages([newPage]); setCurrentPageIndex(0); expectedQueriesRef.current = queries; @@ -72,7 +77,14 @@ function LogsNavigation({ const index = newPages.findIndex((page) => page.queryRange.to === absoluteRange.to); setCurrentPageIndex(index); } - }, [visibleRange, absoluteRange, logsSortOrder, queries]); + addResultsToCache(); + }, [visibleRange, absoluteRange, logsSortOrder, queries, clearCache, addResultsToCache]); + + useEffect(() => { + return () => clearCache(); + // We can't enforce the eslint rule here because we only want to run when component unmounts. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const changeTime = ({ from, to }: AbsoluteTimeRange) => { expectedRangeRef.current = { from, to }; diff --git a/public/app/features/explore/state/explorePane.test.ts b/public/app/features/explore/state/explorePane.test.ts index 6b2d28e7f8d..11a0aefaac3 100644 --- a/public/app/features/explore/state/explorePane.test.ts +++ b/public/app/features/explore/state/explorePane.test.ts @@ -37,6 +37,7 @@ const defaultInitialState = { label: 'Off', value: 0, }, + cache: [], }, }, }; diff --git a/public/app/features/explore/state/explorePane.ts b/public/app/features/explore/state/explorePane.ts index f88eb1a3425..03c67cb605b 100644 --- a/public/app/features/explore/state/explorePane.ts +++ b/public/app/features/explore/state/explorePane.ts @@ -238,6 +238,7 @@ export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), ac datasourceMissing: !datasourceInstance, queryResponse: createEmptyQueryResponse(), logsHighlighterExpressions: undefined, + cache: [], }; } diff --git a/public/app/features/explore/state/query.test.ts b/public/app/features/explore/state/query.test.ts index 99b90a35404..d3370872a51 100644 --- a/public/app/features/explore/state/query.test.ts +++ b/public/app/features/explore/state/query.test.ts @@ -1,5 +1,7 @@ import { addQueryRowAction, + addResultsToCache, + clearCache, cancelQueries, cancelQueriesAction, queryReducer, @@ -10,7 +12,17 @@ import { } from './query'; import { ExploreId, ExploreItemState } from 'app/types'; import { interval, of } from 'rxjs'; -import { ArrayVector, DataQueryResponse, DefaultTimeZone, MutableDataFrame, RawTimeRange, toUtc } from '@grafana/data'; +import { + ArrayVector, + DataQueryResponse, + DefaultTimeZone, + MutableDataFrame, + RawTimeRange, + toUtc, + PanelData, + DataFrame, + LoadingState, +} from '@grafana/data'; import { thunkTester } from 'test/core/thunk/thunkTester'; import { makeExplorePaneState } from './utils'; import { reducerTester } from '../../../../test/core/redux/reducerTester'; @@ -50,6 +62,7 @@ const defaultInitialState = { label: 'Off', value: 0, }, + cache: [], }, }, }; @@ -213,4 +226,95 @@ describe('reducer', () => { }); }); }); + + describe('caching', () => { + it('should add response to cache', async () => { + const store = configureStore({ + ...(defaultInitialState as any), + explore: { + [ExploreId.left]: { + ...defaultInitialState.explore[ExploreId.left], + queryResponse: { + series: [{ name: 'test name' }] as DataFrame[], + state: LoadingState.Done, + } as PanelData, + absoluteRange: { from: 1621348027000, to: 1621348050000 }, + }, + }, + }); + + await store.dispatch(addResultsToCache(ExploreId.left)); + + expect(store.getState().explore[ExploreId.left].cache).toEqual([ + { key: 'from=1621348027000&to=1621348050000', value: { series: [{ name: 'test name' }], state: 'Done' } }, + ]); + }); + + it('should not add response to cache if response is still loading', async () => { + const store = configureStore({ + ...(defaultInitialState as any), + explore: { + [ExploreId.left]: { + ...defaultInitialState.explore[ExploreId.left], + queryResponse: { series: [{ name: 'test name' }] as DataFrame[], state: LoadingState.Loading } as PanelData, + absoluteRange: { from: 1621348027000, to: 1621348050000 }, + }, + }, + }); + + await store.dispatch(addResultsToCache(ExploreId.left)); + + expect(store.getState().explore[ExploreId.left].cache).toEqual([]); + }); + + it('should not add duplicate response to cache', async () => { + const store = configureStore({ + ...(defaultInitialState as any), + explore: { + [ExploreId.left]: { + ...defaultInitialState.explore[ExploreId.left], + queryResponse: { + series: [{ name: 'test name' }] as DataFrame[], + state: LoadingState.Done, + } as PanelData, + absoluteRange: { from: 1621348027000, to: 1621348050000 }, + cache: [ + { + key: 'from=1621348027000&to=1621348050000', + value: { series: [{ name: 'old test name' }], state: LoadingState.Done }, + }, + ], + }, + }, + }); + + await store.dispatch(addResultsToCache(ExploreId.left)); + + expect(store.getState().explore[ExploreId.left].cache).toHaveLength(1); + expect(store.getState().explore[ExploreId.left].cache).toEqual([ + { key: 'from=1621348027000&to=1621348050000', value: { series: [{ name: 'old test name' }], state: 'Done' } }, + ]); + }); + + it('should clear cache', async () => { + const store = configureStore({ + ...(defaultInitialState as any), + explore: { + [ExploreId.left]: { + ...defaultInitialState.explore[ExploreId.left], + cache: [ + { + key: 'from=1621348027000&to=1621348050000', + value: { series: [{ name: 'old test name' }], state: 'Done' }, + }, + ], + }, + }, + }); + + await store.dispatch(clearCache(ExploreId.left)); + + expect(store.getState().explore[ExploreId.left].cache).toEqual([]); + }); + }); }); diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index 13ca14bd7a5..79ebde5bc71 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -1,5 +1,5 @@ -import { map, mergeMap, throttleTime } from 'rxjs/operators'; -import { identity, Unsubscribable } from 'rxjs'; +import { mergeMap, throttleTime } from 'rxjs/operators'; +import { identity, Unsubscribable, of } from 'rxjs'; import { DataQuery, DataQueryErrorType, @@ -27,19 +27,14 @@ import { ExploreId, QueryOptions } from 'app/types/explore'; import { getTimeZone } from 'app/features/profile/state/selectors'; import { getShiftedTimeRange } from 'app/core/utils/timePicker'; import { notifyApp } from '../../../core/actions'; -import { preProcessPanelData, runRequest } from '../../query/state/runRequest'; -import { - decorateWithFrameTypeMetadata, - decorateWithGraphResult, - decorateWithLogsResult, - decorateWithTableResult, -} from '../utils/decorators'; +import { runRequest } from '../../query/state/runRequest'; +import { decorateData } from '../utils/decorators'; import { createErrorNotification } from '../../../core/copy/appNotification'; import { richHistoryUpdatedAction, stateSave } from './main'; import { AnyAction, createAction, PayloadAction } from '@reduxjs/toolkit'; import { updateTime } from './time'; import { historyUpdatedAction } from './history'; -import { createEmptyQueryResponse } from './utils'; +import { createEmptyQueryResponse, createCacheKey, getResultsFromCache } from './utils'; // // Actions and Payloads @@ -164,6 +159,24 @@ export interface ScanStopPayload { } export const scanStopAction = createAction('explore/scanStop'); +/** + * Adds query results to cache. + * This is currently used to cache last 5 query results for log queries run from logs navigation (pagination). + */ +export interface AddResultsToCachePayload { + exploreId: ExploreId; + cacheKey: string; + queryResponse: PanelData; +} +export const addResultsToCacheAction = createAction('explore/addResultsToCache'); + +/** + * Clears cache. + */ +export interface ClearCachePayload { + exploreId: ExploreId; +} +export const clearCacheAction = createAction('explore/clearCache'); // // Action creators // @@ -309,100 +322,115 @@ export const runQueries = (exploreId: ExploreId, options?: { replaceUrl?: boolea history, refreshInterval, absoluteRange, + cache, } = exploreItemState; + let newQuerySub; - if (!hasNonEmptyQuery(queries)) { - dispatch(clearQueriesAction({ exploreId })); - dispatch(stateSave({ replace: options?.replaceUrl })); // Remember to save to state and update location - return; - } + const cachedValue = getResultsFromCache(cache, absoluteRange); - if (!datasourceInstance) { - return; - } - - // Some datasource's query builders allow per-query interval limits, - // but we're using the datasource interval limit for now - const minInterval = datasourceInstance?.interval; - - stopQueryState(querySubscription); - - const datasourceId = datasourceInstance?.meta.id; - - const queryOptions: QueryOptions = { - minInterval, - // maxDataPoints is used in: - // Loki - used for logs streaming for buffer size, with undefined it falls back to datasource config if it supports that. - // Elastic - limits the number of datapoints for the counts query and for logs it has hardcoded limit. - // Influx - used to correctly display logs in graph - // TODO:unification - // maxDataPoints: mode === ExploreMode.Logs && datasourceId === 'loki' ? undefined : containerWidth, - maxDataPoints: containerWidth, - liveStreaming: live, - }; - - const datasourceName = datasourceInstance.name; - const timeZone = getTimeZone(getState().user); - const transaction = buildQueryTransaction(queries, queryOptions, range, scanning, timeZone); - - let firstResponse = true; - dispatch(changeLoadingStateAction({ exploreId, loadingState: LoadingState.Loading })); - - const newQuerySub = runRequest(datasourceInstance, transaction.request) - .pipe( - // Simple throttle for live tailing, in case of > 1000 rows per interval we spend about 200ms on processing and - // rendering. In case this is optimized this can be tweaked, but also it should be only as fast as user - // actually can see what is happening. - live ? throttleTime(500) : identity, - map((data: PanelData) => preProcessPanelData(data, queryResponse)), - map(decorateWithFrameTypeMetadata), - map(decorateWithGraphResult), - map(decorateWithLogsResult({ absoluteRange, refreshInterval, queries })), - mergeMap(decorateWithTableResult) - ) - .subscribe( - (data) => { - if (!data.error && firstResponse) { - // Side-effect: Saving history in localstorage - const nextHistory = updateHistory(history, datasourceId, queries); - const nextRichHistory = addToRichHistory( - richHistory || [], - datasourceId, - datasourceName, - queries, - false, - '', - '' - ); - dispatch(historyUpdatedAction({ exploreId, history: nextHistory })); - dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory })); - - // We save queries to the URL here so that only successfully run queries change the URL. - dispatch(stateSave({ replace: options?.replaceUrl })); + // If we have results saved in cache, we are going to use those results instead of running queries + if (cachedValue) { + newQuerySub = of(cachedValue) + .pipe(mergeMap((data: PanelData) => decorateData(data, queryResponse, absoluteRange, refreshInterval, queries))) + .subscribe((data) => { + if (!data.error) { + dispatch(stateSave()); } - firstResponse = false; - dispatch(queryStreamUpdatedAction({ exploreId, response: data })); + }); - // Keep scanning for results if this was the last scanning transaction - if (getState().explore[exploreId]!.scanning) { - if (data.state === LoadingState.Done && data.series.length === 0) { - const range = getShiftedTimeRange(-1, getState().explore[exploreId]!.range); - dispatch(updateTime({ exploreId, absoluteRange: range })); - dispatch(runQueries(exploreId)); - } else { - // We can stop scanning if we have a result - dispatch(scanStopAction({ exploreId })); + // If we don't have resuls saved in cache, run new queries + } else { + if (!hasNonEmptyQuery(queries)) { + dispatch(clearQueriesAction({ exploreId })); + dispatch(stateSave({ replace: options?.replaceUrl })); // Remember to save to state and update location + return; + } + + if (!datasourceInstance) { + return; + } + + // Some datasource's query builders allow per-query interval limits, + // but we're using the datasource interval limit for now + const minInterval = datasourceInstance?.interval; + + stopQueryState(querySubscription); + + const datasourceId = datasourceInstance?.meta.id; + + const queryOptions: QueryOptions = { + minInterval, + // maxDataPoints is used in: + // Loki - used for logs streaming for buffer size, with undefined it falls back to datasource config if it supports that. + // Elastic - limits the number of datapoints for the counts query and for logs it has hardcoded limit. + // Influx - used to correctly display logs in graph + // TODO:unification + // maxDataPoints: mode === ExploreMode.Logs && datasourceId === 'loki' ? undefined : containerWidth, + maxDataPoints: containerWidth, + liveStreaming: live, + }; + + const datasourceName = datasourceInstance.name; + const timeZone = getTimeZone(getState().user); + const transaction = buildQueryTransaction(queries, queryOptions, range, scanning, timeZone); + + let firstResponse = true; + dispatch(changeLoadingStateAction({ exploreId, loadingState: LoadingState.Loading })); + + newQuerySub = runRequest(datasourceInstance, transaction.request) + .pipe( + // Simple throttle for live tailing, in case of > 1000 rows per interval we spend about 200ms on processing and + // rendering. In case this is optimized this can be tweaked, but also it should be only as fast as user + // actually can see what is happening. + live ? throttleTime(500) : identity, + mergeMap((data: PanelData) => decorateData(data, queryResponse, absoluteRange, refreshInterval, queries)) + ) + .subscribe( + (data) => { + if (!data.error && firstResponse) { + // Side-effect: Saving history in localstorage + const nextHistory = updateHistory(history, datasourceId, queries); + const nextRichHistory = addToRichHistory( + richHistory || [], + datasourceId, + datasourceName, + queries, + false, + '', + '' + ); + dispatch(historyUpdatedAction({ exploreId, history: nextHistory })); + dispatch(richHistoryUpdatedAction({ richHistory: nextRichHistory })); + + // We save queries to the URL here so that only successfully run queries change the URL. + dispatch(stateSave({ replace: options?.replaceUrl })); } + + firstResponse = false; + + dispatch(queryStreamUpdatedAction({ exploreId, response: data })); + + // Keep scanning for results if this was the last scanning transaction + if (getState().explore[exploreId]!.scanning) { + if (data.state === LoadingState.Done && data.series.length === 0) { + const range = getShiftedTimeRange(-1, getState().explore[exploreId]!.range); + dispatch(updateTime({ exploreId, absoluteRange: range })); + dispatch(runQueries(exploreId)); + } else { + // We can stop scanning if we have a result + dispatch(scanStopAction({ exploreId })); + } + } + }, + (error) => { + dispatch(notifyApp(createErrorNotification('Query processing error', error))); + dispatch(changeLoadingStateAction({ exploreId, loadingState: LoadingState.Error })); + console.error(error); } - }, - (error) => { - dispatch(notifyApp(createErrorNotification('Query processing error', error))); - dispatch(changeLoadingStateAction({ exploreId, loadingState: LoadingState.Error })); - console.error(error); - } - ); + ); + } dispatch(queryStoreSubscriptionAction({ exploreId, querySubscription: newQuerySub })); }; @@ -439,6 +467,25 @@ export function scanStart(exploreId: ExploreId): ThunkResult { }; } +export function addResultsToCache(exploreId: ExploreId): ThunkResult { + return (dispatch, getState) => { + const queryResponse = getState().explore[exploreId]!.queryResponse; + const absoluteRange = getState().explore[exploreId]!.absoluteRange; + const cacheKey = createCacheKey(absoluteRange); + + // Save results to cache only when all results recived and loading is done + if (queryResponse.state === LoadingState.Done) { + dispatch(addResultsToCacheAction({ exploreId, cacheKey, queryResponse })); + } + }; +} + +export function clearCache(exploreId: ExploreId): ThunkResult { + return (dispatch, getState) => { + dispatch(clearCacheAction({ exploreId })); + }; +} + // // Reducer // @@ -629,6 +676,32 @@ export const queryReducer = (state: ExploreItemState, action: AnyAction): Explor }; } + if (addResultsToCacheAction.match(action)) { + const CACHE_LIMIT = 5; + const { cache } = state; + const { queryResponse, cacheKey } = action.payload; + + let newCache = [...cache]; + const isDuplicateKey = newCache.some((c) => c.key === cacheKey); + + if (!isDuplicateKey) { + const newCacheItem = { key: cacheKey, value: queryResponse }; + newCache = [newCacheItem, ...newCache].slice(0, CACHE_LIMIT); + } + + return { + ...state, + cache: newCache, + }; + } + + if (clearCacheAction.match(action)) { + return { + ...state, + cache: [], + }; + } + return state; }; diff --git a/public/app/features/explore/state/utils.ts b/public/app/features/explore/state/utils.ts index 8b72d814782..8fb96f4a303 100644 --- a/public/app/features/explore/state/utils.ts +++ b/public/app/features/explore/state/utils.ts @@ -6,6 +6,7 @@ import { HistoryItem, LoadingState, PanelData, + AbsoluteTimeRange, } from '@grafana/data'; import { ExploreItemState } from 'app/types/explore'; @@ -49,6 +50,7 @@ export const makeExplorePaneState = (): ExploreItemState => ({ graphResult: null, logsResult: null, eventBridge: (null as unknown) as EventBusExtended, + cache: [], }); export const createEmptyQueryResponse = (): PanelData => ({ @@ -96,3 +98,25 @@ export function getUrlStateFromPaneState(pane: ExploreItemState): ExploreUrlStat range: toRawTimeRange(pane.range), }; } + +export function createCacheKey(absRange: AbsoluteTimeRange) { + const params = { + from: absRange.from, + to: absRange.to, + }; + + const cacheKey = Object.entries(params) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v.toString())}`) + .join('&'); + return cacheKey; +} + +export function getResultsFromCache( + cache: Array<{ key: string; value: PanelData }>, + absoluteRange: AbsoluteTimeRange +): PanelData | undefined { + const cacheKey = createCacheKey(absoluteRange); + const cacheIdx = cache.findIndex((c) => c.key === cacheKey); + const cacheValue = cacheIdx >= 0 ? cache[cacheIdx].value : undefined; + return cacheValue; +} diff --git a/public/app/features/explore/utils/decorators.ts b/public/app/features/explore/utils/decorators.ts index dfd2fd4f135..60cd97d5da3 100644 --- a/public/app/features/explore/utils/decorators.ts +++ b/public/app/features/explore/utils/decorators.ts @@ -11,10 +11,11 @@ import { import { config } from '@grafana/runtime'; import { groupBy } from 'lodash'; import { Observable, of } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { map, mergeMap } from 'rxjs/operators'; import { dataFrameToLogsModel } from '../../../core/logs_model'; import { refreshIntervalToSortOrder } from '../../../core/utils/explore'; import { ExplorePanelData } from '../../../types'; +import { preProcessPanelData } from '../../query/state/runRequest'; /** * When processing response first we try to determine what kind of dataframes we got as one query can return multiple @@ -154,6 +155,23 @@ export const decorateWithLogsResult = ( return { ...data, logsResult }; }; +// decorateData applies all decorators +export function decorateData( + data: PanelData, + queryResponse: PanelData, + absoluteRange: AbsoluteTimeRange, + refreshInterval: string | undefined, + queries: DataQuery[] | undefined +): Observable { + return of(data).pipe( + map((data: PanelData) => preProcessPanelData(data, queryResponse)), + map(decorateWithFrameTypeMetadata), + map(decorateWithGraphResult), + map(decorateWithLogsResult({ absoluteRange, refreshInterval, queries })), + mergeMap(decorateWithTableResult) + ); +} + /** * Check if frame contains time series, which for our purpose means 1 time column and 1 or more numeric columns. */ diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index e4f913cca21..506ba3bcbe0 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -149,6 +149,13 @@ export interface ExploreItemState { showTable?: boolean; showTrace?: boolean; showNodeGraph?: boolean; + + /** + * We are using caching to store query responses of queries run from logs navigation. + * In logs navigation, we do pagination and we don't want our users to unnecessarily run the same queries that they've run just moments before. + * We are currently caching last 5 query responses. + */ + cache: Array<{ key: string; value: PanelData }>; } export interface ExploreUpdateState { From e9e438ee2f720231c412e0693342f2f3d6055d5a Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Mon, 24 May 2021 15:09:33 +0300 Subject: [PATCH 27/43] Form: Expose all return values from useForm (#34380) --- packages/grafana-ui/src/components/Forms/Form.tsx | 4 ++-- packages/grafana-ui/src/types/forms.ts | 5 +---- .../features/alerting/components/NotificationChannelForm.tsx | 5 +++-- .../alerting/components/NotificationChannelOptions.tsx | 5 +++-- .../manage-dashboards/components/ImportDashboardForm.tsx | 2 +- 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/grafana-ui/src/components/Forms/Form.tsx b/packages/grafana-ui/src/components/Forms/Form.tsx index c3b4eeb0913..b10051597ae 100644 --- a/packages/grafana-ui/src/components/Forms/Form.tsx +++ b/packages/grafana-ui/src/components/Forms/Form.tsx @@ -24,7 +24,7 @@ export function Form({ maxWidth = 600, ...htmlProps }: FormProps) { - const { handleSubmit, register, control, trigger, getValues, formState, watch, setValue } = useForm({ + const { handleSubmit, trigger, formState, ...rest } = useForm({ mode: validateOn, defaultValues, }); @@ -45,7 +45,7 @@ export function Form({ onSubmit={handleSubmit(onSubmit)} {...htmlProps} > - {children({ register, errors: formState.errors, control, getValues, formState, watch, setValue })} + {children({ errors: formState.errors, formState, ...rest })} ); } diff --git a/packages/grafana-ui/src/types/forms.ts b/packages/grafana-ui/src/types/forms.ts index 15684115dab..c757bee9223 100644 --- a/packages/grafana-ui/src/types/forms.ts +++ b/packages/grafana-ui/src/types/forms.ts @@ -1,10 +1,7 @@ import { UseFormReturn, FieldValues, FieldErrors } from 'react-hook-form'; export { SubmitHandler as FormsOnSubmit, FieldErrors as FormFieldErrors } from 'react-hook-form'; -export type FormAPI = Pick< - UseFormReturn, - 'register' | 'control' | 'formState' | 'getValues' | 'watch' | 'setValue' -> & { +export type FormAPI = Omit, 'trigger' | 'handleSubmit'> & { errors: FieldErrors; }; diff --git a/public/app/features/alerting/components/NotificationChannelForm.tsx b/public/app/features/alerting/components/NotificationChannelForm.tsx index 14b9fe9979f..a9abc49a523 100644 --- a/public/app/features/alerting/components/NotificationChannelForm.tsx +++ b/public/app/features/alerting/components/NotificationChannelForm.tsx @@ -9,7 +9,8 @@ import { ChannelSettings } from './ChannelSettings'; import config from 'app/core/config'; -interface Props extends Omit, 'formState' | 'setValue'> { +interface Props + extends Pick, 'control' | 'errors' | 'register' | 'watch' | 'getValues'> { selectableChannels: Array>; selectedChannel?: NotificationChannelType; imageRendererAvailable: boolean; @@ -19,7 +20,7 @@ interface Props extends Omit, 'formState' | 'set } export interface NotificationSettingsProps - extends Omit, 'formState' | 'watch' | 'getValues' | 'setValue'> { + extends Pick, 'control' | 'errors' | 'register'> { currentFormValues: NotificationChannelDTO; } diff --git a/public/app/features/alerting/components/NotificationChannelOptions.tsx b/public/app/features/alerting/components/NotificationChannelOptions.tsx index f268a4d5886..9cdb59a61d5 100644 --- a/public/app/features/alerting/components/NotificationChannelOptions.tsx +++ b/public/app/features/alerting/components/NotificationChannelOptions.tsx @@ -1,10 +1,11 @@ import React, { FC } from 'react'; import { SelectableValue } from '@grafana/data'; -import { Button, Checkbox, Field, FormAPI, Input } from '@grafana/ui'; +import { Button, Checkbox, Field, Input } from '@grafana/ui'; import { OptionElement } from './OptionElement'; import { NotificationChannelDTO, NotificationChannelOption, NotificationChannelSecureFields } from '../../../types'; +import { NotificationSettingsProps } from './NotificationChannelForm'; -interface Props extends Omit, 'formState' | 'getValues' | 'watch' | 'setValue'> { +interface Props extends NotificationSettingsProps { selectedChannelOptions: NotificationChannelOption[]; currentFormValues: NotificationChannelDTO; secureFields: NotificationChannelSecureFields; diff --git a/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx b/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx index 43ba364b8ba..865a75a07f5 100644 --- a/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx +++ b/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx @@ -15,7 +15,7 @@ import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import { DashboardInput, DashboardInputs, DataSourceInput, ImportDashboardDTO } from '../state/reducers'; import { validateTitle, validateUid } from '../utils/validation'; -interface Props extends Omit, 'formState' | 'setValue'> { +interface Props extends Pick, 'register' | 'errors' | 'control' | 'getValues' | 'watch'> { uidReset: boolean; inputs: DashboardInputs; initialFolderId: number; From 6970c9ebfd140c0b96c4ffa02788de34773eaa1a Mon Sep 17 00:00:00 2001 From: Dimitris Sotirakis Date: Mon, 24 May 2021 15:17:40 +0300 Subject: [PATCH 28/43] Use `laher/mergefs` - remove custom code (#34571) --- go.mod | 1 + go.sum | 4 + pkg/cmd/grafana-cli/commands/mergefs.go | 92 ------------------- pkg/cmd/grafana-cli/commands/mergefs_test.go | 68 -------------- .../scuemata_validation_command_test.go | 7 +- 5 files changed, 9 insertions(+), 163 deletions(-) delete mode 100644 pkg/cmd/grafana-cli/commands/mergefs.go delete mode 100644 pkg/cmd/grafana-cli/commands/mergefs_test.go diff --git a/go.mod b/go.mod index 7761246b9ca..4f382baddf2 100644 --- a/go.mod +++ b/go.mod @@ -62,6 +62,7 @@ require ( github.com/jmespath/go-jmespath v0.4.0 github.com/json-iterator/go v1.1.11 github.com/jung-kurt/gofpdf v1.16.2 + github.com/laher/mergefs v0.1.1 github.com/lib/pq v1.10.0 github.com/linkedin/goavro/v2 v2.10.0 github.com/magefile/mage v1.11.0 diff --git a/go.sum b/go.sum index b200f536f05..7c57193f70b 100644 --- a/go.sum +++ b/go.sum @@ -1203,6 +1203,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/labstack/echo/v4 v4.1.11/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g= github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/laher/mergefs v0.1.1 h1:nV2bTS57vrmbMxeR6uvJpI8LyGl3QHj4bLBZO3aUV58= +github.com/laher/mergefs v0.1.1/go.mod h1:FSY1hYy94on4Tz60waRMGdO1awwS23BacqJlqf9lJ9Q= github.com/lann/builder v0.0.0-20150808151131-f22ce00fd939/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= @@ -1243,6 +1245,8 @@ github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= +github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/moq v0.0.0-20190312154309-6cfb0558e1bd/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ= github.com/mattermost/xml-roundtrip-validator v0.0.0-20201213122252-bcd7e1b9601e h1:qqXczln0qwkVGcpQ+sQuPOVntt2FytYarXXxYSNJkgw= github.com/mattermost/xml-roundtrip-validator v0.0.0-20201213122252-bcd7e1b9601e/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= diff --git a/pkg/cmd/grafana-cli/commands/mergefs.go b/pkg/cmd/grafana-cli/commands/mergefs.go deleted file mode 100644 index c31449f5e94..00000000000 --- a/pkg/cmd/grafana-cli/commands/mergefs.go +++ /dev/null @@ -1,92 +0,0 @@ -package commands - -import ( - "errors" - "io/fs" - "os" - "sort" - - "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" -) - -// MergeFS contains a slice of different filesystems that can be merged together -type MergeFS struct { - filesystems []fs.FS -} - -// Merge filesystems -func Merge(filesystems ...fs.FS) fs.FS { - return MergeFS{filesystems: filesystems} -} - -// Open opens the named file. -func (mfs MergeFS) Open(name string) (fs.File, error) { - for _, filesystem := range mfs.filesystems { - file, err := filesystem.Open(name) - if err == nil { - return file, nil - } - } - return nil, os.ErrNotExist -} - -// ReadDir reads from the directory, and produces a DirEntry array of different -// directories. -// -// It iterates through all different filesystems that exist in the mfs MergeFS -// filesystem slice and it identifies overlapping directories that exist in different -// filesystems -func (mfs MergeFS) ReadDir(name string) ([]fs.DirEntry, error) { - dirsMap := make(map[string]fs.DirEntry) - for _, filesystem := range mfs.filesystems { - if fsys, ok := filesystem.(fs.ReadDirFS); ok { - dir, err := fsys.ReadDir(name) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - logger.Debugf("directory in filepath %s was not found in filesystem", name) - continue - } - return nil, err - } - for _, v := range dir { - if _, ok := dirsMap[v.Name()]; !ok { - dirsMap[v.Name()] = v - } - } - continue - } - - file, err := filesystem.Open(name) - if err != nil { - logger.Debugf("filepath %s was not found in filesystem", name) - continue - } - - dir, ok := file.(fs.ReadDirFile) - if !ok { - return nil, &fs.PathError{Op: "readdir", Path: name, Err: errors.New("not implemented")} - } - - fsDirs, err := dir.ReadDir(-1) - if err != nil { - return nil, err - } - sort.Slice(fsDirs, func(i, j int) bool { return fsDirs[i].Name() < fsDirs[j].Name() }) - for _, v := range fsDirs { - if _, ok := dirsMap[v.Name()]; !ok { - dirsMap[v.Name()] = v - } - } - if err := file.Close(); err != nil { - logger.Error("failed to close file", "err", err) - } - } - dirs := make([]fs.DirEntry, 0, len(dirsMap)) - - for _, value := range dirsMap { - dirs = append(dirs, value) - } - - sort.Slice(dirs, func(i, j int) bool { return dirs[i].Name() < dirs[j].Name() }) - return dirs, nil -} diff --git a/pkg/cmd/grafana-cli/commands/mergefs_test.go b/pkg/cmd/grafana-cli/commands/mergefs_test.go deleted file mode 100644 index e79503db4be..00000000000 --- a/pkg/cmd/grafana-cli/commands/mergefs_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package commands - -import ( - "io/fs" - "os" - "path/filepath" - "testing" - "testing/fstest" - - "github.com/stretchr/testify/require" -) - -func TestMergeFS(t *testing.T) { - var filePaths = []struct { - path string - dirArrayLength int - child string - }{ - // MapFS takes in account the current directory in addition to all included directories and produces a "" dir - {"a", 1, "z"}, - {"a/z", 1, "bar.cue"}, - {"b", 1, "z"}, - {"b/z", 1, "foo.cue"}, - } - - tempDir := os.DirFS(filepath.Join("testdata", "mergefs")) - a := fstest.MapFS{ - "a": &fstest.MapFile{Mode: fs.ModeDir}, - "a/z": &fstest.MapFile{Mode: fs.ModeDir}, - "a/z/bar.cue": &fstest.MapFile{Data: []byte("bar")}, - } - - filesystem := Merge(tempDir, a) - - t.Run("testing mergefs.ReadDir", func(t *testing.T) { - for _, fp := range filePaths { - t.Run("testing path: "+fp.path, func(t *testing.T) { - dirs, err := fs.ReadDir(filesystem, fp.path) - require.NoError(t, err) - require.Len(t, dirs, fp.dirArrayLength) - - for i := 0; i < len(dirs); i++ { - require.Equal(t, dirs[i].Name(), fp.child) - } - }) - } - }) - - t.Run("testing mergefs.Open", func(t *testing.T) { - data := make([]byte, 3) - file, err := filesystem.Open("a/z/bar.cue") - require.NoError(t, err) - - _, err = file.Read(data) - require.NoError(t, err) - require.Equal(t, "bar", string(data)) - - file, err = filesystem.Open("b/z/foo.cue") - require.NoError(t, err) - - _, err = file.Read(data) - require.NoError(t, err) - require.Equal(t, "foo", string(data)) - - err = file.Close() - require.NoError(t, err) - }) -} diff --git a/pkg/cmd/grafana-cli/commands/scuemata_validation_command_test.go b/pkg/cmd/grafana-cli/commands/scuemata_validation_command_test.go index 2f9294daf99..cca7ed473d7 100644 --- a/pkg/cmd/grafana-cli/commands/scuemata_validation_command_test.go +++ b/pkg/cmd/grafana-cli/commands/scuemata_validation_command_test.go @@ -8,6 +8,7 @@ import ( "testing/fstest" "github.com/grafana/grafana/pkg/schema/load" + "github.com/laher/mergefs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -35,7 +36,7 @@ func TestValidateScuemataBasics(t *testing.T) { filesystem := fstest.MapFS{ "cue/data/gen.cue": &fstest.MapFile{Data: genCue}, } - mergedFS := Merge(filesystem, defaultBaseLoadPaths.BaseCueFS) + mergedFS := mergefs.Merge(filesystem, defaultBaseLoadPaths.BaseCueFS) var baseLoadPaths = load.BaseLoadPaths{ BaseCueFS: mergedFS, @@ -53,7 +54,7 @@ func TestValidateScuemataBasics(t *testing.T) { filesystem := fstest.MapFS{ "cue/data/gen.cue": &fstest.MapFile{Data: genCue}, } - mergedFS := Merge(filesystem, defaultBaseLoadPaths.BaseCueFS) + mergedFS := mergefs.Merge(filesystem, defaultBaseLoadPaths.BaseCueFS) var baseLoadPaths = load.BaseLoadPaths{ BaseCueFS: mergedFS, @@ -78,7 +79,7 @@ func TestValidateScuemataBasics(t *testing.T) { "valid.json": &fstest.MapFile{Data: validPanel}, "invalid.json": &fstest.MapFile{Data: invalidPanel}, } - mergedFS := Merge(filesystem, defaultBaseLoadPaths.BaseCueFS) + mergedFS := mergefs.Merge(filesystem, defaultBaseLoadPaths.BaseCueFS) var baseLoadPaths = load.BaseLoadPaths{ BaseCueFS: mergedFS, From a0b78313f3b863b8a1504b59b97333a4910fe00c Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Mon, 24 May 2021 14:20:11 +0200 Subject: [PATCH 29/43] Alerting: Remove unused NGAlerting components (#34568) --- .../features/alerting/AlertRuleList.test.tsx | 1 - .../app/features/alerting/AlertRuleList.tsx | 24 +-- .../features/alerting/NextGenAlertingPage.tsx | 179 ---------------- .../components/AlertDefinitionItem.tsx | 55 ----- .../components/AlertDefinitionOptions.tsx | 83 -------- .../components/AlertingQueryPreview.tsx | 118 ----------- .../alerting/components/EmptyState.tsx | 37 ---- .../components/PreviewInstancesTab.tsx | 23 -- .../alerting/components/PreviewQueryTab.tsx | 74 ------- public/app/features/alerting/state/actions.ts | 198 +----------------- .../app/features/alerting/state/reducers.ts | 74 +------ .../app/features/alerting/state/selectors.ts | 24 +-- public/app/routes/routes.tsx | 14 -- public/app/types/alerting.ts | 39 +--- public/app/types/store.ts | 3 +- 15 files changed, 18 insertions(+), 928 deletions(-) delete mode 100644 public/app/features/alerting/NextGenAlertingPage.tsx delete mode 100644 public/app/features/alerting/components/AlertDefinitionItem.tsx delete mode 100644 public/app/features/alerting/components/AlertDefinitionOptions.tsx delete mode 100644 public/app/features/alerting/components/AlertingQueryPreview.tsx delete mode 100644 public/app/features/alerting/components/EmptyState.tsx delete mode 100644 public/app/features/alerting/components/PreviewInstancesTab.tsx delete mode 100644 public/app/features/alerting/components/PreviewQueryTab.tsx diff --git a/public/app/features/alerting/AlertRuleList.test.tsx b/public/app/features/alerting/AlertRuleList.test.tsx index 19621dd3ed0..935b3d309db 100644 --- a/public/app/features/alerting/AlertRuleList.test.tsx +++ b/public/app/features/alerting/AlertRuleList.test.tsx @@ -25,7 +25,6 @@ const setup = (propOverrides?: object) => { togglePauseAlertRule: jest.fn(), search: '', isLoading: false, - ngAlertDefinitions: [], }; Object.assign(props, propOverrides); diff --git a/public/app/features/alerting/AlertRuleList.tsx b/public/app/features/alerting/AlertRuleList.tsx index cd9c75bbec3..b44a2aba32a 100644 --- a/public/app/features/alerting/AlertRuleList.tsx +++ b/public/app/features/alerting/AlertRuleList.tsx @@ -5,7 +5,7 @@ import Page from 'app/core/components/Page/Page'; import AlertRuleItem from './AlertRuleItem'; import appEvents from 'app/core/app_events'; import { getNavModel } from 'app/core/selectors/navModel'; -import { AlertDefinition, AlertRule, StoreState } from 'app/types'; +import { AlertRule, StoreState } from 'app/types'; import { getAlertRulesAsync, togglePauseAlertRule } from './state/actions'; import { getAlertRuleItems, getSearchQuery } from './state/selectors'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; @@ -13,7 +13,6 @@ import { SelectableValue } from '@grafana/data'; import { config, locationService } from '@grafana/runtime'; import { setSearchQuery } from './state/reducers'; import { Button, LinkButton, Select, VerticalGroup } from '@grafana/ui'; -import { AlertDefinitionItem } from './components/AlertDefinitionItem'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { ShowModalReactEvent } from '../../types/events'; import { AlertHowToModal } from './AlertHowToModal'; @@ -24,7 +23,6 @@ function mapStateToProps(state: StoreState) { alertRules: getAlertRuleItems(state), search: getSearchQuery(state.alertRules), isLoading: state.alertRules.isLoading, - ngAlertDefinitions: state.alertDefinition.alertDefinitions, }; } @@ -125,23 +123,13 @@ export class AlertRuleListUnconnected extends PureComponent { - {alertRules.map((rule, index) => { - // Alert definition has "title" as name property. - if (rule.hasOwnProperty('name')) { - return ( - this.onTogglePause(rule as AlertRule)} - /> - ); - } + {alertRules.map((rule) => { return ( - this.onTogglePause(rule as AlertRule)} /> ); })} diff --git a/public/app/features/alerting/NextGenAlertingPage.tsx b/public/app/features/alerting/NextGenAlertingPage.tsx deleted file mode 100644 index 1d84dc6a46b..00000000000 --- a/public/app/features/alerting/NextGenAlertingPage.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import React, { FormEvent, PureComponent } from 'react'; -import { hot } from 'react-hot-loader'; -import { connect, ConnectedProps } from 'react-redux'; -import { css } from '@emotion/css'; -import { GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; -import { PageToolbar, stylesFactory, ToolbarButton, withTheme2, Themeable2 } from '@grafana/ui'; -import { config } from 'app/core/config'; -import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper'; -import { AlertingQueryEditor } from './components/AlertingQueryEditor'; -import { AlertDefinitionOptions } from './components/AlertDefinitionOptions'; -import { AlertingQueryPreview } from './components/AlertingQueryPreview'; -import { - cleanUpDefinitionState, - createAlertDefinition, - evaluateAlertDefinition, - evaluateNotSavedAlertDefinition, - getAlertDefinition, - updateAlertDefinition, - updateAlertDefinitionOption, - updateAlertDefinitionUiState, -} from './state/actions'; -import { StoreState } from 'app/types'; -import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; -import { GrafanaQuery } from '../../types/unified-alerting-dto'; - -function mapStateToProps(state: StoreState, props: RouteProps) { - return { - uiState: state.alertDefinition.uiState, - getInstances: state.alertDefinition.getInstances, - alertDefinition: state.alertDefinition.alertDefinition, - pageId: props.match.params.id as string, - }; -} - -const mapDispatchToProps = { - updateAlertDefinitionUiState, - updateAlertDefinitionOption, - evaluateAlertDefinition, - updateAlertDefinition, - createAlertDefinition, - getAlertDefinition, - evaluateNotSavedAlertDefinition, - cleanUpDefinitionState, -}; - -const connector = connect(mapStateToProps, mapDispatchToProps); - -interface RouteProps extends GrafanaRouteComponentProps<{ id: string }> {} - -interface OwnProps extends Themeable2 { - saveDefinition: typeof createAlertDefinition | typeof updateAlertDefinition; -} - -type Props = OwnProps & ConnectedProps; - -class UnthemedNextGenAlertingPage extends PureComponent { - componentDidMount() { - const { getAlertDefinition, pageId } = this.props; - - if (pageId) { - getAlertDefinition(pageId); - } - } - - componentWillUnmount() { - this.props.cleanUpDefinitionState(); - } - - onChangeAlertOption = (event: FormEvent) => { - const formEvent = event as FormEvent; - this.props.updateAlertDefinitionOption({ [formEvent.currentTarget.name]: formEvent.currentTarget.value }); - }; - - onChangeInterval = (interval: SelectableValue) => { - this.props.updateAlertDefinitionOption({ - intervalSeconds: interval.value, - }); - }; - - onConditionChange = (condition: SelectableValue) => { - this.props.updateAlertDefinitionOption({ - condition: condition.value, - }); - }; - - onSaveAlert = () => { - const { alertDefinition, createAlertDefinition, updateAlertDefinition } = this.props; - - if (alertDefinition.uid) { - updateAlertDefinition(); - } else { - createAlertDefinition(); - } - }; - - onDiscard = () => { - locationService.replace(`${config.appSubUrl}/alerting/ng/list`); - }; - - onTest = () => { - const { alertDefinition, evaluateAlertDefinition, evaluateNotSavedAlertDefinition } = this.props; - if (alertDefinition.uid) { - evaluateAlertDefinition(); - } else { - evaluateNotSavedAlertDefinition(); - } - }; - - renderToolbarActions() { - return [ - - Discard - , - - Test - , - - Save - , - ]; - } - - render() { - const { alertDefinition, uiState, updateAlertDefinitionUiState, getInstances, theme } = this.props; - - const styles = getStyles(theme); - - return ( -
- - {this.renderToolbarActions()} - -
- , - {}} />, - ]} - uiState={uiState} - updateUiState={updateAlertDefinitionUiState} - rightPaneComponents={ - - } - /> -
-
- ); - } -} - -const NextGenAlertingPageUnconnected = withTheme2(UnthemedNextGenAlertingPage); - -export default hot(module)(connector(NextGenAlertingPageUnconnected)); - -const getStyles = stylesFactory((theme: GrafanaTheme2) => ({ - wrapper: css` - width: calc(100% - 55px); - height: 100%; - position: fixed; - top: 0; - bottom: 0; - background: ${theme.colors.background.canvas}; - display: flex; - flex-direction: column; - `, - splitPanesWrapper: css` - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - position: relative; - `, -})); diff --git a/public/app/features/alerting/components/AlertDefinitionItem.tsx b/public/app/features/alerting/components/AlertDefinitionItem.tsx deleted file mode 100644 index 64407a22207..00000000000 --- a/public/app/features/alerting/components/AlertDefinitionItem.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React, { FC } from 'react'; -// @ts-ignore -import Highlighter from 'react-highlight-words'; -import { FeatureState } from '@grafana/data'; -import { Card, FeatureBadge, Icon, LinkButton } from '@grafana/ui'; -import { AlertDefinition } from 'app/types'; -import { config } from '@grafana/runtime'; - -interface Props { - alertDefinition: AlertDefinition; - search: string; -} - -export const AlertDefinitionItem: FC = ({ alertDefinition, search }) => { - return ( - - - - - - - {alertDefinition.description} - - - - {[ - - Edit alert - , - ]} - - - ); -}; - -const CardTitle = (title: string, search: string) => ( -
- - -
-); diff --git a/public/app/features/alerting/components/AlertDefinitionOptions.tsx b/public/app/features/alerting/components/AlertDefinitionOptions.tsx deleted file mode 100644 index db22fb8d801..00000000000 --- a/public/app/features/alerting/components/AlertDefinitionOptions.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React, { FC, FormEvent } from 'react'; -import { css } from '@emotion/css'; -import { GrafanaTheme, SelectableValue } from '@grafana/data'; -import { Field, Input, Select, Tab, TabContent, TabsBar, TextArea, useStyles } from '@grafana/ui'; -import { AlertDefinition } from 'app/types'; - -const intervalOptions: Array> = [ - { value: 60, label: '1m' }, - { value: 300, label: '5m' }, - { value: 600, label: '10m' }, -]; - -interface Props { - alertDefinition: AlertDefinition; - onChange: (event: FormEvent) => void; - onIntervalChange: (interval: SelectableValue) => void; - onConditionChange: (refId: SelectableValue) => void; -} - -export const AlertDefinitionOptions: FC = ({ alertDefinition, onChange, onIntervalChange }) => { - const styles = useStyles(getStyles); - - return ( -
- - - - - - - - -