From e5dd7efdee6a683a5722286afcf428f10871736a Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Mon, 20 Apr 2020 15:48:38 +0200 Subject: [PATCH] Provisioning: Allows specifying uid for datasource and use that in derived fields (#23585) * Add uid to datasource * Fix uid passing when provisioning * Better error handling and Uid column type change * Fix test and strict null error counts * Add backend tests * Add tests * Fix strict null checks * Update test * Improve tests * Update pkg/services/sqlstore/datasource.go Co-Authored-By: Arve Knudsen * Variable rename Co-authored-by: Arve Knudsen --- packages/grafana-data/src/types/dataLink.ts | 4 +- packages/grafana-data/src/types/datasource.ts | 1 + .../src/components/FormField/FormField.tsx | 3 +- pkg/api/datasources.go | 2 +- pkg/api/frontendsettings.go | 1 + .../encrypt_datasource_passwords_test.go | 8 +- pkg/models/datasource.go | 15 +- .../datasources/config_reader_test.go | 8 +- .../provisioning/datasources/datasources.go | 4 +- .../all-properties/all-properties.yaml | 1 + .../provisioning/datasources/types.go | 5 + pkg/services/sqlstore/datasource.go | 32 ++ pkg/services/sqlstore/datasource_test.go | 312 ++++++++++-------- .../sqlstore/migrations/datasource_mig.go | 18 + pkg/services/sqlstore/migrator/dialect.go | 1 + .../sqlstore/migrator/mysql_dialect.go | 7 + .../sqlstore/migrator/postgres_dialect.go | 9 +- .../sqlstore/migrator/sqlite_dialect.go | 8 +- public/app/features/explore/LogsContainer.tsx | 4 +- public/app/features/explore/state/actions.ts | 28 +- public/app/features/plugins/datasource_srv.ts | 10 +- .../plugins/specs/datasource_srv.test.ts | 3 + .../datasource/jaeger/datasource.test.ts | 1 + .../loki/configuration/ConfigEditor.tsx | 2 +- ...ections.test.tsx => DebugSection.test.tsx} | 0 .../loki/configuration/DerivedField.test.tsx | 50 +++ .../loki/configuration/DerivedField.tsx | 57 ++-- .../loki/result_transformer.test.ts | 29 +- .../datasource/loki/result_transformer.ts | 7 +- public/app/plugins/datasource/loki/types.ts | 2 +- .../datasource/testdata/QueryEditor.tsx | 2 +- .../plugins/datasource/testdata/runStreams.ts | 12 +- .../plugins/panel/annolist/AnnoListPanel.tsx | 2 +- scripts/ci-frontend-metrics.sh | 2 +- 34 files changed, 446 insertions(+), 204 deletions(-) rename public/app/plugins/datasource/loki/configuration/{DebugSections.test.tsx => DebugSection.test.tsx} (100%) create mode 100644 public/app/plugins/datasource/loki/configuration/DerivedField.test.tsx diff --git a/packages/grafana-data/src/types/dataLink.ts b/packages/grafana-data/src/types/dataLink.ts index d77adf5ce9e..78be49c25a0 100644 --- a/packages/grafana-data/src/types/dataLink.ts +++ b/packages/grafana-data/src/types/dataLink.ts @@ -29,7 +29,9 @@ export interface DataLink { onClick?: (event: DataLinkClickEvent) => void; // At the moment this is used for derived fields for metadata about internal linking. - meta?: any; + meta?: { + datasourceUid?: string; + }; } export type LinkTarget = '_blank' | '_self'; diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts index e68973ce22a..09434572b37 100644 --- a/packages/grafana-data/src/types/datasource.ts +++ b/packages/grafana-data/src/types/datasource.ts @@ -505,6 +505,7 @@ export interface DataSourceSettings { id: number; + uid: string; type: string; name: string; meta: DataSourcePluginMeta; diff --git a/packages/grafana-ui/src/components/FormField/FormField.tsx b/packages/grafana-ui/src/components/FormField/FormField.tsx index 037afccb45b..5d7096d2a6b 100644 --- a/packages/grafana-ui/src/components/FormField/FormField.tsx +++ b/packages/grafana-ui/src/components/FormField/FormField.tsx @@ -7,7 +7,8 @@ export interface Props extends InputHTMLAttributes { label: string; tooltip?: PopoverContent; labelWidth?: number; - inputWidth?: number; + // If null no width will be specified not even default one + inputWidth?: number | null; inputEl?: React.ReactNode; } diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index 1f59541dc4c..ebf86070e5e 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -128,7 +128,7 @@ func AddDataSource(c *models.ReqContext, cmd models.AddDataSourceCommand) Respon cmd.OrgId = c.OrgId if err := bus.Dispatch(&cmd); err != nil { - if err == models.ErrDataSourceNameExists { + if err == models.ErrDataSourceNameExists || err == models.ErrDataSourceUidExists { return Error(409, err.Error(), err) } diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 2bd9dbc79ea..0c9681f7b95 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -67,6 +67,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i var dsMap = map[string]interface{}{ "id": ds.Id, + "uid": ds.Uid, "type": ds.Type, "name": ds.Name, "url": url, diff --git a/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go b/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go index 58293978edb..f10601c91e1 100644 --- a/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go +++ b/pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go @@ -19,10 +19,10 @@ func TestPasswordMigrationCommand(t *testing.T) { defer session.Close() datasources := []*models.DataSource{ - {Type: "influxdb", Name: "influxdb", Password: "foobar"}, - {Type: "graphite", Name: "graphite", BasicAuthPassword: "foobar"}, - {Type: "prometheus", Name: "prometheus"}, - {Type: "elasticsearch", Name: "elasticsearch", Password: "pwd"}, + {Type: "influxdb", Name: "influxdb", Password: "foobar", Uid: "influx"}, + {Type: "graphite", Name: "graphite", BasicAuthPassword: "foobar", Uid: "graphite"}, + {Type: "prometheus", Name: "prometheus", Uid: "prom"}, + {Type: "elasticsearch", Name: "elasticsearch", Password: "pwd", Uid: "elastic"}, } // set required default values diff --git a/pkg/models/datasource.go b/pkg/models/datasource.go index 780ab801042..f937c0fe827 100644 --- a/pkg/models/datasource.go +++ b/pkg/models/datasource.go @@ -28,11 +28,13 @@ const ( ) var ( - ErrDataSourceNotFound = errors.New("Data source not found") - ErrDataSourceNameExists = errors.New("Data source with same name already exists") - ErrDataSourceUpdatingOldVersion = errors.New("Trying to update old version of datasource") - ErrDatasourceIsReadOnly = errors.New("Data source is readonly. Can only be updated from configuration") - ErrDataSourceAccessDenied = errors.New("Data source access denied") + ErrDataSourceNotFound = errors.New("Data source not found") + ErrDataSourceNameExists = errors.New("Data source with the same name already exists") + ErrDataSourceUidExists = errors.New("Data source with the same uid already exists") + ErrDataSourceUpdatingOldVersion = errors.New("Trying to update old version of datasource") + ErrDatasourceIsReadOnly = errors.New("Data source is readonly. Can only be updated from configuration") + ErrDataSourceAccessDenied = errors.New("Data source access denied") + ErrDataSourceFailedGenerateUniqueUid = errors.New("Failed to generate unique datasource id") ) type DsAccess string @@ -57,6 +59,7 @@ type DataSource struct { JsonData *simplejson.Json SecureJsonData securejsondata.SecureJsonData ReadOnly bool + Uid string Created time.Time Updated time.Time @@ -144,6 +147,7 @@ type AddDataSourceCommand struct { IsDefault bool `json:"isDefault"` JsonData *simplejson.Json `json:"jsonData"` SecureJsonData map[string]string `json:"secureJsonData"` + Uid string `json:"uid"` OrgId int64 `json:"-"` ReadOnly bool `json:"-"` @@ -168,6 +172,7 @@ type UpdateDataSourceCommand struct { JsonData *simplejson.Json `json:"jsonData"` SecureJsonData map[string]string `json:"secureJsonData"` Version int `json:"version"` + Uid string `json:"uid"` OrgId int64 `json:"-"` Id int64 `json:"-"` diff --git a/pkg/services/provisioning/datasources/config_reader_test.go b/pkg/services/provisioning/datasources/config_reader_test.go index c4b8be84e31..1adb26780ed 100644 --- a/pkg/services/provisioning/datasources/config_reader_test.go +++ b/pkg/services/provisioning/datasources/config_reader_test.go @@ -161,7 +161,7 @@ func TestDatasourceAsConfig(t *testing.T) { So(dsCfg.APIVersion, ShouldEqual, 1) - validateDatasource(dsCfg) + validateDatasourceV1(dsCfg) validateDeleteDatasources(dsCfg) dsCount := 0 @@ -231,6 +231,12 @@ func validateDatasource(dsCfg *configs) { So(ds.SecureJSONData["tlsClientKey"], ShouldEqual, "ZkN4aG1aNkja/gKAB1wlnKFIsy2SRDq4slrM0A==") } +func validateDatasourceV1(dsCfg *configs) { + validateDatasource(dsCfg) + ds := dsCfg.Datasources[0] + So(ds.UID, ShouldEqual, "test_uid") +} + type fakeRepository struct { inserted []*models.AddDataSourceCommand deleted []*models.DeleteDataSourceByNameCommand diff --git a/pkg/services/provisioning/datasources/datasources.go b/pkg/services/provisioning/datasources/datasources.go index 31ba561f24b..9498e9c1f59 100644 --- a/pkg/services/provisioning/datasources/datasources.go +++ b/pkg/services/provisioning/datasources/datasources.go @@ -50,13 +50,13 @@ func (dc *DatasourceProvisioner) apply(cfg *configs) error { } if err == models.ErrDataSourceNotFound { - dc.log.Info("inserting datasource from configuration ", "name", ds.Name) + dc.log.Info("inserting datasource from configuration ", "name", ds.Name, "uid", ds.UID) insertCmd := createInsertCommand(ds) if err := bus.Dispatch(insertCmd); err != nil { return err } } else { - dc.log.Debug("updating datasource from configuration", "name", ds.Name) + dc.log.Debug("updating datasource from configuration", "name", ds.Name, "uid", ds.UID) updateCmd := createUpdateCommand(ds, cmd.Result.Id) if err := bus.Dispatch(updateCmd); err != nil { return err diff --git a/pkg/services/provisioning/datasources/testdata/all-properties/all-properties.yaml b/pkg/services/provisioning/datasources/testdata/all-properties/all-properties.yaml index abd1253f839..0aabaeae8d2 100644 --- a/pkg/services/provisioning/datasources/testdata/all-properties/all-properties.yaml +++ b/pkg/services/provisioning/datasources/testdata/all-properties/all-properties.yaml @@ -24,6 +24,7 @@ datasources: tlsClientKey: "ZkN4aG1aNkja/gKAB1wlnKFIsy2SRDq4slrM0A==" editable: true version: 10 + uid: "test_uid" deleteDatasources: - name: old-graphite3 diff --git a/pkg/services/provisioning/datasources/types.go b/pkg/services/provisioning/datasources/types.go index b27a2ca03ea..cc89c9e9308 100644 --- a/pkg/services/provisioning/datasources/types.go +++ b/pkg/services/provisioning/datasources/types.go @@ -43,6 +43,7 @@ type upsertDataSourceFromConfig struct { JSONData map[string]interface{} SecureJSONData map[string]string Editable bool + UID string } type configsV0 struct { @@ -108,6 +109,7 @@ type upsertDataSourceFromConfigV1 struct { JSONData values.JSONValue `json:"jsonData" yaml:"jsonData"` SecureJSONData values.StringMapValue `json:"secureJsonData" yaml:"secureJsonData"` Editable values.BoolValue `json:"editable" yaml:"editable"` + UID values.StringValue `json:"uid" yaml:"uid"` } func (cfg *configsV1) mapToDatasourceFromConfig(apiVersion int64) *configs { @@ -138,6 +140,7 @@ func (cfg *configsV1) mapToDatasourceFromConfig(apiVersion int64) *configs { SecureJSONData: ds.SecureJSONData.Value(), Editable: ds.Editable.Value(), Version: ds.Version.Value(), + UID: ds.UID.Value(), }) // Using Raw value for the warnings here so that even if it uses env interpolation and the env var is empty @@ -234,6 +237,7 @@ func createInsertCommand(ds *upsertDataSourceFromConfig) *models.AddDataSourceCo JsonData: jsonData, SecureJsonData: ds.SecureJSONData, ReadOnly: !ds.Editable, + Uid: ds.UID, } } @@ -247,6 +251,7 @@ func createUpdateCommand(ds *upsertDataSourceFromConfig, id int64) *models.Updat return &models.UpdateDataSourceCommand{ Id: id, + Uid: ds.UID, OrgId: ds.OrgID, Name: ds.Name, Type: ds.Type, diff --git a/pkg/services/sqlstore/datasource.go b/pkg/services/sqlstore/datasource.go index 9eb55f2ab27..f6767028b60 100644 --- a/pkg/services/sqlstore/datasource.go +++ b/pkg/services/sqlstore/datasource.go @@ -1,6 +1,8 @@ package sqlstore import ( + "github.com/grafana/grafana/pkg/util/errutil" + "strings" "time" "github.com/grafana/grafana/pkg/components/simplejson" @@ -101,6 +103,14 @@ func AddDataSource(cmd *models.AddDataSourceCommand) error { cmd.JsonData = simplejson.New() } + if cmd.Uid == "" { + uid, err := generateNewDatasourceUid(sess, cmd.OrgId) + if err != nil { + return errutil.Wrapf(err, "Failed to generate UID for datasource %q", cmd.Name) + } + cmd.Uid = uid + } + ds := &models.DataSource{ OrgId: cmd.OrgId, Name: cmd.Name, @@ -121,9 +131,13 @@ func AddDataSource(cmd *models.AddDataSourceCommand) error { Updated: time.Now(), Version: 1, ReadOnly: cmd.ReadOnly, + Uid: cmd.Uid, } if _, err := sess.Insert(ds); err != nil { + if dialect.IsUniqueConstraintViolation(err) && strings.Contains(strings.ToLower(dialect.ErrorMessage(err)), "uid") { + return models.ErrDataSourceUidExists + } return err } if err := updateIsDefaultFlag(ds, sess); err != nil { @@ -172,6 +186,7 @@ func UpdateDataSource(cmd *models.UpdateDataSourceCommand) error { Updated: time.Now(), ReadOnly: cmd.ReadOnly, Version: cmd.Version + 1, + Uid: cmd.Uid, } sess.UseBool("is_default") @@ -209,3 +224,20 @@ func UpdateDataSource(cmd *models.UpdateDataSourceCommand) error { return err }) } + +func generateNewDatasourceUid(sess *DBSession, orgId int64) (string, error) { + for i := 0; i < 3; i++ { + uid := generateNewUid() + + exists, err := sess.Where("org_id=? AND uid=?", orgId, uid).Get(&models.DataSource{}) + if err != nil { + return "", err + } + + if !exists { + return uid, nil + } + } + + return "", models.ErrDataSourceFailedGenerateUniqueUid +} diff --git a/pkg/services/sqlstore/datasource_test.go b/pkg/services/sqlstore/datasource_test.go index 97b1348f460..53c8766c012 100644 --- a/pkg/services/sqlstore/datasource_test.go +++ b/pkg/services/sqlstore/datasource_test.go @@ -3,173 +3,213 @@ package sqlstore import ( "testing" - . "github.com/smartystreets/goconvey/convey" - "github.com/grafana/grafana/pkg/models" + "github.com/stretchr/testify/require" ) -type Test struct { - Id int64 - Name string -} - func TestDataAccess(t *testing.T) { - Convey("Testing DB", t, func() { - InitTestDB(t) - Convey("Can add datasource", func() { + defaultAddDatasourceCommand := models.AddDataSourceCommand{ + OrgId: 10, + Name: "nisse", + Type: models.DS_GRAPHITE, + Access: models.DS_ACCESS_DIRECT, + Url: "http://test", + } + + defaultUpdateDatasourceCommand := models.UpdateDataSourceCommand{ + OrgId: 10, + Name: "nisse_updated", + Type: models.DS_GRAPHITE, + Access: models.DS_ACCESS_DIRECT, + Url: "http://test", + } + + initDatasource := func() *models.DataSource { + cmd := defaultAddDatasourceCommand + err := AddDataSource(&cmd) + require.NoError(t, err) + + query := models.GetDataSourcesQuery{OrgId: 10} + err = GetDataSources(&query) + require.NoError(t, err) + require.Equal(t, 1, len(query.Result)) + + return query.Result[0] + } + + t.Run("AddDataSource", func(t *testing.T) { + t.Run("Can add datasource", func(t *testing.T) { + InitTestDB(t) + err := AddDataSource(&models.AddDataSourceCommand{ OrgId: 10, Name: "laban", - Type: models.DS_INFLUXDB, + Type: models.DS_GRAPHITE, Access: models.DS_ACCESS_DIRECT, Url: "http://test", Database: "site", ReadOnly: true, }) - - So(err, ShouldBeNil) + require.NoError(t, err) query := models.GetDataSourcesQuery{OrgId: 10} err = GetDataSources(&query) - So(err, ShouldBeNil) - - So(len(query.Result), ShouldEqual, 1) + require.NoError(t, err) + require.Equal(t, 1, len(query.Result)) ds := query.Result[0] - So(ds.OrgId, ShouldEqual, 10) - So(ds.Database, ShouldEqual, "site") - So(ds.ReadOnly, ShouldBeTrue) + require.EqualValues(t, 10, ds.OrgId) + require.Equal(t, "site", ds.Database) + require.True(t, ds.ReadOnly) }) - Convey("Given a datasource", func() { - err := AddDataSource(&models.AddDataSourceCommand{ + t.Run("generates uid if not specified", func(t *testing.T) { + InitTestDB(t) + ds := initDatasource() + require.NotEmpty(t, ds.Uid) + }) + + t.Run("fails to insert ds with same uid", func(t *testing.T) { + InitTestDB(t) + cmd1 := defaultAddDatasourceCommand + cmd2 := defaultAddDatasourceCommand + cmd1.Uid = "test" + cmd2.Uid = "test" + err := AddDataSource(&cmd1) + require.NoError(t, err) + err = AddDataSource(&cmd2) + require.Error(t, err) + require.IsType(t, models.ErrDataSourceUidExists, err) + }) + }) + + t.Run("UpdateDataSource", func(t *testing.T) { + t.Run("updates datasource with version", func(t *testing.T) { + InitTestDB(t) + ds := initDatasource() + cmd := defaultUpdateDatasourceCommand + cmd.Id = ds.Id + cmd.Version = ds.Version + err := UpdateDataSource(&cmd) + require.NoError(t, err) + }) + + t.Run("does not overwrite Uid if not specified", func(t *testing.T) { + InitTestDB(t) + ds := initDatasource() + require.NotEmpty(t, ds.Uid) + + cmd := defaultUpdateDatasourceCommand + cmd.Id = ds.Id + err := UpdateDataSource(&cmd) + require.NoError(t, err) + + query := models.GetDataSourceByIdQuery{Id: ds.Id} + err = GetDataSourceById(&query) + require.NoError(t, err) + require.Equal(t, ds.Uid, query.Result.Uid) + }) + + t.Run("prevents update if version changed", func(t *testing.T) { + InitTestDB(t) + ds := initDatasource() + + cmd := models.UpdateDataSourceCommand{ + Id: ds.Id, + OrgId: 10, + Name: "nisse", + Type: models.DS_GRAPHITE, + Access: models.DS_ACCESS_PROXY, + Url: "http://test", + Version: ds.Version, + } + // Make a copy as UpdateDataSource modifies it + cmd2 := cmd + + err := UpdateDataSource(&cmd) + require.NoError(t, err) + + err = UpdateDataSource(&cmd2) + require.Error(t, err) + }) + + t.Run("updates ds without version specified", func(t *testing.T) { + InitTestDB(t) + ds := initDatasource() + + cmd := &models.UpdateDataSourceCommand{ + Id: ds.Id, OrgId: 10, Name: "nisse", Type: models.DS_GRAPHITE, - Access: models.DS_ACCESS_DIRECT, + Access: models.DS_ACCESS_PROXY, Url: "http://test", - }) - So(err, ShouldBeNil) + } + + err := UpdateDataSource(cmd) + require.NoError(t, err) + }) + + t.Run("updates ds without higher version", func(t *testing.T) { + InitTestDB(t) + ds := initDatasource() + + cmd := &models.UpdateDataSourceCommand{ + Id: ds.Id, + OrgId: 10, + Name: "nisse", + Type: models.DS_GRAPHITE, + Access: models.DS_ACCESS_PROXY, + Url: "http://test", + Version: 90000, + } + + err := UpdateDataSource(cmd) + require.NoError(t, err) + }) + }) + + t.Run("DeleteDataSourceById", func(t *testing.T) { + t.Run("can delete datasource", func(t *testing.T) { + InitTestDB(t) + ds := initDatasource() + + err := DeleteDataSourceById(&models.DeleteDataSourceByIdCommand{Id: ds.Id, OrgId: ds.OrgId}) + require.NoError(t, err) query := models.GetDataSourcesQuery{OrgId: 10} err = GetDataSources(&query) - So(err, ShouldBeNil) + require.NoError(t, err) - ds := query.Result[0] + require.Equal(t, 0, len(query.Result)) + }) - Convey(" updated ", func() { - cmd := &models.UpdateDataSourceCommand{ - Id: ds.Id, - OrgId: 10, - Name: "nisse", - Type: models.DS_GRAPHITE, - Access: models.DS_ACCESS_PROXY, - Url: "http://test", - Version: ds.Version, - } + t.Run("Can not delete datasource with wrong orgId", func(t *testing.T) { + InitTestDB(t) + ds := initDatasource() - Convey("with same version as source", func() { - err := UpdateDataSource(cmd) - So(err, ShouldBeNil) - }) + err := DeleteDataSourceById(&models.DeleteDataSourceByIdCommand{Id: ds.Id, OrgId: 123123}) + require.NoError(t, err) + query := models.GetDataSourcesQuery{OrgId: 10} + err = GetDataSources(&query) + require.NoError(t, err) - Convey("when someone else updated between read and update", func() { - query := models.GetDataSourcesQuery{OrgId: 10} - err = GetDataSources(&query) - So(err, ShouldBeNil) - - ds := query.Result[0] - intendedUpdate := &models.UpdateDataSourceCommand{ - Id: ds.Id, - OrgId: 10, - Name: "nisse", - Type: models.DS_GRAPHITE, - Access: models.DS_ACCESS_PROXY, - Url: "http://test", - Version: ds.Version, - } - - updateFromOtherUser := &models.UpdateDataSourceCommand{ - Id: ds.Id, - OrgId: 10, - Name: "nisse", - Type: models.DS_GRAPHITE, - Access: models.DS_ACCESS_PROXY, - Url: "http://test", - Version: ds.Version, - } - - err := UpdateDataSource(updateFromOtherUser) - So(err, ShouldBeNil) - - err = UpdateDataSource(intendedUpdate) - So(err, ShouldNotBeNil) - }) - - Convey("updating datasource without version", func() { - cmd := &models.UpdateDataSourceCommand{ - Id: ds.Id, - OrgId: 10, - Name: "nisse", - Type: models.DS_GRAPHITE, - Access: models.DS_ACCESS_PROXY, - Url: "http://test", - } - - Convey("should not raise errors", func() { - err := UpdateDataSource(cmd) - So(err, ShouldBeNil) - }) - }) - - Convey("updating datasource without higher version", func() { - cmd := &models.UpdateDataSourceCommand{ - Id: ds.Id, - OrgId: 10, - Name: "nisse", - Type: models.DS_GRAPHITE, - Access: models.DS_ACCESS_PROXY, - Url: "http://test", - Version: 90000, - } - - Convey("should not raise errors", func() { - err := UpdateDataSource(cmd) - So(err, ShouldBeNil) - }) - }) - }) - - Convey("Can delete datasource by id", func() { - err := DeleteDataSourceById(&models.DeleteDataSourceByIdCommand{Id: ds.Id, OrgId: ds.OrgId}) - So(err, ShouldBeNil) - - err = GetDataSources(&query) - So(err, ShouldBeNil) - - So(len(query.Result), ShouldEqual, 0) - }) - - Convey("Can delete datasource by name", func() { - err := DeleteDataSourceByName(&models.DeleteDataSourceByNameCommand{Name: ds.Name, OrgId: ds.OrgId}) - So(err, ShouldBeNil) - - err = GetDataSources(&query) - So(err, ShouldBeNil) - - So(len(query.Result), ShouldEqual, 0) - }) - - Convey("Can not delete datasource with wrong orgId", func() { - err := DeleteDataSourceById(&models.DeleteDataSourceByIdCommand{Id: ds.Id, OrgId: 123123}) - So(err, ShouldBeNil) - - err = GetDataSources(&query) - So(err, ShouldBeNil) - - So(len(query.Result), ShouldEqual, 1) - }) + require.Equal(t, 1, len(query.Result)) }) }) + + t.Run("DeleteDataSourceByName", func(t *testing.T) { + InitTestDB(t) + ds := initDatasource() + query := models.GetDataSourcesQuery{OrgId: 10} + + err := DeleteDataSourceByName(&models.DeleteDataSourceByNameCommand{Name: ds.Name, OrgId: ds.OrgId}) + require.NoError(t, err) + + err = GetDataSources(&query) + require.NoError(t, err) + + require.Equal(t, 0, len(query.Result)) + }) } diff --git a/pkg/services/sqlstore/migrations/datasource_mig.go b/pkg/services/sqlstore/migrations/datasource_mig.go index 54d86d34dba..e9f8d34e5ff 100644 --- a/pkg/services/sqlstore/migrations/datasource_mig.go +++ b/pkg/services/sqlstore/migrations/datasource_mig.go @@ -133,4 +133,22 @@ func addDataSourceMigration(mg *Migrator) { const setEmptyJSONWhereNullJSON = `UPDATE data_source SET json_data = '{}' WHERE json_data is null` mg.AddMigration("Update json_data with nulls", NewRawSqlMigration(setEmptyJSONWhereNullJSON)) + + // add column uid for linking + mg.AddMigration("Add uid column", NewAddColumnMigration(tableV2, &Column{ + Name: "uid", Type: DB_NVarchar, Length: 40, Nullable: false, Default: "0", + })) + + // Initialize as id as that is unique already + mg.AddMigration( + "Update uid value", + NewRawSqlMigration(""). + Sqlite("UPDATE data_source SET uid=printf('%09d',id);"). + Postgres("UPDATE data_source SET uid=lpad('' || id::text,9,'0');"). + Mysql("UPDATE data_source SET uid=lpad(id,9,'0');"), + ) + + mg.AddMigration("Add unique index datasource_org_id_uid", NewAddIndexMigration(tableV2, &Index{ + Cols: []string{"org_id", "uid"}, Type: UniqueIndex, + })) } diff --git a/pkg/services/sqlstore/migrator/dialect.go b/pkg/services/sqlstore/migrator/dialect.go index 5246f17971f..2846e0f77cc 100644 --- a/pkg/services/sqlstore/migrator/dialect.go +++ b/pkg/services/sqlstore/migrator/dialect.go @@ -48,6 +48,7 @@ type Dialect interface { NoOpSql() string IsUniqueConstraintViolation(err error) bool + ErrorMessage(err error) string IsDeadlock(err error) bool } diff --git a/pkg/services/sqlstore/migrator/mysql_dialect.go b/pkg/services/sqlstore/migrator/mysql_dialect.go index a658819ea2f..c0df81292f0 100644 --- a/pkg/services/sqlstore/migrator/mysql_dialect.go +++ b/pkg/services/sqlstore/migrator/mysql_dialect.go @@ -148,6 +148,13 @@ func (db *Mysql) IsUniqueConstraintViolation(err error) bool { return db.isThisError(err, mysqlerr.ER_DUP_ENTRY) } +func (db *Mysql) ErrorMessage(err error) string { + if driverErr, ok := err.(*mysql.MySQLError); ok { + return driverErr.Message + } + return "" +} + func (db *Mysql) IsDeadlock(err error) bool { return db.isThisError(err, mysqlerr.ER_LOCK_DEADLOCK) } diff --git a/pkg/services/sqlstore/migrator/postgres_dialect.go b/pkg/services/sqlstore/migrator/postgres_dialect.go index 2598680ea2f..9292993f8c6 100644 --- a/pkg/services/sqlstore/migrator/postgres_dialect.go +++ b/pkg/services/sqlstore/migrator/postgres_dialect.go @@ -2,11 +2,11 @@ package migrator import ( "fmt" + "github.com/lib/pq" "strconv" "strings" "github.com/grafana/grafana/pkg/util/errutil" - "github.com/lib/pq" "xorm.io/xorm" ) @@ -149,6 +149,13 @@ func (db *Postgres) isThisError(err error, errcode string) bool { return false } +func (db *Postgres) ErrorMessage(err error) string { + if driverErr, ok := err.(*pq.Error); ok { + return driverErr.Message + } + return "" +} + func (db *Postgres) IsUniqueConstraintViolation(err error) bool { return db.isThisError(err, "23505") } diff --git a/pkg/services/sqlstore/migrator/sqlite_dialect.go b/pkg/services/sqlstore/migrator/sqlite_dialect.go index c631d163d07..509e59aac94 100644 --- a/pkg/services/sqlstore/migrator/sqlite_dialect.go +++ b/pkg/services/sqlstore/migrator/sqlite_dialect.go @@ -2,7 +2,6 @@ package migrator import ( "fmt" - "github.com/mattn/go-sqlite3" "xorm.io/xorm" ) @@ -95,6 +94,13 @@ func (db *Sqlite3) isThisError(err error, errcode int) bool { return false } +func (db *Sqlite3) ErrorMessage(err error) string { + if driverErr, ok := err.(sqlite3.Error); ok { + return driverErr.Error() + } + return "" +} + func (db *Sqlite3) IsUniqueConstraintViolation(err error) bool { return db.isThisError(err, int(sqlite3.ErrConstraintUnique)) } diff --git a/public/app/features/explore/LogsContainer.tsx b/public/app/features/explore/LogsContainer.tsx index 17a9df4f93e..6367b206c60 100644 --- a/public/app/features/explore/LogsContainer.tsx +++ b/public/app/features/explore/LogsContainer.tsx @@ -101,11 +101,11 @@ export class LogsContainer extends PureComponent { getFieldLinks = (field: Field, rowIndex: number) => { const data = getLinksFromLogsField(field, rowIndex); return data.map(d => { - if (d.link.meta?.datasourceName) { + if (d.link.meta?.datasourceUid) { return { ...d.linkModel, onClick: () => { - this.props.splitOpen(d.link.meta.datasourceName, field.values.get(rowIndex)); + this.props.splitOpen({ dataSourceUid: d.link.meta.datasourceUid, query: field.values.get(rowIndex) }); }, }; } diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts index d1583d85fce..c2bc3e0df7a 100644 --- a/public/app/features/explore/state/actions.ts +++ b/public/app/features/explore/state/actions.ts @@ -121,14 +121,14 @@ export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult { +export function changeDatasource(exploreId: ExploreId, datasourceName: string): ThunkResult { return async (dispatch, getState) => { let newDataSourceInstance: DataSourceApi; - if (!datasource) { + if (!datasourceName) { newDataSourceInstance = await getDatasourceSrv().get(); } else { - newDataSourceInstance = await getDatasourceSrv().get(datasource); + newDataSourceInstance = await getDatasourceSrv().get(datasourceName); } const currentDataSourceInstance = getState().explore[exploreId].datasourceInstance; @@ -697,11 +697,12 @@ export function splitClose(itemId: ExploreId): ThunkResult { } /** - * Open the split view and copy the left state to be the right state. - * The right state is automatically initialized. - * The copy keeps all query modifications but wipes the query results. + * Open the split view and the right state is automatically initialized. + * If options are specified it initializes that pane with the datasource and query from options. + * Otherwise it copies the left state to be the right state. The copy keeps all query modifications but wipes the query + * results. */ -export function splitOpen(dataSourceName?: string, query?: string): ThunkResult { +export function splitOpen(options?: { dataSourceUid: string; query: string }): ThunkResult { return async (dispatch, getState) => { // Clone left state to become the right state const leftState: ExploreItemState = getState().explore[ExploreId.left]; @@ -710,19 +711,24 @@ export function splitOpen(dataSourceName?: string, query?: string): ThunkResult< }; const queryState = getState().location.query[ExploreId.left] as string; const urlState = parseUrlState(queryState); + + // TODO: Instead of splitting and then setting query/datasource we may probably do it in one action call rightState.queries = leftState.queries.slice(); rightState.urlState = urlState; dispatch(splitOpenAction({ itemState: rightState })); - if (dataSourceName && query) { - // This is hardcoded for Jaeger right now + if (options) { + // TODO: This is hardcoded for Jaeger right now. Need to be changed so that target datasource can define the + // query shape. const queries = [ { - query, + query: options.query, refId: 'A', } as DataQuery, ]; - await dispatch(changeDatasource(ExploreId.right, dataSourceName)); + + const dataSourceSettings = getDatasourceSrv().getDataSourceSettingsByUid(options.dataSourceUid); + await dispatch(changeDatasource(ExploreId.right, dataSourceSettings.name)); await dispatch(setQueriesAction({ exploreId: ExploreId.right, queries })); } diff --git a/public/app/features/plugins/datasource_srv.ts b/public/app/features/plugins/datasource_srv.ts index d6bde0d58db..5fc89a0afbf 100644 --- a/public/app/features/plugins/datasource_srv.ts +++ b/public/app/features/plugins/datasource_srv.ts @@ -6,7 +6,7 @@ import config from 'app/core/config'; import { importDataSourcePlugin } from './plugin_loader'; import { DataSourceSrv as DataSourceService, getDataSourceSrv as getDataSourceService } from '@grafana/runtime'; // Types -import { AppEvents, DataSourceApi, DataSourceSelectItem, ScopedVars } from '@grafana/data'; +import { AppEvents, DataSourceApi, DataSourceInstanceSettings, DataSourceSelectItem, ScopedVars } from '@grafana/data'; import { auto } from 'angular'; import { TemplateSrv } from '../templating/template_srv'; import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; @@ -30,6 +30,10 @@ export class DatasourceSrv implements DataSourceService { this.datasources = {}; } + getDataSourceSettingsByUid(uid: string): DataSourceInstanceSettings | undefined { + return Object.values(config.datasources).find(ds => ds.uid === uid); + } + get(name?: string, scopedVars?: ScopedVars): Promise { if (!name) { return this.get(config.defaultDatasource); @@ -93,12 +97,12 @@ export class DatasourceSrv implements DataSourceService { } } - getAll() { + getAll(): DataSourceInstanceSettings[] { const { datasources } = config; return Object.keys(datasources).map(name => datasources[name]); } - getExternal() { + getExternal(): DataSourceInstanceSettings[] { const datasources = this.getAll().filter(ds => !ds.meta.builtIn); return sortBy(datasources, ['name']); } diff --git a/public/app/features/plugins/specs/datasource_srv.test.ts b/public/app/features/plugins/specs/datasource_srv.test.ts index 041c9692dd8..36b59e9f6e9 100644 --- a/public/app/features/plugins/specs/datasource_srv.test.ts +++ b/public/app/features/plugins/specs/datasource_srv.test.ts @@ -24,6 +24,7 @@ describe('datasource_srv', () => { config.datasources = { buildInDs: { id: 1, + uid: '1', type: 'b', name: 'buildIn', meta: { builtIn: true } as DataSourcePluginMeta, @@ -31,6 +32,7 @@ describe('datasource_srv', () => { }, nonBuildIn: { id: 2, + uid: '2', type: 'e', name: 'external1', meta: { builtIn: false } as DataSourcePluginMeta, @@ -38,6 +40,7 @@ describe('datasource_srv', () => { }, nonExplore: { id: 3, + uid: '3', type: 'e2', name: 'external2', meta: {} as PluginMeta, diff --git a/public/app/plugins/datasource/jaeger/datasource.test.ts b/public/app/plugins/datasource/jaeger/datasource.test.ts index 19995571545..6d47bbeeb0c 100644 --- a/public/app/plugins/datasource/jaeger/datasource.test.ts +++ b/public/app/plugins/datasource/jaeger/datasource.test.ts @@ -57,6 +57,7 @@ async function withMockedBackendSrv(srv: BackendSrv, fn: () => Promise) { const defaultSettings: DataSourceInstanceSettings = { id: 0, + uid: '0', type: 'tracing', name: 'jaeger', meta: { diff --git a/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx index 9244c854867..0da1c32f8df 100644 --- a/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx +++ b/public/app/plugins/datasource/loki/configuration/ConfigEditor.tsx @@ -39,7 +39,7 @@ export const ConfigEditor = (props: Props) => {
onOptionsChange(setMaxLines(options, value))} />
diff --git a/public/app/plugins/datasource/loki/configuration/DebugSections.test.tsx b/public/app/plugins/datasource/loki/configuration/DebugSection.test.tsx similarity index 100% rename from public/app/plugins/datasource/loki/configuration/DebugSections.test.tsx rename to public/app/plugins/datasource/loki/configuration/DebugSection.test.tsx diff --git a/public/app/plugins/datasource/loki/configuration/DerivedField.test.tsx b/public/app/plugins/datasource/loki/configuration/DerivedField.test.tsx new file mode 100644 index 00000000000..503fd7ec7dd --- /dev/null +++ b/public/app/plugins/datasource/loki/configuration/DerivedField.test.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { DerivedField } from './DerivedField'; +import DataSourcePicker from '../../../../core/components/Select/DataSourcePicker'; + +jest.mock('app/core/config', () => ({ + config: { + featureToggles: { + tracingIntegration: true, + }, + }, +})); + +jest.mock('app/features/plugins/datasource_srv', () => ({ + getDatasourceSrv() { + return { + getExternal(): any[] { + return []; + }, + }; + }, +})); + +describe('DerivedField', () => { + it('shows internal link if uid is set', () => { + const value = { + matcherRegex: '', + name: '', + datasourceUid: 'test', + }; + const wrapper = shallow( {}} onDelete={() => {}} suggestions={[]} />); + + expect( + wrapper + .find('DataSourceSection') + .dive() + .find(DataSourcePicker).length + ).toBe(1); + }); + + it('shows url link if uid is not set', () => { + const value = { + matcherRegex: '', + name: '', + url: 'test', + }; + const wrapper = shallow( {}} onDelete={() => {}} suggestions={[]} />); + expect(wrapper.find('DataSourceSection').length).toBe(0); + }); +}); diff --git a/public/app/plugins/datasource/loki/configuration/DerivedField.tsx b/public/app/plugins/datasource/loki/configuration/DerivedField.tsx index 7ab1dd8414d..706ab58d9af 100644 --- a/public/app/plugins/datasource/loki/configuration/DerivedField.tsx +++ b/public/app/plugins/datasource/loki/configuration/DerivedField.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { css } from 'emotion'; import { Button, FormField, DataLinkInput, stylesFactory, LegacyForms } from '@grafana/ui'; const { Switch } = LegacyForms; @@ -9,6 +9,7 @@ import { DerivedFieldConfig } from '../types'; import DataSourcePicker from 'app/core/components/Select/DataSourcePicker'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { config } from 'app/core/config'; +import { usePrevious } from 'react-use'; const getStyles = stylesFactory(() => ({ row: css` @@ -33,7 +34,18 @@ type Props = { export const DerivedField = (props: Props) => { const { value, onChange, onDelete, suggestions, className } = props; const styles = getStyles(); - const [hasIntenalLink, setHasInternalLink] = useState(!!value.datasourceName); + const [showInternalLink, setShowInternalLink] = useState(!!value.datasourceUid); + const previousUid = usePrevious(value.datasourceUid); + + // Force internal link visibility change if uid changed outside of this component. + useEffect(() => { + if (!previousUid && value.datasourceUid && !showInternalLink) { + setShowInternalLink(true); + } + if (previousUid && !value.datasourceUid && showInternalLink) { + setShowInternalLink(false); + } + }, [previousUid, value.datasourceUid, showInternalLink]); const handleChange = (field: keyof typeof value) => (event: React.ChangeEvent) => { onChange({ @@ -81,11 +93,11 @@ export const DerivedField = (props: Props) => {
onChange({ @@ -105,27 +117,27 @@ export const DerivedField = (props: Props) => {
{ - if (hasIntenalLink) { + if (showInternalLink) { onChange({ ...value, - datasourceName: undefined, + datasourceUid: undefined, }); } - setHasInternalLink(!hasIntenalLink); + setShowInternalLink(!showInternalLink); }} /> - {hasIntenalLink && ( + {showInternalLink && ( { + onChange={datasourceUid => { onChange({ ...value, - datasourceName, + datasourceUid, }); }} - datasourceName={value.datasourceName} + datasourceUid={value.datasourceUid} /> )}
@@ -135,29 +147,30 @@ export const DerivedField = (props: Props) => { }; type DataSourceSectionProps = { - datasourceName?: string; - onChange: (name: string) => void; + datasourceUid?: string; + onChange: (uid: string) => void; }; + const DataSourceSection = (props: DataSourceSectionProps) => { - const { datasourceName, onChange } = props; + const { datasourceUid, onChange } = props; const datasources: DataSourceSelectItem[] = getDatasourceSrv() .getExternal() .map( - (ds: any) => + ds => ({ - value: ds.name, + value: ds.uid, name: ds.name, meta: ds.meta, } as DataSourceSelectItem) ); - const selectedDatasource = datasourceName && datasources.find(d => d.name === datasourceName); + + let selectedDatasource = datasourceUid && datasources.find(d => d.value === datasourceUid); return ( { - onChange(newValue.name); - }} + // Uid and value should be always set in the db and so in the items. + onChange={ds => onChange(ds.value!)} datasources={datasources} - current={selectedDatasource} + current={selectedDatasource || undefined} /> ); }; diff --git a/public/app/plugins/datasource/loki/result_transformer.test.ts b/public/app/plugins/datasource/loki/result_transformer.test.ts index 51b3155123e..63544a3977a 100644 --- a/public/app/plugins/datasource/loki/result_transformer.test.ts +++ b/public/app/plugins/datasource/loki/result_transformer.test.ts @@ -1,6 +1,7 @@ -import { CircularDataFrame, FieldType, MutableDataFrame } from '@grafana/data'; +import { CircularDataFrame, FieldCache, FieldType, MutableDataFrame } from '@grafana/data'; import { LokiLegacyStreamResult, LokiStreamResult, LokiTailResponse } from './types'; import * as ResultTransformer from './result_transformer'; +import { enhanceDataFrame } from './result_transformer'; const legacyStreamResult: LokiLegacyStreamResult[] = [ { @@ -180,3 +181,29 @@ describe('loki result transformer', () => { }); }); }); + +describe('enhanceDataFrame', () => { + it('', () => { + const df = new MutableDataFrame({ fields: [{ name: 'line', values: ['nothing', 'trace1=1234', 'trace2=foo'] }] }); + enhanceDataFrame(df, { + derivedFields: [ + { + matcherRegex: 'trace1=(\\w+)', + name: 'trace1', + url: 'http://localhost/${__value.raw}', + }, + { + matcherRegex: 'trace2=(\\w+)', + name: 'trace2', + datasourceUid: 'uid', + }, + ], + }); + expect(df.fields.length).toBe(3); + const fc = new FieldCache(df); + expect(fc.getFieldByName('trace1').values.toArray()).toEqual([null, '1234', null]); + expect(fc.getFieldByName('trace1').config.links[0]).toEqual({ url: 'http://localhost/${__value.raw}', title: '' }); + expect(fc.getFieldByName('trace2').values.toArray()).toEqual([null, null, 'foo']); + expect(fc.getFieldByName('trace2').config.links[0]).toEqual({ title: '', meta: { datasourceUid: 'uid' } }); + }); +}); diff --git a/public/app/plugins/datasource/loki/result_transformer.ts b/public/app/plugins/datasource/loki/result_transformer.ts index b9915d0ae91..12e8dc2eb5f 100644 --- a/public/app/plugins/datasource/loki/result_transformer.ts +++ b/public/app/plugins/datasource/loki/result_transformer.ts @@ -381,7 +381,6 @@ export function lokiLegacyStreamsToDataframes( /** * Adds new fields and DataLinks to DataFrame based on DataSource instance config. - * @param dataFrame */ export const enhanceDataFrame = (dataFrame: DataFrame, config: LokiOptions | null): void => { if (!config) { @@ -395,14 +394,14 @@ export const enhanceDataFrame = (dataFrame: DataFrame, config: LokiOptions | nul const fields = derivedFields.reduce((acc, field) => { const config: FieldConfig = {}; - if (field.url || field.datasourceName) { + if (field.url || field.datasourceUid) { config.links = [ { url: field.url, title: '', - meta: field.datasourceName + meta: field.datasourceUid ? { - datasourceName: field.datasourceName, + datasourceUid: field.datasourceUid, } : undefined, }, diff --git a/public/app/plugins/datasource/loki/types.ts b/public/app/plugins/datasource/loki/types.ts index 9b1fe667bf0..59082cbfad4 100644 --- a/public/app/plugins/datasource/loki/types.ts +++ b/public/app/plugins/datasource/loki/types.ts @@ -128,7 +128,7 @@ export type DerivedFieldConfig = { matcherRegex: string; name: string; url?: string; - datasourceName?: string; + datasourceUid?: string; }; export interface TransformerOptions { diff --git a/public/app/plugins/datasource/testdata/QueryEditor.tsx b/public/app/plugins/datasource/testdata/QueryEditor.tsx index 71d7dd674f7..a0bd297a0f7 100644 --- a/public/app/plugins/datasource/testdata/QueryEditor.tsx +++ b/public/app/plugins/datasource/testdata/QueryEditor.tsx @@ -44,7 +44,7 @@ export class QueryEditor extends PureComponent { onScenarioChange = (item: SelectableValue) => { this.props.onChange({ ...this.props.query, - scenarioId: item.value, + scenarioId: item.value!, }); }; diff --git a/public/app/plugins/datasource/testdata/runStreams.ts b/public/app/plugins/datasource/testdata/runStreams.ts index b7a42ba7a73..db2361f1f50 100644 --- a/public/app/plugins/datasource/testdata/runStreams.ts +++ b/public/app/plugins/datasource/testdata/runStreams.ts @@ -54,7 +54,7 @@ export function runSignalStream( data.addField({ name: 'time', type: FieldType.time }); data.addField({ name: 'value', type: FieldType.number }); - const { spread, speed, bands, noise } = query; + const { spread, speed, bands = 0, noise } = query; for (let i = 0; i < bands; i++) { const suffix = bands > 1 ? ` ${i + 1}` : ''; @@ -217,9 +217,15 @@ export function runFetchStream( return reader.read().then(processChunk); }; + if (!query.url) { + throw new Error('query.url is not defined'); + } + fetch(new Request(query.url)).then(response => { - reader = response.body.getReader(); - reader.read().then(processChunk); + if (response.body) { + reader = response.body.getReader(); + reader.read().then(processChunk); + } }); return () => { diff --git a/public/app/plugins/panel/annolist/AnnoListPanel.tsx b/public/app/plugins/panel/annolist/AnnoListPanel.tsx index 95b9eb6a37e..f51b4043d5e 100644 --- a/public/app/plugins/panel/annolist/AnnoListPanel.tsx +++ b/public/app/plugins/panel/annolist/AnnoListPanel.tsx @@ -189,7 +189,7 @@ export class AnnoListPanel extends PureComponent { }); }; - renderTags = (tags: string[], remove: boolean): JSX.Element => { + renderTags = (tags: string[], remove: boolean): JSX.Element | null => { if (!tags || !tags.length) { return null; } diff --git a/scripts/ci-frontend-metrics.sh b/scripts/ci-frontend-metrics.sh index ceb37a317e8..17300e96d96 100755 --- a/scripts/ci-frontend-metrics.sh +++ b/scripts/ci-frontend-metrics.sh @@ -4,7 +4,7 @@ echo -e "Collecting code stats (typescript errors & more)" -ERROR_COUNT_LIMIT=795 +ERROR_COUNT_LIMIT=791 DIRECTIVES_LIMIT=172 CONTROLLERS_LIMIT=139